Skip to content

Ledger Working Release#1

Open
roncodes wants to merge 192 commits intomainfrom
dev-v0.0.1
Open

Ledger Working Release#1
roncodes wants to merge 192 commits intomainfrom
dev-v0.0.1

Conversation

@roncodes
Copy link
Member

@roncodes roncodes commented Oct 2, 2024

No description provided.

roncodes and others added 4 commits October 2, 2024 18:33
- Add database migrations for accounts, journals, invoices, invoice_items, and wallets
- Implement models with full relationships and traits
- Create LedgerService, WalletService, and InvoiceService for business logic
- Add controllers for accounts, invoices, wallets, and transactions
- Create API resources for all models
- Implement events and observers for invoice lifecycle
- Configure routes and service provider
- Support double-entry bookkeeping with journal entries
- Enable system-wide transaction viewing
- Implement wallet functionality for driver/entity payments
feat: Complete backend implementation for Ledger module
@roncodes roncodes changed the title v0.0.1 - WIP first ledger release Ledger Working Release Feb 28, 2026
roncodes and others added 25 commits February 28, 2026 10:42
M1.1 — Fix WalletService accounting direction
- Deposit: DEBIT Cash (asset+), CREDIT Wallet Liability (liability+)
- Withdrawal: DEBIT Wallet Liability (liability-), CREDIT Cash (asset-)
- Added currency propagation from wallet to journal entry
- Replaced getDefaultExpenseAccount with getDefaultCashAccount for withdrawals
  (expense account is used for driver payouts in M3, not raw withdrawals)

M1.2 — Add HasPublicId trait to Journal model
- Added use HasPublicId trait
- Set publicIdPrefix = 'journal'
- Added public_id to $fillable and $appends

M1.3 — Migration: add public_id to ledger_journals table
- New migration: 2024_01_01_000006_add_public_id_to_ledger_journals_table.php
- Adds nullable unique string column after _key

M1.4 — Enrich LedgerService::createJournalEntry
- Transaction payload now populates: subject_uuid, subject_type, gateway_uuid,
  notes, gateway_transaction_id from $options
- currency falls back to debit account currency before defaulting to USD
- Added getTrialBalance() method (debit/credit totals across all accounts)
- Improved getGeneralLedger() with eager-loaded relations and secondary sort
- Improved getBalanceAtDate() with explicit int cast
- Enriched docblocks on all public methods

M1.5 — Improve InvoiceService::createItemsFromOrder
- Three-strategy item resolution:
  1. FleetOps payload entities (native order items)
  2. Order meta 'items' array (storefront-style)
  3. Fallback single summary line item
- Separate line items for delivery_fee and service_fee from order meta
- Currency resolved from options > order meta > default USD
- recordPayment now passes subject_uuid/subject_type to journal entry

M1.6 — Add LedgerSeeder
- New file: server/seeds/LedgerSeeder.php
- Seeds 24 default system accounts (assets, liabilities, equity, revenue, expenses)
- Idempotent via firstOrCreate — safe to run multiple times
- runForCompany() method for use in company provisioning hooks
- Covers: Cash, Bank, AR, AP, Wallet Pool, Driver Payable, Tax Payable,
  Stripe Clearing, Gateway Clearing, Delivery Revenue, Service Fee Revenue,
  Driver Payout Expense, Gateway Fees, Refunds, and more

M1.7 — Add journal entry routes and reporting routes
- GET/POST/DELETE ledger/int/v1/journals (JournalController)
- GET ledger/int/v1/accounts/{id}/ledger (general ledger per account)
- GET ledger/int/v1/reports/trial-balance (ReportController)
- POST ledger/int/v1/invoices/{id}/send (InvoiceController::send)
- New controllers: JournalController, ReportController
- AccountController: injected LedgerService, added generalLedger() method
- InvoiceController: added send() method with customer email validation
…yer (M2)

## Overview
Complete refactor and re-imagination of the payment gateway system as a
first-class, interface-driven, extensible architecture. Replaces the
ad-hoc Storefront gateway code with a clean, driver-based system that
makes adding new payment gateways a 3-step process.

## M2.1 — Core Abstraction Layer
- Add GatewayDriverInterface (Contracts/) — the contract every driver must implement
  Methods: purchase(), refund(), handleWebhook(), createPaymentMethod(),
           getCapabilities(), getConfigSchema(), initialize()
- Add PurchaseRequest DTO — typed, immutable purchase request
- Add RefundRequest DTO — typed, immutable refund request
- Add GatewayResponse DTO — normalized response from any gateway
  Includes: status, successful, gatewayTransactionId, eventType,
            amount, currency, message, data, rawResponse, errorCode
- Add AbstractGatewayDriver — base class with shared helpers (logInfo,
  logError, hasCapability, config, formatAmount)
- Add WebhookSignatureException — thrown on HMAC/signature failures
- Add Gateway model (ledger_gateways) — replaces storefront.gateways
  Features: encrypted:array config cast, HasPublicId, HasUuid,
            decryptedConfig(), getWebhookUrl(), capabilities JSON column
