@portalshq/monetization 0.2.0 → 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 +41 -164
- package/dist/entitlements.d.ts +50 -0
- package/dist/entitlements.js +101 -0
- package/dist/postgres-store.d.ts +12 -1
- package/dist/postgres-store.js +94 -4
- package/package.json +1 -1
- package/sql/002_credit_debits.sql +12 -0
- package/sql/003_credit_grants.sql +19 -0
package/README.md
CHANGED
|
@@ -1,182 +1,59 @@
|
|
|
1
1
|
# @portalshq/monetization
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
charges, entitlements, a ledger, and a transactional outbox.
|
|
3
|
+
**Reusable agent module:** [integration guide](../../docs/package-agents/monetization.md)
|
|
5
4
|
|
|
6
|
-
|
|
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
|
-
|
|
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.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
15
21
|
|
|
16
|
-
|
|
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
|
|
22
|
+
## Required setup
|
|
49
23
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
24
|
+
1. Apply `sql/001_monetization.sql` to PostgreSQL.
|
|
25
|
+
2. Create `PostgresBillingStore` and `PostgresEntitlementStore` from the same pool.
|
|
26
|
+
3. Inject a server-side Stripe platform client, server-owned `BillingCatalog`, and `IdGenerator` into `Monetization`.
|
|
27
|
+
4. Create/refresh the tenant profile and complete Stripe onboarding before checkout.
|
|
28
|
+
5. Route the **raw, unparsed** Stripe webhook body to `handleWebhook`, then run `MonetizationDispatcher.drainOnce()` from a worker.
|
|
53
29
|
|
|
54
30
|
```ts
|
|
31
|
+
const monetization = new Monetization({ stripe, store, catalog, ids });
|
|
55
32
|
const { url } = await monetization.createCheckout({
|
|
56
|
-
purchaseId, tenantId, channelId,
|
|
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
|
-
},
|
|
33
|
+
purchaseId, tenantId, channelId, buyerId, productKey, successUrl, cancelUrl,
|
|
104
34
|
});
|
|
105
|
-
|
|
106
|
-
// on a schedule — a worker, a cron, or a loop
|
|
107
|
-
const { delivered, failed, granted, revoked, withoutSession } = await dispatcher.drainOnce();
|
|
108
35
|
```
|
|
109
36
|
|
|
110
|
-
|
|
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
|
-
```
|
|
139
|
-
|
|
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
|
|
37
|
+
## Core API
|
|
153
38
|
|
|
154
|
-
|
|
155
|
-
`
|
|
156
|
-
`
|
|
157
|
-
|
|
158
|
-
`
|
|
159
|
-
|
|
39
|
+
- `ensureTenantProfile()`, `refreshTenantCapabilities()`, `createConnectOnboardingLink()`: provision and assess the tenant's Stripe Connect recipient account.
|
|
40
|
+
- `ensureBillingCustomer()`, `createCheckout()`: create platform-held customer/payment flows. `purchaseId` is the idempotency key and cannot be reused for different purchase facts.
|
|
41
|
+
- `handleWebhook(rawBody, signature, secret)`: atomically applies Stripe events to purchases, ledger, and outbox. Use `refundPurchase()` for refunds.
|
|
42
|
+
- `MonetizationDispatcher`: drains the outbox, applies entitlements, and invokes optional application side effects. Monitor `isStale()`, `listParked()`, and requeue repaired events with `requeueParked()`.
|
|
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`.
|
|
160
45
|
|
|
161
|
-
|
|
162
|
-
were retained (see "Destination charges, and why"):
|
|
46
|
+
## Safety and operations
|
|
163
47
|
|
|
164
|
-
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
-
|
|
168
|
-
|
|
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`.
|
|
48
|
+
- 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.
|
|
49
|
+
- Verify webhook signatures before responding; preserve the exact raw request body.
|
|
50
|
+
- Destination charges make the platform the losses collector. Configure and review dispute reversal policy (`reverseTransferForDispute`) before production.
|
|
51
|
+
- A settlement without `sessionId` cannot match session-scoped entitlement rules. Alert on a non-zero `withoutSession` drain result.
|
|
52
|
+
- This package is tenant-keyed. It is not for platform B2B subscriptions; use `@portalshq/platform-billing` for those.
|
|
178
53
|
|
|
179
|
-
|
|
180
|
-
`@portalshq/platform-billing`; they serve the platform's own B2B flows, not
|
|
181
|
-
audience monetization.
|
|
54
|
+
### Lock ordering with interaction credit holds
|
|
182
55
|
|
|
56
|
+
`PostgresEntitlementStore.consume` locks grants by ID and then consumes the oldest
|
|
57
|
+
matching grants first. The interaction orchestrator uses the same grant lock order
|
|
58
|
+
for reservations, captures and refunds. Deploy these package updates together when
|
|
59
|
+
sharing a PostgreSQL entitlement database.
|
package/dist/entitlements.d.ts
CHANGED
|
@@ -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>;
|
package/dist/entitlements.js
CHANGED
|
@@ -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");
|
package/dist/postgres-store.d.ts
CHANGED
|
@@ -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>;
|
package/dist/postgres-store.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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");
|
|
@@ -231,11 +315,17 @@ export class PostgresEntitlementStore {
|
|
|
231
315
|
// concurrent consumers each see a partial balance, both conclude there is
|
|
232
316
|
// enough, and both commit — spending the last unit twice. Serializing here
|
|
233
317
|
// costs throughput on a low-frequency path and buys correctness.
|
|
234
|
-
const candidates = await client.query(`SELECT id, scope, remaining
|
|
318
|
+
const candidates = await client.query(`SELECT id, scope, remaining,
|
|
319
|
+
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS created_order
|
|
320
|
+
FROM monetization_entitlements
|
|
235
321
|
WHERE consumer_id = $1 AND kind = $2 AND remaining > 0
|
|
236
322
|
AND (expires_at IS NULL OR expires_at >= now())
|
|
237
|
-
ORDER BY
|
|
238
|
-
|
|
323
|
+
ORDER BY id FOR UPDATE`, [consumerId, kind]);
|
|
324
|
+
// Lock by id, as interaction credit holds/refunds do, then spend oldest first.
|
|
325
|
+
// Keeping lock acquisition separate from spend order avoids cross-module deadlocks.
|
|
326
|
+
const applicable = candidates.rows
|
|
327
|
+
.filter((row) => grantAppliesAt(row.scope, site))
|
|
328
|
+
.sort((a, b) => a.created_order < b.created_order ? -1 : a.created_order > b.created_order ? 1 : 0);
|
|
239
329
|
const available = applicable.reduce((total, row) => total + Number(row.remaining), 0);
|
|
240
330
|
if (available < n) {
|
|
241
331
|
await client.query("ROLLBACK");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@portalshq/monetization",
|
|
3
|
-
"version": "0.
|
|
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
|
+
);
|