Stage 7 · Master
Phase 8 — Maintenance Service
Payment Status
Recording payments and deriving an invoice's status without ever touching a payment gateway
What This Service Deliberately Does Not Do
There is no Razorpay, Stripe, or UPI integration anywhere in this codebase, and there will not be one in this module. A real deployment would have a separate payment-gateway integration (webhook receiver, reconciliation with the provider's settlement reports) hand off a confirmed payment to this service via the endpoint built below. Scoping the lesson this way keeps the focus on the part every HOA platform needs regardless of which gateway it eventually picks: an accurate, tenant-isolated ledger of what was billed and what has been paid.
A payment record is idempotent by design — a webhook retry, or a gateway that fires the same confirmation twice, must never post the same payment against an invoice twice. And every payment must move the invoice through the legal state machine built in the Monthly Charges lesson (CanTransition), never around it.
CREATE TABLE payment_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
invoice_id UUID NOT NULL REFERENCES invoices (id),
amount_paise BIGINT NOT NULL CHECK (amount_paise > 0),
method TEXT NOT NULL CHECK (method IN ('upi', 'bank_transfer', 'cash', 'cheque')),
idempotency_key TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The real duplicate-submission guard: replaying the same
-- Idempotency-Key for this tenant is a no-op, not a second payment.
UNIQUE (tenant_id, idempotency_key)
);
CREATE INDEX payment_records_invoice_id_idx ON payment_records (invoice_id);idempotency_key is caller-supplied (typically the payment gateway's own transaction ID) rather than generated here — the caller is the one who knows whether two requests represent the same real-world payment.
DROP TABLE IF EXISTS payment_records;Payment Domain Types
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
type PaymentMethod string
const (
PaymentMethodUPI PaymentMethod = "upi"
PaymentMethodBankTransfer PaymentMethod = "bank_transfer"
PaymentMethodCash PaymentMethod = "cash"
PaymentMethodCheque PaymentMethod = "cheque"
)
type PaymentRecord struct {
ID uuid.UUID
TenantID uuid.UUID
InvoiceID uuid.UUID
AmountPaise Paise
Method PaymentMethod
IdempotencyKey string
RecordedAt time.Time
}
var (
ErrPaymentExceedsInvoiceBalance = errors.New("payment: amount exceeds invoice's remaining balance")
ErrIllegalStatusTransition = errors.New("invoice: requested status transition is not legal from the current status")
ErrInvoiceNotFound = errors.New("invoice: not found for this tenant")
ErrIdempotencyKeyRequired = errors.New("payment: Idempotency-Key header is required")
)Recording a Payment Under Row Lock
Two payments for the same invoice arriving concurrently (a resident paying online while a committee member manually records a cash payment) must never both read the same 'balance remaining' and both succeed when only one should. FOR UPDATE on the invoice row serializes the two attempts: whichever transaction commits first determines the balance the second one sees.
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
type PaymentRepository struct {
pool *pgxpool.Pool
}
func NewPaymentRepository(pool *pgxpool.Pool) *PaymentRepository {
return &PaymentRepository{pool: pool}
}
// RecordPayment locks the invoice row, computes the new paid total and
// status, validates both against the invoice's total and the legal
// transition table, and writes the payment record and updated invoice in
// one transaction. If idempotencyKey has already been used for this
// tenant, the unique index rejects the insert and this method returns the
// invoice unchanged with created=false — a safe no-op for a retried request.
func (r *PaymentRepository) RecordPayment(ctx context.Context, tenantID, invoiceID uuid.UUID, amount domain.Paise, method domain.PaymentMethod, idempotencyKey string) (domain.Invoice, bool, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Invoice{}, false, err
}
defer tx.Rollback(ctx)
var inv domain.Invoice
err = tx.QueryRow(ctx, `
SELECT id, tenant_id, flat_id, resident_id, billing_period, status, total_paise, paid_paise
FROM invoices
WHERE id = $1 AND tenant_id = $2
FOR UPDATE`,
invoiceID, tenantID,
).Scan(&inv.ID, &inv.TenantID, &inv.FlatID, &inv.ResidentID, &inv.BillingPeriod, &inv.Status, &inv.TotalPaise, &inv.PaidPaise)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Invoice{}, false, domain.ErrInvoiceNotFound
}
if err != nil {
return domain.Invoice{}, false, err
}
newPaid := inv.PaidPaise.Add(amount)
if newPaid > inv.TotalPaise {
return domain.Invoice{}, false, domain.ErrPaymentExceedsInvoiceBalance
}
newStatus := domain.InvoiceStatusPartiallyPaid
if newPaid == inv.TotalPaise {
newStatus = domain.InvoiceStatusPaid
}
if !domain.CanTransition(inv.Status, newStatus) && inv.Status != newStatus {
return domain.Invoice{}, false, domain.ErrIllegalStatusTransition
}
var paymentID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO payment_records (tenant_id, invoice_id, amount_paise, method, idempotency_key)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING id`,
tenantID, invoiceID, amount, method, idempotencyKey,
).Scan(&paymentID)
if errors.Is(err, pgx.ErrNoRows) {
// Same idempotency key seen before for this tenant: treat as a
// successful no-op and return the invoice exactly as it stood
// before this call, without applying the payment a second time.
return inv, false, tx.Commit(ctx)
}
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return inv, false, tx.Commit(ctx)
}
return domain.Invoice{}, false, err
}
if _, err := tx.Exec(ctx, `UPDATE invoices SET paid_paise = $1, status = $2 WHERE id = $3`, newPaid, newStatus, invoiceID); err != nil {
return domain.Invoice{}, false, err
}
inv.PaidPaise, inv.Status = newPaid, newStatus
if err := tx.Commit(ctx); err != nil {
return domain.Invoice{}, false, err
}
return inv, true, nil
}
// WriteOff marks an invoice as written off if the current status legally
// allows it, guarded by the same FOR UPDATE lock and CanTransition rule
// as a payment.
func (r *PaymentRepository) WriteOff(ctx context.Context, tenantID, invoiceID uuid.UUID) (domain.Invoice, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.Invoice{}, err
}
defer tx.Rollback(ctx)
var status domain.InvoiceStatus
err = tx.QueryRow(ctx, `SELECT status FROM invoices WHERE id = $1 AND tenant_id = $2 FOR UPDATE`, invoiceID, tenantID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Invoice{}, domain.ErrInvoiceNotFound
}
if err != nil {
return domain.Invoice{}, err
}
if !domain.CanTransition(status, domain.InvoiceStatusWrittenOff) {
return domain.Invoice{}, domain.ErrIllegalStatusTransition
}
if _, err := tx.Exec(ctx, `UPDATE invoices SET status = $1 WHERE id = $2`, domain.InvoiceStatusWrittenOff, invoiceID); err != nil {
return domain.Invoice{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.Invoice{}, err
}
inv := domain.Invoice{ID: invoiceID, TenantID: tenantID, Status: domain.InvoiceStatusWrittenOff}
return inv, nil
}The FOR UPDATE lock is acquired before newPaid is even computed — a second concurrent RecordPayment call for the same invoice blocks at the SELECT until the first transaction commits or rolls back, so it always sees the up-to-date paid_paise.
| Attempted transition | Allowed by CanTransition? | Why |
|---|---|---|
| paid → partially_paid | No | Paid is terminal; a refund would need a distinct future workflow, not a backward status move |
| written_off → issued | No | Written-off is terminal; reopening a written-off invoice is a data-correction operation, not a status transition |
| issued → overdue | Yes | The reconciler in the Monitoring lesson performs exactly this transition once a due date passes unpaid |
| draft → paid | No | An invoice must be issued before any payment can be recorded against it |
Handlers
package service
import (
"context"
"github.com/google/uuid"
"github.com/hoa-platform/backend/pkg/tenantctx"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
)
type PaymentRepository interface {
RecordPayment(ctx context.Context, tenantID, invoiceID uuid.UUID, amount domain.Paise, method domain.PaymentMethod, idempotencyKey string) (domain.Invoice, bool, error)
WriteOff(ctx context.Context, tenantID, invoiceID uuid.UUID) (domain.Invoice, error)
}
type InvoiceReader interface {
Get(ctx context.Context, tenantID, invoiceID uuid.UUID) (domain.Invoice, error)
}
type PaymentService struct {
payments PaymentRepository
invoices InvoiceReader
}
func NewPaymentService(payments PaymentRepository, invoices InvoiceReader) *PaymentService {
return &PaymentService{payments: payments, invoices: invoices}
}
func (s *PaymentService) Get(ctx context.Context, invoiceID uuid.UUID) (domain.Invoice, error) {
tenantID := tenantctx.MustFromContext(ctx)
return s.invoices.Get(ctx, tenantID, invoiceID)
}
func (s *PaymentService) RecordPayment(ctx context.Context, invoiceID uuid.UUID, amount domain.Paise, method domain.PaymentMethod, idempotencyKey string) (domain.Invoice, error) {
if idempotencyKey == "" {
return domain.Invoice{}, domain.ErrIdempotencyKeyRequired
}
tenantID := tenantctx.MustFromContext(ctx)
inv, _, err := s.payments.RecordPayment(ctx, tenantID, invoiceID, amount, method, idempotencyKey)
return inv, err
}
func (s *PaymentService) WriteOff(ctx context.Context, invoiceID uuid.UUID) (domain.Invoice, error) {
tenantID := tenantctx.MustFromContext(ctx)
return s.payments.WriteOff(ctx, tenantID, invoiceID)
}package http
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/hoa-platform/backend/services/maintenance-service/internal/domain"
"github.com/hoa-platform/backend/services/maintenance-service/internal/service"
)
type InvoiceHandler struct {
svc *service.PaymentService
}
func NewInvoiceHandler(svc *service.PaymentService) *InvoiceHandler {
return &InvoiceHandler{svc: svc}
}
func (h *InvoiceHandler) Get(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid invoice id"})
return
}
inv, err := h.svc.Get(c.Request.Context(), id)
h.respond(c, inv, err, http.StatusOK)
}
type recordPaymentRequest struct {
AmountPaise int64 `json:"amount_paise" binding:"required"`
Method string `json:"method" binding:"required"`
}
func (h *InvoiceHandler) RecordPayment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid invoice id"})
return
}
idempotencyKey := c.GetHeader("Idempotency-Key")
var req recordPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
inv, err := h.svc.RecordPayment(c.Request.Context(), id, domain.Paise(req.AmountPaise), domain.PaymentMethod(req.Method), idempotencyKey)
h.respond(c, inv, err, http.StatusOK)
}
func (h *InvoiceHandler) WriteOff(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid invoice id"})
return
}
inv, err := h.svc.WriteOff(c.Request.Context(), id)
h.respond(c, inv, err, http.StatusOK)
}
func (h *InvoiceHandler) respond(c *gin.Context, inv domain.Invoice, err error, okStatus int) {
switch {
case err == nil:
c.JSON(okStatus, inv)
case errors.Is(err, domain.ErrInvoiceNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrIdempotencyKeyRequired):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case errors.Is(err, domain.ErrPaymentExceedsInvoiceBalance), errors.Is(err, domain.ErrIllegalStatusTransition):
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
}
}respond is a single small helper shared by all three handlers on this type — the error-to-status mapping only needs to be written and reviewed once.
Reconciling a Deliberately Corrupted Balance
Applied exercise
Reject a payment method the tenant has disabled
Some societies only accept UPI and bank transfer, disallowing cash and cheque to keep an auditable trail. Payment method acceptance should be configurable per tenant rather than hardcoded.
- Add an allowed_payment_methods TEXT[] column to a new tenant settings table (or extend an existing tenant-scoped table if your design already has one), defaulting to all four methods.
- Before calling PaymentRepository.RecordPayment, have PaymentService check the requested method against the tenant's allowed list and return a new domain.ErrPaymentMethodNotAllowed if it is not permitted.
- Map the new error to HTTP 422 in InvoiceHandler.respond.
- Write a unit test using a hand-rolled fake settings source proving a disallowed method is rejected before the fake PaymentRepository is ever called.
Deliverable
A tenant-configurable allowed-payment-methods check enforced in PaymentService before the repository call, its error mapped to 422, and a passing unit test proving the repository is never reached for a disallowed method.
Completion checks
- go test ./services/maintenance-service/... -run TestPaymentService passes.
- A disallowed method returns 422 without any row being written to payment_records.
- The existing idempotency-replay and balance-exceeded verification steps above still behave identically.
Why does maintenance-service never integrate with a payment gateway directly?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.