Stage 7 · Master
Phase 8 — Maintenance Service
Billing
Money as an integer, and the rates it is computed from
Why This Service Never Stores a Float
flat-service models square footage as a float because a small rounding error in area is cosmetic. Money is different: a rounding error in a maintenance bill is a resident being charged the wrong amount, and float64 cannot represent most decimal currency amounts exactly. maintenance-service stores every amount as an int64 count of paise (1 rupee = 100 paise) and never performs float arithmetic on money anywhere in the codebase.
package domain
import (
"fmt"
"math"
)
// Paise is an integer amount of Indian paise (1 rupee = 100 paise). Every
// monetary value in this service — charge rates, invoice totals, line
// items, payments — is a Paise. Nothing in this package or any repository
// built on top of it performs float arithmetic on money.
type Paise int64
// Add returns the sum. It exists mainly so call sites read as money
// arithmetic rather than raw int64 addition, and so a future overflow
// guard has exactly one place to live.
func (p Paise) Add(other Paise) Paise {
return p + other
}
// String renders paise as a rupee amount for display only — never for
// storage, comparison, or further arithmetic.
func (p Paise) String() string {
rupees := int64(p) / 100
remainder := int64(p) % 100
if remainder < 0 {
remainder = -remainder
}
return fmt.Sprintf("Rs.%d.%02d", rupees, remainder)
}
// ProrateBySqft computes a per-sqft charge for a given area, rounding
// half away from zero to the nearest paisa. Rates and areas both come
// from Postgres NUMERIC columns decoded as float64 at the repository
// boundary — this is the one deliberate, contained float computation in
// the service, and it happens exactly once per charge per invoice, never
// repeated or re-derived from a stored float later.
func ProrateBySqft(ratePerSqftPaise Paise, areaSqft float64) Paise {
exact := float64(ratePerSqftPaise) * areaSqft
if exact >= 0 {
return Paise(math.Floor(exact + 0.5))
}
return Paise(-math.Floor(-exact + 0.5))
}ProrateBySqft is the only function in the domain package that touches float64 at all, and its result is immediately an exact Paise integer — nothing downstream ever sees the float.
Charge Types: Fixed Names, Versioned Rates
A charge type is a named category of billing — 'General Maintenance', 'Sinking Fund', 'Parking' — that rarely changes once created. Its rate does change, sometimes yearly at an AGM, which is exactly why rate belongs on a separate, versioned table built in the next lesson. This lesson only creates the stable charge_types row and the rate_basis that decides how the rate applies.
CREATE TABLE charge_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
rate_basis TEXT NOT NULL CHECK (rate_basis IN ('flat_rate', 'per_sqft')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, code)
);
CREATE INDEX charge_types_tenant_id_idx ON charge_types (tenant_id);code is a short machine key ('GEN_MAINT', 'SINKING_FUND') the billing run and admin tooling refer to; name is what a resident sees on an invoice line. Both are tenant-scoped, so two societies can each define their own 'PARKING' code independently.
DROP TABLE IF EXISTS charge_types;Domain Type and Error Mapping
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type RateBasis string
const (
RateBasisFlat RateBasis = "flat_rate"
RateBasisPerSqft RateBasis = "per_sqft"
)
type ChargeType struct {
ID uuid.UUID
TenantID uuid.UUID
Code string
Name string
RateBasis RateBasis
CreatedAt time.Time
}
var (
ErrChargeTypeCodeTaken = errors.New("charge_type: code already exists for this tenant")
ErrChargeTypeNotFound = errors.New("charge_type: not found for this tenant")
ErrInvalidRateBasis = errors.New("charge_type: rate_basis must be flat_rate or per_sqft")
)INSERT INTO charge_types (tenant_id, code, name, rate_basis)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at;
-- Constraint names, not generic CRUD structure, define the translation:
-- charge_types_tenant_id_code_key -> ErrChargeTypeCodeTaken
-- charge_types_rate_basis_check -> ErrInvalidRateBasisFlat Service already taught pgx repository construction, QueryRow, Scan, rows.Close, and tenant-scoped List. Maintenance reuses those mechanics. The new knowledge is translating named financial-policy constraints into stable domain errors instead of copying another complete repository.
Service, Handler, and Router
The service layer is intentionally thin here — charge type creation has no cross-cutting rule beyond what the migration and repository already enforce. It exists as a seam: later lessons add real orchestration (the billing run) without ever calling a repository directly from a handler.
type ChargeTypeRepository interface {
Create(ctx context.Context, ct domain.ChargeType) (domain.ChargeType, error)
List(ctx context.Context, tenantID uuid.UUID) ([]domain.ChargeType, error)
}
func (s *ChargeTypeService) Create(ctx context.Context, code, name string, basis domain.RateBasis) (domain.ChargeType, error) {
tenantID := tenantctx.MustFromContext(ctx)
if basis != domain.RateBasisFlat && basis != domain.RateBasisPerSqft {
return domain.ChargeType{}, domain.ErrInvalidRateBasis
}
return s.repo.Create(ctx, domain.ChargeType{TenantID: tenantID, Code: code, Name: name, RateBasis: basis})
}Flat Service's validation lesson owns the request-validation-plus-database-constraint pattern; this service adds one new invariant, rate_basis, because it changes how money is computed.
type createChargeTypeRequest struct {
Code string `json:"code" binding:"required"`
Name string `json:"name" binding:"required"`
RateBasis string `json:"rate_basis" binding:"required"`
}
func (h *ChargeTypeHandler) Create(c *gin.Context) {
// Binding and tenant middleware match the CRUD shape from Organization Service.
ct, err := h.svc.Create(c.Request.Context(), req.Code, req.Name, domain.RateBasis(req.RateBasis))
switch {
case err == nil:
c.JSON(http.StatusCreated, ct)
case errors.Is(err, domain.ErrChargeTypeCodeTaken):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrInvalidRateBasis):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
default:
c.Error(err)
}
}chargeTypeRepo := postgres.NewChargeTypeRepository(pool)
chargeTypeSvc := service.NewChargeTypeService(chargeTypeRepo)
chargeTypeHandler := handler.NewChargeTypeHandler(chargeTypeSvc)
chargeTypes := router.Group("/charge-types")
chargeTypes.POST("", chargeTypeHandler.Create)
chargeTypes.GET("", chargeTypeHandler.List)The pool, router, middleware, and server startup are the same composition-root shape taught in Organization Service; Maintenance's addition is the charge-type wiring and port 8083.
Checking Exact Money Arithmetic
Flat Service's validation lesson owns the two-enforcement-layer pattern; Maintenance only introduces the financial invariant that rate_basis must be either flat_rate or per_sqft.
Applied exercise
Add a per-society charge type limit
A tenant should not be able to define an unbounded number of charge types — beyond roughly ten, it is almost always a data-entry mistake rather than a real new charge category.
- Add a domain.ErrTooManyChargeTypes sentinel error.
- In ChargeTypeService.Create, count the tenant's existing charge types via a new repository method before inserting, and reject with the sentinel once the count reaches 10.
- Map the sentinel to HTTP 422 in the handler.
- Write a table-driven unit test using a hand-rolled fake ChargeTypeRepository that pre-seeds 10 rows and asserts the 11th Create call is rejected without ever calling the fake's Create method.
Deliverable
An updated ChargeTypeService with a count guard, its sentinel error, the 422 mapping, and a passing unit test proving the fake's Create is never invoked on the 11th attempt.
Completion checks
- The count check happens before Create is called on the repository, not after.
- go test ./services/maintenance-service/... -run TestChargeTypeService passes.
- The existing charge-types test suite from this lesson still passes unchanged.
Why does maintenance-service store money as Paise (int64) instead of a decimal or float type?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.