@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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +332 -273
  3. package/dist/_chunks/errors.js +1 -1
  4. package/dist/_chunks/events.d.ts +386 -78
  5. package/dist/_chunks/events.js +1035 -19
  6. package/dist/_chunks/index.d.ts +3752 -305
  7. package/dist/_chunks/locale.d.ts +270 -2
  8. package/dist/_chunks/src.js +925 -31
  9. package/dist/_chunks/validators.d.ts +46 -2
  10. package/dist/_chunks/validators.js +128 -27
  11. package/dist/_chunks/webhook-core.d.ts +2 -2
  12. package/dist/contract.d.ts +4 -4
  13. package/dist/contract.js +4 -4
  14. package/dist/index.d.ts +447 -33
  15. package/dist/index.js +1659 -26
  16. package/dist/server.d.ts +66 -16
  17. package/dist/server.js +35 -4
  18. package/package.json +14 -13
  19. package/dist/_chunks/steward.d.ts +0 -189
  20. package/dist/_chunks/steward.js +0 -564
  21. package/dist/testing/fixtures/events/LOCK.json +0 -27
  22. package/dist/testing/fixtures/events/account.deleted.json +0 -36
  23. package/dist/testing/fixtures/events/account.state_changed.json +0 -53
  24. package/dist/testing/fixtures/events/account.updated.json +0 -37
  25. package/dist/testing/fixtures/events/checkout.completed.json +0 -36
  26. package/dist/testing/fixtures/events/checkout.expired.json +0 -26
  27. package/dist/testing/fixtures/events/checkout.failed.json +0 -27
  28. package/dist/testing/fixtures/events/invoice.created.json +0 -37
  29. package/dist/testing/fixtures/events/invoice.issued.json +0 -38
  30. package/dist/testing/fixtures/events/invoice.voided.json +0 -39
  31. package/dist/testing/fixtures/events/subscription.activated.json +0 -40
  32. package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +0 -38
  33. package/dist/testing/fixtures/events/subscription.canceled.json +0 -29
  34. package/dist/testing/fixtures/events/subscription.expired.json +0 -27
  35. package/dist/testing/fixtures/events/subscription.payment_failed.json +0 -38
  36. package/dist/testing/fixtures/events/subscription.reactivated.json +0 -36
  37. package/dist/testing/fixtures/events/subscription.renewed.json +0 -39
  38. package/dist/testing/fixtures/events/subscription.suspended.json +0 -36
  39. package/dist/testing/fixtures/events/subscription.terminated.json +0 -28
  40. package/dist/testing.d.ts +0 -656
  41. package/dist/testing.js +0 -3684
package/README.md CHANGED
@@ -1,190 +1,195 @@
1
1
  # @stewardhq/sdk
2
2
 
