# 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: ```php 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: ```text 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: ```php 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: ```php 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: ```sql 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: ```text gross_charge_cents discount_eligible_base_cents requested_discount_cents applied_discount_cents net_charge_cents ``` Calculate eligible base directly from invoice lines: ```text 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: ```text 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: ```text requested_discount_cents eligible_base_before_cents applied_discount_cents application_order ``` The authoritative invoice discount total must be: ```text 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 ```text 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 ```text 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 ```text 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: ```text 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: ```text payment_corrections - id - payment_id - correction_type - approved_refundable_cents - status - reason - approved_by - approved_at ``` Allowed correction types may include: ```text 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: ```text 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: ```text 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: ```text 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 ```text 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: ```text 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: ```php 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: ```text 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 ```text draft issued unpaid partially_paid paid credited voided ``` #### Refund ```text requested approved partially_paid paid rejected cancelled partially_reversed reversed exception ``` #### Reimbursement ```text pending approved paid rejected reversed ``` #### Payout ```text 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 ```text 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: ```text 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: ```text 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: ```text 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: ```text 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: ```text 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.