Files
alrahma_sunday_school/docs/remaining_financial_issues_fix_plan.md
root 38644b32ae
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m19s
fix invoice and enrollment fees
2026-08-27 18:34:36 -04:00

40 KiB

Remaining Financial Issues Remediation Plan

Project baseline: Archive_2.zip
Purpose: Fix every remaining financial defect identified in the latest audit.
Release status until completion: NO-GO

This plan is intentionally implementation-specific. Each item names the affected component, the required code behavior, database work, tests, and acceptance evidence. An item is not complete because the code was changed. It is complete only when its tests pass and the acceptance checks are demonstrated.


1. Delivery Rules

Every remediation item must be delivered with:

  • The code change.
  • Any required migration.
  • Unit tests.
  • Integration tests.
  • Concurrency or retry tests where relevant.
  • Authorization tests for routes and commands.
  • A repository search proving the old implementation no longer exists.
  • Before-and-after reconciliation evidence for affected financial records.

Do not combine unrelated fixes into one oversized pull request. Each pull request must be independently reviewable and reversible.

Recommended branch sequence:

  1. finance/additional-charge-ledger-lines
  2. finance/discount-eligible-base
  3. finance/refund-eligibility-corrections
  4. finance/refund-state-and-idempotency
  5. finance/invoice-snapshot-atomicity
  6. finance/payment-idempotency
  7. finance/expense-reimbursement-hardening
  8. finance/reporting-projections
  9. finance/status-route-migration
  10. finance/migration-enforcement-tests

Phase 1: Fix Invoice-Line Integrity

FIN-001: Persist applied additional charges as immutable invoice lines

Current defect

New invoices use invoice_lines as the authoritative charge source, but applying an additional charge only changes the additional_charges row and recalculates the invoice. No invoice line is created. The charge can be marked applied while the invoice total remains unchanged.

Primary files

  • app/Controllers/View/ExtraChargesController.php
  • app/Libraries/InvoiceLedgerService.php
  • Additional charge and invoice-line models
  • Related migrations

Required implementation

Create one domain method:

InvoiceAdjustmentService::applyAdditionalCharge(
    int $chargeId,
    int $invoiceId,
    int $actorId
): InvoiceLedgerResult

Inside one transaction:

  1. Lock the additional charge row with FOR UPDATE.
  2. Lock the invoice row with FOR UPDATE.
  3. Verify the invoice exists and is not voided.
  4. Verify the charge belongs to the same parent, school year, and semester as the invoice.
  5. Verify the charge status is approved.
  6. Verify no active invoice line already references this charge.
  7. Convert the charge to a signed amount:
    • add becomes positive.
    • deduct becomes negative.
  8. Insert an immutable invoice_lines row:
    • line_type = additional_charge or additional_deduction
    • source_type = additional_charge
    • source_id = charge.id
    • line_amount_cents = signed amount
    • discount_eligible according to the approved business rule
  9. Update the charge to applied.
  10. Save applied_invoice_line_id, applied_by, and applied_at.
  11. Recalculate the invoice projection.
  12. Insert an audit event.
  13. Check every write result and transaction status.
  14. Commit.

Required schema changes

Add to additional_charges if missing:

applied_invoice_line_id
applied_by
applied_at
voided_by
voided_at
void_reason

Add a unique constraint preventing more than one active invoice line for the same additional charge source.

Reversal behavior

Do not change an applied charge back to pending.

Create:

InvoiceAdjustmentService::reverseAdditionalCharge(
    int $chargeId,
    string $reason,
    int $actorId
): InvoiceLedgerResult

The reversal must:

  1. Lock the original charge and invoice.
  2. Verify the original charge is applied.
  3. Insert a reversing invoice line with the opposite amount.
  4. Preserve the original line unchanged.
  5. Mark the charge reversed.
  6. Link the reversing line to the original line.
  7. Recalculate the invoice.
  8. Record an audit event.

Fallback ledger correction

When legacy invoices have no invoice lines, additional-charge fallback logic must:

  • Include only status = applied.
  • Include only charges attached to the invoice.
  • Apply charge_type correctly.
  • Exclude pending, approved, rejected, voided, and reversed charges.

Tests

  • Apply positive charge and verify invoice increases.
  • Apply deduction and verify invoice decreases.
  • Apply same charge twice and verify second request fails.
  • Reverse applied charge and verify a reversing line is created.
  • Verify original line remains unchanged.
  • Verify pending and approved charges do not affect fallback totals.
  • Concurrently apply the same charge twice; exactly one succeeds.

