Skip to content

refactor(billing): adopt Money value object across Wallet, Invoice and BillingPlan#1320

Merged
iammukeshm merged 1 commit into
fullstackhero:mainfrom
marcelo-maciel:refactor/billing-money-adoption
Jul 1, 2026
Merged

refactor(billing): adopt Money value object across Wallet, Invoice and BillingPlan#1320
iammukeshm merged 1 commit into
fullstackhero:mainfrom
marcelo-maciel:refactor/billing-money-adoption

Conversation

@marcelo-maciel

Copy link
Copy Markdown
Contributor

What

Follow-up to #1316. Promoting Money to BuildingBlocks left the Billing module modeling money two ways: Money on TopupRequest, but raw decimal + Currency on Wallet, WalletTransaction, Invoice, InvoiceLineItem and BillingPlan. As requested in the #1316 review ("land the follow-up promptly so the half-state stays short-lived"), this folds the remaining money-bearing fields into Money and reintroduces the Money arithmetic (Add/Subtract/Multiply/Round and the +/-/* operators) that was trimmed from #1316, now justified by real production callers:

Method Production caller
Add Wallet.Credit balance, Invoice.RecalculateTotals subtotal over line items
Subtract Wallet.Debit balance
Multiply BillingPlan.TermPrice (annual fallback = monthly base * 12)
Round InvoiceLineItem amount (quantity * unitPrice, away-from-zero)

Persistence

Aggregate-level amounts that already had a paired Currency column reuse it through OwnsOne, so those adoptions are pure no-op remaps with no schema change:

  • Wallet.Balance
  • Invoice.SubtotalAmount
  • BillingPlan.MonthlyBasePrice

Sub-entity and optional amounts gain a per-row currency column, since a Money value object carries its own currency:

  • WalletTransaction.Currency (new, NOT NULL)
  • InvoiceLineItem.AmountCurrency (new, NOT NULL)
  • BillingPlan.AnnualPriceCurrency (new, nullable, only set when AnnualPrice is present)

Migration 20260701063312_BillingMoneyValueObjectAdoption adds the three columns as nullable, backfills each from the owning aggregate's currency (WalletTransaction from its Wallet, InvoiceLineItem from its Invoice, BillingPlan.AnnualPriceCurrency from the plan's own Currency), then enforces NOT NULL on the two required ones. Existing rows keep their aggregate's currency; no data loss.

Contract impact

