1. 11 Sep, 2026 20 commits
    • DevPilot's avatar
      feat(accounting): unified cheque register with full lifecycle history · 927edd62
      DevPilot authored
      The cheque data existed but the screen was a per-direction list and the
      status was overwritten in place — there was no record of who did what,
      when, or what the previous state was. Adds:
      
      - صادر ووارد شيكات بنكية: one screen for both directions, with the
        full filter set (date range on either the cheque date or the movement
        date, direction, number, party, bank, branch, status, amount range),
        a reset, and per-direction summary cards whose totals are clickable
        and drive the filters.
      - instrument_movements: every action is appended as an immutable row
        (action, from/to status, date, user, bank, reference, notes). Nothing
        is ever deleted, so each cheque carries a complete audit trail.
        Existing cheques get an opening "register" movement on migrate so the
        history starts from a known point.
      - The complete status sets for both directions — registered, ready,
        delivered, deposited, under collection, pending, collected, paid,
        bounced, endorsed, returned, replaced, cancelled, closed — with a
        direction-specific transition map that refuses illogical moves such
        as collecting a cancelled cheque.
      - Cheques with movements cannot be deleted; corrections are new
        actions, not edits.
      
      The older screens now funnel through the same service, and the bounce
      and resolve paths log movements too, so the trail stays complete no
      matter which screen the action came from.
      927edd62
    • DevPilot's avatar
      docs: add tax rules, scoped voucher lookup and gap drill-down to the tutorial · ec7ca512
      DevPilot authored
      Two new chapters bring the guide to 108 steps over 13 chapters:
      
      - قواعد الضرائب: entering and amending income-tax brackets from the new
        screen — including the simulator that proves the calculation before
        payroll runs, and the validation refusing a set with a gap between
        brackets — plus the revenue tax profiles screen.
      - The voucher account lookup now scoped per side, shown for both a
        صرف and a قبض voucher, and the "عرض التفاصيل" drill-down on the
        accounting gaps screen listing the real documents behind each count.
      ec7ca512
    • DevPilot's avatar
      fix(accounting): gap detail queries referenced columns that do not exist · fa94e5b8
      DevPilot authored
      The drill-down queries were written against assumed column names and
      every one of them failed, so "عرض التفاصيل" always answered "لا توجد
      تفاصيل متاحة". Corrected against the real schema: pool zone bookings
      have label/zone_row/zone_col and no zone table, player cards use
      valid_from/issued_at and card_number, pool bookings carry booker_name
      rather than a member join, and private match bookings store total_cost
      rather than total_amount.
      fa94e5b8
    • DevPilot's avatar
      feat(hr,accounting): tax bracket admin screen, scoped voucher account lookup, gap drill-down · b16d18aa
      DevPilot authored
      Three things that were asked for and were genuinely missing:
      
      1. ضريبة كسب العمل had no screen at all — the brackets lived only in
         hr_tax_brackets and could only be changed with SQL. Adds a proper
         admin screen: brackets are versioned as a set per effective_date,
         old sets are kept (never deleted) so past payroll stays explainable,
         and activating a set deactivates the others. The form validates that
         brackets are contiguous, that only the last one is open-ended, and
         auto-fills the next bracket's start. A built-in simulator shows the
         tax on any annual income so the accountant can verify the set before
         running payroll.
      
         Also hardens the live calculator: IncomeTaxService summed every row
         flagged active regardless of effective_date, so two overlapping
         active sets produced a silently wrong tax. It now uses the newest
         active set only.
      
      2. The voucher screen searched the entire chart of accounts for both
         sides, so you could pick a fixed-asset account as the cash side or a
         cash account as the expense side — the latter trips the "same as the
         cash account" guard and the voucher just refuses to save. The lookup
         is now scoped per side: cash/bank accounts for the counter side, and
         expense (صرف) or revenue (قبض) accounts for the line side depending
         on the voucher's direction.
      
      3. The accounting gaps screen showed only a count per gap. Each gap now
         has a "عرض التفاصيل" button that lists the actual documents behind
         that number — id, date, party, quantity, recorded amount and status
         — so the accountant can check the cases before choosing a rate.
      b16d18aa
    • DevPilot's avatar
      docs: replace the screen catalogue with a real step-by-step tutorial · 33dc1451
      DevPilot authored
      The previous PDF showed screens from the outside — long full-page dumps
      of lists, with no instruction. This one teaches the system by driving
      it: every step opens the screen, fills the form with real data, submits,
      shows the result, and then shows the journal entry that operation
      produced. Screenshots are top-of-screen crops only.
      
      93 steps across 11 chapters: manual journal entry; the full fixed-asset
      lifecycle (purchase, maintenance, monthly depreciation, disposal with
      gain/loss) with the auto-generated entry after each; the government
      procurement cycle from PR through tender, committees, technical
      evaluation, the 3-offer gate, re-tender, award by item and the
      resulting purchase orders; the auction cycle from lots and committees
      through bidding, award, collection and the disposal entry; payment and
      receipt vouchers with cheque tracking; asset custody; GL sync preview;
      and the five financial statements.
      
      The superseded catalogue PDF is removed — it remains in git history.
      33dc1451
    • DevPilot's avatar
      fix(inventory): custody transfer never recorded the new custodian · b77649e8
      DevPilot authored
      The transfer form posts new_employee_id / new_location, but
      storeTransfer() read to_employee_id / to_location. Both came back
      empty, so every transfer wrote NULL over custodian_employee_id and
      blanked the location — the asset ended up with no custodian at all,
      and asset_custody_history recorded the move with an empty recipient.
      
      Reads the names the form actually sends, keeping the old ones as a
      fallback.
      b77649e8
    • DevPilot's avatar
      feat(procurement): allow re-tendering after the 3-offer gate rejects a tender · 8353e628
      DevPilot authored
      When fewer than three offers pass technical evaluation the tender is
      flagged "تحتاج إعادة طرح" — but that status also hid the invite-vendors
      form, so there was no way to actually invite more vendors and the
      process dead-ended with no route forward.
      
      Adds a "إعادة طرح المناقصة" action next to the rejection reason that
      returns the tender to published and clears the reason, so additional
      vendors can be invited and their offers recorded.
      8353e628
    • DevPilot's avatar
      fix: optional date inputs posted as '' crashed saves across 17 controllers · 831a3e96
      DevPilot authored
      MySQL strict mode rejects '' for a DATE/DATETIME column, so any form
      with an untouched optional date aborted the whole insert with
      "Incorrect date value: ''". This was not one bug — quote responses,
      vouchers, bounced cheques, bank loans, documentary credits, letters of
      guarantee, settlements, tenders, auctions, committees, payments,
      overtime, permission requests and more all passed the raw post value
      straight through.
      
      Adds Request::postDate(), which returns NULL for an empty date field,
      and routes all 33 optional-date call sites through it. QuoteService
      also guards expiry_date directly since it is called from more than one
      path.
      831a3e96
    • DevPilot's avatar
      feat(accounting,procurement,auctions,inventory): reported fixes and committee dates/attachments · d75ca900
      DevPilot authored
      Four issues raised from the demo instance:
      
      1. GL sync preview kept listing rows that could never sync. The preview
         selected every non-voided payment while syncPayments() skips
         amount <= 0 in PHP, so zero-amount payments stayed "pending"
         forever — most visible right after a sync. Both queries now filter
         amount > 0, matching the dashboard count.
      
      2. The five financial statements (income statement, balance sheet,
         consolidated balance sheet, cash flow, changes in equity) moved out
         of the flat Accounting menu into their own "القوائم المالية" group
         inside the المالية section.
      
      3. Technical and financial committees — in both tenders and auctions —
         now carry تاريخ التشكيل / تاريخ الانعقاد / تاريخ البت on the
         formation screen, plus multi-file attachments per committee
         (new committee_attachments table + CommitteeAttachmentService,
         modelled on the existing Support attachment service). Dates and
         attachment links render on the tender and auction pages, with a
         permission-checked download route for each scope.
      
      4. Asset custody screens inner-joined inventory_items, so every asset
         without a stock item — buildings, courts, machines registered
         directly, which is most of the register — was invisible there. Now
         LEFT JOIN with the same name fallback the asset card already uses.
      d75ca900
    • DevPilot's avatar
      fix(procurement): add missing quote response form view · 55d6ac8e
      DevPilot authored
      QuoteController@recordResponse rendered
      Procurement.Views.quotes.response_form, which was never created — so
      /procurement/quotes/{id}/response returned a 500 and vendor prices
      could not be recorded at all. Without it the whole tender cycle is
      blocked: no quote totals, so no technical gate, no financial
      comparison, and no award.
      
      Fields match what storeResponse() reads: quote_item_ids[],
      quantities[], unit_prices[], delivery_days[], plus delivery/payment
      terms and expiry date.
      55d6ac8e
    • DevPilot's avatar
      fix(accounting): voucher save crashed when payment method is not a cheque · b4200e99
      DevPilot authored
      The cheque fields stay in the voucher form for every payment method, so
      check_date posts as '' when paying cash or by transfer. That went
      straight into a DATE column and MySQL rejected the row with
      "Incorrect date value: ''", so no non-cheque voucher could be saved at
      all. Send NULL instead; VoucherService already accepted null.
      
      Found by driving the voucher screen end-to-end while building the
      step-by-step accounting tutorial.
      b4200e99
    • DevPilot's avatar
      docs: add the comprehensive money/accounting guide with live screenshots · e0581a11
      DevPilot authored
      167-page Arabic guide covering every money-related screen in the
      system (139 screens across 20 modules): journal entries, chart of
      accounts, all financial statements, checks/bounced checks, bank
      reconciliation, treasury, procurement + tenders, purchase orders,
      fixed assets, auctions, rentals, sales/POS, payroll/tax/insurance,
      membership and sports-activity billing, pricing/discounts, and the
      reports catalog. Every screenshot is real, captured logged in as
      super admin against the live instance — not mockups.
      
      Building it surfaced and fixed three live bugs along the way (see
      previous commit): PO balance/purchase-volume reports referencing
      non-existent poi.quantity/qty_received columns, a missing
      price-deviation view, and HR insurance form2's wrong join.
      e0581a11
    • DevPilot's avatar
      fix(procurement,hr): fix crashing reports — wrong PO-item columns, missing view, wrong join · f5c65f90
      DevPilot authored
      PO balance and purchase-volume reports referenced poi.quantity /
      poi.qty_received, which don't exist — the real columns are
      quantity_ordered / quantity_received. Price-deviation report pointed
      at a view that was never created. HR insurance form2 joined
      hr_salary_adjustments through employees.id, but that table links
      straight to hr_employee_profiles via employee_profile_id (confirmed
      against the model and every other query on that table) — the extra
      employees join used a column, employee_id, that was never there.
      Found live via BOOT FAILURE pages while capturing screenshots for the
      accountant's tutorial PDF.
      f5c65f90
    • DevPilot's avatar
      docs: add Arabic accountant tutorial PDF for the new features · ea19e57b
      DevPilot authored
      Covers GL sync preview, Cash Flow Statement, Statement of Changes in
      Equity, asset maintenance/sale, the government procurement tender
      cycle, and the auctions module — step-by-step with exact menu paths
      and button labels for the club's accountant.
      ea19e57b
    • DevPilot's avatar
      fix(inventory): asset custody screens crashed on wrong employee name column · cf7b724f
      DevPilot authored
      Controller selects employees as full_name_ar; both custody_index and
      custody_transfer views were reading name_ar, which doesn't exist on
      that result set. Live crash: Undefined array key "name_ar" at
      custody_index.php:14. Pre-existing bug, unrelated to recent changes —
      just hadn't been hit until now. Confirmed via repo-wide grep this was
      the only place with the mismatch.
      cf7b724f
    • DevPilot's avatar
      feat(auctions): add asset-sale and facility-rental auction module · a22986b4
      DevPilot authored
      New Auctions module covering the full committee-driven auction cycle:
      one auction carries one booklet and many lots, each lot bundles one or
      more assets/facilities and snapshots their cost/depreciation/book value
      at lot-creation time, technical committee marks lots fit or not fit for
      disposal, every bid (including losing ones) stays on the lot, and the
      financial committee's award is per-lot so different lots can go to
      different winners. An asset only flips to disposed once its settlement
      is fully paid — not at award time — and disposal reuses the existing
      GL gain/loss posting path. A rental-type award creates a lease contract
      and marks the facility under lease instead of sold.
      a22986b4
    • DevPilot's avatar
      feat(procurement): government tender cycle — committees, technical/financial... · 1d760560
      DevPilot authored
      feat(procurement): government tender cycle — committees, technical/financial gate, multi-vendor award
      
      Adds the documented, auditable purchase cycle on top of the existing
      PR/quote infrastructure: a tender booklet (كراسة شروط) per PR, vendor
      invitations that open a quote record per vendor (rejected quotes stay
      on file with their reason, never deleted), technical committee
      evaluation with the 3-accepted-offer minimum before financial review
      (otherwise the tender is flagged for re-tender with a reason), a
      financial committee comparison scoped to technically-accepted quotes
      only, and item-level award that can split one PR across several
      vendors and therefore several purchase orders — replacing the old
      strict PR:PO 1:1 assumption. Every PO produced carries its tender and
      evaluation id back to the original PR.
      1d760560
    • DevPilot's avatar
      feat(inventory): add Asset Maintenance tracking with GL posting · afa29392
      DevPilot authored
      Closes the remaining gap in the fixed-asset lifecycle (acquisition,
      depreciation, disposal/sale already existed) by adding a maintenance
      log per asset with cost, vendor, and next-due tracking. Each category
      now carries its own maintenance expense account, mirroring how
      depreciation and disposal are already mapped, and maintenance cost
      posts Dr expense / Cr cash-bank-payable through the same operational
      posting pipeline.
      afa29392
    • DevPilot's avatar
      feat(accounting): add Cash Flow Statement and Statement of Changes in Equity · 89695e0e
      DevPilot authored
      Rounds out the القوائم المالية group alongside the existing income
      statement, balance sheet, and consolidated balance sheet. Cash flow uses
      the indirect method with a reconciling line so it always foots to the
      real cash-account movement; the equity statement ties exactly to the
      balance sheet's total_equity at both ends of the period.
      89695e0e
    • DevPilot's avatar
      feat(accounting): add GL sync preview before posting unsynced records · 5c5a5d2c
      DevPilot authored
      Lets a super admin review every unposted payment/fine/installment/sale/
      payroll/refund/rental-deposit row before committing the sync, instead of
      posting blind from the dashboard warning banner.
      5c5a5d2c
  2. 10 Sep, 2026 2 commits
  3. 08 Sep, 2026 2 commits
    • DevPilot's avatar
      docs(sayd-mobile): add full planning suite for the member app proposal · 04f46767
      DevPilot authored
      Four-pillar decomposition (membership/money, activities, events, gate/invitations)
      with data model, API spec, UX/motion system, and delivery plan.
      04f46767
    • DevPilot's avatar
      fix(rentals): contract page fataled for every approved contract · 808c63d1
      DevPilot authored
      I broke this. The page-actions block reads $gracePeriod to build the
      bulk-generate confirmation, but $gracePeriod was computed inside the content
      block — and page_actions renders first, so the variable did not exist yet.
      Production turns that notice into a fatal, so the contract page died outright.
      
      It had always been wrong; it was simply unreachable, because the guard above it
      requires status approved or active and until yesterday no contract could reach
      those states (the approve button was hidden on drafts). Making approval possible
      made the broken path reachable, and I shipped it without exercising the page in
      the state I had just unlocked.
      
      The shared variables now sit above both sections, which is where anything either
      section needs has to be.
      
      Why the test missed it: I was piping render output through `grep -v Warning:`,
      which filtered out the exact notice that is fatal in production. The check now
      installs an error handler that promotes every notice to an exception — the same
      thing ExceptionHandler does — and renders the contract page in all eight
      statuses and the invoice page in all three. All pass.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      808c63d1
  4. 07 Sep, 2026 13 commits
    • DevPilot's avatar
      fix(rentals): make the contract cycle visible on the contract page · 108ebc7d
      DevPilot authored
      Asked how to make a contract live, and the honest answer was that you could not
      find out from the screen — so the screen now says it.
      
      The approve card was shown only when status was exactly 'pending_approval'. A
      contract created from the form lands on 'draft' (the controller even defaults to
      'pending', which is not in the status list at all), so the one button that makes
      a contract live was invisible on precisely the contracts that needed it. It now
      appears for draft, pending and pending_approval.
      
      Added a four-step tracker across the top — العقد اتسجّل ← اعتماد العقد ← توليد
      الفواتير ← تحصيل الفاتورة — with the ticks reflecting real state and a line
      naming the single next action and where its button is. Underneath it, in words:
      which day of the month this contract's invoices fall due, read from the
      contract's own payment_due_day rather than assumed to be the 5th, that a short
      month pulls it back to the last day, and that the late fee is computed from the
      due date automatically.
      
      Invoice rows now carry a تحصيل button straight to the collection form when they
      are unpaid, instead of a عرض link that gave no hint the money is taken there.
      
      Verified rendered in draft, approved and active: the next-step line changes with
      state, the due day reads from the contract, and 11 unpaid rows show تحصيل while
      the paid one shows عرض.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      108ebc7d
    • DevPilot's avatar
      feat(rentals): collect a rental invoice into a treasury from the invoice screen · 758eaebf
      DevPilot authored
      The rent could be invoiced but not collected. `payInvoice` required a
      `payment_id` that already existed, and the form asked the accountant to type
      "رقم الدفعة من النظام" — a number they had no way to obtain, because nothing on
      this screen created a payment. So a generated invoice could never be settled.
      
      `collectInvoice` takes the money properly: pick the treasury, the method and the
      date, and it writes the payment against that treasury, then settles the invoice
      through the same RentalInvoiceService::markPaid the old path used — so the late
      fee is still calculated from the due date and the journal entry is unchanged.
      
      payInvoice is left in place for the case it was built for: a cashier who already
      raised the receipt and has its number.
      
      Verified end to end on a production clone: contract approved, 12 monthly
      invoices generated on the contract's own payment_due_day, one collected into
      «الخزنة الرئيسية», and the entry posted Dr الصندوق / Cr إيجار محلات + ض.ق.م with
      the trial balance still at 0.00.
      Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
      758eaebf
    • 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
  5. 06 Sep, 2026 3 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