Acceptance evidence

  • Every applied charge has exactly one source invoice line.
  • Every reversed charge has an original line and one reversal line.
  • No applied charge changes invoice total by zero unless the approved amount is zero, which must itself be rejected.

FIN-002: Make invoice creation and invoice-line creation atomic

Current defect

Invoice creation, invoice-line insertion, and ledger recalculation are separate operations. An invoice can be saved without authoritative invoice lines.

Primary files

  • app/Controllers/View/InvoiceController.php
  • app/Libraries/InvoiceLedgerService.php

Required implementation

Wrap invoice issuance in one service transaction:

InvoiceIssuanceService::issueInvoice(IssueInvoiceCommand $command)

Required sequence:

  1. Begin transaction.
  2. Lock or enforce the invoice uniqueness key.
  3. Insert invoice in draft status.
  4. Build all line snapshots.
  5. Insert every line.
  6. Verify the number and total of inserted lines.
  7. Change invoice to issued.
  8. Recalculate projection from invoice lines.
  9. Verify invoice projection save.
  10. Insert audit event.
  11. Commit.

On any failure, roll back the invoice and all lines.

Required code changes

  • issueInitialInvoiceLines() must return a typed result or throw.
  • Check insertBatch() return value.
  • Reject issuance when zero lines are created unless zero invoices are explicitly supported.
  • Do not silently fall back to live configuration for a newly issued invoice.
  • If an issued invoice has no lines, raise an integrity exception.

Tests

  • Force line insertion failure and verify invoice is not saved.
  • Force projection update failure and verify invoice and lines roll back.
  • Issue the same invoice concurrently and verify the unique rule permits one.
  • Verify issued invoice always has at least one authoritative line.

Acceptance evidence

Query returns zero rows:

SELECT i.id
FROM invoices i
LEFT JOIN invoice_lines l ON l.invoice_id = i.id
WHERE i.status = 'issued'
GROUP BY i.id
HAVING COUNT(l.id) = 0;

FIN-003: Correct invoice snapshot composition

Current defects

Initial snapshots do not fully capture existing approved/applied adjustments. Event charges can be loaded without the invoice semester. Legacy invoice backfill can make event fees discount eligible.

Required implementation

When issuing an invoice, snapshot:

  • Tuition lines.
  • Event-fee lines for the exact school year and semester.
  • Existing approved adjustments that are meant to be included at issuance.
  • Existing approved deductions.
  • Any other authorized invoice charge types.

Each line must store the correct discount_eligible value.

Legacy migration strategy

Do not classify a full legacy invoice total as tuition.

For each legacy invoice:

  1. Recover tuition, event, and adjustment components where historical source data is trustworthy.
  2. Store separate lines with correct eligibility.
  3. If components cannot be reconstructed, create:
    • legacy_total line.
    • A separate persisted legacy_discount_eligible_base_cents.
  4. Do not infer that the whole legacy amount is discount eligible.

Tests

  • Semester-one invoice excludes semester-two event charges.
  • Event-only invoice has zero eligible discount base.
  • Legacy invoice preserves its original total.
  • Legacy event portion is not made discount eligible.

FIN-004: Render invoice documents from frozen invoice lines

Current defect

Invoice PDFs and document views reconstruct descriptions and charges from current enrollment and event data. The stored total may remain fixed while the rendered invoice changes.

Required implementation

Invoice rendering must use:

  • invoice_lines.description
  • invoice_lines.quantity
  • invoice_lines.unit_amount_cents
  • invoice_lines.line_amount_cents
  • Snapshot metadata

Do not reload current:

  • Enrollments
  • Class assignments
  • Event charges
  • Withdrawal status
  • Pricing configuration

Current data may be shown separately as non-financial context only if clearly labeled and not used to describe issued charges.

Tests

  1. Issue an invoice.
  2. Save its PDF hash or normalized rendered data.
  3. Change enrollment and event configuration.
  4. Regenerate the invoice document.
  5. Verify financial line descriptions and values are unchanged.

Phase 2: Correct Discount Calculations

FIN-005: Expose the actual eligible discount base from the ledger

Current defect

Discount application uses invoice balance due, which includes payments, refunds, and ineligible charges. The ledger later caps the discount differently.

Required ledger output

Add:

gross_charge_cents
discount_eligible_base_cents
requested_discount_cents
applied_discount_cents
net_charge_cents

Calculate eligible base directly from invoice lines:

discount eligible base =
sum(line_amount_cents where discount_eligible = true and line is active)

Do not use:

  • Balance due
  • Customer credit
  • Total payments
  • Refunds
  • Full invoice total as a fallback

If eligible base is zero, applied discount must be zero.

