← all branches

feat/billing-improvements

needs attentionviewing older commit
3afe12c · incrementalpre-PRreviewed 2026-07-30 19:23 UTC1H · 7M · 6L · 6I
The branch
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
The changes (whole branch)
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+1861services/billing+1501domains/cross-domain+15916packages/bill-pdf+314118apps/platform/src/api+4421pnpm-lock.yaml+180
Blast
23 files, +863/−154 across CDK infra, Lambda service, cross-domain coordinator, bill-pdf package, and platform API handler
no-tests-for-view-model-mapper ssm-plaintext-credentials iam-arn-ssm-token-risk
ci· No open PR — CI status unavailable for pre-PR branchcoderabbit· No .coderabbit.yaml in repo

Findings · 24

correctness5

medium

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.

medium

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.

low

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.

low

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.

info

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

medium

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.

medium

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.

low

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.

low

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.

info

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.

info

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

medium

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.

medium

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.

low

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.

low

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

high

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.

medium

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.

info

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

medium

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).

medium

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.

low

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.

low

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.

info

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.

info

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.

History · 2 commits

  1. f406509needs attentionincremental5H · 8M · 8L2026-07-30 22:31
  2. 3afe12cneeds attentionincremental1H · 7M · 6L2026-07-30 19:23current