Stage 7 · Master
Phase 9 — Payment Service
Payment Workflow
Build a payment aggregate that initiates real collections without letting gateway SDK details leak into core society logic.
Payment Collects Money; Maintenance Still Owns the Debt
The maintenance module already answers "what is owed" by producing monthly charges, late fees, and transaction rows inside payment-service. Payment adds a different responsibility: taking one or more unpaid maintenance_charges rows, opening a collection attempt with an external gateway, and then remembering what happened to that attempt over time. That separation matters because the payment aggregate should never recompute dues, waive a charge, or mutate the accounting rules that generated the debt in the first place.
| Concern | Maintenance ledger | Payment aggregate |
|---|---|---|
| Primary question | How much does this flat owe and why? | Did money move for these selected charges? |
| Source of truth | maintenance_charges and maintenance_transactions | payments and payment_charge_items |
| Failure mode | Wrong balance because a charge rule is incorrect | Duplicate collection attempt because initiation is replayed |
| Tenant boundary | Every charge query is scoped by org_id | Every payment query is scoped by org_id and only references charge IDs inside that tenant |
If Payment starts recalculating maintenance balances, you lose the ability to swap gateways independently from billing policy. Payment should orchestrate collection against existing unpaid charges, not become a second accounting subsystem.
The First Payment Migration Stores Attempts, Charge Links, and Request Keys
The initial schema needs three durable facts: the payment row itself, the list of maintenance charges the payment is trying to settle, and the idempotency record that lets the API replay the exact same initiation response without calling the provider twice. The link table is what keeps payment history explainable later: a resident should be able to see which monthly charges were actually settled by one successful collection.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
flat_id UUID NOT NULL,
resident_id UUID NOT NULL,
provider TEXT NOT NULL,
provider_reference TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('created', 'initiated', 'authorized', 'captured', 'failed', 'refunded')),
status_rank SMALLINT NOT NULL DEFAULT 0,
amount_paise BIGINT NOT NULL CHECK (amount_paise > 0),
currency CHAR(3) NOT NULL DEFAULT 'INR',
initiated_at TIMESTAMPTZ,
last_event_at TIMESTAMPTZ,
last_provider_event_id TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (org_id, id),
UNIQUE (provider, provider_reference)
);
CREATE INDEX payments_org_status_created_at_idx
ON payments (org_id, status_rank, created_at DESC);
CREATE TABLE payment_charge_items (
payment_id UUID NOT NULL,
org_id UUID NOT NULL,
charge_id UUID NOT NULL,
-- Snapshot columns: copied from maintenance-service's answer at initiation
-- and never updated afterwards. They are what makes payment history
-- answerable from this database alone.
charge_description TEXT NOT NULL,
charge_billing_month DATE NOT NULL,
charge_amount_paise BIGINT NOT NULL CHECK (charge_amount_paise > 0),
amount_allocated_paise BIGINT NOT NULL CHECK (amount_allocated_paise > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (payment_id, charge_id),
FOREIGN KEY (org_id, payment_id) REFERENCES payments (org_id, id) ON DELETE CASCADE
);
CREATE TABLE payment_idempotency_keys (
org_id UUID NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
payment_id UUID REFERENCES payments(id),
response_code INTEGER,
response_body JSONB,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (org_id, idempotency_key)
);
CREATE INDEX payment_idempotency_expires_at_idx
ON payment_idempotency_keys (expires_at);payment_charge_items keeps the aggregate explainable: one payment may allocate its total across several unpaid maintenance rows without copying those rows into the payment table.
payment-service owns exactly three tables and none of them is maintenance_charges — that table lives in maintenance-service's database, which this service cannot reach with SQL and must never be given credentials for. So org_id, flat_id, resident_id and charge_id are stored as plain UUIDs: logical references the application resolves, not physical ones PostgreSQL enforces. The composite FK inside this database, (org_id, payment_id) to payments, stays because both sides are ours. Validation moves up a layer: the initiation path asks maintenance-service to confirm the charge exists, is unpaid, and belongs to the same org before a payment row is written, and a charge that disappears afterwards surfaces in the nightly settlement reconciliation rather than as a constraint violation nobody is watching for.
DROP TABLE IF EXISTS payment_idempotency_keys;
DROP TABLE IF EXISTS payment_charge_items;
DROP TABLE IF EXISTS payments;The payment aggregate references flat, resident, and charge rows that already carry org_id. If a repository reads charges by ID without org_id, one tenant can accidentally or maliciously initiate a payment against another tenant's unpaid row. That is not just a data leak; it is charging the wrong household.
A Provider Port Keeps Gateway SDK Types Out of the Aggregate
The gateway integration belongs at the edge. The payment package should depend on an interface that speaks the domain's language — initiate a collection, verify a webhook signature, parse a provider event — and nothing in the aggregate should import Razorpay request structs, PayU client types, or Stripe webhook helpers directly. When you later add a second provider for NRI residents or a fallback provider during an outage, you replace an adapter instead of rewriting business logic.
package payment
import (
"context"
"time"
)
type Provider interface {
Name() string
Initiate(ctx context.Context, req InitiateRequest) (InitiateResult, error)
VerifyWebhookSignature(payload []byte, signatureHeader string) bool
ParseEvent(payload []byte) (Event, error)
}
type InitiateRequest struct {
PaymentID string
OrgID string
ResidentID string
AmountPaise int64
Currency string
IdempotencyKey string
ChargeReferences []string
CallbackURL string
}
type InitiateResult struct {
Provider string
ProviderReference string
CheckoutURL string
ExpiresAt time.Time
RawResponse []byte
}
type EventType string
const (
EventInitiated EventType = "initiated"
EventAuthorized EventType = "authorized"
EventCaptured EventType = "captured"
EventFailed EventType = "failed"
EventRefunded EventType = "refunded"
)
type Event struct {
Provider string
EventID string
PaymentReference string
EventType EventType
AmountPaise int64
Currency string
OccurredAt time.Time
SignatureHeader string
}The port is intentionally small. The domain cares that a payment was initiated or that a signed event arrived, not which concrete SDK helper built the HTTP request.
Request-Scoped Idempotency Begins Before the First Gateway Call
A client retry is normal: mobile radios flap, browsers retry after a slow spinner, and residents double-tap a pay button. The API must treat Idempotency-Key plus the request body hash as a durable lookup key. Same tenant, same idempotency key, same body hash means return the stored response. Same tenant, same key, different hash means reject the request with HTTP 409 because the caller is trying to reuse a key for different business intent.
package payment
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrIdempotencyKeyRequired = errors.New("payment: missing idempotency key")
ErrChargeSelectionInvalid = errors.New("payment: charges missing, already paid, or outside tenant scope")
ErrAmountMismatch = errors.New("payment: amount does not match selected charges")
)
type IdempotencyConflictError struct {
ExistingHash string
RequestHash string
}
func (e IdempotencyConflictError) Error() string {
return "payment: idempotency key reused with different payload"
}
type Service struct {
pool *pgxpool.Pool
provider Provider
charges ChargeDirectory
clock func() time.Time
}
func NewService(pool *pgxpool.Pool, provider Provider, charges ChargeDirectory) *Service {
return &Service{
pool: pool,
charges: charges,
provider: provider,
clock: time.Now,
}
}
type InitiatePaymentInput struct {
ChargeIDs []uuid.UUID
AmountPaise int64
Currency string
}
type InitiatePaymentResponse struct {
PaymentID uuid.UUID
Provider string
ProviderReference string
CheckoutURL string
Status string
}
type storedIdempotency struct {
RequestHash string
ResponseBody []byte
}
// ChargeSnapshot is what maintenance-service returns about one unpaid
// charge. payment-service copies it into payment_charge_items and never
// reads maintenance-service's tables again for that payment.
type ChargeSnapshot struct {
ID uuid.UUID
FlatID uuid.UUID
ResidentID uuid.UUID
Description string
BillingMonth time.Time
AmountDuePaise int64
}
// ChargeDirectory is the only way this service learns anything about a
// charge. The consuming package owns the port, so transport adapters and
// tests can satisfy it without sharing maintenance-service's database.
type ChargeDirectory interface {
// ReserveUnpaid asks the owning service to confirm every id is unpaid and
// belongs to orgID, and to hold them for the duration of this attempt.
// It returns ErrChargeSelectionInvalid if any id is missing, already
// settled, or outside the tenant.
ReserveUnpaid(ctx context.Context, orgID uuid.UUID, chargeIDs []uuid.UUID) ([]ChargeSnapshot, error)
}
func (s *Service) InitiatePayment(
ctx context.Context,
orgID uuid.UUID,
residentID uuid.UUID,
idempotencyKey string,
rawBody []byte,
input InitiatePaymentInput,
) (InitiatePaymentResponse, error) {
if idempotencyKey == "" {
return InitiatePaymentResponse{}, ErrIdempotencyKeyRequired
}
if len(input.ChargeIDs) == 0 {
return InitiatePaymentResponse{}, ErrChargeSelectionInvalid
}
requestHash := hashRequest(rawBody)
now := s.clock().UTC()
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return InitiatePaymentResponse{}, err
}
defer tx.Rollback(ctx)
// Serialize the read-then-create sequence for one tenant/key. Without
// this lock, two first-time requests can both miss the lookup and the
// loser returns a unique-constraint error instead of the cached response.
lockKey := orgID.String() + ":" + idempotencyKey
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", lockKey); err != nil {
return InitiatePaymentResponse{}, err
}
existing, found, err := s.lookupIdempotency(ctx, tx, orgID, idempotencyKey, now)
if err != nil {
return InitiatePaymentResponse{}, err
}
if found {
if existing.RequestHash != requestHash {
return InitiatePaymentResponse{}, IdempotencyConflictError{
ExistingHash: existing.RequestHash,
RequestHash: requestHash,
}
}
var cached InitiatePaymentResponse
if err := json.Unmarshal(existing.ResponseBody, &cached); err != nil {
return InitiatePaymentResponse{}, err
}
return cached, nil
}
if _, err := tx.Exec(ctx,
`INSERT INTO payment_idempotency_keys (org_id, idempotency_key, request_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
orgID,
idempotencyKey,
requestHash,
now.Add(24*time.Hour),
); err != nil {
return InitiatePaymentResponse{}, err
}
// maintenance_charges lives in maintenance-service's database. There is no
// SELECT ... FOR UPDATE available across that boundary, so the reservation
// is a request, and the answer it returns is the last time this service
// needs to ask.
charges, err := s.charges.ReserveUnpaid(ctx, orgID, input.ChargeIDs)
if err != nil {
return InitiatePaymentResponse{}, err
}
var total int64
for _, charge := range charges {
total += charge.AmountDuePaise
}
if len(charges) != len(input.ChargeIDs) {
return InitiatePaymentResponse{}, ErrChargeSelectionInvalid
}
if total != input.AmountPaise {
return InitiatePaymentResponse{}, ErrAmountMismatch
}
flatID := charges[0].FlatID
for _, charge := range charges {
if charge.ResidentID != residentID || charge.FlatID != flatID {
return InitiatePaymentResponse{}, ErrChargeSelectionInvalid
}
}
paymentID := uuid.New()
initiateResult, err := s.provider.Initiate(ctx, InitiateRequest{
PaymentID: paymentID.String(),
OrgID: orgID.String(),
ResidentID: residentID.String(),
AmountPaise: input.AmountPaise,
Currency: input.Currency,
IdempotencyKey: idempotencyKey,
ChargeReferences: chargeIDsAsStrings(input.ChargeIDs),
CallbackURL: fmt.Sprintf("/payments/webhooks/%s", s.provider.Name()),
})
if err != nil {
return InitiatePaymentResponse{}, err
}
if _, err := tx.Exec(ctx,
`INSERT INTO payments (
id, org_id, flat_id, resident_id, provider, provider_reference,
status, status_rank, amount_paise, currency, initiated_at, metadata
) VALUES ($1, $2, $3, $4, $5, $6, 'initiated', 1, $7, $8, $9, $10)`,
paymentID,
orgID,
flatID,
residentID,
initiateResult.Provider,
initiateResult.ProviderReference,
input.AmountPaise,
input.Currency,
now,
map[string]any{"provider_response": string(initiateResult.RawResponse)},
); err != nil {
return InitiatePaymentResponse{}, err
}
for _, charge := range charges {
if _, err := tx.Exec(ctx,
`INSERT INTO payment_charge_items (
payment_id, org_id, charge_id,
charge_description, charge_billing_month, charge_amount_paise,
amount_allocated_paise
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
paymentID,
orgID,
charge.ID,
charge.Description,
charge.BillingMonth,
charge.AmountDuePaise,
charge.AmountDuePaise,
); err != nil {
return InitiatePaymentResponse{}, err
}
}
response := InitiatePaymentResponse{
PaymentID: paymentID,
Provider: initiateResult.Provider,
ProviderReference: initiateResult.ProviderReference,
CheckoutURL: initiateResult.CheckoutURL,
Status: "initiated",
}
responseBody, err := json.Marshal(response)
if err != nil {
return InitiatePaymentResponse{}, err
}
if _, err := tx.Exec(ctx,
`UPDATE payment_idempotency_keys
SET payment_id = $3,
response_code = $4,
response_body = $5,
updated_at = $6
WHERE org_id = $1 AND idempotency_key = $2`,
orgID,
idempotencyKey,
paymentID,
201,
responseBody,
now,
); err != nil {
return InitiatePaymentResponse{}, err
}
if err := tx.Commit(ctx); err != nil {
return InitiatePaymentResponse{}, err
}
return response, nil
}
func (s *Service) lookupIdempotency(
ctx context.Context,
tx pgx.Tx,
orgID uuid.UUID,
idempotencyKey string,
now time.Time,
) (storedIdempotency, bool, error) {
var row storedIdempotency
err := tx.QueryRow(ctx,
`SELECT request_hash, response_body
FROM payment_idempotency_keys
WHERE org_id = $1
AND idempotency_key = $2
AND expires_at > $3
FOR UPDATE`,
orgID,
idempotencyKey,
now,
).Scan(&row.RequestHash, &row.ResponseBody)
if errors.Is(err, pgx.ErrNoRows) {
return storedIdempotency{}, false, nil
}
if err != nil {
return storedIdempotency{}, false, err
}
return row, true, nil
}
func hashRequest(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func chargeIDsAsStrings(ids []uuid.UUID) []string {
result := make([]string, 0, len(ids))
for _, id := range ids {
result = append(result, id.String())
}
return result
}The important shape is transaction first, provider call once, durable response cache last. Same key plus same hash returns the prior JSON payload; same key plus different hash fails as a business conflict instead of silently opening a second charge attempt.
{
"error": "idempotency_key_reused_with_different_payload",
"message": "Idempotency-Key already exists with a different request body",
"existing_request_hash": "d8e8fca2dc0f896fd7cb4cb0031ba249",
"request_hash": "4a7d1ed414474e4033ac29ccb8653d9b"
}Return HTTP 409 Conflict here. A 200 would mask a client bug, and a 500 would suggest the server is unhealthy rather than protecting a business invariant.
Replay the Same Initiation Request and Inspect the Stored Result
The first local proof should be boring: run the service, submit a payment initiation, then submit the identical request again with the same key. The second response should have the same payment_id and provider_reference, and your provider fake or sandbox logs should show exactly one outbound initiation call. A second concrete failure mode to test is the same key with a different amount — that must stop at 409 before the provider is touched.
- Failure mode: same key reused with a different amount or charge set must be rejected, not treated as a legitimate retry.
- Security concern: scope both the idempotency lookup and the charge lookup by org_id so one tenant cannot collide with or settle another tenant's charges.
- Verification target: two identical initiation requests should produce one payment row, one provider call, and one reusable cached response.
Applied exercise
Expire idempotency keys without creating a duplicate-charge blind spot
A resident disputes a duplicate maintenance debit that happened 32 hours after their first attempt because the app generated a fresh payment request after the 24-hour idempotency window had already expired.
- Extend payment_idempotency_keys handling so expired rows are ignored for normal retries but still searchable for operator investigations.
- Add service logic that returns a support-friendly duplicate warning when a new request targets the same unpaid charge set within a configurable dispute lookback window.
- Decide whether the dispute lookback should compare charge IDs alone or charge IDs plus amount and justify the trade-off.
- Document the HTTP response your API should return when the old key is expired but the new request looks suspiciously identical.
Deliverable
A design note and code patch outline showing how 24-hour retry semantics and longer duplicate-charge investigations can coexist without weakening the primary idempotency guarantee.
Completion checks
- The main initiation path still returns the cached response only for non-expired idempotency rows.
- Suspicious duplicates outside the retry window do not automatically re-open the old response path.
- Your proposal keeps org_id in every lookup used for dispute review.
Why does the payment package depend on a Provider interface instead of importing a concrete gateway SDK directly?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.