Tests

  • Event-only invoice has zero eligible base.
  • Tuition plus event invoice includes tuition only.
  • Deduction lines reduce the eligible base only when the business rule says they do.
  • Payments do not change discount eligibility.

FIN-006: Remove duplicate discount recalculation from InvoiceController

Current defect

recalculateAndUpdateDiscount() recalculates discounts from live tuition, processes one usage row, updates legacy fields, and ignores some write results.

Required implementation

Delete the method or replace its body with a call to the canonical discount service.

No invoice controller method may independently:

  • Calculate percentage discounts.
  • Calculate fixed discount caps.
  • Query live tuition to update an issued invoice.
  • Select one discount usage as though it is the only usage.
  • Write discount_amount directly.

Repository check

The following concepts must only exist in the discount domain service and ledger:

percentage discount calculation
eligible base calculation
discount cap calculation
discount ordering

FIN-007: Store and consume the actual applied discount

Required fields

For every discount usage:

requested_discount_cents
eligible_base_before_cents
applied_discount_cents
application_order

The authoritative invoice discount total must be:

SUM(applied_discount_cents for active usages)

The ledger must stop using legacy discount_amount except during migration compatibility.

Required migration

  1. Backfill cents fields.
  2. Recalculate active usages against historical invoice eligibility where reliable.
  3. Flag ambiguous records for reconciliation.
  4. Move application reads to cents fields.
  5. Make legacy fields read-only or remove them after migration.

Tests

  • Stored usage amount equals invoice-applied amount.
  • Reports use applied amount.
  • Discount cannot exceed eligible base.
  • Multiple discounts apply in deterministic order.

FIN-008: Enforce voucher locking, ordering, and uniqueness

Required transaction

  1. Lock voucher.
  2. Lock invoice.
  3. Lock active discount usages for that invoice.
  4. Revalidate voucher dates, active state, school year, semester, parent eligibility, and remaining uses.
  5. Calculate remaining eligible base.
  6. Calculate actual applied amount.
  7. Insert usage.
  8. Increment voucher usage.
  9. Recalculate invoice.
  10. Commit.

Required constraints

  • Unique voucher usage per invoice unless repeat use is explicitly supported.
  • Applied amount nonnegative.
  • Usage count cannot exceed maximum through application logic and locked transaction.

Tests

  • Concurrent final voucher use; one succeeds.
  • Fixed then percentage discount.
  • Percentage then fixed is rejected if ordering is fixed.
  • Event-only invoice receives zero discount.
  • Expired or inactive voucher is rejected inside the transaction.

Phase 3: Correct Refund Eligibility and Source Rules

FIN-009: Fix invoice-overpayment eligibility double subtraction

Current defect

Current customer credit already reflects completed cash refunds, but completed payouts are subtracted again.

Required source-specific formulas

Invoice overpayment

available refundable amount =
current invoice customer credit
- active approved unpaid reservations for that invoice source

Do not subtract completed payouts again.

Payment duplicate or payment correction

available refundable amount =
approved refundable portion of the payment
- completed payouts tied to that payment source
- active approved unpaid reservations tied to that payment source

Credit memo or administrative credit

available refundable amount =
remaining cash-refundable amount on the approved credit source
- completed payouts
- active reservations

Implement separate strategy methods rather than one generic subtraction formula.

Tests

  • $100 credit, $40 already refunded, no reservations gives $60 available.
  • $60 current credit and $20 reserved gives $40 available.
  • Payment-source completed payout is subtracted exactly once.

FIN-010: Implement or remove unsupported refund types

Current defect

Tuition or administrative refund requests can map to source types whose eligibility is always zero.

Required decision

Choose one of the following and enforce it consistently:

Option A: Implement real source records

Create:

credit_memos
administrative_credits

Each source must have:

  • Parent
  • Invoice
  • Approved amount
  • Cash-refundable amount
  • Status
  • Reason
  • Approver
  • Audit history

Option B: Remove unsupported types

Remove tuition and administrative-credit refund options from:

  • Validation rules
  • Request forms
  • Controller mapping
  • Reports
  • Status logic

Do not leave a selectable type that can never pass eligibility.

Acceptance evidence

Every exposed refund type has a complete request, approval, payout, reversal, and reporting path.


FIN-011: Prove duplicate-payment eligibility

Current defect

Any recorded payment can be treated as a duplicate without proving duplication or overpayment.

Required implementation

A duplicate-payment refund must reference:

  • The duplicate payment.
  • The invoice.
  • An approved duplicate-payment determination or correction record.

Create a record such as:

payment_corrections
- id
- payment_id
- correction_type
- approved_refundable_cents
- status
- reason
- approved_by
- approved_at

