1. 03 Sep, 2026 11 commits
    • Mahmoud Aglan's avatar
      feat(pricing): scope a discount to members or to non-members, and let a... · cc9d0d65
      Mahmoud Aglan authored
      feat(pricing): scope a discount to members or to non-members, and let a branch-targeted one save at all
      
      Membership tier decided only which base price was read; it was invisible to
      every rule. A club wanting '10% off, members only' had no way to say it.
      The nearest rule type, membership_duration, is tenure in months, which is a
      different question and happily matches a non-member who has been around a
      while. New membership_type rule type, with the CHECK constraint widened to
      admit it — no existing row changes value, and the new one is unreachable
      until a rule is authored with it.
      
      The tier the rule matches is the same one that chose the base price, so a
      discount and the price it discounts cannot disagree about who this is. An
      unset tier reads as non_member, matching step 1, so a 'members only' rule
      cannot quietly reach someone nobody ever classified.
      
      And the bug the test for it found: NO branch-targeted discount could be
      saved. pricing_rule_branches.academy_id is NOT NULL and the pivot has no
      model, so no BelongsToAcademy hook filled it and a bare sync() died on a
      not-null violation — every rule authored in the wizard with a branch
      ticked. Stamped by hand, the way ProgramForm already does for
      program_products.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      cc9d0d65
    • Mahmoud Aglan's avatar
      feat(billing): charge a mid-month joiner for trainings, not for days · ecae5ecb
      Mahmoud Aglan authored
      Proration counted remaining days on a hardcoded 30-day month, a unit the
      academy never sold. A programme meeting Sunday and Tuesday holds no
      sessions over a long weekend, so someone joining on the 22nd was billed a
      third of a month for perhaps two trainings — and the calendar never
      noticed. September 2026, Sun+Tue: three of the month's nine sessions
      remain on the 22nd, not nine of thirty days.
      
      SessionCountService counts from the timetable rather than from
      training_sessions, because the generator only materialises rows about a
      week ahead and counting rows would under-report the rest of the month —
      exactly the question proration asks. Its rules are the generator's,
      deliberately identical: an active schedule row naming the weekday,
      effective that day, with no training-affecting holiday on it.
      
      The desk now chooses per registration: شهر كامل, نص شهر, or باقي تمرينات
      الشهر. The mode is settable from the browser by design, and safe to be —
      the academy setting remains the gate, and an unrecognised value falls back
      to the default rather than being honoured. Joining after the month's last
      session owes nothing for that month, so no invoice is raised at all; a
      zero-total one is what AccountAnomalyScanner reports as corruption.
      
      Also: a branch whose takings never pass through the system. A partner-run
      site bills nobody — participants enrol unbilled, enrolments are marked
      waived, and the branch's income is entered afterwards on the
      external-revenue screen. Skipping the invoice rather than writing a zero
      one, for the same reason as above. The guard sits before the renewal
      command's adoption step, not after: those enrolments carry no billing date
      precisely because they are off the cycle, and adoption would read that as
      an oversight and put every one of them onto it.
      
      Fixes a latent crash on the way: BranchSettingsService called
      app('current_academy') unguarded, which throws rather than returning null
      outside a request. The renewal command binds no academy, so asking it the
      billing question from the console would have taken the nightly run down.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      ecae5ecb
    • Mahmoud Aglan's avatar
      feat(programs): let a programme state its hours, its kit and its price without leaving the form · 243cac65
      Mahmoud Aglan authored
      Four things the programme form could not say, all of which sent the desk
      somewhere else or nowhere at all.
      
      A timetable. Recording that a group trains Sunday and Tuesday from four to
      half five required the visual grid, which demands a facility and a space —
      so clubs that place nobody on a grid had no timetable at all, and the
      proration and attendance engines had nothing to read. The form now writes
      plain TrainingSchedule rows on the programme's default group with
      facility_id null. Rows the grid HAS placed are left alone: clearing a
      checkbox here must not strip a space reservation off a scheduled session.
      A day dropped from the selection is deactivated rather than deleted,
      because generated sessions hold a hard FK to the row and attendance hangs
      off those sessions.
      
      Renewal defaults. A new programme opened as manual_renew, so every one had
      to be corrected by hand or quietly stopped billing. It now opens
      auto_renew, monthly, on the first.
      
      Kits. program_products already records 'this player must buy that thing',
      but only for a single product; a kit sold as one thing could not be
      required. program_kits is a separate pivot rather than a nullable kit_id
      on program_products, whose product_id is NOT NULL and whose uniqueness is
      (academy, programme, product). Nor is a kit expanded into its components:
      the POS writes an invoice line carrying itemable_type = Kit, so a
      programme requiring the parts would report every buyer as missing all of
      them. The group roster flags a missing kit the way it flags a missing
      product.
      
      Prices. The programmes list showed no price, so comparing what two
      programmes cost meant opening both. Both tiers now show, in one query for
      the page, and a tier with no active base price reads 'غير محدد' rather
      than 0 — the engine hard-fails there, so a zero would be a figure nobody
      will ever be charged.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      243cac65
    • Mahmoud Aglan's avatar
      feat(terminology): let each branch call its people what it actually calls them · e8def3f5
      Mahmoud Aglan authored
      The product calls a person who belongs to the organisation عضو and
      everyone else غير عضو. That is only true for a club. The same software
      runs inside residential compounds (مقيم / غير مقيم), on beaches and in
      resorts (مشترك / زائر), and in hotels (نزيل / زائر), where every screen
      read as though it had been written for somebody else.
      
      The data model does not move: participants.membership_type still holds
      'member' and 'non_member', every query and every enum is untouched, and
      nothing about pricing or membership logic changes. Only the words shown
      to a human do, and they are chosen per branch — one academy can run a
      club and a compound at the same time.
      
      Eight Arabic forms are stored per branch rather than derived, because
      Arabic will not let you derive them: ال prefixes the noun in العضو but
      the second word in غير العضو, and the plural of عضو is أعضاء while the
      plural of مقيم is مقيمين. Everything a screen needs beyond those eight —
      نوع العضوية, رقم العضوية, سعر العضو — composes from them in
      Identity\Support\Terminology. Plurals are stored in the ـين form because
      almost every site prints them after a preposition or in an idafa.
      
      TerminologyService takes an explicit ?int $branchId like every other
      domain service, so it stays callable from a queued notification or an
      artisan report; terms()/term()/membership_label() in app/Helpers are the
      adapter that reads BranchContext for a Blade file. Reads go through the
      query builder rather than the model, because the sidebar consults this on
      every page and BranchSetting's branch scope would filter a settings
      screen editing branch B, viewed from branch A, down to nothing. The
      service is bound scoped, so its memo lives exactly one request.
      
      Presets (club, compound, resort, hotel, gym) fill the settings screen and
      stay editable afterwards; the fields are stored in the existing
      branch_settings table, so there is no schema change. An unconfigured
      branch — and the public website, and a user in all-branches mode — gets
      the default preset rather than blank labels. term() throws on an unknown
      key rather than echoing it back, because a typo that printed
      "membershipp_type" onto a receipt would survive review.
      
      62 call sites converted across the sidebar, participant screens, the
      registration wizard, the portal, reports, messaging, imports and the
      printed card. New screen at /settings/terminology behind
      permission:settings.manage.
      
      Verified: 413 tests green, no failures.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      e8def3f5
    • Mahmoud Aglan's avatar
      fix(expenses): read the upload's metadata before store() moves it away · 7622238b
      Mahmoud Aglan authored
      This is the actual reason no receipt could be attached — 1b62a44f-… and
      every other support code from the expense pages:
      
          UnableToRetrieveMetadata: Unable to retrieve the file_size for file
          at location: livewire-tmp/adbO73…jpg
          ExpenseShow.php(93): TemporaryUploadedFile->getSize()
      
      store() moves the file out of livewire-tmp. The attachment array put
      'path' => $file->store(…) on its first line, and PHP evaluates array
      literals in order, so getMimeType() and getSize() on the lines below ran
      against a path that had just stopped existing. Validation passed, the
      vanished-upload guard passed, the file was even written to its final
      home — and then the request died with a 500 on the way to the row.
      Nothing was ever saved.
      
      Both handlers now read name, mime and size into locals first and store
      last. Reproduced against a real local disk before and after; Livewire's
      test harness swaps in a temp disk that does not move the file, which is
      why a component test would have gone green on the broken code, so the
      regression guard asserts the source ordering instead.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      7622238b
    • Mahmoud Aglan's avatar
      fix(expenses): let a phone photo of a receipt actually attach and display · 32e52a04
      Mahmoud Aglan authored
      Three things stood between the desk and an attached receipt:
      
      - The picker on the create form listed extensions (.jpg,.png,…), which on
        iOS greys out the camera roll; and both components validated
        mimes:jpg,jpeg,png,pdf,webp, so a HEIC photo — the iPhone default —
        was refused after the picker had accepted it. Both now accept image/*
        plus heic/heif, and validate the same set.
      - The attachment stream built Content-Disposition by hand with
        addslashes(). Receipt names here are Arabic, and an Arabic filename is
        not a legal header value: the inline preview came back broken and the
        download came back mangled. Symfony builds the header now, via
        Storage::response() for inline and download() for the save.
      - HEIC is an image no browser but Safari paints, so it falls back to the
        file card instead of a broken <img> (attachmentIsViewableImage()).
      
      Also: the create form advertised a 5MB ceiling while the real one is
      config('uploads.max_kb') (150MB), and its uploaded-file chip called
      getClientOriginalName() on whatever sat in the property.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      32e52a04
    • Mahmoud Aglan's avatar
      fix(pricing): count a player's age forwards, and hold the picker to the same ceiling as the engine · 54df3e88
      Mahmoud Aglan authored
      Carbon 3 signs its differences: `$a->diffIn*($b)` answers `$b − $a`. The
      pricing engine asked it the other way round —
      `now()->diffInYears($birthday)` — so every participant it priced arrived
      at the rules with a negative age and a negative membership duration.
      
      The damage ran in both directions at once. A rule with a `min` never
      matched anyone again: the loyalty and annual recipes both ask for twelve
      months, and −25 is not twelve. A rule with a `max` matched the entire
      academy: the juniors recipe is `max: 6`, and −34 is comfortably under
      six, so one click in the rule builder would have taken 10% off every
      price in the club. The registration wizard computed age correctly in its
      own provisional context, which is why the desk saw one price at
      registration and another at renewal.
      
      Age and tenure now read from the older moment forward, through two named
      helpers that say why, and a date in the future is no age rather than a
      negative one.
      
      Alongside it, the discount picker: `selectedDiscountIds` is a public
      Livewire property, so it is a list the browser sends, and the total was
      summed from whatever ids arrived. applyDiscount() refuses a blocked rule
      and diverts an above-ceiling one into an approval request; neither guard
      survived to where the money was worked out. The engine's verdict is
      re-read there now, the academy's global discount ceiling applies to a
      hand-assembled total exactly as it does at step 8, a manual discount
      above the actor's cap reaches neither the total nor the invoice
      snapshot, and the picker's state is #[Locked] — it is driven entirely
      by wire:click, so nothing needed to arrive from the browser at all.
      
      Also here, found while reading for the above:
      
      - POSTerminal::updateQuantity() did not check the index exists, so an
        invented one wrote a cart line made of a quantity and nothing else.
      - The same reversed diff in four other places: overdue invoices and
        renewals reported negative days on the dashboard and in reminder
        messages, expiring memberships reported negative days remaining, and
        a product's months-active pinned to 1, inflating its average monthly
        movement to its entire lifetime sales.
      - validateCoupon() still carried a comment promising academy-wide
        coupons, three commits after branch_owns_the_catalogue removed them.
      
      Verified: 379 tests green on SQLite and against the restored OC-Sport
      tenant. That tenant carries one pricing rule (sibling_order, 4 EGP) and
      no invoice with a discount snapshot, so there is no historical billing
      to correct — the bug was waiting on the first age or loyalty rule.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      54df3e88
    • Mahmoud Aglan's avatar
      feat(settlements): give the desk the tool that removes a demand billed twice · 579a3a8b
      Mahmoud Aglan authored
      The last commit taught the settlement screen to SEE money billed twice. It
      still could not do anything about it: the panel described the problem and
      then offered "تسجيل باقي القيمة", which bills MORE. The only way to
      actually fix the reported account was an admin editing the invoice by
      hand, which is how it broke in the first place.
      
      So the screen gets the missing action. `void_duplicate` takes a demand off
      the books — reduce an invoice, or cancel it outright — and it is
      deliberately not a waiver. A waiver forgives a debt that was real and is
      revenue given away; this removes a demand that should never have existed,
      so the club is not out of pocket by a piaster and it reports as negative
      billing. The cart's third tile flips to "سيُحذف من المطلوب" rather than
      showing a minus sign under "سيُفوتَر".
      
      Detecting is only half a tool. An operator standing in front of a parent
      needs to know WHICH of six invoices carries the duplicate, and the second
      spent working that out is the second the wrong one gets cancelled. So the
      wizard matches the excess back to the invoices that could be carrying it,
      newest first, and pre-fills the amount: on the reported account it offers
      "INV-000593: 5,500 -> 3,000" as one button. The worklist states the
      figure on the row, so the excess is visible before anything is opened, and
      severity now colours the chips — a double-billing and a missing month read
      identically before, which is why the list got worked top-to-bottom.
      
      The guard is the whole value of the thing: an invoice with money collected
      against it is never touched. Reducing one below what was paid strands real
      money — the payment row saying one thing, its immutable ledger entry
      another, which is exactly the damage the old correction wizard used to do.
      That money has somewhere to go (move_payment, or credit_wallet) and which
      is right is a person's decision, so the action refuses and the panel says
      so instead of offering a button. Same refusal for an invoice belonging to
      another participant, for raising an amount through a tool named for
      lowering it, and for an invoice with more than one line, where reducing
      the header cannot say which of two lines was the duplicate.
      
      Verified end to end against the restored tenant, which still carries the
      account in its broken state: scanner flags one, screen proposes
      INV-000593 5,500 -> 3,000, applying it leaves billed 8,000 of an 8,000
      card with 3,000 still owed, and the re-scan comes back clean — the same
      correction that had to be made by hand on production, now one click.
      364 tests green on SQLite and Postgres.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      579a3a8b
    • Mahmoud Aglan's avatar
      fix(settlement): the card's price is the debt, not the sum of everything typed toward it · 396f19ac
      Mahmoud Aglan authored
      Participant 128 on OC-Sport: an 8,000 registration card, 2,500 paid in
      July and 2,500 in August. The settlement screen told the operator he had
      paid 5,000 of 10,500 and still owed 5,500, and offered a button to bill
      him that 5,500 on top.
      
      The 10,500 is real, and that is the problem. INV-000310 carried the first
      instalment on 2 July. On 5 August the full 8,000 card was invoiced again
      as INV-000588, 2,500 was collected against it, and the next day it was
      split — reduced to 2,500 with a 5,500 remainder as INV-000593 — by
      someone who never saw the July instalment. Three lines, 10,500, for a
      card that costs 8,000.
      
      bundleStatus() then read `max(billed, price)`. That reading treats
      hand-typed lines as if they defined the obligation, so every duplicate
      and every correction raised the debt, and the excess disappeared into a
      larger number instead of being noticed. But a hand-typed line is an
      instalment TOWARD a card whose price the product record still holds: the
      card is what is owed. A real product sale is different — its price froze
      at the till — so that keeps billing as the obligation.
      
      So: expected is the frozen sale price for a real sale, and the card's
      price otherwise. Anything typed past it is the same money entered twice
      and is reported as `over_billed_bundle` rather than absorbed. #128 now
      reads 5,000 of 8,000 with 3,000 left, flagged for a 2,500 double entry.
      A scan of the restored tenant finds exactly one such account: his.
      
      Two guards on the tool that produced it. The correction wizard clamped
      paid_amount down to the new total and rewrote the payment rows
      themselves, so cutting an invoice below what had been collected against
      it destroyed real money — the payment row said one thing and its
      double-entry transaction still said another, and nobody was told. It now
      refuses and names the settlement wizard, which can move the payment or
      credit it to a wallet. And the split step lists what the account already
      carries, so a second "first instalment" is visible before it is created
      rather than three weeks after.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      396f19ac
    • Mahmoud Aglan's avatar
      fix(pos): charge the member rate, and sell on the plan that was configured · 22d43e2b
      Mahmoud Aglan authored
      A product carries a member rate, a non-member rate, and instalment plans
      open to one tier or the other. Only the registration wizard ever read any
      of it. The till resolved prices through the pricing engine, the engine
      knew only about base_prices, and nothing writes a product's member rate
      there — on OC-Sport there is not one base_prices row for any product — so
      every calculate() threw, the terminal fell back to selling_price, and
      every member buying at reception paid the walk-in price with nothing on
      the receipt to say so. The 8,000 card costs members 6,000; they were
      charged 8,000. The plans were invisible too: the only partial payment the
      terminal offered was a free-typed عربون, so a receptionist taking the
      first instalment of an agreed schedule typed it into a manual line, and
      the sale left no plan behind for anything to track.
      
      The precedence now lives in the engine, once, so every caller gets it:
      a base price tagged with this membership type wins, then the product's
      own column for this tier, then any other base price. selling_price stays
      out of it — it is the catalogue's advertised number, not a configured
      price, and admitting it would make "nobody set a price" undetectable,
      which is the hard fail the pricing rules require. The terminal falls back
      to priceForTier() only when the engine has nothing at all, and says on
      screen which price list is in force.
      
      Plans reach the terminal as a per-line picker: pick a schedule, choose
      how many instalments are being paid today, see the rest. Everything is
      re-resolved in POSService from the database — the cart is a public
      property, so a plan id in it is a number the browser chose, and a plan
      belonging to another product or to the other tier is refused rather than
      ignored. participantId is #[Locked] for the same reason: it now decides
      which price list the sale is quoted from.
      
      buildSchedule() rounded every auto slot up, so three instalments of an
      8,000.00 card came to 8,000.01 — a plan asking for a piaster the invoice
      never billed, which could never reach `completed`. Piasters split the way
      they do everywhere else here: floor each share, last slot takes the
      remainder.
      
      Verified against the restored OC-Sport tenant: member #18 prices at
      6,000.00 on the 2,000x3 members' plan, non-member #257 at 8,000.00 on the
      2,500/2,500/3,000 plan; both schedules sum exactly. Full suite green on
      SQLite and on Postgres, POS terminal renders.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      22d43e2b
    • Mahmoud Aglan's avatar
      fix(portal): build the shell's stylesheet, and give it the brand it already had · fdeedfd2
      Mahmoud Aglan authored
      portal.css is built with `source(none)`, so a template not named in an
      @source line contributes nothing. layouts/portal.blade.php was not named.
      Every utility the shell alone used — h-8 w-8 on the logo, max-w-2xl on the
      column, pb-28 above the tab bar — was absent from the bundle, so a tenant
      logo rendered at its natural size, the page went wider than the phone, and
      the first tab sat off-screen. The layouts are scanned now, and a test asserts
      they stay scanned.
      
      The rail made it worse: .rail padded itself 1rem and pulled back -1rem, the
      bleed trick for a rail inside a padded column. Its one caller sits in an
      unpadded header and supplies its own padding, so the negative margins had
      nothing to cancel and made the element 2rem wider than the viewport.
      
      Colour. OC Sport themed their website navy and gold and never opened the
      branding screen, so the portal, the PWA theme colour and the admin were all
      still on our shipped blue. Branding colours still sitting on the default are
      now inherited from the academy's own website palette; picking any colour
      settles it. Editing the site bumps the brand cache, or the rest of the
      product keeps yesterday's palette indefinitely.
      
      That exposed what shade 600 was doing as a link colour: anchored on the
      tenant's own colour, it lands within 0.02 of a dark navy, so links rendered
      as body text. ColorRamp now derives an interactive colour placed at a
      lightness that reads as a colour and clears 4.5:1 on the surface it is drawn
      on — one for light, one for dark — and an ink() for the semantic palette,
      which is chosen to be seen as a fill: amber is about 2:1 as 10px text on
      white, and that is what every error message and status chip was using.
      Filled buttons get a computed foreground instead of a hardcoded #fff.
      
      Screens: the outstanding balance was a third of a three-across statistics
      row, wrapping onto two lines, and repeated verbatim in the action list above
      it. It is one fact and it is the fact members open the app for, so it is the
      headline, figure large and currency small, and the row is two tiles. Icon
      path data was retyped in three places and the home screen's copies were
      truncated mid-curve — a member saw a tick where a wallet should be — so
      there is one icon component. The status chip five screens built by hand is
      one component too.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fdeedfd2
  2. 02 Sep, 2026 11 commits
    • Mahmoud Aglan's avatar
      feat(uploads): one 150 MB ceiling, agreed on by every layer that can refuse · 6b1c1297
      Mahmoud Aglan authored
      A file crossing this system passes four size checks and they disagreed:
      the component's rules said 5 MB, Livewire's undeclared default said 12,
      PHP said 20, nginx said 25. Whichever was smallest won, with a message
      written by whoever owned that layer — and nginx's refusal is a 413 error
      page, not something a receptionist can act on.
      
      config/uploads.php holds the number now, and the four layers are set from
      it in the right order: nginx (160M) is the most generous so it never
      refuses first, then post_max_size (160M) above upload_max_filesize (150M)
      so a file at the limit is rejected as a file rather than as a malformed
      request, then Livewire's temporary-upload rule, then the component. The
      size named in each Arabic error message is interpolated from the same
      config instead of retyped, because the old messages said "5 ميجابايت"
      while the rule said something else.
      
      Time limits went with it: max_input_time is what cuts off a body still
      arriving, and 150 MB over Egyptian mobile data is minutes, so it and
      nginx's client_body_timeout go to 300s and Livewire's max_upload_time to
      30 minutes. Pictures keep their own small ceilings — a logo is carried on
      every page load.
      
      And the settlement worklist stops treating the running month as a problem.
      Late now means a month that has ENDED and was not collected — read from
      the month the invoice names, not from its due date — because a club
      collects all month and does not consider a player a problem on the 9th.
      A card being paid on an agreed plan whose next instalment has not come due
      is not an anomaly either. Together: a player who owes only this month and
      whose bundled product is bought or paid up to date does not appear at all,
      which is the whole point of the screen. An unpaid bundled product goes
      back to standing on its own, since that is exactly what it exists to find.
      
      Verified on the restored tenant: 24 settlement cases including the new
      month rule (last month unpaid flags, this month never does, whatever the
      due date), full suite 334 tests on both connections.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      6b1c1297
    • Mahmoud Aglan's avatar
      fix(uploads): a temporary file that is gone is a sentence, not a 500 · d396f784
      Mahmoud Aglan authored
      Error eab81218-6482-47eb-9d09-aa54259069aa: an expense receipt, from an
      iPhone, at 16:22 today. League\Flysystem\UnableToRetrieveMetadata —
      "Unable to retrieve the file_size for livewire-tmp/Vy05fpe….jpg".
      
      Livewire uploads in two steps: the file lands in livewire-tmp on its own
      request, and the component reads it on a later one. Between those two the
      file can be gone — every push redeploys the container and livewire-tmp is
      not persistent, Livewire's own cleanup removes stale files, and a phone
      happily resends a form after the app has restarted. The first thing to
      touch the file is validation, because `max:5120` calls getSize(), so the
      receptionist standing at the desk with a receipt got an error page and a
      support code instead of a form.
      
      The file being gone is not exceptional, it is Tuesday. ChecksTemporaryUploads
      asks whether the pending upload still exists (treating an unreachable disk
      as gone rather than letting a storage exception reach the browser), clears
      the dead handle so the next attempt starts clean, and puts one Arabic
      sentence on the field: اختر الملف مرة أخرى وأعد الرفع.
      
      Applied to every component that reads an upload, not just the one that was
      reported — expense receipt and expense form, the three portal uploads
      (payment proof, documents, requests), branding images, the page builder,
      the gallery, the document wizard, the event wizard's cover and gallery
      photos, and the participant import.
      
      Also pins what the group roster already does with combined invoices, since
      it was worth proving rather than assuming: participants 219 and 97 each
      paid part of the federation card on an invoice shared with a kit, typed as
      free text, and the roster allocates the payment across the lines and shows
      the card's share against the price for that member's tier — 2,500 of 8,000
      and 2,000 of 6,000, both labelled أقساط.
      
      Full suite 325 tests on SQLite and on the restored tenant, no failures.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      d396f784
    • Mahmoud Aglan's avatar
      fix(settlements): find the name, count the money, and stop crying wolf · fe003fd4
      Mahmoud Aglan authored
      Three things the screen got wrong on the first day it was used.
      
      Search could not find people. `like '%term%'` over name_ar fails on this
      data for two reasons that have nothing to do with the searcher being
      careless: nobody agrees about hamza (عبدالله أحمد / عبدالله احمد is the
      same child, so is يحيى/يحيي and حمزة/حمزه), and a name on file is four or
      five words while the person searching types the two they remember —
      "عبدالله صلاح" against "عبدالله أحمد صلاح سيد" matches nothing because
      those words are not adjacent. ArabicSearch folds both sides to one
      spelling and requires each word of the term to appear somewhere in the
      name, folding in SQL (Postgres translate) so the database does the work.
      
      The bundle probe asked whether a product line existed, so a boy who paid
      2,500 toward his federation card — typed as "القسط الاول" on the same
      invoice as his kit — was reported as never having bought one. That is the
      exact reading the group roster stopped doing last week, and two screens
      answering the same question differently is worse than either answer. It
      now reads the money the way BundledProductLine does, bare instalments
      included where the programme requires exactly one product, and reports a
      position rather than a yes/no: paid, part paid with the remainder and a
      progress bar, or nothing at all.
      
      And the worklist was flagging ordinary business. A renewal issued on the
      1st and due on the 8th is not an anomaly, it is Tuesday — so the flags now
      fire on invoices past their due date, not merely unpaid. A card being paid
      off on an agreed plan through the till is not an anomaly either; only
      money recorded outside the product is. "Requires a card and has not bought
      one" is a sales fact, not a payment anomaly, so it annotates an account
      without summoning it. And enrolment start_date is copied from the GROUP's
      season start, so a player entered in August carried a 16 July start and
      was reported as owing months of a season he was not in — the month he
      joined is the later of start_date and enrollment_date.
      
      On the restored tenant this takes the worklist from 165 accounts to 77,
      and unbilled-month flags from 35 to 1. Participant 219 now reads
      "سدد 2,500 من 8,000" instead of "لم يُحاسَب على مستلزم البرنامج".
      
      Verified: 46 settlement/search/render cases pass, full suite 318 tests on
      both SQLite and the restored tenant, no failures.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fe003fd4
    • Mahmoud Aglan's avatar
      test(branch): check the records a screen is handed, not the markup it prints · 20c5cf15
      Mahmoud Aglan authored
      Reported: the weekly schedule shows a schedule on a branch that was never
      scheduled. Investigated against a restored tenant and the component is correct —
      branch 2 gets only branch 2's sessions, and a branch with nothing gets an empty
      grid. But chasing it found a real hole in the suite.
      
      BranchScopedScreensTest searches rendered HTML for another branch's uuids. The
      weekly schedule grid prints group names and times and no uuid at all, and
      training_sessions was not even among the tables it collected uuids for. A
      foreign session sitting in that grid would have left nothing in the markup to
      search for, and the suite would have stayed green while the screen was wrong.
      Several other grids and calendars are the same shape.
      
      So this asserts one level earlier, on the objects rather than the markup. Every
      component reachable at a parameterless URL is mounted under every branch, the
      data handed to its views is captured through a view composer, and every model in
      it that carries a branch_id must belong to the active branch — or be null only
      where null still means "every branch" (people and the academy calendar), or
      belong to a model that declares BRANCH_SCOPE_EXEMPT. It reads that declaration
      rather than keeping a second list that would drift away from it.
      
      Components come from the router rather than a hand-written list, so a screen
      added next month is covered without anyone remembering.
      
      1,188 component mounts across nine branches, 9.5M records inspected, no screen
      handing its view another branch's record.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      20c5cf15
    • Mahmoud Aglan's avatar
      feat(settlements): settle a member's account instead of editing around it · e45bd6d7
      Mahmoud Aglan authored
      A club that ran on paper for years does not arrive in the system as a
      clean ledger. On the first academy live, 33 players were registered with
      an invoice raised and the "pay now" toggle left off — 25 of them in two
      data-entry evenings — and 27 of those are now carrying an unpaid
      registration month plus an unpaid September renewal. Nine paid for the
      federation card in instalments typed into free-text lines. Eight invoices
      were issued at zero because no price existed yet. Three people exist
      twice. None of that is a bug in one screen; it is a whole class of file
      that reality got ahead of.
      
      The desk had four tools that each did a slice: collect a payment, correct
      one invoice's amount, back-fill missing invoices, register someone who
      started months ago. None of them answers the question an operator has in
      front of a parent — this file is wrong in several ways at once, what do
      we do about all of it — so corrections were made wherever a screen
      allowed them and the ledger drifted further.
      
      SettlementService applies a reviewed set of corrections as one
      transaction and one record: money taken and never entered (on the day it
      was actually taken), a month closed for less than it was billed because
      the player joined halfway through, a month dropped entirely, a month
      nobody billed, a card or kit sold outside the system, a free-text line
      linked to the product it was really paying for, an agreed instalment
      plan, a payment sitting on the wrong month, and an overpayment held as
      wallet credit. Money moves through PaymentService so the ledger, the
      balance and the receipt all happen; stock through InventoryService; a
      waiver is written as the admin_override the roster already knows how to
      explain, leaving subtotal_amount alone so "650 of 900, discounted" still
      reads. Nothing calls auth() or session(): actor, branch and amounts are
      parameters.
      
      AccountAnomalyScanner finds the files rather than waiting for an argument
      at the desk — seven cases, worst first, each with the sentence that says
      what to check. SettlementWorklist lists them with a CSV export;
      AccountSettlementWizard puts one account on a page, proposes the
      corrections that fit what it found, shows exactly what will be collected,
      waived and billed, and demands a written reason before it writes
      anything.
      
      Both screens are gated on a new settlements.manage permission — waiving a
      month is the academy's call, and an owner should not need a platform
      administrator to make it — delivered by migration as well as seeder,
      since db:seed only runs on a first deploy.
      
      Two things the tests caught rather than production: Postgres refuses FOR
      UPDATE on an aggregate, so numbering settlements from max(id) would have
      rolled back a whole settlement the operator had already confirmed; and
      payment_plans_status_check has no 'partial', so a part-paid plan is
      active with the count saying how far along it is.
      
      Verified against a restored oc-sport tenant: 20 settlement cases and 7
      render/permission cases pass, including cross-participant access, a
      future date, an oversized payment, and a failing second action rolling
      the first one back. Full suite 298 tests, no failures.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      e45bd6d7
    • Mahmoud Aglan's avatar
      fix(roster): count the registration money however the receptionist typed it · fa06830c
      Mahmoud Aglan authored
      The قيد column asked the database one question — is there an invoice line
      carrying this product's itemable morph? That is how the POS writes a sale
      and not how most of this money was collected. A receptionist taking the
      first instalment types "القسط الاول من القيد" into a free-text line; the
      player has paid, and the roster called him a non-buyer. On OC-Sport that
      is fourteen lines across nine players, every one of whom had paid.
      
      BundledProductLine reads those lines the way SubscriptionLine reads
      subscription ones: over whole normalised words, matched against the words
      that identify the product and nothing else. "قيد اشتراك فريق اتحاد الكرة"
      is identified by قيد and اتحاد — اشتراك heads half the subscription lines
      in the same ledger and فريق is how the programmes are named, so any word
      appearing in a programme name is dropped as unable to tell the two apart.
      A line that names nothing at all ("القسط الاول") is attributed only where
      it can be: the programme requires exactly one product and the invoice pays
      for no training, so there is one thing here paid in instalments and that
      is what it is paying off.
      
      The column now shows the money rather than a yes/no: paid so far against
      what is owed, "أقساط" while it is being paid off, "مدفوع بالكامل" once it
      is settled. What is owed comes from the product line when there is one —
      that price was agreed and frozen at the sale. Money typed by hand is only
      the instalments taken so far, so 2,500 of 2,500 would call a third of a
      card paid in full; there the total is the product's price for this
      member's tier, marked ≈ and explained in the cell's title.
      
      Two matching consequences: instalment wording no longer counts as
      subscription (training is billed by the month here, so a bare "القسط
      الاول" made a player who had paid 2,500 toward his card read as having
      paid for July's training), and free players no longer inflate the header's
      "بدون" count — they are exempt from the bundle, and the cell already says
      so with a dash.
      
      Verified against a restored OC-Sport tenant: all six فريق rosters render,
      the reported player (عبدالله أحمد صلاح سيد) now reads 2,500 / 8,000 ≈
      (31%) أقساط instead of "لم يشترِ", and every hand-typed payer is counted.
      271 tests pass.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fa06830c
    • Mahmoud Aglan's avatar
      fix(branch): split a programme two branches share, and give operators a way to check a client · e22cd9e4
      Mahmoud Aglan authored
      Everything so far was validated against one client database. The tenants are not
      all shaped like OC-Sport, every one of them runs `migrate --force` on its next
      container start, and there is no staging in between — so the risk worth chasing
      was never OC-Sport, it was the tenant I cannot see.
      
      Reproduced by building one. On a database where a programme's groups run at two
      branches, 2026_09_13_000002 declines to guess and falls through to the
      main-branch fallback. The group at the other branch keeps pointing at a
      programme that branch can no longer see: its name renders blank, and
      PricingService cannot find a base price for it, so the enrolment cannot be
      billed at all. `لا يوجد سعر محدد`, on a group that worked the day before. Silent,
      and caused by the migration rather than found by it.
      
        - 2026_09_13_000003 replicates instead of picking a winner. The programme keeps
          its identity where it was pinned, every other branch using it gets a copy of
          its own, and that branch's groups, enrolments, active prices and product
          bundles are repointed at the copy. Nothing is deleted and nothing changes
          branch. Two branches running "فريق 2018" now have two rows that can diverge,
          which is the point — the same answer the product already gives for groups.
          Verified on a constructed tenant carrying the fault, and a no-op on OC-Sport.
      
        - `php artisan branch:audit` reports what is silent in the UI: strictly-scoped
          rows with no branch (not a leak — a disappearance, present in SQL and on no
          screen), children in a different branch from their parent, and programmes
          with live enrolments and no active price. Exits non-zero so it can gate a
          deploy check. On OC-Sport it finds one genuine pre-existing problem —
          programme #32 has six active enrolments and no price at all — and no branch
          integrity faults.
      
        - BranchValidationRulesTest closes a gap in the suite itself: every other
          branch test needs a restored Postgres tenant and skips without one, so on an
          ordinary `php artisan test` none of them run. This one reads source, so it
          runs everywhere — banning a raw `exists:` rule on a branch-owned table (they
          compile to a raw query that accepts any id in the academy, and the property
          feeding one is usually browser-settable), and failing when a model carries
          branch_id in $fillable without declaring how it is scoped.
      
        - Event now declares BRANCH_SCOPE_EXEMPT with its reasoning, so that being
          academy-wide reads as a decision rather than as a model somebody forgot.
      
      Standard suite: 231 tests, no failures. Against a restored tenant: 28 branch
      tests, 23,093 assertions — and the same suite passes against the constructed
      tenant that carried the split fault.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      e22cd9e4
    • Mahmoud Aglan's avatar
      test(branch): prove each widget filters by the branch selected, not merely by a branch · bf549c87
      Mahmoud Aglan authored
      The previous check asked whether a dashboard query mentions branch_id. That is
      the weaker half of the question. A widget that hardcoded the main branch, or read
      a stale id off the URL, or took auth()->user()->branch_id instead of the session,
      would mention branch_id on every query and still show the wrong branch's numbers
      — and it would look perfectly correct in testing, because the main branch is the
      one usually selected.
      
      So the assertion is now on the bound value, not the SQL text. Laravel's bindings
      are positional, so the value belonging to a `branch_id = ?` predicate is found by
      counting the placeholders before it. Every dashboard is rendered under each
      branch that carries data, and every branch id bound into a branch_id comparison
      must equal the branch the session selected.
      
      The seven widget components are also mounted directly, rather than only through
      the pages that embed them. Two of them — EnrollmentTrends and RevenueWidget —
      are written but on no view today, so page-level coverage alone would have said
      nothing about either.
      
      Measured on the live tenant: with branch 1 selected, 536 branch bindings, all of
      them 1. With branch 2, 1,182 bindings, all of them 2. No dashboard binds a branch
      other than the selected one.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      bf549c87
    • Mahmoud Aglan's avatar
      test(branch): watch the SQL a dashboard runs, not just the records it renders · 73a61a77
      Mahmoud Aglan authored
      The two existing suites cannot catch a leaking dashboard. BranchIsolationTest
      proves the scope narrows a model; BranchScopedScreensTest looks for another
      branch's records in the rendered HTML. But a dashboard renders totals, not
      records — a revenue widget quietly summing every branch shows a number that is
      simply wrong, with no uuid anywhere to give it away, and both suites pass.
      
      Dashboards are also where the raw query builder lives, because that is what
      aggregates are written in, and a raw DB::table() goes straight past every global
      scope. So this asserts at the only layer that sees both: the wire.
      
      Two checks:
      
        - Every SQL statement each of sixteen dashboards runs is captured with
          DB::listen, and any query reading a branch-owned table without mentioning
          `branch_id` anywhere — its own WHERE, a join, a subquery the scope added —
          is a failure. Deliberately crude, because a strict SQL parse would be worse
          than useless here: a query that never says the word never asked.
      
        - The per-branch figures for participants, enrolments, invoices, payment
          totals, attendance and groups must add up to the academy-wide figure. A
          widget ignoring the branch returns the whole academy for every branch, so
          the sum comes out a multiple of the truth.
      
      Current state: all sixteen render, 1,354 queries captured, 902 of them touching
      branch-owned tables, and every one names a branch.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      73a61a77
    • Mahmoud Aglan's avatar
      fix(branch): the catalogue belongs to a branch too, and events belong to none · d3cbd5f1
      Mahmoud Aglan authored
      The first pass put programmes, products, prices, promotions, warehouses and
      receipt templates in the *shared* bucket, where `branch_id IS NULL` means "every
      branch uses this row". The reasoning was that a missing base price is a hard
      failure that stops a sale, so hard-filtering the catalogue risked leaving a
      branch unable to sell anything.
      
      That bought safety with the wrong currency. A branch is meant to read as its own
      installation — its own programmes at its own prices, its own products, its own
      stores — and a programme offered at one branch turning up in another branch's
      dropdown is the same bug as a group doing it. The shared bucket just hid it
      behind a plausible-sounding rule.
      
      The data said the caution was unnecessary. Across the live tenant no group
      points at a programme in another branch, no base price prices a programme in
      another branch, and exactly three catalogue rows had no branch at all. The
      catalogue was already per-branch in practice and merely unlabelled.
      
        - Programmes, base prices, pricing rules, promotions, products, product
          categories, kits, warehouses, receipt templates and wallets move to the
          strict bucket. `kits` and `product_categories` gain the column; the rest
          only needed their nulls resolved, from actual usage where a link existed
          and from the main branch otherwise.
      
        - Events go the other way and lose the trait entirely, with their
          registrations. An event is an academy-wide occasion and is genuinely not
          per branch. The column stays — dropping it would be destructive — but
          nothing reads it.
      
        - Only people and the academy calendar stay shared: employees, trainers,
          guardians, holidays. A coach who works two pitches needs one record visible
          from both, not two that drift. Even there the nulls are narrowed — anyone
          who demonstrably belongs to one branch is pinned to it, which in this tenant
          leaves none shared at all.
      
        - Pricing rules are the one model whose branch is genuinely many-valued: the
          wizard targets a list through `pricing_rule_branches` and deliberately
          leaves the legacy column null. A column scope would have hidden every rule
          it has ever created, so PricingRule supplies its own scope reading the pivot
          — and BelongsToBranch now lets a model do that.
      
        - Forms that let a user save a catalogue row with no branch now require one,
          and the "كل الفروع" option is gone from those pickers: on a strictly scoped
          table that choice does not mean every branch, it means none. The setup
          wizard's seeded prices are filed against their programme's branch rather
          than null, so a new academy does not finish setup unable to sell.
      
        - Comments throughout said "SHARED — branch_id NULL means every branch uses
          this row". They now say what is true.
      
      tests/Feature/PricingSurvivesBranchScopeTest.php is the guard on the original
      worry: it prices every live enrolment inside its own branch on each run. It
      passes, and reports the one programme that has no active base price at all — a
      pre-existing gap, unrelated to scoping. Branch suites: 14 tests / 23,060
      assertions against a restored tenant. Standard suite: 224 tests, no failures.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      d3cbd5f1
    • Mahmoud Aglan's avatar
      feat(branch): make branch an isolation boundary instead of a filter each screen remembers · d714d239
      Mahmoud Aglan authored
      Branch was built as a reporting lens. Nine tables carried `branch_id`, the trait
      that stamped it deliberately added no scope, and each of 212 Livewire components
      was individually responsible for remembering to filter. Most remembered for
      their main list query. Almost none remembered for the <select> rendered beside
      it — so picking a branch narrowed the table and left every dropdown, typeahead,
      count badge and print template still offering the other branches' groups,
      players, facilities and trainers.
      
      On OC-Sport, where only ZSC is live and six other branches sit half-configured,
      that surfaced as stray rows turning up in pickers all over the app. An audit of
      the whole surface found 434 such leaks across 165 files.
      
      Per-screen patching would not have held: the next component added would forget
      again. So branch now works the way academy already does — a global scope on the
      model, which no screen can route around.
      
        - BranchScopeState owns the on/off switch, BranchScope the filter itself.
          ResolveBranchContext activates enforcement; it stays off for console and
          queues, guests, member-portal accounts and the routes in
          config/branch_scope.php. A cron that silently billed one branch, or a parent
          hidden from their own child, would each be worse than the leak being fixed.
      
        - Models fall into four buckets. STRICT (operational records — players,
          groups, facilities, invoices, payments, attendance) filter `branch_id = :active`.
          SHARED (catalogue and configuration — products, base prices, pricing rules,
          programmes, trainers) filter `branch_id = :active OR branch_id IS NULL`,
          because null there means "every branch uses this row" and hard-filtering it
          would leave other branches with no active base price — a hard failure that
          stops a sale, not a tightening. CHILD line items scope through their parent
          relation rather than growing a denormalised branch_id that drifts. The rest
          are academy-level and get no trait.
      
        - The migration adds branch_id to 22 more tables and backfills every existing
          row parent-first (a session from its group, an attendance record from the
          session), falling back to the academy's main branch. Every step is guarded
          and additive, and it is safe to re-run: verified by rollback and re-migrate
          against a populated tenant copy.
      
        - The residue a scope cannot catch is fixed by hand: `exists:` validation
          rules run raw SQL and accepted any id in the academy, browser-settable
          `#[Url] public $branch_id` properties were an authorisation bypass rather
          than a filter, raw DB::table aggregates bypassed Eloquent entirely, and
          several figures attributed a row through the wrong table (an invoice's
          branch read from its payments'). Screens that are cross-branch by design —
          transfers, executive roll-ups, branch administration — now opt out
          explicitly and narrowly.
      
      Verified against a restored OC-Sport copy: BranchIsolationTest and
      BranchScopedScreensTest, 13 tests / 22,897 assertions, comparing what Eloquent
      returns under each branch against what raw SQL says is in it, rendering every
      staff screen, and proving a branch id in the URL cannot override the selected
      one and a detail route cannot open another branch's record. Standard suite: 223
      tests, no failures.
      
      docs/agent-rules/19-branch-isolation.md records the rule and how to add a new
      branch-owned table.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      d714d239
  3. 01 Sep, 2026 18 commits
    • 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
    • Mahmoud Aglan's avatar
      fix(cache): stop refusing to restore the objects we cache on purpose · 905ffefc
      Mahmoud Aglan authored
      Every branded page on the live tenant answered 500 with
      "BrandingService::for(): Return value must be of type BrandProfile,
      __PHP_Incomplete_Class returned".
      
      config/cache.php shipped Laravel's default serializable_classes => false,
      which unserializes cache values with allowed_classes: false. That is safe for
      an app that caches only scalars and arrays, and fatal for one that does not.
      We cache whole value objects deliberately: BrandProfile is the entire tenant
      brand, resolved once and held until branding changes, and read by every admin,
      portal and print layout. With classes refused it came back as
      __PHP_Incomplete_Class, the return type threw, and the admin went dark.
      
      The setting exists to stop a gadget chain in a cache an attacker can already
      write to. Ours is the tenant's own Postgres, reachable only by the app;
      anyone who can write there can do worse directly. CACHE_SERIALIZABLE_CLASSES
      lets a deployment pass its own allowlist without a code change.
      
      BrandingService now also checks what the cache handed back before trusting it,
      and rebuilds when it is not a profile. A cache that cannot return this class
      should cost a rebuild per request, never a 500 — the failure has to degrade,
      not detonate.
      
      Verified: every admin screen and the group roster render against a restored
      copy of the live tenant with CACHE_STORE=database.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      905ffefc
    • Mahmoud Aglan's avatar
      fix(pos): charge a member the member price · 55b03a06
      Mahmoud Aglan authored
      The terminal read products.selling_price and never called the pricing engine,
      so a product priced per membership type — a member rate and a walk-in rate,
      which is how base_prices has always modelled it — sold at whichever single
      number the catalogue carried. Members were charged the non-member price and
      nothing on the receipt showed it had happened.
      
      Every product line now comes from PricingService, which resolves the base
      price for THIS buyer and then applies the academy's rules to it. selling_price
      stays the fallback for a product nobody has priced through the engine: it is
      the price the catalogue advertises, and refusing the sale outright would close
      the shop over a configuration gap.
      
      Cashiers scan first and identify the customer afterwards at least as often as
      the other way round, so selecting or clearing a participant re-prices what is
      already in the cart, and the product grid shows that buyer's price with the
      list price struck through beside it — a rate the cashier cannot quote out loud
      is a rate that gets argued about at the counter.
      
      Checkout prices everything again before it believes any of it. The cart is a
      public Livewire property, so the unit prices arriving at checkout are whatever
      the browser last sent; if the engine's answer differs from what the cashier is
      looking at, the sale stops rather than charging a total nobody saw.
      
      Verified against a restored copy of the live tenant: with member/non-member
      base prices on a real product, member 900, non-member 1,200, walk-in 1,200,
      and a product with no base price falls back to its catalogue 8,000.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      55b03a06
    • Mahmoud Aglan's avatar
      fix(groups): read the subscription column off what the invoice actually says · f0f54ec3
      Mahmoud Aglan authored
      The roster column called itself الدفع and answered two questions wrongly.
      
      WHAT COUNTS. "Subscription" was every invoice line with no itemable — which
      is every line a receptionist typed by hand. On the live tenant that swept in
      42,800 EGP of federation registration fees ("قسط القيد", "قيد اشتراك",
      "أقساط متبقية من قيد اتحاد الكرة") and kit ("الزي", "شنطة لبس") and reported
      it as training money players had paid. SubscriptionLine now reads the text:
      the academy's own product names first, then its programme names, then the
      words for registration and kit — matched on whole normalised WORDS, never
      substrings, because "تجهيزي" contains the letters of "زي" and a substring
      match turns a subscription into merchandise. Anything still unrecognised
      keeps counting as subscription, so no line disappears unannounced.
      
      WHICH MONTH. The month was the invoice's issue_date, which is only the
      covered month when the invoice was raised inside it. August's subscription
      typed up in September belonged to no cycle at all, and one invoice covering
      July and August counted twice over. Months now come from the invoice's own
      metadata, then any month named in the line or the notes ("اشتراك يوليو 2026",
      "اشتراك شهر 8", ranges), then the issue date — and a line covering several
      months is split evenly across them, remainder on the last, so the parts can
      never exceed what was billed. A line that names no month is still judged by
      its date, which is the only evidence there is.
      
      A year is only read as a date when it sits near the invoice, so the age group
      in "فريق 2011/2012" cannot date a 2026 subscription to 2011.
      
      Verified against a restored copy of the live tenant: subscription billed for
      July 123,173 -> 101,073 EGP and August 200,538 -> 187,038 EGP, and every
      excluded line was checked one by one — all 14 are registration or kit, none
      is a programme. No line currently moves month; that half is future-proofing
      plus the combined-invoice case the old data is full of.
      
      Column renamed الدفع -> الاشتراك and the header now carries the year as well
      as the month, because "سبتمبر" alone does not say which September.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      f0f54ec3
    • Mahmoud Aglan's avatar
      fix(website): reachable dropdowns, one h1 per page, and the content a reference sweep found missing · 36e7bbf3
      Mahmoud Aglan authored
      A pointer-level test and a page-by-page diff against the reference site, rather
      than reading the markup.
      
      Dropdowns could be opened but not used. The panel sat 4px below its trigger with
      nothing in the gap, and the menu closed the instant the pointer left the
      trigger's box — so travelling to an item crossed dead space and the menu shut
      before the click landed. The panel now starts flush against the trigger
      (`top-full`, padding inside the panel rather than a margin outside it), and the
      close is delayed and cancellable. Same contract applied to the language
      switcher, which had the identical gap. Verified by dispatching real mouse moves
      along the path a hand takes: the menu survives the trip and the click lands on
      /en/football.
      
      `columns` silently dropped its children. It reads them from slots col1..colN, so
      a child in the generic `default` slot — what a blueprint import or a hand-built
      tree naturally produces — matched no column and vanished with no error and no
      empty box. That is how the about-us video disappeared. Loose children are now
      dealt out across the columns.
      
      Nine of eleven pages had no h1 at all. Blocks hardcoded their heading level, so
      whichever section carried a page's title rendered as h2 and the document had no
      top-level heading. `heading_level` is now part of the section-header contract
      (and of text_image, video, profile_card, contact_form, app_download, cta,
      rich_text), so the section carrying the page title is an authoring decision
      rather than an accident of which block was used. Every page now has exactly one.
      
      Also from the sweep:
      - El3ab's entire privacy policy — nine sections, both languages — was absent
        from our page. Restored from the reference render.
      - The line the reference shows above its contact form was missing; contact_form
        gains `form_intro`.
      - The chairman's name was a <p> on top of the portrait; it is a heading.
      - Footer column titles were <h4> with no <h3> above them, a skipped level in the
        outline screen readers navigate by. Promoted to <h3>.
      - `heading_rule` had been added to app_download *inside* its features repeater,
        so every feature row offered a meaningless "underline" control and the section
        heading offered none. Moved to where the heading actually is.
      
      Verified: 22 page/locale combinations answer 200 with no logged block failures,
      137 block/variant combinations render, every page has exactly one h1, the
      carousel still does not scroll the page, and the suite passes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      36e7bbf3
    • Mahmoud Aglan's avatar
      fix(website): stop the partner carousel scrolling the page, and turn the hero... · 737b1fdb
      Mahmoud Aglan authored
      fix(website): stop the partner carousel scrolling the page, and turn the hero showcase into a real flipping carousel
      
      Three faults, all found by watching the built site rather than reading it.
      
      The partner carousel dragged the whole page down to itself. Its timer called
      `el.scrollIntoView()` every few seconds, and scrollIntoView walks up and scrolls
      *every* scrollable ancestor including the document — so a reader anywhere on the
      page was hauled to the partners section on a loop. It now sets `scrollLeft` on
      its own track, which cannot move anything but itself. Verified: twelve seconds
      on the homepage, `window.scrollY` never leaves 0, while the track's own
      scrollLeft still advances.
      
      The hero showcase was manual when it should play itself. The reference plays
      each design for a beat, turns it on its Y axis to show the back, holds again,
      then hands over to the next — an Embla autoplay at `delay: 2500` with a
      `duration-700` `rotateY(180deg)` flip and `backfaceVisibility`, read out of
      their own bundle rather than guessed. Ours sat still until someone clicked a
      swatch. It now runs that cycle: a genuine 3D flip of one object, not a crossfade
      between two pictures, with both faces backface-hidden so they never show through
      one another mid-turn. Hovering pauses it; reduced-motion skips it entirely.
      
      The colour swatches were large filled circles under the garment, which read as
      loud page furniture rather than a control. They are gone by default. A site that
      wants them gets `showcase_controls`, and they render as the same discreet
      progress dashes the other carousels use.
      
      New fields: `showcase_autoplay` (default on), `showcase_interval` (default
      2500ms, matching the reference), `showcase_controls` (default off).
      
      Verified on a local replica carrying the full 11-page site: sampling the hero
      across a cycle shows active=1 front → active=1 flipped → active=2 front, so the
      flip and the hand-over both really happen; 22 page/locale combinations answer
      200 with no logged block failures; 137 block/variant combinations render; suite
      passes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      737b1fdb
    • Mahmoud Aglan's avatar
      feat(website): let the branches block announce a location before it opens · 16db6135
      Mahmoud Aglan authored
      Checking the rendered result against the client's own branch data showed the
      block was reproducing the wrong rule. Their site lists fifteen branches and dims
      nine of them, each captioned with the dates it opens — the dimming tracks
      whether the branch is switched on, and the season window is the explanation
      shown to the reader, not the test.
      
      Ours filtered `is_active = false` out of the query entirely, so those nine
      simply did not exist on the page. A branch under construction is exactly the
      thing a marketing site wants to show.
      
      - `getBranches()` takes `$includeInactive`, cached under its own key so the two
        result sets cannot overwrite each other.
      - `data_branches` offers `show_inactive`, and treats a branch as dormant when it
        is switched off OR outside its declared season — a branch with no window stays
        open all year, as before.
      - Open branches sort first, so an announced-but-closed location never pushes a
        working one below the fold.
      
      Verified on the local replica with two dormant branches alongside seven live
      ones: the dormant pair render dimmed with their location and opening dates while
      the rest keep the accent border. 22 page/locale combinations answer 200 with no
      logged block failures, 137 block/variant combinations render, suite passes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      16db6135
    • Mahmoud Aglan's avatar
      feat(website): make the builder able to reproduce a real bilingual site · 21792905
      Mahmoud Aglan authored
      Building an existing client site inside the builder surfaced the gaps between
      "the blocks exist" and "the blocks can express a finished design". Each of these
      was found by rendering the target and comparing it, not by reading the code.
      
      Blueprint export/import (v2) now carries the whole design, not a third of it.
      It exported pages only — so importing a design gave you the content with a
      default theme and no navigation, which looks nothing like its source. It now
      carries theme settings and menus too. Menu items record their page by SLUG,
      because a page id means nothing in another tenant's database and would import a
      navigation pointing at whatever happened to hold that id. Tracking identifiers
      are deliberately excluded from the whitelist: importing a design must never
      start reporting one client's traffic into another's account. v1 files still
      import.
      
      Isolation that did not isolate. BlockRenderer catches a failing block so the
      rest of the site survives, but Laravel's View::render() calls flushState() when
      any view throws, which clears the section stack of the page *around* it.
      Rendered inside the layout's @section, one bad block therefore killed the whole
      page at @endsection with an unrelated "Cannot end a section" error — the exact
      opposite of the intent. Blocks are now rendered before the layout runs, where
      there is no open section to corrupt.
      
      Bilingual content reached templates raw. Translatable repeater sub-fields
      (a button label, a card title, a partner name) are stored as ['ar'=>…,'en'=>…]
      and read straight out of the data array, so they arrived at {{ }} as arrays and
      took the block down with "htmlspecialchars(): array given". website_text()
      resolves them, and 49 such reads across 15 block views now use it.
      
      The English site rendered right-to-left. website.css hardcoded
      `direction: rtl` on .website-body, silently overriding the dir attribute the
      layout computes from the locale. Direction now follows the document.
      
      There was no English at all. No lang/ directory existed, so every __() returned
      its Arabic key and English visitors read Arabic form labels, buttons and
      helper text. lang/en.json covers all 125 public-site strings.
      
      Smaller gaps, each of which made a real design impossible to express:
      - 'glass' was a valid navbar template and a forbidden column value; the CHECK
        constraint predated it, so choosing it failed at write time.
      - navbar_cta_text had no English twin, so a bilingual site showed one language's
        button to both audiences.
      - An empty navbar CTA fell back to the default label, so the button could not
        be turned off.
      - An anchor menu item resolved to a bare "#id", which points at nothing from a
        sub-page; it now addresses the homepage in the reader's language.
      - A footer column title was a plain string, so it could not be bilingual; the
        'about' column dropped the social row whenever columns were configured.
      - product_showcase stacked its showcase under a centred headline instead of
        laying out as the split it is.
      - A map field stores coords as ['lat'=>…,'lng'=>…] and the view passed the array
        to urlencode(), killing the block.
      - New: 'stacked' info cards, a footer spacer, heading rules and eyebrows on the
        contact block, and a nowrap on highlighted heading fragments so
        "Welcome to {OC-Sport}" cannot break mid-phrase.
      
      Verified against a local Postgres replica carrying a real 11-page bilingual
      site: 22 page/locale combinations answer 200 with zero logged block failures,
      all 137 block/variant combinations render, reserved and unknown paths still 404,
      the four migrations apply and roll back on both an existing tenant and a
      from-scratch install + seed, and the suite passes (140 tests, 483 assertions).
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      21792905
    • Mahmoud Aglan's avatar
      fix(website): make the v3 block builder actually render, and add the capabilities it was missing · 0c9d0eec
      Mahmoud Aglan authored
      The page + block builder shipped in 39f468a9 could not render a single page.
      Two independent faults, both fatal:
      
      1. `website/page.blade.php` passes no `$sections`, but `layout.blade.php`
         includes `navbar.blade.php` and `footer.blade.php`, which both dereference
         it. Every builder page died with "Undefined variable $sections" before the
         first block was rendered. `$sections` belongs to the legacy section site;
         both dispatchers now default it and prefer the authored menu tree, so one
         navbar and one footer serve both worlds and existing tenants keep the
         navigation they have.
      
      2. The `ec-*` class layer every block partial styles itself through
         (ec-heading, ec-muted, ec-surface, ec-btn, ec-eyebrow, ec-prose, ec-marquee,
         ec-block) was used in 25+ views and defined in no stylesheet. Even past the
         crash, a rendered page had no colours, no cards, no buttons. The layer is
         now written against the --site-* variables the layout already emits, so a
         theme change repaints the whole site.
      
      Alongside the fix, the capabilities a data-driven marketing site needs:
      
      - Per-language URLs. `/en/...` and `/ar/...` address the same page, SetLocale
        reads the prefix ahead of the session, and the layout emits lang, dir,
        canonical and hreflang alternates from the active locale instead of a
        hardcoded rtl. A shared link now opens in the language it names. `en` and
        `ar` are reserved slugs so a page cannot hide behind a locale prefix.
      - One section-header contract: eyebrow, heading, accent rule, subtitle, shared
        by 21 block types through `_header`, with `{braced}` fragments of a heading
        rendered in the accent colour.
      - New variants: glass navbar, `overlay_split` profile card, `focus_carousel`
        logo strip, `season_cards` branches. Ambient motion (float/glow/pulse/
        shimmer) exposed in the motion panel; showcase motion on the hero image.
      - A footer "powered by" accent band, and footer columns driven by a menu so
        they cannot drift from the navbar.
      - Branches gain photo_path and a season window; the block dims a branch that
        is out of season. A branch with no window is open all year, so nothing
        changes for existing data.
      
      Also fixed while in here: `getBranches()` bypasses Eloquent and never checked
      `deleted_at`, so a branch deleted in the ERP kept appearing on the public site.
      
      Verified on a local Postgres replica: all 136 block/variant combinations render
      with no logged failures, the locale routes answer 200 and reserved paths still
      404, migrations apply and roll back cleanly on both an existing tenant and a
      from-scratch install + seed, and the suite passes (140 tests, 483 assertions).
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      0c9d0eec
    • Mahmoud Aglan's avatar
      feat(portal): self-registration behind a closed door, and the accessibility gate · 500806ca
      Mahmoud Aglan authored
      The last of the programme, plus the exit gate CLAUDE.md requires before any UI
      work counts as finished.
      
      Self-registration (W10) — shipped, and closed
      ---------------------------------------------
      This is the only path in the product that writes into `people` and
      `participants` with no member of staff in the loop, so it ships **off**:
      `portal.self_registration_enabled` defaults to false on every tenant including
      new ones, and the middleware answers 404 — never 403, because a 403 advertises
      that there is a signup form here and invites someone to look for the setting.
      A public form that starts accepting strangers because a deploy happened is not
      a decision anybody made.
      
      Phone verification is the precondition, not a feature. The flow it replaces was
      a study in how not to do this: `verify()` accepted the constant '0000' whenever
      a seeded setting said 'demo', then resolved *any* active user by phone —
      academy owners included — and minted a token with `mobile:*`; and in the other
      mode it generated a code, cached it, and never sent it anywhere, so turning the
      bypass off locked everyone out rather than securing anything.
      
      So: no bypass exists, in any mode, behind any flag. Only the SHA-256 is stored,
      with a bounded attempt count, in a table rather than the cache — a code you
      cannot audit is a code you cannot investigate. A send that fails deletes the
      record, because a stored code nobody received is precisely the old failure.
      There is a test asserting '0000' and '1234' are refused.
      
      Registration goes **through** ParticipantService rather than around it. Writing
      the row directly skipped the participant number, the already-a-member check,
      the audit columns and ParticipantRegistered — a second creation path that
      looked identical and was not. It does not enrol and it does not take money:
      EnrollmentService::enroll() needs an actor authorised to enrol and a
      self-registering guardian is not one. The member asks; staff enrol.
      
      `people.created_by` is NOT NULL and there is no staff member here, so the
      account is created first with no person attached and becomes the author of its
      own records — which is also the truth about who typed them. Loosening the
      column would have weakened it for every other path.
      
      DuplicateDetectionService runs on every signup and its findings are stored on
      the row and shown in the approval queue. It has existed for a long time with
      nothing surfacing what it found, so a second Person for an existing member
      appeared silently and the two drifted apart forever.
      
      The accessibility gate
      ----------------------
      Eleven checks against the HTML the portal actually renders for a real member,
      not against the templates: language matching direction, image alternatives,
      accessible names on every icon-only control, a label for every form control,
      named landmarks, focus never globally removed, reduced motion honoured, a
      stated focus ring, announced errors, and dir=ltr on numeric inputs.
      
      Two things it found. There was no explicit focus-visible style, so the ring was
      the browser default — a thin blue line that disappears against a tenant whose
      brand is blue; it is now `currentColor`, which inherits an already
      contrast-checked colour and is legible on every surface in both themes.
      And validation messages were rendered as plain text: a screen-reader user
      submitted a form and heard nothing. Every one is a live region now, asserted at
      the source, because an error block only renders when there is an error and a
      clean page proves nothing either way.
      
      Also: `:focus` gets scroll-margin so a focused control is never left under the
      sticky header or the bottom tab bar (2.4.11).
      
      Verification
      ------------
      - 145 migrations from zero on an empty database, seeded, booted a second time:
        every portal grant intact, self-registration absent and therefore closed
        (SettingsService returns the default, which is false — it fails closed).
      - The restored oc_sport tenant: nothing to migrate, seeders clean, health 200,
        713 invoices / 356 participants / 649 payments untouched.
      - Suite: 76 pass on SQLite; on the tenant PortalSmoke 3/3, AdminScreens 2/2,
        PaymentProof 11/11, CheckInScan 10/10, ServiceRequestEffect 14/14,
        SelfRegistration 13/13, PortalAccessibility 11/11.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      500806ca
    • Mahmoud Aglan's avatar
      feat(portal): notification preferences, language, and transfer reconciliation · 8d392c04
      Mahmoud Aglan authored
      Three P1 items, and a latent bug each of them depended on.
      
      SetLocale was registered nowhere. The middleware has existed since early on
      and no middleware group ever included it, so `app()->getLocale()` returned the
      config default on every request and the bilingual half of an Arabic-first
      product was dead code. Worse, that default was 'en' — so every page announced
      `lang="en"` while being marked `dir="rtl"`, telling a screen reader two
      contradictory things about the same text. The default is now 'ar', the
      middleware runs, and the portal's direction follows the locale instead of being
      hardcoded.
      
      notification_preferences had per-event, per-channel columns and no interface
      anywhere: it was written to by the deleted API and by nothing else, so every
      member received everything on every channel with no way to say otherwise. The
      preferences screen defaults an unset choice to ON — the member has not asked
      for less, and silently defaulting to off means a missed instalment nobody was
      told about.
      
      The device list is on the same screen, because push is the one channel whose
      recipients a member cannot otherwise see: an old phone, a browser at work, a
      device someone else now owns, all receiving silently until the token rotates.
      
      Transfer reconciliation is the control that makes proof approval honest. A
      screenshot is not evidence; matching the day's total against the academy's own
      statement is. The report is per branch per day with deliberately blank
      statement and signature columns, because a report that cannot be signed is not
      a control. It also surfaces ageing proofs — a member told "we will check" who
      heard nothing — and transfers recorded with no proof behind them, which are
      legitimate but which a reconciler needs to expect.
      
      One column asserts a database constraint rather than a number: an approved
      proof with no payment is made unrepresentable by
      payment_proofs_approved_payment_check, so a non-zero count there means the
      constraint is gone, and the screen says exactly that. A constraint nobody ever
      looks at is one you find out about the hard way.
      
      notification_preferences.academy_id added to the model's fillable — S1 added
      the column and the model was still writing rows that belonged to no tenant.
      
      Verified on the restored tenant: 12 portal screens and 6 staff screens render
      200; suite 76 pass on SQLite.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      8d392c04
    • Mahmoud Aglan's avatar
      feat(portal): consent, deletion, requests that take effect, and the seeder bug... · e5ce6f7f
      Mahmoud Aglan authored
      feat(portal): consent, deletion, requests that take effect, and the seeder bug that would have erased it all
      
      The additions the addendum marked P0 and the programme had not built, plus
      two ordering bugs found by testing a from-scratch install rather than only the
      incremental one.
      
      The seeder bug (would have broken the portal on the second deploy)
      ------------------------------------------------------------------
      PermissionSeeder deletes every permission_role row for a role and reinserts
      its own list, and db:seed runs on EVERY container start when
      RUN_SEED_ON_FIRST_DEPLOY is true. So the portal.* grants added by migration
      would have worked exactly until the next deploy and then vanished — the
      portal 403'ing for every member, with nothing in the logs and no migration to
      blame.
      
      And on a brand-new client the migration runs before any academy exists, so it
      created no player role and granted nothing at all.
      
      Both are fixed where they belong: the permissions and the `player` role are in
      PermissionSeeder and RolesAndPermissionsSeeder now, so a fresh install gets
      them and the seeder stops erasing them. The migration stays for existing
      tenants. Verified by migrating an empty database from zero, seeding it, then
      booting it a second time and re-checking every grant.
      
      attendance.scan reaches trainers, head trainers and reception — the people who
      actually stand at a gate. payments.approve_proof reaches accountants.
      
      B2 — consent and deletion (a store-submission blocker)
      ------------------------------------------------------
      Apple 5.1.1(v) and Google both refuse an app that creates accounts and cannot
      delete them, so this is what makes a submission possible rather than a
      refinement to add later.
      
      There was no consent record anywhere in the schema — not a column — while this
      product publishes children's photographs on a public website and sends
      marketing over WhatsApp. Consents are versioned and append-only, enforced by a
      database trigger: a consent is a statement about a particular text at a
      particular moment, so editing one destroys the only thing that makes it
      evidence. Withdrawal is a new row. Bumping the document version invalidates
      previous answers, because a boolean would silently claim a member agreed to
      text they have never seen.
      
      Deletion is redaction, not erasure. This is also an accounting system:
      invoices, payments and ledger rows are the academy's books, and a member must
      not be able to delete them by tapping a button. The person's identifying data
      is destroyed, the login is destroyed, the financial record survives without
      their name. Three gates before that: re-authentication, a cooling-off window,
      and a blocked state with the reason shown when money is owed or an enrolment
      is live — shown up front, because a refusal at the last step is not respectful.
      
      Data export is the other half of the same obligation and is streamed, never
      stored: a file of somebody's whole record sitting on disk waiting to be
      collected is a second copy of the data they asked to control.
      
      B4 + E7 — requests that actually do something
      ---------------------------------------------
      `grep -rln ServiceRequest` found a model, an event, a listener and a provider,
      and no admin screen. Approving a freeze never called ParticipantService::freeze()
      — the column changed and the subscription kept running. A member was told
      their subscription was frozen when it was not.
      
      Approval is now defined by its effect, and the effect runs in the same
      transaction: if it fails the approval fails with it and the request stays
      pending. "Approved, and nothing happened" is worse than "still pending".
      
      E7 decided: an excuse is a service_request, never a direct attendance write.
      Two contradictory implementations existed and neither worked — ParentExcuseForm
      validated, stored its medical attachment to the PUBLIC disk, then discarded the
      record behind a `// TODO` while telling the parent it had succeeded; and the
      deleted API wrote status='excused' with no marker and no check that the session
      belonged to the participant, so a player could excuse himself and corrupt every
      attendance figure the product reports. Approval goes through
      AttendanceMarkingService with the approving staff member as marker, and a
      coach's existing observation is never overwritten. ParentExcuseForm is deleted:
      it never worked, so there was nothing to preserve.
      
      B1, B3, B5, B7, B11, B12, B13
      ------------------------------
      - Document upload and renewal. The admin half has been complete for a long
        time — DocumentApprovalList, a nightly documents:expire, a
        MedicalCertificateAlert — and the member half did not exist, so a certificate
        expired at 06:00 and the member had no way inside the product to fix it.
      - Payable instalments. reminders:installments and push:installment-due fire
        daily and the only payment path ever built charged the whole due_amount: the
        push said pay and the app could not. Settled from the wallet, which is the
        one payment the portal can complete immediately — money the academy already
        holds.
      - Waitlist accept/decline. The offer, the expiry and the push all existed with
        no accept surface anywhere, so the offer expired and the place went to nobody.
      - Renewal surface, for RenewalPolicy::ManualRenew, which explicitly means a
        human decides.
      - can_authorize_payment gates instalment payment as well as proof submission.
      - Every member upload is streamed from the private disk with attachment,
        nosniff and no-store. A member upload is never a URL.
      - /health asserts the schema this code needs and names what is missing.
        Verified against a deliberately half-migrated database: 503, and
        ["payment_proofs","invoices.branch_id"].
      
      /parent retired
      ---------------
      Permanent redirects to the equivalent portal screen, parameters preserved so a
      bookmarked invoice still lands on that invoice. Two member portals must not
      coexist: they diverge, and the one nobody updates is the one a member has
      bookmarked.
      
      E2 recorded in config/compliance.php: 18, hardcoded rather than per-academy,
      because a settings row nobody tunes is a false choice and a twelve year old
      must never open the app and see the household's arrears.
      
      Verification
      ------------
      - 144 migrations from zero on an empty database, then db:seed, then a second
        boot — every grant intact.
      - The same on the restored oc_sport tenant: nothing to migrate, seed clean,
        713 invoices / 356 participants / 649 payments untouched.
      - 11 portal screens render 200 for a real member; 4 staff screens for an owner;
        members refused on both staff screens.
      - Suite: 76 pass on SQLite; on the tenant, PortalSmoke 3/3, AdminScreens 2/2,
        PaymentProof 11/11, CheckInScan 10/10, ServiceRequestEffect 14/14.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      e5ce6f7f
    • Mahmoud Aglan's avatar
      fix(routing): register the scanner above the wildcard that was swallowing it · fb2519c6
      Mahmoud Aglan authored
      /attendance/{session} binds its parameter to a uuid, so /attendance/scan
      registered after it never matched: the wildcard took 'scan' first and died
      casting it to a uuid — a 500, not a 404, so it did not look like a routing
      problem at all.
      
      Found by rendering the staff screens rather than by trusting that they route:
      route:list sorts its output, so it showed the scanner sitting above the
      wildcard when the file has it below. The list is not the matcher.
      
      Adds the staff-screen smoke test that caught it, which also asserts a member
      account is refused the approval queue and the scanner.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fb2519c6
    • Mahmoud Aglan's avatar
      feat(portal): PWA, push, the check-in gate, the native shell, and the docs that were wrong · 1819b943
      Mahmoud Aglan authored
      S6, S7, the staff half of S8, S9 and S10, plus the admin screens S3 and S5
      were waiting on.
      
      PWA (S6)
      --------
      The worker lives at /app/sw.js and is generated per deploy, because the
      precache list comes from public/build/manifest.json and the cache name is a
      hash of it — a deploy evicts the old cache instead of leaving a worker
      serving asset URLs that no longer exist.
      
      Its scope is /app/, not the root: a root worker would control /dashboard and
      /api too, serving admins a stale shell and leaving cached credentialed
      responses on a shared front-desk tablet.
      
      HTML is never precached. wire:navigate swaps <head> wholesale and prefetches
      on hover, so an HTML cache fills with unvisited pages and then injects @vite
      hashes from a build that no longer exists — a blank page with no error.
      /livewire/* is never cached at all: its snapshot checksum is bound to APP_KEY
      and the session, so a replayed one is a corrupt-snapshot error rather than a
      stale render. The only offline artifact is a static page with no session and
      no CSRF token in it.
      
      nginx gets exact-match locations for /app/sw.js and /app/manifest.webmanifest.
      Both end in an extension the static-asset regex claims, and that regex ends in
      try_files $uri =404 — so without these the worker 404s before reaching PHP.
      
      Push (S7)
      ---------
      FCM, not VAPID. kreait/firebase-php is installed, device_tokens exists, twelve
      listeners already funnel through PushNotificationService, every client has
      their own Firebase project, and FCM HTTP v1 delivers to Web Push endpoints
      with the same CloudMessage and the same token column. VAPID buys independence
      from Google — not a constraint here — for a second sender, table, log path and
      prune policy.
      
      So the change is: platform CHECK widened to include 'web', a user_agent column
      for sensible pruning, and a unique index on (device_token, user_id) — never on
      the token alone, which is what the deleted DeviceController keyed on, letting
      anyone claim anyone's token so the victim's phone received the attacker's
      notifications. Duplicates are cleared before the index, because a failed
      migration blocks every later one on that client forever.
      
      The check-in gate (S8)
      ----------------------
      Staff-scan only. The printed-poster direction stays cut: a printed QR is a
      public, permanent, non-secret string, so rotation is impossible by
      construction — it proves the member once visited, or knows someone who did.
      
      The scanner screen works with a connected barcode reader by default and uses
      BarcodeDetector where the browser has it, because most reception desks have
      the reader and not the camera permission.
      
      A real bug the tests caught: participants.status is cast to an enum, so
      comparing it to the string 'active' was always false — the gate would have
      turned everyone away.
      
      The native shell (S9)
      ---------------------
      flutter_shell/ holds one long-lived Sanctum token in the Keychain or
      EncryptedSharedPreferences with the single ability portal:session, and
      exchanges it at /app/session-exchange for an ordinary web session in the
      WebView's own jar. The token never reaches JavaScript. /app/* is never
      exempted from CSRF — that shortcut is what turns a wrapper from safe into
      trivially exploitable.
      
      Every bridge is an exported native capability, so each is narrow and checked
      natively: the host allowlist is compared against the origin read from the
      controller, never from the page; biometrics gate a native action and return
      nothing the page can use as an authorisation decision; QR is decoded natively
      and only the string crosses.
      
      flutter_inappwebview rather than webview_flutter, because <input type="file">
      is inert in a bare Android WebView without onShowFileChooser — and that single
      gap breaks the transfer-proof upload, which is the portal's whole money path.
      
      Two endpoints only, and they are the only routes on the sanctum guard. The
      deleted API minted tokens with mobile:* — every endpoint it would ever grow.
      
      E3's recommendation stands and the shell is not shipped this cycle. It exists
      so that shipping is a decision rather than a project.
      
      App content (S10)
      -----------------
      One `channel` column on website_news and website_sections instead of the
      parallel CMS the plan called for. website_sections, website_news,
      website_menus, media, a page builder and website:blueprint export|import all
      already exist; a second CMS is a second migration surface, a second editor to
      keep in step, and a second place for content to go missing, forever.
      
      Admin screens
      -------------
      Portal invitations, where the raw link exists for exactly one render and is
      never recoverable afterwards. The duplicate-account merge screen E4 asked for
      — the prerequisite for ever putting a unique index on users.phone, and the
      reason ambiguous phone logins can be refused rather than guessed. Both move
      references rather than deleting rows: a deleted user id in a financial record
      is worse than a duplicate account.
      
      Documentation that was actively wrong
      -------------------------------------
      docs/agent-rules/05-financial-integrity.md described double-entry as two rows
      with a type of 'debit' or 'credit', and 16-enums-and-checks.md registered that
      vocabulary. That schema has never existed — 2024_01_01_000013 created the
      single-row shape with both account columns from the start. Anyone writing code
      from that text got a mass-assignment no-op and a row that silently said
      nothing. CLAUDE.md repeated the same claim, and also said Livewire 3 while
      composer.json says ^4.3 — a difference that decides whether a public property
      is an IDOR.
      
      The test suite is now symmetrical: tests that build their own tables skip off
      SQLite, tests that need a real tenant skip off Postgres, so the whole file
      runs clean under either connection instead of one of them being a lie.
      
      Suite: 76 pass on SQLite (24 skipped), and against the restored tenant
      PortalSmokeTest 3/3, PaymentProofTest 11/11, CheckInScanTest 10/10.
      portal.css is 4.98 kB gzipped against app.css at 31.10 kB.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      1819b943
    • Mahmoud Aglan's avatar
      feat(financial): InstaPay transfers, reviewed before they become money · 2c95f617
      Mahmoud Aglan authored
      S5. The academy publishes a handle, the member transfers and records what
      they sent, and staff turn that claim into a payment only after matching it
      against the academy's own statement.
      
      An unverified screenshot must never create a Payment. `transactions` are
      immutable and recordPayment() forces status = Confirmed and posts to the
      ledger immediately, so a proof is a separate object with its own lifecycle
      and only approval calls recordPayment() — double-entry happens exactly once
      and nothing in the ledger is ever edited.
      
      **A screenshot is not evidence.** It is a convenience. The control is
      `sender_reference`, unique per academy per method behind a partial index,
      which kills replay, cross-invoice reuse and "someone else's transfer against
      my invoice" in one constraint. The reviewer types the amount from the
      statement; `amount_claimed` is what the payer said and is never what gets
      posted.
      
      Concurrency is a conditional UPDATE, not a disabled button. Two reviewers
      open the queue and both see an enabled Approve; the second one's UPDATE
      matches zero rows and raises InvalidStatusTransitionException. A row that has
      left `pending` is frozen by a BEFORE UPDATE trigger — approving a proof is
      the moral equivalent of taking cash, and Auditable::createAuditLog() takes
      its user from auth() at boot and silently writes nothing when it cannot
      resolve an academy, so the approval facts are columns on the row rather than
      an audit-log dependency.
      
      Overpayment is capped at what is due and the excess is deposited to the
      member's wallet in the same transaction. InvoiceStatus::Overpaid exists but
      nothing consumes it and it drives due_amount negative, after which
      getCollectionRate() and ParticipantBillingService start summing negatives.
      
      branch_id is NOT NULL on a proof. Revenue is branch-attributed only through
      payments, so a NULL-branch payment lands in the all-branches total and in no
      branch — the columns stop summing with no error anywhere.
      
      E6 decided as recommended: all five method CHECKs that lacked `instapay` get
      it, the till included. Reception will take an InstaPay transfer within a
      month of launch, and the failure mode of leaving the POS out is a Postgres
      23514 at the till in front of a customer. pos_transactions and
      pos_split_payments also gain `bank_transfer`, which they never had.
      
      The review queue ships before the member-facing upload, on purpose: a proof
      that can be submitted and never reviewed is a promise to a member that nobody
      is keeping.
      
      Proof files go to the private disk and are streamed by a controller that
      authorises the submitter, a co-guardian of the same member, and staff holding
      payments.approve_proof — Content-Disposition: attachment, nosniff, no-store.
      The parent excuse form wrote its medical attachments to the public disk; that
      is the mistake not to repeat.
      
      Verified against a restored copy of backups/oc_sport-20260831-081053.dump: a
      600 EGP transfer against a 500 EGP invoice posts 500 to the invoice
      (Dr 1010 Bank / Cr 4000 Training Revenue, branch attributed) and 100 to the
      wallet; duplicate reference, self-approval, zero amount, second approval and
      editing a settled row are all refused.
      
      Suite: 87 passed, 3 skipped locally; 11 InstaPay tests pass against the
      restored tenant.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2c95f617
    • Mahmoud Aglan's avatar
      feat(portal): identity, the member portal shell, and the check-in pass · acca60b0
      Mahmoud Aglan authored
      S3, S4 and the core of S8.
      
      Identity (S3)
      -------------
      GuardianResolver replaces ten hand-copied
      `Guardian::where('person_id', …)->first()` lookups, every one wrong in the
      same two ways: `->first()` on a column with no unique constraint, so a
      guardian holding two rows saw one set of children and was 403'd on the rest,
      silently; and no answer at all for an adult member, because all eleven
      app/Livewire/Parent/* components end in ->firstOrFail() and a player has no
      guardian row. That is why a player given the `parent` role saw empty lists —
      the domain had no path from a user to his own participant.
      
      PermissionService::getChildParticipantIds() also carried
      `->where('person_id', …)->orWhere('user_id', …)`, which with the tenant
      global scope appended compiles to `person_id = ? OR (user_id = ? AND
      academy_id = ?)` — the first branch escaping the tenant filter entirely. The
      closure is what keeps both branches inside it.
      
      A real `player` role and the portal.* permissions ship as a guarded
      migration, not a seeder: db:seed only runs when RUN_SEED_ON_FIRST_DEPLOY is
      true, so a client deployed outside the one-click template would never receive
      them. Same pattern as 2026_09_01_000001.
      
      portal_invitations stores only the SHA-256 of its token — a raw token in a
      row is a password in a row — and consumption is one conditional UPDATE whose
      WHERE clause carries every condition, so two taps on the same link on a phone
      cannot both create an account. Activation lives in a plain controller, never
      Livewire: a single-use token in a public property is serialised into the page
      on every round-trip.
      
      users.email stays NOT NULL UNIQUE, deliberately. 2024_01_01_000002 declares
      it inside Schema::create, so Postgres emits a UNIQUE CONSTRAINT that cannot
      be made partial without a DROP CONSTRAINT in up(); CREATE INDEX CONCURRENTLY
      cannot run in a migration transaction; and password_reset_tokens.email is the
      primary key the broker keys on. Portal accounts get p{uuid}@portal.invalid
      (RFC 2606, never routable) plus an email_is_synthetic flag every mail path
      checks. No unique index on users.phone either: 2026_08_30_000004 logged that
      it left duplicates in place, so one would hard-fail on at least one live
      client and then block that client's migrations forever.
      
      Phone login now refuses when one number matches several different people —
      signing someone into a stranger's account — while still resolving a genuine
      duplicate pair for the same person.
      
      config/branch_lock.php gains portal.* and parent.*: RequireBranchSelection
      runs on the whole web group, so without it any user holding branches.view_all
      in all-branches mode is bounced out of the portal by middleware.
      
      The portal (S4)
      ---------------
      Five tabs at /app — الرئيسية, التدريب, المدفوعات, الأكاديمية, حسابي — with
      the pass as a header affordance because it is per active profile: a guardian
      with three children needs three.
      
      PortalContext is the scope rule the IA turns on, decided once instead of
      eleven times: training is member-scoped, money is family-scoped. The old
      components each re-read session('active_child_id') independently while
      ParentFinances ignored it and aggregated everyone — the domain saying out
      loud that a household has one balance. The active id is re-validated against
      GuardianResolver on every read, so a value put into the session, or left
      there after a withdrawal, cannot widen what an account sees.
      
      No participant id is held in a public property anywhere in the namespace.
      This is Livewire v4, where a plain public property is settable from the
      browser, so a check in mount() that is not repeated in render() is
      decoration, not a check.
      
      portal.css is the only entrypoint built with `source(none)`. app.css and
      website.css are each a bare `@import 'tailwindcss'`, so v4 auto-detects from
      the project root and both emit the identical complete utility set — a third
      file written the same way would have been a third identical copy. Measured:
      portal.css 17.11 kB / 4.54 kB gzipped against app.css at 208 kB / 31 kB.
      
      Screens surface what was always one join away and never loaded: the coach
      taking each session and the reason for a substitution, cancelled_reason so an
      empty week does not read the same as Eid, and per-event registration for the
      right child — answerable only since event_registrations gained participant_id
      in S1.
      
      The check-in pass (S8 core)
      ---------------------------
      qr_check_in_enabled has been a toggle in system settings with zero functional
      readers since 2026_07_27: the product advertised a feature that did not exist.
      
      The pass asserts identity and never authorizes. Enrolment, participant
      status, session existence and branch are fresh reads at every scan, which is
      what makes a suspension take effect at the next scan rather than the next
      token rotation. The secret is derived by HKDF from a pepper that is
      deliberately not APP_KEY, revocation is one integer column, and a scanned
      code is consumed by INSERT … ON CONFLICT DO NOTHING inside the same
      transaction as the attendance write — a Cache::has/put pair would be a
      time-of-check race, and two scanners at one gate is exactly when it loses.
      Relay is not solvable; it is made worthless instead.
      
      SelfCheckInService writes through AttendanceMarkingService with the scanning
      staff as the marker rather than adding a second attendance write path. The
      deleted API had one of those: POST /v1/absences/report wrote status='excused'
      with no marker, no transition check, no audit and no check that the session
      belonged to the participant.
      
      QrCode is written rather than pulled in — there is no Composer step here that
      can add to the committed lock file, and the alternative was the existing
      pattern of an <img> pointing at api.qrserver.com, which sends the member's
      token to a third party and fails when the venue's wifi does.
      
      It was verified module-for-module against an independent implementation
      across versions 1-10 and all eight masks, given identical codewords. That
      found two bugs neither visible nor throwing: a Reed-Solomon generator
      polynomial built with its terms reversed, and missing version-information
      blocks for versions 7 and up, whose 36 modules were being filled with payload
      and shifting the whole stream. Both produced a plausible square of black and
      white that no scanner accepts. tests/Fixtures/qr_golden.php freezes that
      verification.
      
      Verified against a restored copy of backups/oc_sport-20260831-081053.dump:
      all seven portal screens render 200 for a real member account, the manifest
      is tenant-branded and no-store, and a member opening another family's invoice
      gets 403.
      
      Suite: 76 passed, 3 skipped (the tenant smoke test skips off Postgres rather
      than pretending SQLite is production).
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      acca60b0
    • Mahmoud Aglan's avatar
      feat(branding): one resolved brand, and the fields that were collected but never read · 2dff900b
      Mahmoud Aglan authored
      S2 of the mobile-portal programme. Branding lived in four uncoordinated
      stores with no sync, and each layout resolved it itself in an `@php` block
      issuing one SettingsService::get() per field — about sixteen SELECTs per
      admin render, repeated on every Livewire round-trip, each with its own
      fallback. That is how primary_color came to be defined three times with
      three different defaults.
      
      BrandingService returns a readonly BrandProfile, cached under the academy's
      new `branding_version` and bumped on save, so it is held until branding
      actually changes rather than for a guessed number of minutes, and a queue
      worker cannot serve last week's colours. Verified on the restored oc_sport
      copy: a second resolve inside one request issues 0 queries.
      
      Defects fixed, each verified against that copy:
      
      - `academies.address` did not exist. AcademySettings has been reading and
        writing it on every save since it was written, and Eloquent silently
        dropped it — no academy has ever had an address stored.
      - `branding.academy_name` was read by the parent layout and by every printed
        sheet and written by nothing, so both showed the literal string "الكابتن"
        on every tenant. It is seeded from the academy's own name and is now
        editable. The login page now reads "او سي سبورت" on the verified tenant.
      - components/print/sheet.blade.php emitted the raw storage path into an
        <img src>, so the logo was broken on every printed sheet. Paths become
        URLs in BrandingService and nowhere else.
      - Guests had no academy bound at all, so the login screen — and the member
        portal's own sign-in, when it exists — rendered under the fallback brand on
        every client. An installation with exactly one academy now resolves it for
        guests too; more than one is ambiguous and binds nothing.
      - AcademySettings had no authorize() call while every sibling settings screen
        does.
      
      Dead fields: the plan's rule is wire it or delete it, and none of them
      survived as collect-but-ignore. login_background now grounds the login
      screen, invoice_header and invoice_footer_text and show_logo_in_invoice
      reach the printed invoice, header_bg colours the topbar, compact_sidebar
      narrows the rail, and success_color/danger_color colour the flash strip.
      
      Colour derivation. sidebar.blade.php hardcoded `color: #fff` on the brand
      accent — this is a tenant-branded product, so a client whose brand is yellow
      got white on yellow at 1.53:1. ColorRamp derives a 50…900 OKLCH ramp plus a
      foreground chosen by WCAG contrast: that same yellow now gets #111827 at
      11.58:1. Nine brand colours are asserted at AA or better.
      
      The ramp is anchored on the tenant's own lightness rather than fixed
      targets, because fixed targets are non-monotonic for an inherently light
      brand: yellow sits at L 0.86, so a table putting 400 at L 0.70 makes 400
      darker than 500. Chroma falls steeply at the pale end — at L 0.97 a chroma
      of 0.10 is outside sRGB and clips to mud.
      
      E1 decided as the addendum recommends: `@custom-variant dark` is declared
      against the `.dark` class. Roughly 900 `dark:` utilities have been compiling
      to prefers-color-scheme and rendering an untested dark ERP for every OS-dark
      user, while the toggle did nothing. The OS-driven rendering stops here and
      the toggle becomes the only thing that switches themes.
      
      App icons are generated with GD directly rather than by adding
      intervention/image: GD is the only image extension in the Dockerfile, and
      the whole job is decode, letterbox, resize, write PNG. Dimensions are read
      from the header before decoding, since a small file can declare enormous
      dimensions. Filenames are content-hashed because nginx serves assets
      `expires 1y; immutable`.
      
      SVG uploads are refused everywhere they were accepted. An SVG on the
      academy's own origin executes script with the site's privileges and
      clean_html() never sees it.
      
      npm run build byte baseline before portal.css exists:
      app.css 208.10 kB / 31.02 kB gzip, website.css 217.08 kB / 33.25 kB gzip.
      
      Suite: 67 passed, 0 failed.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2dff900b
    • Mahmoud Aglan's avatar
      fix(financial): make the double-entry ledger say what actually happened · 8d251d14
      Mahmoud Aglan authored
      S1 of the mobile-portal programme. Every item here is a live defect, and
      each one blocks the portal's money path rather than merely preceding it.
      
      The ledger. PaymentService::resolveDebitAccount() returned the literal 1
      and resolveCreditAccount() returned 2, both with a `// TODO`. Seeder order
      made that Dr Cash / Cr Bank on every payment the product has ever taken —
      627 of 701 rows on the restored oc_sport copy — so no revenue account was
      ever credited and FinancialOverview::getRevenueBySource(), which groups
      transactions by credit_account_id restricted to revenue accounts, could
      only ever return []. Accounts now resolve by code within the academy and
      hard-fail when absent, and a payment is split across revenue accounts in
      proportion to the invoice's own lines, floored with intdiv() and the
      remainder on the last row. Routing InstaPay into the old ledger would have
      multiplied a broken ledger across a new channel.
      
      The guards. Every rule 05-financial-integrity.md names lived in the UI, in
      two hand-copied Livewire components, so any new caller inherited none of
      them. amount > 0, amount <= due re-read under lockForUpdate inside the
      transaction, invoice not cancelled/paid, academy and currency agreement all
      sit in the service now. Draft is deliberately still payable: the POS issues
      an invoice as a draft and settles it in the same transaction.
      
      updatePaidAmount() was a read-modify-write on money with no lock — two
      settlements landing together each read the old paid_amount and one
      increment was lost.
      
      Paymob confirmed callbacks inline: no lock, no Transaction row at all, and
      an idempotency guard that was dead code because the finder already filtered
      status = Pending, so a retried webhook credited the invoice twice. It goes
      through PaymentService::confirmPending() now, which asserts the captured
      amount matches.
      
      POS cash sales double-counted the drawer: POSService incremented
      total_cash_in and UpdateCashSessionTotals incremented it again, inflating
      the expected drawer 2x and producing phantom variance at close. One writer
      each now. A split tendered above the total (cash handed over, change given)
      capped at the amount due instead of producing an overpaid invoice.
      
      RefundService refunded the full payment only, so an over-approved amount
      could not be corrected; it also hardcoded accounts 2/1 with a comment
      claiming A/R, which is account 3, and debited the refunding user's own
      drawer rather than the one that took the money.
      
      Migrations, all guarded and all verified against a restored copy of
      backups/oc_sport-20260831-081053.dump:
      
      - chart of accounts seeded for every academy, not just Academy::first().
        The verified tenant was missing 4060, and db:seed only runs on first
        deploy — so a hard-failing resolver had to be preceded by this.
      - invoices.branch_id and transactions.branch_id, backfilled. Revenue was
        branch-attributed only through payments.branch_id, and getCollectionRate()
        scopes invoices through whereHas('payments'), so an invoice with no
        payment yet belonged to no branch. Portal invoices awaiting a proof would
        have vanished from every branch's overdue figure. 588/713 invoices and
        644 transactions attributed.
      - academy_id on invoice_items, installments and notification_preferences,
        participant_id on event_registrations — four tenant tables that broke the
        tenancy invariant, all reachable from the portal.
      - notification channel CHECK widened to push and whatsapp.
        PushNotificationService writes 'push' and the CHECK allowed only
        in_app|email|sms, so every push delivery log insert raises 23514 today and
        the catch block writes another failing insert.
      - guardians and guardian_participant relationship_type CHECKs reconciled to
        their union. NewRegistrationWizard validates one field against the pivot's
        vocabulary and writes it to both tables, so picking أخ / أخت / وصي crashes
        registration on the guardians CHECK right now.
      - invoice_number_counters replaces generateNumber()'s count()+1 against a
        UNIQUE(academy_id, number) index — a guaranteed collision the moment
        members can check out without a receptionist serialising them, and it
        reissued numbers soft-deleted invoices still hold.
      - the deleted mobile API's INV-MOB lines repaired: it wrote line_total,
        which is not a column, so total_amount defaulted to 0 and every downstream
        allocation read the sale as worthless.
      
      tests/Feature/ExampleTest.php deleted: the stock Laravel scaffold test has
      failed since `init` (it GETs / with no tenant database), permanently
      red-lighting the suite and masking real failures.
      
      Suite: 61 passed, 0 failed.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      8d251d14