@owlmeans/server-payment 0.1.18-rc.10 → 0.1.18-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -26
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +301 -49
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +46 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +126 -4
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +48 -2
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +66 -3
- package/build/consts.js.map +1 -1
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +16 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +13 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +5 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +84 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +5 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +25 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +225 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +43 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +479 -138
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +255 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +17 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +125 -87
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +5 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +40 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +27 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +123 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +42 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +175 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +21 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +91 -18
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +392 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +31 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +43 -7
- package/build/utils.js.map +1 -1
- package/package.json +16 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +145 -7
- package/src/consts.ts +79 -3
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +22 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +94 -18
- package/src/observer.ts +31 -11
- package/src/plan.ts +89 -0
- package/src/plugins/estimate.ts +298 -0
- package/src/plugins/events.ts +544 -123
- package/src/plugins/portal.ts +296 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +133 -88
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +69 -7
- package/src/service.ts +152 -18
- package/src/subscription.ts +224 -0
- package/src/sync.ts +109 -17
- package/src/types.ts +456 -31
- package/src/usage.ts +453 -0
- package/src/utils.ts +78 -10
- package/tests/checkout.spec.ts +128 -83
- package/tests/context.ts +91 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +218 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +789 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +17 -3
- package/tests/sync.spec.ts +88 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- package/tests/webhook-manager.spec.ts +152 -0
package/README.md
CHANGED
|
@@ -1,43 +1,126 @@
|
|
|
1
1
|
# @owlmeans/server-payment
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Server-side payments for OwlMeans applications: a Stripe gateway embedded in the backend
|
|
4
|
+
(amount and quantity checkout, subscriptions, the customer portal), a subscription store that
|
|
5
|
+
understands the whole Stripe lifecycle, a usage ledger for counted plan limits, the entitlement
|
|
6
|
+
service that resolves what an organization entity may do, and the two gate services that refuse a
|
|
7
|
+
request before its handler runs. The contracts — plans, limits, promos, the entitlement view, the
|
|
8
|
+
refusal errors — live in `@owlmeans/payment`.
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
register completion callbacks on the payment observer. Amount policies are validated at declaration
|
|
9
|
-
time. Stripe Checkout is always authoritative: completion is idempotent by session id and an
|
|
10
|
-
amount checkout is fulfilled only after its paid currency and subtotal match its signed-in metadata.
|
|
10
|
+
## Declare the catalogue
|
|
11
11
|
|
|
12
12
|
```typescript
|
|
13
|
-
import { CheckoutPricingMode, PlanDuration, ProductType } from '@owlmeans/payment'
|
|
13
|
+
import { CheckoutPricingMode, LimitKind, LimitWindow, PlanDuration, ProductType } from '@owlmeans/payment'
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
declarePaymentPlan, declarePaymentProduct, portalBranding, stripeSecrets,
|
|
16
16
|
} from '@owlmeans/server-payment'
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
stripeSecrets(cfg, { api: '/secrets/stripe-key' }) // `webhook` is an optional override
|
|
19
|
+
portalBranding(cfg, { returnUrl: 'https://app.example.com/billing', headline: 'Example' })
|
|
20
|
+
|
|
21
|
+
declarePaymentProduct(cfg, { sku: 'app-plans', type: ProductType.Service, services: ['app'], name: 'Plans' })
|
|
22
|
+
declarePaymentPlan(cfg, {
|
|
23
|
+
productSku: 'app-plans', sku: 'free', duration: PlanDuration.Monthly, rank: 0, free: true, price: 0,
|
|
24
|
+
capabilities: [{ scope: 'feature', permissions: { basic: true } }],
|
|
25
|
+
limits: { seats: { kind: LimitKind.Occupancy, limit: 1 }, exports: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 3 } },
|
|
21
26
|
})
|
|
22
27
|
declarePaymentPlan(cfg, {
|
|
23
|
-
productSku: 'app-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
|
|
28
|
-
},
|
|
28
|
+
productSku: 'app-plans', sku: 'pro-monthly', duration: PlanDuration.Monthly, rank: 10, price: 20,
|
|
29
|
+
recurring: { interval: 'month' },
|
|
30
|
+
capabilities: [{ scope: 'feature', permissions: { basic: true, whitelabel: true } }],
|
|
31
|
+
limits: { seats: { kind: LimitKind.Occupancy, limit: 5 }, exports: { kind: LimitKind.Window, window: LimitWindow.Day, limit: 20 } },
|
|
29
32
|
})
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
declarePaymentProduct(cfg, { sku: 'app-credits', type: ProductType.Consumable, services: ['app'], name: 'Credits' })
|
|
35
|
+
declarePaymentPlan(cfg, {
|
|
36
|
+
productSku: 'app-credits', sku: 'app-credit-unit', duration: PlanDuration.Consumable, price: 0.02,
|
|
37
|
+
pricingMode: CheckoutPricingMode.Amount,
|
|
38
|
+
amountPolicy: { currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
|
|
39
|
+
presetsMinor: [1_000, 5_000], fixedMinor: 0, rateBps: 200 },
|
|
34
40
|
})
|
|
35
41
|
```
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
`declarePaymentPlan` validates each declaration before recording it; `assertPlanDeclarations` checks
|
|
44
|
+
the catalogue as a whole (one free plan per rank, no two paid plans of a product at one rank) and runs
|
|
45
|
+
when the gateway initializes. A limit key may use a different kind on different plans — each kind keeps
|
|
46
|
+
its own counter window, so a lifetime count still sticks to the entity when it moves to a monthly plan.
|
|
47
|
+
|
|
48
|
+
## Wire the services
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import {
|
|
52
|
+
appendPaymentGatewayService, entitlements, gateway, observer, paymentGateEntrypoints,
|
|
53
|
+
} from '@owlmeans/server-payment'
|
|
54
|
+
|
|
55
|
+
appendPaymentGatewayService(context) // the process that owns Stripe
|
|
56
|
+
appendPaymentGatewayService(context, { manage: false }) // a process that only reads entitlements
|
|
57
|
+
export const serverBindings = [...paymentGateEntrypoints]
|
|
58
|
+
|
|
59
|
+
observer(context).onTopUp(async completion => { /* credit, idempotent by completion.externalId */ })
|
|
60
|
+
observer(context).onSubscription(async event => { /* idempotent by event.eventKey */ })
|
|
61
|
+
observer(context).onRefund(async event => { /* claw back, idempotent by event.eventKey */ })
|
|
62
|
+
observer(context).onDispute(async event => { /* … */ })
|
|
63
|
+
observer(context).onPaymentFailed(async event => { /* notify */ })
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A managed gateway brings Stripe to the declared state at boot — products and prices, one
|
|
67
|
+
customer-portal configuration, one webhook endpoint at this deployment's public URL (its signing
|
|
68
|
+
secret stored in `payment-webhook`) — and each step is fingerprinted, so an unchanged deployment
|
|
69
|
+
makes no Stripe call.
|
|
70
|
+
|
|
71
|
+
Several deployments of one service may share a Stripe account (typically every test-mode
|
|
72
|
+
deployment), each with its own database and URL — and a deployment's identity is its webhook URL,
|
|
73
|
+
even an undeliverable local one.
|
|
74
|
+
|
|
75
|
+
- **Webhook endpoint.** A deployment owns exactly the endpoint at its own URL: it deletes an
|
|
76
|
+
endpoint only when its own `payment-webhook` rows name it — the endpoint of a URL it has moved away
|
|
77
|
+
from — and never one it merely finds at another URL, whatever its metadata says. The endpoint of a
|
|
78
|
+
retired deployment is removed by hand in the Stripe dashboard; one deleted from outside comes back
|
|
79
|
+
on the next forced `resync`.
|
|
80
|
+
- **Portal configuration.** Each deployment has its own, tagged
|
|
81
|
+
`{ owlmeans: 'payment', service, deployment: <webhook URL> }`. It updates the configuration its
|
|
82
|
+
`portal:<service>` fingerprint row names, unless that one is tagged for another deployment, and
|
|
83
|
+
without a row adopts only a configuration carrying exactly its own tag. Stripe cannot delete a
|
|
84
|
+
portal configuration, so one a deployment can no longer identify stays in the account and a new
|
|
85
|
+
one is created.
|
|
86
|
+
|
|
87
|
+
## Use it
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// Checkout and the portal (in-process; resolve the stable entityId at your authenticated boundary)
|
|
91
|
+
const url = await gateway(ctx).createLink(ctx, { productSku: 'app-plans', planSku: 'pro-monthly', entityId, service: 'app', successUrl })
|
|
92
|
+
const portal = await gateway(ctx).portalLink(ctx, entityId, { flow: PortalFlow.Change, planSku: 'pro-monthly', returnUrl })
|
|
93
|
+
await gateway(ctx).grantInternalPlan(ctx, entityId, 'free') // when the organization is created
|
|
94
|
+
|
|
95
|
+
// What the entity may do
|
|
96
|
+
const view = await entitlements(ctx).entitlements(entityId) // the EntitlementView a UI renders
|
|
97
|
+
await entitlements(ctx).hasCapability(entityId, 'feature:whitelabel')
|
|
98
|
+
|
|
99
|
+
// Spend a counted allowance — key it by the record it pays for
|
|
100
|
+
await entitlements(ctx).consume({ entityId, limitKey: 'exports', eventKey: `export:${reportId}`, ref: reportId })
|
|
101
|
+
await entitlements(ctx).release({ entityId, limitKey: 'exports', eventKey: `export:${reportId}` })
|
|
102
|
+
|
|
103
|
+
// Periodic repair
|
|
104
|
+
await reconcileAll(ctx, { freePlanSku: 'free' })
|
|
105
|
+
await entitlements(ctx).reconcileOccupancy(entityId, 'seats', liveSeatCount)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Routes declare what they need; the gates refuse before the handler:
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
protocol(route(...), contract(...), { gate: { alias: ENTITLEMENT_GATE, params: ['feature:whitelabel'] } })
|
|
112
|
+
protocol(route(...), contract(...), { gate: { alias: LIMIT_GATE, params: [formatLimitParam('seats')] } })
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`CapabilityRequired` and `LimitExhausted` extend `AuthForbidden`, so an HTTP boundary answers 403.
|
|
116
|
+
The limit gate never consumes; the handler does.
|
|
117
|
+
|
|
118
|
+
## Routes
|
|
119
|
+
|
|
120
|
+
`paymentGateEntrypoints` binds the immutable `paymentGate` tree: the public Stripe webhook
|
|
121
|
+
(`POST /payment-gate/webhook/:paygate`, verified by signature over the raw body), and two
|
|
122
|
+
Ed25519-guarded repairs — `resync` (products, portal, webhook endpoint) and `resyncSubscriptions`
|
|
123
|
+
(re-reads every live Stripe subscription, answering `{ scanned, updated }`).
|
|
41
124
|
|
|
42
125
|
<!-- owlmeans:agent-guidance:start -->
|
|
43
126
|
## Agent guidance
|
|
@@ -47,7 +130,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
47
130
|
your project's skill store (`.agents/skills/`):
|
|
48
131
|
|
|
49
132
|
```sh
|
|
50
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
133
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.29
|
|
51
134
|
```
|
|
52
135
|
|
|
53
136
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-payment",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.12",
|
|
5
|
+
"generatedAt": "2026-09-18T19:03:53.989Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -7,77 +7,329 @@ user-invocable: false
|
|
|
7
7
|
|
|
8
8
|
# @owlmeans/server-payment
|
|
9
9
|
|
|
10
|
-
**Install:** `bun add @owlmeans/server-payment@^0.1.18-rc.
|
|
10
|
+
**Install:** `bun add @owlmeans/server-payment@^0.1.18-rc.12`
|
|
11
11
|
|
|
12
|
-
Public MIT package
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Public MIT package. It embeds Stripe into an application backend and owns everything between
|
|
13
|
+
Stripe and an entity's entitlements: the subscription store, one-time fulfillments, the usage
|
|
14
|
+
ledger of counted limits, plan resolution, the two gate services and Stripe's own configuration
|
|
15
|
+
(products, prices, the portal, the webhook endpoint). The application owns its catalogue, what a
|
|
16
|
+
purchase is worth to it (credits, provisioning) and its side effects. Contracts — plans, limits,
|
|
17
|
+
promos, the entitlement view, refusals — are `@owlmeans/payment`; the model across packages is
|
|
18
|
+
the `entitlements` skill.
|
|
15
19
|
|
|
16
20
|
## Wiring
|
|
17
21
|
|
|
18
22
|
```typescript
|
|
19
|
-
stripeSecrets(cfg, { api: '/secrets/stripe-key'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
stripeSecrets(cfg, { api: '/secrets/stripe-key' }) // webhook secret: optional override
|
|
24
|
+
portalBranding(cfg, { returnUrl: 'https://app.example.com/billing', headline: 'Example' })
|
|
25
|
+
declarePaymentPricing(cfg, { // absent entirely: today's fixed behaviour, unchanged
|
|
26
|
+
tax: { automatic: true, behavior: TaxBehavior.Exclusive, collectTaxId: true, estimate: true },
|
|
27
|
+
currency: { adaptive: true, estimate: true },
|
|
28
|
+
})
|
|
29
|
+
declarePaymentProduct(cfg, { sku: 'app-plans', type: ProductType.Service, services: ['app'], name: 'Plans' })
|
|
30
|
+
declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'free', rank: 0, free: true, price: 0, … })
|
|
31
|
+
declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'pro-monthly', rank: 10, price: 20,
|
|
32
|
+
recurring: { interval: 'month' }, capabilities: […], limits: { seats: {…} } })
|
|
33
|
+
|
|
34
|
+
appendPaymentGatewayService(context) // the process that talks to Stripe
|
|
35
|
+
appendPaymentGatewayService(context, { manage: false }) // a worker that only reads entitlements
|
|
27
36
|
export const serverBindings = [...paymentGateEntrypoints]
|
|
28
|
-
observer(context).
|
|
37
|
+
observer(context).onSubscription(async event => { /* keyed by event.eventKey */ })
|
|
29
38
|
```
|
|
30
39
|
|
|
31
|
-
`
|
|
32
|
-
`
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
- `appendPaymentGatewayService` registers seven resources, the catalogue service
|
|
41
|
+
(`PAYMENT_SERVICE`), the completion observer, the gateway (`GATEWAY_SERVICE`), the capability
|
|
42
|
+
gate (`ENTITLEMENT_GATE`), the limit gate (`LIMIT_GATE`) and the entitlement service
|
|
43
|
+
(`ENTITLEMENT_SERVICE`), each only when not registered already.
|
|
44
|
+
- **`manage: false`** registers the same surface with no Stripe client: no bootstrap at init, and
|
|
45
|
+
`createLink`, `portalLink`, `resyncSubscription`, `resyncAll` and the webhook route throw
|
|
46
|
+
`PaygateError('unmanaged')`. `grantInternalPlan` and the whole entitlement service work, because
|
|
47
|
+
they are Mongo only.
|
|
48
|
+
- Gateway methods take the stable `entityId`. A public handler resolves it from its request
|
|
49
|
+
entity first; protocol bodies carry `entitySlug`.
|
|
50
|
+
- A value in `stripeSecrets` / `portalBranding` that starts with `/` is read from that file at
|
|
51
|
+
boot, and a missing file fails the boot — so leave `webhook` out unless the file exists.
|
|
52
|
+
|
|
53
|
+
## Declaring plans
|
|
54
|
+
|
|
55
|
+
- **`rank`** orders a product's plans; a higher rank is an upgrade. A safe integer `>= 0`; absent
|
|
56
|
+
reads as `0`.
|
|
57
|
+
- **The free plan is a plan**: `free: true`, `price: 0`, no `gateways`. It is never synchronized to
|
|
58
|
+
Stripe and never checked out; an entity without an entitling subscription resolves to it.
|
|
59
|
+
- `gateways` names the paygates a plan is sold through; absent inherits the product's.
|
|
60
|
+
- `declarePaymentPlan` refuses before recording: a bad rank or a priced/gatewayed free plan
|
|
61
|
+
(`PlanRankConflict`), a capability set under the reserved `limit` scope, or a limit that is
|
|
62
|
+
malformed — a window limit without its window, a lifetime/occupancy limit with one, a ceiling that
|
|
63
|
+
is not a safe integer, a promo whose `until` is not a `Date` (`LimitMisdeclared('<key>:<reason>')`).
|
|
64
|
+
- `assertPlanDeclarations` runs when the gateway initializes and fails the boot on two free plans
|
|
65
|
+
at one rank, or two paid non-consumable plans of one product at one rank.
|
|
66
|
+
- A limit key may use a different kind on different plans (lifetime on the free plan, a monthly window
|
|
67
|
+
on a paid one). The counter window is derived from the kind, so each kind counts separately and a
|
|
68
|
+
lifetime count stays with the entity through upgrades, downgrades and cancellations.
|
|
69
|
+
|
|
70
|
+
## Records
|
|
71
|
+
|
|
72
|
+
None declares an ObjectId reference: `entityId` is an organization key and every other id is Stripe's.
|
|
73
|
+
|
|
74
|
+
| Collection | One row per | Indexes |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `payment-paygate-customer` | Stripe customer (`deletedAt` once deleted) | `{paygate, externalId}` unique · `{paygate, entityId}` · `{paygate, profileId}` |
|
|
77
|
+
| `payment-subscription` | subscription: `sub_…`, `free:<entityId>`, `internal:<planSku>:<entityId>` | `{paygate, externalId}` unique · `{entityId, status, rank:-1}` · `{entityId, planSku}` · `{paygate, customerId}` · `{paygate, itemId}` sparse · `{paygate, status, updatedAt}` |
|
|
78
|
+
| `payment-fulfillment` | one-time checkout session | `{paygate, externalId}` unique · `{paygate, paymentIntentId}` sparse · `{paygate, chargeId}` sparse · `{entityId, createdAt:-1}` |
|
|
79
|
+
| `payment-webhook` | managed webhook endpoint (`secret` is `secure: true`) | `{paygate, service, url}` unique |
|
|
80
|
+
| `payment-usage` | usage event — the ledger | `{entityId, limitKey, eventKey}` unique · `{entityId, limitKey, window}` · `{entityId, limitKey, ref}` sparse · `{entityId, createdAt:-1}` |
|
|
81
|
+
| `payment-usage-counter` | (entity, limit, window) projection | `{entityId, limitKey, window}` unique |
|
|
82
|
+
| `payment-fingerprint` | synchronized product (`<productSku>`) or portal (`portal:<service>`) | `{sku}` unique |
|
|
83
|
+
|
|
84
|
+
Every stored property is declared in the record schema: the resource writes a property its schema
|
|
85
|
+
does not know as a string, and the collection validator rejects it.
|
|
86
|
+
|
|
87
|
+
## Checkout and fulfillment
|
|
88
|
+
|
|
89
|
+
- `Amount`: one inline `price_data` item for the synchronized product, quantity 1, its `tax_behavior`
|
|
90
|
+
the declared `PricingPolicy.tax.behavior` (default `'exclusive'`), no promotion codes; the net
|
|
91
|
+
`amountMinor` and the grossed-up `chargeAmountMinor` travel in the session metadata. `Quantity`:
|
|
92
|
+
the reusable price under the plan lookup key, adjustable quantity. Subscription: `planSku` (else
|
|
93
|
+
the product's first recurring plan), quantity 1.
|
|
94
|
+
- A plan the paygate does not sell — a free plan, another gateway's plan — is refused
|
|
95
|
+
(`ProductError`). `checkoutOptions(policy, promotions)` puts automatic tax, billing address
|
|
96
|
+
collection, tax-id collection and Adaptive Pricing (`adaptive_pricing`) on the session, each
|
|
97
|
+
independently, exactly as `PricingPolicy` declares them — an undeclared policy reproduces the
|
|
98
|
+
fixed pre-policy session (automatic tax + tax-id collection on, no Adaptive Pricing).
|
|
99
|
+
- `checkout.session.completed` and `…async_payment_succeeded` fulfill only a `payment`-mode session
|
|
100
|
+
with `payment_status === 'paid'`; an amount session must match its metadata currency and subtotal.
|
|
101
|
+
Adaptive Pricing never disturbs this: Session/webhook amounts stay in the integration currency
|
|
102
|
+
(USD here) whatever currency the buyer paid in.
|
|
103
|
+
- A pending `payment-fulfillment` row (with `paymentIntentId` and `invoiceId`) is written before
|
|
104
|
+
observers run; `fulfilledAt` is stamped after they succeed. An observer throw escapes so Stripe
|
|
105
|
+
redelivers, and the observer must stay idempotent by `externalId` — a crash after its side effect
|
|
106
|
+
and before the stamp redelivers the same session.
|
|
107
|
+
|
|
108
|
+
## Price sync and the tax estimate
|
|
109
|
+
|
|
110
|
+
- `syncStripeProducts` gives a matching, still-`unspecified` price the declared `tax.behavior` IN
|
|
111
|
+
PLACE (`prices.update`; Stripe forbids changing a price already `exclusive`/`inclusive`) and a
|
|
112
|
+
fresh one on creation; a price already carrying the OPPOSITE behavior is deactivated and replaced,
|
|
113
|
+
the same as any other catalogue mismatch. Before an in-place update it checks the account's own
|
|
114
|
+
tax-settings default (`stripe.tax.settings.retrieve`) and skips the update — logging why — when
|
|
115
|
+
that default would make the price behave the other way for existing renewals, unless
|
|
116
|
+
`declarePaymentPricing({ stripe: { migrateUnspecifiedPrices: true } })` opts in. The behavior is
|
|
117
|
+
part of the sync fingerprint, so declaring or changing it re-syncs every product exactly once.
|
|
118
|
+
- `GatewayService.estimatePrice(ctx, { entityId, productSku, planSku?, country? })` (`estimatePrice`
|
|
119
|
+
in `plugins/estimate.ts`) is a Stripe Tax calculation (plus, with `currency.estimate` and
|
|
120
|
+
`currency.adaptive` both on, an FX Quotes lookup) for one product/plan's reference amount, at a
|
|
121
|
+
billing country the request names or the entity's paygate customer's. **$0.05 per distinct**
|
|
122
|
+
(currency, country, amount, tax code, behavior, matching tax ids) **combination** — cached per
|
|
123
|
+
gateway-service instance (never module-level: several instances in one process, as in tests, must
|
|
124
|
+
never share hits) for 24h; a rate-limit or connection error is never cached. Its status
|
|
125
|
+
(`TaxEstimateStatus`) covers a resolved rate, EU/GB reverse charge (a saved tax id whose OWN
|
|
126
|
+
country matches the one being estimated), no tax, "compute at checkout" (an unsupported
|
|
127
|
+
jurisdiction, or an invalid-request error such as a US address with no postal code), and
|
|
128
|
+
"choose a country" (neither the request nor the customer names one — zero Stripe calls). The FX
|
|
129
|
+
Quotes call is a Stripe PREVIEW endpoint (`STRIPE_FX_QUOTES_API_VERSION`, `stripe.rawRequest`),
|
|
130
|
+
and its failure only drops the estimate's `local` field, never the tax half.
|
|
131
|
+
|
|
132
|
+
## The subscription store
|
|
35
133
|
|
|
36
|
-
|
|
134
|
+
- **The effective plan** is the highest-ranked row in `ENTITLING_STATUSES` (catalogue rank; the
|
|
135
|
+
newest on a tie), else the declared free plan, else `PlanRequired`. An internal row with a past
|
|
136
|
+
`periodEnd` no longer entitles; a row naming a plan the catalogue lost is skipped (logged once).
|
|
137
|
+
- `mapStatus`: `active`→Active, `trialing`→Trial, `past_due`→PastDue (entitled, flagged),
|
|
138
|
+
`unpaid`/`paused`→Suspended, `incomplete`→Created, `incomplete_expired`→Ended, `canceled`→Canceled.
|
|
139
|
+
`pause_collection` on an active or trialing subscription is Suspended with `pausedAt`.
|
|
140
|
+
- **State is written before observers, and classified against what observers were last told.**
|
|
141
|
+
`propagated` holds the last propagated `{planSku, rank, status, cancelAtPeriodEnd, pausedAt,
|
|
142
|
+
renewedInvoiceId}`; it and `lastEventId` are stamped only after every observer succeeded. An
|
|
143
|
+
observer that throws therefore sees the same change again when Stripe retries, while an observer
|
|
144
|
+
that reads entitlements already sees the new plan.
|
|
145
|
+
- Classification, first match: `created` (the first propagated state that entitles — an
|
|
146
|
+
`incomplete` subscription reports nothing yet) · `canceled` · `paused` · `resumed` (pause cleared,
|
|
147
|
+
or suspended → entitling) · `upgraded`/`downgraded` by rank on a plan change (equal rank reads
|
|
148
|
+
`upgraded`) · `cancel-scheduled` · `cancel-undone` · `renewed` (a paid cycle invoice, once per
|
|
149
|
+
invoice) · `past-due` · `suspended` · `trial-ending` · otherwise nothing is reported.
|
|
150
|
+
- A repeated delivery (`lastEventId`) is ignored, and a webhook payload older than the stored state
|
|
151
|
+
(`event.created` before `syncedAt`) is not applied. State Stripe is asked for (invoice events,
|
|
152
|
+
resync) is always fresh.
|
|
153
|
+
- `grantInternalPlan(ctx, entityId, planSku, { force?, periodEnd? })` upserts an internal row,
|
|
154
|
+
keeps its `createdAt` (what promos are grandfathered against), and reports `created` once. A
|
|
155
|
+
plan that is not free needs `force`.
|
|
156
|
+
- `resyncSubscription(ctx, { entityId | subscriptionId })` retrieves and applies each subscription
|
|
157
|
+
exactly as a webhook would; one Stripe no longer has is canceled. `resyncAll` covers every
|
|
158
|
+
non-terminal Stripe row. Both answer how many rows changed.
|
|
37
159
|
|
|
38
|
-
|
|
39
|
-
`price_data` item for the synchronized Stripe Product, quantity 1, tax-exclusive, with no
|
|
40
|
-
adjustable quantity and no promotion codes. No reusable Stripe Price is created; a superseded
|
|
41
|
-
quantity Price under the plan lookup key is deactivated.
|
|
42
|
-
- `Quantity`: load the reusable Stripe Price and create an adjustable item from the quantity
|
|
43
|
-
policy. This remains supported so existing consumers and already-open Checkout sessions work.
|
|
44
|
-
- Subscription: reusable recurring Stripe Price, quantity 1, with billing portal management.
|
|
160
|
+
## The usage ledger
|
|
45
161
|
|
|
46
|
-
|
|
47
|
-
|
|
162
|
+
`payment-usage` events are the source of truth; `payment-usage-counter` is the projection that
|
|
163
|
+
admission reads.
|
|
48
164
|
|
|
49
|
-
|
|
165
|
+
- **`consume({ entityId, limitKey, eventKey, amount?, ref?, reason? })`** — the counter is
|
|
166
|
+
incremented FIRST, by one conditional upsert that matches only while `used <= limit - amount`,
|
|
167
|
+
then the event is appended. No room ⇒ `LimitExhausted` with `used`, `limit`, `resetsAt`, and no
|
|
168
|
+
event. **Invariant: the counter may over-count, never over-admit.**
|
|
169
|
+
- **An event key is idempotent.** A key already written replays its outcome (`replayed: true`)
|
|
170
|
+
before admission is asked — at the ceiling too; a concurrent duplicate that loses the unique event
|
|
171
|
+
undoes its increment and replays the winner. A released key stays released.
|
|
172
|
+
- Key a consumption by the record it pays for (`eventKey: <purpose>:<recordId>`, `ref: recordId`).
|
|
173
|
+
`consumption(entityId, limitKey, eventKey)` answers whether that event still holds a unit;
|
|
174
|
+
`consumptionByRef(entityId, limitKey, ref)` answers the newest active event of a record — for a
|
|
175
|
+
unit acquired under a fresh key per cycle.
|
|
176
|
+
- **`release`** appends `release:<eventKey>` first, decrements only when that append was new (never
|
|
177
|
+
below zero), then stamps `releasedAt`. The default amount is the consumed amount.
|
|
178
|
+
- A lapsed promo makes the ceiling `0`; an undeclared key is `LimitUnknown`.
|
|
179
|
+
- **`reconcileCounters(entityId?)`** recomputes every counter from the ledger sum, deletes past
|
|
180
|
+
day/month counters with no events (never lifetime or occupancy), and creates the current window
|
|
181
|
+
counter of every window limit.
|
|
182
|
+
- **`reconcileOccupancy(entityId, key, actual)`** sets an occupancy counter to the live count and
|
|
183
|
+
keeps the ledger equal to it with one adjusting event per UTC day (`reconcile:<key>:<YYYY-MM-DD>`,
|
|
184
|
+
adjusted in place by a later run that day). `overSince` is set when over the limit and cleared
|
|
185
|
+
within it. It never stops anything — what happens to an entity over its limit is the
|
|
186
|
+
application's decision.
|
|
187
|
+
- `reconcileEntity(ctx, entityId, { freePlanSku?, resync? })` and `reconcileAll(ctx, { freePlanSku?,
|
|
188
|
+
resync?, entities? })` combine the optional Stripe resync, the free-plan backfill and the counter
|
|
189
|
+
repair; a failing entity is counted, never fatal.
|
|
50
190
|
|
|
51
|
-
|
|
52
|
-
nothing until `payment_status === 'paid'`. Amount fulfillment compares Stripe currency and actual
|
|
53
|
-
`amount_subtotal` with the server-created metadata. Completion is discriminated:
|
|
191
|
+
## Entitlements and gates
|
|
54
192
|
|
|
55
|
-
- `
|
|
56
|
-
|
|
193
|
+
- `entitlements(ctx)` → `effectivePlan`, `entitlements` (the `EntitlementView`, with
|
|
194
|
+
`plan.subscribedAt` = the subscription's `createdAt`), `hasCapability`, `limitState`, the ledger
|
|
195
|
+
operations above. It takes no context argument and never calls Stripe.
|
|
196
|
+
- **Capability gate** (`makeCapabilityGate(alias = ENTITLEMENT_GATE, { productSkus?, resolveEntity?,
|
|
197
|
+
requirePermission? })`, also `makeEntitlementGate`): authentication and an entity are required
|
|
198
|
+
(`AuthForbidden`); it passes when the view grants ANY parameter. The plan is the authority — a
|
|
199
|
+
token permission set to `false` denies, a token grant alone never allows, and `requirePermission`
|
|
200
|
+
(IAM `hasPermission`) is off by default because platform tokens carry no permissions. An
|
|
201
|
+
unreadable store refuses. Refusal: `CapabilityRequired(params)`.
|
|
202
|
+
- **Limit gate** (`makeLimitGate(alias = LIMIT_GATE, { resolveEntity? })`): passes when ANY
|
|
203
|
+
`limit:<key>[>=n]` has `remaining >= n`; malformed and undeclared parameters are skipped, a store
|
|
204
|
+
error refuses. Refusal: `LimitExhausted` for the first declared key. **It never consumes.**
|
|
205
|
+
- The two gates are two aliases because an entrypoint's gates are collected per gate service.
|
|
206
|
+
`entitlementsOf(ctx, entityId, productSkus?)` returns the capability sets in force.
|
|
57
207
|
|
|
58
|
-
|
|
59
|
-
and gets `fulfilledAt` only after they succeed; observer failures escape so Stripe retries. Consumer
|
|
60
|
-
callbacks must still use `externalId` as their own append-only event key, covering a crash after the
|
|
61
|
-
side effect and before `fulfilledAt`. A session without pricing-mode metadata is a legacy quantity
|
|
62
|
-
session and remains fulfillable.
|
|
208
|
+
## Stripe self-management
|
|
63
209
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
210
|
+
Runs in `initialize()` of a managed gateway, after the context is ready; each step is independent
|
|
211
|
+
and logged when it fails.
|
|
212
|
+
|
|
213
|
+
- **Products and prices** of plans sold through Stripe, fingerprinted per product. An amount plan
|
|
214
|
+
owns no reusable price.
|
|
215
|
+
- **The portal configuration** (`ensurePortalConfiguration`): customer update (email, address, tax
|
|
216
|
+
id), invoice history, payment method update, cancellation at period end without proration, and
|
|
217
|
+
price switching between every product's active recurring prices with prorations — both
|
|
218
|
+
subscription features off when nothing recurring is sold. `portalBranding` supplies the business
|
|
219
|
+
profile and default return URL; fingerprint `portal:<service>` holds its id, and its hash covers
|
|
220
|
+
the catalogue (including the declared tax behavior — a price `sync.ts` replaces refreshes the
|
|
221
|
+
portal's `products[].prices` too), the branding and the deployment key, so an unchanged
|
|
222
|
+
declaration makes no call.
|
|
223
|
+
- **Each deployment owns its own portal configuration**, tagged
|
|
224
|
+
`{ owlmeans: 'payment', service, deployment: webhookUrlOf(ctx) }` (`STRIPE_DEPLOYMENT_KEY`) — the
|
|
225
|
+
webhook URL keys it even when undeliverable (local). The configuration the fingerprint row names
|
|
226
|
+
is retrieved and updated, unless its metadata tags it for another deployment or service, which is
|
|
227
|
+
never overwritten. Without a usable row, only an active configuration tagged with exactly this
|
|
228
|
+
service and deployment key is adopted (how a forced `resync`, which clears fingerprints, finds its
|
|
229
|
+
own again); a configuration carrying only the service label is not. Stripe cannot delete portal
|
|
230
|
+
configurations, so one a deployment can no longer identify stays behind and a new one is created.
|
|
231
|
+
- **`portalLink(ctx, entityId, { flow, planSku?, returnUrl })`**: a customer is required
|
|
232
|
+
(`PortalUnavailable('customer')`); `Manage` opens the home, `PaymentMethod` the payment form;
|
|
233
|
+
`Cancel`, `Update` and `Change` need an entitling Stripe subscription
|
|
234
|
+
(`PortalUnavailable('subscription')`), and `Change` confirms its stored item switching to
|
|
235
|
+
`planSku`'s price as exactly one item. Deep links return with `after_completion: redirect`.
|
|
236
|
+
`PortalUnavailable` declares 409, so a portal asked of an entity with nothing to manage answers
|
|
237
|
+
409 Conflict, never 500.
|
|
238
|
+
- **The webhook endpoint** (`ensureWebhookEndpoint`): at `webhookUrlOf(ctx)` — this service's
|
|
239
|
+
public URL plus the webhook route — on the API version read back from the client
|
|
240
|
+
(`apiVersionOf`, the SDK default; never a literal), subscribed to `WEBHOOK_EVENTS`. A URL that is
|
|
241
|
+
not https on a public dotted host is skipped. An unchanged `{url, apiVersion, events}` hash makes
|
|
242
|
+
no call; `force` (the `resync` route) first verifies the stored endpoint exists and recreates one
|
|
243
|
+
deleted from outside; changed events update in place; a changed API version deletes and recreates
|
|
244
|
+
(the version is create-only); an endpoint already at this exact URL that no row names is replaced
|
|
245
|
+
(its secret is unknowable). The secret — returned only by create — is stored field-encrypted where
|
|
246
|
+
the database has a key.
|
|
247
|
+
- **A deployment is its webhook URL, and deletes only what it remembers.** Deployments of one
|
|
248
|
+
service may share a Stripe account, each with its own database and URL. A `payment-webhook` row of
|
|
249
|
+
the same paygate and service at another URL is a URL this deployment has left: after the current
|
|
250
|
+
endpoint is in place, the endpoint that row names is deleted (already gone is fine) and the row
|
|
251
|
+
removed. An endpoint at another URL that no row names belongs to another deployment and is never
|
|
252
|
+
deleted; the `{ owlmeans: 'payment', service }` metadata on created endpoints is an operator's
|
|
253
|
+
label, never deletion authority. A retired deployment's endpoint is removed by hand. The portal
|
|
254
|
+
configuration follows the same identity (above).
|
|
255
|
+
- **Signature verification** tries the configured `webhook` override, then the stored secret
|
|
256
|
+
(`stripeWebhookSecrets`); none configured is `WebhookSetupError('secret')`.
|
|
257
|
+
- **Do not bump the Stripe SDK major**: the pinned API version reads `current_period_*`,
|
|
258
|
+
`invoice.subscription` and `charge.invoice` at the top level, and they move in later versions.
|
|
259
|
+
|
|
260
|
+
## Event dispatch
|
|
261
|
+
|
|
262
|
+
| Event | Persisted | Observer · event key |
|
|
263
|
+
|---|---|---|
|
|
264
|
+
| `customer.created` / `.updated` | customer upsert | — |
|
|
265
|
+
| `customer.deleted` | customer `deletedAt` | — |
|
|
266
|
+
| `checkout.session.completed` / `.async_payment_succeeded` | paid payment session ⇒ fulfillment, `fulfilledAt` after observers | `onTopUp` · `externalId` (session id) |
|
|
267
|
+
| `checkout.session.async_payment_failed` | fulfillment `failedAt` | `onPaymentFailed {kind:'checkout'}` · `payment-failed:<session>:0` |
|
|
268
|
+
| `checkout.session.expired` | unfulfilled fulfillment purged | — |
|
|
269
|
+
| `customer.subscription.created` / `.updated` / `.pending_update_applied` / `.pending_update_expired` / `.paused` / `.resumed` | subscription applied | `onSubscription` · classified |
|
|
270
|
+
| `customer.subscription.deleted` | applied as Canceled + `endedAt` | `canceled` |
|
|
271
|
+
| `customer.subscription.trial_will_end` | applied | `trial-ending` |
|
|
272
|
+
| `invoice.paid` | cycle invoice ⇒ subscription re-read, applied as a renewal; otherwise `latestInvoiceId` | `renewed` |
|
|
273
|
+
| `invoice.payment_failed` / `.payment_action_required` | subscription re-read and applied | classified (`past-due`), then `onPaymentFailed {kind:'invoice', attempt, nextAttemptAt, actionRequired}` · `payment-failed:<invoice>:<attempt>` |
|
|
274
|
+
| `invoice.upcoming` | nothing (enabled for the application's own use) | — |
|
|
275
|
+
| `invoice.marked_uncollectible` | subscription re-read and applied | classified (`suspended`) |
|
|
276
|
+
| `invoice.voided` | `latestInvoiceId` refreshed | — |
|
|
277
|
+
| `charge.refunded` (each refund of the charge), `refund.created` / `.updated` (succeeded only) | fulfillment `refundedMinor` / `refundedAt` | `onRefund` · `refund:<refund>` |
|
|
278
|
+
| `refund.failed` | — | — |
|
|
279
|
+
| `charge.dispute.created` / `.funds_withdrawn` / `.funds_reinstated` / `.closed` | target `disputedAt`, `disputeStatus` | `onDispute {phase}` · `dispute:<dispute>:<phase>` |
|
|
280
|
+
|
|
281
|
+
A refund or dispute resolves to its target by payment intent (fulfillment), by charge (stored
|
|
282
|
+
charge, the charge's payment intent, else its invoice), by invoice (the subscription whose latest
|
|
283
|
+
invoice it is, else the invoice's subscription). Nothing resolved ⇒ logged, no observer.
|
|
284
|
+
|
|
285
|
+
## Observer API and idempotency keys
|
|
286
|
+
|
|
287
|
+
Callbacks run sequentially and are awaited; a throw escapes so Stripe redelivers. Every callback
|
|
288
|
+
must be idempotent by its key.
|
|
289
|
+
|
|
290
|
+
| Callback | Payload | Key |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| `onTopUp` | `TopUpCompletion` (`amount` / `quantity`) | `externalId` — the session id |
|
|
293
|
+
| `onSubscription` | `SubscriptionEvent {change, previous, current, active, eventKey, invoiceId?, externalEventId?}`; snapshots carry `rank`, `status`, period, `capabilities`, `limits` | `subscription:<id>:created:<createdAt ISO>` · `…:renewed:<invoice>` · `…:upgraded|downgraded:<planSku>` · `…:trial-ending:<trialEnd ISO>` · `…:<change>:<event id>` (`sync-<ISO>` from a resync) |
|
|
294
|
+
| `onRefund` | `RefundEvent {target, amountMinor, refundedTotalMinor, paidMinor?, partial, netAmountMinor?, chargeAmountMinor?, …}` | `refund:<refund>` |
|
|
295
|
+
| `onDispute` | `DisputeEvent {phase, status, amountMinor, …}` | `dispute:<dispute>:<phase>` |
|
|
296
|
+
| `onPaymentFailed` | `PaymentFailedEvent {kind, attempt?, nextAttemptAt?, actionRequired?, …}` | `payment-failed:<session|invoice>:<attempt>` |
|
|
297
|
+
|
|
298
|
+
A proportional claw-back of a top-up uses the net credited value against what was paid:
|
|
299
|
+
`netAmountMinor * refundedTotalMinor / paidMinor`.
|
|
67
300
|
|
|
68
301
|
## Protocols and security
|
|
69
302
|
|
|
70
|
-
`paymentGate` is an immutable protocol tree
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
303
|
+
`paymentGate` is an immutable protocol tree bound by `paymentGateEntrypoints` with
|
|
304
|
+
`bind(protocol, handler)`: `webhook` (`POST /payment-gate/webhook/:paygate`) is public because
|
|
305
|
+
Stripe signs the untouched raw body — never put an application guard on it; `resync` (products,
|
|
306
|
+
portal, webhook endpoint, fingerprints ignored) and `resyncSubscriptions` (`{ scanned, updated }`)
|
|
307
|
+
carry `GUARD_ED25519`, so another service of the deployment triggers them with its own key.
|
|
308
|
+
|
|
309
|
+
## Testing
|
|
75
310
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
311
|
+
Unit specs run a real server context — real catalogue, services and gates — over in-memory
|
|
312
|
+
resources and a fake Stripe that records every SDK call, so "no Stripe call" is an assertion. The
|
|
313
|
+
Mongo-gated spec proves admission under concurrency and the collection validators against a real
|
|
314
|
+
database.
|
|
79
315
|
|
|
80
316
|
## External docs
|
|
81
317
|
|
|
82
318
|
- https://docs.stripe.com/api/checkout/sessions/create — Checkout accepts inline `price_data` with integer minor-unit `unit_amount`; automatic tax is enabled on the Session and amount items are tax-exclusive.
|
|
83
319
|
- https://docs.stripe.com/checkout/fulfillment — Fulfillment must be idempotent, check payment state and support delayed-payment success events rather than trusting completion alone.
|
|
320
|
+
- https://docs.stripe.com/api/webhook_endpoints/create — the signing `secret` is returned only by create; update accepts `enabled_events`, `disabled`, `url`, `description`, `metadata`; `api_version` is create-only.
|
|
321
|
+
- https://docs.stripe.com/api/events/types — the event names `WEBHOOK_EVENTS` subscribes to.
|
|
322
|
+
- https://docs.stripe.com/api/subscriptions/object — statuses `incomplete|incomplete_expired|trialing|active|past_due|canceled|unpaid|paused`; `pause_collection` pauses collection without changing the status; `paused` only after a trial without a payment method; on `2025-02-24.acacia` (stripe-node 17) `current_period_start/end` and `invoice.subscription` are top-level and move in later versions.
|
|
323
|
+
- https://docs.stripe.com/customer-management/portal-deep-links and https://docs.stripe.com/api/customer_portal/sessions/create — `flow_data.type` ∈ `payment_method_update|subscription_cancel|subscription_update|subscription_update_confirm`; `subscription_update_confirm.items` holds exactly one `{ id: <subscription item id>, price, quantity }`; `after_completion` is `redirect|hosted_confirmation|portal_homepage`; the configuration must enable `subscription_update` (with `products[{product, prices[]}]`) and `subscription_cancel`.
|
|
324
|
+
- https://docs.stripe.com/api/customer_portal/configurations/create — `features.{customer_update, invoice_history, payment_method_update, subscription_cancel{enabled, mode, proration_behavior}, subscription_update{enabled, default_allowed_updates, products, proration_behavior}}`, `business_profile`, `default_return_url`, `metadata`; updatable by id, retrievable and listable (`active`, paginated), and never deletable — a configuration can only be deactivated.
|
|
325
|
+
- https://docs.stripe.com/api/tax/calculations/create — `line_items[].tax_behavior` (default `exclusive`), `tax_code`; `customer_details.address_source` ∈ `billing|shipping`; `tax_ids[]` shifts liability (a valid id is never validated for correctness); the response's `tax_breakdown[].tax_rate_details.percentage_decimal` is a STRING (parse it exactly, never `Number(x) * 10_000`) and `.rate_type` ∈ `flat_amount|percentage` (a flat rate never scales with the amount).
|
|
326
|
+
- https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior — a price's `tax_behavior` can be set only from `unspecified`; once `exclusive`/`inclusive` it cannot change, and `inferred_by_currency` (an account tax-settings default) resolves to exclusive for USD/CAD, inclusive otherwise.
|
|
327
|
+
- https://docs.stripe.com/payments/currencies/localize-prices/adaptive-pricing — `adaptive_pricing.enabled` on a Checkout Session; requires the price currency to be a settlement currency; Session/PaymentIntent/webhook amounts stay in the integration currency, with `presentment_details.{presentment_amount, presentment_currency}` alongside them when the buyer paid differently.
|
|
328
|
+
- https://docs.stripe.com/api/fx_quotes/create — a **preview** endpoint (needs a preview `Stripe-Version`, called via `stripe.rawRequest`); `to_currency`/`from_currencies[]`; `lock_duration: 'none'` is free, `five_minutes|hour|day` add a fee (`rate_details.duration_premium`) baked into `exchange_rate`.
|
|
329
|
+
|
|
330
|
+
## Related
|
|
331
|
+
|
|
332
|
+
- [[entitlements]] — the model across packages
|
|
333
|
+
- [[payment]] — the contracts: grammars, window algebra, views, refusals
|
|
334
|
+
- [[web-payment]] — hooks and pieces over the entitlement view
|
|
335
|
+
- [[mongo-resource]] — raw collection access, duplicate-key detection, field locking
|
package/build/actions/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,2BAA2B,CAAA"}
|
package/build/actions/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,2BAA2B,CAAA"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Re-read every live paygate subscription — the manually or periodically triggered repair. */
|
|
2
|
+
export declare const resyncSubscriptions: import("@owlmeans/server-entrypoint").BoundEntrypointHandler<import("@owlmeans/entrypoint").EntrypointProtocol<{}, import("../consts.js").ResyncSubscriptionsResult>>;
|
|
3
|
+
//# sourceMappingURL=resync-subscriptions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resync-subscriptions.d.ts","sourceRoot":"","sources":["../../src/actions/resync-subscriptions.ts"],"names":[],"mappings":"AAOA,+FAA+F;AAC/F,eAAO,MAAM,mBAAmB,uKACY,CAAA"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { handlers } from '@owlmeans/server-api';
|
|
2
|
+
import { paymentGate } from '../consts.js';
|
|
3
|
+
import { gateway } from '../utils.js';
|
|
4
|
+
const bind = handlers();
|
|
5
|
+
/** Re-read every live paygate subscription — the manually or periodically triggered repair. */
|
|
6
|
+
export const resyncSubscriptions = bind.request(paymentGate.resyncSubscriptions, async (_request, context) => await gateway(context).resyncAll(context));
|
|
7
|
+
//# sourceMappingURL=resync-subscriptions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resync-subscriptions.js","sourceRoot":"","sources":["../../src/actions/resync-subscriptions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAA;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAGrC,MAAM,IAAI,GAAG,QAAQ,EAAW,CAAA;AAEhC,+FAA+F;AAC/F,MAAM,CAAC,MAAM,mBAAmB,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,mBAAmB,EAAE,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,CAC3G,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAA"}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
+
/** Forget every sync fingerprint and bring Stripe back to the declared catalogue, portal and webhook. */
|
|
1
2
|
export declare const resync: import("@owlmeans/server-entrypoint").BoundEntrypointHandler<import("@owlmeans/entrypoint").EntrypointProtocol<{}, import("../consts.js").ResyncResult>>;
|
|
2
3
|
//# sourceMappingURL=resync.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resync.d.ts","sourceRoot":"","sources":["../../src/actions/resync.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"resync.d.ts","sourceRoot":"","sources":["../../src/actions/resync.ts"],"names":[],"mappings":"AASA,yGAAyG;AACzG,eAAO,MAAM,MAAM,0JAKjB,CAAA"}
|