Allowed correction types may include:

duplicate
wrong_parent
wrong_invoice
wrong_amount
processor_duplicate

The eligibility service must verify the approved correction and remaining refundable amount.

Alternative: allow invoice-credit-based refund only when the payment demonstrably caused current invoice customer credit. This still requires allocation logic linking the excess to that payment.

Required behavior

During refund request creation:

  • Derive invoice_id and parent_id from the payment.
  • Reject conflicting client-provided values.
  • Require a valid successful payment status.
  • Reject a payment already fully refunded or reversed.

Tests

  • Normal payment required to settle an invoice cannot be refunded as duplicate.
  • Approved duplicate payment can be refunded.
  • Payment on wrong invoice follows correction workflow.
  • Payment-derived refund always stores its invoice.

FIN-012: Lock every refund source during approval and payout

Required locking matrix

Source type Rows to lock
Invoice overpayment refund, invoice, active source reservations
Payment duplicate/correction refund, payment, invoice, correction record, reservations
Credit memo refund, credit memo, invoice, reservations
Administrative credit refund, administrative credit, invoice, reservations

Locks must occur before eligibility is recalculated.

Concurrency tests

  • Two refund approvals against one payment source.
  • Two approvals against one invoice credit.
  • Approval racing with payment reversal.
  • Payout racing with a new invoice adjustment.

Only valid remaining credit may be approved or paid.


Phase 4: Fix Refund State, Payout, and Retry Behavior

FIN-013: Lock refund rejection and enforce state transitions

Required rejection transaction

  1. Begin transaction.
  2. Lock refund row.
  3. Reload normalized status.
  4. Permit rejection only from requested.
  5. Save rejection reason, actor, and timestamp.
  6. Insert audit event.
  7. Commit.

Reject attempts from:

  • Approved
  • Partially paid
  • Paid
  • Cancelled
  • Reversed
  • Exception

State machine

Allowed transitions:

requested -> approved
requested -> rejected
requested -> cancelled
approved -> partially_paid
approved -> paid
approved -> cancelled
partially_paid -> paid
paid -> reversal_pending
paid -> partially_reversed
paid -> reversed
partially_paid -> partially_reversed

Every transition must be performed through one state service.


FIN-014: Validate idempotency against a request fingerprint

Current defect

Any existing payout with the same idempotency key can be returned as success even when it belongs to another refund or different operation.

Required implementation

Persist a fingerprint containing:

operation_type
refund_id
amount_cents
payment_method
currency
external_reference where applicable

On repeated key:

  • Identical fingerprint: return original result.
  • Different fingerprint: return 409 IDEMPOTENCY_CONFLICT.
  • Never report unrelated payout as successful.

Use separate operation types for:

  • Refund payout
  • Refund reversal
  • Payment creation
  • Inventory receipt

Required database fields

Add a request fingerprint hash or the normalized fields required to compare.

Tests

  • Same key and same request returns original payout.
  • Same key and different amount returns 409.
  • Same key on another refund returns 409.
  • Payout key cannot be reused for reversal.

FIN-015: Make payout rows authoritative

Current defect

Payout eligibility still reads mutable refund_paid_amount.

Required implementation

Calculate:

completed payout total =
sum(completed cash_out payouts)
- sum(completed reversal payouts)

Use payout rows for:

  • Remaining approved amount.
  • Refund status.
  • Invoice ledger effect.
  • Reports.
  • Reconciliation.
  • Reversal limits.

refund_paid_amount may remain only as a cached projection updated from payout rows. It must never be trusted as input.

Integrity check

Add a daily query comparing the cache to payout-row totals. Differences must alert and block further payout until reconciled.


FIN-016: Implement external payout states for online refunds

Current defect

Online refunds are marked completed without external confirmation.

Required state flow

created -> processing -> completed
created -> processing -> failed
processing -> unknown
unknown -> completed
unknown -> failed

Required behavior

For online or gateway-backed refunds:

  1. Insert payout as processing.
  2. Call provider using the idempotency key.
  3. Store provider operation ID.
  4. Mark completed only after confirmed provider success.
  5. On timeout, mark unknown, not failed.
  6. Reconcile unknown status with the provider.
  7. Apply invoice ledger effect only when completed.

For manual cash or check payouts:

  • Require explicit staff confirmation.
  • Require check number for checks.
  • Require external or receipt reference according to policy.

Tests

  • Provider success.
  • Provider decline.
  • Timeout after provider success.
  • Retry with same idempotency key.
  • Provider webhook or reconciliation confirmation.

