@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.
- package/CHANGELOG.md +440 -158
- package/README.md +141 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +45 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +97 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +31 -13
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +107 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +51 -0
- package/dist/fulfillment/providerDispatchWorker.js +65 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +110 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +245 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +277 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +39 -0
- package/dist/invoicing/index.js +37 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +23 -3
- package/release-gates.json +74 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +24 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +94 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +32 -13
- package/src/fulfillment/omnipack/index.ts +99 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +61 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +71 -0
- package/src/invoicing/contracts/host.ts +84 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +102 -0
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +39 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- 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
|
+
}
|