Skip to content

⭐ Billing

Terrence Daniels edited this page Sep 16, 2026 · 5 revisions

A Kafka consumer/producer applying a rate plan to a meter's aggregated usage and generating an invoice — the final stage of Phase 1's core loop (Meter Simulator → Usage Aggregation → Billing), now event-driven end to end.

Composition

  • IRatePlan — the Strategy pattern interface: one method, CalculateCost(hourlyUsage)
  • FlatRateRatePlan, TieredRatePlan, TimeOfUseRatePlan — three interchangeable pricing strategies (flat $/kWh; tiered thresholds with an open-ended final bracket; peak/off-peak windows that correctly handle wrapping past midnight)
  • RatePlanOptions — the actual pricing values, bound from a checked-in appsettings.json
  • BillingOptions — which rate plan applies by default and standard payment terms (DefaultRatePlanType, PaymentTermsDays), the same checked-in-config reasoning as RatePlanOptions, now that there's no per-call rate plan choice
  • Invoice — the persisted bill: AccountId, period, TotalKwh, AmountDue, DueDate, which RatePlanType was applied — upserted per (AccountId, PeriodStart, PeriodEnd, RatePlanType) rather than always inserted
  • InvoiceGenerator — composes the above: applies the configured default rate plan directly to the usage figure it's handed, computes the cost, upserts the invoice, publishes it
  • UsageAggregatedConsumer — a BackgroundService subscribed to usage.aggregated, feeding each event to InvoiceGenerator in its own DI scope
  • IBillingInvoiceGeneratedPublisher / BillingInvoiceGeneratedPublisher — the same narrow-abstraction pattern as Usage Aggregation's publisher, keeping Confluent.Kafka's IProducer<TKey,TValue> out of anything that needs testing
  • InvoicePaymentConsumer / InvoicePaymentInitiator — a second, independent consumer of billing.invoice.generated (mirrors UsageAggregatedConsumer/InvoiceGenerator's consumer-plus-testable-POCO shape) that creates a real Stripe Checkout Session per invoice
  • IStripeCheckoutSessionCreator / StripeCheckoutSessionCreator — the Stripe-facing abstraction, same narrow-wrapper pattern as the Kafka publishers above
  • StripeWebhookHandler — verifies Stripe's real webhook signature and updates Invoice.Status on payment success/failure (see below)

A deliberate exception to "no checked-in config"

Unlike Meter Simulator's deployment-specific config (street/city/zip, which must come from Aspire parameters + user secrets, never a committed file), RatePlanOptions' and BillingOptions' values live in a checked-in appsettings.json. Rate schedules and payment terms are real published business configuration, not a deployment secret — the same reasoning any production billing service would apply.

Rate plan choice moved from per-call to configured default

POST /invoices used to let the caller pick which rate plan applied per invoice, since there was no Account/Customer Service to know a customer's assigned plan. Now that Billing reacts automatically to usage.aggregated events rather than being explicitly called, that per-call choice is gone — BillingOptions.DefaultRatePlanType decides for the whole deployment instead. Invoice itself has since been re-keyed to AccountId now that Account/Customer Service exists — see "A Phase 1 deviation, resolved in Phase 3" below.

Upserting, not inserting — because events never stop

usage.aggregated republishes continuously as usage accumulates within an hour, so InvoiceGenerator upserts the Invoice row for a given (AccountId, PeriodStart, PeriodEnd, RatePlanType) instead of always creating a new one — otherwise every single event would produce its own invoice. A unique index backs this, added via a dedicated EF migration alongside the new DueDate column.

Service boundaries kept real, now via real Kafka

Billing never reached into Usage Aggregation's Postgres tables directly, even when they shared one physical database in Phase 1 — first via a REST call to Usage Aggregation's read endpoint, now by consuming the usage.aggregated event it publishes. Same boundary, real transport underneath it now.

A Phase 1 deviation, resolved in Phase 3

Invoice used to be keyed by MeterId, not AccountId — the same deviation Usage Aggregation carried, for the same reason: no Account/Customer Service existed yet to map a meter to an account. Phase 3 built that service and re-keyed Invoice to AccountId for real. Old MeterId-keyed rows had no valid AccountId to migrate to, so the migration deletes them rather than carrying forward data that would be wrong under the new column's meaning — the same one-time clean transition applied to Usage Aggregation's HourlyUsage.

Real Stripe test-mode payment collection

Invoice gained Status/StripeCheckoutSessionId/StripePaymentIntentId/PaidAt, backfilled to "Open" for every pre-existing row (a deliberate deviation from the MeterId→AccountId migration above: these are still valid financial records, not meaningless data under a new key, so they're backfilled, not deleted). InvoicePaymentConsumer reacts to the same billing.invoice.generated event Invoice already publishes, creating a real Stripe Checkout Session per invoice — deliberately on AutoOffsetReset.Latest, not Earliest like UsageAggregatedConsumer, since replaying this topic's real history would fire a live Stripe API call per historical invoice. Billing's first-ever HTTP endpoint, POST /webhooks/stripe, verifies Stripe's real signature (Stripe.net's EventUtility.ConstructEvent) and updates Status on real payment success/failure. Full reasoning — the metadata/idempotency-key/retry-layer fixes, the PCI/SAQ-A decision, the $0.50 Stripe-minimum crash found via live verification (issue #27) — in docs/architecture.md.

Verified live, not just built

Ran the full three-service AppHost and confirmed via psql: 91 real Invoice rows landing in Postgres from real Kafka traffic, correct rate-plan math (AmountDue = TotalKwh × rate), correct DueDate (GeneratedAt + PaymentTermsDays), and the upsert confirmed working — one meter's current-hour invoice updating in place rather than duplicating. A real bug was caught getting there: the first live run crashed Billing at startup because the EF migration adding the new unique index failed against pre-existing duplicate rows from earlier Phase 1 smoke tests; fixed by having the migration dedupe before creating the index, so it's safe against any environment with old data, not just this one — issue #19. Re-verified after the AccountId re-keying as part of Phase 3's full pipeline check: real AccountId-keyed Invoice rows confirmed in Postgres, correctly matched to a real webhook delivery by Notification Service. Stripe payments verified with a real free Stripe account and the real Stripe CLI: a real Checkout Session created for a real invoice, a real successful payment (test card 4242 4242 4242 4242) confirmed via psql to set Status = 'Paid' with a real PaidAt/StripePaymentIntentId, a real declined payment (4000 0000 0000 0002) confirmed to set Status = 'PaymentFailed', and idempotency confirmed by resending the same Stripe event and observing no change.

Tested

24 xUnit tests: FlatRateRatePlan, TieredRatePlan (including the exact tier-boundary edge case), and TimeOfUseRatePlan (including a peak window wrapping past midnight), InvoiceGenerator against EF Core's InMemory provider and a hand-rolled fake IBillingInvoiceGeneratedPublisher (including a dedicated test for the upsert-not-duplicate behavior and asserting the generated invoice carries the correct AccountId), InvoicePaymentInitiator against a hand-rolled fake IStripeCheckoutSessionCreator, and StripeWebhookHandler — using Stripe.net's own EventUtility.GenerateSignatureHeader to construct validly-signed test payloads with no live Stripe connection needed, including an invalid-signature-rejection test and a late-arriving-failure-after-success idempotency test.