FIN-017: Restrict overpayment refund recalculation to its own source

Current defect

Invoice overpayment recalculation can rewrite unrelated open refunds and can block legitimate future overpayment refunds because an old paid refund exists.

Required implementation

Only update or create a refund where:

source_type = invoice_overpayment
source_id = invoice_id

Never modify refunds from another source.

The recalculation transaction must:

  1. Lock invoice.
  2. Calculate current customer credit.
  3. Lock active invoice-overpayment refund reservations.
  4. Update only the active overpayment reservation for this exact source.
  5. Never reduce approved amount below completed payout total.
  6. Permit a new future refund cycle when new credit appears after an earlier refund was fully paid.
  7. Preserve all previous paid refunds as immutable history.

Suggested model

Use separate refund requests per credit event rather than reopening old paid rows.

Tests

  • Duplicate-payment refund remains unchanged during invoice overpayment recalculation.
  • New overpayment after a prior paid refund creates a new request.
  • Recalculation racing with payout remains consistent.
  • Approved amount never falls below net completed payout.

Phase 5: Fix Payment Retry and Evidence Handling

FIN-018: Add client-to-server idempotency for manual payments

Current defect

The server creates a new transaction ID on every request. A retry can create a second partial payment.

Required implementation

  1. Generate an operation UUID when the payment form is loaded or immediately before first submission.
  2. Store it in the form.
  3. Reuse it for all retries.
  4. Add unique idempotency_key to the payment record or payment operation table.
  5. Store request fingerprint:
    • Parent
    • Invoice
    • Amount
    • Method
    • Currency
  6. Same key plus identical request returns original payment.
  7. Same key plus different request returns 409.

Tests

  • Response lost after commit, retry creates no second payment.
  • Same key with different amount returns conflict.
  • Two concurrent submissions with same key create one payment.

FIN-019: Stage payment evidence safely

Required flow

  1. Validate evidence.
  2. Save to temporary storage.
  3. Begin transaction.
  4. Create payment.
  5. Allocate payment.
  6. Recalculate invoice.
  7. Commit.
  8. Move evidence to permanent storage.
  9. On rollback, remove temporary evidence.

If permanent file finalization fails after commit:

  • Preserve payment.
  • Mark evidence status incomplete.
  • Alert operations.
  • Do not silently delete the payment.

Tests

  • Payment validation failure cleans temporary file.
  • Database rollback cleans temporary file.
  • File finalization failure produces an operational exception state.

FIN-020: Use ledger discount values in payment notifications

Payment notifications and receipts must use:

  • Applied discount from ledger projection.
  • Net invoice charges from ledger.
  • Valid payment total.
  • Completed refund total.
  • Amount due and customer credit.

Remove raw discount sums from payment notification code.


Phase 6: Enforce Expense and Reimbursement Integrity

FIN-021: Enforce expense immutability in the write endpoint

Current defect

The edit form checks reimbursement state, but direct POST to the update method can bypass it.

Required implementation

Inside the update transaction:

  1. Lock expense.
  2. Lock active reimbursements for that expense.
  3. Reject changes to protected fields when any active or paid reimbursement exists.
  4. Check update result.
  5. Commit only on success.

Protected fields:

  • Amount
  • Category
  • Purchaser
  • School year
  • Receipt
  • Donation flag
  • Currency

Allow only non-financial notes if explicitly approved.

Receipt replacement

Use temporary storage and transaction-safe finalization.

Status updates

updateStatus() must:

  • Lock expense.
  • Validate legal transition.
  • Prevent approval changes after reimbursement without reversal.
  • Record actor and reason.

Tests

  • Direct POST cannot edit reimbursed expense.
  • Paid expense amount cannot change.
  • Receipt replacement rolls back safely.
  • Donation-to-reimbursable transition resets approval.

FIN-022: Make reimbursement batches use one domain service

Current defect

Batch processing can bypass the safer individual reimbursement service and can resurrect rejected or reversed records.

Required implementation

Create one service:

ReimbursementService::payApprovedExpense(
    int $expenseId,
    int $recipientId,
    int $batchId,
    PaymentDetails $payment,
    int $actorId
)

Both individual and batch workflows must call it.

The service must:

  • Lock expense.
  • Lock existing reimbursement rows.
  • Reject active, paid, rejected, or reversed records unless the exact transition is allowed.
  • Verify amount equals approved reimbursable amount.
  • Verify recipient.
  • Verify accounting period.
  • Verify batch relationship.
  • Insert or transition reimbursement safely.
  • Link expense.
  • Record audit event.

