Stage 7 · Master
Phase 8 — Maintenance Service
Monthly Charges
Versioned rates and a pure, deterministic invoice generator
Why a Rate Needs History, Not Just a Current Value
If charge_types carried the rate directly and an admin edited it after an AGM, every previously generated invoice would silently reprice the moment anyone re-read it — a January invoice generated in January must always show January's rate, even if the rate changes in June. charge_versions gives every rate change a lifespan (effective_from, effective_to) so 'the rate on this date' is a query, not a guess.
CREATE TABLE charge_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
charge_type_id UUID NOT NULL REFERENCES charge_types (id),
rate_paise BIGINT NOT NULL CHECK (rate_paise >= 0),
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (effective_to IS NULL OR effective_to > effective_from)
);
CREATE INDEX charge_versions_tenant_charge_type_idx
ON charge_versions (tenant_id, charge_type_id);
-- A charge type can have at most one currently-open version (effective_to
-- IS NULL). Closing the old version and inserting the new one must happen
-- in the same transaction, or this index rejects the second insert.
CREATE UNIQUE INDEX charge_versions_one_open_idx
ON charge_versions (charge_type_id)
WHERE effective_to IS NULL;The partial unique index is the actual mechanism preventing two simultaneously-open rates for one charge type — it only indexes rows where effective_to IS NULL, so closed (historical) versions never conflict with each other.
DROP TABLE IF EXISTS charge_versions;package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type ChargeVersion struct {
ID uuid.UUID
TenantID uuid.UUID
ChargeTypeID uuid.UUID
RatePaise Paise
EffectiveFrom time.Time
EffectiveTo *time.Time
}
// ActiveCharge is what the billing run actually needs: a charge type's
// rate basis paired with the rate that was in force on the billing date.
// It lives in the domain package (not repository, not service) because
// both the postgres repository and the invoice generator in service
// depend on this exact type — see the note on type identity below.
type ActiveCharge struct {
ChargeTypeID uuid.UUID
ChargeName string
RateBasis RateBasis
RatePaise Paise
}
var ErrNoOpenChargeVersion = errors.New("charge_version: charge type has no currently open version")ActiveCharge is deliberately declared in domain rather than in repository/postgres or service — Go's structural typing means a method returning repository.ActiveCharge cannot satisfy an interface expecting service.ActiveCharge even if the fields are identical. One shared type in the leaf domain package, imported by both, avoids the mismatch entirely.
package postgres
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
type ChargeVersionRepository struct {
pool *pgxpool.Pool
}
func NewChargeVersionRepository(pool *pgxpool.Pool) *ChargeVersionRepository {
return &ChargeVersionRepository{pool: pool}
}
// ReviseRate closes whatever version is currently open for chargeTypeID
// (if any) and opens a new one, atomically. Both statements run in one
// transaction: if the close succeeds but the process crashes before the
// insert, the charge type would be left with zero open versions rather
// than two — a state the next billing run detects as ErrNoOpenChargeVersion
// instead of silently double-billing.
func (r *ChargeVersionRepository) ReviseRate(ctx context.Context, tenantID, chargeTypeID uuid.UUID, ratePaise domain.Paise, effectiveFrom time.Time) (domain.ChargeVersion, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.ChargeVersion{}, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
UPDATE charge_versions
SET effective_to = $1
WHERE tenant_id = $2 AND charge_type_id = $3 AND effective_to IS NULL`,
effectiveFrom, tenantID, chargeTypeID,
); err != nil {
return domain.ChargeVersion{}, err
}
var cv domain.ChargeVersion
cv.TenantID, cv.ChargeTypeID, cv.RatePaise, cv.EffectiveFrom = tenantID, chargeTypeID, ratePaise, effectiveFrom
err = tx.QueryRow(ctx, `
INSERT INTO charge_versions (tenant_id, charge_type_id, rate_paise, effective_from)
VALUES ($1, $2, $3, $4)
RETURNING id`,
tenantID, chargeTypeID, ratePaise, effectiveFrom,
).Scan(&cv.ID)
if err != nil {
return domain.ChargeVersion{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.ChargeVersion{}, err
}
return cv, nil
}
// ListActiveOn returns every charge type's rate as of billingDate, joined
// with the charge type's name and rate basis. A charge type with no open
// version on that date (revised into a gap, or never given a first rate)
// is simply absent from the result — the caller decides whether that is
// acceptable or an error.
func (r *ChargeVersionRepository) ListActiveOn(ctx context.Context, tenantID uuid.UUID, billingDate time.Time) ([]domain.ActiveCharge, error) {
const q = `
SELECT ct.id, ct.name, ct.rate_basis, cv.rate_paise
FROM charge_versions cv
JOIN charge_types ct ON ct.id = cv.charge_type_id
WHERE cv.tenant_id = $1
AND cv.effective_from <= $2
AND (cv.effective_to IS NULL OR cv.effective_to > $2)`
rows, err := r.pool.Query(ctx, q, tenantID, billingDate)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.ActiveCharge
for rows.Next() {
var ac domain.ActiveCharge
if err := rows.Scan(&ac.ChargeTypeID, &ac.ChargeName, &ac.RateBasis, &ac.RatePaise); err != nil {
return nil, err
}
out = append(out, ac)
}
return out, rows.Err()
}
var _ = pgx.ErrNoRowsListActiveOn's date range check (effective_from <= date AND (effective_to IS NULL OR effective_to > date)) is the query-time equivalent of 'the rate in force on this day' — it works identically for today's billing run and for a historical replay of March's run.
Invoices and Line Items
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
flat_id UUID NOT NULL,
resident_id UUID NOT NULL,
billing_period DATE NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('draft', 'issued', 'partially_paid', 'paid', 'overdue', 'written_off')
),
total_paise BIGINT NOT NULL CHECK (total_paise >= 0),
paid_paise BIGINT NOT NULL DEFAULT 0 CHECK (paid_paise >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One invoice per flat per billing period per tenant, forever. This is
-- the real idempotency guarantee for the billing run, not a check in
-- application code.
UNIQUE (tenant_id, flat_id, billing_period)
);
CREATE INDEX invoices_tenant_status_idx ON invoices (tenant_id, status);
CREATE TABLE invoice_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices (id) ON DELETE CASCADE,
charge_type_id UUID NOT NULL REFERENCES charge_types (id),
description TEXT NOT NULL,
amount_paise BIGINT NOT NULL CHECK (amount_paise >= 0)
);
CREATE INDEX invoice_line_items_invoice_id_idx ON invoice_line_items (invoice_id);flat_id and resident_id are bare UUIDs with no foreign keys — they reference rows in flat-service's and resident-service's own databases, which this service cannot and should not join against directly.
DROP TABLE IF EXISTS invoice_line_items;
DROP TABLE IF EXISTS invoices;A Pure Function That Generates an Invoice
Everything so far has been schema and repositories — necessary, but not the interesting part. The interesting part is that turning a flat's area and a tenant's active charges into an invoice is a pure function: no database call, no network call, no time.Now(). Given the same inputs, it produces byte-identical output every time, which is what makes a billing run's correctness testable without ever touching Postgres.
package domain
import (
"time"
"github.com/google/uuid"
)
type InvoiceStatus string
const (
InvoiceStatusDraft InvoiceStatus = "draft"
InvoiceStatusIssued InvoiceStatus = "issued"
InvoiceStatusPartiallyPaid InvoiceStatus = "partially_paid"
InvoiceStatusPaid InvoiceStatus = "paid"
InvoiceStatusOverdue InvoiceStatus = "overdue"
InvoiceStatusWrittenOff InvoiceStatus = "written_off"
)
// legalTransitions enumerates every InvoiceStatus a ledger event is
// allowed to move an invoice to from its current status. It is consulted
// by CanTransition and is the single source of truth for the state
// machine — the lesson on Payment Status builds directly on this map, and
// the Monitoring lesson's reconciliation job re-derives status using the
// same rules rather than duplicating them.
var legalTransitions = map[InvoiceStatus][]InvoiceStatus{
InvoiceStatusDraft: {InvoiceStatusIssued},
InvoiceStatusIssued: {InvoiceStatusPartiallyPaid, InvoiceStatusPaid, InvoiceStatusOverdue, InvoiceStatusWrittenOff},
InvoiceStatusPartiallyPaid: {InvoiceStatusPaid, InvoiceStatusOverdue, InvoiceStatusWrittenOff},
InvoiceStatusOverdue: {InvoiceStatusPartiallyPaid, InvoiceStatusPaid, InvoiceStatusWrittenOff},
InvoiceStatusPaid: {},
InvoiceStatusWrittenOff: {},
}
func CanTransition(from, to InvoiceStatus) bool {
for _, allowed := range legalTransitions[from] {
if allowed == to {
return true
}
}
return false
}
type Invoice struct {
ID uuid.UUID
TenantID uuid.UUID
FlatID uuid.UUID
ResidentID uuid.UUID
BillingPeriod time.Time
Status InvoiceStatus
TotalPaise Paise
PaidPaise Paise
}
type InvoiceLineItem struct {
ID uuid.UUID
InvoiceID uuid.UUID
ChargeTypeID uuid.UUID
Description string
AmountPaise Paise
}Paid and WrittenOff map to an empty slice rather than being absent from the map — CanTransition returns false for any 'from' state with no listed destinations, which correctly makes both terminal.
package service
import (
"time"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
// GenerateMonthlyInvoice is a pure function: no I/O, no clock reads, no
// randomness. Every value it needs — including "now" in the form of
// billingPeriod — is passed in by the caller. Two calls with identical
// arguments always produce identical results, which is what makes this
// function unit-testable without Testcontainers and what makes a
// mid-month rerun of the billing job produce the same invoice it would
// have produced the first time, before the idempotent-insert guard in the
// next lesson even gets involved.
func GenerateMonthlyInvoice(
tenantID, flatID, residentID uuid.UUID,
billingPeriod time.Time,
flatAreaSqft float64,
charges []domain.ActiveCharge,
) (domain.Invoice, []domain.InvoiceLineItem) {
invoiceID := uuid.New()
var total domain.Paise
lineItems := make([]domain.InvoiceLineItem, 0, len(charges))
for _, charge := range charges {
var amount domain.Paise
switch charge.RateBasis {
case domain.RateBasisPerSqft:
amount = domain.ProrateBySqft(charge.RatePaise, flatAreaSqft)
default:
amount = charge.RatePaise
}
lineItems = append(lineItems, domain.InvoiceLineItem{
ID: uuid.New(),
InvoiceID: invoiceID,
ChargeTypeID: charge.ChargeTypeID,
Description: charge.ChargeName,
AmountPaise: amount,
})
total = total.Add(amount)
}
invoice := domain.Invoice{
ID: invoiceID,
TenantID: tenantID,
FlatID: flatID,
ResidentID: residentID,
BillingPeriod: billingPeriod,
Status: domain.InvoiceStatusIssued,
TotalPaise: total,
}
return invoice, lineItems
}Invoices are generated directly at InvoiceStatusIssued rather than Draft — this service has no manual review step between generation and issuing, a deliberate scope decision revisited in this lesson's exercise.
GenerateMonthlyInvoice is deterministic in every field except the generated IDs, which must be unique per call by design — two different flats billed in the same run cannot share an invoice ID. The local verification below diffs everything except the id fields for exactly this reason.
Diffing the Deterministic Invoice Projection
A standalone command builds two identical inputs, calls GenerateMonthlyInvoice on each, and prints both results as JSON. Piping through jq to strip the non-deterministic id fields and diffing the rest is a direct, executable demonstration that the function has no hidden state.
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
"github.com/hoa-platform/backend/services/maintenance-service/internal/service"
)
// gen-preview exists purely to demonstrate GenerateMonthlyInvoice's
// determinism from the command line — it is never deployed and has no
// place in production tooling.
func main() {
tenantID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
flatID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
residentID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
billingPeriod := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
charges := []domain.ActiveCharge{
{ChargeTypeID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), ChargeName: "General Maintenance", RateBasis: domain.RateBasisPerSqft, RatePaise: 250},
{ChargeTypeID: uuid.MustParse("55555555-5555-5555-5555-555555555555"), ChargeName: "Sinking Fund", RateBasis: domain.RateBasisFlat, RatePaise: 50000},
}
invoice, lineItems := service.GenerateMonthlyInvoice(tenantID, flatID, residentID, billingPeriod, 950.5, charges)
out := struct {
Total string
LineItems []struct {
Description string
Amount string
}
}{Total: invoice.TotalPaise.String()}
for _, li := range lineItems {
out.LineItems = append(out.LineItems, struct {
Description string
Amount string
}{Description: li.Description, Amount: li.AmountPaise.String()})
}
if err := json.NewEncoder(os.Stdout).Encode(out); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}The preview binary prints only the deterministic-by-value fields (rendered amounts, descriptions) and deliberately omits the randomly generated IDs, so the diff below compares exactly the parts that must be identical.
Applied exercise
Add a per-flat charge override
Some flats negotiate a different rate for one charge type (a ground-floor unit paying a reduced parking charge, say) without changing the tenant-wide rate everyone else pays.
- Add a domain.ChargeOverride{FlatID, ChargeTypeID, RatePaise} type.
- Change GenerateMonthlyInvoice's signature to accept an additional []domain.ChargeOverride parameter, and use an override's rate in place of the tenant-wide ActiveCharge rate when one exists for the flat and charge type.
- Keep the function pure — no new I/O, only a new parameter.
- Write a table-driven test with at least three cases: no overrides (existing behavior unchanged), one override present, and an override for a charge type the flat isn't actually billed for (must be silently ignored, not injected as a new line item).
Deliverable
An updated GenerateMonthlyInvoice signature, the ChargeOverride type, and a passing table-driven test covering all three cases above.
Completion checks
- go test ./services/maintenance-service/internal/service/... -run TestGenerateMonthlyInvoice passes.
- An override for a charge type absent from the flat's active charges produces no extra line item.
- Existing callers of GenerateMonthlyInvoice (this lesson's gen-preview) still compile after being updated to pass nil for the new parameter.
Why does charge rate history live in a separate charge_versions table rather than as a mutable rate column on charge_types?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.