@portalshq/monetization 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  Audience-to-creator payments: Stripe Connect destination charges, tenant payout readiness, a PostgreSQL ledger/outbox, and rule-based entitlements.
6
6
 
7
+ A **tenant** is a producer, independent of the audience users who are consumers.
8
+ A **channel** is the producer venue; a **program** is the software experience
9
+ owned by a channel. Attribute application activity and entitlement kinds to the
10
+ program ID. A central `tenants` registry can identify producers without a user
11
+ foreign key.
12
+
13
+ The current package and vendored SQL still use `tenant_id` for payout profiles,
14
+ purchases and entitlement records. It is a required package key, not a newly
15
+ introduced `program_id` column. An integration that uses a program ID in this
16
+ field must provision the corresponding payout profile and Stripe mapping and
17
+ coordinate all purchase, rule and grant keys. Do not reassign existing tenant
18
+ keys, Stripe accounts or grant kinds without an explicit data migration. A
19
+ program-prefixed entitlement kind isolates a program because the entitlement
20
+ store filters by consumer and kind.
21
+
7
22
  ## Required setup
8
23
 
9
24
  1. Apply `sql/001_monetization.sql` to PostgreSQL.
@@ -26,6 +41,7 @@ const { url } = await monetization.createCheckout({
26
41
  - `handleWebhook(rawBody, signature, secret)`: atomically applies Stripe events to purchases, ledger, and outbox. Use `refundPurchase()` for refunds.
27
42
  - `MonetizationDispatcher`: drains the outbox, applies entitlements, and invokes optional application side effects. Monitor `isStale()`, `listParked()`, and requeue repaired events with `requeueParked()`.
28
43
  - `PostgresEntitlementStore.consume()` / `remaining()`: consume or inspect entitlement balances.
44
+ - `debitCredits(input)` / `grantCredits(input)` (`CreditDebitStore` / `CreditGrantStore`): retry-safe credit operations keyed by an immutable `operationId`. The debit locks grants, verifies tenant/consumer/kind/scope, debits in deterministic order, and persists the outcome (including `insufficient`, so a later balance cannot charge a declined operation). The grant issues reward/courtesy credits as a non-purchase-backed grant under a `credit-grant:<kind>` rule and records it in `monetization_credit_grants`. Identical retries replay the stored result; a reused operation id with different facts is an idempotency conflict. Apply `sql/002_credit_debits.sql` and `sql/003_credit_grants.sql`.
29
45
 
30
46
  ## Safety and operations
31
47
 
@@ -85,6 +85,45 @@ export interface EntitlementGrant {
85
85
  createdAt: string;
86
86
  updatedAt: string;
87
87
  }
88
+ /** Immutable facts for one retry-safe credit debit. */
89
+ export interface CreditDebitInput {
90
+ operationId: string;
91
+ tenantId: string;
92
+ consumerId: string;
93
+ kind: string;
94
+ site: EntitlementScope;
95
+ amount: number;
96
+ }
97
+ export interface CreditDebitResult {
98
+ outcome: "debited" | "insufficient";
99
+ allocations: readonly {
100
+ grantId: string;
101
+ amount: number;
102
+ }[];
103
+ }
104
+ export interface CreditDebitStore {
105
+ debitCredits(input: CreditDebitInput): Promise<CreditDebitResult>;
106
+ }
107
+ /** Immutable facts for one retry-safe credit grant (reward or courtesy issue). */
108
+ export interface CreditGrantInput {
109
+ operationId: string;
110
+ tenantId: string;
111
+ consumerId: string;
112
+ kind: string;
113
+ site: EntitlementScope;
114
+ amount: number;
115
+ }
116
+ export interface CreditGrantResult {
117
+ outcome: "granted";
118
+ grantId: string;
119
+ }
120
+ export interface CreditGrantStore {
121
+ grantCredits(input: CreditGrantInput): Promise<CreditGrantResult>;
122
+ }
123
+ export declare function normalizeCreditDebit(input: CreditDebitInput): CreditDebitInput;
124
+ export declare function normalizeCreditGrant(input: CreditGrantInput): CreditGrantInput;
125
+ /** The entitlement rule a `grantCredits` operation ensures before granting. */
126
+ export declare function creditGrantRuleId(kind: string): string;
88
127
  export interface EntitlementStore {
89
128
  saveRule(rule: EntitlementRule): Promise<void>;
90
129
  getRule(id: string): Promise<EntitlementRule | undefined>;
@@ -120,10 +159,21 @@ export declare class InMemoryEntitlementStore implements EntitlementStore {
120
159
  private readonly rules;
121
160
  private readonly grants;
122
161
  private readonly granted;
162
+ private readonly creditDebits;
163
+ private readonly creditGrants;
123
164
  saveRule(rule: EntitlementRule): Promise<void>;
124
165
  getRule(id: string): Promise<EntitlementRule | undefined>;
125
166
  listRules(tenantId: string): Promise<readonly EntitlementRule[]>;
126
167
  grant(grants: readonly EntitlementGrant[]): Promise<void>;
168
+ debitCredits(input: CreditDebitInput): Promise<CreditDebitResult>;
169
+ /**
170
+ * Issue `amount` credits as a new grant, exactly once per `operationId`.
171
+ * The grant is not purchase-backed (`purchaseId` carries the operation id,
172
+ * never a real purchase), so refund flows can never revoke a reward.
173
+ * An identical retry returns the original result; a reused operation id
174
+ * carrying different facts fails.
175
+ */
176
+ grantCredits(input: CreditGrantInput): Promise<CreditGrantResult>;
127
177
  consume(consumerId: string, kind: string, site: EntitlementScope, n: number): Promise<boolean>;
128
178
  remaining(consumerId: string, kind: string, site: EntitlementScope): Promise<number>;
129
179
  revokeForPurchase(purchaseId: string): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  /**
2
3
  * Tenant-configured entitlements.
3
4
  *
@@ -64,6 +65,40 @@ export function resolveScope(scope, facts) {
64
65
  }
65
66
  return resolved;
66
67
  }
68
+ /** Shared shape of a retry-safe credit operation's immutable facts. */
69
+ function normalizeCreditOperation(input) {
70
+ for (const value of [input.operationId, input.tenantId, input.consumerId, input.kind]) {
71
+ if (typeof value !== "string" || !value.trim() || value.length > 512)
72
+ throw new TypeError("Invalid credit operation identifier");
73
+ }
74
+ if (!Number.isSafeInteger(input.amount) || input.amount < 1)
75
+ throw new TypeError("Invalid credit operation amount");
76
+ if (!input.site || typeof input.site !== "object" || Array.isArray(input.site))
77
+ throw new TypeError("Invalid credit operation scope");
78
+ const site = {};
79
+ for (const key of Object.keys(input.site))
80
+ if (!["channelId", "sessionId", "date"].includes(key))
81
+ throw new TypeError("Invalid credit operation scope");
82
+ for (const key of ["channelId", "sessionId", "date"]) {
83
+ const value = input.site[key];
84
+ if (value !== undefined) {
85
+ if (typeof value !== "string" || !value.trim() || value.length > 512)
86
+ throw new TypeError("Invalid credit operation scope");
87
+ site[key] = value;
88
+ }
89
+ }
90
+ return { operationId: input.operationId, tenantId: input.tenantId, consumerId: input.consumerId, kind: input.kind, site, amount: input.amount };
91
+ }
92
+ export function normalizeCreditDebit(input) {
93
+ return normalizeCreditOperation(input);
94
+ }
95
+ export function normalizeCreditGrant(input) {
96
+ return normalizeCreditOperation(input);
97
+ }
98
+ /** The entitlement rule a `grantCredits` operation ensures before granting. */
99
+ export function creditGrantRuleId(kind) {
100
+ return `credit-grant:${kind}`;
101
+ }
67
102
  /** End of the UTC day named by `scope.date`, or undefined when the rule is not date-bounded. */
68
103
  export function scopeExpiresAt(scope) {
69
104
  return scope.date ? `${utcDay(scope.date)}T23:59:59.999Z` : undefined;
@@ -155,6 +190,8 @@ export class InMemoryEntitlementStore {
155
190
  rules = new Map();
156
191
  grants = new Map();
157
192
  granted = new Set();
193
+ creditDebits = new Map();
194
+ creditGrants = new Map();
158
195
  async saveRule(rule) {
159
196
  validateRule(rule);
160
197
  this.rules.set(rule.id, rule);
@@ -174,6 +211,70 @@ export class InMemoryEntitlementStore {
174
211
  this.grants.set(grant.id, grant);
175
212
  }
176
213
  }
214
+ async debitCredits(input) {
215
+ const normalized = normalizeCreditDebit(input);
216
+ const existing = this.creditDebits.get(normalized.operationId);
217
+ if (existing) {
218
+ if (JSON.stringify(existing.input) !== JSON.stringify(normalized))
219
+ throw new Error("Credit debit idempotency conflict");
220
+ return structuredClone(existing.result);
221
+ }
222
+ const candidates = [...this.grants.values()].filter(g => g.tenantId === normalized.tenantId && g.consumerId === normalized.consumerId && g.kind === normalized.kind && g.remaining > 0 && grantAppliesAt(g.scope, normalized.site) && (g.expiresAt === undefined || Date.parse(g.expiresAt) >= Date.now())).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
223
+ const enough = candidates.reduce((sum, g) => sum + g.remaining, 0) >= normalized.amount;
224
+ const allocations = [];
225
+ let owed = enough ? normalized.amount : 0;
226
+ for (const grant of candidates) {
227
+ const amount = Math.min(owed, grant.remaining);
228
+ if (amount) {
229
+ grant.remaining -= amount;
230
+ owed -= amount;
231
+ allocations.push({ grantId: grant.id, amount });
232
+ }
233
+ }
234
+ const result = { outcome: enough ? "debited" : "insufficient", allocations };
235
+ this.creditDebits.set(normalized.operationId, { input: normalized, result });
236
+ return structuredClone(result);
237
+ }
238
+ /**
239
+ * Issue `amount` credits as a new grant, exactly once per `operationId`.
240
+ * The grant is not purchase-backed (`purchaseId` carries the operation id,
241
+ * never a real purchase), so refund flows can never revoke a reward.
242
+ * An identical retry returns the original result; a reused operation id
243
+ * carrying different facts fails.
244
+ */
245
+ async grantCredits(input) {
246
+ const normalized = normalizeCreditGrant(input);
247
+ const existing = this.creditGrants.get(normalized.operationId);
248
+ if (existing) {
249
+ if (JSON.stringify(existing.input) !== JSON.stringify(normalized))
250
+ throw new Error("Credit grant idempotency conflict");
251
+ return structuredClone(existing.result);
252
+ }
253
+ const ruleId = creditGrantRuleId(normalized.kind);
254
+ if (!this.rules.has(ruleId)) {
255
+ this.rules.set(ruleId, { id: ruleId, tenantId: normalized.tenantId, kind: normalized.kind, scope: normalized.site, conditions: [], quantity: 1, createdAt: new Date().toISOString() });
256
+ }
257
+ const grantId = randomUUID();
258
+ const now = new Date().toISOString();
259
+ const expiresAt = scopeExpiresAt(normalized.site);
260
+ this.grants.set(grantId, {
261
+ id: grantId,
262
+ ruleId,
263
+ tenantId: normalized.tenantId,
264
+ consumerId: normalized.consumerId,
265
+ kind: normalized.kind,
266
+ scope: normalized.site,
267
+ quantity: normalized.amount,
268
+ remaining: normalized.amount,
269
+ purchaseId: normalized.operationId,
270
+ ...(expiresAt ? { expiresAt } : {}),
271
+ createdAt: now,
272
+ updatedAt: now,
273
+ });
274
+ const result = { outcome: "granted", grantId };
275
+ this.creditGrants.set(normalized.operationId, { input: normalized, result });
276
+ return structuredClone(result);
277
+ }
177
278
  async consume(consumerId, kind, site, n) {
178
279
  if (!Number.isInteger(n) || n < 1)
179
280
  throw new TypeError("consume count must be a positive integer");
@@ -1,7 +1,7 @@
1
1
  import type { Pool } from "pg";
2
2
  import type Stripe from "stripe";
3
3
  import type { BillingOutboxEvent, BillingStore, BillingTransaction, Purchase, TenantBillingProfile } from "./types.js";
4
- import type { EntitlementGrant, EntitlementRule, EntitlementScope, EntitlementStore } from "./entitlements.js";
4
+ import type { EntitlementGrant, EntitlementRule, EntitlementScope, EntitlementStore, CreditDebitInput, CreditDebitResult, CreditGrantInput, CreditGrantResult } from "./entitlements.js";
5
5
  /** Durable PostgreSQL adapter. Apply sql/001_monetization.sql before use. */
6
6
  export declare class PostgresBillingStore implements BillingStore {
7
7
  private readonly pool;
@@ -39,6 +39,17 @@ export declare class PostgresEntitlementStore implements EntitlementStore {
39
39
  getRule(id: string): Promise<EntitlementRule | undefined>;
40
40
  listRules(tenantId: string): Promise<readonly EntitlementRule[]>;
41
41
  grant(grants: readonly EntitlementGrant[]): Promise<void>;
42
+ /** Debit and its idempotency record commit atomically in the billing database. */
43
+ debitCredits(input: CreditDebitInput): Promise<CreditDebitResult>;
44
+ /**
45
+ * Issue `amount` credits as a new grant, exactly once per `operationId`.
46
+ * The rule, grant and operation record commit atomically, so a crash
47
+ * leaves either nothing or a replayable record — replaying the operation
48
+ * id returns the stored grant rather than issuing twice. The grant's
49
+ * `purchase_id` is NULL: reward credits are not purchase-backed and a
50
+ * refund flow can never revoke them.
51
+ */
52
+ grantCredits(input: CreditGrantInput): Promise<CreditGrantResult>;
42
53
  consume(consumerId: string, kind: string, site: EntitlementScope, n: number): Promise<boolean>;
43
54
  remaining(consumerId: string, kind: string, site: EntitlementScope): Promise<number>;
44
55
  revokeForPurchase(purchaseId: string): Promise<void>;
@@ -1,4 +1,5 @@
1
- import { grantAppliesAt, validateRule } from "./entitlements.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { grantAppliesAt, validateRule, normalizeCreditDebit, normalizeCreditGrant, creditGrantRuleId, scopeExpiresAt } from "./entitlements.js";
2
3
  /** Durable PostgreSQL adapter. Apply sql/001_monetization.sql before use. */
3
4
  export class PostgresBillingStore {
4
5
  pool;
@@ -221,6 +222,89 @@ export class PostgresEntitlementStore {
221
222
  VALUES ${tuples.join(",")}
222
223
  ON CONFLICT (rule_id, purchase_id) DO NOTHING`, values);
223
224
  }
225
+ /** Debit and its idempotency record commit atomically in the billing database. */
226
+ async debitCredits(input) {
227
+ const x = normalizeCreditDebit(input);
228
+ const client = await this.pool.connect();
229
+ try {
230
+ await client.query("BEGIN");
231
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`credit-debit:${x.operationId}`]);
232
+ const prior = (await client.query("SELECT * FROM monetization_credit_debits WHERE operation_id=$1", [x.operationId])).rows[0];
233
+ if (prior) {
234
+ const same = prior.tenant_id === x.tenantId && prior.consumer_id === x.consumerId && prior.kind === x.kind && Number(prior.amount) === x.amount && JSON.stringify(normalizeCreditDebit({ ...x, site: prior.scope }).site) === JSON.stringify(x.site);
235
+ if (!same)
236
+ throw new Error("Credit debit idempotency conflict");
237
+ await client.query("COMMIT");
238
+ return { outcome: prior.outcome, allocations: prior.allocations };
239
+ }
240
+ const rows = (await client.query(`SELECT id, scope, remaining, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS created_order FROM monetization_entitlements WHERE tenant_id=$1 AND consumer_id=$2 AND kind=$3 AND remaining>0 AND (expires_at IS NULL OR expires_at>=now()) ORDER BY id FOR UPDATE`, [x.tenantId, x.consumerId, x.kind])).rows;
241
+ const candidates = rows.filter(row => grantAppliesAt(row.scope, x.site)).sort((a, b) => a.created_order.localeCompare(b.created_order) || a.id.localeCompare(b.id));
242
+ const enough = candidates.reduce((sum, row) => sum + Number(row.remaining), 0) >= x.amount;
243
+ const allocations = [];
244
+ let owed = enough ? x.amount : 0;
245
+ for (const row of candidates) {
246
+ const amount = Math.min(owed, Number(row.remaining));
247
+ if (amount) {
248
+ await client.query("UPDATE monetization_entitlements SET remaining=remaining-$2,updated_at=now() WHERE id=$1", [row.id, amount]);
249
+ owed -= amount;
250
+ allocations.push({ grantId: row.id, amount });
251
+ }
252
+ }
253
+ const outcome = enough ? "debited" : "insufficient";
254
+ await client.query("INSERT INTO monetization_credit_debits(operation_id,tenant_id,consumer_id,kind,scope,amount,outcome,allocations) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [x.operationId, x.tenantId, x.consumerId, x.kind, JSON.stringify(x.site), x.amount, outcome, JSON.stringify(allocations)]);
255
+ await client.query("COMMIT");
256
+ return { outcome, allocations };
257
+ }
258
+ catch (cause) {
259
+ await client.query("ROLLBACK");
260
+ throw cause;
261
+ }
262
+ finally {
263
+ client.release();
264
+ }
265
+ }
266
+ /**
267
+ * Issue `amount` credits as a new grant, exactly once per `operationId`.
268
+ * The rule, grant and operation record commit atomically, so a crash
269
+ * leaves either nothing or a replayable record — replaying the operation
270
+ * id returns the stored grant rather than issuing twice. The grant's
271
+ * `purchase_id` is NULL: reward credits are not purchase-backed and a
272
+ * refund flow can never revoke them.
273
+ */
274
+ async grantCredits(input) {
275
+ const x = normalizeCreditGrant(input);
276
+ const client = await this.pool.connect();
277
+ try {
278
+ await client.query("BEGIN");
279
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`credit-grant:${x.operationId}`]);
280
+ const prior = (await client.query("SELECT * FROM monetization_credit_grants WHERE operation_id=$1", [x.operationId])).rows[0];
281
+ if (prior) {
282
+ const same = prior.tenant_id === x.tenantId && prior.consumer_id === x.consumerId && prior.kind === x.kind && Number(prior.amount) === x.amount && JSON.stringify(normalizeCreditGrant({ ...x, site: prior.scope }).site) === JSON.stringify(x.site);
283
+ if (!same)
284
+ throw new Error("Credit grant idempotency conflict");
285
+ await client.query("COMMIT");
286
+ return { outcome: "granted", grantId: prior.grant_id };
287
+ }
288
+ const ruleId = creditGrantRuleId(x.kind);
289
+ await client.query(`INSERT INTO monetization_entitlement_rules(id,tenant_id,kind,scope,conditions,quantity,created_at)
290
+ VALUES($1,$2,$3,$4,'[]'::jsonb,1,now()) ON CONFLICT (id) DO NOTHING`, [ruleId, x.tenantId, x.kind, JSON.stringify(x.site)]);
291
+ const grantId = randomUUID();
292
+ const expiresAt = scopeExpiresAt(x.site) ?? null;
293
+ await client.query(`INSERT INTO monetization_entitlements
294
+ (id, rule_id, tenant_id, consumer_id, kind, scope, quantity, remaining, purchase_id, expires_at, created_at, updated_at)
295
+ VALUES($1,$2,$3,$4,$5,$6,$7,$7,NULL,$8,now(),now())`, [grantId, ruleId, x.tenantId, x.consumerId, x.kind, JSON.stringify(x.site), x.amount, expiresAt]);
296
+ await client.query("INSERT INTO monetization_credit_grants(operation_id,tenant_id,consumer_id,kind,scope,amount,grant_id,outcome) VALUES($1,$2,$3,$4,$5,$6,$7,'granted')", [x.operationId, x.tenantId, x.consumerId, x.kind, JSON.stringify(x.site), x.amount, grantId]);
297
+ await client.query("COMMIT");
298
+ return { outcome: "granted", grantId };
299
+ }
300
+ catch (cause) {
301
+ await client.query("ROLLBACK");
302
+ throw cause;
303
+ }
304
+ finally {
305
+ client.release();
306
+ }
307
+ }
224
308
  async consume(consumerId, kind, site, n) {
225
309
  if (!Number.isInteger(n) || n < 1)
226
310
  throw new TypeError("consume count must be a positive integer");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portalshq/monetization",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Audience-to-creator monetization for Portals: Stripe Connect destination charges, entitlements, ledger, and transactional outbox. Keyed on tenant, not channel.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,12 @@
1
+ -- Idempotency records live in the same database and transaction as credit debits.
2
+ CREATE TABLE IF NOT EXISTS monetization_credit_debits (
3
+ operation_id text PRIMARY KEY,
4
+ tenant_id text NOT NULL,
5
+ consumer_id text NOT NULL,
6
+ kind text NOT NULL,
7
+ scope jsonb NOT NULL,
8
+ amount integer NOT NULL CHECK (amount > 0),
9
+ outcome text NOT NULL CHECK (outcome IN ('debited', 'insufficient')),
10
+ allocations jsonb NOT NULL DEFAULT '[]'::jsonb,
11
+ created_at timestamptz NOT NULL DEFAULT now()
12
+ );
@@ -0,0 +1,19 @@
1
+ -- Idempotent platform-issued credit grants (rewards, courtesy credits).
2
+ -- Apply after 001 and 002. Records live in the same database as grants and
3
+ -- commit in the same transaction as the grant they create.
4
+
5
+ -- Reward grants are not purchase-backed: purchase_id stays NULL so refund
6
+ -- flows (revokeForPurchase) can never revoke them.
7
+ ALTER TABLE monetization_entitlements ALTER COLUMN purchase_id DROP NOT NULL;
8
+
9
+ CREATE TABLE IF NOT EXISTS monetization_credit_grants (
10
+ operation_id text PRIMARY KEY,
11
+ tenant_id text NOT NULL,
12
+ consumer_id text NOT NULL,
13
+ kind text NOT NULL,
14
+ scope jsonb NOT NULL,
15
+ amount integer NOT NULL CHECK (amount > 0),
16
+ outcome text NOT NULL CHECK (outcome IN ('granted')),
17
+ grant_id text,
18
+ created_at timestamptz NOT NULL DEFAULT now()
19
+ );