@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.
- package/README.md +182 -0
- package/dist/dispatcher.d.ts +105 -0
- package/dist/dispatcher.js +138 -0
- package/dist/entitlements.d.ts +155 -0
- package/dist/entitlements.js +275 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/outbox-dispatcher.d.ts +16 -0
- package/dist/outbox-dispatcher.js +29 -0
- package/dist/portals-billing.d.ts +137 -0
- package/dist/portals-billing.js +498 -0
- package/dist/postgres-store.d.ts +45 -0
- package/dist/postgres-store.js +276 -0
- package/dist/stripe-client.d.ts +9 -0
- package/dist/stripe-client.js +14 -0
- package/dist/stripe-connect-client.d.ts +45 -0
- package/dist/stripe-connect-client.js +75 -0
- package/dist/types.d.ts +225 -0
- package/dist/types.js +1 -0
- package/package.json +43 -0
- package/sql/001_monetization.sql +158 -0
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# @portalshq/monetization
|
|
2
|
+
|
|
3
|
+
Audience-to-creator monetization for Portals. Stripe Connect destination
|
|
4
|
+
charges, entitlements, a ledger, and a transactional outbox.
|
|
5
|
+
|
|
6
|
+
Applications provide authorization, catalog configuration, persistence, and
|
|
7
|
+
URLs. They never provide authoritative prices, fees, or destination account ids
|
|
8
|
+
from a browser.
|
|
9
|
+
|
|
10
|
+
## Credentials
|
|
11
|
+
|
|
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.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
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
|
+
},
|
|
104
|
+
});
|
|
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
|
+
```
|
|
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
|
|
153
|
+
|
|
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.
|
|
160
|
+
|
|
161
|
+
**Accounts v2 replaced the deprecated v1 accounts API**, and destination charges
|
|
162
|
+
were retained (see "Destination charges, and why"):
|
|
163
|
+
|
|
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`.
|
|
178
|
+
|
|
179
|
+
`StripePlatformBilling` and `createStripePlatformBilling` moved to
|
|
180
|
+
`@portalshq/platform-billing`; they serve the platform's own B2B flows, not
|
|
181
|
+
audience monetization.
|
|
182
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { BillingOutboxEvent, BillingStore, IdGenerator } from "./types.js";
|
|
2
|
+
import { type EntitlementStore } from "./entitlements.js";
|
|
3
|
+
/** What entitlement handling did to one outbox event. */
|
|
4
|
+
export interface SettlementApplication {
|
|
5
|
+
granted: number;
|
|
6
|
+
revoked: number;
|
|
7
|
+
/**
|
|
8
|
+
* Set when the settlement carried no `sessionId`. A purchase with no session
|
|
9
|
+
* cannot match a session-scoped rule, so a non-zero count here means either
|
|
10
|
+
* the integration passes no `sessionId` at checkout, or sessions are not in
|
|
11
|
+
* use. Both are worth seeing rather than inferring.
|
|
12
|
+
*/
|
|
13
|
+
withoutSession: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface MonetizationDispatcherOptions {
|
|
16
|
+
store: BillingStore;
|
|
17
|
+
/**
|
|
18
|
+
* Required. Entitlement handling is not optional: a paid purchase that never
|
|
19
|
+
* grants anything is a silent failure, not a configuration choice. Supply the
|
|
20
|
+
* store even if you have no rules yet — an empty rule set is a valid state and
|
|
21
|
+
* is recorded as such.
|
|
22
|
+
*/
|
|
23
|
+
entitlements: EntitlementStore;
|
|
24
|
+
ids: IdGenerator;
|
|
25
|
+
/**
|
|
26
|
+
* Your own side effects, for example publishing a settled superchat to the
|
|
27
|
+
* live chat. Runs after entitlement handling, inside the same delivery, so a
|
|
28
|
+
* failure marks the event undelivered and it is retried. Never required.
|
|
29
|
+
*/
|
|
30
|
+
onEvent?: (event: BillingOutboxEvent, applied: SettlementApplication) => void | Promise<void>;
|
|
31
|
+
now?: () => Date;
|
|
32
|
+
batchSize?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Delivery attempts before an event is parked for an operator. Defaults to 10.
|
|
35
|
+
*
|
|
36
|
+
* A permanently-failing event would otherwise be re-claimed on every drain
|
|
37
|
+
* forever, and because `claimOutbox` orders by `created_at` it would occupy
|
|
38
|
+
* the first slots of every batch — so a few poison events degrade throughput
|
|
39
|
+
* and eventually stall the queue. Parking bounds that.
|
|
40
|
+
*
|
|
41
|
+
* Parking is not deletion: the row is retained with its payload and error, and
|
|
42
|
+
* `requeueDeadOutbox` replays it once the cause is fixed. Set this to a large
|
|
43
|
+
* number to prefer blocking-and-alerting over parking.
|
|
44
|
+
*/
|
|
45
|
+
maxAttempts?: number;
|
|
46
|
+
logger?: (message: string, meta: Record<string, unknown>) => void;
|
|
47
|
+
}
|
|
48
|
+
export interface DrainResult {
|
|
49
|
+
delivered: number;
|
|
50
|
+
failed: number;
|
|
51
|
+
granted: number;
|
|
52
|
+
revoked: number;
|
|
53
|
+
/** Events parked this pass for an operator. Retained, not dropped. */
|
|
54
|
+
parked: number;
|
|
55
|
+
/** Events already past the attempt budget when claimed; parked without an attempt. */
|
|
56
|
+
exhausted: number;
|
|
57
|
+
/** Settlements that arrived without a `sessionId`. See `SettlementApplication`. */
|
|
58
|
+
withoutSession: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Outbox delivery with entitlement handling already wired in.
|
|
62
|
+
*
|
|
63
|
+
* This exists so that the correct wiring is the shortest path. Constructing a
|
|
64
|
+
* `BillingOutboxDispatcher` directly and pointing its `publish` at
|
|
65
|
+
* `applySettlementEvent` is possible but is the footgun that motivated this class:
|
|
66
|
+
* nothing failed when it was forgotten, entitlements simply never granted.
|
|
67
|
+
*
|
|
68
|
+
* Entitlement handling here is unconditional. `onEvent` is the optional part.
|
|
69
|
+
*
|
|
70
|
+
* Run `drainOnce` on a schedule. A worker that stops is indistinguishable from a
|
|
71
|
+
* quiet platform, so `isStale` is provided for a health check.
|
|
72
|
+
*/
|
|
73
|
+
export declare class MonetizationDispatcher {
|
|
74
|
+
private readonly options;
|
|
75
|
+
private readonly dispatcher;
|
|
76
|
+
private readonly now;
|
|
77
|
+
private readonly logger?;
|
|
78
|
+
private readonly maxAttempts;
|
|
79
|
+
private lastDrainAtMs;
|
|
80
|
+
private granted;
|
|
81
|
+
private revoked;
|
|
82
|
+
private withoutSession;
|
|
83
|
+
private parked;
|
|
84
|
+
private exhausted;
|
|
85
|
+
constructor(options: MonetizationDispatcherOptions);
|
|
86
|
+
/** @returns a sentinel so an unparseable payload fails delivery and is retried, not silently skipped. */
|
|
87
|
+
private applyToEntitlements;
|
|
88
|
+
drainOnce(): Promise<DrainResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Returns parked events to the queue with a fresh attempt budget. Call this
|
|
91
|
+
* once the cause is fixed — a corrected rule, a repaired payload, a restored
|
|
92
|
+
* dependency. Without it, parking would be silent data loss.
|
|
93
|
+
*/
|
|
94
|
+
requeueParked(limit?: number): Promise<number>;
|
|
95
|
+
/** Parked events awaiting an operator, with the error that parked them. */
|
|
96
|
+
listParked(limit?: number): Promise<readonly BillingOutboxEvent[]>;
|
|
97
|
+
/** ISO timestamp of the last drain, or undefined if it has never run. */
|
|
98
|
+
lastDrainAt(): string | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* True when the dispatcher has never run, or has not run within `maxAgeMs`
|
|
101
|
+
* (default 10 minutes). A stopped worker and a quiet platform look identical
|
|
102
|
+
* from the outside; this is how they are told apart.
|
|
103
|
+
*/
|
|
104
|
+
isStale(maxAgeMs?: number): boolean;
|
|
105
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { BillingOutboxDispatcher } from "./outbox-dispatcher.js";
|
|
2
|
+
import { applySettlementEvent, readSettlementFacts, } from "./entitlements.js";
|
|
3
|
+
/**
|
|
4
|
+
* Outbox delivery with entitlement handling already wired in.
|
|
5
|
+
*
|
|
6
|
+
* This exists so that the correct wiring is the shortest path. Constructing a
|
|
7
|
+
* `BillingOutboxDispatcher` directly and pointing its `publish` at
|
|
8
|
+
* `applySettlementEvent` is possible but is the footgun that motivated this class:
|
|
9
|
+
* nothing failed when it was forgotten, entitlements simply never granted.
|
|
10
|
+
*
|
|
11
|
+
* Entitlement handling here is unconditional. `onEvent` is the optional part.
|
|
12
|
+
*
|
|
13
|
+
* Run `drainOnce` on a schedule. A worker that stops is indistinguishable from a
|
|
14
|
+
* quiet platform, so `isStale` is provided for a health check.
|
|
15
|
+
*/
|
|
16
|
+
export class MonetizationDispatcher {
|
|
17
|
+
options;
|
|
18
|
+
dispatcher;
|
|
19
|
+
now;
|
|
20
|
+
logger;
|
|
21
|
+
maxAttempts;
|
|
22
|
+
lastDrainAtMs;
|
|
23
|
+
granted = 0;
|
|
24
|
+
revoked = 0;
|
|
25
|
+
withoutSession = 0;
|
|
26
|
+
parked = 0;
|
|
27
|
+
exhausted = 0;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
this.now = options.now ?? (() => new Date());
|
|
31
|
+
this.logger = options.logger;
|
|
32
|
+
this.maxAttempts = options.maxAttempts ?? 10;
|
|
33
|
+
if (!Number.isInteger(this.maxAttempts) || this.maxAttempts < 1) {
|
|
34
|
+
throw new TypeError("maxAttempts must be a positive integer");
|
|
35
|
+
}
|
|
36
|
+
this.dispatcher = new BillingOutboxDispatcher({
|
|
37
|
+
store: options.store,
|
|
38
|
+
batchSize: options.batchSize,
|
|
39
|
+
// An event that is already past its budget is parked before any attempt,
|
|
40
|
+
// so it cannot consume a delivery slot and then fail again.
|
|
41
|
+
publish: async (event) => {
|
|
42
|
+
if (event.attempts > this.maxAttempts) {
|
|
43
|
+
await options.store.markOutboxDead(event.id, `exceeded maxAttempts (${this.maxAttempts})`);
|
|
44
|
+
this.exhausted += 1;
|
|
45
|
+
this.logger?.("outbox event parked: attempts exhausted", {
|
|
46
|
+
eventId: event.id, type: event.type, attempts: event.attempts,
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const applied = await this.applyToEntitlements(event);
|
|
51
|
+
this.granted += applied.granted;
|
|
52
|
+
this.revoked += applied.revoked;
|
|
53
|
+
if (applied.withoutSession)
|
|
54
|
+
this.withoutSession += 1;
|
|
55
|
+
if (this.options.onEvent)
|
|
56
|
+
await this.options.onEvent(event, applied);
|
|
57
|
+
if (applied.withoutSession) {
|
|
58
|
+
this.logger?.("settlement arrived without a sessionId", {
|
|
59
|
+
eventId: event.id, purchaseId: event.payload.purchaseId, type: event.type,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** @returns a sentinel so an unparseable payload fails delivery and is retried, not silently skipped. */
|
|
66
|
+
async applyToEntitlements(event) {
|
|
67
|
+
const base = { granted: 0, revoked: 0, withoutSession: false };
|
|
68
|
+
if (event.type !== "billing.purchase_settled"
|
|
69
|
+
&& event.type !== "billing.purchase_refunded"
|
|
70
|
+
&& event.type !== "billing.purchase_disputed") {
|
|
71
|
+
return base;
|
|
72
|
+
}
|
|
73
|
+
// Surface a malformed payload as a delivery failure rather than letting it
|
|
74
|
+
// pass: an event that cannot be evaluated must be retried and alerted, not
|
|
75
|
+
// marked delivered.
|
|
76
|
+
const withoutSession = event.type === "billing.purchase_settled"
|
|
77
|
+
&& readSettlementFacts(event.payload).sessionId === null;
|
|
78
|
+
const result = await applySettlementEvent(this.options.entitlements, event, this.options.ids, this.now().toISOString());
|
|
79
|
+
return { ...base, ...result, withoutSession };
|
|
80
|
+
}
|
|
81
|
+
async drainOnce() {
|
|
82
|
+
// Counters are per-drain, not cumulative: a caller reading the result wants
|
|
83
|
+
// what this pass did, not a running total.
|
|
84
|
+
this.granted = 0;
|
|
85
|
+
this.revoked = 0;
|
|
86
|
+
this.withoutSession = 0;
|
|
87
|
+
this.parked = 0;
|
|
88
|
+
this.exhausted = 0;
|
|
89
|
+
const result = await this.dispatcher.drainOnce();
|
|
90
|
+
this.lastDrainAtMs = this.now().getTime();
|
|
91
|
+
if (this.parked > 0 || this.exhausted > 0) {
|
|
92
|
+
this.logger?.("outbox events parked for an operator", {
|
|
93
|
+
parked: this.parked, exhausted: this.exhausted, maxAttempts: this.maxAttempts,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (result.failed > 0) {
|
|
97
|
+
this.logger?.("outbox delivery had failures", { failed: result.failed, delivered: result.delivered });
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
delivered: result.delivered,
|
|
101
|
+
failed: result.failed,
|
|
102
|
+
granted: this.granted,
|
|
103
|
+
revoked: this.revoked,
|
|
104
|
+
parked: this.parked,
|
|
105
|
+
exhausted: this.exhausted,
|
|
106
|
+
withoutSession: this.withoutSession,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Returns parked events to the queue with a fresh attempt budget. Call this
|
|
111
|
+
* once the cause is fixed — a corrected rule, a repaired payload, a restored
|
|
112
|
+
* dependency. Without it, parking would be silent data loss.
|
|
113
|
+
*/
|
|
114
|
+
async requeueParked(limit = 100) {
|
|
115
|
+
const requeued = await this.options.store.requeueDeadOutbox(limit);
|
|
116
|
+
if (requeued > 0)
|
|
117
|
+
this.logger?.("requeued parked outbox events", { requeued });
|
|
118
|
+
return requeued;
|
|
119
|
+
}
|
|
120
|
+
/** Parked events awaiting an operator, with the error that parked them. */
|
|
121
|
+
async listParked(limit = 100) {
|
|
122
|
+
return this.options.store.listDeadOutbox(limit);
|
|
123
|
+
}
|
|
124
|
+
/** ISO timestamp of the last drain, or undefined if it has never run. */
|
|
125
|
+
lastDrainAt() {
|
|
126
|
+
return this.lastDrainAtMs === undefined ? undefined : new Date(this.lastDrainAtMs).toISOString();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* True when the dispatcher has never run, or has not run within `maxAgeMs`
|
|
130
|
+
* (default 10 minutes). A stopped worker and a quiet platform look identical
|
|
131
|
+
* from the outside; this is how they are told apart.
|
|
132
|
+
*/
|
|
133
|
+
isStale(maxAgeMs = 10 * 60_000) {
|
|
134
|
+
if (this.lastDrainAtMs === undefined)
|
|
135
|
+
return true;
|
|
136
|
+
return this.now().getTime() - this.lastDrainAtMs > maxAgeMs;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { IdGenerator, PurchaseSettledFacts } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tenant-configured entitlements.
|
|
4
|
+
*
|
|
5
|
+
* An entitlement is a grant made to a paying end user ("consumer"), scoped by
|
|
6
|
+
* a set of dimensions. A rule is owned and configured by the tenant, not the
|
|
7
|
+
* platform: the channel owner decides what a purchase unlocks and for how long.
|
|
8
|
+
*
|
|
9
|
+
* The scope is an arbitrary subset of dimensions, and **the dimensions present
|
|
10
|
+
* are both the matcher and the lifetime**:
|
|
11
|
+
*
|
|
12
|
+
* { channelId } → valid for the life of the channel
|
|
13
|
+
* { channelId, sessionId } → valid only during that session
|
|
14
|
+
* { channelId, date } → valid only for purchases settling that day,
|
|
15
|
+
* and expires at the end of it
|
|
16
|
+
* { channelId, sessionId, date } → the narrowest form
|
|
17
|
+
*
|
|
18
|
+
* Every dimension a rule names must match for the grant to be made. A grant
|
|
19
|
+
* is always made to a consumer, so a rule never grants "the channel" anything.
|
|
20
|
+
*
|
|
21
|
+
* Grants are balances, not flags. A rule that nothing ever consumes stays
|
|
22
|
+
* permanently available, which is how a standing membership is expressed, and
|
|
23
|
+
* a rule the application consumes one unit at a time is how a single-use
|
|
24
|
+
* unlock is expressed. Both use the same primitive.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* A scope dimension whose value is this token binds to the matching fact of the
|
|
28
|
+
* purchase at grant time, instead of being compared literally.
|
|
29
|
+
*
|
|
30
|
+
* A superchat grants influence over the story turn it was bought for, so its
|
|
31
|
+
* rule must be scoped to "whichever session this purchase happened in" — a
|
|
32
|
+
* fixed session id could never match. `"$self"` says bind; the grant then carries
|
|
33
|
+
* the concrete value and normal equality applies at consumption.
|
|
34
|
+
*/
|
|
35
|
+
export declare const SELF_BIND = "$self";
|
|
36
|
+
export interface EntitlementScope {
|
|
37
|
+
channelId?: string;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
/** ISO `yyyy-mm-dd` (UTC). Restricts the rule to purchases settling that day. */
|
|
40
|
+
date?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Replaces self-binding dimensions with the concrete facts, producing the scope
|
|
44
|
+
* the grant is actually scoped to. A binding dimension with no corresponding
|
|
45
|
+
* fact is dropped, so a purchase with no session yields an unscoped grant rather
|
|
46
|
+
* than a permanently unsatisfiable one.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveScope(scope: EntitlementScope, facts: PurchaseSettledFacts): EntitlementScope;
|
|
49
|
+
export type EntitlementCondition = {
|
|
50
|
+
fact: "purchaseKind";
|
|
51
|
+
equals: string;
|
|
52
|
+
} | {
|
|
53
|
+
fact: "productKey";
|
|
54
|
+
equals: string;
|
|
55
|
+
} | {
|
|
56
|
+
fact: "amount";
|
|
57
|
+
gte: number;
|
|
58
|
+
};
|
|
59
|
+
export interface EntitlementRule {
|
|
60
|
+
id: string;
|
|
61
|
+
tenantId: string;
|
|
62
|
+
/** What is granted, e.g. "prompt_influence" or "founding_member". */
|
|
63
|
+
kind: string;
|
|
64
|
+
scope: EntitlementScope;
|
|
65
|
+
/** All must match. Empty means the rule matches any settled purchase. */
|
|
66
|
+
conditions: readonly EntitlementCondition[];
|
|
67
|
+
/** Units granted per qualifying purchase. Must be >= 1. */
|
|
68
|
+
quantity: number;
|
|
69
|
+
createdAt: string;
|
|
70
|
+
}
|
|
71
|
+
export interface EntitlementGrant {
|
|
72
|
+
id: string;
|
|
73
|
+
ruleId: string;
|
|
74
|
+
tenantId: string;
|
|
75
|
+
/** The end user who paid. */
|
|
76
|
+
consumerId: string;
|
|
77
|
+
kind: string;
|
|
78
|
+
scope: EntitlementScope;
|
|
79
|
+
quantity: number;
|
|
80
|
+
remaining: number;
|
|
81
|
+
/** The purchase that created this grant, so a refund can revoke it. */
|
|
82
|
+
purchaseId: string;
|
|
83
|
+
/** End of `scope.date` when the rule named one, else undefined. */
|
|
84
|
+
expiresAt?: string;
|
|
85
|
+
createdAt: string;
|
|
86
|
+
updatedAt: string;
|
|
87
|
+
}
|
|
88
|
+
export interface EntitlementStore {
|
|
89
|
+
saveRule(rule: EntitlementRule): Promise<void>;
|
|
90
|
+
getRule(id: string): Promise<EntitlementRule | undefined>;
|
|
91
|
+
listRules(tenantId: string): Promise<readonly EntitlementRule[]>;
|
|
92
|
+
/** Idempotent per `(ruleId, purchaseId)` so a redelivered outbox event cannot double-grant. */
|
|
93
|
+
grant(grants: readonly EntitlementGrant[]): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Spend `n` units at a site. Consumes the oldest applicable grant with
|
|
96
|
+
* units remaining. Returns false when nothing is available.
|
|
97
|
+
*/
|
|
98
|
+
consume(consumerId: string, kind: string, site: EntitlementScope, n: number): Promise<boolean>;
|
|
99
|
+
/** Units available at a site. */
|
|
100
|
+
remaining(consumerId: string, kind: string, site: EntitlementScope): Promise<number>;
|
|
101
|
+
/** Revoke everything a purchase created, for refunds and disputes. */
|
|
102
|
+
revokeForPurchase(purchaseId: string): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
/** End of the UTC day named by `scope.date`, or undefined when the rule is not date-bounded. */
|
|
105
|
+
export declare function scopeExpiresAt(scope: EntitlementScope): string | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* A grant is usable at a site when every dimension it names matches the site's
|
|
108
|
+
* facts. A channel-scoped grant is therefore valid anywhere in that channel,
|
|
109
|
+
* and a session-scoped grant only inside that session.
|
|
110
|
+
*/
|
|
111
|
+
export declare function grantAppliesAt(grantScope: EntitlementScope, site: EntitlementScope): boolean;
|
|
112
|
+
/** Every dimension named by the rule and every condition must match. */
|
|
113
|
+
export declare function ruleMatches(rule: EntitlementRule, facts: PurchaseSettledFacts): boolean;
|
|
114
|
+
/** Grants a settled purchase should create, one per matching rule for that tenant. */
|
|
115
|
+
export declare function grantsForSettlement(rules: readonly EntitlementRule[], facts: PurchaseSettledFacts, ids: IdGenerator, now: string): EntitlementGrant[];
|
|
116
|
+
/** Rejects rules the engine cannot honour, so a bad rule fails at configuration time. */
|
|
117
|
+
export declare function validateRule(rule: EntitlementRule): void;
|
|
118
|
+
/** Reference store for tests and single-process deployments. */
|
|
119
|
+
export declare class InMemoryEntitlementStore implements EntitlementStore {
|
|
120
|
+
private readonly rules;
|
|
121
|
+
private readonly grants;
|
|
122
|
+
private readonly granted;
|
|
123
|
+
saveRule(rule: EntitlementRule): Promise<void>;
|
|
124
|
+
getRule(id: string): Promise<EntitlementRule | undefined>;
|
|
125
|
+
listRules(tenantId: string): Promise<readonly EntitlementRule[]>;
|
|
126
|
+
grant(grants: readonly EntitlementGrant[]): Promise<void>;
|
|
127
|
+
consume(consumerId: string, kind: string, site: EntitlementScope, n: number): Promise<boolean>;
|
|
128
|
+
remaining(consumerId: string, kind: string, site: EntitlementScope): Promise<number>;
|
|
129
|
+
revokeForPurchase(purchaseId: string): Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Applies a settlement outbox event to the entitlement store.
|
|
133
|
+
*
|
|
134
|
+
* This is the piece that makes entitlements actually run. `BillingOutboxDispatcher`
|
|
135
|
+
* delivers each committed event to a `publish` callback; wiring that callback to
|
|
136
|
+
* this function is what grants on settlement and revokes on refund or dispute.
|
|
137
|
+
*
|
|
138
|
+
* Returns the grants created, or the number revoked, so a caller can log or
|
|
139
|
+
* assert. Safe to call twice for the same event: grants are keyed
|
|
140
|
+
* `(ruleId, purchaseId)` and revocation is idempotent.
|
|
141
|
+
*/
|
|
142
|
+
export declare function applySettlementEvent(store: EntitlementStore, event: {
|
|
143
|
+
type: string;
|
|
144
|
+
payload: Readonly<Record<string, unknown>>;
|
|
145
|
+
}, ids: IdGenerator, now: string): Promise<{
|
|
146
|
+
granted: number;
|
|
147
|
+
revoked: number;
|
|
148
|
+
}>;
|
|
149
|
+
/**
|
|
150
|
+
* Narrows an outbox payload to settlement facts, rejecting a payload that cannot
|
|
151
|
+
* support rule evaluation. The payload carries `schemaVersion: "2"` in its
|
|
152
|
+
* checkout metadata; a payload without the newer fields predates entitlements
|
|
153
|
+
* and is rejected rather than silently matched against missing values.
|
|
154
|
+
*/
|
|
155
|
+
export declare function readSettlementFacts(payload: Readonly<Record<string, unknown>>): PurchaseSettledFacts;
|