Skip to content
Open
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/paid-peering-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(peering): add the paid peering billing ledger
130 changes: 130 additions & 0 deletions migrations/20261109_120000_add_peering_billing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
exports.up = async function (knex) {
await knex.schema.alterTable('invoices', (table) => {
table.enum('purpose', ['admission', 'subscription', 'peering']).nullable()
table.bigint('term_amount_msats').unsigned().nullable()
table.integer('term_period_seconds').nullable()
table.index(['purpose', 'status'], 'idx_invoices_purpose_status')
})

// Existing rows predate the purpose column. A plan means a subscription; no plan means the
// admission-fee flow. Peering invoices always set purpose explicitly.
await knex.raw(`
UPDATE invoices
SET purpose = CASE WHEN plan_id IS NULL THEN 'admission' ELSE 'subscription' END
WHERE purpose IS NULL
`)

await knex.raw('ALTER TABLE invoices ALTER COLUMN purpose SET DEFAULT \'admission\'')
await knex.raw('ALTER TABLE invoices ALTER COLUMN purpose SET NOT NULL')

// confirm_invoice() credits the payer's user balance. A peering invoice is paid
// by another relay, not by a user, so skip the balance credit for that purpose.
await knex.raw(`
CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
payee BYTEA;
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
invoice_unit TEXT;
invoice_purpose TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();

SELECT "pubkey", "confirmed_at", "unit", "purpose"
INTO payee, confirmed_date, invoice_unit, invoice_purpose
FROM "invoices"
WHERE id = invoice_id;

IF confirmed_date IS NULL THEN
UPDATE invoices
SET
"confirmed_at" = confirmation_date,
"amount_paid" = amount_received,
"updated_at" = now_utc()
WHERE id = invoice_id;

IF invoice_purpose <> 'peering' THEN
IF invoice_unit = 'sats' THEN
UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee;
ELSIF invoice_unit = 'msats' THEN
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
ELSIF invoice_unit = 'btc' THEN
UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee;
END IF;
END IF;
END IF;
RETURN 0;
END;
$$;`)

await knex.schema.createTable('peering_payments', (table) => {
// A peer can both buy from and sell to this relay, so direction is part of the key.
table.binary('peer_pubkey').notNullable()
table.enum('direction', ['sell', 'buy']).notNullable().defaultTo('sell')
table
.enum('status', ['unpaid', 'invoiced', 'active', 'renewal_pending', 'lapsed'])
.notNullable()
.defaultTo('unpaid')
table.text('current_invoice_id').nullable()
table.text('renewal_invoice_id').nullable()
table.timestamp('period_start', { useTz: true }).nullable()
table.timestamp('period_end', { useTz: true }).nullable()
table.bigint('term_amount_msats').unsigned().nullable()
table.integer('term_period_seconds').nullable()
table.text('last_settled_invoice_id').nullable()
table.timestamp('last_settled_at', { useTz: true }).nullable()
table.timestamp('last_checked_at', { useTz: true }).nullable()
table.text('last_error').nullable()
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now())

table.primary(['peer_pubkey', 'direction'])
table.index(['status', 'period_end'], 'idx_peering_payments_status_period_end')
})
}

exports.down = async function (knex) {
await knex.schema.dropTable('peering_payments')

// Restore the previous confirm_invoice() before the purpose column disappears.
await knex.raw(`
CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
payee BYTEA;
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
invoice_unit TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();

SELECT "pubkey", "confirmed_at", "unit" INTO payee, confirmed_date, invoice_unit FROM "invoices" WHERE id = invoice_id;
IF confirmed_date IS NULL THEN
UPDATE invoices
SET
"confirmed_at" = confirmation_date,
"amount_paid" = amount_received,
"updated_at" = now_utc()
WHERE id = invoice_id;
IF invoice_unit = 'sats' THEN
UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee;
ELSIF invoice_unit = 'msats' THEN
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
ELSIF invoice_unit = 'btc' THEN
UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee;
END IF;
END IF;
RETURN 0;
END;
$$;`)

await knex.schema.alterTable('invoices', (table) => {
table.dropIndex(['purpose', 'status'], 'idx_invoices_purpose_status')
table.dropColumn('term_amount_msats')
table.dropColumn('term_period_seconds')
table.dropColumn('purpose')
})
}
4 changes: 4 additions & 0 deletions resources/admin/assets/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
'admission.invoice.created': true,
'admission.invoice.paid': true,
'admission.invoice.failed': true,
'peering.invoice.created': true,
'peering.invoice.paid': true,
'peering.invoice.failed': true,
'peering.access.lapsed': true,
'settings.changed': true,
'relay.restarted': false,
'probe.failed': true,
Expand Down
24 changes: 24 additions & 0 deletions resources/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,30 @@ <h2 class="section-title">Operator notifications</h2>
<label class="form-check-label small" for="notifications-event-invoice-failed">Invoice failed</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-invoice-created" class="form-check-input" type="checkbox" data-event-key="peering.invoice.created">
<label class="form-check-label small" for="notifications-event-peering-invoice-created">Peering invoice created</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-invoice-paid" class="form-check-input" type="checkbox" data-event-key="peering.invoice.paid">
<label class="form-check-label small" for="notifications-event-peering-invoice-paid">Peering invoice paid</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-invoice-failed" class="form-check-input" type="checkbox" data-event-key="peering.invoice.failed">
<label class="form-check-label small" for="notifications-event-peering-invoice-failed">Peering invoice failed</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-access-lapsed" class="form-check-input" type="checkbox" data-event-key="peering.access.lapsed">
<label class="form-check-label small" for="notifications-event-peering-access-lapsed">Peering access lapsed</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-settings-changed" class="form-check-input" type="checkbox" data-event-key="settings.changed">
Expand Down
4 changes: 4 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ admin:
admission.invoice.created: true
admission.invoice.paid: true
admission.invoice.failed: true
peering.invoice.created: true
peering.invoice.paid: true
peering.invoice.failed: true
peering.access.lapsed: true
settings.changed: true
relay.restarted: false
probe.failed: true
Expand Down
15 changes: 15 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 InvoicePurpose {
ADMISSION = 'admission',
SUBSCRIPTION = 'subscription',
PEERING = 'peering',
}