- Add GatewayTransaction model (ledger_gateway_transactions) — new audit
  and idempotency log linking gateway events to the core transactions table
  Features: alreadyProcessed(), isProcessed(), markAsProcessed()
- Add PaymentGatewayManager — extends Laravel Manager, resolves drivers
  by name, injects credentials, returns ready-to-use driver instances
  Registers: stripe, qpay, cash drivers
  Exposes: getDriverManifest() for frontend dynamic form rendering
- Migration 000007: create ledger_gateways table
- Migration 000008: create ledger_gateway_transactions table

## M2.2 — Stripe Driver
- Full StripeDriver implementation using stripe/stripe-php SDK
- purchase(): creates PaymentIntent with automatic_payment_methods
- refund(): creates Stripe Refund via Refunds::create()
- handleWebhook(): verifies Stripe-Signature header using constructEvent()
  Maps Stripe events to normalized GatewayResponse event types:
    payment_intent.succeeded → EVENT_PAYMENT_SUCCEEDED
    payment_intent.payment_failed → EVENT_PAYMENT_FAILED
    charge.refunded → EVENT_REFUND_PROCESSED
- createPaymentMethod(): creates SetupIntent for card tokenization
- getConfigSchema(): returns publishable_key, secret_key, webhook_secret fields
- getCapabilities(): purchase, refund, webhooks, tokenization, setup_intents

## M2.3 — QPay Driver
- Full QPayDriver implementation (refactored from Storefront QPay support class)
- Retains all QPay business logic: token auth, invoice creation, deep links
- purchase(): authenticates, creates QPay invoice, returns payment URL + deep links
- refund(): calls QPay refund API with original transaction reference
- handleWebhook(): verifies QPay callback signature, maps to normalized events
- getConfigSchema(): username, password, invoice_code, merchant_id, terminal_id
- getCapabilities(): purchase, refund, webhooks, redirect

## M2.4 — Cash Driver
- CashDriver for cash on delivery and manual payment scenarios
- purchase(): immediately marks as succeeded, generates local reference ID
- refund(): records manual refund, no external API call
- getConfigSchema(): label, instructions (operator-configurable display text)
- getCapabilities(): purchase, refund (no webhooks needed)

## M2.5 — Webhook System
- Add WebhookController (POST /ledger/webhooks/{driver})
  Flow: identify gateway → verify signature → idempotency check →
        persist GatewayTransaction → dispatch normalized event → return 200
  Always returns 200 to prevent gateway retries on internal errors
  Returns 400 only for signature verification failures
- Add PaymentSucceeded event
- Add PaymentFailed event
- Add RefundProcessed event
- Add HandleSuccessfulPayment listener (ShouldQueue, 3 retries, 30s backoff)
  Actions: mark invoice paid, create revenue journal entry, seal transaction
- Add HandleFailedPayment listener (ShouldQueue)
  Actions: mark invoice overdue, seal transaction
- Add HandleProcessedRefund listener (ShouldQueue)
  Actions: mark invoice refunded, create reversal journal entry, seal transaction

## M2.6 — PaymentService & GatewayController
- Add PaymentService — single orchestration entry point for all payments
  charge(): resolve gateway → call driver → persist → dispatch event
  refund(): resolve gateway → call driver → persist → dispatch event
  createPaymentMethod(): tokenize card via driver
  getDriverManifest(): return driver list for frontend
- Add GatewayController with full CRUD + payment operations:
  GET    /ledger/int/v1/gateways            → list all gateways
  POST   /ledger/int/v1/gateways            → create gateway
  GET    /ledger/int/v1/gateways/{id}       → get gateway
  PUT    /ledger/int/v1/gateways/{id}       → update gateway config
  DELETE /ledger/int/v1/gateways/{id}       → delete gateway
  GET    /ledger/int/v1/gateways/drivers    → driver manifest (dynamic forms)
  POST   /ledger/int/v1/gateways/{id}/charge → initiate payment
  POST   /ledger/int/v1/gateways/{id}/refund → refund transaction
  POST   /ledger/int/v1/gateways/{id}/setup-intent → tokenize card
  GET    /ledger/int/v1/gateways/{id}/transactions → transaction history
- Add Gateway API Resource (excludes credentials from responses)
- Add POST /ledger/webhooks/{driver} (public, no auth) to routes.php

## M2.7 — Wiring & Dependencies
- Update LedgerServiceProvider: register PaymentGatewayManager singleton,
  alias as 'ledger.gateway', register PaymentService, bind all 3 event-
  listener pairs via Event::listen()
- Update composer.json: add stripe/stripe-php ^13.0, guzzlehttp/guzzle ^7.0
…stomers

## Stripe SDK
- Fix stripe/stripe-php version to ^17.0 in composer.json
- Update StripeDriver to use StripeClient instance methods (v17 API)
  - paymentIntents->create(), refunds->create(), setupIntents->create()
  - Remove static Stripe::setApiKey() call (deprecated in v17)

