@stewardhq/sdk 0.2.0 → 0.5.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 (39) hide show
  1. package/README.md +123 -88
  2. package/dist/_chunks/errors.js +1 -1
  3. package/dist/_chunks/events.d.ts +315 -4
  4. package/dist/_chunks/events.js +1155 -9
  5. package/dist/_chunks/index.d.ts +3473 -90
  6. package/dist/_chunks/locale.d.ts +269 -1
  7. package/dist/_chunks/src.js +842 -24
  8. package/dist/_chunks/validators.d.ts +45 -1
  9. package/dist/_chunks/validators.js +128 -27
  10. package/dist/contract.d.ts +4 -4
  11. package/dist/contract.js +4 -4
  12. package/dist/index.d.ts +436 -22
  13. package/dist/index.js +1659 -26
  14. package/dist/server.d.ts +44 -6
  15. package/dist/server.js +29 -2
  16. package/package.json +4 -10
  17. package/dist/_chunks/steward.d.ts +0 -189
  18. package/dist/_chunks/steward.js +0 -560
  19. package/dist/testing/fixtures/events/LOCK.json +0 -27
  20. package/dist/testing/fixtures/events/account.deleted.json +0 -36
  21. package/dist/testing/fixtures/events/account.state_changed.json +0 -53
  22. package/dist/testing/fixtures/events/account.updated.json +0 -37
  23. package/dist/testing/fixtures/events/checkout.completed.json +0 -36
  24. package/dist/testing/fixtures/events/checkout.expired.json +0 -26
  25. package/dist/testing/fixtures/events/checkout.failed.json +0 -27
  26. package/dist/testing/fixtures/events/invoice.created.json +0 -37
  27. package/dist/testing/fixtures/events/invoice.issued.json +0 -38
  28. package/dist/testing/fixtures/events/invoice.voided.json +0 -39
  29. package/dist/testing/fixtures/events/subscription.activated.json +0 -40
  30. package/dist/testing/fixtures/events/subscription.cancel_scheduled.json +0 -38
  31. package/dist/testing/fixtures/events/subscription.canceled.json +0 -29
  32. package/dist/testing/fixtures/events/subscription.expired.json +0 -27
  33. package/dist/testing/fixtures/events/subscription.payment_failed.json +0 -38
  34. package/dist/testing/fixtures/events/subscription.reactivated.json +0 -36
  35. package/dist/testing/fixtures/events/subscription.renewed.json +0 -39
  36. package/dist/testing/fixtures/events/subscription.suspended.json +0 -36
  37. package/dist/testing/fixtures/events/subscription.terminated.json +0 -28
  38. package/dist/testing.d.ts +0 -626
  39. package/dist/testing.js +0 -3638
