diff --git a/.changeset/subscription-data-model.md b/.changeset/subscription-data-model.md new file mode 100644 index 000000000..d86a01fae --- /dev/null +++ b/.changeset/subscription-data-model.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(payments): add the subscription data model and plan settings diff --git a/.knip.json b/.knip.json index 38f02c018..eab9b533e 100644 --- a/.knip.json +++ b/.knip.json @@ -17,7 +17,8 @@ ], "ignore": [ ".nostr/**", - "src/utils/relay-probe/**" + "src/utils/relay-probe/**", + "src/repositories/user-subscription-repository.ts" ], "commitlint": false, "eslint": false, diff --git a/CONFIGURATION.md b/CONFIGURATION.md index a336dd451..90a1430a0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -250,6 +250,13 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | | payments.processor | Either `zebedee`, `lnbits`, `lnurl`. | +| payments.subscriptionPlans[].id | Unique plan name, for example `basic`. `grandfathered` is reserved: users admitted before plans existed are moved onto it and keep access without a paid period. | +| payments.subscriptionPlans[].amount | Price of one period in msats. Must be unique across plans, because the amount paid selects the plan. | +| payments.subscriptionPlans[].intervalSeconds | Length of one paid period, in seconds. | +| payments.subscriptionPlans[].graceSeconds | How long access lasts after a period ends without a renewal, in seconds. `0` ends access straight away. | +| payments.subscriptionPlans[].description | Optional text shown to users. | +| payments.subscriptionPlans[].limits.rateLimits | Optional event rate limits for subscribers on this plan, in the same format as `limits.event.rateLimits`. | +| payments.subscriptionPlans[].limits.retentionMaxDays | Optional event retention in days for subscribers on this plan. | | workers.count | Number of workers to spin up to handle incoming connections. | | | Spin workers as many CPUs are available when set to zero. Defaults to zero. | | limits.message.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | diff --git a/migrations/20261016_120000_create_user_subscriptions_table.js b/migrations/20261016_120000_create_user_subscriptions_table.js new file mode 100644 index 000000000..9494da565 --- /dev/null +++ b/migrations/20261016_120000_create_user_subscriptions_table.js @@ -0,0 +1,45 @@ +exports.up = async function (knex) { + await knex.schema.createTable('user_subscriptions', (table) => { + // One subscription per pubkey, so the pubkey is the key. + table.binary('pubkey').primary().references('pubkey').inTable('users').onDelete('CASCADE') + table.text('plan_id').notNullable() + table.enum('status', ['active', 'renewal_pending', 'past_due', 'expired']).notNullable().defaultTo('active') + table.timestamp('current_period_start', { useTz: true }).notNullable() + // NULL means the subscription never ends (the grandfathered plan). + table.timestamp('current_period_end', { useTz: true }).nullable() + table.timestamp('grace_ends_at', { useTz: true }).nullable() + table.boolean('cancel_at_period_end').notNullable().defaultTo(false) + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + + // The renewal and expiry sweeps filter by status and order by the period or grace end. + table.index(['status', 'current_period_end'], 'idx_user_subscriptions_status_period_end') + table.index(['status', 'grace_ends_at'], 'idx_user_subscriptions_status_grace_end') + }) + + await knex.schema.alterTable('invoices', (table) => { + table.text('plan_id').nullable() + table.timestamp('period_start', { useTz: true }).nullable() + table.timestamp('period_end', { useTz: true }).nullable() + }) + + // Everyone admitted so far keeps access without a paid period: they move onto a plan that never + // expires, so turning subscription plans on later cannot lock anyone out. + await knex.raw(` + INSERT INTO user_subscriptions (pubkey, plan_id, status, current_period_start, current_period_end) + SELECT pubkey, 'grandfathered', 'active', COALESCE(tos_accepted_at AT TIME ZONE 'UTC', created_at), NULL + FROM users + WHERE is_admitted = true + ON CONFLICT (pubkey) DO NOTHING + `) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('invoices', (table) => { + table.dropColumn('plan_id') + table.dropColumn('period_start') + table.dropColumn('period_end') + }) + + await knex.schema.dropTable('user_subscriptions') +} diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 49b232307..d4d9f02a5 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -25,6 +25,14 @@ payments: - replace-with-your-pubkey-in-hex event_kinds: - 9735 # Nip-57 Lightning Zap Receipts + # Recurring membership plans. Leave empty to keep the one-time admission fee above. + # The amount paid selects the plan, so every plan needs its own amount. + # - id: basic + # description: Basic membership, paid every 30 days + # amount: 1000000 # msats per period + # intervalSeconds: 2592000 + # graceSeconds: 259200 # access lasts this long after a period ends unrenewed + subscriptionPlans: [] # Applied only when the payments processor does not report an expiry of its own. # Without it such invoices can never be retired and stay pending forever. # Raise it if your processor issues invoices that stay payable for longer than diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts index 3b94ab99b..6ca842785 100644 --- a/src/@types/invoice.ts +++ b/src/@types/invoice.ts @@ -26,6 +26,10 @@ export interface Invoice { updatedAt: Date createdAt: Date verifyURL?: string + /** Subscription plan this invoice pays for, when it is not an admission fee. */ + planId?: string | null + periodStart?: Date | null + periodEnd?: Date | null } export interface LnurlInvoice extends Invoice { @@ -46,4 +50,7 @@ export interface DBInvoice { updated_at: Date created_at: Date verify_url: string + plan_id?: string | null + period_start?: Date | null + period_end?: Date | null } diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index d4ce0d4f3..3f9a5d7de 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -16,6 +16,7 @@ import { Report } from './report' import { EventKindsRange } from './settings' import { SubscriptionFilter } from './subscription' import { User } from './user' +import { UserSubscription } from './user-subscription' export interface EventRetentionOptions { maxDays?: number @@ -59,6 +60,17 @@ export interface IInvoiceRepository { findPendingInvoices(offset?: number, limit?: number, client?: DatabaseClient): Promise } +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 + /** Active or renewal-pending subscriptions whose period has ended by `now`. */ + findPeriodEnded(now: Date, limit?: number, client?: DatabaseClient): Promise + /** Past-due subscriptions whose grace period has ended by `now`. */ + findGraceEnded(now: Date, limit?: number, client?: DatabaseClient): Promise +} + export interface IUserRepository { findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise upsert(user: Partial, client?: DatabaseClient): Promise diff --git a/src/@types/settings.ts b/src/@types/settings.ts index b7e153314..4e91ac58e 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -224,10 +224,31 @@ export interface FeeSchedules { publication: FeeSchedule[] } +export interface SubscriptionPlanLimits { + /** Replaces limits.event.rateLimits for subscribers on this plan. */ + rateLimits?: EventRateLimit[] + /** Replaces limits.event.retention.maxDays for subscribers on this plan. */ + retentionMaxDays?: number +} + +export interface SubscriptionPlan { + /** Unique, and never `grandfathered`, which is reserved. */ + id: string + description?: string + /** Price of one period in msats. Unique across plans, since the amount paid selects the plan. */ + amount: bigint + intervalSeconds: number + /** How long access lasts after a period ends without a renewal. */ + graceSeconds: number + limits?: SubscriptionPlanLimits +} + export interface Payments { enabled: boolean processor: keyof PaymentsProcessors feeSchedules: FeeSchedules + /** Recurring membership plans. Empty keeps the one-time admission fee. */ + subscriptionPlans?: SubscriptionPlan[] /** Fallback when the processor reports no expiry. A reported one always wins. */ invoiceExpirySeconds?: number } diff --git a/src/@types/user-subscription.ts b/src/@types/user-subscription.ts new file mode 100644 index 000000000..2b91bf8a2 --- /dev/null +++ b/src/@types/user-subscription.ts @@ -0,0 +1,36 @@ +import { Pubkey } from './base' + +export enum SubscriptionStatus { + ACTIVE = 'active', + RENEWAL_PENDING = 'renewal_pending', + PAST_DUE = 'past_due', + EXPIRED = 'expired', +} + +/** Plan id of the subscriptions created for users admitted before subscription plans existed. */ +export const GRANDFATHERED_PLAN_ID = 'grandfathered' + +export interface UserSubscription { + pubkey: Pubkey + planId: string + status: SubscriptionStatus + currentPeriodStart: Date + /** Null when the subscription never ends. */ + currentPeriodEnd: Date | null + graceEndsAt: Date | null + cancelAtPeriodEnd: boolean + createdAt: Date + updatedAt: Date +} + +export interface DBUserSubscription { + pubkey: Buffer + plan_id: string + status: SubscriptionStatus + current_period_start: Date + current_period_end: Date | null + grace_ends_at: Date | null + cancel_at_period_end: boolean + created_at: Date + updated_at: Date +} diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts index 4dbcfd81d..36734916e 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -92,6 +92,9 @@ export class InvoiceRepository implements IInvoiceRepository { updated_at: always(new Date()), created_at: prop('createdAt'), verify_url: prop('verifyURL'), + plan_id: prop('planId'), + period_start: prop('periodStart'), + period_end: prop('periodEnd'), })(invoice) logger('row: %o', row) @@ -110,6 +113,9 @@ export class InvoiceRepository implements IInvoiceRepository { 'expires_at', 'created_at', 'verify_url', + 'plan_id', + 'period_start', + 'period_end', ])(row), ) diff --git a/src/repositories/user-subscription-repository.ts b/src/repositories/user-subscription-repository.ts new file mode 100644 index 000000000..354a9f491 --- /dev/null +++ b/src/repositories/user-subscription-repository.ts @@ -0,0 +1,118 @@ +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBUserSubscription, SubscriptionStatus, UserSubscription } from '../@types/user-subscription' +import { fromBuffer, toBuffer } from '../utils/transform' +import { createLogger } from '../factories/logger-factory' +import { IUserSubscriptionRepository } from '../@types/repositories' + +const logger = createLogger('user-subscription-repository') + +const DEFAULT_PAGE_SIZE = 10 + +const fromDBUserSubscription = (row: DBUserSubscription): UserSubscription => ({ + pubkey: fromBuffer(row.pubkey), + planId: row.plan_id, + status: row.status, + currentPeriodStart: row.current_period_start, + currentPeriodEnd: row.current_period_end, + graceEndsAt: row.grace_ends_at, + cancelAtPeriodEnd: row.cancel_at_period_end, + createdAt: row.created_at, + updatedAt: row.updated_at, +}) + +export class UserSubscriptionRepository implements IUserSubscriptionRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async findByPubkey( + pubkey: Pubkey, + client: DatabaseClient = this.dbClient, + ): Promise { + const [row] = await client('user_subscriptions').where('pubkey', toBuffer(pubkey)).select() + + return row ? fromDBUserSubscription(row) : undefined + } + + public async upsert( + subscription: Omit, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('upserting subscription for %s: %s', subscription.pubkey, subscription.planId) + + const now = new Date() + const row: DBUserSubscription = { + pubkey: toBuffer(subscription.pubkey), + plan_id: subscription.planId, + status: subscription.status, + current_period_start: subscription.currentPeriodStart, + current_period_end: subscription.currentPeriodEnd, + grace_ends_at: subscription.graceEndsAt, + cancel_at_period_end: subscription.cancelAtPeriodEnd, + created_at: now, + updated_at: now, + } + + const result = await client('user_subscriptions') + .insert(row) + .onConflict('pubkey') + .merge([ + 'plan_id', + 'status', + 'current_period_start', + 'current_period_end', + 'grace_ends_at', + 'cancel_at_period_end', + 'updated_at', + ]) + + return (result as unknown as { rowCount?: number }).rowCount ?? 0 + } + + public async findDueForRenewal( + before: Date, + limit = DEFAULT_PAGE_SIZE, + client: DatabaseClient = this.dbClient, + ): Promise { + const rows = await client('user_subscriptions') + .where('status', SubscriptionStatus.ACTIVE) + .where('cancel_at_period_end', false) + .whereNotNull('current_period_end') + .where('current_period_end', '<=', before) + .orderBy('current_period_end', 'asc') + .limit(limit) + .select() + + return rows.map(fromDBUserSubscription) + } + + public async findPeriodEnded( + now: Date, + limit = DEFAULT_PAGE_SIZE, + client: DatabaseClient = this.dbClient, + ): Promise { + const rows = await client('user_subscriptions') + .whereIn('status', [SubscriptionStatus.ACTIVE, SubscriptionStatus.RENEWAL_PENDING]) + .whereNotNull('current_period_end') + .where('current_period_end', '<=', now) + .orderBy('current_period_end', 'asc') + .limit(limit) + .select() + + return rows.map(fromDBUserSubscription) + } + + public async findGraceEnded( + now: Date, + limit = DEFAULT_PAGE_SIZE, + client: DatabaseClient = this.dbClient, + ): Promise { + const rows = await client('user_subscriptions') + .where('status', SubscriptionStatus.PAST_DUE) + .whereNotNull('grace_ends_at') + .where('grace_ends_at', '<=', now) + .orderBy('grace_ends_at', 'asc') + .limit(limit) + .select() + + return rows.map(fromDBUserSubscription) + } +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index aa41a7017..5ac0cbee0 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -5,6 +5,7 @@ import { isNil, mergeDeepRight } from 'ramda' import { createLogger } from '../factories/logger-factory' import { EVENT_STORE_BACKENDS, isEventStoreBackend } from './event-store' +import { GRANDFATHERED_PLAN_ID } from '../@types/user-subscription' import { Settings } from '../@types/settings' import { getConfigBaseDir, @@ -589,6 +590,93 @@ const validateRateLimitPeriods = ( }) } +const isPositiveInteger = (value: unknown): boolean => + (typeof value === 'number' || typeof value === 'bigint') && Number.isInteger(Number(value)) && Number(value) > 0 + +// Unlike the global limits, a plan's limits are not covered by the settings schema while the plan list +// is empty by default, so each entry is checked here. +const validatePlanRateLimits = (rateLimits: unknown, path: string, issues: ValidationIssue[]): void => { + if (rateLimits === undefined || rateLimits === null) { + return + } + if (!Array.isArray(rateLimits)) { + issues.push({ path, message: 'rateLimits must be a list' }) + return + } + + rateLimits.forEach((entry, index) => { + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + issues.push({ path: `${path}[${index}]`, message: 'rateLimits entry must be an object' }) + return + } + for (const key of ['period', 'rate']) { + if (!isPositiveInteger(entry[key])) { + issues.push({ path: `${path}[${index}].${key}`, message: `${key} must be a positive integer` }) + } + } + }) +} + +const validateSubscriptionPlans = (plans: unknown, issues: ValidationIssue[]): void => { + // A value that is not a list is already reported against the default settings schema. + if (!Array.isArray(plans)) { + return + } + + const ids = new Set() + const amounts = new Set() + + plans.forEach((plan, index) => { + const prefix = `payments.subscriptionPlans[${index}]` + if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) { + issues.push({ path: prefix, message: 'plan must be an object' }) + return + } + + if (typeof plan.id !== 'string' || !plan.id.trim()) { + issues.push({ path: `${prefix}.id`, message: 'id is required' }) + } else if (plan.id === GRANDFATHERED_PLAN_ID) { + issues.push({ path: `${prefix}.id`, message: `id "${GRANDFATHERED_PLAN_ID}" is reserved` }) + } else if (ids.has(plan.id)) { + issues.push({ path: `${prefix}.id`, message: `id "${plan.id}" is used by more than one plan` }) + } else { + ids.add(plan.id) + } + + // The amount paid selects the plan, so two plans with one price could not be told apart. + if (!isPositiveInteger(plan.amount)) { + issues.push({ path: `${prefix}.amount`, message: 'amount must be a positive integer (msats)' }) + } else if (amounts.has(String(plan.amount))) { + issues.push({ path: `${prefix}.amount`, message: `amount ${plan.amount} is used by more than one plan` }) + } else { + amounts.add(String(plan.amount)) + } + + if (!isPositiveInteger(plan.intervalSeconds)) { + issues.push({ path: `${prefix}.intervalSeconds`, message: 'intervalSeconds must be a positive integer' }) + } + + if (!(Number.isInteger(plan.graceSeconds) && plan.graceSeconds >= 0)) { + issues.push({ path: `${prefix}.graceSeconds`, message: 'graceSeconds must be a non-negative integer' }) + } + + const limits = plan.limits + if (limits !== undefined && limits !== null) { + if (typeof limits !== 'object' || Array.isArray(limits)) { + issues.push({ path: `${prefix}.limits`, message: 'limits must be an object' }) + return + } + validatePlanRateLimits(limits.rateLimits, `${prefix}.limits.rateLimits`, issues) + if (limits.retentionMaxDays !== undefined && !isPositiveInteger(limits.retentionMaxDays)) { + issues.push({ + path: `${prefix}.limits.retentionMaxDays`, + message: 'retentionMaxDays must be a positive integer', + }) + } + } + }) +} + export const validateSettings = (settings: Settings): ValidationIssue[] => { const issues: ValidationIssue[] = [] @@ -664,6 +752,8 @@ export const validateSettings = (settings: Settings): ValidationIssue[] => { } } + validateSubscriptionPlans(settings.payments?.subscriptionPlans, issues) + const pow = settings.limits?.event?.pow if (pow?.enabled) { if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) { diff --git a/src/utils/transform.ts b/src/utils/transform.ts index da3cfc5e6..abea348d0 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -132,6 +132,9 @@ export const fromDBInvoice = applySpec({ updatedAt: prop('updated_at'), createdAt: prop('created_at'), verifyURL: prop('verify_url'), + planId: prop('plan_id'), + periodStart: prop('period_start'), + periodEnd: prop('period_end'), }) export const fromDBUser = applySpec({ diff --git a/test/unit/repositories/invoice-repository.spec.ts b/test/unit/repositories/invoice-repository.spec.ts index 7dedc4b43..281abd08c 100644 --- a/test/unit/repositories/invoice-repository.spec.ts +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -114,6 +114,35 @@ describe('InvoiceRepository', () => { expect(result!.amountRequested).to.equal(1000n) }) + it('maps the subscription plan and period when the invoice has them', async () => { + const periodStart = new Date('2026-02-01T00:00:00.000Z') + const periodEnd = new Date('2026-03-03T00:00:00.000Z') + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ + select: sandbox + .stub() + .resolves([{ ...dbInvoiceRow, plan_id: 'basic', period_start: periodStart, period_end: periodEnd }]), + }), + }) as unknown as DatabaseClient + + const result = await repository.findById('test-invoice-id', client) + + expect(result!.planId).to.equal('basic') + expect(result!.periodStart).to.equal(periodStart) + expect(result!.periodEnd).to.equal(periodEnd) + }) + + it('leaves the plan unset for an admission invoice', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: sandbox.stub().resolves([dbInvoiceRow]) }), + }) as unknown as DatabaseClient + + const result = await repository.findById('test-invoice-id', client) + + expect(result!.planId).to.equal(undefined) + expect(result!.periodEnd).to.equal(undefined) + }) + it('queries invoices table by id', async () => { const whereStub = sandbox.stub().returns({ select: sandbox.stub().resolves([]) }) const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient @@ -256,6 +285,29 @@ describe('InvoiceRepository', () => { expect(sql).to.include(`X'${pubkeyHex}'`) }) + it('stores the subscription plan and period an invoice pays for', () => { + const periodStart = new Date('2026-02-01T00:00:00.000Z') + const periodEnd = new Date('2026-03-03T00:00:00.000Z') + const sql = repository.upsert({ ...testInvoice, planId: 'basic', periodStart, periodEnd }).toString() + + expect(sql).to.include('"plan_id"') + expect(sql).to.include(`'basic'`) + expect(sql).to.include('"period_start"') + expect(sql).to.include('"period_end"') + }) + + it('never rewrites the plan or period of an existing invoice', () => { + const sql = repository + .upsert({ ...testInvoice, planId: 'basic', periodStart: fixedDate, periodEnd: fixedDate }) + .toString() + const update = sql.split('do update set')[1] + + expect(update).to.include('"status"') + expect(update).to.not.include('"plan_id"') + expect(update).to.not.include('"period_start"') + expect(update).to.not.include('"period_end"') + }) + it('includes all required invoice fields', () => { const sql = repository.upsert(testInvoice).toString() diff --git a/test/unit/repositories/user-subscription-repository.spec.ts b/test/unit/repositories/user-subscription-repository.spec.ts new file mode 100644 index 000000000..1f47c9b0c --- /dev/null +++ b/test/unit/repositories/user-subscription-repository.spec.ts @@ -0,0 +1,169 @@ +import * as chai from 'chai' +import knex from 'knex' + +import { DatabaseClient } from '../../../src/@types/base' +import { DBUserSubscription, SubscriptionStatus } from '../../../src/@types/user-subscription' +import { UserSubscriptionRepository } from '../../../src/repositories/user-subscription-repository' + +const { expect } = chai + +describe('UserSubscriptionRepository', () => { + const pubkeyHex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const start = new Date('2026-01-01T00:00:00.000Z') + const end = new Date('2026-01-31T00:00:00.000Z') + + const row: DBUserSubscription = { + pubkey: Buffer.from(pubkeyHex, 'hex'), + plan_id: 'basic', + status: SubscriptionStatus.ACTIVE, + current_period_start: start, + current_period_end: end, + grace_ends_at: null, + cancel_at_period_end: false, + created_at: start, + updated_at: start, + } + + const real = knex({ client: 'pg' }) + let sql: string[] + + // A client that builds real knex queries, records the SQL they would run and answers with `result`. + const clientReturning = (result: unknown): DatabaseClient => + ((table: string) => { + const builder: any = real(table) + builder.then = (resolve: (value: unknown) => unknown) => { + sql.push(builder.toString()) + return Promise.resolve(result).then(resolve) + } + return builder + }) as unknown as DatabaseClient + + let repository: UserSubscriptionRepository + + beforeEach(() => { + sql = [] + repository = new UserSubscriptionRepository({} as DatabaseClient) + }) + + describe('.findByPubkey', () => { + it('returns undefined when the pubkey has no subscription', async () => { + expect(await repository.findByPubkey(pubkeyHex, clientReturning([]))).to.equal(undefined) + }) + + it('maps the row to a UserSubscription and queries by the pubkey bytes', async () => { + const subscription = await repository.findByPubkey(pubkeyHex, clientReturning([row])) + + expect(subscription).to.deep.equal({ + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: start, + currentPeriodEnd: end, + graceEndsAt: null, + cancelAtPeriodEnd: false, + createdAt: start, + updatedAt: start, + }) + expect(sql[0]).to.include('from "user_subscriptions"') + expect(sql[0]).to.include(`"pubkey" = X'${pubkeyHex}'`) + }) + + it('keeps a never-ending subscription as a null period end', async () => { + const subscription = await repository.findByPubkey( + pubkeyHex, + clientReturning([{ ...row, plan_id: 'grandfathered', current_period_end: null }]), + ) + + expect(subscription?.currentPeriodEnd).to.equal(null) + }) + }) + + describe('.upsert', () => { + const subscription = { + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: start, + currentPeriodEnd: end, + graceEndsAt: null, + cancelAtPeriodEnd: false, + } + + it('inserts or updates the row for the pubkey', async () => { + await repository.upsert(subscription, clientReturning({ rowCount: 1 })) + + expect(sql[0]).to.include('insert into "user_subscriptions"') + expect(sql[0]).to.include(`X'${pubkeyHex}'`) + expect(sql[0]).to.include('on conflict ("pubkey") do update set') + }) + + it('updates the subscription but never the pubkey or created_at', async () => { + await repository.upsert(subscription, clientReturning({ rowCount: 1 })) + + const update = sql[0].split('do update set')[1] + for (const column of [ + 'plan_id', + 'status', + 'current_period_start', + 'current_period_end', + 'grace_ends_at', + 'cancel_at_period_end', + 'updated_at', + ]) { + expect(update, column).to.include(`"${column}" = excluded."${column}"`) + } + expect(update).to.not.include('"pubkey"') + expect(update).to.not.include('"created_at"') + }) + + it('returns the number of affected rows', async () => { + expect(await repository.upsert(subscription, clientReturning({ rowCount: 1 }))).to.equal(1) + }) + }) + + 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])) + + expect(subscriptions).to.have.length(1) + expect(sql[0]).to.include(`"status" = 'active'`) + expect(sql[0]).to.include('"cancel_at_period_end" = false') + 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('order by "current_period_end" asc') + expect(sql[0]).to.include('limit 10') + }) + + it('honours the limit', async () => { + await repository.findDueForRenewal(end, 3, clientReturning([])) + + expect(sql[0]).to.include('limit 3') + }) + }) + + describe('.findPeriodEnded', () => { + it('selects active and renewal-pending subscriptions whose period has ended', async () => { + await repository.findPeriodEnded(end, 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') + }) + }) + + describe('.findGraceEnded', () => { + it('selects past-due subscriptions whose grace period has ended', async () => { + await repository.findGraceEnded( + end, + undefined, + clientReturning([{ ...row, status: SubscriptionStatus.PAST_DUE }]), + ) + + expect(sql[0]).to.include(`"status" = 'past_due'`) + expect(sql[0]).to.include('"grace_ends_at" is not null') + expect(sql[0]).to.include(`"grace_ends_at" <= '2026-01-31`) + expect(sql[0]).to.include('order by "grace_ends_at" asc') + }) + }) +}) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index d0eadb226..407385e2a 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -289,6 +289,132 @@ describe('settings-config', () => { }) }) + describe('subscription plan settings', () => { + const settingsWith = (subscriptionPlans: any) => + ({ + info: { relay_url: 'wss://test.relay', name: 'test' }, + network: {}, + payments: { enabled: true, processor: 'lnbits', subscriptionPlans }, + }) as any + + const planIssues = (subscriptionPlans: any) => + validateSettings(settingsWith(subscriptionPlans)).filter((issue) => + issue.path.startsWith('payments.subscriptionPlans'), + ) + + const basic = { id: 'basic', amount: 1000000, intervalSeconds: 2592000, graceSeconds: 259200 } + const premium = { id: 'premium', amount: 5000000, intervalSeconds: 2592000, graceSeconds: 0 } + + it('accepts no plans, an empty list and valid plans', () => { + expect(planIssues(undefined)).to.deep.equal([]) + expect(planIssues([])).to.deep.equal([]) + expect(planIssues([basic, premium])).to.deep.equal([]) + }) + + it('accepts per-plan limits', () => { + const plan = { ...basic, limits: { rateLimits: [{ period: 60000, rate: 120 }], retentionMaxDays: 90 } } + + expect(planIssues([plan])).to.deep.equal([]) + }) + + it('reports a value that is not a list once, through the settings schema', () => { + expect(planIssues({ basic }).map((issue) => issue.path)).to.deep.equal(['payments.subscriptionPlans']) + }) + + it('rejects a plan that is not an object', () => { + expect(planIssues(['basic']).map((issue) => issue.path)).to.deep.equal(['payments.subscriptionPlans[0]']) + }) + + it('rejects a missing, empty, reserved or repeated id', () => { + expect(planIssues([{ ...basic, id: undefined }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].id', + ]) + expect(planIssues([{ ...basic, id: ' ' }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].id', + ]) + expect(planIssues([{ ...basic, id: 'grandfathered' }]).map((issue) => issue.message)).to.deep.equal([ + 'id "grandfathered" is reserved', + ]) + expect(planIssues([basic, { ...premium, id: 'basic' }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[1].id', + ]) + }) + + for (const bad of [0, -1, 1.5, '1000', undefined]) { + it(`rejects amount = ${String(bad)}`, () => { + expect(planIssues([{ ...basic, amount: bad }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].amount', + ]) + }) + + it(`rejects intervalSeconds = ${String(bad)}`, () => { + expect(planIssues([{ ...basic, intervalSeconds: bad }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].intervalSeconds', + ]) + }) + } + + it('rejects two plans with the same amount, since the amount selects the plan', () => { + const issues = planIssues([basic, { ...premium, amount: basic.amount }]) + + expect(issues.map((issue) => issue.path)).to.deep.equal(['payments.subscriptionPlans[1].amount']) + }) + + it('accepts a bigint amount', () => { + expect(planIssues([{ ...basic, amount: 1000000n }])).to.deep.equal([]) + }) + + it('allows a zero grace period but not a negative or fractional one', () => { + expect(planIssues([{ ...basic, graceSeconds: 0 }])).to.deep.equal([]) + for (const bad of [-1, 0.5, '60', undefined]) { + expect(planIssues([{ ...basic, graceSeconds: bad }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].graceSeconds', + ]) + } + }) + + it('rejects invalid per-plan limits', () => { + expect(planIssues([{ ...basic, limits: 'fast' }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits', + ]) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: 0, rate: 1 }] } }]).map((issue) => issue.path), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].period']) + expect(planIssues([{ ...basic, limits: { rateLimits: 'fast' } }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits.rateLimits', + ]) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: 60000, rate: 'fast' }] } }]).map( + (issue) => issue.path, + ), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].rate']) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: '60000', rate: 5 }] } }]).map((issue) => issue.path), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].period']) + expect( + planIssues([{ ...basic, limits: { rateLimits: [{ period: 60000 }] } }]).map((issue) => issue.path), + ).to.deep.equal(['payments.subscriptionPlans[0].limits.rateLimits[0].rate']) + expect(planIssues([{ ...basic, limits: { rateLimits: ['x'] } }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits.rateLimits[0]', + ]) + expect(planIssues([{ ...basic, limits: { retentionMaxDays: 0 } }]).map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].limits.retentionMaxDays', + ]) + }) + + it('reports a problem in each plan', () => { + const issues = planIssues([ + { ...basic, intervalSeconds: 0 }, + { ...premium, graceSeconds: -1 }, + ]) + + expect(issues.map((issue) => issue.path)).to.deep.equal([ + 'payments.subscriptionPlans[0].intervalSeconds', + 'payments.subscriptionPlans[1].graceSeconds', + ]) + }) + }) + describe('adaptive pow settings', () => { const baseSettings = () => ({