@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,275 @@
1
+ /**
2
+ * Tenant-configured entitlements.
3
+ *
4
+ * An entitlement is a grant made to a paying end user ("consumer"), scoped by
5
+ * a set of dimensions. A rule is owned and configured by the tenant, not the
6
+ * platform: the channel owner decides what a purchase unlocks and for how long.
7
+ *
8
+ * The scope is an arbitrary subset of dimensions, and **the dimensions present
9
+ * are both the matcher and the lifetime**:
10
+ *
11
+ * { channelId } → valid for the life of the channel
12
+ * { channelId, sessionId } → valid only during that session
13
+ * { channelId, date } → valid only for purchases settling that day,
14
+ * and expires at the end of it
15
+ * { channelId, sessionId, date } → the narrowest form
16
+ *
17
+ * Every dimension a rule names must match for the grant to be made. A grant
18
+ * is always made to a consumer, so a rule never grants "the channel" anything.
19
+ *
20
+ * Grants are balances, not flags. A rule that nothing ever consumes stays
21
+ * permanently available, which is how a standing membership is expressed, and
22
+ * a rule the application consumes one unit at a time is how a single-use
23
+ * unlock is expressed. Both use the same primitive.
24
+ */
25
+ /**
26
+ * A scope dimension whose value is this token binds to the matching fact of the
27
+ * purchase at grant time, instead of being compared literally.
28
+ *
29
+ * A superchat grants influence over the story turn it was bought for, so its
30
+ * rule must be scoped to "whichever session this purchase happened in" — a
31
+ * fixed session id could never match. `"$self"` says bind; the grant then carries
32
+ * the concrete value and normal equality applies at consumption.
33
+ */
34
+ export const SELF_BIND = "$self";
35
+ /** True when a scope dimension binds to the purchase rather than comparing. */
36
+ function isSelfBind(value) {
37
+ return value === SELF_BIND;
38
+ }
39
+ /**
40
+ * Replaces self-binding dimensions with the concrete facts, producing the scope
41
+ * the grant is actually scoped to. A binding dimension with no corresponding
42
+ * fact is dropped, so a purchase with no session yields an unscoped grant rather
43
+ * than a permanently unsatisfiable one.
44
+ */
45
+ export function resolveScope(scope, facts) {
46
+ const resolved = {};
47
+ if (scope.channelId !== undefined) {
48
+ resolved.channelId = isSelfBind(scope.channelId) ? facts.channelId : scope.channelId;
49
+ }
50
+ if (scope.sessionId !== undefined) {
51
+ // A self-binding session becomes the purchase's own session, so the grant is
52
+ // scoped to where it was bought. With no session there is nothing to bind,
53
+ // so the dimension is dropped rather than left permanently unsatisfiable.
54
+ if (isSelfBind(scope.sessionId)) {
55
+ if (facts.sessionId)
56
+ resolved.sessionId = facts.sessionId;
57
+ }
58
+ else {
59
+ resolved.sessionId = scope.sessionId;
60
+ }
61
+ }
62
+ if (scope.date !== undefined) {
63
+ resolved.date = scope.date;
64
+ }
65
+ return resolved;
66
+ }
67
+ /** End of the UTC day named by `scope.date`, or undefined when the rule is not date-bounded. */
68
+ export function scopeExpiresAt(scope) {
69
+ return scope.date ? `${utcDay(scope.date)}T23:59:59.999Z` : undefined;
70
+ }
71
+ /**
72
+ * A grant is usable at a site when every dimension it names matches the site's
73
+ * facts. A channel-scoped grant is therefore valid anywhere in that channel,
74
+ * and a session-scoped grant only inside that session.
75
+ */
76
+ export function grantAppliesAt(grantScope, site) {
77
+ if (grantScope.channelId !== undefined && grantScope.channelId !== site.channelId)
78
+ return false;
79
+ if (grantScope.sessionId !== undefined && grantScope.sessionId !== site.sessionId)
80
+ return false;
81
+ if (grantScope.date !== undefined && site.date !== undefined && utcDay(grantScope.date) !== utcDay(site.date))
82
+ return false;
83
+ return true;
84
+ }
85
+ /** Every dimension named by the rule and every condition must match. */
86
+ export function ruleMatches(rule, facts) {
87
+ if (rule.tenantId !== facts.tenantId)
88
+ return false;
89
+ if (rule.scope.channelId !== undefined && !isSelfBind(rule.scope.channelId) && rule.scope.channelId !== facts.channelId)
90
+ return false;
91
+ // A self-binding session requires the purchase to actually be in one.
92
+ if (rule.scope.sessionId !== undefined && isSelfBind(rule.scope.sessionId) && !facts.sessionId)
93
+ return false;
94
+ if (rule.scope.sessionId !== undefined && !isSelfBind(rule.scope.sessionId) && rule.scope.sessionId !== facts.sessionId)
95
+ return false;
96
+ if (rule.scope.date !== undefined && utcDay(rule.scope.date) !== utcDay(facts.settledAt))
97
+ return false;
98
+ return rule.conditions.every((condition) => {
99
+ switch (condition.fact) {
100
+ case "purchaseKind":
101
+ return facts.kind === condition.equals;
102
+ case "productKey":
103
+ return facts.productKey === condition.equals;
104
+ case "amount":
105
+ return facts.amount >= condition.gte;
106
+ }
107
+ });
108
+ }
109
+ /** Grants a settled purchase should create, one per matching rule for that tenant. */
110
+ export function grantsForSettlement(rules, facts, ids, now) {
111
+ return rules
112
+ .filter((rule) => ruleMatches(rule, facts))
113
+ .map((rule) => {
114
+ const bound = resolveScope(rule.scope, facts);
115
+ const expiresAt = scopeExpiresAt(bound);
116
+ return {
117
+ id: ids.next(),
118
+ ruleId: rule.id,
119
+ tenantId: rule.tenantId,
120
+ consumerId: facts.buyerId,
121
+ kind: rule.kind,
122
+ scope: bound,
123
+ quantity: rule.quantity,
124
+ remaining: rule.quantity,
125
+ purchaseId: facts.purchaseId,
126
+ ...(expiresAt ? { expiresAt } : {}),
127
+ createdAt: now,
128
+ updatedAt: now,
129
+ };
130
+ });
131
+ }
132
+ /** Rejects rules the engine cannot honour, so a bad rule fails at configuration time. */
133
+ export function validateRule(rule) {
134
+ if (!rule.id.trim())
135
+ throw new TypeError("rule id is required");
136
+ if (!rule.tenantId.trim())
137
+ throw new TypeError("rule tenantId is required");
138
+ if (!rule.kind.trim())
139
+ throw new TypeError("rule kind is required");
140
+ if (!Number.isInteger(rule.quantity) || rule.quantity < 1)
141
+ throw new TypeError("rule quantity must be a positive integer");
142
+ const named = Object.values(rule.scope).filter((value) => value !== undefined);
143
+ if (named.length === 0)
144
+ throw new TypeError("rule scope must name at least one dimension");
145
+ if (rule.scope.date !== undefined)
146
+ utcDay(rule.scope.date);
147
+ for (const condition of rule.conditions) {
148
+ if (condition.fact === "amount" && (!Number.isInteger(condition.gte) || condition.gte < 0)) {
149
+ throw new TypeError("amount condition must be a non-negative integer");
150
+ }
151
+ }
152
+ }
153
+ /** Reference store for tests and single-process deployments. */
154
+ export class InMemoryEntitlementStore {
155
+ rules = new Map();
156
+ grants = new Map();
157
+ granted = new Set();
158
+ async saveRule(rule) {
159
+ validateRule(rule);
160
+ this.rules.set(rule.id, rule);
161
+ }
162
+ async getRule(id) {
163
+ return this.rules.get(id);
164
+ }
165
+ async listRules(tenantId) {
166
+ return [...this.rules.values()].filter((rule) => rule.tenantId === tenantId);
167
+ }
168
+ async grant(grants) {
169
+ for (const grant of grants) {
170
+ const key = `${grant.ruleId}:${grant.purchaseId}`;
171
+ if (this.granted.has(key))
172
+ continue;
173
+ this.granted.add(key);
174
+ this.grants.set(grant.id, grant);
175
+ }
176
+ }
177
+ async consume(consumerId, kind, site, n) {
178
+ if (!Number.isInteger(n) || n < 1)
179
+ throw new TypeError("consume count must be a positive integer");
180
+ const candidates = [...this.grants.values()]
181
+ .filter((grant) => grant.consumerId === consumerId && grant.kind === kind && grant.remaining > 0 && grantAppliesAt(grant.scope, site))
182
+ .filter((grant) => grant.expiresAt === undefined || Date.parse(grant.expiresAt) >= Date.now())
183
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
184
+ const available = candidates.reduce((total, grant) => total + grant.remaining, 0);
185
+ if (available < n)
186
+ return false;
187
+ let owed = n;
188
+ for (const grant of candidates) {
189
+ if (owed === 0)
190
+ break;
191
+ const taken = Math.min(owed, grant.remaining);
192
+ grant.remaining -= taken;
193
+ owed -= taken;
194
+ }
195
+ return true;
196
+ }
197
+ async remaining(consumerId, kind, site) {
198
+ return [...this.grants.values()]
199
+ .filter((grant) => grant.consumerId === consumerId && grant.kind === kind && grantAppliesAt(grant.scope, site))
200
+ .filter((grant) => grant.expiresAt === undefined || Date.parse(grant.expiresAt) >= Date.now())
201
+ .reduce((total, grant) => total + grant.remaining, 0);
202
+ }
203
+ async revokeForPurchase(purchaseId) {
204
+ for (const [id, grant] of this.grants) {
205
+ if (grant.purchaseId === purchaseId)
206
+ this.grants.delete(id);
207
+ }
208
+ }
209
+ }
210
+ function utcDay(value) {
211
+ const parsed = new Date(value.length === 10 ? `${value}T00:00:00.000Z` : value);
212
+ if (Number.isNaN(parsed.getTime()))
213
+ throw new TypeError(`invalid date: ${value}`);
214
+ return parsed.toISOString().slice(0, 10);
215
+ }
216
+ /**
217
+ * Applies a settlement outbox event to the entitlement store.
218
+ *
219
+ * This is the piece that makes entitlements actually run. `BillingOutboxDispatcher`
220
+ * delivers each committed event to a `publish` callback; wiring that callback to
221
+ * this function is what grants on settlement and revokes on refund or dispute.
222
+ *
223
+ * Returns the grants created, or the number revoked, so a caller can log or
224
+ * assert. Safe to call twice for the same event: grants are keyed
225
+ * `(ruleId, purchaseId)` and revocation is idempotent.
226
+ */
227
+ export async function applySettlementEvent(store, event, ids, now) {
228
+ const purchaseId = event.payload.purchaseId;
229
+ if (typeof purchaseId !== "string" || !purchaseId) {
230
+ throw new TypeError("outbox event is missing purchaseId");
231
+ }
232
+ if (event.type === "billing.purchase_refunded" || event.type === "billing.purchase_disputed") {
233
+ await store.revokeForPurchase(purchaseId);
234
+ return { granted: 0, revoked: 1 };
235
+ }
236
+ if (event.type !== "billing.purchase_settled") {
237
+ return { granted: 0, revoked: 0 };
238
+ }
239
+ const facts = readSettlementFacts(event.payload);
240
+ const rules = await store.listRules(facts.tenantId);
241
+ const grants = grantsForSettlement(rules, facts, ids, now);
242
+ await store.grant(grants);
243
+ return { granted: grants.length, revoked: 0 };
244
+ }
245
+ /**
246
+ * Narrows an outbox payload to settlement facts, rejecting a payload that cannot
247
+ * support rule evaluation. The payload carries `schemaVersion: "2"` in its
248
+ * checkout metadata; a payload without the newer fields predates entitlements
249
+ * and is rejected rather than silently matched against missing values.
250
+ */
251
+ export function readSettlementFacts(payload) {
252
+ const required = (key) => {
253
+ const value = payload[key];
254
+ if (typeof value !== "string" || !value) {
255
+ throw new TypeError(`settlement payload is missing ${key}`);
256
+ }
257
+ return value;
258
+ };
259
+ const amount = payload.amount;
260
+ if (typeof amount !== "number" || !Number.isInteger(amount) || amount < 0) {
261
+ throw new TypeError("settlement payload has an invalid amount");
262
+ }
263
+ return {
264
+ purchaseId: required("purchaseId"),
265
+ tenantId: required("tenantId"),
266
+ channelId: required("channelId"),
267
+ sessionId: typeof payload.sessionId === "string" && payload.sessionId ? payload.sessionId : null,
268
+ buyerId: required("buyerId"),
269
+ kind: required("kind"),
270
+ productKey: required("productKey"),
271
+ amount,
272
+ currency: required("currency"),
273
+ settledAt: required("settledAt"),
274
+ };
275
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export * from "./portals-billing.js";
3
+ export * from "./entitlements.js";
4
+ export * from "./postgres-store.js";
5
+ export * from "./outbox-dispatcher.js";
6
+ export * from "./dispatcher.js";
7
+ export * from "./stripe-client.js";
8
+ export * from "./stripe-connect-client.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export * from "./portals-billing.js";
3
+ export * from "./entitlements.js";
4
+ export * from "./postgres-store.js";
5
+ export * from "./outbox-dispatcher.js";
6
+ export * from "./dispatcher.js";
7
+ export * from "./stripe-client.js";
8
+ export * from "./stripe-connect-client.js";
@@ -0,0 +1,16 @@
1
+ import type { BillingOutboxEvent, BillingStore } from "./types.js";
2
+ export interface BillingOutboxDispatcherOptions {
3
+ store: BillingStore;
4
+ publish(event: BillingOutboxEvent): Promise<void>;
5
+ batchSize?: number;
6
+ }
7
+ /** Delivers committed billing events without coupling webhooks to a realtime transport. */
8
+ export declare class BillingOutboxDispatcher {
9
+ private readonly options;
10
+ private readonly batchSize;
11
+ constructor(options: BillingOutboxDispatcherOptions);
12
+ drainOnce(): Promise<{
13
+ delivered: number;
14
+ failed: number;
15
+ }>;
16
+ }
@@ -0,0 +1,29 @@
1
+ /** Delivers committed billing events without coupling webhooks to a realtime transport. */
2
+ export class BillingOutboxDispatcher {
3
+ options;
4
+ batchSize;
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.batchSize = options.batchSize ?? 50;
8
+ if (!Number.isInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 500) {
9
+ throw new TypeError("batchSize must be an integer from 1 to 500");
10
+ }
11
+ }
12
+ async drainOnce() {
13
+ const events = await this.options.store.claimOutbox(this.batchSize);
14
+ let delivered = 0;
15
+ let failed = 0;
16
+ for (const event of events) {
17
+ try {
18
+ await this.options.publish(event);
19
+ await this.options.store.completeOutbox(event.id);
20
+ delivered += 1;
21
+ }
22
+ catch (cause) {
23
+ await this.options.store.failOutbox(event.id, cause instanceof Error ? cause.message : String(cause));
24
+ failed += 1;
25
+ }
26
+ }
27
+ return { delivered, failed };
28
+ }
29
+ }
@@ -0,0 +1,137 @@
1
+ import Stripe from "stripe";
2
+ import type { BillingCatalog, BillingStore, BillingWebhookResult, CreateCheckoutInput, CreateCheckoutResult, IdGenerator, Purchase, TenantBillingProfile } from "./types.js";
3
+ export interface MonetizationOptions {
4
+ /**
5
+ * The platform's Stripe credential. It creates charges on the platform account
6
+ * and transfers them to tenant accounts, so it is a charge-capable key and
7
+ * must be a restricted key scoped to exactly the endpoints used.
8
+ *
9
+ * The end user's `Customer` and payment method also live on this account,
10
+ * which is what lets one payment method be reused across every tenant.
11
+ */
12
+ stripe: Stripe;
13
+ store: BillingStore;
14
+ catalog: BillingCatalog;
15
+ ids: IdGenerator;
16
+ now?: () => Date;
17
+ /**
18
+ * Application policy for reversing a Connect transfer after a dispute. Only
19
+ * invoked when the charge actually produced a transfer, and the reversal uses
20
+ * a stable idempotency key so a retry cannot double-reverse.
21
+ */
22
+ reverseTransferForDispute?: (purchase: Purchase, dispute: Stripe.Dispute) => boolean | Promise<boolean>;
23
+ }
24
+ export interface EnsureTenantProfileInput {
25
+ tenantId: string;
26
+ ownerId: string;
27
+ email?: string;
28
+ country?: string;
29
+ defaultCurrency?: string;
30
+ displayName?: string;
31
+ }
32
+ export interface RefundPurchaseInput {
33
+ purchaseId: string;
34
+ tenantId: string;
35
+ amount?: number;
36
+ reason?: Stripe.RefundCreateParams.Reason;
37
+ }
38
+ /** Outcome of checking what Stripe actually recorded against the expected catalog. */
39
+ export type SettlementVerification = {
40
+ ok: true;
41
+ } | {
42
+ ok: false;
43
+ reason: "fee_mismatch" | "amount_mismatch";
44
+ expected: number;
45
+ actual: number;
46
+ };
47
+ /**
48
+ * Direct-charge checkout and Connect management.
49
+ *
50
+ * The tenant is merchant of record: charges are created on the tenant's own
51
+ * account, so the caller supplies a Stripe credential scoped to that account and
52
+ * the platform never holds a key that can move tenant money. The platform's own
53
+ * credential is limited to Connect management.
54
+ */
55
+ export declare class Monetization {
56
+ private readonly options;
57
+ private readonly now;
58
+ constructor(options: MonetizationOptions);
59
+ getTenantProfile(tenantId: string): Promise<TenantBillingProfile | undefined>;
60
+ /**
61
+ * Creates the tenant's connected account via **Accounts v2**
62
+ * (`stripe.v2.core.accounts`), configured as a recipient for destination charges.
63
+ *
64
+ * The v1 `stripe.accounts.create({ type: "express" })` form is deprecated and
65
+ * is not used. `losses_collector: "application"` is required for destination
66
+ * charges: the platform must hold the negative balance so a dispute transfer
67
+ * can be reversed. The cost is that the platform carries dispute liability.
68
+ */
69
+ ensureTenantProfile(input: EnsureTenantProfileInput): Promise<TenantBillingProfile>;
70
+ /** Reads the v2 capability path. Never reads the deprecated v1 boolean fields. */
71
+ refreshTenantCapabilities(tenantId: string): Promise<TenantBillingProfile>;
72
+ createConnectOnboardingLink(tenantId: string, refreshUrl: string, returnUrl: string): Promise<{
73
+ url: string;
74
+ }>;
75
+ private tenantClient;
76
+ /**
77
+ * Creates a destination-charge Checkout Session on the **platform** account,
78
+ * transferring to the tenant's connected account on success.
79
+ *
80
+ * `customerId` is the end user's platform-account `Customer`. Supply it to
81
+ * reuse payment details already saved for another tenant; omit it and Stripe
82
+ * creates a Customer for this session only. This is the mechanism behind
83
+ * "provide payment once, use on any tenant" — a `PaymentMethod` cannot move
84
+ * between connected accounts, so the Customer has to live on the platform.
85
+ *
86
+ * `application_fee_amount` comes from the server-side catalog;
87
+ * `CreateCheckoutInput` has no fee field, so no caller can influence it.
88
+ *
89
+ * `integration_identifier` labels the session in the Dashboard for tracking
90
+ * and comparing checkout flows.
91
+ */
92
+ /**
93
+ * The platform-account `Customer` for this consumer, creating it on first use.
94
+ *
95
+ * Because the platform holds the customer rather than each tenant, one saved
96
+ * payment method pays for purchases across every tenant the consumer buys
97
+ * from. A duplicate is possible if two requests race before either commits, in
98
+ * which case the last write wins and the superseded Customer is orphaned in
99
+ * Stripe; that is a rare, harmless leak rather than a wrong charge, so it is
100
+ * not worth a lock.
101
+ */
102
+ ensureBillingCustomer(buyerId: string): Promise<{
103
+ id: string;
104
+ }>;
105
+ createCheckout(input: CreateCheckoutInput): Promise<CreateCheckoutResult>;
106
+ reportMeterEvent(tenantId: string, customerId: string, eventName: string, value: number, identifier: string): Promise<void>;
107
+ /** Customer and portal session both live on the tenant's account. */
108
+ createCustomerPortalSession(tenantId: string, customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session>;
109
+ /**
110
+ * Checks what Stripe actually recorded against the server-side catalog.
111
+ *
112
+ * The platform sets the fee, so this is defense in depth rather than the
113
+ * primary control: it catches a misconfigured catalog, a replayed session, or
114
+ * a manually created charge. Call it before granting entitlements or paying
115
+ * out.
116
+ */
117
+ verifySettlement(purchaseId: string, actualAmount: number, actualFeeAmount: number | null): Promise<SettlementVerification>;
118
+ refundPurchase(input: RefundPurchaseInput): Promise<Stripe.Refund>;
119
+ handleWebhook(rawBody: string | Buffer, signature: string, secret: string): Promise<BillingWebhookResult>;
120
+ /**
121
+ * Reads the purchase and reverses the Connect transfer when policy allows.
122
+ * Runs outside the webhook transaction; the resulting ledger entry is written
123
+ * by `applyEvent` once the transaction commits.
124
+ */
125
+ private maybeReverseTransfer;
126
+ private applyEvent;
127
+ private ledger;
128
+ private findPurchaseForCharge;
129
+ /**
130
+ * Settlement facts. Every field an entitlement rule can match on is present
131
+ * here, so a rule is evaluable from the outbox alone without re-reading the
132
+ * purchase. `schemaVersion` was bumped to 2 because the payload gained
133
+ * `tenantId`, `sessionId`, `productKey`, and `settledAt`.
134
+ */
135
+ private outbox;
136
+ private requireReadyProfile;
137
+ }