@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 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;