@atcn/subledger 1.3.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/LICENSE +202 -0
- package/README.md +22 -0
- package/dist/allocation.d.ts +29 -0
- package/dist/allocation.js +69 -0
- package/dist/bridge.d.ts +104 -0
- package/dist/bridge.js +161 -0
- package/dist/documents.d.ts +1877 -0
- package/dist/documents.js +310 -0
- package/dist/exceptions.d.ts +46 -0
- package/dist/exceptions.js +52 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/matching.d.ts +12 -0
- package/dist/matching.js +15 -0
- package/dist/projection.d.ts +99 -0
- package/dist/projection.js +218 -0
- package/dist/response.d.ts +24 -0
- package/dist/response.js +34 -0
- package/dist/rollup.d.ts +78 -0
- package/dist/rollup.js +176 -0
- package/dist/types.d.ts +280 -0
- package/dist/types.js +188 -0
- package/dist/verify.d.ts +30 -0
- package/dist/verify.js +372 -0
- package/package.json +40 -0
- package/test-vectors/vectors.json +108 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { digestOf } from "@atcn/schema";
|
|
2
|
+
import { computeRollup, costSign } from "./rollup.js";
|
|
3
|
+
import { CLOSURE_DOCUMENT_TYPE, RECEIPT_DOCUMENT_TYPE, SUBLEDGER_SCHEMA_VERSION, } from "./documents.js";
|
|
4
|
+
import { ATTESTABLE_FIELDS } from "./types.js";
|
|
5
|
+
/** Claims superseded by a later correction stay visible and gain the "superseded" label. */
|
|
6
|
+
export function labelClaims(claims) {
|
|
7
|
+
const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
|
|
8
|
+
return claims.map((c) => (superseded.has(c.event_id) && !c.assurance.includes("superseded") ? { ...c, assurance: [...c.assurance, "superseded"] } : c));
|
|
9
|
+
}
|
|
10
|
+
const STATUS_BY_CLAIM = {
|
|
11
|
+
acceptance: "accepted",
|
|
12
|
+
completion: "completed",
|
|
13
|
+
partial_completion: "partially_completed",
|
|
14
|
+
cancellation: "cancelled",
|
|
15
|
+
provider_failure: "provider_failed",
|
|
16
|
+
};
|
|
17
|
+
/** Delivery status is the latest non-superseded status claim. It is a recorded claim, not an adjudicated truth. */
|
|
18
|
+
export function deliveryStatus(claims) {
|
|
19
|
+
const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
|
|
20
|
+
const active = claims.filter((c) => STATUS_BY_CLAIM[c.type] && !superseded.has(c.event_id));
|
|
21
|
+
const latest = active.at(-1);
|
|
22
|
+
return latest ? STATUS_BY_CLAIM[latest.type] : "delegated";
|
|
23
|
+
}
|
|
24
|
+
/** Totals for a single delegation's own events (no allocation detail, which may name other cost centers). */
|
|
25
|
+
export function receiptTotals(delegation, events) {
|
|
26
|
+
const rollup = computeRollup({
|
|
27
|
+
root: { task_id: "receipt_scope", currency: delegation.currency, budget_minor: null },
|
|
28
|
+
delegations: [{ ...delegation, parent_delegation_id: null }],
|
|
29
|
+
events,
|
|
30
|
+
attribution: Object.fromEntries(events.map((e) => [e.financial_event_id, delegation.delegation_id])),
|
|
31
|
+
allocations: {},
|
|
32
|
+
});
|
|
33
|
+
const direct = rollup.nodes.find((n) => n.node_id === delegation.delegation_id).direct;
|
|
34
|
+
const result = {};
|
|
35
|
+
for (const [currency, { allocated: _allocated, unallocated: _unallocated, ...rest }] of Object.entries(direct))
|
|
36
|
+
result[currency] = rest;
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function openCorrectionFields(responses) {
|
|
40
|
+
const fields = new Set();
|
|
41
|
+
for (const r of responses) {
|
|
42
|
+
if (r.statement.response_type !== "propose_correction")
|
|
43
|
+
continue;
|
|
44
|
+
if (r.decision?.status === "accepted")
|
|
45
|
+
continue;
|
|
46
|
+
for (const c of r.statement.corrections)
|
|
47
|
+
fields.add(c.field);
|
|
48
|
+
}
|
|
49
|
+
return fields;
|
|
50
|
+
}
|
|
51
|
+
export function receiptFieldStatus(delegation, claims, events, priorResponses) {
|
|
52
|
+
const superseded = new Set(claims.map((c) => c.supersedes_event_id).filter((id) => id !== null));
|
|
53
|
+
const active = claims.filter((c) => !superseded.has(c.event_id) && c.type !== "correction");
|
|
54
|
+
const stateOf = (claim) => {
|
|
55
|
+
if (!claim)
|
|
56
|
+
return "missing";
|
|
57
|
+
if (claim.asserted_by === "provider")
|
|
58
|
+
return "provider_reported";
|
|
59
|
+
if (claim.asserted_by === "buyer")
|
|
60
|
+
return "buyer_asserted";
|
|
61
|
+
return "imported";
|
|
62
|
+
};
|
|
63
|
+
const status = {
|
|
64
|
+
"delivery.status": stateOf(active.filter((c) => STATUS_BY_CLAIM[c.type]).at(-1)),
|
|
65
|
+
"delivery.evidence": stateOf(active.filter((c) => c.evidence.length > 0).at(-1)),
|
|
66
|
+
"scope.terms_digest": delegation.terms_digest ? "buyer_asserted" : "missing",
|
|
67
|
+
"financial.amounts": events.some((e) => costSign(e.type) !== 0 || e.type === "quote") ? "imported" : "missing",
|
|
68
|
+
"financial.status": events.some((e) => e.normalized_status !== "unknown") ? "imported" : "missing",
|
|
69
|
+
};
|
|
70
|
+
for (const field of openCorrectionFields(priorResponses))
|
|
71
|
+
status[field] = "contested";
|
|
72
|
+
return status;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Provider receipt: one delegation only. Omits sibling delegations, root customer/project/cost-center data,
|
|
76
|
+
* allocation lines, liability and economic-event IDs, and private event hashes (acceptance 8, 19).
|
|
77
|
+
*/
|
|
78
|
+
export function buildReceiptPayload(input) {
|
|
79
|
+
const d = input.delegation;
|
|
80
|
+
const claims = labelClaims(input.claims);
|
|
81
|
+
const fieldStatus = receiptFieldStatus(d, claims, input.events, input.prior_responses);
|
|
82
|
+
return {
|
|
83
|
+
document_type: RECEIPT_DOCUMENT_TYPE,
|
|
84
|
+
schema_version: SUBLEDGER_SCHEMA_VERSION,
|
|
85
|
+
receipt_id: input.receipt_id,
|
|
86
|
+
revision: input.revision,
|
|
87
|
+
previous_receipt_id: input.previous?.receipt_id ?? null,
|
|
88
|
+
previous_receipt_digest: input.previous?.digest ?? null,
|
|
89
|
+
issued_at: input.issued_at,
|
|
90
|
+
expires_at: input.expires_at,
|
|
91
|
+
issuer: input.issuer,
|
|
92
|
+
delegation: {
|
|
93
|
+
delegation_id: d.delegation_id,
|
|
94
|
+
root_task_id: d.root_task_id,
|
|
95
|
+
external_ref: d.external_ref,
|
|
96
|
+
provider_job_ref: d.provider_job_ref,
|
|
97
|
+
shared_description: d.shared_description,
|
|
98
|
+
terms_digest: d.terms_digest,
|
|
99
|
+
currency: d.currency,
|
|
100
|
+
quoted_max_minor: d.quoted_max_minor,
|
|
101
|
+
quote_basis: d.quote_basis,
|
|
102
|
+
accepted_amount_minor: d.accepted_amount_minor,
|
|
103
|
+
expected_delivery: d.expected_delivery,
|
|
104
|
+
retrospective: d.retrospective,
|
|
105
|
+
downstream_visibility: d.downstream_visibility,
|
|
106
|
+
},
|
|
107
|
+
provider: {
|
|
108
|
+
provider_id: input.provider?.provider_id ?? null,
|
|
109
|
+
name_stated: input.provider?.name ?? d.provider_name_stated,
|
|
110
|
+
provider_own_id: input.provider?.provider_own_id ?? d.provider_own_id,
|
|
111
|
+
identity_binding: input.provider_key_bound ? "key_bound" : "not_bound",
|
|
112
|
+
},
|
|
113
|
+
delivery_claims: claims.map(({ delegation_id: _delegationId, ...claim }) => claim),
|
|
114
|
+
financial_events: input.events.map(({ liability_owner: _liabilityOwner, economic_event_id: _economicEventId, fx: _fx, ...event }) => ({
|
|
115
|
+
...event,
|
|
116
|
+
allocation_version: input.allocation_versions[event.financial_event_id] ?? 0,
|
|
117
|
+
})),
|
|
118
|
+
totals: receiptTotals(d, input.events),
|
|
119
|
+
field_status: fieldStatus,
|
|
120
|
+
unverified_fields: ATTESTABLE_FIELDS.filter((f) => fieldStatus[f] !== "missing"),
|
|
121
|
+
corrections: input.prior_responses
|
|
122
|
+
.filter((r) => r.statement.response_type === "propose_correction")
|
|
123
|
+
.map((r) => ({ response_id: r.response_id, receipt_revision: r.receipt_revision, fields: r.statement.fields, decision: r.decision?.status ?? "open" })),
|
|
124
|
+
lineage: { complete: input.capture_gaps.length === 0, capture_gaps: input.capture_gaps.map(({ delegation_id: _delegationId, ...gap }) => gap) },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Latest allocation lines per financial event, the input the roll-up uses. */
|
|
128
|
+
export function latestAllocations(allocations) {
|
|
129
|
+
const latest = {};
|
|
130
|
+
for (const a of allocations)
|
|
131
|
+
if (!latest[a.financial_event_id] || latest[a.financial_event_id].version < a.version)
|
|
132
|
+
latest[a.financial_event_id] = a;
|
|
133
|
+
return latest;
|
|
134
|
+
}
|
|
135
|
+
export function rollupFor(task, delegations, events, allocations) {
|
|
136
|
+
const latest = latestAllocations(allocations);
|
|
137
|
+
return computeRollup({
|
|
138
|
+
root: { task_id: task.task_id, currency: task.currency, budget_minor: task.budget_minor },
|
|
139
|
+
delegations,
|
|
140
|
+
events: events.map((e) => e.record),
|
|
141
|
+
attribution: Object.fromEntries(events.map((e) => [e.record.financial_event_id, e.attributed_to])),
|
|
142
|
+
allocations: Object.fromEntries(Object.entries(latest).map(([id, a]) => [id, a.lines])),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function closureDisclosure(input) {
|
|
146
|
+
const missing = [];
|
|
147
|
+
const unverified = [];
|
|
148
|
+
const contested = [];
|
|
149
|
+
const providerReported = [];
|
|
150
|
+
const retrospective = [];
|
|
151
|
+
if (input.task.retrospective)
|
|
152
|
+
retrospective.push(`task:${input.task.task_id}`);
|
|
153
|
+
for (const d of input.delegations) {
|
|
154
|
+
if (!d.provider_id)
|
|
155
|
+
missing.push(`delegation:${d.delegation_id}.provider`);
|
|
156
|
+
if (!d.terms_digest)
|
|
157
|
+
missing.push(`delegation:${d.delegation_id}.terms_digest`);
|
|
158
|
+
if (!input.claims.some((c) => c.delegation_id === d.delegation_id && STATUS_BY_CLAIM[c.type]))
|
|
159
|
+
missing.push(`delegation:${d.delegation_id}.delivery_status`);
|
|
160
|
+
if (d.downstream_visibility === "unknown")
|
|
161
|
+
missing.push(`delegation:${d.delegation_id}.downstream_work`);
|
|
162
|
+
if (d.retrospective)
|
|
163
|
+
retrospective.push(`delegation:${d.delegation_id}`);
|
|
164
|
+
}
|
|
165
|
+
for (const c of input.claims) {
|
|
166
|
+
if (c.evidence.length === 0)
|
|
167
|
+
unverified.push(`delivery_claim:${c.event_id}.evidence`);
|
|
168
|
+
if (c.asserted_by === "provider")
|
|
169
|
+
providerReported.push(`delivery_claim:${c.event_id}`);
|
|
170
|
+
if (c.retrospective)
|
|
171
|
+
retrospective.push(`delivery_claim:${c.event_id}`);
|
|
172
|
+
}
|
|
173
|
+
for (const { record } of input.events) {
|
|
174
|
+
if (!record.evidence)
|
|
175
|
+
unverified.push(`financial_event:${record.financial_event_id}.evidence`);
|
|
176
|
+
if (record.retrospective)
|
|
177
|
+
retrospective.push(`financial_event:${record.financial_event_id}`);
|
|
178
|
+
}
|
|
179
|
+
for (const r of input.responses) {
|
|
180
|
+
if (r.statement.response_type === "submit_evidence")
|
|
181
|
+
providerReported.push(`response:${r.response_id}`);
|
|
182
|
+
if (r.statement.response_type === "propose_correction" && r.decision?.status !== "accepted") {
|
|
183
|
+
for (const c of r.statement.corrections)
|
|
184
|
+
contested.push(`receipt:${r.receipt_id}.${c.field}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { missing, unverified, contested, provider_reported: providerReported, retrospective };
|
|
188
|
+
}
|
|
189
|
+
/** Private closure snapshot of the full root task: lineage, event digests, all allocation versions, roll-up, and open exceptions. */
|
|
190
|
+
export function buildClosurePayload(input) {
|
|
191
|
+
return {
|
|
192
|
+
document_type: CLOSURE_DOCUMENT_TYPE,
|
|
193
|
+
schema_version: SUBLEDGER_SCHEMA_VERSION,
|
|
194
|
+
closure_id: input.closure_id,
|
|
195
|
+
version: input.version,
|
|
196
|
+
previous_closure_id: input.previous?.closure_id ?? null,
|
|
197
|
+
previous_closure_digest: input.previous?.digest ?? null,
|
|
198
|
+
generated_at: input.generated_at,
|
|
199
|
+
issuer: input.issuer,
|
|
200
|
+
task: input.task,
|
|
201
|
+
delegations: input.delegations,
|
|
202
|
+
delivery_claims: labelClaims(input.claims),
|
|
203
|
+
financial_events: input.events.map((e) => ({ record: e.record, event_digest: digestOf(e.record), attributed_to: e.attributed_to })),
|
|
204
|
+
allocations: input.allocations,
|
|
205
|
+
rollup: rollupFor(input.task, input.delegations, input.events, input.allocations),
|
|
206
|
+
open_exceptions: input.open_exceptions,
|
|
207
|
+
receipts: input.receipts,
|
|
208
|
+
responses: input.responses,
|
|
209
|
+
key_bindings: input.key_bindings,
|
|
210
|
+
lineage: {
|
|
211
|
+
complete: input.capture_gaps.length === 0,
|
|
212
|
+
capture_gaps: input.capture_gaps,
|
|
213
|
+
unknown_downstream: input.delegations.filter((d) => d.downstream_visibility === "unknown").map((d) => d.delegation_id),
|
|
214
|
+
},
|
|
215
|
+
disclosure: closureDisclosure(input),
|
|
216
|
+
...(input.obligation_links && input.obligation_links.length > 0 ? { obligation_links: input.obligation_links } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Correction, type ResponseStatement } from "./documents.js";
|
|
2
|
+
import type { AttestableField, EvidenceRef, ResponseType } from "./types.js";
|
|
3
|
+
export interface StatementInput {
|
|
4
|
+
receipt: {
|
|
5
|
+
receipt_id: string;
|
|
6
|
+
digest: string;
|
|
7
|
+
revision: number;
|
|
8
|
+
issuer_operator_id: string;
|
|
9
|
+
};
|
|
10
|
+
response_type: ResponseType;
|
|
11
|
+
fields: AttestableField[];
|
|
12
|
+
note?: string | null;
|
|
13
|
+
evidence?: EvidenceRef[];
|
|
14
|
+
corrections?: Correction[];
|
|
15
|
+
}
|
|
16
|
+
/** Builds the statement a provider responds with. Field order is irrelevant: the signature covers canonical JSON. */
|
|
17
|
+
export declare function buildResponseStatement(input: StatementInput): ResponseStatement;
|
|
18
|
+
export declare function statementDigest(statement: ResponseStatement): string;
|
|
19
|
+
/** Provider-side signing (in the browser page, SDK, or provider tooling). The service never sees the private key. */
|
|
20
|
+
export declare function signStatement(statement: ResponseStatement, privateKey: string): string;
|
|
21
|
+
/** Operator-side countersignature over a closure or receipt payload, made with a key the operator holds. */
|
|
22
|
+
export declare function countersignPayload(payload: unknown, privateKey: string): string;
|
|
23
|
+
export declare function verifyCountersignature(payload: unknown, signature: string, publicKey: string): boolean;
|
|
24
|
+
export declare function verifyStatementSignature(statement: ResponseStatement, signature: string, publicKey: string): boolean;
|
package/dist/response.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { canonicalize, digestOf, signBytes, utf8Encode, verifyBytes } from "@atcn/schema";
|
|
2
|
+
import { RESPONSE_STATEMENT_TYPE } from "./documents.js";
|
|
3
|
+
/** Builds the statement a provider responds with. Field order is irrelevant: the signature covers canonical JSON. */
|
|
4
|
+
export function buildResponseStatement(input) {
|
|
5
|
+
return {
|
|
6
|
+
document_type: RESPONSE_STATEMENT_TYPE,
|
|
7
|
+
receipt_id: input.receipt.receipt_id,
|
|
8
|
+
receipt_digest: input.receipt.digest,
|
|
9
|
+
receipt_revision: input.receipt.revision,
|
|
10
|
+
issuer_operator_id: input.receipt.issuer_operator_id,
|
|
11
|
+
response_type: input.response_type,
|
|
12
|
+
fields: [...new Set(input.fields)].sort(),
|
|
13
|
+
note: input.note ?? null,
|
|
14
|
+
evidence: input.evidence ?? [],
|
|
15
|
+
corrections: input.corrections ?? [],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function statementDigest(statement) {
|
|
19
|
+
return digestOf(statement);
|
|
20
|
+
}
|
|
21
|
+
/** Provider-side signing (in the browser page, SDK, or provider tooling). The service never sees the private key. */
|
|
22
|
+
export function signStatement(statement, privateKey) {
|
|
23
|
+
return signBytes(utf8Encode(canonicalize(statement)), privateKey);
|
|
24
|
+
}
|
|
25
|
+
/** Operator-side countersignature over a closure or receipt payload, made with a key the operator holds. */
|
|
26
|
+
export function countersignPayload(payload, privateKey) {
|
|
27
|
+
return signBytes(utf8Encode(canonicalize(payload)), privateKey);
|
|
28
|
+
}
|
|
29
|
+
export function verifyCountersignature(payload, signature, publicKey) {
|
|
30
|
+
return verifyBytes(utf8Encode(canonicalize(payload)), signature, publicKey);
|
|
31
|
+
}
|
|
32
|
+
export function verifyStatementSignature(statement, signature, publicKey) {
|
|
33
|
+
return verifyBytes(utf8Encode(canonicalize(statement)), signature, publicKey);
|
|
34
|
+
}
|
package/dist/rollup.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AllocationLine, DelegationNode, FinancialEventRecord, FinancialEventType, RootNode } from "./types.js";
|
|
2
|
+
/** Per-currency figures for one node. Amounts are never combined across currencies. */
|
|
3
|
+
export interface Totals {
|
|
4
|
+
quoted: number;
|
|
5
|
+
accepted: number;
|
|
6
|
+
invoiced: number;
|
|
7
|
+
charged: number;
|
|
8
|
+
fees: number;
|
|
9
|
+
adjustments: number;
|
|
10
|
+
refunded: number;
|
|
11
|
+
credits: number;
|
|
12
|
+
reported_paid: number;
|
|
13
|
+
/** Buyer expense: invoiced + charged + fees + adjustments - refunded - credits. */
|
|
14
|
+
net_cost: number;
|
|
15
|
+
/** Billed but not reported paid: invoiced + charged + fees + adjustments - credits - reported_paid. Refunds cancel out of both sides. */
|
|
16
|
+
unresolved: number;
|
|
17
|
+
/** Cost reported for downstream work that the buyer does not pay directly (included in a parent fee, or paid by another party). */
|
|
18
|
+
downstream_reported: number;
|
|
19
|
+
allocated: number;
|
|
20
|
+
unallocated: number;
|
|
21
|
+
}
|
|
22
|
+
export type CurrencyTotals = Record<string, Totals>;
|
|
23
|
+
export interface NodeRollup {
|
|
24
|
+
node_id: string;
|
|
25
|
+
parent_id: string | null;
|
|
26
|
+
direct: CurrencyTotals;
|
|
27
|
+
descendant: CurrencyTotals;
|
|
28
|
+
total: CurrencyTotals;
|
|
29
|
+
event_ids: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface RollupInput {
|
|
32
|
+
root: RootNode;
|
|
33
|
+
delegations: DelegationNode[];
|
|
34
|
+
events: FinancialEventRecord[];
|
|
35
|
+
/** Current attribution: financial_event_id -> node id (task id or delegation id). */
|
|
36
|
+
attribution: Record<string, string>;
|
|
37
|
+
/** Current allocation lines per financial_event_id. */
|
|
38
|
+
allocations: Record<string, AllocationLine[]>;
|
|
39
|
+
}
|
|
40
|
+
export interface Rollup {
|
|
41
|
+
root_task_id: string;
|
|
42
|
+
nodes: NodeRollup[];
|
|
43
|
+
root_total: CurrencyTotals;
|
|
44
|
+
excluded_event_ids: {
|
|
45
|
+
reversed: string[];
|
|
46
|
+
reversals: string[];
|
|
47
|
+
fx_rates: string[];
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** +1 adds to buyer cost, -1 reduces it, 0 carries no cost (quotes, payment reports, reversals, rates). */
|
|
51
|
+
export declare function costSign(type: FinancialEventType): 1 | -1 | 0;
|
|
52
|
+
export declare function emptyTotals(): Totals;
|
|
53
|
+
/** True when an event counts as the buyer's own expense rather than reported downstream cost (acceptance 25). */
|
|
54
|
+
export declare function isBuyerExpense(event: FinancialEventRecord): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Deterministic roll-up of one root task (acceptance 1, 6, 25). Each event is attributed to exactly one node
|
|
57
|
+
* and counted once; parents show direct and descendant cost separately. Reversed events and the reversals
|
|
58
|
+
* themselves are excluded from all totals but stay in the record.
|
|
59
|
+
*/
|
|
60
|
+
export declare function computeRollup(input: RollupInput): Rollup;
|
|
61
|
+
export interface Conversion {
|
|
62
|
+
report_currency: string;
|
|
63
|
+
converted_net_cost: number | null;
|
|
64
|
+
rates_used: {
|
|
65
|
+
currency: string;
|
|
66
|
+
fx_event_id: string;
|
|
67
|
+
rate_numerator: number;
|
|
68
|
+
rate_denominator: number;
|
|
69
|
+
as_of: string;
|
|
70
|
+
}[];
|
|
71
|
+
missing_rates: string[];
|
|
72
|
+
rounding: "half_up_per_currency";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Converts per-currency net cost into one report currency only with explicit, dated fx_rate events (acceptance 5).
|
|
76
|
+
* Uses the latest rate dated on or before asOf for each currency; any missing rate leaves the total null.
|
|
77
|
+
*/
|
|
78
|
+
export declare function convertNetCost(totals: CurrencyTotals, reportCurrency: string, fxEvents: FinancialEventRecord[], asOf: string): Conversion;
|
package/dist/rollup.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** +1 adds to buyer cost, -1 reduces it, 0 carries no cost (quotes, payment reports, reversals, rates). */
|
|
2
|
+
export function costSign(type) {
|
|
3
|
+
if (type === "invoice" || type === "charge" || type === "fee" || type === "adjustment")
|
|
4
|
+
return 1;
|
|
5
|
+
if (type === "refund" || type === "credit")
|
|
6
|
+
return -1;
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
export function emptyTotals() {
|
|
10
|
+
return {
|
|
11
|
+
quoted: 0,
|
|
12
|
+
accepted: 0,
|
|
13
|
+
invoiced: 0,
|
|
14
|
+
charged: 0,
|
|
15
|
+
fees: 0,
|
|
16
|
+
adjustments: 0,
|
|
17
|
+
refunded: 0,
|
|
18
|
+
credits: 0,
|
|
19
|
+
reported_paid: 0,
|
|
20
|
+
net_cost: 0,
|
|
21
|
+
unresolved: 0,
|
|
22
|
+
downstream_reported: 0,
|
|
23
|
+
allocated: 0,
|
|
24
|
+
unallocated: 0,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function bucket(totals, currency) {
|
|
28
|
+
totals[currency] ??= emptyTotals();
|
|
29
|
+
return totals[currency];
|
|
30
|
+
}
|
|
31
|
+
function addInto(target, source) {
|
|
32
|
+
for (const [currency, values] of Object.entries(source)) {
|
|
33
|
+
const into = bucket(target, currency);
|
|
34
|
+
for (const key of Object.keys(values))
|
|
35
|
+
into[key] += values[key];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** True when an event counts as the buyer's own expense rather than reported downstream cost (acceptance 25). */
|
|
39
|
+
export function isBuyerExpense(event) {
|
|
40
|
+
return event.payer === "buyer" && event.included_in_event_id === null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Deterministic roll-up of one root task (acceptance 1, 6, 25). Each event is attributed to exactly one node
|
|
44
|
+
* and counted once; parents show direct and descendant cost separately. Reversed events and the reversals
|
|
45
|
+
* themselves are excluded from all totals but stay in the record.
|
|
46
|
+
*/
|
|
47
|
+
export function computeRollup(input) {
|
|
48
|
+
const reversed = new Set(input.events.filter((e) => e.type === "reversal" && e.reverses_event_id).map((e) => e.reverses_event_id));
|
|
49
|
+
const nodeIds = [input.root.task_id, ...input.delegations.map((d) => d.delegation_id)];
|
|
50
|
+
const nodes = new Map();
|
|
51
|
+
nodes.set(input.root.task_id, { node_id: input.root.task_id, parent_id: null, direct: {}, descendant: {}, total: {}, event_ids: [] });
|
|
52
|
+
for (const d of input.delegations) {
|
|
53
|
+
nodes.set(d.delegation_id, { node_id: d.delegation_id, parent_id: d.parent_delegation_id ?? input.root.task_id, direct: {}, descendant: {}, total: {}, event_ids: [] });
|
|
54
|
+
}
|
|
55
|
+
const latestQuote = new Map();
|
|
56
|
+
for (const event of input.events) {
|
|
57
|
+
const nodeId = input.attribution[event.financial_event_id];
|
|
58
|
+
if (!nodeId || !nodes.has(nodeId))
|
|
59
|
+
continue;
|
|
60
|
+
if (event.type === "fx_rate" || event.type === "reversal")
|
|
61
|
+
continue;
|
|
62
|
+
if (reversed.has(event.financial_event_id))
|
|
63
|
+
continue;
|
|
64
|
+
const node = nodes.get(nodeId);
|
|
65
|
+
node.event_ids.push(event.financial_event_id);
|
|
66
|
+
const t = bucket(node.direct, event.currency);
|
|
67
|
+
if (event.type === "quote") {
|
|
68
|
+
const previous = latestQuote.get(nodeId);
|
|
69
|
+
if (!previous || previous.event_date < event.event_date || (previous.event_date === event.event_date && previous.financial_event_id < event.financial_event_id)) {
|
|
70
|
+
latestQuote.set(nodeId, event);
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (event.type === "payment_reported") {
|
|
75
|
+
t.reported_paid += event.amount_minor;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const sign = costSign(event.type);
|
|
79
|
+
if (!isBuyerExpense(event)) {
|
|
80
|
+
t.downstream_reported += sign * event.amount_minor;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (event.type === "invoice")
|
|
84
|
+
t.invoiced += event.amount_minor;
|
|
85
|
+
if (event.type === "charge")
|
|
86
|
+
t.charged += event.amount_minor;
|
|
87
|
+
if (event.type === "fee")
|
|
88
|
+
t.fees += event.amount_minor;
|
|
89
|
+
if (event.type === "adjustment")
|
|
90
|
+
t.adjustments += event.amount_minor;
|
|
91
|
+
if (event.type === "refund")
|
|
92
|
+
t.refunded += event.amount_minor;
|
|
93
|
+
if (event.type === "credit")
|
|
94
|
+
t.credits += event.amount_minor;
|
|
95
|
+
const lines = input.allocations[event.financial_event_id] ?? [];
|
|
96
|
+
const allocated = lines.filter((l) => l.target.type !== "unallocated").reduce((sum, l) => sum + l.amount_minor, 0);
|
|
97
|
+
t.allocated += sign * Math.sign(event.amount_minor || 1) * allocated;
|
|
98
|
+
}
|
|
99
|
+
for (const d of input.delegations) {
|
|
100
|
+
const node = nodes.get(d.delegation_id);
|
|
101
|
+
const quote = latestQuote.get(d.delegation_id);
|
|
102
|
+
if (quote)
|
|
103
|
+
bucket(node.direct, quote.currency).quoted += quote.amount_minor;
|
|
104
|
+
else if (d.quoted_max_minor !== null)
|
|
105
|
+
bucket(node.direct, d.currency).quoted += d.quoted_max_minor;
|
|
106
|
+
if (d.accepted_amount_minor !== null)
|
|
107
|
+
bucket(node.direct, d.currency).accepted += d.accepted_amount_minor;
|
|
108
|
+
}
|
|
109
|
+
const rootQuote = latestQuote.get(input.root.task_id);
|
|
110
|
+
if (rootQuote)
|
|
111
|
+
bucket(nodes.get(input.root.task_id).direct, rootQuote.currency).quoted += rootQuote.amount_minor;
|
|
112
|
+
for (const node of nodes.values()) {
|
|
113
|
+
for (const t of Object.values(node.direct)) {
|
|
114
|
+
t.net_cost = t.invoiced + t.charged + t.fees + t.adjustments - t.refunded - t.credits;
|
|
115
|
+
t.unresolved = t.invoiced + t.charged + t.fees + t.adjustments - t.credits - t.reported_paid;
|
|
116
|
+
t.unallocated = t.net_cost - t.allocated;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Children before parents: deepest first, so each subtree total is final when added to its parent.
|
|
120
|
+
const depth = (id) => {
|
|
121
|
+
let d = 0;
|
|
122
|
+
let current = nodes.get(id);
|
|
123
|
+
while (current.parent_id) {
|
|
124
|
+
d += 1;
|
|
125
|
+
current = nodes.get(current.parent_id);
|
|
126
|
+
}
|
|
127
|
+
return d;
|
|
128
|
+
};
|
|
129
|
+
const order = [...nodeIds].sort((a, b) => depth(b) - depth(a));
|
|
130
|
+
for (const id of order) {
|
|
131
|
+
const node = nodes.get(id);
|
|
132
|
+
addInto(node.total, node.direct);
|
|
133
|
+
addInto(node.total, node.descendant);
|
|
134
|
+
if (node.parent_id)
|
|
135
|
+
addInto(nodes.get(node.parent_id).descendant, node.total);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
root_task_id: input.root.task_id,
|
|
139
|
+
nodes: nodeIds.map((id) => nodes.get(id)),
|
|
140
|
+
root_total: nodes.get(input.root.task_id).total,
|
|
141
|
+
excluded_event_ids: {
|
|
142
|
+
reversed: [...reversed].sort(),
|
|
143
|
+
reversals: input.events.filter((e) => e.type === "reversal").map((e) => e.financial_event_id).sort(),
|
|
144
|
+
fx_rates: input.events.filter((e) => e.type === "fx_rate").map((e) => e.financial_event_id).sort(),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Converts per-currency net cost into one report currency only with explicit, dated fx_rate events (acceptance 5).
|
|
150
|
+
* Uses the latest rate dated on or before asOf for each currency; any missing rate leaves the total null.
|
|
151
|
+
*/
|
|
152
|
+
export function convertNetCost(totals, reportCurrency, fxEvents, asOf) {
|
|
153
|
+
const rates_used = [];
|
|
154
|
+
const missing_rates = [];
|
|
155
|
+
let sum = 0n;
|
|
156
|
+
for (const [currency, t] of Object.entries(totals).sort(([a], [b]) => a.localeCompare(b))) {
|
|
157
|
+
if (currency === reportCurrency) {
|
|
158
|
+
sum += BigInt(t.net_cost);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const candidates = fxEvents
|
|
162
|
+
.filter((e) => e.type === "fx_rate" && e.fx && e.fx.base_currency === currency && e.fx.quote_currency === reportCurrency && e.event_date <= asOf)
|
|
163
|
+
.sort((a, b) => (a.event_date === b.event_date ? a.financial_event_id.localeCompare(b.financial_event_id) : a.event_date.localeCompare(b.event_date)));
|
|
164
|
+
const rate = candidates.at(-1);
|
|
165
|
+
if (!rate) {
|
|
166
|
+
missing_rates.push(currency);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const numerator = BigInt(t.net_cost) * BigInt(rate.fx.rate_numerator);
|
|
170
|
+
const denominator = BigInt(rate.fx.rate_denominator);
|
|
171
|
+
const half = denominator / 2n;
|
|
172
|
+
sum += numerator >= 0n ? (numerator + half) / denominator : -((-numerator + half) / denominator);
|
|
173
|
+
rates_used.push({ currency, fx_event_id: rate.financial_event_id, rate_numerator: rate.fx.rate_numerator, rate_denominator: rate.fx.rate_denominator, as_of: rate.event_date });
|
|
174
|
+
}
|
|
175
|
+
return { report_currency: reportCurrency, converted_net_cost: missing_rates.length ? null : Number(sum), rates_used, missing_rates, rounding: "half_up_per_currency" };
|
|
176
|
+
}
|