## M3.1 — WalletTransaction model + migration
- New model: WalletTransaction with full type/direction/status constants
  - Types: deposit, withdrawal, transfer_in, transfer_out, payout, fee, refund, adjustment, earning
  - Directions: credit, debit
  - Statuses: pending, completed, failed, reversed
  - Polymorphic 'subject' relationship (driver/customer/order/invoice)
  - Scopes: credits(), debits(), completed(), ofType()
  - Helpers: isCredit(), isDebit(), isCompleted(), getFormattedAmountAttribute()
- New migration: ledger_wallet_transactions with composite indexes for
  common query patterns (wallet+type, wallet+direction, wallet+status, wallet+date)

## M3.2 — Wallet model enriched
- Added transactions(), completedTransactions(), credits(), debits() HasMany relationships
- Added getTypeAttribute() — infers 'driver'/'customer'/'company' from subject_type
- Added getFormattedBalanceAttribute() — cents to decimal string
- Added canDebit() / canCredit() guards (frozen wallets accept credits, not debits)
- Added close() state transition
- Added credit()/debit() low-level balance methods with atomic increment/decrement
- Added Wallet::forSubject() static factory (findOrCreate by subject)
- Added STATUS_ACTIVE/FROZEN/CLOSED constants
- Added 'type' and 'formatted_balance' to $appends

## M3.3 — WalletService fully enriched
- deposit() now creates WalletTransaction audit record + uses wallet->credit()
- withdraw() now creates WalletTransaction audit record + uses wallet->debit()
- transfer() now creates paired WalletTransaction records (transfer_in + transfer_out)
- New: topUp() — charges a gateway via PaymentService, credits wallet on sync success
- New: creditEarnings() — credits driver earnings with TYPE_EARNING transaction
- New: processPayout() — debits driver wallet with TYPE_PAYOUT transaction
- New: provisionBatch() — bulk wallet provisioning for existing drivers/customers
- New: recalculateBalance() — reconciliation utility from transaction history
- Lazy PaymentService resolution to avoid circular dependency

## M3.4 — WalletController fully enriched
- Constructor injection of WalletService
- deposit()/withdraw() now return {wallet, transaction} JSON (not just wallet)
- transfer() now returns {from_wallet, to_wallet, from_transaction, to_transaction}
- New: topUp() endpoint — POST /wallets/{id}/topup
- New: payout() endpoint — POST /wallets/{id}/payout
- New: getTransactions() — GET /wallets/{id}/transactions with full filtering
- New: freeze() — POST /wallets/{id}/freeze
- New: unfreeze() — POST /wallets/{id}/unfreeze
- New: recalculate() — POST /wallets/{id}/recalculate (reconciliation)
- Private resolveWallet() helper for DRY wallet lookup

## M3.5 — Public API (Customer/Driver facing)
- New controller: Api/v1/WalletApiController
  - GET  /ledger/v1/wallet              — get own wallet (auto-provisions)
  - GET  /ledger/v1/wallet/balance      — get balance + formatted_balance
  - GET  /ledger/v1/wallet/transactions — paginated transaction history
  - POST /ledger/v1/wallet/topup        — top up via gateway
- New resource: Http/Resources/v1/WalletTransaction (safe public serialization)
- Wallet resource enriched with 'type' and 'formatted_balance' fields

## M3.6 — Routes updated
- Added all new internal wallet routes (topup, payout, freeze, unfreeze, recalculate, transactions)
- Added public API route group (/ledger/v1/...) with fleetbase.api middleware
- Added /ledger/int/v1/wallet-transactions standalone query endpoint
- Added /ledger/int/v1/reports/wallet-summary endpoint
- New: WalletTransactionController (standalone cross-wallet query + find)
- New: ReportController::walletSummary() — wallet counts, period stats, top driver wallets
M4.1 — LedgerService: new financial statement methods
- getBalanceSheet(): generates Balance Sheet (Assets = Liabilities + Equity)
  with per-account rows, section totals, and equation verification
- getIncomeStatement(): generates P&L for a period using journal activity
  (not running balances), with revenue/expense line items and net income
- getCashFlowSummary(): derives cash flows from WalletTransactions grouped
  into Operating / Financing / Investing activities; cross-validates against
  journal Cash account (code 1000) opening/closing balance
- getArAging(): buckets outstanding invoices by days overdue into 5 buckets:
  current, 1-30, 31-60, 61-90, 90+; includes per-invoice detail rows
- getDashboardMetrics(): comprehensive KPI set with period-over-period
  comparison (% change), outstanding AR, wallet totals by currency,
  daily revenue trend, invoice status counts, and last 10 journal entries
- computeNetFlow(): internal helper for cash flow direction calculation
- percentageChange(): internal helper for period-over-period KPI deltas
- Refactored getBalanceAtDate() to use Account::TYPE_* constants
- Refactored getTrialBalance() to use Account::TYPE_* constants + orderBy code