Batch rules

  • Lock batch and all active items.
  • Validate every item before creating any payment.
  • If one item fails, roll back all.
  • Never skip invalid items.
  • Close batch only after every active item has a valid paid reimbursement.

Tests

  • Rejected reimbursement cannot become paid.
  • Reversed reimbursement cannot be resurrected.
  • Invalid batch item rolls back the whole batch.
  • Two batch-close requests cannot double pay.

FIN-023: Implement reimbursement reversal

Required model

Create immutable reimbursement transaction or reversal records.

A reversal must:

  1. Lock original reimbursement.
  2. Verify paid status and unreversed amount.
  3. Insert reversal record.
  4. Update expense reimbursement availability according to policy.
  5. Preserve original reimbursement.
  6. Record reason, actor, and timestamp.
  7. Reconcile any external payment reference.

Do not edit a paid reimbursement amount to zero.

Tests

  • Full reversal.
  • Partial reversal if allowed.
  • Duplicate reversal rejected.
  • Expense correction after reversal follows approval workflow.

Phase 7: Finish Reporting and Status Normalization

FIN-024: Migrate every financial report and export to canonical data

Current defects

Some reports still use raw:

  • discount_amount
  • refund_paid_amount
  • SUM(paid_amount)
  • Legacy status variants

Required implementation

Create dedicated read models or SQL views for:

invoice gross charges
discount eligible base
applied discounts
net charges
valid payments
completed refund payouts
refund reversals
amount due
customer credit
reserved refundable credit
available refundable credit
approved expense amount
paid reimbursement amount
inventory receipt totals

Every report and export must consume these projections.

Required replacements

  • Discount export uses applied_discount_cents.
  • Refund export lists payout rows and reversal rows.
  • Payment totals use canonical successful-status query.
  • Invoice reports use invoice-line projections.
  • Parent summaries use the parent ledger projection.
  • No report independently reconstructs accounting formulas.

Cross-surface test

For a selected invoice, verify exact equality across:

  • Invoice page
  • Manual payment page
  • Refund page
  • Financial report
  • CSV/PDF export
  • API response

FIN-025: Normalize all statuses to lowercase machine values

Required canonical values

Invoice

draft
issued
unpaid
partially_paid
paid
credited
voided

Refund

requested
approved
partially_paid
paid
rejected
cancelled
partially_reversed
reversed
exception

Reimbursement

pending
approved
paid
rejected
reversed

Payout

created
processing
unknown
completed
failed
reversed

Required implementation

  1. Migrate existing mixed-case values.
  2. Update controllers.
  3. Update models.
  4. Update queries.
  5. Update reports.
  6. Update form values.
  7. Add check constraints.
  8. Remove compatibility writes.
  9. Permit legacy aliases only in a one-time migration mapping.

Fix InvoiceModel::getUnpaidInvoices() to include canonical partially_paid.

Remove direct invoice mutation methods

Remove or make private any model methods that directly modify:

  • Total amount
  • Balance
  • Discount total
  • Refund total
  • Additional-charge totals

All mutations must pass through invoice lines and ledger recalculation.


Phase 8: Secure Routes and Remove Bypasses

FIN-026: Protect every financial route explicitly

Required filters

Every route must have:

  • Authentication.
  • Exact permission.
  • Correct method.
  • CSRF for browser writes.
  • Parent/organization ownership enforcement.

Routes requiring immediate review

  • Invoice parent lookup.
  • Invoice creation.
  • Invoice listing.
  • Parent invoice payment pages.
  • Payment parent lookup.
  • Payment creation.
  • Payment details.
  • Financial report download.
  • Refund actions.
  • Expense actions.
  • Reimbursement batch actions.
  • PO receipt and cancellation.

Permission examples

invoice.view
invoice.issue
invoice.adjust
payment.view
payment.create
payment.reverse
refund.view
refund.request
refund.approve
refund.pay
refund.reverse
discount.apply
expense.view
expense.edit
expense.approve
reimbursement.pay
reimbursement.reverse
purchase_order.receive
financial_report.view

Tests

  • Anonymous request returns 401.
  • Authenticated user without permission returns 403.
  • Wrong parent or organization scope returns 403 or 404.
  • State changes through GET are impossible.
  • CSRF failure blocks browser writes.

FIN-027: Add route and domain-bypass tests

Automated checks must verify:

  • Every configured controller method exists.
  • Route parameters match method signatures.
  • Financial write routes call an approved domain service.
  • Generic CRUD cannot mutate financial models.
  • No command, scheduled job, or admin utility writes financial totals directly.
  • No model callback silently changes settled financial records.

Phase 9: Make Database Hardening Fail Loudly

