@fun-xyz/fiat-contract 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ Four things, zero runtime logic beyond validation:
17
17
  | Import | Weight | Contains | Used by |
18
18
  | --- | --- | --- | --- |
19
19
  | `@fun-xyz/fiat-contract/types` | **0.1 KB** (types erase) | every type; no runtime values | production, both repos |
20
- | `@fun-xyz/fiat-contract/table` | **13.8 KB**, zero deps | `TRANSITION_TABLE`, `stateKey`, `tableEntry`, `isTerminal`, `walkTable`, `TABLE_VERSION`, `TERMINAL_ORDER_STATUSES`, `DOCUMENTED_ENDPOINTS` | **production frontend** + backend |
20
+ | `@fun-xyz/fiat-contract/table` | **13.8 KB**, zero deps | `TRANSITION_TABLE`, `stateKey`, `tableEntry`, `isTerminal`, `walkTable`, `TABLE_VERSION`, `TERMINAL_ORDER_STATUSES`, `DOCUMENTED_ENDPOINTS`, `UNOFFERED_ENDPOINTS`, `UNREACHABLE_STATES`, `unofferedEndpoints`, `unreachableStates` | **production frontend** + backend |
21
21
  | `@fun-xyz/fiat-contract` | 47.6 KB, needs zod | the above + 46 zod schemas + assertions + 16 fixtures | tests, and backend dev/test guards |
22
22
 
23
23
  `./table` is not a micro-optimisation. Terminality is table data a **shipped** client must read
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "0.6.0";
2
+ var version = "0.7.0";
3
3
 
4
4
  // src/table.ts
5
5
  var TABLE_VERSION = version;
@@ -30,22 +30,46 @@ var TRANSITION_TABLE = {
30
30
  endpoint: "POST /fiat/session",
31
31
  when: "no valid provider session token (own-tables check)"
32
32
  },
33
+ {
34
+ id: "open_session",
35
+ mode: "SUBMIT",
36
+ endpoint: "POST /fiat/payment-session",
37
+ when: "returning user, session + KYC valid, capture-first rail \u2014 the method authorises before any order exists",
38
+ note: "Returns PAYMENT{CAPTURE} carrying the Surface. Without this entry the capture-first rail has no legal way in: a transition-following client takes POST /fiat/orders instead, and the backend refuses that when the provider declares the capture-first capability and no authorisation is present. Named open_session per the Fiat Contract 0.7 doc, which adds quote-time-minted surface rows beside it."
39
+ },
33
40
  {
34
41
  id: "continue",
35
42
  mode: "SUBMIT",
36
43
  endpoint: "POST /fiat/orders",
37
- when: "returning user, session + KYC valid \u2014 the quote screen doubles as review"
44
+ when: "returning user, session + KYC valid, order-first rail \u2014 the quote screen doubles as review",
45
+ note: "Narrowed to the order-first rail: on a capture-first method the order cannot be created before the payment is authorised, so the sibling payment-session entry is the one that applies."
38
46
  },
39
47
  {
40
48
  id: "continue",
41
49
  mode: "SUBMIT",
42
50
  endpoint: "POST /fiat/auth",
43
51
  when: "no ambient Fun identity (non-fomo surfaces only)"
52
+ },
53
+ {
54
+ id: "capture",
55
+ mode: "CLIENT_SURFACE",
56
+ endpoint: "POST /fiat/orders",
57
+ when: "capture-then-order rail whose session is minted at quote time (inline pay sheet)",
58
+ note: "The 0.7 row that makes the single-screen Apple Pay design legal: the surface rides QUOTE, the provider component IS the primary CTA, and its result creates the order. Apple policy forbids a visible step between an Apple-branded tap and the sheet, and TransakApplePay exposes no programmatic trigger, so a two-step via payment-session cannot serve this rail. Requires state.surface (\u03942)."
59
+ },
60
+ {
61
+ id: "capture",
62
+ mode: "CLIENT_SURFACE",
63
+ endpoint: "POST /fiat/orders/:id/surface-result",
64
+ when: "order-then-capture rail whose provider order was created with the quote-time session",
65
+ note: "Same quote-time placement, other topology: the order already exists, so the surface reports against it rather than creating one."
44
66
  }
45
67
  ],
46
68
  mayReturn: [
47
69
  "SESSION_AUTH",
48
70
  ...KYC_ANY,
71
+ "PAYMENT/CAPTURE",
72
+ "PAYMENT/INSTRUCT",
49
73
  "PENDING_ORDER",
50
74
  "ORDER/AWAITING_CONFIRMATION",
51
75
  "ORDER/CREATED",
@@ -53,8 +77,10 @@ var TRANSITION_TABLE = {
53
77
  ],
54
78
  terminal: false,
55
79
  notes: [
56
- "Re-quote on amount change is a client-local event, not a transition.",
57
- 'If the PENDING_ORDER removal lands, QUOTE additionally returns PAYMENT{INSTRUCT} / ORDER{CREATED} directly. TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER".'
80
+ "Re-quote on amount change is a client-local event, not a transition. A new quote carries a new surface; the client remounts the adapter rather than reusing the old session.",
81
+ "A surface on QUOTE and a SUBMIT to the same endpoint may not coexist \u2014 two paths to one order. assertLegalEmission enforces it; the server picks one per rail.",
82
+ `Both PAYMENT states are reachable directly from QUOTE: the FE doc canvas draws S1 \u2192 S5 (card, capture-first) and S1 \u2192 S6 (bank). This entry was first ported from the FE doc's bindings table, whose "May receive back" column omits both \u2014 the canvas is the correct source of the two.`,
83
+ 'TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER" would additionally have QUOTE return ORDER{CREATED} for an active order. That decision does not gate the PAYMENT edges above.'
58
84
  ]
59
85
  },