M4.2 — ReportController: full suite of report endpoints
- dashboard()         GET /ledger/int/v1/reports/dashboard
- trialBalance()      GET /ledger/int/v1/reports/trial-balance
- balanceSheet()      GET /ledger/int/v1/reports/balance-sheet
- incomeStatement()   GET /ledger/int/v1/reports/income-statement
- cashFlow()          GET /ledger/int/v1/reports/cash-flow
- arAging()           GET /ledger/int/v1/reports/ar-aging
- walletSummary()     GET /ledger/int/v1/reports/wallet-summary
All endpoints validate date inputs and return structured JSON with status/data envelope

M4.3 — routes.php: added 5 new report routes
- reports/dashboard
- reports/balance-sheet
- reports/income-statement
- reports/cash-flow
- reports/ar-aging
## Overview
Full Ember engine frontend for the Ledger module. Implements all 8 navigation
sections with declarative sidebar (EmberWormhole pattern from iam-engine),
7 Ember Data models, 16 route controllers, 16 templates, 26 components, and
80 app/ re-export files.

## Sidebar Navigation (declarative HBS, not universe menu service)
- Dashboard (home route)
- Billing > Invoices, Transactions
- Wallets
- Accounting > Journal Entries, Chart of Accounts
- Reports
- Settings > Payment Gateways

## Ember Data Models (addon/models/)
- account, invoice, transaction, wallet, wallet-transaction, journal, gateway

## Routes & Controllers
- home (dashboard)
- billing/invoices/index + details (tabs: details, line-items, transactions)
- billing/transactions/index + details (tabs: details)
- wallets/index + details (tabs: details, transactions)
- accounting/journal/index + details (tabs: details)
- accounting/accounts/index + details (tabs: details, general-ledger)
- reports/index (tab-based: trial-balance, balance-sheet, income-statement, cash-flow, ar-aging)
- settings/gateways/index + details (tabs: configuration, webhook-events)

## Components (26 total across 8 namespaces)
dashboard/: kpi-metric, revenue-chart, invoice-summary, wallet-balances, activity-feed
invoice/: panel-header, details, line-items, transactions
transaction/: panel-header, details
wallet/: panel-header, details, transaction-history
journal/: panel-header, details
account/: panel-header, details, general-ledger
report/: balance-sheet, income-statement, cash-flow, ar-aging
gateway/: panel-header, details, webhook-events, form (dynamic config schema renderer)

## engine.js / extension.js / routes.js
- engine.js: clean Ember engine with correct dependencies
- extension.js: registers header menu item and dashboard widget only
- routes.js: full nested route tree matching all 8 sections

## app/ Re-exports (80 files)
All routes, controllers, models, and components re-exported from app/ directory
under the @fleetbase/ledger-engine namespace for Ember resolver compatibility.
…blic_id with id/uuid

Frontend fixes:
- addon/routes.js: all details route path params changed from /:public_id to /:id
- All 6 detail routes: model({ public_id }) -> model({ id }), findRecord uses id
- All 7 Ember Data models: removed @attr('string') public_id (Ember uses id automatically)
- All list controllers: transitionTo calls use .id not .public_id
- billing/invoices/index/details controller: fetch calls use namespace option
- wallets/index and wallets/index/details controllers: fetch calls use namespace option
- reports/index controller: endpointMap paths corrected, namespace option added
- settings/gateways/index controller: gateways/drivers fetch uses namespace option
- routes/home.js: dashboard fetch uses namespace option
- components/invoice/transactions.js: guard uses id, fetch uses namespace option
- components/wallet/transaction-history.js: guard uses id, fetch uses namespace option
- components/account/general-ledger.js: guard uses id, fetch uses namespace option
- components/gateway/webhook-events.js: guard uses id, fetch uses namespace option

Backend fixes:
- server/src/Http/Resources/v1/Gateway.php: now extends FleetbaseResource,
  id field returns uuid for internal requests and public_id for public API requests,
  consistent with all other Ledger resources (Account, Invoice, Wallet, etc.)
- All other controllers already resolve {id} via orWhere(uuid, id) — no changes needed
… per group

Replace single Panel with Layout::Sidebar::Section wrappers with the correct
pattern of one Panel per navigation group, matching the pallet/iam-engine/dev-engine
pattern. Each group (Ledger, Billing, Wallets, Accounting, Reports, Settings) is
now its own collapsible Panel. Also added missing <ContextPanel /> to application.hbs.
…ebar panels

Adapters:
- Add addon/adapters/ledger.js base adapter with namespace 'ledger/int/v1'
- Add per-model adapters for account, invoice, transaction, wallet,
  wallet-transaction, journal, gateway — each re-exporting ledger base adapter
- Add app/adapters/ re-exports for all adapters

