@openlup/platform 0.23.0 → 0.25.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 (165) hide show
  1. package/CHANGELOG.md +327 -269
  2. package/README.md +123 -12
  3. package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
  4. package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
  5. package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
  6. package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/composition/subscriptionContribution.d.ts +18 -0
  10. package/dist/composition/subscriptionContribution.js +19 -0
  11. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  12. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  13. package/dist/fulfillment/dispatchProjection.js +4 -2
  14. package/dist/fulfillment/omnipack/client.js +14 -6
  15. package/dist/fulfillment/omnipack/index.js +5 -2
  16. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  17. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  18. package/dist/inventory/index.d.ts +2 -0
  19. package/dist/inventory/index.js +1 -0
  20. package/dist/inventory/postgresStockAvailability.d.ts +24 -0
  21. package/dist/inventory/postgresStockAvailability.js +48 -0
  22. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  23. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  24. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  25. package/dist/invoicing/accountingLineAllocation.js +69 -0
  26. package/dist/invoicing/adapterConformance.d.ts +42 -0
  27. package/dist/invoicing/adapterConformance.js +296 -0
  28. package/dist/invoicing/artifacts.d.ts +7 -0
  29. package/dist/invoicing/artifacts.js +20 -0
  30. package/dist/invoicing/bindings.d.ts +46 -0
  31. package/dist/invoicing/bindings.js +69 -0
  32. package/dist/invoicing/channelPolicy.d.ts +28 -0
  33. package/dist/invoicing/channelPolicy.js +27 -0
  34. package/dist/invoicing/continuity.d.ts +12 -0
  35. package/dist/invoicing/continuity.js +18 -0
  36. package/dist/invoicing/contracts/facts.d.ts +11 -1
  37. package/dist/invoicing/contracts/host.d.ts +3 -4
  38. package/dist/invoicing/contracts/index.d.ts +1 -1
  39. package/dist/invoicing/contracts/store.d.ts +15 -5
  40. package/dist/invoicing/contribution.d.ts +21 -0
  41. package/dist/invoicing/contribution.js +19 -0
  42. package/dist/invoicing/correction.d.ts +42 -0
  43. package/dist/invoicing/correction.js +44 -0
  44. package/dist/invoicing/delivery.d.ts +85 -0
  45. package/dist/invoicing/delivery.js +79 -0
  46. package/dist/invoicing/documentHistory.d.ts +50 -0
  47. package/dist/invoicing/documentHistory.js +213 -0
  48. package/dist/invoicing/documentSync.d.ts +24 -0
  49. package/dist/invoicing/documentSync.js +26 -0
  50. package/dist/invoicing/dutyCollector.d.ts +15 -0
  51. package/dist/invoicing/dutyCollector.js +35 -0
  52. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  53. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  54. package/dist/invoicing/handoff.d.ts +37 -0
  55. package/dist/invoicing/handoff.js +62 -0
  56. package/dist/invoicing/index.d.ts +38 -0
  57. package/dist/invoicing/index.js +36 -0
  58. package/dist/invoicing/issue.d.ts +39 -0
  59. package/dist/invoicing/issue.js +55 -0
  60. package/dist/invoicing/migrations.d.ts +48 -0
  61. package/dist/invoicing/migrations.js +25 -0
  62. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  63. package/dist/invoicing/nativeUnsupported.js +22 -0
  64. package/dist/invoicing/paymentReadback.d.ts +20 -0
  65. package/dist/invoicing/paymentReadback.js +47 -0
  66. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  67. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  68. package/dist/invoicing/readModel.d.ts +74 -0
  69. package/dist/invoicing/readModel.js +90 -0
  70. package/dist/invoicing/readback.d.ts +4 -0
  71. package/dist/invoicing/readback.js +30 -0
  72. package/dist/invoicing/reversal.d.ts +37 -0
  73. package/dist/invoicing/reversal.js +28 -0
  74. package/dist/invoicing/sellerRevision.d.ts +19 -0
  75. package/dist/invoicing/sellerRevision.js +14 -0
  76. package/dist/invoicing/settlementSync.d.ts +18 -0
  77. package/dist/invoicing/settlementSync.js +32 -0
  78. package/dist/invoicing/snapshot.d.ts +52 -0
  79. package/dist/invoicing/snapshot.js +141 -0
  80. package/dist/subscriptions/commandExamples.d.ts +6 -0
  81. package/dist/subscriptions/commandExamples.js +20 -0
  82. package/dist/subscriptions/contracts/commands.d.ts +8 -1
  83. package/dist/subscriptions/contracts/index.d.ts +2 -2
  84. package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
  85. package/dist/subscriptions/index.d.ts +22 -4
  86. package/dist/subscriptions/index.js +22 -3
  87. package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
  88. package/dist/subscriptions/lifecycleMessages.js +38 -0
  89. package/dist/subscriptions/paymentOutcomes.js +1 -1
  90. package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
  91. package/dist/subscriptions/postgresCommandStore.js +172 -0
  92. package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
  93. package/dist/subscriptions/postgresRetryStore.js +11 -0
  94. package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
  95. package/dist/subscriptions/postgresStockWaitStore.js +174 -0
  96. package/dist/subscriptions/services.d.ts +31 -0
  97. package/dist/subscriptions/services.js +15 -0
  98. package/package.json +3 -3
  99. package/release-gates.json +21 -7
  100. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  101. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  102. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  103. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  104. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  105. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  106. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  107. package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
  108. package/sql/20261024180000_subscription_stock_wait.sql +330 -0
  109. package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
  110. package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
  111. package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
  112. package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
  113. package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
  114. package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
  115. package/src/composition/index.ts +1 -0
  116. package/src/composition/subscriptionContribution.ts +25 -0
  117. package/src/fulfillment/contracts/dispatch.ts +4 -0
  118. package/src/fulfillment/dispatchProjection.ts +4 -2
  119. package/src/fulfillment/omnipack/client.ts +13 -6
  120. package/src/fulfillment/omnipack/index.ts +5 -2
  121. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  122. package/src/inventory/index.ts +2 -0
  123. package/src/inventory/postgresStockAvailability.ts +54 -0
  124. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  125. package/src/invoicing/accountingLineAllocation.ts +69 -0
  126. package/src/invoicing/adapterConformance.ts +249 -0
  127. package/src/invoicing/artifacts.ts +19 -0
  128. package/src/invoicing/bindings.ts +80 -0
  129. package/src/invoicing/channelPolicy.ts +50 -0
  130. package/src/invoicing/continuity.ts +15 -0
  131. package/src/invoicing/contracts/facts.ts +12 -1
  132. package/src/invoicing/contracts/host.ts +3 -4
  133. package/src/invoicing/contracts/index.ts +1 -1
  134. package/src/invoicing/contracts/store.ts +11 -7
  135. package/src/invoicing/contribution.ts +28 -0
  136. package/src/invoicing/correction.ts +56 -0
  137. package/src/invoicing/delivery.ts +122 -0
  138. package/src/invoicing/documentHistory.ts +301 -0
  139. package/src/invoicing/documentSync.ts +51 -0
  140. package/src/invoicing/dutyCollector.ts +31 -0
  141. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  142. package/src/invoicing/handoff.ts +71 -0
  143. package/src/invoicing/index.ts +38 -0
  144. package/src/invoicing/issue.ts +64 -0
  145. package/src/invoicing/migrations.ts +25 -0
  146. package/src/invoicing/nativeUnsupported.ts +24 -0
  147. package/src/invoicing/paymentReadback.ts +70 -0
  148. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  149. package/src/invoicing/readModel.ts +80 -0
  150. package/src/invoicing/readback.ts +26 -0
  151. package/src/invoicing/reversal.ts +34 -0
  152. package/src/invoicing/sellerRevision.ts +20 -0
  153. package/src/invoicing/settlementSync.ts +37 -0
  154. package/src/invoicing/snapshot.ts +192 -0
  155. package/src/subscriptions/commandExamples.ts +19 -0
  156. package/src/subscriptions/contracts/commands.ts +35 -2
  157. package/src/subscriptions/contracts/index.ts +2 -2
  158. package/src/subscriptions/contracts/stockWait.ts +14 -2
  159. package/src/subscriptions/index.ts +30 -3
  160. package/src/subscriptions/lifecycleMessages.ts +37 -0
  161. package/src/subscriptions/paymentOutcomes.ts +1 -1
  162. package/src/subscriptions/postgresCommandStore.ts +183 -0
  163. package/src/subscriptions/postgresRetryStore.ts +11 -0
  164. package/src/subscriptions/postgresStockWaitStore.ts +170 -0
  165. package/src/subscriptions/services.ts +21 -0