FIN-028: Stop swallowing required migration failures

Current defect

Some hardening migrations catch constraint errors, log warnings, and continue. Deployment can appear successful without required safeguards.

Required implementation

For every required constraint:

  • Pre-clean or reconcile conflicting data.
  • Attempt constraint creation.
  • Verify the constraint exists.
  • Throw and fail the migration if it does not exist.

Required constraints include:

  • Invoice identity uniqueness.
  • Refund payout idempotency uniqueness.
  • Discount usage uniqueness.
  • One active reimbursement per expense.
  • Batch sequence uniqueness.
  • Positive payout amount.
  • Valid additional-charge type.
  • PO quantity and cost checks.
  • Foreign keys for financial source relationships.

Database support

Declare the supported production database and minimum version.

If MySQL-only:

  • Document the exact supported MySQL version.
  • Run migration tests against that version.
  • Do not imply portability to unsupported engines.

Migration test

After migrations, query metadata and assert every required index, foreign key, generated column, and check constraint exists.


Phase 10: Add Inventory Receipt Idempotency and Reversal

FIN-029: Make PO receiving retry-safe

Required schema

Create:

inventory_receipt_operations
- id
- idempotency_key
- purchase_order_id
- request_fingerprint
- status
- actor_id
- created_at

Create immutable receipt transaction rows per PO line.

Required behavior

  • Same key and same request returns the original receipt result.
  • Same key and different quantities returns 409.
  • Retry after committed response loss does not add stock again.
  • Completion status is derived from all receipt rows.

Tests

  • Concurrent same-key receipts create one inventory effect.
  • Retry after commit creates no additional quantity.
  • Different request with reused key conflicts.

FIN-030: Implement inventory receipt reversal

A receiving mistake must be corrected with a reversal transaction.

Required checks:

  • Original receipt exists.
  • Reversal does not exceed unreversed quantity.
  • Current inventory is sufficient unless negative inventory is explicitly supported.
  • PO received quantities are recalculated.
  • PO status is recalculated.
  • Original receipt remains unchanged.

Phase 11: Logging and Sensitive Data

FIN-031: Remove full financial request logging

Do not log entire POST bodies for refunds, payments, reimbursements, or expenses.

Log structured fields only:

request_id
actor_id
operation
entity_id
amount_cents
currency
idempotency_key hash or truncated value
result_code

Never log:

  • Full check number.
  • Bank account data.
  • Card data.
  • Uploaded document contents.
  • Full raw request payload.
  • Sensitive external references unless redacted.

Add tests or static checks for prohibited logging patterns in financial controllers.


Phase 12: Automated Verification

FIN-032: Add unit tests

Minimum formula coverage:

  • Exact payment.
  • Partial payment.
  • Multiple payments.
  • Overpayment.
  • Partial cash refund.
  • Full cash refund.
  • Multiple payouts.
  • Payout reversal.
  • Invalid payment statuses.
  • Discount eligible base.
  • Event-only invoice discount.
  • Additional charge and deduction.
  • Additional-charge reversal.
  • Refund reservation.
  • Invoice-overpayment eligibility after prior payout.
  • Payment-source refund eligibility.
  • Reimbursement amount and reversal.
  • PO receipt and reversal.

FIN-033: Add integration tests

Required workflows:

  1. Issue invoice with frozen lines.
  2. Apply additional charge.
  3. Apply deduction.
  4. Apply discounts in order.
  5. Record payment.
  6. Create overpayment.
  7. Request refund.
  8. Approve refund.
  9. Pay refund.
  10. Reverse refund.
  11. Verify every report and export.

Additional workflows:

  • Invoice issuance rollback.
  • Evidence upload rollback.
  • Payment retry.
  • Online refund timeout and reconciliation.
  • Expense edit after reimbursement.
  • Batch reimbursement rollback.
  • PO receipt retry.
  • Migration constraint verification.

FIN-034: Add concurrency tests

Use the production database engine.

Required races:

  • Same additional charge applied twice.
  • Last voucher use requested twice.
  • Two refund approvals against one credit.
  • Refund payout and invoice adjustment.
  • Refund rejection and approval.
  • Same payment submitted twice.
  • Two reimbursements for one expense.
  • Two batch-close requests.
  • Two PO receipts for the same lines.

Expected invariants:

no duplicate financial event
no over-refund
no over-discount
no double reimbursement
no duplicate inventory receipt
no settled record overwritten

FIN-035: Add repository guardrails

CI must fail if financial arithmetic appears outside approved services.

Search for suspicious patterns including:

refund_paid_amount
SUM(paid_amount)
SUM(amount)
discount_amount
newBalance
total - discount
total - paid
balance =
Partially Paid
Unpaid
Paid

