@atcn/subledger 1.4.0 → 1.5.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/README.md +11 -6
- package/dist/documents.d.ts +1067 -26
- package/dist/documents.js +121 -7
- package/dist/exceptions.d.ts +47 -2
- package/dist/exceptions.js +173 -3
- package/dist/expectations.d.ts +96 -0
- package/dist/expectations.js +236 -0
- package/dist/importing.d.ts +53 -0
- package/dist/importing.js +192 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/outcome.d.ts +36 -0
- package/dist/outcome.js +64 -0
- package/dist/projection.d.ts +28 -3
- package/dist/projection.js +69 -4
- package/dist/rails.d.ts +108 -0
- package/dist/rails.js +242 -0
- package/dist/response.d.ts +2 -0
- package/dist/response.js +1 -0
- package/dist/rollup.d.ts +1 -1
- package/dist/rollup.js +10 -4
- package/dist/types.d.ts +279 -7
- package/dist/types.js +179 -6
- package/dist/usage.d.ts +41 -0
- package/dist/usage.js +86 -0
- package/dist/verify.d.ts +8 -1
- package/dist/verify.js +385 -7
- package/package.json +6 -4
- package/test-vectors/reconciliation-results.json +151 -0
- package/test-vectors/reconciliation.json +107 -0
- package/test-vectors/vectors.json +25666 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { canonicalize, signBytes, utf8Encode, verifyBytes } from "@atcn/schema";
|
|
2
|
+
import { deliveryStatus } from "./projection.js";
|
|
3
|
+
import { costSign } from "./rollup.js";
|
|
4
|
+
/**
|
|
5
|
+
* Estimates and holds (schema 1.5): what the agent, a budget gateway or the operator expected work to cost before it
|
|
6
|
+
* ran, compared with what it actually cost. Record only: ATCN never enforces, blocks or reserves anything.
|
|
7
|
+
*/
|
|
8
|
+
export const EXPECTATION_STATEMENT_TYPE = "atcn.subledger.expectation";
|
|
9
|
+
export function buildExpectationStatement(input) {
|
|
10
|
+
return {
|
|
11
|
+
document_type: EXPECTATION_STATEMENT_TYPE,
|
|
12
|
+
type: input.type,
|
|
13
|
+
source: input.source,
|
|
14
|
+
source_event_id: input.source_event_id,
|
|
15
|
+
provider_reference: input.provider_reference ?? null,
|
|
16
|
+
amount_minor: input.amount_minor,
|
|
17
|
+
currency: input.currency,
|
|
18
|
+
issued_at: new Date(input.issued_at).toISOString(),
|
|
19
|
+
issued_by: input.expectation.issued_by,
|
|
20
|
+
source_ref: input.expectation.source_ref,
|
|
21
|
+
basis: input.expectation.basis,
|
|
22
|
+
expires_at: input.expectation.expires_at === null ? null : new Date(input.expectation.expires_at).toISOString(),
|
|
23
|
+
supersedes: input.expectation.supersedes,
|
|
24
|
+
hold_status: input.expectation.hold_status ?? null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The statement a stored estimate or hold record was signed over. */
|
|
28
|
+
export function expectationStatementOf(record) {
|
|
29
|
+
return buildExpectationStatement({
|
|
30
|
+
type: record.type,
|
|
31
|
+
source: record.source,
|
|
32
|
+
source_event_id: record.source_event_id,
|
|
33
|
+
provider_reference: record.provider_reference,
|
|
34
|
+
amount_minor: record.amount_minor,
|
|
35
|
+
currency: record.currency,
|
|
36
|
+
issued_at: record.event_date,
|
|
37
|
+
expectation: record.expectation,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Agent- or gateway-side signing. The operator never holds the signer's private key. */
|
|
41
|
+
export function signExpectation(statement, privateKey) {
|
|
42
|
+
return signBytes(utf8Encode(canonicalize(statement)), privateKey);
|
|
43
|
+
}
|
|
44
|
+
export function verifyExpectationSignature(statement, signature, publicKey) {
|
|
45
|
+
return verifyBytes(utf8Encode(canonicalize(statement)), signature, publicKey);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Why a signed estimate or hold does not verify, or null when it does (or carries no signature). The key must be bound
|
|
49
|
+
* to the named provider and not revoked when the record was issued; an agent's estimate must be signed by the
|
|
50
|
+
* provider of the delegation it is attributed to.
|
|
51
|
+
*/
|
|
52
|
+
export function expectationSignatureProblem(record, delegationProviderId, keyBindings) {
|
|
53
|
+
const signer = record.expectation?.signer;
|
|
54
|
+
if (!signer)
|
|
55
|
+
return null;
|
|
56
|
+
const label = `${record.type} ${record.financial_event_id}`;
|
|
57
|
+
const binding = keyBindings.find((b) => b.binding_id === signer.binding_id && b.key_id === signer.key_id);
|
|
58
|
+
if (!binding)
|
|
59
|
+
return `${label} is signed with a key binding that is not listed`;
|
|
60
|
+
if (binding.provider_id !== signer.provider_id)
|
|
61
|
+
return `${label} key binding belongs to another provider`;
|
|
62
|
+
if (binding.revoked_at !== null && binding.revoked_at <= record.event_date)
|
|
63
|
+
return `${label} was signed after its key binding was revoked`;
|
|
64
|
+
if (record.expectation.issued_by === "agent" && signer.provider_id !== delegationProviderId)
|
|
65
|
+
return `${label} is an agent estimate signed by a provider other than the delegation's`;
|
|
66
|
+
if (!signedStatementVerifies(() => verifyExpectationSignature(expectationStatementOf(record), signer.value, binding.public_key)))
|
|
67
|
+
return `${label} signature does not verify`;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/** Labels are never collapsed: a verified agent signature is provider_key_signed, a gateway's is gateway_signed, anything else is buyer_recorded. */
|
|
71
|
+
export function expectationAssurance(record, delegationProviderId, keyBindings) {
|
|
72
|
+
const expectation = record.expectation;
|
|
73
|
+
if (!expectation.signer || expectationSignatureProblem(record, delegationProviderId, keyBindings) !== null)
|
|
74
|
+
return ["buyer_recorded"];
|
|
75
|
+
return [expectation.issued_by === "gateway" ? "gateway_signed" : "provider_key_signed"];
|
|
76
|
+
}
|
|
77
|
+
/** Delivery statuses after which an open hold is treated as released: the work did not go ahead. */
|
|
78
|
+
const RELEASING_STATUSES = ["cancelled", "provider_failed"];
|
|
79
|
+
/** Variance in basis points of `base`, rounded half away from zero, using integers only. Null when there is no base. */
|
|
80
|
+
export function varianceBps(difference, base) {
|
|
81
|
+
if (base === null || base === 0)
|
|
82
|
+
return null;
|
|
83
|
+
const sign = difference < 0 ? -1 : 1;
|
|
84
|
+
return sign * Math.floor((Math.abs(difference) * 10_000 * 2 + base) / (2 * base));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Estimates and holds compared with actual cost, per node and for the task, in the task's currency. Null when the task
|
|
88
|
+
* has no estimates or holds. A node's actual cost is its own net cost (what was billed against it directly).
|
|
89
|
+
*/
|
|
90
|
+
export function buildExpectationReport(input) {
|
|
91
|
+
const currency = input.task.currency;
|
|
92
|
+
const expectations = input.events.filter((e) => e.record.expectation !== undefined);
|
|
93
|
+
if (expectations.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
const providerOf = new Map(input.delegations.map((d) => [d.delegation_id, d.provider_id]));
|
|
96
|
+
const statusOfDelegation = (nodeId) => deliveryStatus(input.claims.filter((c) => c.delegation_id === nodeId));
|
|
97
|
+
const supersededIds = new Set(expectations.filter((e) => e.record.expectation.supersedes !== null).map((e) => `${e.record.source}\u0000${e.record.type}\u0000${e.record.expectation.supersedes}`));
|
|
98
|
+
const isSuperseded = (record) => supersededIds.has(`${record.source}\u0000${record.type}\u0000${record.source_event_id}`);
|
|
99
|
+
const directNetCost = (nodeId) => input.rollup.nodes.find((n) => n.node_id === nodeId)?.direct[currency]?.net_cost ?? 0;
|
|
100
|
+
const chargesOn = (nodeId) => input.events
|
|
101
|
+
.filter((e) => e.attributed_to === nodeId && e.record.currency === currency && costSign(e.record.type) === 1 && !input.rollup.excluded_event_ids.reversed.includes(e.record.financial_event_id))
|
|
102
|
+
.map((e) => e.record);
|
|
103
|
+
const firstChargeAt = (nodeId) => chargesOn(nodeId).map((r) => r.event_date).sort()[0] ?? null;
|
|
104
|
+
const firstChargeRecordedAt = (nodeId) => chargesOn(nodeId).map((r) => r.imported_at).sort()[0] ?? null;
|
|
105
|
+
// An estimate dated before the first charge but recorded after it was back-dated, unless the import says it is retrospective.
|
|
106
|
+
const isAfterCharge = (record, nodeId) => {
|
|
107
|
+
const firstCharge = firstChargeAt(nodeId);
|
|
108
|
+
if (firstCharge === null)
|
|
109
|
+
return false;
|
|
110
|
+
return record.event_date > firstCharge || (!record.retrospective && record.imported_at > firstChargeRecordedAt(nodeId));
|
|
111
|
+
};
|
|
112
|
+
const records = expectations.map(({ record, attributed_to: nodeId }) => {
|
|
113
|
+
let status = "current";
|
|
114
|
+
if (record.currency !== currency)
|
|
115
|
+
status = "other_currency";
|
|
116
|
+
else if (isSuperseded(record))
|
|
117
|
+
status = "superseded";
|
|
118
|
+
else if (record.type === "estimate" && isAfterCharge(record, nodeId))
|
|
119
|
+
status = "after_charge";
|
|
120
|
+
let holdStatus = record.expectation.hold_status ?? null;
|
|
121
|
+
if (holdStatus === "open" && RELEASING_STATUSES.includes(statusOfDelegation(nodeId)))
|
|
122
|
+
holdStatus = "released";
|
|
123
|
+
const assurance = expectationAssurance(record, providerOf.get(nodeId) ?? null, input.key_bindings);
|
|
124
|
+
return {
|
|
125
|
+
financial_event_id: record.financial_event_id,
|
|
126
|
+
node_id: nodeId,
|
|
127
|
+
type: record.type,
|
|
128
|
+
issued_by: record.expectation.issued_by,
|
|
129
|
+
status,
|
|
130
|
+
hold_status: holdStatus,
|
|
131
|
+
assurance: status === "superseded" ? [...assurance, "superseded"] : assurance,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
// Of several current estimates on a node, the latest issued is used; the others stay listed as not_latest.
|
|
135
|
+
const recordById = new Map(expectations.map((e) => [e.record.financial_event_id, e.record]));
|
|
136
|
+
const nodeIds = [...new Set(records.map((r) => r.node_id))];
|
|
137
|
+
const usedEstimate = new Map();
|
|
138
|
+
for (const nodeId of nodeIds) {
|
|
139
|
+
const current = records.filter((r) => r.node_id === nodeId && r.type === "estimate" && r.status === "current").map((r) => recordById.get(r.financial_event_id));
|
|
140
|
+
const latest = current.reduce((best, e) => (best === null || e.event_date >= best.event_date ? e : best), null);
|
|
141
|
+
if (latest)
|
|
142
|
+
usedEstimate.set(nodeId, latest);
|
|
143
|
+
}
|
|
144
|
+
for (const r of records) {
|
|
145
|
+
if (r.type === "estimate" && r.status === "current" && usedEstimate.get(r.node_id)?.financial_event_id !== r.financial_event_id)
|
|
146
|
+
r.status = "not_latest";
|
|
147
|
+
}
|
|
148
|
+
const nodes = nodeIds.map((nodeId) => {
|
|
149
|
+
const estimate = usedEstimate.get(nodeId) ?? null;
|
|
150
|
+
const held = records
|
|
151
|
+
.filter((r) => r.node_id === nodeId && r.type === "hold" && r.status === "current" && (r.hold_status === "open" || r.hold_status === "captured"))
|
|
152
|
+
.reduce((total, r) => total + recordById.get(r.financial_event_id).amount_minor, 0);
|
|
153
|
+
const hasHold = records.some((r) => r.node_id === nodeId && r.type === "hold" && r.status === "current");
|
|
154
|
+
return { node_id: nodeId, estimate_event_id: estimate?.financial_event_id ?? null, ...variance(estimate?.amount_minor ?? null, hasHold ? held : null, directNetCost(nodeId)) };
|
|
155
|
+
});
|
|
156
|
+
const estimated = nodes.some((n) => n.estimated_minor !== null) ? nodes.reduce((total, n) => total + (n.estimated_minor ?? 0), 0) : null;
|
|
157
|
+
const anyHold = records.some((r) => r.type === "hold" && r.status === "current");
|
|
158
|
+
const held = nodes.reduce((total, n) => total + n.held_minor, 0);
|
|
159
|
+
const actual = input.rollup.root_total[currency]?.net_cost ?? 0;
|
|
160
|
+
const estimatedNodes = new Set(nodes.filter((n) => n.estimated_minor !== null).map((n) => n.node_id));
|
|
161
|
+
const unestimated = input.rollup.nodes.filter((n) => !estimatedNodes.has(n.node_id)).reduce((total, n) => total + (n.direct[currency]?.net_cost ?? 0), 0);
|
|
162
|
+
return { currency, task: { ...variance(estimated, anyHold ? held : null, actual), unestimated_minor: unestimated }, nodes, records };
|
|
163
|
+
}
|
|
164
|
+
function variance(estimated, held, actual) {
|
|
165
|
+
return {
|
|
166
|
+
estimated_minor: estimated,
|
|
167
|
+
held_minor: held ?? 0,
|
|
168
|
+
actual_minor: actual,
|
|
169
|
+
variance_vs_estimate_minor: estimated === null ? null : actual - estimated,
|
|
170
|
+
variance_vs_estimate_bps: estimated === null ? null : varianceBps(actual - estimated, estimated),
|
|
171
|
+
variance_vs_hold_minor: held === null ? null : actual - held,
|
|
172
|
+
variance_vs_hold_bps: held === null ? null : varianceBps(actual - held, held),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Signals from the expectation report. Recorded, not prevented: none of them blocks work or changes a clearing outcome.
|
|
177
|
+
* `closing` marks the derivation done when the task is closed, where an open hold with nothing charged is flagged.
|
|
178
|
+
*/
|
|
179
|
+
export function expectationExceptions(report, input) {
|
|
180
|
+
const result = [];
|
|
181
|
+
const delegationOf = (nodeId) => (nodeId === input.task.task_id ? null : nodeId);
|
|
182
|
+
const tolerance = input.task.estimate_tolerance_bps ?? 0;
|
|
183
|
+
const recordById = new Map(input.events.map((e) => [e.record.financial_event_id, e.record]));
|
|
184
|
+
for (const node of report.nodes) {
|
|
185
|
+
if (node.estimated_minor !== null && node.actual_minor * 10_000 > node.estimated_minor * (10_000 + tolerance)) {
|
|
186
|
+
result.push({
|
|
187
|
+
kind: "actual_exceeds_estimate",
|
|
188
|
+
dedupe_key: `actual_exceeds_estimate:${node.node_id}`,
|
|
189
|
+
delegation_id: delegationOf(node.node_id),
|
|
190
|
+
detail: `actual ${node.actual_minor} ${report.currency} exceeds estimate ${node.estimated_minor} by ${node.variance_vs_estimate_minor} (${node.variance_vs_estimate_bps} bps, tolerance ${tolerance} bps); recorded, not prevented`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (node.variance_vs_hold_minor !== null && node.variance_vs_hold_minor > 0) {
|
|
194
|
+
result.push({
|
|
195
|
+
kind: "actual_exceeds_hold",
|
|
196
|
+
dedupe_key: `actual_exceeds_hold:${node.node_id}`,
|
|
197
|
+
delegation_id: delegationOf(node.node_id),
|
|
198
|
+
detail: `actual ${node.actual_minor} ${report.currency} exceeds held ${node.held_minor} by ${node.variance_vs_hold_minor}; recorded, not prevented`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const r of report.records) {
|
|
203
|
+
const record = recordById.get(r.financial_event_id);
|
|
204
|
+
if (r.status === "after_charge") {
|
|
205
|
+
result.push({
|
|
206
|
+
kind: "estimate_after_charge",
|
|
207
|
+
dedupe_key: `estimate_after_charge:${r.financial_event_id}`,
|
|
208
|
+
delegation_id: delegationOf(r.node_id),
|
|
209
|
+
detail: `estimate ${r.financial_event_id} issued ${record.event_date} and recorded ${record.imported_at}, after the first charge on its node; kept, not used as the estimate`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (r.type !== "hold" || r.status !== "current" || r.hold_status !== "open")
|
|
213
|
+
continue;
|
|
214
|
+
const expiresAt = record.expectation.expires_at;
|
|
215
|
+
const expired = expiresAt !== null && expiresAt < input.now;
|
|
216
|
+
const nothingCharged = report.nodes.find((n) => n.node_id === r.node_id).actual_minor === 0;
|
|
217
|
+
if (expired || (input.closing === true && nothingCharged)) {
|
|
218
|
+
result.push({
|
|
219
|
+
kind: "hold_not_released",
|
|
220
|
+
dedupe_key: `hold_not_released:${r.financial_event_id}`,
|
|
221
|
+
delegation_id: delegationOf(r.node_id),
|
|
222
|
+
detail: expired ? `hold ${r.financial_event_id} of ${record.amount_minor} ${record.currency} is still open past its expiry ${expiresAt}` : `hold ${r.financial_event_id} of ${record.amount_minor} ${record.currency} is still open at close with nothing charged`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
/** A statement whose dates cannot be read cannot have been signed as given, so it does not verify. */
|
|
229
|
+
function signedStatementVerifies(verify) {
|
|
230
|
+
try {
|
|
231
|
+
return verify();
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type ExpectationIssuer } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turns rows of an external export (a provider bill, a gateway's estimate/hold log) into financial event bodies.
|
|
4
|
+
* Rows come from CSV or JSONL; a column map names which column feeds which field. Unsigned: imported rows are
|
|
5
|
+
* buyer_recorded, whoever produced the export.
|
|
6
|
+
*/
|
|
7
|
+
export type ImportRow = Record<string, unknown>;
|
|
8
|
+
export type ImportKind = "charge" | "invoice" | "estimate" | "hold";
|
|
9
|
+
export declare const IMPORT_KINDS: readonly ImportKind[];
|
|
10
|
+
/** Fields a column can feed. amount_major is a decimal amount ("1.25") converted exactly to minor units. */
|
|
11
|
+
export declare const IMPORT_FIELDS: readonly ["source_event_id", "amount_minor", "amount_major", "currency", "event_date", "provider_reference", "provider_status", "provider_job_ref", "task_external_ref", "delegation_external_ref", "issued_by", "source_ref", "basis", "expires_at", "supersedes", "hold_status"];
|
|
12
|
+
export type ImportField = (typeof IMPORT_FIELDS)[number];
|
|
13
|
+
export interface ImportOptions {
|
|
14
|
+
kind: ImportKind;
|
|
15
|
+
/** The financial event source, for example "gamma-billing" or "cost-gateway". */
|
|
16
|
+
source: string;
|
|
17
|
+
/** Field -> column. A field without an entry reads the column of the same name. */
|
|
18
|
+
map?: Partial<Record<ImportField, string>>;
|
|
19
|
+
/** Used when no currency column is mapped or present. */
|
|
20
|
+
currency?: string;
|
|
21
|
+
/** Who issued imported estimates and holds when no issued_by column is given (default "gateway"). */
|
|
22
|
+
issuedBy?: ExpectationIssuer;
|
|
23
|
+
/**
|
|
24
|
+
* Columns that identify a row when the export has no stable event id. The key is a hash of their canonical JSON,
|
|
25
|
+
* so an exact replay deduplicates and a replay that changes any other field is refused as duplicate_event.
|
|
26
|
+
*/
|
|
27
|
+
keyColumns?: string[];
|
|
28
|
+
/** Digits after the decimal point for amount_major (default 2). */
|
|
29
|
+
minorDigits?: number;
|
|
30
|
+
}
|
|
31
|
+
/** RFC 4180 CSV with a header row: comma separated, double-quoted fields, doubled quotes inside quotes. */
|
|
32
|
+
export declare function parseCsvRows(text: string): ImportRow[];
|
|
33
|
+
/** One JSON object per line; blank lines are skipped. */
|
|
34
|
+
export declare function parseJsonlRows(text: string): ImportRow[];
|
|
35
|
+
/** A source_event_id derived from the canonical JSON of a row's identifying columns. */
|
|
36
|
+
export declare function derivedSourceEventId(identity: Record<string, unknown>): string;
|
|
37
|
+
/** Converts "12.5" to 1250 with 2 minor digits, using string arithmetic so no floating point rounding occurs. */
|
|
38
|
+
export declare function majorToMinor(value: string, minorDigits?: number): number;
|
|
39
|
+
/** A financial event body for one row. Throws with a readable message when a required value is missing. */
|
|
40
|
+
export declare function financialEventFromRow(row: ImportRow, options: ImportOptions): Record<string, unknown>;
|
|
41
|
+
export interface ImportResult {
|
|
42
|
+
/** Each event with the 1-based number of the row it came from. */
|
|
43
|
+
events: {
|
|
44
|
+
row: number;
|
|
45
|
+
event: Record<string, unknown>;
|
|
46
|
+
}[];
|
|
47
|
+
errors: {
|
|
48
|
+
row: number;
|
|
49
|
+
error: string;
|
|
50
|
+
}[];
|
|
51
|
+
}
|
|
52
|
+
/** Converts every row; a bad row is reported by its 1-based number without stopping the others. */
|
|
53
|
+
export declare function importRows(rows: ImportRow[], options: ImportOptions): ImportResult;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { digestOf } from "@atcn/schema";
|
|
2
|
+
import { EXPECTATION_ISSUERS, HOLD_STATUSES } from "./types.js";
|
|
3
|
+
export const IMPORT_KINDS = ["charge", "invoice", "estimate", "hold"];
|
|
4
|
+
/** Fields a column can feed. amount_major is a decimal amount ("1.25") converted exactly to minor units. */
|
|
5
|
+
export const IMPORT_FIELDS = [
|
|
6
|
+
"source_event_id",
|
|
7
|
+
"amount_minor",
|
|
8
|
+
"amount_major",
|
|
9
|
+
"currency",
|
|
10
|
+
"event_date",
|
|
11
|
+
"provider_reference",
|
|
12
|
+
"provider_status",
|
|
13
|
+
"provider_job_ref",
|
|
14
|
+
"task_external_ref",
|
|
15
|
+
"delegation_external_ref",
|
|
16
|
+
"issued_by",
|
|
17
|
+
"source_ref",
|
|
18
|
+
"basis",
|
|
19
|
+
"expires_at",
|
|
20
|
+
"supersedes",
|
|
21
|
+
"hold_status",
|
|
22
|
+
];
|
|
23
|
+
/** RFC 4180 CSV with a header row: comma separated, double-quoted fields, doubled quotes inside quotes. */
|
|
24
|
+
export function parseCsvRows(text) {
|
|
25
|
+
const rows = [];
|
|
26
|
+
let row = [];
|
|
27
|
+
let field = "";
|
|
28
|
+
let inQuotes = false;
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
const ch = text[i];
|
|
31
|
+
if (inQuotes) {
|
|
32
|
+
if (ch === '"' && text[i + 1] === '"') {
|
|
33
|
+
field += '"';
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
else if (ch === '"')
|
|
37
|
+
inQuotes = false;
|
|
38
|
+
else
|
|
39
|
+
field += ch;
|
|
40
|
+
}
|
|
41
|
+
else if (ch === '"')
|
|
42
|
+
inQuotes = true;
|
|
43
|
+
else if (ch === ",") {
|
|
44
|
+
row.push(field);
|
|
45
|
+
field = "";
|
|
46
|
+
}
|
|
47
|
+
else if (ch === "\n" || ch === "\r") {
|
|
48
|
+
if (ch === "\r" && text[i + 1] === "\n")
|
|
49
|
+
i++;
|
|
50
|
+
row.push(field);
|
|
51
|
+
rows.push(row);
|
|
52
|
+
row = [];
|
|
53
|
+
field = "";
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
field += ch;
|
|
57
|
+
}
|
|
58
|
+
if (field !== "" || row.length > 0) {
|
|
59
|
+
row.push(field);
|
|
60
|
+
rows.push(row);
|
|
61
|
+
}
|
|
62
|
+
const [header, ...data] = rows.filter((r) => r.some((cell) => cell.trim() !== ""));
|
|
63
|
+
if (!header)
|
|
64
|
+
return [];
|
|
65
|
+
const names = header.map((h) => h.trim());
|
|
66
|
+
return data.map((cells) => Object.fromEntries(names.map((name, i) => [name, (cells[i] ?? "").trim()])));
|
|
67
|
+
}
|
|
68
|
+
/** One JSON object per line; blank lines are skipped. */
|
|
69
|
+
export function parseJsonlRows(text) {
|
|
70
|
+
return text
|
|
71
|
+
.split(/\r?\n/)
|
|
72
|
+
.map((line, index) => ({ line: line.trim(), number: index + 1 }))
|
|
73
|
+
.filter(({ line }) => line !== "")
|
|
74
|
+
.map(({ line, number }) => {
|
|
75
|
+
let value;
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(line);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new Error(`line ${number} is not JSON: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
83
|
+
throw new Error(`line ${number} is not a JSON object`);
|
|
84
|
+
return value;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** A source_event_id derived from the canonical JSON of a row's identifying columns. */
|
|
88
|
+
export function derivedSourceEventId(identity) {
|
|
89
|
+
return `jcs-${digestOf(identity)}`;
|
|
90
|
+
}
|
|
91
|
+
/** Converts "12.5" to 1250 with 2 minor digits, using string arithmetic so no floating point rounding occurs. */
|
|
92
|
+
export function majorToMinor(value, minorDigits = 2) {
|
|
93
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value.trim());
|
|
94
|
+
if (!match)
|
|
95
|
+
throw new Error(`amount ${JSON.stringify(value)} is not a decimal number`);
|
|
96
|
+
const [, sign, whole, fraction = ""] = match;
|
|
97
|
+
if (fraction.length > minorDigits)
|
|
98
|
+
throw new Error(`amount ${value} has more than ${minorDigits} decimal places`);
|
|
99
|
+
const minor = Number(whole + fraction.padEnd(minorDigits, "0"));
|
|
100
|
+
if (!Number.isSafeInteger(minor))
|
|
101
|
+
throw new Error(`amount ${value} is too large`);
|
|
102
|
+
return sign === "-" ? -minor : minor;
|
|
103
|
+
}
|
|
104
|
+
/** A financial event body for one row. Throws with a readable message when a required value is missing. */
|
|
105
|
+
export function financialEventFromRow(row, options) {
|
|
106
|
+
const columnOf = (field) => options.map?.[field] ?? field;
|
|
107
|
+
const text = (field) => {
|
|
108
|
+
const value = row[columnOf(field)];
|
|
109
|
+
if (value === undefined || value === null || value === "")
|
|
110
|
+
return null;
|
|
111
|
+
return typeof value === "string" ? value : String(value);
|
|
112
|
+
};
|
|
113
|
+
const required = (field) => {
|
|
114
|
+
const value = text(field);
|
|
115
|
+
if (value === null)
|
|
116
|
+
throw new Error(`missing ${field} (column ${columnOf(field)})`);
|
|
117
|
+
return value;
|
|
118
|
+
};
|
|
119
|
+
let amount;
|
|
120
|
+
if (text("amount_minor") !== null) {
|
|
121
|
+
amount = Number(text("amount_minor"));
|
|
122
|
+
if (!Number.isSafeInteger(amount))
|
|
123
|
+
throw new Error(`amount_minor ${text("amount_minor")} is not a whole number`);
|
|
124
|
+
}
|
|
125
|
+
else if (text("amount_major") !== null) {
|
|
126
|
+
amount = majorToMinor(text("amount_major"), options.minorDigits ?? 2);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
throw new Error(`missing amount (column ${columnOf("amount_minor")} or ${columnOf("amount_major")})`);
|
|
130
|
+
}
|
|
131
|
+
let sourceEventId = text("source_event_id");
|
|
132
|
+
if (sourceEventId === null) {
|
|
133
|
+
if (!options.keyColumns || options.keyColumns.length === 0)
|
|
134
|
+
throw new Error(`missing source_event_id (column ${columnOf("source_event_id")}); name the identifying columns with keyColumns`);
|
|
135
|
+
const identity = Object.fromEntries(options.keyColumns.map((column) => [column, row[column] ?? null]));
|
|
136
|
+
if (Object.values(identity).every((value) => value === null || value === ""))
|
|
137
|
+
throw new Error(`key columns ${options.keyColumns.join(", ")} are all empty`);
|
|
138
|
+
sourceEventId = derivedSourceEventId(identity);
|
|
139
|
+
}
|
|
140
|
+
const body = {
|
|
141
|
+
type: options.kind,
|
|
142
|
+
source: options.source,
|
|
143
|
+
source_event_id: sourceEventId,
|
|
144
|
+
provider_reference: text("provider_reference"),
|
|
145
|
+
provider_status: text("provider_status"),
|
|
146
|
+
amount_minor: amount,
|
|
147
|
+
currency: text("currency") ?? options.currency ?? required("currency"),
|
|
148
|
+
event_date: isoDate(required("event_date"), "event_date"),
|
|
149
|
+
match: {
|
|
150
|
+
provider_job_ref: text("provider_job_ref"),
|
|
151
|
+
task_external_ref: text("task_external_ref"),
|
|
152
|
+
delegation_external_ref: text("delegation_external_ref"),
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
if (options.kind === "estimate" || options.kind === "hold") {
|
|
156
|
+
const issuedBy = text("issued_by") ?? options.issuedBy ?? "gateway";
|
|
157
|
+
if (!EXPECTATION_ISSUERS.includes(issuedBy))
|
|
158
|
+
throw new Error(`issued_by ${issuedBy} is not one of ${EXPECTATION_ISSUERS.join(", ")}`);
|
|
159
|
+
const holdStatus = text("hold_status") ?? "open";
|
|
160
|
+
if (options.kind === "hold" && !HOLD_STATUSES.includes(holdStatus))
|
|
161
|
+
throw new Error(`hold_status ${holdStatus} is not one of ${HOLD_STATUSES.join(", ")}`);
|
|
162
|
+
const expiresAt = text("expires_at");
|
|
163
|
+
body.expectation = {
|
|
164
|
+
issued_by: issuedBy,
|
|
165
|
+
source_ref: text("source_ref"),
|
|
166
|
+
basis: text("basis"),
|
|
167
|
+
expires_at: expiresAt === null ? null : isoDate(expiresAt, "expires_at"),
|
|
168
|
+
supersedes: text("supersedes"),
|
|
169
|
+
...(options.kind === "hold" ? { hold_status: holdStatus } : {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
function isoDate(value, field) {
|
|
175
|
+
const time = Date.parse(value);
|
|
176
|
+
if (Number.isNaN(time))
|
|
177
|
+
throw new Error(`${field} ${JSON.stringify(value)} is not a date`);
|
|
178
|
+
return new Date(time).toISOString();
|
|
179
|
+
}
|
|
180
|
+
/** Converts every row; a bad row is reported by its 1-based number without stopping the others. */
|
|
181
|
+
export function importRows(rows, options) {
|
|
182
|
+
const result = { events: [], errors: [] };
|
|
183
|
+
rows.forEach((row, index) => {
|
|
184
|
+
try {
|
|
185
|
+
result.events.push({ row: index + 1, event: financialEventFromRow(row, options) });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
result.errors.push({ row: index + 1, error: error.message });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return result;
|
|
192
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,3 +8,8 @@ export * from "./response.js";
|
|
|
8
8
|
export * from "./verify.js";
|
|
9
9
|
export * from "./bridge.js";
|
|
10
10
|
export * from "./matching.js";
|
|
11
|
+
export * from "./usage.js";
|
|
12
|
+
export * from "./expectations.js";
|
|
13
|
+
export * from "./importing.js";
|
|
14
|
+
export * from "./outcome.js";
|
|
15
|
+
export * from "./rails.js";
|
package/dist/index.js
CHANGED
|
@@ -8,3 +8,8 @@ export * from "./response.js";
|
|
|
8
8
|
export * from "./verify.js";
|
|
9
9
|
export * from "./bridge.js";
|
|
10
10
|
export * from "./matching.js";
|
|
11
|
+
export * from "./usage.js";
|
|
12
|
+
export * from "./expectations.js";
|
|
13
|
+
export * from "./importing.js";
|
|
14
|
+
export * from "./outcome.js";
|
|
15
|
+
export * from "./rails.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DeliveryClaim, KeyBindingRecord } from "./documents.js";
|
|
2
|
+
import { type EvidenceRef, type SignedClaimType } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Signed outcome claims (schema 1.5): the provider signs how its work ended (completed, partly completed, cancelled or
|
|
5
|
+
* failed), naming its own job reference (for A2A, the task id) and pointing at its evidence. The buyer records the
|
|
6
|
+
* claim; a matching key binding makes it provider_key_signed. A downstream agent the buyer never dealt with can sign
|
|
7
|
+
* one too, so a delegation chain with a broken edge still carries a verifiable outcome for that edge.
|
|
8
|
+
*/
|
|
9
|
+
export declare const OUTCOME_STATEMENT_TYPE = "atcn.subledger.outcome";
|
|
10
|
+
/** What the provider signs. */
|
|
11
|
+
export interface OutcomeStatement {
|
|
12
|
+
document_type: typeof OUTCOME_STATEMENT_TYPE;
|
|
13
|
+
type: SignedClaimType;
|
|
14
|
+
provider_job_ref: string;
|
|
15
|
+
occurred_at: string;
|
|
16
|
+
note: string | null;
|
|
17
|
+
evidence: EvidenceRef[];
|
|
18
|
+
}
|
|
19
|
+
export declare function buildOutcomeStatement(input: {
|
|
20
|
+
type: SignedClaimType;
|
|
21
|
+
provider_job_ref: string;
|
|
22
|
+
occurred_at: string;
|
|
23
|
+
note?: string | null;
|
|
24
|
+
evidence?: EvidenceRef[];
|
|
25
|
+
}): OutcomeStatement;
|
|
26
|
+
/** Provider-side signing. The operator never holds the provider's private key. */
|
|
27
|
+
export declare function signOutcomeStatement(statement: OutcomeStatement, privateKey: string): string;
|
|
28
|
+
export declare function verifyOutcomeSignature(statement: OutcomeStatement, signature: string, publicKey: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Why a signed outcome claim does not verify, or null when it does (or carries no signature). The key must be bound to
|
|
31
|
+
* the delegation's provider and not revoked when the claim occurred, and the delegation must have a provider_job_ref.
|
|
32
|
+
*/
|
|
33
|
+
export declare function outcomeSignatureProblem(claim: DeliveryClaim, delegation: {
|
|
34
|
+
provider_id: string | null;
|
|
35
|
+
provider_job_ref: string | null;
|
|
36
|
+
}, keyBindings: KeyBindingRecord[]): string | null;
|
package/dist/outcome.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { canonicalize, signBytes, utf8Encode, verifyBytes } from "@atcn/schema";
|
|
2
|
+
import { SIGNED_CLAIM_TYPES } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Signed outcome claims (schema 1.5): the provider signs how its work ended (completed, partly completed, cancelled or
|
|
5
|
+
* failed), naming its own job reference (for A2A, the task id) and pointing at its evidence. The buyer records the
|
|
6
|
+
* claim; a matching key binding makes it provider_key_signed. A downstream agent the buyer never dealt with can sign
|
|
7
|
+
* one too, so a delegation chain with a broken edge still carries a verifiable outcome for that edge.
|
|
8
|
+
*/
|
|
9
|
+
export const OUTCOME_STATEMENT_TYPE = "atcn.subledger.outcome";
|
|
10
|
+
export function buildOutcomeStatement(input) {
|
|
11
|
+
return {
|
|
12
|
+
document_type: OUTCOME_STATEMENT_TYPE,
|
|
13
|
+
type: input.type,
|
|
14
|
+
provider_job_ref: input.provider_job_ref,
|
|
15
|
+
occurred_at: new Date(input.occurred_at).toISOString(),
|
|
16
|
+
note: input.note ?? null,
|
|
17
|
+
evidence: input.evidence ?? [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Provider-side signing. The operator never holds the provider's private key. */
|
|
21
|
+
export function signOutcomeStatement(statement, privateKey) {
|
|
22
|
+
return signBytes(utf8Encode(canonicalize(statement)), privateKey);
|
|
23
|
+
}
|
|
24
|
+
export function verifyOutcomeSignature(statement, signature, publicKey) {
|
|
25
|
+
return verifyBytes(utf8Encode(canonicalize(statement)), signature, publicKey);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Why a signed outcome claim does not verify, or null when it does (or carries no signature). The key must be bound to
|
|
29
|
+
* the delegation's provider and not revoked when the claim occurred, and the delegation must have a provider_job_ref.
|
|
30
|
+
*/
|
|
31
|
+
export function outcomeSignatureProblem(claim, delegation, keyBindings) {
|
|
32
|
+
const signer = claim.signer;
|
|
33
|
+
if (!signer)
|
|
34
|
+
return null;
|
|
35
|
+
const label = `${claim.type} claim ${claim.event_id}`;
|
|
36
|
+
const type = SIGNED_CLAIM_TYPES.find((t) => t === claim.type);
|
|
37
|
+
if (!type)
|
|
38
|
+
return `${label} is signed, but only ${SIGNED_CLAIM_TYPES.join(", ")} claims may be`;
|
|
39
|
+
if (delegation.provider_job_ref === null)
|
|
40
|
+
return `${label} is signed, but its delegation has no provider_job_ref to sign over`;
|
|
41
|
+
if (signer.provider_id !== delegation.provider_id)
|
|
42
|
+
return `${label} is signed by a provider other than the delegation's`;
|
|
43
|
+
const binding = keyBindings.find((b) => b.binding_id === signer.binding_id && b.key_id === signer.key_id);
|
|
44
|
+
if (!binding)
|
|
45
|
+
return `${label} is signed with a key binding that is not listed`;
|
|
46
|
+
if (binding.provider_id !== signer.provider_id)
|
|
47
|
+
return `${label} key binding belongs to another provider`;
|
|
48
|
+
if (binding.revoked_at !== null && binding.revoked_at <= claim.occurred_at)
|
|
49
|
+
return `${label} was signed after its key binding was revoked`;
|
|
50
|
+
const jobRef = delegation.provider_job_ref;
|
|
51
|
+
const verifies = signedStatementVerifies(() => verifyOutcomeSignature(buildOutcomeStatement({ type, provider_job_ref: jobRef, occurred_at: claim.occurred_at, note: claim.note, evidence: claim.evidence }), signer.value, binding.public_key));
|
|
52
|
+
if (!verifies)
|
|
53
|
+
return `${label} signature does not verify`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** A statement whose dates cannot be read cannot have been signed as given, so it does not verify. */
|
|
57
|
+
function signedStatementVerifies(verify) {
|
|
58
|
+
try {
|
|
59
|
+
return verify();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|