• Mahmoud Aglan's avatar
    fix(billing): bill every player on the 1st, and never let a renewal run fail in silence · 5da51dfe
    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: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
    5da51dfe
Name
Last commit
Last update
..
AdminScreensSmokeTest.php Loading commit data...
BranchLockRedirectTest.php Loading commit data...
CheckInScanTest.php Loading commit data...
ErrorPageDisclosureTest.php Loading commit data...
ExpenseReceiptTest.php Loading commit data...
FinancialTopProgramsQueryTest.php Loading commit data...
GenerateRenewalInvoicesTest.php Loading commit data...
GroupRosterAndPosRenderTest.php Loading commit data...
MobileApiRemovedTest.php Loading commit data...
PaymentLedgerTest.php Loading commit data...
PaymentProofTest.php Loading commit data...
PortalAccessibilityTest.php Loading commit data...
PortalSmokeTest.php Loading commit data...
SelfRegistrationTest.php Loading commit data...
ServiceRequestEffectTest.php Loading commit data...
SubscriptionCycleFigureTest.php Loading commit data...