School policy (non-negotiable)

  • A fee is officially paid only when status is APPROVED and a receipt exists in the system.
  • Parents must submit a claim within 7 calendar days of payment (configurable per school).
  • Claims older than the policy window move to EXPIRED unless bursar extends.
  • Balance on the parent portal shows outstanding until approval — not when MoMo is sent.

End-to-end flow

PARENT SYSTEM (fee-service) BURSAR | | | |-- Pays cash/MoMo/bank ------------>| | | (outside system) | | | | | |-- Opens portal "I have paid" ----->| | |-- Fills claim form -------------->| status: PENDING_VERIFICATION | | |-- SMS/email alert ---------->| New claim in queue | | | | |<-- Opens claim --------------| | | status: UNDER_REVIEW | | | | | |<-- Approves + links invoice -| | | status: APPROVED | | |-- PaymentReceived event ---->| | |-- Receipt PDF + SMS -------->| Parent |<-- Receipt in document library ----| |

Direct in-office recording (bursar enters while parent waits) remains supported and skips the claim queue — it creates an APPROVED payment immediately with source BURSAR_DIRECT.

Payment claim statuses

StatusWho sees itMeaningNext actions
DRAFT Parent only Form started, not submitted Parent completes and submits
PENDING_VERIFICATION Parent + Bursar Claim submitted, awaiting bursar Bursar opens → UNDER_REVIEW
UNDER_REVIEW Parent + Bursar Bursar is actively checking Approve, reject, or request info
NEEDS_INFO Parent + Bursar Bursar needs more details Parent updates claim → back to PENDING_VERIFICATION
APPROVED Parent + Bursar + Director Verified; receipt issued; balance reduced Terminal (unless void by admin)
REJECTED Parent + Bursar Payment not accepted (with reason) Parent may resubmit new claim if error
DUPLICATE_SUSPECTED Bursar Same transaction ID exists Bursar merges or rejects duplicate
CANCELLED Parent Parent withdrew before review Terminal
EXPIRED Parent + Bursar Not actioned within policy window Bursar may reopen with override

Payment record status (after approval)

Separate from claim status — the financial payments table:

  • CONFIRMED — approved claim or direct bursar entry
  • VOIDED — director/bursar void with mandatory reason (audit logged)
  • REFUNDED — partial or full refund linked to original payment

Parent claim form — fields

FieldRequiredValidation
StudentYesMust be child linked to logged-in guardian
Invoice / termYesOpen invoice for selected student; partial pay allowed
Amount (UGX)Yes> 0; warn if > outstanding balance
Payment methodYesCASH, MTN_MOMO, AIRTEL_MOMO, BANK_TRANSFER, BANK_AGENT, OTHER
Transaction IDConditionalRequired for MoMo/bank; optional for cash (use office slip ref)
Phone used / payer phoneConditionalRequired for MoMo; validates E.164 Uganda
Agent numberConditionalRequired when method = BANK_AGENT
Bank nameConditionalRequired for BANK_TRANSFER
Payment dateYesNot in future; not older than school max (default 90 days)
Notes / reasonNoFree text — e.g. "Term 2 boarding balance"
Proof attachmentRecommendedPNG/JPG/PDF max 5 MB → file-service
UI sample — Parent payment claim Open full screen →

Bursar verification queue

Bursar dashboard shows filters: Pending, Under review, Needs info, Duplicates, All today.

On approve, bursar must:

  1. Confirm amount matches MoMo/bank/cash record
  2. Select invoice line(s) to apply payment (auto-suggest oldest open invoice)
  3. Optionally adjust approved amount (with reason if different from claim)
  4. Click Approve & issue receipt — atomic transaction

System generates receipt number, publishes PaymentReceived, posts to finance-service ledger (Phase 2 basic), notifies parent.

UI sample — Bursar verification queue Open full screen →

Edge cases & business rules

