@openlup/platform 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +440 -158
  2. package/README.md +141 -589
  3. package/dist/fulfillment/adapterConformance.js +1 -1
  4. package/dist/fulfillment/bindings.js +1 -1
  5. package/dist/fulfillment/contracts/conformance.d.ts +3 -1
  6. package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
  7. package/dist/fulfillment/contracts/index.d.ts +3 -3
  8. package/dist/fulfillment/contracts/observation.d.ts +21 -1
  9. package/dist/fulfillment/contracts/readback.d.ts +21 -0
  10. package/dist/fulfillment/contracts/store.d.ts +3 -0
  11. package/dist/fulfillment/contracts/sync.d.ts +58 -2
  12. package/dist/fulfillment/dispatchProjection.d.ts +20 -1
  13. package/dist/fulfillment/dispatchProjection.js +97 -6
  14. package/dist/fulfillment/index.d.ts +3 -2
  15. package/dist/fulfillment/index.js +2 -2
  16. package/dist/fulfillment/migrations.d.ts +5 -0
  17. package/dist/fulfillment/migrations.js +1 -0
  18. package/dist/fulfillment/observationProjection.d.ts +24 -3
  19. package/dist/fulfillment/observationProjection.js +65 -12
  20. package/dist/fulfillment/omnipack/client.d.ts +3 -1
  21. package/dist/fulfillment/omnipack/client.js +31 -13
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +107 -45
  24. package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
  25. package/dist/fulfillment/omnipack/mappers.js +24 -2
  26. package/dist/fulfillment/omnipack/observation.d.ts +11 -4
  27. package/dist/fulfillment/omnipack/observation.js +44 -29
  28. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  29. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  30. package/dist/fulfillment/postgresDispatchStore.js +1 -1
  31. package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
  32. package/dist/fulfillment/postgresShipmentSpine.js +17 -3
  33. package/dist/fulfillment/postgresStockSyncPort.js +6 -3
  34. package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
  35. package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
  36. package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +65 -20
  38. package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
  39. package/dist/fulfillment/providerReconciliationWorker.js +75 -19
  40. package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
  41. package/dist/fulfillment/providerStockSyncWorker.js +229 -120
  42. package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
  43. package/dist/fulfillment/verifiedIngress.d.ts +38 -11
  44. package/dist/fulfillment/verifiedIngress.js +36 -8
  45. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  46. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  47. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  48. package/dist/invoicing/accountingLineAllocation.js +69 -0
  49. package/dist/invoicing/adapterConformance.d.ts +42 -0
  50. package/dist/invoicing/adapterConformance.js +296 -0
  51. package/dist/invoicing/artifacts.d.ts +7 -0
  52. package/dist/invoicing/artifacts.js +20 -0
  53. package/dist/invoicing/bindings.d.ts +46 -0
  54. package/dist/invoicing/bindings.js +69 -0
  55. package/dist/invoicing/capabilities.d.ts +11 -0
  56. package/dist/invoicing/capabilities.js +10 -0
  57. package/dist/invoicing/channelPolicy.d.ts +28 -0
  58. package/dist/invoicing/channelPolicy.js +27 -0
  59. package/dist/invoicing/continuity.d.ts +12 -0
  60. package/dist/invoicing/continuity.js +18 -0
  61. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  62. package/dist/invoicing/contracts/capabilities.js +1 -0
  63. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  64. package/dist/invoicing/contracts/companyLookup.js +1 -0
  65. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  66. package/dist/invoicing/contracts/delivery.js +1 -0
  67. package/dist/invoicing/contracts/facts.d.ts +110 -0
  68. package/dist/invoicing/contracts/facts.js +1 -0
  69. package/dist/invoicing/contracts/host.d.ts +245 -0
  70. package/dist/invoicing/contracts/host.js +1 -0
  71. package/dist/invoicing/contracts/index.d.ts +16 -0
  72. package/dist/invoicing/contracts/index.js +1 -0
  73. package/dist/invoicing/contracts/provider.d.ts +126 -0
  74. package/dist/invoicing/contracts/provider.js +1 -0
  75. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  76. package/dist/invoicing/contracts/settlement.js +1 -0
  77. package/dist/invoicing/contracts/store.d.ts +277 -0
  78. package/dist/invoicing/contracts/store.js +1 -0
  79. package/dist/invoicing/contribution.d.ts +21 -0
  80. package/dist/invoicing/contribution.js +19 -0
  81. package/dist/invoicing/correction.d.ts +42 -0
  82. package/dist/invoicing/correction.js +44 -0
  83. package/dist/invoicing/delivery.d.ts +85 -0
  84. package/dist/invoicing/delivery.js +79 -0
  85. package/dist/invoicing/documentHistory.d.ts +50 -0
  86. package/dist/invoicing/documentHistory.js +213 -0
  87. package/dist/invoicing/documentSync.d.ts +24 -0
  88. package/dist/invoicing/documentSync.js +26 -0
  89. package/dist/invoicing/dutyCollector.d.ts +15 -0
  90. package/dist/invoicing/dutyCollector.js +35 -0
  91. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  92. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  93. package/dist/invoicing/handoff.d.ts +37 -0
  94. package/dist/invoicing/handoff.js +62 -0
  95. package/dist/invoicing/index.d.ts +39 -0
  96. package/dist/invoicing/index.js +37 -0
  97. package/dist/invoicing/issue.d.ts +39 -0
  98. package/dist/invoicing/issue.js +55 -0
  99. package/dist/invoicing/migrations.d.ts +48 -0
  100. package/dist/invoicing/migrations.js +25 -0
  101. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  102. package/dist/invoicing/nativeUnsupported.js +22 -0
  103. package/dist/invoicing/paymentReadback.d.ts +20 -0
  104. package/dist/invoicing/paymentReadback.js +47 -0
  105. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  106. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  107. package/dist/invoicing/readModel.d.ts +74 -0
  108. package/dist/invoicing/readModel.js +90 -0
  109. package/dist/invoicing/readback.d.ts +4 -0
  110. package/dist/invoicing/readback.js +30 -0
  111. package/dist/invoicing/reversal.d.ts +37 -0
  112. package/dist/invoicing/reversal.js +28 -0
  113. package/dist/invoicing/sellerRevision.d.ts +19 -0
  114. package/dist/invoicing/sellerRevision.js +14 -0
  115. package/dist/invoicing/settlementSync.d.ts +18 -0
  116. package/dist/invoicing/settlementSync.js +32 -0
  117. package/dist/invoicing/snapshot.d.ts +52 -0
  118. package/dist/invoicing/snapshot.js +141 -0
  119. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  120. package/dist/subscriptions/automaticRenewal.js +81 -0
  121. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  122. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  123. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  124. package/dist/subscriptions/contracts/commands.js +1 -0
  125. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  126. package/dist/subscriptions/contracts/dunning.js +1 -0
  127. package/dist/subscriptions/contracts/index.d.ts +6 -0
  128. package/dist/subscriptions/contracts/index.js +1 -0
  129. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  130. package/dist/subscriptions/contracts/messages.js +1 -0
  131. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  132. package/dist/subscriptions/contracts/renewal.js +1 -0
  133. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  134. package/dist/subscriptions/contracts/stockWait.js +1 -0
  135. package/dist/subscriptions/index.d.ts +39 -0
  136. package/dist/subscriptions/index.js +17 -0
  137. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  138. package/dist/subscriptions/paymentOutcomes.js +27 -0
  139. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  140. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  141. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  142. package/dist/subscriptions/postgresRetryStore.js +142 -0
  143. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  144. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  145. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  146. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  147. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  148. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  149. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  150. package/dist/subscriptions/renewalIdentity.js +4 -0
  151. package/package.json +23 -3
  152. package/release-gates.json +74 -7
  153. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  154. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  155. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  156. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  157. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  158. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  159. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  160. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  161. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  162. package/src/fulfillment/adapterConformance.ts +1 -1
  163. package/src/fulfillment/bindings.ts +1 -1
  164. package/src/fulfillment/contracts/conformance.ts +3 -1
  165. package/src/fulfillment/contracts/dispatch.ts +24 -5
  166. package/src/fulfillment/contracts/index.ts +3 -3
  167. package/src/fulfillment/contracts/observation.ts +11 -3
  168. package/src/fulfillment/contracts/readback.ts +13 -0
  169. package/src/fulfillment/contracts/store.ts +1 -0
  170. package/src/fulfillment/contracts/sync.ts +13 -2
  171. package/src/fulfillment/dispatchProjection.ts +94 -7
  172. package/src/fulfillment/index.ts +3 -2
  173. package/src/fulfillment/migrations.ts +1 -0
  174. package/src/fulfillment/observationProjection.ts +64 -13
  175. package/src/fulfillment/omnipack/client.ts +32 -13
  176. package/src/fulfillment/omnipack/index.ts +99 -39
  177. package/src/fulfillment/omnipack/mappers.ts +23 -2
  178. package/src/fulfillment/omnipack/observation.ts +45 -27
  179. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  180. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  181. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  182. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  183. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  184. package/src/fulfillment/providerDispatchWorker.ts +61 -20
  185. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  186. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  187. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  188. package/src/fulfillment/verifiedIngress.ts +44 -11
  189. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  190. package/src/invoicing/accountingLineAllocation.ts +69 -0
  191. package/src/invoicing/adapterConformance.ts +249 -0
  192. package/src/invoicing/artifacts.ts +19 -0
  193. package/src/invoicing/bindings.ts +80 -0
  194. package/src/invoicing/capabilities.ts +9 -0
  195. package/src/invoicing/channelPolicy.ts +50 -0
  196. package/src/invoicing/continuity.ts +15 -0
  197. package/src/invoicing/contracts/capabilities.ts +15 -0
  198. package/src/invoicing/contracts/companyLookup.ts +13 -0
  199. package/src/invoicing/contracts/delivery.ts +24 -0
  200. package/src/invoicing/contracts/facts.ts +71 -0
  201. package/src/invoicing/contracts/host.ts +84 -0
  202. package/src/invoicing/contracts/index.ts +16 -0
  203. package/src/invoicing/contracts/provider.ts +78 -0
  204. package/src/invoicing/contracts/settlement.ts +39 -0
  205. package/src/invoicing/contracts/store.ts +102 -0
  206. package/src/invoicing/contribution.ts +28 -0
  207. package/src/invoicing/correction.ts +56 -0
  208. package/src/invoicing/delivery.ts +122 -0
  209. package/src/invoicing/documentHistory.ts +301 -0
  210. package/src/invoicing/documentSync.ts +51 -0
  211. package/src/invoicing/dutyCollector.ts +31 -0
  212. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  213. package/src/invoicing/handoff.ts +71 -0
  214. package/src/invoicing/index.ts +39 -0
  215. package/src/invoicing/issue.ts +64 -0
  216. package/src/invoicing/migrations.ts +25 -0
  217. package/src/invoicing/nativeUnsupported.ts +24 -0
  218. package/src/invoicing/paymentReadback.ts +70 -0
  219. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  220. package/src/invoicing/readModel.ts +80 -0
  221. package/src/invoicing/readback.ts +26 -0
  222. package/src/invoicing/reversal.ts +34 -0
  223. package/src/invoicing/sellerRevision.ts +20 -0
  224. package/src/invoicing/settlementSync.ts +37 -0
  225. package/src/invoicing/snapshot.ts +192 -0
  226. package/src/subscriptions/automaticRenewal.ts +87 -0
  227. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  228. package/src/subscriptions/contracts/commands.ts +46 -0
  229. package/src/subscriptions/contracts/dunning.ts +48 -0
  230. package/src/subscriptions/contracts/index.ts +6 -0
  231. package/src/subscriptions/contracts/messages.ts +18 -0
  232. package/src/subscriptions/contracts/renewal.ts +325 -0
  233. package/src/subscriptions/contracts/stockWait.ts +34 -0
  234. package/src/subscriptions/index.ts +24 -0
  235. package/src/subscriptions/paymentOutcomes.ts +23 -0
  236. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  237. package/src/subscriptions/postgresRetryStore.ts +174 -0
  238. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  239. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  240. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  241. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -0,0 +1,122 @@
