@stewardhq/sdk 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +332 -273
- package/dist/_chunks/errors.js +1 -1
- package/dist/_chunks/events.d.ts +386 -78
- package/dist/_chunks/events.js +1035 -19
- package/dist/_chunks/index.d.ts +3752 -305
- package/dist/_chunks/locale.d.ts +270 -2
- package/dist/_chunks/src.js +925 -31
- package/dist/_chunks/validators.d.ts +46 -2
- package/dist/_chunks/validators.js +128 -27
- package/dist/_chunks/webhook-core.d.ts +2 -2
- package/dist/contract.d.ts +4 -4
- package/dist/contract.js +4 -4
- package/dist/index.d.ts +447 -33
- package/dist/index.js +1659 -26
- package/dist/server.d.ts +66 -16
- package/dist/server.js +35 -4
- package/package.json +14 -13
- package/dist/_chunks/steward.d.ts +0 -189
- package/dist/_chunks/steward.js +0 -564
- package/dist/testing/fixtures/events/LOCK.json +0 -27
- package/dist/testing/fixtures/events/account.deleted.json +0 -36
- package/dist/testing/fixtures/events/account.state_changed.json +0 -53
- package/dist/testing/fixtures/events/account.updated.json +0 -37
- package/dist/testing/fixtures/events/checkout.completed.json +0 -36
- package/dist/testing/fixtures/events/checkout.expired.json +0 -26
- package/dist/testing/fixtures/events/checkout.failed.json +0 -27
- package/dist/testing/fixtures/events/invoice.created.json +0 -37
- package/dist/testing/fixtures/events/invoice.issued.json +0 -38
- package/dist/testing/fixtures/events/invoice.voided.json +0 -39
- package/dist/testing/fixtures/events/subscription.activated.json +0 -40
- package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +0 -38
- package/dist/testing/fixtures/events/subscription.canceled.json +0 -29
- package/dist/testing/fixtures/events/subscription.expired.json +0 -27
- package/dist/testing/fixtures/events/subscription.payment_failed.json +0 -38
- package/dist/testing/fixtures/events/subscription.reactivated.json +0 -36
- package/dist/testing/fixtures/events/subscription.renewed.json +0 -39
- package/dist/testing/fixtures/events/subscription.suspended.json +0 -36
- package/dist/testing/fixtures/events/subscription.terminated.json +0 -28
- package/dist/testing.d.ts +0 -656
- package/dist/testing.js +0 -3684
package/README.md
CHANGED
|
@@ -1,190 +1,195 @@
|
|
|
1
1
|
# @stewardhq/sdk
|
|
2
2
|
|
|
3
|
-
Steward ↔
|
|
4
|
-
|
|
3
|
+
Steward ↔ product integration: contract schemas (zod), a typed API client and webhook signing.
|
|
4
|
+
Source: [z9cloud/steward](https://github.com/z9cloud/steward/tree/main/sdk).
|
|
5
5
|
|
|
6
|
-
##
|
|
6
|
+
## Installation
|
|
7
7
|
|
|
8
|
-
npmjs
|
|
8
|
+
Public on npmjs; no token needed.
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
11
|
pnpm add @stewardhq/sdk
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
ESM, Node ≥ 22.
|
|
14
|
+
ESM, Node ≥ 22. Its only runtime dependency is zod.
|
|
15
15
|
|
|
16
|
-
##
|
|
16
|
+
## Subpaths
|
|
17
17
|
|
|
18
|
-
| Import |
|
|
18
|
+
| Import | Contents |
|
|
19
19
|
| --- | --- |
|
|
20
|
-
| `@stewardhq/sdk` |
|
|
21
|
-
| `@stewardhq/sdk/contract` |
|
|
22
|
-
| `@stewardhq/sdk/webhook` | `signWebhook` / `verifyWebhook` / `verifyWebhookRaw` (Node HMAC,
|
|
23
|
-
| `@stewardhq/sdk/webhook/web` |
|
|
24
|
-
| `@stewardhq/sdk/
|
|
25
|
-
| `@stewardhq/sdk/server` | `Webhooks()`: imzalı event alıcısı, canlı durum + önbellek, tipli hook'lar (Node); `Checkout()`: hosted checkout oturumu (`create` → `url`) ve dönüş sonucu (`result`); `CustomerPortal()`: hosted müşteri portalı oturumu (`create` → `url`) |
|
|
26
|
-
| `@stewardhq/sdk/testing` | Test çiftleri (Node): `createFakeSteward()` (HTTP düzeyinde sahte steward, `simulate.*`, `connect`), `signedEvent()` |
|
|
20
|
+
| `@stewardhq/sdk` | All schemas and types + the throwing client `createSteward`, the account state cache `stateCache`, `defineCatalog`, the error classes + `httpStatusFor`, `isValidTckn`/`isValidVkn`/`isE164`; metering: `Events()`, `Meters()` |
|
|
21
|
+
| `@stewardhq/sdk/contract` | Schemas and types only |
|
|
22
|
+
| `@stewardhq/sdk/webhook` | `signWebhook` / `verifyWebhook` / `verifyWebhookRaw` (Node HMAC, synchronous) |
|
|
23
|
+
| `@stewardhq/sdk/webhook/web` | The same API on WebCrypto (async; edge/workerd/browser) |
|
|
24
|
+
| `@stewardhq/sdk/server` | `Webhooks()`: a signed event receiver, live state + cache, typed hooks (Node); `Checkout()`: a hosted checkout session (`create` → `url`) and its return result (`result`); `CustomerPortal()`: a hosted customer portal session (`create` → `url`) |
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
`./webhook`
|
|
26
|
+
The root entry, `./contract` and `./webhook/web` do not use `node:*` (edge/browser);
|
|
27
|
+
`./webhook` is the synchronous Node HMAC.
|
|
30
28
|
|
|
31
|
-
##
|
|
29
|
+
## Usage
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
Account state (the read model) — no entitlement projection is kept in the product database;
|
|
32
|
+
gates read steward live through a per-pod cache:
|
|
35
33
|
|
|
36
34
|
```ts
|
|
37
35
|
import { createSteward, stateCache } from "@stewardhq/sdk";
|
|
38
36
|
|
|
39
37
|
export const steward = createSteward();
|
|
40
38
|
export const state = stateCache(steward, {
|
|
41
|
-
catalog, // entitlements()
|
|
42
|
-
// steward
|
|
39
|
+
catalog, // makes entitlements() typed
|
|
40
|
+
// used when steward is down and there is no stale value (AccountState | null); catalog.stateOf: full state from the catalog (version 0)
|
|
43
41
|
fallback: async (ref) => {
|
|
44
42
|
const org = await findOrg(ref);
|
|
45
43
|
return org ? catalog.stateOf(ref, org.plan, { displayName: org.name }) : null;
|
|
46
44
|
},
|
|
47
45
|
onFallback: ({ ref, reason }) => metrics.billingFallback.inc({ reason }),
|
|
48
|
-
// missing: "fallback" → steward
|
|
46
|
+
// missing: "fallback" → a record with no account in steward also goes to the fallback (if the account is only created at the first checkout)
|
|
49
47
|
// ttlMs: 30_000, staleIfErrorMs: 600_000, maxEntries: 10_000, logger
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
const e = await state.entitlements(orgId); // { max_projects: number | null; sso: boolean }
|
|
53
|
-
await state.state(orgId, { fresh: true }); // TTL
|
|
54
|
-
state.prime((await steward.grants.create(orgId, input)).account); //
|
|
55
|
-
const byRef = await state.many(orgIds); // Map<ref, AccountState>,
|
|
51
|
+
await state.state(orgId, { fresh: true }); // without waiting for the TTL
|
|
52
|
+
state.prime((await steward.grants.create(orgId, input)).account); // a mutation response into the cache
|
|
53
|
+
const byRef = await state.many(orgIds); // Map<ref, AccountState>, at most 8 in parallel
|
|
56
54
|
```
|
|
57
55
|
|
|
58
|
-
|
|
|
56
|
+
| Situation | Behaviour |
|
|
59
57
|
| --- | --- |
|
|
60
|
-
| TTL
|
|
61
|
-
| TTL
|
|
62
|
-
|
|
|
63
|
-
| 404 `account_not_found` (
|
|
64
|
-
| 404 `account_not_found`, `missing: "fallback"` | `fallback(ref)` (
|
|
65
|
-
| `prime(account)` |
|
|
58
|
+
| Within the TTL | From memory, no request; concurrent reads of the same account share one request |
|
|
59
|
+
| TTL expired | `If-None-Match`: a 304 renews the window, a 200 stores the new state |
|
|
60
|
+
| Network error, timeout, 5xx, 429 | After expiry, the stale value for `staleIfErrorMs` (WARN), then `fallback` (WARN + `onFallback`), and if there is none (or it returns `null`) the original `StewardNetworkError`/`StewardApiError`. After a failed request, steward is not called again for that account for `min(ttlMs, 5 s)` |
|
|
61
|
+
| 404 `account_not_found` (default `missing: "throw"`) | Not an outage: no fallback, nothing cached, `StewardApiError` is thrown (a missing `upsert` stays visible) |
|
|
62
|
+
| 404 `account_not_found`, `missing: "fallback"` | `fallback(ref)` (a required option); no WARN, and `onFallback`'s reason is `account_not_found` (kept apart from the outage metric); the stale value is not used. The result is not cached, and the "absent" fact is held for `min(ttlMs, 5 s)`; `prime`/`invalidate`/`fresh` end it (a checkout result and a webhook both `prime`). A `null` fallback → the 404 is thrown |
|
|
63
|
+
| `prime(account)` | Writes only when `version >= the cached one` (never goes backwards); `null`/`undefined` is ignored |
|
|
66
64
|
|
|
67
|
-
|
|
68
|
-
`invalidate(ref)`
|
|
65
|
+
The cache is in-memory per pod (LRU); across many pods, consistency is bounded by the TTL.
|
|
66
|
+
`invalidate(ref)` makes the next read ask immediately (with the ETag).
|
|
69
67
|
|
|
70
|
-
Webhook
|
|
71
|
-
|
|
68
|
+
Webhook receiver (`/server`, Node) — the signature is checked on the raw body, then the
|
|
69
|
+
contract; state is read live from steward and written into the cache; the hooks are typed:
|
|
72
70
|
|
|
73
71
|
```ts
|
|
74
72
|
import { Webhooks } from "@stewardhq/sdk/server";
|
|
75
73
|
|
|
76
74
|
const webhooks = Webhooks({
|
|
77
|
-
steward, cache: state, // refetch (steward
|
|
78
|
-
// secrets
|
|
75
|
+
steward, cache: state, // refetch (the default once steward is given): the payload is only a trigger
|
|
76
|
+
// without secrets, STEWARD_WEBHOOK_SECRET is read on every delivery ("new,old" rotation); toleranceSeconds: 300
|
|
79
77
|
onStateChanged: async ({ ref, state, cause }) => {
|
|
80
78
|
const org = await findOrg(ref);
|
|
81
|
-
if (!org) return "ignore"; //
|
|
79
|
+
if (!org) return "ignore"; // unknown account: 200, and neither the other hooks nor after run
|
|
82
80
|
await applyOrgPlan(org, state.planCode, state.accessState);
|
|
83
81
|
},
|
|
84
|
-
onSubscriptionTerminated: ({ ref }) => flagOps(ref), // on<
|
|
82
|
+
onSubscriptionTerminated: ({ ref }) => flagOps(ref), // on<Type>: from the old type or from state_changed's cause
|
|
85
83
|
onPaymentFailed: ({ ref, state, profile }) => mailOwner(ref, profile), // + onSuspended, onTerminated
|
|
86
|
-
after: ({ ref, state }) => propagate(ref, state.accessState), //
|
|
87
|
-
// includeProfile: true →
|
|
84
|
+
after: ({ ref, state }) => propagate(ref, state.accessState), // after the other hooks, on every delivery
|
|
85
|
+
// includeProfile: true → the notification hooks get the billing profile (email); refetch: false → the state from the body
|
|
88
86
|
});
|
|
89
87
|
|
|
90
|
-
app.post("/v1/billing/events", (c) => webhooks.handle(c.req.raw)); //
|
|
88
|
+
app.post("/v1/billing/events", (c) => webhooks.handle(c.req.raw)); // or: const { status, body } = await webhooks.receive(rawBody, headers)
|
|
91
89
|
```
|
|
92
90
|
|
|
93
|
-
|
|
|
91
|
+
| Situation | Response |
|
|
94
92
|
| --- | --- |
|
|
95
|
-
|
|
|
96
|
-
| `onStateChanged` `"ignore"`
|
|
97
|
-
|
|
|
98
|
-
| `endpoint.ping` | 200 `ping`,
|
|
99
|
-
| `refetch: false`
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
`onPaymentFailed`…, `onUnknownEvent`, `after`)
|
|
107
|
-
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
93
|
+
| The hooks ran | 200 `{ok: true, handled: "received"}` |
|
|
94
|
+
| `onStateChanged` returned `"ignore"` | 200 `ignored` + WARN; the cause hooks and `after` do not run |
|
|
95
|
+
| Account deleted: `account.deleted` or `cause: "account.deleted"` | 200 `deleted`; no live read, and `onAccountDeleted` + `after` run with the anonymised final state from the body (`onStateChanged` does not run; `cache.invalidate` is called if present). If a delivery queued before the deletion gets `account_deleted` (410) on its live read, it returns 200 `deleted` + WARN with no hooks |
|
|
96
|
+
| `endpoint.ping` | 200 `ping`, no hooks |
|
|
97
|
+
| `refetch: false` and the body carries only a snapshot (a queue from before the upgrade) | 200 `no_state` + WARN: `onStateChanged` does not run, and the cause hooks and `after` run with `state: null` |
|
|
98
|
+
| Unrecognised type | 200; `onUnknownEvent` (preceded by `onStateChanged` if there is state) |
|
|
99
|
+
| Signature / header / time window | 401 |
|
|
100
|
+
| Signed but not contract-conformant body, or an id mismatch | 400 + ALARM (steward retries) |
|
|
101
|
+
| No secret, current state unreadable (`state_unavailable`), profile unreadable, or a hook threw | 503 (steward retries, and every hook runs again) |
|
|
102
|
+
|
|
103
|
+
Types: when `steward` is given and `refetch` is not `false`, the `state` of every hook
|
|
104
|
+
(`onStateChanged`, `on<Type>`, `onPaymentFailed`…, `onUnknownEvent`, `after`) is an
|
|
105
|
+
`AccountState` — if the state cannot be read, 503 is returned and no hook runs; `after`'s
|
|
106
|
+
`handled` is `"received"`. With `refetch: false`, or in a receiver without `steward`, the cause
|
|
107
|
+
hooks, `onUnknownEvent` and `after` get `AccountState | null` (a queue from before the upgrade;
|
|
108
|
+
`onStateChanged` still runs with full state). The distinction is inferred from the types of the
|
|
109
|
+
`refetch` and `steward` fields (when the type is ambiguous, the nullable one).
|
|
110
|
+
|
|
111
|
+
**The SDK does not deduplicate, and holds no version gate or lock.** With `refetch`, every
|
|
112
|
+
delivery reads the state live from steward: an out-of-order or repeated delivery sees the most
|
|
113
|
+
current state, and even an old body carrying only a snapshot is fully usable
|
|
114
|
+
(`event.data.account` may be stale; use `state`). The same event can still arrive several
|
|
115
|
+
times, and deliveries for the same account can arrive concurrently: hooks must write
|
|
116
|
+
idempotently (write the plan field from the state, "if already suspended, don't touch",
|
|
117
|
+
`coalesce` the termination flag). To have a delivery retried, throw from a hook → 503.
|
|
118
|
+
`refetch: false` uses the state in the body (no ordering guarantee; wait for the old queue to
|
|
119
|
+
drain). An endpoint that asks for both the old type and `account.state_changed` for the same
|
|
120
|
+
occurrence runs the cause hooks twice: pick one in the endpoint filter.
|
|
121
|
+
|
|
122
|
+
Hosted checkout (`/server`) — the profile, tax identity, consents and the payment form all live
|
|
123
|
+
on steward's page; the product only opens the session, redirects the user to `url`, and asks
|
|
124
|
+
for the result on return:
|
|
123
125
|
|
|
124
126
|
```ts
|
|
125
127
|
import { Checkout } from "@stewardhq/sdk/server";
|
|
126
128
|
|
|
127
129
|
const checkout = Checkout({
|
|
128
130
|
steward, cache: state,
|
|
129
|
-
//
|
|
130
|
-
// steward
|
|
131
|
+
// absolute; the origin must be allowed in steward's settings (appUrl + extraReturnOrigins). If
|
|
132
|
+
// placeholders are present steward fills only those; otherwise the address is used as-is (no parameters added).
|
|
131
133
|
successUrl: `${process.env.APP_URL}/{LOCALE}/app/orgs/{ACCOUNT_REF}/billing?checkout={CHECKOUT_ID}`,
|
|
132
|
-
consents: (locale) => legalDocs(locale), // [{document, version, url}]
|
|
133
|
-
// cancelUrl (
|
|
134
|
+
consents: (locale) => legalDocs(locale), // [{document, version, url}] or an array; consented to on the page
|
|
135
|
+
// cancelUrl (defaults to the appUrl setting), locale and currency (default to the product setting)
|
|
134
136
|
});
|
|
135
137
|
|
|
136
138
|
app.post("/v1/orgs/:orgId/billing/checkout", async (c) => {
|
|
137
|
-
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); //
|
|
139
|
+
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); // authorization BEFORE the call is the product's job
|
|
138
140
|
const { url } = await checkout.create({ ref: c.req.param("orgId"), planCode: "team", interval: "month",
|
|
139
141
|
locale: c.get("locale"), actorRef: `user:${c.get("user").id}` }); // displayName?, prefill?: {email, name, kind}
|
|
140
|
-
return c.json({ url }); //
|
|
142
|
+
return c.json({ url }); // the browser 303s to url
|
|
141
143
|
});
|
|
142
144
|
|
|
143
|
-
app.get("/v1/orgs/:orgId/billing/checkout/:id", async (c) => //
|
|
145
|
+
app.get("/v1/orgs/:orgId/billing/checkout/:id", async (c) => // the return page ?checkout=<id>
|
|
144
146
|
c.json(await checkout.result(c.req.param("id"), { ref: c.req.param("orgId") })));
|
|
145
147
|
// { status: "open" | "completed" | "failed" | "expired" | "canceled", planCode?, account? }
|
|
146
148
|
```
|
|
147
149
|
|
|
148
|
-
|
|
|
150
|
+
| Situation | Behaviour |
|
|
149
151
|
| --- | --- |
|
|
150
|
-
| `create` | `POST …/checkout-sessions` hosted
|
|
151
|
-
| `result`,
|
|
152
|
-
| `result`, `completed` |
|
|
153
|
-
| `locale` | `CustomerPortal
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
152
|
+
| `create` | `POST …/checkout-sessions` with the hosted body (no profile or identity); no call to the provider; `{id, url, expiresAt}`. If the account does not exist it is opened with `displayName`, and without that, `account_not_found`. Steward's errors pass through (`already_subscribed` 409, `plan_not_sellable` 422, `return_url_not_allowed` → `httpStatusFor` 500 + ALARM) |
|
|
153
|
+
| `result`, session belongs to another account / invalid id / no session | The same error: `StewardApiError` `checkout_session_not_found` (404) — `?checkout=` is user input, so ownership is not leaked |
|
|
154
|
+
| `result`, `completed` | The `account` in the response is `cache.prime`d (the return page shows the new plan without waiting for the webhook; the webhook is still the source of truth) |
|
|
155
|
+
| `locale` | Same rule as `CustomerPortal`: the primary subtag of a BCP-47-like tag (`tr-TR` → `tr`, `en-US` → `en`). An unsupported language in the call is ignored: the one from the options is used, and failing that nothing is sent (steward's `defaultLocale`); the `consents` function is called with that language |
|
|
156
|
+
| A relative or non-http(s) `successUrl`, `cancelUrl` or document address; an unsupported `locale` in the options | `StewardConfigError` at construction |
|
|
157
|
+
| No `url` in the response (a steward from before F2b) | `StewardContractError` |
|
|
158
|
+
|
|
159
|
+
If the user closes the page, steward closes the session once it expires
|
|
160
|
+
(`checkoutTtlMinutes`), and the product learns the result from `account.state_changed`.
|
|
161
|
+
`steward.checkoutSessions.create` also only accepts the hosted body; the billing profile is
|
|
162
|
+
written only on the hosted checkout and portal pages.
|
|
163
|
+
|
|
164
|
+
Hosted customer portal (`/server`) — subscription status, cancel at period end, retry payment,
|
|
165
|
+
the invoice list/PDF and the corporate billing profile all live on steward's page; the product
|
|
166
|
+
only checks authorization, opens the session and redirects. There is no cancel, retry or
|
|
167
|
+
invoice endpoint on the product side: the results arrive at `Webhooks()` as
|
|
168
|
+
`account.state_changed`:
|
|
165
169
|
|
|
166
170
|
```ts
|
|
167
171
|
import { CustomerPortal } from "@stewardhq/sdk/server";
|
|
168
172
|
|
|
169
|
-
const portal = CustomerPortal({ steward, returnUrl: `${process.env.APP_URL}/billing` }); // returnUrl
|
|
173
|
+
const portal = CustomerPortal({ steward, returnUrl: `${process.env.APP_URL}/billing` }); // without returnUrl, the appUrl setting
|
|
170
174
|
|
|
171
175
|
app.post("/v1/orgs/:orgId/billing/portal", async (c) => {
|
|
172
|
-
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); //
|
|
176
|
+
if (!(await isOwner(c))) return c.json({ error: "forbidden" }, 403); // authorization BEFORE the call is the product's job
|
|
173
177
|
const { url } = await portal.create({ ref: c.req.param("orgId"), locale: c.get("locale"), actorRef: `user:${c.get("user").id}` });
|
|
174
|
-
return c.json({ url }); //
|
|
178
|
+
return c.json({ url }); // the browser 303s to url
|
|
175
179
|
});
|
|
176
180
|
```
|
|
177
181
|
|
|
178
|
-
|
|
|
182
|
+
| Situation | Behaviour |
|
|
179
183
|
| --- | --- |
|
|
180
|
-
| `create` | `POST …/portal-sessions {locale?, returnUrl?}` → `{id, url, expiresAt}`;
|
|
181
|
-
|
|
|
182
|
-
| `returnUrl` origin
|
|
183
|
-
| `locale` | `tr`/`en` (
|
|
184
|
-
| `actorRef
|
|
185
|
-
| Portal
|
|
184
|
+
| `create` | `POST …/portal-sessions {locale?, returnUrl?}` → `{id, url, expiresAt}`; state does not change and no event is emitted. The lifetime is the product setting `portalTtlMinutes` (30 minutes by default); an expired link, or one with the wrong key, gives 404 |
|
|
185
|
+
| No account | `StewardApiError` `account_not_found` (404) — unlike checkout, no account is opened |
|
|
186
|
+
| The `returnUrl` origin is not allowed | `return_url_not_allowed` (422; `httpStatusFor` 500 + ALARM) |
|
|
187
|
+
| `locale` | `tr`/`en` (e.g. `tr-TR` → `tr`; the same rule as `Checkout`); any other language is not sent and steward uses its `defaultLocale` |
|
|
188
|
+
| Missing or malformed `actorRef`, or a relative / non-http(s) `returnUrl` | `StewardConfigError` (no request is sent) |
|
|
189
|
+
| Portal actions | The actor in steward's audit trail is `customer:<ref>`; a cancellation is `subscription.cancel_scheduled` (reason `customer_portal`), a profile change is `billing_profile.updated`, and a retry does not change state (the collection result is `subscription.reactivated`) |
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
At the low level the signature is verified over the raw body and only then parsed with the
|
|
192
|
+
contract:
|
|
188
193
|
|
|
189
194
|
```ts
|
|
190
195
|
import { verifyWebhook } from "@stewardhq/sdk/webhook";
|
|
@@ -192,93 +197,100 @@ import { verifyWebhook } from "@stewardhq/sdk/webhook";
|
|
|
192
197
|
const rawBody = await request.text();
|
|
193
198
|
const result = verifyWebhook({ secrets: [process.env.STEWARD_WEBHOOK_SECRET!], headers: request.headers, rawBody });
|
|
194
199
|
if (!result.ok) return new Response(null, { status: 400 });
|
|
195
|
-
// result.event: BillingEvent —
|
|
200
|
+
// result.event: BillingEvent — deduplicate on `id`, and do not apply an older `version`.
|
|
196
201
|
```
|
|
197
202
|
|
|
198
|
-
API
|
|
199
|
-
(`STEWARD_URL`, `STEWARD_API_KEY`; edge
|
|
200
|
-
`StewardConfigError`
|
|
203
|
+
API client — `createSteward()` reads its settings from the options and otherwise from the
|
|
204
|
+
environment (`STEWARD_URL`, `STEWARD_API_KEY`; on the edge, pass `env`), and throws
|
|
205
|
+
`StewardConfigError` **at construction** if anything is missing (except for the admin-only
|
|
206
|
+
client, below). A failed call throws:
|
|
201
207
|
|
|
202
208
|
```ts
|
|
203
209
|
import { createSteward } from "@stewardhq/sdk";
|
|
204
210
|
|
|
205
|
-
export const steward = createSteward({ actorRef: "system" }); //
|
|
211
|
+
export const steward = createSteward({ actorRef: "system" }); // or createSteward({ env, fetch, timeoutMs: 10_000 })
|
|
206
212
|
|
|
207
213
|
const state = await steward.accounts.state(accountRef); // AccountState
|
|
208
|
-
await steward.grants.create(accountRef, { planCode: "team", reason: "
|
|
214
|
+
await steward.grants.create(accountRef, { planCode: "team", reason: "enterprise agreement" }, { actorRef: `staff:${staffId}` });
|
|
209
215
|
```
|
|
210
216
|
|
|
211
|
-
|
|
|
217
|
+
| Resource | Methods |
|
|
212
218
|
| --- | --- |
|
|
213
|
-
| `accounts` | `upsert(ref, input)`, `get(ref, {include?})`, `state(ref)`, `readState(ref, {etag?})` (304 → `{notModified: true}`), `setPlan(ref, {planCode \| null, reason, mode?, endsAt?, overrides?})` → `{grant, account, warnings}` (
|
|
219
|
+
| `accounts` | `upsert(ref, input)`, `get(ref, {include?})`, `state(ref)`, `readState(ref, {etag?})` (304 → `{notModified: true}`), `setPlan(ref, {planCode \| null, reason, mode?, endsAt?, overrides?})` → `{grant, account, warnings}` (entitlements are a union of layers — there is no `rank`; M35: `mode` is accepted and **ignored**, and `warnings` is always empty), `resync(ref)` → `{queued, eventId}` (the stored state as `account.state_changed`, `cause: "resync"`), `delete(ref)` → `{ref, deletedAt}` (KVKK anonymisation; invoices remain; any live subscription must be cancelled `immediate` first — otherwise `subscription_active`, or `checkout_in_progress` while a checkout form is open; afterwards every endpoint for the account returns `account_deleted` 410) |
|
|
214
220
|
| `checkoutSessions` | `create(ref, input)`, `get(id)` |
|
|
215
|
-
| `portalSessions` | `create(ref, {locale?, returnUrl?})` → `{id, url, expiresAt}` (hosted
|
|
221
|
+
| `portalSessions` | `create(ref, {locale?, returnUrl?})` → `{id, url, expiresAt}` (the hosted customer portal; `CustomerPortal()` wraps this) |
|
|
216
222
|
| `subscriptions` | `cancel(id, input)` |
|
|
217
223
|
| `grants` | `create(ref, input)`, `revoke(id)` |
|
|
218
|
-
| `invoices` | `issue(id, input)`, `document(id)` → `{contentType, bytes}`, `createManual(ref, {lines, currency, taxRateBps, taxInclusive?, dueAt?, note?})` (
|
|
224
|
+
| `invoices` | `issue(id, input)`, `document(id)` → `{contentType, bytes}`, `createManual(ref, {lines, currency, taxRateBps, taxInclusive?, dueAt?, note?})` (a manual draft; needs a profile, and is issued with `issue`), `void(id, {reason})` (manual invoices only; `invoice.voided` goes only to an endpoint that asks for it in its filter) |
|
|
219
225
|
| `catalog` | `get()`, `read({etag?})` |
|
|
220
|
-
| — | `stats()`, `me()`, `doctor()` →
|
|
221
|
-
| `admin` | `catalog.sync(input)`, `accounts.refresh()`, `settings.{get(), update(patch)}`, `endpoints.{list(), create(input), rotate(id, {secret}), deactivate(id)}` — `STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (
|
|
222
|
-
|
|
223
|
-
- **
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
(`code`
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
226
|
+
| — | `stats()`, `me()`, `doctor()` → setup diagnostics (`GET /v1/doctor`; below) |
|
|
227
|
+
| `admin` | `catalog.sync(input)`, `accounts.refresh()`, `settings.{get(), update(patch)}`, `endpoints.{list(), create(input), rotate(id, {secret}), deactivate(id)}` — requires `STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (or `adminBaseUrl`/`adminApiKey`); without them, `StewardConfigError` at the call |
|
|
228
|
+
|
|
229
|
+
- **Mutations** take `{ idempotencyKey?, actorRef? }` as their last argument. Without a key,
|
|
230
|
+
one is generated per call; without `actorRef`, the default from `createSteward` is used. If
|
|
231
|
+
neither exists, or the format is invalid (`type[:id]`), the request is **not sent**:
|
|
232
|
+
`StewardConfigError` (with the same `code` as the service's: `actor_ref_required` /
|
|
233
|
+
`invalid_actor_ref`).
|
|
234
|
+
- **Admin only:** when neither `STEWARD_URL` nor `STEWARD_API_KEY` is set but
|
|
235
|
+
`STEWARD_ADMIN_URL` + `STEWARD_ADMIN_API_KEY` (or `adminBaseUrl`/`adminApiKey`) are, the
|
|
236
|
+
client is constructed (for the CLI and catalog sync): `admin.*` works, `doctor()` and `me()`
|
|
237
|
+
— open to both scopes — go out with the admin key, and the product endpoints and `raw.*`
|
|
238
|
+
reject at the call with `StewardConfigError` (no request is sent). If only half of the
|
|
239
|
+
product setting is given, it still fails at construction.
|
|
240
|
+
- **Errors:** an error with a response is `StewardApiError` (`code`, `httpStatus`, `category`,
|
|
241
|
+
`retryable`, `requestId`, `retryAfterMs`), no response is `StewardNetworkError`
|
|
242
|
+
(`network_error`/`timeout`), and a response that does not match the schema is
|
|
243
|
+
`StewardContractError`.
|
|
244
|
+
- **Retries** (`retries: { attempts: 2, on: ["network", "5xx", "429"], maxDelayMs: 5000 }`): on
|
|
245
|
+
a network error/timeout, a 5xx or a 429, GETs and mutations carrying an `Idempotency-Key` are
|
|
246
|
+
retried at most `attempts` more times (by default at most 3 requests), with the same key on
|
|
247
|
+
every attempt. The wait is `Retry-After` when given, otherwise exponential (250 ms,
|
|
248
|
+
500 ms…) + jitter; both are capped by `maxDelayMs`. 4xx other than 429, and responses with
|
|
249
|
+
`retryable: false` (`provider_error`, `provider_not_configured`), are not retried.
|
|
250
|
+
`timeoutMs` is per attempt.
|
|
251
|
+
- Requests carry `Steward-Api-Version` and `X-Steward-Sdk: @stewardhq/sdk/<version>`
|
|
252
|
+
(observability only).
|
|
253
|
+
- Web standards only (`fetch`, `crypto.randomUUID`, `AbortSignal.timeout`): the same code on
|
|
254
|
+
Node 22, on the edge and on workerd. The root entry and `./webhook/web` are run on workerd
|
|
255
|
+
with `nodejs_compat` disabled on every pack check (`scripts/check-workerd.mjs`).
|
|
256
|
+
|
|
257
|
+
Administration (admin key; in-cluster, port-forward) — product settings and webhook endpoints.
|
|
258
|
+
**The caller generates** the webhook secret and writes it into their own Secret; steward never
|
|
259
|
+
returns the secret in any response:
|
|
249
260
|
|
|
250
261
|
```ts
|
|
251
262
|
import { generateWebhookSecret } from "@stewardhq/sdk";
|
|
252
263
|
|
|
253
264
|
await steward.admin.settings.update({
|
|
254
|
-
appUrl: "https://app.example.com", // origin
|
|
265
|
+
appUrl: "https://app.example.com", // its origin is allowed for the checkout return; + extraReturnOrigins
|
|
255
266
|
privacyUrl: "https://app.example.com/kvkk",
|
|
256
|
-
branding: { name: "Acme", accentColor: "#0f766e" }, //
|
|
267
|
+
branding: { name: "Acme", accentColor: "#0f766e" }, // shallow merge; null clears a field
|
|
257
268
|
checkoutTtlMinutes: 30,
|
|
258
269
|
});
|
|
259
270
|
|
|
260
|
-
const secret = generateWebhookSecret(); // whsec_ + 32
|
|
271
|
+
const secret = generateWebhookSecret(); // whsec_ + 32 random bytes
|
|
261
272
|
const endpoint = await steward.admin.endpoints.create({ url: "http://api.acme.svc.cluster.local/billing/events", secret });
|
|
262
|
-
// eventTypes
|
|
263
|
-
await steward.admin.endpoints.rotate(endpoint.id, { secret: generateWebhookSecret() }); //
|
|
273
|
+
// without eventTypes, ["account.state_changed"]; the address must be https:// or http://*.svc.cluster.local
|
|
274
|
+
await steward.admin.endpoints.rotate(endpoint.id, { secret: generateWebhookSecret() }); // the old secret stays as a second signature during the transition
|
|
264
275
|
```
|
|
265
276
|
|
|
266
|
-
|
|
277
|
+
Setup diagnostics — the product key is enough; expect `ok: true` before the first checkout:
|
|
267
278
|
|
|
268
279
|
```ts
|
|
269
280
|
const report = await steward.doctor();
|
|
270
|
-
// report.ok: `error`
|
|
281
|
+
// report.ok: no warning at `error` severity
|
|
271
282
|
// report.warnings: [{ code: "endpoint_wrong_secret", severity: "error", message, endpointId }, ...]
|
|
272
|
-
// report.endpoints[].diagnosis: ok | wrong_secret (
|
|
283
|
+
// report.endpoints[].diagnosis: ok | wrong_secret (last delivery 401) | old_contract (400) | unreachable | failing | no_deliveries
|
|
273
284
|
// report.catalog, report.provider (environment: sandbox | live | fake), report.hosted.ready, report.dunning.dryRun
|
|
274
285
|
```
|
|
275
286
|
|
|
276
|
-
|
|
277
|
-
`lastError`
|
|
287
|
+
The codes and diagnoses are an open set (display an unknown one, nothing more).
|
|
288
|
+
`provider.healthy` and `lastError` are the observation of the steward pod that answered.
|
|
278
289
|
|
|
279
|
-
|
|
280
|
-
`null` =
|
|
281
|
-
|
|
290
|
+
Catalog — a typed definition in code; a missing or extra entitlement, a wrong type (`limit` →
|
|
291
|
+
an integer or `null` = unlimited, `flag` → boolean, `text` → string) and a price for an
|
|
292
|
+
undefined plan are compile errors, and if the types are bypassed the definition throws
|
|
293
|
+
`StewardConfigError` immediately (TypeScript ≥ 5.4):
|
|
282
294
|
|
|
283
295
|
```ts
|
|
284
296
|
import { defineCatalog, flag, limit } from "@stewardhq/sdk";
|
|
@@ -293,139 +305,186 @@ export const catalog = defineCatalog({
|
|
|
293
305
|
prices: [{ plan: "team", interval: "month", currency: "TRY", amountMinor: 125_000, taxInclusive: true, taxRateBps: 2000 }],
|
|
294
306
|
});
|
|
295
307
|
|
|
296
|
-
await steward.admin.catalog.sync(catalog.toSyncInput()); // admin
|
|
308
|
+
await steward.admin.catalog.sync(catalog.toSyncInput()); // with the admin key (a CLI/deploy step)
|
|
297
309
|
const e = catalog.resolve(snapshot); // { max_projects: number | null; sso: boolean }
|
|
298
310
|
const placeholder = catalog.stateOf("org_1", "team", { accessState: "active", displayName: "Acme" });
|
|
299
|
-
//
|
|
300
|
-
//
|
|
311
|
+
// a full AccountState: version 0, updatedAt 1970, catalogVersion null, the plan's entitlements, no subscription or open checkout,
|
|
312
|
+
// profile { present: false }; without a plan, the default plan (for a stateCache fallback)
|
|
301
313
|
```
|
|
302
314
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
315
|
+
For a missing key or one whose type does not match, `resolve` takes the value from the
|
|
316
|
+
snapshot's plan (or, if that plan is not in the catalog, from the default plan) and never
|
|
317
|
+
produces "unlimited"; `onFallback` can raise an alarm. Without a snapshot, the default plan's
|
|
318
|
+
entitlements.
|
|
306
319
|
|
|
307
|
-
|
|
320
|
+
Benefits (billing core) — reusable definitions plans list by code; `rank` is optional (the
|
|
321
|
+
benefits resolution does not read it). A `featureFlag()` sets catalog features with their
|
|
322
|
+
types; an undefined benefit or feature key and a wrong value type do not compile:
|
|
308
323
|
|
|
309
324
|
```ts
|
|
310
|
-
import {
|
|
325
|
+
import { defineCatalog, featureFlag, flag, limit } from "@stewardhq/sdk";
|
|
311
326
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
327
|
+
export const catalog = defineCatalog({
|
|
328
|
+
defaultPlan: "free",
|
|
329
|
+
features: { max_projects: limit(), sso: flag() },
|
|
330
|
+
benefits: { sso: featureFlag({ sso: true }, { description: { tr: "Tek oturum açma", en: "Single sign-on" } }) },
|
|
331
|
+
plans: {
|
|
332
|
+
free: { name: "Free", sellable: false, entitlements: { max_projects: 1, sso: false } },
|
|
333
|
+
team: { name: "Team", sellable: true, entitlements: { max_projects: null, sso: false }, benefits: ["sso"] },
|
|
334
|
+
},
|
|
335
|
+
prices: [{ plan: "team", interval: "month", currency: "TRY", amountMinor: 125_000, taxInclusive: true, taxRateBps: 2000 }],
|
|
336
|
+
});
|
|
337
|
+
type BenefitCode = typeof catalog.$benefit; // "sso"
|
|
338
|
+
const { benefits } = await state.state(orgId); // [{ code, type, properties, source, grantedAt }]; code typed
|
|
316
339
|
```
|
|
317
340
|
|
|
318
|
-
|
|
319
|
-
`
|
|
320
|
-
|
|
321
|
-
log içindir (steward'ın mesajı, yoksa kod). Fiyat gösterimi hosted checkout/portal
|
|
322
|
-
sayfalarındadır; SDK para biçimlendirme yardımcısı taşımaz.
|
|
341
|
+
`entitlementsOf`, `resolve` and `stateOf` take a plan's entitlements from the contract's
|
|
342
|
+
resolver (`resolveEntitlements`), the rule steward applies. `steward catalog push` sends a
|
|
343
|
+
catalog with benefits only to a steward whose `me().capabilities` lists `"benefits"`.
|
|
323
344
|
|
|
324
|
-
|
|
325
|
-
|
|
345
|
+
`Webhooks()` gains `onInvoicePaid` (`invoice.paid`) and `onBenefitGranted` / `onBenefitRevoked`
|
|
346
|
+
(`benefit_grant.created` / `.revoked`, with `benefit` and `grantId`; list these types in the
|
|
347
|
+
endpoint's `eventTypes`); like every hook they get the live `state`. Invoices and subscriptions
|
|
348
|
+
(billing core): `steward.invoices.createPaymentSession(id, { locale?, returnUrl? })` → `{ id, url,
|
|
349
|
+
expiresAt }` (redirect to steward's pay page), `steward.invoices.waive(id, { reason })`,
|
|
350
|
+
`steward.subscriptions.change(id, { planCode?, interval?, price?: "current", when: "next_period" })`,
|
|
351
|
+
`steward.subscriptions.uncancel(id)`.
|
|
326
352
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
353
|
+
Metering (phase 2) — the product records WHAT HAPPENED as events; catalog meters (a saved filter
|
|
354
|
+
+ an aggregation) decide what counts as usage; credits are `meterCredit()` benefits; a metered
|
|
355
|
+
price bills overage on the renewal invoice. Units may be decimals (≤ 6 places; the SDK computes in
|
|
356
|
+
micro units, never float math):
|
|
331
357
|
|
|
332
358
|
```ts
|
|
333
|
-
import {
|
|
359
|
+
import { count, defineCatalog, Events, meter, meterCredit, Meters, sum } from "@stewardhq/sdk";
|
|
334
360
|
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
361
|
+
export const catalog = defineCatalog({
|
|
362
|
+
defaultPlan: "free",
|
|
363
|
+
features: { max_projects: limit(), sso: flag() },
|
|
364
|
+
benefits: { tokens_50k: meterCredit("ai_tokens", { units: 50_000 }), tokens_2m: meterCredit("ai_tokens", { units: 2_000_000, rollover: { capUnits: 1_000_000 } }) },
|
|
365
|
+
meters: {
|
|
366
|
+
ai_tokens: meter({ event: "ai.completion", aggregate: sum("tokens"), label: { tr: "AI token", en: "AI tokens" } }),
|
|
367
|
+
api_calls: meter({ aggregate: count(), label: { tr: "API isteği", en: "API requests" },
|
|
368
|
+
filter: { and: [{ name: "api.request" }, { "metadata.status": { lt: 500 } }] } }),
|
|
369
|
+
},
|
|
370
|
+
plans: { free: { …, benefits: ["tokens_50k"] }, team: { …, benefits: ["tokens_2m"] } },
|
|
371
|
+
prices: [{ plan: "team", interval: "month", currency: "TRY", amountMinor: 125_000, taxInclusive: true, taxRateBps: 2000 }],
|
|
372
|
+
meteredPrices: [{ plan: "team", meter: "ai_tokens", currency: "TRY", amountMinor: 1_250, perUnits: 1_000, taxInclusive: true, taxRateBps: 2000 }],
|
|
338
373
|
});
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const paid = await fake.fetch(new URL(`${id}/pay`, url), { method: "POST", body: new URLSearchParams({ k: new URL(url).searchParams.get("k")!, outcome: "succeeded" }) });
|
|
350
|
-
paid.headers.get("location"); // successUrl, yer tutucular servisle aynı kuralla dolu
|
|
351
|
-
|
|
352
|
-
// Portal (CustomerPortal().create): url sahte portal sayfası; servisle aynı rotalar ve bildirim kodları
|
|
353
|
-
const portalSession = await portal.create({ ref: "org_1", actorRef: "user:1" });
|
|
354
|
-
const k = new URL(portalSession.url).searchParams.get("k")!;
|
|
355
|
-
const base = `${fake.baseUrl}/public/v1/portal/${portalSession.id}`;
|
|
356
|
-
await fake.fetch(`${base}/cancel`, { method: "POST", body: new URLSearchParams({ k }) }); // 200 onay sayfası
|
|
357
|
-
await fake.fetch(`${base}/cancel`, { method: "POST", body: new URLSearchParams({ k, confirm: "1" }) }); // 303 …&done=canceled
|
|
358
|
-
await fake.simulate.portalCancel(portalSession.id); // ya da doğrudan: { result: "ok" | "already_scheduled" | "no_subscription", account }
|
|
359
|
-
await fake.simulate.portalRetryPayment(portalSession.id); // past_due'da { result: "ok" } — durum değişmez; sonuç renewal ile
|
|
360
|
-
|
|
361
|
-
await fake.simulate.flush(); // bekleyen event'ler alıcıya, sırayla (2xx dışı → bekler)
|
|
362
|
-
|
|
363
|
-
await fake.simulate.renewal("org_1", { fail: true }); // saat dönem sonuna; past_due + dunning
|
|
364
|
-
await fake.simulate.advance({ days: 7 }); // zamanlanmış işler kendi anında (dunning, dönem sonu iptali, checkout süresi, grant penceresi, portal oturumu silme)
|
|
365
|
-
fake.simulate.outage(true, { mode: "503" }); // ya da "network": fetch reddeder
|
|
366
|
-
await fake.simulate.deliver("account.state_changed", { ref: "org_1", duplicate: true, outOfOrder: true });
|
|
367
|
-
fake.simulate.redeliver(eventId); // operatörün pod CLI `redeliver-event`'i: aynı event sonraki flush'ta yeniden
|
|
368
|
-
|
|
369
|
-
fake.emails("org_1"); // billing emails steward would send: [{ template, to, locale, eventId, … }]
|
|
370
|
-
|
|
371
|
-
const { request } = signedEvent("subscription.renewed", { accountRef: "org_1" }, { secret });
|
|
372
|
-
await webhooks.handle(request());
|
|
374
|
+
type Meter = typeof catalog.$meter; // "ai_tokens" | "api_calls"
|
|
375
|
+
type Event = typeof catalog.$event; // "ai.completion" (the `event` shorthands)
|
|
376
|
+
|
|
377
|
+
export const events = Events({ steward, catalog, actorRef: "service:api" });
|
|
378
|
+
export const meters = Meters({ steward, catalog, cache: state, events });
|
|
379
|
+
|
|
380
|
+
const gate = await meters.check(org.id, "ai_tokens", { units: estimatedTokens });
|
|
381
|
+
if (!gate.allowed) return c.json({ error: "credits_exhausted", balance: gate.balance }, 402);
|
|
382
|
+
events.track(org.id, "ai.completion", { tokens: 1500, model: "gpt-4o" }, { externalId: `cmpl_${res.id}` });
|
|
383
|
+
process.once("SIGTERM", () => void events.flush()); // the SDK never hooks process events
|
|
373
384
|
```
|
|
374
385
|
|
|
375
|
-
`
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
`
|
|
385
|
-
`
|
|
386
|
-
|
|
387
|
-
`
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
`
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
`
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
`error=no_subscription|already_canceled|nothing_to_retry|retry_too_soon`), profil alan hata kodları
|
|
406
|
-
(422) ve sonuçları servisle aynıdır; uyumluluk paketi iki hedefi de sayfa üzerinden sürer.
|
|
407
|
-
Yönlendirmeler mutlak adrestir (servis köke göre yol verir). Sahte sağlayıcı hata vermediğinden
|
|
408
|
-
`provider_error` bildirimi yok. "Ödemeyi yeniden dene" isteği kabul eder (`ok`, abonelik başına
|
|
409
|
-
10 dakikada bir; dry-run olmayan dunning retry adımı da sayılır) ama tahsilatı kendisi
|
|
410
|
-
sonuçlandırmaz: servisteki gibi sonuç sonradan gelir — `simulate.renewal(ref)` aynı başarısız
|
|
411
|
-
siparişi başarılı sayar (`subscription.reactivated`), `{fail: true}` yine başarısız.
|
|
412
|
-
`simulate.portalCancel/portalRetryPayment` bilinmeyen ya da süresi geçmiş oturumda fırlatır.
|
|
413
|
-
|
|
414
|
-
## `@steward/contract`'tan geçiş
|
|
415
|
-
|
|
416
|
-
| Eski | Yeni |
|
|
417
|
-
| --- | --- |
|
|
418
|
-
| `@z9cloud/steward-contract` | `@stewardhq/sdk` (ya da yalnız şema için `@stewardhq/sdk/contract`) |
|
|
419
|
-
| `@z9cloud/steward-contract/client` | `@stewardhq/sdk` `createSteward` (fırlatan; fırlatmayan `createBillingClient` dışa açılmaz) |
|
|
420
|
-
| `@z9cloud/steward-contract/webhook` | `@stewardhq/sdk/webhook` |
|
|
421
|
-
| `@z9cloud/steward-contract/fixtures/events/*.json` | `@stewardhq/sdk/testing/fixtures/events/*.json` |
|
|
422
|
-
| `getEntitlements(ref)` / `accounts.entitlements(ref)` | `accounts.state(ref)` ya da `stateCache` (tek okuma modeli `AccountState`; uç 2026-09-16'da kaldırıldı) |
|
|
423
|
-
| `listEvents` / `redeliverEvent` / `events.*` | Yok (2026-09-16): kaçırılan durum için `accounts.resync(ref)`; dead teslimatı operatör pod CLI `redeliver-event` ile yeniden gönderir (testte `fake.simulate.redeliver`) |
|
|
386
|
+
- `Events({ steward, catalog?, actorRef?, flushIntervalMs = 2000, maxBatch = 500, maxQueue = 10_000,
|
|
387
|
+
autoFlush = true, onRejected?, onDrop? })`: `track(ref, name, metadata?, { externalId?, timestamp? })`
|
|
388
|
+
is sync and never throws (an invalid event → WARN + `onDrop`); batches go to `POST
|
|
389
|
+
/v1/events/ingest` with one `Idempotency-Key` per batch, reused by every retry (`503
|
|
390
|
+
events_backlog` included); after the retries the batch waits at the front of the queue (backoff
|
|
391
|
+
≤ 30 s); a full queue hands its OLDEST events to `onDrop(events, { reason: "queue_full" })`, an
|
|
392
|
+
invalid event and a batch refused for good (4xx) go there too (`"invalid_event"`,
|
|
393
|
+
`"request_failed"`); per-event rejections go to `onRejected(rejected)` (not retried). `track`
|
|
394
|
+
stamps `timestamp` with the call's time unless given. Give a deterministic `externalId`
|
|
395
|
+
(`cmpl_<id>`): the generated UUID only protects against the SDK's own retries. On the edge:
|
|
396
|
+
`Events({ autoFlush: false })` + `ctx.waitUntil(events.flush())`; `flush()` never rejects (what
|
|
397
|
+
failed stays queued). `ingest(events, { idempotencyKey? })` sends directly and throws;
|
|
398
|
+
`pending(ref)` lists the account's events steward has not counted yet (queued, in flight, acked in
|
|
399
|
+
the last minute) — `Meters({ events })` subtracts them.
|
|
400
|
+
- `Meters({ steward, catalog, cache?, events?, ttlMs = 10_000, staleIfErrorMs = 600_000,
|
|
401
|
+
onUnavailable = "allow", onFallback? })`: `get(ref)` (every meter, `Record<Meter, MeterBalance>`),
|
|
402
|
+
`balance(ref, meter)`, `check(ref, meter, { units = 1 })` → `{ allowed, balance, standing,
|
|
403
|
+
billable, source }` with `allowed = billable ? !capReached : includedUnits === null || balance −
|
|
404
|
+
pending ≥ units` (a billable meter closes once steward reports its period overage reached the
|
|
405
|
+
rate's `capMinor`). Reads are cached (ETag/304); this process's queued (and not yet rolled up) events are
|
|
406
|
+
subtracted; when steward is unreachable the stale value is used for 10 min, then the catalog's
|
|
407
|
+
default-plan credit (or the account's coarse facts from `cache`) and `onUnavailable` decide.
|
|
408
|
+
- `stateOf()` carries the `meters` block when the catalog has meters; `Webhooks()` gains
|
|
409
|
+
`onMeterThreshold` (standing `ok → low → exhausted`, also as the cause of
|
|
410
|
+
`account.state_changed`), `onMeterPeriodClosed` (with `period`) and `onBenefitCycled`
|
|
411
|
+
(`benefit_grant.cycled`). `steward.events.ingest`, `steward.meters.get/read/periods`,
|
|
412
|
+
`steward.credits.grant`, `steward.meterCharges.list` are the raw calls. Push a catalog with
|
|
413
|
+
meters only to a steward whose `me().capabilities` lists `"meters"` (an older one drops them).
|
|
414
|
+
|
|
415
|
+
Errors — the product API's status code:
|
|
424
416
|
|
|
425
|
-
|
|
417
|
+
```ts
|
|
418
|
+
import { httpStatusFor, StewardError } from "@stewardhq/sdk";
|
|
426
419
|
|
|
427
|
-
|
|
420
|
+
if (error instanceof StewardError) {
|
|
421
|
+
const { status, headers } = httpStatusFor(error, { logger }); // already_subscribed 409, rate_limited 503 + Retry-After…
|
|
422
|
+
return Response.json({ error: error.code }, { status, headers });
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
A code that is not in the table is mapped by `category` (`not_found` 404, `conflict` 409,
|
|
427
|
+
`provider` 502, `unavailable` 503; `validation`/`config` 500 + an ERROR log). The text shown to
|
|
428
|
+
the user is the product's (`error.code` → your own i18n); `error.message` is for logs
|
|
429
|
+
(steward's message, or the code if there is none). Price display lives on the hosted checkout
|
|
430
|
+
and portal pages; the SDK carries no currency formatting helper.
|
|
428
431
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
+
Validators: `isValidTckn`, `isValidVkn` (checksum digit) and `isE164(value, {country: "TR"}?)`
|
|
433
|
+
check the value as-is (no whitespace trimming).
|
|
434
|
+
|
|
435
|
+
## Removed APIs
|
|
436
|
+
|
|
437
|
+
| Old | New |
|
|
438
|
+
| --- | --- |
|
|
439
|
+
| `@stewardhq/sdk/testing` (`signedEvent`, `createFakeSteward`) | Gone (0.4.0): write integration tests against the service, or stub `fetch` |
|
|
440
|
+
| `getEntitlements(ref)` / `accounts.entitlements(ref)` | `accounts.state(ref)` or `stateCache` (the single read model `AccountState`; the endpoint was removed on 2026-09-16) |
|
|
441
|
+
| `listEvents` / `redeliverEvent` / `events.*` | Gone (2026-09-16): for missed state use `accounts.resync(ref)`; an operator redelivers a dead delivery with the pod CLI's `redeliver-event` |
|
|
442
|
+
|
|
443
|
+
## Versioning
|
|
444
|
+
|
|
445
|
+
Semver. Removing or renaming a field is breaking (major; before 1.0, minor). Release: bump the
|
|
446
|
+
version in `sdk/package.json` and push the `sdk-v<version>` tag
|
|
447
|
+
(`.github/workflows/sdk-publish.yml`). **The first version on npmjs is 0.5.0** (2026-09-21);
|
|
448
|
+
0.4.0 stayed inside the repository and was never published.
|
|
449
|
+
|
|
450
|
+
**0.6.0** (contract 0.5.0, bank transfer F1; additive; `API_VERSION` `2026-10-01` unchanged):
|
|
451
|
+
|
|
452
|
+
- `Checkout().create({ replacesSubscriptionId })`: an upgrade that replaces the live subscription only
|
|
453
|
+
when the payment is recorded (do not cancel it before the checkout); `result()` adds `paymentMethod`.
|
|
454
|
+
- State: `openCheckout.paymentMethod` (`bank_transfer` while the order waits for a transfer) and
|
|
455
|
+
`bankTransferCode`; `subscription.activated` detail `replacedSubscriptionId`; `CheckoutSession`
|
|
456
|
+
`paymentMethod` / `replacesSubscriptionId`.
|
|
457
|
+
- Settings: `billing.bankTransfer` (`enabled`, `paymentDays`); email template `bank_transfer_instructions`.
|
|
458
|
+
- Admin: `BankTransferRecordInputSchema` / `BankTransferRecordedSchema` (`POST /v1/admin/bank-transfers`;
|
|
459
|
+
a transfer completes a checkout or pays an unpaid invoice, `checkoutSessionId` / `invoiceId`);
|
|
460
|
+
error codes `replaces_subscription_invalid`, `bank_transfer_*`.
|
|
461
|
+
- State change cause `bank_transfer.code_assigned` (the account chose bank transfer on the invoice pay page).
|
|
462
|
+
|
|
463
|
+
**0.5.0** (contract 0.4.0, metering phase 2; additive; `API_VERSION` `2026-10-01` unchanged):
|
|
464
|
+
|
|
465
|
+
- Catalog: `meters` + `meter()`, `count()` / `sum(key)` / `max(key)` / `unique(key)`, `meterCredit()`
|
|
466
|
+
benefits, `meteredPrices`; `typeof catalog.$meter` / `$event`; `stateOf()` adds `meters`.
|
|
467
|
+
- `Events()` (`track`, `flush`, `ingest`, `pending`; `onRejected`, `onDrop(events, {reason})`) and `Meters()`
|
|
468
|
+
(`get`, `balance` → `MeterBalance`, `check`, `invalidate`) in the root entry, no `node:*`; `stateCache().peek(ref)`.
|
|
469
|
+
- Client: `events.ingest`, `meters.get/read/periods`, `credits.grant`, `meterCharges.list`.
|
|
470
|
+
- Hooks: `onMeterThreshold`, `onMeterPeriodClosed` (+ `period`), `onBenefitCycled`.
|
|
471
|
+
|
|
472
|
+
**0.4.0** (contract 0.3.0, billing core phase 1; `API_VERSION` `2026-10-01` unchanged):
|
|
473
|
+
|
|
474
|
+
- Breaking (0.x minor): the `@stewardhq/sdk/testing` subpath (`signedEvent`, the fixtures, the
|
|
475
|
+
fake steward) was removed; tests are written against the real service or a `fetch` stub.
|
|
476
|
+
- Catalog: `benefits` + `featureFlag()`, `plans[].benefits`, `typeof catalog.$benefit`;
|
|
477
|
+
`PlanSpec.rank` is optional and is not read during entitlement resolution (a union of layers,
|
|
478
|
+
the contract's `resolveBenefits`).
|
|
479
|
+
- State: `AccountState.benefits`, `subscriptions[]` (the singular `subscription` is the primary
|
|
480
|
+
subscription), `openInvoices[]`; `amountMinor` on a subscription is the amount that
|
|
481
|
+
subscription locked.
|
|
482
|
+
- Events (when requested in the endpoint's `eventTypes`): `invoice.paid`,
|
|
483
|
+
`benefit_grant.created/updated/revoked`; the hooks `onInvoicePaid`, `onBenefitGranted`,
|
|
484
|
+
`onBenefitRevoked` (and `onBenefitGrantCreated/Updated/Revoked` derived from the type).
|
|
485
|
+
- Client: `invoices.createPaymentSession`, `invoices.waive`, `subscriptions.change`,
|
|
486
|
+
`subscriptions.uncancel`; `capabilities` in the `me()` response.
|
|
487
|
+
|
|
488
|
+
## License
|
|
489
|
+
|
|
490
|
+
Apache License 2.0 — see [LICENSE](LICENSE).
|