3
- Steward ↔ ürün entegrasyonu: sözleşme şemaları (zod), tipli API istemcisi ve
4
- webhook imzası. Kaynak: [z9cloud/steward](https://github.com/z9cloud/steward/tree/main/sdk).
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
- ## Kurulum
6
+ ## Installation
7
7
 
8
- npmjs'te public; token gerekmez.
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. Tek çalışma zamanı bağımlılığı zod.
14
+ ESM, Node ≥ 22. Its only runtime dependency is zod.
15
15
 
16
- ## Alt yollar
16
+ ## Subpaths
17
17
 
18
- | Import | İçerik |
18
+ | Import | Contents |
19
19
  | --- | --- |
20
- | `@stewardhq/sdk` | Tüm şema/tipler + fırlatan istemci `createSteward`, hesap durumu önbelleği `stateCache`, `defineCatalog`, hata sınıfları + `httpStatusFor`, `isValidTckn`/`isValidVkn`/`isE164` |
21
- | `@stewardhq/sdk/contract` | Yalnızca şemalar ve tipler |
22
- | `@stewardhq/sdk/webhook` | `signWebhook` / `verifyWebhook` / `verifyWebhookRaw` (Node HMAC, senkron) |
23
- | `@stewardhq/sdk/webhook/web` | Aynı API WebCrypto ile (async; edge/workerd/tarayıcı) |
24
- | `@stewardhq/sdk/testing/fixtures/events/<tür>.json` | Her event türünün örnek gövdesi |
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
- Kök giriş, `./contract` ve `./webhook/web` `node:*` kullanmaz (edge/tarayıcı);
29
- `./webhook` senkron Node HMAC'idir.
26
+ The root entry, `./contract` and `./webhook/web` do not use `node:*` (edge/browser);
27
+ `./webhook` is the synchronous Node HMAC.
30
28
 
31
- ## Kullanım
29
+ ## Usage
32
30
 
33
- Hesap durumu (okuma modeli) — ürün DB'sinde hak projeksiyonu tutulmaz; kapılar steward'ı
34
- pod başına bir önbellek üzerinden canlı okur:
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() tipli olur
42
- // steward kapalı ve bayat değer yoksa (AccountState | null); catalog.stateOf: katalogdan tam durum (version 0)
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'da hesabı olmayan kayıt da fallback'e (hesap ilk checkout'ta açılıyorsa)
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'i beklemeden
54
- state.prime((await steward.grants.create(orgId, input)).account); // mutasyon yanıtı önbelleğe
55
- const byRef = await state.many(orgIds); // Map<ref, AccountState>, en çok 8 paralel
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
- | Durum | Davranış |
56
+ | Situation | Behaviour |
59
57
  | --- | --- |
60
- | TTL içinde | Bellekten, istek yok; aynı hesabın eşzamanlı okumaları tek istek |
61
- | TTL doldu | `If-None-Match`: 304 süreyi yeniler, 200 yeni durumu yazar |
62
- | Ağ hatası, zaman aşımı, 5xx, 429 | Süre dolduktan sonra `staleIfErrorMs` boyunca bayat değer (WARN), sonra `fallback` (WARN + `onFallback`), o da yoksa (ya da `null`) asıl `StewardNetworkError`/`StewardApiError`. Başarısız istekten sonra o hesap için `min(ttlMs, 5 sn)` steward'a gidilmez |
63
- | 404 `account_not_found` (varsayılan `missing: "throw"`) | Kesinti değil: fallback yok, önbelleğe girmez, `StewardApiError` fırlar (eksik `upsert` görünür kalır) |
64
- | 404 `account_not_found`, `missing: "fallback"` | `fallback(ref)` (zorunlu seçenek); WARN yok, `onFallback` nedeni `account_not_found` (kesinti metriğinden ayrılır); bayat değer kullanılmaz. Sonuç önbelleğe girmez, "yok" bilgisi `min(ttlMs, 5 sn)` tutulur; `prime`/`invalidate`/`fresh` bitirir (checkout sonucu ve webhook `prime` eder). Fallback `null` → 404 fırlar |
65
- | `prime(account)` | Yalnızca `version >= önbellekteki` ise yazar (geri gitmez); `null`/`undefined` yok sayılır |
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
- Önbellek pod başına bellek içidir (LRU); çok pod'da tutarlılık TTL kadardır.
68
- `invalidate(ref)` sonraki okumanın hemen (ETag'le) sormasını sağlar.
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 alıcısı (`/server`, Node) — imza ham gövdede, sonra sözleşme; durum steward'dan
71
- canlı okunur, önbelleğe yazılır; hook'lar tipli:
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 verilince varsayılan): payload yalnız tetikleyici
78
- // secrets verilmezse her teslimatta STEWARD_WEBHOOK_SECRET ("yeni,eski" rotasyonu); toleranceSeconds: 300
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"; // bilinmeyen hesap: 200, diğer hook'lar ve after koşmaz
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<Tip>: eski tipten ya da state_changed cause'undan
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), // diğer hook'lardan sonra, her teslimatta
87
- // includeProfile: true → bildirim hook'ları fatura profilini (e-posta) alır; refetch: false → gövdedeki durum
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)); // ya da: const { status, body } = await webhooks.receive(rawBody, headers)
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
- | Durum | Yanıt |
91
+ | Situation | Response |
94
92
  | --- | --- |