1
+ import type { InvoiceUnsupported } from "./contracts/provider.js";
2
+ import type { InvoiceStorePort } from "./contracts/store.js";
3
+ import type { InvoiceDeliveryPort } from "./contracts/delivery.js";
4
+ import { assertInvoicePdf } from "./artifacts.js";
5
+
6
+ type DeliveryOutcome = Omit<Extract<Awaited<ReturnType<InvoiceDeliveryPort["send"]>>, { kind: "provider_accepted" }>, "proof">
7
+ | Exclude<Awaited<ReturnType<InvoiceDeliveryPort["send"]>>, { kind: "provider_accepted" }>;
8
+
9
+ /** Donor sequencing over host claims, original keys, M04 transport and fenced
10
+ * finalizers. Native hosts MUST bind terminalFailure before leasing work.
11
+ * With CTL-M09-3's 191000 pin, failedBeforeSend settles failed_before_send with
12
+ * evidence { phase: 'before_send', retrySeconds }: whole seconds clamped
13
+ * to 60..86400, absent/invalid uses 900;
14
+ * terminalFailure uses { phase: 'before_send', retryable: false } for no reclaim.
15
+ * Native store mode refuses before claim unless its host projection is bound.
16
+ * @consumer Host delivery facade and explicit native M04 binding.
17
+ */
18
+ type DeliveryJobInput<Claim, Result extends DeliveryOutcome> = {
19
+ enabled: boolean;
20
+ providerEnabled: boolean;
21
+ deliveryEnabled: boolean;
22
+ claim(): Promise<readonly Claim[] | InvoiceUnsupported>;
23
+ attemptCount(claim: Claim): number;
24
+ validate(claim: Claim): void;
25
+ download(claim: Claim): Promise<{ content: Uint8Array; contentType: string }>;
26
+ filename(claim: Claim): string;
27
+ /** A rejected promise proves failure BEFORE transport entry. Once entered,
28
+ * the M04 binding must return uncertain, including timeout/lost ACK. */
29
+ send(claim: Claim, document: { filename: string; content: Uint8Array; contentType: string }): Promise<Result>;
30
+ failedBeforeSend(claim: Claim, error: unknown, retrySeconds: number): Promise<unknown>;
31
+ /** Must durably stop reclaim using the same fence. Native failed_before_send
32
+ * evidence { phase: 'before_send', retryable: false } sets available_at to infinity. */
33
+ terminalFailure(claim: Claim, result: Extract<Result, { kind: "failed_before_send" | "unsupported" }>): Promise<unknown>;
34
+ uncertain(claim: Claim, result: Extract<Result, { kind: "uncertain" }>): Promise<unknown>;
35
+ accepted(claim: Claim, result: Extract<Result, { kind: "provider_accepted" }>): Promise<unknown>;
36
+ finalizeUncertain(claim: Claim, receiptRef: string): Promise<unknown>;
37
+ };
38
+ type NativeClaim = Exclude<Awaited<ReturnType<InvoiceStorePort["claimDeliveries"]>>, InvoiceUnsupported>[number];
39
+ type NativeDeliveryJobInput = {
40
+ enabled: boolean;
41
+ providerEnabled: boolean;
42
+ deliveryEnabled: boolean;
43
+ store: Pick<InvoiceStorePort, "claimDeliveries" | "settleDelivery">;
44
+ claimOptions: Parameters<InvoiceStorePort["claimDeliveries"]>[0];
45
+ download(claim: NativeClaim): Promise<{ content: Uint8Array; contentType: string }>;
46
+ filename(claim: NativeClaim): string;
47
+ send: InvoiceDeliveryPort["send"];
48
+ };
49
+ /** Host callback mode preserves donor sequencing. Native store mode supplies
50
+ * fenced terminal/backoff finalizers and the M04 envelope from claimDeliveries.
51
+ * @consumer Host delivery facade and explicit native M04 binding.
52
+ */
53
+ export function runInvoiceDeliveryJob(input: NativeDeliveryJobInput): ReturnType<typeof runDeliveryJob>;
54
+ export function runInvoiceDeliveryJob<Claim, Result extends DeliveryOutcome>(input: DeliveryJobInput<Claim, Result>): ReturnType<typeof runDeliveryJob>;
55
+ export function runInvoiceDeliveryJob<Claim, Result extends DeliveryOutcome>(input: NativeDeliveryJobInput | DeliveryJobInput<Claim, Result>): ReturnType<typeof runDeliveryJob> {
56
+ if (!("store" in input)) return runDeliveryJob(input);
57
+ const settle = async (claim: NativeClaim, result: Parameters<InvoiceStorePort["settleDelivery"]>[0]["result"], retrySeconds = 900) => {
58
+ const outcome = await input.store.settleDelivery({ deliveryRef: claim.delivery.deliveryRef, token: claim.token,
59
+ generation: claim.generation, providerDocumentRef: claim.providerDocumentRef, retrySeconds, result });
60
+ if (outcome.kind !== "recorded") throw new Error("invoicing_delivery_fence");
61
+ };
62
+ return runDeliveryJob<NativeClaim, Awaited<ReturnType<InvoiceDeliveryPort["send"]>>>({
63
+ enabled: input.enabled, providerEnabled: input.providerEnabled, deliveryEnabled: input.deliveryEnabled,
64
+ claim: () => input.store.claimDeliveries(input.claimOptions), attemptCount: claim => claim.generation,
65
+ validate: () => {}, download: input.download, filename: input.filename,
66
+ send: (claim, document) => input.send({ ...claim.delivery, ...document }),
67
+ failedBeforeSend: (claim, _error, retrySeconds) => settle(claim, { kind: "failed_before_send", retryable: true, code: "retryable" }, retrySeconds),
68
+ terminalFailure: (claim, result) => settle(claim, result.kind === "failed_before_send" ? result
69
+ : { kind: "failed_before_send", retryable: false, code: "retryable" }),
70
+ uncertain: (claim, result) => settle(claim, result), accepted: (claim, result) => settle(claim, result),
71
+ finalizeUncertain: (claim, receiptRef) => settle(claim, { kind: "uncertain", code: "uncertain_delivery", receiptRef }),
72
+ });
73
+ }
74
+ async function runDeliveryJob<Claim, Result extends DeliveryOutcome>(input: DeliveryJobInput<Claim, Result>) {
75
+ if (!input.enabled) return skipped("provider_email_disabled");
76
+ if (!input.providerEnabled) return skipped("provider_disabled");
77
+ if (!input.deliveryEnabled) return skipped("email_delivery_provider_disabled");
78
+ // JS hosts missing a mandatory terminal policy must refuse before claiming.
79
+ if (typeof input.terminalFailure !== "function") return skipped("host_binding_required");
80
+ const claims = await input.claim();
81
+ if (!Array.isArray(claims)) return skipped("host_binding_required");
82
+ let updated = 0;
83
+ let failures = 0;
84
+ for (const claim of claims as readonly Claim[]) {
85
+ let result: Result;
86
+ const retrySeconds = Math.min(3600, 300 * Math.max(1, input.attemptCount(claim)));
87
+ try {
88
+ input.validate(claim);
89
+ const pdf = await input.download(claim);
90
+ assertInvoicePdf(pdf);
91
+ result = await input.send(claim, { ...pdf, filename: input.filename(claim) });
92
+ } catch (error) {
93
+ failures += 1;
94
+ await input.failedBeforeSend(claim, error, retrySeconds);
95
+ continue;
96
+ }
97
+ if (result.kind === "failed_before_send" || result.kind === "unsupported") {
98
+ failures += 1;
99
+ if (result.kind === "failed_before_send" && result.retryable) {
100
+ await input.failedBeforeSend(claim, result, retrySeconds);
101
+ } else {
102
+ await input.terminalFailure(claim, result as Extract<Result, { kind: "failed_before_send" | "unsupported" }>);
103
+ }
104
+ continue;
105
+ }
106
+ if (result.kind === "uncertain") {
107
+ failures += 1;
108
+ await input.uncertain(claim, result as Extract<Result, { kind: "uncertain" }>);
109
+ continue;
110
+ }
111
+ try {
112
+ await input.accepted(claim, result as Extract<Result, { kind: "provider_accepted" }>);
113
+ updated += 1;
114
+ } catch {
115
+ failures += 1;
116
+ // Acceptance plus a lost local ACK cannot become a retryable send.
117
+ await input.finalizeUncertain(claim, result.receiptRef).catch(() => undefined);
118
+ }
119
+ }
120
+ return { ok: failures === 0, checked: claims.length, updated, skipped: false, failures };
121
+ }
122
+ function skipped(reason: string) { return { ok: true, checked: 0, updated: 0, skipped: true, reason, failures: 0 }; }
@@ -0,0 +1,301 @@
1
+ type InvoiceDocumentRole = "original" | "correction" | "replacement";
2
+ type InvoiceDocumentArtifact = "invoice" | "correction";
3
+ type InvoiceDocumentEmailState = "not_requested" | "pending" | "provider_accepted" | "failed";
4
+
5
+ type InvoiceDocumentHistoryRow = Record<string, unknown>;
6
+
7
+ interface InvoiceDocumentHistoryEntry {
8
+ documentKey: string;
9
+ invoiceId: string;
10
+ parentDocumentRef: string | null;
11
+ orderId: string;
12
+ invoiceRef: string;
13
+ role: InvoiceDocumentRole;
14
+ artifact: InvoiceDocumentArtifact;
15
+ status: string;
16
+ blockedReason: string | null;
17
+ documentKind: string | null;
18
+ providerKind: string | null;
19
+ providerDocumentRef: string | null;
20
+ providerDocumentNumber: string | null;
21
+ clearanceReference: string | null;
22
+ clearanceState: string;
23
+ totalGrossMinor: number | null;
24
+ /**
25
+ * The currency the document was **issued in**, copied from
26
+ * `accounting_invoices.currency` (`NOT NULL CHECK (char_length = 3)`).
27
+ *
28
+ * It travels with the amount rather than being resolved by whoever renders it. An
29
+ * issued invoice is the most historical money the platform holds: it is a document
30
+ * of record whose denomination was fixed the day it was written, and a reader that
31
+ * asked the current settlement profile instead would re-label the customer's own
32
+ * accounting the first time a deployment changed what it sells in.
33
+ */
34
+ currency: string;
35
+ issuedAt: string | null;
36
+ createdAt: string;
37
+ isCurrent: boolean;
38
+ downloadAvailable: boolean;
39
+ emailState: InvoiceDocumentEmailState;
40
+ }
41
+
42
+ interface InvoiceDocumentHistory {
43
+ current: InvoiceDocumentHistoryEntry | null;
44
+ documents: InvoiceDocumentHistoryEntry[];
45
+ }
46
+
47
+ interface ResolveInvoiceDocumentHistoryInput {
48
+ invoices: InvoiceDocumentHistoryRow[];
49
+ operations?: InvoiceDocumentHistoryRow[];
50
+ deliveryOutboxes?: InvoiceDocumentHistoryRow[];
51
+ providerArtifactEligible?: (providerKind: string) => boolean;
52
+ }
53
+
54
+ const CURRENT_STATUSES = new Set(["issued", "accepted"]);
55
+ const HISTORICAL_INVOICE_STATUSES = new Set(["issued", "accepted", "corrected"]);
56
+
57
+ export function resolveInvoiceDocumentHistory({
58
+ invoices,
59
+ operations = [],
60
+ deliveryOutboxes = [],
61
+ providerArtifactEligible = () => true,
62
+ }: ResolveInvoiceDocumentHistoryInput): InvoiceDocumentHistory {
63
+ const orderedInvoices = [...invoices].sort(compareRowsChronologically);
64
+ const correctionOperations = operations.filter((row) => text(row.operation) === "correction_issued");
65
+ const activeInvoice = [...orderedInvoices]
66
+ .filter((row) => currentInvoiceEligible(row, providerArtifactEligible))
67
+ .sort(compareRowsNewestFirst)[0] ?? null;
68
+ const activeInvoiceId = activeInvoice ? text(activeInvoice.id) : null;
69
+ const documents: InvoiceDocumentHistoryEntry[] = [];
70
+
71
+ for (const invoice of orderedInvoices) {
72
+ const invoiceId = text(invoice.id);
73
+ const replacement = nullableText(invoice.correction_of_invoice_id) !== null;
74
+ const providerDocumentRef = nullableText(invoice.provider_document_ref);
75
+ documents.push({
76
+ documentKey: `invoice:${invoiceId}`,
77
+ invoiceId,
78
+ parentDocumentRef: replacement ? `invoice:${nullableText(invoice.correction_of_invoice_id)}` : null,
79
+ orderId: text(invoice.order_id),
80
+ invoiceRef: text(invoice.invoice_ref),
81
+ role: replacement ? "replacement" : "original",
82
+ artifact: "invoice",
83
+ status: text(invoice.status),
84
+ blockedReason: nullableText(invoice.blocked_reason),
85
+ documentKind: nullableText(invoice.document_kind),
86
+ providerKind: nullableText(invoice.provider_kind),
87
+ providerDocumentRef,
88
+ providerDocumentNumber: nullableText(invoice.provider_document_number),
89
+ clearanceReference: nullableText(invoice.clearance_reference),
90
+ clearanceState: text(invoice.clearance_state),
91
+ totalGrossMinor: nonnegativeNumber(invoice.total_gross_minor),
92
+ currency: text(invoice.currency),
93
+ issuedAt: nullableText(invoice.created_at),
94
+ createdAt: text(invoice.created_at),
95
+ isCurrent: invoiceId === activeInvoiceId,
96
+ downloadAvailable: historicalInvoiceDownloadEligible(invoice, providerArtifactEligible),
97
+ emailState: resolveEmailState({
98
+ invoice,
99
+ providerDocumentRef,
100
+ operations,
101
+ deliveryOutboxes,
102
+ preferInvoiceFallback: !correctionProviderDocumentRef(invoice),
103
+ }),
104
+ });
105
+
106
+ const correctionProviderId = correctionProviderDocumentRef(invoice);
107
+ if (!correctionProviderId) continue;
108
+ const correctionOperation = correctionOperations
109
+ .filter((row) => text(row.invoice_id) === invoiceId)
110
+ .sort(compareRowsNewestFirst)[0] ?? null;
111
+ const correctionNumber = correctionProviderDocumentNumber(invoice);
112
+ const correctionCreatedAt = nullableText(correctionOperation?.created_at)
113
+ ?? laterTimestamp(text(invoice.updated_at), text(invoice.created_at));
114
+ const providerKind = nullableText(invoice.provider_kind);
115
+ const persistedCorrectionStatus = normalize(invoice.correction_status);
116
+ const correctionStatus = ["issued", "accepted"].includes(persistedCorrectionStatus)
117
+ ? persistedCorrectionStatus
118
+ : "not_ready";
119
+ documents.push({
120
+ documentKey: `correction:${invoiceId}`,
121
+ invoiceId,
122
+ parentDocumentRef: `invoice:${invoiceId}`,
123
+ orderId: text(invoice.order_id),
124
+ invoiceRef: correctionNumber ?? `correction-${text(invoice.invoice_ref)}`.slice(0, 160),
125
+ role: "correction",
126
+ artifact: "correction",
127
+ status: correctionStatus,
128
+ blockedReason: nullableText(invoice.blocked_reason),
129
+ documentKind: "correction",
130
+ providerKind,
131
+ providerDocumentRef: correctionProviderId,
132
+ providerDocumentNumber: correctionNumber,
133
+ clearanceReference: null,
134
+ clearanceState: "not_required",
135
+ totalGrossMinor: null,
136
+ // A correction has no total of its own on this row, but it is a document of the
137
+ // same invoice and therefore of the same currency. Stating it keeps the field
138
+ // total, so a consumer never has to ask "which document has a currency?".
139
+ currency: text(invoice.currency),
140
+ issuedAt: correctionCreatedAt || null,
141
+ createdAt: correctionCreatedAt,
142
+ isCurrent: false,
143
+ downloadAvailable: Boolean(
144
+ providerKind
145
+ && providerArtifactEligible(providerKind)
146
+ && nullableText(invoice.blocked_reason) === null
147
+ && ["issued", "accepted"].includes(correctionStatus),
148
+ ),
149
+ emailState: resolveEmailState({
150
+ invoice,
151
+ providerDocumentRef: correctionProviderId,
152
+ operations,
153
+ deliveryOutboxes,
154
+ preferInvoiceFallback: true,
155
+ }),
156
+ });
157
+ }
158
+
159
+ documents.sort(compareDocumentsChronologically);
160
+ return {
161
+ current: documents.find((document) => document.isCurrent) ?? null,
162
+ documents,
163
+ };
164
+ }
165
+
166
+ function currentInvoiceEligible(
167
+ row: InvoiceDocumentHistoryRow,
168
+ providerArtifactEligible: (providerKind: string) => boolean,
169
+ ): boolean {
170
+ const providerKind = nullableText(row.provider_kind);
171
+ return Boolean(
172
+ providerKind
173
+ && providerArtifactEligible(providerKind)
174
+ && nullableText(row.provider_document_ref)
175
+ && nullableText(row.blocked_reason) === null
176
+ && CURRENT_STATUSES.has(normalize(row.status)),
177
+ );
178
+ }
179
+
180
+ function historicalInvoiceDownloadEligible(
181
+ row: InvoiceDocumentHistoryRow,
182
+ providerArtifactEligible: (providerKind: string) => boolean,
183
+ ): boolean {
184
+ const providerKind = nullableText(row.provider_kind);
185
+ return Boolean(
186
+ providerKind
187
+ && providerArtifactEligible(providerKind)
188
+ && nullableText(row.provider_document_ref)
189
+ && nullableText(row.blocked_reason) === null
190
+ && HISTORICAL_INVOICE_STATUSES.has(normalize(row.status)),
191
+ );
192
+ }
193
+
194
+ function resolveEmailState({
195
+ invoice,
196
+ providerDocumentRef,
197
+ operations,
198
+ deliveryOutboxes,
199
+ preferInvoiceFallback,
200
+ }: {
201
+ invoice: InvoiceDocumentHistoryRow;
202
+ providerDocumentRef: string | null;
203
+ operations: InvoiceDocumentHistoryRow[];
204
+ deliveryOutboxes: InvoiceDocumentHistoryRow[];
205
+ preferInvoiceFallback: boolean;
206
+ }): InvoiceDocumentEmailState {
207
+ if (!providerDocumentRef) return "not_requested";
208
+ const invoiceId = text(invoice.id);
209
+ const accepted = operations.some((row) => {
210
+ if (text(row.invoice_id) !== invoiceId || text(row.operation) !== "provider_email_sent") return false;
211
+ const payloadProviderId = nullableText(record(row.payload).providerDocumentRef);
212
+ return (payloadProviderId ?? nullableText(row.provider_ref)) === providerDocumentRef;
213
+ });
214
+ if (accepted) return "provider_accepted";
215
+
216
+ const outbox = deliveryOutboxes
217
+ .filter((row) => text(row.invoice_id) === invoiceId)
218
+ .filter((row) => {
219
+ const targetProviderId = nullableText(record(row.metadata).providerDocumentRef);
220
+ return targetProviderId ? targetProviderId === providerDocumentRef : preferInvoiceFallback;
221
+ })
222
+ .sort(compareRowsNewestFirst)[0];
223
+ const outboxStatus = normalize(outbox?.status);
224
+ if (["failed", "uncertain", "cancelled"].includes(outboxStatus)) return "failed";
225
+ if (["pending", "processing", "queued"].includes(outboxStatus)) return "pending";
226
+ if (outboxStatus === "succeeded") return "provider_accepted";
227
+
228
+ if (preferInvoiceFallback) {
229
+ const emailStatus = normalize(invoice.email_status);
230
+ if (emailStatus === "failed") return "failed";
231
+ if (emailStatus === "pending") return "pending";
232
+ if (emailStatus === "sent") return "provider_accepted";
233
+ }
234
+ return "not_requested";
235
+ }
236
+
237
+ function correctionProviderDocumentRef(row: InvoiceDocumentHistoryRow): string | null {
238
+ return nullableText(record(row.metadata).correctionProviderDocumentRef);
239
+ }
240
+
241
+ function correctionProviderDocumentNumber(row: InvoiceDocumentHistoryRow): string | null {
242
+ return nullableText(record(row.metadata).correctionProviderDocumentNumber);
243
+ }
244
+
245
+ function compareRowsChronologically(a: InvoiceDocumentHistoryRow, b: InvoiceDocumentHistoryRow): number {
246
+ return compareTimestamps(a.created_at, b.created_at) || text(a.id).localeCompare(text(b.id));
247
+ }
248
+
249
+ function compareRowsNewestFirst(a: InvoiceDocumentHistoryRow, b: InvoiceDocumentHistoryRow): number {
250
+ return compareTimestamps(b.created_at ?? b.updated_at, a.created_at ?? a.updated_at) || text(b.id).localeCompare(text(a.id));
251
+ }
252
+
253
+ function compareDocumentsChronologically(a: InvoiceDocumentHistoryEntry, b: InvoiceDocumentHistoryEntry): number {
254
+ return compareTimestamps(a.createdAt, b.createdAt)
255
+ || documentRolePriority(a.role) - documentRolePriority(b.role)
256
+ || a.documentKey.localeCompare(b.documentKey);
257
+ }
258
+
259
+ function documentRolePriority(role: InvoiceDocumentRole): number {
260
+ if (role === "original") return 0;
261
+ if (role === "correction") return 1;
262
+ return 2;
263
+ }
264
+
265
+ function laterTimestamp(a: string, b: string): string {
266
+ const aTime = Date.parse(a);
267
+ const bTime = Date.parse(b);
268
+ if (Number.isFinite(aTime) && Number.isFinite(bTime)) return aTime >= bTime ? a : b;
269
+ return a || b;
270
+ }
271
+
272
+ function compareTimestamps(a: unknown, b: unknown): number {
273
+ const aTime = Date.parse(text(a));
274
+ const bTime = Date.parse(text(b));
275
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
276
+ if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
277
+ if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
278
+ return 0;
279
+ }
280
+
281
+ function nonnegativeNumber(value: unknown): number | null {
282
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
283
+ }
284
+
285
+ function record(value: unknown): InvoiceDocumentHistoryRow {
286
+ return value && typeof value === "object" && !Array.isArray(value)
287
+ ? value as InvoiceDocumentHistoryRow
288
+ : {};
289
+ }
290
+
291
+ function normalize(value: unknown): string {
292
+ return text(value).trim().toLowerCase();
293
+ }
294
+
295
+ function text(value: unknown): string {
296
+ return typeof value === "string" ? value : "";
297
+ }
298
+
299
+ function nullableText(value: unknown): string | null {
300
+ return typeof value === "string" && value.trim() ? value.trim() : null;
301
+ }
@@ -0,0 +1,51 @@
1
+ /** Direct donor extraction: host supplies attachment kinds, durable provider
2
+ * references, status/event keys, hashing and recording. Downloads confer no blob
3
+ * storage proof. The optional observation mode retains polling without adding
4
+ * another public runner/export or provider-specific status vocabulary. */
5
+ export async function syncAcceptedInvoiceDocuments<Target, Status, Document>(input: {
6
+ target: Target;
7
+ status: Status;
8
+ downloads: readonly ((target: Target) => Promise<Document>)[];
9
+ record(target: Target, status: Status, documents: readonly Document[]): Promise<unknown>;
10
+ }): Promise<void>;
11
+ export async function syncAcceptedInvoiceDocuments<Target, Status, Document>(input: {
12
+ targets(): Promise<readonly Target[]>;
13
+ observe(target: Target): Promise<Status>;
14
+ recordStatus(target: Target, status: Status): Promise<unknown>;
15
+ accepted(status: Status): boolean;
16
+ downloads: readonly ((target: Target) => Promise<Document>)[];
17
+ record(target: Target, status: Status, documents: readonly Document[]): Promise<unknown>;
18
+ }): Promise<{ ok: boolean; checked: number; updated: number; skipped: false; failures: number }>;
19
+ export async function syncAcceptedInvoiceDocuments<Target, Status, Document>(input: {
20
+ target?: Target;
21
+ status?: Status;
22
+ targets?: () => Promise<readonly Target[]>;
23
+ observe?: (target: Target) => Promise<Status>;
24
+ recordStatus?: (target: Target, status: Status) => Promise<unknown>;
25
+ accepted?: (status: Status) => boolean;
26
+ downloads: readonly ((target: Target) => Promise<Document>)[];
27
+ record(target: Target, status: Status, documents: readonly Document[]): Promise<unknown>;
28
+ }): Promise<void | { ok: boolean; checked: number; updated: number; skipped: false; failures: number }> {
29
+ const sync = async (target: Target, status: Status) => {
30
+ const documents = await Promise.all(input.downloads.map(download => download(target)));
31
+ await input.record(target, status, documents);
32
+ };
33
+ if (!input.targets) {
34
+ await sync(input.target as Target, input.status as Status);
35
+ return;
36
+ }
37
+ const targets = await input.targets();
38
+ let updated = 0;
39
+ let failures = 0;
40
+ for (const target of targets) {
41
+ try {
42
+ const status = await input.observe!(target);
43
+ await input.recordStatus!(target, status);
44
+ if (input.accepted!(status)) await sync(target, status);
45
+ updated += 1;
46
+ } catch {
47
+ failures += 1;
48
+ }
49
+ }
50
+ return { ok: failures === 0, checked: targets.length, updated, skipped: false as const, failures };
51
+ }
@@ -0,0 +1,31 @@
1
+ import type { InvoiceStorePort, InvoiceUnsupported } from "./contracts/index.js";
2
+
3
+ /** Collect retained sources, then prepare each leased duty in separate transactions.
4
+ * The host commits every store call; no money/parcel callback or provider I/O is
5
+ * accepted here. Missing facts stay owed until the host installs their revision.
6
+ * @consumer Independent invoice duty binding.
7
+ */
8
+ export async function collectInvoiceDuties(input: {
9
+ store: Pick<InvoiceStorePort, "sweep" | "prepareFacts" | "finishFacts">;
10
+ limit?: number;
11
+ }): Promise<{ kind: "collected"; collected: number; prepared: number; failures: number } | InvoiceUnsupported> {
12
+ const batch = await input.store.sweep(input.limit ?? 10);
13
+ if ("kind" in batch) return batch;
14
+ let prepared = 0, failures = 0;
15
+ for (const claim of batch.claims) {
16
+ try {
17
+ const result = await input.store.prepareFacts(claim);
18
+ if ("kind" in result) { failures++; continue; }
19
+ if (result.status === "stale") continue;
20
+ const finished = await input.store.finishFacts({ ...claim, result: { ...result, status: result.status } });
21
+ if ("kind" in finished) failures++;
22
+ else if (finished.status === "ready" || finished.status === "issued" || finished.status === "voided") prepared++;
23
+ else if (finished.status !== "stale") failures++;
24
+ } catch {
25
+ // A failed prepare/finalize leaves the persisted lease reclaimable. Never
26
+ // manufacture a successful finish or prevent another claim's progress.
27
+ failures++;
28
+ }
29
+ }
30
+ return { kind: "collected", collected: batch.collected, prepared, failures };
31
+ }
@@ -0,0 +1,144 @@
1
+ interface AccountingInvoiceIssuePort {
2
+ requestInvoiceIssueFromPaidOrder(input: { idempotencyKey: string; orderId: string; providerKind: string }): Promise<unknown>;
3
+ requestInvoiceIssueFromFulfillmentHandoff(input: { idempotencyKey: string; fulfillmentOrderId: string; providerKind: string }): Promise<unknown>;
4
+ }
5
+ import {
6
+ decideChannelInvoice,
7
+ type OrderInvoicePolicyReader,
8
+ } from "./channelPolicy.js";
9
+
10
+ // Structural mirror of the commerce OrderPaidFulfillmentPort, declared locally
11
+ // so the accounting domain does not import the commerce domain (the
12
+ // architecture guardrail forbids importing another domain's non-public
13
+ // internals). The composition root in api/cron passes the real commerce port,
14
+ // which is structurally compatible.
15
+ export type AutoDispatchFulfillmentResult =
16
+ | { kind: "completed" | "skipped"; detail?: Record<string, unknown> }
17
+ | { kind: "retryable" | "snooze" | "fatal"; reason: string }
18
+ // A paid order routed to manual review (split shipment unsupported): no
19
+ // handoff, so no accounting invoice is requested (readHandedOverFulfillmentOrderId
20
+ // returns null). Mirrors OrderPaidFulfillmentResult so the commerce port stays
21
+ // structurally assignable here.
22
+ | { kind: "manual_review"; reason: string; detail?: Record<string, unknown> };
23
+
24
+ export interface AutoDispatchFulfillmentPort {
25
+ ensureFulfilledFromPaidOrder(input: {
26
+ orderUuid: string;
27
+ outboxEventId: string;
28
+ signal: AbortSignal;
29
+ }): Promise<AutoDispatchFulfillmentResult>;
30
+ }
31
+
32
+ type FulfillmentHandoffRequest = {
33
+ idempotencyKey: string;
34
+ fulfillmentOrderId: string;
35
+ actorUserId: string;
36
+ };
37
+
38
+ type FulfillmentHandoffPort<TResult = unknown> = {
39
+ handOffCommerceFulfillmentOrder: (request: FulfillmentHandoffRequest) => Promise<TResult>;
40
+ };
41
+
42
+ export interface AccountingHandoffTriggerOptions {
43
+ accountingPort: AccountingInvoiceIssuePort;
44
+ providerKind: string;
45
+ keys: { shadow(key: string): string; paid(orderId: string): string; handoff(orderId: string): string };
46
+ failOnAccountingError?: boolean;
47
+ issueTrigger?: "handoff" | "paid";
48
+ // Wave B6. Absent everywhere this wave did not wire it, and an absent reader
49
+ // decides `issue` — so every composition that predates it keeps today's
50
+ // behaviour byte for byte.
51
+ invoicePolicyReader?: OrderInvoicePolicyReader;
52
+ }
53
+
54
+ export function withAccountingInvoiceShadowTrigger<TResult, TPort extends FulfillmentHandoffPort<TResult>>(
55
+ fulfillmentPort: TPort,
56
+ options: Omit<AccountingHandoffTriggerOptions, "accountingPort"> & {
57
+ accountingPort: Pick<AccountingInvoiceIssuePort, "requestInvoiceIssueFromFulfillmentHandoff"> & Partial<AccountingInvoiceIssuePort>;
58
+ },
59
+ ): TPort {
60
+ return {
61
+ ...fulfillmentPort,
62
+ async handOffCommerceFulfillmentOrder(request: FulfillmentHandoffRequest) {
63
+ const result = await fulfillmentPort.handOffCommerceFulfillmentOrder(request);
64
+ try {
65
+ // The operator hand-off reaches this wrapper holding only the
66
+ // fulfillment order id, so the policy is read by that key.
67
+ const decision = await decideChannelInvoice(options.invoicePolicyReader, {
68
+ by: "fulfillmentOrder",
69
+ fulfillmentOrderId: request.fulfillmentOrderId,
70
+ });
71
+ if (decision.issue) {
72
+ await options.accountingPort.requestInvoiceIssueFromFulfillmentHandoff({
73
+ idempotencyKey: options.keys.shadow(request.idempotencyKey),
74
+ fulfillmentOrderId: request.fulfillmentOrderId,
75
+ providerKind: options.providerKind,
76
+ });
77
+ }
78
+ } catch (error) {
79
+ if (options.failOnAccountingError) throw error;
80
+ }
81
+ return result;
82
+ },
83
+ } as TPort;
84
+ }
85
+
86
+ // Auto-dispatch sibling of withAccountingInvoiceShadowTrigger. The outbox
87
+ // commerce.order.paid -> fulfillment path drives handoff through the
88
+ // OrderPaidFulfillmentPort (ensureFulfilledFromPaidOrder), which issues the
89
+ // mark-handed-over RPC directly and therefore never reaches the admin
90
+ // hand-off wrapper above. Without this, an auto-dispatched paid order reaches
91
+ // handed_over but no accounting invoice is ever requested. Same semantics:
92
+ // idempotent (the issue RPC dedups on the order-derived invoice_ref, so any
93
+ // key is duplicate-safe) and fail-soft (accounting errors never fail the
94
+ // fulfillment dispatch — the outbox event still settles as processed).
95
+ export function withAutoDispatchAccountingInvoiceTrigger(
96
+ fulfillmentPort: AutoDispatchFulfillmentPort,
97
+ options: AccountingHandoffTriggerOptions,
98
+ ): AutoDispatchFulfillmentPort {
99
+ return {
100
+ async ensureFulfilledFromPaidOrder(input) {
101
+ // Both branches hold the order uuid, so both ask by the order key — the
102
+ // strongest key available, and the one that needs no join.
103
+ const lookup = { by: "order" as const, orderUuid: input.orderUuid };
104
+ if (options.issueTrigger === "paid") {
105
+ try {
106
+ const decision = await decideChannelInvoice(options.invoicePolicyReader, lookup);
107
+ if (decision.issue) {
108
+ await options.accountingPort.requestInvoiceIssueFromPaidOrder({
109
+ idempotencyKey: options.keys.paid(input.orderUuid),
110
+ orderId: input.orderUuid,
111
+ providerKind: options.providerKind,
112
+ });
113
+ }
114
+ } catch (error) {
115
+ if (options.failOnAccountingError) throw error;
116
+ }
117
+ }
118
+ const result = await fulfillmentPort.ensureFulfilledFromPaidOrder(input);
119
+ if (options.issueTrigger === "paid") return result;
120
+ const fulfillmentOrderId = readHandedOverFulfillmentOrderId(result);
121
+ if (fulfillmentOrderId) {
122
+ try {
123
+ const decision = await decideChannelInvoice(options.invoicePolicyReader, lookup);
124
+ if (decision.issue) {
125
+ await options.accountingPort.requestInvoiceIssueFromFulfillmentHandoff({
126
+ idempotencyKey: options.keys.handoff(input.orderUuid),
127
+ fulfillmentOrderId,
128
+ providerKind: options.providerKind,
129
+ });
130
+ }
131
+ } catch (error) {
132
+ if (options.failOnAccountingError) throw error;
133
+ }
134
+ }
135
+ return result;
136
+ },
137
+ };
138
+ }
139
+
140
+ function readHandedOverFulfillmentOrderId(result: AutoDispatchFulfillmentResult): string | null {
141
+ if (result.kind !== "completed" && result.kind !== "skipped") return null;
142
+ const id = result.detail?.fulfillmentOrderId;
143
+ return typeof id === "string" ? id : null;
144
+ }