Files
alrahma_sunday_school/docs/withdrawal_refund_calculation_plan.md
T
root d906a915d6
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s
add refund logic and fix books inventory logic
2026-08-22 13:44:25 -04:00

35 KiB
Raw Blame History

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 parents 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:
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 students 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:

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 schools 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:

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:

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:

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.

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:

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 17 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:

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 students 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 schools 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 calculators 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:
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:
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 invoices 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 todays 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.
  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.