@portalshq/monetization 0.2.0 → 0.2.2

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
@@ -1,182 +1,43 @@
1
1
  # @portalshq/monetization
2
2
 
3
- Audience-to-creator monetization for Portals. Stripe Connect destination
4
- charges, entitlements, a ledger, and a transactional outbox.
3
+ **Reusable agent module:** [integration guide](../../docs/package-agents/monetization.md)
5
4
 
6
- Applications provide authorization, catalog configuration, persistence, and
7
- URLs. They never provide authoritative prices, fees, or destination account ids
8
- from a browser.
5
+ Audience-to-creator payments: Stripe Connect destination charges, tenant payout readiness, a PostgreSQL ledger/outbox, and rule-based entitlements.
9
6
 
10
- ## Credentials
7
+ ## Required setup
11
8
 
12
- No package here reads `process.env` for a secret. The application boundary
13
- resolves the credential and injects it, so a library can never quietly pick up
14
- an ambient key and tests cannot be influenced by one.
15
-
16
- ```ts
17
- // application boundary — the only place env is read
18
- const secretKey = process.env.STRIPE_SECRET_KEY?.trim();
19
- if (!secretKey) throw new Error("STRIPE_SECRET_KEY is not configured");
20
-
21
- const monetization = new Monetization({
22
- stripe: createStripePlatformClient(secretKey),
23
- store, ids, catalog,
24
- });
25
- const transfers = new StripeConnectClient(secretKey);
26
- ```
27
-
28
- Store it in the deploy platform's secrets vault (AWS Secrets Manager, GCP Secret
29
- Manager, Azure Key Vault), or a write-only environment variable where no vault
30
- exists — Vercel, for instance, has no built-in vault. Never in source.
31
-
32
- ### Credentials
33
-
34
- `stripe` is the platform's charge-capable key: it creates charges and transfers
35
- them to tenants, and it holds the end users' payment methods. Use a **restricted
36
- key** scoped to only the endpoints used, plus an IP access policy. Prefer the
37
- deploy platform's secrets vault over a plain environment variable.
38
-
39
- | Credential | Acts on | Purpose |
40
- |---|---|---|
41
- | **Platform RAK** (`rk_`) | Platform account, all connected accounts | Creates charges and transfers; provisions tenant accounts; reads capabilities; creates onboarding links |
42
- | **Platform-sales RAK** (`rk_`) | Platform account | Enterprise and B2B sales, no Connect involved. Served by `StripePlatformBilling` in `@portalshq/platform-billing` |
43
-
44
- End users never hold a Stripe credential, and neither do tenants. Tenants get
45
- Stripe-hosted onboarding plus dashboard access via account links the platform
46
- creates.
47
-
48
- ## Checkout
49
-
50
- Pass a stable application order id as `purchaseId`. Retrying that id returns the
51
- existing Stripe Checkout Session; reusing it for different purchase data is
52
- rejected. Checkout is refused until the tenant can receive payouts.
9
+ 1. Apply `sql/001_monetization.sql` to PostgreSQL.
10
+ 2. Create `PostgresBillingStore` and `PostgresEntitlementStore` from the same pool.
11
+ 3. Inject a server-side Stripe platform client, server-owned `BillingCatalog`, and `IdGenerator` into `Monetization`.
12
+ 4. Create/refresh the tenant profile and complete Stripe onboarding before checkout.
13
+ 5. Route the **raw, unparsed** Stripe webhook body to `handleWebhook`, then run `MonetizationDispatcher.drainOnce()` from a worker.
53
14
 
