@openlup/platform 0.23.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 +218 -59
- package/README.md +93 -11
- package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
- package/dist/fulfillment/dispatchProjection.d.ts +1 -1
- package/dist/fulfillment/dispatchProjection.js +4 -2
- package/dist/fulfillment/omnipack/client.js +14 -6
- package/dist/fulfillment/omnipack/index.js +5 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
- package/dist/fulfillment/providerDispatchWorker.js +19 -1
- 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/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/facts.d.ts +11 -1
- package/dist/invoicing/contracts/host.d.ts +3 -4
- package/dist/invoicing/contracts/index.d.ts +1 -1
- package/dist/invoicing/contracts/store.d.ts +15 -5
- 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 +38 -0
- package/dist/invoicing/index.js +36 -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/package.json +3 -3
- package/release-gates.json +21 -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/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/contracts/dispatch.ts +4 -0
- package/src/fulfillment/dispatchProjection.ts +4 -2
- package/src/fulfillment/omnipack/client.ts +13 -6
- package/src/fulfillment/omnipack/index.ts +5 -2
- package/src/fulfillment/providerDispatchWorker.ts +14 -1
- 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/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/facts.ts +12 -1
- package/src/invoicing/contracts/host.ts +3 -4
- package/src/invoicing/contracts/index.ts +1 -1
- package/src/invoicing/contracts/store.ts +11 -7
- 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 +38 -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
|
@@ -48,7 +48,7 @@ export interface InvoiceFacts {
|
|
|
48
48
|
readonly clearanceRequired: boolean;
|
|
49
49
|
readonly currency: string;
|
|
50
50
|
readonly buyer: InvoiceBuyer;
|
|
51
|
-
/**
|
|
51
|
+
/** Claim projection only: the first durable claim freezes the seller; legacy obligations retain execution-time fallback. */
|
|
52
52
|
readonly seller?: InvoiceSeller;
|
|
53
53
|
readonly payment: { readonly intentRef: string; readonly providerRef: string | null; readonly providerPaymentRef: string | null; readonly amountMinor: number; readonly currency: string; readonly localSettlementState: "matched" | "unavailable" };
|
|
54
54
|
readonly orderMoney: { readonly subtotalMinor: number; readonly discountMinor: number; readonly shippingMinor: number; readonly shippingDiscountMinor: number; readonly totalMinor: number } | null;
|
|
@@ -58,3 +58,14 @@ export interface InvoiceFacts {
|
|
|
58
58
|
readonly totalGrossMinor: number;
|
|
59
59
|
readonly chargedTotalGrossMinor: number;
|
|
60
60
|
}
|
|
61
|
+
|
|
62
|
+
/** Host-selected immutable revision; it contains no provider or country defaults. */
|
|
63
|
+
export interface InvoiceSellerRevision<Seller = InvoiceSeller> {
|
|
64
|
+
readonly seller: Seller;
|
|
65
|
+
readonly revisionRef: string;
|
|
66
|
+
}
|
|
67
|
+
/** Added only to returned claim facts, never to installed historical revisions. */
|
|
68
|
+
export interface InvoiceClaimedFacts extends InvoiceFacts {
|
|
69
|
+
readonly sellerRevision?: string;
|
|
70
|
+
readonly sellerMode?: "frozen" | "legacy_fallback";
|
|
71
|
+
}
|
|
@@ -12,8 +12,8 @@ import type { InvoiceStorePort } from "./store.js";
|
|
|
12
12
|
export interface InvoiceHostPorts {
|
|
13
13
|
facts: { read(orderId: string): Promise<{ readonly kind: "found"; readonly facts: InvoiceFacts } | { readonly kind: "unknown"; readonly code: "missing_facts" } | InvoiceUnsupported> };
|
|
14
14
|
/** Read the current host seller for the next claim, never per duty. Null orderId
|
|
15
|
-
* means an unscoped claim (including corrections).
|
|
16
|
-
* first claim; revisionRef feeds the claim wrapper's revision argument.
|
|
15
|
+
* means an unscoped claim (including corrections). The durable wrapper freezes this seller at
|
|
16
|
+
* the first-ever claim; revisionRef feeds the claim wrapper's revision argument.
|
|
17
17
|
*/
|
|
18
18
|
seller: { read(input: { readonly orderId: string | null }): Promise<{ readonly kind: "found"; readonly seller: InvoiceSeller; readonly revisionRef: string } | { readonly kind: "unknown"; readonly code: "unknown" } | InvoiceUnsupported> };
|
|
19
19
|
/** Read-only projection of the final claimed M07 identity, never a refund writer.
|
|
@@ -71,8 +71,7 @@ export interface InvoiceHostPorts {
|
|
|
71
71
|
resolveDownload(input: { readonly accountRef: string; readonly orderId: string; readonly documentRef: string }): Promise<{ readonly kind: "authorized"; readonly identity: InvoiceDocumentIdentity; readonly artifactRef: string; readonly fileName: string; readonly parentDocumentRef: string | null } | { readonly kind: "denied" } | { readonly kind: "unknown"; readonly code: "unknown" } | InvoiceUnsupported>;
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
-
/**
|
|
75
|
-
* is supplied in P1. Closures/credentials must never be serialized with options.
|
|
74
|
+
/** Explicit service composition inputs; the host retains worker and route authority. Closures/credentials must never be serialized with options.
|
|
76
75
|
*/
|
|
77
76
|
export interface InvoiceServicesOptions {
|
|
78
77
|
readonly enabled?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @consumer Frozen fact provider and installed declaration consumer. */
|
|
2
|
-
export type { InvoiceFacts, InvoiceLine, InvoiceSeller, InvoiceBuyer } from "./facts.js";
|
|
2
|
+
export type { InvoiceFacts, InvoiceLine, InvoiceSeller, InvoiceBuyer, InvoiceSellerRevision, InvoiceClaimedFacts } from "./facts.js";
|
|
3
3
|
/** @consumer A4 issue/correction bridge, adapters and operator proof readback. */
|
|
4
4
|
export type { InvoiceIdentity, InvoiceDocumentIdentity, InvoiceWriteResult, InvoiceReadbackResult, InvoiceProviderPort, InvoiceUnsupported, InvoiceDiagnosticCode, InvoiceProof } from "./provider.js";
|
|
5
5
|
/** @consumer Host artifact storage/download binding. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InvoiceHostPorts } from "./host.js";
|
|
2
|
-
import type { InvoiceFacts } from "./facts.js";
|
|
2
|
+
import type { InvoiceFacts, InvoiceClaimedFacts } from "./facts.js";
|
|
3
3
|
import type { InvoiceIdentity, InvoiceDocumentIdentity, InvoiceProof, InvoiceUnsupported, InvoiceWriteResult, InvoiceReadbackResult } from "./provider.js";
|
|
4
4
|
import type { InvoiceClearanceObservation, InvoiceArtifactResult, InvoiceDeliveryPort } from "./delivery.js";
|
|
5
5
|
type InvoiceDutyStatus = "owed" | "blocked" | "ready" | "processing" | "unknown" | "issued" | "voided" | "unsupported" | "faulted";
|
|
@@ -14,7 +14,7 @@ export interface InvoiceClaim {
|
|
|
14
14
|
readonly generation: number;
|
|
15
15
|
readonly leaseUntil: string;
|
|
16
16
|
readonly identity: InvoiceIdentity;
|
|
17
|
-
readonly facts:
|
|
17
|
+
readonly facts: InvoiceClaimedFacts;
|
|
18
18
|
readonly action: "create" | "readback";
|
|
19
19
|
readonly correction: { readonly before: InvoiceFacts; readonly reason: string; readonly after: { readonly totalNetMinor: 0; readonly totalTaxMinor: 0; readonly totalGrossMinor: 0 } } | null;
|
|
20
20
|
}
|
|
@@ -40,14 +40,15 @@ export interface InvoiceDutyReadback {
|
|
|
40
40
|
}
|
|
41
41
|
/** Every operation is one local transaction; commit before external I/O. Native
|
|
42
42
|
* success atomically saves late identity and compensation; stale catch writes nothing.
|
|
43
|
-
*
|
|
43
|
+
* Compatibility stores bind provider selection at construction; the structural
|
|
44
|
+
* native store accepts it per claim and persists the first provider on the duty.
|
|
44
45
|
* No implementation, executor or migration is installed by these declarations.
|
|
45
46
|
*/
|
|
46
47
|
export interface InvoiceStorePort {
|
|
47
48
|
/** Never call inside a renewal money, dunning or dispatch transaction without
|
|
48
49
|
* isolation: capture failure must not propagate; the sweep recovers.
|
|
49
50
|
*/
|
|
50
|
-
captureDuty(input: { readonly requestKey: string; readonly sourceKind: "paid" | "handoff"; readonly sourceId: string; readonly orderId: string; readonly occurredAt: string; readonly evidence: { readonly settlementIntentId: string; readonly amountMinor: number; readonly currency: string } | { readonly shipmentId: string; readonly handedOverAt: string } }): Promise<{ readonly dutyId: string; readonly status: InvoiceDutyStatus } | InvoiceUnsupported>;
|
|
51
|
+
captureDuty(input: { readonly requestKey: string; readonly sourceKind: "paid" | "handoff"; readonly sourceId: string; readonly orderId: string; readonly occurredAt: string; readonly evidence: { readonly settlementIntentId: string; readonly amountMinor: number; readonly currency: string } | { readonly shipmentId: string; readonly handedOverAt: string } }): Promise<{ readonly dutyId: string; readonly status: InvoiceDutyStatus } | { readonly kind: "conflict"; readonly code: "identity_mismatch" } | InvoiceUnsupported>;
|
|
51
52
|
installFacts(input: { readonly dutyId: string; readonly facts: InvoiceFacts }): Promise<{ readonly dutyId: string; readonly status: InvoiceDutyStatus; readonly replayed?: boolean } | InvoiceUnsupported>;
|
|
52
53
|
/** Pinned split facts lease: sweep and prepare commit separately; finishing
|
|
53
54
|
* requires the same token and authoritative document/fact match. No host I/O
|
|
@@ -58,7 +59,7 @@ export interface InvoiceStorePort {
|
|
|
58
59
|
finishFacts(input: { readonly dutyId: string; readonly token: string; readonly result: Omit<Exclude<Awaited<ReturnType<InvoiceStorePort["prepareFacts"]>>, InvoiceUnsupported>, "status"> & { readonly status: "owed" | "ready" | "issued" | "voided" | "unsupported" } }): Promise<{ readonly dutyId: string; readonly status: "owed" | "ready" | "issued" | "voided" | "unsupported" | "faulted" | "stale" } | InvoiceUnsupported>;
|
|
59
60
|
readDuties(orderId: string): Promise<InvoiceDutyReadback | InvoiceUnsupported>;
|
|
60
61
|
/** Filter order before locking/limit; never lease unrelated orders then filter.
|
|
61
|
-
*
|
|
62
|
+
* P7 host composition reads the injected seller before the separate first-claim SQL wrapper;
|
|
62
63
|
* its revisionRef supplies p_revision, without requiring a duty from the caller.
|
|
63
64
|
*/
|
|
64
65
|
claim(input: { readonly limit: number; readonly visibilitySeconds: number; readonly providerRef: string; readonly orderId?: string | null }): Promise<readonly InvoiceClaim[] | InvoiceUnsupported>;
|
|
@@ -80,7 +81,7 @@ export interface InvoiceStorePort {
|
|
|
80
81
|
* Missing means no M09 duty, never invoice absence: retain/retry the reversal
|
|
81
82
|
* intent and route legacy documents to the incumbent path.
|
|
82
83
|
*/
|
|
83
|
-
requestReversal(input: { readonly requestKey: string; readonly orderId: string; readonly reason: "order_canceled" | "order_refunded"; readonly proof: InvoiceProof }): Promise<{ readonly kind: "missing" | "voided" | "pending_readback" | "correction_requested"; readonly dutyId?: string; readonly replayed?: boolean } | { readonly kind: "unsupported_recorded"; readonly dutyId?: string; readonly replayed?: boolean } | InvoiceUnsupported>;
|
|
84
|
+
requestReversal(input: { readonly requestKey: string; readonly orderId: string; readonly reason: "order_canceled" | "order_refunded"; readonly proof: InvoiceProof }): Promise<{ readonly kind: "missing" | "voided" | "pending_readback" | "correction_requested"; readonly dutyId?: string; readonly replayed?: boolean } | { readonly kind: "unsupported_recorded"; readonly dutyId?: string; readonly replayed?: boolean } | { readonly kind: "conflict"; readonly code: "identity_mismatch" } | InvoiceUnsupported>;
|
|
84
85
|
/** Existing local-only request remains distinct from fiscal issuance. */
|
|
85
86
|
requestLocalDocument(input: { readonly requestKey: string; readonly orderId: string; readonly providerRef: string }): Promise<{ readonly kind: "requested"; readonly documentId: string; readonly invoiceRef: string; readonly status: string; readonly replayed: boolean } | InvoiceUnsupported>;
|
|
86
87
|
listClearanceTargets(limit: number): Promise<readonly { readonly documentId: string; readonly identity: InvoiceDocumentIdentity; readonly providerDocumentRef: string; readonly providerDocumentNumber: string | null; readonly state: InvoiceClearanceObservation["state"] }[] | InvoiceUnsupported>;
|
|
@@ -92,7 +93,10 @@ export interface InvoiceStorePort {
|
|
|
92
93
|
claimDeliveries(input: { readonly limit: number; readonly visibilitySeconds: number; readonly requireClearance: boolean; readonly orderId?: string | null }): Promise<readonly { readonly token: string; readonly generation: number; readonly identity: InvoiceDocumentIdentity; readonly providerDocumentRef: string; readonly delivery: Omit<Parameters<InvoiceDeliveryPort["send"]>[0], "filename" | "content" | "contentType"> }[] | InvoiceUnsupported>;
|
|
93
94
|
/** Native uncertain settlement passes p_receipt NULL and retains a non-null
|
|
94
95
|
* receiptRef hint in p_evidence; managed uses it as the provider message id.
|
|
95
|
-
* Managed failed_before_send uses retrySeconds
|
|
96
|
+
* Managed failed_before_send uses the retrySeconds RPC argument. Native
|
|
97
|
+
* failed_before_send carries { phase: 'before_send', retryable, retrySeconds }
|
|
98
|
+
* in p_evidence: whole seconds clamp to 60..86400, absent/invalid uses 900.
|
|
99
|
+
* retryable:false parks at infinity, with failed status and last_error visible.
|
|
96
100
|
*/
|
|
97
101
|
settleDelivery(input: { readonly deliveryRef: string; readonly token: string; readonly generation: number; readonly providerDocumentRef: string; readonly retrySeconds: number; readonly result: Exclude<Awaited<ReturnType<InvoiceDeliveryPort["send"]>>, InvoiceUnsupported> }): Promise<{ readonly kind: "recorded" | "stale"; readonly replayed: boolean } | InvoiceUnsupported>;
|
|
98
102
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
|
|
2
|
+
|
|
3
|
+
/** Inert descriptors for the host's existing registry and executor. No registration. */
|
|
4
|
+
export function createInvoiceContribution(input: {
|
|
5
|
+
version: string;
|
|
6
|
+
requiredSchema: RequiredSchema;
|
|
7
|
+
invoicing: Readonly<Record<string, ((...args: never[]) => unknown) | null>>;
|
|
8
|
+
handlers: Contribution["handlers"];
|
|
9
|
+
schedules: Contribution["schedules"];
|
|
10
|
+
routes: Contribution["routes"];
|
|
11
|
+
}) {
|
|
12
|
+
const contribution: Contribution = {
|
|
13
|
+
manifest: {
|
|
14
|
+
name: "@openlup/platform", version: input.version, kind: "rail",
|
|
15
|
+
module: { id: "invoicing", kind: "capability", provides: ["./invoicing"], dependsOn: [] },
|
|
16
|
+
emits: [], handles: input.handlers.map(handler => handler.eventType), env: [],
|
|
17
|
+
schedules: input.schedules.map(({ id, cadenceSeconds }) => ({ id, cadenceSeconds })),
|
|
18
|
+
requiredPorts: Object.keys(input.invoicing), routes: input.routes.map(route => route.id), requiredSchema: input.requiredSchema,
|
|
19
|
+
},
|
|
20
|
+
handlers: input.handlers, schedules: input.schedules, routes: input.routes,
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
contributions: [contribution],
|
|
24
|
+
ports: Object.fromEntries(Object.entries(input.invoicing).filter(([, fn]) => typeof fn === "function").map(([name, fn]) => [`@openlup/platform/invoicing:${name}`, fn])),
|
|
25
|
+
routes: Object.fromEntries(input.routes.map(route => [`@openlup/platform/invoicing:${route.id}`, route.handle])),
|
|
26
|
+
triggers: Object.fromEntries(input.schedules.map(schedule => [`@openlup/platform/invoicing:${schedule.id}`, schedule.run])),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -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
|
+
}
|