diff --git a/.changeset/subscription-billing-foundation.md b/.changeset/subscription-billing-foundation.md new file mode 100644 index 00000000..0bf870ca --- /dev/null +++ b/.changeset/subscription-billing-foundation.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add subscription billing foundation — user_subscriptions table, extended invoices, types, and repository diff --git a/.knip.json b/.knip.json index 001eba2e..9a0669ba 100644 --- a/.knip.json +++ b/.knip.json @@ -15,7 +15,8 @@ ], "ignore": [ ".nostr/**", - "src/repositories/invite-code-repository.ts" + "src/repositories/invite-code-repository.ts", + "src/repositories/user-subscription-repository.ts" ], "commitlint": false, "eslint": false, diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5091ea2f..b6171598 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -224,4 +224,9 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | | payments.processor | Either `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `nwc`. | +| payments.subscriptionPlans[] | Optional subscription plan definitions for recurring relay billing. Disabled when empty. Plans live in settings, not the database. | +| payments.subscriptionPlans[].id | Stable plan identifier referenced by `user_subscriptions.plan_id`. | +| payments.subscriptionPlans[].amount | Recurring price in msats for the configured interval. | +| payments.subscriptionPlans[].interval | Billing cadence: `weekly`, `monthly`, or `yearly`. | +| payments.subscriptionPlans[].benefits.retentionDays | Optional per-plan retention override (enforcement lands in a later PR). | | 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. | diff --git a/migrations/20260708_120000_create_subscription_billing_foundation.js b/migrations/20260708_120000_create_subscription_billing_foundation.js new file mode 100644 index 00000000..cabc439e --- /dev/null +++ b/migrations/20260708_120000_create_subscription_billing_foundation.js @@ -0,0 +1,63 @@ +exports.up = async function (knex) { + await knex.schema.createTable('user_subscriptions', (table) => { + table.uuid('id').notNullable().defaultTo(knex.raw('uuid_generate_v4()')) + table.binary('pubkey').primary() + table.text('plan_id').notNullable() + table + .enum('status', ['active', 'renewal_pending', 'past_due', 'expired', 'canceled'], { + useNative: true, + enumName: 'subscription_status', + }) + .notNullable() + table.timestamp('current_period_start', { useTz: true }).notNullable() + table.timestamp('current_period_end', { useTz: true }).notNullable() + table.timestamp('grace_until', { 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()) + }) + + await knex.raw('CREATE UNIQUE INDEX user_subscriptions_id_idx ON user_subscriptions(id)') + + await knex.raw( + 'ALTER TABLE user_subscriptions ADD CONSTRAINT user_subscriptions_pubkey_fkey FOREIGN KEY (pubkey) REFERENCES users(pubkey) ON DELETE CASCADE', + ) + + await knex.raw( + 'CREATE INDEX user_subscriptions_status_period_end_idx ON user_subscriptions(status, current_period_end)', + ) + + await knex.schema.alterTable('invoices', (table) => { + table + .enum('fee_schedule', ['admission', 'subscription', 'publication'], { + useNative: true, + enumName: 'invoice_fee_schedule', + }) + .notNullable() + .defaultTo('admission') + table.text('plan_id').nullable() + table.uuid('subscription_id').nullable() + table.timestamp('period_start', { useTz: true }).nullable() + table.timestamp('period_end', { useTz: true }).nullable() + }) + + await knex.raw( + 'ALTER TABLE invoices ADD CONSTRAINT invoices_subscription_id_fkey FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id) ON DELETE SET NULL', + ) +} + +exports.down = async function (knex) { + await knex.schema.alterTable('invoices', (table) => { + table.dropForeign('subscription_id') + table.dropColumn('period_end') + table.dropColumn('period_start') + table.dropColumn('subscription_id') + table.dropColumn('plan_id') + table.dropColumn('fee_schedule') + }) + + await knex.schema.dropTableIfExists('user_subscriptions') + + await knex.raw('DROP TYPE IF EXISTS invoice_fee_schedule') + await knex.raw('DROP TYPE IF EXISTS subscription_status') +} diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 00aeba35..26bfcee0 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -11,6 +11,7 @@ info: payments: enabled: false processor: zebedee + subscriptionPlans: [] feeSchedules: admission: - enabled: false diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts index 3b94ab99..1ea7b972 100644 --- a/src/@types/invoice.ts +++ b/src/@types/invoice.ts @@ -12,6 +12,12 @@ export enum InvoiceStatus { EXPIRED = 'expired', } +export enum InvoiceFeeSchedule { + ADMISSION = 'admission', + SUBSCRIPTION = 'subscription', + PUBLICATION = 'publication', +} + export interface Invoice { id: string pubkey: Pubkey @@ -26,6 +32,11 @@ export interface Invoice { updatedAt: Date createdAt: Date verifyURL?: string + feeSchedule?: InvoiceFeeSchedule + planId?: string | null + subscriptionId?: string | null + periodStart?: Date | null + periodEnd?: Date | null } export interface LnurlInvoice extends Invoice { @@ -46,4 +57,9 @@ export interface DBInvoice { updated_at: Date created_at: Date verify_url: string + fee_schedule?: InvoiceFeeSchedule + plan_id?: string | null + subscription_id?: string | null + period_start?: Date | null + period_end?: Date | null } diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index ff12b8dc..98666b7c 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -9,6 +9,7 @@ import { Invoice } from './invoice' import { Nip05Verification } from './nip05' import { SubscriptionFilter } from './subscription' import { User } from './user' +import { UserSubscription } from './user-subscription' export interface EventRetentionOptions { maxDays?: number @@ -73,3 +74,10 @@ export interface IInviteCodeRepository { deleteExpiredCodes(): Promise } +export interface IUserSubscriptionRepository { + findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise + upsert(subscription: UserSubscription, client?: DatabaseClient): Promise + findDueForRenewal(before: Date, limit?: number, client?: DatabaseClient): Promise + findExpired(asOf: Date, limit?: number, client?: DatabaseClient): Promise +} + diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 12136a19..0f89f1f3 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -179,10 +179,30 @@ export interface FeeSchedules { publication: FeeSchedule[] } +export type SubscriptionPlanInterval = 'weekly' | 'monthly' | 'yearly' + +export interface SubscriptionPlanBenefits { + retentionDays?: number + eventRateLimitMultiplier?: number + connectionLimit?: number +} + +export interface SubscriptionPlan { + id: string + name: string + enabled: boolean + amount: bigint + interval: SubscriptionPlanInterval + gracePeriodDays?: number + renewalReminderDays?: number[] + benefits?: SubscriptionPlanBenefits +} + export interface Payments { enabled: boolean processor: keyof PaymentsProcessors feeSchedules: FeeSchedules + subscriptionPlans?: SubscriptionPlan[] } export interface LnurlPaymentsProcessor { diff --git a/src/@types/user-subscription.ts b/src/@types/user-subscription.ts new file mode 100644 index 00000000..b12dc6ac --- /dev/null +++ b/src/@types/user-subscription.ts @@ -0,0 +1,35 @@ +import { Pubkey } from './base' + +export enum SubscriptionStatus { + ACTIVE = 'active', + RENEWAL_PENDING = 'renewal_pending', + PAST_DUE = 'past_due', + EXPIRED = 'expired', + CANCELED = 'canceled', +} + +export interface UserSubscription { + id: string + pubkey: Pubkey + planId: string + status: SubscriptionStatus + currentPeriodStart: Date + currentPeriodEnd: Date + graceUntil: Date | null + cancelAtPeriodEnd: boolean + createdAt: Date + updatedAt: Date +} + +export interface DBUserSubscription { + id: string + pubkey: Buffer + plan_id: string + status: SubscriptionStatus + current_period_start: Date + current_period_end: Date + grace_until: 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 4dbcfd81..05b30e03 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -1,6 +1,6 @@ import { always, applySpec, head, ifElse, is, map, omit, pipe, prop, propSatisfies, toString } from 'ramda' -import { DBInvoice, Invoice, InvoiceStatus } from '../@types/invoice' +import { DBInvoice, Invoice, InvoiceFeeSchedule, InvoiceStatus } from '../@types/invoice' import { fromDBInvoice, toBuffer } from '../utils/transform' import { createLogger } from '../factories/logger-factory' import { DatabaseClient } from '../@types/base' @@ -92,6 +92,15 @@ export class InvoiceRepository implements IInvoiceRepository { updated_at: always(new Date()), created_at: prop('createdAt'), verify_url: prop('verifyURL'), + fee_schedule: ifElse( + propSatisfies(is(String), 'feeSchedule'), + prop('feeSchedule'), + always(InvoiceFeeSchedule.ADMISSION), + ), + plan_id: prop('planId'), + subscription_id: prop('subscriptionId'), + period_start: prop('periodStart'), + period_end: prop('periodEnd'), })(invoice) logger('row: %o', row) @@ -110,6 +119,11 @@ export class InvoiceRepository implements IInvoiceRepository { 'expires_at', 'created_at', 'verify_url', + 'fee_schedule', + 'plan_id', + 'subscription_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 00000000..8708466e --- /dev/null +++ b/src/repositories/user-subscription-repository.ts @@ -0,0 +1,141 @@ +import { DatabaseClient, Pubkey } from '../@types/base' +import { IUserSubscriptionRepository } from '../@types/repositories' +import { DBUserSubscription, SubscriptionStatus, UserSubscription } from '../@types/user-subscription' +import { createLogger } from '../factories/logger-factory' +import { toBuffer } from '../utils/transform' +import { randomUUID } from 'crypto' + +const logger = createLogger('user-subscription-repository') + +function fromDBUserSubscription(row: DBUserSubscription): UserSubscription { + return { + id: row.id, + pubkey: row.pubkey.toString('hex'), + planId: row.plan_id, + status: row.status, + currentPeriodStart: row.current_period_start, + currentPeriodEnd: row.current_period_end, + graceUntil: row.grace_until, + cancelAtPeriodEnd: row.cancel_at_period_end, + createdAt: row.created_at, + updatedAt: row.updated_at, + } +} + +function toDBUserSubscription(subscription: UserSubscription): DBUserSubscription { + return { + id: subscription.id, + pubkey: toBuffer(subscription.pubkey), + plan_id: subscription.planId, + status: subscription.status, + current_period_start: subscription.currentPeriodStart, + current_period_end: subscription.currentPeriodEnd, + grace_until: subscription.graceUntil, + cancel_at_period_end: subscription.cancelAtPeriodEnd, + created_at: subscription.createdAt, + updated_at: subscription.updatedAt, + } +} + +export class UserSubscriptionRepository implements IUserSubscriptionRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async findByPubkey( + pubkey: Pubkey, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('find subscription by pubkey') + + const [row] = await client('user_subscriptions') + .where('pubkey', toBuffer(pubkey)) + .select() + + if (!row) { + return + } + + return fromDBUserSubscription(row) + } + + public async upsert( + subscription: UserSubscription, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('upsert subscription for %s', subscription.pubkey) + + const now = new Date() + const row = { + ...toDBUserSubscription({ + ...subscription, + id: subscription.id || randomUUID(), + updatedAt: now, + createdAt: subscription.createdAt ?? now, + }), + updated_at: now, + } + + await client('user_subscriptions') + .insert(row) + .onConflict('pubkey') + .merge([ + 'plan_id', + 'status', + 'current_period_start', + 'current_period_end', + 'grace_until', + 'cancel_at_period_end', + 'updated_at', + ]) + + const saved = await this.findByPubkey(subscription.pubkey, client) + if (!saved) { + throw new Error(`Unable to upsert subscription for ${subscription.pubkey}`) + } + + return saved + } + + public async findDueForRenewal( + before: Date, + limit: number = 100, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('find subscriptions due for renewal before %s (limit %d)', before.toISOString(), limit) + + const rows = await client('user_subscriptions') + .whereIn('status', [SubscriptionStatus.ACTIVE, SubscriptionStatus.RENEWAL_PENDING]) + .where('current_period_end', '<=', before) + .orderBy('current_period_end', 'asc') + .limit(limit) + .select() + + return rows.map(fromDBUserSubscription) + } + + public async findExpired( + asOf: Date, + limit: number = 100, + client: DatabaseClient = this.dbClient, + ): Promise { + logger('find expired subscriptions as of %s (limit %d)', asOf.toISOString(), limit) + + const rows = await client('user_subscriptions') + .where(function () { + this.where('status', SubscriptionStatus.PAST_DUE).where(function () { + this.whereNull('grace_until').orWhere('grace_until', '<=', asOf) + }) + }) + .orWhere(function () { + this.whereIn('status', [SubscriptionStatus.ACTIVE, SubscriptionStatus.RENEWAL_PENDING]).where( + 'current_period_end', + '<=', + asOf, + ) + }) + .orderBy('current_period_end', 'asc') + .limit(limit) + .select() + + return rows.map(fromDBUserSubscription) + } +} diff --git a/src/utils/transform.ts b/src/utils/transform.ts index da3cfc5e..5caa7fca 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -132,6 +132,11 @@ export const fromDBInvoice = applySpec({ updatedAt: prop('updated_at'), createdAt: prop('created_at'), verifyURL: prop('verify_url'), + feeSchedule: prop('fee_schedule'), + planId: prop('plan_id'), + subscriptionId: prop('subscription_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 7dedc4b4..f594531b 100644 --- a/test/unit/repositories/invoice-repository.spec.ts +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -5,7 +5,7 @@ import sinonChai from 'sinon-chai' import chaiAsPromised from 'chai-as-promised' import { DatabaseClient } from '../../../src/@types/base' -import { Invoice, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { Invoice, InvoiceFeeSchedule, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' import { IInvoiceRepository } from '../../../src/@types/repositories' import { InvoiceRepository } from '../../../src/repositories/invoice-repository' @@ -264,5 +264,59 @@ describe('InvoiceRepository', () => { expect(sql).to.include('"unit"') expect(sql).to.include('"description"') }) + + it('includes subscription billing fields when provided', () => { + const sql = repository + .upsert({ + ...testInvoice, + feeSchedule: InvoiceFeeSchedule.SUBSCRIPTION, + planId: 'pro', + subscriptionId: '11111111-1111-4111-8111-111111111111', + periodStart: fixedDate, + periodEnd: new Date('2026-02-01T00:00:00.000Z'), + }) + .toString() + + expect(sql).to.include('"fee_schedule"') + expect(sql).to.include('"plan_id"') + expect(sql).to.include('"subscription_id"') + expect(sql).to.include('"period_start"') + expect(sql).to.include('"period_end"') + expect(sql).to.include('subscription') + expect(sql).to.include('pro') + }) + + it('defaults fee_schedule to admission when omitted', () => { + const sql = repository.upsert(testInvoice).toString() + + expect(sql).to.include('"fee_schedule"') + expect(sql).to.include('admission') + }) + }) + + describe('fromDBInvoice subscription fields', () => { + it('maps nullable subscription billing columns', async () => { + const selectStub = sandbox.stub().resolves([ + { + ...dbInvoiceRow, + fee_schedule: 'subscription', + plan_id: 'basic', + subscription_id: '11111111-1111-4111-8111-111111111111', + period_start: fixedDate, + period_end: new Date('2026-02-01T00:00:00.000Z'), + }, + ]) + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: selectStub }), + }) as unknown as DatabaseClient + + const result = await repository.findById('test-invoice-id', client) + + expect(result!.feeSchedule).to.equal('subscription') + expect(result!.planId).to.equal('basic') + expect(result!.subscriptionId).to.equal('11111111-1111-4111-8111-111111111111') + expect(result!.periodStart).to.deep.equal(fixedDate) + expect(result!.periodEnd).to.deep.equal(new Date('2026-02-01T00:00:00.000Z')) + }) }) }) 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 00000000..13aed4bc --- /dev/null +++ b/test/unit/repositories/user-subscription-repository.spec.ts @@ -0,0 +1,184 @@ +import * as chai from 'chai' +import * as sinon from 'sinon' +import knex from 'knex' +import sinonChai from 'sinon-chai' +import chaiAsPromised from 'chai-as-promised' + +import { DatabaseClient } from '../../../src/@types/base' +import { SubscriptionStatus, UserSubscription } from '../../../src/@types/user-subscription' +import { UserSubscriptionRepository } from '../../../src/repositories/user-subscription-repository' + +chai.use(sinonChai) +chai.use(chaiAsPromised) + +const { expect } = chai + +describe('UserSubscriptionRepository', () => { + let repository: UserSubscriptionRepository + let sandbox: sinon.SinonSandbox + let dbClient: DatabaseClient + + const fixedDate = new Date('2026-07-08T00:00:00.000Z') + const pubkeyHex = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + const subscriptionId = '11111111-1111-4111-8111-111111111111' + + const dbSubscriptionRow = { + id: subscriptionId, + pubkey: Buffer.from(pubkeyHex, 'hex'), + plan_id: 'basic', + status: SubscriptionStatus.ACTIVE, + current_period_start: fixedDate, + current_period_end: new Date('2026-08-08T00:00:00.000Z'), + grace_until: null as Date | null, + cancel_at_period_end: false, + created_at: fixedDate, + updated_at: fixedDate, + } + + const testSubscription: UserSubscription = { + id: subscriptionId, + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + currentPeriodStart: fixedDate, + currentPeriodEnd: new Date('2026-08-08T00:00:00.000Z'), + graceUntil: null, + cancelAtPeriodEnd: false, + createdAt: fixedDate, + updatedAt: fixedDate, + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + sandbox.useFakeTimers(fixedDate.getTime()) + dbClient = knex({ client: 'pg' }) + repository = new UserSubscriptionRepository(dbClient) + }) + + afterEach(async () => { + try { + await dbClient.destroy() + } finally { + sandbox.restore() + } + }) + + describe('.findByPubkey', () => { + it('returns undefined when no subscription exists', async () => { + const selectStub = sandbox.stub().resolves([]) + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: selectStub }), + }) as unknown as DatabaseClient + + const result = await repository.findByPubkey(pubkeyHex, client) + + expect(result).to.be.undefined + }) + + it('returns a transformed subscription when found', async () => { + const selectStub = sandbox.stub().resolves([dbSubscriptionRow]) + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ select: selectStub }), + }) as unknown as DatabaseClient + + const result = await repository.findByPubkey(pubkeyHex, client) + + expect(result).to.deep.include({ + id: subscriptionId, + pubkey: pubkeyHex, + planId: 'basic', + status: SubscriptionStatus.ACTIVE, + cancelAtPeriodEnd: false, + }) + }) + + it('queries the user_subscriptions table by pubkey', async () => { + const whereStub = sandbox.stub().returns({ select: sandbox.stub().resolves([]) }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + await repository.findByPubkey(pubkeyHex, client) + + expect(client).to.have.been.calledWith('user_subscriptions') + expect(whereStub).to.have.been.calledWith('pubkey', Buffer.from(pubkeyHex, 'hex')) + }) + }) + + describe('.upsert', () => { + it('inserts into user_subscriptions with on-conflict merge', async () => { + const mergeStub = sandbox.stub().resolves() + const onConflictStub = sandbox.stub().returns({ merge: mergeStub }) + const insertStub = sandbox.stub().returns({ onConflict: onConflictStub }) + const findSelectStub = sandbox.stub().resolves([dbSubscriptionRow]) + const client = sandbox.stub().returns({ + insert: insertStub, + where: sandbox.stub().returns({ select: findSelectStub }), + }) as unknown as DatabaseClient + + await repository.upsert(testSubscription, client) + + expect(client).to.have.been.calledWith('user_subscriptions') + expect(onConflictStub).to.have.been.calledWith('pubkey') + expect(mergeStub).to.have.been.calledOnce + }) + + it('returns the saved subscription', async () => { + const mergeStub = sandbox.stub().resolves() + const onConflictStub = sandbox.stub().returns({ merge: mergeStub }) + const insertStub = sandbox.stub().returns({ onConflict: onConflictStub }) + const findSelectStub = sandbox.stub().resolves([dbSubscriptionRow]) + const client = sandbox.stub().returns({ + insert: insertStub, + where: sandbox.stub().returns({ select: findSelectStub }), + }) as unknown as DatabaseClient + + const result = await repository.upsert(testSubscription, client) + + expect(result.planId).to.equal('basic') + expect(result.pubkey).to.equal(pubkeyHex) + }) + }) + + describe('.findDueForRenewal', () => { + it('queries active and renewal_pending subscriptions ending before the cutoff', async () => { + const selectStub = sandbox.stub().resolves([dbSubscriptionRow]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const wherePeriodEndStub = sandbox.stub().returns({ orderBy: orderByStub }) + const whereInStub = sandbox.stub().returns({ where: wherePeriodEndStub }) + const client = sandbox.stub().returns({ whereIn: whereInStub }) as unknown as DatabaseClient + + const cutoff = new Date('2026-08-01T00:00:00.000Z') + const result = await repository.findDueForRenewal(cutoff, 25, client) + + expect(client).to.have.been.calledWith('user_subscriptions') + expect(whereInStub).to.have.been.calledWith('status', [ + SubscriptionStatus.ACTIVE, + SubscriptionStatus.RENEWAL_PENDING, + ]) + expect(wherePeriodEndStub).to.have.been.calledWith('current_period_end', '<=', cutoff) + expect(limitStub).to.have.been.calledWith(25) + expect(result).to.have.length(1) + expect(result[0].planId).to.equal('basic') + }) + }) + + describe('.findExpired', () => { + it('queries subscriptions that are past due or past period end', async () => { + const selectStub = sandbox.stub().resolves([dbSubscriptionRow]) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const orWhereStub = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub = sandbox.stub().returns({ orWhere: orWhereStub }) + const client = sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient + + const asOf = new Date('2026-09-01T00:00:00.000Z') + const result = await repository.findExpired(asOf, 10, client) + + expect(client).to.have.been.calledWith('user_subscriptions') + expect(whereStub).to.have.been.calledOnce + expect(orWhereStub).to.have.been.calledOnce + expect(limitStub).to.have.been.calledWith(10) + expect(result).to.have.length(1) + }) + }) +})