54
15
  ```ts
16
+ const monetization = new Monetization({ stripe, store, catalog, ids });
55
17
  const { url } = await monetization.createCheckout({
56
- purchaseId, tenantId, channelId, sessionId, buyerId, productKey,
57
- successUrl, cancelUrl,
58
- });
59
- ```
60
-
61
- `sessionId` is optional. When present it becomes a fact that session-scoped
62
- entitlement rules can match on.
63
-
64
- ## Webhooks, refunds, disputes
65
-
66
- `handleWebhook` processes signed events transactionally: the event is claimed
67
- atomically, so a redelivered event is a no-op and a settlement can never be
68
- applied twice. Refund webhooks are applied as cumulative Stripe state and
69
- ledger only the new delta. To recover tenant funds after a dispute, provide
70
- `reverseTransferForDispute`; an approved reversal uses the recorded Connect
71
- transfer and a stable Stripe idempotency key. Webhook effects and outbox writes
72
- commit in one `BillingStore` transaction.
73
-
74
- ## Entitlements
75
-
76
- An entitlement is a grant made to a paying end user, configured by the tenant.
77
- The scope is an arbitrary subset of dimensions, and **the dimensions present are
78
- both the matcher and the lifetime**:
79
-
80
- | Scope | Meaning |
81
- |---|---|
82
- | `{ channelId }` | valid for the life of the channel |
83
- | `{ channelId, sessionId }` | valid only during that session |
84
- | `{ channelId, date }` | only for purchases settling that day; expires at end of it |
85
- | `{ channelId, sessionId, date }` | the narrowest form |
86
-
87
- Every dimension a rule names must match for the grant to be made. A grant is
88
- always made to a consumer, so a rule never grants "the channel" anything.
89
-
90
- ## Wiring: one construction, nothing to remember
91
-
92
- `MonetizationDispatcher` is the intended entry point for outbox delivery, and it
93
- wires entitlement handling for you:
94
-
95
- ```ts
96
- const dispatcher = new MonetizationDispatcher({
97
- store: new PostgresBillingStore(pool),
98
- entitlements: new PostgresEntitlementStore(pool), // required
99
- ids: { next: () => randomUUID() },
100
- onEvent: (event, applied) => {
101
- // your own side effects, e.g. publish a settled superchat to the live chat
102
- if (event.type === "billing.purchase_settled") publishToChat(event.payload);
103
- },
18
+ purchaseId, tenantId, channelId, buyerId, productKey, successUrl, cancelUrl,
104
19
  });
105
-
106
- // on a schedule — a worker, a cron, or a loop
107
- const { delivered, failed, granted, revoked, withoutSession } = await dispatcher.drainOnce();
108
- ```
109
-
110
- **Entitlement handling is unconditional.** `onEvent` is the optional part. There
111
- is no configuration in which a settled purchase silently fails to grant, because
112
- the alternative — constructing a `BillingOutboxDispatcher` and pointing its
113
- `publish` at `applySettlementEvent` — is a step whose omission produces no error.
114
- Use that path only if you are deliberately building your own delivery loop.
115
-
116
- `drainOnce` returns per-drain counters, not a running total. `withoutSession`
117
- counts settlements that arrived with no `sessionId`: a purchase with no session
118
- cannot match a session-scoped rule, so a non-zero count means either
119
- `sessionId` is not being passed at checkout, or sessions are unused. Both are
120
- worth seeing rather than inferring from a rule that never matches.
121
-
122
- A worker that stops is indistinguishable from a quiet platform, so:
123
-
124
- ```ts
125
- if (dispatcher.isStale()) alert("monetization outbox is not draining");
126
- ```
127
-
128
- Grants are **balances, not flags**. A rule nothing ever consumes stays
129
- permanently available, which is how a standing membership is expressed; a rule
130
- the application spends one unit at a time is how a single-use unlock is
131
- expressed. Both use the same primitive:
132
-
133
- ```ts
134
- const store = new PostgresEntitlementStore(pool);
135
-
136
- await store.consume(consumerId, "prompt_influence", { channelId, sessionId }, 1);
137
- await store.remaining(consumerId, "founding_member", { channelId });
138
20
  ```
139
21
 
140
- Rules are evaluated off the settlement outbox, so `billing.purchase_settled`
141
- carries every fact a rule can match on. Grants are keyed `(rule_id, purchase_id)`,
142
- so a redelivered event cannot double-grant. `purchase_refunded` and
143
- `purchase_disputed` revoke what a purchase created.
144
-
145
- ## Persistence
146
-
147
- `PostgresBillingStore` (purchases, ledger, outbox) and
148
- `PostgresEntitlementStore` (rules, grants) are the provided adapters. Apply
149
- `sql/001_monetization.sql` first. Both sit behind small ports, so an application
150
- already using an ORM can implement them without `pg`.
151
-
152
- ## Breaking changes in 0.2.0
22
+ ## Core API
153
23
 
