For monthly logistics statements, make the signed statement input the accounting boundary and compare both the dashboard snapshot and the live query against that same immutable input. If either view silently uses a different cutoff, filter, timezone, or revision, matching totals would be luck rather than evidence.
Start with this decision table. It turns a vague report that ‘the numbers don't match’ into a choice about authority and auditability.
| Approach | Pick this when | Signature and audit trail | Main limitation |
|---|---|---|---|
| Snapshot-first | The dashboard snapshot was explicitly frozen for the statement run | Sign the snapshot manifest and retain its input identity | Later corrections won't appear until a new revision is issued |
| Live-query-first | The document is an on-demand operational view, not a closed statement | Record the exact query contract and result identity | The same request can legitimately reflect newer source data |
| Signed statement ledger | A monthly statement must be reproducible after operational data changes | Sign a canonical manifest that links inputs, output, and revision | Requires lifecycle rules for corrections and reissuance |
For a closed monthly statement, the third option is the clearest default. The PDF is an output. The signed manifest is the evidence that explains which records became that output.
How should you debug statement numbers that do not match a dashboard snapshot or live query?
Compare identities before totals. A statement total, a dashboard tile, and a live query may all display the same currency and month while answering different questions. Write down the statement period, cutoff instant, timezone, account scope, status filter, revision, and rounding rule for each path. One differing field is enough to explain a mismatch.
Use a tiny reconciliation record rather than three screenshots. Screenshots help a human recognize a page, but they don't establish the query contract behind it. The record below gives each value a source and gives the comparison a stable vocabulary.
import { createHash } from "node:crypto";
type Money = { currency: string; minorUnits: bigint };
type QueryContract = {
accountId: string;
periodStart: string;
periodEndExclusive: string;
cutoff: string;
timezone: string;
statuses: readonly string[];
revision: number;
};
type ObservedTotal = {
source: "statement-input" | "dashboard-snapshot" | "live-query";
contract: QueryContract;
total: Money;
};
const stableContract = (contract: QueryContract): string =>
JSON.stringify({
...contract,
statuses: [...contract.statuses].sort(),
});
const contractId = (contract: QueryContract): string =>
createHash("sha256").update(stableContract(contract)).digest("hex");
function classifyMismatch(reference: ObservedTotal, candidate: ObservedTotal): string {
if (contractId(reference.contract) !== contractId(candidate.contract)) {
return "INPUT_CONTRACT_MISMATCH";
}
if (reference.total.currency !== candidate.total.currency) {
return "CURRENCY_MISMATCH";
}
if (reference.total.minorUnits !== candidate.total.minorUnits) {
return "CALCULATION_MISMATCH";
}
return "MATCH";
}
This ordering matters. If the contract identity differs, stop comparing arithmetic. A dashboard snapshot cut at month-end and a live query run later can contain different eligible records; recomputing both totals more carefully doesn't make their input sets equivalent. Fix the comparison target first. Then inspect calculation logic only when the contracts match.
Keep money in integer minor units through aggregation. Keep period end exclusive. Sort set-like fields before hashing. Those are implementation choices in this example, not claims that every ledger must use the same representation. Your mileage may vary on the canonical serialization — the requirement is that producer and verifier use one documented form.
Small distinction. Big payoff.
Pick snapshot-first for a deliberately frozen operational view
Snapshot-first works when the dashboard already creates a named, immutable dataset at the statement cutoff. In that design, statement generation consumes the snapshot identifier rather than rerunning a mutable query. The manifest records that identifier, the query contract, the computed totals, and the generated document identity. An auditor can walk backward from the document to the exact frozen input.
The catch is correction handling. A frozen snapshot should not quietly absorb a late shipment adjustment while retaining the same identity. Issue a new revision, link it to the prior one, and make the visible statement revision unambiguous. Don't use snapshot-first when the dashboard cache is merely a performance artifact with no retention or version contract; a cache key is not automatically an accounting boundary.
Think of the flow as a diagram in words: eligible shipment charges enter a frozen snapshot; the snapshot and calculation policy enter the statement builder; the builder emits a PDF plus a manifest; the signing step binds the manifest to its signature; the audit index stores the chain of identities. Each arrow carries an identifier. That's the useful part.
Pick live-query-first for current operational answers
Live-query-first is suitable when users are asking, ‘What does the account show now?’ The query contract still needs to be explicit, and the response should carry enough context to explain its cutoff and scope, but the result is allowed to change as source records change. This is often the honest behavior for an operational dashboard.
It is not suitable when a signed monthly statement must reproduce an already closed result. In that case, stick with an immutable statement input or signed ledger. Otherwise a correct live total can disagree with a correct historical statement, and the interface gives the operator no principled way to decide which value answers the dispute.
No drama. They are different questions.
Implement a signed manifest and reconciliation gate
The deepest implementation work belongs at the handoff into document generation. Build one canonical manifest, validate it, hash it, and send only validated data to the PDF renderer. ISO 32000-2 specifies PDF as a document format and includes digital-signature facilities; the application still has to define what business input the signed document represents. Signing a PDF without preserving the input identity protects the document while leaving the reconciliation question unanswered.
Here is a compact TypeScript shape for a logistics bundle that can merge shipment pages or split them by account. The audit fields stay outside the rendering template, so a typography change can't redefine the business total.
import { createHash, sign, verify, type KeyObject } from "node:crypto";
type BundleItem = {
documentId: string;
accountId: string;
sequence: number;
amountMinor: bigint;
};
type StatementManifest = {
statementId: string;
revision: number;
previousManifestHash?: string;
currency: string;
contractId: string;
items: readonly BundleItem[];
};
function canonicalManifest(manifest: StatementManifest): string {
return JSON.stringify({
...manifest,
items: [...manifest.items]
.sort((a, b) => a.sequence - b.sequence)
.map((item) => ({ ...item, amountMinor: item.amountMinor.toString() })),
});
}
function sealManifest(manifest: StatementManifest, privateKey: KeyObject) {
const bytes = Buffer.from(canonicalManifest(manifest), "utf8");
return {
manifestHash: createHash("sha256").update(bytes).digest("hex"),
signature: sign(null, bytes, privateKey).toString("base64"),
};
}
function verifyManifest(
manifest: StatementManifest,
signatureBase64: string,
publicKey: KeyObject,
): boolean {
const bytes = Buffer.from(canonicalManifest(manifest), "utf8");
return verify(null, bytes, publicKey, Buffer.from(signatureBase64, "base64"));
}
The signature call assumes a key type compatible with the selected Node.js signing mode; key selection and custody belong in the deployment design, not in a document template. I'm not sure which trust boundary fits your organization without its retention policy and verifier model. Resolve that choice before production by naming who verifies statements, how long verification must remain possible, and how keys are rotated.
Put a gate immediately before rendering. It should reject duplicate sequence numbers, mixed accounts in a single-account statement, a currency mismatch, a total that differs from the manifest sum, or a missing predecessor link on a correction. Emit structured events for statement_id, revision, contract_id, and manifest_hash. Metrics should count classifications such as INPUT_CONTRACT_MISMATCH separately from calculation mismatches; an alert that only says ‘totals differ’ sends the on-call engineer back to guesswork.
Test the boundary with pairs, not isolated fixtures: same inputs in different order must canonicalize identically; one changed amount must change the manifest hash; a split bundle must preserve every source item exactly once; merged bundles must reject incompatible account or currency scopes; and a revised statement must point to its predecessor. Deployment should canary the reconciliation classifier before it blocks generation, because historical data may expose undocumented query-contract differences even when the arithmetic is sound.
Know the limits before choosing the boundary
A signed manifest proves integrity relative to a key and a canonical representation. It does not prove that an upstream charge was commercially correct, that an account scope was authorized, or that the chosen cutoff matched a contract. Those controls need their own evidence.
Snapshot-first adds storage and revision management. Live-query-first sacrifices historical reproducibility by design. A signed ledger adds key custody, verifier, retention, and reissuance responsibilities. Choose the least complex option that meets the statement's dispute and audit requirements — and keep an operational dashboard out of the role of historical authority unless it explicitly owns immutable, versioned snapshots.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
Top comments (0)