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

Filter by extension

Filter by extension

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

feat: add subscription billing foundation — user_subscriptions table, extended invoices, types, and repository
3 changes: 2 additions & 1 deletion .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Original file line number Diff line number Diff line change
@@ -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')
}
1 change: 1 addition & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ info:
payments:
enabled: false
processor: zebedee
subscriptionPlans: []
feeSchedules:
admission:
- enabled: false
Expand Down
16 changes: 16 additions & 0 deletions src/@types/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
8 changes: 8 additions & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,3 +74,10 @@ export interface IInviteCodeRepository {
deleteExpiredCodes(): Promise<number>
}

export interface IUserSubscriptionRepository {
findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise<UserSubscription | undefined>
upsert(subscription: UserSubscription, client?: DatabaseClient): Promise<UserSubscription>
findDueForRenewal(before: Date, limit?: number, client?: DatabaseClient): Promise<UserSubscription[]>
findExpired(asOf: Date, limit?: number, client?: DatabaseClient): Promise<UserSubscription[]>
}

20 changes: 20 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions src/@types/user-subscription.ts
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 15 additions & 1 deletion src/repositories/invoice-repository.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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),
Comment thread
Ferryx349 marked this conversation as resolved.
)

Expand Down
141 changes: 141 additions & 0 deletions src/repositories/user-subscription-repository.ts
Original file line number Diff line number Diff line change
@@ -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<UserSubscription | undefined> {
logger('find subscription by pubkey')

const [row] = await client<DBUserSubscription>('user_subscriptions')
.where('pubkey', toBuffer(pubkey))
.select()

if (!row) {
return
}

return fromDBUserSubscription(row)
}

public async upsert(
subscription: UserSubscription,
client: DatabaseClient = this.dbClient,
): Promise<UserSubscription> {
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<DBUserSubscription>('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<UserSubscription[]> {
logger('find subscriptions due for renewal before %s (limit %d)', before.toISOString(), limit)

const rows = await client<DBUserSubscription>('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<UserSubscription[]> {
logger('find expired subscriptions as of %s (limit %d)', asOf.toISOString(), limit)

const rows = await client<DBUserSubscription>('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)
}
}
Loading
Loading