154
- Renamed from `@portalshq/billing`. Money is now keyed on `tenantId` rather than
155
- `channelId`, so the `billing_*` tables are replaced rather than migrated —
156
- `sql/001_monetization.sql` is the new baseline. `ChannelBillingProfile` is now
157
- `TenantBillingProfile`, `createChannelCheckout` is `createCheckout`, and
158
- `ChannelPurchase` is `Purchase`. No production data was written against the
159
- Connect path before this release, so nothing required migrating.
24
+ - `ensureTenantProfile()`, `refreshTenantCapabilities()`, `createConnectOnboardingLink()`: provision and assess the tenant's Stripe Connect recipient account.
25
+ - `ensureBillingCustomer()`, `createCheckout()`: create platform-held customer/payment flows. `purchaseId` is the idempotency key and cannot be reused for different purchase facts.
26
+ - `handleWebhook(rawBody, signature, secret)`: atomically applies Stripe events to purchases, ledger, and outbox. Use `refundPurchase()` for refunds.
27
+ - `MonetizationDispatcher`: drains the outbox, applies entitlements, and invokes optional application side effects. Monitor `isStale()`, `listParked()`, and requeue repaired events with `requeueParked()`.
28
+ - `PostgresEntitlementStore.consume()` / `remaining()`: consume or inspect entitlement balances.
160
29
 
161
- **Accounts v2 replaced the deprecated v1 accounts API**, and destination charges
162
- were retained (see "Destination charges, and why"):
30
+ ## Safety and operations
163
31
 
164
- - `ensureTenantProfile` calls `stripe.v2.core.accounts.create` with a merchant
165
- configuration. The deprecated `stripe.accounts.create({ type: "express" })`
166
- form is not used anywhere.
167
- - Capability state is read from
168
- `configuration.recipient.capabilities.stripe_balance.stripe_transfers.status`.
169
- The v1 fields `charges_enabled`, `payouts_enabled`, and `details_submitted` are
170
- gone, as are their columns.
171
- - `TenantBillingProfile` drops `stripeCustomerId` and the three v1 booleans,
172
- gaining `stripeAccountId` and `transfersStatus`.
173
- - `createConnectOnboardingLink` uses the v2 `accountLinks` resource.
174
- - `createCheckout` sets `transfer_data.destination` and `integration_identifier`,
175
- and accepts an optional `customerId` for the platform-held end user.
176
- - New: `verifySettlement`, `refreshTenantCapabilities`, `SettlementVerification`.
177
- - Checkout metadata `schemaVersion` is `3`.
32
+ - Keep Stripe keys, webhook secrets, catalog pricing, fee amounts, and destination account IDs server-side. The catalog is authoritative; browser inputs must never set price or fee.
33
+ - Verify webhook signatures before responding; preserve the exact raw request body.
34
+ - Destination charges make the platform the losses collector. Configure and review dispute reversal policy (`reverseTransferForDispute`) before production.
35
+ - A settlement without `sessionId` cannot match session-scoped entitlement rules. Alert on a non-zero `withoutSession` drain result.
36
+ - This package is tenant-keyed. It is not for platform B2B subscriptions; use `@portalshq/platform-billing` for those.
178
37
 
179
- `StripePlatformBilling` and `createStripePlatformBilling` moved to
180
- `@portalshq/platform-billing`; they serve the platform's own B2B flows, not
181
- audience monetization.
38
+ ### Lock ordering with interaction credit holds
182
39
 
40
+ `PostgresEntitlementStore.consume` locks grants by ID and then consumes the oldest
41
+ matching grants first. The interaction orchestrator uses the same grant lock order
42
+ for reservations, captures and refunds. Deploy these package updates together when
43
+ sharing a PostgreSQL entitlement database.
@@ -231,11 +231,17 @@ export class PostgresEntitlementStore {
231
231
  // concurrent consumers each see a partial balance, both conclude there is
232
232
  // enough, and both commit — spending the last unit twice. Serializing here
233
233
  // costs throughput on a low-frequency path and buys correctness.
234
- const candidates = await client.query(`SELECT id, scope, remaining FROM monetization_entitlements
234
+ const candidates = await client.query(`SELECT id, scope, remaining,
235
+ to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS created_order
236
+ FROM monetization_entitlements
235
237
  WHERE consumer_id = $1 AND kind = $2 AND remaining > 0
236
238
  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
+ ORDER BY id FOR UPDATE`, [consumerId, kind]);
240
+ // Lock by id, as interaction credit holds/refunds do, then spend oldest first.
241
+ // Keeping lock acquisition separate from spend order avoids cross-module deadlocks.
242
+ const applicable = candidates.rows
243
+ .filter((row) => grantAppliesAt(row.scope, site))
244
+ .sort((a, b) => a.created_order < b.created_order ? -1 : a.created_order > b.created_order ? 1 : 0);
239
245
  const available = applicable.reduce((total, row) => total + Number(row.remaining), 0);
240
246
  if (available < n) {
241
247
  await client.query("ROLLBACK");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portalshq/monetization",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",