34 KiB
Exact Financial Remediation Plan
Scope
This plan covers all currently identified defects in:
- Invoices
- Payments and manual payments
- Refund requests, approvals, payouts, and recalculation
- Discounts and vouchers
- Additional charges
- Expenses
- Reimbursements and reimbursement batches
- Purchase orders and inventory receiving
- Financial reports
- Routes, APIs, authorization, and persistence
- Historical data integrity
- Automated testing
The implementation must follow the order below. Later work depends on earlier accounting rules being correct.
Work Package 1: Remove Every Duplicate Invoice-Balance Formula
F-001: Correct InvoiceController::generateInvoice()
Defect
app/Controllers/View/InvoiceController.php still calculates:
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
Cash refunds are incorrectly subtracted.
The same method writes display-style statuses:
Paid
Partially Paid
Unpaid
instead of canonical machine statuses.
Required change
Delete the local balance calculation.
After creating or updating invoice data, call only:
$ledger = $invoiceLedgerService->recalculate($invoiceId);
Use the returned values:
$ledger->totalAmountCents
$ledger->discountCents
$ledger->paidCents
$ledger->completedRefundCents
$ledger->rawBalanceCents
$ledger->balanceDueCents
$ledger->customerCreditCents
$ledger->status
Do not calculate invoice status in the controller.
Required formula inside the ledger
raw balance =
net charges
- valid payments
+ completed cash refunds
amount due = max(0, raw balance)
customer credit = max(0, -raw balance)
Acceptance criteria
- No invoice balance formula remains in
InvoiceController. - No invoice status is manually assigned in
InvoiceController. - Generated invoices and recalculated invoices return identical totals.
- Status values are lowercase canonical values.
Required tests
- Invoice with no payment.
- Partially paid invoice.
- Fully paid invoice.
- Overpaid invoice.
- Partially refunded invoice.
- Fully refunded overpayment.
- Payment after refund.
- Invoice generated twice with identical source data.
F-002: Remove financial calculations from manual-payment views
Defect
app/Views/payment/manual_pay.php calculates balances in PHP and JavaScript using the wrong refund sign.
Required change
The controller must supply each invoice with:
[
'invoice_id' => ...,
'display_total' => ...,
'balance_due_cents' => ...,
'customer_credit_cents' => ...,
'status' => ...,
]
The view must only display those values.
Remove JavaScript calculations based on:
- Invoice total
- Discounts
- Payments
- Refunds
- Existing balance fields
JavaScript may calculate only the amount entered by the user versus the already-provided backend balance.
Acceptance criteria
Repository search shows no invoice accounting formula in:
app/Views
public JavaScript
inline JavaScript
The manual-payment screen shows exactly the same balance as the ledger API.
Required tests
- Backend ledger balance equals rendered data attribute.
- Full-payment button uses backend-provided balance.
- Refunded invoice displays the corrected amount due.
- Customer credit is never displayed as a negative balance.
Work Package 2: Establish One Canonical Financial Service
F-003: Make InvoiceLedgerService the only invoice calculator
Required change
All financial entry points must call the same service.
Required callers include:
- Invoice creation
- Invoice regeneration
- Payment creation
- Manual payment creation
- Payment reversal
- Discount application
- Discount removal
- Additional-charge application
- Refund approval
- Refund payout
- Refund reversal
- Financial reports
- Parent financial summaries
Repository enforcement
Search for all occurrences of:
refund_paid_amount
paid_amount
discount_amount
balance
total_amount
SUM(
Partially Paid
Unpaid
Paid
Classify every occurrence as:
- Canonical ledger implementation
- Database migration
- Display-only
- Invalid duplicate calculation
Delete or replace every invalid duplicate.
Acceptance criteria
Outside the ledger and migrations:
- Controllers do not calculate balances.
- Views do not calculate balances.
- Reports do not calculate balances.
- JavaScript does not calculate balances.
- API controllers do not calculate balances.
F-004: Define valid payment contribution centrally
Required change
Create one method:
InvoiceLedgerService::getValidPaymentTotalCents(int $invoiceId): int
Only include successful financial states.
Suggested included states:
successful
completed
paid
Suggested excluded states:
pending
failed
declined
cancelled
voided
reversed
refunded
chargeback
Normalize actual project status values before implementing the query.
All refund eligibility, invoice balances, parent summaries, and reports must use this method or its underlying canonical query.
Acceptance criteria
No raw SUM(paid_amount) is used to determine refundable credit or invoice balance outside the ledger repository.
Required tests
Create one payment in every supported status and verify that only valid final payments contribute.
Work Package 3: Freeze Historical Invoice Values
F-005: Stop recalculating issued invoices from live configuration
Defect
The current ledger rebuilds historical invoices from current:
- Enrollments
- Tuition settings
- Student class assignments
- Event charges
- Refund deadlines
- Calculator versions
Required schema
Create invoice_lines:
id
invoice_id
line_type
source_type
source_id
description
quantity
unit_amount_cents
line_amount_cents
discount_eligible
calculation_version
metadata_json
created_at
updated_at
voided_at
Required behavior
When an invoice is issued:
- Calculate tuition and charges once.
- Save immutable invoice lines.
- Calculate totals only from those lines.
- Never reload current enrollment or current pricing to recalculate that invoice.
Allowed post-issue changes:
- Credit memo
- Debit adjustment
- New additional-charge line
- Voiding and reissuing the invoice
Disallowed changes:
- Replacing historical tuition based on current configuration
- Replacing event fees based on current event records
- Rewriting original issued lines
Legacy migration
For existing invoices:
- Where historical source details are trustworthy, backfill individual lines.
- Where they are not trustworthy, create a single
legacy_invoice_totalline preserving the issued total. - Do not rebuild old invoices from current tuition settings.
Acceptance criteria
Changing current tuition, enrollment, event configuration, or refund deadline does not alter an issued invoice.
Required tests
- Issue invoice.
- Change tuition configuration.
- Recalculate after payment.
- Confirm invoice charges remain unchanged.
Repeat for enrollment and event changes.
Work Package 4: Rebuild Refund Source and Eligibility Rules
F-006: Require a specific refund source
Required source types
invoice_overpayment
payment_duplicate
payment_correction
credit_memo
administrative_credit
Each refund must store:
source_type
source_id
invoice_id
parent_id
requested_amount_cents
approved_amount_cents
Rules
Invoice overpayment
Must reference an invoice with current customer credit.
Duplicate payment
Must reference one specific valid payment.
Payment correction
Must reference the payment being corrected.
Credit memo
Must reference the approved credit memo.
Administrative credit
Must reference an approved administrative adjustment record.
Prohibited behavior
- No source-free duplicate-payment refund.
- No refund created from only parent ID and amount.
- No fallback to the latest invoice.
- No attaching a payout to an invoice without credit.
Acceptance criteria
Every refund can be traced to a specific source record.
F-007: Create one refund-eligibility service
Create:
RefundEligibilityService
Required methods:
calculateAvailableCredit(
int $parentId,
?int $invoiceId,
string $sourceType,
int $sourceId
): RefundEligibilityResult
validateRequestedAmount(
RefundEligibilityResult $eligibility,
int $requestedAmountCents
): void
Calculation
available refundable amount =
current source credit
- completed payouts for the source
- active approved unpaid refund reservations
For partially paid refunds, reserve only the unpaid remainder.
Pending requests should not automatically become payable. Approval must recalculate eligibility.
Result object
RefundEligibilityResult {
sourceCreditCents
completedPayoutCents
reservedAmountCents
availableAmountCents
reasonCodes
}
Acceptance criteria
Refund request, approval, payout, and reports all use the same eligibility service.
Work Package 5: Fix Refund Request Creation
F-008: Validate refund requests transactionally
Request flow
- Validate authentication and permission.
- Validate source type and source ID.
- Load the source.
- Verify source ownership.
- Calculate current eligibility.
- Reject zero or negative amounts.
- Reject amounts above available credit.
- Create the refund request.
- Write an audit record.
Required validation
- Parent exists.
- Invoice exists when required.
- Invoice belongs to parent.
- Payment exists when required.
- Payment belongs to the invoice and parent.
- Payment has a refundable status.
- Currency matches.
- Amount is positive.
- Amount does not exceed source availability.
Acceptance criteria
A request cannot be saved with an unsupported or missing source.
Work Package 6: Fix Refund Approval
F-009: Lock and revalidate during approval
Current defect
Approval reads refund state before transaction locking and does not revalidate current credit.
Required transaction
BEGIN
SELECT refund FOR UPDATE
verify current status = requested
SELECT source invoice/payment/credit FOR UPDATE
recalculate available refundable amount
verify requested amount <= available amount
verify no conflicting approved refund exists
set approved_amount
set status = approved
set approved_by
set approved_at
insert audit event
COMMIT
Required state transitions
Allowed:
requested → approved
requested → rejected
Rejected, cancelled, paid, and reversed refunds cannot be approved.
Segregation of duties
For configured high-value thresholds:
- Requester cannot approve their own request.
- A second approver is required.
Acceptance criteria
Two concurrent approvals against the same $100 credit cannot approve more than $100 total.
Required concurrency test
Start two approval transactions for separate $100 refund requests against the same $100 credit.
Expected:
- One succeeds.
- One fails after locked revalidation.
Work Package 7: Replace Mutable Refund Aggregates with Immutable Payouts
F-010: Create refund_payouts
Required schema
id
refund_id
amount_cents
currency
payout_type
payment_method
status
external_reference
check_number
check_date
evidence_path
idempotency_key
processed_by
processed_at
reversed_payout_id
failure_code
failure_message
created_at
updated_at
Required constraints
UNIQUE(idempotency_key)
FOREIGN KEY(refund_id)
FOREIGN KEY(reversed_payout_id)
amount_cents > 0
Payout types
cash_out
account_credit
reversal
Only completed cash_out payouts increase invoice amount due by reducing retained cash.
An account credit must be represented as a credit balance or credit memo, not as cash leaving the organization.
Migration
Convert existing refund_paid_amount values into legacy payout rows.
Preserve original:
- Payment method
- Check number
- External reference
- Date
- User
Where details are missing, mark the payout as legacy_import rather than inventing evidence.
Aggregate fields
refund_paid_amount may remain temporarily as a cached projection, but it must not be the source of truth.
Acceptance criteria
Every partial payout is a separate immutable row.
F-011: Implement idempotent refund payout processing
Required input
Every payout request must contain an idempotency key.
For browser requests, generate an operation UUID before submission and reuse it for retries.
Required transaction
BEGIN
check idempotency key
if payout exists:
return existing result
SELECT refund FOR UPDATE
verify status is approved or partially_paid
SELECT source records FOR UPDATE
recalculate current refundable amount
recalculate unpaid approved refund remainder
verify payout amount <= both values
insert refund_payout as processing
perform or register payout operation
update payout to completed
derive refund status from payout totals
recalculate invoice ledger
insert audit event
COMMIT
Required status derivation
completed payouts = 0
→ approved
0 < completed payouts < approved amount
→ partially_paid
completed payouts = approved amount
→ paid
Never allow:
completed payouts > approved amount
Acceptance criteria
Submitting the same payout request repeatedly produces one payout row and one financial effect.
F-012: Remove invoice fallback during payout
Required change
Delete logic that attaches an unassigned refund to the latest invoice.
If no eligible invoice or source credit exists:
reject payout
Do not guess.
Acceptance criteria
A refund with no valid credit source cannot be paid.
F-013: Prevent refund target below already-paid amount
Required rule
When recalculating an approved refund:
new approved amount >= completed payout total
If current credit falls below already-paid amount:
- Do not reduce the approved amount below the paid amount.
- Mark the refund as an exception requiring reconciliation.
- Create a recovery or adjustment workflow if money was over-refunded.
Acceptance criteria
The database cannot contain:
completed payout total > approved amount
Add a reconciliation query and automated alert.
F-014: Implement refund reversals
Paid payout rows cannot be edited or deleted.
To reverse:
- Lock original payout.
- Verify it is completed.
- Verify unreversed amount.
- Create a reversal payout referencing the original.
- Recalculate refund status.
- Recalculate invoice ledger.
- Record reason and authorizer.
Acceptance criteria
Original payout history remains unchanged.
Work Package 8: Fix Refund Summaries and Parent Recalculation
F-015: Replace raw refund summary queries
Current defects
Refund summaries:
- Use
SUM(amount)instead of canonical payment amount. - Ignore discounts in some parent calculations.
- Use different formulas for parent-level and invoice-level credit.
Required change
Delete direct summary arithmetic from RefundController.
Use:
InvoiceLedgerService::getInvoiceProjection($invoiceId)
ParentLedgerService::getParentProjection($parentId, $schoolYear)
RefundEligibilityService::calculateAvailableCredit(...)
Parent projection must include
net invoice charges
valid payments
completed cash refunds
customer credit
approved refund reservations
available refundable credit
Acceptance criteria
Parent summary, invoice summary, refund request screen, and refund payout validation show identical available-credit values.
Work Package 9: Fix Refund Persistence and Error Handling
F-016: Check every financial write
Every call to:
insert()
update()
save()
delete()
must have its return value checked.
Required pattern:
if (!$model->update($id, $data)) {
throw new FinancialPersistenceException(
'REFUND_UPDATE_FAILED',
$model->errors()
);
}
After all writes:
if ($db->transStatus() === false) {
throw new FinancialPersistenceException('TRANSACTION_FAILED');
}
Apply to
- Refund updates
- Refund payout inserts
- Invoice ledger projection update
- Discount usage
- Voucher usage count
- Expense insert/update
- Reimbursement insert/update
- Batch updates
- Purchase-order receiving
- Inventory updates
- Financial audit records
Acceptance criteria
No financial endpoint returns success after a failed model write.
F-017: Handle uploaded evidence safely
Current defect
Refund evidence is stored before transaction success, leaving orphaned files after rollback.
Required flow
- Validate upload.
- Store in temporary location.
- Begin transaction.
- Create payout.
- Commit transaction.
- Move file to permanent location.
- If move fails, mark payout evidence as incomplete and alert operations.
Alternatively, use durable object storage with temporary status and finalize after commit.
On rollback, delete temporary upload.
Acceptance criteria
Failed payout attempts leave no permanent orphaned evidence files.
F-018: Stop leaking internal exceptions
Required change
Client response:
{
"success": false,
"code": "REFUND_PAYOUT_FAILED",
"message": "The refund could not be processed."
}
Server log:
- Full exception
- Stack trace
- Request ID
- Refund ID
- User ID
- Idempotency key
Acceptance criteria
Database messages, SQL, file paths, and stack traces never appear in client responses.
Work Package 10: Fix Routes, Authorization, and API Bypasses
F-019: Protect every financial route explicitly
Every financial route must require:
- Authentication
- Exact permission
- Correct HTTP method
- CSRF protection for browser writes
- Ownership or organizational scope validation
Permissions
invoice.view
invoice.create
invoice.adjust
payment.view
payment.create
payment.reverse
refund.view
refund.request
refund.approve
refund.pay
refund.reverse
discount.apply
discount.manage
expense.manage
expense.approve
reimbursement.create
reimbursement.approve
reimbursement.pay
purchase_order.manage
financial_report.view
High-risk routes to inspect immediately
- Invoice status changes
- Invoice generation
- Manual payment
- Payment balance update
- Refund request
- Refund approval
- Refund payout
- Refund status update
- Expense update
- Reimbursement process
- Reimbursement batch lock
- PO receiving
- Inventory adjustment
Acceptance criteria
Anonymous users receive 401.
Authenticated users without permission receive 403.
F-020: Remove generic CRUD bypasses
Required repository audit
Inspect generic /api/v1 endpoints and any CRUD controllers for direct access to:
- Payments
- Refunds
- Expenses
- Reimbursements
- Discounts
- Additional charges
- Invoices
Required rule
Financial models cannot be mutated through generic CRUD.
Allowed approaches:
- Remove financial entities from generic CRUD routing.
- Make financial models read-only through generic APIs.
- Route commands through approved financial services.
Acceptance criteria
There is no API endpoint that can directly set:
invoice balance
invoice status
payment status
refund paid amount
refund approved amount
reimbursement amount
expense approval
discount applied amount
without domain validation.
F-021: Add route integrity tests
Automated test must verify:
- Controller class exists.
- Method exists.
- Route parameters match method signature.
- State-changing routes do not use GET.
- Required filters are attached.
Work Package 11: Fix Discounts and Vouchers
F-022: Validate voucher eligibility inside a locked transaction
Required checks
- Voucher exists.
- Voucher is active.
- Current date is within validity range.
- School year matches.
- Semester matches.
- Parent and invoice are eligible.
- Usage limit remains.
- Voucher has not already been applied to the invoice.
- Discount amount is positive.
Transaction
BEGIN
SELECT voucher FOR UPDATE
SELECT invoice FOR UPDATE
recheck validity
recheck usage count
calculate eligible invoice base
calculate actual applied amount
insert discount usage
increment voucher usage
recalculate ledger
COMMIT
Acceptance criteria
Two simultaneous requests cannot consume the last voucher use twice.
F-023: Store actual applied discount
Store:
requested_discount_cents
eligible_base_cents
applied_discount_cents
Reports must use applied_discount_cents.
Formula
applied discount =
min(requested discount, remaining eligible base)
Percentage discounts must apply only to eligible lines.
Acceptance criteria
The amount shown on the invoice, voucher usage, and financial report is identical.
F-024: Define multiple-discount ordering
Recommended deterministic order:
- Approved credit memo
- Fixed discount
- Percentage discount
- Administrative adjustment
Every discount applies to the remaining eligible base.
Database constraint
Prevent duplicate voucher use on the same invoice unless the voucher explicitly supports repeated application.
Required tests
- Two fixed discounts.
- Fixed plus percentage.
- Two percentages.
- Discount exceeds eligible base.
- Event fees excluded from eligibility.
- Concurrent final voucher use.
F-025: Dispatch events only after commit
Do not trigger notifications or integration events before the transaction commits.
Use an outbox record or post-commit dispatch.
Work Package 12: Fix Additional Charges
F-026: Derive ownership from invoice
When an invoice_id is provided:
- Load invoice.
- Derive parent, school year, and semester from invoice.
- Ignore or reject conflicting request values.
Acceptance criteria
A charge cannot be saved for parent A while recalculating parent B’s invoice.
F-027: Use one amount-sign model
Recommended storage:
amount_cents is always nonnegative
charge_type = add | deduct
Ledger interpretation:
add → +amount
deduct → -amount
Creation and editing must use the same normalization.
Database constraints
amount_cents >= 0
charge_type IN ('add', 'deduct')
F-028: Make applied charges immutable
Once a charge generates an issued invoice line:
- Do not edit it directly.
- Do not change its sign.
- Do not change its amount.
- Do not delete it.
Correction requires:
- Void original charge line.
- Create replacement adjustment line.
- Record reason and user.
F-029: Complete pending-charge workflow
Statuses:
pending
approved
applied
rejected
voided
Implement explicit commands for approval and application.
A pending charge must not remain indefinitely without a valid workflow.
Work Package 13: Fix Expenses
F-030: Make expense creation atomic with receipt persistence
Required flow
- Validate request.
- Upload receipt to temporary storage.
- Begin transaction.
- Insert expense.
- Commit.
- Finalize receipt.
- On failure, remove temporary file.
Check the insert result.
School-year validation
Require one canonical format:
YYYY-YYYY
Do not fall back to a single four-digit year.
F-031: Make reimbursed expenses immutable
If an expense has a paid or active reimbursement:
Block direct changes to:
- Amount
- Category
- Purchaser
- School year
- Receipt
- Donation flag
Correction requires reversal and replacement.
Donation conversion
When changing a donation into a reimbursable expense:
- Reset approval status to pending.
- Clear approver.
- Clear approval timestamp.
- Require the normal approval workflow.
Work Package 14: Fix Reimbursements
F-032: Make reimbursement creation atomic
Transaction
BEGIN
SELECT expense FOR UPDATE
verify expense exists
verify approved
verify positive amount
verify not donation
verify not already reimbursed
verify correct recipient
verify correct accounting period
insert reimbursement
link expense
insert audit event
COMMIT
Database constraint
One active reimbursement per expense.
Use either:
- A unique
expense_idin reimbursements, or - A filtered/derived uniqueness strategy supported by the database.
F-033: Disable the legacy reimbursement process endpoint
The weaker legacy process() route must be removed or rewritten to call the same reimbursement service.
No separate validation path may remain.
F-034: Make paid reimbursements immutable
Paid reimbursement fields cannot be overwritten.
Correction requires:
- Reversal record
- Replacement reimbursement
- Link to original transaction
- Reason and authorization
F-035: Make reimbursement batches all-or-nothing
Required transaction
- Lock batch.
- Verify batch is open.
- Lock all active batch items.
- Lock all associated expenses.
- Validate every item.
- Create every reimbursement.
- Verify every item succeeded.
- Close batch.
- Commit.
Do not skip invalid entries.
If one item fails, roll back the entire batch.
Batch number
Replace MAX + 1 without locking.
Use:
- Locked yearly sequence row, and
- Unique database constraint on year plus sequence.
Work Package 15: Fix Purchase Orders and Inventory
F-036: Validate purchase-order values
Required constraints:
quantity > 0
unit_cost_cents >= 0
received_quantity >= 0
received_quantity <= ordered_quantity
Reject negative unit cost.
F-037: Make receiving atomic
Required transaction
- Lock purchase order.
- Lock every affected PO item.
- Verify supply exists before changing received quantity.
- Lock inventory supply row.
- Increment inventory atomically.
- Insert inventory transaction.
- Update received quantity.
- Query all PO lines.
- Mark PO received only when all lines are complete.
- Commit.
Do not calculate completion from submitted rows only.
Acceptance criteria
Omitting one incomplete line from the request cannot mark the PO fully received.
F-038: Handle cancellation after partial receipt
Do not allow direct cancellation after inventory was received.
Required options:
- Reject cancellation, or
- Create inventory reversal transactions before cancellation.
Never silently remove the financial or inventory history.
Work Package 16: Normalize Statuses
F-039: Create canonical machine statuses
Invoice
draft
issued
unpaid
partially_paid
paid
credited
voided
Refund
requested
approved
partially_paid
paid
rejected
cancelled
reversed
exception
Reimbursement
pending
approved
paid
rejected
reversed
Payment
Use one documented status set and one normalization map.
Required change
- Migrate legacy display-style values.
- Update every query.
- Update every model validation rule.
- Remove arbitrary status-update endpoints.
- Derive invoice status from ledger values.
Database enforcement
Use enum or check constraints where supported.
Work Package 17: Replace Independent Report Arithmetic
F-040: Build report projections from the canonical ledger
Reports must not sum raw mutable fields independently.
Create queryable projections for:
invoice gross charges
applied discounts
net charges
valid payments
completed cash refunds
amount due
customer credit
approved refund reservations
available refundable credit
reimbursed expense amount
outstanding reimbursement amount
Required report corrections
- Use actual applied discount, not requested discount.
- Use completed payout rows, not
refund_paid_amount. - Use valid payment status filters.
- Use frozen invoice lines.
- Use canonical customer credit.
Acceptance criteria
For every invoice:
invoice screen total
payment screen total
refund screen total
financial report total
API total
must match exactly.
Work Package 18: Resolve the Detached Payment-Transaction Subsystem
F-041: Decide whether to remove or integrate it
Inspect PaymentTransactionController and PaymentTransactionModel.
Choose one:
Remove
Delete unused controller, model, and routes if the subsystem is obsolete.
Integrate
If required:
- Add explicit routes.
- Validate amount greater than zero.
- Validate referenced payment exists.
- Persist every accepted field.
- Restrict statuses.
- Exclude invalid statuses from totals.
- Route changes through the canonical payment service.
Do not leave unreachable or partially implemented financial code in the application.
Work Package 19: Database Constraints and Indexes
F-042: Add database-level financial invariants
Required constraints should include:
- Unique invoice identity.
- Unique refund payout idempotency key.
- One active reimbursement per expense.
- Unique voucher use per invoice where required.
- Unique batch sequence per year.
- Positive payout amounts.
- Nonnegative charge storage amounts.
- Valid status values.
- Foreign keys for financial relationships.
- Unique external payment/refund references where appropriate.
Application checks are not enough. Requests can race, imports can bypass controllers, and future developers will eventually discover confidence.
Work Package 20: Historical Reconciliation
F-043: Produce a correction report before migration
For every invoice calculate:
stored total
canonical frozen charge total
valid payment total
completed refund payout total
applied discount total
expected balance due
expected customer credit
Flag:
- Wrong refund sign
- Invalid status
- Duplicate invoice
- Excess refund
- Refund without source
- Refund payout above approval
- Discount above eligible base
- Reimbursement mismatch
- Paid record edited later
- Negative or impossible values
Correction policy
Do not silently overwrite history.
Use:
- Adjustment entry
- Credit memo
- Debit adjustment
- Reversal
- Replacement transaction
Record who approved every correction.
Work Package 21: Automated Test Suite
F-044: Unit tests
Required formula tests:
- No payment
- Partial payment
- Exact payment
- Overpayment
- Partial refund
- Full refund
- Multiple refund payouts
- Refund reversal
- Failed payment
- Reversed payment
- Chargeback
- Fixed discount
- Percentage discount
- Discount cap
- Multiple-discount ordering
- Additional charge
- Deduction
- Cent-rounding boundaries
F-045: Integration tests
Required workflows:
- Invoice → payment → refund
- Invoice → discount → payment → refund
- Overpayment → request → approval → payout
- Duplicate payment refund
- Stale refund approval
- Stale refund payout
- Failed database write rollback
- Uploaded evidence rollback
- Historical invoice after tuition change
- Historical invoice after enrollment change
- Reimbursement creation
- Reimbursement reversal
- PO partial receiving
- Inventory update failure
- Report-to-ledger equality
F-046: Concurrency tests
Run simultaneous requests for:
- Duplicate invoice generation
- Final voucher usage
- Refund approval
- Refund payout
- Reimbursement creation
- Reimbursement batch closing
- PO receiving
- Inventory increment
Expected outcomes:
- No duplicate financial event.
- No over-refund.
- No partial transaction.
- No lost inventory update.
- One valid winner where exclusivity is required.
F-047: Authorization tests
Test every financial route for:
- Anonymous access
- Authenticated user without permission
- Wrong parent or organization
- Invalid CSRF token
- Wrong HTTP method
- Invalid status transition
- Invalid payment method
- Direct generic API mutation
F-048: Repository regression checks
Add CI checks that fail when financial formulas appear outside approved services.
Search patterns should include:
refund_paid_amount
SUM(paid_amount)
SUM(amount)
newBalance
balance =
total - discount
total - paid
Partially Paid
Unpaid
Paid
The check should use an allowlist for migrations and canonical repositories.
Work Package 22: Production Acceptance Gate
The system is not production-ready until all answers below are yes.
Accounting
- Is there one invoice balance formula?
- Do cash refunds reduce retained cash correctly?
- Are amount due and customer credit mutually exclusive?
- Are issued invoice charges immutable?
- Do reports match the ledger?
Refunds
- Does every refund reference a valid source?
- Is eligibility revalidated at request, approval, and payout?
- Are approved amounts reserved?
- Can concurrent requests over-approve credit?
- Can concurrent requests pay twice?
- Are payouts immutable?
- Is payout retry idempotent?
- Can a paid refund be reversed without editing history?
- Is latest-invoice fallback removed?
Persistence
- Does every failed write roll back?
- Are model return values checked?
- Are transaction results checked?
- Are database constraints present?
- Are uploads cleaned up after failure?
Security
- Is every financial route authenticated?
- Is every write permission-protected?
- Are generic CRUD bypasses removed?
- Are ownership checks enforced?
- Are internal exceptions hidden?
Other financial modules
- Are discounts locked and capped?
- Are applied charges immutable?
- Are reimbursed expenses immutable?
- Are reimbursements atomic?
- Are reimbursement batches all-or-nothing?
- Is PO receiving atomic?
- Can partially received orders be cancelled safely?
Verification
- Do unit tests pass?
- Do integration tests pass?
- Do concurrency tests pass?
- Do authorization tests pass?
- Does historical reconciliation have zero unexplained differences?
Required Implementation Order
Implement in this exact order:
- F-001 through F-004: remove duplicate formulas and centralize payment status rules.
- F-005: freeze issued invoice values.
- F-006 through F-018: rebuild refund source, approval, payout, reversal, persistence, and summaries.
- F-019 through F-021: secure routes and remove API bypasses.
- F-022 through F-025: repair discounts.
- F-026 through F-029: repair additional charges.
- F-030 through F-035: repair expenses and reimbursements.
- F-036 through F-038: repair purchase orders and inventory.
- F-039 through F-042: normalize statuses and add database constraints.
- F-043: reconcile historical data.
- F-044 through F-048: complete automated verification.
- Run the production acceptance gate.