1. 01 Sep, 2026 3 commits
    • Mahmoud Aglan's avatar
      fix(security): revoke the tokens the removed mobile API issued · 80cc4497
      Mahmoud Aglan authored
      AuthOtpController::verify() accepted a constant '0000' in the mode every instance
      shipped with, and minted a Sanctum token with ability 'mobile:*' for whichever
      active user matched the submitted phone number — staff included. The routes were
      deleted in 883391c7, so the tokens reach nothing today, but a credential that was
      issuable without authentication should not sit in the table waiting for the next
      surface that accepts Sanctum.
      
      Every client gets this, so it is a migration rather than an SSH per instance.
      Two such rows exist on the one instance that used the API; the others have none,
      and the table guard covers instances that never ran Sanctum's migration.
      
      Deleting rows in up() is a deliberate exception to "destructive operations live
      in down() only" — that rule protects schema and client data, and this is neither.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      80cc4497
    • Mahmoud Aglan's avatar
      docs: the test is all-clients or one-client, not structure or records · 05a6a63e
      Mahmoud Aglan authored
      Corrects the rule I wrote two commits ago, which was wrong in the other
      direction. I had said rows never belong in a migration. They do, whenever every
      client needs them — a lookup table, reference data, a permission the code checks,
      a default setting. Seeding those from a migration is the correct pattern, not a
      workaround, and add_branches_view_all_permission is the example.
      
      The repository is common ownership: it defines what every client gets. So the
      only question worth asking is whether a change is for all clients or for one
      specific client. All clients means the repo, and anything touching the database
      goes in a migration whether it is schema or data. One client means SSH to that
      instance and it never enters the repo — because a migration applies to every
      tenant at once and cannot be scoped to one.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      05a6a63e
    • Mahmoud Aglan's avatar
      docs: separate structural changes from record changes · d20598a2
      Mahmoud Aglan authored
      I reached for a migration to delete rows — stale API tokens — because the change
      needed to apply to every client. That is the wrong test, and the rule it violated
      was written down nowhere.
      
      The deciding question is structure or records, never reach. Schema belongs in a
      migration because it is structure. Rows belong in the client's own database
      because they are that client's data, even when several clients need the same
      correction. A migration that edits rows edits them on every tenant at once, with
      no review and no way to do it for one client only — and this product is one
      install per client, each running the same software over their own records.
      
      Also states plainly that we do not deploy: code is committed and pushed, and the
      platform ships it. Nothing here triggers a CapRover build.
      
      Records the code cannot run without — a permission it checks, a default setting
      it reads — are genuinely ambiguous, and the repo has precedent both ways. Noted
      as a grey area to ask about rather than pretending the line is clean.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      d20598a2
  2. 31 Aug, 2026 14 commits
    • Claude's avatar
      Give every expense a receipt you can open · cc417ba9
      Claude authored
      An expense recorded with a scan attached arrived in the database with no
      scan at all. ExpenseForm uploaded the file and passed the path to
      ExpenseService::recordExpense(), which builds its Expense::create() array
      by hand and never copied the two attachment keys across — so the file
      landed on disk and the row forgot about it. It landed on the `public`
      disk too, which needs a storage symlink the containers never create, so
      even a persisted path would have 404'd.
      
      Receipts now go to the private disk and are read back through
      ExpenseAttachmentController, which checks the permission, the academy and
      the active branch before streaming a byte.
      
      The list was also a dead end: a row showed a number and a description and
      offered nothing but "cancel". Rows are now clickable and carry a view
      button, with a paperclip marking the ones that have evidence behind them.
      
      The new detail page is where the expense explains itself — amount,
      category, recipient, method, receipt reference, branch, notes, who
      recorded it and when, and, if it was cancelled, by whom and why. Below
      that sit the journal entries it produced, the original debit/credit pair
      and any reversing entry, so the accounting effect is visible rather than
      implied. The receipt itself previews inline: images as images, PDFs in a
      frame, with download beside them.
      
      An expense recorded without a scan is no longer stuck that way — attach
      one from the detail page, replace it (the displaced file is deleted), or
      remove it. Every attachment records who uploaded it and when. A cancelled
      expense refuses all three: its evidence is frozen with its journal.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      cc417ba9
    • Mahmoud Aglan's avatar
      docs(mobile-portal): the approved programme, its adversarial review, and what it supersedes · 2ef6e088
      Mahmoud Aglan authored
      Another session builds the portal from here, so the entry point has to survive
      being read cold.
      
      docs/specs/mobile-portal/ holds three files. 01-program-plan.md is the approved
      programme — decisions, workstreams, art direction, the full feature inventory and
      verification. 02-critique-addendum.md is a four-lens review of that plan
      (completeness, security/abuse, financial integrity, delivery risk) with every
      claim checked against the code; where the two disagree the addendum wins, and it
      replaces the plan's build order with S0–S10. 00-README.md is the map.
      
      The README leads with four premises the plan was written on that turned out to be
      false, because each changes what gets built: Livewire is ^4.3 not 3 (so a public
      property is client-settable and validating in mount() is not enough); `dark:`
      compiles to prefers-color-scheme with no @custom-variant declared, so ~900
      utilities are live and untested rather than inert; `transactions` is one row with
      debit and credit account columns, not a pair, contradicting CLAUDE.md and two
      agent-rules files; and most of the domain the portal needs already exists.
      
      That last one is the real hazard on this programme. The block-builder engine, the
      parent portal, the push stack and the pricing entry points are all built, so the
      README lists them explicitly under "do not rebuild" — the plan originally proposed
      a second CMS before the review found the first one is generic enough to reuse.
      
      Banners on mobile-app-plan.md, mobile-api-implementation.md and openapi.yaml:
      all three describe the /api/v1 surface deleted in the previous commit, and a
      native-Flutter-per-client approach that was replaced. Left in place as history,
      marked so nobody builds from them.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2ef6e088
    • Mahmoud Aglan's avatar
      fix(security): remove the mobile API surface and stop the 500 page leaking sessions · 883391c7
      Mahmoud Aglan authored
      Two live disclosures and one latent account takeover, plus the infrastructure
      defects that hid them.
      
      AuthOtpController::verify() accepted a constant '0000' whenever auth_otp_mode was
      'demo' — the value every instance was seeded with — and then minted a Sanctum
      token for whichever active user matched the submitted phone number, staff
      included. It was not exploitable as written, because 2026_08_30_000004 had
      normalised users.phone to digits-only local form while normalizePhone() produced
      +20…, so the lookup missed. That is one plausible bug-fix away from being live,
      which is why the whole surface goes rather than the branch.
      
      Deleting /api/v1 also removes: broadcast/send pushing to every device in the
      academy with no permission check; ReceiptController's inverted ownership check,
      which made any non-participant invoice world-readable to any token;
      PaymentController::initiate with no ownership check at all; and
      DeviceController keying updateOrCreate on the FCM token alone, letting one user
      claim another's device. None of it is replaced — the member-facing surface is the
      session-authenticated web portal, so a second token-authenticated surface meant
      building and authorizing everything twice.
      
      bootstrap/app.php built a full diagnostic payload for any 500 and errors/500
      rendered it to the browser, ungated by APP_DEBUG. The session it printed carries
      password_hash_web — the signed-in user's bcrypt hash — alongside the last ten
      queries, the request input and the headers. Now gated on debug, auth keys
      stripped by prefix even there, and the production page is self-contained with no
      CDN. Detail still reaches storage/logs, keyed by the error id shown to the user.
      
      ParentHome::$activeChildId was validated in mount() and selectChild() but used
      raw in render() at eight query sites. Livewire is ^4.3, where a public property
      is settable from the browser, so those checks were decoration: a guardian could
      walk participant ids and read any child's balance, attendance and evaluations.
      Locked, and re-validated in render() since the child list can change between
      requests.
      
      ParentExcuseForm wrote the attachment — typically a child's medical note — to the
      PUBLIC disk, then discarded the record and flashed success. The parent believed
      the absence was excused; nothing was stored, and the record kept feeding the
      consecutive-absence threshold that auto-suspends a participant. It now stores
      nothing and says so, until excuses are modelled properly.
      
      Infrastructure, because each one hid a failure rather than causing one:
      entrypoint continued booting after a failed migration, which serves a stale
      schema and silently blocks every later migration forever; the env whitelist had
      no PAYMOB_, so config:cache baked null credentials and the gateway failed closed
      with no error anywhere; nginx's static-asset regex answered =404 for /sw.js
      before PHP saw it; and Route::fallback returned 200 for every unrouted path, so
      a deleted endpoint served a website page instead of 404.
      
      Verified: 43/44 tests pass. The one failure is ExampleTest, which fails
      identically on unmodified main — confirmed by stashing. Two new tests pin both
      disclosures so they cannot return.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      883391c7
    • Mahmoud Aglan's avatar
      fix(groups): show what a player paid this month, and why it is that number · d2f4bf17
      Mahmoud Aglan authored
      The roster's الدفع column answered a different question from the one it
      appeared to answer, in three compounding ways.
      
      It showed a LIFETIME subscription total beside a monthly bill. A player who paid
      650 in July, 1,200 for a kit bag and 650 in August read as "2,500" for the
      current month. Two months of subscriptions were simply added together.
      
      "Subscription" was defined as "an invoice line with no product link" — a
      negative definition, so every hand-typed line became subscription money. That
      kit bag was typed as free text, so it landed in the subscription figure, was
      missing from product revenue, and left the same screen reporting the player had
      never bought the kit they had paid for.
      
      The red "has not paid" flag came from an unrelated calculation: matching invoice
      text with ilike %اشتراك% plus the programme name. Substring matching on Arabic
      also decides that تجهيزي contains زي. On live data the flag and the amount
      disagreed on 28 of 247 active enrolments — red rows showing a green figure. The
      template's "show unpaid only if flagged AND the amount is zero" guard was not
      defensive coding; it was two sources of truth being reconciled where the
      disagreement stopped being visible.
      
      The figure is now this billing cycle only, derived from the programme's own
      cycle rather than the calendar month, and one computation feeds the amount, the
      row flag and the header counts — so they cannot contradict each other again.
      
      Each figure is colour-coded by WHY it is that number, with a legend above the
      table: paid in full, pro-rated for a mid-month join, admin discount, line price
      override, instalment, partial, unpaid, not yet billed, free. All of it was
      already recorded in invoice and line metadata and never surfaced; the reason,
      who applied it and the original price now appear on the row. Colour never
      carries the meaning alone — each amount also shows a glyph, a label and a
      screen-reader sentence, and every case sits at 4.5:1 against white.
      
      The migration links hand-typed product lines to their product where the full
      trimmed description matches a product name exactly. Substrings are deliberately
      not matched and ambiguous lines are left alone: 44 lines / 209,200 EGP link
      safely, 23 lines / 42,800 EGP are reported for a human instead of guessed at.
      
      Verified by replaying the real production rows behind both reported screenshots
      through the service: every figure the user questioned is now explained.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      d2f4bf17
    • Mahmoud Aglan's avatar
      docs: pin the branch and ref checks into the push rule · 820ea078
      Mahmoud Aglan authored
      Standing authorisation to push fixes without asking was already in place; what
      was missing were the checks that make it safe to exercise.
      
      Both failed today. The session-start git snapshot said `main` while a parallel
      session had since checked out a feature branch in the same working copy, so a
      verified fix was committed to the wrong branch — and `git push origin main`
      then reported "Everything up-to-date" and exited 0 while the fix sat elsewhere.
      A no-op push is indistinguishable from a successful one unless the remote ref
      is checked.
      
      Also makes explicit-path commits mandatory. This checkout is shared with other
      sessions whose in-flight work can be staged in the index; `-a` or `git add -A`
      would sweep it into a fix commit and deploy it to every tenant.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      820ea078
    • Mahmoud Aglan's avatar
      fix(financial): stop binding NULL as a filter Postgres cannot type · 75fbca7a
      Mahmoud Aglan authored
      The financial overview 500'd with SQLSTATE 42P08 on `($4 IS NULL OR
      p.branch_id = $4)`. Postgres fixes each prepared-statement parameter's type
      during parse analysis, and `:branch_id IS NULL` gives it nothing to work from
      — the statement is rejected before it ever reaches the comparison that would
      have typed it. `:academy_id` was the same shape and would have failed next.
      
      The idiom came in with 35200985 and could not be caught here: phpunit runs
      SQLite in memory, which types placeholders at bind time and executes the
      broken form happily.
      
      Fixed by appending the branch and academy filters only when they apply, with
      their bindings, rather than passing NULL as a sentinel — which is what the
      ->when() filters in the same method already do, and keeps the
      (academy_id, branch_id) index usable instead of hiding it behind an OR.
      
      The SQL build is extracted to buildTopProgramsQuery() so it can be asserted on
      without a database. The test pins four things: the placeholders and the
      bindings agree in all four filter combinations, the clauses are omitted rather
      than nulled, the built SQL executes, and no raw SQL under app/ binds a
      placeholder as a NULL sentinel again. That last one is a source scan on
      purpose — the suite's driver is not the production driver, so it cannot
      observe this failure by running.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      75fbca7a
    • Mahmoud Aglan's avatar
      docs: push fixes to main without asking · 6d59b789
      Mahmoud Aglan authored
      Standing authorisation from the user: a verified fix goes out in the same turn
      it is finished, rather than waiting in the working tree for approval.
      
      Written with the order fixed (verify, then commit, then push) and with the
      boundary spelled out, because a push here is a deploy to every tenant at once
      — entrypoint.sh runs migrate --force and db:seed on every container start, and
      there is no staging. Features, schema changes and anything destructive still
      get confirmed.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      6d59b789
    • Mahmoud Aglan's avatar
      fix(branches): stop the branch switcher 500ing on an unbuilt executive view · fc7060b9
      Mahmoud Aglan authored
      config/branch_lock.php names executive.dashboard as the lock's destination,
      but that route was never built. route() throws on an undefined name, so
      switching to "كل الفروع" crashed in production after the session had already
      been written — the user landed in all-branches mode via an error page.
      
      isLocked() already refused to lock without the route, and that was believed
      to make the whole feature dormant. It only made the *gating* dormant: the
      guard sits on the decision, while the crash is at the dereference. Four other
      sites turned the same name into a URL, and BranchSwitcher's was outside the
      gate entirely. Auth/Login reached it only through the config key, so it does
      not even contain the string "executive".
      
      Route every caller through BranchContext::redirectRouteName(), which returns
      the configured route when it exists and degrades to the dashboard when it
      does not. The dashboard is the right fallback while the view is unbuilt: the
      lock is dormant, so it is already unfiltered and showing the every-branch
      numbers the user asked for.
      
      The test pins the resolver in both directions and fails if any Livewire
      component or middleware reads branch_lock.redirect_route directly again.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fc7060b9
    • Mahmoud Aglan's avatar
      fix(attendance): stop the roster moving under the coach's finger · 7af211fc
      Mahmoud Aglan authored
      Taking attendance re-sorted the list by status on every render, so the
      moment a coach marked someone the row jumped somewhere else and everyone
      below it shifted. Coaches lost their place, could not tell who was already
      handled, and recorded the same player several times.
      
      The roster is now ordered by name with the record id as a tie-break —
      never by anything the coach can change from this screen — so the list
      holds still. Marking a player takes them out of the working list entirely
      and into a collapsed "تم تسجيلهم" section, grouped by status with counts,
      where the decision can be reviewed or changed. A confirmation toast names
      the player and the status that was saved, and a progress card shows how
      many are left.
      
      Also here:
      - markAs/markPresent/saveRecordNote now resolve the record within this
        session instead of by bare id, and reject statuses outside the four the
        screen offers
      - service calls are wrapped in try/catch, so a blocked medical certificate
        shows an Arabic message instead of an error page
      - the polymorphic subject relation is eager-loaded with morphWith (was an
        N+1 on every player row)
      - one responsive card list replaces the duplicated mobile/desktop markup;
        targets are ≥36px, the progress bar carries progressbar semantics and
        the toast is an aria-live region
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      7af211fc
    • Mahmoud Aglan's avatar
      feat(branches): make "all branches" a state the app can actually hold · 48a79a76
      Mahmoud Aglan authored
      Session::has() is `! is_null(get($key))`, so it reports false for a key
      holding null — which is exactly how "all branches" was stored. Three
      call sites tested presence that way, so selecting كل الفروع silently
      reverted to a single branch on the next navigation and isAllBranches()
      was unreachable dead code. All three now use exists().
      
      BranchContext is the one place that reads that state. It lives in
      Context, not Services, because the project rule keeps services free of
      session/auth so they stay queue-safe; this is the adapter that turns
      request state into the explicit ?int $branchId services receive. A null
      left by a user whose permission was revoked is repaired rather than
      honoured, and stamping deliberately does not follow branchId() — API
      routes and queued listeners run outside the request, and a record filed
      against no branch would vanish from every per-branch total for good.
      
      The lock itself is dormant on purpose: isLocked() returns false while
      the executive dashboard route does not exist, since locking would
      otherwise 500 every page including its own redirect target. The
      permission ships as a migration as well as a seeder entry, because
      db:seed only runs when RUN_SEED_ON_FIRST_DEPLOY is set.
      
      Also stops enabling the query log outside debug — it retained every
      statement of every request in production memory for nothing.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      48a79a76
    • Mahmoud Aglan's avatar
      feat(groups): show per-product ownership and real amounts paid · b6fd3fb7
      Mahmoud Aglan authored
      A programme can now bundle products it requires (program_products), so
      the group view can answer "who has not bought their registration card"
      — which nothing in the system could express before. products.is_essential
      is global; this is per-programme.
      
      Each bundled product gets its own column: bought or not, a progress bar,
      and the amount settled against the amount billed. Instalments fall out
      of this for free rather than needing their own column.
      
      Reading a payment off a line is not possible here — a subscription and a
      registration card routinely share one invoice. ParticipantBillingService
      allocates each payment across the lines it covers, pro rata on
      subtotal_amount, rounding down so the remainder stays unallocated rather
      than inventing money. Allocation is capped at the amount billed: a
      payment settles total_amount, which also carries tax and fees, so paying
      in full would otherwise allocate over 100% of a line. Verified against
      production — no invoice over-allocates.
      
      The payment column now shows the amount paid rather than a bare "paid",
      with مجاني for free players and لم يدفع for unpaid, and participants
      carry their عضو / غير عضو tag. The enrolment-date column is gone.
      Total collected is shown to users with invoices.list.
      
      The bundling migration is conditional: it acts only where an academy has
      both an active product named قيد and programmes named فريق. Elsewhere it
      does nothing, which is what makes it safe for every tenant. On oc-sport
      that is exactly one product across 12 programmes.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      b6fd3fb7
    • Mahmoud Aglan's avatar
      fix(financial): attribute revenue to what was actually sold · 35200985
      Mahmoud Aglan authored
      Measured against the live oc-sport database, subscription revenue read
      457,970 EGP against a genuine 345,257 — overstated by 32.6% — while the
      per-programme breakdown summed to 39,873, about 12% of reality.
      
      Three distinct causes:
      
      POSService::buildInvoiceItems() discarded the item_type/item_id it was
      handed, so every POS line landed with a NULL itemable_type. Reporting
      reads NULL as "programme subscription", which moved 102,000 EGP of
      product sales into subscription revenue — 90% of the error — and meant
      no product-ownership check could ever pass. Lines now carry their
      Product or Kit. A migration backfills history by matching invoice lines
      to their POS lines, filling only NULL rows and only where the match is
      unambiguous; a production dry run matched 44 of 45 with 0 ambiguous.
      
      Pro-rata allocation divided by invoices.total_amount, but line totals
      sum to subtotal_amount — total_amount also carries discount, tax and
      service fees. Every bundled invoice was therefore split on the wrong
      denominator (10,713 EGP).
      
      topPrograms joined enrolments to invoices and dropped anything without
      an invoice_id. Only 88 of 350 enrolments have one, so 75% of programmes
      reported zero. Now a UNION: the exact link where it exists, participant
      fallback where it does not, split evenly across a participant's
      programmes. Reconciles at 333,105 EGP.
      
      Also: the mounted revenue widgets and the receptionist dashboard omitted
      direction='inbound', counting refunds as income, and the widgets' raw
      queries bypassed SoftDeletes and cancelled invoices.
      
      EnrollExistingWizard read BasePrice directly, ignoring membership type
      and every pricing rule, so it quoted a different figure than the
      registration wizard for the same player. Both now go through
      PricingService.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      35200985
    • Mahmoud Aglan's avatar
      fix(website): validate section + theme edits and keep unpublished sites private · fa4c5088
      Mahmoud Aglan authored
      Section and theme editors wrote straight to columns that carry CHECK
      constraints, so a bad value surfaced as a 500 rather than a field error.
      Adds rules() mirroring the constraints, Arabic messages(), and an error
      summary in both forms.
      
      home() also served unpublished sites to the public. Staff keep their
      preview route; everyone else is sent to login.
      
      Drops a redundant invalidateAll() from ThemeEditor::save(): the call
      passed an argument the method does not take, and would have flushed
      every tenant's cache. WebsiteSettingService::update() already
      invalidates the one academy that changed.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fa4c5088
    • Claude's avatar
      Stamp every transaction with the branch it happened in · cd2faefd
      Claude authored
      Records were reaching the database with no branch, so they belonged to
      no branch and were invisible in every branch view. Three causes:
      
      1. Invoices have no branch_id column, yet three call sites read
         $invoice->branch_id and stored the result. It was always null.
         POSService did this for every point-of-sale payment, which is why the
         walk-in ("عميل عابر") sales had no branch. POS now uses the branch the
         sale was rung up in; the mobile payment controller and InvoiceShow
         take it from the participant being billed.
      
      2. PaymentService::record() only set a branch if its caller happened to
         pass one, and most callers did not.
      
      3. Nothing enforced the rule centrally.
      
      New BelongsToBranch trait stamps the active branch at creation, mirroring
      BelongsToAcademy. It is applied to the models that record an action —
      Payment, Expense, CashSession, FacilityRentPayment, POSTransaction,
      PurchaseOrder, Participant, TrainingGroup — and deliberately not to
      catalogue models such as BasePrice, PricingRule, Product and Employee,
      where a null branch legitimately means "shared across all branches".
      
      The trait adds no global scope on purpose: branch is a reporting lens,
      not an isolation boundary, and scoping globally would break console
      commands, cross-branch reports and the switcher's "all branches" mode.
      It also returns null rather than guessing when there is no request
      context, so scheduled jobs do not misfile academy-wide records.
      
      Also adds a migration trimming stray whitespace — including the
      non-breaking space U+00A0 that survives copy-paste — from names shown to
      users. Those characters are invisible in forms but render as a gap in
      page titles and receipts, and break exact-match lookups.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      cd2faefd
  3. 30 Aug, 2026 13 commits
    • Claude's avatar
      Itemise financial expenses and stop double-counting refunds · 99c1d2b6
      Claude authored
      Three separate defects made the financial figures wrong.
      
      1. Refunds were counted as revenue. Eighteen queries summed payments on
         status='confirmed' with no direction filter, so outbound refunds were
         added to income across the dashboard, the revenue/product/subscription
         widgets, the financial report, the print report and ReportService.
         That inflated revenue by 40,048 EGP all-time, 32,510 this month.
      
      2. Refunds were simultaneously counted as an expense. The refunded
         original already drops out of revenue when its status becomes
         'refunded', so adding the outbound payment to expenses deducted the
         same money a second time. Refunds are now contra-revenue: the revenue
         card shows gross collected, refunds, and the net, and the expense side
         no longer includes them.
      
      3. Expenses were presented as vague lumps, the worst being "مدفوعات أخرى"
         — which was in fact customer refunds. The breakdown is now one line
         per real cost (payroll, facility rent, purchases, and each expense
         category separately), sorted by size, each stating where it comes
         from.
      
      Payroll was missing from expenses entirely; approved and paid payslips
      plus trainer compensation are now included, scoped by branch through the
      trainer's employee record.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      99c1d2b6
    • Claude's avatar
      Scope financial overview expenses and P&L to the active branch · 944c5001
      Claude authored
      The financial overview filtered revenue by branch but not expenses, so
      every branch showed the same expense figure. The "مدفوعات أخرى" line was
      academy-wide outbound payments — 32,510 EGP of customer refunds issued
      this month, all belonging to Zayed — displayed identically under all 7
      branches. Purchase orders had the same problem, and in the 6-month P&L
      chart both income and outbound were unfiltered.
      
      Also: PaymentService::refund() created the outbound payment without
      copying branch_id from the payment being refunded, so refunds taken
      through that path landed in no branch at all and were invisible in every
      branch view. RefundService already did this correctly.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      944c5001
    • Claude's avatar
      docs: move loose specs out of .claude into docs/specs · aeab7b7d
      Claude authored
      102 KB of planning documents (website-builder-v2 spec, mobile app plan, mobile
      API implementation, data snapshots) were sitting directly in .claude/ rather
      than in docs/. They are reference material, not agent configuration.
      
      .claude/ is now empty of markdown, so nothing in this repo is auto-loaded into
      agent context except CLAUDE.md.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      aeab7b7d
    • Claude's avatar
      docs: move agent rules out of auto-injection, compact CLAUDE.md · f114bf42
      Claude authored
      The 18 files in .claude/rules were injected into every agent turn — 38 KB of
      POS, inventory and attendance rules loaded even while editing CSS. They are now
      in docs/agent-rules/ and read on demand.
      
      CLAUDE.md keeps every hard invariant inline (money as piasters, tenancy scoping,
      double-entry immutability, migration-first, RTL logical properties, no dead
      links, safe_url/clean_html) and indexes the detail, so nothing that protects
      code quality was dropped.
      
      Also documents the deployment constraint that governs every migration: all
      tenants build from main and the entrypoint runs migrate --force plus db:seed on
      every container start.
      
      Net: ~57 KB less agent context per turn.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      f114bf42
    • Claude's avatar
      chore(reference): add OC-Sport site mirror for the website migration · fa497ab9
      Claude authored
      Captured 2026-08-30 from oc-sport.com as the source spec for rebuilding the
      client's site in the v3 builder. Contains their 7 pages in both locales, the
      static assets, the public images, their published OpenAPI description and the
      extracted bilingual content catalogue (604 strings x en/ar).
      
      analysis/FINDINGS.md holds the gap analysis that motivated the v3 builder.
      
      Excluded from the Docker build context via .dockerignore, so it never ships to
      a tenant image.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      fa497ab9
    • Claude's avatar
      fix(website): guard editor-supplied URLs against unsafe schemes · c3f2ae53
      Claude authored
      Menu items, button links, announcement bars, popups, floating buttons and
      navbar CTAs all wrote editor-supplied values straight into href attributes.
      Any user with settings.manage could store a `javascript:` URL and have it run
      for every visitor of that tenant's public site.
      
      Adds safe_url(), which allow-lists http/https/mailto/tel/whatsapp, site-relative
      paths and fragments, and rejects protocol-relative URLs, data:, vbscript: and
      entity/whitespace/control-character obfuscation before testing the scheme.
      
      Applied at four layers so no writer can bypass it:
        - input     MenuManager rules + BlockField Link validation
        - model     WebsiteMenuItem::href()
        - render    every editor-supplied href in every website view
        - import    WebsiteBlueprintService, since blueprint files skip form rules
      
      safe_url() returns null rather than '#', so blocks skip the link entirely
      instead of emitting a dead anchor — this satisfies the project's no-href="#"
      rule with the same mechanism.
      
      Also fixes Alpine expression injection in the gallery and schedule lightboxes,
      where a quote inside an image URL could break out of the inline handler: Blade
      escapes ' to &#039; but the browser decodes it before Alpine parses.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      c3f2ae53
    • Claude's avatar
      feat(website): add page + block tree builder (v3) · 39f468a9
      Claude authored
      The v2 builder could not express more than one page: website_sections had a
      unique(academy_id, section_key) constraint, there was no pages table, and
      SectionManager exposed only toggle + reorder. A client with a seven-page site
      had no way to represent page two.
      
      Adds an additive page/block model alongside v2:
      
      - website_pages + website_blocks (nested tree, JSONB data/style)
      - BlockRegistry of BlockType classes: 31 types, 133 layout variants, 237
        fields, 442 validation rules derived from the field schema
      - Page/Block/Menu/Blueprint services, BlockRenderer, BlockDataResolver
      - Builder UI: page manager, block tree editor, schema-driven field forms,
        repeaters, content/design/motion panels, image upload
      - Authored navigation (website_menus) with dropdowns, replacing nav links
        that were previously derived from enabled sections
      - Blueprint import/export via `php artisan website:blueprint`
      - Extended motion library: entrance effects, delay, stagger, parallax
      
      A new block type now costs one PHP class — no migration, no enum case, no
      CHECK constraint.
      
      Nothing here is destructive. website_sections is untouched and "/" falls back
      to the legacy renderer when no builder homepage exists, so already-deployed
      tenants are unaffected until they opt in.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      39f468a9
    • Claude's avatar
      Fix branch leaks in the two receptionist wizards · c54f5aea
      Claude authored
      The enrolment wizard looked up a programme's base price without any
      branch filter, so with per-branch pricing across 7 branches the
      receptionist could be quoted another branch's price.
      
      The registration wizard's printed receipt resolved the branch with
      Branch::first(), so every receipt printed the first branch's details
      regardless of where the registration actually happened.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      c54f5aea
    • Claude's avatar
      Filter every management screen by the active branch · f282f7bc
      Claude authored
      The system predates branches, and adoption of the branch switcher was
      partial: 44 of 186 Livewire components used UsesBranchScope, and several
      that imported it never actually called it. The dashboard was the worst
      case — half its widgets were branch-aware and half silently reported
      academy-wide totals next to them, so the numbers on one screen were not
      comparable with each other.
      
      OC-Sport runs 7 active branches, so every unscoped widget was showing
      six other branches' data.
      
      Dashboard: scoped trainers-present, pending payslips, pending documents,
      low stock and expiring medical certificates, which were academy-wide.
      All six dashboard widgets (revenue, product revenue, subscription
      revenue, enrolment trends, overdue renewals, trainer dues) now filter by
      branch, including the raw-SQL CTEs in the revenue breakdowns.
      
      Lists and reports: events, evaluations, base prices, pricing rules,
      promotions, stock counts, kits, document approvals, trainers, trainer
      advances, payroll, essential deliveries and the financial report.
      
      Pickers: participant, group, program, facility, warehouse, product and
      employee selectors now offer only the active branch's records, so a
      transfer or invoice cannot silently reference another branch.
      
      POS and InvoiceShow used auth()->user()->branch_id directly, ignoring
      the switcher entirely — a user who switched branch still transacted
      against their home branch. Both now read the active branch.
      
      Deliberately left unscoped: parent- and guardian-facing screens, which
      are scoped to their own children and have no branch switcher, and
      single-record detail screens, which are already scoped by the record and
      would hide legitimately related history for participants who moved
      between branches.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      f282f7bc
    • Claude's avatar
      Make login credentials case- and format-insensitive · 60eabb70
      Claude authored
      Postgres '=' is case-sensitive, so a user stored as 'Km...@gmail.com'
      could not log in from a phone keyboard that lowercases the email field.
      The lookup in AuthService returned null before Hash::check ever ran, so
      this presented as "wrong password" and was invisible in login_history —
      that table is only written once a user has been found.
      
      On OC-Sport this affected 8 of 26 accounts, and had already produced one
      duplicate registration: a user who could not get in simply signed up
      again with the same address in lowercase.
      
      - CredentialNormalizer: one canonical shape for emails and phones
      - AuthService: case-insensitive email lookup, deterministically ordered
        so a pre-existing case-duplicate pair resolves to the account actually
        in use rather than an arbitrary row; phone lookup matches local and
        +20 forms
      - User: set-mutators so new rows are stored canonical
      - Migration: normalises existing rows, skipping and logging any that
        would collide, since those are duplicate accounts needing a human
        merge rather than a guess
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      60eabb70
    • Mahmoud Aglan's avatar
      Add branded printable schedule sheets with the space grid · 3d472374
      Mahmoud Aglan authored
      "The schedule" is not one document. Different people print it for different
      reasons and a single layout serves none of them, so this ships three sheets
      sized for their actual use scene:
      
      - Facility day board (A4/A3 landscape, auto-picked by segment count) — time
        down the side, the facility's physical grid segments across the top, every
        booking in its own cell. This is the one that carries the grid, and the one
        that did not exist. Built to be pinned at the court entrance and read from
        a few metres away.
      - Facility week board (A3 landscape) — seven days x time, segments as a badge
        per booking. The notice-board overview.
      - Trainer day cards (A4 portrait, two-up, cut lines) — one pocket card per
        trainer: when, where, which segment, how many players. A trainer does not
        want an A3 off the wall.
      
      Rendered as branded HTML and printed from the browser rather than through a
      PDF library: Arabic shaping survives intact, Cairo and the academy's brand
      colour render exactly, and nothing queues on the server. print-color-adjust
      is set explicitly, without which browsers strip every fill and the whole
      colour-coded board arrives as blank boxes.
      
      Group colours move to a shared GroupColor palette used by both the builder
      and every sheet, so a coach who learns "our group is the teal one" on the
      wall sees the same teal in the app. The hues stay distinguishable in
      greyscale, because plenty of academies print on a mono laser.
      
      Bookings read as a filled, tinted cell rather than a thin coloured stripe —
      a 3px rail is invisible at the distance these are actually read from.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      3d472374
    • Mahmoud Aglan's avatar
      Replace emoji with icons; scope schedule builder by branch and sport · 9454aa03
      Mahmoud Aglan authored
      Icons
      - No emoji anywhere in the UI. Extracted the sidebar's inline SVG map into
        a single <x-ui.icon name="..."> component and added the icons the pricing
        work needed, so there is one source instead of a per-view copy. Discount
        recipes now carry icon NAMES, not glyphs.
      
      Schedule builder
      - Facilities are scoped to the selected branch. The screen listed every
        branch's facilities, which is how someone books the wrong building. A
        ?facility_id= carried over from another branch (bookmark, back button) is
        now dropped instead of silently overriding the branch scope.
      - Groups are scoped to the facility's branch AND to the sports that facility
        hosts, so a football court no longer offers swimming groups. That link did
        not exist, so this adds a facility_activities pivot. A facility that
        declares no activities still hosts anything, so nothing breaks for academies
        that have not filled it in.
      
      Facility grid
      - Removed the arbitrary ceilings (rows/columns capped at 10, lanes at 20).
        Physical space is not limited to a number we picked.
      - New facilities never got a layout, which is why the grid silently failed to
        appear on them. FacilityService::create now seeds one, the migration
        backfills every existing facility that has none, and the default is a 1x1
        grid — "one whole space, not subdivided yet" — rather than inventing a
        subdivision nobody asked for.
      - Grid size is editable straight from facility settings, with a live preview
        of the cells being described. Shrinking onto a segment that holds a
        confirmed future reservation is refused rather than silently dropping
        someone's booking.
      - Sports and starting grid are both settable at creation time too.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      9454aa03
    • Mahmoud Aglan's avatar
      Fix pricing engine and rebuild discounts around recipes and a picker · b3f127c8
      Mahmoud Aglan authored
      Two bugs meant no pricing rule has ever applied correctly:
      
      1. Condition keys never matched. The engine reads min/max/values;
         the wizard wrote min_age/min_children/target_gender and the form
         blade wrote a third set. Ranges saw null bounds and list rules saw
         an empty allow-list, both of which passed, so every rule applied to
         every participant.
      2. Percentages were 100x too small. applyAdjustment divides by 10000
         (basis points) but both screens stored a plain percent, so "20%"
         discounted 0.2%.
      
      They masked each other, which is why the symptom looked like a broken
      engine rather than two bugs — and why everyone moved to the untyped
      super-admin price override instead.
      
      Engine
      - ConditionSchema is now the single owner of the conditions vocabulary;
        builder, engine, simulator and migration all read keys from it.
      - Percent handles all basis-point conversion; nothing else touches the
        raw column.
      - evaluateInList fails closed instead of treating an empty allow-list
        as "match everyone".
      - custom rules no longer auto-apply; they are picker-only.
      - enrollment_timing honours days_before_start (fails closed without a
        program start date instead of silently passing).
      - Global discount cap reads system_settings rather than a hardcoded
        constant with a TODO.
      - New: explain(), audience(), wouldApply(), and role-capped manual
        discounts.
      
      Per-branch
      - pricing_rule_branches pivot so one rule targets many branches,
        instead of one near-identical row per branch that drifts apart.
      
      Stacking
      - is_stackable now defaults to false; best-of-one is the normal case
        and stacking is an explicit opt-in.
      
      Authoring
      - The five-step column editor becomes a recipe gallery plus an Arabic
        sentence, with a live simulator on a real participant and an audience
        count that warns when a rule would hit everyone. Saving a
        conditionless rule is refused.
      
      Checkout
      - ManagesDiscounts trait plus <x-pricing.discount-picker>: branch-scoped,
        searchable, pinned favourites, replace-vs-stack inline, blocked rows
        show why. Wired into CollectPaymentWizard renewals; discount names are
        frozen onto invoice.metadata so receipts survive later rule changes.
      - NewRegistrationWizard now prices through the engine using a
        provisional context built from the form, since the participant row
        does not exist yet. The step-4 guard still checks the base price, so a
        100% discount is not mistaken for an unpriced program.
      
      Migration
      - Rewrites conditions onto the canonical keys and scales percentages to
        basis points. Rules whose conditions cannot be mapped confidently are
        deactivated rather than guessed, with the old JSON kept in
        metadata.legacy_conditions.
      
      Also fixes list and coupon views that rendered the raw column (a 20%
      rule would have displayed as 2000%), and adds the [x-cloak] CSS rule
      that was missing app-wide.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      b3f127c8
  4. 27 Aug, 2026 2 commits
  5. 24 Aug, 2026 1 commit
  6. 23 Aug, 2026 7 commits