Dashboard widget system (correct implementation):
- Rewrite extension.js: use universe/menu-service and universe/widget-service,
  call widgetService.registerDashboard('ledger') and
  widgetService.registerWidgets('ledger', widgets) with 7 widget definitions
  (5 default: overview, revenue-chart, invoice-summary, wallet-balances,
  activity-feed; 2 optional: ar-aging, top-wallets)
- Rewrite home.hbs: use <Dashboard @defaultDashboardId='ledger'
  @defaultDashboardName='Ledger Dashboard' @extension='ledger' /> inside
  <Layout::Section::Body> with <Spacer> and {{outlet}}
- Remove old addon/components/dashboard/ namespace (kpi-metric, revenue-chart,
  invoice-summary, wallet-balances, activity-feed) — replaced by widget/
- Create addon/components/widget/ with 7 widget components (JS + HBS each):
  overview, revenue-chart, invoice-summary, wallet-balances, activity-feed,
  ar-aging, top-wallets — each fetches its own data via fetch service
- Add app/components/widget/ re-exports for all 7 widget components
- Simplify home route — Dashboard component handles all data fetching via widgets
…i, params, options)

All fetch.get calls were incorrectly passing namespace in the params argument:
  fetch.get('url', { namespace: 'ledger/int/v1' })

Fixed to use the correct 3-argument signature:
  fetch.get('url', {}, { namespace: 'ledger/int/v1' })

Files fixed:
- addon/components/widget/overview.js
- addon/components/widget/revenue-chart.js
- addon/components/widget/invoice-summary.js
- addon/components/widget/wallet-balances.js
- addon/components/widget/activity-feed.js
- addon/components/widget/ar-aging.js
- addon/components/widget/top-wallets.js
- addon/controllers/reports/index.js (params now passed as 2nd arg)
- addon/controllers/settings/gateways/index.js
…alls (namespace belongs in adapter, not params)
…trollers

- Add LedgerController base class extending FleetbaseController (sets namespace)
- Remove all hand-rolled query/find/create/update/delete methods from every
  internal v1 controller; CRUD is now handled by HasApiControllerBehavior
  (queryRecord, findRecord, createRecord, updateRecord, deleteRecord)
- Add Filter classes for every resource (Account, Invoice, Journal, Wallet,
  WalletTransaction, Gateway, GatewayTransaction) — auto-resolved by
  Resolve::httpFilterForModel(); each filter implements queryForInternal()
  for company scoping and individual param methods (type, status, query, etc.)
- Add missing resources: Journal, GatewayTransaction, Transaction
- Rewrite routes.php to use fleetbaseRoutes() for all resources; only
  custom action routes (charge, refund, transfer, freeze, etc.) are declared
  manually inside the fleetbaseRoutes callback
- TransactionController overrides findRecord() to append journal entry data
- JournalController keeps createManual() as a custom action (service-layer
  orchestration required); standard createRecord() still available for
  simple creates
…verride

- Add Fleetbase\Ledger\Models\Transaction extending core-api Transaction
  with a journal() hasOne relationship — journal data is now available on
  the model itself, no controller override needed
- Add TransactionFilter with queryForInternal() eager-loading journal entries
- Update Transaction resource to use whenLoaded('journal') for clean serialization
- Simplify TransactionController to a pure LedgerController stub (no overrides)
- Update Journal model to reference Fleetbase\Ledger\Models\Transaction instead
  of the core-api Transaction so the inverse relationship resolves correctly

Fixes: Declaration of findRecord() must be compatible with FleetbaseController
Every migration declared ->unique() inline on the uuid/public_id column
AND then repeated $table->unique(['uuid']) as a standalone call at the
bottom of the same Schema::create block. MySQL creates the index on the
first declaration and then throws:

  SQLSTATE[42000]: Duplicate key name 'ledger_accounts_uuid_unique'

when the second call tries to add an identical index.

Removed the redundant standalone $table->unique(['uuid']) lines from:
  - 2024_01_01_000001_create_ledger_accounts_table
  - 2024_01_01_000002_create_ledger_journals_table
  - 2024_01_01_000003_create_ledger_invoices_table
  - 2024_01_01_000004_create_ledger_invoice_items_table
  - 2024_01_01_000005_create_ledger_wallets_table
  - 2024_01_01_000007_create_ledger_gateways_table
  - 2024_01_01_000008_create_ledger_gateway_transactions_table

Also removed redundant ->index() chained after ->unique() on public_id
columns (a UNIQUE constraint already implies an index in MySQL).
Migrations missing softDeletes() (deleted_at column):
  - ledger_journals — caused 'Unknown column deleted_at' when eager-loading
    journal entries via the Transaction -> journal() hasOne relationship
  - ledger_invoice_items

Models missing SoftDeletes trait (import + use):
  - Gateway
  - GatewayTransaction
  - InvoiceItem
  - Journal

Fleetbase\Ledger\Models\Transaction extends BaseTransaction which already
uses SoftDeletes via the core-api model — no change needed there.
…er layer

