• 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