@@ -0,0 +1,56 @@
1
+ import type { InvoiceWriteResult } from "./contracts/provider.js";
2
+
3
+ /** Durable correction identity, recovery and claim fencing from the donor worker.
4
+ * Hosts project immutable BEFORE facts and bind a conforming recovery provider.
5
+ * @consumer server/domains/accounting/accountingJobService.ts
6
+ */
7
+ export async function runInvoiceCorrectionJob<Claim extends {
8
+ outboxId: string; attemptCount: number; providerInvoiceId: string;
9
+ correctionReason: string; payload: Record<string, unknown>;
10
+ }, Snapshot, Result extends Pick<Extract<InvoiceWriteResult, { kind: "issued" }>, "providerDocumentRef" | "providerDocumentNumber">>(input: {
11
+ createEnabled: boolean;
12
+ providerEnabled: boolean;
13
+ claim(): Promise<readonly Claim[]>;
14
+ project(claim: Claim): Snapshot;
15
+ /** Resolve accepted/recovered receipts only; unknown is never an ACK or absence. */
16
+ create(snapshot: Snapshot, options: {
17
+ correctionReason: string; correctedProviderInvoiceId: string;
18
+ correctionRef: string; recoveryLookupRequired: boolean;
19
+ }): Promise<Result>;
20
+ finish(claim: Claim, result: Result): Promise<unknown>;
21
+ isClaimFenceError(error: unknown): boolean;
22
+ fail(claim: Claim, error: unknown, retrySeconds: number): Promise<unknown>;
23
+ }) {
24
+ if (!input.createEnabled) return skipped("create_disabled");
25
+ if (!input.providerEnabled) return skipped("provider_disabled");
26
+ const claims = await input.claim();
27
+ let updated = 0;
28
+ let failures = 0;
29
+ for (const claim of claims) {
30
+ try {
31
+ const correctionRef = `accounting-correction:${claim.outboxId}`;
32
+ if (claim.payload.providerCreateIdentity !== correctionRef) {
33
+ throw new Error("accounting_correction_recovery_identity_missing");
34
+ }
35
+ const snapshot = input.project(claim);
36
+ const result = await input.create(snapshot, {
37
+ correctionReason: claim.correctionReason,
38
+ correctedProviderInvoiceId: claim.providerInvoiceId,
39
+ correctionRef,
40
+ recoveryLookupRequired: claim.attemptCount > 1,
41
+ });
42
+ await input.finish(claim, result);
43
+ updated += 1;
44
+ } catch (error) {
45
+ failures += 1;
46
+ if (input.isClaimFenceError(error)) continue;
47
+ try {
48
+ await input.fail(claim, error, Math.min(3600, 300 * Math.max(1, claim.attemptCount)));
49
+ } catch (failureError) {
50
+ if (!input.isClaimFenceError(failureError)) throw failureError;
51
+ }
52
+ }
53
+ }
54
+ return { ok: failures === 0, checked: claims.length, updated, skipped: false, failures };
55
+ }
56
+ function skipped(reason: string) { return { ok: true, checked: 0, updated: 0, skipped: true, reason, failures: 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
+ }