export interface Invoice {
id: string
pubkey: Pubkey
Expand All @@ -26,10 +32,16 @@ export interface Invoice {
updatedAt: Date
createdAt: Date
verifyURL?: string
/** What this invoice pays for. Older rows are admission or subscription. */
purpose?: InvoicePurpose | null
/** Subscription plan this invoice pays for, when it is not an admission fee. */
planId?: string | null
periodStart?: Date | null
periodEnd?: Date | null
/** Peering term snapshot used to issue this invoice. */
termAmountMsats?: bigint | null
/** Peering period length in seconds for this invoice. */
termPeriodSeconds?: number | null
}

export interface LnurlInvoice extends Invoice {
Expand All @@ -50,7 +62,10 @@ export interface DBInvoice {
updated_at: Date
created_at: Date
verify_url: string
purpose?: InvoicePurpose | null
plan_id?: string | null
period_start?: Date | null
period_end?: Date | null
term_amount_msats?: string | null
term_period_seconds?: number | null
}
8 changes: 8 additions & 0 deletions src/@types/operator-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ export enum OperatorNotificationEventType {
ADMISSION_INVOICE_CREATED = 'admission.invoice.created',
ADMISSION_INVOICE_PAID = 'admission.invoice.paid',
ADMISSION_INVOICE_FAILED = 'admission.invoice.failed',
PEERING_INVOICE_CREATED = 'peering.invoice.created',
PEERING_INVOICE_PAID = 'peering.invoice.paid',
PEERING_INVOICE_FAILED = 'peering.invoice.failed',
PEERING_ACCESS_LAPSED = 'peering.access.lapsed',
SETTINGS_CHANGED = 'settings.changed',
RELAY_RESTARTED = 'relay.restarted',
PROBE_FAILED = 'probe.failed',
Expand All @@ -26,6 +30,10 @@ export interface OperatorNotificationEventToggles {
'admission.invoice.created'?: boolean
'admission.invoice.paid'?: boolean
'admission.invoice.failed'?: boolean
'peering.invoice.created'?: boolean
'peering.invoice.paid'?: boolean
'peering.invoice.failed'?: boolean
'peering.access.lapsed'?: boolean
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
'settings.changed'?: boolean
'relay.restarted'?: boolean
'probe.failed'?: boolean
Expand Down
63 changes: 63 additions & 0 deletions src/@types/peering.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Pubkey } from './base'
import { PeeringScope, PeeringTerm } from './settings'

/** One scope advertised by a peer in its NIP-11 `peering` section. */
Expand All @@ -14,6 +15,68 @@ export interface PeeringAdvertisement {

export type PeeringSyncStatus = 'idle' | 'connecting' | 'reconciling' | 'live' | 'error' | 'stopped'

export enum PeeringPaymentDirection {
SELL = 'sell',
BUY = 'buy',
}

export enum PeeringPaymentStatus {
UNPAID = 'unpaid',
INVOICED = 'invoiced',
ACTIVE = 'active',
RENEWAL_PENDING = 'renewal_pending',
LAPSED = 'lapsed',
}

/** Billing state for one peer relationship in one direction. */
export interface PeeringPayment {
peerPubkey: Pubkey
direction: PeeringPaymentDirection
status: PeeringPaymentStatus
currentInvoiceId?: string | null
renewalInvoiceId?: string | null
periodStart?: Date | null
periodEnd?: Date | null
termAmountMsats?: bigint | null
termPeriodSeconds?: number | null
lastSettledInvoiceId?: string | null
lastSettledAt?: Date | null
lastCheckedAt?: Date | null
lastError?: string | null
createdAt: Date
updatedAt: Date
}

export interface DBPeeringPayment {
peer_pubkey: Buffer
direction: PeeringPaymentDirection
status: PeeringPaymentStatus
current_invoice_id: string | null
renewal_invoice_id: string | null
period_start: Date | null
period_end: Date | null
term_amount_msats: string | null
term_period_seconds: number | null
last_settled_invoice_id: string | null
last_settled_at: Date | null
last_checked_at: Date | null
last_error: string | null
created_at: Date
updated_at: Date
}

export interface PeeringAccess {
allowed: boolean
status: PeeringPaymentStatus | 'free' | 'unconfigured'
periodEnd?: Date | null
reason?: string
}

export interface PeeringTermSnapshot {
amountMsats: bigint
periodSeconds: number
}

/**
* Per-peer sync state. Written by the peering-sync worker and read by the admin
* console; `lagSeconds` is how old the newest event we hold in a current scope
Expand Down
22 changes: 22 additions & 0 deletions src/@types/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
NotificationDeliveryStatus,
OperatorNotificationChannelType,
} from './operator-notifications'
import { PeeringPayment } from './peering'
import { Report } from './report'
import { EventKindsRange } from './settings'
import { SubscriptionFilter } from './subscription'
Expand Down Expand Up @@ -72,7 +73,28 @@ export interface IInvoiceRepository {
upsert(invoice: Partial<Invoice>, client?: DatabaseClient): Promise<number>
updateStatus(invoice: Pick<Invoice, 'id' | 'status'>, client?: DatabaseClient): Promise<Invoice | undefined>
confirmInvoice(invoiceId: string, amountReceived: bigint, confirmedAt: Date, client?: DatabaseClient): Promise<void>
updatePeriod(invoiceId: string, periodStart: Date, periodEnd: Date, client?: DatabaseClient): Promise<void>
findPendingInvoices(offset?: number, limit?: number, client?: DatabaseClient): Promise<Invoice[]>
/**
* Completed peering invoices whose settlement never committed, oldest first.
* `confirmed_at` stays null when the settlement transaction rolls back, so the
* maintenance worker can retry them instead of stranding a paid peer.
*/
findUnconfirmedPeeringInvoices(limit?: number, offset?: number, client?: DatabaseClient): Promise<Invoice[]>
}

export interface IPeeringPaymentRepository {
findByPeer(pubkey: Pubkey, client?: DatabaseClient): Promise<PeeringPayment | undefined>
/** Inserts an unpaid row when one does not exist, so the following lock always finds it. */
ensureRow(pubkey: Pubkey, client: DatabaseClient): Promise<void>
/** SELECT ... FOR UPDATE; must run inside a transaction. */
lockByPeer(pubkey: Pubkey, client: DatabaseClient): Promise<PeeringPayment | undefined>
save(payment: PeeringPayment, client?: DatabaseClient): Promise<PeeringPayment>
list(): Promise<PeeringPayment[]>
/** Active or renewal-pending relationships whose period ends by `before`. */
findDueForRenewal(before: Date, limit: number, offset: number): Promise<PeeringPayment[]>
/** Active or renewal-pending relationships whose period has ended by `now`. */
findPeriodEnded(now: Date, limit: number, offset: number): Promise<PeeringPayment[]>
}

export interface IUserSubscriptionRepository {
Expand Down
16 changes: 15 additions & 1 deletion src/@types/services.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Pubkey } from './base'
import { DatabaseTransaction, Pubkey } from './base'
import { Invoice } from './invoice'
import { NotificationOutboxPayload } from './notification-outbox'

Expand Down Expand Up @@ -37,7 +37,21 @@ export interface IPaymentsService {
sendInvoiceUpdateNotification(invoice: Invoice): Promise<void>
/** Tells the subscriber, with a kind 402 event, that their renewal invoice can be paid. */
sendRenewalNotification(invoice: Invoice): Promise<void>
/** Creates a peering invoice outside the admission/user flow. */
createPeeringInvoice(
pubkey: Pubkey,
amountMsats: bigint,
description: string,
termPeriodSeconds: number,
client?: DatabaseTransaction,
): Promise<Invoice>
/** Publishes a peering invoice as a signed, p-tagged kind 402 event. */
sendPeeringInvoiceNotification(invoice: Invoice): Promise<void>
/** Publishes only when no unexpired kind 402 event for this invoice exists. */
ensurePeeringInvoiceNotification(invoice: Invoice): Promise<void>
getPendingInvoices(offset?: number): Promise<Invoice[]>
/** Completed peering invoices whose settlement did not commit, for retry. */
getUnconfirmedPeeringInvoices(limit?: number, offset?: number): Promise<Invoice[]>
}

export interface ISubscriptionService {
Expand Down
Loading
Loading