95
- | Hook'lar koştu | 200 `{ok: true, handled: "received"}` |
96
- | `onStateChanged` `"ignore"` döndü | 200 `ignored` + WARN; neden hook'ları ve `after` koşmaz |
97
- | Hesap silindi: `account.deleted` ya da `cause: "account.deleted"` | 200 `deleted`; canlı okuma yok, `onAccountDeleted` + `after` gövdedeki anonim son durumla (`onStateChanged` koşmaz, `cache.invalidate` varsa çağrılır). Silmeden önce kuyruğa girmiş teslimatın canlı okuması `account_deleted` (410) alırsa hook'suz 200 `deleted` + WARN |
98
- | `endpoint.ping` | 200 `ping`, hook yok |
99
- | `refetch: false` ve gövdede yalnızca snapshot (yükseltme öncesi kuyruk) | 200 `no_state` + WARN: `onStateChanged` koşmaz, neden hook'ları ve `after` `state: null` ile koşar |
100
- | Tanınmayan tip | 200; `onUnknownEvent` (durum varsa önce `onStateChanged`) |
101
- | İmza/başlık/zaman penceresi | 401 |
102
- | İmzalı ama sözleşmeye uymayan gövde, id uyuşmazlığı | 400 + ALARM (steward yeniden dener) |
103
- | Sır yok, güncel durum okunamadı (`state_unavailable`), profil okunamadı, bir hook fırlattı | 503 (steward yeniden dener, hook'ların hepsi yeniden koşar) |
104
-
105
- Tipler: `steward` verilmiş ve `refetch: false` değilse her hook'un (`onStateChanged`, `on<Tip>`,
106
- `onPaymentFailed`…, `onUnknownEvent`, `after`) `state`'i `AccountState`'tir — durum okunamazsa 503 döner,
107
- hook koşmaz; `after`'ın `handled`'ı `"received"`. `refetch: false` ya da `steward`'sız alıcıda neden hook'ları,
108
- `onUnknownEvent` ve `after` `AccountState | null` alır (yükseltme öncesi kuyruk; `onStateChanged` yine tam
109
- durumla koşar). Ayrım `refetch` ve `steward` alanlarının tiplerinden çıkarılır (tip belirsizse null'lı).
110
-
111
- **SDK tekrar ayıklamaz, version kapısı ve kilit tutmaz.** `refetch` ile her teslimat
112
- durumu steward'dan canlı okur: sırasız ya da tekrar teslimat en güncel durumu görür,
113
- yalnızca snapshot taşıyan eski gövde de tam kullanılır (`event.data.account` bayat
114
- olabilir; `state`'i kullan). Aynı event yine birden çok kez ve aynı hesabın teslimatları
115
- eşzamanlı gelebilir: hook'lar idempotent yazmalı (plan alanını durumdan yaz, "zaten
116
- askıdaysa dokunma", sonlandırma işareti `coalesce`). Yeniden denenmesi istenen durumda hook
117
- fırlatır → 503. `refetch: false` gövdedeki durumu kullanır (sıra garantisi yok; eski
118
- kuyruğun boşalmasını bekle). Aynı olay için hem eski tipi hem `account.state_changed`'i
119
- isteyen endpoint neden hook'larını iki kez çalıştırır: endpoint filtresinde birini seç.
120
-
121
- Hosted checkout (`/server`) — profil, TCKN/VKN, onaylar ve ödeme formu steward'ın sayfasında;
122
- ürün yalnızca oturumu açar, kullanıcıyı `url`'e yönlendirir ve dönüşte sonucu sorar:
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
- // mutlak; origin steward ayarında izinli (appUrl + extraReturnOrigins). Yer tutucular varsa
130
- // steward yalnızca onları doldurur; yoksa adres aynen kullanılır (parametre eklenmez).
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}] ya da dizi; sayfada onaylatılır
133
- // cancelUrl (yoksa ayar appUrl), locale ve currency (yoksa ürün ayarı)
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); // yetki ÇAĞRIDAN ÖNCE ürünün işi
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 }); // tarayıcı 303 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) => // dönüş sayfası ?checkout=<id>
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
- | Durum | Davranış |
150
+ | Situation | Behaviour |
149
151
  | --- | --- |