ScenarioSystem behaviour
Partial paymentReduces invoice balance; student stays on defaulter list until fully paid
OverpaymentWarn parent on submit; bursar can approve excess → credit on fee account for next term
One MoMo for two childrenParent submits two claims with same transaction ID + note; bursar splits amounts; second flagged DUPLICATE_SUSPECTED until linked
Duplicate transaction IDSecond claim auto-flagged DUPLICATE_SUSPECTED; bursar rejects or confirms split payment
Wrong student selectedBursar rejects with reason; parent resubmits
Cash — no transaction IDParent enters office_reference or bursar-generated slip number
Payment before invoice existsBlock claim — show "No open invoice for this term"
Guardian not linked to student403 — cannot claim for unlinked child
Two bursars open same claimOptimistic lock on version column; second gets conflict message
Bursar approves wrong amountVoid payment (director approval if > threshold); re-open claim
Parent uploads fake screenshotBursar rejects; repeated abuse → admin flag on account
Claim not reviewed in 7 daysAuto EXPIRED; SMS reminder to bursar at day 3 and 6
Sibling discount / bursaryApplied on invoice before claim; parent pays net amount
Multi-campusClaim scoped to student's campus; bursar role limited by campus

Domain events & notifications

EventTriggerConsumers
PaymentClaimSubmittedParent submitsnotification-service → bursar SMS/email; report-service metrics
PaymentClaimNeedsInfoBursar requests infonotification-service → parent
PaymentReceivedClaim approved or direct entryfile-service (receipt PDF), notice-service (document library), notification-service (parent SMS), finance-service (ledger)
PaymentClaimRejectedBursar rejectsnotification-service → parent with reason
PaymentVoidedAdmin voidfinance-service reversal, parent notice

API endpoints (fee-service via gateway)

# Parent portal
GET    /api/v1/portal/fee-accounts/{studentId}          # balance, open invoices
POST   /api/v1/portal/payment-claims                    # submit claim
GET    /api/v1/portal/payment-claims                  # list own claims
GET    /api/v1/portal/payment-claims/{id}             # detail + status history
PUT    /api/v1/portal/payment-claims/{id}             # update when NEEDS_INFO
DELETE /api/v1/portal/payment-claims/{id}             # cancel if PENDING only
POST   /api/v1/portal/payment-claims/{id}/attachments # proof upload

# Bursar / admin
GET    /api/v1/admin/payment-claims?status=&campusId= # verification queue
GET    /api/v1/admin/payment-claims/{id}              # full detail + audit
POST   /api/v1/admin/payment-claims/{id}/start-review   # → UNDER_REVIEW
POST   /api/v1/admin/payment-claims/{id}/needs-info   # body: { message }
POST   /api/v1/admin/payment-claims/{id}/approve        # body: { amount, invoiceIds[] }
POST   /api/v1/admin/payment-claims/{id}/reject         # body: { reason }
POST   /api/v1/admin/payments                           # direct entry (BURSAR_DIRECT)
POST   /api/v1/admin/payments/{id}/void                 # void with reason

Data model (fee-service DB)

payment_claims
  id UUID PK
  organization_id, campus_id
  student_id, guardian_id
  invoice_id (nullable if multi-invoice apply)
  amount_claimed DECIMAL(14,2)
  amount_approved DECIMAL(14,2) NULL
  currency CHAR(3) DEFAULT 'UGX'
  payment_method ENUM
  transaction_id VARCHAR(128) NULL
  payer_phone VARCHAR(20) NULL
  agent_number VARCHAR(32) NULL
  bank_name VARCHAR(64) NULL
  payment_date DATE
  notes TEXT
  proof_file_id UUID NULL → file-service
  status ENUM (see statuses above)
  duplicate_of_claim_id UUID NULL
  reviewed_by UUID NULL
  reviewed_at TIMESTAMPTZ NULL
  rejection_reason TEXT NULL
  version INT DEFAULT 1          -- optimistic lock
  created_at, updated_at

payment_claim_status_history
  id, claim_id, from_status, to_status, actor_id, note, created_at

payments
  id, organization_id, campus_id, student_id
  claim_id UUID NULL             -- null if BURSAR_DIRECT
  source ENUM: PARENT_CLAIM | BURSAR_DIRECT
  amount, payment_method, transaction_id
  receipt_number VARCHAR UNIQUE
  status CONFIRMED | VOIDED | REFUNDED
  void_reason, voided_by, voided_at
  created_at

payment_allocations
  payment_id, invoice_id, amount_applied

fee_invoices / fee_invoice_lines  -- existing invoice model
fee_accounts                      -- per-student running balance
Index requirements: unique partial index on (organization_id, transaction_id) where status NOT IN (REJECTED, CANCELLED, EXPIRED) to catch duplicates at insert time.