-
Mahmoud Aglan authored
On 1 September OC-Sport had 230 active players due for renewal and the nightly command raised zero invoices. It resolved the invoice actor with User::find($enrollment->created_by); `enrollments` has no created_by column — it is enrolled_by — and Eloquent answers null for a missing attribute, so User::find(null) returned null and every enrolment took the "no valid user" branch. Introduced by da344cda on 9 August, which is why August still billed (2 and 5 August) and September did not. The command printed a summary and exited SUCCESS the whole time, so nothing in the system disagreed for four weeks. The column name was the trigger; the silence was the bug. Four things change so this class of failure cannot repeat: * The actor comes from enrolled_by, and falls back (programme creator, then an academy admin) rather than skipping. A receptionist leaving the academy must never be the reason a paying member goes unbilled. * A run that finds players and bills none of them exits FAILURE. The scheduler now reports a broken run instead of a tidy one. * Cycles are caught up. The old code advanced next_billing_date by addMonth() and raised one invoice, so a run lost to a container restart at 07:00 skipped that month permanently. It now bills every cycle between next_billing_date and today. * Invoices are dated the cycle they buy, never the day the job ran, and carry metadata.month — the signal SubscriptionLine trusts above Arabic month names and above issue_date. A September renewal raised on the 20th is still September money. The "renew on the 1st" rule was hand-written in five places and three disagreed: the command drifted the anchor a day every time a run was late, ReconciliationWizard advanced from now() instead of from the cycle it was closing (skipping one), and EnrollmentService ignored billing cycles longer than a month. They now share App\Domain\Training\Support\ BillingCycle, which is the only definition of the rule. Also fixed along the way: * Discounts were put on the invoice header AND the line, and recalculateTotals() computes total = sum(line totals) - header discount, so every discounted renewal charged the discount twice. OC-Sport has an active sibling-discount rule, so this was live money. Lines now carry the undiscounted price, which is the convention ParticipantBillingService already documents. * CollectPaymentWizard deduplicated renewals with notes LIKE %{programme name}% over unpaid statuses only. OC-Sport has three programmes called "فريق 2018", so one player suppressed another's; and once a renewal was paid the guard stopped seeing it and the next visit billed the month again. It now matches on the cycle and the enrolment, and invoice creation shares a transaction with the next_billing_date advance. * An active paying enrolment with a null next_billing_date was invisible to every renewal query in the system, permanently. Such rows are now adopted onto the current cycle — never retroactively. Verified against a copy of the OC-Sport tenant: 230 invoices dated 2026-09-01, 65 members at 650 EGP and 162 non-members at 900 EGP, the sibling discount applied once, re-running adds nothing. Six players on عبدالعال 2012 fail loudly because that programme has no base price — a hard fail by design, and now visible instead of silent. Co-Authored-By:
Claude Opus 5 (1M context) <noreply@anthropic.com>
5da51dfe