@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,80 @@
1
+ import type { InvoiceHostPorts, InvoiceIdentity, InvoiceProviderPort, InvoiceStorePort } from "./contracts/index.js";
2
+ import { assertInvoicePdf } from "./artifacts.js";
3
+ import { opaqueReference, readInvoiceReadback } from "./readback.js";
4
+
5
+ const unsupported = () => ({ kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false } as const);
6
+ const unknown = () => ({ kind: "unknown", code: "unknown" } as const);
7
+
8
+ /** Read-only orchestration. Auth, owner resolution and queries stay in host
9
+ * callbacks. Safe native account projections preserve negative correction totals,
10
+ * currency and parent identity without exposing provider payloads or URLs.
11
+ * @consumer Customer download/history and authorized operator host facades.
12
+ */
13
+ export function createInvoiceReadModel(ports: {
14
+ accountHistory?: InvoiceHostPorts["accountHistory"];
15
+ artifacts?: Pick<InvoiceHostPorts["artifacts"], "download">;
16
+ provider?: Pick<InvoiceProviderPort, "read">;
17
+ duties?: Pick<InvoiceStorePort, "readDuties">;
18
+ } = {}) {
19
+ return {
20
+ async history(input: Parameters<InvoiceHostPorts["accountHistory"]["read"]>[0]): ReturnType<InvoiceHostPorts["accountHistory"]["read"]> {
21
+ if (!ports.accountHistory) return unsupported();
22
+ try {
23
+ const result = await ports.accountHistory.read(input);
24
+ if (result.kind !== "found") return result.kind === "unsupported" ? unsupported() : result.kind === "denied" ? { kind: "denied" } : unknown();
25
+ if (result.accountRef !== input.accountRef || result.orderId !== input.orderId) return { kind: "denied" };
26
+ return {
27
+ kind: "found", accountRef: input.accountRef, orderId: input.orderId,
28
+ documents: result.documents.map(document => ({
29
+ documentRef: document.documentRef, invoiceRef: document.invoiceRef,
30
+ parentDocumentRef: document.parentDocumentRef, role: document.role,
31
+ status: document.status, issuedAt: document.issuedAt,
32
+ clearance: document.clearance ? { state: document.clearance.state, reference: document.clearance.reference } : null,
33
+ documentKind: document.documentKind, artifact: document.artifact,
34
+ providerDocumentNumber: document.providerDocumentNumber,
35
+ totalGrossMinor: document.totalGrossMinor, currency: document.currency,
36
+ emailState: document.emailState, isCurrent: document.isCurrent,
37
+ createdAt: document.createdAt, downloadAvailable: document.downloadAvailable,
38
+ })),
39
+ };
40
+ } catch { return unknown(); }
41
+ },
42
+ async download(input: Parameters<InvoiceHostPorts["accountHistory"]["resolveDownload"]>[0]) {
43
+ if (!ports.accountHistory || !ports.artifacts) return unsupported();
44
+ try {
45
+ const ticket = await ports.accountHistory.resolveDownload(input);
46
+ if (ticket.kind !== "authorized") return ticket.kind === "unsupported" ? unsupported() : ticket.kind === "denied" ? { kind: "denied" as const } : unknown();
47
+ if (ticket.identity.documentRef !== input.documentRef || !opaqueReference(ticket.artifactRef)) return { kind: "denied" as const };
48
+ const pdf = await ports.artifacts.download({ identity: ticket.identity, artifactRef: ticket.artifactRef });
49
+ if (pdf.kind !== "downloaded") return pdf.kind === "unsupported" ? unsupported() : unknown();
50
+ assertInvoicePdf(pdf);
51
+ return { kind: "downloaded" as const, content: pdf.content, contentType: pdf.contentType, fileName: ticket.fileName, parentDocumentRef: ticket.parentDocumentRef };
52
+ } catch { return unknown(); }
53
+ },
54
+ async readback(identity: InvoiceIdentity) {
55
+ return ports.provider ? readInvoiceReadback(ports.provider, identity) : unsupported();
56
+ },
57
+ /** Operator-only duty readback. The host must authorize the caller before
58
+ * invoking it, as operator() does; this method has no auth hook of its own. */
59
+ async readDuties(orderId: string) {
60
+ if (!ports.duties) return unsupported();
61
+ try { return await ports.duties.readDuties(orderId); } catch { return unknown(); }
62
+ },
63
+ /** The authorize callback runs before the query. HTTP status mapping stays host-owned. */
64
+ async operator<Summary>(input: { authorize(): Promise<boolean>; read(): Promise<Summary> }) {
65
+ if (!await input.authorize()) return { kind: "denied" as const };
66
+ return { kind: "found" as const, value: await input.read() };
67
+ },
68
+ /** Legacy ticket resolver verifies the real order owner before any PDF I/O. */
69
+ async ownedPdf<Ticket, Pdf extends { content: Uint8Array; contentType: string }>(input: {
70
+ resolve(): Promise<Ticket | null>;
71
+ download(ticket: Ticket): Promise<Pdf>;
72
+ }) {
73
+ const ticket = await input.resolve();
74
+ if (!ticket) return { kind: "denied" as const };
75
+ const pdf = await input.download(ticket);
76
+ assertInvoicePdf(pdf);
77
+ return { kind: "downloaded" as const, ticket, pdf };
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,26 @@
1
+ import type { InvoiceIdentity, InvoiceProviderPort, InvoiceReadbackResult } from "./contracts/provider.js";
2
+
3
+ const identityKeys = ["providerRef", "requestKey", "providerCreateIdentity", "factRevision", "orderRef", "invoiceRef", "operation", "correctionRef", "parentProviderDocumentRef"] as const;
4
+ /** Internal validation used by the read model. No nullable lookup shortcut. */
5
+ export async function readInvoiceReadback(provider: Pick<InvoiceProviderPort, "read">, identity: InvoiceIdentity): Promise<InvoiceReadbackResult> {
6
+ const unknown = (): InvoiceReadbackResult => ({ kind: "unknown", identity, code: "unknown" });
7
+ try {
8
+ const result = await provider.read(identity);
9
+ if (result.kind === "unsupported") return { kind: "unsupported", code: result.code, retryable: false, effectMayHaveSucceeded: false };
10
+ if (!sameIdentity(identity, result.identity)) return unknown();
11
+ if (result.kind === "found") {
12
+ if (!opaqueReference(result.providerDocumentRef)) return unknown();
13
+ return { kind: "found", identity, providerDocumentRef: result.providerDocumentRef, providerDocumentNumber: result.providerDocumentNumber, proof: { evidenceRef: result.proof.evidenceRef, fingerprint: result.proof.fingerprint, observedAt: result.proof.observedAt } };
14
+ }
15
+ if (result.kind === "absent" && result.evidence.exact === true && result.evidence.complete === true && sameIdentity(identity, result.evidence.scope)) {
16
+ return { kind: "absent", identity, evidence: { evidenceRef: result.evidence.evidenceRef, fingerprint: result.evidence.fingerprint, observedAt: result.evidence.observedAt, exact: true, complete: true, scope: identity } };
17
+ }
18
+ return unknown();
19
+ } catch { return unknown(); }
20
+ }
21
+ function sameIdentity(a: InvoiceIdentity, b: InvoiceIdentity): boolean {
22
+ return Boolean(b) && identityKeys.every(key => a[key] === b[key]);
23
+ }
24
+ export function opaqueReference(ref: string): boolean {
25
+ return typeof ref === "string" && ref.trim().length > 0 && !/^(?:https?:|data:|blob:|\/\/)/i.test(ref.trim());
26
+ }
@@ -0,0 +1,34 @@
1
+ /** Donor reversal outcome/order semantics over host event adapters and writer.
2
+ * @consumer api/_cron/accountingOrderReversalHandlers.ts
3
+ */
4
+ export function createInvoiceReversalHandlers<Row, Request, Result extends { action: string; invoice: { status: string } | null }>(input: {
5
+ enabled: boolean;
6
+ events: readonly { eventType: string; adapt(row: Row): { kind: "ready"; request: Request } | { kind: "invalid_payload" } }[];
7
+ request(request: Request): Promise<Result>;
8
+ }) {
9
+ if (!input.enabled) return [];
10
+ return input.events.map(event => ({
11
+ eventType: event.eventType,
12
+ timeoutMs: 10_000,
13
+ async handle(row: Row): Promise<
14
+ | { kind: "discard"; reason: string }
15
+ | { kind: "processed"; detail: { accountingReversalAction: string; invoiceStatus: string } }
16
+ | { kind: "retry"; reason: string }
17
+ > {
18
+ const compatibility = event.adapt(row);
19
+ if (compatibility.kind === "invalid_payload") {
20
+ return { kind: "discard", reason: "accounting_reversal_payload_invalid" };
21
+ }
22
+ try {
23
+ const result = await input.request(compatibility.request);
24
+ return {
25
+ kind: "processed",
26
+ detail: { accountingReversalAction: result.action, invoiceStatus: result.invoice?.status ?? "no_invoice" },
27
+ };
28
+ } catch (error) {
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return { kind: "retry", reason: message.slice(0, 300) };
31
+ }
32
+ },
33
+ }));
34
+ }
@@ -0,0 +1,20 @@
1
+ import type { InvoiceSellerRevision } from "./contracts/facts.js";
2
+ import type { InvoiceUnsupported } from "./contracts/provider.js";
3
+
4
+ /** Read the host selection before leasing. Persistence and race serialization
5
+ * belong exclusively to the pinned SQL wrapper, never to an in-memory cache.
6
+ * Unknown/unsupported selection consumes no claim and must be retried by the host.
7
+ */
8
+ export async function claimInvoiceSellerRevision<Seller extends object>(read: () => Promise<
9
+ | ({ readonly kind: "found" } & InvoiceSellerRevision<Seller>)
10
+ | { readonly kind: "unknown"; readonly code: "unknown" }
11
+ | InvoiceUnsupported
12
+ >) {
13
+ const selected = await read();
14
+ if (selected.kind !== "found") return selected;
15
+ if (!selected.seller || typeof selected.seller !== "object" || Array.isArray(selected.seller)
16
+ || typeof selected.revisionRef !== "string" || !selected.revisionRef.trim()) {
17
+ return { kind: "unsupported", code: "host_binding_required", retryable: false, effectMayHaveSucceeded: false } as const;
18
+ }
19
+ return { kind: "found", seller: selected.seller, revisionRef: selected.revisionRef } as const;
20
+ }
@@ -0,0 +1,37 @@
1
+ import type { InvoiceSettlementPort, InvoiceSettlementItem } from "./contracts/settlement.js";
2
+
3
+ /** Preserve each immutable balance item, nullable links and original host key.
4
+ * Transport, resolution and richer durable item storage remain host-owned.
5
+ * Legacy batch/payment receipts may collapse items: later reuse is returned as
6
+ * identity_mismatch without another write; processing continues. Only an item-capable
7
+ * recorder can produce distinct recorded receipts. This records bank evidence only and has no payment-success writer.
8
+ */
9
+ export async function syncInvoicePayoutSettlements<Item>(input: {
10
+ list(limit: number): Promise<readonly Item[]>;
11
+ project(item: Item): InvoiceSettlementItem | Promise<InvoiceSettlementItem>;
12
+ settlement: Pick<InvoiceSettlementPort, "record">;
13
+ limit?: number;
14
+ }) {
15
+ const items = await input.list(input.limit ?? 10);
16
+ const result = { checked: items.length, recorded: 0, replayed: 0, items: [] as Awaited<ReturnType<InvoiceSettlementPort["record"]>>[] };
17
+ const receipts = new Map<string, string>();
18
+ for (const item of items) {
19
+ const evidence = await input.project(item);
20
+ const write = await input.settlement.record(evidence);
21
+ if (write.kind !== "recorded" || write.itemRef !== evidence.itemRef) {
22
+ // Unknown/conflict/unsupported are never counted as accepted evidence.
23
+ throw new Error(write.kind === "conflict" ? write.code : write.kind === "unknown" ? "unknown" : "unsupported");
24
+ }
25
+ const scope = JSON.stringify([evidence.providerRef, evidence.batchRef, evidence.itemRef, evidence.providerPaymentRef]);
26
+ const previous = receipts.get(write.settlementItemRef);
27
+ if (previous !== undefined && previous !== scope) {
28
+ result.items.push({ kind: "conflict", itemRef: evidence.itemRef, code: "identity_mismatch" });
29
+ continue;
30
+ }
31
+ receipts.set(write.settlementItemRef, scope);
32
+ result.items.push(write);
33
+ if (write.replayed) result.replayed += 1;
34
+ else result.recorded += 1;
35
+ }
36
+ return result;
37
+ }
@@ -0,0 +1,192 @@
1
+ import type { InvoiceBuyer } from "./contracts/facts.js";
2
+ import { mapCanonicalIssueLines } from "./accountingCanonicalProviderLines.js";
3
+ import { allocateChargedGrossTotals } from "./accountingLineAllocation.js";
4
+
5
+ type IssueLines = ReturnType<typeof mapCanonicalIssueLines>;
6
+ interface Options {
7
+ defaultLineLabel: string;
8
+ deliveryLabel: string;
9
+ defaultTaxRate: string;
10
+ }
11
+ /** Compatibility projection of a claimed host snapshot, not a second store contract.
12
+ * Seller/date/kind/payment selection remains with the host; no environment is read.
13
+ */
14
+ export function mapInvoiceClaimToProviderSnapshot<Seller, Kind, Payment>(input: {
15
+ orderRef: string;
16
+ issueDate: string;
17
+ sellDate: string;
18
+ currency: string;
19
+ seller: Seller;
20
+ buyerSnapshot: Record<string, unknown>;
21
+ documentKind: Kind;
22
+ governmentClearanceRequired: boolean;
23
+ payment: Payment;
24
+ linesSnapshot: unknown[];
25
+ totalGrossMinor: number;
26
+ totalNetMinor: number;
27
+ orderMoney: unknown;
28
+ }, options: Options & { defaultBuyerLabel: string }) {
29
+ return {
30
+ orderRef: input.orderRef,
31
+ issueDate: input.issueDate,
32
+ sellDate: input.sellDate,
33
+ currency: input.currency,
34
+ seller: input.seller,
35
+ buyer: {
36
+ name: textValue(input.buyerSnapshot.name) ?? options.defaultBuyerLabel,
37
+ email: textValue(input.buyerSnapshot.email),
38
+ taxId: textValue(input.buyerSnapshot.taxId),
39
+ companyName: textValue(input.buyerSnapshot.companyName),
40
+ address: mapBuyerAddress(input.buyerSnapshot.address),
41
+ },
42
+ documentKind: input.documentKind,
43
+ governmentClearanceRequired: input.governmentClearanceRequired,
44
+ payment: input.payment,
45
+ lines: mapIssueLinesToChargedTotal(input.linesSnapshot, input.totalGrossMinor, input.totalNetMinor, input.orderMoney, options),
46
+ chargedTotalGrossMinor: input.totalGrossMinor,
47
+ };
48
+ }
49
+
50
+ // Canonical issue snapshots already carry effective item and explicit delivery
51
+ // positions and are verified as a zero-delta passthrough. Historical snapshots
52
+ // without positionKind retain the independent #1693 reconciliation below.
53
+ function mapIssueLinesToChargedTotal(
54
+ linesSnapshot: unknown[],
55
+ chargedTotalGrossMinor: number,
56
+ chargedTotalNetMinor: number,
57
+ orderMoney: unknown,
58
+ options: Options,
59
+ ): IssueLines {
60
+ const canonicalKinds = linesSnapshot.map((line) => textValue(isRecord(line) ? line.positionKind : null));
61
+ if (canonicalKinds.some(Boolean)) {
62
+ if (!canonicalKinds.every((kind) => kind === "item" || kind === "delivery")) {
63
+ throw new Error("accounting_invoice_canonical_positions_mixed_or_invalid");
64
+ }
65
+ return mapCanonicalIssueLines(
66
+ linesSnapshot,
67
+ chargedTotalGrossMinor,
68
+ chargedTotalNetMinor,
69
+ options.defaultLineLabel,
70
+ );
71
+ }
72
+
73
+ const parsed = linesSnapshot.map((line) => parseLineSnapshot(line, options));
74
+ const deliveryGrossMinor = readDeliveryGrossMinor(orderMoney, chargedTotalGrossMinor);
75
+ const chargedTotals = allocateChargedGrossTotals(
76
+ parsed.map((line) => line.totalGrossMinor),
77
+ chargedTotalGrossMinor - deliveryGrossMinor,
78
+ );
79
+ const positions: IssueLines = parsed.map((line, index) => {
80
+ const totalGrossMinor = chargedTotals[index];
81
+ const totalNetMinor = estimateNetMinor(totalGrossMinor, line.vatRate);
82
+ return {
83
+ name: line.name,
84
+ quantity: line.quantity,
85
+ unitGrossMinor: Math.round(totalGrossMinor / line.quantity),
86
+ unitNetMinor: Math.round(totalNetMinor / line.quantity),
87
+ totalGrossMinor,
88
+ totalNetMinor,
89
+ vatRate: line.vatRate,
90
+ };
91
+ });
92
+ if (deliveryGrossMinor > 0) {
93
+ positions.push(buildDeliveryPosition(deliveryGrossMinor, parsed, options));
94
+ }
95
+ return positions;
96
+ }
97
+
98
+ // Legacy-only compatibility: pre-canonical claims had no explicit delivery
99
+ // position, so their effective delivery is the closed header residual. Current
100
+ // canonical claims never enter this path and use persisted shipping columns.
101
+ // Every inconsistent legacy shape degrades to 0 and then fails closed if the
102
+ // catalog lines cannot reconcile to the charged header.
103
+ function readDeliveryGrossMinor(orderMoney: unknown, chargedTotalGrossMinor: number): number {
104
+ if (!isRecord(orderMoney)) return 0;
105
+ const subtotal = numberValue(orderMoney.subtotalCents);
106
+ const discount = numberValue(orderMoney.discountCents);
107
+ const total = numberValue(orderMoney.totalCents);
108
+ if (subtotal === null || discount === null || total === null) return 0;
109
+ if (![subtotal, discount, total].every((value) => Number.isInteger(value) && value >= 0)) return 0;
110
+ if (discount > subtotal) return 0;
111
+ if (total !== chargedTotalGrossMinor) return 0;
112
+ const deliveryGrossMinor = chargedTotalGrossMinor - (subtotal - discount);
113
+ return deliveryGrossMinor > 0 ? deliveryGrossMinor : 0;
114
+ }
115
+
116
+ // Delivery is an ancillary supply and follows the goods' VAT rate; with mixed
117
+ // item rates (or no item line to source a rate from) the correct rate is
118
+ // ambiguous, so the claim fails closed into the outbox last_error.
119
+ function buildDeliveryPosition(
120
+ deliveryGrossMinor: number,
121
+ itemLines: Array<{ vatRate: string }>,
122
+ options: Options,
123
+ ): IssueLines[number] {
124
+ const rates = [...new Set(itemLines.map((line) => line.vatRate.trim()))];
125
+ if (rates.length === 0) {
126
+ throw new Error(`accounting_invoice_delivery_vat_rate_unavailable delivery=${deliveryGrossMinor}`);
127
+ }
128
+ if (rates.length > 1) {
129
+ throw new Error(`accounting_invoice_delivery_vat_rates_mixed rates=${rates.join(",")}`);
130
+ }
131
+ const vatRate = rates[0];
132
+ const totalNetMinor = estimateNetMinor(deliveryGrossMinor, vatRate);
133
+ return {
134
+ name: options.deliveryLabel,
135
+ quantity: 1,
136
+ unitGrossMinor: deliveryGrossMinor,
137
+ unitNetMinor: totalNetMinor,
138
+ totalGrossMinor: deliveryGrossMinor,
139
+ totalNetMinor,
140
+ vatRate,
141
+ };
142
+ }
143
+
144
+ function mapBuyerAddress(value: unknown): NonNullable<InvoiceBuyer["address"]> {
145
+ const address = isRecord(value) ? value : {};
146
+ return {
147
+ line1: textValue(address.line1),
148
+ postalCode: textValue(address.postalCode),
149
+ city: textValue(address.city),
150
+ country: textValue(address.country),
151
+ };
152
+ }
153
+
154
+ function parseLineSnapshot(value: unknown, options: Options): {
155
+ name: string;
156
+ quantity: number;
157
+ totalGrossMinor: number;
158
+ vatRate: string;
159
+ } {
160
+ const row = isRecord(value) ? value : {};
161
+ const quantity = numberValue(row.quantity) || 1;
162
+ const vatRate = textValue(row.vatRate) ?? options.defaultTaxRate;
163
+ // Legacy (pre-#1693) lines_snapshot rows carry only the per-unit
164
+ // unitGrossMinor, so the line total is unit price times quantity.
165
+ const unitGrossMinor = numberValue(row.unitGrossMinor);
166
+ const totalGrossMinor = numberValue(row.totalGrossMinor) ??
167
+ (unitGrossMinor === null ? 0 : unitGrossMinor * quantity);
168
+ return {
169
+ name: textValue(row.name) ?? options.defaultLineLabel,
170
+ quantity,
171
+ totalGrossMinor,
172
+ vatRate,
173
+ };
174
+ }
175
+
176
+ function estimateNetMinor(grossMinor: number, vatRate: string): number {
177
+ const rate = Number(vatRate.replace(",", "."));
178
+ return Number.isFinite(rate) ? Math.round(grossMinor / (1 + rate / 100)) : grossMinor;
179
+ }
180
+
181
+ function textValue(value: unknown): string | null {
182
+ return typeof value === "string" && value.trim() ? value.trim() : null;
183
+ }
184
+
185
+ function numberValue(value: unknown): number | null {
186
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
187
+ }
188
+
189
+
190
+ function isRecord(value: unknown): value is Record<string, unknown> {
191
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
192
+ }
@@ -0,0 +1,87 @@
1
+ import type { RenewalDue, RenewalExecutionRequest, RenewalFrozenSnapshot, RenewalPaymentRail, RenewalPreparedSettlement, RenewalReadback, RenewalReservationResult, RenewalSettlementPort, RenewalStorePort } from "./contracts/renewal.js";
2
+ import type { RenewalFailureHandoffPort } from "./contracts/dunning.js";
3
+ import { automaticRenewalResult, handoffRefusal, recoverPreparedExternalAttempt, refuseRenewalBeforePayment, runRenewalBatchRows } from "./automaticRenewalExecution.js";
4
+ import { readAndApplyProviderOutcome } from "./paymentOutcomes.js";
5
+ export type AutomaticRenewalRunStatus = "succeeded" | "refused" | "deferred" | "awaiting_provider" | "error" | "lease_active" | "terminal_replay";
6
+ export interface AutomaticRenewalRunResult {
7
+ subscriptionId: string; identityKey: string; operationId: string;
8
+ status: AutomaticRenewalRunStatus; replayed: boolean; reason: string | null; caseRef?: string;
9
+ }
10
+ export interface AutomaticRenewalPolicy {
11
+ executionInput(due: RenewalDue, operation: RenewalReadback, prepared: Pick<RenewalPreparedSettlement, "orderId" | "settlementIntentId">): RenewalExecutionRequest;
12
+ validateInput(input: RenewalExecutionRequest): void;
13
+ classifyFailure(reason: string): string;
14
+ identityKey(due: RenewalDue): string;
15
+ }
16
+ export interface AutomaticRenewalRunDeps {
17
+ store: RenewalStorePort;
18
+ inventory: {
19
+ expireReservations(input: { now: string; limit?: number }): Promise<number>;
20
+ reserve(input: { operationId: string; claimToken: string; now: string }): Promise<RenewalReservationResult>;
21
+ };
22
+ settlement: RenewalSettlementPort;
23
+ paymentRails: { resolve(channelKey: string): RenewalPaymentRail | null };
24
+ failureHandoff: RenewalFailureHandoffPort;
25
+ policy: AutomaticRenewalPolicy;
26
+ workerId: string; now?: () => string;
27
+ }
28
+ export async function runAutomaticSubscriptionRenewals(deps: AutomaticRenewalRunDeps, limit = 50): Promise<AutomaticRenewalRunResult[]> {
29
+ const now = (deps.now ?? defaultNow)();
30
+ await deps.inventory.expireReservations({ now, limit: Math.max(limit, 100) });
31
+ const due = await deps.store.listDue(limit, now);
32
+ return runRenewalBatchRows(due, row => runOneAutomaticSubscriptionRenewal(deps, row, now), deps.policy);
33
+ }
34
+ export async function runOneAutomaticSubscriptionRenewal(deps: AutomaticRenewalRunDeps, due: RenewalDue, occurredAt = (deps.now ?? defaultNow)()): Promise<AutomaticRenewalRunResult> {
35
+ const schedule = { subscriptionId: due.subscriptionId, scheduledAt: due.scheduledAt, scheduledInstant: due.scheduledInstant };
36
+ const snapshot = await deps.store.buildCycleSnapshots(schedule);
37
+ const claim = await deps.store.claimDue({ snapshot, workerId: deps.workerId, now: occurredAt });
38
+ if (claim.state === "succeeded" || claim.state === "refused") return automaticRenewalResult(claim, "terminal_replay", claim.terminalReason);
39
+ if (!claim.acquired) return automaticRenewalResult(claim, "lease_active", claim.reason);
40
+ const rail = deps.paymentRails.resolve(snapshot.settlementChannelKey);
41
+ if (!rail) return refuseRenewalBeforePayment({ store: deps.store, operation: claim, reason: "settlement_channel_not_configured", occurredAt });
42
+ if (claim.state === "attempt_prepared") return resumePreparedAttempt(deps, claim, rail, occurredAt);
43
+ const mandate = await deps.store.readMandateEvidence(schedule);
44
+ if (!mandate.authorized || mandate.authorizationRef !== snapshot.authorizationRef) return refuseRenewalBeforePayment({ store: deps.store, operation: claim, reason: "authorization_evidence_mismatch", occurredAt });
45
+ const delivery = await deps.store.admitDeliveryAlignment({ ...schedule, occurredAt });
46
+ if (!delivery.allowed) {
47
+ if (delivery.deferred) return { ...automaticRenewalResult(claim, "deferred", delivery.reason), ...(delivery.caseRef ? { caseRef: delivery.caseRef } : {}) };
48
+ return refuseRenewalBeforePayment({ store: deps.store, operation: claim, reason: delivery.reason, occurredAt });
49
+ }
50
+ const reservation = await deps.inventory.reserve({ operationId: claim.operationId, claimToken: claim.claimToken, now: occurredAt });
51
+ if (!reservation.reserved) return automaticRenewalResult(claim, "refused", reservation.reason ?? "insufficient_atp");
52
+ const prepared = await deps.settlement.prepareRenewal({ operationId: claim.operationId, claimToken: claim.claimToken, now: occurredAt });
53
+ if (prepared.replayed) {
54
+ const readback = await deps.store.readRenewal(claim.identityKey);
55
+ if (!readback) throw new Error("subscription_renewal_prepared_readback_missing");
56
+ return resumePreparedAttempt(deps, readback, rail, occurredAt);
57
+ }
58
+ const executionInput = deps.policy.executionInput(due, claim, prepared);
59
+ try { deps.policy.validateInput(executionInput); }
60
+ catch {
61
+ await deps.settlement.recordTerminalOutcome({ operationId: claim.operationId, eventKey: `${claim.identityKey}:provider-preflight`, operationFingerprint: claim.operationFingerprint, outcome: "refused", reason: "provider_input_invalid", occurredAt });
62
+ return automaticRenewalResult(claim, "refused", "provider_input_invalid");
63
+ }
64
+ let execution: Awaited<ReturnType<RenewalPaymentRail["execute"]>>;
65
+ try { execution = await rail.execute(executionInput); }
66
+ catch { return automaticRenewalResult(claim, "awaiting_provider", "provider_execution_indeterminate"); }
67
+ const externalAttemptRef = execution.externalAttemptRef?.trim() || null;
68
+ if (externalAttemptRef) await deps.settlement.acknowledgeExternalAttempt({ operationId: claim.operationId, operationFingerprint: claim.operationFingerprint, externalAttemptRef, acknowledgedAt: occurredAt });
69
+ if (execution.failure || execution.status === "requires_action") {
70
+ const reason = execution.failure?.reason ?? (execution.status === "requires_action" ? "off_session_sca_required" : "provider_failed");
71
+ return handoffRefusal(deps, claim, prepared, externalAttemptRef, reason, execution.failure?.failureClass ?? deps.policy.classifyFailure(reason), occurredAt);
72
+ }
73
+ if (!externalAttemptRef) return automaticRenewalResult(claim, "awaiting_provider", "provider_attempt_ack_missing");
74
+ return readAndApplyProviderOutcome(deps, claim, prepared, rail, externalAttemptRef, occurredAt);
75
+ }
76
+ async function resumePreparedAttempt(deps: AutomaticRenewalRunDeps, operation: RenewalReadback, rail: RenewalPaymentRail, occurredAt: string): Promise<AutomaticRenewalRunResult> {
77
+ if (!operation.cycleId || !operation.orderId || !operation.settlementIntentId) throw new Error("subscription_renewal_prepared_artifacts_missing");
78
+ const externalAttemptRef = await recoverPreparedExternalAttempt({ operation, rail, settlement: deps.settlement, occurredAt });
79
+ if (!externalAttemptRef) return automaticRenewalResult(operation, "awaiting_provider", "prepared_attempt_ack_missing");
80
+ return readAndApplyProviderOutcome(deps, operation, { cycleId: operation.cycleId, orderId: operation.orderId, settlementIntentId: operation.settlementIntentId }, rail, externalAttemptRef, occurredAt);
81
+ }
82
+ function defaultNow(): string { return new Date().toISOString(); }
83
+ /** No implicit +1: ambiguous history must be resolved by the host before opening a repair case. */
84
+ export function correlatedLogicalCycle(snapshot: RenewalFrozenSnapshot): number {
85
+ if (snapshot.cycle.status !== "correlated") throw new Error("subscription_renewal_cycle_correlation_required");
86
+ return snapshot.cycle.logicalCycleNumber;
87
+ }
@@ -0,0 +1,39 @@
1
+ import { nextRetryAttemptAt } from "@openlup/core/subscription";
2
+ import type { RenewalDue, RenewalPaymentRail, RenewalReadback, RenewalSettlementPort, RenewalStorePort } from "./contracts/renewal.js";
3
+ import type { AutomaticRenewalPolicy, AutomaticRenewalRunDeps, AutomaticRenewalRunResult, AutomaticRenewalRunStatus } from "./automaticRenewal.js";
4
+ export function automaticRenewalResult(operation: RenewalReadback, status: AutomaticRenewalRunStatus, reason: string | null): AutomaticRenewalRunResult {
5
+ return { subscriptionId: operation.subscriptionId, identityKey: operation.identityKey, operationId: operation.operationId, status, replayed: operation.replayed, reason };
6
+ }
7
+ export async function recoverPreparedExternalAttempt(input: { operation: RenewalReadback; rail: RenewalPaymentRail; settlement: RenewalSettlementPort; occurredAt: string }): Promise<string | null> {
8
+ if (input.operation.externalAttemptRef) return input.operation.externalAttemptRef;
9
+ if (!input.operation.settlementIntentId) return null;
10
+ const externalAttemptRef = await input.rail.findExternalAttempt({ settlementIntentId: input.operation.settlementIntentId });
11
+ if (!externalAttemptRef) return null;
12
+ await input.settlement.acknowledgeExternalAttempt({ operationId: input.operation.operationId, operationFingerprint: input.operation.operationFingerprint, externalAttemptRef, acknowledgedAt: input.occurredAt });
13
+ return externalAttemptRef;
14
+ }
15
+ export async function runRenewalBatchRows(due: RenewalDue[], run: (row: RenewalDue) => Promise<AutomaticRenewalRunResult>, policy: Pick<AutomaticRenewalPolicy, "identityKey">): Promise<AutomaticRenewalRunResult[]> {
16
+ const results: AutomaticRenewalRunResult[] = [];
17
+ for (const row of due) {
18
+ try { results.push(await run(row)); }
19
+ catch { results.push({ subscriptionId: row.subscriptionId, identityKey: policy.identityKey(row), operationId: "", status: "error", replayed: false, reason: "renewal_processing_failed" }); }
20
+ }
21
+ return results;
22
+ }
23
+ export async function refuseRenewalBeforePayment(input: { store: RenewalStorePort; operation: RenewalReadback; reason: string; occurredAt: string }): Promise<AutomaticRenewalRunResult> {
24
+ const refused = await input.store.refuseBeforePayment({ operationId: input.operation.operationId, claimToken: input.operation.claimToken, reason: input.reason, occurredAt: input.occurredAt });
25
+ return automaticRenewalResult(refused, "refused", input.reason);
26
+ }
27
+ const HANDOFF_OPENS_AT_RETRY_ATTEMPT = 1;
28
+ export async function handoffRefusal(deps: AutomaticRenewalRunDeps, operation: RenewalReadback, prepared: { cycleId: string; orderId: string; settlementIntentId: string }, externalAttemptRef: string | null, reason: string, failureClass: string, occurredAt: string): Promise<AutomaticRenewalRunResult> {
29
+ await deps.failureHandoff.handoffFailure({
30
+ idempotencyKey: `${operation.identityKey}:failure-handoff`, subscriptionId: operation.subscriptionId,
31
+ clientId: operation.cycleSnapshot.clientId, cycleId: prepared.cycleId, orderId: prepared.orderId,
32
+ paymentIntentId: prepared.settlementIntentId, retryAttempt: HANDOFF_OPENS_AT_RETRY_ATTEMPT,
33
+ failureClass, failureReason: reason,
34
+ nextRetryAt: nextRetryAttemptAt(occurredAt, HANDOFF_OPENS_AT_RETRY_ATTEMPT, undefined, failureClass),
35
+ occurredAt, amountMinor: operation.cycleSnapshot.pricingSnapshot.totalAmountMinor, currency: operation.cycleSnapshot.pricingSnapshot.currency,
36
+ });
37
+ await deps.settlement.recordTerminalOutcome({ operationId: operation.operationId, eventKey: `${operation.identityKey}:provider-refusal`, operationFingerprint: operation.operationFingerprint, outcome: "refused", externalRef: externalAttemptRef, reason, occurredAt });
38
+ return automaticRenewalResult(operation, "refused", reason);
39
+ }
@@ -0,0 +1,46 @@
1
+ export interface SubscriptionCommandContext {
2
+ actor: { kind: "customer" | "operator"; actorId: string; clientId: string };
3
+ subscriptionId: string; idempotencyKey: string; now: string;
4
+ }
5
+ export type SubscriptionCommand = SubscriptionCommandContext & (
6
+ | { action: "pause"; pausePreset: string; reason: string | null; survey: string | null; saveOffer: string | null }
7
+ | { action: "resume" | "skip" | "cancel"; reason: string | null; confirmedChargeTiming: boolean }
8
+ | { action: "slide"; newNextCycleAt: string; confirmedChargeTiming: boolean }
9
+ | { action: "edit_future_terms"; shippingAddressId: string; planDays: number; cadenceDays: number;
10
+ expectedTemplateVersion: number; acceptedQuoteHash: string; confirmedChargeTiming: boolean;
11
+ coreLines: readonly SubscriptionBundleLine[]; addonLines: readonly SubscriptionBundleLine[];
12
+ compositionConstraint: string }
13
+ );
14
+ export interface SubscriptionBundleLine { sku: string; quantity: number }
15
+ export interface SubscriptionCommandReceipt {
16
+ actorId: string; idempotencyKey: string; commandFingerprint: string;
17
+ outcome: "applied" | "replayed" | "noop"; subscriptionId: string;
18
+ originalState: string; nextCycleAt: string | null; revision: string; eventId: string | null;
19
+ }
20
+ export type SubscriptionUnsupportedCode = "multi_cycle_prepaid_unsupported" | "native_generic_bundle_commands_unsupported" | "native_command_bound_receipts_unsupported" | "native_prepared_attempt_store_unsupported";
21
+ export interface SubscriptionUnsupported { supported: false; refusalCode: SubscriptionUnsupportedCode }
22
+ /** Readiness must be selected explicitly; a port declaration does not install a store. */
23
+ export interface SubscriptionCapabilities {
24
+ graph: "managed" | "native";
25
+ multiCyclePrepaid: { supported: false; refusalCode: "multi_cycle_prepaid_unsupported" };
26
+ genericBundleCommands: { supported: true } | { supported: false; refusalCode: "native_generic_bundle_commands_unsupported" };
27
+ commandBoundReceipts: { supported: true } | { supported: false; refusalCode: "native_command_bound_receipts_unsupported" };
28
+ preparedAttemptStore: { supported: true } | { supported: false; refusalCode: "native_prepared_attempt_store_unsupported" };
29
+ retryRecovery: "unavailable" | "selected";
30
+ stockRecovery: "unavailable" | "selected";
31
+ deliveryAdmission: "required";
32
+ executorCount: 1;
33
+ defaultEnabled: false;
34
+ }
35
+ export interface SubscriptionCommandPort {
36
+ readonly capabilities: SubscriptionCapabilities;
37
+ /** Future term writes, actor/command receipt and events must commit atomically. */
38
+ apply(command: SubscriptionCommand): Promise<SubscriptionCommandReceipt | SubscriptionUnsupported>;
39
+ }
40
+ export interface SubscriptionActivationPort {
41
+ activate(input: { idempotencyKey: string; orderId: string; paymentIntentId: string;
42
+ paidEvidenceRef: string; paidAt: string; paymentMethodRef: string; paymentMethodKind: string;
43
+ checkoutKind: string }): Promise<
44
+ | { outcome: "noop" | "blocked"; reason: string }
45
+ | { outcome: "active"; subscriptionId: string; orderId: string; paymentIntentId: string; nextCycleAt: string; cadenceDays: number; replayed: boolean }>;
46
+ }
@@ -0,0 +1,48 @@
1
+ export interface DunningCaseOpenInput {
2
+ idempotencyKey: string; subscriptionId: string; cycleId: string | null; clientId: string;
3
+ retryAttempt: number; nextRetryAt: string | null; failureClass: string | null;
4
+ failureReason: string | null; occurredAt: string; amountMinor: number | null;
5
+ currency: string | null; templateSlug: string; recoveryUrlPath: string | null;
6
+ }
7
+ export interface DunningCaseOpened {
8
+ caseId: string; caseStatus: string; replayed: boolean; notificationId: string | null;
9
+ }
10
+ export interface DunningCaseAdvanced {
11
+ caseId: string; caseStatus: string; notificationId: string | null;
12
+ retryAttempt: number | null; paused: false; pauseWindowId: null; terminal: boolean;
13
+ }
14
+ export type DunningRefusalCode = "case_not_open" | "recoverable_order_unavailable";
15
+ export interface DunningCaseLifecyclePort {
16
+ openCase(input: DunningCaseOpenInput): Promise<DunningCaseOpened>;
17
+ recordFailedAttempt(input: { caseId: string; occurredAt: string; failureClass: string | null; failureReason: string | null; retryAttempt: number; nextRetryAt: string | null; templateSlug: string; recoveryUrlPath: string | null }): Promise<DunningCaseAdvanced>;
18
+ recordRecovery(input: { caseId: string; recoveredAt: string; templateSlug: string }): Promise<
19
+ | { outcome: "recovered"; caseId: string; caseStatus: string; replayed: boolean; notificationId: string | null }
20
+ | { outcome: "refused"; caseId: string; caseStatus: string; refusalCode: DunningRefusalCode }>;
21
+ issueRecoveryLink(input: { caseId: string; tokenHash: string; expiresAt: string }): Promise<
22
+ | { outcome: "issued"; orderId: string }
23
+ | { outcome: "refused"; refusalCode: DunningRefusalCode }>;
24
+ }
25
+ export interface RenewalRetryState {
26
+ rowPresent: boolean; retryAttempt: number | null; nextRetryAt: string | null;
27
+ }
28
+ export interface RenewalRetryObligation {
29
+ operationId: string; subscriptionId: string; cycleId: string; orderId: string;
30
+ settlementIntentId: string; logicalCycleNumber: number; nativeRenewalCounter: number;
31
+ retryAttempt: number; externalAttemptSequence: number; nextRetryAt: string | null;
32
+ failureClass: string | null; failureReason: string | null; occurredAt: string;
33
+ identityKey: string; requestFingerprint: string;
34
+ disposition: "retry_scheduled" | "payment_failed" | "paid";
35
+ }
36
+ export interface RenewalRecoveryAuthorization {
37
+ tokenId: string; tokenHash: string; caseId: string; clientId: string;
38
+ authUserId: string | null; subscriptionId: string; cycleId: string; orderId: string;
39
+ payerAccountRef: string | null; purpose: "repair_payment" | "resume_subscription";
40
+ retryAttempt: number; paymentMethodRef: string; paymentMethodKind: string;
41
+ expiresAt: string; usedAt: string | null; revokedAt: string | null; requestedAt: string;
42
+ }
43
+ export interface RenewalFailureHandoffPort {
44
+ handoffFailure(input: { idempotencyKey: string; subscriptionId: string; clientId: string;
45
+ cycleId: string; orderId: string; paymentIntentId: string; retryAttempt: number;
46
+ failureClass: string; failureReason: string; nextRetryAt: string | null;
47
+ occurredAt: string; amountMinor: number | null; currency: string | null }): Promise<{ caseId: string | null; replayed: boolean }>;
48
+ }
@@ -0,0 +1,6 @@
1
+ export type { RenewalSchedule, RenewalIdentity, RenewalAdmission, RenewalLine, RenewalCycleCorrelation, RenewalTemplateSnapshot, RenewalPricingSnapshot, RenewalOrderSnapshot, RenewalFrozenSnapshot, RenewalDue, RenewalOperationState, RenewalReadback, RenewalPaymentEvidence, RenewalTransactionUnits, RenewalReservationResult, RenewalPreparedSettlement, RenewalStorePort, RenewalSettlementPort, RenewalExecutionRequest, RenewalPaymentRail, RenewalResult } from "./renewal.js";
2
+ export type { DunningCaseOpenInput, DunningCaseOpened, DunningCaseAdvanced, DunningRefusalCode, DunningCaseLifecyclePort, RenewalRetryState, RenewalRetryObligation, RenewalRecoveryAuthorization, RenewalFailureHandoffPort } from "./dunning.js";
3
+ export type { StockWaitRecoveryBinding, StockWaitDeficit, StockWaitCase, StockWaitStorePort } from "./stockWait.js";
4
+ export type { SubscriptionCommandContext, SubscriptionCommand, SubscriptionBundleLine, SubscriptionCommandReceipt, SubscriptionUnsupportedCode, SubscriptionUnsupported, SubscriptionCapabilities, SubscriptionCommandPort, SubscriptionActivationPort } from "./commands.js";
5
+ export type { SubscriptionLifecycleFact, SubscriptionLifecycleMessageDescriptor, SubscriptionLifecycleMessagePort } from "./messages.js";
6
+ export type { SubscriptionRenewalOutcome, HostedRenewalDue, SubscriptionRenewalChargeResult, SubscriptionCycleSnapshotResult, SubscriptionCycleOrderInput, SubscriptionCycleOrderResult, SubscriptionReservationPreflightResult, SubscriptionRenewalPersistencePort, FailurePropagationKind, FailurePropagationContext, CycleRetryState, DunningCaseOpenOutcome, CycleChargeFailurePropagationPort, FailurePropagationResult, RenewalPrepareInput, RenewalAttemptResult, RenewalIntentResult, RenewalPaymentPort, RenewalExecutionPort, RenewalDeliveryAdmission, RenewalAllowedAdmission, RenewalDeliveryClient, RenewalFailureDeps, RenewalPreflightDeps, RenewalCycleDeps, RenewalExecutionContext, RenewalOutcomeContext, RenewalReplayContext, RenewalPreparedAttemptPrepareInput, RenewalPreparedAttemptPersistenceResult, RenewalPreparedAttemptFinalizeInput, RenewalPreparedAttemptPersistencePort } from "./renewal.js";