60
86
  FUN_AUTH: {
@@ -352,6 +378,7 @@ var DOCUMENTED_ENDPOINTS = [
352
378
  "GET /fiat/payment-methods",
353
379
  "POST /fiat/quote",
354
380
  "POST /fiat/auth",
381
+ "POST /fiat/payment-session",
355
382
  "POST /fiat/session",
356
383
  "POST /fiat/session/verify",
357
384
  "GET /fiat/kyc",
@@ -366,6 +393,54 @@ var DOCUMENTED_ENDPOINTS = [
366
393
  "POST /fiat/orders/:id/cancel",
367
394
  "POST /fiat/orders/:id/surface-result"
368
395
  ];
396
+ var UNOFFERED_ENDPOINTS = [
397
+ {
398
+ endpoint: "GET /fiat/payment-methods",
399
+ reason: "Discovery, called before any flow state exists \u2014 there is no state to hang it off."
400
+ },
401
+ {
402
+ endpoint: "POST /fiat/kyc/document",
403
+ reason: "FILE fields stream to it directly; the harness owns that upload path and it is not a transition (see KYC/INPUT_REQUIRED notes)."
404
+ },
405
+ {
406
+ endpoint: "POST /fiat/instruments",
407
+ reason: "No state offers it and no published doc explains why. TODO(open-decision): saved instruments may simply be unreached in v1 \u2014 confirm, or wire it to the state that should offer it."
408
+ },
409
+ {
410
+ endpoint: "GET /fiat/orders/:id/instructions",
411
+ reason: "PAYMENT{INSTRUCT} carries its instructions in state and offers no fetch. TODO(open-decision): that state also notes no re-issue endpoint exists, which sits oddly beside this one being published \u2014 confirm or retire it."
412
+ },
413
+ {
414
+ endpoint: "POST /fiat/orders/:id/reference",
415
+ reason: "The bank-reference submission is offered by no state. TODO(open-decision): PAYMENT{INSTRUCT} is the plausible home \u2014 confirm or wire it."
416
+ }
417
+ ];
418
+ var UNREACHABLE_STATES = [
419
+ {
420
+ stateKey: "FUN_AUTH",
421
+ reason: "No bindings row published (docStatus UNSPECIFIED) and never rendered in fomo, where identity is ambient. Entered out-of-band, not by a mayReturn edge."
422
+ }
423
+ ];
424
+ function unofferedEndpoints() {
425
+ const offered = /* @__PURE__ */ new Set();
426
+ for (const key of STATE_KEYS) {
427
+ for (const allowed of TRANSITION_TABLE[key].allowedTransitions) offered.add(allowed.endpoint);
428
+ }
429
+ return DOCUMENTED_ENDPOINTS.filter((endpoint) => !offered.has(endpoint));
430
+ }
431
+ function unreachableStates(from = "QUOTE") {
432
+ const seen = /* @__PURE__ */ new Set([from]);
433
+ const queue = [from];
434
+ while (queue.length > 0) {
435
+ for (const target of TRANSITION_TABLE[queue.pop()].mayReturn ?? []) {
436
+ if (!seen.has(target)) {
437
+ seen.add(target);
438
+ queue.push(target);
439
+ }
440
+ }
441
+ }
442
+ return STATE_KEYS.filter((key) => !seen.has(key));
443
+ }
369
444
  function stateKey(state) {
370
445
  switch (state.kind) {
371
446
  case "QUOTE":
@@ -408,8 +483,12 @@ export {
408
483
  TRANSITION_TABLE,
409
484
  STATE_KEYS,
410
485
  DOCUMENTED_ENDPOINTS,
486
+ UNOFFERED_ENDPOINTS,
487
+ UNREACHABLE_STATES,
488
+ unofferedEndpoints,
489
+ unreachableStates,
411
490
  stateKey,
412
491
  tableEntry,
413
492
  isTerminal
414
493
  };
415
- //# sourceMappingURL=chunk-EHYDQU4T.mjs.map
494
+ //# sourceMappingURL=chunk-OGNYHIOE.mjs.map
package/dist/index.js CHANGED
@@ -51,6 +51,7 @@ __export(index_exports, {
51
51
  InstructionsSchema: () => InstructionsSchema,
52
52
  InstrumentSchema: () => InstrumentSchema,
53
53
  JsonSchema: () => JsonSchema,
54
+ KYCProviderSchema: () => KYCProviderSchema,
54
55
  KycCaptureStateSchema: () => KycCaptureStateSchema,
55
56
  KycInputRequiredStateSchema: () => KycInputRequiredStateSchema,
56
57
  KycNoActionRequiredStateSchema: () => KycNoActionRequiredStateSchema,
@@ -73,6 +74,8 @@ __export(index_exports, {
73
74
  SelectOptionSchema: () => SelectOptionSchema,
74
75
  SessionAuthStateSchema: () => SessionAuthStateSchema,
75
76
  StatusHistoryEntrySchema: () => StatusHistoryEntrySchema,
77
+ SurfaceKindSchema: () => SurfaceKindSchema,
78
+ SurfaceProviderSchema: () => SurfaceProviderSchema,
76
79
  SurfaceSchema: () => SurfaceSchema,
77
80
  TABLE_VERSION: () => TABLE_VERSION,
78
81
  TERMINAL_ORDER_STATUSES: () => TERMINAL_ORDER_STATUSES,
@@ -81,6 +84,8 @@ __export(index_exports, {
81
84
  TransitionParamsSchema: () => TransitionParamsSchema,
82
85
  TransitionSchema: () => TransitionSchema,
83
86
  TxSchema: () => TxSchema,
87
+ UNOFFERED_ENDPOINTS: () => UNOFFERED_ENDPOINTS,
88
+ UNREACHABLE_STATES: () => UNREACHABLE_STATES,
84
89
  assertAllFixtures: () => assertAllFixtures,
85
90
  assertFiatStepResponse: () => assertFiatStepResponse,
86
91
  assertFixture: () => assertFixture,
@@ -95,6 +100,8 @@ __export(index_exports, {
95
100
  looksLikeFieldSpec: () => looksLikeFieldSpec,
96
101
  stateKey: () => stateKey,
97
102
  tableEntry: () => tableEntry,
103
+ unofferedEndpoints: () => unofferedEndpoints,
104
+ unreachableStates: () => unreachableStates,
98
105
  walkTable: () => walkTable
99
106
  });
100
107
  module.exports = __toCommonJS(index_exports);
@@ -380,8 +387,12 @@ var HTTP_VERBS = ["GET", "POST"];
380
387
  var ENDPOINT_PATTERN = /^(GET|POST) \/fiat\/[^\s]*$/;
381
388
  var $endpoint = import_zod.z.string().regex(ENDPOINT_PATTERN, 'endpoint must read "<GET|POST> /fiat/\u2026"');
382
389
  var FiatEndpointSchema = $endpoint;
383
- var $provider = import_zod.z.enum(["TRANSAK", "SWAPPED"]);
390
+ var $provider = import_zod.z.enum(["TRANSAK", "SWAPPED", "BANXA"]);
384
391
  var FiatProviderSchema = $provider;
392
+ var $kycProvider = import_zod.z.enum(["SUMSUB"]);
393
+ var KYCProviderSchema = $kycProvider;
394
+ var $surfaceProvider = import_zod.z.enum([...$provider.options, ...$kycProvider.options]);
395
+ var SurfaceProviderSchema = $surfaceProvider;
385
396
  var $orderStatus = import_zod.z.enum([
386
397
  "CREATING",
387
398
  "AWAITING_PAYMENT",
@@ -473,13 +484,22 @@ var $refund = obj({
473
484
  expectedBy: import_zod.z.string()
474
485
  });
475
486
  var RefundSchema = $refund;
476
- var $surface = import_zod.z.union([
477
- obj({ kind: import_zod.z.literal("URL_REDIRECT"), url: import_zod.z.string() }),
478
- obj({ kind: import_zod.z.literal("URL_EMBED"), url: import_zod.z.string() }),
479
- obj({ kind: import_zod.z.literal("PCI_COMPONENT"), session: import_zod.z.string() }),
480
- obj({ kind: import_zod.z.literal("PAY_SHEET"), session: import_zod.z.string() }),
481
- obj({ kind: import_zod.z.literal("ACH_COMPONENT"), session: import_zod.z.string() })
482
- ]);
487
+ var $surfaceKind = import_zod.z.enum(["URL_REDIRECT", "URL_EMBED", "PCI_COMPONENT", "PAY_SHEET", "ACH_COMPONENT"]);
488
+ var SurfaceKindSchema = $surfaceKind;
489
+ var $surface = obj({
490
+ kind: $surfaceKind,
491
+ provider: $surfaceProvider,
492
+ /**
493
+ * The one deliberate non-strict leaf in the contract. Provider inputs are the adapter's
494
+ * vocabulary, so typing them here would drag every provider field change through a contract
495
+ * release and both consumers — and a normalised `environment` could only be lossy across
496
+ * Transak's four values and Banxa's two. Both ends narrow instead: fun-backend emits from a
497
+ * typed provider session, the client adapter validates with its own schema, and a recorded
498
+ * fixture per `(provider, kind)` keeps the two honest.
499
+ */
500
+ params: import_zod.z.record(import_zod.z.string(), JsonSchema),
501
+ expiresAt: import_zod.z.string().optional()
502
+ });
483
503
  var SurfaceSchema = $surface;
484
504
  var $instructionField = obj({
485
505
  id: import_zod.z.string(),
@@ -609,6 +629,8 @@ var AuthChallengeSchema = $authChallenge;
609
629
  var $quoteState = obj({
610
630
  kind: import_zod.z.literal("QUOTE"),
611
631
  quote: $quote,
632
+ /** Present when the provider's component is the primary CTA, inline on the entry screen. */
633
+ surface: $surface.optional(),
612
634
  error: $failureReason.optional()
613
635
  });
614
636
  var QuoteStateSchema = $quoteState;
@@ -718,7 +740,7 @@ var $stepResponse = obj({
718
740
  var FiatStepResponseSchema = $stepResponse;
719
741
 
720
742
  // package.json
721
- var version = "0.6.0";
743
+ var version = "0.7.0";
722
744
 
723
745
  // src/table.ts
724
746
  var TABLE_VERSION = version;
@@ -749,22 +771,46 @@ var TRANSITION_TABLE = {
749
771
  endpoint: "POST /fiat/session",
750
772
  when: "no valid provider session token (own-tables check)"
751
773
  },
774
+ {
775
+ id: "open_session",
776
+ mode: "SUBMIT",
777
+ endpoint: "POST /fiat/payment-session",
778
+ when: "returning user, session + KYC valid, capture-first rail \u2014 the method authorises before any order exists",
779
+ note: "Returns PAYMENT{CAPTURE} carrying the Surface. Without this entry the capture-first rail has no legal way in: a transition-following client takes POST /fiat/orders instead, and the backend refuses that when the provider declares the capture-first capability and no authorisation is present. Named open_session per the Fiat Contract 0.7 doc, which adds quote-time-minted surface rows beside it."
780
+ },
752
781
  {
753
782
  id: "continue",
754
783
  mode: "SUBMIT",
755
784
  endpoint: "POST /fiat/orders",
756
- when: "returning user, session + KYC valid \u2014 the quote screen doubles as review"
785
+ when: "returning user, session + KYC valid, order-first rail \u2014 the quote screen doubles as review",
786
+ note: "Narrowed to the order-first rail: on a capture-first method the order cannot be created before the payment is authorised, so the sibling payment-session entry is the one that applies."
757
787
  },
758
788
  {
759
789
  id: "continue",
760
790
  mode: "SUBMIT",
761
791
  endpoint: "POST /fiat/auth",
762
792
  when: "no ambient Fun identity (non-fomo surfaces only)"
793
+ },
794
+ {
795
+ id: "capture",
796
+ mode: "CLIENT_SURFACE",
797
+ endpoint: "POST /fiat/orders",
798
+ when: "capture-then-order rail whose session is minted at quote time (inline pay sheet)",
799
+ note: "The 0.7 row that makes the single-screen Apple Pay design legal: the surface rides QUOTE, the provider component IS the primary CTA, and its result creates the order. Apple policy forbids a visible step between an Apple-branded tap and the sheet, and TransakApplePay exposes no programmatic trigger, so a two-step via payment-session cannot serve this rail. Requires state.surface (\u03942)."
800
+ },
801
+ {
802
+ id: "capture",
803
+ mode: "CLIENT_SURFACE",
804
+ endpoint: "POST /fiat/orders/:id/surface-result",
805
+ when: "order-then-capture rail whose provider order was created with the quote-time session",
806
+ note: "Same quote-time placement, other topology: the order already exists, so the surface reports against it rather than creating one."
763
807
  }
764
808
  ],
765
809
  mayReturn: [
766
810
  "SESSION_AUTH",
767
811
  ...KYC_ANY,
812
+ "PAYMENT/CAPTURE",
813
+ "PAYMENT/INSTRUCT",
768
814
  "PENDING_ORDER",
769
815
  "ORDER/AWAITING_CONFIRMATION",
770
816
  "ORDER/CREATED",
@@ -772,8 +818,10 @@ var TRANSITION_TABLE = {
772
818
  ],
773
819
  terminal: false,
774
820
  notes: [
775
- "Re-quote on amount change is a client-local event, not a transition.",
776
- 'If the PENDING_ORDER removal lands, QUOTE additionally returns PAYMENT{INSTRUCT} / ORDER{CREATED} directly. TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER".'
821
+ "Re-quote on amount change is a client-local event, not a transition. A new quote carries a new surface; the client remounts the adapter rather than reusing the old session.",
822
+ "A surface on QUOTE and a SUBMIT to the same endpoint may not coexist \u2014 two paths to one order. assertLegalEmission enforces it; the server picks one per rail.",
823
+ `Both PAYMENT states are reachable directly from QUOTE: the FE doc canvas draws S1 \u2192 S5 (card, capture-first) and S1 \u2192 S6 (bank). This entry was first ported from the FE doc's bindings table, whose "May receive back" column omits both \u2014 the canvas is the correct source of the two.`,
824
+ 'TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER" would additionally have QUOTE return ORDER{CREATED} for an active order. That decision does not gate the PAYMENT edges above.'
777
825
  ]
778
826
  },
779
827
  FUN_AUTH: {
@@ -1071,6 +1119,7 @@ var DOCUMENTED_ENDPOINTS = [
1071
1119
  "GET /fiat/payment-methods",
1072
1120
  "POST /fiat/quote",
1073
1121
  "POST /fiat/auth",
1122
+ "POST /fiat/payment-session",
1074
1123
  "POST /fiat/session",
1075
1124
  "POST /fiat/session/verify",
1076
1125
  "GET /fiat/kyc",
@@ -1085,6 +1134,54 @@ var DOCUMENTED_ENDPOINTS = [
1085
1134
  "POST /fiat/orders/:id/cancel",
1086
1135
  "POST /fiat/orders/:id/surface-result"
1087
1136
  ];
1137
+ var UNOFFERED_ENDPOINTS = [
1138
+ {
1139
+ endpoint: "GET /fiat/payment-methods",
1140
+ reason: "Discovery, called before any flow state exists \u2014 there is no state to hang it off."
1141
+ },
1142
+ {
1143
+ endpoint: "POST /fiat/kyc/document",
1144
+ reason: "FILE fields stream to it directly; the harness owns that upload path and it is not a transition (see KYC/INPUT_REQUIRED notes)."
1145
+ },
1146
+ {
1147
+ endpoint: "POST /fiat/instruments",
1148
+ reason: "No state offers it and no published doc explains why. TODO(open-decision): saved instruments may simply be unreached in v1 \u2014 confirm, or wire it to the state that should offer it."
1149
+ },
1150
+ {
1151
+ endpoint: "GET /fiat/orders/:id/instructions",
1152
+ reason: "PAYMENT{INSTRUCT} carries its instructions in state and offers no fetch. TODO(open-decision): that state also notes no re-issue endpoint exists, which sits oddly beside this one being published \u2014 confirm or retire it."
1153
+ },
1154
+ {
1155
+ endpoint: "POST /fiat/orders/:id/reference",
1156
+ reason: "The bank-reference submission is offered by no state. TODO(open-decision): PAYMENT{INSTRUCT} is the plausible home \u2014 confirm or wire it."
1157
+ }
1158
+ ];
1159
+ var UNREACHABLE_STATES = [
1160
+ {
1161
+ stateKey: "FUN_AUTH",
1162
+ reason: "No bindings row published (docStatus UNSPECIFIED) and never rendered in fomo, where identity is ambient. Entered out-of-band, not by a mayReturn edge."
1163
+ }
1164
+ ];
1165
+ function unofferedEndpoints() {
1166
+ const offered = /* @__PURE__ */ new Set();
1167
+ for (const key of STATE_KEYS) {
1168
+ for (const allowed of TRANSITION_TABLE[key].allowedTransitions) offered.add(allowed.endpoint);
1169
+ }
1170
+ return DOCUMENTED_ENDPOINTS.filter((endpoint) => !offered.has(endpoint));
1171
+ }
1172
+ function unreachableStates(from = "QUOTE") {
1173
+ const seen = /* @__PURE__ */ new Set([from]);
1174
+ const queue = [from];
1175
+ while (queue.length > 0) {
1176
+ for (const target of TRANSITION_TABLE[queue.pop()].mayReturn ?? []) {
1177
+ if (!seen.has(target)) {
1178
+ seen.add(target);
1179
+ queue.push(target);
1180
+ }
1181
+ }
1182
+ }
1183
+ return STATE_KEYS.filter((key) => !seen.has(key));
1184
+ }
1088
1185
  function stateKey(state) {
1089
1186
  switch (state.kind) {
1090
1187
  case "QUOTE":
@@ -1167,6 +1264,64 @@ var FIXTURE_DATA = {
1167
1264
  }
1168
1265
  ]
1169
1266
  },
1267
+ "screen-01b-quote-with-pay-sheet": {
1268
+ "state": {
1269
+ "kind": "QUOTE",
1270
+ "quote": {
1271
+ "quoteRef": "q_8f2",
1272
+ "fiat": {
1273
+ "currency": "GBP",
1274
+ "amount": "100.00"
1275
+ },
1276
+ "crypto": {
1277
+ "currency": "USDC",
1278
+ "network": "polygon",
1279
+ "amount": "98.41"
1280
+ },
1281
+ "rate": "0.9841",
1282
+ "totalFee": "1.59",
1283
+ "feeBreakdown": [
1284
+ {
1285
+ "id": "provider_fee",
1286
+ "labelFallback": "Processing fee",
1287
+ "amount": "1.09"
1288
+ },
1289
+ {
1290
+ "id": "network_fee",
1291
+ "labelFallback": "Network fee",
1292
+ "amount": "0.50"
1293
+ }
1294
+ ],
1295
+ "paymentMethod": "bank_transfer",
1296
+ "expiresAt": "\u2026"
1297
+ },
1298
+ "surface": {
1299
+ "kind": "PAY_SHEET",
1300
+ "provider": "TRANSAK",
1301
+ "params": {
1302
+ "sessionId": "txs_\u2026",
1303
+ "environment": "STAGING"
1304
+ }
1305
+ }
1306
+ },
1307
+ "provider": "TRANSAK",
1308
+ "transitions": [
1309
+ {
1310
+ "id": "capture",
1311
+ "mode": "CLIENT_SURFACE",
1312
+ "report": {
1313
+ "endpoint": "POST /fiat/orders",
1314
+ "params": {
1315
+ "quoteRef": "q_8f2",
1316
+ "depositAddress": "\u2026"
1317
+ },
1318
+ "expects": [
1319
+ "paymentAuthorisationId"
1320
+ ]
1321
+ }
1322
+ }
1323
+ ]
1324
+ },
1170
1325
  "screen-02-session-auth": {
1171
1326
  "state": {
1172
1327
  "kind": "SESSION_AUTH",
@@ -1305,7 +1460,11 @@ var FIXTURE_DATA = {
1305
1460
  "phase": "CAPTURE",
1306
1461
  "surface": {
1307
1462
  "kind": "PCI_COMPONENT",
1308
- "session": "eyJ\u2026"
1463
+ "provider": "TRANSAK",
1464
+ "params": {
1465
+ "sessionId": "eyJ\u2026",
1466
+ "environment": "STAGING"
1467
+ }
1309
1468
  }
1310
1469
  },
1311
1470
  "provider": "TRANSAK",
@@ -1630,6 +1789,18 @@ var FIXTURES = [
1630
1789
  screen: 1,
1631
1790
  stateKey: "QUOTE"
1632
1791
  },
1792
+ {
1793
+ id: "screen-01b-quote-with-pay-sheet",
1794
+ file: "screen-01b-quote-with-pay-sheet.json",
1795
+ source: "CONTRACT_DOC",
1796
+ docRef: "Fiat Contract 0.7 \xA7The contract diff \xB7 example envelopes",
1797
+ screen: 1,
1798
+ stateKey: "QUOTE",
1799
+ notes: [
1800
+ "The 0.7 single-screen shape: the quote carries its own PAY_SHEET, so the provider component is the primary CTA rather than something a later state reveals.",
1801
+ "Quote payload is screen-01-quote verbatim; only the surface and the transition differ."
1802
+ ]
1803
+ },
1633
1804
  {
1634
1805
  id: "screen-02-session-auth",
1635
1806
  file: "screen-02-session-auth.json",
@@ -1867,6 +2038,16 @@ function checkLegalEmission(state, transitions) {
1867
2038
  `${entry.key} is terminal (table) and must carry transitions: [] \u2014 got ${transitions.length}`
1868
2039
  );
1869
2040
  }
2041
+ const surfaceTargets = new Set(
2042
+ transitions.filter((t) => t.mode === "CLIENT_SURFACE").map((t) => t.report.endpoint)
2043
+ );
2044
+ for (const transition of transitions) {
2045
+ if (transition.mode === "SUBMIT" && surfaceTargets.has(transition.endpoint)) {
2046
+ issues.push(
2047
+ `transition "${transition.id}": SUBMIT ${transition.endpoint} coexists with a CLIENT_SURFACE reporting to it`
2048
+ );
2049
+ }
2050
+ }
1870
2051
  for (const transition of transitions) {
1871
2052
  issues.push(...bodyKeyIssues(transition));
1872
2053
  if (transition.mode === "CLIENT_SURFACE" && !("surface" in state)) {
package/dist/index.mjs CHANGED
@@ -5,10 +5,14 @@ import {
5
5
  TABLE_VERSION,
6
6
  TERMINAL_ORDER_STATUSES,
7
7
  TRANSITION_TABLE,
8
+ UNOFFERED_ENDPOINTS,
9
+ UNREACHABLE_STATES,
8
10
  isTerminal,
9
11
  stateKey,
10
- tableEntry
11
- } from "./chunk-EHYDQU4T.mjs";
12
+ tableEntry,
13
+ unofferedEndpoints,
14
+ unreachableStates
15
+ } from "./chunk-OGNYHIOE.mjs";
12
16
 
13
17
  // src/codes.ts
14
18
  var COUNTRY_CURRENCY_LIST = [
@@ -291,8 +295,12 @@ var HTTP_VERBS = ["GET", "POST"];
291
295
  var ENDPOINT_PATTERN = /^(GET|POST) \/fiat\/[^\s]*$/;
292
296
  var $endpoint = z.string().regex(ENDPOINT_PATTERN, 'endpoint must read "<GET|POST> /fiat/\u2026"');
293
297
  var FiatEndpointSchema = $endpoint;
294
- var $provider = z.enum(["TRANSAK", "SWAPPED"]);
298
+ var $provider = z.enum(["TRANSAK", "SWAPPED", "BANXA"]);
295
299
  var FiatProviderSchema = $provider;
300
+ var $kycProvider = z.enum(["SUMSUB"]);
301
+ var KYCProviderSchema = $kycProvider;
302
+ var $surfaceProvider = z.enum([...$provider.options, ...$kycProvider.options]);
303
+ var SurfaceProviderSchema = $surfaceProvider;
296
304
  var $orderStatus = z.enum([
297
305
  "CREATING",
298
306
  "AWAITING_PAYMENT",
@@ -384,13 +392,22 @@ var $refund = obj({
384
392
  expectedBy: z.string()
385
393
  });
386
394
  var RefundSchema = $refund;
387
- var $surface = z.union([
388
- obj({ kind: z.literal("URL_REDIRECT"), url: z.string() }),
389
- obj({ kind: z.literal("URL_EMBED"), url: z.string() }),
390
- obj({ kind: z.literal("PCI_COMPONENT"), session: z.string() }),
391
- obj({ kind: z.literal("PAY_SHEET"), session: z.string() }),
392
- obj({ kind: z.literal("ACH_COMPONENT"), session: z.string() })
393
- ]);
395
+ var $surfaceKind = z.enum(["URL_REDIRECT", "URL_EMBED", "PCI_COMPONENT", "PAY_SHEET", "ACH_COMPONENT"]);
396
+ var SurfaceKindSchema = $surfaceKind;
397
+ var $surface = obj({
398
+ kind: $surfaceKind,
399
+ provider: $surfaceProvider,
400
+ /**
401
+ * The one deliberate non-strict leaf in the contract. Provider inputs are the adapter's
402
+ * vocabulary, so typing them here would drag every provider field change through a contract
403
+ * release and both consumers — and a normalised `environment` could only be lossy across
404
+ * Transak's four values and Banxa's two. Both ends narrow instead: fun-backend emits from a
405
+ * typed provider session, the client adapter validates with its own schema, and a recorded
406
+ * fixture per `(provider, kind)` keeps the two honest.
407
+ */
408
+ params: z.record(z.string(), JsonSchema),
409
+ expiresAt: z.string().optional()
410
+ });
394
411
  var SurfaceSchema = $surface;
395
412
  var $instructionField = obj({
396
413
  id: z.string(),
@@ -520,6 +537,8 @@ var AuthChallengeSchema = $authChallenge;
520
537
  var $quoteState = obj({
521
538
  kind: z.literal("QUOTE"),
522
539
  quote: $quote,
540
+ /** Present when the provider's component is the primary CTA, inline on the entry screen. */
541
+ surface: $surface.optional(),
523
542
  error: $failureReason.optional()
524
543
  });
525
544
  var QuoteStateSchema = $quoteState;
@@ -674,6 +693,64 @@ var FIXTURE_DATA = {
674
693
  }
675
694
  ]
676
695
  },
696
+ "screen-01b-quote-with-pay-sheet": {
697
+ "state": {
698
+ "kind": "QUOTE",
699
+ "quote": {
700
+ "quoteRef": "q_8f2",
701
+ "fiat": {
702
+ "currency": "GBP",
703
+ "amount": "100.00"
704
+ },
705
+ "crypto": {
706
+ "currency": "USDC",
707
+ "network": "polygon",
708
+ "amount": "98.41"
709
+ },
710
+ "rate": "0.9841",
711
+ "totalFee": "1.59",
712
+ "feeBreakdown": [
713
+ {
714
+ "id": "provider_fee",
715
+ "labelFallback": "Processing fee",
716
+ "amount": "1.09"
717
+ },
718
+ {
719
+ "id": "network_fee",
720
+ "labelFallback": "Network fee",
721
+ "amount": "0.50"
722
+ }
723
+ ],
724
+ "paymentMethod": "bank_transfer",
725
+ "expiresAt": "\u2026"
726
+ },
727
+ "surface": {
728
+ "kind": "PAY_SHEET",
729
+ "provider": "TRANSAK",
730
+ "params": {
731
+ "sessionId": "txs_\u2026",
732
+ "environment": "STAGING"
733
+ }
734
+ }
735
+ },
736
+ "provider": "TRANSAK",
737
+ "transitions": [
738
+ {
739
+ "id": "capture",
740
+ "mode": "CLIENT_SURFACE",
741
+ "report": {
742
+ "endpoint": "POST /fiat/orders",
743
+ "params": {
744
+ "quoteRef": "q_8f2",
745
+ "depositAddress": "\u2026"
746
+ },
747
+ "expects": [
748
+ "paymentAuthorisationId"
749
+ ]
750
+ }
751
+ }
752
+ ]
753
+ },
677
754
  "screen-02-session-auth": {
678
755
  "state": {
679
756
  "kind": "SESSION_AUTH",
@@ -812,7 +889,11 @@ var FIXTURE_DATA = {
812
889
  "phase": "CAPTURE",
813
890
  "surface": {
814
891
  "kind": "PCI_COMPONENT",
815
- "session": "eyJ\u2026"
892
+ "provider": "TRANSAK",
893
+ "params": {
894
+ "sessionId": "eyJ\u2026",
895
+ "environment": "STAGING"
896
+ }
816
897
  }
817
898
  },
818
899
  "provider": "TRANSAK",
@@ -1137,6 +1218,18 @@ var FIXTURES = [
1137
1218
  screen: 1,
1138
1219
  stateKey: "QUOTE"
1139
1220
  },
1221
+ {
1222
+ id: "screen-01b-quote-with-pay-sheet",
1223
+ file: "screen-01b-quote-with-pay-sheet.json",
1224
+ source: "CONTRACT_DOC",
1225
+ docRef: "Fiat Contract 0.7 \xA7The contract diff \xB7 example envelopes",
1226
+ screen: 1,
1227
+ stateKey: "QUOTE",
1228
+ notes: [
1229
+ "The 0.7 single-screen shape: the quote carries its own PAY_SHEET, so the provider component is the primary CTA rather than something a later state reveals.",
1230
+ "Quote payload is screen-01-quote verbatim; only the surface and the transition differ."
1231
+ ]
1232
+ },
1140
1233
  {
1141
1234
  id: "screen-02-session-auth",
1142
1235
  file: "screen-02-session-auth.json",
@@ -1374,6 +1467,16 @@ function checkLegalEmission(state, transitions) {
1374
1467
  `${entry.key} is terminal (table) and must carry transitions: [] \u2014 got ${transitions.length}`
1375
1468
  );
1376
1469
  }
1470
+ const surfaceTargets = new Set(
1471
+ transitions.filter((t) => t.mode === "CLIENT_SURFACE").map((t) => t.report.endpoint)
1472
+ );
1473
+ for (const transition of transitions) {
1474
+ if (transition.mode === "SUBMIT" && surfaceTargets.has(transition.endpoint)) {
1475
+ issues.push(
1476
+ `transition "${transition.id}": SUBMIT ${transition.endpoint} coexists with a CLIENT_SURFACE reporting to it`
1477
+ );
1478
+ }
1479
+ }
1377
1480
  for (const transition of transitions) {
1378
1481
  issues.push(...bodyKeyIssues(transition));
1379
1482
  if (transition.mode === "CLIENT_SURFACE" && !("surface" in state)) {
@@ -1463,6 +1566,7 @@ export {
1463
1566
  InstructionsSchema,
1464
1567
  InstrumentSchema,
1465
1568
  JsonSchema,
1569
+ KYCProviderSchema,
1466
1570
  KycCaptureStateSchema,
1467
1571
  KycInputRequiredStateSchema,
1468
1572
  KycNoActionRequiredStateSchema,
@@ -1485,6 +1589,8 @@ export {
1485
1589
  SelectOptionSchema,
1486
1590
  SessionAuthStateSchema,
1487
1591
  StatusHistoryEntrySchema,
1592
+ SurfaceKindSchema,
1593
+ SurfaceProviderSchema,
1488
1594
  SurfaceSchema,
1489
1595
  TABLE_VERSION,
1490
1596
  TERMINAL_ORDER_STATUSES,
@@ -1493,6 +1599,8 @@ export {
1493
1599
  TransitionParamsSchema,
1494
1600
  TransitionSchema,
1495
1601
  TxSchema,
1602
+ UNOFFERED_ENDPOINTS,
1603
+ UNREACHABLE_STATES,
1496
1604
  assertAllFixtures,
1497
1605
  assertFiatStepResponse,
1498
1606
  assertFixture,
@@ -1507,6 +1615,8 @@ export {
1507
1615
  looksLikeFieldSpec,
1508
1616
  stateKey,
1509
1617
  tableEntry,
1618
+ unofferedEndpoints,
1619
+ unreachableStates,
1510
1620
  walkTable
1511
1621
  };
1512
1622
  //# sourceMappingURL=index.mjs.map
package/dist/schemas.d.ts CHANGED
@@ -27,13 +27,15 @@
27
27
  * They are module-private, so none of it reaches the published declarations.
28
28
  */
29
29
  import { z } from 'zod';
30
- import type { CryptoAmount, FailureReason, FeeLine, FiatEndpoint, FieldSpec, FlowState, FormDescriptor, FormField, FormFieldType, Instructions, InstructionField, Instrument, JsonValue, FiatAmount, OrderRef, OrderStatus, OrderSummary, PollSpec, FiatProvider, Quote, Recovery, Refund, ReportSpec, SelectOption, StatusHistoryEntry, FiatStepResponse, Surface, Transition, TransitionInputs, TransitionParams, Tx } from './types';
30
+ import type { CryptoAmount, FailureReason, FeeLine, FiatEndpoint, FieldSpec, FlowState, FormDescriptor, FormField, FormFieldType, Instructions, InstructionField, Instrument, JsonValue, FiatAmount, OrderRef, OrderStatus, OrderSummary, PollSpec, FiatProvider, Quote, Recovery, Refund, ReportSpec, SelectOption, StatusHistoryEntry, FiatStepResponse, Surface, SurfaceKind, SurfaceProvider, KYCProvider, Transition, TransitionInputs, TransitionParams, Tx } from './types';
31
31
  export declare const JsonSchema: z.ZodType<JsonValue>;
32
32
  export declare const HTTP_VERBS: readonly ["GET", "POST"];
33
33
  /** `"POST /fiat/session/verify"`, `"GET /fiat/orders/o_31c"` — the verb rides the string. */
34
34
  export declare const ENDPOINT_PATTERN: RegExp;
35
35
  export declare const FiatEndpointSchema: z.ZodType<FiatEndpoint>;
36
36
  export declare const FiatProviderSchema: z.ZodType<FiatProvider>;
37
+ export declare const KYCProviderSchema: z.ZodType<KYCProvider>;
38
+ export declare const SurfaceProviderSchema: z.ZodType<SurfaceProvider>;
37
39
  export declare const OrderStatusSchema: z.ZodType<OrderStatus>;
38
40
  export declare const RecoverySchema: z.ZodType<Recovery>;
39
41
  export declare const FailureReasonSchema: z.ZodType<FailureReason>;
@@ -47,6 +49,7 @@ export declare const OrderSummarySchema: z.ZodType<OrderSummary>;
47
49
  export declare const StatusHistoryEntrySchema: z.ZodType<StatusHistoryEntry>;
48
50
  export declare const TxSchema: z.ZodType<Tx>;
49
51
  export declare const RefundSchema: z.ZodType<Refund>;
52
+ export declare const SurfaceKindSchema: z.ZodType<SurfaceKind>;
50
53
  export declare const SurfaceSchema: z.ZodType<Surface>;
51
54
  export declare const InstructionFieldSchema: z.ZodType<InstructionField>;
52
55
  export declare const InstructionsSchema: z.ZodType<Instructions>;
package/dist/table.d.ts CHANGED
@@ -84,10 +84,45 @@ export interface TableEntry {
84
84
  export declare const TRANSITION_TABLE: Readonly<Record<StateKey, TableEntry>>;
85
85
  export declare const STATE_KEYS: StateKey[];
86
86
  /**
87
- * The `/fiat/*` surface as enumerated in contract §Endpoints. Informational data — emissions are
88
- * checked against each state's `allowedTransitions`, not against this list.
87
+ * The `/fiat/*` surface. Informational data — emissions are checked against each state's
88
+ * `allowedTransitions`, not against this list.
89
+ *
90
+ * Sourced from contract §Endpoints, with one deliberate divergence: `POST /fiat/payment-session` is
91
+ * named by the table (QUOTE's capture-first entry) while the doc's endpoint table still lists no
92
+ * session-minting call for that rail.
93
+ */
94
+ export declare const DOCUMENTED_ENDPOINTS: readonly ["GET /fiat/payment-methods", "POST /fiat/quote", "POST /fiat/auth", "POST /fiat/payment-session", "POST /fiat/session", "POST /fiat/session/verify", "GET /fiat/kyc", "POST /fiat/kyc/form", "POST /fiat/kyc/document", "POST /fiat/instruments", "POST /fiat/orders", "GET /fiat/orders/:id", "GET /fiat/orders/:id/instructions", "POST /fiat/orders/:id/confirm-payment", "POST /fiat/orders/:id/reference", "POST /fiat/orders/:id/cancel", "POST /fiat/orders/:id/surface-result"];
95
+ /**
96
+ * Documented endpoints no flow state offers, and why. Declared so an endpoint nothing can reach is
97
+ * visible instead of silent — the table test asserts the unoffered set equals exactly these.
98
+ */
99
+ export declare const UNOFFERED_ENDPOINTS: readonly {
100
+ endpoint: EndpointTemplate;
101
+ reason: string;
102
+ }[];
103
+ /**
104
+ * States entered out-of-band rather than by returning from a call. Declared so an unreachable state
105
+ * is visible instead of silent — the table test asserts reachability from QUOTE equals (all state
106
+ * keys − these).
107
+ */
108
+ export declare const UNREACHABLE_STATES: readonly {
109
+ stateKey: StateKey;
110
+ reason: string;
111
+ }[];
112
+ /**
113
+ * The live answer to what `UNOFFERED_ENDPOINTS` declares: documented endpoints that no state's
114
+ * `allowedTransitions` names. The table test asserts the two agree, so a new orphan and a stale
115
+ * declaration both fail.
116
+ */
117
+ export declare function unofferedEndpoints(): readonly EndpointTemplate[];
118
+ /**
119
+ * The live answer to what `UNREACHABLE_STATES` declares: states no `mayReturn` path reaches from
120
+ * `QUOTE`, the flow's entry. The table test asserts the two agree.
121
+ *
122
+ * `mayReturn` is the only edge set the contract publishes, so it is the only graph there is to walk
123
+ * — a state absent from every one of them cannot be arrived at by following the contract.
89
124
  */
90
- export declare const DOCUMENTED_ENDPOINTS: readonly ["GET /fiat/payment-methods", "POST /fiat/quote", "POST /fiat/auth", "POST /fiat/session", "POST /fiat/session/verify", "GET /fiat/kyc", "POST /fiat/kyc/form", "POST /fiat/kyc/document", "POST /fiat/instruments", "POST /fiat/orders", "GET /fiat/orders/:id", "GET /fiat/orders/:id/instructions", "POST /fiat/orders/:id/confirm-payment", "POST /fiat/orders/:id/reference", "POST /fiat/orders/:id/cancel", "POST /fiat/orders/:id/surface-result"];
125
+ export declare function unreachableStates(from?: StateKey): readonly StateKey[];
91
126
  /** State → its table key. The one place kind/phase/reason is flattened. */
92
127
  export declare function stateKey(state: FlowState): StateKey;
93
128
  export declare const tableEntry: (state: FlowState) => TableEntry;
package/dist/table.js CHANGED
@@ -25,14 +25,18 @@ __export(table_exports, {
25
25
  TABLE_VERSION: () => TABLE_VERSION,
26
26
  TERMINAL_ORDER_STATUSES: () => TERMINAL_ORDER_STATUSES,
27
27
  TRANSITION_TABLE: () => TRANSITION_TABLE,
28
+ UNOFFERED_ENDPOINTS: () => UNOFFERED_ENDPOINTS,
29
+ UNREACHABLE_STATES: () => UNREACHABLE_STATES,
28
30
  isTerminal: () => isTerminal,
29
31
  stateKey: () => stateKey,
30
- tableEntry: () => tableEntry
32
+ tableEntry: () => tableEntry,
33
+ unofferedEndpoints: () => unofferedEndpoints,
34
+ unreachableStates: () => unreachableStates
31
35
  });
32
36
  module.exports = __toCommonJS(table_exports);
33
37
 
34
38
  // package.json
35
- var version = "0.6.0";
39
+ var version = "0.7.0";
36
40
 
37
41
  // src/table.ts
38
42
  var TABLE_VERSION = version;
@@ -63,22 +67,46 @@ var TRANSITION_TABLE = {
63
67
  endpoint: "POST /fiat/session",
64
68
  when: "no valid provider session token (own-tables check)"
65
69
  },
70
+ {
71
+ id: "open_session",
72
+ mode: "SUBMIT",
73
+ endpoint: "POST /fiat/payment-session",
74
+ when: "returning user, session + KYC valid, capture-first rail \u2014 the method authorises before any order exists",
75
+ note: "Returns PAYMENT{CAPTURE} carrying the Surface. Without this entry the capture-first rail has no legal way in: a transition-following client takes POST /fiat/orders instead, and the backend refuses that when the provider declares the capture-first capability and no authorisation is present. Named open_session per the Fiat Contract 0.7 doc, which adds quote-time-minted surface rows beside it."
76
+ },
66
77
  {
67
78
  id: "continue",
68
79
  mode: "SUBMIT",
69
80
  endpoint: "POST /fiat/orders",
70
- when: "returning user, session + KYC valid \u2014 the quote screen doubles as review"
81
+ when: "returning user, session + KYC valid, order-first rail \u2014 the quote screen doubles as review",
82
+ note: "Narrowed to the order-first rail: on a capture-first method the order cannot be created before the payment is authorised, so the sibling payment-session entry is the one that applies."
71
83
  },
72
84
  {
73
85
  id: "continue",
74
86
  mode: "SUBMIT",
75
87
  endpoint: "POST /fiat/auth",
76
88
  when: "no ambient Fun identity (non-fomo surfaces only)"
89
+ },
90
+ {
91
+ id: "capture",
92
+ mode: "CLIENT_SURFACE",
93
+ endpoint: "POST /fiat/orders",
94
+ when: "capture-then-order rail whose session is minted at quote time (inline pay sheet)",
95
+ note: "The 0.7 row that makes the single-screen Apple Pay design legal: the surface rides QUOTE, the provider component IS the primary CTA, and its result creates the order. Apple policy forbids a visible step between an Apple-branded tap and the sheet, and TransakApplePay exposes no programmatic trigger, so a two-step via payment-session cannot serve this rail. Requires state.surface (\u03942)."
96
+ },
97
+ {
98
+ id: "capture",
99
+ mode: "CLIENT_SURFACE",
100
+ endpoint: "POST /fiat/orders/:id/surface-result",
101
+ when: "order-then-capture rail whose provider order was created with the quote-time session",
102
+ note: "Same quote-time placement, other topology: the order already exists, so the surface reports against it rather than creating one."
77
103
  }
78
104
  ],
79
105
  mayReturn: [
80
106
  "SESSION_AUTH",
81
107
  ...KYC_ANY,
108
+ "PAYMENT/CAPTURE",
109
+ "PAYMENT/INSTRUCT",
82
110
  "PENDING_ORDER",
83
111
  "ORDER/AWAITING_CONFIRMATION",
84
112
  "ORDER/CREATED",
@@ -86,8 +114,10 @@ var TRANSITION_TABLE = {
86
114
  ],
87
115
  terminal: false,
88
116
  notes: [
89
- "Re-quote on amount change is a client-local event, not a transition.",
90
- 'If the PENDING_ORDER removal lands, QUOTE additionally returns PAYMENT{INSTRUCT} / ORDER{CREATED} directly. TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER".'
117
+ "Re-quote on amount change is a client-local event, not a transition. A new quote carries a new surface; the client remounts the adapter rather than reusing the old session.",
118
+ "A surface on QUOTE and a SUBMIT to the same endpoint may not coexist \u2014 two paths to one order. assertLegalEmission enforces it; the server picks one per rail.",
119
+ `Both PAYMENT states are reachable directly from QUOTE: the FE doc canvas draws S1 \u2192 S5 (card, capture-first) and S1 \u2192 S6 (bank). This entry was first ported from the FE doc's bindings table, whose "May receive back" column omits both \u2014 the canvas is the correct source of the two.`,
120
+ 'TODO(open-decision): contract \xA7"Proposal: remove PENDING_ORDER" would additionally have QUOTE return ORDER{CREATED} for an active order. That decision does not gate the PAYMENT edges above.'
91
121
  ]
92
122
  },
93
123
  FUN_AUTH: {
@@ -385,6 +415,7 @@ var DOCUMENTED_ENDPOINTS = [
385
415
  "GET /fiat/payment-methods",
386
416
  "POST /fiat/quote",
387
417
  "POST /fiat/auth",
418
+ "POST /fiat/payment-session",
388
419
  "POST /fiat/session",
389
420
  "POST /fiat/session/verify",
390
421
  "GET /fiat/kyc",
@@ -399,6 +430,54 @@ var DOCUMENTED_ENDPOINTS = [
399
430
  "POST /fiat/orders/:id/cancel",
400
431
  "POST /fiat/orders/:id/surface-result"
401
432
  ];
433
+ var UNOFFERED_ENDPOINTS = [
434
+ {
435
+ endpoint: "GET /fiat/payment-methods",
436
+ reason: "Discovery, called before any flow state exists \u2014 there is no state to hang it off."
437
+ },
438
+ {
439
+ endpoint: "POST /fiat/kyc/document",
440
+ reason: "FILE fields stream to it directly; the harness owns that upload path and it is not a transition (see KYC/INPUT_REQUIRED notes)."
441
+ },
442
+ {
443
+ endpoint: "POST /fiat/instruments",
444
+ reason: "No state offers it and no published doc explains why. TODO(open-decision): saved instruments may simply be unreached in v1 \u2014 confirm, or wire it to the state that should offer it."
445
+ },
446
+ {
447
+ endpoint: "GET /fiat/orders/:id/instructions",
448
+ reason: "PAYMENT{INSTRUCT} carries its instructions in state and offers no fetch. TODO(open-decision): that state also notes no re-issue endpoint exists, which sits oddly beside this one being published \u2014 confirm or retire it."
449
+ },
450
+ {
451
+ endpoint: "POST /fiat/orders/:id/reference",
452
+ reason: "The bank-reference submission is offered by no state. TODO(open-decision): PAYMENT{INSTRUCT} is the plausible home \u2014 confirm or wire it."
453
+ }
454
+ ];
455
+ var UNREACHABLE_STATES = [
456
+ {
457
+ stateKey: "FUN_AUTH",
458
+ reason: "No bindings row published (docStatus UNSPECIFIED) and never rendered in fomo, where identity is ambient. Entered out-of-band, not by a mayReturn edge."
459
+ }
460
+ ];
461
+ function unofferedEndpoints() {
462
+ const offered = /* @__PURE__ */ new Set();
463
+ for (const key of STATE_KEYS) {
464
+ for (const allowed of TRANSITION_TABLE[key].allowedTransitions) offered.add(allowed.endpoint);
465
+ }
466
+ return DOCUMENTED_ENDPOINTS.filter((endpoint) => !offered.has(endpoint));
467
+ }
468
+ function unreachableStates(from = "QUOTE") {
469
+ const seen = /* @__PURE__ */ new Set([from]);
470
+ const queue = [from];
471
+ while (queue.length > 0) {
472
+ for (const target of TRANSITION_TABLE[queue.pop()].mayReturn ?? []) {
473
+ if (!seen.has(target)) {
474
+ seen.add(target);
475
+ queue.push(target);
476
+ }
477
+ }
478
+ }
479
+ return STATE_KEYS.filter((key) => !seen.has(key));
480
+ }
402
481
  function stateKey(state) {
403
482
  switch (state.kind) {
404
483
  case "QUOTE":
package/dist/table.mjs CHANGED
@@ -4,18 +4,26 @@ import {
4
4
  TABLE_VERSION,
5
5
  TERMINAL_ORDER_STATUSES,
6
6
  TRANSITION_TABLE,
7
+ UNOFFERED_ENDPOINTS,
8
+ UNREACHABLE_STATES,
7
9
  isTerminal,
8
10
  stateKey,
9
- tableEntry
10
- } from "./chunk-EHYDQU4T.mjs";
11
+ tableEntry,
12
+ unofferedEndpoints,
13
+ unreachableStates
14
+ } from "./chunk-OGNYHIOE.mjs";
11
15
  export {
12
16
  DOCUMENTED_ENDPOINTS,
13
17
  STATE_KEYS,
14
18
  TABLE_VERSION,
15
19
  TERMINAL_ORDER_STATUSES,
16
20
  TRANSITION_TABLE,
21
+ UNOFFERED_ENDPOINTS,
22
+ UNREACHABLE_STATES,
17
23
  isTerminal,
18
24
  stateKey,
19
- tableEntry
25
+ tableEntry,
26
+ unofferedEndpoints,
27
+ unreachableStates
20
28
  };
21
29
  //# sourceMappingURL=table.mjs.map
package/dist/types.d.ts CHANGED
@@ -65,8 +65,10 @@ export type FiatEndpoint = 'GET /fiat/payment-methods' | 'POST /fiat/quote' | 'P
65
65
  * `SWAPPED` added at fun-backend's request (2026-08-17) so its legacy fops payment-provider id
66
66
  * has one shared vocabulary instead of a second fun-backend-local enum — it never appears as a
67
67
  * `FiatStepResponse.provider` value since Swapped's fops flow doesn't emit this response.
68
+ * `BANXA` added at 0.7 (Provider Landscape: Tier 1). A vendor name here is a routing key only —
69
+ * it never decides which fields exist, which is what keeps provider churn out of this package.
68
70
  */
69
- export type FiatProvider = 'TRANSAK' | 'SWAPPED';
71
+ export type FiatProvider = 'TRANSAK' | 'SWAPPED' | 'BANXA';
70
72
  /**
71
73
  * The order status vocabulary. Monotonic ranks, no backwards moves. Terminal outcomes are
72
74
  * OrderStatus, never FlowState kinds.
@@ -199,27 +201,45 @@ export interface Refund {
199
201
  amount: string;
200
202
  expectedBy: string;
201
203
  }
204
+ /** What the user sees, and therefore where it can be placed. */
205
+ export type SurfaceKind = 'URL_REDIRECT' | 'URL_EMBED' | 'PCI_COMPONENT' | 'PAY_SHEET' | 'ACH_COMPONENT';
202
206
  /**
203
- * Carried as *data* in CAPTURE phases only (`PAYMENT{CAPTURE}` · `KYC{CAPTURE}`).
204
- * Δ2: the CLIENT_SURFACE transition carries just `report`single-carry, no duplication.
205
- * `session` values are secrets: never log them (FE doc §Decisions, redaction).
207
+ * Identity vendors Fun verifies into. Its own axis rather than a `FiatProvider` value, because
208
+ * Fun is the KYC-of-record in its own tenantan identity surface is never the onramp's to mint.
209
+ * `SUMSUB` alone at 0.7; adding a value later is additive, and a vendor with no installed adapter
210
+ * lands on the client's existing unsupported screen.
206
211
  */
207
- export type Surface = {
208
- kind: 'URL_REDIRECT';
209
- url: string;
210
- } | {
211
- kind: 'URL_EMBED';
212
- url: string;
213
- } | {
214
- kind: 'PCI_COMPONENT';
215
- session: string;
216
- } | {
217
- kind: 'PAY_SHEET';
218
- session: string;
219
- } | {
220
- kind: 'ACH_COMPONENT';
221
- session: string;
222
- };
212
+ export type KYCProvider = 'SUMSUB';
213
+ /**
214
+ * Which adapter renders the surface. Can differ from `FiatStepResponse.provider`: an identity
215
+ * surface is Fun's to mint, not the onramp's.
216
+ *
217
+ * A routing key only — it never narrows the shape of `params`.
218
+ */
219
+ export type SurfaceProvider = FiatProvider | KYCProvider;
220
+ /**
221
+ * Carried as data in `QUOTE` (inline in the entry screen's primary slot) and in the CAPTURE
222
+ * phases (a screen of its own). Δ2 unchanged: the CLIENT_SURFACE transition carries only
223
+ * `report`, so a surface is never described twice.
224
+ *
225
+ * `params` is the adapter's vocabulary — opaque here, narrowed at the adapter boundary, and
226
+ * honoured verbatim. A provider's own `environment` value lives here and is never normalised:
227
+ * Transak's is `LOCAL | DEVELOPMENT | STAGING | PRODUCTION`, Banxa's is `sandbox | production`,
228
+ * and URL surfaces carry none, so a Fun-owned enum could only be lossy.
229
+ *
230
+ * `params` values are secrets by default. Client redaction defaults every key closed and opens
231
+ * only names that are safe under *any* provider's vocabulary.
232
+ */
233
+ export interface Surface {
234
+ kind: SurfaceKind;
235
+ provider: SurfaceProvider;
236
+ params: Record<string, JsonValue>;
237
+ /**
238
+ * When the provider stops honouring the session, if it says so. Lets a client re-quote before
239
+ * the button goes dead; absent, the surface lives as long as the quote that carried it.
240
+ */
241
+ expiresAt?: string;
242
+ }
223
243
  export interface InstructionField {
224
244
  id: string;
225
245
  labelFallback: string;
@@ -402,9 +422,16 @@ export type AuthChallenge = Record<string, JsonValue>;
402
422
  * 2. terminal outcomes are OrderStatus, never kinds
403
423
  * 3. every fallible state carries `error?: FailureReason`
404
424
  */
405
- export type FlowState = {
425
+ export type FlowState =
426
+ /**
427
+ * `surface` present means the provider's own component IS the primary CTA, inline on the entry
428
+ * screen — the placement Apple policy forces for a pay sheet, since no visible step may sit
429
+ * between the Apple-branded tap and the sheet. Absent, the quote's SUBMITs drive our button.
430
+ */
431
+ {
406
432
  kind: 'QUOTE';
407
433
  quote: Quote;
434
+ surface?: Surface;
408
435
  error?: FailureReason;
409
436
  } | {
410
437
  kind: 'FUN_AUTH';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fun-xyz/fiat-contract",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Published conformance contract for Fun's headless fiat onramp: FlowState/Transition types, zod schemas, the transition table as data, recorded fixtures, assertion helpers.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -78,4 +78,4 @@
78
78
  ]
79
79
  }
80
80
  }
81
- }
81
+ }