Stage 7 · Master
Phase 9 — Payment Service
Payment History
Expose resident and admin history views while reconciling your ledger against nightly settlement data without leaking tenant existence.
Payment History Must Explain Which Charges Were Settled
A bare list of payment rows is not useful to a resident asking why a debit happened — the answer has to name the dues, penalties, or adjustments the debit covered. The obvious implementation joins back to maintenance_charges, and it is unavailable: that table belongs to another service's database. This is why the line-item rows written at initiation carry a snapshot of each charge's description, billing month, and amount. History reads three payment-owned tables and nothing else.
User Service already owns keyset pagination: the seek predicate, the signed opaque cursor, and the reasons OFFSET degrades. Payment history reuses that decision instead of restating it — the only new wrinkle here is that the sort key is (created_at, id) on a joined result set, so the seek tuple has to compare both columns together.
package payment
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrHistoryNotFound = errors.New("payment history: not found")
ErrHistoryForbidden = errors.New("payment history: forbidden")
)
// FlatDirectory and ResidentDirectory are the read contracts this service
// consumes instead of joining tables it does not own. Both are declared in
// the consuming package and satisfied by HTTP clients in the composition root.
type FlatDirectory interface {
Exists(ctx context.Context, orgID, flatID uuid.UUID) (bool, error)
}
type ResidentDirectory interface {
IsActiveResidentOfFlat(ctx context.Context, orgID, userID, flatID uuid.UUID) (bool, error)
}
type HistoryService struct {
pool *pgxpool.Pool
flats FlatDirectory
residents ResidentDirectory
}
type HistoryItem struct {
PaymentID uuid.UUID
FlatID uuid.UUID
ChargeID uuid.UUID
BillingMonth time.Time
PaymentStatus string
PaymentAmount int64
AllocatedAmount int64
Provider string
ProviderReference string
CreatedAt time.Time
}
type HistoryCursor struct {
CreatedAt time.Time
PaymentID uuid.UUID
}
func (c HistoryCursor) isZero() bool { return c.PaymentID == uuid.Nil }
// seekArgs renders the cursor as two query arguments. A zero cursor becomes
// (NULL, NULL) so the first page and every later page run the same SQL. The
// opaque signed encoding of this cursor is the one User Service already built
// in Phase 3 — payments reuse that encoder rather than inventing a second one.
func (c HistoryCursor) seekArgs() (any, any) {
if c.isZero() {
return nil, nil
}
return c.CreatedAt, c.PaymentID
}
type HistoryPage struct {
Items []HistoryItem
NextCursor HistoryCursor
HasMore bool
}
// finish pages by payment, never by joined row. One payment settles many
// charges, so a row-counted LIMIT would cut a payment in half and the next
// seek would skip its remaining allocations forever. The page query asks for
// limit+1 *payments*; this drops the probe payment's rows entirely and takes
// the cursor from the last payment fully included.
func finish(items []HistoryItem, limit int) HistoryPage {
var order []uuid.UUID
seen := make(map[uuid.UUID]struct{}, limit+1)
for _, item := range items {
if _, ok := seen[item.PaymentID]; ok {
continue
}
seen[item.PaymentID] = struct{}{}
order = append(order, item.PaymentID)
}
page := HistoryPage{Items: items}
if len(order) > limit {
probe := order[limit]
kept := items[:0:0]
for _, item := range items {
if item.PaymentID == probe {
continue
}
kept = append(kept, item)
}
page.Items = kept
page.HasMore = true
order = order[:limit]
}
if len(order) > 0 {
last := page.Items[len(page.Items)-1]
page.NextCursor = HistoryCursor{CreatedAt: last.CreatedAt, PaymentID: last.PaymentID}
}
return page
}
func NewHistoryService(pool *pgxpool.Pool, flats FlatDirectory, residents ResidentDirectory) *HistoryService {
return &HistoryService{pool: pool, flats: flats, residents: residents}
}
func (s *HistoryService) ListFlatPayments(
ctx context.Context,
orgID uuid.UUID,
actorUserID uuid.UUID,
role string,
flatID uuid.UUID,
limit int,
after HistoryCursor,
) (HistoryPage, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
// Neither flats nor residents live in this database. Both questions go to
// the services that own the answer, and both failure shapes collapse into
// ErrHistoryNotFound so a caller cannot probe for existence.
flatExists, err := s.flats.Exists(ctx, orgID, flatID)
if err != nil {
return HistoryPage{}, err
}
if !flatExists {
return HistoryPage{}, ErrHistoryNotFound
}
switch role {
case "RESIDENT":
residentOwnsFlat, err := s.residents.IsActiveResidentOfFlat(ctx, orgID, actorUserID, flatID)
if err != nil {
return HistoryPage{}, err
}
if !residentOwnsFlat {
return HistoryPage{}, ErrHistoryNotFound
}
case "ORG_ADMIN":
default:
return HistoryPage{}, ErrHistoryForbidden
}
seekAt, seekID := after.seekArgs()
rows, err := s.pool.Query(ctx,
`WITH page AS (
SELECT p.id, p.created_at
FROM payments p
WHERE p.org_id = $1
AND p.flat_id = $2
AND ($3::timestamptz IS NULL OR (p.created_at, p.id) < ($3, $4::uuid))
ORDER BY p.created_at DESC, p.id DESC
LIMIT $5
)
SELECT p.id, p.flat_id, pci.charge_id, pci.charge_billing_month, p.status,
p.amount_paise, pci.amount_allocated_paise, p.provider,
p.provider_reference, p.created_at
FROM page
JOIN payments p
ON p.id = page.id AND p.org_id = $1
JOIN payment_charge_items pci
ON pci.org_id = p.org_id AND pci.payment_id = p.id
ORDER BY p.created_at DESC, p.id DESC, pci.charge_id ASC`,
orgID,
flatID,
seekAt,
seekID,
limit+1,
)
if err != nil {
return HistoryPage{}, err
}
defer rows.Close()
items := make([]HistoryItem, 0, limit)
for rows.Next() {
var item HistoryItem
if err := rows.Scan(
&item.PaymentID,
&item.FlatID,
&item.ChargeID,
&item.BillingMonth,
&item.PaymentStatus,
&item.PaymentAmount,
&item.AllocatedAmount,
&item.Provider,
&item.ProviderReference,
&item.CreatedAt,
); err != nil {
return HistoryPage{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return HistoryPage{}, err
}
return finish(items, limit), nil
}
func (s *HistoryService) ListOrgPayments(
ctx context.Context,
orgID uuid.UUID,
role string,
limit int,
after HistoryCursor,
) (HistoryPage, error) {
if role != "ORG_ADMIN" {
return HistoryPage{}, ErrHistoryForbidden
}
if limit <= 0 || limit > 100 {
limit = 20
}
seekAt, seekID := after.seekArgs()
rows, err := s.pool.Query(ctx,
`SELECT p.id, p.flat_id, p.id, p.created_at, p.status,
p.amount_paise, p.amount_paise, p.provider,
p.provider_reference, p.created_at
FROM payments p
WHERE p.org_id = $1
AND ($2::timestamptz IS NULL OR (p.created_at, p.id) < ($2, $3::uuid))
ORDER BY p.created_at DESC, p.id DESC
LIMIT $4`,
orgID,
seekAt,
seekID,
limit+1,
)
if err != nil {
return HistoryPage{}, err
}
defer rows.Close()
items := make([]HistoryItem, 0, limit)
for rows.Next() {
var item HistoryItem
if err := rows.Scan(
&item.PaymentID,
&item.FlatID,
&item.ChargeID,
&item.BillingMonth,
&item.PaymentStatus,
&item.PaymentAmount,
&item.AllocatedAmount,
&item.Provider,
&item.ProviderReference,
&item.CreatedAt,
); err != nil {
return HistoryPage{}, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return HistoryPage{}, err
}
return finish(items, limit), nil
}Returning ErrHistoryNotFound for a resident who asks for another flat keeps cross-tenant and cross-flat existence from leaking through the API surface.
Resident History Queries Must Be Flat-Scoped and Tenant-Scoped
A resident history endpoint such as GET /flats/{flatID}/payments should only show the flat attached to that resident inside the current org. If the JWT org_id is different from the flat's org_id, return 404 rather than 403; a 403 tells an attacker the flat exists somewhere else, while 404 keeps tenant existence opaque. By contrast, GET /payments is org-wide and should be admin-only because staff can otherwise infer collection patterns across households they do not administer.
| Caller | Endpoint | Required scope | When to return 404 |
|---|---|---|---|
| RESIDENT | /flats/{flatID}/payments | org_id from JWT plus resident->flat ownership inside that org | When the flat is outside the tenant or not owned by that resident |
| ORG_ADMIN | /payments | org_id from JWT across all payment rows in that org | When the org itself has no matching page of results |
| Any other role | /payments | No access | Never; this should be a true authorization failure |
Nightly Reconciliation Classifies Drift Before Finance Escalates It
Webhooks are near-real-time, not infallible. Maintenance Service already runs a reconciler for ledger drift between charges and payments; this one asks a different question — does our ledger agree with the *provider's* settlement export, joined on provider_reference? That comparison catches three classes the internal reconciler cannot see: a local capture missing from settlement, a settlement row missing from the local ledger, and an amount mismatch. Each class means something different operationally, so each should page or notify with a distinct label instead of one generic "reconciliation failed" alert.
package payment
import (
"context"
"encoding/csv"
"fmt"
"io"
"strconv"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type DriftClass string
const (
DriftCapturedMissingFromSettlement DriftClass = "captured_missing_from_settlement"
DriftSettlementWithoutLocalPayment DriftClass = "settlement_without_local_payment"
DriftAmountMismatch DriftClass = "amount_mismatch"
)
type AlertSink interface {
Publish(ctx context.Context, class DriftClass, labels map[string]string) error
}
type settlementRow struct {
ProviderReference string
AmountPaise int64
Currency string
SettledAt time.Time
}
type localSettlementRow struct {
PaymentID uuid.UUID
ProviderReference string
Status Status
AmountPaise int64
}
type Reconciler struct {
pool *pgxpool.Pool
transitions *TransitionStore
alerts AlertSink
clock func() time.Time
}
func NewReconciler(pool *pgxpool.Pool, transitions *TransitionStore, alerts AlertSink) *Reconciler {
return &Reconciler{
pool: pool,
transitions: transitions,
alerts: alerts,
clock: time.Now,
}
}
func (r *Reconciler) Reconcile(ctx context.Context, orgID uuid.UUID, provider string, export io.Reader) error {
settlementRows, err := parseSettlementCSV(export)
if err != nil {
return err
}
localRows, err := r.loadLocalRows(ctx, orgID, provider)
if err != nil {
return err
}
seen := make(map[string]settlementRow, len(settlementRows))
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
for _, row := range settlementRows {
seen[row.ProviderReference] = row
local, ok := localRows[row.ProviderReference]
if !ok {
if err := r.alerts.Publish(ctx, DriftSettlementWithoutLocalPayment, map[string]string{
"org_id": orgID.String(),
"provider": provider,
"provider_reference": row.ProviderReference,
}); err != nil {
return err
}
continue
}
if local.AmountPaise != row.AmountPaise {
if err := r.alerts.Publish(ctx, DriftAmountMismatch, map[string]string{
"org_id": orgID.String(),
"provider": provider,
"provider_reference": row.ProviderReference,
}); err != nil {
return err
}
continue
}
if local.Status != PaymentStatusCaptured && local.Status != PaymentStatusRefunded {
if _, err := r.transitions.Apply(ctx, tx, orgID, local.PaymentID, PaymentStatusCaptured, "reconcile/"+row.ProviderReference); err != nil {
return err
}
}
}
for ref, local := range localRows {
if local.Status == PaymentStatusCaptured {
if _, ok := seen[ref]; !ok {
if err := r.alerts.Publish(ctx, DriftCapturedMissingFromSettlement, map[string]string{
"org_id": orgID.String(),
"provider": provider,
"provider_reference": ref,
}); err != nil {
return err
}
}
}
}
return tx.Commit(ctx)
}
func (r *Reconciler) loadLocalRows(ctx context.Context, orgID uuid.UUID, provider string) (map[string]localSettlementRow, error) {
rows, err := r.pool.Query(ctx,
`SELECT id, provider_reference, status, amount_paise
FROM payments
WHERE org_id = $1
AND provider = $2
AND created_at >= $3`,
orgID,
provider,
r.clock().Add(-48*time.Hour),
)
if err != nil {
return nil, err
}
defer rows.Close()
result := map[string]localSettlementRow{}
for rows.Next() {
var row localSettlementRow
if err := rows.Scan(&row.PaymentID, &row.ProviderReference, &row.Status, &row.AmountPaise); err != nil {
return nil, err
}
result[row.ProviderReference] = row
}
return result, rows.Err()
}
func parseSettlementCSV(source io.Reader) ([]settlementRow, error) {
reader := csv.NewReader(source)
records, err := reader.ReadAll()
if err != nil {
return nil, err
}
if len(records) < 2 {
return nil, fmt.Errorf("payment reconcile: missing settlement rows")
}
rows := make([]settlementRow, 0, len(records)-1)
for _, record := range records[1:] {
amount, err := strconv.ParseInt(record[1], 10, 64)
if err != nil {
return nil, err
}
settledAt, err := time.Parse(time.RFC3339, record[3])
if err != nil {
return nil, err
}
rows = append(rows, settlementRow{
ProviderReference: record[0],
AmountPaise: amount,
Currency: record[2],
SettledAt: settledAt,
})
}
return rows, nil
}Reconciliation is allowed to advance state only through the same guarded transition store from lesson 3. Even finance-driven corrections must respect monotonic rank rules.
| Drift class | What the comparison finds | Likely meaning | Immediate action |
|---|---|---|---|
| captured_missing_from_settlement | Local payment says captured, export has no matching provider_reference | Delayed settlement, settlement export lag, or fraud/chargeback signal | Alert finance and keep watching; do not silently downgrade the payment |
| settlement_without_local_payment | Settlement export has a provider_reference with no local payment row | Missed webhook or broken initiation persistence | Trigger inbox replay or manual import investigation |
| amount_mismatch | Both sides have the row but amounts differ | Partial capture, currency rounding, or provider-side fee confusion | Open an operator review; never auto-rewrite amount_paise |
- Failure mode: returning 403 for another tenant's flat leaks that the flat exists somewhere else; use 404 for the resident-scoped endpoint instead.
- Security concern: org-wide history is admin-only because collection patterns are operationally sensitive even inside the same tenant.
- Verification target: reconciliation should classify missing, orphaned, and amount-mismatch rows differently so alerts carry operational meaning.
Applied exercise
Classify settlement drift from a mixed nightly export
Your local ledger contains order_a=250000 captured, order_b=300000 captured, and order_c=150000 initiated. The provider export contains order_a,250000,INR,2026-08-02T00:10:00Z, order_c,150000,INR,2026-08-02T00:11:00Z, and order_x,190000,INR,2026-08-02T00:12:00Z.
- Classify each reference into one of the three drift classes or explain why it is healthy.
- Describe which row, if any, should be advanced through the guarded transition path from lesson 3.
- Write the alert labels you would emit for the problematic rows.
- Explain why the resident history endpoint must not expose any reconciliation-only fields.
Deliverable
A drift report that maps every reference to a class, recommended follow-up, and whether a payment status correction is allowed.
Completion checks
- order_b is identified as captured_missing_from_settlement.
- order_x is identified as settlement_without_local_payment.
- order_c is recognized as a candidate for guarded capture advancement rather than an amount mismatch.
Why should a resident querying another tenant's flat payment history receive 404 instead of 403?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.