feat/billing-improvements
needs attentionviewing older commit3afe12c · incrementalpre-PRreviewed 2026-07-30 19:23 UTC1H · 7M · 6L · 6I- Purpose
- Add batch bill PDF generation capability — fan out rendering across a fleet of bills using SFN Map so each bill's JS chart derivation runs on its own Lambda vCPU rather than being serialized on a shared event loop
- Goal
- Bill-PDF generator: SFN Map fan-out over bills array → per-bill Lambda (computeBillShell + deriveBillChartsFromSeries + react-pdf → S3)
- Sub-goals
- SG-1: Legacy Smarter/THOR PDF format parity in react-pdf renderer (bill-document.tsx, view-model)
- SG-2: Read-once bill compute — charts derived from same series as billing, no second Tinybird read
- SG-3: CDK infrastructure — SFN Standard + Lambda (this commit), SSM params, IAM, CfnOutputs
- What
- Added BillPdfGeneratorSfnStack (SFN Map + NodejsFunction) and generate-bill-pdf.handler.ts Lambda. Stack wires 4 SSM String params (database URL, Tinybird token/URL, CFE bucket name) as env vars. Handler calls computeBillShell(includeSeries:true) → deriveBillChartsFromSeries → renderBillPdf → S3 upload at computed-bills/{site}/{yearMonth}/{tariff}.pdf. Package.json gains @batu/bill-pdf, @batu/cross-domain, @batu/storage, @batu/tinybird, @batu/utility-domain dependencies.
- Why
- BAT-283: batch bill PDF generation needs to scale across many sites; single-process serialization (C-vs-D benchmark) was 2.2× slower; Lambda-per-bill fan-out resolves the bottleneck by giving each render its own vCPU
- Areas
- infra/cdk+186−1services/billing+150−1domains/cross-domain+159−16packages/bill-pdf+314−118apps/platform/src/api+44−21pnpm-lock.yaml+18−0
- Blast
- 23 files, +863/−154 across CDK infra, Lambda service, cross-domain coordinator, bill-pdf package, and platform API handler
Findings · 24
correctness5
S3 IAM policy ARN embeds an SSM CFN token — may fail to resolve in IAM resource ARNs
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
PolicyStatement resource is `arn:aws:s3:::${billsBucketName}/computed-bills/*` where billsBucketName is a StringParameter.valueForStringParameter() token. CloudFormation resolves SSM tokens in resource Properties but NOT inside IAM policy resource ARNs at synthesis time — CDK embeds the token literal, CloudFormation tries to resolve it in an IAM ARN, which fails. The correct approach is `Bucket.fromBucketName()` then `bucket.grantPut(renderFn)` / `grantRead`, or `Bucket.fromBucketAttributes()` with the resolved name token.
Tinybird calls inside DB transaction — holds PG connections during external HTTP under fan-out of 40
services/billing/src/handlers/generate-bill-pdf.handler.ts
computeBillShell (Tinybird external I/O) runs inside a database.transaction() block alongside DB reads. Tinybird HTTP calls can take 1–5 s; with maxConcurrency=40, up to 40 simultaneous PG transactions are held open. Not a crash-level bug but risks connection-pool exhaustion at scale. The platform handler has the same pattern (known accepted deviation); document the risk explicitly.
markFailed Pass emits nested {Error,Cause} object — callers must parse Cause to recover error message
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
addCatch sets resultPath:'$.error' so SFN writes {Error:'<name>',Cause:'<stringified>'} into $.error. The Pass re-emits this object. Any consumer reading result.error as a string will silently get '[object Object]'. Cause is JSON-stringified — document this in the schema or unwrap it in the Pass parameters.
inclusiveEnd subtracts 86_400_000 ms — off by 1 hour on Tijuana DST spring-forward day
services/billing/src/handlers/generate-bill-pdf.handler.ts
Display-only: the period end and due date on the PDF will be wrong by 1 hour (shifted to 23:00 local) for America/Tijuana sites on the DST spring-forward day. Use a calendar-aware date helper consistent with site-metrics-seam rules.
No Retry on LambdaInvoke — transient throttles fail immediately to MarkBillFailed
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
No retry policy on RenderOneBill LambdaInvoke; Lambda throttles or cold-start failures go straight to MarkBillFailed. Consider retryOnServiceExceptions or explicit Retry configuration.
security6
s3:GetObject grant not required — over-permission on computed-bills prefix
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
The Lambda IAM policy grants s3:GetObject on computed-bills/* but the handler only uploads (PutObject + AbortMultipartUpload). GetObject violates least-privilege and adds a read path from the Lambda role. Remove unless a concrete read use-case is added.
POSTGRES_URL and TINYBIRD_TOKEN stored as SSM String (plaintext) — visible in env var dumps
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
SSM String parameters are unencrypted; values appear in CloudTrail GetParameter events, Lambda GetFunctionConfiguration responses, and console dumps. TINYBIRD_TOKEN is a bearer credential. Workaround: inject the secret ARN and call SSM GetParameter(WithDecryption=true) / Secrets Manager at cold-start (cache in module scope). The current approach is a known gap; should be tracked as a remediation item.
No authz check on sitePublicId — SFN invoker must be trusted
services/billing/src/handlers/generate-bill-pdf.handler.ts
The handler accepts a raw sitePublicId from the SFN event without verifying caller authorization. Acceptable only if StartExecution access is restricted by IAM to trusted internal callers. Confirm the SFN execution IAM policy is tight.
tariffCode S3 key not length-bounded — could exceed S3's 1024-byte key limit on data corruption
services/billing/src/handlers/generate-bill-pdf.handler.ts
tariffCode comes from the DB (trusted) so path traversal is not a risk, but an unexpectedly long value could exceed S3's 1024-byte key limit. Add a .slice(0, 64) bound for robustness.
Lambda/SFN timeout gap: Lambda 2 min, SFN task 3 min — 1-minute zombie window
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
If Lambda hits its 2-min hard limit, SFN waits 1 more minute before timing out the task. No hung executions result but the Map item fails silently without alerting. Operational concern, not a blocker.
database as unknown as Database double cast bypasses TypeScript structural check
services/billing/src/handlers/generate-bill-pdf.handler.ts
Known monorepo pattern. If the Drizzle client API diverges from the Database interface after an upgrade, the error surfaces at runtime instead of build time. Acceptable given current constraints.
conventions4
toViewModel duplicates bill-pdf.mapper.ts — mapper already exists in the platform handler
services/billing/src/handlers/generate-bill-pdf.handler.ts:64
canonical-form.md requires domain→response mapping in a dedicated mapper file. apps/platform/src/api/mappers/bill-pdf.mapper.ts already exports toBillPdfViewModel. The Lambda re-implements this mapping inline, creating divergence. The mapper (or a shared export from @batu/bill-pdf) should be imported, not re-implemented.
CO2E_PER_MXN = 0.000435 duplicated across two handlers
services/billing/src/handlers/generate-bill-pdf.handler.ts:34
The constant appears verbatim in both this handler and apps/platform/src/api/handlers/bill-pdf.handler.ts:43. A change to one will not propagate to the other, silently mis-billing tCO2e on PDFs. Move to @batu/bill-pdf (alongside the view-model type) or @batu/cross-domain.
batu:provider: 'thor' tag is semantically wrong — renderer is tariff-agnostic
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts:67
The bill-pdf generator renders any tariff (GDMTH CFE, PDBT, THOR). Tagging it 'thor' mislabels cost/usage reports. Use 'internal' or align with the tagging standard for non-external-provider stacks.
fmt() uses UTC in platform handler vs site-timezone in Lambda handler — one is wrong
services/billing/src/handlers/generate-bill-pdf.handler.ts:57
This handler's fmt() correctly applies the site's timezone; apps/platform/src/api/handlers/bill-pdf.handler.ts uses getUTCDate/getUTCMonth/getUTCFullYear. For sites in timezones offset from UTC, the two produce different period/due-date labels for the same bill. One is wrong; align them.
tests3
toViewModel has non-trivial logic with no unit tests — wrong band key may silently NaN
services/billing/src/handlers/generate-bill-pdf.handler.ts:64
toViewModel performs TOU band extraction, distributionKw = Math.max(dband.base, dband.intermediate, dband.peak), and CO2e calculation with no test coverage. The band key 'intermediate' may be wrong (ComputeBillResult uses 'intermedia'/'intermedio'), which would make dband.intermediate = undefined and Math.max silently return NaN, corrupting the PDF demand values. A unit test exercising a TOU bill with demand breakdown would catch this immediately.
BUCKET guard and compute-failure throw paths have no tests
services/billing/src/handlers/generate-bill-pdf.handler.ts:92
The handler has two critical early-exit paths (missing BUCKET env var, compute failure) that are not covered. The SFN Catch relies on these throws producing the right error shape; a regression would silently misroute failures.
deriveBillChartsFromSeries is well-covered by earlier branch commit
domains/cross-domain/src/__tests__/bill-charts-from-series.test.ts
bill-charts-from-series.test.ts added earlier in this branch provides good coverage of the chart derivation logic (daily kWh, FAC, hourly profile, empty-series). Substantially reduces integration risk of the compute→chart pipeline.
improvement6
S3 client instantiated per invocation — should be module-level like Tinybird client
services/billing/src/handlers/generate-bill-pdf.handler.ts
new S3ClientImpl({ bucketName: BUCKET }) is created inside the handler body. Move to module scope so it is reused across warm invocations (same pattern as cachedTb for Tinybird).
maxConcurrency: 40 is a magic number — extract to named constant with rationale
infra/cdk/src/stacks/services/billing/bill-pdf-generator-sfn.stack.ts
Hardcoded to 40 with no comment on the bound (Lambda concurrency limit, DB connection pool, or S3 rate). Extract to a named constant or CDK context config so it can be tuned per environment.
fmt() date formatter manual part-assembly can be replaced by .format()
services/billing/src/handlers/generate-bill-pdf.handler.ts
Intl.DateTimeFormat with es-MX locale and day/month/year options already produces DD/MM/AAAA via .format(); the formatToParts loop + manual template is unnecessary.
inclusiveEnd raw-millisecond arithmetic — use calendar-aware helpers
services/billing/src/handlers/generate-bill-pdf.handler.ts
Adding/subtracting 86_400_000 ms per day breaks on DST transitions (Tijuana). site-metrics-seam rules mandate calendar-aware date math for billing periods.
gathered narrowing uses 'ctx' in gathered — consider discriminated union
services/billing/src/handlers/generate-bill-pdf.handler.ts
Property-presence narrowing is fragile compared to an explicit discriminant ('tag: full|partial'). Low priority — current guard is functionally correct.
toViewModel single-letter variables reduce readability
services/billing/src/handlers/generate-bill-pdf.handler.ts
Variables like dband, e, d in the mapping function obscure intent. Expand to descriptive names (distributionBands, energyCharge, etc.) for easier future audits.