@happyvertical/smrt-subscriptions 0.60.14 → 0.60.16
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/AGENTS.md +152 -0
- package/README.md +206 -0
- package/dist/billing/apply.d.ts +52 -0
- package/dist/billing/apply.d.ts.map +1 -0
- package/dist/billing/checkout-sessions.d.ts +99 -0
- package/dist/billing/checkout-sessions.d.ts.map +1 -0
- package/dist/billing/config.d.ts +64 -0
- package/dist/billing/config.d.ts.map +1 -0
- package/dist/billing/events.d.ts +49 -0
- package/dist/billing/events.d.ts.map +1 -0
- package/dist/billing/host.d.ts +3 -0
- package/dist/billing/host.d.ts.map +1 -0
- package/dist/billing/index.d.ts +4 -0
- package/dist/billing/index.d.ts.map +1 -0
- package/dist/billing/plans.d.ts +45 -0
- package/dist/billing/plans.d.ts.map +1 -0
- package/dist/billing/signing.d.ts +24 -0
- package/dist/billing/signing.d.ts.map +1 -0
- package/dist/billing/types.d.ts +265 -0
- package/dist/billing/types.d.ts.map +1 -0
- package/dist/collections/TenantUsageMetricCollection.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3230 -1123
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +314 -4
- package/dist/models/BillingCheckoutSession.d.ts +39 -0
- package/dist/models/BillingCheckoutSession.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/recipes.d.ts +1 -1
- package/dist/services/entitlement-changes.d.ts +115 -0
- package/dist/services/entitlement-changes.d.ts.map +1 -0
- package/dist/services/hosted-ai-spend.d.ts +182 -0
- package/dist/services/hosted-ai-spend.d.ts.map +1 -0
- package/dist/services/index.d.ts +3 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/subscription-feature-source.d.ts +89 -0
- package/dist/services/subscription-feature-source.d.ts.map +1 -0
- package/dist/services/subscription-resolver.d.ts +34 -1
- package/dist/services/subscription-resolver.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +103 -8
- package/dist/smrt-knowledge.okf.md +6 -2
- package/dist/vault-account/actions.d.ts +24 -0
- package/dist/vault-account/actions.d.ts.map +1 -0
- package/dist/vault-account/errors.d.ts +20 -0
- package/dist/vault-account/errors.d.ts.map +1 -0
- package/dist/vault-account/index.d.ts +5 -0
- package/dist/vault-account/index.d.ts.map +1 -0
- package/dist/vault-account/relay-admin-client.d.ts +10 -0
- package/dist/vault-account/relay-admin-client.d.ts.map +1 -0
- package/dist/vault-account/types.d.ts +115 -0
- package/dist/vault-account/types.d.ts.map +1 -0
- package/package.json +9 -6
package/AGENTS.md
CHANGED
|
@@ -86,6 +86,51 @@ that reverted to DECIMAL passes every SQLite suite.
|
|
|
86
86
|
`retainsPaidPeriod`, derived on save from an entitled pre-cancel row.
|
|
87
87
|
- **Recipe `subscriptions.plans`** (`src/recipes.ts`, help in `src/plans.recipe.md`): lists plans, subscriptions, plan versions (read-only, written by `plan-versioning.ts`) and charges (read-only, written by the usage service) for the planner and other catalog hosts. Fields it shows carry `@field({ description })` in plain words, money fields saying "in cents"; keep them when editing the models. Reseller models and pricing rules stay out of it.
|
|
88
88
|
|
|
89
|
+
## Hosted AI spend caps (#4760)
|
|
90
|
+
|
|
91
|
+
`src/services/hosted-ai-spend.ts`. Plans cap platform-paid (`source:
|
|
92
|
+
hosted`) inference with ordinary `PlanThreshold`s on two reserved keys, so
|
|
93
|
+
window, amount and enforcement come from the plan (pinned version included):
|
|
94
|
+
`AI_HOSTED_SPEND_METRIC` (`ai.hosted.spend_micros`, the tenant) and
|
|
95
|
+
`AI_HOSTED_USER_SPEND_METRIC` (`ai.hosted.user_spend_micros`, each user).
|
|
96
|
+
|
|
97
|
+
- `limit` is **integer micro-dollars** (1 USD = 1,000,000; the money rule at
|
|
98
|
+
per-call precision). `block` blocks at or over the cap, `warn` warns,
|
|
99
|
+
`observe` never affects a call; `warningRatio` (default 0.8) warns early;
|
|
100
|
+
a 0 cap blocks every hosted call. A malformed hosted cap (fractional or
|
|
101
|
+
negative limit, unknown window/enforcement) throws
|
|
102
|
+
`HostedAiSpendPolicyError` (the gate fails closed); it never reads as "no
|
|
103
|
+
cap". `resolvePlanThresholds()` returns thresholds unfiltered for this
|
|
104
|
+
reason (the models' `getThresholds()` drop invalid entries) and throws on
|
|
105
|
+
non-array JSON.
|
|
106
|
+
- Spend: `createHostedAiSpendReader(db)` sums `_smrt_ai_usage.estimated_cost`
|
|
107
|
+
(USD, rounded to micro-dollars) by `tenant_id` and `created_at` in the
|
|
108
|
+
cap's window for rows whose `tags` hold `"source":"hosted"` (and
|
|
109
|
+
`"userId":"<id>"` for the per-user cap), as exact, case-sensitive JSON
|
|
110
|
+
fragments (`hostedAiTagFragment()`; `strpos` on the PostgreSQL dialect,
|
|
111
|
+
`instr` elsewhere, never `LIKE`, which folds ASCII case on SQLite and
|
|
112
|
+
would merge `Alice` and `alice`); errors only over-count.
|
|
113
|
+
`operation: 'blocked'` rows and external-agent measurements never count;
|
|
114
|
+
uncosted rows add nothing (give hosted models a cost rate). The `userId`
|
|
115
|
+
tag exists only while the hosted gate's ambient-user resolver is
|
|
116
|
+
registered.
|
|
117
|
+
- `resetsAt`: the calendar window's end; for a blocked `rolling` cap (30
|
|
118
|
+
days), `HostedAiSpendReader.liftsAt()`: one window-function query (newest
|
|
119
|
+
first running `SUM(estimated_cost)`, rounded as `summarize()` rounds)
|
|
120
|
+
returns the row whose expiry first brings the total below the cap, plus
|
|
121
|
+
30 days (null for a 0 cap). A blocked decision names the blocking cap that
|
|
122
|
+
lifts last.
|
|
123
|
+
- `HostedAiSpendEvaluator` (`create({ db })`): plan thresholds via
|
|
124
|
+
`SubscriptionResolver.resolvePlanThresholds()` (entitled terms, no usage
|
|
125
|
+
meters; memoized per tenant for `termsTtlMs`, 30 s, failures evicted,
|
|
126
|
+
`invalidate()`), spend read fresh per call; per-user caps only with a
|
|
127
|
+
user. Errors propagate. smrt-inference's `./gate` consumes it.
|
|
128
|
+
- `resolveEntitlements()` / the tenant usage meter report these keys with
|
|
129
|
+
quantity 0: only the evaluator meters them. The meter's AI summary (and so
|
|
130
|
+
`ai.requests`) skips `operation: 'blocked'` rows. `SpendingPolicy` rows stay the
|
|
131
|
+
billed-charge (cents) policy; hosted AI caps are plan terms because the
|
|
132
|
+
issue's window and amount come from the plan.
|
|
133
|
+
|
|
89
134
|
## Reseller billing (#3059)
|
|
90
135
|
|
|
91
136
|
- Billing ownership is consumed from `smrt-tenancy`'s
|
|
@@ -116,6 +161,84 @@ that reverted to DECIMAL passes every SQLite suite.
|
|
|
116
161
|
it `currentShortfall()`, so a hook that charges asynchronously can see a
|
|
117
162
|
concurrent top-up (smrt-commerce's `autoTopUpHook()`, #3139).
|
|
118
163
|
|
|
164
|
+
## Billing host glue (#4786)
|
|
165
|
+
|
|
166
|
+
`src/billing/` is the reusable Stripe checkout, signed idempotent webhook and
|
|
167
|
+
portal wiring (`createBillingHost()`, `readBillingEnv()`); the recipe and
|
|
168
|
+
environment names are in the README. Invariants:
|
|
169
|
+
|
|
170
|
+
- **No direct Stripe calls.** The provider is the structural
|
|
171
|
+
`BillingProviderPort`; `StripeAccountingProvider` from
|
|
172
|
+
`@happyvertical/accounting` satisfies it (a test pins that). A missing SDK
|
|
173
|
+
capability is fixed in the SDK first. `@happyvertical/accounting` is a
|
|
174
|
+
devDependency only (tests run the real provider over a fake `fetch`).
|
|
175
|
+
- **Webhook order is load-bearing:** size cap, signature header, own timestamp
|
|
176
|
+
tolerance, SDK `verify` on the raw body, parse, handled-type filter,
|
|
177
|
+
completed-claim pre-check, then (checkout only) the outside-transaction
|
|
178
|
+
provider read-back, then `runOnce` claiming the event id *and* applying it.
|
|
179
|
+
All DB access inside that transaction must use the `tx` handle; a read on the
|
|
180
|
+
base handle deadlocks single-connection SQLite. Never claim before applying.
|
|
181
|
+
- **Trust comes from our own state.** Checkout metadata is HMAC-signed
|
|
182
|
+
(`smrt_sig`, `signing.ts`) and an unsigned or foreign-signed session never
|
|
183
|
+
links a tenant or plan.
|
|
184
|
+
- **Checkout is bound to a recorded session (#4814)**, `checkout-sessions.ts`
|
|
185
|
+
and `models/BillingCheckoutSession.ts` (`_smrt_billing_checkout_sessions`,
|
|
186
|
+
no API surface; a root export because the manifest advertises it).
|
|
187
|
+
`createCheckout` records the session id
|
|
188
|
+
before returning the URL and signs `smrt_exp`; the completion (inside the
|
|
189
|
+
`runOnce` transaction, after the PostgreSQL `checkout:<id>` lock) checks, in
|
|
190
|
+
order: record exists, status is not `completed` / `needs_reconciliation`
|
|
191
|
+
(no-op `ignored`), record tenant equals the signed tenant, `smrt_exp` is
|
|
192
|
+
well-formed, event time is not after the signed or recorded expiry. A
|
|
193
|
+
missing, mismatched or expired record never links and never drops: the row
|
|
194
|
+
becomes `needs_reconciliation` (outcome `needs_reconciliation`, error log).
|
|
195
|
+
Only `applied` marks a record `completed`. `reconcileCheckout()` (link) and
|
|
196
|
+
`dismissCheckout()` (terminal `dismissed`, for a payment that is not the
|
|
197
|
+
tenant's) are the only ways out and are operator-only; reconcile never skips
|
|
198
|
+
the apply-time conflict checks. The list shows `customerId`,
|
|
199
|
+
`targetTenantId` and `claimedTenantHasOwnSession`, is paged, and has a count.
|
|
200
|
+
The expiry is deterministic per idempotency window so a repeated click sends
|
|
201
|
+
identical provider parameters. Change the table shape only with owner
|
|
202
|
+
approval. A provider subscription or customer id links to one
|
|
203
|
+
tenant; a conflicting event is ignored with a reason code, not merged.
|
|
204
|
+
Verification accepts the signing secret plus `metadataVerificationSecrets`
|
|
205
|
+
(rotation); a purpose-matching session that fails them logs at error.
|
|
206
|
+
A price the host cannot map that differs from the recorded one is held
|
|
207
|
+
(`incomplete`, plan and recorded price kept, `metadata.stripe.held` set so
|
|
208
|
+
checkout still refuses a second subscription) with an error log, never
|
|
209
|
+
carried over onto the previous plan.
|
|
210
|
+
Ignore (2xx, logged) for security refusals; fail (5xx) for configuration or
|
|
211
|
+
database faults so the provider retries.
|
|
212
|
+
- **Only operator plans are sold by default** (`tenantPlans: 'deny'`); price
|
|
213
|
+
resolution never lets a tenant-owned plan outrank a global one. Checkout
|
|
214
|
+
metadata also signs the price and known customer, and the webhook refuses a
|
|
215
|
+
session whose read-back subscription differs. PostgreSQL events lock the
|
|
216
|
+
subscription and tenant (advisory, sorted) and re-read before deciding.
|
|
217
|
+
- **Order state lives on the row** (`metadata.stripe.lastEventAt`, `terminal`);
|
|
218
|
+
a checkout read-back stamps `max(eventCreated, readAt)` so later-delivered
|
|
219
|
+
older events cannot rewind it. Cancellation caps `currentPeriodEnd` at now.
|
|
220
|
+
- **Plan changes** from the provider use `replacePlanVersion({ policy:
|
|
221
|
+
'authorized', reason: 'stripe:<eventId>' })`; the provider billed the change,
|
|
222
|
+
so a mid-period downgrade is honoured and audited. Do not relax the pinning
|
|
223
|
+
rules for any other caller.
|
|
224
|
+
- **No secrets out.** Responses use fixed messages; logs get ids and reason
|
|
225
|
+
codes only, errors pass through `safeErrorMessage()`. `readBillingEnv` keeps
|
|
226
|
+
secrets non-enumerable. Add a named test per new threat in
|
|
227
|
+
`__tests__/billing-host.test.ts`.
|
|
228
|
+
|
|
229
|
+
## Vault account restart and supersede (#4841)
|
|
230
|
+
|
|
231
|
+
`src/vault-account/`. `supersedeVaultAccount`, `unsupersedeVaultAccount` and
|
|
232
|
+
`provisionRestartAccount` take injected ports (`VaultRelayAdminPort`,
|
|
233
|
+
`VaultAccountDirectory`, an audit sink); no HTTP server or relay is needed.
|
|
234
|
+
Order is load-bearing: owner of the tenant and of both accounts (`not_owner`),
|
|
235
|
+
then `requireRecentAuthentication` from `smrt-users/step-up`
|
|
236
|
+
(`step_up_required`, default 300 s), then the `authorized` audit record, then
|
|
237
|
+
exactly one relay call, then the outcome record. Refusals are audited and never
|
|
238
|
+
reach the relay; a member never learns about the step-up. Audit entries carry
|
|
239
|
+
ids and codes only (no session id, key, token or message text). Import only the
|
|
240
|
+
`@happyvertical/smrt-users/step-up` subpath, never the package root.
|
|
241
|
+
|
|
119
242
|
## Notes
|
|
120
243
|
|
|
121
244
|
- Keep Stripe-specific fields as provider binding metadata. Runtime Stripe API
|
|
@@ -129,3 +252,32 @@ that reverted to DECIMAL passes every SQLite suite.
|
|
|
129
252
|
current subscription and plan.
|
|
130
253
|
- Subscription UI components should stay provider-neutral and receive actions
|
|
131
254
|
from the host app.
|
|
255
|
+
- **Feature grants as flags (#4761).** `SubscriptionFeatureSource` implements
|
|
256
|
+
`smrt-features`' `FeatureGrantSource` structurally (no dependency either
|
|
257
|
+
way). Build one per request (`SubscriptionFeatureSource.create(options)` or
|
|
258
|
+
`new SubscriptionFeatureSource({ resolver })`; it memoizes grants per
|
|
259
|
+
tenant) and pass it as `grantSource`, or register an app-wide one built with
|
|
260
|
+
`memoize: false` through `registerSubscriptionFeatureSource()`, which writes
|
|
261
|
+
the features slot key `FEATURE_GRANT_SOURCE_SLOT_KEY`. It reads
|
|
262
|
+
`SubscriptionResolver.resolveFeatureGrants()`, which shares the
|
|
263
|
+
version-pinning and entitlement rules of `resolveEntitlements()` but never
|
|
264
|
+
evaluates thresholds, so a flag check reads no usage meters. Missing
|
|
265
|
+
subscription tables (`isMissingSubscriptionTableError`) and no entitled
|
|
266
|
+
subscription report no grants silently; every other error propagates.
|
|
267
|
+
`findGrantingPlan()` names the first active plan by `sortOrder` visible in
|
|
268
|
+
the current context.
|
|
269
|
+
- **Entitlement change hook (#4759).** `src/services/entitlement-changes.ts`.
|
|
270
|
+
The host wraps its billing-webhook write in
|
|
271
|
+
`trackEntitlementChange({ tenantId, resolver }, apply)`: it snapshots
|
|
272
|
+
`resolveFeatureGrants()` (keys plus grant `metadata`, no usage reads)
|
|
273
|
+
before and after, diffs `gained` / `lost` / `changed` (metadata, e.g. a
|
|
274
|
+
plan budget) and calls listeners registered with
|
|
275
|
+
`registerEntitlementChangeListener()` (a `Symbol.for` slot) or passed in.
|
|
276
|
+
Listeners run after `apply`; their failures are returned in `failures`,
|
|
277
|
+
never thrown, and never undo the write, so listeners must be idempotent.
|
|
278
|
+
An `apply` error propagates and notifies nobody. A redelivered webhook
|
|
279
|
+
diffs to no change, so a lost reaction is repaired by the consumer's
|
|
280
|
+
reconcile entry point (hosted inference: `createHostedInferenceReconciler()`
|
|
281
|
+
with `snapshotEntitlements()`), run on redelivery or a schedule. Listeners satisfy the
|
|
282
|
+
structural type (smrt-inference's hosted listener), so this package
|
|
283
|
+
depends on none of them.
|
package/README.md
CHANGED
|
@@ -39,6 +39,17 @@ Create one resolver per request or application unit of work and reuse the
|
|
|
39
39
|
loaded `EntitlementResolutionContext` when several checks need the same
|
|
40
40
|
subscription and plan.
|
|
41
41
|
|
|
42
|
+
### Plan grants as feature flags
|
|
43
|
+
|
|
44
|
+
`SubscriptionFeatureSource` feeds a tenant's plan grants into
|
|
45
|
+
`@happyvertical/smrt-features`, so `FeatureResolver` and its `requireFeature()`
|
|
46
|
+
gate answer from the plan. Build one per request with
|
|
47
|
+
`SubscriptionFeatureSource.create(dbOptions)` and pass it as `grantSource`, or
|
|
48
|
+
register an app-wide one (`memoize: false`) with
|
|
49
|
+
`registerSubscriptionFeatureSource()`. With no subscription tables or no
|
|
50
|
+
entitled subscription it reports no grants. A global feature override still
|
|
51
|
+
outranks a grant (an operator stop), and a tenant override outranks both.
|
|
52
|
+
|
|
42
53
|
## Core model
|
|
43
54
|
|
|
44
55
|
- `SubscriptionPlan` is tenant-scoped by `(tenantId, planKey)` and stores
|
|
@@ -350,6 +361,201 @@ keeps its credit ledger (reversible in a system context), and the new parent
|
|
|
350
361
|
creates its own balance. A parent's `retail` cap counts only what the child owes that
|
|
351
362
|
parent.
|
|
352
363
|
|
|
364
|
+
## Billing host glue (Stripe)
|
|
365
|
+
|
|
366
|
+
`createBillingHost()` is the reusable checkout, webhook and customer-portal
|
|
367
|
+
wiring for hosts that sell a `SubscriptionPlan` through Stripe (#4786). A host
|
|
368
|
+
configures it; it does not re-implement it. Every provider call goes through
|
|
369
|
+
`@happyvertical/accounting`; there are no direct Stripe calls here. Web only:
|
|
370
|
+
phone apps never sell a subscription in-app.
|
|
371
|
+
|
|
372
|
+
### Host configuration
|
|
373
|
+
|
|
374
|
+
| Variable | Meaning |
|
|
375
|
+
| --- | --- |
|
|
376
|
+
| `STRIPE_SECRET_KEY` | Secret or restricted key (`sk_` / `rk_`). |
|
|
377
|
+
| `STRIPE_WEBHOOK_SECRET` | Webhook endpoint signing secret (`whsec_`). |
|
|
378
|
+
| `STRIPE_WEBHOOK_TOLERANCE` | Optional. Maximum signature age in seconds, 1 to 900, default 300. |
|
|
379
|
+
| `BILLING_METADATA_SECRET` | Optional. Secret that signs new Checkout metadata. Unset: the webhook secret is used, so rotating the webhook secret also rotates this. Set it to a stable value to decouple the two. |
|
|
380
|
+
| `BILLING_METADATA_PREVIOUS_SECRETS` | Optional, comma-separated. Retired metadata secrets still accepted when a completed Checkout is verified. |
|
|
381
|
+
| `STRIPE_PRICE_<PLAN_KEY>` | Price id sold for a plan, for example `STRIPE_PRICE_PRO`. Wins over `SubscriptionPlan.providerPriceId`. |
|
|
382
|
+
| `BILLING_SUCCESS_URL`, `BILLING_CANCEL_URL`, `BILLING_PORTAL_RETURN_URL` | Fixed absolute https destinations (plain http only for localhost). |
|
|
383
|
+
|
|
384
|
+
`readBillingEnv(process.env, { planKeys })` validates these and keeps the
|
|
385
|
+
secrets in non-enumerable properties, so logging the config does not print
|
|
386
|
+
them. Its errors name the variable, never the value.
|
|
387
|
+
|
|
388
|
+
**Rotating secrets.** A Checkout session is signed when it is created and
|
|
389
|
+
verified when it completes, which can be days later. If the signing secret
|
|
390
|
+
changes in between (by default it is the webhook secret), a paid session no
|
|
391
|
+
longer verifies and the purchase is dropped. Before rotating, put the outgoing
|
|
392
|
+
secret in `BILLING_METADATA_PREVIOUS_SECRETS` (`metadataVerificationSecrets`)
|
|
393
|
+
and keep it there until sessions created under it have expired. A completed
|
|
394
|
+
session that claims to be ours but does not verify under any accepted secret
|
|
395
|
+
is logged at error level (`billing.webhook.checkout_signature_invalid`), so a
|
|
396
|
+
dropped purchase is visible.
|
|
397
|
+
|
|
398
|
+
### Recipe (SvelteKit)
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
// src/lib/server/billing.ts
|
|
402
|
+
import { getAccountingProvider } from '@happyvertical/accounting';
|
|
403
|
+
import {
|
|
404
|
+
createBillingHost,
|
|
405
|
+
readBillingEnv,
|
|
406
|
+
TenantSubscriptionCollection,
|
|
407
|
+
} from '@happyvertical/smrt-subscriptions';
|
|
408
|
+
|
|
409
|
+
const env = readBillingEnv(process.env, { planKeys: ['pro', 'team'] });
|
|
410
|
+
const provider = await getAccountingProvider({
|
|
411
|
+
type: 'stripe',
|
|
412
|
+
secretKey: env.stripe.secretKey, // read explicitly: non-enumerable on purpose
|
|
413
|
+
webhookSecret: env.stripe.webhookSecret,
|
|
414
|
+
webhookTolerance: env.stripe.webhookTolerance,
|
|
415
|
+
});
|
|
416
|
+
const { db } = await TenantSubscriptionCollection.create(smrtConfig);
|
|
417
|
+
|
|
418
|
+
export const billing = createBillingHost({
|
|
419
|
+
provider,
|
|
420
|
+
db, // needs transaction(): SQLite or PostgreSQL
|
|
421
|
+
webhookSecret: env.stripe.webhookSecret,
|
|
422
|
+
metadataSigningSecret: env.metadata.signingSecret,
|
|
423
|
+
metadataVerificationSecrets: env.metadata.previousSecrets,
|
|
424
|
+
webhookToleranceSeconds: env.stripe.webhookTolerance,
|
|
425
|
+
urls: env.urls,
|
|
426
|
+
prices: env.prices,
|
|
427
|
+
logger, // optional; receives ids and reason codes only
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// src/routes/api/billing/checkout/+server.ts (authenticate and authorise first)
|
|
431
|
+
export const POST = async ({ request, locals }) => {
|
|
432
|
+
const membership = await requirePermission(locals, 'billing.manage');
|
|
433
|
+
return billing.handlers.checkout(request, {
|
|
434
|
+
tenantId: membership.tenantId,
|
|
435
|
+
email: membership.userEmail,
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// .../portal/+server.ts -> billing.handlers.portal(request, { tenantId })
|
|
440
|
+
// .../webhook/+server.ts -> billing.handlers.webhook(request) (no auth; signature only)
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
The webhook route must receive the raw body. Point the Stripe endpoint at it
|
|
444
|
+
and subscribe it to `checkout.session.completed` and
|
|
445
|
+
`customer.subscription.created|updated|deleted|paused|resumed`.
|
|
446
|
+
|
|
447
|
+
### What it guarantees
|
|
448
|
+
|
|
449
|
+
- **Checkout** takes only a plan key or id from the request. Tenant, price,
|
|
450
|
+
customer and URLs come from the host's authenticated tenant, server
|
|
451
|
+
configuration and the tenant's own subscription row. A plan must be visible
|
|
452
|
+
to the tenant (its own or global), active, fixed-price and priced, and the
|
|
453
|
+
tenant must not already hold a live Stripe subscription (use the portal to
|
|
454
|
+
change plans). Only operator-owned (global) plans are sold unless the host
|
|
455
|
+
sets `tenantPlans: 'allow'`, so a tenant that can write plan rows cannot buy
|
|
456
|
+
entitlements it defined itself or shadow a plan key; set `allow` only when
|
|
457
|
+
tenants cannot write `SubscriptionPlan`. `canPurchasePlan` adds host policy.
|
|
458
|
+
The session metadata is HMAC-signed and bound to the server price, an
|
|
459
|
+
expiry and, when known, the billing customer, so another integration on the
|
|
460
|
+
same Stripe account cannot forge metadata for a tenant, and signed metadata
|
|
461
|
+
cannot be reused for a different price or another customer's subscription.
|
|
462
|
+
- **First purchases are bound to the session the host created (#4814).** The
|
|
463
|
+
host records each Checkout session id (table
|
|
464
|
+
`_smrt_billing_checkout_sessions`: session id, tenant, expiry, status
|
|
465
|
+
`pending`, `completed` or `needs_reconciliation`) before it returns the
|
|
466
|
+
buyer's URL, and signs an expiry (`smrt_exp`, 25 hours, so a Stripe session's
|
|
467
|
+
24 hours plus an hour) into the metadata. A paid session links a tenant only
|
|
468
|
+
when, in order: the signature verifies, a record exists for exactly that
|
|
469
|
+
session id, it is still `pending`, it names the same tenant as the signed
|
|
470
|
+
metadata, and the event time (not the delivery time, so late webhook retries
|
|
471
|
+
of an in-time payment still count) is before the signed and recorded expiry.
|
|
472
|
+
Metadata copied onto another integration's session is therefore never
|
|
473
|
+
linked, however it is paid. A completed session is single use: a second
|
|
474
|
+
completion with the same session id changes nothing.
|
|
475
|
+
- **A paid session that cannot be bound is kept, not dropped.** If the record
|
|
476
|
+
is missing, expired or names another tenant, the tenant is not linked, the
|
|
477
|
+
event is claimed (no provider retry loop) and the session is stored as
|
|
478
|
+
`needs_reconciliation` with a reason code (`session_record_missing`,
|
|
479
|
+
`session_expired`, `session_record_mismatch`). The webhook answers 200 with
|
|
480
|
+
`outcome: 'needs_reconciliation'` and logs
|
|
481
|
+
`billing.checkout.needs_reconciliation` at error level (ids and codes only).
|
|
482
|
+
A genuine buyer whose record was lost, or a session that was in flight when
|
|
483
|
+
this binding was deployed (it has no record), lands here. An operator lists
|
|
484
|
+
them with `host.listCheckoutsNeedingReconciliation()` and links one on
|
|
485
|
+
purpose with `host.reconcileCheckout({ sessionId, tenantId, operatorId })`:
|
|
486
|
+
server-side only (authenticate and authorise the operator first; it is not a
|
|
487
|
+
request handler), it re-reads the subscription from the provider, requires
|
|
488
|
+
it to be the subscription and customer recorded when the session was
|
|
489
|
+
flagged, applies the same tenant/customer conflict checks as a normal
|
|
490
|
+
completion, links once, and logs `billing.checkout.reconciled` with the
|
|
491
|
+
operator id. Each listed row carries the paying `customerId`, the tenant the
|
|
492
|
+
host recorded the session for (`targetTenantId`) and
|
|
493
|
+
`claimedTenantHasOwnSession` (true suggests a copy of that tenant's signed
|
|
494
|
+
metadata): confirm the customer is the tenant's own before linking. Treat a
|
|
495
|
+
flagged session you did not expect as another integration's payment and close
|
|
496
|
+
it with `host.dismissCheckout({ sessionId, operatorId, reason })` (terminal
|
|
497
|
+
`dismissed`, logged with the operator id and reason), so it leaves the list.
|
|
498
|
+
The list is paged (`{ limit, offset }`, default 200) and
|
|
499
|
+
`host.countCheckoutsNeedingReconciliation()` gives the total.
|
|
500
|
+
- **Webhook** verifies the signature on the raw body with a bounded timestamp
|
|
501
|
+
tolerance, then claims the event id durably in the same transaction as the
|
|
502
|
+
subscription write (`runOnce`, `_smrt_run_once_claims`). A replay is a 2xx
|
|
503
|
+
no-op; a failed apply rolls the claim back (non-2xx) so the provider's retry
|
|
504
|
+
runs. Out-of-order events converge: each applied event records its time on
|
|
505
|
+
the row and an older one is skipped, and a cancellation wins a same-second
|
|
506
|
+
tie. A completed checkout reads the subscription back from the provider, so
|
|
507
|
+
the row reflects current state however events were ordered. A plan change at
|
|
508
|
+
the provider is applied as an authorised plan-version replacement, so
|
|
509
|
+
feature keys flip within that one webhook (including a mid-period
|
|
510
|
+
downgrade the provider billed).
|
|
511
|
+
- **Linking** is by provider ids stored on `TenantSubscription`. A subscription
|
|
512
|
+
or customer already linked to one tenant is never re-linked to another, and
|
|
513
|
+
an event for an unknown subscription is acknowledged and ignored.
|
|
514
|
+
- **Portal** opens the portal for the customer on the caller's own
|
|
515
|
+
subscription row only.
|
|
516
|
+
- **Responses and logs** carry fixed messages, ids and reason codes. Provider
|
|
517
|
+
error text is never returned, and anything shaped like a key is redacted
|
|
518
|
+
before logging.
|
|
519
|
+
|
|
520
|
+
Operational notes: if a second Checkout for a tenant that already holds a live
|
|
521
|
+
subscription is paid (two tabs racing), the webhook cannot track it and logs
|
|
522
|
+
`billing.checkout.orphaned_subscription` at error level with the provider
|
|
523
|
+
subscription id; alert on it and cancel or refund that subscription at the
|
|
524
|
+
provider. Keep server clocks synchronised (NTP): event ordering compares the
|
|
525
|
+
provider's whole-second event time with this host's read-back time. A
|
|
526
|
+
concurrent duplicate of an event being applied answers
|
|
527
|
+
409 and Stripe retries it. Plans and which price backs which plan are the
|
|
528
|
+
owner's input; this module only maps them. No schema change is needed: event
|
|
529
|
+
claims use core's `_smrt_run_once_claims`, retained for its pruning window
|
|
530
|
+
(30 days by default), far longer than Stripe's three-day retry horizon.
|
|
531
|
+
|
|
532
|
+
## Hosted vault-account restart and supersede (#4841)
|
|
533
|
+
|
|
534
|
+
The hosted account layer (the service that holds the vault relay's admin
|
|
535
|
+
token) replaces a family's vault account after a restart. That is a takeover
|
|
536
|
+
of the backup line if done by the wrong person, so every action here is gated:
|
|
537
|
+
|
|
538
|
+
- `supersedeVaultAccount`, `unsupersedeVaultAccount` (relay admin `PUT` and
|
|
539
|
+
`DELETE .../superseded-by`) and `provisionRestartAccount` (a fresh account
|
|
540
|
+
linked to the tenant plus the working device's relay signing key, step 1 of
|
|
541
|
+
the restart in the app-runtime README).
|
|
542
|
+
- Each requires the caller to be the tenant **owner** (`NotOwnerError`, code
|
|
543
|
+
`not_owner`: a member, a device-only or layered session, no session, or an
|
|
544
|
+
account of another tenant) **and** a **step-up**: the owner re-authenticated
|
|
545
|
+
with a fresh password or passkey within `stepUpMaxAgeSeconds` (default 300;
|
|
546
|
+
`StepUpRequiredError`, code `step_up_required`). A signed-in session or an
|
|
547
|
+
unlocked device is not enough. See `smrt-users` "Step-up".
|
|
548
|
+
- The relay is called only after both checks pass, exactly once. Every attempt
|
|
549
|
+
is written to your `audit` sink (`denied` with the reason code, `authorized`
|
|
550
|
+
before the call, then `succeeded` or `failed`); records hold ids, codes and
|
|
551
|
+
times, never a password, session id, token or key. If the `authorized`
|
|
552
|
+
record cannot be written, nothing is done.
|
|
553
|
+
- Collaborators are injected: `session` (from `loadSessionContext`), `relay`
|
|
554
|
+
(`VaultRelayAdminPort`; `createVaultRelayAdminClient({ baseUrl, adminToken })`
|
|
555
|
+
speaks the admin HTTP API, and a `VaultRelay` instance fits in-process),
|
|
556
|
+
`directory` (which tenant owns an account, and linking a new one so
|
|
557
|
+
`backup.vault` resolves), and `audit`.
|
|
558
|
+
|
|
353
559
|
## Svelte entry point
|
|
354
560
|
|
|
355
561
|
`@happyvertical/smrt-subscriptions/svelte` exports provider-neutral,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { DatabaseInterface } from '@happyvertical/sql';
|
|
2
|
+
import type { TenantSubscription } from '../models/TenantSubscription.js';
|
|
3
|
+
import { type SubscriptionSnapshot } from './events.js';
|
|
4
|
+
import type { BillingLogger } from './types.js';
|
|
5
|
+
/** JSON-safe result stored with the idempotency claim and replayed. */
|
|
6
|
+
export interface AppliedOutcome {
|
|
7
|
+
action: 'applied' | 'ignored' | 'needs_reconciliation';
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ApplyContext {
|
|
11
|
+
db: DatabaseInterface;
|
|
12
|
+
tenantPlans: 'deny' | 'allow';
|
|
13
|
+
/** PostgreSQL: serialize concurrent events for one subscription or tenant. */
|
|
14
|
+
postgres: boolean;
|
|
15
|
+
prices: Record<string, string> | undefined;
|
|
16
|
+
logger: BillingLogger | undefined;
|
|
17
|
+
}
|
|
18
|
+
interface EventFacts {
|
|
19
|
+
eventId: string;
|
|
20
|
+
eventType: string;
|
|
21
|
+
/** The provider's own event time (whole seconds). */
|
|
22
|
+
eventCreated: Date;
|
|
23
|
+
/** When the snapshot was read, if it was read rather than delivered. */
|
|
24
|
+
observedAt?: Date;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Serialize applies that touch the same provider subscription or tenant on
|
|
28
|
+
* PostgreSQL, where two transactions can otherwise both read the row before
|
|
29
|
+
* either writes and let an older event overwrite a newer one. Transaction-
|
|
30
|
+
* scoped, taken in sorted order. Embedded engines already have one writer.
|
|
31
|
+
*/
|
|
32
|
+
export declare function lockBillingKeys(ctx: Pick<ApplyContext, 'db' | 'postgres'>, keys: string[]): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* A row held for an unmapped price still has a live provider subscription
|
|
35
|
+
* (`metadata.stripe.held`): checkout must not sell a second one over it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function isHeldSubscription(row: TenantSubscription): boolean;
|
|
38
|
+
/** A `customer.subscription.*` event for an already-linked subscription. */
|
|
39
|
+
export declare function applySubscriptionEvent(ctx: ApplyContext, facts: EventFacts, snapshot: SubscriptionSnapshot): Promise<AppliedOutcome>;
|
|
40
|
+
export interface CheckoutLink {
|
|
41
|
+
tenantId: string;
|
|
42
|
+
planId: string;
|
|
43
|
+
checkoutSessionId: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A completed Checkout whose signed metadata names the tenant and plan. The
|
|
47
|
+
* snapshot is the subscription read back from the provider after the event,
|
|
48
|
+
* so the row reflects current state however the events were ordered.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyCheckoutCompleted(ctx: ApplyContext, facts: EventFacts, link: CheckoutLink, snapshot: SubscriptionSnapshot): Promise<AppliedOutcome>;
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apply.d.ts","sourceRoot":"","sources":["../../src/billing/apply.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AAE1E,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAE5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,uEAAuE;AACvE,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,SAAS,GAAG,SAAS,GAAG,sBAAsB,CAAC;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,iBAAiB,CAAC;IACtB,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9B,8EAA8E;IAC9E,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAC3C,MAAM,EAAE,aAAa,GAAG,SAAS,CAAC;CACnC;AAED,UAAU,UAAU;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,YAAY,EAAE,IAAI,CAAC;IACnB,wEAAwE;IACxE,UAAU,CAAC,EAAE,IAAI,CAAC;CACnB;AASD;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,UAAU,CAAC,EAC1C,IAAI,EAAE,MAAM,EAAE,GACb,OAAO,CAAC,IAAI,CAAC,CAQf;AAiDD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,kBAAkB,GAAG,OAAO,CASnE;AA6LD,4EAA4E;AAC5E,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CAyEzB;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,YAAY,EAClB,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CA4EzB"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding a completed Checkout to the session the host created (#4814).
|
|
3
|
+
*
|
|
4
|
+
* The signed metadata alone is copyable: another integration on the same
|
|
5
|
+
* Stripe account can put it on its own session and pay first. So the host
|
|
6
|
+
* records each session id when it creates the session, and a paid session
|
|
7
|
+
* links a tenant only when a pending record for exactly that id names the
|
|
8
|
+
* same tenant and the signed expiry has not passed at the event time.
|
|
9
|
+
* Anything else is never linked and never dropped: the paid session is
|
|
10
|
+
* recorded as `needs_reconciliation` for an operator.
|
|
11
|
+
*/
|
|
12
|
+
import type { DatabaseInterface } from '@happyvertical/sql';
|
|
13
|
+
import { type BillingCheckoutSession } from '../models/BillingCheckoutSession.js';
|
|
14
|
+
import { type AppliedOutcome, type ApplyContext, applyCheckoutCompleted } from './apply.js';
|
|
15
|
+
import { type SubscriptionSnapshot } from './events.js';
|
|
16
|
+
/** Signed metadata key holding the expiry, in whole epoch seconds. */
|
|
17
|
+
export declare const METADATA_EXPIRY_KEY = "smrt_exp";
|
|
18
|
+
/**
|
|
19
|
+
* Stripe expires a Checkout session after 24 hours; the host allows an hour
|
|
20
|
+
* more. The expiry is checked against the event time, not the delivery time,
|
|
21
|
+
* so a late webhook retry of an in-time payment still completes.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CHECKOUT_SESSION_TTL_MS: number;
|
|
24
|
+
export type CheckoutRefusal = 'session_record_missing' | 'session_record_mismatch' | 'session_expired';
|
|
25
|
+
type Verdict = {
|
|
26
|
+
kind: 'bound';
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'replay';
|
|
29
|
+
reason: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'reconcile';
|
|
32
|
+
reason: CheckoutRefusal;
|
|
33
|
+
};
|
|
34
|
+
/** The binding rules, in order. Pure: no I/O. */
|
|
35
|
+
export declare function checkBinding(record: BillingCheckoutSession | null, input: {
|
|
36
|
+
tenantId: string;
|
|
37
|
+
exp: string | undefined;
|
|
38
|
+
eventTime: Date;
|
|
39
|
+
}): Verdict;
|
|
40
|
+
/** Record a session the host just created. Idempotent per session id. */
|
|
41
|
+
export declare function recordCheckoutSession(db: DatabaseInterface, input: {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
tenantId: string;
|
|
44
|
+
expiresAt: Date;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
export interface ReconcileContext {
|
|
47
|
+
eventId: string;
|
|
48
|
+
eventCreated: string;
|
|
49
|
+
subscriptionId: string;
|
|
50
|
+
customerId: string;
|
|
51
|
+
planId: string;
|
|
52
|
+
claimedTenantId: string;
|
|
53
|
+
}
|
|
54
|
+
export declare function parseReconcileContext(record: BillingCheckoutSession): ReconcileContext | null;
|
|
55
|
+
export interface BoundCompletion {
|
|
56
|
+
sessionId: string;
|
|
57
|
+
/** From the verified signed metadata. */
|
|
58
|
+
tenantId: string;
|
|
59
|
+
planId: string;
|
|
60
|
+
exp: string | undefined;
|
|
61
|
+
eventId: string;
|
|
62
|
+
eventTime: Date;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Inside the idempotency transaction: bind the paid session to its record,
|
|
66
|
+
* then link the tenant. `ctx.db` is the transaction handle.
|
|
67
|
+
*/
|
|
68
|
+
export declare function completeBoundCheckout(ctx: ApplyContext, input: BoundCompletion, facts: Parameters<typeof applyCheckoutCompleted>[1], snapshot: SubscriptionSnapshot): Promise<AppliedOutcome>;
|
|
69
|
+
/** A flagged session and the ids captured when it was flagged. */
|
|
70
|
+
export declare function loadFlaggedCheckout(db: DatabaseInterface, sessionId: string): Promise<{
|
|
71
|
+
record: BillingCheckoutSession;
|
|
72
|
+
flagged: ReconcileContext;
|
|
73
|
+
} | null>;
|
|
74
|
+
/**
|
|
75
|
+
* Operator action, inside one transaction (`ctx.db`): link a flagged paid
|
|
76
|
+
* session to `tenantId` on purpose. `snapshot` is the subscription read back
|
|
77
|
+
* from the provider before the transaction; it must still be the one recorded
|
|
78
|
+
* when the session was flagged. The row is re-read under the lock, so a
|
|
79
|
+
* session is reconciled at most once.
|
|
80
|
+
*/
|
|
81
|
+
export declare function reconcileFlaggedCheckout(ctx: ApplyContext, input: {
|
|
82
|
+
sessionId: string;
|
|
83
|
+
tenantId: string;
|
|
84
|
+
planId?: string;
|
|
85
|
+
operatorId: string;
|
|
86
|
+
snapshot: SubscriptionSnapshot;
|
|
87
|
+
}): Promise<AppliedOutcome | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Operator action, inside one transaction (`ctx.db`): close a flagged session
|
|
90
|
+
* without linking it. The row is re-read under the lock, so a session is
|
|
91
|
+
* dismissed or reconciled at most once, whichever comes first.
|
|
92
|
+
*/
|
|
93
|
+
export declare function dismissFlaggedCheckout(ctx: ApplyContext, input: {
|
|
94
|
+
sessionId: string;
|
|
95
|
+
operatorId: string;
|
|
96
|
+
reason: string;
|
|
97
|
+
}): Promise<boolean>;
|
|
98
|
+
export {};
|
|
99
|
+
//# sourceMappingURL=checkout-sessions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkout-sessions.d.ts","sourceRoot":"","sources":["../../src/billing/checkout-sessions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EACL,KAAK,sBAAsB,EAE5B,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,sBAAsB,EAEvB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAsB,KAAK,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAE5E,sEAAsE;AACtE,eAAO,MAAM,mBAAmB,aAAa,CAAC;AAC9C;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,QAAsB,CAAC;AAE3D,MAAM,MAAM,eAAe,GACvB,wBAAwB,GACxB,yBAAyB,GACzB,iBAAiB,CAAC;AAEtB,KAAK,OAAO,GACR;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,eAAe,CAAA;CAAE,CAAC;AAEnD,iDAAiD;AACjD,wBAAgB,YAAY,CAC1B,MAAM,EAAE,sBAAsB,GAAG,IAAI,EACrC,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GACpE,OAAO,CAsBT;AAED,yEAAyE;AACzE,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,iBAAiB,EACrB,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,IAAI,CAAA;CAAE,GAC9D,OAAO,CAAC,IAAI,CAAC,CAcf;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,sBAAsB,GAC7B,gBAAgB,GAAG,IAAI,CAqBzB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE,eAAe,EACtB,KAAK,EAAE,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC,CAAC,CAAC,EACnD,QAAQ,EAAE,oBAAoB,GAC7B,OAAO,CAAC,cAAc,CAAC,CAmEzB;AAED,kEAAkE;AAClE,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC;IACT,MAAM,EAAE,sBAAsB,CAAC;IAC/B,OAAO,EAAE,gBAAgB,CAAC;CAC3B,GAAG,IAAI,CAAC,CAMR;AAED;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE;IACL,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,oBAAoB,CAAC;CAChC,GACA,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAyChC;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,YAAY,EACjB,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC/D,OAAO,CAAC,OAAO,CAAC,CAelB"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host configuration surface for the billing glue (#4786).
|
|
3
|
+
*
|
|
4
|
+
* Environment variables, with the secrets held in non-enumerable properties so
|
|
5
|
+
* a stray `JSON.stringify(config)` or `console.log(config)` cannot print them.
|
|
6
|
+
* Error messages name the variable and never its value.
|
|
7
|
+
*/
|
|
8
|
+
import type { BillingUrls } from './types.js';
|
|
9
|
+
export declare const BILLING_ENV: {
|
|
10
|
+
readonly secretKey: "STRIPE_SECRET_KEY";
|
|
11
|
+
readonly webhookSecret: "STRIPE_WEBHOOK_SECRET";
|
|
12
|
+
readonly webhookTolerance: "STRIPE_WEBHOOK_TOLERANCE";
|
|
13
|
+
readonly metadataSecret: "BILLING_METADATA_SECRET";
|
|
14
|
+
readonly metadataPreviousSecrets: "BILLING_METADATA_PREVIOUS_SECRETS";
|
|
15
|
+
readonly successUrl: "BILLING_SUCCESS_URL";
|
|
16
|
+
readonly cancelUrl: "BILLING_CANCEL_URL";
|
|
17
|
+
readonly portalReturnUrl: "BILLING_PORTAL_RETURN_URL";
|
|
18
|
+
};
|
|
19
|
+
export declare const DEFAULT_WEBHOOK_TOLERANCE_SECONDS = 300;
|
|
20
|
+
export declare const MAX_WEBHOOK_TOLERANCE_SECONDS = 900;
|
|
21
|
+
/** The env var holding the provider price id for a plan key. */
|
|
22
|
+
export declare function priceEnvName(planKey: string): string;
|
|
23
|
+
export interface BillingEnvOptions {
|
|
24
|
+
/** Plan keys whose `STRIPE_PRICE_<KEY>` variables should be read. */
|
|
25
|
+
planKeys: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export interface BillingEnvConfig {
|
|
28
|
+
/**
|
|
29
|
+
* Provider credentials for `getAccountingProvider({ type: 'stripe', … })`.
|
|
30
|
+
* `secretKey` and `webhookSecret` are non-enumerable (they do not survive
|
|
31
|
+
* a spread, `JSON.stringify` or `console.log`), so read them by name.
|
|
32
|
+
*/
|
|
33
|
+
stripe: {
|
|
34
|
+
readonly secretKey: string;
|
|
35
|
+
readonly webhookSecret: string;
|
|
36
|
+
webhookTolerance: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Checkout-metadata signing (#4786). `signingSecret` is unset unless
|
|
40
|
+
* `BILLING_METADATA_SECRET` is configured (the host then falls back to the
|
|
41
|
+
* webhook secret). `previousSecrets` (comma-separated
|
|
42
|
+
* `BILLING_METADATA_PREVIOUS_SECRETS`) are still accepted when a completed
|
|
43
|
+
* checkout is verified, so rotating a secret does not drop sessions created
|
|
44
|
+
* before the rotation. Both are non-enumerable.
|
|
45
|
+
*/
|
|
46
|
+
metadata: {
|
|
47
|
+
readonly signingSecret: string | undefined;
|
|
48
|
+
readonly previousSecrets: readonly string[];
|
|
49
|
+
};
|
|
50
|
+
urls: BillingUrls;
|
|
51
|
+
/** Plan key to price id for every plan whose variable is set. */
|
|
52
|
+
prices: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
type Env = Record<string, string | undefined>;
|
|
55
|
+
/**
|
|
56
|
+
* Validate a redirect destination: absolute, no embedded credentials, and
|
|
57
|
+
* https (plain http only for loopback development hosts).
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertBillingUrl(name: string, value: string): string;
|
|
60
|
+
export declare function assertWebhookTolerance(seconds: number): number;
|
|
61
|
+
/** Read and validate billing configuration from an environment map. */
|
|
62
|
+
export declare function readBillingEnv(env: Env, options: BillingEnvOptions): BillingEnvConfig;
|
|
63
|
+
export {};
|
|
64
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/billing/config.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,WAAW;;;;;;;;;CASd,CAAC;AAEX,eAAO,MAAM,iCAAiC,MAAM,CAAC;AACrD,eAAO,MAAM,6BAA6B,MAAM,CAAC;AAEjD,gEAAgE;AAChE,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,MAAM,WAAW,iBAAiB;IAChC,qEAAqE;IACrE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,MAAM,EAAE;QACN,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;QAC/B,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;IACF;;;;;;;OAOG;IACH,QAAQ,EAAE;QACR,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;QAC3C,QAAQ,CAAC,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;KAC7C,CAAC;IACF,IAAI,EAAE,WAAW,CAAC;IAClB,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AAED,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAa9C;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAqBpE;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAW9D;AAED,uEAAuE;AACvE,wBAAgB,cAAc,CAC5B,GAAG,EAAE,GAAG,EACR,OAAO,EAAE,iBAAiB,GACzB,gBAAgB,CAyElB"}
|