@fun-xyz/fiat-contract 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1665 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AuthChallengeSchema: () => AuthChallengeSchema,
24
+ BlockedStateSchema: () => BlockedStateSchema,
25
+ ContractViolation: () => ContractViolation,
26
+ CryptoAmountSchema: () => CryptoAmountSchema,
27
+ DOCUMENTED_ENDPOINTS: () => DOCUMENTED_ENDPOINTS,
28
+ ENDPOINT_PATTERN: () => ENDPOINT_PATTERN,
29
+ EnvelopeSchema: () => EnvelopeSchema,
30
+ FIELD_SPEC_TYPES: () => FIELD_SPEC_TYPES,
31
+ FIXTURES: () => FIXTURES,
32
+ FIXTURE_COVERAGE_GAPS: () => FIXTURE_COVERAGE_GAPS,
33
+ FailureReasonSchema: () => FailureReasonSchema,
34
+ FeeLineSchema: () => FeeLineSchema,
35
+ FiatAmountSchema: () => FiatAmountSchema,
36
+ FiatEndpointSchema: () => FiatEndpointSchema,
37
+ FiatProviderSchema: () => FiatProviderSchema,
38
+ FieldSpecSchema: () => FieldSpecSchema,
39
+ FlowStateSchema: () => FlowStateSchema,
40
+ FormDescriptorSchema: () => FormDescriptorSchema,
41
+ FormFieldSchema: () => FormFieldSchema,
42
+ FormFieldTypeSchema: () => FormFieldTypeSchema,
43
+ FunAuthStateSchema: () => FunAuthStateSchema,
44
+ HTTP_VERBS: () => HTTP_VERBS,
45
+ InstructionFieldSchema: () => InstructionFieldSchema,
46
+ InstructionsSchema: () => InstructionsSchema,
47
+ InstrumentSchema: () => InstrumentSchema,
48
+ JsonSchema: () => JsonSchema,
49
+ KycCaptureStateSchema: () => KycCaptureStateSchema,
50
+ KycInputRequiredStateSchema: () => KycInputRequiredStateSchema,
51
+ KycNoActionRequiredStateSchema: () => KycNoActionRequiredStateSchema,
52
+ OrderAwaitingConfirmationStateSchema: () => OrderAwaitingConfirmationStateSchema,
53
+ OrderCreatedStateSchema: () => OrderCreatedStateSchema,
54
+ OrderRefSchema: () => OrderRefSchema,
55
+ OrderStatusSchema: () => OrderStatusSchema,
56
+ OrderSummarySchema: () => OrderSummarySchema,
57
+ PaymentCaptureStateSchema: () => PaymentCaptureStateSchema,
58
+ PaymentInstructStateSchema: () => PaymentInstructStateSchema,
59
+ PendingOrderStateSchema: () => PendingOrderStateSchema,
60
+ PollSpecSchema: () => PollSpecSchema,
61
+ QuoteSchema: () => QuoteSchema,
62
+ QuoteStateSchema: () => QuoteStateSchema,
63
+ RecoverySchema: () => RecoverySchema,
64
+ RefundSchema: () => RefundSchema,
65
+ ReportSpecSchema: () => ReportSpecSchema,
66
+ STATE_KEYS: () => STATE_KEYS,
67
+ SelectOptionSchema: () => SelectOptionSchema,
68
+ SessionAuthStateSchema: () => SessionAuthStateSchema,
69
+ StatusHistoryEntrySchema: () => StatusHistoryEntrySchema,
70
+ StepResponseFragmentSchema: () => StepResponseFragmentSchema,
71
+ StepResponseSchema: () => StepResponseSchema,
72
+ SurfaceSchema: () => SurfaceSchema,
73
+ TABLE_VERSION: () => TABLE_VERSION,
74
+ TERMINAL_ORDER_STATUSES: () => TERMINAL_ORDER_STATUSES,
75
+ TRANSITION_TABLE: () => TRANSITION_TABLE,
76
+ TransitionInputsSchema: () => TransitionInputsSchema,
77
+ TransitionParamsSchema: () => TransitionParamsSchema,
78
+ TransitionSchema: () => TransitionSchema,
79
+ TxSchema: () => TxSchema,
80
+ assertAllFixtures: () => assertAllFixtures,
81
+ assertEnvelope: () => assertEnvelope,
82
+ assertEnvelopeFragment: () => assertEnvelopeFragment,
83
+ assertFixture: () => assertFixture,
84
+ assertLegalEmission: () => assertLegalEmission,
85
+ assertLegalReturn: () => assertLegalReturn,
86
+ checkLegalEmission: () => checkLegalEmission,
87
+ endpointMatches: () => endpointMatches,
88
+ fixtureMeta: () => fixtureMeta,
89
+ isTerminal: () => isTerminal,
90
+ loadFixture: () => loadFixture,
91
+ loadFixtures: () => loadFixtures,
92
+ looksLikeFieldSpec: () => looksLikeFieldSpec,
93
+ stateKey: () => stateKey,
94
+ tableEntry: () => tableEntry,
95
+ walkTable: () => walkTable
96
+ });
97
+ module.exports = __toCommonJS(index_exports);
98
+
99
+ // src/schemas.ts
100
+ var import_zod = require("zod");
101
+ var obj = (shape) => import_zod.z.object(shape).strict();
102
+ var JsonSchema = import_zod.z.lazy(
103
+ () => import_zod.z.union([
104
+ import_zod.z.string(),
105
+ import_zod.z.number(),
106
+ import_zod.z.boolean(),
107
+ import_zod.z.null(),
108
+ import_zod.z.array(JsonSchema),
109
+ import_zod.z.record(import_zod.z.string(), JsonSchema)
110
+ ])
111
+ );
112
+ var HTTP_VERBS = ["GET", "POST"];
113
+ var ENDPOINT_PATTERN = /^(GET|POST) \/fiat\/[^\s]*$/;
114
+ var $endpoint = import_zod.z.string().regex(ENDPOINT_PATTERN, 'endpoint must read "<GET|POST> /fiat/\u2026"');
115
+ var FiatEndpointSchema = $endpoint;
116
+ var $provider = import_zod.z.enum(["TRANSAK", "SWAPPED"]);
117
+ var FiatProviderSchema = $provider;
118
+ var $orderStatus = import_zod.z.enum([
119
+ "CREATING",
120
+ "AWAITING_PAYMENT",
121
+ "PAYMENT_VERIFYING",
122
+ "PROCESSING",
123
+ "PENDING_DELIVERY",
124
+ "ON_HOLD",
125
+ "CRYPTO_SENT",
126
+ "SETTLED",
127
+ "EXPIRED",
128
+ "CANCELLED",
129
+ "FAILED",
130
+ "REFUNDED",
131
+ "CAPTURE_ORPHANED"
132
+ ]);
133
+ var OrderStatusSchema = $orderStatus;
134
+ var $recovery = import_zod.z.enum(["REQUOTE", "RETRY_PAYMENT", "CONTACT_SUPPORT", "NONE"]);
135
+ var RecoverySchema = $recovery;
136
+ var $failureReason = import_zod.z.union([
137
+ obj({
138
+ category: import_zod.z.enum([
139
+ "PAYMENT_DECLINED",
140
+ "KYC_REJECTED",
141
+ "PROVIDER_OUTAGE",
142
+ "USER_ERROR",
143
+ "CANCELLED"
144
+ ]),
145
+ retryable: import_zod.z.boolean(),
146
+ recovery: $recovery,
147
+ code: import_zod.z.string().optional()
148
+ }),
149
+ obj({
150
+ category: import_zod.z.literal("EXPIRED"),
151
+ which: import_zod.z.enum(["QUOTE", "PAYMENT_WINDOW", "INSTRUCTIONS"]),
152
+ retryable: import_zod.z.boolean(),
153
+ recovery: $recovery,
154
+ code: import_zod.z.string().optional()
155
+ })
156
+ ]);
157
+ var FailureReasonSchema = $failureReason;
158
+ var $money = obj({ currency: import_zod.z.string(), amount: import_zod.z.string() });
159
+ var FiatAmountSchema = $money;
160
+ var $cryptoAmount = obj({
161
+ currency: import_zod.z.string(),
162
+ network: import_zod.z.string(),
163
+ amount: import_zod.z.string()
164
+ });
165
+ var CryptoAmountSchema = $cryptoAmount;
166
+ var $feeLine = obj({ id: import_zod.z.string(), labelFallback: import_zod.z.string(), amount: import_zod.z.string() });
167
+ var FeeLineSchema = $feeLine;
168
+ var $quote = obj({
169
+ quoteRef: import_zod.z.string(),
170
+ fiat: $money,
171
+ crypto: $cryptoAmount,
172
+ rate: import_zod.z.string(),
173
+ totalFee: import_zod.z.string(),
174
+ feeBreakdown: import_zod.z.array($feeLine),
175
+ paymentMethod: import_zod.z.string(),
176
+ expiresAt: import_zod.z.string()
177
+ });
178
+ var QuoteSchema = $quote;
179
+ var $instrument = obj({ brandLabel: import_zod.z.string(), last4: import_zod.z.string() });
180
+ var InstrumentSchema = $instrument;
181
+ var $orderRef = obj({
182
+ orderId: import_zod.z.string(),
183
+ fiat: $money,
184
+ crypto: $cryptoAmount,
185
+ method: import_zod.z.string(),
186
+ createdAt: import_zod.z.string(),
187
+ status: $orderStatus
188
+ });
189
+ var OrderRefSchema = $orderRef;
190
+ var $orderSummary = obj({
191
+ orderId: import_zod.z.string(),
192
+ fiat: $money,
193
+ crypto: $cryptoAmount.optional(),
194
+ method: import_zod.z.string(),
195
+ createdAt: import_zod.z.string(),
196
+ instrument: $instrument.optional()
197
+ });
198
+ var OrderSummarySchema = $orderSummary;
199
+ var $statusHistoryEntry = obj({ status: $orderStatus });
200
+ var StatusHistoryEntrySchema = $statusHistoryEntry;
201
+ var $tx = obj({ chainId: import_zod.z.number(), hash: import_zod.z.string() });
202
+ var TxSchema = $tx;
203
+ var $refund = obj({
204
+ currency: import_zod.z.string(),
205
+ amount: import_zod.z.string(),
206
+ expectedBy: import_zod.z.string()
207
+ });
208
+ var RefundSchema = $refund;
209
+ var $surface = import_zod.z.union([
210
+ obj({ kind: import_zod.z.literal("URL_REDIRECT"), url: import_zod.z.string() }),
211
+ obj({ kind: import_zod.z.literal("URL_EMBED"), url: import_zod.z.string() }),
212
+ obj({ kind: import_zod.z.literal("PCI_COMPONENT"), session: import_zod.z.string() }),
213
+ obj({ kind: import_zod.z.literal("PAY_SHEET"), session: import_zod.z.string() }),
214
+ obj({ kind: import_zod.z.literal("ACH_COMPONENT"), session: import_zod.z.string() })
215
+ ]);
216
+ var SurfaceSchema = $surface;
217
+ var $instructionField = obj({
218
+ id: import_zod.z.string(),
219
+ labelFallback: import_zod.z.string(),
220
+ value: import_zod.z.string()
221
+ });
222
+ var InstructionFieldSchema = $instructionField;
223
+ var $instructions = import_zod.z.union([
224
+ obj({ kind: import_zod.z.literal("DEEPLINK"), url: import_zod.z.string(), appVariants: import_zod.z.array(import_zod.z.string()) }),
225
+ obj({ kind: import_zod.z.literal("QR_IMAGE"), url: import_zod.z.string() }),
226
+ obj({
227
+ kind: import_zod.z.literal("BANK_FIELDS"),
228
+ fields: import_zod.z.array($instructionField),
229
+ expiresAt: import_zod.z.string().optional()
230
+ }),
231
+ obj({ kind: import_zod.z.literal("REFERENCE"), code: import_zod.z.string(), expiresAt: import_zod.z.string() })
232
+ ]);
233
+ var InstructionsSchema = $instructions;
234
+ var $selectOption = obj({ id: import_zod.z.string(), labelFallback: import_zod.z.string() });
235
+ var SelectOptionSchema = $selectOption;
236
+ var $formFieldType = import_zod.z.enum(["TEXT", "DATE", "SELECT", "COUNTRY", "FILE", "HOSTED_LINK"]);
237
+ var FormFieldTypeSchema = $formFieldType;
238
+ var $formField = obj({
239
+ id: import_zod.z.string(),
240
+ type: $formFieldType,
241
+ labelFallback: import_zod.z.string(),
242
+ required: import_zod.z.boolean(),
243
+ regex: import_zod.z.string().optional(),
244
+ options: import_zod.z.array($selectOption).optional(),
245
+ url: import_zod.z.string().optional()
246
+ });
247
+ var FormFieldSchema = $formField;
248
+ var $formDescriptor = obj({ formId: import_zod.z.string(), fields: import_zod.z.array($formField) });
249
+ var FormDescriptorSchema = $formDescriptor;
250
+ var FIELD_SPEC_TYPES = [
251
+ "TEXT",
252
+ "DATE",
253
+ "COUNTRY",
254
+ "FILE",
255
+ "SELECT",
256
+ "HOSTED_LINK",
257
+ "FORM_VALUES"
258
+ ];
259
+ var $fieldSpec = import_zod.z.union([
260
+ obj({
261
+ type: import_zod.z.literal("TEXT"),
262
+ length: import_zod.z.number().optional(),
263
+ regex: import_zod.z.string().optional(),
264
+ labelFallback: import_zod.z.string().optional()
265
+ }),
266
+ obj({ type: import_zod.z.literal("DATE"), labelFallback: import_zod.z.string().optional() }),
267
+ obj({ type: import_zod.z.literal("COUNTRY"), labelFallback: import_zod.z.string().optional() }),
268
+ obj({ type: import_zod.z.literal("FILE"), labelFallback: import_zod.z.string().optional() }),
269
+ obj({
270
+ type: import_zod.z.literal("SELECT"),
271
+ options: import_zod.z.array($selectOption).optional(),
272
+ labelFallback: import_zod.z.string().optional()
273
+ }),
274
+ obj({ type: import_zod.z.literal("HOSTED_LINK"), url: import_zod.z.string(), labelFallback: import_zod.z.string().optional() }),
275
+ obj({ type: import_zod.z.literal("FORM_VALUES") })
276
+ ]);
277
+ var FieldSpecSchema = $fieldSpec;
278
+ var looksLikeFieldSpec = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.type === "string" && FIELD_SPEC_TYPES.includes(value.type);
279
+ var $params = import_zod.z.record(import_zod.z.string(), JsonSchema).superRefine((params, ctx) => {
280
+ for (const [key, value] of Object.entries(params)) {
281
+ if (looksLikeFieldSpec(value)) {
282
+ ctx.addIssue({
283
+ code: "custom",
284
+ path: [key],
285
+ message: `params.${key} is FieldSpec-shaped ({type: "\u2026"}). params carry server literals the client spreads verbatim; a spec the client must collect belongs in inputs.`
286
+ });
287
+ }
288
+ }
289
+ });
290
+ var TransitionParamsSchema = $params;
291
+ var $inputs = import_zod.z.record(import_zod.z.string(), $fieldSpec);
292
+ var TransitionInputsSchema = $inputs;
293
+ var $pollSpec = obj({
294
+ endpoint: $endpoint,
295
+ params: $params.optional(),
296
+ intervalMs: import_zod.z.number(),
297
+ backoffFactor: import_zod.z.number().optional(),
298
+ maxIntervalMs: import_zod.z.number().optional(),
299
+ giveUpAfterMs: import_zod.z.number().optional()
300
+ });
301
+ var PollSpecSchema = $pollSpec;
302
+ var $reportSpec = obj({
303
+ endpoint: $endpoint,
304
+ params: $params.optional(),
305
+ expects: import_zod.z.array(import_zod.z.string()).optional()
306
+ });
307
+ var ReportSpecSchema = $reportSpec;
308
+ var $transition = import_zod.z.union([
309
+ obj({
310
+ id: import_zod.z.string(),
311
+ mode: import_zod.z.literal("SUBMIT"),
312
+ endpoint: $endpoint,
313
+ params: $params.optional(),
314
+ inputs: $inputs.optional(),
315
+ labelFallback: import_zod.z.string().optional()
316
+ }),
317
+ obj({ id: import_zod.z.string(), mode: import_zod.z.literal("AWAIT"), poll: $pollSpec }),
318
+ obj({ id: import_zod.z.string(), mode: import_zod.z.literal("CLIENT_SURFACE"), report: $reportSpec })
319
+ ]);
320
+ var TransitionSchema = $transition;
321
+ var $authChallenge = import_zod.z.record(import_zod.z.string(), JsonSchema);
322
+ var AuthChallengeSchema = $authChallenge;
323
+ var $quoteState = obj({
324
+ kind: import_zod.z.literal("QUOTE"),
325
+ quote: $quote,
326
+ error: $failureReason.optional()
327
+ });
328
+ var QuoteStateSchema = $quoteState;
329
+ var $funAuthState = obj({
330
+ kind: import_zod.z.literal("FUN_AUTH"),
331
+ challenge: $authChallenge,
332
+ error: $failureReason.optional()
333
+ });
334
+ var FunAuthStateSchema = $funAuthState;
335
+ var $sessionAuthState = obj({
336
+ kind: import_zod.z.literal("SESSION_AUTH"),
337
+ channel: import_zod.z.literal("EMAIL_OTP"),
338
+ error: $failureReason.optional()
339
+ });
340
+ var SessionAuthStateSchema = $sessionAuthState;
341
+ var $kycCaptureState = obj({
342
+ kind: import_zod.z.literal("KYC"),
343
+ phase: import_zod.z.literal("CAPTURE"),
344
+ surface: $surface,
345
+ error: $failureReason.optional()
346
+ });
347
+ var KycCaptureStateSchema = $kycCaptureState;
348
+ var $kycInputRequiredState = obj({
349
+ kind: import_zod.z.literal("KYC"),
350
+ phase: import_zod.z.literal("INPUT_REQUIRED"),
351
+ form: $formDescriptor,
352
+ error: $failureReason.optional()
353
+ });
354
+ var KycInputRequiredStateSchema = $kycInputRequiredState;
355
+ var $kycNoActionRequiredState = obj({
356
+ kind: import_zod.z.literal("KYC"),
357
+ phase: import_zod.z.literal("NO_ACTION_REQUIRED"),
358
+ reason: import_zod.z.enum(["IN_REVIEW", "ON_HOLD", "REJECTED"]),
359
+ retryAfter: import_zod.z.string().optional(),
360
+ failureReason: $failureReason.optional(),
361
+ error: $failureReason.optional()
362
+ });
363
+ var KycNoActionRequiredStateSchema = $kycNoActionRequiredState;
364
+ var $pendingOrderState = obj({
365
+ kind: import_zod.z.literal("PENDING_ORDER"),
366
+ order: $orderRef,
367
+ error: $failureReason.optional()
368
+ });
369
+ var PendingOrderStateSchema = $pendingOrderState;
370
+ var $blockedState = obj({
371
+ kind: import_zod.z.literal("BLOCKED"),
372
+ reason: import_zod.z.string(),
373
+ retryAfter: import_zod.z.string().nullable().optional(),
374
+ error: $failureReason.optional()
375
+ });
376
+ var BlockedStateSchema = $blockedState;
377
+ var $paymentCaptureState = obj({
378
+ kind: import_zod.z.literal("PAYMENT"),
379
+ phase: import_zod.z.literal("CAPTURE"),
380
+ surface: $surface,
381
+ error: $failureReason.optional()
382
+ });
383
+ var PaymentCaptureStateSchema = $paymentCaptureState;
384
+ var $paymentInstructState = obj({
385
+ kind: import_zod.z.literal("PAYMENT"),
386
+ phase: import_zod.z.literal("INSTRUCT"),
387
+ orderId: import_zod.z.string().optional(),
388
+ instructions: $instructions,
389
+ error: $failureReason.optional()
390
+ });
391
+ var PaymentInstructStateSchema = $paymentInstructState;
392
+ var $orderAwaitingConfirmationState = obj({
393
+ kind: import_zod.z.literal("ORDER"),
394
+ phase: import_zod.z.literal("AWAITING_CONFIRMATION"),
395
+ quote: $quote,
396
+ error: $failureReason.optional()
397
+ });
398
+ var OrderAwaitingConfirmationStateSchema = $orderAwaitingConfirmationState;
399
+ var $orderCreatedState = obj({
400
+ kind: import_zod.z.literal("ORDER"),
401
+ phase: import_zod.z.literal("CREATED"),
402
+ order: $orderSummary.optional(),
403
+ status: $orderStatus,
404
+ statusHistory: import_zod.z.array($statusHistoryEntry).optional(),
405
+ tx: $tx.optional(),
406
+ refund: $refund.optional(),
407
+ failureReason: $failureReason.optional(),
408
+ error: $failureReason.optional()
409
+ });
410
+ var OrderCreatedStateSchema = $orderCreatedState;
411
+ var $flowState = import_zod.z.union([
412
+ $quoteState,
413
+ $funAuthState,
414
+ $sessionAuthState,
415
+ $kycCaptureState,
416
+ $kycInputRequiredState,
417
+ $kycNoActionRequiredState,
418
+ $pendingOrderState,
419
+ $blockedState,
420
+ $paymentCaptureState,
421
+ $paymentInstructState,
422
+ $orderAwaitingConfirmationState,
423
+ $orderCreatedState
424
+ ]);
425
+ var FlowStateSchema = $flowState;
426
+ var $stepResponse = obj({
427
+ state: $flowState,
428
+ provider: $provider,
429
+ transitions: import_zod.z.array($transition),
430
+ orderId: import_zod.z.string().optional()
431
+ });
432
+ var StepResponseSchema = $stepResponse;
433
+ var $stepResponseFragment = obj({
434
+ state: $flowState,
435
+ provider: $provider.optional(),
436
+ transitions: import_zod.z.array($transition),
437
+ orderId: import_zod.z.string().optional()
438
+ });
439
+ var StepResponseFragmentSchema = $stepResponseFragment;
440
+ var EnvelopeSchema = StepResponseSchema;
441
+
442
+ // src/table.ts
443
+ var TABLE_VERSION = "0.2.1";
444
+ var TERMINAL_ORDER_STATUSES = [
445
+ "SETTLED",
446
+ "EXPIRED",
447
+ "CANCELLED",
448
+ "FAILED",
449
+ "REFUNDED"
450
+ ];
451
+ var KYC_ANY = [
452
+ "KYC/CAPTURE",
453
+ "KYC/INPUT_REQUIRED",
454
+ "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
455
+ "KYC/NO_ACTION_REQUIRED:ON_HOLD",
456
+ "KYC/NO_ACTION_REQUIRED:REJECTED"
457
+ ];
458
+ var TRANSITION_TABLE = {
459
+ QUOTE: {
460
+ key: "QUOTE",
461
+ kind: "QUOTE",
462
+ screens: [1],
463
+ docStatus: "SPECIFIED",
464
+ allowedTransitions: [
465
+ {
466
+ id: "continue",
467
+ mode: "SUBMIT",
468
+ endpoint: "POST /fiat/session",
469
+ when: "no valid provider session token (own-tables check)"
470
+ },
471
+ {
472
+ id: "continue",
473
+ mode: "SUBMIT",
474
+ endpoint: "POST /fiat/orders",
475
+ when: "returning user, session + KYC valid \u2014 the quote screen doubles as review"
476
+ },
477
+ {
478
+ id: "continue",
479
+ mode: "SUBMIT",
480
+ endpoint: "POST /fiat/auth",
481
+ when: "no ambient Fun identity (non-fomo surfaces only)"
482
+ }
483
+ ],
484
+ mayReturn: [
485
+ "SESSION_AUTH",
486
+ ...KYC_ANY,
487
+ "PENDING_ORDER",
488
+ "ORDER/AWAITING_CONFIRMATION",
489
+ "ORDER/CREATED",
490
+ "BLOCKED"
491
+ ],
492
+ terminal: false,
493
+ notes: [
494
+ "Re-quote on amount change is a client-local event, not a transition.",
495
+ 'If the PENDING_ORDER removal lands, QUOTE additionally returns PAYMENT{INSTRUCT} / ORDER{CREATED} directly. TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER".'
496
+ ]
497
+ },
498
+ FUN_AUTH: {
499
+ key: "FUN_AUTH",
500
+ kind: "FUN_AUTH",
501
+ screens: [],
502
+ docStatus: "UNSPECIFIED",
503
+ allowedTransitions: [],
504
+ terminal: false,
505
+ notes: [
506
+ "TODO(open-decision): shape and transitions owned by the auth spike (contract \xA7Auth & ref binding, item 5).",
507
+ "Never renders in fomo \u2014 identity is ambient, so the FE doc gives it no screen and no bindings row."
508
+ ]
509
+ },
510
+ SESSION_AUTH: {
511
+ key: "SESSION_AUTH",
512
+ kind: "SESSION_AUTH",
513
+ screens: [2],
514
+ docStatus: "SPECIFIED",
515
+ allowedTransitions: [
516
+ { id: "verify", mode: "SUBMIT", endpoint: "POST /fiat/session/verify" }
517
+ ],
518
+ mayReturn: [
519
+ "SESSION_AUTH",
520
+ ...KYC_ANY,
521
+ "ORDER/AWAITING_CONFIRMATION",
522
+ "PENDING_ORDER"
523
+ ],
524
+ terminal: false,
525
+ notes: [
526
+ "A bad code re-enters this same state with error {category: USER_ERROR, code: BAD_CODE} \u2014 banner, not a screen change.",
527
+ "No resend affordance is specified in either doc (known gap, FE doc Screen 2)."
528
+ ]
529
+ },
530
+ "KYC/CAPTURE": {
531
+ key: "KYC/CAPTURE",
532
+ kind: "KYC",
533
+ phase: "CAPTURE",
534
+ screens: [8],
535
+ docStatus: "SPECIFIED",
536
+ allowedTransitions: [
537
+ {
538
+ id: "capture",
539
+ mode: "CLIENT_SURFACE",
540
+ endpoint: "GET /fiat/kyc",
541
+ note: 'report target per FE bindings ("POST surface report \u2192 GET /fiat/kyc") and contract Flow C.'
542
+ }
543
+ ],
544
+ mayReturn: [
545
+ "KYC/INPUT_REQUIRED",
546
+ "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
547
+ "KYC/NO_ACTION_REQUIRED:ON_HOLD",
548
+ "KYC/NO_ACTION_REQUIRED:REJECTED",
549
+ "ORDER/AWAITING_CONFIRMATION"
550
+ ],
551
+ terminal: false,
552
+ notes: [
553
+ "Dropped from v1 (no UK headless-module coverage) \u2014 FE Screen 8 is a tombstone. Kind retained as vocabulary; the handshake (supportedStepKinds) keeps the backend from routing v1 SDKs here."
554
+ ]
555
+ },
556
+ "KYC/INPUT_REQUIRED": {
557
+ key: "KYC/INPUT_REQUIRED",
558
+ kind: "KYC",
559
+ phase: "INPUT_REQUIRED",
560
+ screens: [3],
561
+ docStatus: "SPECIFIED",
562
+ allowedTransitions: [
563
+ { id: "submit_round", mode: "SUBMIT", endpoint: "POST /fiat/kyc/form" }
564
+ ],
565
+ mayReturn: [
566
+ "KYC/INPUT_REQUIRED",
567
+ "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
568
+ "KYC/NO_ACTION_REQUIRED:ON_HOLD",
569
+ "KYC/NO_ACTION_REQUIRED:REJECTED",
570
+ "ORDER/AWAITING_CONFIRMATION"
571
+ ],
572
+ terminal: false,
573
+ notes: [
574
+ "One POST per round; conditional requirements may open another round.",
575
+ "FILE fields stream to POST /fiat/kyc/document \u2014 an upload path the harness owns, not a transition.",
576
+ "Hosted KYC links arrive as HOSTED_LINK form fields, not as a Surface."
577
+ ]
578
+ },
579
+ "KYC/NO_ACTION_REQUIRED:IN_REVIEW": {
580
+ key: "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
581
+ kind: "KYC",
582
+ phase: "NO_ACTION_REQUIRED",
583
+ reason: "IN_REVIEW",
584
+ screens: [9],
585
+ docStatus: "SPECIFIED",
586
+ allowedTransitions: [{ id: "poll", mode: "AWAIT", endpoint: "GET /fiat/kyc" }],
587
+ mayReturn: [
588
+ "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
589
+ "KYC/INPUT_REQUIRED",
590
+ "KYC/NO_ACTION_REQUIRED:ON_HOLD",
591
+ "KYC/NO_ACTION_REQUIRED:REJECTED",
592
+ "ORDER/AWAITING_CONFIRMATION"
593
+ ],
594
+ terminal: false,
595
+ notes: [
596
+ "Documented poll shape: intervalMs 3000, backoffFactor 1.5, maxIntervalMs 10000, giveUpAfterMs 90000.",
597
+ "Pre-order giveUp destination is a design gap (FE doc Screen 9) \u2014 client-local, no contract impact."
598
+ ]
599
+ },
600
+ "KYC/NO_ACTION_REQUIRED:ON_HOLD": {
601
+ key: "KYC/NO_ACTION_REQUIRED:ON_HOLD",
602
+ kind: "KYC",
603
+ phase: "NO_ACTION_REQUIRED",
604
+ reason: "ON_HOLD",
605
+ screens: [10],
606
+ docStatus: "SPECIFIED",
607
+ allowedTransitions: [],
608
+ mayReturn: KYC_ANY,
609
+ terminal: false,
610
+ notes: [
611
+ "THE proof that terminality is not array emptiness: transitions [] and NOT terminal. Re-entry after retryAfter re-derives."
612
+ ]
613
+ },
614
+ "KYC/NO_ACTION_REQUIRED:REJECTED": {
615
+ key: "KYC/NO_ACTION_REQUIRED:REJECTED",
616
+ kind: "KYC",
617
+ phase: "NO_ACTION_REQUIRED",
618
+ reason: "REJECTED",
619
+ screens: [11],
620
+ docStatus: "SPECIFIED",
621
+ allowedTransitions: [
622
+ {
623
+ id: "escalate",
624
+ mode: "SUBMIT",
625
+ endpoint: "GET /fiat/kyc",
626
+ when: "an escalation round is offered",
627
+ note: "TODO(open-decision): placeholder shape \u2014 the FE doc flags that a GET carrying params is not a real request shape, and OQ7 owns which rejections escalate. FE doc open question 7."
628
+ }
629
+ ],
630
+ mayReturn: KYC_ANY,
631
+ terminal: false,
632
+ notes: [
633
+ "Terminal *variant*: failureReason {KYC_REJECTED, retryable: false, recovery: CONTACT_SUPPORT} + transitions [] \u2014 renders in place, never routes through ORDER{CREATED, FAILED}. Because escalation is also legal here, the entry is not flatly terminal.",
634
+ "TODO(open-decision): contract OQ7 \u2014 terminal vs escalating rejections are not enumerated."
635
+ ]
636
+ },
637
+ PENDING_ORDER: {
638
+ key: "PENDING_ORDER",
639
+ kind: "PENDING_ORDER",
640
+ screens: [12],
641
+ docStatus: "SPECIFIED",
642
+ allowedTransitions: [
643
+ { id: "resume", mode: "SUBMIT", endpoint: "GET /fiat/orders/:id" },
644
+ { id: "cancel", mode: "SUBMIT", endpoint: "POST /fiat/orders/:id/cancel" }
645
+ ],
646
+ mayReturn: ["ORDER/CREATED", "QUOTE"],
647
+ terminal: false,
648
+ notes: [
649
+ 'TODO(open-decision): removal proposed (contract \xA7"Proposal: remove PENDING_ORDER"); FE Screen 12 is frozen until it lands. The two-call resume-or-cancel shape is why transitions is an array.',
650
+ "SUBMIT is a user-fired request, not a POST: GET /fiat/orders/:id under SUBMIT is legal."
651
+ ]
652
+ },
653
+ BLOCKED: {
654
+ key: "BLOCKED",
655
+ kind: "BLOCKED",
656
+ screens: [16],
657
+ docStatus: "SPECIFIED",
658
+ allowedTransitions: [],
659
+ mayReturn: [],
660
+ terminal: true,
661
+ notes: [
662
+ "Region/compliance block \u2014 fires from anywhere (contract \xA7Global transitions). retryAfter renders a dated comeback; null = none.",
663
+ "TODO(open-decision): v1 scope assumed yes for FCA/UK \u2014 product confirm (FE doc open question 4)."
664
+ ]
665
+ },
666
+ "PAYMENT/CAPTURE": {
667
+ key: "PAYMENT/CAPTURE",
668
+ kind: "PAYMENT",
669
+ phase: "CAPTURE",
670
+ screens: [5],
671
+ docStatus: "SPECIFIED",
672
+ allowedTransitions: [
673
+ {
674
+ id: "capture",
675
+ mode: "CLIENT_SURFACE",
676
+ endpoint: "POST /fiat/orders",
677
+ when: "capture-then-order topology (Transak headless cards: the component yields requestId, an input to createOrder)",
678
+ note: 'TODO(open-decision): report target per card topology \u2014 Transak ask (contract Flow A: "confirm before freeze").'
679
+ },
680
+ {
681
+ id: "capture",
682
+ mode: "CLIENT_SURFACE",
683
+ endpoint: "POST /fiat/orders/:id/surface-result",
684
+ when: "the order already exists (order-then-capture topology)",
685
+ note: 'Endpoint per contract \xA7Endpoints ("client reports CLIENT_SURFACE outcome; backend reconciles vs webhooks").'
686
+ }
687
+ ],
688
+ mayReturn: ["ORDER/CREATED", "PAYMENT/CAPTURE", "QUOTE"],
689
+ terminal: false,
690
+ notes: [
691
+ "Decline re-entry: same state, fresh surface.session, error {category: PAYMENT_DECLINED, retryable: true, recovery: RETRY_PAYMENT}.",
692
+ "The Surface is carried in state, never in the transition (\u03942, single-carry)."
693
+ ]
694
+ },
695
+ "PAYMENT/INSTRUCT": {
696
+ key: "PAYMENT/INSTRUCT",
697
+ kind: "PAYMENT",
698
+ phase: "INSTRUCT",
699
+ screens: [6],
700
+ docStatus: "SPECIFIED",
701
+ allowedTransitions: [
702
+ {
703
+ id: "confirm_paid",
704
+ mode: "SUBMIT",
705
+ endpoint: "POST /fiat/orders/:id/confirm-payment"
706
+ },
707
+ {
708
+ id: "cancel",
709
+ mode: "SUBMIT",
710
+ endpoint: "POST /fiat/orders/:id/cancel",
711
+ when: "in-wizard cancel placement wins",
712
+ note: "TODO(open-decision): cancel placement \u2014 in-wizard vs an out-of-wizard pending-orders surface (FE doc open questions 1+2). Per-rail cancel eligibility is contract OQ7."
713
+ }
714
+ ],
715
+ mayReturn: ["ORDER/CREATED"],
716
+ terminal: false,
717
+ notes: [
718
+ "No instructions re-issue endpoint exists; expired-instructions recovery is unenumerated. TODO(open-decision): contract OQ7.",
719
+ "Async instructions (PREPARING) are deliberately not in v1 \u2014 Transak returns bank details synchronously."
720
+ ]
721
+ },
722
+ "ORDER/AWAITING_CONFIRMATION": {
723
+ key: "ORDER/AWAITING_CONFIRMATION",
724
+ kind: "ORDER",
725
+ phase: "AWAITING_CONFIRMATION",
726
+ screens: [4],
727
+ docStatus: "SPECIFIED",
728
+ allowedTransitions: [{ id: "confirm", mode: "SUBMIT", endpoint: "POST /fiat/orders" }],
729
+ mayReturn: [
730
+ "PAYMENT/CAPTURE",
731
+ "PAYMENT/INSTRUCT",
732
+ "ORDER/CREATED",
733
+ "QUOTE",
734
+ "PENDING_ORDER"
735
+ ],
736
+ terminal: false,
737
+ notes: [
738
+ "Bank rails only \u2014 card rails skip it, the pay sheet was the confirmation.",
739
+ "Carries a refreshed quote: the price may have drifted during KYC."
740
+ ]
741
+ },
742
+ "ORDER/CREATED": {
743
+ key: "ORDER/CREATED",
744
+ kind: "ORDER",
745
+ phase: "CREATED",
746
+ screens: [7, 13, 14, 15],
747
+ docStatus: "SPECIFIED",
748
+ allowedTransitions: [
749
+ {
750
+ id: "poll",
751
+ mode: "AWAIT",
752
+ endpoint: "GET /fiat/orders/:id",
753
+ when: "status is non-terminal",
754
+ note: "Documented giveUpAfterMs 300000 \u2014 on give-up the wizard exits and the durable order takes over."
755
+ },
756
+ {
757
+ id: "retry_payment",
758
+ mode: "SUBMIT",
759
+ endpoint: "POST /fiat/orders",
760
+ when: "failureReason.recovery === RETRY_PAYMENT"
761
+ },
762
+ {
763
+ id: "requote",
764
+ mode: "SUBMIT",
765
+ endpoint: "POST /fiat/quote",
766
+ when: "failureReason.recovery === REQUOTE"
767
+ },
768
+ {
769
+ id: "cancel",
770
+ mode: "SUBMIT",
771
+ endpoint: "POST /fiat/orders/:id/cancel",
772
+ when: "in-wizard cancel placement wins and the rail allows it",
773
+ note: "TODO(open-decision): cancel placement (FE doc open questions 1+2); per-rail eligibility is contract OQ7."
774
+ }
775
+ ],
776
+ mayReturn: ["ORDER/CREATED"],
777
+ terminal: false,
778
+ terminalStatuses: TERMINAL_ORDER_STATUSES,
779
+ notes: [
780
+ "Flow-terminality here rides status \u2208 terminalStatuses, not the state kind and not array emptiness.",
781
+ "A terminal status may still carry a recovery transition: FAILED + retryable failureReason emits it (FE doc Screen 14); non-retryable emits [].",
782
+ "ON_HOLD is a non-terminal status (resolves to PROCESSING or REFUNDED) \u2014 and is NOT the same thing as the KYC ON_HOLD reason; see the note on that field.",
783
+ "CAPTURE_ORPHANED is non-terminal pending the backend rank map (added from review 2026-08-14).",
784
+ "TODO(open-decision): contract OQ7 \u2014 under/over-payment is the biggest unenumerated branch."
785
+ ]
786
+ }
787
+ };
788
+ var STATE_KEYS = Object.keys(TRANSITION_TABLE);
789
+ var DOCUMENTED_ENDPOINTS = [
790
+ "GET /fiat/payment-methods",
791
+ "POST /fiat/quote",
792
+ "POST /fiat/auth",
793
+ "POST /fiat/session",
794
+ "POST /fiat/session/verify",
795
+ "GET /fiat/kyc",
796
+ "POST /fiat/kyc/form",
797
+ "POST /fiat/kyc/document",
798
+ "POST /fiat/instruments",
799
+ "POST /fiat/orders",
800
+ "GET /fiat/orders/:id",
801
+ "GET /fiat/orders/:id/instructions",
802
+ "POST /fiat/orders/:id/confirm-payment",
803
+ "POST /fiat/orders/:id/reference",
804
+ "POST /fiat/orders/:id/cancel",
805
+ "POST /fiat/orders/:id/surface-result"
806
+ ];
807
+ function stateKey(state) {
808
+ switch (state.kind) {
809
+ case "QUOTE":
810
+ return "QUOTE";
811
+ case "FUN_AUTH":
812
+ return "FUN_AUTH";
813
+ case "SESSION_AUTH":
814
+ return "SESSION_AUTH";
815
+ case "PENDING_ORDER":
816
+ return "PENDING_ORDER";
817
+ case "BLOCKED":
818
+ return "BLOCKED";
819
+ case "KYC": {
820
+ if (state.phase === "CAPTURE") return "KYC/CAPTURE";
821
+ if (state.phase === "INPUT_REQUIRED") return "KYC/INPUT_REQUIRED";
822
+ return `KYC/NO_ACTION_REQUIRED:${state.reason}`;
823
+ }
824
+ case "PAYMENT":
825
+ return state.phase === "CAPTURE" ? "PAYMENT/CAPTURE" : "PAYMENT/INSTRUCT";
826
+ case "ORDER":
827
+ return state.phase === "AWAITING_CONFIRMATION" ? "ORDER/AWAITING_CONFIRMATION" : "ORDER/CREATED";
828
+ default: {
829
+ const exhaustive = state;
830
+ throw new Error(`unknown FlowState: ${JSON.stringify(exhaustive)}`);
831
+ }
832
+ }
833
+ }
834
+ var tableEntry = (state) => TRANSITION_TABLE[stateKey(state)];
835
+ function isTerminal(state) {
836
+ const entry = tableEntry(state);
837
+ if (entry.terminalStatuses && state.kind === "ORDER" && state.phase === "CREATED") {
838
+ return entry.terminalStatuses.includes(state.status);
839
+ }
840
+ return entry.terminal;
841
+ }
842
+
843
+ // src/fixtures/data.ts
844
+ var FIXTURE_DATA = {
845
+ "screen-01-quote": {
846
+ "state": {
847
+ "kind": "QUOTE",
848
+ "quote": {
849
+ "quoteRef": "q_8f2",
850
+ "fiat": {
851
+ "currency": "GBP",
852
+ "amount": "100.00"
853
+ },
854
+ "crypto": {
855
+ "currency": "USDC",
856
+ "network": "polygon",
857
+ "amount": "98.41"
858
+ },
859
+ "rate": "0.9841",
860
+ "totalFee": "1.59",
861
+ "feeBreakdown": [
862
+ {
863
+ "id": "provider_fee",
864
+ "labelFallback": "Processing fee",
865
+ "amount": "1.09"
866
+ },
867
+ {
868
+ "id": "network_fee",
869
+ "labelFallback": "Network fee",
870
+ "amount": "0.50"
871
+ }
872
+ ],
873
+ "paymentMethod": "bank_transfer",
874
+ "expiresAt": "\u2026"
875
+ }
876
+ },
877
+ "provider": "TRANSAK",
878
+ "transitions": [
879
+ {
880
+ "id": "continue",
881
+ "mode": "SUBMIT",
882
+ "endpoint": "POST /fiat/session",
883
+ "params": {
884
+ "quoteRef": "q_8f2"
885
+ }
886
+ }
887
+ ]
888
+ },
889
+ "screen-02-session-auth": {
890
+ "state": {
891
+ "kind": "SESSION_AUTH",
892
+ "channel": "EMAIL_OTP"
893
+ },
894
+ "provider": "TRANSAK",
895
+ "transitions": [
896
+ {
897
+ "id": "verify",
898
+ "mode": "SUBMIT",
899
+ "endpoint": "POST /fiat/session/verify",
900
+ "params": {
901
+ "quoteRef": "q_8f2"
902
+ },
903
+ "inputs": {
904
+ "code": {
905
+ "type": "TEXT",
906
+ "length": 6
907
+ }
908
+ }
909
+ }
910
+ ]
911
+ },
912
+ "screen-03-kyc-input-required": {
913
+ "state": {
914
+ "kind": "KYC",
915
+ "phase": "INPUT_REQUIRED",
916
+ "form": {
917
+ "formId": "personal_details",
918
+ "fields": [
919
+ {
920
+ "id": "firstName",
921
+ "type": "TEXT",
922
+ "labelFallback": "First name",
923
+ "required": true
924
+ },
925
+ {
926
+ "id": "dob",
927
+ "type": "DATE",
928
+ "labelFallback": "Date of birth",
929
+ "required": true
930
+ },
931
+ {
932
+ "id": "country",
933
+ "type": "COUNTRY",
934
+ "labelFallback": "Country",
935
+ "required": true
936
+ },
937
+ {
938
+ "id": "sourceOfFunds",
939
+ "type": "SELECT",
940
+ "labelFallback": "Source of funds",
941
+ "required": true,
942
+ "options": [
943
+ {
944
+ "id": "salary",
945
+ "labelFallback": "Salary"
946
+ }
947
+ ]
948
+ },
949
+ {
950
+ "id": "idProof",
951
+ "type": "HOSTED_LINK",
952
+ "labelFallback": "Verify your ID",
953
+ "required": true,
954
+ "url": "\u2026"
955
+ }
956
+ ]
957
+ }
958
+ },
959
+ "provider": "TRANSAK",
960
+ "transitions": [
961
+ {
962
+ "id": "submit_round",
963
+ "mode": "SUBMIT",
964
+ "endpoint": "POST /fiat/kyc/form",
965
+ "params": {
966
+ "formId": "personal_details"
967
+ },
968
+ "inputs": {
969
+ "values": {
970
+ "type": "FORM_VALUES"
971
+ }
972
+ }
973
+ }
974
+ ]
975
+ },
976
+ "screen-04-order-review": {
977
+ "state": {
978
+ "kind": "ORDER",
979
+ "phase": "AWAITING_CONFIRMATION",
980
+ "quote": {
981
+ "quoteRef": "q_8f2",
982
+ "fiat": {
983
+ "currency": "GBP",
984
+ "amount": "100.00"
985
+ },
986
+ "crypto": {
987
+ "currency": "USDC",
988
+ "network": "polygon",
989
+ "amount": "98.41"
990
+ },
991
+ "rate": "0.9841",
992
+ "totalFee": "1.59",
993
+ "feeBreakdown": [
994
+ {
995
+ "id": "provider_fee",
996
+ "labelFallback": "Processing fee",
997
+ "amount": "1.09"
998
+ },
999
+ {
1000
+ "id": "network_fee",
1001
+ "labelFallback": "Network fee",
1002
+ "amount": "0.50"
1003
+ }
1004
+ ],
1005
+ "paymentMethod": "bank_transfer",
1006
+ "expiresAt": "\u2026"
1007
+ }
1008
+ },
1009
+ "provider": "TRANSAK",
1010
+ "transitions": [
1011
+ {
1012
+ "id": "confirm",
1013
+ "mode": "SUBMIT",
1014
+ "endpoint": "POST /fiat/orders",
1015
+ "params": {
1016
+ "quoteRef": "q_8f2"
1017
+ }
1018
+ }
1019
+ ]
1020
+ },
1021
+ "screen-05-payment-capture": {
1022
+ "state": {
1023
+ "kind": "PAYMENT",
1024
+ "phase": "CAPTURE",
1025
+ "surface": {
1026
+ "kind": "PCI_COMPONENT",
1027
+ "session": "eyJ\u2026"
1028
+ }
1029
+ },
1030
+ "provider": "TRANSAK",
1031
+ "transitions": [
1032
+ {
1033
+ "id": "capture",
1034
+ "mode": "CLIENT_SURFACE",
1035
+ "report": {
1036
+ "endpoint": "POST /fiat/orders",
1037
+ "params": {
1038
+ "quoteRef": "q_8f2"
1039
+ },
1040
+ "expects": [
1041
+ "requestId"
1042
+ ]
1043
+ }
1044
+ }
1045
+ ]
1046
+ },
1047
+ "screen-09-kyc-in-review": {
1048
+ "state": {
1049
+ "kind": "KYC",
1050
+ "phase": "NO_ACTION_REQUIRED",
1051
+ "reason": "IN_REVIEW"
1052
+ },
1053
+ "provider": "TRANSAK",
1054
+ "transitions": [
1055
+ {
1056
+ "id": "poll",
1057
+ "mode": "AWAIT",
1058
+ "poll": {
1059
+ "endpoint": "GET /fiat/kyc",
1060
+ "params": {
1061
+ "quoteRef": "q_8f2"
1062
+ },
1063
+ "intervalMs": 3e3,
1064
+ "backoffFactor": 1.5,
1065
+ "maxIntervalMs": 1e4,
1066
+ "giveUpAfterMs": 9e4
1067
+ }
1068
+ }
1069
+ ]
1070
+ },
1071
+ "screen-10-kyc-on-hold": {
1072
+ "state": {
1073
+ "kind": "KYC",
1074
+ "phase": "NO_ACTION_REQUIRED",
1075
+ "reason": "ON_HOLD",
1076
+ "retryAfter": "2026-08-20T09:00:00Z"
1077
+ },
1078
+ "provider": "TRANSAK",
1079
+ "transitions": []
1080
+ },
1081
+ "screen-11-kyc-rejected-escalation": {
1082
+ "state": {
1083
+ "kind": "KYC",
1084
+ "phase": "NO_ACTION_REQUIRED",
1085
+ "reason": "REJECTED"
1086
+ },
1087
+ "provider": "TRANSAK",
1088
+ "transitions": [
1089
+ {
1090
+ "id": "escalate",
1091
+ "mode": "SUBMIT",
1092
+ "endpoint": "GET /fiat/kyc",
1093
+ "params": {
1094
+ "tier": "escalated"
1095
+ }
1096
+ }
1097
+ ]
1098
+ },
1099
+ "screen-12-pending-order": {
1100
+ "state": {
1101
+ "kind": "PENDING_ORDER",
1102
+ "order": {
1103
+ "orderId": "o_31c",
1104
+ "fiat": {
1105
+ "currency": "GBP",
1106
+ "amount": "100.00"
1107
+ },
1108
+ "crypto": {
1109
+ "currency": "USDC",
1110
+ "network": "polygon",
1111
+ "amount": "98.41"
1112
+ },
1113
+ "method": "bank_transfer",
1114
+ "createdAt": "\u2026",
1115
+ "status": "AWAITING_PAYMENT"
1116
+ }
1117
+ },
1118
+ "provider": "TRANSAK",
1119
+ "transitions": [
1120
+ {
1121
+ "id": "resume",
1122
+ "mode": "SUBMIT",
1123
+ "endpoint": "GET /fiat/orders/o_31c"
1124
+ },
1125
+ {
1126
+ "id": "cancel",
1127
+ "mode": "SUBMIT",
1128
+ "endpoint": "POST /fiat/orders/o_31c/cancel"
1129
+ }
1130
+ ]
1131
+ },
1132
+ "screen-13-order-settled": {
1133
+ "state": {
1134
+ "kind": "ORDER",
1135
+ "phase": "CREATED",
1136
+ "order": {
1137
+ "orderId": "o_31c",
1138
+ "fiat": {
1139
+ "currency": "GBP",
1140
+ "amount": "100.00"
1141
+ },
1142
+ "crypto": {
1143
+ "currency": "USDC",
1144
+ "network": "polygon",
1145
+ "amount": "98.41"
1146
+ },
1147
+ "method": "card",
1148
+ "instrument": {
1149
+ "brandLabel": "Chase Debit",
1150
+ "last4": "4021"
1151
+ },
1152
+ "createdAt": "\u2026"
1153
+ },
1154
+ "status": "SETTLED",
1155
+ "tx": {
1156
+ "chainId": 137,
1157
+ "hash": "0x\u2026"
1158
+ }
1159
+ },
1160
+ "provider": "TRANSAK",
1161
+ "transitions": []
1162
+ },
1163
+ "screen-14-order-failed": {
1164
+ "state": {
1165
+ "kind": "ORDER",
1166
+ "phase": "CREATED",
1167
+ "order": {
1168
+ "orderId": "o_31c",
1169
+ "fiat": {
1170
+ "currency": "GBP",
1171
+ "amount": "100.00"
1172
+ },
1173
+ "method": "card",
1174
+ "createdAt": "\u2026"
1175
+ },
1176
+ "status": "FAILED",
1177
+ "failureReason": {
1178
+ "category": "PAYMENT_DECLINED",
1179
+ "retryable": true,
1180
+ "recovery": "RETRY_PAYMENT"
1181
+ }
1182
+ },
1183
+ "provider": "TRANSAK",
1184
+ "transitions": [
1185
+ {
1186
+ "id": "retry_payment",
1187
+ "mode": "SUBMIT",
1188
+ "endpoint": "POST /fiat/orders",
1189
+ "params": {
1190
+ "quoteRef": "q_8f2"
1191
+ }
1192
+ }
1193
+ ]
1194
+ },
1195
+ "screen-15-order-refunded": {
1196
+ "state": {
1197
+ "kind": "ORDER",
1198
+ "phase": "CREATED",
1199
+ "order": {
1200
+ "orderId": "o_31c",
1201
+ "fiat": {
1202
+ "currency": "GBP",
1203
+ "amount": "100.00"
1204
+ },
1205
+ "method": "bank_transfer",
1206
+ "createdAt": "\u2026"
1207
+ },
1208
+ "status": "REFUNDED",
1209
+ "refund": {
1210
+ "currency": "GBP",
1211
+ "amount": "100.00",
1212
+ "expectedBy": "\u2026"
1213
+ }
1214
+ },
1215
+ "provider": "TRANSAK",
1216
+ "transitions": []
1217
+ },
1218
+ "screen-16-blocked": {
1219
+ "state": {
1220
+ "kind": "BLOCKED",
1221
+ "reason": "REGION_UNSUPPORTED",
1222
+ "retryAfter": null
1223
+ },
1224
+ "provider": "TRANSAK",
1225
+ "transitions": []
1226
+ },
1227
+ "worked-example-01-quote": {
1228
+ "state": {
1229
+ "kind": "QUOTE",
1230
+ "quote": {
1231
+ "quoteRef": "q_8f2",
1232
+ "fiat": {
1233
+ "currency": "GBP",
1234
+ "amount": "100.00"
1235
+ },
1236
+ "crypto": {
1237
+ "currency": "USDC",
1238
+ "network": "polygon",
1239
+ "amount": "98.41"
1240
+ },
1241
+ "rate": "0.9841",
1242
+ "totalFee": "1.59",
1243
+ "feeBreakdown": [
1244
+ {
1245
+ "id": "provider_fee",
1246
+ "labelFallback": "Processing fee",
1247
+ "amount": "1.09"
1248
+ },
1249
+ {
1250
+ "id": "network_fee",
1251
+ "labelFallback": "Network fee",
1252
+ "amount": "0.50"
1253
+ }
1254
+ ],
1255
+ "paymentMethod": "bank_transfer",
1256
+ "expiresAt": "2026-08-11T21:33:00Z"
1257
+ }
1258
+ },
1259
+ "transitions": [
1260
+ {
1261
+ "id": "continue",
1262
+ "mode": "SUBMIT",
1263
+ "endpoint": "POST /fiat/session",
1264
+ "params": {
1265
+ "quoteRef": "q_8f2"
1266
+ },
1267
+ "inputs": {
1268
+ "email": {
1269
+ "type": "TEXT"
1270
+ }
1271
+ }
1272
+ }
1273
+ ]
1274
+ },
1275
+ "worked-example-02-session-auth": {
1276
+ "state": {
1277
+ "kind": "SESSION_AUTH",
1278
+ "channel": "EMAIL_OTP"
1279
+ },
1280
+ "transitions": [
1281
+ {
1282
+ "id": "verify",
1283
+ "mode": "SUBMIT",
1284
+ "endpoint": "POST /fiat/session/verify",
1285
+ "params": {
1286
+ "quoteRef": "q_8f2"
1287
+ },
1288
+ "inputs": {
1289
+ "code": {
1290
+ "type": "TEXT",
1291
+ "length": 6
1292
+ }
1293
+ }
1294
+ }
1295
+ ]
1296
+ },
1297
+ "worked-example-04-payment-instruct": {
1298
+ "orderId": "o_3d1",
1299
+ "state": {
1300
+ "kind": "PAYMENT",
1301
+ "phase": "INSTRUCT",
1302
+ "instructions": {
1303
+ "kind": "BANK_FIELDS",
1304
+ "fields": [
1305
+ {
1306
+ "id": "beneficiary",
1307
+ "labelFallback": "Beneficiary",
1308
+ "value": "Transak"
1309
+ },
1310
+ {
1311
+ "id": "sort_code",
1312
+ "labelFallback": "Sort code",
1313
+ "value": "04-00-04"
1314
+ },
1315
+ {
1316
+ "id": "account_number",
1317
+ "labelFallback": "Account number",
1318
+ "value": "12345678"
1319
+ },
1320
+ {
1321
+ "id": "reference",
1322
+ "labelFallback": "Reference",
1323
+ "value": "o_3d1"
1324
+ }
1325
+ ],
1326
+ "expiresAt": "\u2026"
1327
+ }
1328
+ },
1329
+ "transitions": [
1330
+ {
1331
+ "id": "confirm_paid",
1332
+ "mode": "SUBMIT",
1333
+ "endpoint": "POST /fiat/orders/o_3d1/confirm-payment"
1334
+ }
1335
+ ]
1336
+ }
1337
+ };
1338
+
1339
+ // src/fixtures/index.ts
1340
+ var FIXTURES = [
1341
+ {
1342
+ id: "screen-01-quote",
1343
+ file: "screen-01-quote.json",
1344
+ source: "FE_DOC",
1345
+ docRef: "Screen 1 \xB7 Quote / review",
1346
+ screen: 1,
1347
+ stateKey: "QUOTE",
1348
+ complete: true
1349
+ },
1350
+ {
1351
+ id: "screen-02-session-auth",
1352
+ file: "screen-02-session-auth.json",
1353
+ source: "FE_DOC",
1354
+ docRef: "Screen 2 \xB7 Verification code (OTP)",
1355
+ screen: 2,
1356
+ stateKey: "SESSION_AUTH",
1357
+ complete: true
1358
+ },
1359
+ {
1360
+ id: "screen-03-kyc-input-required",
1361
+ file: "screen-03-kyc-input-required.json",
1362
+ source: "FE_DOC",
1363
+ docRef: "Screen 3 \xB7 Identity form (KYC)",
1364
+ screen: 3,
1365
+ stateKey: "KYC/INPUT_REQUIRED",
1366
+ complete: true,
1367
+ notes: ["Carries the FORM_VALUES input spec \u2014 the no-double-carry case of the accepted split."]
1368
+ },
1369
+ {
1370
+ id: "screen-04-order-review",
1371
+ file: "screen-04-order-review.json",
1372
+ source: "FE_DOC",
1373
+ docRef: "Screen 4 \xB7 Order review (bank rail)",
1374
+ screen: 4,
1375
+ stateKey: "ORDER/AWAITING_CONFIRMATION",
1376
+ complete: true,
1377
+ notes: [
1378
+ `The screen card publishes this payload by reference \u2014 "quote: <same shape as Screen 1, refreshed>" \u2014 so the quote here is Screen 1's payload verbatim. The numbers are not re-published as refreshed values, so they are unchanged; only the shape is contractual.`
1379
+ ]
1380
+ },
1381
+ {
1382
+ id: "screen-05-payment-capture",
1383
+ file: "screen-05-payment-capture.json",
1384
+ source: "FE_DOC",
1385
+ docRef: "Screen 5 \xB7 Card payment",
1386
+ screen: 5,
1387
+ stateKey: "PAYMENT/CAPTURE",
1388
+ complete: true,
1389
+ notes: [
1390
+ "Capture-then-order topology (report \u2192 POST /fiat/orders, expects requestId).",
1391
+ "TODO(open-decision): report target per card topology is a Transak ask (contract Flow A)."
1392
+ ]
1393
+ },
1394
+ {
1395
+ id: "screen-09-kyc-in-review",
1396
+ file: "screen-09-kyc-in-review.json",
1397
+ source: "FE_DOC",
1398
+ docRef: "Screen 9 \xB7 Verifying identity",
1399
+ screen: 9,
1400
+ stateKey: "KYC/NO_ACTION_REQUIRED:IN_REVIEW",
1401
+ complete: true
1402
+ },
1403
+ {
1404
+ id: "screen-10-kyc-on-hold",
1405
+ file: "screen-10-kyc-on-hold.json",
1406
+ source: "FE_DOC",
1407
+ docRef: "Screen 10 \xB7 Verification on hold",
1408
+ screen: 10,
1409
+ stateKey: "KYC/NO_ACTION_REQUIRED:ON_HOLD",
1410
+ complete: true,
1411
+ notes: ["transitions: [] and NOT terminal \u2014 the fixture that proves terminality is table data."]
1412
+ },
1413
+ {
1414
+ id: "screen-11-kyc-rejected-escalation",
1415
+ file: "screen-11-kyc-rejected-escalation.json",
1416
+ source: "FE_DOC",
1417
+ docRef: "Screen 11 \xB7 Verification unsuccessful",
1418
+ screen: 11,
1419
+ stateKey: "KYC/NO_ACTION_REQUIRED:REJECTED",
1420
+ complete: true,
1421
+ notes: [
1422
+ "TODO(open-decision): the escalation transition is a flagged placeholder \u2014 a GET carrying params is not a real request shape (FE doc Screen 11 + open question 7).",
1423
+ "The terminal REJECTED variant (failureReason + transitions []) is described in prose, not published as JSON \u2014 no fixture."
1424
+ ]
1425
+ },
1426
+ {
1427
+ id: "screen-12-pending-order",
1428
+ file: "screen-12-pending-order.json",
1429
+ source: "FE_DOC",
1430
+ docRef: "Screen 12 \xB7 Order in progress",
1431
+ screen: 12,
1432
+ stateKey: "PENDING_ORDER",
1433
+ complete: true,
1434
+ notes: [
1435
+ "TODO(open-decision): PENDING_ORDER removal proposed; Screen 12 frozen. Kept so 0.1.0 can validate the current shape."
1436
+ ]
1437
+ },
1438
+ {
1439
+ id: "screen-13-order-settled",
1440
+ file: "screen-13-order-settled.json",
1441
+ source: "FE_DOC",
1442
+ docRef: "Screen 13 \xB7 Success",
1443
+ screen: 13,
1444
+ stateKey: "ORDER/CREATED",
1445
+ complete: true
1446
+ },
1447
+ {
1448
+ id: "screen-14-order-failed",
1449
+ file: "screen-14-order-failed.json",
1450
+ source: "FE_DOC",
1451
+ docRef: "Screen 14 \xB7 Something went wrong",
1452
+ screen: 14,
1453
+ stateKey: "ORDER/CREATED",
1454
+ complete: true,
1455
+ notes: ["Terminal status (FAILED) still carrying a recovery transition \u2014 retryable failureReason."]
1456
+ },
1457
+ {
1458
+ id: "screen-15-order-refunded",
1459
+ file: "screen-15-order-refunded.json",
1460
+ source: "FE_DOC",
1461
+ docRef: "Screen 15 \xB7 Refund on the way",
1462
+ screen: 15,
1463
+ stateKey: "ORDER/CREATED",
1464
+ complete: true
1465
+ },
1466
+ {
1467
+ id: "screen-16-blocked",
1468
+ file: "screen-16-blocked.json",
1469
+ source: "FE_DOC",
1470
+ docRef: "Screen 16 \xB7 Not available",
1471
+ screen: 16,
1472
+ stateKey: "BLOCKED",
1473
+ complete: true,
1474
+ notes: ["The only flatly terminal state entry in the table."]
1475
+ },
1476
+ {
1477
+ id: "worked-example-01-quote",
1478
+ file: "worked-example-01-quote.json",
1479
+ source: "CONTRACT_DOC",
1480
+ docRef: "\xA7Worked example, step 1 (method selection)",
1481
+ stateKey: "QUOTE",
1482
+ complete: false,
1483
+ notes: ["Carries inputs {email: {type: TEXT}} \u2014 the collected-spec half of the split."]
1484
+ },
1485
+ {
1486
+ id: "worked-example-02-session-auth",
1487
+ file: "worked-example-02-session-auth.json",
1488
+ source: "CONTRACT_DOC",
1489
+ docRef: "\xA7Worked example, step 2 (session challenge)",
1490
+ stateKey: "SESSION_AUTH",
1491
+ complete: false
1492
+ },
1493
+ {
1494
+ id: "worked-example-04-payment-instruct",
1495
+ file: "worked-example-04-payment-instruct.json",
1496
+ source: "CONTRACT_DOC",
1497
+ docRef: "\xA7Worked example, steps 4\u20135 (order + bank instructions)",
1498
+ stateKey: "PAYMENT/INSTRUCT",
1499
+ complete: false,
1500
+ notes: [
1501
+ "The envelope-sibling `orderId` case. TODO(open-decision): orderId placement (envelope sibling here vs inside state in the FE doc v0)."
1502
+ ]
1503
+ }
1504
+ ];
1505
+ var FIXTURE_COVERAGE_GAPS = [
1506
+ {
1507
+ stateKey: "FUN_AUTH",
1508
+ reason: "No envelope exists in either doc \u2014 shape owned by the auth spike, and FUN_AUTH never renders in fomo. TODO(open-decision): contract \xA7Auth & ref binding, item 5."
1509
+ },
1510
+ {
1511
+ stateKey: "KYC/CAPTURE",
1512
+ reason: "Dropped from v1 (FE Screen 8 tombstone, no UK module coverage); no envelope was ever published. Kind retained as handshake-gated vocabulary."
1513
+ }
1514
+ ];
1515
+ var fixtureMeta = (id) => {
1516
+ const meta = FIXTURES.find((f) => f.id === id);
1517
+ if (!meta) throw new Error(`unknown fixture: ${id}`);
1518
+ return meta;
1519
+ };
1520
+ function loadFixture(id) {
1521
+ const meta = fixtureMeta(id);
1522
+ const data = FIXTURE_DATA[meta.id];
1523
+ if (data === void 0) throw new Error(`fixture data missing for ${meta.id} \u2014 run npm run generate:fixtures`);
1524
+ return JSON.parse(JSON.stringify(data));
1525
+ }
1526
+ function loadFixtures() {
1527
+ return FIXTURES.map((meta) => ({ meta, json: loadFixture(meta.id) }));
1528
+ }
1529
+
1530
+ // src/assert.ts
1531
+ var ContractViolation = class extends Error {
1532
+ issues;
1533
+ constructor(message, issues = []) {
1534
+ super(issues.length ? `${message}
1535
+ - ${issues.join("\n - ")}` : message);
1536
+ this.name = "ContractViolation";
1537
+ this.issues = issues;
1538
+ }
1539
+ };
1540
+ var formatIssues = (error) => {
1541
+ const issues = error.issues;
1542
+ if (!issues) return [String(error)];
1543
+ return issues.map((issue) => {
1544
+ const path = (issue.path ?? []).join(".");
1545
+ return path ? `${path}: ${issue.message ?? "invalid"}` : issue.message ?? "invalid";
1546
+ });
1547
+ };
1548
+ function assertEnvelope(json) {
1549
+ const result = StepResponseSchema.safeParse(json);
1550
+ if (!result.success) throw new ContractViolation("invalid envelope", formatIssues(result.error));
1551
+ return result.data;
1552
+ }
1553
+ function assertEnvelopeFragment(json) {
1554
+ const result = StepResponseFragmentSchema.safeParse(json);
1555
+ if (!result.success)
1556
+ throw new ContractViolation("invalid envelope fragment", formatIssues(result.error));
1557
+ return result.data;
1558
+ }
1559
+ function endpointMatches(template, actual) {
1560
+ const pattern = template.split("/").map((segment) => segment.startsWith(":") ? "[^/]+" : escapeRegExp(segment)).join("/");
1561
+ return new RegExp(`^${pattern}$`).test(actual);
1562
+ }
1563
+ var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1564
+ var transitionEndpoint = (transition) => {
1565
+ switch (transition.mode) {
1566
+ case "SUBMIT":
1567
+ return transition.endpoint;
1568
+ case "AWAIT":
1569
+ return transition.poll.endpoint;
1570
+ case "CLIENT_SURFACE":
1571
+ return transition.report.endpoint;
1572
+ }
1573
+ };
1574
+ var matchesAllowed = (transition, allowed) => allowed.mode === transition.mode && endpointMatches(allowed.endpoint, transitionEndpoint(transition));
1575
+ function bodyKeyIssues(transition) {
1576
+ const issues = [];
1577
+ const collide = (a, b, left, right) => {
1578
+ for (const key of a) {
1579
+ if (b.includes(key)) issues.push(`transition "${transition.id}": key "${key}" is in both ${left} and ${right}`);
1580
+ }
1581
+ };
1582
+ if (transition.mode === "SUBMIT") {
1583
+ const params = Object.keys(transition.params ?? {});
1584
+ const inputs = Object.keys(transition.inputs ?? {});
1585
+ collide(params, inputs, "params", "inputs");
1586
+ for (const [key, value] of Object.entries(transition.params ?? {})) {
1587
+ if (looksLikeFieldSpec(value)) {
1588
+ issues.push(
1589
+ `transition "${transition.id}": params.${key} is FieldSpec-shaped \u2014 a spec the client must collect belongs in inputs`
1590
+ );
1591
+ }
1592
+ }
1593
+ }
1594
+ if (transition.mode === "CLIENT_SURFACE") {
1595
+ const params = Object.keys(transition.report.params ?? {});
1596
+ collide(params, transition.report.expects ?? [], "report.params", "report.expects");
1597
+ }
1598
+ return issues;
1599
+ }
1600
+ function checkLegalEmission(state, transitions) {
1601
+ const entry = tableEntry(state);
1602
+ const issues = [];
1603
+ if (entry.terminal && transitions.length > 0) {
1604
+ issues.push(
1605
+ `${entry.key} is terminal (table) and must carry transitions: [] \u2014 got ${transitions.length}`
1606
+ );
1607
+ }
1608
+ for (const transition of transitions) {
1609
+ issues.push(...bodyKeyIssues(transition));
1610
+ if (transition.mode === "CLIENT_SURFACE" && !("surface" in state)) {
1611
+ issues.push(
1612
+ `transition "${transition.id}": CLIENT_SURFACE requires the Surface in state (\u03942 single-carry) \u2014 ${entry.key} carries none`
1613
+ );
1614
+ }
1615
+ if (transition.mode === "SUBMIT" && Object.values(transition.inputs ?? {}).some((spec) => spec.type === "FORM_VALUES") && !("form" in state)) {
1616
+ issues.push(
1617
+ `transition "${transition.id}": inputs {type: FORM_VALUES} collects state.form \u2014 ${entry.key} carries no form`
1618
+ );
1619
+ }
1620
+ if (entry.docStatus === "SPECIFIED" && !entry.allowedTransitions.some((a) => matchesAllowed(transition, a))) {
1621
+ issues.push(
1622
+ `transition "${transition.id}": ${transition.mode} ${transitionEndpoint(transition)} is not in ${entry.key}'s legal set (${entry.allowedTransitions.map((a) => `${a.mode} ${a.endpoint}`).join(" | ") || "none"})`
1623
+ );
1624
+ }
1625
+ }
1626
+ return {
1627
+ state: entry.key,
1628
+ issues,
1629
+ unjudgeable: entry.docStatus === "UNSPECIFIED"
1630
+ };
1631
+ }
1632
+ function assertLegalEmission(state, transitions) {
1633
+ const { issues, state: key } = checkLegalEmission(state, transitions);
1634
+ if (issues.length) throw new ContractViolation(`illegal emission for ${key}`, issues);
1635
+ }
1636
+ function assertLegalReturn(from, to) {
1637
+ const entry = tableEntry(from);
1638
+ const target = stateKey(to);
1639
+ if (!entry.mayReturn) return;
1640
+ if (!entry.mayReturn.includes(target)) {
1641
+ throw new ContractViolation(
1642
+ `illegal return: ${entry.key} \u2192 ${target}`,
1643
+ [`${entry.key} may receive back: ${entry.mayReturn.join(" \xB7 ") || "nothing (terminal)"}`]
1644
+ );
1645
+ }
1646
+ }
1647
+ function walkTable(fn) {
1648
+ return STATE_KEYS.map((key) => fn(TRANSITION_TABLE[key], key));
1649
+ }
1650
+ function assertFixture(id) {
1651
+ const meta = fixtureMeta(id);
1652
+ const json = loadFixture(id);
1653
+ const envelope = meta.complete ? assertEnvelope(json) : assertEnvelopeFragment(json);
1654
+ if (stateKey(envelope.state) !== meta.stateKey) {
1655
+ throw new ContractViolation(
1656
+ `fixture ${id}: manifest says ${meta.stateKey}, envelope carries ${stateKey(envelope.state)}`
1657
+ );
1658
+ }
1659
+ assertLegalEmission(envelope.state, envelope.transitions);
1660
+ return { meta, envelope };
1661
+ }
1662
+ function assertAllFixtures() {
1663
+ return FIXTURES.map((meta) => assertFixture(meta.id));
1664
+ }
1665
+ //# sourceMappingURL=index.js.map