150
- | `create` | `POST …/checkout-sessions` hosted gövdeyle (profil/kimlik yok); sağlayıcıya çağrı yok; `{id, url, expiresAt}`. Hesap yoksa `displayName` ile açılır, o da yoksa `account_not_found`. Steward hataları aynen (`already_subscribed` 409, `plan_not_sellable` 422, `return_url_not_allowed` → `httpStatusFor` 500 + ALARM) |
151
- | `result`, oturum başka hesabın / id geçersiz / oturum yok | Aynı hata: `StewardApiError` `checkout_session_not_found` (404) — `?checkout=` kullanıcı girdisidir, sahiplik ele verilmez |
152
- | `result`, `completed` | Yanıttaki `account` `cache.prime` edilir (dönüş sayfası webhook'u beklemeden yeni planı gösterir; webhook yine kaynak) |
153
- | `locale` | `CustomerPortal` ile aynı kural: BCP-47 benzeri etiketin birincil alt etiketi (`tr-TR` → `tr`, `en-US` → `en`). Çağrıdaki desteklenmeyen dil yok sayılır: seçenekteki dil, o da yoksa gönderilmez (steward `defaultLocale`); `consents` işlevi bu dille çağrılır |
154
- | Göreli/http(s) olmayan `successUrl`, `cancelUrl`, belge adresi; seçenekte desteklenmeyen `locale` | Oluşturmada `StewardConfigError` |
155
- | Yanıtta `url` yok (F2b öncesi steward) | `StewardContractError` |
156
-
157
- Kullanıcı sayfayı kapatırsa oturum süresi dolunca (`checkoutTtlMinutes`) steward kapatır;
158
- ürün sonucu `account.state_changed` ile öğrenir. `steward.checkoutSessions.create` de yalnızca
159
- hosted gövdeyi kabul eder; fatura profili yalnızca hosted checkout ve portal sayfalarında yazılır.
160
-
161
- Hosted müşteri portalı (`/server`) — abonelik durumu, dönem sonunda iptal, ödemeyi yeniden dene,
162
- fatura listesi/PDF ve kurumsal fatura profili steward'ın sayfasında; ürün yalnızca yetkiyi
163
- denetler, oturumu açar ve yönlendirir. Ürün tarafında iptal/yeniden dene/fatura ucu yoktur:
164
- sonuçlar `account.state_changed` ile `Webhooks()`'a gelir:
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 yoksa ayar appUrl
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); // yetki ÇAĞRIDAN ÖNCE ürünün işi
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 }); // tarayıcı 303 url
178
+ return c.json({ url }); // the browser 303s to url
175
179
  });
176
180
  ```
177
181
 
178
- | Durum | Davranış |
182
+ | Situation | Behaviour |
179
183
  | --- | --- |
180
- | `create` | `POST …/portal-sessions {locale?, returnUrl?}` → `{id, url, expiresAt}`; durum değişmez, event yok. Süre ürün ayarı `portalTtlMinutes` (varsayılan 30 dk); süresi geçen ya da anahtarı yanlış bağlantı 404 |
181
- | Hesap yok | `StewardApiError` `account_not_found` (404) — checkout'tan farklı olarak hesap açılmaz |
182
- | `returnUrl` origin'i izinli değil | `return_url_not_allowed` (422; `httpStatusFor` 500 + ALARM) |
183
- | `locale` | `tr`/`en` (ör. `tr-TR` → `tr`; `Checkout` ile aynı kural); başka dil gönderilmez, steward `defaultLocale`'i kullanır |
184
- | `actorRef` eksik/biçimsiz, göreli ya da http(s) olmayan `returnUrl` | `StewardConfigError` (istek gitmez) |
185
- | Portal aksiyonları | Aktör steward denetim izinde `customer:<ref>`; iptal `subscription.cancel_scheduled` (neden `customer_portal`), profil `billing_profile.updated`, yeniden dene durum değiştirmez (tahsilat sonucu `subscription.reactivated`) |
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
- Düşük seviyede imza ham gövde üzerinde doğrulanır, sonra sözleşmeyle parse edilir:
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 — tekrarı `id` ile ayıkla, eski `version`'ı uygulama.
200
+ // result.event: BillingEvent — deduplicate on `id`, and do not apply an older `version`.
196
201
  ```
197
202
 
198
- API istemcisi — `createSteward()` ayarları seçeneklerden, yoksa ortamdan okur
199
- (`STEWARD_URL`, `STEWARD_API_KEY`; edge'de `env` verilir) ve eksikse **açılışta**
200
- `StewardConfigError` fırlatır (yalnızca admin ayarlı istemci hariç, aşağıda). Başarısız çağrı fırlatır:
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" }); // ya da createSteward({ env, fetch, timeoutMs: 10_000 })
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: "kurumsal sözleşme" }, { actorRef: `staff:${staffId}` });
214
+ await steward.grants.create(accountRef, { planCode: "team", reason: "enterprise agreement" }, { actorRef: `staff:${staffId}` });
209
215
  ```
210
216
 
211
- | Kaynak | Metotlar |
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}` (canlı abonelik planı hedefi geçiyorsa `mode: "ensure"` uyarı, `"strict"` `subscription_plan_higher`), `resync(ref)` → `{queued, eventId}` (saklı durum `account.state_changed`, `cause: "resync"`), `delete(ref)` → `{ref, deletedAt}` (KVKK anonimleştirme; faturalar kalır; önce canlı abonelik `immediate` iptal — yoksa `subscription_active`, formu açık checkout'ta `checkout_in_progress`; sonra hesabın her ucu `account_deleted` 410) |
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 müşteri portalı; `CustomerPortal()` bunu sarar) |
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?})` (manuel taslak; profil gerekir, `issue` ile kesilir), `void(id, {reason})` (yalnızca manuel fatura; `invoice.voided` yalnızca filtresinde isteyen endpoint'e) |
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()` → kurulum teşhisi (`GET /v1/doctor`; aşağıda) |
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` (ya da `adminBaseUrl`/`adminApiKey`) ister; yoksa çağrıda `StewardConfigError` |
222
-
223
- - **Mutasyonlar** son argümanda `{ idempotencyKey?, actorRef? }` alır. Anahtar
224
- verilmezse çağrı başına üretilir; `actorRef` verilmezse `createSteward`'daki varsayılan gider.
225
- İkisi de yoksa ya da biçimi geçersizse (`tür[:kimlik]`) istek **gitmez**: `StewardConfigError`
226
- (`code` servisinkiyle aynı: `actor_ref_required` / `invalid_actor_ref`).
227
- - **Yalnızca yönetim:** `STEWARD_URL` ve `STEWARD_API_KEY` ikisi de yoksa ve `STEWARD_ADMIN_URL` +
228
- `STEWARD_ADMIN_API_KEY` (ya da `adminBaseUrl`/`adminApiKey`) verilmişse istemci kurulur (CLI, katalog
229
- senkronu): `admin.*` çalışır, iki kapsama açık `doctor()` ve `me()` admin anahtarıyla gider, ürün uçları
230
- ve `raw.*` çağrıda `StewardConfigError` ile reddeder (istek gitmez). Ürün ayarının yalnızca yarısı
231
- verilmişse yine açılışta hata.
232
- - **Hatalar:** yanıtlı hata `StewardApiError` (`code`, `httpStatus`, `category`,
233
- `retryable`, `requestId`, `retryAfterMs`), yanıt yok `StewardNetworkError`
234
- (`network_error`/`timeout`), şemaya uymayan yanıt `StewardContractError`.
235
- - **Tekrar deneme** (`retries: { attempts: 2, on: ["network", "5xx", "429"], maxDelayMs: 5000 }`):
236
- ağ hatası/zaman aşımı, 5xx ve 429'da GET'ler ve `Idempotency-Key` taşıyan mutasyonlar
237
- en çok `attempts` kez daha (varsayılan en çok 3 istek) denenir; anahtar bütün denemelerde
238
- aynıdır. Bekleme `Retry-After` kadar, yoksa üstel (250 ms, 500 ms…) + jitter; ikisi de
239
- `maxDelayMs` ile sınırlı. 429 dışındaki 4xx ve `retryable: false` yanıtlar
240
- (`provider_error`, `provider_not_configured`) denenmez. `timeoutMs` deneme başınadır.
241
- - İstekler `Steward-Api-Version` ve `X-Steward-Sdk: @stewardhq/sdk/<sürüm>` taşır (yalnızca gözlem).
242
- - Yalnızca web standartları (`fetch`, `crypto.randomUUID`, `AbortSignal.timeout`): Node 22,
243
- edge ve workerd'de aynı kod. Kök giriş ve `./webhook/web` her paket kontrolünde
244
- workerd'de `nodejs_compat` kapalı koşturulur (`scripts/check-workerd.mjs`).
245
-
246
- Yönetim (admin anahtarı; cluster içi, port-forward) — ürün ayarları ve webhook endpoint'leri.
247
- Webhook sırrını **çağıran üretir** ve kendi Secret'ına yazar; steward sırrı hiçbir yanıtta
248
- geri vermez:
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'i checkout dönüşüne izinli; + extraReturnOrigins
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" }, // sığ birleşir; alanı null temizler
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 rastgele bayt
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 verilmezse ["account.state_changed"]; adres https:// ya da http://*.svc.cluster.local
263
- await steward.admin.endpoints.rotate(endpoint.id, { secret: generateWebhookSecret() }); // eski sır geçiş için ikinci imza
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
- Kurulum teşhisi — ürün anahtarı yeter; ilk checkout'tan önce `ok: true` beklenir:
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` önemde uyarı yok
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 (son teslimat 401) | old_contract (400) | unreachable | failing | no_deliveries
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
- Kodlar ve teşhisler açık kümedir (bilinmeyeni yalnızca gösterin). `provider.healthy` ve
277
- `lastError` yanıtı veren steward pod'unun gözlemidir.
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
- Katalog — kodda tipli tanım; eksik/fazla hak, yanlış tip (`limit` → tam sayı ya da
280
- `null` = sınırsız, `flag` → boolean, `text` → string) ve tanımsız plana fiyat derleme
281
- hatasıdır, tipler atlatılırsa tanım anında `StewardConfigError` fırlatır (TypeScript ≥ 5.4):
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 anahtarıyla (CLI/deploy adımı)
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
- // tam AccountState: version 0, updatedAt 1970, catalogVersion null, haklar planınki, abonelik/açık checkout yok,
300
- // profil { present: false }; plan verilmezse varsayılan plan (stateCache fallback'i için)
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
- `resolve` eksik ya da tipi uymayan anahtarda değeri snapshot'ın planından (plan
304
- katalogda yoksa varsayılan plandan) alır, asla "sınırsız" üretmez; `onFallback`
305
- ile alarm verilebilir. Snapshot yoksa varsayılan planın hakları.
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
- Hatalar — ürün API'sinin durum kodu:
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 { httpStatusFor, StewardError } from "@stewardhq/sdk";
325
+ import { defineCatalog, featureFlag, flag, limit } from "@stewardhq/sdk";
311
326
 
312
- if (error instanceof StewardError) {
313
- const { status, headers } = httpStatusFor(error, { logger }); // already_subscribed 409, rate_limited 503 + Retry-After…
314
- return Response.json({ error: error.code }, { status, headers });
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
- Tabloda olmayan kod `category`'ye göre eşlenir (`not_found` 404, `conflict` 409,
319
- `provider` 502, `unavailable` 503; `validation`/`config` 500 + ERROR log).
320
- Kullanıcıya gösterilen metin ürünündür (`error.code` → kendi i18n'iniz); `error.message`
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
- Doğrulayıcılar: `isValidTckn`, `isValidVkn` (sağlama hanesi), `isE164(value, {country: "TR"}?)`
325
- değeri olduğu gibi denetler (boşluk kırpmaz).
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
- Testler (`/testing`, Node) — sahte steward sunucu açmaz, `fetch` verir; başlıkları
328
- (`Bearer`, `X-Actor-Ref`, `Idempotency-Key` tekrarı), ETag/304, hata gövdelerini ve
329
- event kurallarını servis gibi uygular. Aynı uyumluluk paketi hem sahteye hem gerçek
330
- servis imajına karşı koşar:
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 { createFakeSteward, signedEvent } from "@stewardhq/sdk/testing";
359
+ import { count, defineCatalog, Events, meter, meterCredit, Meters, sum } from "@stewardhq/sdk";
334
360
 
335
- const fake = createFakeSteward({
336
- catalog, // oluşturmada senkronlanır
337
- webhook: { secret }, // eventTypes verilmezse ["account.state_changed"]; "legacy" = 15 eski tip
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
- fake.connect((req) => webhooks.handle(req)); // alıcı sonradan (ya da webhook.handler); uygulama fake'ten sonra kurulabilir
340
- const steward = fake.steward; // ya da ürünün kendi createSteward({ fetch: fake.fetch, baseUrl: fake.baseUrl, … })
341
- await steward.accounts.upsert("org_1", { displayName: "Org", notificationEmails: ["owner@acme.com"] }); // billing email recipients (optional)
342
- const session = await steward.checkoutSessions.create("org_1", input);
343
- await fake.simulate.checkoutCompleted(session.id); // ya da checkoutFailed; hosted oturumda checkoutCanceled da
344
-
345
- // Hosted (Checkout().create): url sahte steward'ın sayfası; tarayıcı gibi sürülebilir
346
- const { id, url } = await checkout.create({ ref: "org_1", planCode: "team", interval: "month", actorRef: "user:1" });
347
- const { redirectUrl, session: done } = await fake.simulate.completeHostedCheckout(url); // sayfa GET + "Tamamla" (outcome: "failed" → Reddet)
348
- const page = await fake.fetch(url); // ya da elle: "Tamamla / Reddet / Vazgeç" formları (k gizli alanda)
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
- `fake.requests` gelen istekleri, `fake.events(ref?)` yayınlanan event'leri tutar. `fake.emails(ref?)`
376
- steward'ın göndereceği faturalandırma e-postalarını servisle aynı kurallarla (şablon, gönderim anındaki
377
- alaka, alıcılar, dil, kapatılmış şablon) listeler; içerik render edilmez ve gönderim gecikmesizdir
378
- ([e-postalar](../docs/integrate/emails.md#test)). Endpoint `webhook`
379
- seçeneğiyle oluşturmada açılır; filtre verilmezse yönetim ucunun yeni ürüne verdiği
380
- `["account.state_changed"]`, `eventTypes: "legacy"` pod CLI'nın filtresiz (15 eski tip) endpoint'i.
381
- Alıcı bağlanmadan `flush`/`deliver` fırlatır (event'ler outbox'ta bekler); `connect` alıcıyı değiştirir.
382
- Sahtenin bilerek farklı olduğu yerler: teslimat asenkron değil (`flush()`), `dead`
383
- olmaz ve geri çekilme beklenmez; sağlayıcı hata vermez; dönüş origin'i (`settings.appUrl`,
384
- `settings.extraReturnOrigins` ya da kısayolu `returnOrigins`) hiç ayarlanmazsa her
385
- `successUrl`/`cancelUrl`/`returnUrl` kabul; `admin.endpoints` ile açılan endpoint yalnızca kayıttır (teslimat
386
- `webhook` seçeneğinin alıcısına gider, `lastDelivery` hep null; `doctor()`'da teşhisi
387
- `no_deliveries`, sağlayıcı `fake` ve sağlıklı); dunning'de yeniden deneme
388
- sonucu `renewal` ile gelir; her yanıt `X-Request-Id` (hata gövdesinde `requestId`) taşır;
389
- idempotency kayıtları süpürülmez. Hosted checkout'ta sahte sayfa servisin sayfasını taklit
390
- etmez, yalnızca `url` ↔ `simulate` sözleşmesini sürülebilir kılar: profil formu, TCKN/VKN
391
- doğrulaması, onay kutuları, deneme hakkı, CSP ve POST kaynak denetimi yok. Servis profili
392
- müşterinin `details` gönderiminde yazar; sahte bunu tamamlanma/red anında yapar — hesabın
393
- profili yoksa `prefill`den (ad, e-posta, tür; geri kalanı sabit test verisi, kurumsalda VKN
394
- `1234567890`) sahte profil yazılır (`billing_profile.updated`), varsa aynen kalır; belgeler
395
- o anda onaylanmış sayılır. Sayfada sağlayıcı formu olmadığından vazgeç her zaman `canceled`
396
- olur ve süresi dolan hosted oturumun nedeni `expired`dır.
397
-
398
- Portal oturumu (`portalSessions.create`) servisle aynı kurallarla açılır (hesap yoksa 404,
399
- `returnUrl` origin politikası, süre `portalTtlMinutes`, 43 karakterlik sayfa anahtarı; süresi
400
- geçen, anahtarsız ya da anahtarı yanlış oturum aynı 404). Sahte portal sayfası da servisin
401
- sayfasını taklit etmez — marka, tr/en metinler, CSP, POST kaynak denetimi (`Sec-Fetch-Site`),
402
- IP başına yanlış anahtar ve oturum başına aksiyon sayaçları (429) yok — ama rotaları
403
- (`GET ?k=`, `POST …/cancel` iki adım `confirm=1`, `…/retry-payment`, `…/profile`,
404
- `GET …/invoices/:id/document?k=`), 303 bildirim kodları (`done=canceled|retry_requested|profile_saved`,
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
- GitHub Packages `.npmrc` satırı ve Docker `--secret` bağlaması artık gerekmez.
417
+ ```ts
418
+ import { httpStatusFor, StewardError } from "@stewardhq/sdk";
426
419
 
427
- ## Sürümleme
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
- Semver. Alan kaldırma/yeniden adlandırma kırıcıdır (major; 1.0 öncesi minor).
430
- Yayın: `sdk/package.json` sürümü artırılır, `sdk-v<sürüm>` etiketi push edilir
431
- (`.github/workflows/sdk-publish.yml`).
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).