@portalshq/monetization 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ import { grantAppliesAt, validateRule } from "./entitlements.js";
2
+ /** Durable PostgreSQL adapter. Apply sql/001_monetization.sql before use. */
3
+ export class PostgresBillingStore {
4
+ pool;
5
+ constructor(pool) {
6
+ this.pool = pool;
7
+ }
8
+ async getTenantProfile(tenantId) {
9
+ const result = await this.pool.query("SELECT * FROM monetization_tenant_profiles WHERE tenant_id = $1", [tenantId]);
10
+ return result.rows[0] ? profileFromRow(result.rows[0]) : undefined;
11
+ }
12
+ async findTenantProfileByAccount(accountId) {
13
+ const result = await this.pool.query("SELECT * FROM monetization_tenant_profiles WHERE stripe_account_id = $1", [accountId]);
14
+ return result.rows[0] ? profileFromRow(result.rows[0]) : undefined;
15
+ }
16
+ async getBillingCustomer(buyerId) {
17
+ const { rows } = await this.pool.query(`SELECT stripe_customer_id FROM monetization_billing_customers WHERE buyer_id = $1`, [buyerId]);
18
+ return rows[0]?.stripe_customer_id;
19
+ }
20
+ async saveBillingCustomer(buyerId, stripeCustomerId, createdAt) {
21
+ await this.pool.query(`INSERT INTO monetization_billing_customers (buyer_id, stripe_customer_id, created_at)
22
+ VALUES ($1,$2,$3)
23
+ ON CONFLICT (buyer_id) DO UPDATE SET stripe_customer_id = EXCLUDED.stripe_customer_id`, [buyerId, stripeCustomerId, createdAt]);
24
+ }
25
+ async saveTenantProfile(profile) {
26
+ await saveProfile(this.pool, profile);
27
+ }
28
+ async createPurchase(purchase) {
29
+ await savePurchase(this.pool, purchase);
30
+ return purchase;
31
+ }
32
+ async getPurchase(id) {
33
+ const result = await this.pool.query("SELECT * FROM monetization_purchases WHERE id = $1", [id]);
34
+ return result.rows[0] ? purchaseFromRow(result.rows[0]) : undefined;
35
+ }
36
+ async findPurchaseByStripeReference(reference) {
37
+ if (!reference)
38
+ return undefined;
39
+ const result = await this.pool.query(`SELECT * FROM monetization_purchases
40
+ WHERE stripe_checkout_session_id = $1 OR stripe_payment_intent_id = $1
41
+ OR stripe_charge_id = $1 OR stripe_transfer_id = $1
42
+ LIMIT 1`, [reference]);
43
+ return result.rows[0] ? purchaseFromRow(result.rows[0]) : undefined;
44
+ }
45
+ async processStripeEvent(event, apply) {
46
+ const client = await this.pool.connect();
47
+ try {
48
+ await client.query("BEGIN");
49
+ const claimed = await client.query("INSERT INTO monetization_stripe_events (id, type, created_at) VALUES ($1, $2, to_timestamp($3)) ON CONFLICT DO NOTHING RETURNING id", [event.id, event.type, event.created]);
50
+ if (claimed.rowCount === 0) {
51
+ await client.query("ROLLBACK");
52
+ return "duplicate";
53
+ }
54
+ await apply(new PostgresBillingTransaction(client));
55
+ await client.query("UPDATE monetization_stripe_events SET processed_at = now() WHERE id = $1", [event.id]);
56
+ await client.query("COMMIT");
57
+ return "processed";
58
+ }
59
+ catch (cause) {
60
+ await client.query("ROLLBACK");
61
+ throw cause;
62
+ }
63
+ finally {
64
+ client.release();
65
+ }
66
+ }
67
+ async claimOutbox(limit) {
68
+ const result = await this.pool.query(`UPDATE monetization_outbox SET claimed_at = now(), attempts = attempts + 1
69
+ WHERE id IN (
70
+ SELECT id FROM monetization_outbox
71
+ WHERE delivered_at IS NULL AND dead_at IS NULL
72
+ AND (claimed_at IS NULL OR claimed_at < now() - interval '5 minutes')
73
+ ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT $1
74
+ ) RETURNING *`, [limit]);
75
+ return result.rows.map(outboxFromRow);
76
+ }
77
+ async completeOutbox(id) {
78
+ await this.pool.query("UPDATE monetization_outbox SET delivered_at = now(), claimed_at = NULL, last_error = NULL WHERE id = $1", [id]);
79
+ }
80
+ async failOutbox(id, error) {
81
+ await this.pool.query("UPDATE monetization_outbox SET claimed_at = NULL, last_error = $2 WHERE id = $1", [id, error.slice(0, 2_000)]);
82
+ }
83
+ /**
84
+ * Parks the event rather than deleting it. `last_error` is kept and the claim
85
+ * is released, so an operator can see what failed and replay it once fixed.
86
+ */
87
+ async markOutboxDead(id, error) {
88
+ await this.pool.query("UPDATE monetization_outbox SET dead_at = now(), claimed_at = NULL, last_error = $2 WHERE id = $1", [id, error.slice(0, 2_000)]);
89
+ }
90
+ async listDeadOutbox(limit) {
91
+ const result = await this.pool.query("SELECT * FROM monetization_outbox WHERE dead_at IS NOT NULL ORDER BY created_at LIMIT $1", [limit]);
92
+ return result.rows.map(outboxFromRow);
93
+ }
94
+ async requeueDeadOutbox(limit) {
95
+ const result = await this.pool.query(`UPDATE monetization_outbox SET dead_at = NULL, attempts = 0, claimed_at = NULL
96
+ WHERE id IN (
97
+ SELECT id FROM monetization_outbox
98
+ WHERE dead_at IS NOT NULL
99
+ ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT $1
100
+ ) RETURNING id`, [limit]);
101
+ return result.rowCount ?? 0;
102
+ }
103
+ }
104
+ class PostgresBillingTransaction {
105
+ client;
106
+ constructor(client) {
107
+ this.client = client;
108
+ }
109
+ async getPurchase(id) {
110
+ const result = await this.client.query("SELECT * FROM monetization_purchases WHERE id = $1 FOR UPDATE", [id]);
111
+ return result.rows[0] ? purchaseFromRow(result.rows[0]) : undefined;
112
+ }
113
+ async findPurchaseByStripeReference(reference) {
114
+ if (!reference)
115
+ return undefined;
116
+ const result = await this.client.query(`SELECT * FROM monetization_purchases
117
+ WHERE stripe_checkout_session_id = $1 OR stripe_payment_intent_id = $1 OR stripe_charge_id = $1 OR stripe_transfer_id = $1
118
+ LIMIT 1 FOR UPDATE`, [reference]);
119
+ return result.rows[0] ? purchaseFromRow(result.rows[0]) : undefined;
120
+ }
121
+ async savePurchase(purchase) { await savePurchase(this.client, purchase); }
122
+ async saveTenantProfile(profile) { await saveProfile(this.client, profile); }
123
+ async appendLedger(entry) {
124
+ await this.client.query(`INSERT INTO monetization_ledger (id, purchase_id, stripe_event_id, kind, amount, currency, stripe_fee, net, metadata, created_at)
125
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (id) DO NOTHING`, [entry.id, entry.purchaseId, entry.stripeEventId ?? null, entry.kind, entry.amount, entry.currency, entry.stripeFee ?? null, entry.net ?? null, entry.metadata ?? {}, entry.createdAt]);
126
+ }
127
+ async appendOutbox(event) {
128
+ await this.client.query(`INSERT INTO monetization_outbox (id, type, aggregate_id, payload, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (id) DO NOTHING`, [event.id, event.type, event.aggregateId, event.payload, event.createdAt]);
129
+ }
130
+ }
131
+ async function saveProfile(db, profile) {
132
+ await db.query(`INSERT INTO monetization_tenant_profiles
133
+ (tenant_id, owner_id, stripe_account_id, transfers_status, default_currency, country, created_at, updated_at)
134
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
135
+ ON CONFLICT (tenant_id) DO UPDATE SET owner_id=EXCLUDED.owner_id, stripe_account_id=EXCLUDED.stripe_account_id,
136
+ transfers_status=EXCLUDED.transfers_status, default_currency=EXCLUDED.default_currency,
137
+ country=EXCLUDED.country, updated_at=EXCLUDED.updated_at`, [profile.tenantId, profile.ownerId, profile.stripeAccountId, profile.transfersStatus,
138
+ profile.defaultCurrency, profile.country ?? null, profile.createdAt, profile.updatedAt]);
139
+ }
140
+ async function savePurchase(db, purchase) {
141
+ await db.query(`INSERT INTO monetization_purchases
142
+ (id, tenant_id, channel_id, session_id, buyer_id, customer_id, product_key, kind, amount, currency,
143
+ platform_fee_amount, state, stripe_checkout_session_id, stripe_payment_intent_id, stripe_charge_id,
144
+ stripe_transfer_id, stripe_application_fee_id, refunded_amount, settled_at, created_at, updated_at)
145
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
146
+ ON CONFLICT (id) DO UPDATE SET state=EXCLUDED.state, stripe_checkout_session_id=EXCLUDED.stripe_checkout_session_id,
147
+ stripe_payment_intent_id=EXCLUDED.stripe_payment_intent_id, stripe_charge_id=EXCLUDED.stripe_charge_id,
148
+ stripe_transfer_id=EXCLUDED.stripe_transfer_id, stripe_application_fee_id=EXCLUDED.stripe_application_fee_id,
149
+ refunded_amount=EXCLUDED.refunded_amount, settled_at=EXCLUDED.settled_at, updated_at=EXCLUDED.updated_at`, [purchase.id, purchase.tenantId, purchase.channelId, purchase.sessionId ?? null, purchase.buyerId,
150
+ purchase.customerId ?? null, purchase.productKey, purchase.kind, purchase.amount, purchase.currency, purchase.platformFeeAmount, purchase.state,
151
+ purchase.stripeCheckoutSessionId ?? null, purchase.stripePaymentIntentId ?? null, purchase.stripeChargeId ?? null,
152
+ purchase.stripeTransferId ?? null, purchase.stripeApplicationFeeId ?? null, purchase.refundedAmount,
153
+ purchase.settledAt ?? null, purchase.createdAt, purchase.updatedAt]);
154
+ }
155
+ function profileFromRow(row) {
156
+ return { tenantId: row.tenant_id, ownerId: row.owner_id, stripeAccountId: row.stripe_account_id,
157
+ transfersStatus: row.transfers_status, defaultCurrency: row.default_currency,
158
+ ...(row.country ? { country: row.country } : {}), createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) };
159
+ }
160
+ function purchaseFromRow(row) {
161
+ return { id: row.id, tenantId: row.tenant_id, channelId: row.channel_id,
162
+ ...(row.session_id ? { sessionId: row.session_id } : {}), buyerId: row.buyer_id,
163
+ ...(row.customer_id ? { customerId: row.customer_id } : {}),
164
+ productKey: row.product_key, kind: row.kind, amount: Number(row.amount), currency: row.currency,
165
+ platformFeeAmount: Number(row.platform_fee_amount), state: row.state,
166
+ ...(row.stripe_checkout_session_id ? { stripeCheckoutSessionId: row.stripe_checkout_session_id } : {}),
167
+ ...(row.stripe_payment_intent_id ? { stripePaymentIntentId: row.stripe_payment_intent_id } : {}),
168
+ ...(row.stripe_charge_id ? { stripeChargeId: row.stripe_charge_id } : {}),
169
+ ...(row.stripe_transfer_id ? { stripeTransferId: row.stripe_transfer_id } : {}),
170
+ ...(row.stripe_application_fee_id ? { stripeApplicationFeeId: row.stripe_application_fee_id } : {}),
171
+ refundedAmount: Number(row.refunded_amount), ...(row.settled_at ? { settledAt: iso(row.settled_at) } : {}),
172
+ createdAt: iso(row.created_at), updatedAt: iso(row.updated_at) };
173
+ }
174
+ function outboxFromRow(row) {
175
+ return {
176
+ id: row.id, type: row.type, aggregateId: row.aggregate_id, payload: row.payload,
177
+ createdAt: iso(row.created_at), attempts: Number(row.attempts ?? 0),
178
+ ...(row.last_error ? { lastError: row.last_error } : {}),
179
+ ...(row.dead_at ? { deadAt: iso(row.dead_at) } : {}),
180
+ };
181
+ }
182
+ function iso(value) { return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); }
183
+ /**
184
+ * Durable entitlement store. Grants are keyed `(rule_id, purchase_id)` so a
185
+ * redelivered settlement outbox event cannot double-grant, and `consume` takes
186
+ * a row lock per grant so two concurrent consumers cannot both spend the last
187
+ * unit.
188
+ */
189
+ export class PostgresEntitlementStore {
190
+ pool;
191
+ constructor(pool) {
192
+ this.pool = pool;
193
+ }
194
+ async saveRule(rule) {
195
+ validateRule(rule);
196
+ await this.pool.query(`INSERT INTO monetization_entitlement_rules
197
+ (id, tenant_id, kind, scope, conditions, quantity, created_at)
198
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
199
+ ON CONFLICT (id) DO UPDATE SET kind=EXCLUDED.kind, scope=EXCLUDED.scope,
200
+ conditions=EXCLUDED.conditions, quantity=EXCLUDED.quantity`, [rule.id, rule.tenantId, rule.kind, rule.scope, rule.conditions, rule.quantity, rule.createdAt]);
201
+ }
202
+ async getRule(id) {
203
+ const result = await this.pool.query("SELECT * FROM monetization_entitlement_rules WHERE id = $1", [id]);
204
+ return result.rows[0] ? ruleFromRow(result.rows[0]) : undefined;
205
+ }
206
+ async listRules(tenantId) {
207
+ const result = await this.pool.query("SELECT * FROM monetization_entitlement_rules WHERE tenant_id = $1 ORDER BY created_at", [tenantId]);
208
+ return result.rows.map(ruleFromRow);
209
+ }
210
+ async grant(grants) {
211
+ if (grants.length === 0)
212
+ return;
213
+ const values = [];
214
+ const tuples = grants.map((grant, index) => {
215
+ const at = index * 10;
216
+ values.push(grant.id, grant.ruleId, grant.tenantId, grant.consumerId, grant.kind, grant.scope, grant.quantity, grant.remaining, grant.purchaseId, grant.expiresAt ?? null);
217
+ return `($${at + 1},$${at + 2},$${at + 3},$${at + 4},$${at + 5},$${at + 6},$${at + 7},$${at + 8},$${at + 9},$${at + 10}, now(), now())`;
218
+ });
219
+ await this.pool.query(`INSERT INTO monetization_entitlements
220
+ (id, rule_id, tenant_id, consumer_id, kind, scope, quantity, remaining, purchase_id, expires_at, created_at, updated_at)
221
+ VALUES ${tuples.join(",")}
222
+ ON CONFLICT (rule_id, purchase_id) DO NOTHING`, values);
223
+ }
224
+ async consume(consumerId, kind, site, n) {
225
+ if (!Number.isInteger(n) || n < 1)
226
+ throw new TypeError("consume count must be a positive integer");
227
+ const client = await this.pool.connect();
228
+ try {
229
+ await client.query("BEGIN");
230
+ // Plain FOR UPDATE, not SKIP LOCKED: skipping locked rows would let two
231
+ // concurrent consumers each see a partial balance, both conclude there is
232
+ // enough, and both commit — spending the last unit twice. Serializing here
233
+ // costs throughput on a low-frequency path and buys correctness.
234
+ const candidates = await client.query(`SELECT id, scope, remaining FROM monetization_entitlements
235
+ WHERE consumer_id = $1 AND kind = $2 AND remaining > 0
236
+ AND (expires_at IS NULL OR expires_at >= now())
237
+ ORDER BY created_at FOR UPDATE`, [consumerId, kind]);
238
+ const applicable = candidates.rows.filter((row) => grantAppliesAt(row.scope, site));
239
+ const available = applicable.reduce((total, row) => total + Number(row.remaining), 0);
240
+ if (available < n) {
241
+ await client.query("ROLLBACK");
242
+ return false;
243
+ }
244
+ let owed = n;
245
+ for (const row of applicable) {
246
+ if (owed === 0)
247
+ break;
248
+ const taken = Math.min(owed, Number(row.remaining));
249
+ await client.query("UPDATE monetization_entitlements SET remaining = remaining - $2, updated_at = now() WHERE id = $1", [row.id, taken]);
250
+ owed -= taken;
251
+ }
252
+ await client.query("COMMIT");
253
+ return true;
254
+ }
255
+ catch (cause) {
256
+ await client.query("ROLLBACK");
257
+ throw cause;
258
+ }
259
+ finally {
260
+ client.release();
261
+ }
262
+ }
263
+ async remaining(consumerId, kind, site) {
264
+ const result = await this.pool.query(`SELECT scope, remaining FROM monetization_entitlements
265
+ WHERE consumer_id = $1 AND kind = $2 AND (expires_at IS NULL OR expires_at >= now())`, [consumerId, kind]);
266
+ return result.rows
267
+ .filter((row) => grantAppliesAt(row.scope, site))
268
+ .reduce((total, row) => total + Number(row.remaining), 0);
269
+ }
270
+ async revokeForPurchase(purchaseId) {
271
+ await this.pool.query("DELETE FROM monetization_entitlements WHERE purchase_id = $1", [purchaseId]);
272
+ }
273
+ }
274
+ function ruleFromRow(row) {
275
+ return { id: row.id, tenantId: row.tenant_id, kind: row.kind, scope: row.scope, conditions: row.conditions, quantity: Number(row.quantity), createdAt: iso(row.created_at) };
276
+ }
@@ -0,0 +1,9 @@
1
+ import Stripe from "stripe";
2
+ /**
3
+ * Minimal Stripe client factory.
4
+ *
5
+ * `platform-billing` carries its own equivalent for the platform B2B paths.
6
+ * The duplication is deliberate: it is three validated lines, and sharing it
7
+ * would force one of the two sibling packages to depend on the other.
8
+ */
9
+ export declare function createStripePlatformClient(secretKey: string): Stripe;
@@ -0,0 +1,14 @@
1
+ import Stripe from "stripe";
2
+ /**
3
+ * Minimal Stripe client factory.
4
+ *
5
+ * `platform-billing` carries its own equivalent for the platform B2B paths.
6
+ * The duplication is deliberate: it is three validated lines, and sharing it
7
+ * would force one of the two sibling packages to depend on the other.
8
+ */
9
+ export function createStripePlatformClient(secretKey) {
10
+ const normalized = secretKey.trim();
11
+ if (!normalized)
12
+ throw new TypeError("Stripe secret key is required");
13
+ return new Stripe(normalized);
14
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Stripe Connect wrapper for marketplace provider payouts.
3
+ * Uses Stripe Connect's Transfers API, not Payouts — Transfers go to
4
+ * connected accounts (your providers), not to external bank accounts.
5
+ * That distinction matters: Transfers happen instantly within Stripe;
6
+ * Payouts (connected account → bank) happen on the provider's schedule.
7
+ *
8
+ * STRIPE_SECRET_KEY is resolved at the application boundary and injected via
9
+ * the constructor. The platform's own Stripe account holds the credential; each
10
+ * *tenant's* destination account is a Connect account id held in
11
+ * `monetization_tenant_profiles`, not a separate secret. Never hardcode either.
12
+ */
13
+ export interface ConnectTransfer {
14
+ amount: number;
15
+ currency: string;
16
+ destination: string;
17
+ transferGroup?: string;
18
+ metadata?: Record<string, string>;
19
+ }
20
+ export declare class StripeConnectClient {
21
+ private secretKey;
22
+ /**
23
+ * The secret key is required by injection. This package never reads the
24
+ * environment: the application boundary resolves the credential and hands it
25
+ * over, so a library can never quietly pick up an ambient key, and tests
26
+ * cannot be influenced by one.
27
+ */
28
+ constructor(secretKey: string);
29
+ /**
30
+ * Transfer provider payout to their connected Stripe account.
31
+ * Call AFTER the platform has collected the gross amount from the buyer —
32
+ * transfer only the net after rake and Stripe Connect fee.
33
+ */
34
+ transferToProvider(transfer: ConnectTransfer): Promise<{
35
+ transferId: string;
36
+ }>;
37
+ /**
38
+ * Execute all royalty splits from calculateRoyaltySplits() as a single
39
+ * transfer group so they're traceable together in the Stripe dashboard.
40
+ */
41
+ executeRoyaltySplits(splits: Array<{
42
+ stripeAccountId: string;
43
+ amountCents: number;
44
+ }>, currency: string, groupId: string): Promise<string[]>;
45
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Stripe Connect wrapper for marketplace provider payouts.
3
+ * Uses Stripe Connect's Transfers API, not Payouts — Transfers go to
4
+ * connected accounts (your providers), not to external bank accounts.
5
+ * That distinction matters: Transfers happen instantly within Stripe;
6
+ * Payouts (connected account → bank) happen on the provider's schedule.
7
+ *
8
+ * STRIPE_SECRET_KEY is resolved at the application boundary and injected via
9
+ * the constructor. The platform's own Stripe account holds the credential; each
10
+ * *tenant's* destination account is a Connect account id held in
11
+ * `monetization_tenant_profiles`, not a separate secret. Never hardcode either.
12
+ */
13
+ export class StripeConnectClient {
14
+ secretKey;
15
+ /**
16
+ * The secret key is required by injection. This package never reads the
17
+ * environment: the application boundary resolves the credential and hands it
18
+ * over, so a library can never quietly pick up an ambient key, and tests
19
+ * cannot be influenced by one.
20
+ */
21
+ constructor(secretKey) {
22
+ const normalized = secretKey.trim();
23
+ if (!normalized)
24
+ throw new TypeError("Stripe secret key is required");
25
+ this.secretKey = normalized;
26
+ }
27
+ /**
28
+ * Transfer provider payout to their connected Stripe account.
29
+ * Call AFTER the platform has collected the gross amount from the buyer —
30
+ * transfer only the net after rake and Stripe Connect fee.
31
+ */
32
+ async transferToProvider(transfer) {
33
+ const res = await fetch("https://api.stripe.com/v1/transfers", {
34
+ method: "POST",
35
+ headers: {
36
+ Authorization: `Bearer ${this.secretKey}`,
37
+ "Content-Type": "application/x-www-form-urlencoded",
38
+ },
39
+ body: new URLSearchParams({
40
+ amount: String(transfer.amount),
41
+ currency: transfer.currency,
42
+ destination: transfer.destination,
43
+ ...(transfer.transferGroup ? { transfer_group: transfer.transferGroup } : {}),
44
+ ...(transfer.metadata
45
+ ? Object.fromEntries(Object.entries(transfer.metadata).map(([k, v]) => [`metadata[${k}]`, v]))
46
+ : {}),
47
+ }),
48
+ });
49
+ if (!res.ok) {
50
+ throw new Error(`Stripe Connect transfer failed: ${await res.text()}`);
51
+ }
52
+ const data = (await res.json());
53
+ return { transferId: data.id };
54
+ }
55
+ /**
56
+ * Execute all royalty splits from calculateRoyaltySplits() as a single
57
+ * transfer group so they're traceable together in the Stripe dashboard.
58
+ */
59
+ async executeRoyaltySplits(splits, currency, groupId) {
60
+ const transferIds = [];
61
+ for (const split of splits) {
62
+ if (split.amountCents <= 0)
63
+ continue;
64
+ const { transferId } = await this.transferToProvider({
65
+ amount: split.amountCents,
66
+ currency,
67
+ destination: split.stripeAccountId,
68
+ transferGroup: groupId,
69
+ metadata: { royaltyGroup: groupId },
70
+ });
71
+ transferIds.push(transferId);
72
+ }
73
+ return transferIds;
74
+ }
75
+ }
@@ -0,0 +1,225 @@
1
+ import type Stripe from "stripe";
2
+ export type PurchaseKind = "super_chat" | "tip" | "channel_product" | (string & {});
3
+ export type PurchaseState = "pending" | "checkout_created" | "settled" | "failed" | "partially_refunded" | "refunded" | "disputed";
4
+ /**
5
+ * v2 capability status for the tenant's recipient configuration. Read from
6
+ * `account.configuration.recipient.capabilities.stripe_balance.stripe_transfers.status`.
7
+ * The v1 fields `payouts_enabled` / `charges_enabled` are deprecated and unused.
8
+ */
9
+ export type TransfersStatus = "active" | "inactive" | "pending";
10
+ /**
11
+ * One Connect account per tenant, created via Accounts v2 as a **recipient**
12
+ * for destination charges.
13
+ *
14
+ * The end user's `Customer` and payment method live on the **platform** account,
15
+ * not the tenant's. A consumer provides payment details once and the same
16
+ * payment method is reused for every tenant they pay, which is the reason
17
+ * destination charges are used rather than direct charges. Funds transfer to the
18
+ * tenant on payment success; `application_fee_amount` is the platform's rake.
19
+ *
20
+ * `losses_collector: "application"` is required here — it is what makes dispute
21
+ * transfer reversals possible — so the platform is merchant of record and
22
+ * carries negative-balance and dispute liability across tenant volume.
23
+ */
24
+ export interface TenantBillingProfile {
25
+ tenantId: string;
26
+ ownerId: string;
27
+ /** The tenant's connected account, the destination for transfers. */
28
+ stripeAccountId: string;
29
+ transfersStatus: TransfersStatus;
30
+ defaultCurrency: string;
31
+ country?: string;
32
+ createdAt: string;
33
+ updatedAt: string;
34
+ }
35
+ /**
36
+ * Something a tenant sells.
37
+ *
38
+ * Named `Product` rather than `BillingCatalogItem` because it is the tenant's
39
+ * product, not a billing implementation detail: the catalog is just where the
40
+ * platform looks it up by key. `key` is the tenant-local identifier the buyer
41
+ * and the purchase record refer to; it need not be globally unique.
42
+ */
43
+ export interface Product {
44
+ /** Tenant-local identifier. What `CreateCheckoutInput.productKey` names. */
45
+ key: string;
46
+ purchaseKind: PurchaseKind;
47
+ name: string;
48
+ /**
49
+ * Tenant-authored copy for this product, e.g. what the buyer gets. Carried so
50
+ * a catalog can be rendered to a buyer without a second parallel map. The
51
+ * platform does not interpret it.
52
+ */
53
+ description?: string;
54
+ unitAmount: number;
55
+ currency: string;
56
+ /**
57
+ * Rake withheld via `application_fee_amount`. On direct charges the tenant
58
+ * creates the charge, so it chooses this value; `verifySettlement` checks
59
+ * what Stripe actually recorded before anything is granted or settled.
60
+ */
61
+ platformFeeAmount: number;
62
+ stripePriceId?: string;
63
+ refundApplicationFee?: boolean;
64
+ }
65
+ /** Where the platform looks up the tenant's products by key. */
66
+ export interface BillingCatalog {
67
+ resolve(productKey: string): Product;
68
+ }
69
+ /**
70
+ * @deprecated Use {@link Product}. Retained so existing catalogs keep compiling.
71
+ */
72
+ export type BillingCatalogItem = Product;
73
+ /**
74
+ * One audience payment. `tenantId` decides where the money goes; `channelId`
75
+ * is attribution only. Both are kept because a tenant's revenue rollup is
76
+ * reported per channel but paid per tenant.
77
+ */
78
+ export interface Purchase {
79
+ id: string;
80
+ tenantId: string;
81
+ channelId: string;
82
+ /** Fact for session-scoped entitlement rules. Absent when the purchase did not occur in a session. */
83
+ sessionId?: string;
84
+ /** The end user who paid. Distinct from `tenantId`, which receives the money. */
85
+ buyerId: string;
86
+ /**
87
+ * The platform-account `Customer` for this end user. Customers and payment
88
+ * methods live on the platform, not per tenant, so one payment method is
89
+ * reused across every tenant this consumer pays.
90
+ */
91
+ customerId?: string;
92
+ productKey: string;
93
+ kind: PurchaseKind;
94
+ amount: number;
95
+ currency: string;
96
+ platformFeeAmount: number;
97
+ state: PurchaseState;
98
+ stripeCheckoutSessionId?: string;
99
+ stripePaymentIntentId?: string;
100
+ stripeChargeId?: string;
101
+ /** The Connect transfer to the tenant. Reversed on dispute, per Connect's model. */
102
+ stripeTransferId?: string;
103
+ stripeApplicationFeeId?: string;
104
+ refundedAmount: number;
105
+ /** Set when the purchase reaches `settled`. Fact for date-scoped entitlement rules. */
106
+ settledAt?: string;
107
+ createdAt: string;
108
+ updatedAt: string;
109
+ }
110
+ export interface BillingLedgerEntry {
111
+ id: string;
112
+ purchaseId: string;
113
+ stripeEventId?: string;
114
+ kind: "settlement" | "refund" | "dispute" | "transfer_reversal";
115
+ amount: number;
116
+ currency: string;
117
+ stripeFee?: number;
118
+ net?: number;
119
+ metadata?: Readonly<Record<string, string>>;
120
+ createdAt: string;
121
+ }
122
+ export type BillingOutboxEventType = "billing.purchase_settled" | "billing.purchase_refunded" | "billing.purchase_disputed";
123
+ export interface BillingOutboxEvent {
124
+ id: string;
125
+ type: BillingOutboxEventType;
126
+ aggregateId: string;
127
+ payload: Readonly<Record<string, unknown>>;
128
+ createdAt: string;
129
+ /** How many times this event has been claimed for delivery. */
130
+ attempts: number;
131
+ /** Why the last delivery attempt failed, truncated by the store. */
132
+ lastError?: string;
133
+ /** Set when the event exceeded its attempt budget and was parked for an operator. */
134
+ deadAt?: string;
135
+ }
136
+ /** Every fact an outbox payload must carry for entitlement rules to be evaluable. */
137
+ export interface PurchaseSettledFacts {
138
+ purchaseId: string;
139
+ tenantId: string;
140
+ channelId: string;
141
+ sessionId: string | null;
142
+ buyerId: string;
143
+ kind: string;
144
+ productKey: string;
145
+ amount: number;
146
+ currency: string;
147
+ settledAt: string;
148
+ }
149
+ export interface BillingTransaction {
150
+ getPurchase(id: string): Promise<Purchase | undefined>;
151
+ findPurchaseByStripeReference(reference: string): Promise<Purchase | undefined>;
152
+ savePurchase(purchase: Purchase): Promise<void>;
153
+ saveTenantProfile(profile: TenantBillingProfile): Promise<void>;
154
+ appendLedger(entry: BillingLedgerEntry): Promise<void>;
155
+ appendOutbox(event: BillingOutboxEvent): Promise<void>;
156
+ }
157
+ export interface BillingStore {
158
+ /**
159
+ * Returns this consumer's platform-held Customer id, or undefined if they have
160
+ * never checked out. Consumers are platform-scoped, not tenant-scoped.
161
+ */
162
+ getBillingCustomer(buyerId: string): Promise<string | undefined>;
163
+ /** Records the Customer created for a consumer. Idempotent per buyer. */
164
+ saveBillingCustomer(buyerId: string, stripeCustomerId: string, createdAt: string): Promise<void>;
165
+ getTenantProfile(tenantId: string): Promise<TenantBillingProfile | undefined>;
166
+ findTenantProfileByAccount(accountId: string): Promise<TenantBillingProfile | undefined>;
167
+ saveTenantProfile(profile: TenantBillingProfile): Promise<void>;
168
+ createPurchase(purchase: Purchase): Promise<Purchase>;
169
+ getPurchase(id: string): Promise<Purchase | undefined>;
170
+ /**
171
+ * Resolves a purchase by any recorded Stripe reference (checkout session,
172
+ * payment intent, charge, or transfer). Available on the store as well as the
173
+ * transaction because work that must happen outside a webhook transaction —
174
+ * such as reversing a Connect transfer — still needs to find its purchase.
175
+ */
176
+ findPurchaseByStripeReference(reference: string): Promise<Purchase | undefined>;
177
+ /** Must atomically claim event.id and commit all callback writes. */
178
+ processStripeEvent(event: Stripe.Event, apply: (transaction: BillingTransaction) => Promise<void>): Promise<"processed" | "duplicate">;
179
+ /** Claims pending events. Dead events are never claimed. */
180
+ claimOutbox(limit: number): Promise<readonly BillingOutboxEvent[]>;
181
+ completeOutbox(id: string): Promise<void>;
182
+ failOutbox(id: string, error: string): Promise<void>;
183
+ /**
184
+ * Parks an event that exceeded its attempt budget. The row is **retained**
185
+ * with its payload and error so nothing is lost: a customer who paid still has
186
+ * a settlement event on record, and it can be replayed.
187
+ */
188
+ markOutboxDead(id: string, error: string): Promise<void>;
189
+ /** Dead events awaiting attention, newest last. For an operator view. */
190
+ listDeadOutbox(limit: number): Promise<readonly BillingOutboxEvent[]>;
191
+ /**
192
+ * Returns dead events to the pending queue with a fresh attempt budget. This
193
+ * is how a fixed rule or a repaired payload is replayed rather than lost.
194
+ */
195
+ requeueDeadOutbox(limit: number): Promise<number>;
196
+ }
197
+ export interface IdGenerator {
198
+ next(): string;
199
+ }
200
+ export interface CreateCheckoutInput {
201
+ /** Stable application order id. Reuse it when retrying the same checkout. */
202
+ purchaseId: string;
203
+ tenantId: string;
204
+ channelId: string;
205
+ sessionId?: string;
206
+ buyerId: string;
207
+ /**
208
+ * The platform-account `Customer` holding this end user's saved payment
209
+ * method. Omit to let Stripe create one for the session; supply it to reuse
210
+ * payment details the consumer already provided for another tenant.
211
+ */
212
+ customerId?: string;
213
+ productKey: string;
214
+ successUrl: string;
215
+ cancelUrl: string;
216
+ }
217
+ export interface CreateCheckoutResult {
218
+ purchase: Purchase;
219
+ sessionId: string;
220
+ url: string | null;
221
+ }
222
+ export interface BillingWebhookResult {
223
+ eventId: string;
224
+ status: "processed" | "duplicate";
225
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};