Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/subscription-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(payments): add the subscription lifecycle, renewal invoices and plan-based write access
3 changes: 1 addition & 2 deletions .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
],
"ignore": [
".nostr/**",
"src/utils/relay-probe/**",
"src/repositories/user-subscription-repository.ts"
"src/utils/relay-probe/**"
],
"commitlint": false,
"eslint": false,
Expand Down
3 changes: 2 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta
| payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. |
| payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. |
| payments.processor | Either `zebedee`, `lnbits`, `lnurl`. |
| payments.subscriptionPlans | Recurring membership plans. Leave empty to keep the one-time admission fee. With plans and `payments.enabled`, `POST /invoices` takes a `planId` instead of a fee schedule, a payment of at least the plan price starts or extends a period (from the period end while access is still running, otherwise from now), and writes need an active subscription or grace period. Admitted users with no subscription keep access. About three days before a period ends the relay creates a renewal invoice and sends it to the subscriber in a kind 402 event, replacing it if it expires unpaid; it moves the subscription through grace to expired once the period and grace pass without payment. Users still have to pay renewal invoices themselves, since Lightning cannot be charged automatically. The invoice page lets users pick a plan. |
| payments.subscriptionPlans[].id | Unique plan name, for example `basic`. `grandfathered` is reserved: users admitted before plans existed are moved onto it and keep access without a paid period. |
| payments.subscriptionPlans[].amount | Price of one period in msats. Must be unique across plans, because the amount paid selects the plan. |
| payments.subscriptionPlans[].amount | Price of one period in msats. Must be unique across plans. A payment below it does not start or extend a period. |
| payments.subscriptionPlans[].intervalSeconds | Length of one paid period, in seconds. |
| payments.subscriptionPlans[].graceSeconds | How long access lasts after a period ends without a renewal, in seconds. `0` ends access straight away. |
| payments.subscriptionPlans[].description | Optional text shown to users. |
Expand Down
7 changes: 3 additions & 4 deletions resources/get-invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ <h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
<div class="row">
<div class="d-flex justify-content-center mt-2">
<p class="description-text">
This <a href="https://github.com/nostr-protocol/nostr">Nostr</a> relay <strong>requires</strong> a one-time
admission
fee. <br /> Once payment is complete, you will be able to post and fetch events from this relay.
{{fee_description_html}}
</p>
</div>
<div class="d-flex justify-content-center mb-1 mt-2">
Expand All @@ -36,6 +34,7 @@ <h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
<div class="row justify-content-center">
<div class="card col col-md-10 col-lg-6 mb-4">
<div class="card-body">
{{plan_fields_html}}
<div class="d-flex flex-column mb-4">
<label for="pubkey" class="h5">Your Nostr public key</label>
<input type="text" name="pubkey" class="form-control form-control-sm" id="pubkey" placeholder="npub... or hex..." pattern="^([0-9a-f]{64}|npub1[ac-hj-np-z02-9]+)$" required>
Expand All @@ -55,7 +54,7 @@ <h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
<div class="row">
<div class="d-flex justify-content-center mb-3">
<input type="hidden" name="feeSchedule" value="admission" />
<button id="submitBtn" class="btn btn-lg btn-warning" type="submit">Pay {{amount}} sats</button>
<button id="submitBtn" class="btn btn-lg btn-warning" type="submit">{{pay_label}}</button>
</div>
</div>
<div class="row d-none" id="powered-by-zebedee">
Expand Down
1 change: 1 addition & 0 deletions resources/post-invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ <h2 class="text-danger">Invoice expired!</h2>
<input type="hidden" name="pubkey" value="{{pubkey_html}}" required>
<input type="checkbox" class="d-none" name="tosAccepted" value="yes" checked required>
<input type="hidden" name="feeSchedule" value="admission" />
<input type="hidden" name="planId" value="{{plan_id_html}}" />
<button class="btn btn-lg btn-primary" type="submit">Get another invoice</button>
</div>
</div>
Expand Down
21 changes: 17 additions & 4 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,25 @@ export interface IInvoiceRepository {
export interface IUserSubscriptionRepository {
findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise<UserSubscription | undefined>
upsert(subscription: Omit<UserSubscription, 'createdAt' | 'updatedAt'>, client?: DatabaseClient): Promise<number>
/** Active subscriptions whose period ends by `before` and that are not set to cancel. */
findDueForRenewal(before: Date, limit?: number, client?: DatabaseClient): Promise<UserSubscription[]>
/**
* Applies `changes` only while the stored row still matches `expected` (status, plan, period end
* and grace end), so a payment confirmed after the row was read is not overwritten.
* Returns false when the row has changed.
*/
updateIfCurrent(
expected: UserSubscription,
changes: Partial<Pick<UserSubscription, 'status' | 'graceEndsAt'>>,
client?: DatabaseClient,
): Promise<boolean>
/**
* Subscriptions whose period ends by `before` and that need a renewal invoice: active ones, and
* renewal-pending ones whose invoice has expired unpaid. `offset` skips rows already tried.
*/
findDueForRenewal(before: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise<UserSubscription[]>
/** Active or renewal-pending subscriptions whose period has ended by `now`. */
findPeriodEnded(now: Date, limit?: number, client?: DatabaseClient): Promise<UserSubscription[]>
findPeriodEnded(now: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise<UserSubscription[]>
/** Past-due subscriptions whose grace period has ended by `now`. */
findGraceEnded(now: Date, limit?: number, client?: DatabaseClient): Promise<UserSubscription[]>
findGraceEnded(now: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise<UserSubscription[]>
}

export interface IUserRepository {
Expand Down
14 changes: 12 additions & 2 deletions src/@types/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,24 @@ export interface IWotGraphService {

export interface IPaymentsService {
getInvoiceFromPaymentsProcessor(invoice: string | Invoice): Promise<Partial<Invoice>>
createInvoice(pubkey: Pubkey, amount: bigint, description: string): Promise<Invoice>
createInvoice(pubkey: Pubkey, amount: bigint, description: string, planId?: string): Promise<Invoice>
updateInvoice(invoice: Partial<Invoice>): Promise<void>
updateInvoiceStatus(invoice: Pick<Invoice, 'id' | 'status'>): Promise<Invoice>
confirmInvoice(invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt' | 'status' | 'pubkey'>): Promise<void>
confirmInvoice(
invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt' | 'status' | 'pubkey'> &
Partial<Pick<Invoice, 'planId'>>,
): Promise<void>
sendInvoiceUpdateNotification(invoice: Invoice): Promise<void>
/** Tells the subscriber, with a kind 402 event, that their renewal invoice can be paid. */
sendRenewalNotification(invoice: Invoice): Promise<void>
getPendingInvoices(offset?: number): Promise<Invoice[]>
}

export interface ISubscriptionService {
/** One pass of renewal invoices, period ends and grace expiries. Returns how many subscriptions changed. */
processLifecycle(now?: Date): Promise<number>
}

export interface NotificationDispatchContext {
outboxId?: string
attemptNumber?: number
Expand Down
21 changes: 20 additions & 1 deletion src/app/maintenance-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
INotificationDeliveryLogRepository,
INotificationOutboxRepository,
} from '../@types/repositories'
import { IMaintenanceService, INotificationOutboxService, IPaymentsService } from '../@types/services'
import {
IMaintenanceService,
INotificationOutboxService,
IPaymentsService,
ISubscriptionService,
} from '../@types/services'
import { Settings } from '../@types/settings'
import { createLogger } from '../factories/logger-factory'
import { PENDING_INVOICE_PAGE_SIZE } from '../services/payments-service'
Expand Down Expand Up @@ -100,6 +105,7 @@ export class MaintenanceWorker implements IRunnable {
private readonly notificationDeliveryLogRepository: INotificationDeliveryLogRepository,
private readonly notificationOutboxRepository: INotificationOutboxRepository,
private readonly opsCache?: ICacheAdapter,
private readonly subscriptionService?: ISubscriptionService,
) {
this.process
.on('SIGINT', this.onExit.bind(this))
Expand Down Expand Up @@ -251,13 +257,26 @@ export class MaintenanceWorker implements IRunnable {
logger('updated %d of %d invoices successfully', successful, invoices.length)
}

await this.processSubscriptions()

await clearOldEventsPromise
await this.publishOpsHeartbeat({
lastRunStartedAt,
lastRunFinishedAt: new Date().toISOString(),
})
}

private async processSubscriptions(): Promise<void> {
try {
const changed = await this.subscriptionService?.processLifecycle()
if (changed) {
logger('moved %d subscription(s) through their lifecycle', changed)
}
} catch (error) {
logger.error('Unable to process subscriptions', error)
}
}

private async processNotificationOutbox(): Promise<void> {
try {
const delivered = await this.notificationOutboxService.processBatch()
Expand Down
36 changes: 33 additions & 3 deletions src/controllers/invoices/get-invoice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,57 @@ import { Request, Response } from 'express'

import { createSettings } from '../../factories/settings-factory'

import { FeeSchedule } from '../../@types/settings'
import { FeeSchedule, Settings } from '../../@types/settings'
import { IController } from '../../@types/controllers'

import { areSubscriptionsEnabled, getSubscriptionPlans } from '../../utils/subscription'
import { getTemplate } from '../../utils/template-cache'
import { getPublicPathPrefix } from '../../utils/http'

const ADMISSION_DESCRIPTION =
'This <a href="https://github.com/nostr-protocol/nostr">Nostr</a> relay <strong>requires</strong> a one-time admission fee. <br /> Once payment is complete, you will be able to post and fetch events from this relay.'

const SUBSCRIPTION_DESCRIPTION =
'This <a href="https://github.com/nostr-protocol/nostr">Nostr</a> relay <strong>requires</strong> a subscription. <br /> Choose a plan. Once payment is complete, you will be able to post and fetch events from this relay until the period ends.'

const SECONDS_PER_DAY = 86400

const renderPlanFields = (settings: Settings): string => {
const options = getSubscriptionPlans(settings)
.map((plan, index) => {
const days = plan.intervalSeconds / SECONDS_PER_DAY
const period = Number.isInteger(days) ? `${days} day${days === 1 ? '' : 's'}` : `${plan.intervalSeconds} seconds`
const label = `${plan.id}: ${(BigInt(plan.amount) / 1000n).toString()} sats per ${period}`
const id = `plan-${index}`

return `<div class="form-check"><input class="form-check-input" type="radio" name="planId" id="${id}" value="${escapeHtml(plan.id)}"${index === 0 ? ' checked' : ''} required><label class="form-check-label" for="${id}">${escapeHtml(label)}${plan.description ? ` - ${escapeHtml(plan.description)}` : ''}</label></div>`
})
.join('')

return `<div class="d-flex flex-column mb-4"><span class="h5">Plan</span>${options}</div>`
}

export class GetInvoiceController implements IController {
public async handleRequest(req: Request, res: Response): Promise<void> {
const settings = createSettings()

const subscriptionsEnabled = areSubscriptionsEnabled(settings)
if (
pathEq(['payments', 'enabled'], true, settings) &&
pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings)
(subscriptionsEnabled || pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings))
) {
const name = path<string>(['info', 'name'])(settings)
const feeSchedule = path<FeeSchedule>(['payments', 'feeSchedules', 'admission', '0'], settings)
const page = getTemplate('./resources/get-invoice.html')
.replaceAll('{{name}}', escapeHtml(name))
.replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, settings)))
.replaceAll('{{processor_json}}', safeJsonForScript(settings.payments.processor))
.replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
.replaceAll('{{fee_description_html}}', subscriptionsEnabled ? SUBSCRIPTION_DESCRIPTION : ADMISSION_DESCRIPTION)
.replaceAll('{{plan_fields_html}}', subscriptionsEnabled ? renderPlanFields(settings) : '')
.replaceAll(
'{{pay_label}}',
subscriptionsEnabled ? 'Pay' : `Pay ${(BigInt(feeSchedule.amount) / 1000n).toString()} sats`,
)
.replaceAll('{{nonce}}', res.locals.nonce)

res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(page)
Expand Down
59 changes: 38 additions & 21 deletions src/controllers/invoices/post-invoice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IUserRepository } from '../../@types/repositories'

import { createLogger } from '../../factories/logger-factory'

import { areSubscriptionsEnabled, findSubscriptionPlan } from '../../utils/subscription'
import { escapeHtml, safeJsonForScript } from '../../utils/html'
import { fromBech32, toBech32 } from '../../utils/transform'
import { getPublicKey, getRelayPrivateKey } from '../../utils/event'
Expand Down Expand Up @@ -57,8 +58,17 @@ export class PostInvoiceController implements IController {
return
}

const isAdmissionInvoice = request.body?.feeSchedule === 'admission'
if (!isAdmissionInvoice) {
const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings)
const planId = request.body?.planId
const plan =
subscriptionsEnabled && typeof planId === 'string' ? findSubscriptionPlan(currentSettings, planId) : undefined
if (subscriptionsEnabled) {
if (!plan) {
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid plan')

return
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
} else if (request.body?.feeSchedule !== 'admission') {
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid fee')

return
Expand Down Expand Up @@ -87,37 +97,43 @@ export class PostInvoiceController implements IController {
return
}

const isApplicableFee = (feeSchedule: FeeSchedule) =>
feeSchedule.enabled && !feeSchedule.whitelists?.pubkeys?.includes(pubkey)
const admissionFee = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) ?? []
let amount: bigint
if (plan) {
// Renewing early, or moving to another plan, is allowed, so an admitted user is not turned away.
amount = BigInt(plan.amount)
} else {
const isApplicableFee = (feeSchedule: FeeSchedule) =>
feeSchedule.enabled && !feeSchedule.whitelists?.pubkeys?.includes(pubkey)
const admissionFee = currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) ?? []

if (!Array.isArray(admissionFee) || !admissionFee.length) {
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('No admission fee required')
if (!Array.isArray(admissionFee) || !admissionFee.length) {
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('No admission fee required')

return
}
return
}

const minBalance = currentSettings.limits?.event?.pubkey?.minBalance
const user = await this.userRepository.findByPubkey(pubkey)
if (user && user.isAdmitted && (!minBalance || user.balance >= minBalance)) {
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('User is already admitted.')
const minBalance = currentSettings.limits?.event?.pubkey?.minBalance
const user = await this.userRepository.findByPubkey(pubkey)
if (user && user.isAdmitted && (!minBalance || user.balance >= minBalance)) {
response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('User is already admitted.')

return
return
}

amount = admissionFee.reduce((sum, fee) => BigInt(fee.amount) + sum, 0n)
}

let invoice: Invoice
const amount = admissionFee.reduce((sum, fee) => BigInt(fee.amount) + sum, 0n)

try {
const description = `${relayName} Admission Fee for ${toBech32('npub')(pubkey)}`
const description = plan
? `${relayName} ${plan.id} subscription for ${toBech32('npub')(pubkey)}`
: `${relayName} Admission Fee for ${toBech32('npub')(pubkey)}`

invoice = await this.paymentsService.createInvoice(pubkey, amount, description)
invoice = await this.paymentsService.createInvoice(pubkey, amount, description, plan?.id)
} catch (error) {
logger.error('Unable to create invoice. Reason:', error)
response
.status(500)
.setHeader('content-type', 'text/plain')
.send('Unable to create invoice')
response.status(500).setHeader('content-type', 'text/plain').send('Unable to create invoice')
return
}

Expand All @@ -134,6 +150,7 @@ export class PostInvoiceController implements IController {
.replaceAll('{{relay_url_html}}', escapeHtml(relayUrl))
.replaceAll('{{invoice_html}}', escapeHtml(invoice.bolt11))
.replaceAll('{{pubkey_html}}', escapeHtml(pubkey))
.replaceAll('{{plan_id_html}}', escapeHtml(plan?.id ?? ''))
.replaceAll('{{path_prefix}}', escapeHtml(pathPrefix))
.replaceAll('{{amount}}', (amount / 1000n).toString())
// JS contexts — safeJsonForScript serializes and escapes < to prevent </script> injection
Expand Down
2 changes: 2 additions & 0 deletions src/factories/maintenance-worker-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createAdminOpsCacheAdapter } from './admin-ops-cache-adapter-factory'
import { createMaintenanceService } from './maintenance-service-factory'
import { createNotificationOutboxService } from './notification-outbox-service-factory'
import { createPaymentsService } from './payments-service-factory'
import { createSubscriptionService } from './subscription-service-factory'
import { createSettings } from './settings-factory'
import { getMasterDbClient } from '../database/client'
import { MaintenanceWorker } from '../app/maintenance-worker'
Expand All @@ -24,5 +25,6 @@ export const maintenanceWorkerFactory = () => {
notificationDeliveryLogRepository,
notificationOutboxRepository,
createAdminOpsCacheAdapter(),
createSubscriptionService(),
)
}
Loading
Loading