1800 lines
34 KiB
Markdown
1800 lines
34 KiB
Markdown
# 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:
|
||
|
||
```php
|
||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||
```
|
||
|
||
Cash refunds are incorrectly subtracted.
|
||
|
||
The same method writes display-style statuses:
|
||
|
||
```text
|
||
Paid
|
||
Partially Paid
|
||
Unpaid
|
||
```
|
||
|
||
instead of canonical machine statuses.
|
||
|
||
### Required change
|
||
|
||
Delete the local balance calculation.
|
||
|
||
After creating or updating invoice data, call only:
|
||
|
||
```php
|
||
$ledger = $invoiceLedgerService->recalculate($invoiceId);
|
||
```
|
||
|
||
Use the returned values:
|
||
|
||
```php
|
||
$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
|
||
|
||
```text
|
||
raw balance =
|
||
net charges
|
||
- valid payments
|
||
+ completed cash refunds
|
||
```
|
||
|
||
```text
|
||
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:
|
||
|
||
```php
|
||
[
|
||
'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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```php
|
||
InvoiceLedgerService::getValidPaymentTotalCents(int $invoiceId): int
|
||
```
|
||
|
||
Only include successful financial states.
|
||
|
||
Suggested included states:
|
||
|
||
```text
|
||
successful
|
||
completed
|
||
paid
|
||
```
|
||
|
||
Suggested excluded states:
|
||
|
||
```text
|
||
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`:
|
||
|
||
```text
|
||
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:
|
||
|
||
1. Calculate tuition and charges once.
|
||
2. Save immutable invoice lines.
|
||
3. Calculate totals only from those lines.
|
||
4. 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_total` line 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
|
||
|
||
```text
|
||
invoice_overpayment
|
||
payment_duplicate
|
||
payment_correction
|
||
credit_memo
|
||
administrative_credit
|
||
```
|
||
|
||
Each refund must store:
|
||
|
||
```text
|
||
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:
|
||
|
||
```php
|
||
RefundEligibilityService
|
||
```
|
||
|
||
Required methods:
|
||
|
||
```php
|
||
calculateAvailableCredit(
|
||
int $parentId,
|
||
?int $invoiceId,
|
||
string $sourceType,
|
||
int $sourceId
|
||
): RefundEligibilityResult
|
||
```
|
||
|
||
```php
|
||
validateRequestedAmount(
|
||
RefundEligibilityResult $eligibility,
|
||
int $requestedAmountCents
|
||
): void
|
||
```
|
||
|
||
### Calculation
|
||
|
||
```text
|
||
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
|
||
|
||
```php
|
||
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
|
||
|
||
1. Validate authentication and permission.
|
||
2. Validate source type and source ID.
|
||
3. Load the source.
|
||
4. Verify source ownership.
|
||
5. Calculate current eligibility.
|
||
6. Reject zero or negative amounts.
|
||
7. Reject amounts above available credit.
|
||
8. Create the refund request.
|
||
9. 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
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
UNIQUE(idempotency_key)
|
||
FOREIGN KEY(refund_id)
|
||
FOREIGN KEY(reversed_payout_id)
|
||
amount_cents > 0
|
||
```
|
||
|
||
### Payout types
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
completed payouts = 0
|
||
→ approved
|
||
|
||
0 < completed payouts < approved amount
|
||
→ partially_paid
|
||
|
||
completed payouts = approved amount
|
||
→ paid
|
||
```
|
||
|
||
Never allow:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
1. Lock original payout.
|
||
2. Verify it is completed.
|
||
3. Verify unreversed amount.
|
||
4. Create a reversal payout referencing the original.
|
||
5. Recalculate refund status.
|
||
6. Recalculate invoice ledger.
|
||
7. 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:
|
||
|
||
```php
|
||
InvoiceLedgerService::getInvoiceProjection($invoiceId)
|
||
ParentLedgerService::getParentProjection($parentId, $schoolYear)
|
||
RefundEligibilityService::calculateAvailableCredit(...)
|
||
```
|
||
|
||
### Parent projection must include
|
||
|
||
```text
|
||
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:
|
||
|
||
```php
|
||
insert()
|
||
update()
|
||
save()
|
||
delete()
|
||
```
|
||
|
||
must have its return value checked.
|
||
|
||
Required pattern:
|
||
|
||
```php
|
||
if (!$model->update($id, $data)) {
|
||
throw new FinancialPersistenceException(
|
||
'REFUND_UPDATE_FAILED',
|
||
$model->errors()
|
||
);
|
||
}
|
||
```
|
||
|
||
After all writes:
|
||
|
||
```php
|
||
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
|
||
|
||
1. Validate upload.
|
||
2. Store in temporary location.
|
||
3. Begin transaction.
|
||
4. Create payout.
|
||
5. Commit transaction.
|
||
6. Move file to permanent location.
|
||
7. 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:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
requested_discount_cents
|
||
eligible_base_cents
|
||
applied_discount_cents
|
||
```
|
||
|
||
Reports must use `applied_discount_cents`.
|
||
|
||
### Formula
|
||
|
||
```text
|
||
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:
|
||
|
||
1. Approved credit memo
|
||
2. Fixed discount
|
||
3. Percentage discount
|
||
4. 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:
|
||
|
||
1. Load invoice.
|
||
2. Derive parent, school year, and semester from invoice.
|
||
3. 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:
|
||
|
||
```text
|
||
amount_cents is always nonnegative
|
||
charge_type = add | deduct
|
||
```
|
||
|
||
Ledger interpretation:
|
||
|
||
```text
|
||
add → +amount
|
||
deduct → -amount
|
||
```
|
||
|
||
Creation and editing must use the same normalization.
|
||
|
||
### Database constraints
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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
|
||
|
||
1. Validate request.
|
||
2. Upload receipt to temporary storage.
|
||
3. Begin transaction.
|
||
4. Insert expense.
|
||
5. Commit.
|
||
6. Finalize receipt.
|
||
7. On failure, remove temporary file.
|
||
|
||
Check the insert result.
|
||
|
||
### School-year validation
|
||
|
||
Require one canonical format:
|
||
|
||
```text
|
||
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
|
||
|
||
```text
|
||
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_id` in 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
|
||
|
||
1. Lock batch.
|
||
2. Verify batch is open.
|
||
3. Lock all active batch items.
|
||
4. Lock all associated expenses.
|
||
5. Validate every item.
|
||
6. Create every reimbursement.
|
||
7. Verify every item succeeded.
|
||
8. Close batch.
|
||
9. 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:
|
||
|
||
```text
|
||
quantity > 0
|
||
unit_cost_cents >= 0
|
||
received_quantity >= 0
|
||
received_quantity <= ordered_quantity
|
||
```
|
||
|
||
Reject negative unit cost.
|
||
|
||
---
|
||
|
||
## F-037: Make receiving atomic
|
||
|
||
### Required transaction
|
||
|
||
1. Lock purchase order.
|
||
2. Lock every affected PO item.
|
||
3. Verify supply exists before changing received quantity.
|
||
4. Lock inventory supply row.
|
||
5. Increment inventory atomically.
|
||
6. Insert inventory transaction.
|
||
7. Update received quantity.
|
||
8. Query all PO lines.
|
||
9. Mark PO received only when all lines are complete.
|
||
10. 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
|
||
|
||
```text
|
||
draft
|
||
issued
|
||
unpaid
|
||
partially_paid
|
||
paid
|
||
credited
|
||
voided
|
||
```
|
||
|
||
### Refund
|
||
|
||
```text
|
||
requested
|
||
approved
|
||
partially_paid
|
||
paid
|
||
rejected
|
||
cancelled
|
||
reversed
|
||
exception
|
||
```
|
||
|
||
### Reimbursement
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
1. F-001 through F-004: remove duplicate formulas and centralize payment status rules.
|
||
2. F-005: freeze issued invoice values.
|
||
3. F-006 through F-018: rebuild refund source, approval, payout, reversal, persistence, and summaries.
|
||
4. F-019 through F-021: secure routes and remove API bypasses.
|
||
5. F-022 through F-025: repair discounts.
|
||
6. F-026 through F-029: repair additional charges.
|
||
7. F-030 through F-035: repair expenses and reimbursements.
|
||
8. F-036 through F-038: repair purchase orders and inventory.
|
||
9. F-039 through F-042: normalize statuses and add database constraints.
|
||
10. F-043: reconcile historical data.
|
||
11. F-044 through F-048: complete automated verification.
|
||
12. Run the production acceptance gate.
|