diff --git a/.changeset/subscription-lifecycle.md b/.changeset/subscription-lifecycle.md new file mode 100644 index 000000000..6cd04ee8e --- /dev/null +++ b/.changeset/subscription-lifecycle.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(payments): add the subscription lifecycle, renewal invoices and plan-based write access diff --git a/.knip.json b/.knip.json index eab9b533e..38f02c018 100644 --- a/.knip.json +++ b/.knip.json @@ -17,8 +17,7 @@ ], "ignore": [ ".nostr/**", - "src/utils/relay-probe/**", - "src/repositories/user-subscription-repository.ts" + "src/utils/relay-probe/**" ], "commitlint": false, "eslint": false, diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 90a1430a0..e5888025d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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. | diff --git a/resources/get-invoice.html b/resources/get-invoice.html index 9682a0c77..4f85b7073 100644 --- a/resources/get-invoice.html +++ b/resources/get-invoice.html @@ -20,9 +20,7 @@

{{name}}

- This Nostr relay requires a one-time - admission - fee.
Once payment is complete, you will be able to post and fetch events from this relay. + {{fee_description_html}}

@@ -36,6 +34,7 @@

{{name}}

+ {{plan_fields_html}}
@@ -55,7 +54,7 @@

{{name}}

- +
diff --git a/resources/post-invoice.html b/resources/post-invoice.html index 119d07501..cfbad1e87 100644 --- a/resources/post-invoice.html +++ b/resources/post-invoice.html @@ -83,6 +83,7 @@

Invoice expired!

+
diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 1846fd55d..987044ca0 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -63,12 +63,25 @@ export interface IInvoiceRepository { export interface IUserSubscriptionRepository { findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise upsert(subscription: Omit, client?: DatabaseClient): Promise - /** Active subscriptions whose period ends by `before` and that are not set to cancel. */ - findDueForRenewal(before: Date, limit?: number, client?: DatabaseClient): Promise + /** + * 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>, + client?: DatabaseClient, + ): Promise + /** + * 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 /** Active or renewal-pending subscriptions whose period has ended by `now`. */ - findPeriodEnded(now: Date, limit?: number, client?: DatabaseClient): Promise + findPeriodEnded(now: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise /** Past-due subscriptions whose grace period has ended by `now`. */ - findGraceEnded(now: Date, limit?: number, client?: DatabaseClient): Promise + findGraceEnded(now: Date, limit?: number, offset?: number, client?: DatabaseClient): Promise } export interface IUserRepository { diff --git a/src/@types/services.ts b/src/@types/services.ts index 652b453ea..21a04c177 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -27,14 +27,24 @@ export interface IWotGraphService { export interface IPaymentsService { getInvoiceFromPaymentsProcessor(invoice: string | Invoice): Promise> - createInvoice(pubkey: Pubkey, amount: bigint, description: string): Promise + createInvoice(pubkey: Pubkey, amount: bigint, description: string, planId?: string): Promise updateInvoice(invoice: Partial): Promise updateInvoiceStatus(invoice: Pick): Promise - confirmInvoice(invoice: Pick): Promise + confirmInvoice( + invoice: Pick & + Partial>, + ): Promise sendInvoiceUpdateNotification(invoice: Invoice): Promise + /** Tells the subscriber, with a kind 402 event, that their renewal invoice can be paid. */ + sendRenewalNotification(invoice: Invoice): Promise getPendingInvoices(offset?: number): Promise } +export interface ISubscriptionService { + /** One pass of renewal invoices, period ends and grace expiries. Returns how many subscriptions changed. */ + processLifecycle(now?: Date): Promise +} + export interface NotificationDispatchContext { outboxId?: string attemptNumber?: number diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index 6a4a6f04f..b3d9e1f42 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -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' @@ -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)) @@ -251,6 +257,8 @@ export class MaintenanceWorker implements IRunnable { logger('updated %d of %d invoices successfully', successful, invoices.length) } + await this.processSubscriptions() + await clearOldEventsPromise await this.publishOpsHeartbeat({ lastRunStartedAt, @@ -258,6 +266,17 @@ export class MaintenanceWorker implements IRunnable { }) } + private async processSubscriptions(): Promise { + 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 { try { const delivered = await this.notificationOutboxService.processBatch() diff --git a/src/controllers/invoices/get-invoice-controller.ts b/src/controllers/invoices/get-invoice-controller.ts index 510d9b92a..a2e3f0737 100644 --- a/src/controllers/invoices/get-invoice-controller.ts +++ b/src/controllers/invoices/get-invoice-controller.ts @@ -4,19 +4,44 @@ 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 Nostr relay requires a one-time admission fee.
Once payment is complete, you will be able to post and fetch events from this relay.' + +const SUBSCRIPTION_DESCRIPTION = + 'This Nostr relay requires a subscription.
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 `
` + }) + .join('') + + return `
Plan${options}
` +} + export class GetInvoiceController implements IController { public async handleRequest(req: Request, res: Response): Promise { 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(['info', 'name'])(settings) const feeSchedule = path(['payments', 'feeSchedules', 'admission', '0'], settings) @@ -24,7 +49,12 @@ export class GetInvoiceController implements IController { .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) diff --git a/src/controllers/invoices/post-invoice-controller.ts b/src/controllers/invoices/post-invoice-controller.ts index f35443dcf..0e0751afa 100644 --- a/src/controllers/invoices/post-invoice-controller.ts +++ b/src/controllers/invoices/post-invoice-controller.ts @@ -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' @@ -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 + } + } else if (request.body?.feeSchedule !== 'admission') { response.status(400).setHeader('content-type', 'text/plain; charset=utf8').send('Invalid fee') return @@ -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 } @@ -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 injection diff --git a/src/factories/maintenance-worker-factory.ts b/src/factories/maintenance-worker-factory.ts index 5e280d8a5..dd2e4a908 100644 --- a/src/factories/maintenance-worker-factory.ts +++ b/src/factories/maintenance-worker-factory.ts @@ -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' @@ -24,5 +25,6 @@ export const maintenanceWorkerFactory = () => { notificationDeliveryLogRepository, notificationOutboxRepository, createAdminOpsCacheAdapter(), + createSubscriptionService(), ) } diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index b3bf9ccd2..964460b0b 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -19,6 +19,8 @@ import { NegOpenMessageHandler } from '../handlers/neg-open-message-handler' import { SubscribeMessageHandler } from '../handlers/subscribe-message-handler' import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handler' import { eventStrategyFactory } from './event-strategy-factory' +import { getMasterDbClient } from '../database/client' +import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' import { rateLimiterFactory } from './rate-limiter-factory' import { createSettings } from './settings-factory' import { wotGraphServiceFactory } from './wot-graph-service-factory' @@ -31,6 +33,14 @@ export const getCache = (): ICacheAdapter => { return cacheAdapter } +let userSubscriptionRepository: UserSubscriptionRepository | undefined = undefined +const getUserSubscriptionRepository = (): UserSubscriptionRepository => { + if (!userSubscriptionRepository) { + userSubscriptionRepository = new UserSubscriptionRepository(getMasterDbClient()) + } + return userSubscriptionRepository +} + export const messageHandlerFactory = ( eventRepository: IEventRepository, @@ -61,6 +71,7 @@ export const messageHandlerFactory = getCache(), rateLimiterFactory, wotGraphServiceFactory(getCache(), eventRepository, createSettings), + getUserSubscriptionRepository(), ) } case MessageType.REQ: diff --git a/src/factories/payments-service-factory.ts b/src/factories/payments-service-factory.ts index c16ebb16a..07e3bcf59 100644 --- a/src/factories/payments-service-factory.ts +++ b/src/factories/payments-service-factory.ts @@ -4,6 +4,7 @@ import { createPaymentsProcessor } from './payments-processor-factory' import { createSettings } from './settings-factory' import { InvoiceRepository } from '../repositories/invoice-repository' import { PaymentsService } from '../services/payments-service' +import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' import { UserRepository } from '../repositories/user-repository' import { NotificationOutboxRepository } from '../repositories/notification-outbox-repository' @@ -24,5 +25,6 @@ export const createPaymentsService = () => { eventRepository, createSettings, notificationOutboxRepository, + new UserSubscriptionRepository(dbClient), ) } diff --git a/src/factories/subscription-service-factory.ts b/src/factories/subscription-service-factory.ts new file mode 100644 index 000000000..845dc22af --- /dev/null +++ b/src/factories/subscription-service-factory.ts @@ -0,0 +1,8 @@ +import { getMasterDbClient } from '../database/client' +import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' +import { SubscriptionService } from '../services/subscription-service' +import { createPaymentsService } from './payments-service-factory' +import { createSettings } from './settings-factory' + +export const createSubscriptionService = () => + new SubscriptionService(new UserSubscriptionRepository(getMasterDbClient()), createPaymentsService(), createSettings) diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index d90f248cf..ba362f990 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -33,8 +33,14 @@ import { isSealEvent, isWelcomeRumorEvent, } from '../utils/event' +import { areSubscriptionsEnabled, getAccessEndsAt, hasSubscriptionAccess } from '../utils/subscription' import { isAuthRequired } from '../utils/nip42' -import { IEventRepository, INip05VerificationRepository, IUserRepository } from '../@types/repositories' +import { + IEventRepository, + INip05VerificationRepository, + IUserRepository, + IUserSubscriptionRepository, +} from '../@types/repositories' import { IEventStrategy, IMessageHandler } from '../@types/message-handlers' import { admissionCacheKey, CacheAdmissionState } from '../constants/caching' import { createEventCommandResult } from '../telemetry/event-metrics' @@ -61,6 +67,7 @@ export class EventMessageHandler implements IMessageHandler { private readonly cache: ICacheAdapter, private readonly rateLimiter: Factory, private readonly wotGraphService: IWotGraphService, + private readonly userSubscriptionRepository?: IUserSubscriptionRepository, ) {} public async handleMessage(message: IncomingEventMessage): Promise { @@ -163,7 +170,10 @@ export class EventMessageHandler implements IMessageHandler { this.processNip05Metadata(event) } catch (error) { logger.error('error handling message', message, error) - this.webSocket.emit(WebSocketAdapterEvent.Message, createEventCommandResult(event.id, false, 'error: unable to process event')) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createEventCommandResult(event.id, false, 'error: unable to process event'), + ) } } @@ -477,7 +487,11 @@ export class EventMessageHandler implements IMessageHandler { const feeSchedules = paymentsEnabled ? currentSettings.payments?.feeSchedules?.admission?.filter(isApplicableFee) : undefined - const admissionFeeRequired = Array.isArray(feeSchedules) && feeSchedules.length > 0 + // With plans and no admission fee at all, a plan is what admits a user. + const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings) + const onlyPlansAdmit = + subscriptionsEnabled && !currentSettings.payments?.feeSchedules?.admission?.some((schedule) => schedule.enabled) + const admissionFeeRequired = (Array.isArray(feeSchedules) && feeSchedules.length > 0) || onlyPlansAdmit if (!admissionFeeRequired && !nip43Enabled) { return } @@ -512,13 +526,29 @@ export class EventMessageHandler implements IMessageHandler { return notAdmittedReason } - const minBalance = paymentsEnabled ? currentSettings.limits?.event?.pubkey?.minBalance ?? 0n : 0n + const minBalance = paymentsEnabled ? (currentSettings.limits?.event?.pubkey?.minBalance ?? 0n) : 0n if (minBalance > 0n && user.balance < minBalance) { this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_INSUFFICIENT_BALANCE, 60) return 'blocked: insufficient balance' } - this.cacheSet(cacheKey, CacheAdmissionState.ADMITTED, 300) + let admittedTtl = 300 + if (subscriptionsEnabled && this.userSubscriptionRepository) { + const subscription = await this.userSubscriptionRepository.findByPubkey(event.pubkey) + const now = new Date() + if (!hasSubscriptionAccess(subscription, now)) { + this.cacheSet(cacheKey, CacheAdmissionState.BLOCKED_NOT_ADMITTED, 60) + return 'blocked: subscription expired' + } + + // Cache no longer than access lasts, so the block lands when the period ends rather than minutes later. + const accessEndsAt = subscription ? getAccessEndsAt(subscription) : undefined + if (accessEndsAt) { + admittedTtl = Math.max(1, Math.min(admittedTtl, Math.ceil((accessEndsAt.getTime() - now.getTime()) / 1000))) + } + } + + this.cacheSet(cacheKey, CacheAdmissionState.ADMITTED, admittedTtl) } private cacheSet(key: string, value: string, ttl: number): void { diff --git a/src/repositories/user-subscription-repository.ts b/src/repositories/user-subscription-repository.ts index 354a9f491..3fc570ca2 100644 --- a/src/repositories/user-subscription-repository.ts +++ b/src/repositories/user-subscription-repository.ts @@ -1,6 +1,7 @@ import { DatabaseClient, Pubkey } from '../@types/base' import { DBUserSubscription, SubscriptionStatus, UserSubscription } from '../@types/user-subscription' import { fromBuffer, toBuffer } from '../utils/transform' +import { InvoiceStatus } from '../@types/invoice' import { createLogger } from '../factories/logger-factory' import { IUserSubscriptionRepository } from '../@types/repositories' @@ -67,18 +68,57 @@ export class UserSubscriptionRepository implements IUserSubscriptionRepository { return (result as unknown as { rowCount?: number }).rowCount ?? 0 } + public async updateIfCurrent( + expected: UserSubscription, + changes: Partial>, + client: DatabaseClient = this.dbClient, + ): Promise { + const update: Partial = { updated_at: new Date() } + if (changes.status !== undefined) { + update.status = changes.status + } + if (changes.graceEndsAt !== undefined) { + update.grace_ends_at = changes.graceEndsAt + } + + const updated = await client('user_subscriptions') + .where('pubkey', toBuffer(expected.pubkey)) + .where('status', expected.status) + .where('plan_id', expected.planId) + .whereRaw('current_period_end IS NOT DISTINCT FROM ?', [expected.currentPeriodEnd]) + .whereRaw('grace_ends_at IS NOT DISTINCT FROM ?', [expected.graceEndsAt]) + .update(update) + + return updated > 0 + } + public async findDueForRenewal( before: Date, limit = DEFAULT_PAGE_SIZE, + offset = 0, client: DatabaseClient = this.dbClient, ): Promise { + const now = new Date() const rows = await client('user_subscriptions') - .where('status', SubscriptionStatus.ACTIVE) + .where((builder) => + builder.where('status', SubscriptionStatus.ACTIVE).orWhere((renewalPending) => + renewalPending.where('status', SubscriptionStatus.RENEWAL_PENDING).whereNotExists(function () { + // A renewal invoice that is still payable needs no replacement. + this.select('invoices.id') + .from('invoices') + .whereRaw('invoices.pubkey = user_subscriptions.pubkey') + .whereRaw('invoices.plan_id = user_subscriptions.plan_id') + .where('invoices.status', InvoiceStatus.PENDING) + .where((unexpired) => unexpired.whereNull('invoices.expires_at').orWhere('invoices.expires_at', '>', now)) + }), + ), + ) .where('cancel_at_period_end', false) .whereNotNull('current_period_end') .where('current_period_end', '<=', before) .orderBy('current_period_end', 'asc') .limit(limit) + .offset(offset) .select() return rows.map(fromDBUserSubscription) @@ -87,6 +127,7 @@ export class UserSubscriptionRepository implements IUserSubscriptionRepository { public async findPeriodEnded( now: Date, limit = DEFAULT_PAGE_SIZE, + offset = 0, client: DatabaseClient = this.dbClient, ): Promise { const rows = await client('user_subscriptions') @@ -95,6 +136,7 @@ export class UserSubscriptionRepository implements IUserSubscriptionRepository { .where('current_period_end', '<=', now) .orderBy('current_period_end', 'asc') .limit(limit) + .offset(offset) .select() return rows.map(fromDBUserSubscription) @@ -103,6 +145,7 @@ export class UserSubscriptionRepository implements IUserSubscriptionRepository { public async findGraceEnded( now: Date, limit = DEFAULT_PAGE_SIZE, + offset = 0, client: DatabaseClient = this.dbClient, ): Promise { const rows = await client('user_subscriptions') @@ -111,6 +154,7 @@ export class UserSubscriptionRepository implements IUserSubscriptionRepository { .where('grace_ends_at', '<=', now) .orderBy('grace_ends_at', 'asc') .limit(limit) + .offset(offset) .select() return rows.map(fromDBUserSubscription) diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index a1fb91bb9..41cfb112e 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -2,7 +2,13 @@ import { andThen, otherwise, pipe } from 'ramda' import { broadcastEvent, getPublicKey, getRelayPrivateKey, identifyEvent, signEvent } from '../utils/event' import { DatabaseClient, Pubkey } from '../@types/base' import { FeeSchedule, Settings } from '../@types/settings' -import { IEventRepository, IInvoiceRepository, INotificationOutboxRepository, IUserRepository } from '../@types/repositories' +import { + IEventRepository, + IInvoiceRepository, + INotificationOutboxRepository, + IUserRepository, + IUserSubscriptionRepository, +} from '../@types/repositories' import { OperatorNotificationEventType } from '../@types/operator-notifications' import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' @@ -12,6 +18,7 @@ import { createLogger } from '../factories/logger-factory' import { IPaymentsProcessor } from '../@types/clients' import { IPaymentsService } from '../@types/services' import { Transaction } from '../database/transaction' +import { applyPayment, areSubscriptionsEnabled, findSubscriptionPlan } from '../utils/subscription' import { resolveInvoiceExpiry } from '../utils/invoice' const logger = createLogger('payments-service') @@ -28,6 +35,7 @@ export class PaymentsService implements IPaymentsService { private readonly eventRepository: IEventRepository, private readonly settings: () => Settings, private readonly notificationOutboxRepository: INotificationOutboxRepository, + private readonly userSubscriptionRepository?: IUserSubscriptionRepository, ) {} public async getPendingInvoices(offset = 0): Promise { @@ -53,7 +61,7 @@ export class PaymentsService implements IPaymentsService { } } - public async createInvoice(pubkey: Pubkey, amount: bigint, description: string): Promise { + public async createInvoice(pubkey: Pubkey, amount: bigint, description: string, planId?: string): Promise { logger('create invoice for %s for %s: %s', pubkey, amount.toString(), description) const transaction = new Transaction(this.dbClient) @@ -88,6 +96,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: date, verifyURL: invoiceResponse.verifyURL, + planId, }, transaction.transaction, ) @@ -117,6 +126,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: invoiceResponse.createdAt, verifyURL: invoiceResponse.verifyURL, + planId, } } catch (error) { await transaction.rollback() @@ -168,6 +178,14 @@ export class PaymentsService implements IPaymentsService { await transaction.begin() + const currentSettings = this.settings() + const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings) + // The callbacks only pass what their processor reports, so the plan, the unit and whether the + // invoice was already confirmed come from the stored invoice, read before it is confirmed. + const stored = subscriptionsEnabled + ? await this.invoiceRepository.findById(invoice.id, transaction.transaction) + : undefined + await this.invoiceRepository.confirmInvoice( invoice.id, invoice.amountPaid, @@ -175,13 +193,12 @@ export class PaymentsService implements IPaymentsService { transaction.transaction, ) - const currentSettings = this.settings() - let amountPaidMsat = invoice.amountPaid - if (invoice.unit === InvoiceUnit.SATS) { + const unit = invoice.unit ?? stored?.unit + if (unit === InvoiceUnit.SATS) { amountPaidMsat *= 1000n - } else if (invoice.unit === InvoiceUnit.BTC) { + } else if (unit === InvoiceUnit.BTC) { amountPaidMsat *= 1000n * 100000000n } @@ -192,7 +209,24 @@ export class PaymentsService implements IPaymentsService { return sum + (isApplicableFee(feeSchedule) ? BigInt(feeSchedule.amount) : 0n) }, 0n) - if (admissionFeeAmount > 0n && amountPaidMsat >= admissionFeeAmount) { + const plan = findSubscriptionPlan(currentSettings, invoice.planId ?? stored?.planId) + + if (plan && this.userSubscriptionRepository) { + if (stored?.confirmedAt) { + // A repeated callback must not grant another period. + logger('invoice %s was already confirmed, not extending the subscription again', invoice.id) + } else if (amountPaidMsat >= BigInt(plan.amount)) { + const date = new Date() + const existing = await this.userSubscriptionRepository.findByPubkey(invoice.pubkey, transaction.transaction) + await this.userSubscriptionRepository.upsert( + applyPayment(invoice.pubkey, existing, plan, date), + transaction.transaction, + ) + await this.userRepository.admitUser(invoice.pubkey, date, transaction.transaction) + } else { + logger('invoice %s paid %s msats, below plan %s price %s', invoice.id, amountPaidMsat, plan.id, plan.amount) + } + } else if (admissionFeeAmount > 0n && amountPaidMsat >= admissionFeeAmount) { const date = new Date() await this.userRepository.admitUser(invoice.pubkey, date, transaction.transaction) } @@ -220,14 +254,6 @@ export class PaymentsService implements IPaymentsService { public async sendInvoiceUpdateNotification(invoice: Invoice): Promise { logger('invoice updated notification %s: %o', invoice.id, invoice) - const currentSettings = this.settings() - - const { - info: { relay_url: relayUrl }, - } = currentSettings - - const relayPrivkey = getRelayPrivateKey(relayUrl) - const relayPubkey = getPublicKey(relayPrivkey) let unit: string = invoice.unit let amount: bigint | undefined = invoice.amountPaid @@ -245,11 +271,40 @@ export class PaymentsService implements IPaymentsService { const now = new Date() const expiration = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate()) + await this.publishInvoiceNotification(invoice, `Invoice paid: ${amount.toString()} ${unit}`, expiration) + } + + public async sendRenewalNotification(invoice: Invoice): Promise { + logger('subscription renewal notification %s: %o', invoice.id, invoice) + + let unit: string = invoice.unit + let amount = invoice.amountRequested + if (invoice.unit === InvoiceUnit.MSATS) { + amount /= 1000n + unit = InvoiceUnit.SATS + } + + // The notice is only useful while the invoice can still be paid. + const expiration = invoice.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000) + + await this.publishInvoiceNotification(invoice, `Subscription renewal due: ${amount.toString()} ${unit}`, expiration) + } + + private async publishInvoiceNotification(invoice: Invoice, content: string, expiration: Date): Promise { + const { + info: { relay_url: relayUrl }, + } = this.settings() + + const relayPrivkey = getRelayPrivateKey(relayUrl) + const relayPubkey = getPublicKey(relayPrivkey) + + const now = new Date() + const unsignedInvoiceEvent: UnidentifiedEvent & Pick = { pubkey: relayPubkey, kind: EventKinds.INVOICE_UPDATE, created_at: Math.floor(now.getTime() / 1000), - content: `Invoice paid: ${amount.toString()} ${unit}`, + content, tags: [ [EventTags.Pubkey, invoice.pubkey], [EventTags.Invoice, invoice.bolt11], diff --git a/src/services/subscription-service.ts b/src/services/subscription-service.ts new file mode 100644 index 000000000..9744d16d2 --- /dev/null +++ b/src/services/subscription-service.ts @@ -0,0 +1,150 @@ +import { IPaymentsService, ISubscriptionService } from '../@types/services' +import { Settings } from '../@types/settings' +import { SubscriptionStatus, UserSubscription } from '../@types/user-subscription' +import { IUserSubscriptionRepository } from '../@types/repositories' +import { areSubscriptionsEnabled, endPeriod, findSubscriptionPlan, RENEWAL_LEAD_SECONDS } from '../utils/subscription' +import { createLogger } from '../factories/logger-factory' +import { toBech32 } from '../utils/transform' + +const logger = createLogger('subscription-service') + +/** Subscriptions per query, and how many queries one pass makes before leaving the rest to the next. */ +const SWEEP_PAGE_SIZE = 10 +const SWEEP_MAX_PAGES = 10 + +type Sweep = 'ended' | 'grace' | 'renewal' + +export class SubscriptionService implements ISubscriptionService { + /** + * Where each sweep starts. Rows a pass could not handle stay at the front of its query, so the next + * pass starts behind them. Without this, more unhandled rows than one pass reads would hide every + * row after them. It starts over from the front once a pass reaches the end of the rows. + */ + private readonly cursors: Record = { ended: 0, grace: 0, renewal: 0 } + + public constructor( + private readonly userSubscriptionRepository: IUserSubscriptionRepository, + private readonly paymentsService: IPaymentsService, + private readonly settings: () => Settings, + ) {} + + public async processLifecycle(now = new Date()): Promise { + const currentSettings = this.settings() + if (!areSubscriptionsEnabled(currentSettings)) { + return 0 + } + + // Ends first, so a subscription that lapsed is not also sent a renewal invoice. + const changed = + (await this.sweep( + 'ended', + (limit, offset) => this.userSubscriptionRepository.findPeriodEnded(now, limit, offset), + (subscription) => this.endPeriod(subscription, currentSettings, now), + )) + + (await this.sweep( + 'grace', + (limit, offset) => this.userSubscriptionRepository.findGraceEnded(now, limit, offset), + (subscription) => this.expire(subscription), + )) + + (await this.sweep( + 'renewal', + (limit, offset) => + this.userSubscriptionRepository.findDueForRenewal( + new Date(now.getTime() + RENEWAL_LEAD_SECONDS * 1000), + limit, + offset, + ), + (subscription) => this.requestRenewal(subscription, currentSettings), + )) + + return changed + } + + /** + * Each handled subscription leaves the query that found it, so the next page starts where the + * unhandled ones end: skipping them with an offset keeps a subscription that cannot be handled, + * such as one on a plan the operator removed, from blocking the ones behind it. The work of one + * pass is bounded, and the next one carries on behind the rows this one skipped. + */ + private async sweep( + sweep: Sweep, + find: (limit: number, offset: number) => Promise, + handle: (subscription: UserSubscription) => Promise, + ): Promise { + let changed = 0 + let skipped = this.cursors[sweep] + let reachedEnd = false + + for (let page = 0; page < SWEEP_MAX_PAGES; page++) { + const subscriptions = await find(SWEEP_PAGE_SIZE, skipped) + + for (const subscription of subscriptions) { + let handled = false + try { + handled = await handle(subscription) + } catch (error) { + logger.error('Unable to process subscription for %s. Reason:', subscription.pubkey, error) + } + + if (handled) { + changed++ + } else { + skipped++ + } + } + + if (subscriptions.length < SWEEP_PAGE_SIZE) { + reachedEnd = true + break + } + } + + this.cursors[sweep] = reachedEnd ? 0 : skipped + + return changed + } + + private async requestRenewal(subscription: UserSubscription, settings: Settings): Promise { + const plan = findSubscriptionPlan(settings, subscription.planId) + if (!plan) { + logger('plan %s of %s is no longer configured, not renewing', subscription.planId, subscription.pubkey) + return false + } + + const description = `${settings.info.name} ${plan.id} subscription renewal for ${toBech32('npub')(subscription.pubkey)}` + const invoice = await this.paymentsService.createInvoice( + subscription.pubkey, + BigInt(plan.amount), + description, + plan.id, + ) + + // The subscriber is told about the invoice either way: it can be paid whatever the status says. + await this.paymentsService.sendRenewalNotification(invoice) + + if (subscription.status === SubscriptionStatus.ACTIVE) { + return this.userSubscriptionRepository.updateIfCurrent(subscription, { + status: SubscriptionStatus.RENEWAL_PENDING, + }) + } + + return true + } + + private async endPeriod(subscription: UserSubscription, settings: Settings, now: Date): Promise { + const plan = findSubscriptionPlan(settings, subscription.planId) + if (!plan) { + logger('plan %s of %s is no longer configured, not ending its period', subscription.planId, subscription.pubkey) + return false + } + + return this.userSubscriptionRepository.updateIfCurrent(subscription, endPeriod(subscription, plan, now)) + } + + private async expire(subscription: UserSubscription): Promise { + return this.userSubscriptionRepository.updateIfCurrent(subscription, { + status: SubscriptionStatus.EXPIRED, + graceEndsAt: null, + }) + } +} diff --git a/src/utils/subscription.ts b/src/utils/subscription.ts new file mode 100644 index 000000000..cd445aa51 --- /dev/null +++ b/src/utils/subscription.ts @@ -0,0 +1,100 @@ +import { Pubkey } from '../@types/base' +import { Settings, SubscriptionPlan } from '../@types/settings' +import { GRANDFATHERED_PLAN_ID, SubscriptionStatus, UserSubscription } from '../@types/user-subscription' + +/** How long before a period ends the relay issues the renewal invoice. */ +export const RENEWAL_LEAD_SECONDS = 3 * 24 * 60 * 60 + +export const getSubscriptionPlans = (settings: Settings): SubscriptionPlan[] => + settings.payments?.subscriptionPlans ?? [] + +/** Plans only take effect while payments are on, so an operator can switch them off without editing the list. */ +export const areSubscriptionsEnabled = (settings: Settings): boolean => + settings.payments?.enabled === true && getSubscriptionPlans(settings).length > 0 + +export const findSubscriptionPlan = (settings: Settings, planId: string | null | undefined) => + planId ? getSubscriptionPlans(settings).find((plan) => plan.id === planId) : undefined + +/** + * When access ends, or undefined if it never does. + * + * Judged from the dates rather than the stored status, so a user is not locked out or kept + * in by a sweep that has not run yet. A paid period carries its grace end from the payment, + * so access runs on through grace even before the sweep marks the subscription past due. + */ +export const getAccessEndsAt = (subscription: UserSubscription): Date | undefined => { + if (subscription.status === SubscriptionStatus.EXPIRED) { + return new Date(0) + } + + if (!subscription.currentPeriodEnd) { + return undefined + } + + return subscription.graceEndsAt ?? subscription.currentPeriodEnd +} + +/** + * Users with no row are admitted users from before plans were enabled, or admitted without + * paying (invite code, NIP-43), so they keep access. + */ +export const hasSubscriptionAccess = (subscription: UserSubscription | undefined, now: Date): boolean => { + if (!subscription) { + return true + } + + const endsAt = getAccessEndsAt(subscription) + + return !endsAt || endsAt.getTime() > now.getTime() +} + +/** + * The subscription after a payment for `plan`. A payment made while access is still running + * extends it from the current period end, so paying early costs nothing. After access has + * lapsed, or on the never-ending grandfathered plan, the new period starts now. + */ +export const applyPayment = ( + pubkey: Pubkey, + existing: UserSubscription | undefined, + plan: SubscriptionPlan, + now: Date, +): Omit => { + const stillRunning = + existing && + existing.planId !== GRANDFATHERED_PLAN_ID && + existing.currentPeriodEnd && + (existing.status === SubscriptionStatus.ACTIVE || existing.status === SubscriptionStatus.RENEWAL_PENDING) && + existing.currentPeriodEnd.getTime() > now.getTime() + + const periodStart = stillRunning ? (existing.currentPeriodEnd as Date) : now + const periodEnd = new Date(periodStart.getTime() + plan.intervalSeconds * 1000) + + return { + pubkey, + planId: plan.id, + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: periodStart, + currentPeriodEnd: periodEnd, + graceEndsAt: plan.graceSeconds > 0 ? new Date(periodEnd.getTime() + plan.graceSeconds * 1000) : null, + cancelAtPeriodEnd: false, + } +} + +/** + * Where a subscription whose period has ended goes next: straight to expired if the plan has + * no grace or the grace window has already passed, otherwise into grace. + */ +export const endPeriod = ( + subscription: UserSubscription, + plan: SubscriptionPlan, + now: Date, +): Pick => { + const periodEnd = subscription.currentPeriodEnd as Date + const graceEndsAt = new Date(periodEnd.getTime() + plan.graceSeconds * 1000) + + if (plan.graceSeconds <= 0 || graceEndsAt.getTime() <= now.getTime()) { + return { status: SubscriptionStatus.EXPIRED, graceEndsAt: null } + } + + return { status: SubscriptionStatus.PAST_DUE, graceEndsAt } +} diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index da3ff4814..dc3defe1c 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -359,6 +359,48 @@ describe('MaintenanceWorker', () => { expect(paymentsService.getPendingInvoices).not.to.have.been.called }) + describe('subscription lifecycle', () => { + let subscriptionService: { processLifecycle: Sinon.SinonStub } + + beforeEach(() => { + subscriptionService = { processLifecycle: sandbox.stub().resolves(0) } + worker = new MaintenanceWorker( + fakeProcess as any, + paymentsService, + maintenanceService, + settings as any, + nip05VerificationRepository, + notificationOutboxService, + notificationDeliveryLogRepository, + notificationOutboxRepository, + undefined, + subscriptionService as any, + ) + }) + + it('runs the subscription lifecycle once per pass', async () => { + await (worker as any).onSchedule() + + expect(subscriptionService.processLifecycle).to.have.been.calledOnce + }) + + it('skips it while payments are disabled', async () => { + settingsState.payments = { enabled: false } as any + + await (worker as any).onSchedule() + + expect(subscriptionService.processLifecycle).not.to.have.been.called + }) + + it('still clears old events and finishes the pass when the lifecycle fails', async () => { + subscriptionService.processLifecycle.rejects(new Error('db down')) + + await (worker as any).onSchedule() + + expect(maintenanceService.clearOldEvents).to.have.been.calledOnce + }) + }) + it('skips an invoice when the processor returns no id', async () => { settingsState.payments = { enabled: true } as any paymentsService.getPendingInvoices.resolves([pendingInvoice]) diff --git a/test/unit/controllers/invoices/get-invoice-controller.spec.ts b/test/unit/controllers/invoices/get-invoice-controller.spec.ts index 9b76c06e7..96cea8917 100644 --- a/test/unit/controllers/invoices/get-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/get-invoice-controller.spec.ts @@ -98,7 +98,9 @@ describe('GetInvoiceController', () => { describe('when payments and admission fee are enabled', () => { beforeEach(() => { createSettingsStub.returns(enabledPaymentsSettings) - getTemplateStub.returns('{{name}}|{{path_prefix}}|{{processor_json}}|{{amount}}|{{nonce}}') + getTemplateStub.returns( + '{{name}}|{{path_prefix}}|{{processor_json}}|{{pay_label}}|{{fee_description_html}}|{{plan_fields_html}}|{{nonce}}', + ) }) it('loads the get-invoice template', async () => { @@ -121,7 +123,9 @@ describe('GetInvoiceController', () => { expect(sent).to.not.include('{{name}}') expect(sent).to.not.include('{{path_prefix}}') expect(sent).to.not.include('{{processor_json}}') - expect(sent).to.not.include('{{amount}}') + expect(sent).to.not.include('{{pay_label}}') + expect(sent).to.not.include('{{fee_description_html}}') + expect(sent).to.not.include('{{plan_fields_html}}') expect(sent).to.not.include('{{nonce}}') }) @@ -141,11 +145,11 @@ describe('GetInvoiceController', () => { it('renders amount in sats (msats / 1000)', async () => { // 21000 msats → 21 sats - getTemplateStub.returns('{{amount}}') + getTemplateStub.returns('{{pay_label}}') await controller.handleRequest({} as any, res) - expect(res.send.firstCall.args[0]).to.equal('21') + expect(res.send.firstCall.args[0]).to.equal('Pay 21 sats') }) it('safe-serializes processor for inline script', async () => { @@ -179,4 +183,80 @@ describe('GetInvoiceController', () => { expect(res.send.firstCall.args[0]).to.equal('/nostream/invoices') }) }) + + describe('when subscription plans are configured', () => { + const plansSettings = (overrides: any = {}) => ({ + ...enabledPaymentsSettings, + payments: { + ...enabledPaymentsSettings.payments, + subscriptionPlans: [ + { id: 'basic', amount: 5000000n, intervalSeconds: 2592000, graceSeconds: 259200 }, + { id: 'vip<1>', amount: 25000000n, intervalSeconds: 7776000, graceSeconds: 0, description: 'all "perks"' }, + ], + ...overrides, + }, + }) + + beforeEach(() => { + createSettingsStub.returns(plansSettings()) + getTemplateStub.returns('{{fee_description_html}}|{{plan_fields_html}}|{{pay_label}}') + }) + + it('lists every plan as a radio option with its price and period, the first one selected', async () => { + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent.match(/name="planId"/g)).to.have.length(2) + expect(sent).to.include('value="basic" checked') + expect(sent).to.include('basic: 5000 sats per 30 days') + expect(sent).to.include('25000 sats per 90 days') + }) + + it('escapes plan ids and descriptions', async () => { + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.not.include('vip<1>') + expect(sent).to.include('vip<1>') + expect(sent).to.not.include('"perks"') + }) + + it('describes a subscription and asks to pay without a fixed amount', async () => { + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.include('requires a subscription') + expect(sent).to.not.include('one-time admission fee') + expect(sent.endsWith('|Pay')).to.equal(true) + }) + + it('is available without an admission fee, since a plan is what admits a user', async () => { + createSettingsStub.returns( + plansSettings({ feeSchedules: { admission: [{ enabled: false, amount: 21000, whitelists: {} }] } }), + ) + + await controller.handleRequest({} as any, res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('responds with 404 while payments are disabled', async () => { + createSettingsStub.returns(plansSettings({ enabled: false })) + + await controller.handleRequest({} as any, res) + + expect(res.status).to.have.been.calledWith(404) + }) + + it('keeps the admission page when no plan is configured', async () => { + createSettingsStub.returns(plansSettings({ subscriptionPlans: [] })) + + await controller.handleRequest({} as any, res) + + const sent = res.send.firstCall.args[0] as string + expect(sent).to.include('one-time admission fee') + expect(sent).to.not.include('planId') + expect(sent.endsWith('|Pay 21 sats')).to.equal(true) + }) + }) }) diff --git a/test/unit/controllers/invoices/post-invoice-controller.spec.ts b/test/unit/controllers/invoices/post-invoice-controller.spec.ts index 9607c0be9..a6e68cc21 100644 --- a/test/unit/controllers/invoices/post-invoice-controller.spec.ts +++ b/test/unit/controllers/invoices/post-invoice-controller.spec.ts @@ -222,6 +222,134 @@ describe('PostInvoiceController', () => { }) }) + describe('subscription plans', () => { + const plan = { id: 'basic', amount: 5000000n, intervalSeconds: 2592000, graceSeconds: 259200 } + const plansSettings = () => ({ + ...baseSettings, + payments: { ...baseSettings.payments, subscriptionPlans: [plan, { ...plan, id: 'premium', amount: 9000000n }] }, + }) + const planBody = { ...validBody, planId: 'basic' } + const stubbedPaymentsService = () => ({ + createInvoice: sinon + .stub() + .resolves({ id: 'inv-ref-123', bolt11: 'lnbc1...', expiresAt: new Date('2030-01-01') }), + }) + + it('creates an invoice for the plan price and tags it with the plan', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + const res = makeRes() + + await controller.handleRequest({ body: planBody } as any, res) + + expect(res.status).to.have.been.calledWith(200) + const [pubkey, amount, description, planId] = paymentsService.createInvoice.firstCall.args + expect(pubkey).to.equal(VALID_PUBKEY) + expect(amount).to.equal(5000000n) + expect(description).to.contain('basic') + expect(planId).to.equal('basic') + }) + + it('keeps the chosen plan in the retry form of an expired invoice', async () => { + getTemplateStub.returns('{{plan_id_html}}') + const controller = makeController({ settings: plansSettings }) + const res = makeRes() + + await controller.handleRequest({ body: { ...validBody, planId: 'premium' } } as any, res) + + expect(res.send).to.have.been.calledWith('premium') + }) + + it('leaves the plan empty in the retry form when no plan is configured', async () => { + getTemplateStub.returns('[{{plan_id_html}}]') + const controller = makeController() + const res = makeRes() + + await controller.handleRequest({ body: validBody } as any, res) + + expect(res.send).to.have.been.calledWith('[]') + }) + + it('charges the price of the chosen plan', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + + await controller.handleRequest({ body: { ...validBody, planId: 'premium' } } as any, makeRes()) + + expect(paymentsService.createInvoice.firstCall.args[1]).to.equal(9000000n) + }) + + it('returns 400 when no plan is chosen, since the amount selects the plan', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + const res = makeRes() + + await controller.handleRequest({ body: validBody } as any, res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Invalid plan') + expect(paymentsService.createInvoice).not.to.have.been.called + }) + + it('returns 400 for an unknown plan or a plan id that is not a string', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ settings: plansSettings, paymentsService }) + + for (const planId of ['gold', 'grandfathered', 42, ['basic'], { id: 'basic' }]) { + const res = makeRes() + + await controller.handleRequest({ body: { ...validBody, planId } } as any, res) + + expect(res.status).to.have.been.calledWith(400) + expect(res.send).to.have.been.calledWith('Invalid plan') + } + expect(paymentsService.createInvoice).not.to.have.been.called + }) + + it('lets an admitted user renew or change plan', async () => { + const userRepository = { findByPubkey: sinon.stub().resolves({ isAdmitted: true, balance: 99999 }) } + const controller = makeController({ settings: plansSettings, userRepository }) + const res = makeRes() + + await controller.handleRequest({ body: planBody } as any, res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('needs no admission fee schedule', async () => { + const settings = () => ({ + ...plansSettings(), + payments: { ...plansSettings().payments, feeSchedules: { admission: [] } }, + }) + const controller = makeController({ settings }) + const res = makeRes() + + await controller.handleRequest({ body: planBody } as any, res) + + expect(res.status).to.have.been.calledWith(200) + }) + + it('still requires the ToS to be accepted', async () => { + const controller = makeController({ settings: plansSettings }) + const res = makeRes() + + await controller.handleRequest({ body: { ...planBody, tosAccepted: 'no' } } as any, res) + + expect(res.send).to.have.been.calledWith('ToS agreement: not accepted') + }) + + it('ignores a plan id while plans are not configured, keeping the admission fee', async () => { + const paymentsService = stubbedPaymentsService() + const controller = makeController({ paymentsService }) + + await controller.handleRequest({ body: { ...validBody, planId: 'basic' } } as any, makeRes()) + + const [, amount, , planId] = paymentsService.createInvoice.firstCall.args + expect(amount).to.equal(21000n) + expect(planId).to.equal(undefined) + }) + }) + describe('invoice creation', () => { it('returns 500 when the payments service throws', async () => { const paymentsService = { diff --git a/test/unit/handlers/event-message-handler.spec.ts b/test/unit/handlers/event-message-handler.spec.ts index 14edc1b2c..22e072bc8 100644 --- a/test/unit/handlers/event-message-handler.spec.ts +++ b/test/unit/handlers/event-message-handler.spec.ts @@ -17,6 +17,7 @@ import { EventKinds, EventExpirationTimeMetadataKey, EventTags } from '../../../ import { EventMessageHandler } from '../../../src/handlers/event-message-handler' import { IUserRepository } from '../../../src/@types/repositories' import { IWebSocketAdapter } from '../../../src/@types/adapters' +import { SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' import { getCurrentRate, recordEvent, resetAdaptivePowState } from '../../../src/utils/adaptive-pow' import { WebSocketAdapterEvent } from '../../../src/constants/adapter' @@ -1645,6 +1646,162 @@ describe('EventMessageHandler', () => { ) }) }) + + describe('subscriptions', () => { + const DAY = 86_400_000 + let subscriptionFindByPubkeyStub: SinonStub + + const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: 'f'.repeat(64), + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: new Date(Date.now() - 10 * DAY), + currentPeriodEnd: new Date(Date.now() + 20 * DAY), + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) + + beforeEach(() => { + settings.payments.subscriptionPlans = [ + { id: 'basic', amount: 1000n, intervalSeconds: 2_592_000, graceSeconds: 259_200 }, + ] + userRepositoryFindByPubkeyStub.resolves({ isAdmitted: true, balance: 0n }) + subscriptionFindByPubkeyStub = sandbox.stub() + handler = new EventMessageHandler( + webSocket, + () => null, + {} as any, + userRepository, + () => settings, + {} as any, + cacheStub, + () => ({ hit: async () => false }), + {} as any, + { findByPubkey: subscriptionFindByPubkeyStub } as any, + ) + }) + + it('admits a user with an active subscription', async () => { + subscriptionFindByPubkeyStub.resolves(subscription()) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('admits an admitted user who has no subscription row, so enabling plans locks nobody out', async () => { + subscriptionFindByPubkeyStub.resolves(undefined) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('admits a user in the grace period', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: new Date(Date.now() - DAY), + graceEndsAt: new Date(Date.now() + DAY), + }), + ) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('blocks an expired subscription and caches the block for 60s', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: subscription expired') + + expect(cacheStub.setKey).to.have.been.calledWith( + `${event.pubkey}:is-admitted`, + CacheAdmissionState.BLOCKED_NOT_ADMITTED, + 60, + ) + }) + + it('admits a subscription that lapsed but is still within its grace, before the sweep has moved it on', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ + currentPeriodEnd: new Date(Date.now() - 1000), + graceEndsAt: new Date(Date.now() + 2 * DAY), + }), + ) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('blocks a lapsed subscription before the sweep has moved it on', async () => { + subscriptionFindByPubkeyStub.resolves( + subscription({ currentPeriodEnd: new Date(Date.now() - 1000), graceEndsAt: null }), + ) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: subscription expired') + }) + + it('blocks a user who was never admitted, without looking at subscriptions', async () => { + userRepositoryFindByPubkeyStub.resolves(undefined) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: pubkey not admitted') + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + }) + + it('caches admission no longer than the access lasts', async () => { + subscriptionFindByPubkeyStub.resolves(subscription({ currentPeriodEnd: new Date(Date.now() + 40_000) })) + + await (handler as any).isUserAdmitted(event) + + const [, , ttl] = cacheStub.setKey.lastCall.args + expect(ttl).to.be.within(39, 41) + }) + + it('caches admission for the usual 300s when access lasts longer', async () => { + subscriptionFindByPubkeyStub.resolves(subscription()) + + await (handler as any).isUserAdmitted(event) + + expect(cacheStub.setKey).to.have.been.calledWith(`${event.pubkey}:is-admitted`, CacheAdmissionState.ADMITTED, 300) + }) + + it('uses the cache without reading subscriptions', async () => { + cacheStub.getKey.resolves(CacheAdmissionState.ADMITTED) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + }) + + it('gates on the plan when no admission fee is configured', async () => { + settings.payments.feeSchedules.admission = [] + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.equal('blocked: subscription expired') + }) + + it('lets a pubkey whitelisted on the only admission fee through without a subscription', async () => { + settings.payments.feeSchedules.admission[0].whitelists.pubkeys = [event.pubkey] + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('ignores subscriptions while payments are off', async () => { + settings.payments.enabled = false + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + }) + + it('ignores subscriptions when no plan is configured', async () => { + settings.payments.subscriptionPlans = [] + subscriptionFindByPubkeyStub.resolves(subscription({ status: SubscriptionStatus.EXPIRED })) + + await expect((handler as any).isUserAdmitted(event)).to.eventually.be.undefined + + expect(subscriptionFindByPubkeyStub).not.to.have.been.called + }) + }) }) describe('checkNip05Verification', () => { diff --git a/test/unit/repositories/user-subscription-repository.spec.ts b/test/unit/repositories/user-subscription-repository.spec.ts index 1f47c9b0c..4837b348a 100644 --- a/test/unit/repositories/user-subscription-repository.spec.ts +++ b/test/unit/repositories/user-subscription-repository.spec.ts @@ -121,9 +121,67 @@ describe('UserSubscriptionRepository', () => { }) }) + describe('.updateIfCurrent', () => { + const current = { + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: start, + currentPeriodEnd: end, + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: start, + updatedAt: start, + } + + it('updates only the row that still matches the state that was read', async () => { + await repository.updateIfCurrent(current, { status: SubscriptionStatus.RENEWAL_PENDING }, clientReturning(1)) + + expect(sql[0]).to.include('update "user_subscriptions" set') + expect(sql[0]).to.include(`"status" = 'renewal_pending'`) + expect(sql[0]).to.include(`"pubkey" = X'${pubkeyHex}'`) + expect(sql[0]).to.include(`"status" = 'active'`) + expect(sql[0]).to.include(`"plan_id" = 'basic'`) + expect(sql[0]).to.include(`current_period_end IS NOT DISTINCT FROM '2026-01-31`) + expect(sql[0]).to.include('grace_ends_at IS NOT DISTINCT FROM NULL') + }) + + it('matches a grace end that was set', async () => { + const graceEndsAt = new Date('2026-02-03T00:00:00.000Z') + + await repository.updateIfCurrent( + { ...current, status: SubscriptionStatus.PAST_DUE, graceEndsAt }, + { status: SubscriptionStatus.EXPIRED, graceEndsAt: null }, + clientReturning(1), + ) + + expect(sql[0]).to.include(`"status" = 'past_due'`) + expect(sql[0]).to.include(`grace_ends_at IS NOT DISTINCT FROM '2026-02-03`) + expect(sql[0]).to.include('"grace_ends_at" = NULL') + }) + + it('never changes the plan or the period, and leaves untouched fields out', async () => { + await repository.updateIfCurrent(current, { status: SubscriptionStatus.EXPIRED }, clientReturning(1)) + + expect(sql[0]).to.not.match(/set[^w]*"plan_id"/) + expect(sql[0]).to.not.match(/set[^w]*"current_period_end"/) + expect(sql[0]).to.not.match(/set[^w]*"grace_ends_at"/) + expect(sql[0]).to.include('"updated_at" =') + }) + + it('reports whether a row was updated', async () => { + expect( + await repository.updateIfCurrent(current, { status: SubscriptionStatus.EXPIRED }, clientReturning(1)), + ).to.equal(true) + expect( + await repository.updateIfCurrent(current, { status: SubscriptionStatus.EXPIRED }, clientReturning(0)), + ).to.equal(false) + }) + }) + describe('.findDueForRenewal', () => { it('selects active subscriptions that are not cancelling and end by the cut-off', async () => { - const subscriptions = await repository.findDueForRenewal(end, undefined, clientReturning([row])) + const subscriptions = await repository.findDueForRenewal(end, undefined, undefined, clientReturning([row])) expect(subscriptions).to.have.length(1) expect(sql[0]).to.include(`"status" = 'active'`) @@ -134,22 +192,40 @@ describe('UserSubscriptionRepository', () => { expect(sql[0]).to.include('limit 10') }) - it('honours the limit', async () => { - await repository.findDueForRenewal(end, 3, clientReturning([])) + it('also selects renewal-pending subscriptions whose renewal invoice is no longer payable', async () => { + await repository.findDueForRenewal(end, undefined, undefined, clientReturning([])) + + expect(sql[0]).to.include(`"status" = 'renewal_pending'`) + expect(sql[0]).to.include('not exists (select "invoices"."id" from "invoices"') + expect(sql[0]).to.include('invoices.pubkey = user_subscriptions.pubkey') + expect(sql[0]).to.include('invoices.plan_id = user_subscriptions.plan_id') + expect(sql[0]).to.include(`"invoices"."status" = 'pending'`) + expect(sql[0]).to.include('"invoices"."expires_at" is null or "invoices"."expires_at" >') + }) + + it('honours the limit and the offset', async () => { + await repository.findDueForRenewal(end, 3, 6, clientReturning([])) expect(sql[0]).to.include('limit 3') + expect(sql[0]).to.include('offset 6') }) }) describe('.findPeriodEnded', () => { it('selects active and renewal-pending subscriptions whose period has ended', async () => { - await repository.findPeriodEnded(end, undefined, clientReturning([row])) + await repository.findPeriodEnded(end, undefined, undefined, clientReturning([row])) expect(sql[0]).to.include(`"status" in ('active', 'renewal_pending')`) expect(sql[0]).to.include('"current_period_end" is not null') expect(sql[0]).to.include(`"current_period_end" <= '2026-01-31`) expect(sql[0]).to.include('limit 10') }) + + it('honours the offset', async () => { + await repository.findPeriodEnded(end, 10, 4, clientReturning([])) + + expect(sql[0]).to.include('offset 4') + }) }) describe('.findGraceEnded', () => { @@ -157,6 +233,7 @@ describe('UserSubscriptionRepository', () => { await repository.findGraceEnded( end, undefined, + undefined, clientReturning([{ ...row, status: SubscriptionStatus.PAST_DUE }]), ) @@ -165,5 +242,11 @@ describe('UserSubscriptionRepository', () => { expect(sql[0]).to.include(`"grace_ends_at" <= '2026-01-31`) expect(sql[0]).to.include('order by "grace_ends_at" asc') }) + + it('honours the offset', async () => { + await repository.findGraceEnded(end, 10, 2, clientReturning([])) + + expect(sql[0]).to.include('offset 2') + }) }) }) diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts index c32fbc6fb..2fe8ab6dd 100644 --- a/test/unit/services/payments-service.spec.ts +++ b/test/unit/services/payments-service.spec.ts @@ -212,6 +212,15 @@ describe('PaymentsService', () => { expect(result.pubkey).to.equal('pubkey1234') }) + it('stores the plan on the invoice and returns it', async () => { + settings.returns({ payments: { invoiceExpirySeconds: 3600 } }) + + const result = await service.createInvoice('pubkey1234', 1000n, 'test', 'basic') + + expect(invoiceRepository.upsert.firstCall.args[0]).to.include({ planId: 'basic' }) + expect(result.planId).to.equal('basic') + }) + it('gives the invoice an expiry when the processor does not report one', async () => { // Without this the row can never be retired and stays pending forever. settings.returns({ payments: { invoiceExpirySeconds: 3600 } }) @@ -488,6 +497,172 @@ describe('PaymentsService', () => { }) + describe('subscription plans', () => { + const DAY = 86_400_000 + const plan = { id: 'basic', amount: 5_000_000n, intervalSeconds: 2_592_000, graceSeconds: 259_200 } + let userSubscriptionRepository: any + + const plansSettings = (plans: any[] = [plan], admission: any[] = [{ enabled: true, amount: 1000n }]) => ({ + payments: { enabled: true, feeSchedules: { admission }, subscriptionPlans: plans }, + }) + + beforeEach(() => { + userSubscriptionRepository = { + findByPubkey: sandbox.stub().resolves(undefined), + upsert: sandbox.stub().resolves(1), + } + invoiceRepository.findById = sandbox.stub().resolves(stubInvoice({ planId: 'basic' })) + settings.returns(plansSettings()) + service = new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + settings, + notificationOutboxRepository, + userSubscriptionRepository, + ) + }) + + it('starts a subscription for the plan on the stored invoice and admits the user', async () => { + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + expect(invoiceRepository.findById).to.have.been.calledOnceWithExactly('invoice-id', mockTrx) + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + const [subscription, client] = userSubscriptionRepository.upsert.firstCall.args + expect(subscription).to.include({ pubkey: 'pubkey1234', planId: 'basic', status: 'active' }) + expect(subscription.currentPeriodEnd.getTime() - subscription.currentPeriodStart.getTime()).to.equal( + 30 * DAY, + ) + expect(client).to.equal(mockTrx) + expect(userRepository.admitUser).to.have.been.calledOnce + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('prefers the plan on the invoice it was given over the stored one', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: null })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n, planId: 'basic' })) + + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + }) + + it('extends a running subscription from its period end', async () => { + const periodEnd = new Date(Date.now() + 5 * DAY) + userSubscriptionRepository.findByPubkey.resolves({ + pubkey: 'pubkey1234', + planId: 'basic', + status: 'active', + currentPeriodStart: new Date(Date.now() - 25 * DAY), + currentPeriodEnd: periodEnd, + graceEndsAt: null, + cancelAtPeriodEnd: false, + }) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + const [subscription] = userSubscriptionRepository.upsert.firstCall.args + expect(subscription.currentPeriodStart).to.deep.equal(periodEnd) + expect(subscription.currentPeriodEnd).to.deep.equal(new Date(periodEnd.getTime() + 30 * DAY)) + }) + + it('does not extend the subscription again for a repeated callback of an invoice that was already confirmed', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: 'basic', confirmedAt: new Date() })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + expect(userSubscriptionRepository.findByPubkey).not.to.have.been.called + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('reads the stored invoice before confirming it, so a first confirmation is told from a repeat', async () => { + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n })) + + expect(invoiceRepository.findById).to.have.been.calledBefore(invoiceRepository.confirmInvoice) + }) + + it('takes the unit from the stored invoice when the callback does not report one', async () => { + // OpenNode reports the amount in sats without a unit. + invoiceRepository.findById.resolves(stubInvoice({ planId: 'basic', unit: InvoiceUnit.SATS })) + + await service.confirmInvoice(makeCompletedInvoice({ unit: undefined, amountPaid: 5_000n } as any)) + + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + }) + + it('still rejects an underpayment when the unit comes from the stored invoice', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: 'basic', unit: InvoiceUnit.SATS })) + + await service.confirmInvoice(makeCompletedInvoice({ unit: undefined, amountPaid: 4_999n } as any)) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + }) + + it('converts SATS to msats before comparing against the plan price', async () => { + await service.confirmInvoice(makeCompletedInvoice({ unit: InvoiceUnit.SATS, amountPaid: 5_000n })) + + expect(userSubscriptionRepository.upsert).to.have.been.calledOnce + }) + + it('does not start a subscription when the paid amount is below the plan price', async () => { + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 4_999_999n })) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(userRepository.admitUser).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('does not fall back to the admission fee when a plan invoice is underpaid', async () => { + settings.returns(plansSettings([plan], [{ enabled: true, amount: 1000n }])) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 4_000_000n })) + + expect(userRepository.admitUser).not.to.have.been.called + }) + + it('treats an invoice without a plan as an admission payment', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: null })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('treats an invoice whose plan was removed from the settings as an admission payment', async () => { + invoiceRepository.findById.resolves(stubInvoice({ planId: 'retired' })) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) + + expect(userSubscriptionRepository.upsert).not.to.have.been.called + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('does not read the invoice or subscriptions while no plan is configured', async () => { + settings.returns(plansSettings([])) + + await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) + + expect(invoiceRepository.findById).not.to.have.been.called + expect(userSubscriptionRepository.findByPubkey).not.to.have.been.called + expect(userRepository.admitUser).to.have.been.calledOnce + }) + + it('rolls back when the subscription cannot be saved, so the user is not admitted for free', async () => { + userSubscriptionRepository.upsert.rejects(new Error('db error')) + + await expect(service.confirmInvoice(makeCompletedInvoice({ amountPaid: 5_000_000n }))).to.be.rejectedWith( + 'db error', + ) + + expect(mockTrx.rollback).to.have.been.calledOnce + expect(mockTrx.commit).not.to.have.been.called + expect(userRepository.admitUser).not.to.have.been.called + }) + }) + it('rolls back the transaction and re-throws on error', async () => { settings.returns(makeSettings([])) invoiceRepository.confirmInvoice.rejects(new Error('db error')) @@ -500,6 +675,66 @@ describe('PaymentsService', () => { }) + describe('sendRenewalNotification', () => { + const renewalInvoice = () => + stubInvoice({ + pubkey: 'subscriber1234', + bolt11: 'lnbc50000n1...', + amountRequested: 5_000_000n, + unit: InvoiceUnit.MSATS, + expiresAt: new Date('2030-01-01T00:00:00Z'), + planId: 'basic', + }) + + beforeEach(() => { + settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) + }) + + it('tells the subscriber what is due, with the invoice to pay, until the invoice expires', async () => { + await service.sendRenewalNotification(renewalInvoice()) + + const unsigned = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args[0] + expect(unsigned.kind).to.equal(402) + expect(unsigned.pubkey).to.equal('fakepubkey') + expect(unsigned.content).to.equal('Subscription renewal due: 5000 sats') + expect(unsigned.tags).to.deep.include(['p', 'subscriber1234']) + expect(unsigned.tags).to.deep.include(['bolt11', 'lnbc50000n1...']) + expect(unsigned.tags).to.deep.include(['expiration', '1893456000']) + }) + + it('keeps the unit when it is not msats', async () => { + await service.sendRenewalNotification({ ...renewalInvoice(), unit: InvoiceUnit.SATS, amountRequested: 5000n }) + + expect((eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args[0].content).to.equal( + 'Subscription renewal due: 5000 sats', + ) + }) + + it('expires the notice a day out when the invoice has no expiry', async () => { + const before = Math.floor(Date.now() / 1000) + + await service.sendRenewalNotification({ ...renewalInvoice(), expiresAt: null }) + + const expiration = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args[0].tags.find( + (tag: string[]) => tag[0] === 'expiration', + ) + expect(Number(expiration[1])).to.be.within(before + 86_399, before + 86_402) + }) + + it('persists and broadcasts the signed event', async () => { + await service.sendRenewalNotification(renewalInvoice()) + + expect(eventRepository.create).to.have.been.calledOnce + expect(eventUtils.broadcastEvent).to.have.been.calledOnce + }) + + it('does not throw when the pipeline fails', async () => { + eventRepository.create.rejects(new Error('db down')) + + await expect(service.sendRenewalNotification(renewalInvoice())).to.be.fulfilled + }) + }) + describe('sendInvoiceUpdateNotification', () => { beforeEach(() => { settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) diff --git a/test/unit/services/subscription-service.spec.ts b/test/unit/services/subscription-service.spec.ts new file mode 100644 index 000000000..bd091403d --- /dev/null +++ b/test/unit/services/subscription-service.spec.ts @@ -0,0 +1,348 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import Sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +import { RENEWAL_LEAD_SECONDS } from '../../../src/utils/subscription' +import { Settings } from '../../../src/@types/settings' +import { SubscriptionService } from '../../../src/services/subscription-service' +import { SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' + +const { expect } = chai + +const DAY = 86_400_000 +const NOW = new Date('2026-06-15T00:00:00Z') +const at = (offsetMs: number) => new Date(NOW.getTime() + offsetMs) +const PUBKEY = 'a'.repeat(64) + +const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: PUBKEY, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: at(-28 * DAY), + currentPeriodEnd: at(2 * DAY), + graceEndsAt: at(5 * DAY), + cancelAtPeriodEnd: false, + createdAt: at(-28 * DAY), + updatedAt: at(-28 * DAY), + ...overrides, +}) + +describe('SubscriptionService', () => { + let sandbox: Sinon.SinonSandbox + let repository: Record + let paymentsService: { createInvoice: Sinon.SinonStub; sendRenewalNotification: Sinon.SinonStub } + let currentSettings: Settings + let service: SubscriptionService + + beforeEach(() => { + sandbox = Sinon.createSandbox() + repository = { + findByPubkey: sandbox.stub().resolves(undefined), + upsert: sandbox.stub().resolves(1), + updateIfCurrent: sandbox.stub().resolves(true), + findDueForRenewal: sandbox.stub().resolves([]), + findPeriodEnded: sandbox.stub().resolves([]), + findGraceEnded: sandbox.stub().resolves([]), + } + paymentsService = { + createInvoice: sandbox.stub().resolves({ id: 'invoice-id', bolt11: 'lnbc1...' }), + sendRenewalNotification: sandbox.stub().resolves(), + } + currentSettings = { + info: { name: 'test relay' }, + payments: { + enabled: true, + subscriptionPlans: [ + { id: 'basic', amount: 1_000_000n, intervalSeconds: 30 * 86_400, graceSeconds: 3 * 86_400 }, + ], + }, + } as unknown as Settings + service = new SubscriptionService(repository as any, paymentsService as any, () => currentSettings) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('processLifecycle', () => { + it('does nothing while subscriptions are off', async () => { + currentSettings = { + ...currentSettings, + payments: { enabled: true, subscriptionPlans: [] }, + } as unknown as Settings + + expect(await service.processLifecycle(NOW)).to.equal(0) + expect(repository.findPeriodEnded).not.to.have.been.called + expect(repository.findDueForRenewal).not.to.have.been.called + }) + + it('does nothing while payments are off', async () => { + currentSettings = { + ...currentSettings, + payments: { ...currentSettings.payments, enabled: false }, + } as unknown as Settings + + expect(await service.processLifecycle(NOW)).to.equal(0) + expect(repository.findPeriodEnded).not.to.have.been.called + }) + + describe('renewal invoices', () => { + it('looks for subscriptions ending within the renewal lead time', async () => { + await service.processLifecycle(NOW) + + expect(repository.findDueForRenewal).to.have.been.calledOnce + expect(repository.findDueForRenewal.firstCall.args[0]).to.deep.equal( + new Date(NOW.getTime() + RENEWAL_LEAD_SECONDS * 1000), + ) + }) + + it('issues an invoice for the plan price, tells the subscriber and waits for payment', async () => { + const due = subscription() + repository.findDueForRenewal.onFirstCall().resolves([due]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(paymentsService.createInvoice).to.have.been.calledOnce + const [pubkey, amount, description, planId] = paymentsService.createInvoice.firstCall.args + expect(pubkey).to.equal(PUBKEY) + expect(amount).to.equal(1_000_000n) + expect(description).to.contain('test relay').and.contain('basic') + expect(planId).to.equal('basic') + expect(paymentsService.sendRenewalNotification).to.have.been.calledOnceWithExactly({ + id: 'invoice-id', + bolt11: 'lnbc1...', + }) + expect(repository.updateIfCurrent).to.have.been.calledOnceWithExactly(due, { + status: SubscriptionStatus.RENEWAL_PENDING, + }) + }) + + it('replaces an expired renewal invoice without touching the status, which is already renewal pending', async () => { + repository.findDueForRenewal + .onFirstCall() + .resolves([subscription({ status: SubscriptionStatus.RENEWAL_PENDING })]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(paymentsService.createInvoice).to.have.been.calledOnce + expect(paymentsService.sendRenewalNotification).to.have.been.calledOnce + expect(repository.updateIfCurrent).not.to.have.been.called + }) + + it('does not change the status when the invoice cannot be created, so the next pass retries', async () => { + repository.findDueForRenewal.onFirstCall().resolves([subscription()]) + paymentsService.createInvoice.rejects(new Error('processor down')) + + expect(await service.processLifecycle(NOW)).to.equal(0) + + expect(paymentsService.sendRenewalNotification).not.to.have.been.called + expect(repository.updateIfCurrent).not.to.have.been.called + }) + + it('does not count a renewal whose subscription was paid in the meantime', async () => { + repository.findDueForRenewal.onFirstCall().resolves([subscription()]) + repository.updateIfCurrent.resolves(false) + + expect(await service.processLifecycle(NOW)).to.equal(0) + }) + + it('skips a subscription whose plan was removed from the settings', async () => { + repository.findDueForRenewal.onFirstCall().resolves([subscription({ planId: 'retired' })]) + + expect(await service.processLifecycle(NOW)).to.equal(0) + + expect(paymentsService.createInvoice).not.to.have.been.called + expect(repository.updateIfCurrent).not.to.have.been.called + }) + }) + + describe('ended periods', () => { + it('moves a lapsed subscription into grace counted from the period end', async () => { + const periodEnd = at(-DAY) + const lapsed = subscription({ currentPeriodEnd: periodEnd }) + repository.findPeriodEnded.onFirstCall().resolves([lapsed]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(repository.updateIfCurrent).to.have.been.calledOnce + const [expected, changes] = repository.updateIfCurrent.firstCall.args + expect(expected).to.equal(lapsed) + expect(changes.status).to.equal(SubscriptionStatus.PAST_DUE) + expect(changes.graceEndsAt).to.deep.equal(new Date(periodEnd.getTime() + 3 * DAY)) + }) + + it('also ends a period whose renewal invoice was never paid', async () => { + repository.findPeriodEnded + .onFirstCall() + .resolves([subscription({ status: SubscriptionStatus.RENEWAL_PENDING, currentPeriodEnd: at(-DAY) })]) + + await service.processLifecycle(NOW) + + expect(repository.updateIfCurrent.firstCall.args[1].status).to.equal(SubscriptionStatus.PAST_DUE) + }) + + it('expires a lapsed subscription straight away when the plan has no grace', async () => { + currentSettings.payments.subscriptionPlans[0].graceSeconds = 0 + repository.findPeriodEnded.onFirstCall().resolves([subscription({ currentPeriodEnd: at(-DAY) })]) + + await service.processLifecycle(NOW) + + expect(repository.updateIfCurrent.firstCall.args[1]).to.deep.equal({ + status: SubscriptionStatus.EXPIRED, + graceEndsAt: null, + }) + }) + + it('leaves a subscription whose plan was removed from the settings alone', async () => { + repository.findPeriodEnded.onFirstCall().resolves([subscription({ planId: 'retired' })]) + + expect(await service.processLifecycle(NOW)).to.equal(0) + expect(repository.updateIfCurrent).not.to.have.been.called + }) + + it('does not overwrite a subscription that was paid after it was read', async () => { + repository.findPeriodEnded.onFirstCall().resolves([subscription({ currentPeriodEnd: at(-DAY) })]) + repository.updateIfCurrent.resolves(false) + + expect(await service.processLifecycle(NOW)).to.equal(0) + }) + }) + + describe('grace expiry', () => { + it('expires a subscription whose grace period is over', async () => { + const pastDue = subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: at(-5 * DAY), + graceEndsAt: at(-DAY), + }) + repository.findGraceEnded.onFirstCall().resolves([pastDue]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(repository.updateIfCurrent).to.have.been.calledOnceWithExactly(pastDue, { + status: SubscriptionStatus.EXPIRED, + graceEndsAt: null, + }) + }) + + it('expires even when the plan was removed, since the grace end is already recorded', async () => { + repository.findGraceEnded + .onFirstCall() + .resolves([subscription({ planId: 'retired', status: SubscriptionStatus.PAST_DUE, graceEndsAt: at(-DAY) })]) + + expect(await service.processLifecycle(NOW)).to.equal(1) + }) + + it('does not expire a subscription that was paid after it was read', async () => { + repository.findGraceEnded.onFirstCall().resolves([subscription({ status: SubscriptionStatus.PAST_DUE })]) + repository.updateIfCurrent.resolves(false) + + expect(await service.processLifecycle(NOW)).to.equal(0) + }) + }) + + describe('paging', () => { + const page = (count: number, overrides: Partial = {}) => + Array.from({ length: count }, () => subscription({ currentPeriodEnd: at(-DAY), ...overrides })) + + it('asks again while pages come back full, then stops on a short one', async () => { + repository.findPeriodEnded.onCall(0).resolves(page(10)) + repository.findPeriodEnded.onCall(1).resolves(page(3)) + + expect(await service.processLifecycle(NOW)).to.equal(13) + + expect(repository.findPeriodEnded).to.have.been.calledTwice + }) + + it('moves past rows it cannot handle, so they do not block the ones behind them', async () => { + const stuck = page(10, { planId: 'retired' }) + const fine = page(3) + repository.findPeriodEnded.onCall(0).resolves(stuck) + repository.findPeriodEnded.onCall(1).resolves(fine) + + expect(await service.processLifecycle(NOW)).to.equal(3) + + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0]) + expect(repository.findPeriodEnded.secondCall.args.slice(1)).to.deep.equal([10, 10]) + }) + + it('carries on behind the rows it skipped in the next pass, so more stuck rows than one pass reads do not hide the rest', async () => { + repository.findPeriodEnded.resolves(page(10, { planId: 'retired' })) + + await service.processLifecycle(NOW) + + // The first pass read ten pages of stuck rows and gave up at the cap; the next starts behind them. + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0]) + repository.findPeriodEnded.resetHistory() + repository.findPeriodEnded.resolves([]) + + await service.processLifecycle(NOW) + + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 100]) + }) + + it('starts over from the front once a pass reaches the end of the rows', async () => { + repository.findPeriodEnded.onCall(0).resolves(page(10, { planId: 'retired' })) + repository.findPeriodEnded.onCall(1).resolves([]) + await service.processLifecycle(NOW) + repository.findPeriodEnded.resetHistory() + repository.findPeriodEnded.resolves([]) + + await service.processLifecycle(NOW) + + expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0]) + }) + + it('counts only the rows left behind when handled ones drop out of the next query', async () => { + const mixed = [...page(7), ...page(3, { planId: 'retired' })] + repository.findPeriodEnded.onCall(0).resolves(mixed) + repository.findPeriodEnded.onCall(1).resolves([]) + + await service.processLifecycle(NOW) + + expect(repository.findPeriodEnded.secondCall.args.slice(1)).to.deep.equal([10, 3]) + }) + + it('keeps going after one subscription fails', async () => { + repository.findPeriodEnded.onFirstCall().resolves([subscription(), subscription({ pubkey: 'b'.repeat(64) })]) + repository.updateIfCurrent.onFirstCall().rejects(new Error('db error')) + + expect(await service.processLifecycle(NOW)).to.equal(1) + + expect(repository.updateIfCurrent).to.have.been.calledTwice + }) + + it('caps the work of one pass', async () => { + repository.findPeriodEnded.resolves(page(10)) + + expect(await service.processLifecycle(NOW)).to.equal(100) + + expect(repository.findPeriodEnded).to.have.callCount(10) + }) + }) + + it('ends periods before sending renewals, so a lapsed subscription is not also sent an invoice', async () => { + const calls: string[] = [] + repository.findPeriodEnded.callsFake(async () => { + calls.push('ended') + return [] + }) + repository.findGraceEnded.callsFake(async () => { + calls.push('grace') + return [] + }) + repository.findDueForRenewal.callsFake(async () => { + calls.push('renewal') + return [] + }) + + await service.processLifecycle(NOW) + + expect(calls).to.deep.equal(['ended', 'grace', 'renewal']) + }) + }) +}) diff --git a/test/unit/utils/subscription.spec.ts b/test/unit/utils/subscription.spec.ts new file mode 100644 index 000000000..901de7b9e --- /dev/null +++ b/test/unit/utils/subscription.spec.ts @@ -0,0 +1,244 @@ +import { expect } from 'chai' + +import { GRANDFATHERED_PLAN_ID, SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' +import { + applyPayment, + areSubscriptionsEnabled, + endPeriod, + findSubscriptionPlan, + getAccessEndsAt, + hasSubscriptionAccess, +} from '../../../src/utils/subscription' +import { Settings, SubscriptionPlan } from '../../../src/@types/settings' + +const DAY = 86_400_000 +const NOW = new Date('2026-06-15T00:00:00Z') +const at = (offsetMs: number) => new Date(NOW.getTime() + offsetMs) + +const plan: SubscriptionPlan = { + id: 'basic', + amount: 1_000_000n, + intervalSeconds: 30 * 86_400, + graceSeconds: 3 * 86_400, +} + +const subscription = (overrides: Partial = {}): UserSubscription => ({ + pubkey: 'a'.repeat(64), + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: at(-10 * DAY), + currentPeriodEnd: at(20 * DAY), + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: at(-10 * DAY), + updatedAt: at(-10 * DAY), + ...overrides, +}) + +describe('subscription utils', () => { + describe('areSubscriptionsEnabled', () => { + const settings = (payments: unknown) => ({ payments }) as unknown as Settings + + it('is on only when payments are enabled and a plan is configured', () => { + expect(areSubscriptionsEnabled(settings({ enabled: true, subscriptionPlans: [plan] }))).to.equal(true) + expect(areSubscriptionsEnabled(settings({ enabled: false, subscriptionPlans: [plan] }))).to.equal(false) + expect(areSubscriptionsEnabled(settings({ enabled: true, subscriptionPlans: [] }))).to.equal(false) + expect(areSubscriptionsEnabled(settings({ enabled: true }))).to.equal(false) + expect(areSubscriptionsEnabled({} as Settings)).to.equal(false) + }) + }) + + describe('findSubscriptionPlan', () => { + const settings = { payments: { enabled: true, subscriptionPlans: [plan] } } as unknown as Settings + + it('finds a plan by id', () => { + expect(findSubscriptionPlan(settings, 'basic')).to.equal(plan) + }) + + it('returns undefined for an unknown, empty or missing id', () => { + expect(findSubscriptionPlan(settings, 'gold')).to.equal(undefined) + expect(findSubscriptionPlan(settings, '')).to.equal(undefined) + expect(findSubscriptionPlan(settings, null)).to.equal(undefined) + expect(findSubscriptionPlan(settings, undefined)).to.equal(undefined) + }) + }) + + describe('hasSubscriptionAccess', () => { + it('keeps access for a user with no subscription row', () => { + expect(hasSubscriptionAccess(undefined, NOW)).to.equal(true) + }) + + it('keeps access on a subscription that never ends', () => { + const grandfathered = subscription({ planId: GRANDFATHERED_PLAN_ID, currentPeriodEnd: null }) + + expect(hasSubscriptionAccess(grandfathered, NOW)).to.equal(true) + }) + + it('keeps access until the period ends, whatever the sweep has done', () => { + expect(hasSubscriptionAccess(subscription({ currentPeriodEnd: at(1) }), NOW)).to.equal(true) + expect(hasSubscriptionAccess(subscription({ currentPeriodEnd: at(0) }), NOW)).to.equal(false) + expect(hasSubscriptionAccess(subscription({ currentPeriodEnd: at(-1) }), NOW)).to.equal(false) + }) + + it('keeps access while a renewal is pending and the period still runs', () => { + const pending = subscription({ status: SubscriptionStatus.RENEWAL_PENDING, currentPeriodEnd: at(DAY) }) + + expect(hasSubscriptionAccess(pending, NOW)).to.equal(true) + }) + + it('keeps access through the grace period and loses it when grace ends', () => { + const pastDue = (graceEndsAt: Date) => + subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: at(-2 * DAY), + graceEndsAt, + }) + + expect(hasSubscriptionAccess(pastDue(at(DAY)), NOW)).to.equal(true) + expect(hasSubscriptionAccess(pastDue(at(0)), NOW)).to.equal(false) + }) + + it('loses access once expired, even if the stored period end is in the future', () => { + const expired = subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: at(20 * DAY) }) + + expect(hasSubscriptionAccess(expired, NOW)).to.equal(false) + }) + + it('keeps access through grace before the sweep has put a lapsed subscription into it', () => { + const lapsed = subscription({ + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: at(-DAY), + graceEndsAt: at(2 * DAY), + }) + + expect(hasSubscriptionAccess(lapsed, NOW)).to.equal(true) + }) + + it('loses access when a lapsed subscription has no grace', () => { + const lapsed = subscription({ status: SubscriptionStatus.ACTIVE, currentPeriodEnd: at(-DAY), graceEndsAt: null }) + + expect(hasSubscriptionAccess(lapsed, NOW)).to.equal(false) + }) + + it('loses access once the grace end has passed, even if the sweep has not run', () => { + const lapsed = subscription({ + status: SubscriptionStatus.RENEWAL_PENDING, + currentPeriodEnd: at(-5 * DAY), + graceEndsAt: at(-DAY), + }) + + expect(hasSubscriptionAccess(lapsed, NOW)).to.equal(false) + }) + }) + + describe('getAccessEndsAt', () => { + it('is the period end, the grace end once past due, and nothing when it never ends', () => { + const periodEnd = at(5 * DAY) + const graceEndsAt = at(8 * DAY) + + expect(getAccessEndsAt(subscription({ currentPeriodEnd: periodEnd }))).to.deep.equal(periodEnd) + expect( + getAccessEndsAt( + subscription({ status: SubscriptionStatus.PAST_DUE, currentPeriodEnd: periodEnd, graceEndsAt }), + ), + ).to.deep.equal(graceEndsAt) + expect(getAccessEndsAt(subscription({ currentPeriodEnd: null }))).to.equal(undefined) + }) + }) + + describe('applyPayment', () => { + const pubkey = 'b'.repeat(64) + + it('starts the first period now', () => { + const result = applyPayment(pubkey, undefined, plan, NOW) + + expect(result).to.deep.equal({ + pubkey, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: NOW, + currentPeriodEnd: at(30 * DAY), + graceEndsAt: at(33 * DAY), + cancelAtPeriodEnd: false, + }) + }) + + it('extends a running period from its end, so paying early loses no time', () => { + const existing = subscription({ currentPeriodStart: at(-25 * DAY), currentPeriodEnd: at(5 * DAY) }) + + const result = applyPayment(pubkey, existing, plan, NOW) + + expect(result.currentPeriodStart).to.deep.equal(at(5 * DAY)) + expect(result.currentPeriodEnd).to.deep.equal(at(35 * DAY)) + expect(result.status).to.equal(SubscriptionStatus.ACTIVE) + }) + + it('extends from the period end while a renewal is pending', () => { + const existing = subscription({ status: SubscriptionStatus.RENEWAL_PENDING, currentPeriodEnd: at(2 * DAY) }) + + expect(applyPayment(pubkey, existing, plan, NOW).currentPeriodStart).to.deep.equal(at(2 * DAY)) + }) + + it('starts now once the period has ended, so the gap is not billed back', () => { + const pastDue = subscription({ + status: SubscriptionStatus.PAST_DUE, + currentPeriodEnd: at(-2 * DAY), + graceEndsAt: at(DAY), + }) + const expired = subscription({ status: SubscriptionStatus.EXPIRED, currentPeriodEnd: at(-40 * DAY) }) + + for (const existing of [pastDue, expired]) { + const result = applyPayment(pubkey, existing, plan, NOW) + + expect(result.currentPeriodStart).to.deep.equal(NOW) + expect(result.graceEndsAt).to.deep.equal(at(33 * DAY)) + expect(result.status).to.equal(SubscriptionStatus.ACTIVE) + } + }) + + it('starts now when moving off the grandfathered plan', () => { + const grandfathered = subscription({ planId: GRANDFATHERED_PLAN_ID, currentPeriodEnd: null }) + + const result = applyPayment(pubkey, grandfathered, plan, NOW) + + expect(result.currentPeriodStart).to.deep.equal(NOW) + expect(result.currentPeriodEnd).to.deep.equal(at(30 * DAY)) + }) + + it('gives a plan with no grace no grace end', () => { + const result = applyPayment(pubkey, undefined, { ...plan, graceSeconds: 0 }, NOW) + + expect(result.graceEndsAt).to.equal(null) + }) + + it('uses the paid plan, so a plan change takes effect on payment', () => { + const premium = { ...plan, id: 'premium', intervalSeconds: 7 * 86_400 } + + const result = applyPayment(pubkey, subscription(), premium, NOW) + + expect(result.planId).to.equal('premium') + expect(result.currentPeriodEnd).to.deep.equal(at(20 * DAY + 7 * DAY)) + }) + }) + + describe('endPeriod', () => { + const ended = subscription({ currentPeriodEnd: at(-DAY) }) + + it('puts the subscription into grace counted from the period end', () => { + expect(endPeriod(ended, plan, NOW)).to.deep.equal({ + status: SubscriptionStatus.PAST_DUE, + graceEndsAt: at(2 * DAY), + }) + }) + + it('expires straight away when the plan has no grace', () => { + expect(endPeriod(ended, { ...plan, graceSeconds: 0 }, NOW).status).to.equal(SubscriptionStatus.EXPIRED) + }) + + it('expires straight away when the sweep was late and grace has already passed', () => { + const longEnded = subscription({ currentPeriodEnd: at(-10 * DAY) }) + + expect(endPeriod(longEnded, plan, NOW)).to.deep.equal({ status: SubscriptionStatus.EXPIRED, graceEndsAt: null }) + }) + }) +})