1. 05 Sep, 2026 17 commits
    • Mahmoud Aglan's avatar
      feat(accounting): manage the chart of accounts from the screen — move, delete, reclassify · 1edf3267
      Mahmoud Aglan authored
      The chart could only be created and edited. Restructuring it — the thing that
      actually gets asked for — meant a developer.
      
      Adds move / delete / promote-demote with the guards a ledger needs, each refusing
      with the specific reason rather than failing later at month end:
      
      MOVE
      - Refuses a move under the account's own descendant, which would detach the
        subtree from the root and make the tree query loop.
      - Refuses a parent of a different account_type — that would file an asset under
        liabilities and quietly corrupt the balance sheet.
      - Refuses a non-header parent.
      - Carries the whole subtree and recomputes every level beneath.
      
      DELETE
      - An account with posted history is ARCHIVED, never deleted: removing it would
        leave old journal lines pointing at a name that no longer exists. The button
        relabels itself to "أرشفة" and says why.
      - Refuses while children exist, or while anything still points at it — posting
        rules, tax profiles, voucher types, vouchers, bank accounts, treasuries — and
        lists what, so the blocker is actionable.
      - System accounts can be deactivated, not removed.
      
      PROMOTE / DEMOTE
      - Refuses to promote an account that already carries movement; a header takes no
        entries, so its balance would be stranded.
      - Refuses to demote one with children; entries would post at a summary level and
        double-count up the tree.
      
      The management panel loads the usage check before offering anything, so a button
      that is going to be refused is disabled with the reason instead of being offered
      and failing.
      
      Reference scanning tolerates a missing table or column, so a trimmed or older
      install does not break the screen. Descendant walking is iterative and guarded
      against a pre-existing cycle rather than recursing into a hang.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      1edf3267
    • Mahmoud Aglan's avatar
      feat(accounting): payment and receipt vouchers, and fix the instrument seed · b43c5268
      Mahmoud Aglan authored
      VOUCHERS (سندات الصرف والقبض)
      Pay an expense without thinking in debits and credits. The clerk says "صرف ٥٬٠٠٠
      دعاية نقدي" — picks a type, the cash account, and what it was for — and the double
      entry is derived and previewed live before saving.
      
        outflow (صرف)   Dr each expense line   Cr cash / bank
        inflow  (قبض)   Dr cash / bank         Cr each revenue line
      
      Voucher TYPES are rows, not code: a club adds "سند صرف كهرباء" with its account
      pre-selected from the screen. Seeded with general, advertising, maintenance,
      utilities, bank, and two receipt types.
      
      Handled deliberately:
      - Input VAT splits out per line, so a supplier invoice with 14% recoverable tax
        records the expense net and the tax in its own asset account without the clerk
        doing the arithmetic. Inclusive and exclusive are both correct.
      - Every account is checked postable before saving; a header or inactive account is
        named in the error rather than failing at post time.
      - A line pointing at the cash account itself is refused — the entry would cancel
        to nothing.
      - Posting is idempotent: a voucher that already carries a journal entry is refused.
      - Cancelling a POSTED voucher reverses its entry rather than deleting it; a posted
        entry is answered with an opposite entry, never erased.
      - Voucher numbers retry on collision so two clerks saving at once cannot take the
        same number.
      - A type in use deactivates instead of deleting, so its vouchers keep their type.
      - Approval is optional per type and blocks posting until granted.
      
      INSTRUMENT SEED FIX
      Phase_104_003 died on a duplicate key and never recorded: its existence check
      filtered on is_header = 0, so it missed 230602 أوراق الدفع قصيرة الأجل — which
      exists as a header — and tried to insert it. Existence is now checked by code
      alone, and notes payable hangs at 23060201 underneath it.
      
      Account codes for the seeded voucher types were read off the live chart rather
      than guessed: 3303/3304/3305 are all headers, and 3305 is stationery, not
      advertising. They now point at 330702 دعاية و إعلان, 33061 صيانة مباني, and
      330401 كهرباء.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      b43c5268
    • Mahmoud Aglan's avatar
      fix(accounting): billing sources installed none — information_schema case, and wire cheques · 2780509a
      Mahmoud Aglan authored
      TWO THINGS.
      
      1. Billing sources installed nothing.
         validate() read information_schema with lower-case keys (column_name,
         data_type) while this server returns them upper case, so every column looked
         missing, every source failed validation, and the seed skipped all seven while
         reporting success. Columns are now aliased explicitly. The defaults moved into
         BillingSourceService::syncDefaults() so they can be re-installed after a schema
         change instead of being trapped in a one-shot seed, and anything that still
         does not fit is named rather than dropped.
      
         Found by running the validator against the live database instead of trusting
         that an empty table meant "nothing to do".
      
      2. The cheque lifecycle now posts.
         CheckLifecycleService had a correct state machine and zero journal entries, so
         a cheque moving desk → bank → collected, or bouncing, left no trace in the
         ledger at all.
      
         Each movement now posts through configurable account pointers:
      
           deposited   Dr شيكات تحت التحصيل  / Cr أوراق قبض
           collected   Dr البنك              / Cr شيكات تحت التحصيل
           bounced     Dr مدينون (شيكات مرتدة) / Cr شيكات تحت التحصيل
           endorsed    Dr الدائن             / Cr أوراق قبض
           paid        Dr أوراق دفع          / Cr البنك
      
         The bounce charge posts as its own entry so it can be waived without touching
         the restored debt. Re-presenting a bounced cheque moves it back to
         under_collection and posts the deposit leg again, so a second and third
         presentation each leave their own trail.
      
         Posting happens AFTER the status commit on purpose: a cheque physically moving
         to the bank must be recorded even when its accounts are unmapped, otherwise the
         paperwork and the system disagree. An unpostable move returns a warning.
      
         Also corrects a real error along the way: AccountCodes sends a cheque payment
         straight to the bank. Taking a post-dated cheque is not money in the bank — it
         is a note receivable until the bank collects it. The counter account is now a
         configurable pointer per payment method (treasury:method_check → أوراق قبض),
         so it is fixed from the screen rather than in code, and a header account there
         is refused with the pointer name to map.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2780509a
    • Mahmoud Aglan's avatar
      feat(accounting): billing sources — a new revenue path needs a row, not a developer · 5e36f062
      Mahmoud Aglan authored
      Removes the "this module needs code" category rather than labelling it.
      
      Most unbilled money in the ERP has one shape: a module writes a priced row into
      its own table and never tells accounting. Wiring each module by hand means a
      developer for every revenue path, forever — which is what I handed over last
      time instead of solving it.
      
      A billing source declares that shape as data: which table holds the money, which
      column is the amount, which rows are still outstanding, who owes it, and how it
      posts. One screen then lists every outstanding charge across every source and
      collects it through PaymentService — the same funnel a member payment uses, so
      it gets a receipt, treasury custody and a journal entry.
      
      Seeded and working immediately: hourly court bookings, sports subscriptions,
      lockers, facility reservations, private matches, rental invoices, annual member
      subscriptions.
      
      Edge cases handled deliberately:
      
      - No free-text SQL anywhere. Filters are structured (column / operator / value)
        rendered into prepared statements; a settings screen that accepted a WHERE
        clause would be an injection hole. Identifiers are matched against
        information_schema and a strict pattern before interpolation.
      - Every source is re-validated on save AND before every listing, because a
        migration can drop a column underneath a source that was fine yesterday. An
        invalid source is shown as broken instead of silently returning nothing.
      - The amount is re-read from the source row at collection time, never trusted
        from the form, so a stale list or a tampered field cannot set the charge.
      - Double-collection is blocked by our own billing_source_collections table
        rather than the module's paid flag — some sources have no write-back column at
        all, and a module can overwrite its own flag. The check is repeated at collect
        time to cover the gap between listing and click.
      - Partial collection only where the source allows it, never above the row total.
      - A player is not a member: a member_id that members does not have is dropped
        rather than tripping the payment foreign key.
      - Write-back is best-effort and isolated — a missing column must not undo a real
        payment, so the failure is logged and the receipt stands.
      - Zero and negative rows are excluded; an empty IN () renders as a false
        predicate rather than a syntax error.
      - Payer names resolve in two queries, not two per row.
      - A source with collections against it deactivates instead of deleting, because
        those rows are the audit trail for real money.
      
      Permission keys were read off role_permissions rather than assumed —
      payment.create does not exist in this install.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      5e36f062
    • Mahmoud Aglan's avatar
      fix(accounting): connection centre 500'd under only_full_group_by · 14e892ff
      Mahmoud Aglan authored
      The stream list used LEFT JOIN revenue_posting_rules + GROUP BY s.id and selected
      r.id / r.stage. MySQL rejects that under only_full_group_by — r.id is not
      functionally dependent on s.id — so the page threw PDOException on every load.
      
      Caught by running the query against the live database rather than trusting that
      it looked reasonable.
      
      The representative rule is now picked in its own aggregate and joined back by id,
      preferring the collection stage since that is the one people mean by "where does
      this money go", then the newest effective date and version.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      14e892ff
    • Mahmoud Aglan's avatar
      docs: ten scenarios for connecting and splitting revenue, in click order · 5ed15737
      Mahmoud Aglan authored
      Written to be executed in the room. Each scenario is the sentence the accountant
      will say, then the exact buttons in order, then the journal entry that appears.
      
      Covers the one that prompted it — "the 150,000 splits 30% to this fund, 10% to
      that one" — plus flat amounts, the percentage-base question, creating a missing
      fund account mid-meeting, VAT inclusive vs exclusive, deferred subscription
      revenue, connecting an unconnected path, breaking out an aggregate account,
      undoing a change, and proving the numbers reconcile.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      5ed15737
    • Mahmoud Aglan's avatar
      feat(accounting): connection centre — every money path, what it needs, and split-anything · ac28a438
      Mahmoud Aglan authored
      Answers the question the finance review will actually ask: "we know it is not
      connected — how do we connect it, from inside the system, now?"
      
      Three parts.
      
      1. مركز التوصيل (/accounting/revenue-mapping/connections)
         Every money path in the ERP in one list, split by WHAT IT NEEDS rather than by
         severity, because that decides who can close it:
      
           - تُوصَّل الآن من الشاشة — the module already fires an event carrying the
             amount, so mapping the accounts is the whole fix. Has a button.
           - تحتاج تعديل برمجي — the module writes the money to its own table and fires
             nothing. Mapping would change nothing, so there is deliberately NO button
             and the row says exactly what is missing. A button here would be a lie.
           - موصولة — with the current split shown inline and a rewire button.
      
         Each row reads the amount sitting in that module's own table live, so every gap
         is a number instead of an adjective.
      
      2. Rewire and split anything, including already-connected paths
         The connected list shows each current split and offers "قسّم على حسابات" when a
         path still posts to a single account. Any line can be a percentage, a flat
         amount, or the remainder — so "30% of the 150,000 to this fund, 10% to that one,
         the rest to membership revenue" is three lines and a save. Saving takes a new
         version with an effective date; posted entries never move.
      
      3. Create the destination account without leaving the screen
         A fund that does not exist yet used to mean leaving for the chart of accounts
         and losing the room. "+ حساب جديد" creates the leaf under a chosen header,
         takes the next free code, inherits type and nature, and drops straight into the
         line. Refuses to hang a child off a posting account, which would strand its
         balance.
      
      Also seeds the club fund accounts a distribution rule needs to point at — sports
      support, member welfare, martyrs stamp (already priced at 5 EGP in the service
      catalogue with nowhere to post it), federation share, facilities development.
      They are liabilities, not revenue: money earmarked for a fund is held on that
      fund's behalf, and posting it to revenue would overstate income.
      
      And 27 previously invisible paths are now catalogued with an honest wiring_status,
      a plain-Arabic note on what is missing, and a pointer to the table holding the
      money — so the screen shows the whole picture instead of only the working parts.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      ac28a438
    • Mahmoud Aglan's avatar
      docs: step-by-step accounting guide for the finance review · dbf2ca59
      Mahmoud Aglan authored
      Terminology, screen-by-screen walkthrough, a demo running order, and — most
      importantly — an honest status matrix: 267 money paths mapped across 67 modules,
      71 of which actually reach the ledger.
      
      Section 6 lists what must NOT be demoed or claimed. Being caught overstating in
      front of accountants is far worse than a known, quantified gap, so the guide
      leads with the exclusions and gives the exact wording for the hard question.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      dbf2ca59
    • Mahmoud Aglan's avatar
      fix(cron): the scheduled-job subsystem has never run, and gate it before it does · cd771856
      Mahmoud Aglan authored
      cron/runner.php writes a cron_job_log row before every eligible job. That table
      does not exist, so the runner threw on the first job with shouldRun() === true
      and none of the 43 scheduled jobs has ever executed: subscription generation,
      instalment default handling, activity-subscription revocation, academy
      settlements, coach payroll, monthly depreciation, and every expiry reminder.
      
      The container's crontab is present and cron is running — the hourly entry has
      been firing into an immediate exception the whole time, which is why
      storage/logs/cron.log does not exist.
      
      Creating the table alone would be reckless the night before a finance review:
      the crontab fires hourly, so all 43 would start on the next tick, and several
      write off receivables, impose fines, drop memberships and auto-complete waivers
      (which now post accrual entries). So the runner is additionally gated behind
      system_config.cron_enabled, seeded to 0.
      
      Turn it on from Settings when someone can watch the first run. Until then the
      runner exits with a clear message rather than pretending to work.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      cd771856
    • Mahmoud Aglan's avatar
      fix(hr): payroll posted nothing — the handler had the schema wrong three ways · 7313a539
      Mahmoud Aglan authored
      onPayrollPaid read total_gross and total_net off hr_payroll_runs and grouped
      hr_payroll_components_log by component_type. None of those columns exist:
      
        hr_payroll_runs          has gross_earnings / net_salary
        hr_payroll_components_log has `type`, not component_type
        hr_payroll_periods        has period_code, not period_name
      
      Confirmed with SHOW COLUMNS on the live database. The handler threw "Unknown
      column" on its first query, and the listener only logs, so payroll silently
      posted NOTHING — no salary expense, no employer insurance share, no withheld
      tax anywhere in the ledger.
      
      It also had the grain wrong. PayrollController dispatches hr.payroll.paid once
      PER EMPLOYEE; an hr_payroll_runs row is a single payslip, not a whole run, and
      the period lives in hr_payroll_periods. Every amount needed is on the payslip.
      
      Rewritten against the real schema:
      
        Dr Salary Expense              gross_earnings
        Dr Employer Insurance Expense  insurance_employer
        Cr Bank                        net_salary
        Cr Insurance Payable           insurance_employee + insurance_employer
        Cr Tax Payable                 tax_amount
        Cr Employee Loans              loan_deduction
        Cr Other Deductions Payable    penalty + absence + other
      
      Balances by construction: the payslip satisfies gross - total_deductions = net
      and the deduction buckets sum to total_deductions. Verified on all three live
      payslips — e.g. run 1: Dr 15,000.00 + 2,362.50 = Cr 4,402.20 + 3,748.50 +
      9,211.80 = 17,362.50.
      
      A salary-deducted loan instalment credits the employee-advances receivable
      rather than being treated as income. Penalties and absence deductions are parked
      in accrued expenses and registered as a configurable pointer, because whether
      they belong there or as a reduction of salary expense is a decision for the
      accountants, not a constant in code.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      7313a539
    • Mahmoud Aglan's avatar
      fix(accounting): COGS could never post — sale_items has no total_cost column · f8c11843
      Mahmoud Aglan authored
      onSaleCompleted summed sale_items.total_cost. That column does not exist; the
      table stores a per-unit cost_price alongside quantity. Confirmed with SHOW
      COLUMNS on the live database.
      
      Every sale therefore threw "Unknown column" inside the sale.completed listener,
      which is wrapped in a try/catch that only writes to the log. So inventory was
      relieved in the stock ledger while the general ledger kept carrying it, and no
      cost of sales was ever recognised — the gross margin on every sale was overstated
      by its entire cost.
      
      Now SUM(cost_price * quantity) over non-refunded lines. This also un-breaks
      onSaleVoided, which reverses the 'sale_cogs' entry and could never find one.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      f8c11843
    • Mahmoud Aglan's avatar
      fix(accounting): balance sheet did not balance — accumulated profit was omitted · e06900e1
      Mahmoud Aglan authored
      Assets came to 124,051,895.29 against liabilities + equity of 84,950,136.58.
      Out by 39,101,758.71 — a balance sheet that does not balance.
      
      Cause: the sheet added only the CURRENT fiscal year's net income to equity. No
      year-end closing entry has ever been posted here (period_closings is empty), so
      the revenue and expense accounts still carry all-time balances and retained
      earnings has never absorbed prior years. The earlier years' profit therefore sat
      in the income accounts and appeared nowhere on the sheet.
      
      Verified against the live ledger:
        liabilities                     79,821,436.63
        revenue - expenses (all time)   44,230,458.66   (80,820,684.83 - 36,590,226.17)
                                       ---------------
                                       124,051,895.29 = total assets, exactly
      
      Accumulated profit now runs from the first posted entry rather than the fiscal
      year start. This stays correct after closing entries begin: a closing entry moves
      the profit into retained earnings and zeroes the income accounts, so the figure
      then covers only post-closing activity while the closed profit sits in the equity
      accounts. The current fiscal year's slice is still returned separately, as
      current_period_net_income, because that is what the board asks about.
      
      Note for the chart: there are no accounts typed 'equity' at all — capital (2101)
      and retained earnings (210201) are typed 'liability'. That is why total_equity
      consists solely of the accumulated-profit line. The sheet balances either way,
      but the classification is worth revisiting with the accountants.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      e06900e1
    • Mahmoud Aglan's avatar
      fix(facilities): five screens queried a reservations column that does not exist · 8af96aea
      Mahmoud Aglan authored
      `reservations` identifies its booker with booker_type plus player_id / member_id.
      There is no booker_id column — confirmed with SHOW COLUMNS on the live database,
      not from the migrations. Five call sites queried it anyway, so every one of them
      threw a SQL error:
      
        FacilityDashboards/Controllers/FacilityDashboardController.php  (x2)
        PlaygroundAdmin/Services/ClubDashboardService.php               (x2)
        PlaygroundAdmin/Services/PlaygroundMirrorService.php            (x3)
        FacilityGrids/Services/PoolFinancialService.php                 (x1)
        PlayerApi/Services/PlayerBookingService.php                     (x4)
      
      Effect: the facility dashboard, the club-wide playground dashboard, the pool
      financial panel and the playground mirror hard-500 on every load, and the player
      app could never create a booking — the INSERT named booker_id too. That matches
      the data: 7 reservations exist with booker_type set and player_id/member_id both
      NULL, and zero player bookings.
      
      Reads become COALESCE(player_id, member_id); the joins key on the specific column
      for their booker_type; the INSERT writes player_id.
      
      Also PlaygroundMirrorService queried private_match_bookings.match_date, which is
      booking_date on that table. (live_matches genuinely has match_date, so MatchCenter
      is untouched.) And sa_bookings / pool_bookings really do have booker_id, so those
      references are correct and left alone.
      
      Every rewritten query was executed against the live database before committing.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      8af96aea
    • Mahmoud Aglan's avatar
      fix(accounting): opening balances were double-counted in three reports · 2f77d3e1
      Mahmoud Aglan authored
      The opening figures live in TWO places in this ledger: the
      chart_of_accounts.opening_balance column AND 24 posted journal entries dated
      2024-07-01 with reference_type='opening', totalling 90,601,962.36.
      
      Three reports read the column and then also summed the ledger movement that
      already contained those same entries, counting the opening twice:
      
      - Trial balance (LedgerService::getTrialBalance)
      - General ledger  (LedgerService::getAccountLedger)
      - Balance sheet   (FinancialReportService::getBalanceSheet, and the
                         consolidated sheet which delegates to it)
      
      Measured on live data, trial balance over FY 2024/2025:
        1103 مشروعات تحت التنفيذ   reported 85,627,410.75  actual 43,923,543.75
        210201 أرباح مرحلة         reported -146,645,270   actual -73,322,635
      i.e. exactly double on every account carrying an opening balance. The report
      still footed, because opening balances net to zero across debit and credit —
      so it looked right and every line was wrong. Only periods containing
      2024-07-01 were affected; a 2026 trial balance was already correct.
      
      The opening column is now derived as cumulative posted movement BEFORE the
      period start, which is the standard definition, removes the double count
      structurally, and works for any period rather than only a year boundary. The
      trial balance query is also restructured into two independent aggregates so
      no row multiplication is possible and an account whose only movement predates
      the period still appears.
      
      Income statement was already correct and is unchanged.
      
      Also in this commit:
      
      - LedgerService::rebuildBalances() + a seed that runs it. The opening import
        wrote journal rows without going through JournalService, so 24 accounts had
        a cached current_balance disagreeing with the ledger — retained earnings
        cached 0.00 against an actual 73,322,635.00. The reports read the ledger and
        were fine, but the Chart of Accounts screen and the bank-reconciliation
        opening figure read the cache, which is precisely where an accountant would
        find a number contradicting the trial balance.
      
      - Carnet guest entry never posted. Accounting listened on
        'carnet.guest_entry_recorded'; GuestEntryService dispatches
        'carnet_guest.entry_recorded' (underscore, not dot). Notifications listens on
        the correct name, which is why notifications worked and the ledger entry
        never appeared. Fees were recorded in carnet_guest_entries.amount_paid and
        posted nowhere.
      
      - 'tournament.fee_collected' has no dispatcher anywhere. Documented as dead
        rather than left looking wired.
      
      - Two fiscal years were flagged is_current; the seed leaves exactly the one
        containing today. FiscalYear::findByDate now resolves overlapping years
        deterministically (open first, then narrowest range) instead of taking
        whatever the database returned — this chart has calendar years overlapping a
        July-June year, so Jul-Dec 2024 matches two. No entry is reassigned; all 795
        are already inside their assigned year.
      
      - PostingRouter and postViaRule now probe App::db() with try/catch. It is typed
        `: Database` and throws when unbound rather than returning null, so the
        previous null guards could never fire.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2f77d3e1
    • Mahmoud Aglan's avatar
      feat(accounting): extend posting engine to the full accounting cycle · fb3097a2
      Mahmoud Aglan authored
      Generalises the revenue engine from "collection" to every stage a document
      posts at, and routes all 26 auto-posting paths through it.
      
      Two new dimensions on a rule:
      
        stage      accrual | collection | payment | refund | writeoff | transfer
        direction  inflow  → counter account DEBITED, allocation lines CREDITED
                   outflow → allocation lines DEBITED, counter account CREDITED
      
      So the same allocation maths now drives revenue, expense, receivable and
      payable postings. Contra-revenue is always a debit regardless of direction.
      
      Where the amounts are computed elsewhere and only the accounts need to be
      configurable — payroll components, treasury legs, COGS, rental legs — a
      second mechanism (PostingRouter::accountFor) resolves a configurable account
      pointer instead of forcing those through the allocator. Both are edited from
      the same screen.
      
      Dead posting paths fixed. Each of these targeted a header account, which
      JournalService refuses, and the callers only Logger::error — so they have
      been failing invisibly:
      
      - 230601 الموردون is a header → the ENTIRE procurement cycle (vendor invoice,
        vendor payment, return-to-vendor) could never post. Now 230601002.
      - 310103 حصة الشركة في التأمينات did not exist at all → payroll dropped the
        employer insurance line, then a balancing fallback silently increased the
        bank credit to force the entry to balance, misstating cash. The account is
        created, and an imbalance now refuses to post and reports instead.
      - 230804 جاري مصلحة الضرائب is a header → rental VAT could never post.
        Now 23080404 ضريبة القيمة المضافة.
      - AccountCodes::INPUT_TAX resolved to 120408 مدينو بيع أوراق مالية, an
        unrelated account. Input VAT now posts to 12041106.
      - Member write-off debited MISCELLANEOUS_REVENUE. A bad debt is an expense;
        it now posts to 3328 ديون معدومة.
      - $result['entry_id'] is never returned by JournalService (the key is
        journal_entry_id), so rental invoices, treasury settlements and treasury
        deposits never linked back to their journal entry.
      - SUB_TREASURY_CASH points at 12060102 الصندوق بالدولار, the USD box. Left
        deliberately unmapped and surfaced on the diagnostics page so finance picks
        the right EGP account rather than having one guessed for them.
      
      Accruals now also create the accounts_receivable sub-ledger row alongside the
      GL entry, which is why that table was empty against 970,592.67 EGP of
      scheduled instalments.
      
      Verified against a full clone of the production schema and chart of accounts
      in a throwaway database: all six stages post balanced entries, VAT 14%
      inclusive on 1140 yields 1000 revenue + 140 tax, a five-line split (two fixed
      + two percentage + remainder) balances to the piastre, and a 12,000 annual
      subscription produces exactly 12 monthly deferral rows summing to 12,000 with
      the recognition run posting the current period. 27 allocation unit tests pass.
      
      Seeded rules reproduce existing behaviour except where that behaviour was a
      silent failure. Unconfigured stages still fall through to the legacy path.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      fb3097a2
    • Mahmoud Aglan's avatar
      feat(accounting): grant revenue-mapping permissions to chart-of-accounts roles · dc305901
      Mahmoud Aglan authored
      Whoever can read the chart of accounts can read where revenue lands; whoever
      can change it can change the mapping. Without this the محاسب role sees the
      Accounting menu but gets 403 on the revenue-mapping screen.
      
      super_admin holds the '*' wildcard and needs no explicit grant.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      dc305901
    • Mahmoud Aglan's avatar
      feat(accounting): configurable revenue posting engine (account determination) · bb3e8ccd
      Mahmoud Aglan authored
      Replaces the hardcoded AccountCodes::creditAccountForPaymentType() match
      statement with a versioned, effective-dated mapping that finance controls
      from /accounting/revenue-mapping.
      
      Every collected amount can now be split across multiple GL accounts by flat
      amount, percentage, or remainder, with VAT handled as its own layer and
      deferred revenue amortised over the service period.
      
      What the live DB showed, and this addresses:
      - 4,256,399.96 EGP across 129 transactions posted to a single catch-all
        account (410515 إيرادات متنوعه) — waiver, separation, death, foreign
        membership, early settlement and four payment types that had no rule in
        the code at all and silently fell through to `default`.
      - 240,582 EGP of divorce fees posted to 410302 «محل 1», a shop rental account.
      - 120301 العملاء and 230804 جاري مصلحة الضرائب are header accounts, and
        JournalService rejects posting to headers — so every AR and VAT entry has
        been failing silently. accounts_receivable holds 0 rows against 970,592.67
        EGP of unpaid instalments.
      
      Model follows SAP account determination / Dynamics posting profiles, adapted
      to Egyptian VAT law 67/2016 and EAS 48 revenue recognition:
      
      - revenue_streams              catalogue of every chargeable thing
      - revenue_tax_profiles         rate + inclusive/exclusive + treatment
      - revenue_posting_rules        versioned, effective-dated, scopeable
      - revenue_posting_rule_lines   the split components
      - revenue_posting_log          which rule version produced which entry
      - revenue_recognition_schedules deferred revenue amortisation
      
      Allocation order is fixed and deterministic: tax extraction, then fixed
      amounts, then percentages, then a mandatory remainder line that absorbs
      rounding residue so the entry always balances.
      
      Tax is a separate layer rather than a split because inclusive and exclusive
      pricing are not the same number: 14% of a tax-inclusive 1140 is 140 on
      revenue of 1000, not 159.60. Deferral is separate for the same reason — it
      is a split across periods, not accounts.
      
      Adds two postable accounts the chart was missing: 120301004 أعضاء النادي
      (مدينون) and 12041106 ضريبة القيمة المضافة — مدخلات.
      
      Seeded rules reproduce current posting behaviour exactly, so this deploy
      moves no reported number. Streams landing in a catch-all are flagged for
      review rather than silently re-pointed — repointing them moves real revenue
      between accounts and is finance's decision.
      
      Unconfigured streams fall through to the legacy path unchanged.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      bb3e8ccd
  2. 04 Sep, 2026 1 commit
    • Mahmoud Aglan's avatar
      build(proposal): add PDF export with print stylesheet · 14972ee2
      Mahmoud Aglan authored
      The microsite is built for scrolling, so a naive print lost most of it:
      reveals start invisible, the phone shows one prototype screen at a time,
      the portal shows one admin screen, and <details> print collapsed.
      
      - @media print in styles.css: force reveals visible, drop the fixed nav
        and prototype tools, start each section on a fresh page, and mark cards,
        timeline items, tables and price blocks break-inside:avoid so none is
        split across a page boundary
      - generate-pdf.mjs (puppeteer): expands both prototypes before printing —
        the single phone frame becomes a labelled 3x3 grid of nine real app
        screens, and all seven portal screens are stacked. 1240x1754 pages (A4
        proportion at 150dpi), backgrounds on.
      - Adds a "تحميل العرض PDF" button to the hero; the print stylesheet hides
        .btn-row so it does not appear inside the PDF itself
      
      Output is 14 pages with a real text layer: Arabic extracts correctly and
      figures stay searchable. Ghostscript compression reaches 2.3MB but its
      font re-embedding drops Arabic strings from the text layer, so the
      uncompressed 8.5MB file is kept instead.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      14972ee2
  3. 03 Sep, 2026 5 commits
    • Mahmoud Aglan's avatar
      content(proposal): reprice to 500k, 2-4 weeks, add club news, trim tech detail · deefac32
      Mahmoud Aglan authored
      Commercial terms:
      - Price 900,000 -> 500,000 EGP, breakdown rebased to sum exactly
        (230k app + 160k portal/CMS + 60k gate/invites + 50k launch)
      - Payments simplified from three milestones to two: 360,000 on signing,
        140,000 on delivery
      - Timeline 4-6 weeks -> 2-4 weeks, timeline recompressed from five
        milestones to four
      - Early-signing discount recalculated: 5% = 25,000 (was 45,000)
      - "غير شاملة ضريبة القيمة المضافة" now stated in the price hero, the
        totals row, under the payment schedule, in the hero stat and footer,
        and bolded in the FAQ
      
      New scope — club news / blog:
      - Fourth axis added; news moved out of the deferred list into phase one
      - Live prototype gains a news feed, article page and a fifth tab, with
        home showing the two latest items
      - Portal gains a news management screen: article list with reach stats,
        editor with category, image drop and publish-notification toggle
      - Article artwork is a branded crest placeholder, not stock photography —
        the club supplies real images at launch and inventing them would
        misrepresent what has been approved
      
      Reduced technical detail per request:
      - Dropped the per-template column-name lists (six of them) and the three
        import-engine cards, replaced with one plain note
      - Removed API/OTP/RTL/iOS-14/Android-8 jargon throughout; rewrote the
        security and extensibility cards in business terms
      - Simplified the in-scope list wording
      
      Also makes .tbl scroll rather than clip: a table too wide for its column
      silently lost its last cells instead of becoming reachable.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      deefac32
    • Mahmoud Aglan's avatar
      feat(proposal): replace static mockup with a working app prototype · cf882849
      Mahmoud Aglan authored
      The prototype in the proposal was eight inline <div>s toggled with
      display:none — screenshots in a phone bezel. It now embeds a real
      single-page app from app/, so the screens shown in the proposal are the
      screens that ship, and the board can actually use it.
      
      app/ — hash routed, so any screen is linkable (#/dues):
        splash, login, otp, home, dues, pay, paying, success, receipts, qr,
        invites, activities, activity/:id, schedule, notifications, profile
      
      State is live, not scripted. Selecting dues recomputes the total before
      paying; paying clears those dues, creates a receipt and posts a
      notification; issuing an invite decrements the balance; subscribing to an
      activity adds the subscription, consumes a place and generates the first
      invoice into المستحقات. The QR regenerates every 60s against a countdown
      ring. Dark/light theme persists. Verified end to end by driving the app in
      headless Chrome, not just by rendering it.
      
      Chose an SPA over the multi-page pattern used by the older Proposal/
      prototype: no white flash between screens, real forward/back transitions,
      and shared state across screens, which is the whole point of showing a
      collection flow.
      
      Proposal integration:
      - Phone hosts <iframe src="app/">; the side list drives it over
        postMessage and the prototype reports its route back, so the list and
        the annotation stay in sync when someone navigates inside the phone
      - Theme toggle, reset, and open-fullscreen controls
      - Annotation panel rewritten per screen
      
      Removed 409 lines of static screen markup and the 93 lines of CSS that
      served it (.sbar/.app-hd/.mcard/.tabbar/.sport-item/.otp-row/.qr-*),
      verified dead by checking real class= usage, not substring matches.
      
      Adds .dockerignore: the image is built with `COPY . /usr/share/nginx/html`,
      so any stray working file in this folder would be served publicly.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      cf882849
    • Mahmoud Aglan's avatar
      design(proposal): replace all emoji with an inline SVG icon set · ff106889
      Mahmoud Aglan authored
      Emoji render as platform-specific colour cartoons (Apple/Windows/Android
      each differ) which reads informal in a document going to a club board, and
      they cannot inherit brand colour. Replaced every one with a 33-symbol
      inline SVG sprite: 24x24, stroke-based, currentColor, sized in em so each
      existing icon slot keeps its own scale.
      
      Removed: swimmer, bell, receipt, credit card, mobile, bank, football,
      martial-arts, tennis, cartwheel, page, lock, floppy, plug, envelope, gear,
      up/down arrows, and the EG regional-indicator flag pair.
      
      Also converted the geometric glyphs sitting in icon slots (fisheye,
      diamonds, house, quadrant-circle, square-fill, clock) plus the list
      check/x/arrow marks, so the icon layer is uniformly SVG rather than a mix
      of text glyphs and emoji. A source scan for emoji ranges now returns clean.
      
      Sprite is hidden with position/width/height rather than display:none,
      which can break <use> resolution in some engines.
      
      Fixes an unrelated pre-existing contrast bug found while verifying: .card
      h4 is declared after .dark h4 at equal specificity, so the three cards in
      the dark flow section rendered navy-on-navy. Added .dark .card h4.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      ff106889
    • Mahmoud Aglan's avatar
      content(proposal): de-AI the Arabic, add crest watermark, rescope to 4-6 weeks · 783a14e6
      Mahmoud Aglan authored
      Copy — the previous register read as marketing/AI boilerplate to a board
      audience. Removed the tells and rewrote in institutional MSA:
      - Drop staccato fragment headlines and their trailing periods
        ("ثلاثة محاور. لا أكثر." -> "نطاق المرحلة الأولى: ثلاثة محاور")
      - Drop rhetorical punchlines ("الاحتكاك يقتل الاشتراك."،
        "ليست صورًا تخيلية."، "من ينسى، لا يدفع.") for descriptive prose
      - Drop the «مصنع البيانات» metaphor and the pitch-deck eyebrow "لماذا الآن"
      - Rewrite all 8 prototype screen annotations from slogans to labels
        ("البوابة تعرف من يدخل" -> "الدخول بكود QR")
      - Reduce rhetorical em-dashes; keep structural ones only
      
      Schedule — project is 4-6 weeks, not 12. Timeline recompressed from six
      milestones over 12 weeks to five over 6, with the 4-week case stated as
      conditional on data and accounts landing in week 1. Updated hero stat,
      scope lede, price card, plan heading and footer badge to match.
      
      Design — elegant crest watermark:
      - Oversized club crest bleeding off the inline-start edge of the hero,
        gold-tinted, offset so it does not double with the hero logo
      - Alternating-side crest ghosts on problem/scope/flow/price sections
      - Crest in the price card and footer, plus a gold hairline on the footer
      - Uses background-image + filter, not mask-image: masks give a cleaner
        silhouette but do not paint in headless Chrome, so this variant is the
        one that can actually be verified before shipping
      - Logical properties throughout (inset-inline, margin-inline) so the
        watermark mirrors correctly in RTL
      - Added a prefers-reduced-motion guard for the scroll reveal
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      783a14e6
    • Mahmoud Aglan's avatar
      content(proposal): rewrite Arabic copy, reprice to 900k, drop support scope · a91250e3
      Mahmoud Aglan authored
      Proposal microsite for Nady El-Seid mobile app (sayd-mobile).
      
      Arabic copy:
      - Fix agreement/tamyiz errors: اكتملت العدد -> اكتمل العدد,
        متأخر يومان -> يومين, "92 يوم" -> "92 يومًا", منها 214 متأخر -> متأخرًا,
        يومان تدريب -> يوما تدريب (dual mudaf drops nun)
      - Fix ambiguous/wrong forms: فيتحدث رصيده -> فيُحدَّث رصيده فورًا,
        الحمام الأولمبي -> حمام السباحة الأولمبي
      - Remove translationese: comma-lists rewritten with و, passives given
        back their agents, Egyptian تشتغلون -> تبدأون التشغيل
      - Unify register to plural address (اضغط -> اضغطوا, شاهد -> شاهدوا)
      - Apply reviewed headline/lede/ROI rewrites with corrected orthography
      
      Commercial terms:
      - Price 700,000 -> 900,000 EGP; breakdown rebased to sum exactly
        (400k app + 290k portal + 110k gate/invites + 100k integration)
      - Remove all post-launch support: annual maintenance contract, 6 free
        months, 1-year warranty, 99.5% SLA, first-year store fees. Delivery
        and release only, stated explicitly in a "غير مشمول" block and FAQ
      - Payments 40/30/20/10 -> 40/25/35: signing, data-entry plan approved
        + club opens required accounts and services, completed delivery
      - Timeline week 1-2 renamed to cover data-entry planning and names the
        account-opening as a parallel club obligation
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      a91250e3
  4. 01 Sep, 2026 3 commits
    • Mahmoud Aglan's avatar
      dxfdgfnzdfh · 8a8d4428
      Mahmoud Aglan authored
      8a8d4428
    • Mahmoud Aglan's avatar
      docs: condense architecture-map protocol in CLAUDE.md · 2dd20e37
      Mahmoud Aglan authored
      Pre-existing uncommitted working-tree change, not part of the member search
      work. Committed separately so the 478-line reduction stays visible in history.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      2dd20e37
    • Mahmoud Aglan's avatar
      feat(members): unified people search across members and dependents · b7522599
      Mahmoud Aglan authored
      Overhaul /members/search so one bar finds anyone in the club — the member,
      a spouse, a child or a temporary member — and add an advanced filter panel.
      
      MemberSearchService becomes the single source of truth for people search:
      
      - UNIONs members + spouses + children + temporary_members into one normalised
        row per matched PERSON (person_type, relation, parent membership, rank).
      - Token AND matching on names, so word order no longer matters:
        "محمود احمد" finds "أحمد سيد محمود".
      - Arabic orthographic folding (أ إ آ ٱ→ا, ى→ي, ة→ه, ؤ→و, ئ→ي) applied to both
        the query and the column, so "احمد" matches "أحمد".
      - Arabic-Indic and Persian digits folded to ASCII before identifier matching.
      - Relevance ranking: exact membership number / national id, then name prefix,
        then substring. LIKE wildcards in user input are escaped.
      
      Scopes (member/spouse/child/temporary) and fields (name, membership number,
      national id, phone, form number, passport) are selectable; branch, membership
      status and membership type filter on the parent membership. A scope whose table
      lacks the requested field is skipped rather than matching nothing.
      
      The legacy search() keeps its exact signature and output shape, so the three
      existing API consumers are untouched.
      
      Also:
      - Split Member::getStatusOptions() (statuses an employee may ASSIGN) from
        getAllStatusLabels() (every status, for display/filtering). deceased,
        transferred and waived exist in live data but were missing from the list, so
        they could not be filtered on; they are deliberately kept out of the
        assignable set because the Death, Transfer and Waiver workflows own those
        transitions.
      - Dependent deep links honour spouse.view / child.view / temp.view and fall
        back to the membership file when denied.
      - Map children.relationship (son/daughter) and temporary_members.category
        (nanny/parent/unmarried_daughter) to Arabic for display.
      - The search form submitted to /members, dropping most of what was typed; it
        now posts back to /members/search.
      - Sidebar declared member.search while the route requires member.view; aligned.
      
      Architecture Map and Dependency Graph updated per project protocol, including
      the placeholder-ordering constraint in buildScopeQuery() and the three inline
      member-search SQL blocks that remain unconsolidated.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      b7522599
  5. 31 Aug, 2026 2 commits
    • Mahmoud Aglan's avatar
      docs(proposal): add Nady El-Seid mobile-app + data-portal proposal microsite · c39af584
      Mahmoud Aglan authored
      Phase-1 scope only: membership renewals/installments, sports activity
      invoices, QR gate entry + invitations. Interactive HTML prototypes for
      8 mobile screens and 6 portal screens, 6 downloadable CSV import
      templates, 12-week plan, 700,000 EGP commercial offer.
      
      Deployed to CapRover as app 'sayd-mobile'.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      c39af584
    • Mahmoud Aglan's avatar
      fix(pricing,members): merge board-offer discounts into the member special-discount dropdown · 01e3b4ef
      Mahmoud Aglan authored
      Client clarified their earlier request: the member "special discount"
      dropdown should show board-approved special_discounts AND active
      عروض مجلس الإدارة (board_offers) cash discounts side by side, not one
      instead of the other — they'd stopped seeing anything they'd added
      under Board Offers.
      
      - Add members.special_discount_source ('special_discount'|'board_offer')
        and drop the hard FK on special_discount_id (a single column can no
        longer FK exactly one table). Integrity is now validated in
        MemberController::parseDiscountSelection().
      - BoardOffer::allActiveWithCashDiscount() surfaces board offers that
        define a cash discount as selectable options.
      - SpecialDiscountService::resolveAssignedDiscount()/amountForType() give
        one place that normalizes "the member's assigned discount" across both
        source tables — used by BillingService's invoice line item, the show
        page's applied-discount banner, and the dropdown's own validation.
      - fill-form/edit/show views render two <optgroup>s ("عروض مجلس الإدارة" /
        "الخصومات الخاصة") with prefixed option values (bo:<id> / sd:<id>) so a
        single form field can select from either table; edit.php's live
        discount-amount preview now handles fixed-amount discounts too, not
        just percentage.
      Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
      01e3b4ef
  6. 30 Aug, 2026 4 commits
    • Mahmoud Aglan's avatar
      fix(members,subscriptions): drop due-date column from family tables, collect... · 67196669
      Mahmoud Aglan authored
      fix(members,subscriptions): drop due-date column from family tables, collect annual subscription via الخزنة
      
      - Remove the "تاريخ الاستحقاق" column from the spouses/children/temporary
        members tables on the member show page (display-only, no schema change).
      - Annual subscription payments no longer post directly from the
        اشتراك سنوي page. SubscriptionController::payYear() now queues a
        payment_request instead of calling PaymentService::processPayment()
        directly; the subscription rows are only marked paid once خزنة العضويات
        (Membership Treasury / Cashier) actually collects it, via a new
        payment_request.completed listener
        (SubscriptionSyncService::completeFamilyYearPayment()).
      - Closed the same bypass on the legacy generic /payments/process/{id}
        page, which had its own divergent partial-payment, oldest-year-first
        logic for annual_subscription that skipped the treasury entirely and
        violated the all-or-nothing-per-family rule; that path now redirects to
        the member's subscriptions page instead.
      Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
      67196669
    • Mahmoud Aglan's avatar
      fix(pricing): require board decision for member special discounts, allow... · 570cb709
      Mahmoud Aglan authored
      fix(pricing): require board decision for member special discounts, allow fixed-amount down payment on board offers
      
      - special_discounts gains board_decision_number/board_decision_date; the
        member-facing "special discount" dropdown (fill-form, edit, show, apply)
        now only lists/accepts discounts backed by a board decision instead of
        the full unaudited special_discounts catalog, per client request that the
        dropdown should only read board-approved discounts.
      - board_offers gains inst_down_payment_type (percentage/fixed_amount) so a
        board-approved offer's installment down payment (المقدم) can be a fixed
        cash amount, not only a percentage. Wired through BoardOfferService,
        InstallmentCalculator (new min_down_amount override) and
        PaymentLifecycleService so the fixed amount is actually enforced at
        billing time, not just in the admin form.
      Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
      570cb709
    • Mahmoud Aglan's avatar
      fix(auth): resolve route/menu/role permission drift causing phantom 403s · d39d9293
      Mahmoud Aglan authored
      Users saw sidebar links that returned 403. Root cause was drift between four
      independently-authored declaration sets that nothing reconciles: the permission
      catalogue (bootstrap.php), the route gate (Routes.php), the menu gate
      (MenuRegistry) and the role grants (seeds).
      
      Route shadowing (Router::dispatch is first-match-wins over a sorted module glob):
      - GET /reports was declared by both Members and Reports; Members won and enforced
        member.reports while the sidebar gated on report.view_membership. Members'
        report routes moved to /members/reports/*.
      - GET /sports-dashboard[/export] was declared by three modules, so the dashboard
        index and its drill-downs were served by different modules. Disciplines ->
        /disciplines/dashboard, PlaygroundAdmin -> /playgrounds/dashboard[/export];
        /sports-dashboard is now wholly owned by SportsDashboard.
      - Members/Routes.php used unconstrained {id} in 15 routes, so /members/<anything>
        was swallowed by MemberController@show. Constrained to {id:\d+}, matching every
        other module. All 25 affected links updated.
      
      Gate alignment:
      - Six menu entries gated on a different permission than the route they link to
        (/members/search, /sports, /carnets, /rentals/entities,
        /notifications/templates, /reports).
      
      Authorization bypasses:
      - RetroactiveWizardController hardcoded a role_code = 'super_admin' query,
        throwing "هذه الأداة متاحة فقط لمدير النظام". Replaced with a registered
        member.retroactive permission enforced by the route and grantable via the
        Roles UI.
      - report_definitions.required_permission was stored and displayed but never
        checked, so report.view_membership was enough to open ANY report by code,
        including financial ones. Now enforced on view/export/print; the listing
        filters to what the viewer can actually run.
      
      Role grants (Phase_105_001, idempotent):
      - Closes the reported gaps for report_viewer, general_manager, receptionist,
        sports_officer, academy_manager and membership_director; grants the sports
        report keys to board_member/auditor so enforcing the per-report permission
        does not silently remove reports; revokes member.view/member.search from
        facilities_manager, who keeps bookings and reservations.
      
      Data correctness:
      - SaFinanceReportService read base_price from sa_pricing_rules, a facility
        booking table with neither that column nor activity_type, and derived revenue
        as headcount x a rate-card price. Now sums actual sa_registrations
        .registration_fee, matching how subscription and booking revenue are computed.
      
      Regression guard:
      - php cli.php permissions:audit reconciles all four declaration sets, reproduces
        the router's load order, and exits non-zero on drift. Run it after touching any
        Routes.php, menu block or role seed.
      
      Docs: new docs/architecture-maps/Authorization.md; cross-module authorization
      section added to DEPENDENCY-GRAPH.md.
      
      Note: the live DB was unreachable from the dev environment, so role grants were
      verified by replaying the seeds and schema came from migrations, not the live DB.
      PHPUnit is not installed locally; all changed files lint clean.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      d39d9293
    • Mahmoud Aglan's avatar
      fix(sports-activity): resolve broken queries, fee/threshold drift, and billing gaps · e139a8a2
      Mahmoud Aglan authored
      - ActivitySubscriptions: fix generate/calculateRate querying nonexistent
        `enrollments` table; use `academy_enrollments` with correct columns
      - SportsDashboard: fix queries against nonexistent `disciplines` table;
        use `sport_disciplines`
      - Sports: unify conversion-fee percentage to a single source
        (MembershipRulesService::getAthleticMemberConversionRules), preventing
        the eligibility preview from drifting from what's actually charged
      - SportsActivity: align absence-threshold fallback defaults between
        AttendanceRuleService and TrainingAttendanceService via a shared constant
      - SportsActivity: auto-bill first month on Registration Wizard completion,
        matching the direct-enrollment path so wizard-registered players aren't
        left uncharged until the monthly batch runs
      - ActivitySubscriptions: guard paySubscription() to only transition
        pending/overdue -> paid, making it idempotent against the (currently
        unreachable) payment.completed listener path
      - ActivitySubscriptions: dispatch academy.enrollment_created from the
        enroll wizard so PlayerAffairs' auto-billing listener actually fires
      
      Also adds/updates Architecture Maps for Sports, SportsActivity,
      SportsDashboard, ActivitySubscriptions and the cross-module Dependency
      Graph, per this repo's mandatory architecture-map workflow.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
      e139a8a2
  7. 29 Aug, 2026 2 commits
    • Mahmoud Aglan's avatar
      feat(dashboard): role-aware dashboards + super-admin command center · f46e7a77
      Mahmoud Aglan authored
      Every user previously saw the same dashboard: DashboardDataService::getData()
      returned one fixed payload with no reference to the current employee. A cashier
      got membership stats they could not act on; an HR manager got revenue instead of
      headcount.
      
      Each role now gets a curated dashboard. Role presets pick the layout, permissions
      gate every widget (mirroring MenuRegistry::getVisible), and multi-role users get
      the deduped union of their presets. Super admin gets a 5-KPI, 16-widget command
      center across six sections.
      
      Wires up WidgetRegistry, which existed fully written but was used by nothing.
      
      144 widgets, all SQL executed and verified against the live schema — 46 were
      corrected during verification, including a month-to-date figure compared against
      a full prior month (a fake collapse every month), spouse counts missing their
      status filter, and receivables that included debt owed by archived deceased
      members.
      
      Only the headline plus first six widgets query on load; the rest hydrate through
      GET /dashboard/widget/{key}, which re-checks permission server-side and renders
      via the same partial as the eager path. Employees with no mapped role fall back
      to the previous shared dashboard, preserved verbatim.
      
      Also loads Chart.js, which PlayerAffairs has always called behind a
      `typeof Chart !== 'undefined'` guard while the library was loaded nowhere —
      those evaluation charts were silently dead and now render.
      Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
      f46e7a77
    • Mahmoud Aglan's avatar
      commit all · 7b74997d
      Mahmoud Aglan authored
      7b74997d
  8. 28 Aug, 2026 2 commits
  9. 27 Aug, 2026 1 commit
    • Mahmoud Aglan's avatar
      feat(sa-reports, discounts): sports activity reports + membership discount fixes · 40f1c0c0
      Mahmoud Aglan authored
      Sports Activity Reports: player reports with filters (discipline/program/group/
      player type/medical/payment status/branch) and finance reports (revenue/costs/
      profit with daily/weekly/monthly/yearly/3yr/5yr/custom periods). CSV and PDF
      export for both. Role-based access with 3 new permissions.
      
      Membership Discounts: fix BillingService to include regulatory discount as bill
      line item, add regulatory discount section to edit page, add FYI discount guide
      to show page covering all 3 discount types (special, regulatory, board offers),
      handle regulatory discount in update controller with document upload.
      Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
      40f1c0c0
  10. 26 Aug, 2026 3 commits