1. 07 Sep, 2026 11 commits
    • DevPilot's avatar
      fix(ui): buttons that led nowhere, across the financial screens · 72d9ec28
      DevPilot authored
      Swept every link and form action in the financial modules against the real
      route patterns. The asset problem was not a one-off.
      
      **Screens you could reach but not use.** Three edit screens existed, worked, and
      had nothing linking to them: vendor invoices, return-to-vendor, and the legacy
      pricing configs. Buttons added on both the list and the detail page, gated on
      the same status the controller already enforces — draft only, since neither
      document should change once verified or submitted.
      
      **Links pointing at routes that do not exist.**
      
        - Asset custody was entirely unusable: both the transfer button and the
          transfer form posted to /inventory/assets/{id}/transfer, but the route is
          /inventory/assets/custody/{id}/transfer. Nobody could ever hand over an
          asset.
        - Pricing configs was broken at both ends — the list linked to
          /pricing/{id}/edit and the form posted to /pricing/{id}, while the routes
          are under /pricing/configs. The edit screen was unreachable and unsavable.
        - Quote comparison posted to /select-winner, which was never built; the
          endpoint is /procurement/quotes/evaluate.
        - The quotes list "عرض" pointed at a detail page that does not exist. It now
          opens the response screen, which is what a quote is actually for.
        - "تظلم" on a fine was an <a>, so it issued a GET against a POST-only route.
          It is a form now, with a confirmation, since it records something.
      
      **Buttons for things that were never built.** BOM edit had no route and no
      controller method — a guaranteed 404 in two places. Removed rather than faked;
      creating and viewing a BOM both work, and editing one is a feature nobody has
      written yet. Settlements had exactly one action and it opened a detail page that
      does not exist either; replaced with اعتماد, the action that does exist and the
      only thing a draft settlement is waiting on.
      
      Not changed, having checked: vouchers and budgets flagged in the sweep were
      artifacts of my own parser — PHP unescapes '\\d+' in single quotes, and the
      budgets links emit a query string from inside the PHP tag. Both resolve fine.
      
      Verified: 1503 routes resolve, 255 controllers instantiate, 243 services load,
      zero orphaned edit screens remain, and every screen touched renders.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      72d9ec28
    • DevPilot's avatar
      fix(inventory): fixed assets had no name, and no way back in to edit them · 39ee2678
      DevPilot authored
      Two things I got wrong when I opened up direct asset registration.
      
      **There was nowhere to type what the asset IS.** The register carried a tag, a
      category and a serial — the name was borrowed from `inventory_items.name_ar`,
      which works only while every asset comes from stock. A building, a court or a
      transformer has no stock row, so the detail screen showed an empty cell and the
      form never asked. The register is meant to be read by someone doing a physical
      count: "أثاث ومعدات" tells them nothing, "مكيف سبليت ٣ حصان — قاعة الاجتماعات"
      tells them exactly what to look for. `asset_name` is now a required field,
      backfilled from the linked item where there is one and from the category
      otherwise, so no existing row loses the name it was already showing. It is also
      searchable.
      
      **The edit route had no button.** `/inventory/assets/{id}/edit` existed and
      worked; nothing linked to it, so once an asset was saved there was no way back
      in from the UI. Buttons on both the detail page and the register rows, hidden
      for disposed assets since those are history.
      
      Also corrected the labels that direct registration made wrong: the column header
      and search hint still said "الصنف", and the empty state still claimed assets are
      only created automatically when stock of type "أصل" is received.
      
      Verified on a production clone: name saves, displays on both screens, is
      findable by search, both edit buttons render, 1503 routes resolve.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      39ee2678
    • DevPilot's avatar
      feat(accounting): full bounced-cheque cycle, and the cheques the bank went quiet on · 9bb0c943
      DevPilot authored
      A bounce was one status change and one entry. It is actually four separate
      facts, and collapsing them is where the books go wrong.
      
        1. The debt comes back. A cheque was never money, it was a promise; when it
           fails the drawer owes again. This part already worked.
        2. The BANK charges the club. That charge leaves the club's account whoever
           ends up bearing it, so it posts Dr مصروفات بنكية / Cr البنك the moment it
           happens. Nothing recorded it before — which means the bank reconciliation
           could never have tied out on any month with a bounce in it.
        3. Somebody bears that charge. Billing the drawer is a separate claim posted
           separately, so waiving it later does not touch the original debt. Saying
           the club bears it while also billing the drawer is now refused: it is a
           contradiction that quietly inflates income.
        4. It has to end. Collected, replaced, re-presented and cleared, sent to
           legal, or written off. A bounce with no ending is a receivable nobody is
           chasing. Only the write-off posts here (Dr ديون معدومة / Cr شيكات مرتدة,
           for the cheque plus any fees billed on top, because both are being given
           up); the others are closed by events that already post on their own, and
           posting again would double them.
      
      The register now remembers what a bounce actually needs: the bank's reason code,
      how many times the cheque has been presented, how many times it came back, what
      the bank took, what was billed, who bore it, the protest number, and how it
      ended. Reasons that carry criminal liability in Egypt — insufficient funds, a
      closed account, a stop-payment on a valid cheque — are flagged, because the
      club's response differs even though the entry does not.
      
      The other half is delayed collection: a cheque past its due date that has NOT
      bounced. The bank has said nothing, so there is no accounting event and the
      screen posts nothing — but it is money the club is counting on and has not got,
      split by whether it never went to the bank or went and never came back.
      
      Also fixed: presentation_count only counted retries, so a cheque presented once
      and returned read as never presented. It now increments on every trip to the
      bank, in the transition itself rather than in the retry path.
      
      Verified on a production clone through the real EventBus: a 50,000 cheque
      deposited, bounced with a 75 bank charge and 100 billed to the drawer, produced
      four correct entries; re-presented and bounced again, totals accumulated to 150
      and 200; written off for 50,200; and every guard fired — bouncing something not
      under collection, an unknown reason code, a negative charge, club-bears-plus-bill,
      resolving twice, and re-presenting after resolution. Trial balance diff 0.00.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      9bb0c943
    • DevPilot's avatar
      feat(accounting): open any trial-balance account and read its history newest first · 1d1ec8ae
      DevPilot authored
      The trial balance showed a number with no way to ask where it came from. Every
      account row is now a link into that account's ledger, carrying the same date
      range and the same cost-centre/branch filters — so the movements you land on are
      exactly the ones that produced the figure you clicked.
      
      The ledger itself now reads newest first. The running balance is still
      accumulated in date order, because "the balance after this entry" only means
      anything with the earlier entries already in; the list is reversed after that
      accumulation, so the order flips and every balance still says what it says. The
      opening-balance row moved to the bottom accordingly — it is the oldest line on
      a newest-first list.
      
      Each entry number links to the full journal entry, so a line in one account
      opens the whole قيد with its other side. The reference type is shown under the
      reference number, which is usually what tells you which operation raised it.
      
      Verified against 779 movements on the cash account: order is newest first, the
      running balance on the top row equals the closing balance, and both screens
      render with 165 clickable rows and 779 entry links.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      1d1ec8ae
    • DevPilot's avatar
      docs: one accounting guide instead of nine · 76880e09
      DevPilot authored
      There were nine accounting documents, each covering part of the picture and
      pointing at the others for the rest — so no one file answered a question end to
      end. They are now one: docs/الدليل-المحاسبي-الكامل.md, 1,607 lines, self-contained,
      with no reference to any sibling guide.
      
      Merged in full, nothing dropped:
      
        - معالج توزيع المبالغ — the five scopes, stage skipping, member-category
          precedence, the running remainder, the mandatory remainder line, the
          resulting-entry panel, versioning on save, and all 17 rejection cases
        - أنواع البنود for both collection and disbursement, and why a stamp fee
          booked as revenue overstates profit and misstates the tax base
        - الضرائب — inclusive vs added vs exempt vs zero-rated, and why exempt blocks
          input-tax recovery while zero-rated does not
        - مسار الفلوس — the four chains, the FIFO ageing behind «فين الفلوس دلوقتي»,
          and the treasury reclassification with its three guarantees
        - الاستحقاقات — including the point the whole thing turns on: collection posts
          revenue directly, so an accrual left standing books the income twice
        - سد الفجوات, the three-way purchase match, the cheque lifecycle through
          bounce and re-presentation, vouchers, chart-of-accounts management
        - the fixed-asset cycle, reclassification, payroll, inventory, closing,
          reports, the 39-tool index, troubleshooting, and the open decisions
      
      Verified: 26 diagrams parse against the real mermaid parser, all 59 routes it
      cites resolve against the router, all 37 table-of-contents anchors resolve, and
      a 29-topic coverage check against the replaced files comes back clean. The nine
      originals remain in git history.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      76880e09
    • DevPilot's avatar
      feat(accounting): tools for the four things that still needed a human · 5ed78ec8
      DevPilot authored
      Every item I had left as "the accountant must decide" now has a screen to decide
      it on. Nothing accounting-related is left needing code.
      
      **فئات الأصول — /inventory/asset-categories.** The screen I referenced in the
      manual and in a seed comment, and had not written. Depreciation posts through
      these three accounts per category; a category missing either depreciation
      account is silently skipped, so the asset ages in the register and the balance
      sheet never moves. The screen leads with exactly that failure — categories that
      hold assets but cannot post — offers only postable accounts, and refuses a
      half-mapped category, because one depreciation account without the other cannot
      balance an entry.
      
      **إعادة تبويب الحسابات — /accounting/reclassification.** A balance in the wrong
      account cannot be edited: the ledger is the record, and a balance that disagrees
      with the entries behind it is worse than a wrong one. So it moves by a dated,
      balanced, reversible entry. It lists what is genuinely stuck — 8 accounts here,
      including 43.9m on «مشروعات تحت التنفيذ» and 4.8m on a header — derives the
      DIRECTION from the balance rather than asking (getting that backwards doubles a
      balance instead of moving it), and refuses a header or cross-type destination.
      
      To empty a header at all, JournalService needed to allow one posted there. That
      is the only way out in double-entry, so the flag exists, is set by this service
      alone, and the service independently refuses to let a header be the destination.
      
      **رسملة مشروع تحت التنفيذ.** A finished project is not a purchase — the money
      was spent over months and sits in CIP, which deliberately does not depreciate.
      Capitalising moves the accumulated cost to the asset account so depreciation can
      start. Booking it as a purchase would credit cash for money already spent. Now a
      third option on the asset form, next to purchase and opening balance.
      
      **أرشفة سنة مالية — /accounting/fiscal-years.** Overlapping years were flagged
      but unfixable from the UI. Archiving retires one without deleting it, and is
      refused outright when the year carries entries or is the current one.
      
      The manual is rewritten around this: every wizard now has numbered UI steps, and
      a new index lists all 39 accountant-facing tools with what each does. The
      decisions section now names the tool for each item instead of describing the
      problem.
      
      Verified on a production clone with all 409 foreign keys: reclassification moves
      4,799,436 off a header and leaves it at 0.00 with all guards firing,
      capitalisation posts Dr Asset / Cr CIP and posts nothing without a CIP account,
      categories screen offers 593 postable accounts and no headers, 1,499 routes
      resolve, 254 controllers instantiate, 242 services load, trial balance diff 0.00,
      and all 56 diagrams across docs/ parse against the real mermaid parser.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      5ed78ec8
    • DevPilot's avatar
      docs: step-by-step accounting manual for the club's accountant · c4a8f6cb
      DevPilot authored
      One file the accountant can work from: every step names the screen, the route,
      the button, and the journal entry it produces. Covers setup, the fixed-asset
      cycle, daily, monthly and year-end routines, payroll, inventory, tax, closing,
      and how to check the books are sound.
      
      The last section is the part that matters for the meeting — seven things the
      system cannot decide on its own, each with the screen to decide it on: the
      overlapping fiscal years, the empty asset register against 4.7m of machinery and
      41.7m of construction in progress, the opening balances sitting on header
      accounts, the placeholder bank account numbers, three payroll runs calculated
      but never paid, nine broken player/member links, and the fact that no period has
      ever been closed.
      
      Every figure is read from the live books, every one of the 23 routes it cites
      resolves against the router, and all 15 diagrams parse against the real mermaid
      parser — as do the other 42 across docs/.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      c4a8f6cb
    • DevPilot's avatar
      fix(accounting): make the accounting fundamentals actually work · 33c76389
      DevPilot authored
      Auditing the basics against the live books turned up four things that could
      never have worked, plus a fifth that worked too well.
      
      CLOSING A FISCAL YEAR WAS IMPOSSIBLE — three separate faults stacked:
      
        1. `Database::execute()` did not exist. Six call sites used it — closing a
           period, setting the current fiscal year, setting the default bank account,
           rebuilding an account balance, deleting a facility blackout — and every one
           was a fatal the moment it ran. Added to Database rather than patched at the
           call sites. This alone is why period_closings was empty.
        2. Closing a year requires every month closed first, then dates its entry the
           last day of the year — inside a month it just required to be closed. The
           two rules deadlocked. The closing entry, and only it, may now post there.
        3. The same entry has to zero accounts retired during the year, which the
           inactive-account guard refused. Closing an account is exactly when you must
           be able to clear it.
        4. And `period_closings.period` is varchar(7) while the year-end key is
           `YYYY-MM-YE` — ten characters — so the entry posted and the record failed,
           leaving the year half-closed.
      
        It now runs end to end: 19 lines, revenue and expenses zeroed to retained
        earnings, year marked closed, balance sheet still balanced.
      
      PAYROLL POSTED TWICE. Salaries are the largest single expense the club books and
      nothing guarded the reference, so a retried event or a second click doubled the
      expense, the withheld tax and the insurance liability. Every other posting in
      that service guards; this one did not.
      
      FIXED ASSETS COULD NOT BE CREATED AT ALL. The club carries 4,705,686 of
      machinery and 41,703,867 of construction in progress, and `asset_register` is
      empty — so nothing has ever depreciated. Not because depreciation is unwired, it
      posts correctly, but because there was no create route, no create method, and
      nothing anywhere that inserted an asset. `item_id` was NOT NULL against
      inventory_items, so registering a building meant inventing a stock row for it.
      
        Now: item_id and warehouse_id are nullable, the eight asset categories the
        chart already defines are seeded with their three GL accounts each, and there
        is a form. It distinguishes a purchase (posts Dr Asset / Cr Cash-or-Payable)
        from an opening-balance asset (posts nothing — the cost is already in the
        ledger, and posting it again would double the balance sheet).
      
        Asset disposal never worked either: `AccountCodes` was not imported, so the
        handler threw a fatal that the bootstrap's catch swallowed. Assets came off
        the register and stayed on the balance sheet.
      
        The register's own queries inner-joined inventory_items and warehouses, which
        would have hidden every real fixed asset behind an empty list and a "not
        found" page. LEFT joins, and the category name where there is no stock item.
      
        The depreciation month picker posted `month` while the controller read
        `period_month`, so whichever month the accountant chose was dropped and the
        current one ran instead.
      
      Overlapping fiscal years are now shown on the fiscal-years screen. The club
      carries calendar years alongside a July–June year, so entries in the overlap
      belong to both and closing one leaves the other open over the same
      transactions. Only the club can say which convention is real, so it is surfaced,
      not guessed.
      
      Verified on a clone of production with all 409 foreign keys: full asset cycle
      posts (purchase, depreciation by category, disposal with its 5,000 loss), all
      1,489 routes resolve, 252 controllers instantiate, 241 services load, trial
      balance diff 0.00, balance sheet balanced.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      33c76389
    • DevPilot's avatar
      feat(accounting): surface the player/member links that break receivables · 61dab765
      DevPilot authored
      The accrual failure had a data cause: nine sa_players rows hold a membership
      NUMBER in member_id (897000, 1015, 101) where a row id belongs. The code no
      longer breaks on them, but the underlying problem is still real and invisible —
      the obligation posts to the ledger while the debt never reaches the member's
      account, so nobody chases it.
      
      That is not something an accountant can decide alone; only whoever knows the
      player can say which member they are. So it goes on the gaps screen, which is
      where the consequence shows up, with the claim count and amount riding on each
      one and a link straight to the player's edit form. Where the number matches an
      actual membership_number the likely member is offered — two of the nine resolve
      that way.
      
      The comparison is numeric on purpose. membership_number is a varchar in
      utf8mb4_unicode_ci and the cast of an integer carries the connection collation,
      so string-comparing them raises an illegal-mix-of-collations error. It did, and
      the catch swallowed it and returned an empty list — a broken query and a clean
      bill of health looked identical on screen. That catch now logs.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      61dab765
    • DevPilot's avatar
      fix(accounting): stop a failed claim from re-posting the accrual every run · 87c22aca
      DevPilot authored
      Booking the accruals against the live books exposed a real defect, and it cost
      one duplicate entry before it was caught.
      
      Nine rows in sa_players carry a membership NUMBER in member_id (101, 1015,
      897000) instead of a member row id. The accrual run reached the first of them,
      the foreign key on accounts_receivable.member_id refused the insert, and the
      exception escaped the loop that records claims in posting_accruals. That loop
      runs AFTER the journal entry is posted, so the entry stood at the full batch
      total with only the claims written before the failure. The next run read the
      remaining documents as never accrued and posted a SECOND entry for them — and
      would have posted one more every night, because the failure is deterministic.
      
      AccrualService::batch now treats a claim it cannot write as something to report,
      not something to throw: the entry is already posted, so abandoning the rest of
      the batch is the one response guaranteed to corrupt the ledger. A member id that
      does not resolve costs the receivable, never the claim — posting_accruals is the
      system of record and already tracks obligations for non-members. Member
      existence is resolved once per batch, not once per claim.
      
      Phase_112_003 repairs what the two runs left: the duplicate is reversed rather
      than deleted so the correction stays visible, its claims move to the entry that
      actually posted their money, and the 110 claims plus 32 receivables that were
      never written are backfilled against it. It verifies the entry and its claims
      agree before finishing, and does nothing at all unless production matches the
      exact broken shape.
      
      Why the clone missed it: the verification harness built tables with
      CREATE TABLE ... LIKE, which silently drops foreign keys — so the constraint
      that breaks production did not exist in the clone, and the run came back green.
      The harness now copies DDL via SHOW CREATE TABLE and asserts the foreign key
      count matches the source (409/409).
      
      Verified against a clone of the actual broken production state: net movement for
      sa_subscription_accrual is 104,769.00 not 184,938.00, all 135 claims equal their
      entry to the cent, 1,316 claims total 835,167.93, trial balance diff 0.00, and
      both the repair and the fixed runner post nothing on a second pass.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      87c22aca
    • DevPilot's avatar
      fix(accounting): make the advances reclassification actually runnable, and safe afterwards · 9f48d4aa
      DevPilot authored
      Phase_107_002 has never run. Its first INSERT wrote to chart_of_accounts.notes,
      a column that does not exist, so the migration threw and MigrationRunner swallowed
      it — leaving 3,032,258 EGP of member advances sitting in revenue this whole time.
      Writing to description_ar instead lets it complete.
      
      That exposed a second problem the original migration would have caused. It
      deactivates 410503/504/505, but payment:down_payment carries an active posting
      rule crediting 410503. Deactivating the account without moving the rule means
      every future down payment records a receipt and posts no journal entry, because
      JournalService refuses an inactive account — quietly worse than the overstatement
      being fixed. The rule now moves to the matching contract-liability account, which
      is where a down payment belonged anyway: it is an advance from the moment it is
      collected, and only becomes revenue as the service is delivered. Otherwise the
      account being emptied would just refill.
      
      Also seeds the four bank accounts the chart already names as the club's current
      accounts. bank_accounts was empty, which is why the cash chain's deposit step
      had nothing to resolve and reported red. Account numbers are deliberate
      placeholders reading "رقم الحساب غير محدد" — a seed has no business inventing an
      IBAN that could reach a printed deposit slip — and the seed never overwrites a
      row someone has already filled in.
      
      And drops POST /waivers/{id}/pay: WaiverController::pay() does not exist, so the
      route was a guaranteed 500. Leftover from a direct-payment design that
      send-to-cashier replaced; nothing posts to it.
      
      Verified on a full 369-table production clone: balances move (410503/504/505 -> 0,
      deactivated; 23081119/20/21 created), entry balances Dr=Cr=3,032,258.00, re-runs
      post nothing, down payments still post clean, no active rule anywhere resolves to
      a dead or header account, all four chains green, trial balance diff 0.00.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      9f48d4aa
  2. 06 Sep, 2026 7 commits
    • DevPilot's avatar
      fix(accounting): GapController fataled on load — private override of a protected parent method · f4ef51e9
      DevPilot authored
      `GapController::currentEmployee()` was declared private where
      App\Core\Controller declares it protected. PHP rejects that at class-load
      time, not at call time, so every request to /accounting/gaps returned a
      500 — the screen was unreachable since it shipped in caca1389.
      
      The override was redundant anyway: the parent's currentEmployee() does
      exactly the same thing. Removed it, and the now-unused App import with it.
      
      Missed because the earlier verification exercised the services and
      rendered the views directly, but never instantiated the controller —
      which is precisely where this class of error surfaces. Added that check
      (reflect + instantiate every new controller, resolve every route handler
      class and method) and ran it across all 1,486 routes in the system.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      f4ef51e9
    • DevPilot's avatar
      feat(accounting): wire the cheque-clearing fee into installment intake · 4729ee75
      DevPilot authored
      Finished what last night shipped as configured-but-manual. Mapped the
      Installments module properly this time: cheques for a membership
      installment plan live in `installment_cheques`, a table with no status
      lifecycle and no connection to the accounting cheque system at all
      (negotiable_instruments sits at zero rows) — two insert sites
      (ChequeController::storeBatch / store) plus a historical-backfill path
      in RetroactiveMembershipService.
      
      The fee cannot be folded into the plan's own total: `total_with_interest`
      is recalculated from principal and interest alone
      (InstallmentController::recalculate) and both intake paths hard-validate
      cheques against it — anything added would be silently erased or reported
      as a shortfall. So it bills as its own claim, the same shape as every
      other accrual in this system: Dr member receivable / Cr the clearing-fee
      revenue account already sitting in the chart from last night
      (410540), collected alongside the member's next ordinary payment.
      
      The billing unit is the CHEQUE, not the batch, via a
      `clearing_fee_charged` flag added to installment_cheques — which is also
      the idempotency key. A plan finished across three cashier visits bills
      three times for exactly the cheques each visit added; a retried request
      bills nothing twice. The 102 live cheques already on file are marked
      charged in the same migration — they were handed over before this
      feature existed, and backfilling a charge onto them would invent
      something no member agreed to.
      
      Verified against a scratch copy of production: a fresh 3-cheque batch
      bills exactly 75.00 across 3 receivables, a replay bills nothing, a
      second visit adding 2 more cheques to the same plan bills exactly
      50.00, a branch with no fee configured bills nothing and leaves those
      cheques correctly unbilled (not stuck), and the historical 102 stay
      untouched. Trial balance nets to 0.00.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      4729ee75
    • DevPilot's avatar
      feat(accounting): implement accountant's spec sheet — split fees, branch... · d74b0ee5
      DevPilot authored
      feat(accounting): implement accountant's spec sheet — split fees, branch policy, government withholding
      
      Sheraton's accountant handed over a 274-row spec for tomorrow's meeting.
      Most of it was already correct (payroll, rentals, academies); this closes
      the concrete gaps and builds the tools for the parts that are policy
      decisions, not code.
      
      ADDITION-FEE REVENUE SPLIT (spec rows 72-88)
      The spec asks for the fee split "according to the ratio of the revenue"
      without ever giving a ratio — but the ratio already existed:
      ChildFeeCalculator/SpouseFeeCalculator compute a membership-value
      component, a form-fee component and an annual-subscription component on
      every addition. It just wasn't persisted past the display breakdown, so
      by payment time nothing to split by. Now stored (fee_component_*
      columns) and posted as three separate credits instead of one lump
      "dependant addition" line, with a safety check that skips the split
      (falls through unchanged) if the collected amount doesn't match the
      stored components exactly.
      
      MEMBERSHIP-FORM STAMP FEE (row 12)
      BillingService's own price breakdown already reads "form fee: 500 —
      stamp: 5" — the 505 EGP was never wrong, accounting just credited all of
      it to form revenue. Now splits the 5 EGP to the government stamp
      liability that already existed in the chart, unused
      (23082103 طابع الشهداء). Only fires where a branch has it configured —
      every other branch posts exactly as before.
      
      BRANCH FEE SETTINGS — a real tool, not a guess
      The spec argues with itself about several fees: "add this at Sheraton"
      against "cancel it everywhere" in consecutive lines, "each branch has
      its own card-commission rate" with a rate given for exactly one branch.
      /accounting/branch-fees is where that gets decided per branch or once
      for all of them, with a one-click "generalize to every branch" action —
      covers the stamp fee, cheque clearing fee, bounced-cheque fee, and a
      tiered card commission (threshold + rate + whole-vs-excess basis, with
      a live preview). Seeded active for Sheraton with the exact figures the
      spec gives; every other branch starts with nothing configured.
      
      CARD COMMISSION — posted as an expense, not left in the bank figure
      Implemented as a small adjusting entry after the main collection posts
      (Dr commission expense / Cr the same card account, reclassifying part of
      what was already debited) rather than rewritten into ~30 payment types'
      posting logic. Verified: 50,000 EGP visa payment above a 10,000
      threshold at 2% posts exactly 800.00, non-visa payments are untouched,
      replay does not double-post.
      
      GOVERNMENT WITHHOLDING ON EXPENSES (rows 223-264)
      Ordinary stamp duty, additional stamp duty, commercial-profits
      withholding — deducted at source from every vendor invoice, reusing
      liability accounts the chart already had unused (23081202-23081204).
      Rates are not guessed: Egyptian withholding schedules are progressive
      law, not a percentage this migration could safely invent, so it ships
      at 0% and inactive until finance sets real rates on the same settings
      screen. Accounts payable now records the NET amount owed after
      withholding, which is what the club actually pays.
      
      CASH DISBURSEMENT BANNED FOR EXPENSES (rows 190-191)
      "As a government institution, cash disbursement is forbidden — every
      expense by cheque." Enforced once, at VendorPaymentService::createPayment,
      with a system_config kill switch for the day this genuinely needs an
      exception (logged as a deliberate override, not a silent bypass).
      
      NOTES PAYABLE — cheques now a liability until the bank clears them
      onVendorPaymentCompleted was crediting Bank directly the moment a
      cheque was issued, before the bank had paid anything. Now credits
      أوراق دفع (notes payable) instead, and /accounting/notes-payable is the
      monthly reconciliation the spec asks for at rows 267-269: pick the
      cheques the bank statement confirms cleared, post one closing entry.
      Zero live vendor payments existed, so this changes no historical data.
      
      Verified end-to-end against a scratch copy of production: every
      scenario checked exactly against hand-computed expected values (not
      just "it posted something") — split amounts, commission arithmetic,
      withholding math, net payable — trial balance nets to 0.00, zero
      unbalanced entries, zero postings to header accounts, every path
      idempotent under replay.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      d74b0ee5
    • DevPilot's avatar
      docs(accounting): step-by-step tutorial for chains, accruals and gap tools · f239fadf
      DevPilot authored
      One walkthrough covering everything the three features do, aimed at the
      accountant who has to run them and the developer who has to maintain
      them. The two existing files stay as references; this is the guided path
      through them.
      
      21 Mermaid diagrams, all verified to parse:
      
        - what was broken, in three pictures
        - split vs move — why the allocation wizard could not express a chain
        - the settlement bug as a sequence, including why a balanced entry hid it
        - the accrue / collect / release cycle and where the double-count
          would have been
        - why a scanner beats event listeners, with the coach payroll listener
          as the worked example
        - the three-way match for procurement
        - first-run order, daily routine, monthly close
        - a troubleshooting decision tree and a table of the actual error
          messages
        - an ER diagram of the five new tables
      
      Also states plainly the four things that still need a finance decision,
      including the two that are deliberately left matching a mapping I
      believe is wrong, because accrual and collection must agree.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      f239fadf
    • DevPilot's avatar
      feat(accounting): gap tools so finance can close what the scanner won't guess · caca1389
      DevPilot authored
      Six streams were left unbooked because the source records no amount: a
      pool zone with no ticket price and no attendance, a player card with no
      fee column, a booking the code writes as zero. The scanner refuses to
      invent a figure, and should — a wrong number in the books is harder to
      find than a missing one, and it looks settled.
      
      But "the system cannot tell you" is not "nobody knows". Finance knows
      what a lane costs. This adds the screen where they say so.
      
      /accounting/gaps — one card per gap:
      
        see the blocker      what exactly is missing, and why it blocks
        propose a value      a flat rate per unit, or the recorded amount
        see the consequence  "احسبلي هينزل كام" computes without writing
        commit               reason and effective date are mandatory
      
      The reason is required because it is what turns an invented number into
      a management estimate — a legitimate basis to account on, provided it is
      stated, approved and attributable. The effective date is required
      because backdating a rate onto years of history rewrites results for
      periods already reported.
      
      Nothing posts from this screen. It records a decision; the accrual
      scanner acts on it next pass. Four new runners stay silent until a
      decision exists, so the rules ship configured but inert.
      
      Academy settlements get a different tool, because it is a different
      problem: the settlement engine reads `academy_contracts` (empty) while
      the 13 real contracts live in `sa_academy_contracts`. The tables are not
      copies — the settlement table carries settlement_day, grace_period_days
      and penalty_rate_pct, which the engine calculates with and the other
      table lacks. So the tool COPIES rather than moves, with the missing
      terms supplied by the accountant rather than defaulted silently, and the
      source contracts keep working untouched.
      
      Verified: gaps stay silent with no decision; a rate of 25/booking books
      8,800 over 352 bookings and re-running books nothing; switching off
      stops future accrual without reversing what was booked; all four
      validation rules reject bad input; 13 contracts import and re-import is
      a no-op. Trial balance still nets to 0.00.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      caca1389
    • DevPilot's avatar
      feat(accounting): posting chains + accrual reconciler · 1bc26cc1
      DevPilot authored
      Two gaps closed, both of which let real money go unrecorded.
      
      CHAINS — money moving between accounts across several entries
      
      The allocation wizard splits one amount inside one entry. It had no
      answer for the same money moving through a sequence of entries as
      separate actions happen: cash into a safe, settled to main, banked.
      Each hop must clear the account the previous one filled, and nothing
      enforced that.
      
      Concretely broken: every cash collection debited one global account
      regardless of which safe took it, then the settlement credited an
      unmapped `treasury:sub_cash` pointer that fell back to الصندوق بالدولار
      — an account no collection had ever touched. The sub-safes were also
      pointed at the USD/EUR cash boxes, so 972,791 EGP of pound takings sat
      in foreign-currency accounts.
      
      A chain step now declares where it leaves money and which earlier step
      it clears; the counter side is derived by re-resolving that step's rule
      against the same document, so a chain cannot be authored that fails to
      net to zero. Each safe owns a GL account, resolved through one service
      both the engine and the chain call. Guards refuse a hop whose two sides
      resolve to the same account, or whose type puts both on the same side.
      
      The historic misposting is corrected by a reviewable journal entry —
      not by rewriting posted history — and cannot be posted twice.
      
      ACCRUALS — obligations the ledger was never told about
      
      24 revenue streams were marked `needs_code`. 18 are now booked, finding
      1,316 claims worth 835,167 EGP that were nowhere in the accounts.
      
      Built as a scanner over the source tables rather than event dispatches
      in twelve modules: an event can be missed or misnamed — the coach
      payroll listener was bound to a name nothing dispatched — while a
      scanner is self-healing, retroactive and idempotent.
      
      Critically, collection here posts revenue directly, so accruing without
      releasing would double-count on every future payment. Each accrual is
      released when its document is paid, by mirroring the same rule.
      
      Outflows that had no entry at all are wired too: staff loans (an asset,
      not an expense), end of service, coach fees, goods receipt (against a
      new clearing account, so the invoice does not book inventory twice),
      depreciation per asset category, stock variances, asset disposal, and
      fine waivers.
      
      6 streams are deliberately left unbooked — no amount or no counterparty
      recorded, so any figure would be a guess. They are listed on screen
      with what each one needs.
      
      Verified against a scratch copy of production: trial balance nets to
      0.00, no unbalanced entries, no postings to header accounts, every pass
      idempotent.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      1bc26cc1
    • Mahmoud Aglan's avatar
      push all · 34bbb7fa
      Mahmoud Aglan authored
      34bbb7fa
  3. 05 Sep, 2026 21 commits
    • Mahmoud Aglan's avatar
      docs(accounting): full Egyptian-Arabic guide with diagrams · e4076074
      Mahmoud Aglan authored
      One document covering the whole revenue posting system end to end: how a
      payment becomes a journal entry, the 97 streams across 14 categories, the
      six stages and which apply to which category, direction, the five scopes,
      the wizard step by step, the allocation waterfall, line types, VAT,
      deferred revenue, rule resolution by specificity, versioning, every
      validation the system enforces, and worked scenarios.
      
      Includes the three accounting defects found in the live data — revenue
      recognised on collection rather than accrual (36 of 42 rules), nothing
      deferred at all, and 3,032,258 EGP of customer advances sitting in revenue
      accounts — with the correct treatment for each.
      
      18 mermaid diagrams, all verified to render with mermaid-cli 11.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      e4076074
    • Mahmoud Aglan's avatar
      feat(accounting): one split, applied across any scope of revenue · c95a8dd4
      Mahmoud Aglan authored
      The wizard could only ever rewire one stream at a time, so making a policy
      ("this is how we split membership money") meant repeating the same work per
      stream — 97 streams across 14 categories. The split is now written once and
      pushed onto whatever it should govern.
      
      Scopes: this stream, a whole category, a hand-picked set, everything not yet
      mapped, or all of it. The screen shows the resolved target list and the count
      before anything is written, and each target still gets its own versioned rule
      — nothing is shared and nothing is retroactive.
      
      AllocationPlanService is the guard rail. Every save runs resolveTargets ->
      validate -> apply, apply() re-validates on its own (there is no FK on
      account_id, so an unvalidated write would point rules at accounts that do not
      exist), and the whole batch is one transaction — a single bad target rolls
      back all of it rather than leaving the chart half-rewired.
      
      Refused before a row is written, all verified against a clone of production:
      header accounts, missing/archived accounts, no remainder line, two remainder
      lines, percentages over 100, zero and negative values, a line type that does
      not match its account's type, an expense line on a collection, mixing inflow
      and outflow streams in one scope, an effective date inside a closed period,
      a deferred line with no recognition account, and a stage the category cannot
      produce (skipped with a reason rather than written).
      
      Also:
      - Direction now drives which line types exist at all, and is editable per
        stream from the wizard. Phase_106_001 corrects 11 payroll/procurement
        streams seeded as 'inflow' — a salary expense and a supplier payment are
        debits, and the screen was offering revenue accounts for them.
      - parseLines() accepted 5 of the 12 line types the schema allows, which made
        outflow streams impossible to configure at all. It now accepts all of them.
      - Deferred revenue is configurable from the wizard (months + recognition
        account, filtered to revenue accounts).
      - The entry preview inverts for outflow: split lines debit, cash credits.
      
      Arithmetic verified against RevenueAllocator at 0.03, 1.00, 150,000.00 and
      999,999.99 EGP, and with 14% inclusive VAT — balanced in every case.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      c95a8dd4
    • Mahmoud Aglan's avatar
      feat(accounting): allocation wizard with a live running remainder · 00dfe062
      Mahmoud Aglan authored
      Splitting a payment across GL accounts previously meant the advanced rule
      editor — a table of lines with no feedback until save. The finance team's
      actual question is "20% here, 30% there, where does the rest go?", which
      needs the remainder visible while you allocate.
      
      - New wizard screen: base amount (pre-filled from the stream's actual
        collection average), member-category scope, progressive allocation with
        the unallocated balance as a running figure plus a proportional bar,
        a mandatory remainder destination, and a live journal-entry preview
        that balances before you can save.
      - The wizard's arithmetic mirrors RevenueAllocator exactly — tax off the
        top, fixed lines from the pool, percentages of net-after-fixed — so the
        preview is what actually posts. Verified against the live allocator:
        150,000 → 20%/30%/rest = 30,000 / 45,000 / 75,000, and with 14%
        inclusive VAT = 26,315.79 / 39,473.69 / 65,789.47 on net 131,578.95.
      - Wire member_category through resolveRule() with specificity scoring
        (category > branch > payment method), so a working-member split wins
        over the general rule with no extra configuration.
      - update() resolves and supersedes only the same-scope rule, and no
        longer reads $memberCategory before assigning it.
      - Wizard is now the default action from the mapping list and the
        connection centre; the advanced editor moves behind a  link.
      - Arabic tutorial in docs/معالج-توزيع-المبالغ.md.
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      00dfe062
    • Mahmoud Aglan's avatar
      docs: rewrite the execution guide — every financial procedure, from the screens · eede3224
      Mahmoud Aglan authored
      Ten scenarios in click order, covering what the review will actually ask:
      splitting a fee across funds, connecting an unconnected charge (and declaring a
      new source live), the rent cycle end to end, the cheque lifecycle including
      bounce and re-presentation, expenses, restructuring the chart, VAT, deferred
      revenue, breaking out an aggregate, and proving the numbers reconcile.
      
      The closing line is the point: when asked about something not listed, the answer
      is no longer "that needs development" — it is "we define it as a billing source
      or a voucher type, let's do it now".
      Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
      eede3224
    • 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
  4. 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