feat: deterministic investor statement PDF generator with signed hash - #895
Merged
thlpkee20-wq merged 1 commit intoAug 31, 2026
Merged
Conversation
Implements the investor statement PDF pipeline for RevoraOrg#874: a content-aware, byte-deterministic renderer (same input -> same sha256) that persists the (statement_id, sha256, generated_at) checkpoint via pdf_render_jobs and a fetch endpoint that re-verifies the persisted hash before serving bytes. - DefaultStatementDataProvider assembles positions, distributions, fees, transactions, revenue, and tax classifications from snapshot, distribution, investment, revenue-report, and investment-lot repositories with stable ordering so renders are reproducible. - renderStatementPdfWithContent renders a frozen-layout PDF with no wall-clock or random values; the legacy watermark/version-stamp behavior (Ed25519 treasury signature, DRAFT watermark, ledger revision footer) is shared via evaluateWatermarkState. - GET /statements/:periodId/:investorId re-computes sha256 over stored bytes and returns 409 CONFLICT on mismatch (tamper detection), with admin/compliance access and investor self-access enforced. - Additive repository queries (findByHolderAndPeriod, listByPeriod, listPayoutsByInvestorForPeriod, listPayoutsByPeriod, findCompletedByInvestorAndPeriod) with deterministic ORDER BYs. - 108 focused tests across provider, renderer, routes, and repositories. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
Implements #874 — Build investor statement PDF generator with deterministic layout and signed hash.
Investors receive quarterly statements (positions, distributions, fees, tax classifications) as PDFs. This PR adds the full pipeline:
DefaultStatementDataProviderassembles statement content fromtoken_balance_snapshots, investments,distributions/distribution_payouts, revenue reports, and immutable investment lots — with deterministic ordering everywhere.renderStatementPdfWithContentrenders a byte-deterministic, frozen-layout PDF: the output is a pure function of (job metadata, statement content, watermark state, ledger revision hash). No wall-clock values, random bytes, or locale-dependent formatting are ever embedded, so regenerating a statement yields the same sha256.pdf_render_jobsrow =(statement_id, sha256, generated_at)viaid,checksum,updated_at) is the source of truth.GET /statements/:periodId/:investorIdre-computes sha256 over the stored bytes on every fetch and serves200 application/pdf(withX-Statement-Sha256+ETag) only on a match; a mismatch returns409 CONFLICTand a security-relevantstatement.tamper-detectedlog.Root cause
There was no statement generator at all: no content assembly, no deterministic renderer, no persisted artifact hash, and no fetch path that could serve (or verify) a statement. The pre-existing
renderStatementPdfDetails(#487 watermark/version-stamp work) renders only job metadata and is not content-aware, and nothing re-verified artifact integrity before serving.The fix and why
localeComparefor text, numeric comparison for decimal strings,Date.getTime()for timestamps), so byte-identical output holds even if a provider returns rows in a different order. Dates render viaDate.toISOString()(UTC, fixed-width); amounts pass through as decimal strings.generatedAtis never embedded.findCompletedByInvestorAndPeriodreturns onlycompletedrows).evaluateWatermarkState) used by both the legacy details renderer and the new content renderer, so the security behavior can't diverge.findByHolderAndPeriod,listByPeriod,listPayoutsByInvestorForPeriod,listPayoutsByPeriod,findCompletedByInvestorAndPeriod) use deterministicORDER BYs and touch no existing tables. The existingrenderStatementPdfDetails/makeStatementRenderFnAPI is unchanged.Alternatives considered:
generatedAtor a timestamp for versioning — rejected: breaks byte determinism and the archival hash contract.Security & failure-mode handling
404 NOT_FOUND404 NOT_FOUND409 CONFLICT+statement.tamper-detectedlog (never serves untrusted bytes)500— internal message not leaked to the client401(identity comes only from the verified JWT; headers never trusted)403(IDOR boundary enforced)403— issuers cannot enumerate investor statements400(validated: non-empty, ≤ 128 chars)processing, reclaimed after stale window; re-render is byte-identical (deterministic storage key)pendingwith backoff; retried render is byte-identical\,(,)) and collapses newlines in free textAcceptance criteria → code & tests
src/services/balanceSnapshotService.ts/src/db/repositories/distributionRepository.tswith a clear contract — the data lineage is documented indocs/investor-statement-deterministic-pdf.md; content assembly lives insrc/services/statementDataProvider.ts, persistence queries insrc/db/repositories/{balanceSnapshot,distribution,pdfRenderJob}Repository.ts.src/routes/statements.test.ts(401/403/IDOR/400/404/409/500, tamper rejection, storage-not-called-on-missing-job).findCompletedByInvestorAndPeriodserves onlycompletedrows;pdfRenderJobRepository.test.tscovers claim/markCompleted/markFailed/reclaim paths; deterministic re-render keeps crash recovery byte-identical.InMemoryStatementPdfStorage(fail-safe 404 until an S3-backed adapter is deployed).How it was tested
statementDataProvider.test.ts,statementPdfService.test.ts,statements.test.ts,pdfRenderJobRepository.test.ts,balanceSnapshotRepository.test.ts,distributionRepository.test.ts— 108 tests, 6 suites.statements.ts97.7% lines,statementDataProvider.ts95.4%,statementPdfService.ts95.9%,pdfRenderJobRepository.ts100% (≥ 95% target).generatedAtnever embedded (mutating it does not change bytes); unsorted provider rows → sorted, byte-stable output.409 CONFLICT; artifact never served on mismatch.tsc --noEmitreports no new errors in any touched file (the repo carries 500+ pre-existing errors in unrelated modules onmaster)..eslintrc.cjsconfig mismatch is pre-existing and fails identically onmaster; new files were linted in legacy mode).npm run validate:alert-mappings✅;npm run audit:cibehaves identically tomaster.masteritself has 56 pre-existing failing suites / 322 failing tests (unrelated modules, e.g.validateZodParams is not a function, changePassword type drift). Our branch adds exactly 2 new suites (both pass) and no new consistent failures. Running the full suite also exposes a pre-existing ts-jest 29.4.x + Jest 30 incompatibility that intermittently crashes the transformer on random suites (reproduced onmastertoo) — not introduced here.Compatibility & migration
pdf_render_jobs.checksumcontinues to be the persisted hash.404(no artifacts exist) rather than failing open.StatementPdfStorageadapter so completed renders survive across instances, and wiring the batch worker (already checkpointingpdf_render_jobs) to the content renderer.Follow-ups worth filing separately
StatementPdfStorageadapter — required before the fetch endpoint can serve real artifacts in production (currentlyInMemoryStatementPdfStorage).tscerrors and 56 pre-existing failing suites onmaster(unrelated to statements) sonpm test/npm run buildcan be green gates.Closes #874