None. All contract DTOs stay decimal and are unwrapped at the mapping boundary (same pattern #1316 used for TopupRequestDto), so there is no API or frontend change.

BuildingBlocks

The only BuildingBlocks change is reintroducing the Money arithmetic, which is exactly the reintroduction pre-approved in the #1316 review. Money stays dependency-free and passes the BuildingBlocks independence, immutability and layering architecture tests.

Verification

  • Build clean with -warnaserror, 0 errors.
  • Unit: Framework 112, Billing 123, Catalog 65, Architecture 51.
  • Integration: 730 passed, 1 skipped, 0 failed, against real PostgreSQL (Testcontainers).
  • Backfill exercised on populated data in a throwaway PostgreSQL: rows seeded with distinct currencies (EUR wallet, GBP invoice, BRL plan) before the migration, all inherited the parent's currency correctly after it.
  • dotnet ef migrations has-pending-model-changes reports no drift versus the snapshot.

Heads-up (unrelated)

A fresh dotnet restore currently fails repo-wide on NU1903 because the transitively resolved Microsoft.OpenApi 2.0.0 (via Microsoft.AspNetCore.OpenApi / Scalar.AspNetCore) is flagged by advisory GHSA-v5pm-xwqc-g5wc, and TreatWarningsAsErrors promotes it to an error. This is pre-existing and independent of this PR (it affects main on any clean restore). Happy to send a separate PR bumping Microsoft.OpenApi to the patched 2.7.5 if useful.

…d BillingPlan

Follow-up to fullstackhero#1316. Promoting Money to BuildingBlocks left Billing modeling money
two ways: Money on TopupRequest, but raw decimal+Currency on Wallet, WalletTransaction,
Invoice, InvoiceLineItem and BillingPlan. This folds the remaining money-bearing fields
into Money and reintroduces the arithmetic (Add/Subtract/Multiply/Round) trimmed from
fullstackhero#1316, now justified by real production callers:

- Add       -> Wallet.Credit balance, Invoice subtotal aggregation over line items
- Subtract  -> Wallet.Debit balance
- Multiply  -> BillingPlan.TermPrice (annual fallback = monthly * 12)
- Round     -> InvoiceLineItem amount (quantity * unitPrice, away-from-zero)

Persistence: aggregate-level amounts that already had a paired Currency column reuse it
via OwnsOne (Wallet.Balance, Invoice.SubtotalAmount, BillingPlan.MonthlyBasePrice) — pure
no-op remap. Sub-entity/optional amounts gain a per-row currency column
(WalletTransaction.Currency, InvoiceLineItem.AmountCurrency, BillingPlan.AnnualPriceCurrency),
backfilled from the owning aggregate's currency before the NOT NULL columns are enforced.

Contract DTOs stay decimal (unwrapped at the mapping boundary), so there is no API or
frontend impact. BuildingBlocks change (Money arithmetic) is the reintroduction pre-approved
by the maintainer in the fullstackhero#1316 review.

Verified: build clean (-warnaserror), unit (Framework 112, Billing 123, Catalog 65, Arch 51),
integration 730/731 on real Postgres, backfill exercised on populated data (EUR/GBP/BRL
inherited from parent), no pending model changes vs snapshot.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@iammukeshm iammukeshm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: approved.

This is exactly the follow-up requested in #1316, delivered as specified. The arithmetic reintroduction (Add/Subtract/Multiply/Round + operators) was pre-approved in that review on the condition that it lands together with real production callers — and every method now has one: AddWallet.Credit balance + Invoice.RecalculateTotals, SubtractWallet.Debit, MultiplyBillingPlan.TermPrice annual fallback, RoundInvoiceLineItem amount. Golden-rule-4 (BuildingBlocks) approval bar is satisfied by that recorded review.

What I verified:

  • Money arithmetic — the with-expression cloning is safe because the constructor uppercases Currency, so the Ordinal comparison in EnsureSameCurrency never sees mixed case. Signed amounts stay allowed (ledger semantics from #1316), and away-from-zero Round(2) exactly preserves the old InvoiceLineItem rounding — the existing 0.005 → 0.01 test passes untouched.
  • Migration20260701063312 is correctly ordered after 20260626155932. The backfill is sound: WalletTransaction and InvoiceLineItem both have required cascade FKs to their parents, so no orphan rows can defeat the NOT NULL promotion; AnnualPriceCurrency is only set where AnnualPrice exists, matching the optional owned navigation. The three aggregate-level adoptions (Wallet.Balance, Invoice.SubtotalAmount, MonthlyBasePrice) genuinely reuse existing columns — pure no-op remaps, verified against the snapshot diff.
  • Behavior preservationRecalculateTotals seeds the fold with Money.Zero(SubtotalAmount.Currency) (set at CreateDraft, never unset), and line items always inherit the invoice currency, so the Add currency check cannot false-positive. Contract DTOs stay decimal; no API or frontend impact.
  • CI — fully green: unit, integration (Testcontainers), coverage gate, DbMigrator container smoke, both scaffold builds, CodeQL.

Non-blocking follow-up notes:

  1. Wallet.Debit builds the negative Money twice — debit for the subtraction plus an inline new Money(-amount, Balance.Currency) for the ledger row. Cosmetic; feel free to fold into one.
  2. Good next follow-up: Wallet.Credit/Debit still take raw decimal and stamp the wallet's own currency, so MarkInvoicePaidAsync crediting invoice.SubtotalAmount.Amount would silently treat an EUR invoice as a USD wallet credit at face value. Pre-existing behavior, but now that both sides are Money, passing it through and letting Add/Subtract throw on mismatch closes the hole for free. Happy to take that as a small follow-up PR.
  3. Your NU1903 heads-up is stale — main already pinned Microsoft.OpenApi to the patched 2.9.0 in #1319, so no separate PR needed there.

Thanks for the thorough PR description and for exercising the backfill on populated multi-currency data — that made this easy to review.

@iammukeshm iammukeshm merged commit 6bdf55f into fullstackhero:main Jul 1, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants