@happyvertical/smrt-subscriptions 0.60.15 → 0.60.17
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 +78 -0
- package/README.md +195 -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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1648 -26
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +313 -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/smrt-knowledge.json +101 -8
- package/dist/smrt-knowledge.okf.md +5 -2
- package/dist/svelte/CommercialOverview.svelte +37 -11
- package/dist/svelte/PlanPicker.svelte +18 -17
- package/dist/svelte/SubscriptionSummary.svelte +13 -11
- package/dist/svelte/UsageThresholds.svelte +21 -17
- 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 -7
package/AGENTS.md
CHANGED
|
@@ -161,6 +161,84 @@ window, amount and enforcement come from the plan (pinned version included):
|
|
|
161
161
|
it `currentShortfall()`, so a hook that charges asynchronously can see a
|
|
162
162
|
concurrent top-up (smrt-commerce's `autoTopUpHook()`, #3139).
|
|
163
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
|
+
|
|
164
242
|
## Notes
|
|
165
243
|
|
|
166
244
|
- Keep Stripe-specific fields as provider binding metadata. Runtime Stripe API
|
package/README.md
CHANGED
|
@@ -361,6 +361,201 @@ keeps its credit ledger (reversible in a system context), and the new parent
|
|
|
361
361
|
creates its own balance. A parent's `retail` cap counts only what the child owes that
|
|
362
362
|
parent.
|
|
363
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
|
+
|
|
364
559
|
## Svelte entry point
|
|
365
560
|
|
|
366
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"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalise verified Stripe webhook payloads into the facts this module acts
|
|
3
|
+
* on (#4786). Pure: no I/O, no trust decisions beyond reading shapes.
|
|
4
|
+
*/
|
|
5
|
+
import type { SubscriptionStatus } from '../types.js';
|
|
6
|
+
import type { BillingSubscriptionState } from './types.js';
|
|
7
|
+
export declare const SUBSCRIPTION_EVENT_TYPES: Set<string>;
|
|
8
|
+
export declare const CHECKOUT_COMPLETED = "checkout.session.completed";
|
|
9
|
+
export declare const SUBSCRIPTION_DELETED = "customer.subscription.deleted";
|
|
10
|
+
/** What the provider says about one subscription at a point in time. */
|
|
11
|
+
export interface SubscriptionSnapshot {
|
|
12
|
+
subscriptionId: string;
|
|
13
|
+
customerId: string;
|
|
14
|
+
status: SubscriptionStatus;
|
|
15
|
+
priceId?: string;
|
|
16
|
+
startedAt?: Date | null;
|
|
17
|
+
currentPeriodStart?: Date | null;
|
|
18
|
+
currentPeriodEnd?: Date | null;
|
|
19
|
+
trialEndsAt?: Date | null;
|
|
20
|
+
cancelAtPeriodEnd?: boolean;
|
|
21
|
+
canceledAt?: Date | null;
|
|
22
|
+
endedAt?: Date | null;
|
|
23
|
+
}
|
|
24
|
+
export interface CheckoutCompletedFacts {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
mode: string | undefined;
|
|
27
|
+
paymentStatus: string | undefined;
|
|
28
|
+
customerId: string | undefined;
|
|
29
|
+
subscriptionId: string | undefined;
|
|
30
|
+
metadata: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
type UnknownRecord = Record<string, unknown>;
|
|
33
|
+
export declare function asRecord(value: unknown): UnknownRecord | null;
|
|
34
|
+
export declare function asString(value: unknown): string | undefined;
|
|
35
|
+
/** A Stripe id that is either a string or an expanded `{ id }` object. */
|
|
36
|
+
export declare function externalId(value: unknown): string | undefined;
|
|
37
|
+
export declare function stripeObject(payload: unknown): UnknownRecord | null;
|
|
38
|
+
export declare function toDate(value: unknown): Date | null;
|
|
39
|
+
/** Unknown provider statuses fail closed to a non-entitling status. */
|
|
40
|
+
export declare function mapSubscriptionStatus(value: string | undefined): SubscriptionStatus;
|
|
41
|
+
/** The first line-item price id: what the provider actually bills. */
|
|
42
|
+
export declare function subscriptionPriceId(object: UnknownRecord): string | undefined;
|
|
43
|
+
/** Facts from a `customer.subscription.*` event object. */
|
|
44
|
+
export declare function subscriptionSnapshotFromObject(object: UnknownRecord, eventType: string): SubscriptionSnapshot | null;
|
|
45
|
+
/** Facts from a provider subscription read back through the SDK. */
|
|
46
|
+
export declare function subscriptionSnapshotFromState(state: BillingSubscriptionState): SubscriptionSnapshot;
|
|
47
|
+
export declare function checkoutFactsFromObject(object: UnknownRecord): CheckoutCompletedFacts | null;
|
|
48
|
+
export {};
|
|
49
|
+
//# sourceMappingURL=events.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/billing/events.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAE3D,eAAO,MAAM,wBAAwB,aAMnC,CAAC;AAEH,eAAO,MAAM,kBAAkB,+BAA+B,CAAC;AAC/D,eAAO,MAAM,oBAAoB,kCAAkC,CAAC;AAEpE,wEAAwE;AACxE,MAAM,WAAW,oBAAoB;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,kBAAkB,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACxB,kBAAkB,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACjC,gBAAgB,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC1B,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,UAAU,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACzB,OAAO,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,KAAK,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE7C,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAI7D;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE3D;AAED,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAE7D;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,GAAG,IAAI,CAEnE;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,GAAG,IAAI,CAQlD;AAeD,uEAAuE;AACvE,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,GAAG,SAAS,GACxB,kBAAkB,CAEpB;AAmBD,sEAAsE;AACtE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,CAM7E;AAiBD,2DAA2D;AAC3D,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,aAAa,EACrB,SAAS,EAAE,MAAM,GAChB,oBAAoB,GAAG,IAAI,CAwB7B;AAED,oEAAoE;AACpE,wBAAgB,6BAA6B,CAC3C,KAAK,EAAE,wBAAwB,GAC9B,oBAAoB,CAmBtB;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,aAAa,GACpB,sBAAsB,GAAG,IAAI,CAW/B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/billing/host.ts"],"names":[],"mappings":"AAqEA,OAAO,EAGL,KAAK,WAAW,EAEhB,KAAK,kBAAkB,EAIxB,MAAM,YAAY,CAAC;AAoFpB,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAguB1E"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { BILLING_ENV, type BillingEnvConfig, type BillingEnvOptions, DEFAULT_WEBHOOK_TOLERANCE_SECONDS, MAX_WEBHOOK_TOLERANCE_SECONDS, priceEnvName, readBillingEnv, } from './config.js';
|
|
2
|
+
export { createBillingHost } from './host.js';
|
|
3
|
+
export { type BillingAuth, type BillingCheckoutLineItem, type BillingCheckoutResult, type BillingCheckoutSessionInput, type BillingHost, BillingHostError, type BillingHostErrorCode, type BillingHostOptions, type BillingLogger, type BillingPlanPurchaseContext, type BillingPortalResult, type BillingProviderPort, type BillingProviderWebhookEvent, type BillingSubscriptionState, type BillingUnreconciledCheckout, type BillingUrls, type BillingWebhookOutcome, type BillingWebhookResponse, } from './types.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/billing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,iCAAiC,EACjC,6BAA6B,EAC7B,YAAY,EACZ,cAAc,GACf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,WAAW,EAChB,gBAAgB,EAChB,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,0BAA0B,EAC/B,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,KAAK,2BAA2B,EAChC,KAAK,wBAAwB,EAC7B,KAAK,2BAA2B,EAChC,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,GAC5B,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan lookup, price mapping and purchasability for the billing glue (#4786).
|
|
3
|
+
*
|
|
4
|
+
* Plans are read in a system context and then checked explicitly: a plan is
|
|
5
|
+
* visible to a tenant only when it is the tenant's own or a global
|
|
6
|
+
* (`tenantId: null`) plan. The provider price always comes from server
|
|
7
|
+
* configuration or the plan's own binding, never from a request.
|
|
8
|
+
*/
|
|
9
|
+
import type { DatabaseInterface } from '@happyvertical/sql';
|
|
10
|
+
import type { SubscriptionPlan } from '../models/SubscriptionPlan.js';
|
|
11
|
+
export declare function planVisibleToTenant(plan: SubscriptionPlan, tenantId: string): boolean;
|
|
12
|
+
/** The provider price id a plan is sold under, or undefined when unset. */
|
|
13
|
+
export declare function priceIdForPlan(plan: SubscriptionPlan, prices: Record<string, string> | undefined): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Whose plans may be sold and resolved from a provider price. `deny` (the
|
|
16
|
+
* default) is operator plans only: a tenant that can write its own
|
|
17
|
+
* `SubscriptionPlan` rows (for example through a mounted generated API) cannot
|
|
18
|
+
* mint a plan, or shadow a global plan key, and buy the entitlements it wrote.
|
|
19
|
+
*/
|
|
20
|
+
export type TenantPlanPolicy = 'deny' | 'allow';
|
|
21
|
+
export declare class PlanDirectory {
|
|
22
|
+
private readonly db;
|
|
23
|
+
private readonly prices;
|
|
24
|
+
private readonly tenantPlans;
|
|
25
|
+
constructor(db: DatabaseInterface, prices: Record<string, string> | undefined, tenantPlans?: TenantPlanPolicy);
|
|
26
|
+
private sellable;
|
|
27
|
+
/**
|
|
28
|
+
* The plan a subscription row already points at, for status-only updates:
|
|
29
|
+
* visibility only, so a row on a legacy tenant plan keeps resolving.
|
|
30
|
+
*/
|
|
31
|
+
findExisting(tenantId: string, planId: string): Promise<SubscriptionPlan | null>;
|
|
32
|
+
private collection;
|
|
33
|
+
/** A plan `tenantId` may buy, by key (own plan first if allowed) or by id. */
|
|
34
|
+
find(tenantId: string, ref: {
|
|
35
|
+
planKey?: string;
|
|
36
|
+
planId?: string;
|
|
37
|
+
}): Promise<SubscriptionPlan | null>;
|
|
38
|
+
/**
|
|
39
|
+
* The plan a provider price id is sold as, for `tenantId`: configured
|
|
40
|
+
* price map first, then plan price bindings. The tenant's own plan beats a
|
|
41
|
+
* global one.
|
|
42
|
+
*/
|
|
43
|
+
findByPriceId(tenantId: string, priceId: string): Promise<SubscriptionPlan | null>;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=plans.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plans.d.ts","sourceRoot":"","sources":["../../src/billing/plans.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+BAA+B,CAAC;AAEtE,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,gBAAgB,EACtB,QAAQ,EAAE,MAAM,GACf,OAAO,CAET;AAMD,2EAA2E;AAC3E,wBAAgB,cAAc,CAC5B,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GACzC,MAAM,GAAG,SAAS,CAOpB;AAED;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,OAAO,CAAC;AAEhD,qBAAa,aAAa;IAEtB,OAAO,CAAC,QAAQ,CAAC,EAAE;IACnB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAFX,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,EAC1C,WAAW,GAAE,gBAAyB;IAGzD,OAAO,CAAC,QAAQ;IAMhB;;;OAGG;IACG,YAAY,CAChB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAUnC,OAAO,CAAC,UAAU;IAIlB,8EAA8E;IACxE,IAAI,CACR,QAAQ,EAAE,MAAM,EAChB,GAAG,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GACzC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;IAkBnC;;;;OAIG;IACG,aAAa,CACjB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC;CAiCpC"}
|