Models renamed (prevents collision with core-api models in Ember Data store):
  account -> ledger-account
  invoice -> ledger-invoice
  journal -> ledger-journal
  wallet  -> ledger-wallet
  wallet-transaction -> ledger-wallet-transaction
  gateway -> ledger-gateway
  transaction -> ledger-transaction

Adapters renamed to match (all still re-export from adapters/ledger.js base).

Base adapter (adapters/ledger.js):
  - Add pathForType(modelName) that strips 'ledger-' prefix before pluralizing
    so 'ledger-account' -> GET /ledger/int/v1/accounts (not /ledger-accounts)

New serializer layer (serializers/):
  - serializers/ledger.js — base LedgerSerializer extending ApplicationSerializer
    with EmbeddedRecordsMixin (classic extend() required for mixin compat)
  - modelNameFromPayloadKey: prepends 'ledger-' so payload 'account' resolves
    to the Ledger model, not any core-api model with the same name
  - payloadKeyFromModelName: strips 'ledger_' prefix and lowercases so
    'ledger-wallet-transaction' -> 'wallet_transaction' in request payloads
  - Per-model stubs (serializers/ledger-*.js) re-export the base serializer

All store.query/findRecord/createRecord calls updated to use prefixed names.
…eports/Settings

Navigation changes:
- Dashboard is now a standalone sidebar item (no panel wrapper)
- 'Billing' renamed to 'Receivables' (Invoices only)
- New 'Payments' panel: Transactions, Wallets, Gateways
  - billing/transactions -> payments/transactions
  - wallets -> payments/wallets
  - settings/gateways -> payments/gateways
- 'Accounting' adds General Ledger item
- 'Reports' expands to 6 individual report routes
  (Income Statement, Balance Sheet, Trial Balance, Cash Flow, AR Aging, Wallet Summary)
- 'Settings' expands to 3 sub-sections
  (Invoice Settings, Payment Settings, Accounting Settings)