Use a narrow allowlist for:

  • Migrations.
  • Compatibility readers scheduled for removal.
  • Canonical financial repositories and services.

CI must also fail when:

  • Financial routes lack auth/permission filters.
  • New direct model mutations are introduced.
  • Required tests are absent for a changed financial service.

Phase 13: Historical Reconciliation

FIN-036: Reconcile existing records after the fixes

For every invoice produce:

frozen charge total
discount eligible base
applied discount total
valid payment total
completed payout total
reversal total
amount due
customer credit
reserved refund amount
available refund amount

Flag:

  • Applied additional charge without invoice line.
  • Invoice line without source.
  • Discount usage above eligible base.
  • Refund payout above approval.
  • Refund without valid source.
  • Aggregate refund amount not matching payout rows.
  • Mixed-case or invalid status.
  • Issued invoice without lines.
  • Reimbursed expense modified after payment.
  • Reimbursement amount mismatch.
  • PO receipt total mismatch.
  • Missing required database constraint.

Correction method

Do not silently overwrite settled records.

Use:

  • Adjustment line.
  • Credit memo.
  • Debit adjustment.
  • Refund reversal.
  • Reimbursement reversal.
  • Inventory receipt reversal.
  • Explicit reconciliation record.

Every correction needs:

  • Reason.
  • Actor.
  • Approval.
  • Timestamp.
  • Reference to the original record.

Final Release Gate

Production approval requires every answer below to be yes.

Invoice integrity

  • Does every issued invoice have authoritative lines?
  • Are invoice issuance and line creation atomic?
  • Do applied additional charges create immutable lines?
  • Are reversals represented by reversing lines?
  • Are invoice PDFs rendered from frozen lines?
  • Do semester filters match the issued invoice?

Discounts

  • Is the eligible base calculated from eligible lines?
  • Is zero eligible base respected?
  • Is actual applied discount stored and reported?
  • Are voucher uses locked and uniquely constrained?
  • Is duplicate discount recalculation removed?

Refunds

  • Does invoice-overpayment eligibility avoid double subtraction?
  • Does every refund type have a real source?
  • Is duplicate-payment status proven?
  • Is the source locked during approval and payout?
  • Is rejection locked and state-safe?
  • Does idempotency validate the request fingerprint?
  • Are payout rows authoritative?
  • Are online payouts completed only after confirmation?
  • Does overpayment recalculation modify only its own source?
  • Can new later overpayments create new refunds?

Payments

  • Are manual payments idempotent?
  • Is evidence staged and cleaned safely?
  • Do notifications use ledger values?

Expenses and reimbursements

  • Is reimbursed-expense immutability enforced in the write method?
  • Do batch and individual reimbursements use one service?
  • Can rejected or reversed reimbursements remain non-payable?
  • Is reimbursement reversal implemented?

Reports and statuses

  • Do all reports and exports use canonical projections?
  • Are statuses normalized to lowercase machine values?
  • Are direct invoice-total mutation methods removed?

Security and database

  • Is every financial route explicitly authenticated and authorized?
  • Are generic mutation bypasses absent?
  • Do required migrations fail when constraints fail?
  • Does metadata verification prove constraints exist?

Inventory

  • Are receipt operations idempotent?
  • Is receipt reversal available?
  • Can retries avoid double stock increases?

Verification

  • Do all unit tests pass?
  • Do all integration tests pass?
  • Do all concurrency tests pass on the production database engine?
  • Do authorization tests pass?
  • Does reconciliation report zero unexplained financial differences?

Required Execution Order

Implement in this order because later fixes depend on earlier invariants:

  1. FIN-001 through FIN-004: invoice-line and snapshot integrity.
  2. FIN-005 through FIN-008: discount base and persistence.
  3. FIN-009 through FIN-017: refund eligibility, state, payouts, and recalculation.
  4. FIN-018 through FIN-020: payment idempotency and evidence.
  5. FIN-021 through FIN-023: expenses and reimbursements.
  6. FIN-024 through FIN-027: reports, statuses, routes, and bypasses.
  7. FIN-028: migration enforcement.
  8. FIN-029 and FIN-030: inventory retry and reversal.
  9. FIN-031: logging cleanup.
  10. FIN-032 through FIN-035: automated verification and guardrails.
  11. FIN-036: historical reconciliation.
  12. Run the final release gate.

Do not release a partial phase when it leaves two sources of truth active. In particular, do not switch reports to new projections while writes still update legacy aggregates independently. That would produce a more modern disagreement, which remains a disagreement.