@stewardhq/sdk 0.3.0 → 0.6.0

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