App re-exports:
- Removed old unprefixed app/models/*.js re-exports
- Created ledger- prefixed re-exports for all 7 models, adapters, serializers
- Created app/serializers/ directory with all 8 re-exports (7 models + base)

Route/template/controller files reorganised to match new structure.
…::Resource::Tabular on all index routes

- All parent route templates (billing/invoices, payments/transactions,
  payments/wallets, payments/gateways, accounting/accounts,
  accounting/journal) now contain only {{outlet}}
- All index templates replaced with Layout::Resource::Tabular using
  correct resource, title, search, columns, pagination, and action bindings
- Created 7 dedicated action services following FleetOps ResourceActionService
  pattern: invoice-actions, transaction-actions, wallet-actions,
  gateway-actions, account-actions, journal-actions, wallet-transaction-actions
- All index controllers refactored to inject their action service and
  define columns, actionButtons, bulkActions as getters
- Added app/services/ re-exports for all 7 action services
roncodes and others added 30 commits March 5, 2026 20:45
Backend (Invoice.php):
- Add protected $with = ['customer', 'items', 'template'] so Eloquent
  always eager-loads these relationships on every query (index, findRecord,
  etc.) without needing explicit ->with() calls in each controller action.
  This fixes the single-record GET /invoices/:id response which was
  returning null for customer and omitting items entirely.

Frontend (ledger-invoice.js):
- Change customer belongsTo from async:false to async:true.
  The customer is a polymorphic morph-to (vendor/contact/etc.).  When
  Ember Data receives a sideloaded record whose model type differs from
  what is already in the store it cannot resolve the relationship
  synchronously, triggering the assertion:
    'You looked up the customer relationship ... but some of the
     associated records were not loaded'
  Making it async lets Ember Data resolve it via the promise proxy
  without throwing.
- Change template belongsTo from async:false to async:true for the same
  reason (polymorphic resolution safety).
- Update customerName / customerEmail / customerPhone / templateName
  computed properties to use .get() on the async proxy and depend on
  isFulfilled so they re-render once the promise resolves.
…anel can resolve invoice/form and all other component paths
…ocus loss via TrackedObject, wider qty/price columns
…ys, and row actions column to invoice-templates index
…atePicker for date fields

- form.js: derive customer_type from customer.customer_type ('customer-vendor' -> 'fleet-ops:vendor',
  'customer-contact' -> 'fleet-ops:contact') instead of hardcoding 'fleet-ops:contact'
- line-items.js: make amount/tax_amount @Tracked on LineItem and call _recalculate() in every
  mutating action so component-level subtotal/tax/total getters re-render correctly
- form.hbs: replace plain <Input @type=date> with <DatePicker> for Invoice Date and Due Date
… line-item totals reset

- serializer/ledger-invoice.js: override serialize() to derive customer_type from
  customerSnapshot.attr('customer_type') ('contact'|'vendor') -> 'fleet-ops:{type}'
- form.js: simplify onCustomerChange to only set the customer relationship; all
  customer_type/uuid serialization is now handled by the serializer
- line-items.hbs: add key="_tmpId" to {{#each}} so Glimmer identity-tracks rows
  and does not destroy/recreate MoneyInput elements when totals re-render,
  which was causing unit_price to reset to 0 after editing the tax field
…in serializer

- line-items.js: make unit_price/quantity/tax_rate/description plain (non-@Tracked)
  to prevent AutoNumeric feedback loop where tracked @value update triggers
  onChange again with double-multiplied cents value, resetting all totals to 0.
  Only amount and tax_amount remain @Tracked to drive reactive totals display.
- serializer/ledger-invoice.js: strip 'customer-' prefix from customer_type before
  building 'fleet-ops:{type}' string, handling both 'vendor' and 'customer-vendor'
  forms that the API may return depending on context.
…ice resource

Utils::toEmberResourceType() already returns the full namespaced type string
(e.g. 'fleet-ops:vendor').  Prepending 'customer-' produced the invalid value
'customer-fleet-ops:vendor'.  Now passes the type through directly.

Frontend serializer updated to match: customer_type from the embedded customer
object is now the full 'fleet-ops:vendor' string, passed through as-is.
- form.hbs: switch DatePicker from @onDateChanged (receives string) to
  @onchange (receives native Date object) so Ember Data date transform
  can serialise it correctly via .toISOString()

- line-items.hbs: replace Ember <Input> with plain <input> elements for
  description, quantity, and tax_rate fields.  Ember's two-way @value
  binding was resetting the input DOM value on every re-render triggered
  by item.amount/@Tracked changes, causing the tax field to fire an
  input event with its reset value (0) and corrupt all calculations.
  Plain <input value={{...}}> sets the initial value once and does not
  update on subsequent renders, breaking the feedback loop.

- InvoiceController: fix onAfterCreate/onAfterUpdate to read items from
  $input (the extracted payload under the 'invoice' key) instead of
  $request->input('items') which looks at the top-level request body
  and returns null since items are nested under invoice.items.  This was
  causing _syncItems to receive an empty array, deleting all items and
  making calculateTotals() return 0.
Root cause: _notifyChange() was called on every keystroke, causing:
1. store.createRecord() on every keystroke -> duplicate items with uuid:null
2. @Items arg change -> Glimmer destroyed/recreated InvoiceLineItemsComponent
3. MoneyInput values reset to 0 because component was recreated

Fix: registerRef/getItems pattern
- line-items.js: Remove _notifyChange from per-keystroke handlers. Add
  getItems() public method. Register self via args.registerRef in constructor.
- form.js: Remove onItemsChange. Add syncItemsToInvoice(invoice) method.
  Add registerLineItemsRef action. Add initialItems getter (stable, never
  mutated during editing). Register self with controller via args.registerRef.
- form.hbs: Use @Items={{this.initialItems}} and @registerRef instead of
  @Items={{this.items}} and @onchange.
- controllers/new.js, edit.js: Add formRef tracked prop. Call
  formRef.syncItemsToInvoice(invoice) before yield invoice.save().
- templates/new.hbs, edit.hbs: Pass @registerRef={{fn (mut this.formRef)}}
  to Invoice::Form.
Bug 1 - formRef 'already consumed' crash (edit form broken):
  @Tracked formRef on the controller caused Glimmer to read formRef during
  the render pass (evaluating fn (mut this.formRef) in the template), then
  Invoice::Form constructor wrote to it in the same pass via args.registerRef.
  Glimmer 5 forbids writing to a tracked property already read in the current
  computation ('already consumed' assertion).

  Fix: Remove @Tracked from formRef on both controllers. Add a plain
  @action registerFormRef(ref) { this.formRef = ref } on each controller.
  Change template binding from @registerRef={{fn (mut this.formRef)}} to
  @registerRef={{this.registerFormRef}}.

Bug 2 - Tax input resets computed totals to 0:
  initialItems was a getter on InvoiceFormComponent. On every render cycle
  (e.g. when tax_amount changed on a LineItem and triggered a form re-render),
  the getter re-evaluated and returned a new array from invoice.items.toArray().
  Glimmer saw a new @Items reference on Invoice::LineItems and destroyed/
  recreated the child component, resetting all LineItem instances to their
  original Ember Data values (losing the user's unit_price edits).

  Fix: Convert initialItems from a getter to a plain property set ONCE in
  the constructor: this.initialItems = invoice?.items?.toArray?.() ?? [].
  The @Items arg on Invoice::LineItems now never changes after mount, so
  Glimmer never destroys/recreates the child component during editing.
…n-tracked unit_price

Root cause of the feedback loop:
  MoneyInput uses AutoNumeric (did-insert, fires once on mount). The inner
  <Input @value={{@value}}> updates element.value whenever @value changes on
  re-render. AutoNumeric intercepts this and fires rawValueModified, which
  calls @onchange again with a double-multiplied storedValue.

Fix:
  unit_price on LineItem is a plain (non-tracked) property. Setting it in
  updateUnitPrice() does NOT dirty the Glimmer tracking graph, so no re-render
  occurs, @value on MoneyInput never changes after mount, and AutoNumeric
  never fires rawValueModified spuriously.

  Only item.amount and item.tax_amount are @Tracked — Glimmer re-renders
  only the Amount cell and footer totals after each input change.
  Footer subtotal/tax/total are @Tracked on the component and updated by
  _updateTotals() after every handler call.

  initialItems on InvoiceFormComponent is a plain property set once in the
  constructor (not a getter), so the @Items arg on Invoice::LineItems never
  changes — Glimmer never destroys/recreates the component during editing.
…rs via Amount sub-component

Root cause (confirmed by studying service-rate):
  service-rate uses MoneyInput in each loops with zero issues because it has
  NO live computed display values.  Our invoice rows need amount = qty x price
  to update live, which requires @Tracked state, which causes re-renders.

  When item.amount (@Tracked) changes, Glimmer re-renders the row.  The row
  re-render passes @value={{item.unit_price}} to MoneyInput.  Since unit_price
  is a plain (non-tracked) property, Glimmer's memoization cache returns the
  stale value from when the row was first mounted (0 for new items).  The
  <Input @value=0> sets element.value=0, AutoNumeric fires rawValueModified,
  @onchange(0) is called, unit_price=0, amount=0 — everything resets to zero.

Fix:
  Extract the Amount cell into Invoice::LineItems::Amount — a separate isolated
  sub-component that only reads item.amount (@Tracked).  When _recalculate()
  updates item.amount, ONLY this sub-component re-renders.  The parent row
  containing MoneyInput has no @Tracked dependencies and is never re-rendered
  after mount.  AutoNumeric never receives a programmatic @value update.
  No feedback loop, no stale cache, no reset to zero.
…y, no POJO class

Model changes:
- unit_price/amount/tax_amount changed to @attr('string') so Ember Data's string
  transform passes values through untouched. The backend Money cast accepts strings
  and strips symbols via numbersOnly(). Using @attr('number') would coerce MoneyInput's
  formatted string (e.g. "$200.00") to NaN.
- Added computedAmount and computedTaxAmount reactive getters on the model.
  Because @attr fields are tracked by Ember Data, these update automatically when
  unit_price, quantity, or tax_rate change. The template reads these via format-currency.

Component changes:
- Removed LineItem POJO class entirely. The component now holds real
  ledger-invoice-item Ember Data records. Existing items come from @Items
  (snapshotted once in the constructor). New items are created via store.createRecord.
- Footer subtotal/tax/total are reactive getters on the component that reduce over
  item.computedAmount / item.computedTaxAmount.
- MoneyInput receives @value={{item.unit_price}} with NO @onchange. AutoNumeric
  formats the display; Ember's <Input> two-way binding writes the formatted string
  back to item.unit_price when the user types. No rawValueModified feedback loop
  because there is no @onchange to call back into.
- Per-item tax rate input retained.
- Amount column uses format-currency item.computedAmount.

Form changes:
- Removed store service injection from form.js.
- syncItemsToInvoice simplified: invoice.items = lineItemsRef.getItems() since
  getItems() now returns real Ember Data records, not plain objects.
- Added @Invoice={{@resource}} arg to Invoice::LineItems so new records can be
  associated with the invoice on creation.
- Removed Invoice::LineItems::Amount sub-component (no longer needed).
…eld names

- InvoiceService::recordPayment: create Transaction record before journal entry
  so payments appear in the Transactions list; pass transaction_uuid in options
- InvoiceController::onAfterCreate: call recogniseRevenue() after totals are
  finalised (Debit AR, Credit Revenue) so revenue appears in Income Statement
- AR aging report: fix frontend field names to match API response structure
  (invoice.number, invoice.customer.name, invoice.bucketLabel, invoice.daysRange)
  and add bucketSummary getter to controller; fix grand_total reference
…e component

- Replace raw fetch.get() with store.query('ledger-transaction', { context: invoice.uuid })
  so every record is a proper LedgerTransactionModel instance normalised through
  the Ember Data pipeline (LedgerSerializer → LedgerAdapter → /ledger/int/v1/transactions)

- Fix filter param: was invoice_uuid (unrecognised) → now context which maps to
  TransactionFilter::context() → WHERE context_uuid = ? (the UUID InvoiceService
  stores when it creates the transaction via recordPayment)

- Replace hand-rolled <table> with <Table @rows @columns> matching the wallet
  transaction-history pattern

- Add columns getter with: ID (anchor → detail view), Date, Description, Type
  (humanize), Direction, Amount (currency cell), Status (status cell), Gateway,
  Payment Method, Payer, row-actions dropdown (view)

- Inject store, intl, transactionActions services; drop fetch service

- Add empty-state UI with receipt icon when no transactions exist

- ledger-invoice model: add @attr('string') uuid — the backend sends uuid for
  internal requests but it was not declared; needed for the context filter

- ledger-transaction model: add uuid, payer_name, payee_name, initiator_name
  attrs — all sent by the Transaction resource but previously undeclared
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant