+ {{plan_fields_html}}
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 `${escapeHtml(label)}${plan.description ? ` - ${escapeHtml(plan.description)}` : ''}
`
+ })
+ .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 })
+ })
+ })
+})