579 lines
35 KiB
Markdown
579 lines
35 KiB
Markdown
# Withdrawal Refund and Book Inventory Implementation Plan
|
||
|
||
## 1. Objective
|
||
|
||
Replace the current “unused weeks remaining” refund calculation with an earned-charge calculation that:
|
||
|
||
1. Retains the snapshotted price of the actual books issued to the student and the tuition earned for the number of chargeable weeks elapsed before withdrawal.
|
||
2. Refunds only money actually paid beyond the parent’s recalculated obligations.
|
||
3. Leaves an outstanding balance when the amount paid is less than the earned charge.
|
||
4. Works correctly for siblings, discounts, other invoice charges, partial refunds, and prior payouts.
|
||
5. Preserves an auditable snapshot so a later configuration, date, inventory, or price change cannot silently alter an approved refund.
|
||
6. Carries verified leftover book stock into the next school year without duplicating physical quantity.
|
||
|
||
This plan is adapted to the current CodeIgniter project, especially:
|
||
|
||
- `app/Services/FeeCalculationService.php`
|
||
- `app/Services/EnrollmentWithdrawalService.php`
|
||
- `app/Controllers/View/ParentController.php`
|
||
- `app/Controllers/View/RefundController.php`
|
||
- `app/Controllers/View/InventoryController.php`
|
||
- `app/Libraries/InvoiceLedgerService.php`
|
||
- `app/Libraries/RefundEligibilityService.php`
|
||
- `app/Libraries/Tuition/OldTuitionCalculatorService.php`
|
||
- `app/Libraries/Tuition/NewTuitionCalculatorService.php`
|
||
- `app/Models/EnrollmentModel.php`
|
||
- `app/Models/RefundModel.php`
|
||
- `app/Models/InventoryItemModel.php`
|
||
- `app/Models/InventoryMovementModel.php`
|
||
- `app/Services/SchoolYearClosingService.php`
|
||
- `app/Views/refunds/list.php`
|
||
- `app/Views/enroll_withdraw/enrollment_withdrawal.php`
|
||
- `app/Views/inventory/book/form.php`
|
||
- `app/Views/inventory/book/index.php`
|
||
- `app/Views/inventory/teacher_distribute.php`
|
||
|
||
## 2. Locked annual-fee policy
|
||
|
||
Annual tuition includes books. The refund must use the prices snapshotted when books were actually issued to the student, not a flat fee and not the book's current editable price. Dividing the full annual fee by the number of weeks and then adding issued-book prices would count books twice.
|
||
|
||
Locked rule:
|
||
|
||
- Require a school-year-specific student charge price for every distributable book.
|
||
- Snapshot that price on every student book issue.
|
||
- Sum the active issue snapshots for the withdrawing student.
|
||
- Treat the existing annual student fee as inclusive of books.
|
||
- Subtract issued-book charges from the annual allocation before prorating the instruction component:
|
||
|
||
```text
|
||
issued_book_charge_cents = sum(active student book issue price snapshots)
|
||
annual_instruction_cents = annual_student_fee_cents - issued_book_charge_cents
|
||
earned_tuition_cents = round(annual_instruction_cents * studied_weeks / total_instructional_weeks)
|
||
retained_charge_cents = issued_book_charge_cents + earned_tuition_cents
|
||
```
|
||
|
||
Do not implement `full annual fee / total weeks + issued book prices`; it would make the full-year retained charge greater than the annual fee. If issued-book prices exceed the student’s annual allocation, block automatic calculation and require financial review rather than producing a negative instruction component.
|
||
|
||
## 3. Locked calculation rules
|
||
|
||
### 3.1 Money representation
|
||
|
||
Perform all calculations in integer cents. Do not calculate refunds with floats. Round once when calculating the prorated earned tuition:
|
||
|
||
```text
|
||
earned_tuition_cents = round(annual_instruction_cents * studied_weeks / total_instructional_weeks)
|
||
```
|
||
|
||
Do not round a displayed one-week fee and multiply it, because that can make all weeks add up to more or less than the annual fee.
|
||
|
||
### 3.2 Studied weeks and days
|
||
|
||
Treat the school-year value identified by the key `total_instructional_weeks` as authoritative. It already reflects the school’s instructional-year calculation, including its treatment of `no_school` dates. The refund calculator must not query calendar events or subtract `no_school` dates again.
|
||
|
||
Derive the chargeable duration and studied weeks as follows:
|
||
|
||
```text
|
||
total_chargeable_days = total_instructional_weeks * 7
|
||
charge_start_date = max(school_year_start_date, student_enrollment_date)
|
||
studied_calendar_days = inclusive_days(charge_start_date, withdrawal_request_date)
|
||
studied_calendar_days = clamp(studied_calendar_days, 0, total_chargeable_days)
|
||
studied_weeks = min(total_instructional_weeks, ceil(studied_calendar_days / 7))
|
||
```
|
||
|
||
- Use the immutable withdrawal request date, not the later admin approval date.
|
||
- Count elapsed calendar days; do not inspect attendance or `calendar_events.no_school`.
|
||
- Any started seven-day period is charged as one studied week. This keeps a student who starts and withdraws during the first week at a one-week charge.
|
||
- Cap the result at `total_instructional_weeks`, even if the withdrawal date is later than the derived chargeable duration.
|
||
- Reject a missing, non-integer, or non-positive `total_instructional_weeks`; do not fall back to `weeks_study`.
|
||
- Preserve an admin override only for incorrect enrollment or withdrawal dates, with a required reason and audit user. The override must not change `total_instructional_weeks` for an already posted calculation.
|
||
|
||
The current `weeks_study` value, which defaults to `8`, must not be used by the refund calculator. Add or confirm the clearly named, school-year-scoped `total_instructional_weeks` value and snapshot both it and the derived `total_chargeable_days` in every calculation.
|
||
|
||
### 3.3 Book charge
|
||
|
||
Use actual student book issues rather than a configured flat fee:
|
||
|
||
```text
|
||
issued_book_charge_cents =
|
||
sum(non-reversed issue quantity * issue unit charge price snapshot)
|
||
```
|
||
|
||
- Include only issues for the same student and school year that occurred on or before the effective withdrawal date.
|
||
- An inventory movement alone is insufficient financial evidence because it has no price snapshot. Every distribution must create an immutable `student_book_issue` record linked to its stock movement.
|
||
- Price changes affect future issues only. They must never alter a prior issue or refund calculation.
|
||
- Books are not returnable. Once a book is issued, its full snapshotted price remains chargeable and the copy never re-enters available stock.
|
||
- Allow only a controlled admin correction when an issue record was created in error. Record a linked reversal with the reason and audit user; never delete the original issue. This correction restores stock and removes the mistaken issue from the charge, but it is not a book return or parent refund credit.
|
||
- If an issue correction occurs after a withdrawal calculation is posted, mark the calculation `requires_review`; never silently change an approved or paid refund.
|
||
- Prevent the same issue from being included twice after refund recalculation or repeated enrollment status changes.
|
||
|
||
### 3.4 Family tuition tier
|
||
|
||
The project charges the first student one amount and additional students another amount. A withdrawal cannot be calculated safely from the identity of the student alone.
|
||
|
||
Continue the existing reverse-stack principle:
|
||
|
||
```text
|
||
withdrawn annual allocation = original family tuition - full-year tuition for remaining active siblings
|
||
```
|
||
|
||
Examples with first/additional fees of `$380/$280`:
|
||
|
||
- Two students, one withdraws: the withdrawal allocation is `$280`; the remaining student still carries the `$380` tier.
|
||
- Three students, one withdraws: the first withdrawal allocation is `$280`.
|
||
- When the final remaining student withdraws: that allocation is `$380`.
|
||
|
||
For multiple withdrawals submitted together, order by withdrawal request date and then student ID, and save the resulting annual allocation. Never recalculate a posted student allocation merely because another sibling withdraws later.
|
||
|
||
### 3.5 Parent-level refund and balance
|
||
|
||
Payments are invoice-level, not student-level. Do not invent a student payment allocation. First adjust the invoice to the correct charge, then let the invoice ledger determine whether the parent has a credit or a balance.
|
||
|
||
```text
|
||
net_payment_cents = valid_payment_cents - completed_refund_payout_cents
|
||
refundable_credit_cents = max(0, net_payment_cents - adjusted_net_charge_cents)
|
||
balance_due_cents = max(0, adjusted_net_charge_cents - net_payment_cents)
|
||
new_refund_request_cents = max(0, refundable_credit_cents - open_refund_reservations_cents)
|
||
```
|
||
|
||
This implements the requested behavior:
|
||
|
||
- Fully paid: refund the amount paid beyond books plus studied weeks and all other valid obligations.
|
||
- Partially paid above the recalculated charge: refund only the excess actually paid.
|
||
- Partially paid below the recalculated charge: refund `$0` and retain the remaining balance.
|
||
- Never refund more than valid payments net of earlier completed payouts.
|
||
|
||
Event fees and approved additional charges remain payable unless a separate policy explicitly reverses them. They must not disappear just because tuition is adjusted.
|
||
|
||
### 3.6 Worked example
|
||
|
||
Assume the annual `$380` includes books, the student's actual issue records contain two books priced at `$30` and `$20`, and there are 30 instructional weeks:
|
||
|
||
```text
|
||
total chargeable days = 30 * 7 = 210
|
||
issued book charge = $30 + $20 = $50
|
||
instruction component = $380 - $50 = $330
|
||
one-week earned tuition = $330 / 30 = $11
|
||
one-week retained charge = $50 + $11 = $61
|
||
```
|
||
|
||
Elapsed days 1–7 produce one studied week; day 8 starts the second studied week. A `no_school` event inside that elapsed period does not change either value.
|
||
|
||
| Valid payments | Adjusted charge | Refund | Balance due |
|
||
| ---: | ---: | ---: | ---: |
|
||
| `$380` | `$61` | `$319` | `$0` |
|
||
| `$100` | `$61` | `$39` | `$0` |
|
||
| `$50` | `$61` | `$0` | `$11` |
|
||
|
||
If the student received only the `$30` book before withdrawal, the book charge is `$30`, not `$50`, and the instruction component is `$380 - $30 = $350`.
|
||
|
||
## 4. Required design
|
||
|
||
### 4.1 Add school-year refund policy fields
|
||
|
||
Use the existing configuration key name `total_instructional_weeks`. Do not introduce a second independently editable setting with a different name. Because the current `configuration` lookup follows the active year and is unsafe for historical calculations, create a migration adding the following durable policy snapshots to `school_years`:
|
||
|
||
- `total_instructional_weeks` unsigned small integer, required before activation.
|
||
- `annual_fee_includes_books` boolean, required to be `1` for this policy and snapshotted for audit; do not expose an unsupported “books excluded” calculation mode.
|
||
- `withdrawal_policy_version` short string, for example `studied_weeks_v1`.
|
||
|
||
Update `SchoolYearModel`, `SchoolYearManagementService`, the administrator school-year form, activation validation, and close/carry-forward logic. Draft years may be edited; active-year policy changes require a reason and must not rewrite existing posted calculations.
|
||
|
||
During migration, seed `school_years.total_instructional_weeks` from `ConfigurationModel::getConfig('total_instructional_weeks')` for the active year after validating it. Thereafter, the configuration UI key must edit the selected school year's field rather than maintain a separate value. Posted withdrawal calculations use their own stored snapshot. Do not read `weeks_study`, derive the value from calendar events, or query `no_school` during refund calculation.
|
||
|
||
### 4.2 Repair the book inventory data model
|
||
|
||
The current inventory code mixes three different concepts in `inventory_items`: a global book catalog, current physical on-hand quantity, and a school-year record. Separate them.
|
||
|
||
#### Global book catalog
|
||
|
||
Keep `inventory_items` as the physical/catalog identity and add or correct:
|
||
|
||
- `author`, because the current form asks for it but the schema/model discard it;
|
||
- `isbn`, `edition`, `sku`, category, and description;
|
||
- `is_active`/`retired_at` so used items are retired instead of deleted;
|
||
- no financial dependence on the item's mutable current price.
|
||
|
||
Do not allow hard deletion of a book with movements or student issues. The current item foreign key cascades movement deletion, which would destroy refund evidence.
|
||
|
||
#### School-year book stock and price
|
||
|
||
Add `inventory_item_years` with:
|
||
|
||
- `id`, `inventory_item_id`, `school_year` and a unique constraint on item/year;
|
||
- `opening_quantity`, `charge_price_cents`, `currency`;
|
||
- `system_closing_quantity`, `counted_closing_quantity`, `variance_quantity`;
|
||
- `status`: `open`, `reconciled`, `carried`, or `closed`;
|
||
- `source_item_year_id`, `closing_batch_id`, lock timestamps and audit users.
|
||
|
||
`charge_price_cents` is the amount retained from the parent when that book is issued. It is not necessarily the school's purchase cost. Require it to be greater than zero before a book can be distributed. Admins must enter it when adding a book and confirm or update it for each new school year.
|
||
|
||
The current `InventoryItemModel` validates `unit_price`, but the migration, `allowedFields`, controller payload, and form do not persist that field. Replace this dead validation with the cents-based year price.
|
||
|
||
#### Operational movements
|
||
|
||
Keep inventory movements as immutable physical events and add:
|
||
|
||
- `inventory_item_year_id`;
|
||
- `idempotency_key` and a unique index;
|
||
- `reversal_of_movement_id`, `status`, and `reversed_at/by`;
|
||
- a source type/id link for a student issue, correction reversal, receipt, close adjustment, or other operation.
|
||
|
||
Do not edit or delete posted distribution movements through the generic movement screen. Correct them with linked reversals. Generic movement updates/deletes currently make financial history mutable.
|
||
|
||
Calculate year on-hand as:
|
||
|
||
```text
|
||
opening quantity + net stock-affecting movements for that item-year
|
||
```
|
||
|
||
Do not sum `initial` movements from every school year. The current `ensureInitialMovementForYear()` plus the all-years `recalcQuantity()` can duplicate stock when a new opening movement is inserted. Migrate one valid opening value into `inventory_item_years`, stop generating quantity-affecting yearly opening movements, and use the latest open item-year as the current quantity source.
|
||
|
||
#### Immutable student book issues
|
||
|
||
Add `student_book_issues` with:
|
||
|
||
- `id`, `student_id`, `enrollment_id`, `parent_id`, `inventory_item_id`, `inventory_item_year_id`;
|
||
- `school_year`, `class_section_id`, `quantity`;
|
||
- `unit_charge_price_cents` and `total_charge_cents` snapshots;
|
||
- `distribution_movement_id`, issue date/user, idempotency key;
|
||
- `status`: `issued` or `reversed`;
|
||
- correction reversal quantity, timestamps, user and required reason.
|
||
|
||
Use a companion `student_book_issue_events` table, or immutable linked issue rows, for correction reversals. Refund calculations query non-reversed issue value through a dedicated service, not raw movement sums. Do not add a return transaction, return credit, or returned-to-stock workflow.
|
||
|
||
The issue price must be copied from `inventory_item_years.charge_price_cents` inside the distribution transaction. Never look up `inventory_items` for a historical refund price.
|
||
|
||
#### Book/class assignment
|
||
|
||
The current book form displays class checkboxes, but `filterItemData()` silently discards them. Either remove that UI or, preferably, add `inventory_book_class_assignments` keyed by book, school year, and class/grade. Use those assignments for distribution filtering; retain category grade ranges as a broader fallback.
|
||
|
||
### 4.3 Fix book creation and distribution workflow
|
||
|
||
In `InventoryController`, the book form, and book list:
|
||
|
||
- add a required price input rendered as dollars but converted and stored in cents;
|
||
- reject negative, zero, malformed, or over-precision prices;
|
||
- display the active school-year charge price;
|
||
- persist author and class assignments or remove fields that are not supported;
|
||
- validate that the selected category belongs to type `book`;
|
||
- prevent duplicate catalog entries using ISBN/edition when present, otherwise an explicit SKU/manual duplicate review;
|
||
- remove semester filtering from physical book availability; inventory is year-scoped, not reset each semester.
|
||
|
||
Fix the teacher distribution flow:
|
||
|
||
- change the current `Deduct & Save` button from `type="button"` to a real submit button;
|
||
- remove duplicated JavaScript handlers;
|
||
- show issued rows as read-only history rather than editable checked boxes;
|
||
- show the book price and exact parent charge before submission;
|
||
- wrap the whole batch in one database transaction;
|
||
- lock the item-year stock row before validating on-hand quantity;
|
||
- insert one idempotent issue and one linked stock movement per student;
|
||
- roll back the entire batch if any student issue fails;
|
||
- prevent issue dates in a closed/read-only school year;
|
||
- support a restricted correction-reversal operation for erroneous distributions instead of using checkbox changes; do not provide a book-return operation.
|
||
|
||
Add `StudentBookIssueService` with methods to distribute, reverse an erroneous issue, list non-reversed issues for a student/year/date, and total snapshotted charge cents. This service is the only inventory entry point the refund calculator should use.
|
||
|
||
### 4.4 Persist an auditable withdrawal calculation
|
||
|
||
Add a `withdrawal_financial_calculations` table with at least:
|
||
|
||
- identifiers: `id`, `enrollment_id`, `student_id`, `parent_id`, `invoice_id`, `school_year`;
|
||
- policy snapshot: version, school-year start date, enrollment date, withdrawal request date, `total_instructional_weeks`, derived total chargeable days, studied calendar days and studied weeks;
|
||
- book evidence: issue IDs, item IDs, quantities, issue dates, unit-price snapshots, any correction-reversal IDs and total issued-book charge;
|
||
- money snapshot: annual fee allocation, instruction component, earned tuition, retained issued-book charge, total retained charge, invoice adjustment, invoice paid, prior payouts, resulting credit and resulting balance;
|
||
- workflow: `preview`, `posted`, `superseded`, or `requires_review`;
|
||
- audit: calculated/posted/overridden timestamps, user IDs, override reason, and a JSON explanation payload.
|
||
|
||
Enforce one active posted calculation per enrollment. A recalculation creates a new version and supersedes the old one; it must not edit history in place.
|
||
|
||
### 4.5 Introduce focused refund services
|
||
|
||
Create:
|
||
|
||
1. `WithdrawalRefundCalculator`: a pure cents-based calculator. It accepts the snapshotted `total_instructional_weeks`, school-year/enrollment/withdrawal dates, student allocation, issued-book snapshots, payments, and existing payout/reservation inputs. It derives total days and studied weeks internally and must not read session state, attendance, calendar events, or active-year configuration.
|
||
2. `WithdrawalFinancialService`: transaction orchestration. It locks the enrollment, invoice, withdrawal calculation, relevant book issue rows, and refunds; posts invoice lines; recalculates the ledger; and creates or updates the refund request.
|
||
3. `StudentBookIssueService`: the authoritative bridge between inventory and refunds. It returns immutable net issue values as of the withdrawal date.
|
||
|
||
Keep `FeeCalculationService::calculateRefund()` temporarily as a compatibility adapter. Replace its `getConfig('weeks_study')` lookup with the validated `total_instructional_weeks` policy resolver and remove its remaining-weeks calculation. Then remove the adapter after all three callers have moved to the new service.
|
||
|
||
### 4.6 Post the withdrawal adjustment to the invoice
|
||
|
||
Calculating a refund without changing the invoice charge leaves partially paid parents with a false overdue balance. Post idempotent invoice lines for each approved withdrawal:
|
||
|
||
- reverse the withdrawn student’s annual allocation;
|
||
- add earned studied-week tuition;
|
||
- add/retain one itemized line per net active book issue using its price snapshot;
|
||
- link all lines to the withdrawal calculation and enrollment;
|
||
- use deterministic `active_source_key` values so retries cannot duplicate them.
|
||
|
||
Because books are included in annual tuition, the reversal and earned tuition are discount-eligible, while itemized book lines should follow the school’s discount policy. Recommended: books are not discount-eligible; this must be confirmed before implementation because it changes the retained amount for discounted families.
|
||
|
||
Do not mutate or delete the original frozen invoice line. Append versioned adjustment lines so the ledger remains auditable.
|
||
|
||
Existing aggregate tuition invoice lines do not preserve per-student allocations. For new invoices, save the tuition calculator’s student-level detail in invoice metadata or separate student tuition lines. For existing invoices, reconstruct the reverse-stack allocation and require manual review if reconstructed totals do not match the frozen tuition line.
|
||
|
||
### 4.7 Correct the ledger and refund eligibility
|
||
|
||
In `InvoiceLedgerService`:
|
||
|
||
- Correct the balance sign:
|
||
|
||
```text
|
||
raw balance = net charge - paid + completed refunds
|
||
```
|
||
|
||
The current code uses `net charge - paid - completed refunds`, which can hide an over-refund.
|
||
|
||
- Keep customer credit as:
|
||
|
||
```text
|
||
customer credit = max(0, paid - completed refunds - net charge)
|
||
```
|
||
|
||
- Recalculate invoice status after the withdrawal adjustment and after every payout/reversal.
|
||
|
||
In `RefundEligibilityService`:
|
||
|
||
- Change `tuition_withdrawal` source credit from all invoice payments to the adjusted invoice’s `customerCreditCents`.
|
||
- Do not subtract completed payouts a second time, because `customerCreditCents` already includes them.
|
||
- Subtract open reservations across all invoice-backed refund source types, not only refunds with the same `source_type`; otherwise an overpayment refund and withdrawal refund can reserve the same credit twice.
|
||
- Enforce a database/transaction rule preventing two open refund claims against the same invoice credit.
|
||
|
||
### 4.8 Centralize the withdrawal workflow
|
||
|
||
The project currently triggers calculation from both `ParentController` and `EnrollmentWithdrawalService`, and `RefundController` contains a repair recalculation. Replace those duplicated paths:
|
||
|
||
1. Parent requests withdrawal:
|
||
- capture immutable request date;
|
||
- set `withdraw under review`;
|
||
- create a calculation preview only;
|
||
- do not create or approve a monetary refund yet.
|
||
2. Admin reviews:
|
||
- show studied weeks, annual allocation, every issued book and snapshotted price, earned tuition, adjusted invoice charge, valid payments, expected refund, and expected remaining balance;
|
||
- permit an enrollment-date or withdrawal-date correction only with a reason; recalculate studied days/weeks from those corrected dates.
|
||
3. Admin confirms:
|
||
- call `WithdrawalFinancialService` inside one transaction;
|
||
- post invoice adjustments and recalculate ledger;
|
||
- if credit is positive, create/update one Pending `tuition_withdrawal` refund and set `refund pending`;
|
||
- if credit is zero, do not create a zero-dollar refund; complete the withdrawal and show either `$0 due` or the remaining balance.
|
||
4. Existing refund approval and idempotent payout flow remains, but approval rechecks the adjusted invoice credit under row locks.
|
||
|
||
Remove the post-commit `new InvoiceController()->generateInvoice()` fallback from the withdrawal path. Financial effects must succeed or roll back in the same transaction as the approved withdrawal.
|
||
|
||
Never select “the latest invoice” when multiple active invoices exist for the parent/year. Stop with `requires_review` until legacy duplicates are reconciled.
|
||
|
||
### 4.9 Carry leftover books through school-year closing
|
||
|
||
Extend `SchoolYearClosingService` rather than relying on lazy opening movements.
|
||
|
||
#### Closing preview and blockers
|
||
|
||
Add an inventory section to the closing preview with each book's:
|
||
|
||
- source opening quantity;
|
||
- receipts, distributions, correction reversals, losses and adjustments;
|
||
- calculated ending quantity;
|
||
- physical counted quantity and variance;
|
||
- current and proposed next-year charge prices;
|
||
- target-year opening quantity.
|
||
|
||
Block closing when:
|
||
|
||
- a book with stock or student issues has a missing/zero price snapshot;
|
||
- calculated stock is negative;
|
||
- physical count is missing;
|
||
- a variance is unresolved;
|
||
- a distribution has no linked student issue or price snapshot;
|
||
- a student issue has no matching stock movement;
|
||
- mutable/duplicate yearly opening movements make the balance ambiguous.
|
||
|
||
Admin must resolve variance with a dated, reasoned adjustment before carry-forward. Do not silently force system quantity to match the count.
|
||
|
||
#### Closing execution
|
||
|
||
During the existing closing execution transaction:
|
||
|
||
1. Lock source item-year rows and relevant movements.
|
||
2. Recheck the preview hash, inventory count and prices.
|
||
3. Create one target `inventory_item_years` row per carried item with `opening_quantity = verified source closing quantity`.
|
||
4. Copy the source charge price as the proposed target-year price; require admin confirmation before the first target-year distribution.
|
||
5. Link target and source rows plus the closing batch for idempotency.
|
||
6. Mark the source item-year `carried`, then `closed` only when target rows and quantities verify.
|
||
|
||
Do not insert a positive physical inventory movement for the carried amount. Carry-forward changes the reporting period, not the number of physical books. Only books physically left on hand carry forward; books issued to students do not become target-year stock.
|
||
|
||
Add inventory results to the closing hash, audit log and UI. `complete()` must refuse to close/activate years until financial carry-forward and inventory carry-forward are both complete.
|
||
|
||
## 5. UI and reporting changes
|
||
|
||
### Enrollment/withdrawal admin page
|
||
|
||
Add a review modal containing:
|
||
|
||
- withdrawal request/effective date;
|
||
- total instructional weeks, derived chargeable days, elapsed studied days and calculated studied weeks;
|
||
- annual student allocation;
|
||
- every issued book, issue date, quantity and snapshotted unit price;
|
||
- permanently retained issued-book charge and any documented erroneous-issue corrections;
|
||
- earned tuition and retained charge;
|
||
- invoice payments, earlier refunds, open reservations;
|
||
- expected refund or remaining balance;
|
||
- any blockers, such as multiple invoices or missing policy values.
|
||
|
||
### Refund list
|
||
|
||
Add a calculation-breakdown action and show:
|
||
|
||
- source student(s);
|
||
- issued book evidence and price snapshots;
|
||
- adjusted charge;
|
||
- paid-to-date;
|
||
- available credit;
|
||
- requested, approved, paid, and remaining refund;
|
||
- reconciliation warning when payouts exceed the recalculated entitlement.
|
||
|
||
Approval must be disabled when the calculation is missing, stale, or marked `requires_review`.
|
||
|
||
### Invoice/PDF
|
||
|
||
Show withdrawal lines explicitly: annual tuition reversal, earned-week tuition, and itemized retained books. The ending invoice balance must equal the ledger calculation.
|
||
|
||
### Book inventory
|
||
|
||
- Book create/edit: require active-year charge price and show whether it is already used by issue snapshots.
|
||
- Book list: show active-year opening, issued, correction-reversed, adjusted and current on-hand quantities plus charge price.
|
||
- Student distribution: show price, total charge, stock, and immutable prior issue status.
|
||
- Student history: list all book issues and correction reversals by year, with the price snapshot used for refunds and the required correction reason.
|
||
- Closing preview: add an inventory reconciliation and carry-forward table with blockers and variance actions.
|
||
|
||
## 6. Migration and reconciliation strategy
|
||
|
||
1. Deploy the inventory catalog, item-year, immutable issue, and refund snapshot schema with the new policy disabled.
|
||
2. Audit existing book items for duplicates, missing author/class data, malformed year tags and missing prices.
|
||
3. Reconstruct one opening/ending balance per item/year from existing movements. Flag any item where multiple `initial` rows or global quantity disagree; do not guess silently.
|
||
4. Convert valid student `distribution` movements into `student_book_issues`. Because old movements have no historical price, require an admin-approved price snapshot before those issues can affect refunds.
|
||
5. Flag legacy positive student book movements for manual review; do not convert them into return credits or available stock automatically. Disable edit/delete for migrated issue movements and replace proven data-entry errors with audited correction reversals.
|
||
6. Populate `total_instructional_weeks` and `annual_fee_includes_books` for the active year.
|
||
7. Run a read-only audit of all Pending/Approved/Partial tuition-withdrawal refunds using the migrated book issue snapshots.
|
||
8. For each open refund, calculate the old and new amounts side by side.
|
||
9. Pending refunds may be superseded and regenerated after admin review.
|
||
10. Approved or partially paid refunds must never be silently reduced below completed payouts. Mark them `requires_review`, preserve payout history, and use the existing reversal workflow if correction is authorized.
|
||
11. Do not automatically rewrite Paid refunds.
|
||
12. Activate the new policy only after inventory variances and refund ledger differences are fully explained.
|
||
|
||
## 7. Test plan
|
||
|
||
### Pure calculation tests
|
||
|
||
- zero, one, several, and all instructional weeks;
|
||
- enrollment before school starts and late enrollment;
|
||
- withdrawal on day 1, day 7, day 8, and after the derived final chargeable day;
|
||
- `no_school` calendar dates do not alter the calculation because they are already reflected in `total_instructional_weeks`;
|
||
- missing/zero total weeks rejected;
|
||
- zero, one, and multiple actual book issues;
|
||
- issues after the withdrawal date excluded;
|
||
- issue price remains stable after current-year price changes;
|
||
- issued-book charges are never credited because a book was handed back;
|
||
- an audited reversal of an issue entered in error is excluded from the charge;
|
||
- book issue total exceeding an inclusive annual allocation blocks automation;
|
||
- annual fee is always book-inclusive and a false/missing policy snapshot blocks calculation;
|
||
- cents rounding across all weeks equals the annual instruction component;
|
||
- first/additional sibling reverse-stack allocation;
|
||
- simultaneous and sequential sibling withdrawals.
|
||
|
||
### Payment/refund tests
|
||
|
||
- no payment: refund `$0`, retained balance remains;
|
||
- payment below, equal to, and above retained charge;
|
||
- fully paid invoice;
|
||
- discount present;
|
||
- event and additional charges present;
|
||
- prior completed refund, reversal, and partial payout;
|
||
- open overpayment reservation prevents duplicate credit use;
|
||
- payment void/chargeback is excluded;
|
||
- payout cannot exceed adjusted credit under concurrent requests.
|
||
|
||
### Workflow/integration tests
|
||
|
||
- parent request creates preview but no payout entitlement;
|
||
- admin confirmation posts each invoice line exactly once;
|
||
- retry is idempotent;
|
||
- status and financial changes roll back together on failure;
|
||
- multiple active invoices block automatic processing;
|
||
- closed school year is read-only;
|
||
- historical calculation is unchanged after policy, date, or calendar-event edits;
|
||
- invoice PDF and refund UI match the ledger;
|
||
- ledger balance increases correctly when a refund is paid or a payout is reversed.
|
||
|
||
### Inventory tests
|
||
|
||
- book creation rejects missing/zero/malformed charge price;
|
||
- author and class assignments persist;
|
||
- the distribution button submits the form;
|
||
- a distribution atomically creates one issue plus one negative movement per student;
|
||
- concurrent distributions cannot drive stock negative;
|
||
- retrying the same batch is idempotent;
|
||
- issued students cannot be charged twice for the same active issue;
|
||
- correction reversals restore stock without deleting issue history;
|
||
- no return action or return credit exists, and an issued copy cannot be added back to stock as a parent return;
|
||
- generic movement edit/delete cannot alter issue-backed movements;
|
||
- current on-hand uses one item-year opening and does not double-count earlier yearly openings;
|
||
- semester changes do not hide physical stock;
|
||
- historical refunds use issue price snapshots, not current book prices.
|
||
|
||
### School-year closing inventory tests
|
||
|
||
- missing physical count, missing price, negative stock, orphan issue, or unresolved variance blocks closing;
|
||
- verified leftover stock becomes the target-year opening exactly once;
|
||
- distributed books are not carried as on-hand stock;
|
||
- carry-forward creates no stock-affecting positive movement;
|
||
- retrying close execution creates no duplicate item-year row or quantity;
|
||
- target price is copied for review but remains unconfirmed until authorized;
|
||
- closing and target activation roll back if inventory carry-forward fails;
|
||
- source and target totals plus issue history remain reproducible after closing.
|
||
|
||
## 8. Acceptance criteria
|
||
|
||
The work is complete only when:
|
||
|
||
1. A one-week withdrawal retains exactly the snapshotted prices of books actually issued by the withdrawal date plus one prorated instructional-week charge.
|
||
2. Refund amount is never greater than net valid payments or adjusted available invoice credit.
|
||
3. A partially paid parent below the retained charge receives no refund and sees the correct remaining balance.
|
||
4. A partially paid parent above the retained charge receives only the excess.
|
||
5. Sibling tuition tiers remain correct after one or more withdrawals.
|
||
6. Existing refunds cannot reserve or pay the same invoice credit twice.
|
||
7. Every amount shown in the withdrawal preview, refund screen, invoice, and ledger agrees to the cent.
|
||
8. Every approved calculation can be reproduced from its stored dates and `total_instructional_weeks` snapshot without querying today’s configuration or calendar events.
|
||
9. Changing a book price never changes a historical issue or refund.
|
||
10. Every issued book has one price-snapshotted issue record and one linked stock movement.
|
||
11. A new school year opens with exactly the verified leftover stock from the closed year, without duplicate opening movements.
|
||
12. Inventory discrepancies block school-year closing instead of being silently carried forward.
|
||
13. Handing a book back never reduces the retained charge or increases on-hand stock; only an audited correction of an issue entered in error can do so.
|
||
|
||
## 9. Recommended implementation order
|
||
|
||
1. Record the locked policies: annual tuition includes books and issued books are not returnable; separately confirm whether book charges are discount-eligible.
|
||
2. Add catalog fixes, item-year balances/prices, immutable student issue tables and constraints.
|
||
3. Migrate and reconcile existing stock movements before enabling any price-based refund.
|
||
4. Fix book create/list/distribution/correction-reversal workflows and add inventory tests.
|
||
5. Integrate verified inventory carry-forward into school-year closing.
|
||
6. Add school-year refund policy fields and the withdrawal calculation audit table.
|
||
7. Add `StudentBookIssueService`, pure refund calculator and unit tests.
|
||
8. Correct invoice ledger refund sign and add regression tests.
|
||
9. Add idempotent withdrawal invoice adjustments with itemized book lines.
|
||
10. Change refund eligibility to adjusted invoice credit and cross-source reservations.
|
||
11. Centralize parent/admin withdrawal paths.
|
||
12. Add preview/breakdown UI, inventory closing UI and invoice PDF lines.
|
||
13. Run the refund/inventory legacy audit and controlled rollout.
|
||
|
||
## 10. Implementation status — 2026-08-21
|
||
|
||
Phase 1 is implemented in the accompanying project package:
|
||
|
||
- completed: schema foundation, school-year policy input, book price/class persistence, immutable price-snapshotted issue records, idempotent distribution, audited error correction, movement protections, pure calculator, invoice-scoped payment calculation, ledger refund-sign correction, physical count blockers, and exact year-end stock carry-forward;
|
||
- tests added: pure calculator unit scenarios;
|
||
- pending: execution of migrations/PHPUnit in a PHP environment, legacy inventory/issue reconciliation, persisted calculation posting and itemized withdrawal invoice lines, full preview/history UI, and integration/concurrency coverage.
|
||
|
||
The new flow must remain in staging until legacy book prices and stock are reconciled. The migration intentionally refuses to invent missing historical prices.
|