package/dist/testing.js DELETED
@@ -1,3638 +0,0 @@
1
- import { C as BillingProfileSchema, D as API_VERSION, O as ActorRefSchema, R as ExternalRefSchema, a as EVENT_TYPES, d as ACCOUNT_INCLUDES, i as EVENT_DETAIL_SCHEMAS, r as BillingEventSchema, s as LEGACY_EVENT_TYPES, x as AccountUpsertInputSchema, z as IdSchema } from "./_chunks/events.js";
2
- import { $ as InvoiceIssueInputSchema, A as webhookEndpointUrlProblem, Bt as applySettingsPatch, G as HEADER_ACTOR_REF, Gt as CatalogSyncInputSchema, J as HEADER_REQUEST_ID, L as ERROR_CODES, M as PlanSetInputSchema, Nt as ProductSettingsPatchSchema, Ot as checkoutRedirectUrl, Pt as ProductSettingsSchema, S as WebhookEndpointCreateInputSchema, Vt as isReturnUrlAllowed, at as ManualInvoiceInputSchema, ct as splitInclusiveTax, gt as PortalSessionCreateInputSchema, h as buildDoctorReport, lt as CancelSubscriptionInputSchema, q as HEADER_IDEMPOTENCY_KEY, rt as InvoiceVoidInputSchema, st as manualInvoiceTotals, ut as GrantCreateInputSchema, v as DEFAULT_ENDPOINT_EVENT_TYPES, w as WebhookEndpointRotateInputSchema, xt as CheckoutSessionCreateInputSchema } from "./_chunks/src.js";
3
- import { i as LIVE_SUBSCRIPTION_STATUSES, r as isValidVkn, s as resolveEntitlements, t as isE164 } from "./_chunks/validators.js";
4
- import { t as createSteward } from "./_chunks/steward.js";
5
- import { n as signWebhook } from "./_chunks/webhook.js";
6
- import { createHash } from "node:crypto";
7
- //#region src/testing/world.ts
8
- const DAY_MS = 864e5;
9
- /** Servisin `DEFAULT_DUNNING_POLICY` kopyası (`domain/dunning-policy.ts`). */
10
- const DEFAULT_DUNNING_POLICY = {
11
- steps: [
12
- {
13
- afterDays: 1,
14
- action: "retry_payment"
15
- },
16
- {
17
- afterDays: 3,
18
- action: "retry_payment"
19
- },
20
- {
21
- afterDays: 7,
22
- action: "suspend"
23
- },
24
- {
25
- afterDays: 30,
26
- action: "terminate"
27
- }
28
- ],
29
- dryRun: true
30
- };
31
- function createWorld(options) {
32
- let offset = 0;
33
- const now = () => options.now().getTime() + offset;
34
- const id = uuidv7Factory(now);
35
- return {
36
- productCode: options.productCode,
37
- settings: options.settings,
38
- permissiveReturnOrigins: options.permissiveReturnOrigins,
39
- adminEndpoints: [],
40
- dunningPolicy: options.dunningPolicy,
41
- endpoint: options.endpointTypes === null ? null : {
42
- id: id(),
43
- types: new Set(options.endpointTypes)
44
- },
45
- features: [],
46
- plans: /* @__PURE__ */ new Map(),
47
- prices: [],
48
- defaultPlanCode: null,
49
- accounts: /* @__PURE__ */ new Map(),
50
- subscriptions: [],
51
- sessions: [],
52
- portalSessions: [],
53
- payments: [],
54
- invoices: [],
55
- grants: [],
56
- events: [],
57
- now,
58
- setNow: (ms) => {
59
- const current = now();
60
- if (ms > current) offset += ms - current;
61
- },
62
- id
63
- };
64
- }
65
- const iso = (ms) => new Date(ms).toISOString();
66
- const isoOrNull = (ms) => ms === null ? null : iso(ms);
67
- function sha256Hex(data) {
68
- return createHash("sha256").update(data).digest("hex");
69
- }
70
- const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
71
- /**
72
- * Dönem ekleme, UTC; ay sonu sabitlenir (31 Ocak + 1 ay = 28/29 Şubat) — servisin
73
- * `periods.ts` `addInterval`'ı (date-fns + UTCDate) ile aynı sonuç, süreç saat diliminden bağımsız.
74
- */
75
- function addInterval(from, interval, count = 1) {
76
- const d = new Date(from);
77
- const months = d.getUTCMonth() + (interval === "year" ? 12 * count : count);
78
- const lastDay = new Date(Date.UTC(d.getUTCFullYear(), months + 1, 0)).getUTCDate();
79
- return Date.UTC(d.getUTCFullYear(), months, Math.min(d.getUTCDate(), lastDay), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds());
80
- }
81
- function sortDeep(value) {
82
- if (Array.isArray(value)) return value.map(sortDeep);
83
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => compare(a, b)).map(([k, v]) => [k, sortDeep(v)]));
84
- return value;
85
- }
86
- /** Anahtar sırasından bağımsız metin. */
87
- const canonicalJson = (value) => JSON.stringify(sortDeep(value));
88
- /** RFC 9562 UUIDv7: 48 bit ms + 12 bit sayaç (aynı ms'de artar) + rastgele. */
89
- function uuidv7Factory(clock) {
90
- let lastMs = 0;
91
- let counter = 0;
92
- return () => {
93
- let ms = clock();
94
- if (ms <= lastMs) {
95
- ms = lastMs;
96
- if (++counter > 4095) {
97
- ms = lastMs + 1;
98
- counter = 0;
99
- }
100
- } else counter = 0;
101
- lastMs = ms;
102
- const bytes = globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(16));
103
- for (let i = 0; i < 6; i++) bytes[i] = Math.floor(ms / 2 ** (8 * (5 - i))) & 255;
104
- bytes[6] = 112 | counter >> 8 & 15;
105
- bytes[7] = counter & 255;
106
- bytes[8] = bytes[8] & 63 | 128;
107
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
108
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
109
- };
110
- }
111
- /** `GET /v1/catalog` gövdesi (servisin `getCatalog` sırası); senkron yoksa null. */
112
- function catalogOf(w) {
113
- if (w.defaultPlanCode === null) return null;
114
- const plans = [...w.plans.values()].filter((p) => p.active).sort((a, b) => a.rank - b.rank || compare(a.code, b.code));
115
- const rank = new Map(plans.map((p) => [p.code, p.rank]));
116
- const prices = w.prices.filter((p) => p.active && rank.has(p.planCode)).sort((a, b) => rank.get(a.planCode) - rank.get(b.planCode) || compare(a.interval, b.interval) || a.intervalCount - b.intervalCount || compare(a.currency, b.currency));
117
- return {
118
- product: w.productCode,
119
- defaultPlanCode: w.defaultPlanCode,
120
- features: [...w.features].sort((a, b) => compare(a.key, b.key)).map((f) => ({
121
- key: f.key,
122
- type: f.type,
123
- ...f.description !== void 0 ? { description: f.description } : {}
124
- })),
125
- plans: plans.map((p) => ({
126
- code: p.code,
127
- name: p.name,
128
- rank: p.rank,
129
- sellable: p.sellable,
130
- entitlements: Object.fromEntries(Object.entries(p.entitlements).sort(([a], [b]) => compare(a, b)))
131
- })),
132
- prices: prices.map((p) => ({
133
- id: p.id,
134
- planCode: p.planCode,
135
- interval: p.interval,
136
- intervalCount: p.intervalCount,
137
- currency: p.currency,
138
- amountMinor: p.amountMinor,
139
- taxInclusive: p.taxInclusive,
140
- taxRateBps: p.taxRateBps,
141
- active: p.active,
142
- createdAt: iso(p.createdAt)
143
- }))
144
- };
145
- }
146
- /** `GET /v1/catalog` ETag'inin tırnaksız değeri ve `AccountState.catalogVersion`. */
147
- const catalogVersionOf = (catalog) => sha256Hex(JSON.stringify(catalog)).slice(0, 32);
148
- function priceOf(w, priceId) {
149
- const price = w.prices.find((p) => p.id === priceId);
150
- if (!price) throw new Error(`sahte steward: fiyat yok ${priceId}`);
151
- return price;
152
- }
153
- const LIVE = LIVE_SUBSCRIPTION_STATUSES;
154
- const DUNNING = ["past_due", "suspended"];
155
- const isLive = (sub) => LIVE.includes(sub.status);
156
- const inDunning = (sub) => DUNNING.includes(sub.status);
157
- function liveSubscription(w, ref) {
158
- return w.subscriptions.find((s) => s.accountRef === ref && isLive(s)) ?? null;
159
- }
160
- /** Servisin `dunningStateOf`'u: sıradaki aksiyon politikadan (başlangıç + gün). */
161
- function dunningStateOf(policy, sub) {
162
- const next = policy.steps[sub.dunningStep];
163
- return {
164
- step: sub.dunningStep,
165
- nextActionAt: next && sub.dunningStartedAt !== null ? iso(sub.dunningStartedAt + next.afterDays * DAY_MS) : null
166
- };
167
- }
168
- function computeState(w, account, now) {
169
- const catalog = catalogOf(w);
170
- if (!catalog) throw new Error(`ürünün varsayılan planı yok (katalog senkronlanmadı): hesap ${account.ref}`);
171
- const live = liveSubscription(w, account.ref);
172
- const livePrice = live ? priceOf(w, live.priceId) : null;
173
- const grants = w.grants.filter((g) => g.accountRef === account.ref && g.revokedAt === null && g.startsAt <= now && (g.endsAt === null || g.endsAt > now)).sort((a, b) => a.createdAt - b.createdAt || compare(a.id, b.id));
174
- const listed = new Set(catalog.plans.map((p) => p.code));
175
- const retired = [.../* @__PURE__ */ new Set([livePrice?.planCode, ...grants.map((g) => g.planCode)])].filter((code) => typeof code === "string" && !listed.has(code)).flatMap((code) => {
176
- const plan = w.plans.get(code);
177
- return plan ? [{
178
- code: plan.code,
179
- rank: plan.rank,
180
- entitlements: plan.entitlements
181
- }] : [];
182
- });
183
- const resolved = resolveEntitlements({
184
- catalog: {
185
- defaultPlanCode: catalog.defaultPlanCode,
186
- features: catalog.features,
187
- plans: [...catalog.plans, ...retired]
188
- },
189
- subscription: live && livePrice ? {
190
- planCode: livePrice.planCode,
191
- status: live.status
192
- } : null,
193
- grants: grants.map((g) => ({
194
- planCode: g.planCode,
195
- overrides: g.overrides
196
- }))
197
- });
198
- const open = w.sessions.filter((s) => s.accountRef === account.ref && s.status === "open" && s.expiresAt > now).sort((a, b) => b.createdAt - a.createdAt || compare(b.id, a.id))[0];
199
- const openPrice = open ? priceOf(w, open.priceId) : null;
200
- const dunning = live !== null && inDunning(live);
201
- return {
202
- ref: account.ref,
203
- displayName: account.displayName,
204
- updatedAt: iso(now),
205
- catalogVersion: catalogVersionOf(catalog),
206
- ...resolved,
207
- subscription: live && livePrice ? {
208
- id: live.id,
209
- status: live.status,
210
- planCode: livePrice.planCode,
211
- interval: livePrice.interval,
212
- amountMinor: livePrice.amountMinor,
213
- currency: livePrice.currency,
214
- currentPeriodEnd: isoOrNull(live.currentPeriodEnd),
215
- cancelAt: isoOrNull(live.cancelAt),
216
- cancelAtPeriodEnd: live.cancelAtPeriodEnd,
217
- pastDueSince: dunning ? isoOrNull(live.pastDueSince ?? live.dunningStartedAt) : null,
218
- currentPeriodStart: isoOrNull(live.currentPeriodStart),
219
- dunning: dunning ? dunningStateOf(w.dunningPolicy, live) : null,
220
- scheduledChange: null
221
- } : null,
222
- openCheckout: open && openPrice ? {
223
- id: open.id,
224
- planCode: openPrice.planCode,
225
- interval: openPrice.interval,
226
- intervalCount: openPrice.intervalCount,
227
- currency: openPrice.currency,
228
- amountMinor: openPrice.amountMinor,
229
- expiresAt: iso(open.expiresAt)
230
- } : null,
231
- profile: {
232
- present: account.profile !== null,
233
- kind: account.profile?.kind ?? null
234
- }
235
- };
236
- }
237
- /** Durumun eski (0.1) snapshot izdüşümü. */
238
- function toSnapshot(state) {
239
- const sub = state.subscription;
240
- return {
241
- version: state.version,
242
- planCode: state.planCode,
243
- accessState: state.accessState,
244
- entitlements: state.entitlements,
245
- subscription: sub ? {
246
- id: sub.id,
247
- status: sub.status,
248
- planCode: sub.planCode,
249
- interval: sub.interval,
250
- amountMinor: sub.amountMinor,
251
- currency: sub.currency,
252
- currentPeriodEnd: sub.currentPeriodEnd,
253
- cancelAt: sub.cancelAt
254
- } : null
255
- };
256
- }
257
- const NOT_COMPARED = /* @__PURE__ */ new Set([
258
- "version",
259
- "updatedAt",
260
- "catalogVersion"
261
- ]);
262
- const fingerprint = (state) => canonicalJson(Object.fromEntries(Object.entries(state).filter(([k]) => !NOT_COMPARED.has(k))));
263
- /** Sözleşmeye uyan zarf; uymuyorsa fırlatır (servis transaction'ı düşürür). */
264
- function envelope(w, ref, state, event, id, createdAt) {
265
- const payload = {
266
- id,
267
- type: event.type,
268
- apiVersion: API_VERSION,
269
- createdAt: iso(createdAt),
270
- product: w.productCode,
271
- accountRef: ref,
272
- data: {
273
- account: state,
274
- detail: event.detail
275
- }
276
- };
277
- const parsed = BillingEventSchema.safeParse(payload);
278
- if (!parsed.success) throw new Error(`sözleşmeye uymayan event (${event.type}): ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
279
- return payload;
280
- }
281
- function emitEvent(w, ref, state, event) {
282
- const now = w.now();
283
- const id = `evt_${w.id()}`;
284
- const payload = envelope(w, ref, state, event, id, now);
285
- const wanted = w.endpoint !== null && w.endpoint.types.has(event.type);
286
- w.events.push({
287
- id,
288
- accountRef: ref,
289
- type: event.type,
290
- payload,
291
- delivery: wanted ? {
292
- status: "pending",
293
- attempts: 0,
294
- lastHttpStatus: null,
295
- lastError: null,
296
- nextAttemptAt: now,
297
- deliveredAt: null
298
- } : null
299
- });
300
- return id;
301
- }
302
- const SNAPSHOT_KEYS = [
303
- "planCode",
304
- "accessState",
305
- "entitlements",
306
- "subscription"
307
- ];
308
- function account(w, ref) {
309
- const row = w.accounts.get(ref);
310
- if (!row) throw new Error(`sahte steward: hesap yok ${ref}`);
311
- return row;
312
- }
313
- /** Saklı durum (`readAccountState`); hesap henüz commit edilmediyse hesaplanır. */
314
- function storedState(w, ref) {
315
- return account(w, ref).state ?? commitAccountChange(w, ref, "account.refreshed").state;
316
- }
317
- function commitAccountChange(w, ref, change) {
318
- const row = account(w, ref);
319
- const previous = row.state;
320
- const next = computeState(w, row, w.now());
321
- const changed = previous === null || fingerprint(previous) !== fingerprint(next);
322
- const state = changed ? {
323
- version: row.version + 1,
324
- ...next
325
- } : previous;
326
- if (changed) {
327
- row.version = state.version;
328
- row.state = state;
329
- row.history.push(state);
330
- }
331
- const primary = typeof change === "string" ? null : change;
332
- let eventId = null;
333
- if (primary) eventId = emitEvent(w, ref, state, primary);
334
- else if (changed && previous) {
335
- const before = toSnapshot(previous);
336
- const after = toSnapshot(state);
337
- const keys = SNAPSHOT_KEYS.filter((k) => canonicalJson(before[k]) !== canonicalJson(after[k]));
338
- if (keys.length > 0) eventId = emitEvent(w, ref, state, {
339
- type: "account.updated",
340
- detail: { changed: [...keys] }
341
- });
342
- }
343
- let stateEventId = null;
344
- if (changed && previous) stateEventId = emitEvent(w, ref, state, {
345
- type: "account.state_changed",
346
- detail: {
347
- cause: primary?.type ?? change,
348
- causeEventId: primary ? eventId : null
349
- }
350
- });
351
- return {
352
- state,
353
- changed,
354
- eventId,
355
- stateEventId
356
- };
357
- }
358
- /**
359
- * Servisin `republishState`'i (`domain/outbox.ts`): verilen (saklı) durumu
360
- * `account.state_changed` (`cause: "resync"`) olarak yazar; version artmaz, eski
361
- * tip yok, teslimat filtre kuralıyla. Event id'si döner.
362
- */
363
- function republishState(w, ref, state) {
364
- return emitEvent(w, ref, state, {
365
- type: "account.state_changed",
366
- detail: {
367
- cause: "resync",
368
- causeEventId: null
369
- }
370
- });
371
- }
372
- function toSubscription(w, row) {
373
- const price = priceOf(w, row.priceId);
374
- return {
375
- id: row.id,
376
- accountRef: row.accountRef,
377
- status: row.status,
378
- planCode: price.planCode,
379
- priceId: row.priceId,
380
- interval: price.interval,
381
- amountMinor: price.amountMinor,
382
- currency: price.currency,
383
- provider: "fake",
384
- providerSubscriptionRef: row.providerSubscriptionRef,
385
- cancelAtPeriodEnd: row.cancelAtPeriodEnd,
386
- cancelAt: isoOrNull(row.cancelAt),
387
- cancelReason: row.cancelReason,
388
- canceledAt: isoOrNull(row.canceledAt),
389
- startedAt: iso(row.startedAt),
390
- currentPeriodStart: isoOrNull(row.currentPeriodStart),
391
- currentPeriodEnd: isoOrNull(row.currentPeriodEnd),
392
- dunningStartedAt: isoOrNull(row.dunningStartedAt)
393
- };
394
- }
395
- function toCheckoutSession(w, row) {
396
- const price = priceOf(w, row.priceId);
397
- return {
398
- id: row.id,
399
- accountRef: row.accountRef,
400
- status: row.status,
401
- planCode: price.planCode,
402
- interval: price.interval,
403
- amountMinor: price.amountMinor,
404
- currency: price.currency,
405
- provider: "fake",
406
- locale: row.locale,
407
- successUrl: row.successUrl,
408
- cancelUrl: row.cancelUrl,
409
- consents: row.consents,
410
- actorRef: row.actorRef,
411
- failureReason: row.failureReason,
412
- subscriptionId: row.subscriptionId,
413
- expiresAt: iso(row.expiresAt),
414
- createdAt: iso(row.createdAt),
415
- completedAt: isoOrNull(row.completedAt)
416
- };
417
- }
418
- function toGrant(row) {
419
- return {
420
- id: row.id,
421
- accountRef: row.accountRef,
422
- planCode: row.planCode,
423
- overrides: row.overrides,
424
- reason: row.reason,
425
- actorRef: row.actorRef,
426
- startsAt: iso(row.startsAt),
427
- endsAt: isoOrNull(row.endsAt),
428
- revokedAt: isoOrNull(row.revokedAt),
429
- createdAt: iso(row.createdAt)
430
- };
431
- }
432
- function toInvoice(row) {
433
- return {
434
- id: row.id,
435
- accountRef: row.accountRef,
436
- subscriptionId: row.subscriptionId,
437
- paymentId: row.paymentId,
438
- status: row.status,
439
- number: row.number,
440
- currency: row.currency,
441
- subtotalMinor: row.subtotalMinor,
442
- taxMinor: row.taxMinor,
443
- totalMinor: row.totalMinor,
444
- taxRateBps: row.taxRateBps,
445
- buyer: row.buyer,
446
- lines: row.lines,
447
- issuer: "manual",
448
- externalRef: row.externalRef,
449
- hasDocument: row.document !== null,
450
- issuedAt: isoOrNull(row.issuedAt),
451
- createdAt: iso(row.createdAt),
452
- dueAt: isoOrNull(row.dueAt ?? null),
453
- note: row.note ?? null,
454
- voidedAt: isoOrNull(row.voidedAt ?? null),
455
- voidReason: row.voidReason ?? null
456
- };
457
- }
458
- //#endregion
459
- //#region src/testing/operations.ts
460
- const ok = (value) => ({
461
- ok: true,
462
- value
463
- });
464
- const fail = (code, message) => message === void 0 ? {
465
- ok: false,
466
- code
467
- } : {
468
- ok: false,
469
- code,
470
- message
471
- };
472
- function matchesType(type, value) {
473
- switch (type) {
474
- case "limit": return value === null || typeof value === "number" && Number.isInteger(value) && value >= 0;
475
- case "boolean": return typeof value === "boolean";
476
- case "string": return typeof value === "string";
477
- }
478
- }
479
- /** `syncCatalogWithProvider`: K4 tamlığı, farkın uygulanması, etkilenen hesapların yenilenmesi. */
480
- function syncCatalog(w, input) {
481
- const missing = [];
482
- const mismatched = [];
483
- for (const p of input.plans) for (const f of input.features) {
484
- const value = p.entitlements[f.key];
485
- if (value === void 0) missing.push(`plan ${p.code}: ${f.key} için değer yok`);
486
- else if (!matchesType(f.type, value)) mismatched.push(`plan ${p.code}: ${f.key} değeri ${f.type} tipine uymuyor`);
487
- }
488
- if (missing.length > 0) return fail("plan_missing_feature", missing.slice(0, 10).join("; "));
489
- if (mismatched.length > 0) return fail("override_type_mismatch", mismatched.slice(0, 10).join("; "));
490
- const now = w.now();
491
- const keys = new Set(input.features.map((f) => f.key));
492
- const removedFeatures = w.features.filter((f) => !keys.has(f.key)).map((f) => f.key);
493
- w.features = input.features.map((f) => ({
494
- key: f.key,
495
- type: f.type,
496
- ...f.description !== void 0 ? { description: f.description } : {}
497
- }));
498
- const canonical = (entitlements) => canonicalJson(entitlements);
499
- const changedPlans = [];
500
- for (const p of input.plans) {
501
- const before = w.plans.get(p.code);
502
- const planChanged = !before || !before.active || before.name !== p.name || before.rank !== p.rank || before.sellable !== p.sellable;
503
- const entitlementsChanged = !before || canonical(before.entitlements) !== canonical(p.entitlements);
504
- w.plans.set(p.code, {
505
- code: p.code,
506
- name: p.name,
507
- rank: p.rank,
508
- sellable: p.sellable,
509
- active: true,
510
- entitlements: entitlementsChanged ? { ...p.entitlements } : before.entitlements
511
- });
512
- if (planChanged || entitlementsChanged) changedPlans.push(p.code);
513
- }
514
- const inputPlans = new Set(input.plans.map((p) => p.code));
515
- const retiredPlans = [];
516
- for (const plan of w.plans.values()) if (plan.active && !inputPlans.has(plan.code)) {
517
- plan.active = false;
518
- retiredPlans.push(plan.code);
519
- }
520
- const defaultPlanChanged = w.defaultPlanCode !== input.defaultPlanCode;
521
- w.defaultPlanCode = input.defaultPlanCode;
522
- const priceKey = (p) => `${p.planCode}/${p.interval}/${p.intervalCount}/${p.currency}`;
523
- const active = new Map(w.prices.filter((p) => p.active).map((p) => [priceKey(p), p]));
524
- const keep = /* @__PURE__ */ new Set();
525
- const createdPrices = [];
526
- const deactivatedPrices = [];
527
- for (const p of input.prices) {
528
- const current = active.get(priceKey(p));
529
- if (current && current.amountMinor === p.amountMinor && current.taxInclusive === p.taxInclusive && current.taxRateBps === p.taxRateBps) {
530
- keep.add(current.id);
531
- continue;
532
- }
533
- if (current) {
534
- current.active = false;
535
- deactivatedPrices.push(current.id);
536
- }
537
- const id = w.id();
538
- w.prices.push({
539
- id,
540
- ...p,
541
- active: true,
542
- createdAt: now
543
- });
544
- createdPrices.push(id);
545
- keep.add(id);
546
- }
547
- for (const price of active.values()) if (price.active && !keep.has(price.id)) {
548
- price.active = false;
549
- deactivatedPrices.push(price.id);
550
- }
551
- const unchanged = createdPrices.length === 0 && deactivatedPrices.length === 0 && changedPlans.length === 0 && retiredPlans.length === 0 && removedFeatures.length === 0 && !defaultPlanChanged;
552
- if (changedPlans.length > 0 || retiredPlans.length > 0 || removedFeatures.length > 0 || defaultPlanChanged) for (const ref of liveAccountRefs(w)) commitAccountChange(w, ref, "catalog.synced");
553
- return ok({
554
- unchanged,
555
- createdPrices,
556
- deactivatedPrices,
557
- changedPlans,
558
- retiredPlans,
559
- removedFeatures,
560
- defaultPlanChanged,
561
- providerErrors: []
562
- });
563
- }
564
- /** Silinmemiş hesaplar: toplu yenileme silinmiş hesabı atlar (servisin `refreshProductAccounts`'u). */
565
- const liveAccountRefs = (w) => [...w.accounts.values()].filter((row) => row.deletedAt === void 0).map((row) => row.ref);
566
- /** `refreshAccounts` (`POST /v1/admin/accounts/refresh`). */
567
- function refreshAccounts(w) {
568
- let changed = 0;
569
- for (const ref of liveAccountRefs(w)) if (commitAccountChange(w, ref, "account.refreshed").changed) changed++;
570
- return { changed };
571
- }
572
- function toAccount(row) {
573
- return {
574
- ref: row.ref,
575
- displayName: row.displayName,
576
- metadata: row.metadata,
577
- createdAt: iso(row.createdAt),
578
- snapshot: toSnapshot(stateOf(row))
579
- };
580
- }
581
- function stateOf(row) {
582
- if (!row.state) throw new Error(`sahte steward: hesap durumu yok ${row.ref}`);
583
- return row.state;
584
- }
585
- function upsertAccount(w, ref, input) {
586
- let row = w.accounts.get(ref);
587
- const created = row === void 0;
588
- if (!row) {
589
- row = {
590
- ref,
591
- displayName: input.displayName,
592
- metadata: input.metadata,
593
- createdAt: w.now(),
594
- version: 0,
595
- state: null,
596
- history: [],
597
- profile: null
598
- };
599
- computeState(w, row, w.now());
600
- w.accounts.set(ref, row);
601
- } else {
602
- row.displayName = input.displayName;
603
- row.metadata = input.metadata;
604
- }
605
- const { state } = commitAccountChange(w, ref, "account.upserted");
606
- return {
607
- account: {
608
- ...toAccount(row),
609
- account: state
610
- },
611
- created
612
- };
613
- }
614
- /** `saveBillingProfile`: bireyselde kurumsal alanlar düşer. */
615
- function saveProfile(row, profile) {
616
- const company = profile.kind === "company";
617
- const saved = {
618
- kind: profile.kind,
619
- contactName: profile.contactName,
620
- ...company && profile.companyName !== void 0 ? { companyName: profile.companyName } : {},
621
- ...company && profile.taxOffice !== void 0 ? { taxOffice: profile.taxOffice } : {},
622
- ...company && profile.taxId !== void 0 ? { taxId: profile.taxId } : {},
623
- email: profile.email,
624
- phone: profile.phone,
625
- address: profile.address,
626
- city: profile.city,
627
- country: profile.country
628
- };
629
- row.profile = saved;
630
- return saved;
631
- }
632
- function accountDetail(w, ref, include) {
633
- const row = w.accounts.get(ref);
634
- if (!row) return null;
635
- const detail = toAccount(row);
636
- const want = new Set(include);
637
- const newest = (rows) => [...rows].sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1));
638
- const subs = [...w.subscriptions.filter((s) => s.accountRef === ref)].sort((a, b) => b.startedAt - a.startedAt);
639
- if (want.has("profile")) detail.profile = row.profile;
640
- if (want.has("subscriptions")) detail.subscriptions = subs.map((s) => toSubscription(w, s));
641
- if (want.has("payments")) {
642
- const ids = new Set(subs.map((s) => s.id));
643
- detail.payments = [...w.payments.filter((p) => ids.has(p.subscriptionId))].sort((a, b) => b.occurredAt - a.occurredAt).map((p) => ({
644
- id: p.id,
645
- subscriptionId: p.subscriptionId,
646
- providerOrderRef: p.providerOrderRef,
647
- status: p.status,
648
- amountMinor: p.amountMinor,
649
- currency: p.currency,
650
- periodStart: isoOrNull(p.periodStart),
651
- periodEnd: isoOrNull(p.periodEnd),
652
- occurredAt: iso(p.occurredAt)
653
- }));
654
- }
655
- if (want.has("checkouts")) detail.checkouts = newest(w.sessions.filter((s) => s.accountRef === ref)).slice(0, 20).map((s) => toCheckoutSession(w, s));
656
- if (want.has("grants")) detail.grants = newest(w.grants.filter((g) => g.accountRef === ref)).map(toGrant);
657
- if (want.has("invoices")) detail.invoices = newest(w.invoices.filter((i) => i.accountRef === ref)).map(toInvoice);
658
- return detail;
659
- }
660
- /** Hosted checkout sayfası (servisin `hostedCheckoutUrl`); sahtede `hosted.ts` sunar. */
661
- const hostedPageUrl = (baseUrl, id, pageKey) => `${baseUrl}/public/v1/checkout/${id}?k=${pageKey}`;
662
- const PAGE_KEY_BYTES = 32;
663
- /** Servis: `appUrl`'in origin'i + `extraReturnOrigins` (sahte, ayarlanmadıysa her adresi kabul eder). */
664
- function returnUrlAllowed(w, url) {
665
- const { settings } = w;
666
- return !(!w.permissiveReturnOrigins || settings.appUrl !== void 0 || settings.extraReturnOrigins.length > 0) || isReturnUrlAllowed(settings, url);
667
- }
668
- /** Planın dönem + para biriminde aktif, satılabilir fiyatı (servis: `intervalCount = 1`). */
669
- function sellablePrice(w, planCode, interval, currency) {
670
- return w.prices.find((p) => p.active && p.interval === interval && p.intervalCount === 1 && p.currency === currency && p.planCode === planCode && w.plans.get(p.planCode)?.active === true && w.plans.get(p.planCode)?.sellable === true);
671
- }
672
- function randomPageKey() {
673
- const bytes = globalThis.crypto.getRandomValues(new Uint8Array(PAGE_KEY_BYTES));
674
- return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
675
- }
676
- /**
677
- * `createHostedCheckoutSession` (service): no provider call; validation order and codes match the
678
- * service (account, return origins, live subscription, sellable price). A missing account is
679
- * opened with `displayName`. `url` is the fake steward's hosted page (`hosted.ts`).
680
- */
681
- function createCheckoutSession(w, baseUrl, ref, input, actorRef) {
682
- const existing = w.accounts.get(ref);
683
- if (!existing && input.displayName === void 0) return fail("account_not_found");
684
- const { settings } = w;
685
- if (!returnUrlAllowed(w, input.successUrl)) return fail("return_url_not_allowed");
686
- if (input.cancelUrl !== void 0 && !returnUrlAllowed(w, input.cancelUrl)) return fail("return_url_not_allowed");
687
- if (existing && liveSubscription(w, ref)) return fail("already_subscribed");
688
- const price = sellablePrice(w, input.planCode, input.interval, input.currency ?? settings.defaultCurrency);
689
- if (!price) return fail("plan_not_sellable");
690
- if (!existing) upsertAccount(w, ref, {
691
- displayName: input.displayName,
692
- metadata: {}
693
- });
694
- const now = w.now();
695
- const pageKey = randomPageKey();
696
- const session = {
697
- id: w.id(),
698
- accountRef: ref,
699
- priceId: price.id,
700
- status: "open",
701
- locale: input.locale ?? settings.defaultLocale,
702
- successUrl: input.successUrl,
703
- cancelUrl: input.cancelUrl ?? null,
704
- consentDocs: (input.consents ?? []).map(({ document, version, url }) => ({
705
- document,
706
- version,
707
- url
708
- })),
709
- prefill: input.prefill ?? null,
710
- pageKey,
711
- consents: [],
712
- actorRef,
713
- failureReason: null,
714
- subscriptionId: null,
715
- expiresAt: now + settings.checkoutTtlMinutes * 6e4,
716
- createdAt: now,
717
- completedAt: null
718
- };
719
- w.sessions.push(session);
720
- const { state } = commitAccountChange(w, ref, "checkout.opened");
721
- return ok({
722
- ...toCheckoutSession(w, session),
723
- url: hostedPageUrl(baseUrl, session.id, pageKey),
724
- account: state
725
- });
726
- }
727
- function checkoutSessionResponse(w, id) {
728
- const row = w.sessions.find((s) => s.id === id);
729
- if (!row) return null;
730
- const session = toCheckoutSession(w, row);
731
- return row.status === "completed" ? {
732
- ...session,
733
- account: storedState(w, row.accountRef)
734
- } : session;
735
- }
736
- function sessionRow(w, id) {
737
- const row = w.sessions.find((s) => s.id === id);
738
- if (!row) throw new Error(`sahte steward: checkout oturumu yok ${id}`);
739
- return row;
740
- }
741
- /** Kapanan oturumda sayfa anahtarı ve ön doldurma silinir (servis `CLOSED_SESSION_CLEANUP`). */
742
- const CLOSED_SESSION_CLEANUP = {
743
- pageKey: null,
744
- prefill: null
745
- };
746
- /** Hosted oturumun sayfası açık mı: anahtar bu oturumun, oturum açık ve süresi geçmemiş (servis `openSession`). */
747
- function openHostedSession(w, id, pageKey) {
748
- const row = w.sessions.find((s) => s.id === id);
749
- if (!row || row.pageKey === null || pageKey.length === 0 || row.pageKey !== pageKey) return null;
750
- return row.status === "open" && row.expiresAt > w.now() ? row : null;
751
- }
752
- /** Sahte `details` gönderiminin profil değerleri (ürünün `prefill`i; geri kalanı sabit, geçerli test verisi). */
753
- function hostedProfile(row, prefill) {
754
- const company = prefill?.kind === "company";
755
- return {
756
- kind: company ? "company" : "individual",
757
- contactName: prefill?.name ?? row.displayName,
758
- ...company ? {
759
- companyName: row.displayName,
760
- taxOffice: "Sahte Vergi Dairesi",
761
- taxId: "1234567890"
762
- } : {},
763
- email: prefill?.email ?? "musteri@example.com",
764
- phone: "+905550000000",
765
- address: "Sahte Mah. Örnek Sok. No: 1",
766
- city: "İstanbul",
767
- country: "TR"
768
- };
769
- }
770
- /**
771
- * Hosted oturum (F2b): sahte sayfada `details` adımı yok. Ödeme sonucundan önce servisteki
772
- * gönderimin izi yazılır: hesabın profili yoksa ön doldurmadan sahte profil (varsa aynen
773
- * kalır: sayfa saklı profili ön doldurur), belgelerin onay izi, `billing_profile.updated`.
774
- * Hesap bu arada abone olduysa servis `details`'te oturumu kapatır;
775
- * çağıranlar o durumda bunu çağırmaz.
776
- */
777
- function submitHostedDetails(w, session) {
778
- const row = account(w, session.accountRef);
779
- if (!row.profile) saveProfile(row, hostedProfile(row, session.prefill));
780
- const acceptedAt = iso(w.now());
781
- session.consents = session.consentDocs.map(({ document, version, url }) => ({
782
- document,
783
- version,
784
- url,
785
- acceptedAt
786
- }));
787
- commitAccountChange(w, session.accountRef, "billing_profile.updated");
788
- }
789
- /** `completeCheckoutReturn` başarısızlık yolu: yalnızca `open` oturum kapanır. */
790
- function failCheckout(w, session, reason) {
791
- if (session.status !== "open") return;
792
- if (!liveSubscription(w, session.accountRef)) submitHostedDetails(w, session);
793
- Object.assign(session, {
794
- status: "failed",
795
- failureReason: reason,
796
- completedAt: w.now(),
797
- ...CLOSED_SESSION_CLEANUP
798
- });
799
- commitAccountChange(w, session.accountRef, {
800
- type: "checkout.failed",
801
- detail: {
802
- checkoutSessionId: session.id,
803
- reason
804
- }
805
- });
806
- }
807
- /**
808
- * Hosted sayfada vazgeç (`HostedCheckoutPort.cancel`; sahte sayfada sağlayıcı formu olmadığından
809
- * her zaman kapanır): yalnızca açık oturum; `canceled`, iç neden `checkout.canceled`. Dönüş
810
- * adresi `cancelUrl ?? settings.appUrl` (yoksa null). Açık olmayan oturumda değişiklik yok.
811
- */
812
- function cancelCheckout(w, session) {
813
- const redirectUrl = session.cancelUrl ?? w.settings.appUrl ?? null;
814
- if (session.status !== "open") return redirectUrl;
815
- Object.assign(session, {
816
- status: "canceled",
817
- completedAt: w.now(),
818
- ...CLOSED_SESSION_CLEANUP
819
- });
820
- commitAccountChange(w, session.accountRef, "checkout.canceled");
821
- return redirectUrl;
822
- }
823
- /** `completeCheckoutReturn` başarı yolu: abonelik, ilk tahsilat, event'ler, taslak fatura. */
824
- function completeCheckout(w, session) {
825
- if (session.status !== "open") return;
826
- const alreadySubscribed = liveSubscription(w, session.accountRef) !== null;
827
- if (!alreadySubscribed) submitHostedDetails(w, session);
828
- const now = w.now();
829
- const price = priceOf(w, session.priceId);
830
- Object.assign(session, {
831
- status: "completed",
832
- completedAt: now,
833
- ...CLOSED_SESSION_CLEANUP
834
- });
835
- if (alreadySubscribed) {
836
- Object.assign(session, {
837
- status: "failed",
838
- failureReason: "already_subscribed"
839
- });
840
- commitAccountChange(w, session.accountRef, {
841
- type: "checkout.failed",
842
- detail: {
843
- checkoutSessionId: session.id,
844
- reason: "already_subscribed"
845
- }
846
- });
847
- return;
848
- }
849
- const periodEnd = addInterval(now, price.interval, price.intervalCount);
850
- const sub = {
851
- id: w.id(),
852
- accountRef: session.accountRef,
853
- priceId: price.id,
854
- providerSubscriptionRef: `fake_sub_${globalThis.crypto.randomUUID()}`,
855
- status: "active",
856
- cancelAtPeriodEnd: false,
857
- cancelAt: null,
858
- cancelReason: null,
859
- canceledAt: null,
860
- startedAt: now,
861
- currentPeriodStart: now,
862
- currentPeriodEnd: periodEnd,
863
- dunningStartedAt: null,
864
- pastDueSince: null,
865
- dunningStep: 0,
866
- nextDunningAt: null,
867
- lastPaymentRetryAt: null
868
- };
869
- w.subscriptions.push(sub);
870
- session.subscriptionId = sub.id;
871
- const payment = insertPayment(w, sub, {
872
- providerOrderRef: `checkout:${session.id}`,
873
- status: "succeeded",
874
- periodStart: now,
875
- periodEnd
876
- });
877
- commitAccountChange(w, session.accountRef, {
878
- type: "subscription.activated",
879
- detail: {
880
- subscriptionId: sub.id,
881
- planCode: price.planCode,
882
- interval: price.interval,
883
- amountMinor: price.amountMinor,
884
- currency: price.currency,
885
- actorRef: session.actorRef
886
- }
887
- });
888
- commitAccountChange(w, session.accountRef, {
889
- type: "checkout.completed",
890
- detail: {
891
- checkoutSessionId: session.id,
892
- subscriptionId: sub.id
893
- }
894
- });
895
- createInvoiceForPayment(w, payment);
896
- }
897
- function insertPayment(w, sub, fields) {
898
- const price = priceOf(w, sub.priceId);
899
- const now = w.now();
900
- const payment = {
901
- id: w.id(),
902
- subscriptionId: sub.id,
903
- attempt: 1,
904
- amountMinor: price.amountMinor,
905
- currency: price.currency,
906
- occurredAt: now,
907
- createdAt: now,
908
- ...fields
909
- };
910
- w.payments.push(payment);
911
- return payment;
912
- }
913
- const day$1 = (ms) => ms === null ? "?" : iso(ms).slice(0, 10);
914
- /** Başarılı tahsilat için taslak fatura (idempotent); profil yoksa fatura yok. */
915
- function createInvoiceForPayment(w, payment) {
916
- const existing = w.invoices.find((i) => i.paymentId === payment.id);
917
- if (existing) return existing.id;
918
- if (payment.status !== "succeeded") return null;
919
- const sub = w.subscriptions.find((s) => s.id === payment.subscriptionId);
920
- const price = priceOf(w, sub.priceId);
921
- const profile = w.accounts.get(sub.accountRef)?.profile;
922
- if (!profile) return null;
923
- const totals = price.taxInclusive ? {
924
- ...splitInclusiveTax(payment.amountMinor, price.taxRateBps),
925
- totalMinor: payment.amountMinor
926
- } : (() => {
927
- const taxMinor = Math.round(payment.amountMinor * price.taxRateBps / 1e4);
928
- return {
929
- subtotalMinor: payment.amountMinor,
930
- taxMinor,
931
- totalMinor: payment.amountMinor + taxMinor
932
- };
933
- })();
934
- const invoice = {
935
- id: w.id(),
936
- accountRef: sub.accountRef,
937
- subscriptionId: sub.id,
938
- paymentId: payment.id,
939
- status: "draft",
940
- number: null,
941
- currency: payment.currency,
942
- ...totals,
943
- taxRateBps: price.taxRateBps,
944
- buyer: profile,
945
- lines: [{
946
- description: `${w.plans.get(price.planCode).name} (${day$1(payment.periodStart)} – ${day$1(payment.periodEnd)})`,
947
- quantity: 1,
948
- unitAmountMinor: totals.subtotalMinor,
949
- amountMinor: totals.subtotalMinor
950
- }],
951
- externalRef: null,
952
- issuedAt: null,
953
- createdAt: w.now(),
954
- document: null
955
- };
956
- w.invoices.push(invoice);
957
- commitAccountChange(w, sub.accountRef, {
958
- type: "invoice.created",
959
- detail: {
960
- invoiceId: invoice.id,
961
- totalMinor: totals.totalMinor,
962
- currency: payment.currency
963
- }
964
- });
965
- return invoice.id;
966
- }
967
- const MAX_DOCUMENT_BYTES = 5242880;
968
- function issueInvoice(w, id, input) {
969
- let document = null;
970
- if (input.documentBase64 !== void 0) {
971
- document = Uint8Array.from(atob(input.documentBase64), (c) => c.charCodeAt(0));
972
- const signature = String.fromCharCode(...document.subarray(0, 5));
973
- if (document.length === 0 || document.length > MAX_DOCUMENT_BYTES || signature !== "%PDF-") return fail("invalid_document");
974
- }
975
- const invoice = w.invoices.find((i) => i.id === id);
976
- if (!invoice) return fail("invoice_not_found");
977
- if (invoice.status !== "draft") return fail("already_issued");
978
- if (w.invoices.some((i) => i.number === input.number)) return fail("number_taken");
979
- Object.assign(invoice, {
980
- status: "issued",
981
- number: input.number,
982
- issuedAt: input.issuedAt !== void 0 ? Date.parse(input.issuedAt) : w.now(),
983
- externalRef: input.externalRef ?? null,
984
- document: document && {
985
- bytes: document,
986
- sha256: sha256Hex(document)
987
- }
988
- });
989
- const { state } = commitAccountChange(w, invoice.accountRef, {
990
- type: "invoice.issued",
991
- detail: {
992
- invoiceId: id,
993
- number: input.number,
994
- totalMinor: invoice.totalMinor,
995
- currency: invoice.currency
996
- }
997
- });
998
- return ok({
999
- ...toInvoice(invoice),
1000
- account: state
1001
- });
1002
- }
1003
- function cancelSubscription(w, id, input, actorRef) {
1004
- const sub = w.subscriptions.find((s) => s.id === id);
1005
- if (!sub) return fail("subscription_not_found");
1006
- if (!isLive(sub)) return fail("already_canceled");
1007
- if (input.mode === "at_period_end" && sub.cancelAtPeriodEnd) return ok({
1008
- ...toSubscription(w, sub),
1009
- account: storedState(w, sub.accountRef)
1010
- });
1011
- const now = w.now();
1012
- if (input.mode === "immediate") Object.assign(sub, {
1013
- status: "canceled",
1014
- cancelAt: now,
1015
- canceledAt: now,
1016
- pastDueSince: null,
1017
- dunningStartedAt: null,
1018
- dunningStep: 0,
1019
- nextDunningAt: null
1020
- });
1021
- else Object.assign(sub, {
1022
- cancelAtPeriodEnd: true,
1023
- cancelAt: sub.currentPeriodEnd !== null && sub.currentPeriodEnd > now ? sub.currentPeriodEnd : now,
1024
- canceledAt: now
1025
- });
1026
- sub.cancelReason = input.reason;
1027
- const { state } = commitAccountChange(w, sub.accountRef, input.mode === "immediate" ? {
1028
- type: "subscription.canceled",
1029
- detail: {
1030
- subscriptionId: sub.id,
1031
- reason: input.reason,
1032
- effectiveAt: iso(now),
1033
- actorRef
1034
- }
1035
- } : {
1036
- type: "subscription.cancel_scheduled",
1037
- detail: {
1038
- subscriptionId: sub.id,
1039
- cancelAt: iso(sub.cancelAt),
1040
- reason: input.reason,
1041
- actorRef
1042
- }
1043
- });
1044
- return ok({
1045
- ...toSubscription(w, sub),
1046
- account: state
1047
- });
1048
- }
1049
- function createGrant(w, ref, input, actorRef) {
1050
- if (!w.accounts.has(ref)) return fail("account_not_found");
1051
- if (input.planCode !== void 0 && w.plans.get(input.planCode)?.active !== true) return fail("plan_not_found");
1052
- const mismatched = overrideTypeMismatches(w, input.overrides);
1053
- if (mismatched !== null) return fail("override_type_mismatch", mismatched);
1054
- const now = w.now();
1055
- const startsAt = input.startsAt !== void 0 ? Date.parse(input.startsAt) : null;
1056
- const endsAt = input.endsAt !== void 0 ? Date.parse(input.endsAt) : null;
1057
- if (endsAt !== null && endsAt <= (startsAt ?? now)) return fail("invalid_window");
1058
- const grant = {
1059
- id: w.id(),
1060
- accountRef: ref,
1061
- planCode: input.planCode ?? null,
1062
- overrides: input.overrides,
1063
- reason: input.reason,
1064
- actorRef,
1065
- startsAt: startsAt ?? now,
1066
- endsAt,
1067
- revokedAt: null,
1068
- createdAt: now,
1069
- startApplied: false,
1070
- endApplied: false
1071
- };
1072
- w.grants.push(grant);
1073
- const { state } = commitAccountChange(w, ref, "grant.created");
1074
- return ok({
1075
- ...toGrant(grant),
1076
- account: state
1077
- });
1078
- }
1079
- /** Zaten iptal edilmiş grant: idempotent başarı. */
1080
- function revokeGrant(w, id) {
1081
- const grant = w.grants.find((g) => g.id === id);
1082
- if (!grant) return false;
1083
- if (grant.revokedAt !== null) return true;
1084
- grant.revokedAt = w.now();
1085
- commitAccountChange(w, grant.accountRef, "grant.revoked");
1086
- return true;
1087
- }
1088
- /** `overrideTypeMismatches`: katalog feature tipine uymayan override'lar, uyumluysa null. */
1089
- function overrideTypeMismatches(w, overrides) {
1090
- const types = new Map(w.features.map((f) => [f.key, f.type]));
1091
- const mismatched = Object.entries(overrides).filter(([key, value]) => types.has(key) && !matchesType(types.get(key), value)).map(([key]) => `${key}: ${types.get(key)} bekleniyor`).sort();
1092
- return mismatched.length > 0 ? mismatched.join(", ") : null;
1093
- }
1094
- /**
1095
- * `setPlan` (K6): aktif plan grant'leri (henüz başlamamışlar dahil; override
1096
- * grant'leri hariç) iptal + yeni plan grant'i + `commitAccountChange(grant.plan_set)`.
1097
- * Tek aktif plan grant'i aynı plan/`endsAt`/`overrides` ise korunur. Canlı abonelik
1098
- * planı hak kuralında hedefi geçiyorsa `strict` → `subscription_plan_higher` (yazım
1099
- * yok), `ensure` → uyarı. Sahtede eşzamanlılık yok (servis advisory lock'la sıralar).
1100
- */
1101
- function setPlan(w, ref, input, actorRef) {
1102
- if (!w.accounts.has(ref)) return fail("account_not_found");
1103
- const { planCode } = input;
1104
- if (planCode !== null && w.plans.get(planCode)?.active !== true) return fail("plan_not_found");
1105
- const mismatched = overrideTypeMismatches(w, input.overrides);
1106
- if (mismatched !== null) return fail("override_type_mismatch", mismatched);
1107
- const now = w.now();
1108
- const endsAt = input.endsAt !== void 0 ? Date.parse(input.endsAt) : null;
1109
- if (endsAt !== null && endsAt <= now) return fail("invalid_window");
1110
- const warnings = [];
1111
- const live = liveSubscription(w, ref);
1112
- if (live && w.defaultPlanCode !== null) {
1113
- const livePlanCode = priceOf(w, live.priceId).planCode;
1114
- const intended = planCode ?? w.defaultPlanCode;
1115
- const { planCode: effective } = resolveEntitlements({
1116
- catalog: {
1117
- defaultPlanCode: w.defaultPlanCode,
1118
- features: [],
1119
- plans: [...w.plans.values()].map((p) => ({
1120
- code: p.code,
1121
- rank: p.rank,
1122
- entitlements: {}
1123
- }))
1124
- },
1125
- subscription: {
1126
- planCode: livePlanCode,
1127
- status: live.status
1128
- },
1129
- grants: planCode !== null ? [{ planCode }] : []
1130
- });
1131
- if (effective !== intended && effective === livePlanCode) {
1132
- const message = `canlı abonelik planı (${livePlanCode}) hedeften (${intended}) yüksek; etkin plan ${livePlanCode}`;
1133
- if (input.mode === "strict") return fail("subscription_plan_higher", `${message}; hiçbir şey yazılmadı`);
1134
- warnings.push({
1135
- code: "subscription_plan_higher",
1136
- message
1137
- });
1138
- }
1139
- }
1140
- const active = w.grants.filter((g) => g.accountRef === ref && g.planCode !== null && g.revokedAt === null && (g.endsAt === null || g.endsAt > now)).sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1));
1141
- const [only] = active;
1142
- const same = planCode !== null && active.length === 1 && only !== void 0 && only.startsAt <= now && only.planCode === planCode && only.endsAt === endsAt && canonicalJson(only.overrides) === canonicalJson(input.overrides);
1143
- let grant = same ? only : null;
1144
- if (!same) {
1145
- for (const g of active) g.revokedAt = now;
1146
- if (planCode !== null) {
1147
- grant = {
1148
- id: w.id(),
1149
- accountRef: ref,
1150
- planCode,
1151
- overrides: input.overrides,
1152
- reason: input.reason,
1153
- actorRef,
1154
- startsAt: now,
1155
- endsAt,
1156
- revokedAt: null,
1157
- createdAt: now,
1158
- startApplied: false,
1159
- endApplied: false
1160
- };
1161
- w.grants.push(grant);
1162
- }
1163
- }
1164
- const { state } = commitAccountChange(w, ref, "grant.plan_set");
1165
- return ok({
1166
- grant: grant ? toGrant(grant) : null,
1167
- account: state,
1168
- warnings
1169
- });
1170
- }
1171
- /** `resyncAccount`: saklı durum `account.state_changed` (`cause: "resync"`) olarak; version aynı. */
1172
- function resyncAccount(w, ref) {
1173
- if (!w.accounts.has(ref)) return fail("account_not_found");
1174
- return ok({
1175
- queued: true,
1176
- eventId: republishState(w, ref, storedState(w, ref))
1177
- });
1178
- }
1179
- /**
1180
- * `deleteAccount` (`domain/account-deletion.ts`, F4e): önkoşullar sırayla (hesap yok 404,
1181
- * silinmiş 410, canlı abonelik) ve hiçbiri tutmazsa değişiklik yok. The service's
1182
- * `checkout_in_progress` (open provider form) never happens here: the fake hosted page has no
1183
- * provider form, so open sessions are formless and deletion closes them. Sonra anonimleştirme:
1184
- * ad/metadata/profil, oturumların ön doldurması/anahtarı ve onay izinin `ipHash`/`userAgent`'ı,
1185
- * portal oturumları, aktif grant'ler; eski event yüklerinde `displayName`. Faturalar kalır.
1186
- */
1187
- function deleteAccount(w, ref, actorRef) {
1188
- const row = w.accounts.get(ref);
1189
- if (!row) return fail("account_not_found");
1190
- if (row.deletedAt !== void 0) return fail("account_deleted");
1191
- const live = liveSubscription(w, ref);
1192
- if (live) return fail("subscription_active", `abonelik ${live.id} canlı; önce hemen iptal edin (mode: immediate)`);
1193
- const now = w.now();
1194
- for (const session of w.sessions.filter((s) => s.accountRef === ref)) {
1195
- if (session.status === "open") Object.assign(session, {
1196
- status: "canceled",
1197
- failureReason: "account_deleted",
1198
- completedAt: now
1199
- });
1200
- Object.assign(session, CLOSED_SESSION_CLEANUP);
1201
- session.consents = session.consents.map((consent) => {
1202
- const { ipHash: _ip, userAgent: _ua, ...kept } = consent;
1203
- return kept;
1204
- });
1205
- }
1206
- w.portalSessions = w.portalSessions.filter((s) => s.accountRef !== ref);
1207
- for (const grant of w.grants) if (grant.accountRef === ref && grant.revokedAt === null) grant.revokedAt = now;
1208
- Object.assign(row, {
1209
- displayName: "",
1210
- metadata: {},
1211
- profile: null,
1212
- deletedAt: now
1213
- });
1214
- for (const event of w.events) {
1215
- const account = event.accountRef === ref ? event.payload.data.account : null;
1216
- if (account && typeof account.displayName === "string") account.displayName = "";
1217
- }
1218
- const deletedAt = iso(now);
1219
- const change = commitAccountChange(w, ref, {
1220
- type: "account.deleted",
1221
- detail: {
1222
- deletedAt,
1223
- actorRef
1224
- }
1225
- });
1226
- if (change.stateEventId === null) emitEvent(w, ref, change.state, {
1227
- type: "account.state_changed",
1228
- detail: {
1229
- cause: "account.deleted",
1230
- causeEventId: change.eventId
1231
- }
1232
- });
1233
- return ok({
1234
- ref,
1235
- deletedAt
1236
- });
1237
- }
1238
- function stats(w) {
1239
- const statuses = [
1240
- "active",
1241
- "past_due",
1242
- "suspended",
1243
- "canceled",
1244
- "expired"
1245
- ];
1246
- const mrr = {};
1247
- for (const sub of w.subscriptions) {
1248
- if (!isLive(sub) || sub.cancelAtPeriodEnd) continue;
1249
- const price = priceOf(w, sub.priceId);
1250
- const months = (price.interval === "year" ? 12 : 1) * price.intervalCount;
1251
- mrr[price.currency] = (mrr[price.currency] ?? 0) + price.amountMinor / months;
1252
- }
1253
- return {
1254
- subscriptions: Object.fromEntries(statuses.map((s) => [s, w.subscriptions.filter((sub) => sub.status === s).length])),
1255
- mrrMinor: Object.fromEntries(Object.entries(mrr).map(([c, v]) => [c, Math.round(v)])),
1256
- accounts: w.accounts.size
1257
- };
1258
- }
1259
- /** Portal sayfası (servisin `hostedPortalUrl`); sahtede `portal.ts` sunar. */
1260
- const hostedPortalUrl = (baseUrl, id, pageKey) => `${baseUrl}/public/v1/portal/${id}?k=${pageKey}`;
1261
- const MAX_PAGE_KEY_LENGTH = 256;
1262
- /** Portal aksiyonlarının aktörü (servisin `customerActorRef`). */
1263
- const customerActorRef = (accountRef) => `customer:${accountRef}`;
1264
- /**
1265
- * `createPortalSession`: hesap yoksa `account_not_found` (checkout'taki gibi açılmaz), `returnUrl`
1266
- * origin'i izinli değilse `return_url_not_allowed`; dil verilmezse `defaultLocale`, süre
1267
- * `portalTtlMinutes`. Anahtar 32 bayt base64url. Durum değişmez, event yok.
1268
- */
1269
- function createPortalSession(w, baseUrl, ref, input, actorRef) {
1270
- if (!w.accounts.has(ref)) return fail("account_not_found");
1271
- if (input.returnUrl !== void 0 && !returnUrlAllowed(w, input.returnUrl)) return fail("return_url_not_allowed");
1272
- const now = w.now();
1273
- const session = {
1274
- id: w.id(),
1275
- accountRef: ref,
1276
- pageKey: randomPageKey(),
1277
- locale: input.locale ?? w.settings.defaultLocale,
1278
- returnUrl: input.returnUrl ?? null,
1279
- actorRef,
1280
- expiresAt: now + w.settings.portalTtlMinutes * 6e4,
1281
- createdAt: now
1282
- };
1283
- w.portalSessions.push(session);
1284
- return ok({
1285
- id: session.id,
1286
- url: hostedPortalUrl(baseUrl, session.id, session.pageKey),
1287
- expiresAt: iso(session.expiresAt)
1288
- });
1289
- }
1290
- /** Süresi geçmemiş portal oturumu (anahtarsız; `simulate`). Yoksa null. */
1291
- function livePortalSession(w, id) {
1292
- const row = w.portalSessions.find((s) => s.id === id);
1293
- return row && row.expiresAt > w.now() ? row : null;
1294
- }
1295
- /** Servisin `validSession`'ı: bilinmeyen id, yanlış anahtar ve süresi geçmiş oturum AYNI sonuç (null). */
1296
- function openPortalSession(w, id, pageKey) {
1297
- if (pageKey.length === 0 || pageKey.length > MAX_PAGE_KEY_LENGTH) return null;
1298
- const row = livePortalSession(w, id);
1299
- return row && row.pageKey === pageKey ? row : null;
1300
- }
1301
- function lastFailedPayment(w, sub) {
1302
- return [...w.payments.filter((p) => p.subscriptionId === sub.id && p.status === "failed")].sort((a, b) => b.occurredAt - a.occurredAt)[0];
1303
- }
1304
- /** Servisin portal `load` görünümü (sayfa için). */
1305
- function portalView(w, session) {
1306
- const row = account(w, session.accountRef);
1307
- const state = storedState(w, session.accountRef);
1308
- const live = liveSubscription(w, session.accountRef);
1309
- const price = live ? priceOf(w, live.priceId) : null;
1310
- return {
1311
- session,
1312
- account: row,
1313
- state,
1314
- planName: w.plans.get(state.planCode)?.name ?? state.planCode,
1315
- subscription: live && price ? {
1316
- row: live,
1317
- price,
1318
- planName: w.plans.get(price.planCode)?.name ?? price.planCode,
1319
- canCancel: !live.cancelAtPeriodEnd,
1320
- canRetryPayment: inDunning(live) && lastFailedPayment(w, live) !== void 0
1321
- } : null,
1322
- invoices: [...w.invoices.filter((i) => i.accountRef === session.accountRef && (i.paymentId !== null || i.status !== "draft"))].sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1)).slice(0, 24),
1323
- returnUrl: session.returnUrl ?? w.settings.appUrl ?? null
1324
- };
1325
- }
1326
- /**
1327
- * Portal iptali (`HostedPortalPort.cancelAtPeriodEnd`): canlı abonelik dönem sonunda
1328
- * (`cancelSubscription`, neden `customer_portal`, aktör `customer:<ref>`; `/v1` iptaliyle aynı
1329
- * kurallar ve event'ler). Zaten planlıysa `already_scheduled` (iz/event yok).
1330
- */
1331
- function portalCancel(w, session) {
1332
- const sub = liveSubscription(w, session.accountRef);
1333
- if (!sub) return "no_subscription";
1334
- if (sub.cancelAtPeriodEnd) return "already_scheduled";
1335
- return cancelSubscription(w, sub.id, {
1336
- mode: "at_period_end",
1337
- reason: "customer_portal",
1338
- skipProvider: false
1339
- }, customerActorRef(session.accountRef)).ok ? "ok" : "no_subscription";
1340
- }
1341
- /**
1342
- * Portal "ödemeyi yeniden dene" (`HostedPortalPort.retryPayment`): past_due/suspended abonelikte
1343
- * son başarısız sipariş sahte sağlayıcıya gönderilmiş sayılır — DURUM DEĞİŞMEZ, event yok;
1344
- * tahsilatın sonucu servisteki gibi sonradan gelir (`simulate.renewal(ref)` aynı siparişi
1345
- * başarılı/`fail` sayar). Abonelik başına 10 dakikada bir (portal ya da dunning retry'ı).
1346
- */
1347
- function portalRetryPayment(w, session) {
1348
- const sub = liveSubscription(w, session.accountRef);
1349
- if (!sub) return "no_subscription";
1350
- if (!inDunning(sub)) return "nothing_to_retry";
1351
- const now = w.now();
1352
- if (sub.lastPaymentRetryAt !== null && sub.lastPaymentRetryAt > now - 6e5) return "retry_too_soon";
1353
- if (!lastFailedPayment(w, sub)) return "no_failed_payment";
1354
- sub.lastPaymentRetryAt = now;
1355
- return "ok";
1356
- }
1357
- const PROFILE_REQUIRED = [
1358
- "kind",
1359
- "contactName",
1360
- "email",
1361
- "phone",
1362
- "address",
1363
- "city"
1364
- ];
1365
- const PROFILE_COMPANY_REQUIRED = [
1366
- "companyName",
1367
- "taxOffice",
1368
- "taxId"
1369
- ];
1370
- const PROFILE_TEXT = [
1371
- "contactName",
1372
- "address",
1373
- "city"
1374
- ];
1375
- const PROFILE_COMPANY_TEXT = ["companyName", "taxOffice"];
1376
- const isBlank = (value) => value === void 0 || value === null || typeof value === "string" && value.trim() === "";
1377
- /**
1378
- * Servisin `validateProfile`'ı (hosted-checkout.ts `checkProfile`): alan anahtarları
1379
- * `profile.<alan>`, alan başına ilk kod; öncelik required > invalid_kind > invalid_characters >
1380
- * şema kodları > VKN/E.164. Değer koda girmez.
1381
- */
1382
- function validatePortalProfile(input) {
1383
- const fieldErrors = {};
1384
- const set = (field, code) => {
1385
- fieldErrors[`profile.${field}`] ??= code;
1386
- };
1387
- const company = input.kind === "company";
1388
- for (const field of company ? [...PROFILE_REQUIRED, ...PROFILE_COMPANY_REQUIRED] : PROFILE_REQUIRED) if (isBlank(input[field])) set(field, "required");
1389
- if (!isBlank(input.kind) && input.kind !== "individual" && !company) set("kind", "invalid_kind");
1390
- for (const field of company ? [...PROFILE_TEXT, ...PROFILE_COMPANY_TEXT] : PROFILE_TEXT) {
1391
- const value = input[field];
1392
- if (typeof value === "string" && /[<>]/.test(value)) set(field, "invalid_characters");
1393
- }
1394
- const parsed = BillingProfileSchema.safeParse(input);
1395
- if (!parsed.success) for (const issue of parsed.error.issues) {
1396
- const field = typeof issue.path[0] === "string" ? issue.path[0] : "";
1397
- if (field === "") continue;
1398
- if (isBlank(input[field])) set(field, "required");
1399
- else if (field === "kind") set(field, "invalid_kind");
1400
- else if (field === "email") set(field, "invalid_email");
1401
- else if (field === "phone") set(field, "invalid_phone");
1402
- else if (field === "taxId" && company) set(field, "invalid_vkn");
1403
- else if (issue.code === "too_small") set(field, "too_short");
1404
- else if (issue.code === "too_big") set(field, "too_long");
1405
- else set(field, "invalid");
1406
- }
1407
- if (company && !isBlank(input.taxId) && !isValidVkn(input.taxId)) set("taxId", "invalid_vkn");
1408
- if (!isBlank(input.phone) && !isE164(input.phone)) set("phone", "invalid_phone");
1409
- return parsed.success && Object.keys(fieldErrors).length === 0 ? {
1410
- ok: true,
1411
- profile: parsed.data
1412
- } : {
1413
- ok: false,
1414
- fieldErrors
1415
- };
1416
- }
1417
- /**
1418
- * Portal profil güncellemesi (`HostedPortalPort.updateProfile`): ülke verilmezse saklı değer
1419
- * korunur (yoksa TR); bireyselde kurumsal alanlar düşer. Durumda yalnızca profilin varlığı ve
1420
- * türü olduğundan event yalnızca bunlar değişince (`billing_profile.updated`).
1421
- */
1422
- function updatePortalProfile(w, session, input) {
1423
- const row = account(w, session.accountRef);
1424
- const raw = { ...input };
1425
- if (raw.country === void 0 && row.profile) raw.country = row.profile.country;
1426
- const checked = validatePortalProfile(raw);
1427
- if (!checked.ok) return checked;
1428
- saveProfile(row, checked.profile);
1429
- commitAccountChange(w, session.accountRef, "billing_profile.updated");
1430
- return { ok: true };
1431
- }
1432
- /** Portal belge indirme: yalnızca oturumun hesabının kesilmiş ve belgeli faturası; yoksa null. */
1433
- function portalInvoiceDocument(w, session, invoiceId) {
1434
- const invoice = w.invoices.find((i) => i.id === invoiceId && i.accountRef === session.accountRef && i.status === "issued");
1435
- if (!invoice?.document || invoice.number === null) return null;
1436
- const safe = invoice.number.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^[._]+/, "").slice(0, 100);
1437
- return {
1438
- bytes: invoice.document.bytes,
1439
- filename: `${safe === "" ? "invoice" : safe}.pdf`
1440
- };
1441
- }
1442
- /** Requeues the event's delivery (dead or delivered included); `false` if there is no such event. */
1443
- function redeliverEvent(w, id) {
1444
- const event = w.events.find((e) => e.id === id);
1445
- if (!event) return false;
1446
- if (event.delivery) Object.assign(event.delivery, {
1447
- status: "pending",
1448
- attempts: 0,
1449
- nextAttemptAt: w.now(),
1450
- lastError: null,
1451
- deliveredAt: null
1452
- });
1453
- return true;
1454
- }
1455
- /**
1456
- * Tahsilat sonucu. Dunning'deki abonelikte AYNI başarısız siparişin yeniden
1457
- * denemesidir (iyzico retry'ı; servisin `existingPayment` yolu), aksi halde
1458
- * mevcut dönem sonundan başlayan yeni sipariş.
1459
- */
1460
- function applyRenewalPayment(w, sub, outcome) {
1461
- const now = w.now();
1462
- const price = priceOf(w, sub.priceId);
1463
- const retried = inDunning(sub) ? [...w.payments.filter((p) => p.subscriptionId === sub.id && p.status === "failed")].sort((a, b) => b.occurredAt - a.occurredAt)[0] : void 0;
1464
- if (outcome === "failed") {
1465
- let payment = retried;
1466
- if (payment) Object.assign(payment, {
1467
- status: "failed",
1468
- attempt: payment.attempt + 1,
1469
- occurredAt: now
1470
- });
1471
- else {
1472
- const periodStart = sub.currentPeriodEnd;
1473
- payment = insertPayment(w, sub, {
1474
- providerOrderRef: `fake_ord_${globalThis.crypto.randomUUID()}`,
1475
- status: "failed",
1476
- periodStart,
1477
- periodEnd: addInterval(periodStart ?? now, price.interval, price.intervalCount)
1478
- });
1479
- }
1480
- const dunningStartedAt = sub.status === "active" ? now : sub.dunningStartedAt ?? now;
1481
- if (sub.status === "active") Object.assign(sub, {
1482
- status: "past_due",
1483
- pastDueSince: now,
1484
- dunningStartedAt: now,
1485
- dunningStep: 0,
1486
- nextDunningAt: now
1487
- });
1488
- const failures = w.payments.filter((p) => p.subscriptionId === sub.id && p.status === "failed" && p.createdAt >= dunningStartedAt).length;
1489
- commitAccountChange(w, sub.accountRef, {
1490
- type: "subscription.payment_failed",
1491
- detail: {
1492
- subscriptionId: sub.id,
1493
- paymentId: payment.id,
1494
- attempt: Math.max(1, failures),
1495
- nextRetryAt: null
1496
- }
1497
- });
1498
- return;
1499
- }
1500
- const periodStart = retried?.periodStart ?? sub.currentPeriodEnd ?? now;
1501
- const periodEnd = retried?.periodEnd ?? addInterval(periodStart, price.interval, price.intervalCount);
1502
- let payment = retried;
1503
- if (payment) Object.assign(payment, {
1504
- status: "succeeded",
1505
- attempt: payment.attempt + 1,
1506
- periodStart,
1507
- periodEnd,
1508
- occurredAt: now
1509
- });
1510
- else payment = insertPayment(w, sub, {
1511
- providerOrderRef: `fake_ord_${globalThis.crypto.randomUUID()}`,
1512
- status: "succeeded",
1513
- periodStart,
1514
- periodEnd
1515
- });
1516
- const invoiceId = createInvoiceForPayment(w, payment);
1517
- const recovering = inDunning(sub);
1518
- const advance = sub.currentPeriodEnd === null || periodEnd > sub.currentPeriodEnd;
1519
- Object.assign(sub, {
1520
- status: "active",
1521
- ...recovering ? {
1522
- pastDueSince: null,
1523
- dunningStartedAt: null,
1524
- dunningStep: 0,
1525
- nextDunningAt: null
1526
- } : {},
1527
- ...advance ? {
1528
- currentPeriodStart: periodStart,
1529
- currentPeriodEnd: periodEnd
1530
- } : {}
1531
- });
1532
- commitAccountChange(w, sub.accountRef, recovering ? {
1533
- type: "subscription.reactivated",
1534
- detail: {
1535
- subscriptionId: sub.id,
1536
- paymentId: payment.id
1537
- }
1538
- } : {
1539
- type: "subscription.renewed",
1540
- detail: {
1541
- subscriptionId: sub.id,
1542
- paymentId: payment.id,
1543
- periodStart: iso(periodStart),
1544
- periodEnd: iso(periodEnd),
1545
- invoiceId
1546
- }
1547
- });
1548
- }
1549
- /** `runDunning` + `processOne`, sahte sağlayıcıda: yeniden deneme tahsilatı değiştirmez (sonuç `renewal` ile gelir). */
1550
- function runDunning(w) {
1551
- const now = w.now();
1552
- const policy = w.dunningPolicy;
1553
- const due = w.subscriptions.filter((s) => inDunning(s) && s.dunningStartedAt !== null && s.nextDunningAt !== null && s.nextDunningAt <= now).sort((a, b) => a.nextDunningAt - b.nextDunningAt);
1554
- let applied = 0;
1555
- for (const sub of due) {
1556
- const start = sub.dunningStartedAt;
1557
- const step = policy.steps[sub.dunningStep];
1558
- if (!step) {
1559
- sub.nextDunningAt = null;
1560
- continue;
1561
- }
1562
- const dueAt = start + step.afterDays * DAY_MS;
1563
- if (dueAt > now) {
1564
- sub.nextDunningAt = dueAt;
1565
- continue;
1566
- }
1567
- applied++;
1568
- const dunningDay = Math.floor((now - start) / DAY_MS);
1569
- const next = policy.steps[sub.dunningStep + 1];
1570
- const advance = () => {
1571
- sub.dunningStep += 1;
1572
- sub.nextDunningAt = next ? start + next.afterDays * DAY_MS : null;
1573
- };
1574
- if (policy.dryRun || step.action === "retry_payment" || step.action === "suspend" && sub.status === "suspended") {
1575
- if (!policy.dryRun && step.action === "retry_payment" && lastFailedPayment(w, sub)) sub.lastPaymentRetryAt = now;
1576
- advance();
1577
- commitAccountChange(w, sub.accountRef, "dunning.advanced");
1578
- } else if (step.action === "suspend") {
1579
- advance();
1580
- sub.status = "suspended";
1581
- commitAccountChange(w, sub.accountRef, {
1582
- type: "subscription.suspended",
1583
- detail: {
1584
- subscriptionId: sub.id,
1585
- dunningDay
1586
- }
1587
- });
1588
- } else {
1589
- advance();
1590
- Object.assign(sub, {
1591
- status: "canceled",
1592
- pastDueSince: null,
1593
- canceledAt: now,
1594
- cancelAt: now,
1595
- cancelReason: "nonpayment",
1596
- nextDunningAt: null
1597
- });
1598
- commitAccountChange(w, sub.accountRef, {
1599
- type: "subscription.terminated",
1600
- detail: {
1601
- subscriptionId: sub.id,
1602
- reason: "nonpayment",
1603
- dunningDay
1604
- }
1605
- });
1606
- }
1607
- }
1608
- return applied;
1609
- }
1610
- function expireScheduledCancellations(w) {
1611
- const now = w.now();
1612
- for (const sub of w.subscriptions) {
1613
- if (!isLive(sub) || !sub.cancelAtPeriodEnd || sub.cancelAt === null || sub.cancelAt > now) continue;
1614
- Object.assign(sub, {
1615
- status: "expired",
1616
- pastDueSince: null,
1617
- dunningStartedAt: null,
1618
- dunningStep: 0,
1619
- nextDunningAt: null
1620
- });
1621
- commitAccountChange(w, sub.accountRef, {
1622
- type: "subscription.expired",
1623
- detail: {
1624
- subscriptionId: sub.id,
1625
- effectiveAt: iso(sub.cancelAt)
1626
- }
1627
- });
1628
- }
1629
- }
1630
- /**
1631
- * Expired open session (formless: the fake hosted page has no provider form, so `expired`).
1632
- * Servis gibi kapanan oturumun `expiresAt`'i job kirasının sonu (o an + 5 dk) kalır.
1633
- */
1634
- function expireCheckoutSessions(w) {
1635
- const now = w.now();
1636
- for (const session of w.sessions) {
1637
- if (session.status !== "open" || session.expiresAt > now) continue;
1638
- Object.assign(session, {
1639
- status: "expired",
1640
- failureReason: "expired",
1641
- completedAt: now,
1642
- expiresAt: now + 3e5,
1643
- ...CLOSED_SESSION_CLEANUP
1644
- });
1645
- commitAccountChange(w, session.accountRef, {
1646
- type: "checkout.expired",
1647
- detail: { checkoutSessionId: session.id }
1648
- });
1649
- }
1650
- }
1651
- function applyGrantWindows(w) {
1652
- const now = w.now();
1653
- const refresh = /* @__PURE__ */ new Set();
1654
- for (const g of w.grants) {
1655
- if (g.revokedAt !== null) continue;
1656
- const start = !g.startApplied && g.startsAt <= now;
1657
- const end = !g.endApplied && g.endsAt !== null && g.endsAt <= now;
1658
- if (!start && !end) continue;
1659
- g.startApplied ||= g.startsAt <= now;
1660
- g.endApplied ||= g.endsAt !== null && g.endsAt <= now;
1661
- refresh.add(g.accountRef);
1662
- }
1663
- for (const ref of refresh) commitAccountChange(w, ref, "grant.window_applied");
1664
- }
1665
- /** `deleteExpiredPortalSessions` (K21): süresi geçen (sınır dahil) portal oturumları silinir; sayfa zaten reddeder. */
1666
- function deleteExpiredPortalSessions(w) {
1667
- const now = w.now();
1668
- w.portalSessions = w.portalSessions.filter((s) => s.expiresAt > now);
1669
- }
1670
- /** Bir job turu (servis sırası: dunning, dönem sonu iptali, checkout süresi, grant penceresi, portal oturumu silme). */
1671
- function runJobs(w) {
1672
- for (let i = 0; i < 100 && runDunning(w) > 0; i++);
1673
- expireScheduledCancellations(w);
1674
- expireCheckoutSessions(w);
1675
- applyGrantWindows(w);
1676
- deleteExpiredPortalSessions(w);
1677
- }
1678
- /** Bir sonraki zamanlanmış iş anı (`after` sonrası, `until` dahil). */
1679
- function nextJobAt(w, after, until) {
1680
- const times = [
1681
- ...w.subscriptions.flatMap((s) => [inDunning(s) ? s.nextDunningAt : null, isLive(s) && s.cancelAtPeriodEnd ? s.cancelAt : null]),
1682
- ...w.sessions.map((s) => s.status === "open" ? s.expiresAt : null),
1683
- ...w.grants.flatMap((g) => g.revokedAt !== null ? [] : [g.startApplied ? null : g.startsAt, g.endApplied ? null : g.endsAt])
1684
- ];
1685
- let next = null;
1686
- for (const t of times) if (t !== null && t > after && t <= until && (next === null || t < next)) next = t;
1687
- return next;
1688
- }
1689
- /** Saati `target`a ilerletir; her zamanlanmış iş kendi anında koşar. */
1690
- function advanceTo(w, target) {
1691
- runJobs(w);
1692
- for (let guard = 0; guard < 1e4; guard++) {
1693
- const next = nextJobAt(w, w.now(), target);
1694
- if (next === null) break;
1695
- w.setNow(next);
1696
- runJobs(w);
1697
- }
1698
- w.setNow(target);
1699
- runJobs(w);
1700
- }
1701
- //#endregion
1702
- //#region src/testing/hosted.ts
1703
- const HEADERS$1 = {
1704
- "cache-control": "no-store",
1705
- "referrer-policy": "no-referrer"
1706
- };
1707
- const escapeHtml = (value) => value.replace(/[&<>"']/g, (ch) => ({
1708
- "&": "&amp;",
1709
- "<": "&lt;",
1710
- ">": "&gt;",
1711
- "\"": "&quot;",
1712
- "'": "&#39;"
1713
- })[ch]);
1714
- const html = (status, body) => new Response(`<!doctype html><html lang="tr"><meta charset="utf-8"><title>Sahte steward ödeme sayfası</title><body>${body}</body></html>`, {
1715
- status,
1716
- headers: {
1717
- ...HEADERS$1,
1718
- "content-type": "text/html; charset=utf-8"
1719
- }
1720
- });
1721
- const notFound$2 = () => html(404, "<h1>Sayfa bulunamadı</h1><p>Bu ödeme bağlantısı geçersiz ya da süresi dolmuş.</p>");
1722
- const redirect$1 = (location) => new Response(null, {
1723
- status: 303,
1724
- headers: {
1725
- ...HEADERS$1,
1726
- location
1727
- }
1728
- });
1729
- const PATH$1 = /^\/public\/v1\/checkout\/([^/]+)(?:\/(pay|cancel))?$/;
1730
- function page(w, session, k) {
1731
- const price = priceOf(w, session.priceId);
1732
- const action = (name) => `${encodeURIComponent(session.id)}/${name}`;
1733
- const key = `<input type="hidden" name="k" value="${escapeHtml(k)}">`;
1734
- const docs = session.consentDocs.map((d) => `<li><a href="${escapeHtml(d.url)}">${escapeHtml(d.document)}</a> (${escapeHtml(d.version)})</li>`).join("");
1735
- return html(200, `<h1>Sahte steward ödeme sayfası</h1>
1736
- <p>Plan: <strong>${escapeHtml(price.planCode)}</strong> · ${escapeHtml(price.interval)} · ${price.amountMinor} ${escapeHtml(price.currency)} (kuruş)</p>
1737
- <p>Hesap: ${escapeHtml(session.accountRef)} · dil: ${escapeHtml(session.locale)}</p>
1738
- ${docs ? `<p>Onaylanmış sayılan belgeler:</p><ul>${docs}</ul>` : ""}
1739
- <form method="post" action="${action("pay")}">${key}<button name="outcome" value="succeeded">Tamamla</button> <button name="outcome" value="failed">Reddet</button></form>
1740
- <form method="post" action="${action("cancel")}">${key}<button>Vazgeç</button></form>`);
1741
- }
1742
- /** `/public/*` istekleri (kimliksiz). Eşleşmeyen yol 404. */
1743
- function handlePublic(w, req) {
1744
- const match = PATH$1.exec(req.url.pathname);
1745
- if (!match) return notFound$2();
1746
- let id;
1747
- try {
1748
- id = decodeURIComponent(match[1]);
1749
- } catch {
1750
- return notFound$2();
1751
- }
1752
- const action = match[2];
1753
- if (action === void 0) {
1754
- if (req.method !== "GET") return notFound$2();
1755
- const k = req.url.searchParams.get("k") ?? "";
1756
- const session = openHostedSession(w, id, k);
1757
- return session ? page(w, session, k) : notFound$2();
1758
- }
1759
- if (req.method !== "POST") return notFound$2();
1760
- const form = new URLSearchParams(req.body);
1761
- const k = form.get("k") ?? "";
1762
- const session = openHostedSession(w, id, k);
1763
- if (!session) return notFound$2();
1764
- if (action === "cancel") {
1765
- const target = cancelCheckout(w, session);
1766
- return target ? redirect$1(target) : html(200, "<h1>Ödeme iptal edildi</h1><p>Sizden ücret alınmadı.</p>");
1767
- }
1768
- if (form.get("outcome") === "failed") failCheckout(w, session, "fake:card_declined");
1769
- else completeCheckout(w, session);
1770
- const status = session.status === "completed" ? "success" : "failed";
1771
- return redirect$1(checkoutRedirectUrl(session, status));
1772
- }
1773
- //#endregion
1774
- //#region src/testing/admin.ts
1775
- /** Servis gövde şeması + üretim dışı adres politikası. */
1776
- const CreateEndpointInputSchema = WebhookEndpointCreateInputSchema.superRefine((input, ctx) => {
1777
- const problem = webhookEndpointUrlProblem(input.url, { allowLoopback: true });
1778
- if (problem !== null) ctx.addIssue({
1779
- code: "custom",
1780
- path: ["url"],
1781
- message: problem
1782
- });
1783
- });
1784
- function settings(w) {
1785
- return w.settings;
1786
- }
1787
- function patchSettings(w, patch) {
1788
- w.settings = applySettingsPatch(w.settings, patch);
1789
- if (patch.appUrl !== void 0 || patch.extraReturnOrigins !== void 0) w.permissiveReturnOrigins = false;
1790
- return w.settings;
1791
- }
1792
- function toEndpoint(row) {
1793
- return {
1794
- id: row.id,
1795
- url: row.url,
1796
- status: row.status,
1797
- eventTypes: [...row.eventTypes],
1798
- description: row.description,
1799
- createdAt: iso(row.createdAt),
1800
- rotatedAt: isoOrNull(row.rotatedAt),
1801
- lastDelivery: null
1802
- };
1803
- }
1804
- function listEndpoints(w) {
1805
- return { items: w.adminEndpoints.map(toEndpoint) };
1806
- }
1807
- function createEndpoint(w, input) {
1808
- const row = {
1809
- id: w.id(),
1810
- url: new URL(input.url).toString(),
1811
- status: "active",
1812
- eventTypes: [...input.eventTypes ?? DEFAULT_ENDPOINT_EVENT_TYPES],
1813
- description: input.description ?? null,
1814
- createdAt: w.now(),
1815
- rotatedAt: null
1816
- };
1817
- w.adminEndpoints.push(row);
1818
- return toEndpoint(row);
1819
- }
1820
- const notFound$1 = {
1821
- ok: false,
1822
- code: "webhook_endpoint_not_found"
1823
- };
1824
- function rotateEndpoint(w, id, _input) {
1825
- const row = w.adminEndpoints.find((e) => e.id === id);
1826
- if (!row) return notFound$1;
1827
- row.rotatedAt = w.now();
1828
- return {
1829
- ok: true,
1830
- value: toEndpoint(row)
1831
- };
1832
- }
1833
- function deactivateEndpoint(w, id) {
1834
- const row = w.adminEndpoints.find((e) => e.id === id);
1835
- if (!row) return notFound$1;
1836
- row.status = "inactive";
1837
- return {
1838
- ok: true,
1839
- value: toEndpoint(row)
1840
- };
1841
- }
1842
- //#endregion
1843
- //#region src/testing/doctor.ts
1844
- function doctorReport(w) {
1845
- const catalog = catalogOf(w);
1846
- const unsellablePrices = [];
1847
- let sellablePrices = 0;
1848
- const prices = w.prices.filter((p) => p.active && w.plans.get(p.planCode)?.active === true && w.plans.get(p.planCode)?.sellable === true).sort((a, b) => {
1849
- const pa = w.plans.get(a.planCode);
1850
- const pb = w.plans.get(b.planCode);
1851
- return pa.rank - pb.rank || cmp(a.planCode, b.planCode) || cmp(a.interval, b.interval) || a.intervalCount - b.intervalCount || cmp(a.currency, b.currency);
1852
- });
1853
- for (const p of prices) if (p.intervalCount === 1) sellablePrices++;
1854
- else unsellablePrices.push({
1855
- priceId: p.id,
1856
- planCode: p.planCode,
1857
- interval: p.interval,
1858
- intervalCount: p.intervalCount,
1859
- currency: p.currency,
1860
- reason: "interval_count_unsupported"
1861
- });
1862
- return buildDoctorReport({
1863
- checkedAt: new Date(w.now()),
1864
- product: {
1865
- code: w.productCode,
1866
- name: w.productCode
1867
- },
1868
- catalog: {
1869
- synced: catalog !== null,
1870
- version: catalog ? catalogVersionOf(catalog) : null,
1871
- plans: catalog?.plans.length ?? 0,
1872
- sellablePrices,
1873
- unsellablePrices
1874
- },
1875
- provider: {
1876
- id: w.settings.provider,
1877
- configured: true,
1878
- environment: "fake",
1879
- merchantId: null,
1880
- healthy: true,
1881
- lastError: null
1882
- },
1883
- settings: w.settings,
1884
- endpoints: w.adminEndpoints.filter((e) => e.status !== "inactive").map((e) => ({
1885
- id: e.id,
1886
- url: e.url,
1887
- status: e.status,
1888
- eventTypes: [...e.eventTypes],
1889
- pending: 0,
1890
- dead: 0,
1891
- oldestPendingAt: null,
1892
- lastDelivery: null
1893
- })),
1894
- dunning: { dryRun: w.dunningPolicy.dryRun }
1895
- });
1896
- }
1897
- const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1898
- //#endregion
1899
- //#region src/testing/invoices.ts
1900
- function createManualInvoice(w, ref, input) {
1901
- const profile = w.accounts.get(ref)?.profile;
1902
- if (!w.accounts.has(ref)) return {
1903
- ok: false,
1904
- code: "account_not_found"
1905
- };
1906
- if (!profile) return {
1907
- ok: false,
1908
- code: "billing_profile_required"
1909
- };
1910
- const totals = manualInvoiceTotals(input);
1911
- const invoice = {
1912
- id: w.id(),
1913
- accountRef: ref,
1914
- subscriptionId: null,
1915
- paymentId: null,
1916
- status: "draft",
1917
- number: null,
1918
- currency: input.currency,
1919
- subtotalMinor: totals.subtotalMinor,
1920
- taxMinor: totals.taxMinor,
1921
- totalMinor: totals.totalMinor,
1922
- taxRateBps: input.taxRateBps,
1923
- buyer: profile,
1924
- lines: totals.lines,
1925
- externalRef: null,
1926
- issuedAt: null,
1927
- createdAt: w.now(),
1928
- document: null,
1929
- dueAt: input.dueAt !== void 0 ? Date.parse(input.dueAt) : null,
1930
- note: input.note ?? null,
1931
- voidedAt: null,
1932
- voidReason: null
1933
- };
1934
- w.invoices.push(invoice);
1935
- const { state } = commitAccountChange(w, ref, {
1936
- type: "invoice.created",
1937
- detail: {
1938
- invoiceId: invoice.id,
1939
- totalMinor: totals.totalMinor,
1940
- currency: input.currency
1941
- }
1942
- });
1943
- return {
1944
- ok: true,
1945
- value: {
1946
- ...toInvoice(invoice),
1947
- account: state
1948
- }
1949
- };
1950
- }
1951
- function voidInvoice(w, id, input) {
1952
- const invoice = w.invoices.find((i) => i.id === id);
1953
- if (!invoice) return {
1954
- ok: false,
1955
- code: "invoice_not_found"
1956
- };
1957
- if (invoice.status === "void") return {
1958
- ok: false,
1959
- code: "invoice_not_voidable"
1960
- };
1961
- if (invoice.paymentId !== null) return {
1962
- ok: false,
1963
- code: "invoice_has_payment"
1964
- };
1965
- Object.assign(invoice, {
1966
- status: "void",
1967
- voidedAt: w.now(),
1968
- voidReason: input.reason
1969
- });
1970
- const { state } = commitAccountChange(w, invoice.accountRef, {
1971
- type: "invoice.voided",
1972
- detail: {
1973
- invoiceId: id,
1974
- number: invoice.number,
1975
- totalMinor: invoice.totalMinor,
1976
- currency: invoice.currency,
1977
- reason: input.reason
1978
- }
1979
- });
1980
- return {
1981
- ok: true,
1982
- value: {
1983
- ...toInvoice(invoice),
1984
- account: state
1985
- }
1986
- };
1987
- }
1988
- //#endregion
1989
- //#region src/testing/http.ts
1990
- const MUTATING = /* @__PURE__ */ new Set([
1991
- "POST",
1992
- "PUT",
1993
- "PATCH",
1994
- "DELETE"
1995
- ]);
1996
- const KEY_RE = /^[A-Za-z0-9_.:-]{8,200}$/;
1997
- const json = (body, status = 200, headers = {}) => ({
1998
- status,
1999
- body: JSON.stringify(body),
2000
- headers: {
2001
- "content-type": "application/json",
2002
- ...headers
2003
- }
2004
- });
2005
- const empty = (status, headers = {}) => ({
2006
- status,
2007
- body: null,
2008
- headers
2009
- });
2010
- /** `If-None-Match` (liste, zayıf ETag ve `*` dahil) verilen ETag'i karşılıyor mu. */
2011
- function notModified(header, etag) {
2012
- if (!header) return false;
2013
- return header.split(",").some((tag) => {
2014
- const value = tag.trim();
2015
- return value === "*" || value.replace(/^W\//, "") === etag;
2016
- });
2017
- }
2018
- function parseBody(ctx, schema) {
2019
- let value;
2020
- try {
2021
- value = JSON.parse(ctx.body);
2022
- } catch {
2023
- return {
2024
- ok: false,
2025
- reply: ctx.error("invalid_json")
2026
- };
2027
- }
2028
- const parsed = schema.safeParse(value);
2029
- if (parsed.success) return {
2030
- ok: true,
2031
- data: parsed.data
2032
- };
2033
- const message = parsed.error.issues.slice(0, 10).map((i) => `${i.path.join(".") || "(kök)"}: ${i.message}`).join("; ");
2034
- return {
2035
- ok: false,
2036
- reply: ctx.error("invalid_request", message)
2037
- };
2038
- }
2039
- const failure = (ctx, result) => ctx.error(result.code, result.message);
2040
- function routes(w, config) {
2041
- const route = (method, path, handle) => ({
2042
- method,
2043
- path: path.split("/").filter(Boolean),
2044
- handle
2045
- });
2046
- const ref = (ctx) => ExternalRefSchema.safeParse(ctx.params.ref).success ? ctx.params.ref : null;
2047
- const admin = (handle) => (ctx) => ctx.auth.scope === "admin" ? handle(ctx) : ctx.error("forbidden", "admin kapsamlı anahtar gerekli");
2048
- return [
2049
- route("GET", "me", (ctx) => json({
2050
- product: w.productCode,
2051
- keyName: ctx.auth.keyName,
2052
- scope: ctx.auth.scope
2053
- })),
2054
- route("GET", "catalog", (ctx) => {
2055
- const catalog = catalogOf(w);
2056
- if (!catalog) return ctx.error("catalog_not_configured", "ürün için henüz katalog senkronlanmadı");
2057
- const etag = `"${catalogVersionOf(catalog)}"`;
2058
- if (ctx.headers.get("if-none-match") === etag) return empty(304, { etag });
2059
- return json(catalog, 200, { etag });
2060
- }),
2061
- route("PUT", "admin/catalog", admin((ctx) => {
2062
- const parsed = parseBody(ctx, CatalogSyncInputSchema);
2063
- if (!parsed.ok) return parsed.reply;
2064
- const result = syncCatalog(w, parsed.data);
2065
- return result.ok ? json(result.value) : failure(ctx, result);
2066
- })),
2067
- route("POST", "admin/accounts/refresh", admin(() => json(refreshAccounts(w)))),
2068
- route("GET", "admin/settings", admin(() => json(settings(w)))),
2069
- route("PATCH", "admin/settings", admin((ctx) => {
2070
- const parsed = parseBody(ctx, ProductSettingsPatchSchema);
2071
- return parsed.ok ? json(patchSettings(w, parsed.data)) : parsed.reply;
2072
- })),
2073
- route("GET", "admin/webhook-endpoints", admin(() => json(listEndpoints(w)))),
2074
- route("POST", "admin/webhook-endpoints", admin((ctx) => {
2075
- const parsed = parseBody(ctx, CreateEndpointInputSchema);
2076
- return parsed.ok ? json(createEndpoint(w, parsed.data), 201) : parsed.reply;
2077
- })),
2078
- route("POST", "admin/webhook-endpoints/:id/rotate", admin((ctx) => {
2079
- if (!IdSchema.safeParse(ctx.params.id).success) return ctx.error("webhook_endpoint_not_found");
2080
- const parsed = parseBody(ctx, WebhookEndpointRotateInputSchema);
2081
- if (!parsed.ok) return parsed.reply;
2082
- const result = rotateEndpoint(w, ctx.params.id, parsed.data);
2083
- return result.ok ? json(result.value) : failure(ctx, result);
2084
- })),
2085
- route("POST", "admin/webhook-endpoints/:id/deactivate", admin((ctx) => {
2086
- if (!IdSchema.safeParse(ctx.params.id).success) return ctx.error("webhook_endpoint_not_found");
2087
- const result = deactivateEndpoint(w, ctx.params.id);
2088
- return result.ok ? json(result.value) : failure(ctx, result);
2089
- })),
2090
- route("PUT", "accounts/:ref", (ctx) => {
2091
- const accountRef = ref(ctx);
2092
- if (!accountRef) return ctx.error("invalid_account_ref");
2093
- const parsed = parseBody(ctx, AccountUpsertInputSchema);
2094
- if (!parsed.ok) return parsed.reply;
2095
- const { account, created } = upsertAccount(w, accountRef, parsed.data);
2096
- return json(account, created ? 201 : 200);
2097
- }),
2098
- route("DELETE", "accounts/:ref", (ctx) => {
2099
- const accountRef = ref(ctx);
2100
- if (!accountRef) return ctx.error("invalid_account_ref");
2101
- const result = deleteAccount(w, accountRef, ctx.actorRef);
2102
- return result.ok ? json(result.value) : failure(ctx, result);
2103
- }),
2104
- route("GET", "accounts/:ref", (ctx) => {
2105
- const accountRef = ref(ctx);
2106
- if (!accountRef) return ctx.error("invalid_account_ref");
2107
- const requested = (ctx.url.searchParams.get("include") ?? "").split(",").filter(Boolean);
2108
- const unknown = requested.filter((i) => !ACCOUNT_INCLUDES.includes(i));
2109
- if (unknown.length > 0) return ctx.error("invalid_include", unknown.join(","));
2110
- const detail = accountDetail(w, accountRef, requested);
2111
- return detail ? json(detail) : ctx.error("account_not_found");
2112
- }),
2113
- route("GET", "accounts/:ref/state", (ctx) => {
2114
- const accountRef = ref(ctx);
2115
- if (!accountRef) return ctx.error("invalid_account_ref");
2116
- if (!w.accounts.has(accountRef)) return ctx.error("account_not_found");
2117
- const state = storedState(w, accountRef);
2118
- const etag = `"${state.version}"`;
2119
- if (notModified(ctx.headers.get("if-none-match"), etag)) return empty(304, { etag });
2120
- return json(state, 200, { etag });
2121
- }),
2122
- route("PUT", "accounts/:ref/plan", (ctx) => {
2123
- const accountRef = ref(ctx);
2124
- if (!accountRef) return ctx.error("invalid_account_ref");
2125
- const parsed = parseBody(ctx, PlanSetInputSchema);
2126
- if (!parsed.ok) return parsed.reply;
2127
- const result = setPlan(w, accountRef, parsed.data, ctx.actorRef);
2128
- return result.ok ? json(result.value) : failure(ctx, result);
2129
- }),
2130
- route("POST", "accounts/:ref/resync", (ctx) => {
2131
- const accountRef = ref(ctx);
2132
- if (!accountRef) return ctx.error("invalid_account_ref");
2133
- const result = resyncAccount(w, accountRef);
2134
- return result.ok ? json(result.value, 202) : failure(ctx, result);
2135
- }),
2136
- route("POST", "accounts/:ref/checkout-sessions", (ctx) => {
2137
- const accountRef = ref(ctx);
2138
- if (!accountRef) return ctx.error("invalid_account_ref");
2139
- const parsed = parseBody(ctx, CheckoutSessionCreateInputSchema);
2140
- if (!parsed.ok) return parsed.reply;
2141
- const result = createCheckoutSession(w, config.baseUrl, accountRef, parsed.data, ctx.actorRef);
2142
- return result.ok ? json(result.value, 201) : failure(ctx, result);
2143
- }),
2144
- route("POST", "accounts/:ref/portal-sessions", (ctx) => {
2145
- const accountRef = ref(ctx);
2146
- if (!accountRef) return ctx.error("invalid_account_ref");
2147
- const parsed = ctx.body.trim() === "" ? {
2148
- ok: true,
2149
- data: {}
2150
- } : parseBody(ctx, PortalSessionCreateInputSchema);
2151
- if (!parsed.ok) return parsed.reply;
2152
- const result = createPortalSession(w, config.baseUrl, accountRef, parsed.data, ctx.actorRef);
2153
- return result.ok ? json(result.value, 201) : failure(ctx, result);
2154
- }),
2155
- route("GET", "checkout-sessions/:id", (ctx) => {
2156
- const session = IdSchema.safeParse(ctx.params.id).success ? checkoutSessionResponse(w, ctx.params.id) : null;
2157
- return session ? json(session) : ctx.error("checkout_session_not_found");
2158
- }),
2159
- route("POST", "subscriptions/:id/cancel", (ctx) => {
2160
- if (!IdSchema.safeParse(ctx.params.id).success) return ctx.error("subscription_not_found");
2161
- const parsed = parseBody(ctx, CancelSubscriptionInputSchema);
2162
- if (!parsed.ok) return parsed.reply;
2163
- const result = cancelSubscription(w, ctx.params.id, parsed.data, ctx.actorRef);
2164
- return result.ok ? json(result.value) : failure(ctx, result);
2165
- }),
2166
- route("POST", "accounts/:ref/grants", (ctx) => {
2167
- const accountRef = ref(ctx);
2168
- if (!accountRef) return ctx.error("invalid_account_ref");
2169
- const parsed = parseBody(ctx, GrantCreateInputSchema);
2170
- if (!parsed.ok) return parsed.reply;
2171
- const result = createGrant(w, accountRef, parsed.data, ctx.actorRef);
2172
- return result.ok ? json(result.value, 201) : failure(ctx, result);
2173
- }),
2174
- route("DELETE", "grants/:id", (ctx) => IdSchema.safeParse(ctx.params.id).success && revokeGrant(w, ctx.params.id) ? empty(204) : ctx.error("grant_not_found")),
2175
- route("GET", "stats", () => json(stats(w))),
2176
- route("GET", "doctor", () => json(doctorReport(w), 200, { "cache-control": "no-store" })),
2177
- route("POST", "invoices/:id/issue", (ctx) => {
2178
- if (!IdSchema.safeParse(ctx.params.id).success) return ctx.error("not_found");
2179
- const parsed = parseBody(ctx, InvoiceIssueInputSchema);
2180
- if (!parsed.ok) return parsed.reply;
2181
- const result = issueInvoice(w, ctx.params.id, parsed.data);
2182
- return result.ok ? json(result.value) : failure(ctx, result);
2183
- }),
2184
- route("POST", "accounts/:ref/invoices", (ctx) => {
2185
- const accountRef = ref(ctx);
2186
- if (!accountRef) return ctx.error("invalid_account_ref");
2187
- const parsed = parseBody(ctx, ManualInvoiceInputSchema);
2188
- if (!parsed.ok) return parsed.reply;
2189
- const result = createManualInvoice(w, accountRef, parsed.data);
2190
- return result.ok ? json(result.value, 201) : failure(ctx, result);
2191
- }),
2192
- route("POST", "invoices/:id/void", (ctx) => {
2193
- if (!IdSchema.safeParse(ctx.params.id).success) return ctx.error("not_found");
2194
- const parsed = parseBody(ctx, InvoiceVoidInputSchema);
2195
- if (!parsed.ok) return parsed.reply;
2196
- const result = voidInvoice(w, ctx.params.id, parsed.data);
2197
- return result.ok ? json(result.value) : failure(ctx, result);
2198
- }),
2199
- route("GET", "invoices/:id/document", (ctx) => {
2200
- const invoice = IdSchema.safeParse(ctx.params.id).success ? w.invoices.find((i) => i.id === ctx.params.id) : void 0;
2201
- if (!invoice?.document) return ctx.error("document_not_found");
2202
- const { bytes, sha256 } = invoice.document;
2203
- return {
2204
- status: 200,
2205
- body: bytes,
2206
- headers: {
2207
- "content-type": "application/pdf",
2208
- "content-length": String(bytes.length),
2209
- "cache-control": "private, no-store",
2210
- "x-content-sha256": sha256
2211
- }
2212
- };
2213
- })
2214
- ];
2215
- }
2216
- function decode(segment) {
2217
- try {
2218
- return decodeURIComponent(segment);
2219
- } catch {
2220
- return segment;
2221
- }
2222
- }
2223
- function match(route, method, segments) {
2224
- if (route.method !== method || route.path.length !== segments.length) return null;
2225
- const params = {};
2226
- for (const [i, part] of route.path.entries()) {
2227
- const segment = segments[i];
2228
- if (part.startsWith(":")) {
2229
- if (segment === "") return null;
2230
- params[part.slice(1)] = decode(segment);
2231
- } else if (part !== segment) return null;
2232
- }
2233
- return params;
2234
- }
2235
- function createHttpHandler(w, config) {
2236
- const table = routes(w, config);
2237
- const idempotency = /* @__PURE__ */ new Map();
2238
- return (req) => {
2239
- const requestId = config.requestId();
2240
- const error = (code, message) => {
2241
- const spec = ERROR_CODES[code];
2242
- const body = {
2243
- error: code,
2244
- ...message !== void 0 ? { message } : {},
2245
- category: spec.category,
2246
- retryable: spec.retryable,
2247
- requestId
2248
- };
2249
- return json(body, spec.httpStatus);
2250
- };
2251
- const reply = run(req, error);
2252
- const headers = new Headers(reply.headers);
2253
- headers.set(HEADER_REQUEST_ID, requestId);
2254
- return new Response(reply.body, {
2255
- status: reply.status,
2256
- headers
2257
- });
2258
- };
2259
- function run(req, error) {
2260
- const path = req.url.pathname;
2261
- if (path === "/health" && req.method === "GET") return json({ ok: true });
2262
- if (path !== "/v1" && !path.startsWith("/v1/")) return error("not_found");
2263
- const header = req.headers.get("authorization") ?? "";
2264
- const token = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
2265
- const auth = token ? config.keys.get(token) : void 0;
2266
- if (!auth) return error("unauthorized");
2267
- const actorRef = req.headers.get(HEADER_ACTOR_REF);
2268
- if (actorRef !== null && !ActorRefSchema.safeParse(actorRef).success) return error("invalid_actor_ref", "biçim: tür[:kimlik], ör. user:42");
2269
- if (actorRef === null && MUTATING.has(req.method)) return error("actor_ref_required", `${HEADER_ACTOR_REF} başlığı zorunlu`);
2270
- const segments = path.slice(3).split("/").slice(1);
2271
- const dispatch = () => {
2272
- if (segments[0] === "accounts" && segments[1] && w.accounts.get(decode(segments[1]))?.deletedAt !== void 0) return error("account_deleted");
2273
- for (const route of table) {
2274
- const params = match(route, req.method, segments);
2275
- if (params) return route.handle({
2276
- ...req,
2277
- params,
2278
- auth,
2279
- actorRef,
2280
- error
2281
- });
2282
- }
2283
- return error("not_found");
2284
- };
2285
- const guarded = () => {
2286
- try {
2287
- return dispatch();
2288
- } catch (cause) {
2289
- config.logger.error(`sahte steward ${req.method} ${path}: ${cause instanceof Error ? cause.message : String(cause)}`);
2290
- return error("internal_error");
2291
- }
2292
- };
2293
- if (!MUTATING.has(req.method)) return guarded();
2294
- const key = req.headers.get(HEADER_IDEMPOTENCY_KEY);
2295
- if (!key) return error("idempotency_key_required", `${HEADER_IDEMPOTENCY_KEY} başlığı zorunlu`);
2296
- if (!KEY_RE.test(key)) return error("invalid_idempotency_key");
2297
- const hash = `${req.method} ${path}${req.url.search}\n${req.body}`;
2298
- const existing = idempotency.get(key);
2299
- if (existing) {
2300
- if (existing.hash !== hash) return error("idempotency_key_reused", "bu anahtar farklı bir istekte kullanıldı");
2301
- if (existing.status === null) return error("request_in_progress");
2302
- const body = existing.status === 204 ? null : existing.body;
2303
- return {
2304
- status: existing.status,
2305
- body,
2306
- headers: {
2307
- "content-type": "application/json",
2308
- "idempotent-replayed": "true"
2309
- }
2310
- };
2311
- }
2312
- idempotency.set(key, {
2313
- hash,
2314
- status: null,
2315
- body: ""
2316
- });
2317
- const result = guarded();
2318
- if (result.status < 200 || result.status >= 300) idempotency.delete(key);
2319
- else {
2320
- const text = typeof result.body === "string" ? result.body : "";
2321
- idempotency.set(key, {
2322
- hash,
2323
- status: result.status,
2324
- body: text
2325
- });
2326
- }
2327
- if (req.method === "DELETE" && result.status === 200 && segments[0] === "accounts" && segments.length === 2) {
2328
- const deletedRef = decode(segments[1]);
2329
- for (const [stored, entry] of idempotency) if (stored !== key && entry.body.includes(`"ref":"${deletedRef}"`) || entry.body.includes(`"accountRef":"${deletedRef}"`)) idempotency.delete(stored);
2330
- }
2331
- return result;
2332
- }
2333
- }
2334
- //#endregion
2335
- //#region src/testing/portal.ts
2336
- const PORTAL_DONE = [
2337
- "canceled",
2338
- "retry_requested",
2339
- "profile_saved"
2340
- ];
2341
- const PORTAL_ERRORS = [
2342
- "provider_error",
2343
- "cancel_not_allowed",
2344
- "retry_not_allowed",
2345
- "no_subscription",
2346
- "already_canceled",
2347
- "nothing_to_retry",
2348
- "retry_too_soon"
2349
- ];
2350
- /** Domain nedeni → bildirim kodu (servisin `notAllowedError` eşlemesi). */
2351
- const REASON_FLASH = {
2352
- no_subscription: "no_subscription",
2353
- already_scheduled: "already_canceled",
2354
- nothing_to_retry: "nothing_to_retry",
2355
- no_failed_payment: "nothing_to_retry",
2356
- retry_too_soon: "retry_too_soon"
2357
- };
2358
- const DONE_TEXT = {
2359
- canceled: "Aboneliğiniz dönem sonunda iptal edilecek.",
2360
- retry_requested: "Ödeme yeniden deneniyor; sonuç birazdan hesabınıza yansır.",
2361
- profile_saved: "Fatura bilgileriniz kaydedildi."
2362
- };
2363
- const ERROR_TEXT = {
2364
- provider_error: "Ödeme sağlayıcısına ulaşılamadı.",
2365
- cancel_not_allowed: "Abonelik şu anda iptal edilemiyor.",
2366
- retry_not_allowed: "Ödeme şu anda yeniden denenemiyor.",
2367
- no_subscription: "Aktif aboneliğiniz yok.",
2368
- already_canceled: "Aboneliğiniz zaten dönem sonunda iptal edilecek.",
2369
- nothing_to_retry: "Yeniden denenecek bir ödeme yok.",
2370
- retry_too_soon: "Ödeme az önce yeniden denendi; birkaç dakika sonra tekrar deneyin."
2371
- };
2372
- const HEADERS = {
2373
- "cache-control": "no-store",
2374
- "referrer-policy": "no-referrer"
2375
- };
2376
- const htmlResponse = (status, title, body) => new Response(`<!doctype html><html lang="tr"><meta charset="utf-8"><title>${escapeHtml(title)}</title><body>${body}</body></html>`, {
2377
- status,
2378
- headers: {
2379
- ...HEADERS,
2380
- "content-type": "text/html; charset=utf-8"
2381
- }
2382
- });
2383
- const notFound = () => htmlResponse(404, "Sayfa bulunamadı", "<h1>Sayfa bulunamadı</h1><p>Bu portal bağlantısı geçersiz ya da süresi dolmuş.</p>");
2384
- const redirect = (location) => new Response(null, {
2385
- status: 303,
2386
- headers: {
2387
- ...HEADERS,
2388
- location
2389
- }
2390
- });
2391
- const PATH = /^\/public\/v1\/portal\/([^/]+)(?:\/(cancel|retry-payment|profile)|\/invoices\/([^/]+)\/document)?$/;
2392
- const day = (ms) => ms === null ? "—" : iso(ms).slice(0, 10);
2393
- const PROFILE_FIELDS = [
2394
- "kind",
2395
- "contactName",
2396
- "email",
2397
- "phone",
2398
- "address",
2399
- "city",
2400
- "companyName",
2401
- "taxId",
2402
- "taxOffice"
2403
- ];
2404
- const FIELD_LABELS = {
2405
- contactName: "Ad soyad",
2406
- email: "E-posta",
2407
- phone: "Telefon",
2408
- address: "Adres",
2409
- city: "Şehir",
2410
- companyName: "Şirket unvanı",
2411
- taxId: "Vergi kimlik numarası",
2412
- taxOffice: "Vergi dairesi"
2413
- };
2414
- /** Servisin `normalizePhone`'u (form.ts): boşluk/parantez/tire atılır, TR yazımları E.164'e. */
2415
- function normalizePhone(input) {
2416
- const compact = input.replace(/[\s().-]/g, "");
2417
- if (/^\+\d+$/.test(compact)) return compact;
2418
- if (/^00\d+$/.test(compact)) return `+${compact.slice(2)}`;
2419
- if (/^0\d{10}$/.test(compact)) return `+90${compact.slice(1)}`;
2420
- if (/^5\d{9}$/.test(compact)) return `+90${compact}`;
2421
- if (/^90\d{10}$/.test(compact)) return `+${compact}`;
2422
- return compact;
2423
- }
2424
- /**
2425
- * Servisin `buildProfile`'ı (portal-form.ts): sayfa yalnızca türü ve serbest metinde `<`/`>`
2426
- * denetler (bu hatalar domain'e gitmez); değerler kırpılır, telefon normalleşir, VKN'deki
2427
- * boşluklar atılır. Asıl doğrulama `ops.updatePortalProfile`'da.
2428
- */
2429
- function buildProfile(values) {
2430
- const fieldErrors = {};
2431
- const kind = values.kind === "individual" || values.kind === "company" ? values.kind : null;
2432
- if (!kind) fieldErrors["profile.kind"] = "invalid_kind";
2433
- const textFields = kind === "company" ? [
2434
- "contactName",
2435
- "address",
2436
- "city",
2437
- "companyName",
2438
- "taxOffice"
2439
- ] : [
2440
- "contactName",
2441
- "address",
2442
- "city"
2443
- ];
2444
- for (const field of textFields) if (/[<>]/.test(values[field])) fieldErrors[`profile.${field}`] = "invalid_characters";
2445
- if (!kind || Object.keys(fieldErrors).length > 0) return {
2446
- ok: false,
2447
- fieldErrors
2448
- };
2449
- return {
2450
- ok: true,
2451
- profile: {
2452
- kind,
2453
- contactName: values.contactName.trim(),
2454
- email: values.email.trim(),
2455
- phone: normalizePhone(values.phone),
2456
- address: values.address.trim(),
2457
- city: values.city.trim(),
2458
- ...kind === "company" ? {
2459
- companyName: values.companyName.trim(),
2460
- taxId: values.taxId.replace(/\s/g, ""),
2461
- taxOffice: values.taxOffice.trim()
2462
- } : {}
2463
- }
2464
- };
2465
- }
2466
- function readProfileValues(form) {
2467
- return Object.fromEntries(PROFILE_FIELDS.map((field) => [field, (form.get(field) ?? "").slice(0, 1e3)]));
2468
- }
2469
- function storedValues(view) {
2470
- const p = view.account.profile;
2471
- return {
2472
- kind: p?.kind ?? "individual",
2473
- contactName: p?.contactName ?? "",
2474
- email: p?.email ?? "",
2475
- phone: p?.phone ?? "",
2476
- address: p?.address ?? "",
2477
- city: p?.city ?? "",
2478
- companyName: p?.companyName ?? "",
2479
- taxId: p?.taxId ?? "",
2480
- taxOffice: p?.taxOffice ?? ""
2481
- };
2482
- }
2483
- /** `/public/v1/portal/*` istekleri (kimliksiz). Eşleşmeyen yol ve yöntem 404. */
2484
- function handlePortal(w, req, options) {
2485
- const match = PATH.exec(req.url.pathname);
2486
- if (!match) return notFound();
2487
- let id;
2488
- let invoiceId;
2489
- try {
2490
- id = decodeURIComponent(match[1]);
2491
- invoiceId = match[3] === void 0 ? void 0 : decodeURIComponent(match[3]);
2492
- } catch {
2493
- return notFound();
2494
- }
2495
- const action = match[2];
2496
- const path = `${options.baseUrl}/public/v1/portal/${encodeURIComponent(id)}`;
2497
- const pageUrl = (k, flash = {}) => `${path}?k=${encodeURIComponent(k)}${flash.done ? `&done=${flash.done}` : flash.error ? `&error=${flash.error}` : ""}`;
2498
- if (action === void 0) {
2499
- if (req.method !== "GET") return notFound();
2500
- const k = req.url.searchParams.get("k") ?? "";
2501
- const session = openPortalSession(w, id, k);
2502
- if (!session) return notFound();
2503
- if (invoiceId !== void 0) {
2504
- const document = portalInvoiceDocument(w, session, invoiceId);
2505
- if (!document) return notFound();
2506
- return new Response(new Uint8Array(document.bytes), {
2507
- status: 200,
2508
- headers: {
2509
- ...HEADERS,
2510
- "content-type": "application/pdf",
2511
- "content-disposition": `attachment; filename="${document.filename}"`,
2512
- "content-length": String(document.bytes.byteLength),
2513
- "x-content-type-options": "nosniff"
2514
- }
2515
- });
2516
- }
2517
- const doneParam = req.url.searchParams.get("done") ?? void 0;
2518
- const done = PORTAL_DONE.includes(doneParam ?? "") ? doneParam : null;
2519
- const errorParam = req.url.searchParams.get("error") ?? void 0;
2520
- const error = !done && PORTAL_ERRORS.includes(errorParam ?? "") ? errorParam : null;
2521
- const view = portalView(w, session);
2522
- return portalPage(view, path, k, {
2523
- done,
2524
- error,
2525
- values: storedValues(view),
2526
- fieldErrors: {}
2527
- }, 200);
2528
- }
2529
- if (req.method !== "POST") return notFound();
2530
- const form = new URLSearchParams(req.body);
2531
- const k = (form.get("k") ?? "").slice(0, 1e3);
2532
- const session = openPortalSession(w, id, k);
2533
- if (!session) return notFound();
2534
- switch (action) {
2535
- case "cancel": {
2536
- if (form.get("confirm") !== "1") {
2537
- const sub = liveSubscription(w, session.accountRef);
2538
- if (!sub) return redirect(pageUrl(k, { error: "no_subscription" }));
2539
- if (sub.cancelAtPeriodEnd) return redirect(pageUrl(k, { error: "already_canceled" }));
2540
- return confirmPage(portalView(w, session), path, k);
2541
- }
2542
- const result = portalCancel(w, session);
2543
- return redirect(pageUrl(k, result === "ok" ? { done: "canceled" } : { error: REASON_FLASH[result] }));
2544
- }
2545
- case "retry-payment": {
2546
- const result = portalRetryPayment(w, session);
2547
- return redirect(pageUrl(k, result === "ok" ? { done: "retry_requested" } : { error: REASON_FLASH[result] }));
2548
- }
2549
- case "profile": {
2550
- const values = readProfileValues(form);
2551
- const built = buildProfile(values);
2552
- const result = built.ok ? updatePortalProfile(w, session, built.profile) : built;
2553
- if (result.ok) return redirect(pageUrl(k, { done: "profile_saved" }));
2554
- return portalPage(portalView(w, session), path, k, {
2555
- done: null,
2556
- error: null,
2557
- values,
2558
- fieldErrors: result.fieldErrors
2559
- }, 422);
2560
- }
2561
- default: return notFound();
2562
- }
2563
- }
2564
- function portalPage(view, path, k, state, status) {
2565
- const key = `<input type="hidden" name="k" value="${escapeHtml(k)}">`;
2566
- const { state: account, subscription: sub } = view;
2567
- const flash = state.done ? `<p role="status" data-done="${state.done}">${escapeHtml(DONE_TEXT[state.done])}</p>` : state.error ? `<p role="alert" data-error="${state.error}">${escapeHtml(ERROR_TEXT[state.error])}</p>` : "";
2568
- const subscription = sub ? `<p>Abonelik: <strong>${escapeHtml(sub.planName)}</strong> (${escapeHtml(sub.price.planCode)}) · ${escapeHtml(sub.price.interval)} · ${sub.price.amountMinor} ${escapeHtml(sub.price.currency)} (kuruş) · durum: ${escapeHtml(sub.row.status)}</p>
2569
- <p>Dönem: ${day(sub.row.currentPeriodStart)} – ${day(sub.row.currentPeriodEnd)}</p>
2570
- ${sub.row.cancelAtPeriodEnd ? `<p>Dönem sonunda iptal edilecek: ${day(sub.row.cancelAt ?? sub.row.currentPeriodEnd)}</p>` : ""}
2571
- ${sub.canRetryPayment ? `<form method="post" action="${escapeHtml(path)}/retry-payment">${key}<button>Ödemeyi yeniden dene</button></form>` : ""}
2572
- ${sub.canCancel ? `<form method="post" action="${escapeHtml(path)}/cancel">${key}<button>Dönem sonunda iptal</button></form>` : ""}` : "<p>Aktif aboneliğiniz yok.</p>";
2573
- const invoices = view.invoices.length ? `<ul>${view.invoices.map((invoice) => {
2574
- const link = invoice.status === "issued" && invoice.document ? ` <a href="${escapeHtml(`${path}/invoices/${encodeURIComponent(invoice.id)}/document?k=${encodeURIComponent(k)}`)}" download>İndir</a>` : "";
2575
- return `<li>${escapeHtml(invoice.number ?? "(numarasız)")} · ${day(invoice.issuedAt ?? invoice.createdAt)} · ${invoice.totalMinor} ${escapeHtml(invoice.currency)} · ${escapeHtml(invoice.status)}${link}</li>`;
2576
- }).join("")}</ul>` : "<p>Fatura yok.</p>";
2577
- const errors = Object.entries(state.fieldErrors);
2578
- const fieldError = (field) => state.fieldErrors[`profile.${field}`] ? ` <small data-field-error="profile.${field}">${escapeHtml(state.fieldErrors[`profile.${field}`])}</small>` : "";
2579
- const input = (field) => `<label>${FIELD_LABELS[field]} <input name="${field}" value="${escapeHtml(state.values[field])}"></label>${fieldError(field)}<br>`;
2580
- const radio = (value, label) => `<label><input type="radio" name="kind" value="${value}"${state.values.kind === value ? " checked" : ""}> ${label}</label>`;
2581
- const profile = `<form method="post" action="${escapeHtml(path)}/profile">${key}
2582
- ${errors.length ? `<ul role="alert">${errors.map(([field, code]) => `<li data-field="${escapeHtml(field)}">${escapeHtml(field)}: ${escapeHtml(code)}</li>`).join("")}</ul>` : ""}
2583
- <p>${radio("individual", "Bireysel")} ${radio("company", "Kurumsal")}${fieldError("kind")}</p>
2584
- ${[
2585
- "contactName",
2586
- "email",
2587
- "phone",
2588
- "address",
2589
- "city",
2590
- "companyName",
2591
- "taxId",
2592
- "taxOffice"
2593
- ].map(input).join("\n")}
2594
- <button>Kaydet</button></form>`;
2595
- const back = view.returnUrl && /^https?:\/\//.test(view.returnUrl) ? `<p><a href="${escapeHtml(view.returnUrl)}" rel="noreferrer">Ürüne dön</a></p>` : "";
2596
- return htmlResponse(status, "Sahte steward müşteri portalı", `${back}<h1>Sahte steward müşteri portalı</h1>
2597
- ${flash}
2598
- <p>Hesap: ${escapeHtml(account.ref)} · ${escapeHtml(account.displayName)} · dil: ${escapeHtml(view.session.locale)}</p>
2599
- <p>Plan: <strong>${escapeHtml(view.planName)}</strong> (${escapeHtml(account.planCode)}) · erişim: ${escapeHtml(account.accessState)}</p>
2600
- <h2>Abonelik</h2>
2601
- ${subscription}
2602
- <h2>Faturalar</h2>
2603
- ${invoices}
2604
- <h2>Fatura bilgileri</h2>
2605
- ${profile}
2606
- <p><small>Bağlantı ${iso(view.session.expiresAt)} tarihine kadar geçerli.</small></p>`);
2607
- }
2608
- /** İptalin ikinci adımı: `confirm=1` ile aynı rotaya gönderir. */
2609
- function confirmPage(view, path, k) {
2610
- const sub = view.subscription;
2611
- return htmlResponse(200, "Aboneliği iptal et", `<h1>Aboneliği iptal et</h1>
2612
- <p>${escapeHtml(sub?.planName ?? view.planName)} aboneliğiniz dönem sonunda (${day(sub?.row.currentPeriodEnd ?? null)}) sona erecek; o zamana kadar erişiminiz sürer.</p>
2613
- <form method="post" action="${escapeHtml(path)}/cancel"><input type="hidden" name="k" value="${escapeHtml(k)}"><input type="hidden" name="confirm" value="1"><button>İptali onayla</button></form>
2614
- <p><a href="${escapeHtml(`${path}?k=${encodeURIComponent(k)}`)}">Vazgeç</a></p>`);
2615
- }
2616
- //#endregion
2617
- //#region src/testing/signed-event.ts
2618
- const FIXTURES = {
2619
- "account.updated": {
2620
- id: "evt_0192f1a0-0000-7000-8000-000000000010",
2621
- type: "account.updated",
2622
- apiVersion: "2026-10-01",
2623
- createdAt: "2026-10-01T09:00:00Z",
2624
- product: "example",
2625
- accountRef: "acct_42",
2626
- data: {
2627
- "account": {
2628
- "version": 3,
2629
- "planCode": "team",
2630
- "accessState": "active",
2631
- "entitlements": {
2632
- "max_users": null,
2633
- "max_devices": 250,
2634
- "max_networks_per_account": 3,
2635
- "max_approved_routes": 10,
2636
- "max_exit_nodes": 1
2637
- },
2638
- "subscription": {
2639
- "id": "0192f1a0-0000-7000-8000-000000000001",
2640
- "status": "active",
2641
- "planCode": "team",
2642
- "interval": "month",
2643
- "amountMinor": 125e3,
2644
- "currency": "TRY",
2645
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2646
- "cancelAt": null
2647
- }
2648
- },
2649
- "detail": { "changed": ["entitlements"] }
2650
- }
2651
- },
2652
- "checkout.completed": {
2653
- id: "evt_0192f1a0-0000-7000-8000-000000000011",
2654
- type: "checkout.completed",
2655
- apiVersion: "2026-10-01",
2656
- createdAt: "2026-10-01T09:00:00Z",
2657
- product: "example",
2658
- accountRef: "acct_42",
2659
- data: {
2660
- "account": {
2661
- "version": 1,
2662
- "planCode": "team",
2663
- "accessState": "active",
2664
- "entitlements": {
2665
- "max_users": null,
2666
- "max_devices": 250,
2667
- "max_networks_per_account": 3,
2668
- "max_approved_routes": 10,
2669
- "max_exit_nodes": 1
2670
- },
2671
- "subscription": {
2672
- "id": "0192f1a0-0000-7000-8000-000000000001",
2673
- "status": "active",
2674
- "planCode": "team",
2675
- "interval": "month",
2676
- "amountMinor": 125e3,
2677
- "currency": "TRY",
2678
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2679
- "cancelAt": null
2680
- }
2681
- },
2682
- "detail": {
2683
- "checkoutSessionId": "0192f1a0-0000-7000-8000-000000000004",
2684
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001"
2685
- }
2686
- }
2687
- },
2688
- "checkout.failed": {
2689
- id: "evt_0192f1a0-0000-7000-8000-000000000012",
2690
- type: "checkout.failed",
2691
- apiVersion: "2026-10-01",
2692
- createdAt: "2026-10-01T09:00:00Z",
2693
- product: "example",
2694
- accountRef: "acct_42",
2695
- data: {
2696
- "account": {
2697
- "version": 0,
2698
- "planCode": "free",
2699
- "accessState": "active",
2700
- "entitlements": {
2701
- "max_users": 3,
2702
- "max_devices": 15,
2703
- "max_networks_per_account": 1,
2704
- "max_approved_routes": 1,
2705
- "max_exit_nodes": 0
2706
- },
2707
- "subscription": null
2708
- },
2709
- "detail": {
2710
- "checkoutSessionId": "0192f1a0-0000-7000-8000-000000000004",
2711
- "reason": "provider:card_declined"
2712
- }
2713
- }
2714
- },
2715
- "checkout.expired": {
2716
- id: "evt_0192f1a0-0000-7000-8000-000000000013",
2717
- type: "checkout.expired",
2718
- apiVersion: "2026-10-01",
2719
- createdAt: "2026-10-01T09:00:00Z",
2720
- product: "example",
2721
- accountRef: "acct_42",
2722
- data: {
2723
- "account": {
2724
- "version": 0,
2725
- "planCode": "free",
2726
- "accessState": "active",
2727
- "entitlements": {
2728
- "max_users": 3,
2729
- "max_devices": 15,
2730
- "max_networks_per_account": 1,
2731
- "max_approved_routes": 1,
2732
- "max_exit_nodes": 0
2733
- },
2734
- "subscription": null
2735
- },
2736
- "detail": { "checkoutSessionId": "0192f1a0-0000-7000-8000-000000000004" }
2737
- }
2738
- },
2739
- "subscription.activated": {
2740
- id: "evt_0192f1a0-0000-7000-8000-000000000014",
2741
- type: "subscription.activated",
2742
- apiVersion: "2026-10-01",
2743
- createdAt: "2026-10-01T09:00:00Z",
2744
- product: "example",
2745
- accountRef: "acct_42",
2746
- data: {
2747
- "account": {
2748
- "version": 1,
2749
- "planCode": "team",
2750
- "accessState": "active",
2751
- "entitlements": {
2752
- "max_users": null,
2753
- "max_devices": 250,
2754
- "max_networks_per_account": 3,
2755
- "max_approved_routes": 10,
2756
- "max_exit_nodes": 1
2757
- },
2758
- "subscription": {
2759
- "id": "0192f1a0-0000-7000-8000-000000000001",
2760
- "status": "active",
2761
- "planCode": "team",
2762
- "interval": "month",
2763
- "amountMinor": 125e3,
2764
- "currency": "TRY",
2765
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2766
- "cancelAt": null
2767
- }
2768
- },
2769
- "detail": {
2770
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2771
- "planCode": "team",
2772
- "interval": "month",
2773
- "amountMinor": 125e3,
2774
- "currency": "TRY",
2775
- "actorRef": "user:42"
2776
- }
2777
- }
2778
- },
2779
- "subscription.renewed": {
2780
- id: "evt_0192f1a0-0000-7000-8000-000000000015",
2781
- type: "subscription.renewed",
2782
- apiVersion: "2026-10-01",
2783
- createdAt: "2026-10-01T09:00:00Z",
2784
- product: "example",
2785
- accountRef: "acct_42",
2786
- data: {
2787
- "account": {
2788
- "version": 2,
2789
- "planCode": "team",
2790
- "accessState": "active",
2791
- "entitlements": {
2792
- "max_users": null,
2793
- "max_devices": 250,
2794
- "max_networks_per_account": 3,
2795
- "max_approved_routes": 10,
2796
- "max_exit_nodes": 1
2797
- },
2798
- "subscription": {
2799
- "id": "0192f1a0-0000-7000-8000-000000000001",
2800
- "status": "active",
2801
- "planCode": "team",
2802
- "interval": "month",
2803
- "amountMinor": 125e3,
2804
- "currency": "TRY",
2805
- "currentPeriodEnd": "2026-12-01T09:00:00Z",
2806
- "cancelAt": null
2807
- }
2808
- },
2809
- "detail": {
2810
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2811
- "paymentId": "0192f1a0-0000-7000-8000-000000000002",
2812
- "periodStart": "2026-11-01T09:00:00Z",
2813
- "periodEnd": "2026-12-01T09:00:00Z",
2814
- "invoiceId": "0192f1a0-0000-7000-8000-000000000003"
2815
- }
2816
- }
2817
- },
2818
- "subscription.payment_failed": {
2819
- id: "evt_0192f1a0-0000-7000-8000-000000000016",
2820
- type: "subscription.payment_failed",
2821
- apiVersion: "2026-10-01",
2822
- createdAt: "2026-10-01T09:00:00Z",
2823
- product: "example",
2824
- accountRef: "acct_42",
2825
- data: {
2826
- "account": {
2827
- "version": 2,
2828
- "planCode": "team",
2829
- "accessState": "active",
2830
- "entitlements": {
2831
- "max_users": null,
2832
- "max_devices": 250,
2833
- "max_networks_per_account": 3,
2834
- "max_approved_routes": 10,
2835
- "max_exit_nodes": 1
2836
- },
2837
- "subscription": {
2838
- "id": "0192f1a0-0000-7000-8000-000000000001",
2839
- "status": "past_due",
2840
- "planCode": "team",
2841
- "interval": "month",
2842
- "amountMinor": 125e3,
2843
- "currency": "TRY",
2844
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2845
- "cancelAt": null
2846
- }
2847
- },
2848
- "detail": {
2849
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2850
- "paymentId": "0192f1a0-0000-7000-8000-000000000002",
2851
- "attempt": 1,
2852
- "nextRetryAt": "2026-11-02T09:00:00Z"
2853
- }
2854
- }
2855
- },
2856
- "subscription.suspended": {
2857
- id: "evt_0192f1a0-0000-7000-8000-000000000017",
2858
- type: "subscription.suspended",
2859
- apiVersion: "2026-10-01",
2860
- createdAt: "2026-10-01T09:00:00Z",
2861
- product: "example",
2862
- accountRef: "acct_42",
2863
- data: {
2864
- "account": {
2865
- "version": 3,
2866
- "planCode": "team",
2867
- "accessState": "suspended",
2868
- "entitlements": {
2869
- "max_users": null,
2870
- "max_devices": 250,
2871
- "max_networks_per_account": 3,
2872
- "max_approved_routes": 10,
2873
- "max_exit_nodes": 1
2874
- },
2875
- "subscription": {
2876
- "id": "0192f1a0-0000-7000-8000-000000000001",
2877
- "status": "suspended",
2878
- "planCode": "team",
2879
- "interval": "month",
2880
- "amountMinor": 125e3,
2881
- "currency": "TRY",
2882
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2883
- "cancelAt": null
2884
- }
2885
- },
2886
- "detail": {
2887
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2888
- "dunningDay": 7
2889
- }
2890
- }
2891
- },
2892
- "subscription.reactivated": {
2893
- id: "evt_0192f1a0-0000-7000-8000-000000000018",
2894
- type: "subscription.reactivated",
2895
- apiVersion: "2026-10-01",
2896
- createdAt: "2026-10-01T09:00:00Z",
2897
- product: "example",
2898
- accountRef: "acct_42",
2899
- data: {
2900
- "account": {
2901
- "version": 4,
2902
- "planCode": "team",
2903
- "accessState": "active",
2904
- "entitlements": {
2905
- "max_users": null,
2906
- "max_devices": 250,
2907
- "max_networks_per_account": 3,
2908
- "max_approved_routes": 10,
2909
- "max_exit_nodes": 1
2910
- },
2911
- "subscription": {
2912
- "id": "0192f1a0-0000-7000-8000-000000000001",
2913
- "status": "active",
2914
- "planCode": "team",
2915
- "interval": "month",
2916
- "amountMinor": 125e3,
2917
- "currency": "TRY",
2918
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2919
- "cancelAt": null
2920
- }
2921
- },
2922
- "detail": {
2923
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2924
- "paymentId": "0192f1a0-0000-7000-8000-000000000002"
2925
- }
2926
- }
2927
- },
2928
- "subscription.cancel_scheduled": {
2929
- id: "evt_0192f1a0-0000-7000-8000-000000000019",
2930
- type: "subscription.cancel_scheduled",
2931
- apiVersion: "2026-10-01",
2932
- createdAt: "2026-10-01T09:00:00Z",
2933
- product: "example",
2934
- accountRef: "acct_42",
2935
- data: {
2936
- "account": {
2937
- "version": 2,
2938
- "planCode": "team",
2939
- "accessState": "active",
2940
- "entitlements": {
2941
- "max_users": null,
2942
- "max_devices": 250,
2943
- "max_networks_per_account": 3,
2944
- "max_approved_routes": 10,
2945
- "max_exit_nodes": 1
2946
- },
2947
- "subscription": {
2948
- "id": "0192f1a0-0000-7000-8000-000000000001",
2949
- "status": "active",
2950
- "planCode": "team",
2951
- "interval": "month",
2952
- "amountMinor": 125e3,
2953
- "currency": "TRY",
2954
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
2955
- "cancelAt": "2026-11-01T09:00:00Z"
2956
- }
2957
- },
2958
- "detail": {
2959
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2960
- "cancelAt": "2026-11-01T09:00:00Z",
2961
- "reason": "müşteri talebi",
2962
- "actorRef": "staff:7"
2963
- }
2964
- }
2965
- },
2966
- "subscription.canceled": {
2967
- id: "evt_0192f1a0-0000-7000-8000-00000000001a",
2968
- type: "subscription.canceled",
2969
- apiVersion: "2026-10-01",
2970
- createdAt: "2026-10-01T09:00:00Z",
2971
- product: "example",
2972
- accountRef: "acct_42",
2973
- data: {
2974
- "account": {
2975
- "version": 2,
2976
- "planCode": "free",
2977
- "accessState": "active",
2978
- "entitlements": {
2979
- "max_users": 3,
2980
- "max_devices": 15,
2981
- "max_networks_per_account": 1,
2982
- "max_approved_routes": 1,
2983
- "max_exit_nodes": 0
2984
- },
2985
- "subscription": null
2986
- },
2987
- "detail": {
2988
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
2989
- "reason": "müşteri talebi",
2990
- "effectiveAt": "2026-10-15T12:00:00Z",
2991
- "actorRef": "staff:7"
2992
- }
2993
- }
2994
- },
2995
- "subscription.expired": {
2996
- id: "evt_0192f1a0-0000-7000-8000-00000000001b",
2997
- type: "subscription.expired",
2998
- apiVersion: "2026-10-01",
2999
- createdAt: "2026-10-01T09:00:00Z",
3000
- product: "example",
3001
- accountRef: "acct_42",
3002
- data: {
3003
- "account": {
3004
- "version": 3,
3005
- "planCode": "free",
3006
- "accessState": "active",
3007
- "entitlements": {
3008
- "max_users": 3,
3009
- "max_devices": 15,
3010
- "max_networks_per_account": 1,
3011
- "max_approved_routes": 1,
3012
- "max_exit_nodes": 0
3013
- },
3014
- "subscription": null
3015
- },
3016
- "detail": {
3017
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
3018
- "effectiveAt": "2026-11-01T09:00:00Z"
3019
- }
3020
- }
3021
- },
3022
- "subscription.terminated": {
3023
- id: "evt_0192f1a0-0000-7000-8000-00000000001c",
3024
- type: "subscription.terminated",
3025
- apiVersion: "2026-10-01",
3026
- createdAt: "2026-10-01T09:00:00Z",
3027
- product: "example",
3028
- accountRef: "acct_42",
3029
- data: {
3030
- "account": {
3031
- "version": 5,
3032
- "planCode": "free",
3033
- "accessState": "active",
3034
- "entitlements": {
3035
- "max_users": 3,
3036
- "max_devices": 15,
3037
- "max_networks_per_account": 1,
3038
- "max_approved_routes": 1,
3039
- "max_exit_nodes": 0
3040
- },
3041
- "subscription": null
3042
- },
3043
- "detail": {
3044
- "subscriptionId": "0192f1a0-0000-7000-8000-000000000001",
3045
- "reason": "nonpayment",
3046
- "dunningDay": 30
3047
- }
3048
- }
3049
- },
3050
- "invoice.created": {
3051
- id: "evt_0192f1a0-0000-7000-8000-00000000001d",
3052
- type: "invoice.created",
3053
- apiVersion: "2026-10-01",
3054
- createdAt: "2026-10-01T09:00:00Z",
3055
- product: "example",
3056
- accountRef: "acct_42",
3057
- data: {
3058
- "account": {
3059
- "version": 2,
3060
- "planCode": "team",
3061
- "accessState": "active",
3062
- "entitlements": {
3063
- "max_users": null,
3064
- "max_devices": 250,
3065
- "max_networks_per_account": 3,
3066
- "max_approved_routes": 10,
3067
- "max_exit_nodes": 1
3068
- },
3069
- "subscription": {
3070
- "id": "0192f1a0-0000-7000-8000-000000000001",
3071
- "status": "active",
3072
- "planCode": "team",
3073
- "interval": "month",
3074
- "amountMinor": 125e3,
3075
- "currency": "TRY",
3076
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
3077
- "cancelAt": null
3078
- }
3079
- },
3080
- "detail": {
3081
- "invoiceId": "0192f1a0-0000-7000-8000-000000000003",
3082
- "totalMinor": 125e3,
3083
- "currency": "TRY"
3084
- }
3085
- }
3086
- },
3087
- "invoice.issued": {
3088
- id: "evt_0192f1a0-0000-7000-8000-00000000001e",
3089
- type: "invoice.issued",
3090
- apiVersion: "2026-10-01",
3091
- createdAt: "2026-10-01T09:00:00Z",
3092
- product: "example",
3093
- accountRef: "acct_42",
3094
- data: {
3095
- "account": {
3096
- "version": 2,
3097
- "planCode": "team",
3098
- "accessState": "active",
3099
- "entitlements": {
3100
- "max_users": null,
3101
- "max_devices": 250,
3102
- "max_networks_per_account": 3,
3103
- "max_approved_routes": 10,
3104
- "max_exit_nodes": 1
3105
- },
3106
- "subscription": {
3107
- "id": "0192f1a0-0000-7000-8000-000000000001",
3108
- "status": "active",
3109
- "planCode": "team",
3110
- "interval": "month",
3111
- "amountMinor": 125e3,
3112
- "currency": "TRY",
3113
- "currentPeriodEnd": "2026-11-01T09:00:00Z",
3114
- "cancelAt": null
3115
- }
3116
- },
3117
- "detail": {
3118
- "invoiceId": "0192f1a0-0000-7000-8000-000000000003",
3119
- "number": "NXS2026000000123",
3120
- "totalMinor": 125e3,
3121
- "currency": "TRY"
3122
- }
3123
- }
3124
- },
3125
- "invoice.voided": {
3126
- id: "evt_0192f1a0-0000-7000-8000-000000000f4f",
3127
- type: "invoice.voided",
3128
- apiVersion: "2026-10-01",
3129
- createdAt: "2026-10-01T09:00:00Z",
3130
- product: "example",
3131
- accountRef: "acct_42",
3132
- data: {
3133
- "account": {
3134
- "ref": "acct_42",
3135
- "displayName": "Örnek A.Ş.",
3136
- "version": 4,
3137
- "updatedAt": "2026-09-20T09:00:00Z",
3138
- "planCode": "enterprise",
3139
- "accessState": "active",
3140
- "entitlements": {
3141
- "max_users": null,
3142
- "max_devices": null,
3143
- "max_networks_per_account": 20,
3144
- "max_approved_routes": 100,
3145
- "max_exit_nodes": 10
3146
- },
3147
- "catalogVersion": "3f2a9c0e7b41d8a65c09e1f4b7d2a8c3",
3148
- "subscription": null,
3149
- "openCheckout": null,
3150
- "profile": {
3151
- "present": true,
3152
- "kind": "company"
3153
- }
3154
- },
3155
- "detail": {
3156
- "invoiceId": "0192f1a0-0000-7000-8000-000000000004",
3157
- "number": "NXS2026000000124",
3158
- "totalMinor": 6e6,
3159
- "currency": "TRY",
3160
- "reason": "sözleşme yeniden düzenlendi"
3161
- }
3162
- }
3163
- },
3164
- "account.state_changed": {
3165
- id: "evt_0192f1a0-0000-7000-8000-000000000020",
3166
- type: "account.state_changed",
3167
- apiVersion: "2026-10-01",
3168
- createdAt: "2026-10-01T09:00:00Z",
3169
- product: "example",
3170
- accountRef: "acct_42",
3171
- data: {
3172
- "account": {
3173
- "ref": "acct_42",
3174
- "displayName": "Örnek A.Ş.",
3175
- "version": 2,
3176
- "updatedAt": "2026-10-01T09:00:00Z",
3177
- "planCode": "team",
3178
- "accessState": "active",
3179
- "entitlements": {
3180
- "max_users": null,
3181
- "max_devices": 250,
3182
- "max_networks_per_account": 3,
3183
- "max_approved_routes": 10,
3184
- "max_exit_nodes": 1
3185
- },
3186
- "catalogVersion": "3f2a9c0e7b41d8a65c09e1f4b7d2a8c3",
3187
- "subscription": {
3188
- "id": "0192f1a0-0000-7000-8000-000000000001",
3189
- "status": "past_due",
3190
- "planCode": "team",
3191
- "interval": "month",
3192
- "amountMinor": 125e3,
3193
- "currency": "TRY",
3194
- "currentPeriodEnd": "2026-10-01T09:00:00Z",
3195
- "cancelAt": null,
3196
- "cancelAtPeriodEnd": false,
3197
- "pastDueSince": "2026-10-01T09:00:00Z",
3198
- "currentPeriodStart": "2026-09-01T09:00:00Z",
3199
- "dunning": {
3200
- "step": 0,
3201
- "nextActionAt": "2026-10-02T09:00:00Z"
3202
- },
3203
- "scheduledChange": null
3204
- },
3205
- "openCheckout": null,
3206
- "profile": {
3207
- "present": true,
3208
- "kind": "company"
3209
- }
3210
- },
3211
- "detail": {
3212
- "cause": "subscription.payment_failed",
3213
- "causeEventId": "evt_0192f1a0-0000-7000-8000-000000000016"
3214
- }
3215
- }
3216
- },
3217
- "account.deleted": {
3218
- id: "evt_0192f1a0-0000-7000-8000-000000000030",
3219
- type: "account.deleted",
3220
- apiVersion: "2026-10-01",
3221
- createdAt: "2026-10-05T09:00:00Z",
3222
- product: "example",
3223
- accountRef: "acct_42",
3224
- data: {
3225
- "account": {
3226
- "ref": "acct_42",
3227
- "displayName": "",
3228
- "version": 9,
3229
- "updatedAt": "2026-10-05T09:00:00Z",
3230
- "planCode": "free",
3231
- "accessState": "active",
3232
- "entitlements": {
3233
- "max_users": 3,
3234
- "max_devices": 15,
3235
- "max_networks_per_account": 1,
3236
- "max_approved_routes": 0,
3237
- "max_exit_nodes": 0
3238
- },
3239
- "catalogVersion": "3f2a9c0e7b41d8a65c09e1f4b7d2a8c3",
3240
- "subscription": null,
3241
- "openCheckout": null,
3242
- "profile": {
3243
- "present": false,
3244
- "kind": null
3245
- }
3246
- },
3247
- "detail": {
3248
- "deletedAt": "2026-10-05T09:00:00Z",
3249
- "actorRef": "user:7"
3250
- }
3251
- }
3252
- }
3253
- };
3254
- /** Tipin fixture'ının derin kopyası. */
3255
- function eventFixture(type) {
3256
- if (!EVENT_TYPES.includes(type)) throw new Error(`bilinmeyen event tipi: ${String(type)}`);
3257
- return structuredClone(FIXTURES[type]);
3258
- }
3259
- const DEFAULT_WEBHOOK_URL = "http://product.test/steward/events";
3260
- const secretsOf = (secret) => typeof secret === "string" ? [secret] : [...secret];
3261
- /** Olayı steward'ın teslimat biçimiyle imzalar. */
3262
- function signBody(event, secrets, timestamp = Math.floor(Date.now() / 1e3)) {
3263
- const body = JSON.stringify(event);
3264
- const headers = {
3265
- ...signWebhook(secrets, event.id, body, timestamp),
3266
- "content-type": "application/json",
3267
- "user-agent": "steward/1"
3268
- };
3269
- return {
3270
- body,
3271
- headers,
3272
- request: (url = DEFAULT_WEBHOOK_URL) => new Request(url, {
3273
- method: "POST",
3274
- headers,
3275
- body
3276
- })
3277
- };
3278
- }
3279
- function signedEvent(type, overrides = {}, options = {}) {
3280
- const secret = options.secret ?? globalThis.process?.env?.STEWARD_WEBHOOK_SECRET;
3281
- const secrets = secret === void 0 ? [] : secretsOf(secret).map((s) => s.trim()).filter(Boolean);
3282
- if (secrets.length === 0) throw new Error("signedEvent: imza sırrı yok (options.secret ya da STEWARD_WEBHOOK_SECRET)");
3283
- const base = eventFixture(type);
3284
- const { data, ...envelope } = overrides;
3285
- const account = {
3286
- ...base.data.account,
3287
- ...data?.account
3288
- };
3289
- if (overrides.accountRef !== void 0 && "ref" in base.data.account && data?.account?.ref === void 0) account.ref = overrides.accountRef;
3290
- const event = {
3291
- ...base,
3292
- id: `evt_${globalThis.crypto.randomUUID()}`,
3293
- ...envelope,
3294
- data: {
3295
- account,
3296
- detail: {
3297
- ...base.data.detail,
3298
- ...data?.detail
3299
- }
3300
- }
3301
- };
3302
- return {
3303
- event,
3304
- ...signBody(event, secrets, options.timestamp)
3305
- };
3306
- }
3307
- //#endregion
3308
- //#region src/testing/fake-steward.ts
3309
- const RETRY_SCHEDULE_SECONDS = [
3310
- 30,
3311
- 120,
3312
- 600,
3313
- 1800,
3314
- 3600,
3315
- 10800,
3316
- 21600
3317
- ];
3318
- /** Yönetim ucunun (`admin.endpoints.create`) yeni endpoint'e varsayılan filtresi. */
3319
- const DEFAULT_EVENT_TYPES = ["account.state_changed"];
3320
- function base64url(bytes) {
3321
- return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
3322
- }
3323
- /** Servisin anahtar biçimi: `stw_<12 hex>_<43 base64url>`. */
3324
- function generateApiKey() {
3325
- const bytes = globalThis.crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(38));
3326
- return `stw_${Array.from(bytes.subarray(0, 6), (b) => b.toString(16).padStart(2, "0")).join("")}_${base64url(bytes.subarray(6))}`;
3327
- }
3328
- const networkError = (reason) => new TypeError("fetch failed", { cause: /* @__PURE__ */ new Error(`sahte steward: ${reason}`) });
3329
- /** `settings` + kısayol seçenekleri (`returnOrigins`, `checkoutTtlMinutes`) → sahtenin ayarları. */
3330
- function initialSettings(options) {
3331
- const patch = { ...options.settings };
3332
- const alias = (option, field) => {
3333
- if (options[option] === void 0) return;
3334
- if (patch[field] !== void 0) throw new Error(`createFakeSteward: ${option} ve settings.${field} birlikte verilemez`);
3335
- patch[field] = options[option];
3336
- };
3337
- alias("returnOrigins", "extraReturnOrigins");
3338
- alias("checkoutTtlMinutes", "checkoutTtlMinutes");
3339
- const parsed = ProductSettingsPatchSchema.safeParse(patch);
3340
- if (!parsed.success) throw new Error(`createFakeSteward: ayarlar geçersiz: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
3341
- return applySettingsPatch(ProductSettingsSchema.parse({ provider: "fake" }), parsed.data);
3342
- }
3343
- function createFakeSteward(options = {}) {
3344
- const base = new URL(options.baseUrl ?? "http://steward.test");
3345
- const basePath = base.pathname.replace(/\/$/, "");
3346
- const baseUrl = `${base.origin}${basePath}`;
3347
- const apiKey = options.apiKey ?? generateApiKey();
3348
- const adminApiKey = options.adminApiKey ?? generateApiKey();
3349
- if (apiKey === adminApiKey) throw new Error("createFakeSteward: apiKey ve adminApiKey farklı olmalı");
3350
- const webhook = options.webhook;
3351
- const w = createWorld({
3352
- productCode: options.productCode ?? "example",
3353
- settings: initialSettings(options),
3354
- permissiveReturnOrigins: options.returnOrigins === void 0 && options.settings?.appUrl == null && options.settings?.extraReturnOrigins === void 0,
3355
- dunningPolicy: options.dunningPolicy ?? DEFAULT_DUNNING_POLICY,
3356
- endpointTypes: webhook ? webhook.eventTypes === "legacy" ? LEGACY_EVENT_TYPES : webhook.eventTypes ?? DEFAULT_EVENT_TYPES : null,
3357
- now: options.now ?? (() => /* @__PURE__ */ new Date())
3358
- });
3359
- if (options.catalog !== void 0) {
3360
- const input = "toSyncInput" in options.catalog ? options.catalog.toSyncInput() : options.catalog;
3361
- const parsed = CatalogSyncInputSchema.safeParse(input);
3362
- if (!parsed.success) throw new Error(`createFakeSteward: katalog geçersiz: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
3363
- const synced = syncCatalog(w, parsed.data);
3364
- if (!synced.ok) throw new Error(`createFakeSteward: katalog reddedildi (${synced.code}): ${synced.message ?? ""}`);
3365
- }
3366
- const handle = createHttpHandler(w, {
3367
- baseUrl,
3368
- keys: /* @__PURE__ */ new Map([[apiKey, {
3369
- keyName: "fake",
3370
- scope: "product"
3371
- }], [adminApiKey, {
3372
- keyName: "fake-admin",
3373
- scope: "admin"
3374
- }]]),
3375
- requestId: () => `req_${globalThis.crypto.randomUUID()}`,
3376
- logger: options.logger ?? console
3377
- });
3378
- const requests = [];
3379
- let outage = null;
3380
- const fakeFetch = (async (input, init) => {
3381
- const req = new Request(input, init);
3382
- if (req.signal.aborted) throw req.signal.reason;
3383
- const url = new URL(req.url);
3384
- const body = req.method === "GET" || req.method === "HEAD" ? null : await req.text();
3385
- const inBase = url.origin === base.origin && (url.pathname === basePath || url.pathname.startsWith(`${basePath}/`));
3386
- const path = `${inBase ? url.pathname.slice(basePath.length) : url.pathname}${url.search}`;
3387
- const record = {
3388
- method: req.method,
3389
- url: req.url,
3390
- path,
3391
- headers: Object.fromEntries(req.headers),
3392
- body,
3393
- status: 0
3394
- };
3395
- requests.push(record);
3396
- if (!inBase) throw networkError(`bilinmeyen adres ${url.origin}`);
3397
- if (outage === "network") throw networkError("kesinti");
3398
- let res;
3399
- const publicPath = url.pathname.slice(basePath.length);
3400
- if (outage === null && (publicPath === "/public" || publicPath.startsWith("/public/"))) {
3401
- const publicReq = {
3402
- method: req.method,
3403
- url: new URL(path, base.origin),
3404
- body: body ?? ""
3405
- };
3406
- res = publicPath.startsWith("/public/v1/portal/") ? handlePortal(w, publicReq, { baseUrl }) : handlePublic(w, publicReq);
3407
- } else if (outage === "503") {
3408
- const requestId = `req_${globalThis.crypto.randomUUID()}`;
3409
- const { category, retryable } = ERROR_CODES.database_unavailable;
3410
- res = Response.json({
3411
- error: "database_unavailable",
3412
- category,
3413
- retryable,
3414
- requestId
3415
- }, {
3416
- status: 503,
3417
- headers: { [HEADER_REQUEST_ID]: requestId }
3418
- });
3419
- } else res = handle({
3420
- method: req.method,
3421
- url: new URL(path, base.origin),
3422
- headers: req.headers,
3423
- body: body ?? ""
3424
- });
3425
- record.status = res.status;
3426
- return res;
3427
- });
3428
- const steward = createSteward({
3429
- actorRef: "system:test",
3430
- retries: { sleep: () => Promise.resolve() },
3431
- ...options.steward,
3432
- env: {},
3433
- baseUrl,
3434
- apiKey,
3435
- adminBaseUrl: baseUrl,
3436
- adminApiKey,
3437
- fetch: fakeFetch
3438
- });
3439
- let handler = webhook?.handler ?? null;
3440
- function requireWebhook(name) {
3441
- if (!webhook) throw new Error(`simulate.${name}: createFakeSteward({webhook}) verilmedi`);
3442
- if (!handler) throw new Error(`simulate.${name}: webhook alıcısı bağlanmadı (webhook.handler ya da fake.connect(handler))`);
3443
- return {
3444
- options: webhook,
3445
- handler
3446
- };
3447
- }
3448
- async function send(hook, event) {
3449
- const signed = signBody(event, secretsOf(hook.options.secret));
3450
- const response = await hook.handler(signed.request(hook.options.url ?? "http://product.test/steward/events"));
3451
- return {
3452
- event,
3453
- body: signed.body,
3454
- headers: signed.headers,
3455
- response
3456
- };
3457
- }
3458
- async function flushOne(hook, row) {
3459
- const d = row.delivery;
3460
- d.attempts += 1;
3461
- const retryAt = () => w.now() + (RETRY_SCHEDULE_SECONDS[d.attempts - 1] ?? 43200) * 1e3;
3462
- let delivery;
3463
- try {
3464
- delivery = await send(hook, row.payload);
3465
- } catch (error) {
3466
- Object.assign(d, {
3467
- lastHttpStatus: null,
3468
- lastError: "network_error",
3469
- nextAttemptAt: retryAt()
3470
- });
3471
- throw error;
3472
- }
3473
- const status = delivery.response.status;
3474
- if (status >= 200 && status < 300) Object.assign(d, {
3475
- status: "delivered",
3476
- deliveredAt: w.now(),
3477
- lastHttpStatus: status,
3478
- lastError: null
3479
- });
3480
- else Object.assign(d, {
3481
- lastHttpStatus: status,
3482
- lastError: `http_${status}`,
3483
- nextAttemptAt: retryAt()
3484
- });
3485
- return delivery;
3486
- }
3487
- function portalSession(name, id) {
3488
- const session = livePortalSession(w, id);
3489
- if (!session) throw new Error(`simulate.${name}: portal oturumu yok ya da süresi dolmuş ${id}`);
3490
- return session;
3491
- }
3492
- /** Güncel durumla (fixture detayı hesaba uyarlanmış) sözleşmeye uyan event. */
3493
- function eventFor(type, ref, state, cause, createdAt) {
3494
- let detail;
3495
- if (type === "account.state_changed") detail = {
3496
- cause: cause ?? "account.refreshed",
3497
- causeEventId: null
3498
- };
3499
- else {
3500
- detail = { ...eventFixture(type).data.detail };
3501
- const sub = state.subscription;
3502
- const known = {
3503
- subscriptionId: sub?.id,
3504
- planCode: sub?.planCode ?? state.planCode,
3505
- interval: sub?.interval,
3506
- amountMinor: sub?.amountMinor,
3507
- currency: sub?.currency,
3508
- checkoutSessionId: state.openCheckout?.id
3509
- };
3510
- const shape = EVENT_DETAIL_SCHEMAS[type].shape;
3511
- for (const [key, value] of Object.entries(known)) if (key in shape && value !== void 0) detail[key] = value;
3512
- }
3513
- return envelope(w, ref, state, {
3514
- type,
3515
- detail
3516
- }, `evt_${w.id()}`, createdAt);
3517
- }
3518
- return {
3519
- baseUrl,
3520
- apiKey,
3521
- adminApiKey,
3522
- fetch: fakeFetch,
3523
- steward,
3524
- requests,
3525
- simulate: {
3526
- async checkoutCompleted(sessionId) {
3527
- completeCheckout(w, sessionRow(w, sessionId));
3528
- return checkoutSessionResponse(w, sessionId);
3529
- },
3530
- async checkoutFailed(sessionId, opts = {}) {
3531
- failCheckout(w, sessionRow(w, sessionId), opts.reason ?? "fake:card_declined");
3532
- return checkoutSessionResponse(w, sessionId);
3533
- },
3534
- async completeHostedCheckout(url, opts = {}) {
3535
- const outcome = opts.outcome ?? "succeeded";
3536
- const page = await fakeFetch(url);
3537
- await page.text();
3538
- if (page.status !== 200) throw new Error(`simulate.completeHostedCheckout: sayfa ${page.status} döndü (oturum açık değil ya da adres yanlış)`);
3539
- const pageUrl = new URL(url);
3540
- const id = decodeURIComponent(pageUrl.pathname.split("/").at(-1) ?? "");
3541
- const res = await fakeFetch(new URL(`${encodeURIComponent(id)}/pay`, pageUrl), {
3542
- method: "POST",
3543
- headers: { "content-type": "application/x-www-form-urlencoded" },
3544
- body: new URLSearchParams({
3545
- k: pageUrl.searchParams.get("k") ?? "",
3546
- outcome
3547
- })
3548
- });
3549
- const location = res.headers.get("location");
3550
- if (res.status !== 303 || location === null) throw new Error(`simulate.completeHostedCheckout: 303 bekleniyordu, ${res.status} geldi`);
3551
- return {
3552
- id,
3553
- redirectUrl: location,
3554
- session: checkoutSessionResponse(w, id)
3555
- };
3556
- },
3557
- async checkoutCanceled(sessionId) {
3558
- const row = sessionRow(w, sessionId);
3559
- cancelCheckout(w, row);
3560
- return checkoutSessionResponse(w, sessionId);
3561
- },
3562
- async renewal(ref, opts = {}) {
3563
- const sub = liveSubscription(w, ref);
3564
- if (!sub) throw new Error(`simulate.renewal: ${ref} için canlı abonelik yok`);
3565
- if (sub.cancelAtPeriodEnd) throw new Error(`simulate.renewal: ${ref} aboneliği dönem sonunda bitecek, yenilenmez (simulate.advance)`);
3566
- const now = w.now();
3567
- advanceTo(w, opts.advanceDays !== void 0 ? now + opts.advanceDays * DAY_MS : Math.max(now, sub.currentPeriodEnd ?? now));
3568
- const current = liveSubscription(w, ref);
3569
- if (!current) throw new Error(`simulate.renewal: ${ref} aboneliği saat ilerlerken sona erdi`);
3570
- applyRenewalPayment(w, current, opts.fail ? "failed" : "succeeded");
3571
- return storedState(w, ref);
3572
- },
3573
- async portalCancel(portalSessionId) {
3574
- const session = portalSession("portalCancel", portalSessionId);
3575
- return {
3576
- result: portalCancel(w, session),
3577
- account: storedState(w, session.accountRef)
3578
- };
3579
- },
3580
- async portalRetryPayment(portalSessionId) {
3581
- const session = portalSession("portalRetryPayment", portalSessionId);
3582
- return {
3583
- result: portalRetryPayment(w, session),
3584
- account: storedState(w, session.accountRef)
3585
- };
3586
- },
3587
- async advance(duration = {}) {
3588
- const ms = (duration.days ?? 0) * DAY_MS + (duration.hours ?? 0) * 36e5 + (duration.minutes ?? 0) * 6e4;
3589
- if (!(ms >= 0)) throw new Error("simulate.advance: süre negatif olamaz");
3590
- advanceTo(w, w.now() + ms);
3591
- },
3592
- outage(on, opts = {}) {
3593
- outage = on ? opts.mode ?? "network" : null;
3594
- },
3595
- async deliver(type, opts) {
3596
- const hook = requireWebhook("deliver");
3597
- const row = w.accounts.get(opts.ref);
3598
- if (!row?.state) throw new Error(`simulate.deliver: hesap yok ${opts.ref}`);
3599
- const current = eventFor(type, opts.ref, row.state, opts.cause, w.now());
3600
- const sequence = [current];
3601
- if (opts.duplicate) sequence.push(current);
3602
- if (opts.outOfOrder) {
3603
- const previous = row.history.at(-2);
3604
- if (!previous) throw new Error(`simulate.deliver: ${opts.ref} için önceki version yok (outOfOrder)`);
3605
- sequence.push(eventFor(type, opts.ref, previous, opts.cause, Date.parse(previous.updatedAt)));
3606
- }
3607
- const out = [];
3608
- for (const event of sequence) out.push(await send(hook, event));
3609
- return out;
3610
- },
3611
- async flush() {
3612
- const hook = requireWebhook("flush");
3613
- const blocked = /* @__PURE__ */ new Set();
3614
- const out = [];
3615
- for (let i = 0; i < w.events.length; i++) {
3616
- const row = w.events[i];
3617
- if (row.delivery?.status !== "pending" || blocked.has(row.accountRef)) continue;
3618
- const delivery = await flushOne(hook, row);
3619
- if (row.delivery.status === "pending") blocked.add(row.accountRef);
3620
- out.push(delivery);
3621
- }
3622
- return out;
3623
- },
3624
- redeliver(eventId) {
3625
- if (!redeliverEvent(w, eventId)) throw new Error(`simulate.redeliver: unknown event ${eventId}`);
3626
- }
3627
- },
3628
- connect(next) {
3629
- if (!webhook) throw new Error("fake.connect: createFakeSteward({webhook: {secret}}) verilmedi (sır ve filtre oluşturmada)");
3630
- if (typeof next !== "function") throw new Error("fake.connect: alıcı fonksiyon olmalı");
3631
- handler = next;
3632
- },
3633
- events: (ref) => w.events.filter((e) => ref === void 0 || e.accountRef === ref).map((e) => e.payload),
3634
- now: () => new Date(w.now())
3635
- };
3636
- }
3637
- //#endregion
3638
- export { DEFAULT_DUNNING_POLICY, createFakeSteward, signedEvent };