Skip to content

Ledger Working Release#1

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

Ledger Working Release#1
roncodes wants to merge 229 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
Fleetbase Dev and others added 20 commits March 13, 2026 22:08
… tracking

- PurchaseRateObserver: auto-generates a Ledger invoice when a Fleet-Ops
  PurchaseRate is created. Triggers recogniseRevenue() via the existing
  InvoiceController::onAfterCreate path (DEBIT AR / CREDIT Revenue).
  Registered conditionally — silently skipped when fleet-ops is not installed.

- StorefrontOrderObserver: detects orders with meta.storefront_id and
  immediately creates a receipt invoice (status=paid, balance=0) plus a
  direct DEBIT Cash / CREDIT Revenue journal entry. Storefront orders are
  always pre-paid so there is no AR leg.

- Fix HandleSuccessfulPayment: replace broken PHP named-argument call to
  createJournalEntry() (TypeError on every invocation) with the correct
  positional call. Invoice-linked payments now route through
  InvoiceService::recordPayment(); unlinked gateway payments fall back to
  a direct Cash/Revenue entry. InvoiceService injected into constructor.

- LedgerServiceProvider: register both new observers in $observers array
  using string class names so Utils::classExists() can guard them.
- Update public invoice URL to use /~/invoice prefix (tilde route fix)
- Rename copyPaymentLink → copyInvoiceUrl; add getInvoiceUrl helper
- Rename 'Copy Payment Link' → 'Copy Invoice URL' in index column actions
- Add copy-invoice-url translation key; update copied/failed messages
- Restructure details actionButtons: Preview and Edit remain individual
  buttons; Send, Record Payment, Void, and Copy Invoice URL are grouped
  into a single DropdownButton (ellipsis trigger)
- Add Invoice URL field to Invoice Information section of details panel
  with clickable link and inline copy button
- Create invoice/details.js backing class to expose invoiceUrl getter
…ession payment flow

- customer-invoice.hbs: use item.amount (not item.total) for line item total column
- customer-invoice.hbs: add isRedirectingToCheckout loading state, show 'Pay with Stripe' button label for stripe gateways
- customer-invoice.js: send gateway_id in submitPayment, handle checkout_url redirect, detect ?payment=success/cancelled on load, add isStripeGateway getter
- invoice/details.hbs: replace manual clipboard button with ClickToCopy component
- PublicInvoiceController: rewrite pay() to detect Stripe driver and call createCheckoutSession; non-Stripe gateways record payment immediately; remove broken amount validation
- StripeDriver: createCheckoutSession() uses inline price_data (no Products/Prices needed), checkout.session.completed mapped to EVENT_PAYMENT_SUCCEEDED in normalizeStripeEvent
## Issue 1 — Webhook URL shows relative path

**Root cause:** `gateway/form.js` fell back to a relative path
`/ledger/webhooks/${driverCode}` when defaulting the webhook_url field.

**Fix:**
- `PaymentGatewayManager::getDriverManifest()` now includes `webhook_url`
  using Laravel's `url()` helper so the manifest always returns a full
  absolute URL (e.g. `https://api.example.com/ledger/webhooks/stripe`).
- `gateway/form.js` now only sets `resource.webhook_url` from
  `driver.webhook_url` (the full URL from the manifest) and never falls
  back to a relative path.
- `gateway/details.hbs` now shows `system_webhook_url` (backend-generated
  absolute URL) with a ClickToCopy button, making it easy to copy into the
  Stripe dashboard.

## Issue 2 — Stripe payment fails with "Attempt to read property 'checkout' on null"

**Root cause:** `updateConfigField` in `gateway/form.js` was bound via
`{{on "input" (fn this.updateConfigField field.key)}}` which passes a DOM
`InputEvent` as the second argument instead of the string value.  This
caused `this.args.resource.config` to be set with Event objects rather than
credential strings, so `secret_key` was never saved correctly and
`StripeClient` was never instantiated.

**Fix:**
- `updateConfigField` now extracts `event.target.value` when the second
  argument is a DOM Event, ensuring credentials are stored as plain strings.
- `StripeDriver::initialize()` now logs a warning when `secret_key` is
  missing so the issue is surfaced in logs immediately.
- `StripeDriver::assertClientInitialized()` added and called at the start
  of every method that uses `$this->client`, throwing a descriptive
  `RuntimeException` instead of a cryptic null-dereference error.
- `PublicInvoiceController::initiateStripeCheckout()` catches the
  `RuntimeException` and returns a user-friendly 422 error.

## Additional improvements

- `ledger-gateway` Ember model: added missing `is_sandbox`, `capabilities`,
  `status_label`, and `driver_label` attributes/computed properties.
- `gateway/form.js selectDriver`: syncs driver capabilities to the resource
  on driver selection so they are persisted with the gateway record.
… with ember-ui getCurrency

- All three settings controllers now use the correct fetch call pattern:
    this.fetch.get('settings/<key>', {}, { namespace: 'ledger/int/v1' })
    this.fetch.post('settings/<key>', { ... }, { namespace: 'ledger/int/v1' })
  Previously the path incorrectly included the full 'ledger/settings/<key>' prefix
  which caused the request to resolve to the wrong URL.

- Replaced hardcoded currency arrays in invoice and accounting controllers with:
    import getCurrency from '@fleetbase/ember-ui/utils/get-currency'
    currencies = getCurrency()  // full list of all world currencies
  This ensures the complete, up-to-date currency list from ember-ui is used.

- Replaced manual PowerSelect currency dropdowns in invoice.hbs and accounting.hbs
  with the <CurrencySelect> component from ember-ui, which provides built-in
  search, emoji flags, and the full getCurrency() list.

- Added selectedCurrency computed getter to invoice and accounting controllers
  so CurrencySelect receives the full currency object as @currencyData.

- onSelectCurrency action now reads currency.code from the object passed by
  CurrencySelect (previously expected option.value from the old PowerSelect).
…settings

- Invoice Settings: default_currency initialises to null (not 'USD').
  The effectiveCurrency getter returns this.default_currency if set,
  otherwise this.currentUser.getCompany()?.currency ?? 'USD'.
  CurrencySelect now binds to effectiveCurrency so the company currency
  is always shown even before the user has saved an explicit override.
  Clearing the selection resets back to the company default.

- Accounting Settings: same pattern applied to base_currency.

- Both templates updated:
  - @Currency now binds to effectiveCurrency (not the raw tracked prop)
  - @Placeholder shows 'Default: <company-currency-code>'
  - @allowClear={{true}} lets users remove an override to revert to company default
  - A subtle helper text line is shown when no override is set:
    'Using organisation default (XXX). Select a currency above to override.'

- Both controllers inject @service currentUser and expose:
    get companyCurrency()   -> this.currentUser.getCompany()?.currency ?? 'USD'
    get effectiveCurrency() -> this.default_currency || this.companyCurrency
    get selectedCurrency()  -> getCurrency(this.effectiveCurrency) ?? null

- saveSettings now persists null (not 'USD') when no override is chosen,
  so the backend correctly stores the absence of an explicit preference.
Payment Settings:
- Fixed LinkTo @route from 'console.ledger.payments.gateways.index.new'
  to engine-relative 'payments.gateways.index.new'. Inside an Ember engine
  the router automatically prepends the mount point, so using the fully-
  qualified name caused it to be doubled to
  'console.ledger.console.ledger.payments.gateways.index.new'.

Invoice Settings & Accounting Settings:
- Removed companyCurrency getter that called this.currentUser.getCompany()
  during rendering. getCompany() internally does this.company = ... which
  mutates a @Tracked property while Glimmer is in the middle of a render
  computation, triggering the 'attempted to update X after it was consumed'
  assertion in Glimmer 5.
- Fix: snapshot the company currency once in the constructor (before any
  render pass) into a plain @Tracked companyCurrency property. The
  effectiveCurrency and selectedCurrency getters now only read plain tracked
  values and never call getCompany() during rendering.
…trollers

getCompany() calls store.peekRecord(company, this.user.company_uuid).
When the controller is instantiated during early route setup (before the
currentUser service has finished loading), user.company_uuid is undefined,
causing store.peekRecord to throw:
  'Expected id to be a string or number, received undefined'

currentUser.currency reads from whoisData via getWhoisProperty() which only
reads from plain tracked objects and localStorage cache — it never calls
store.peekRecord and is safe to call at any time, including in a constructor.

Both SettingsInvoiceController and SettingsAccountingController now use:
  this.companyCurrency = this.currentUser.currency ?? 'USD';
instead of:
  this.companyCurrency = this.currentUser.getCompany()?.currency ?? 'USD';
…hois

Previous approach used currentUser.currency which reads from whoisData
(IP geolocation) — that is the user's detected locale currency, NOT the
organisation's configured currency. This was incorrect.

The correct source is the organisation record itself. currentUser.organizations
is a @Tracked array populated by loadOrganizations() during promiseUser(),
which completes before any console route is activated. Reading it in a getter
is safe — no store.peekRecord call, no state mutation.

companyCurrency getter now:
1. Reads this.currentUser.companyId (alias of userSnapshot.company_uuid)
2. Finds the matching org in this.currentUser.organizations
3. Returns org.currency — the organisation's configured currency
4. Falls back to 'USD' only if the org is not yet in the list

This is the correct, safe, and semantically accurate way to read the
organisation's default currency in a controller.
…settings defaults

Backend (Invoice model boot/creating hook):
- Always auto-generates invoice number when not explicitly provided, fixing
  the SQLSTATE[23000] null-number constraint violation on invoice creation.
- Reads the company's saved 'ledger.invoice-settings' via Setting::lookupCompany
  to get the configured invoice_prefix (default 'INV').
- Also applies default currency, due_date (date + due_date_offset_days),
  notes, and terms from settings when the caller has not set them explicitly.
- Callers that already set these fields (e.g. createFromOrder) are unaffected
  because the hook only fills in empty values.

Frontend (invoice new controller + route):
- Replaced hardcoded DEFAULT_PROPERTIES with a loadDefaults task that fetches
  'settings/invoice-settings' (namespace: ledger/int/v1) and pre-populates the
  new invoice record with the company's configured currency, notes, terms, and
  due date before the form renders.
- Route setupController now calls controller.loadDefaults.perform() on every
  visit so the form always reflects the latest saved settings.
- Template guards against null invoice (while loadDefaults is in flight) with
  a Spinner fallback to prevent 'Cannot read property of null' errors.
…t URLs

The success_url and cancel_url passed to Stripe Checkout were built with
config('app.url') which resolves to the API host (e.g. http://localhost:8000).
After payment Stripe was redirecting back to the API instead of the console.

Fix: use Utils::consoleUrl('~/invoice', [...]) which reads
fleetbase.console.host (CONSOLE_HOST env var) to build the correct
frontend URL, e.g. https://console.example.com/~/invoice?id=...&payment=success
…ession.completed

- WebhookController: use firstOrCreate(gateway_reference_id, type, event_type) instead
  of bare create() to prevent UniqueConstraintViolationException when Stripe fires
  multiple events sharing the same pi_xxx reference ID (e.g. payment_intent.created
  and payment_intent.succeeded). Catch UniqueConstraintViolationException as final
  race-condition guard. Return 200 immediately if record already existed.

- Migration 000024: fix unique constraint on ledger_gateway_transactions from
  (gateway_reference_id, type) to (gateway_reference_id, type, event_type) so each
  distinct Stripe event type gets its own row. Handles both fresh installs (creates
  table) and existing installs (drops old index, adds new one).

- StripeDriver createCheckoutSession: set metadata directly on the CheckoutSession
  (not just on payment_intent_data) so checkout.session.completed webhook event
  carries invoice_uuid in data.object.metadata — required for HandleSuccessfulPayment
  to resolve the invoice via resolveInvoiceUuid path 3.

- StripeDriver handleWebhook: for checkout.session.completed, extract invoice_uuid
  from session metadata and include in GatewayResponse->data bag (path 4 fallback).
  Use payment_intent ID as gatewayTransactionId so the row links to the same pi_xxx
  used by payment_intent.succeeded, preventing duplicate processing.
…nsactions

The original migration 000008 was deleted from the repo, meaning fresh installs
would never create the ledger_gateway_transactions table.

Changes:
- Restored 000008 as the canonical table creation migration with the exact schema
  matching the live database (uuid, public_id, company_uuid, gateway_uuid,
  transaction_uuid, gateway_reference_id, type, event_type, amount, currency,
  status DEFAULT pending, message, raw_response, processed_at, softDeletes,
  timestamps). Original unique constraint (gateway_reference_id, type) is kept
  here so the migration history is accurate.

- Rewrote 000024 as a pure constraint-fix migration (no if/else). It only runs
  Schema::table() to drop the old narrow index and add the new wider
  (gateway_reference_id, type, event_type) index. Fresh installs run 000008
  then 000024 in sequence; existing installs already have the table and only
  need 000024 to fix the constraint.
Eloquent's firstOrCreate() returns a single model instance, not a
[$model, $wasCreated] tuple. The previous code used array destructuring
which assigned $gatewayTransaction = null (offsetGet(0) on the model
returns null) causing 'Attempt to read property processed_at on null'
on every webhook call.

Fix: remove the tuple destructuring and use the model's built-in
$wasRecentlyCreated property (set to true by Eloquent when the record
was just inserted, false when it already existed) to determine whether
to dispatch the event or skip as a duplicate.
…tion

- PublicInvoiceController::show() now returns HTTP 403 for draft invoices
  with a user-friendly message so customers cannot access unpublished invoices.
- Invoice::markAsViewed() now auto-transitions status from 'sent' → 'viewed'
  on first customer access, giving senders visibility into when an invoice
  has been opened. Existing overdue/partial/paid statuses are never downgraded.
- Added UpdateOverdueInvoices Artisan command (ledger:update-overdue-invoices)
  that finds all invoices with due_date < now() and status in [sent, viewed]
  and updates them to 'overdue'. Registered in LedgerServiceProvider.
- CustomerInvoiceComponent now handles HTTP 403 responses with a dedicated
  'not yet available' message, falling back to the server-provided error text.
Both the frontend (new.js) and the backend (Invoice::boot creating hook)
were using a hardcoded fallback of 30 days when no Invoice Settings had
been saved, causing every new invoice form to open with a pre-filled due
date the user never configured.

Changes:
- Invoice.php: $dueDateOffset now resolves to null (not 30) when the
  'due_date_offset_days' key is absent from the saved settings. The due
  date is only auto-calculated when the user has explicitly saved a
  non-zero offset in Invoice Settings.
- new.js: removed the '?? 30' nullish-coalescing fallback. The offset is
  now read as-is; a null/undefined/0 value means 'leave due_date empty'.
Fleetbase Dev and others added 9 commits March 14, 2026 04:10
CSS (stylelint)
- Add empty line before the Invoice Template Builder block comment (comment-empty-line-before)
- Add empty line before .ledger-template-builder-outlet:empty rule (rule-empty-line-before)

HBS (ember-template-lint)
- revenue-chart.hbs: replace inline style concatenation with html-safe + concat helper (no-inline-styles, style-concatenation)
- transactions/details.hbs: remove debug {{log this.tabs}} call (no-log)

JS (ESLint)
- customer-invoice.js: reformat multi-line ternary (prettier); replace router:main private lookup with @service router (ember/no-private-routing-service)
- gateway/form.js: add yield to *loadSchema generator (require-yield)
- invoice-templates/edit.js + new.js: remove unused tracked import (no-unused-vars)
- invoices/new.js: reformat multi-line find() arrow to single line (prettier)
- transactions/index.js, gateways/index.js, wallets/index.js: add yield to *search generators (require-yield)
- settings/accounting.js: fix trailing whitespace in comment (prettier); remove redundant parens in _accountId (prettier)
- settings/invoice.js: fix trailing whitespace in comment (prettier)
- ledger-invoice.js: use brace expansion in computed deps (ember/use-brace-expansion); add invoiceDate dep to issuedAt (ember/require-computed-property-dependencies)
- ledger-journal.js: add .name/.code to computed deps for account getters (ember/require-computed-property-dependencies)
- ledger-transaction.js: remove unused belongsTo import (no-unused-vars)
…n permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…ransactions

- InvoiceService: pass purchaseRate to createFromOrder so line items are
  built from ServiceQuote items (cents-correct amounts) rather than falling
  through to the order-meta fallback that always returned 0.
  Added createItemsFromPurchaseRate() which reads serviceQuote->items first,
  then serviceQuote->amount as a summary fallback, then delegates to the
  existing createItemsFromOrder() as a last resort.

- InvoiceService / Invoice boot(): removed the hardcoded Invoice::generateNumber()
  call in createFromOrder so the boot() creating hook is the sole code path
  that generates the invoice number. The hook already reads invoice_prefix from
  Invoice Settings, so the configured prefix is now always honoured.

- PurchaseRateObserver: pass $purchaseRate as the third argument to
  createFromOrder so the new createItemsFromPurchaseRate path is taken.

- revenue-chart.js: fixed key mismatch — component was reading
  response.data.daily_revenue but the API returns response.data.revenue_trend.
  Each item is now mapped to { date, amount, pct } where pct is the bar width
  as a percentage of the maximum daily value.

- GatewayController::transactions(): changed return type from the strict
  AnonymousResourceCollection (which conflicted with the FleetbaseResource
  collection returned at runtime) to JsonResponse, wrapping the collection
  in response()->json(). This resolves the 500 error.

- Gateway details controller: renamed the 'Webhooks' tab label to
  'Transactions' to accurately reflect the content shown.
Adds a new OrderInvoice Glimmer component that renders the Ledger invoice
associated with a Fleet-Ops order directly inside the order details panel.

Component: addon/components/order-invoice.{js,hbs}

The component accepts three args passed by the Fleet-Ops host:
  @order    - the Fleet-Ops order model instance (primary)
  @resource - alias for the order model instance
  @params   - optional componentParams from the host

On mount it calls store.query('ledger-invoice', { order_uuid, with: 'items' })
to fetch the associated invoice (including sideloaded line items) and renders:
  - Invoice header: number, status badge, View and Open in Ledger actions
  - Invoice Information panel: number, status, dates, currency
  - Line Items panel: description / qty / unit price / tax / amount table
    with subtotal, tax, and total footer rows
  - Payment Summary panel: total amount, amount paid, balance due
  - Notes panel (shown only when notes are present)
  - Empty state when no invoice exists for the order

extension.js: registered the component as a Fleet-Ops order details tab via
  menuService.registerMenuItem('fleet-ops:component:order:details', ...)
…saction linking

Issue 1 — Default invoice template setting:

  backend (SettingController):
  - Added `default_template_uuid` to invoice settings defaults, GET enrichment,
    and POST validation. The GET endpoint now resolves the template record and
    returns a `default_template` object (uuid, public_id, name) alongside the
    UUID so the frontend can display the name without a separate lookup.
    Mirrors the existing `default_gateway` enrichment in payment settings.

  backend (Invoice::boot creating hook):
  - Added `$defTemplateUuid` resolution from settings.
  - Added `if (empty($invoice->template_uuid) && !empty($defTemplateUuid))`
    guard so the default template is applied to every new invoice — including
    those auto-generated from Fleet-Ops purchase rates — when the caller has
    not explicitly set a template.

  frontend (settings/invoice.js):
  - Added `@service store`, `@tracked default_template_uuid`, `@tracked default_template`,
    `@tracked availableTemplates`.
  - Added `loadTemplates` ember-concurrency task that queries `ledger-invoice-template`.
  - Added `selectedTemplate` computed getter.
  - Added `onSelectDefaultTemplate` action.
  - `getSettings` now reads and stores `default_template_uuid` / `default_template`.
  - `saveSettings` now sends `default_template_uuid` to the backend.

  frontend (settings/invoice.hbs):
  - Added "Default Invoice Template" ContentPanel with a PowerSelect dropdown
    showing all available templates. Includes a "Create a template" link when
    no templates exist, matching the UX pattern of the payment gateway selector.

Issue 2 — Invoice not linked to transaction:

  InvoiceService::createFromOrder:
  - Added `'transaction_uuid' => $options['transaction_uuid'] ?? null` and
    `'template_uuid'    => $options['template_uuid'] ?? null` to the
    Invoice::create() call. PurchaseRateObserver already passed
    `transaction_uuid` in $options but it was silently ignored because it
    was never forwarded to the model. This single-line fix ensures the
    generated invoice is linked to the Fleet-Ops purchase rate transaction
    and appears in the invoice's Transactions tab.
…nt cross-engine TypeError

The order-invoice component is rendered inside the Fleet-Ops engine context.
@service invoiceActions is only registered in the Ledger engine container, so
the injection resolved to undefined in Fleet-Ops, causing:

  TypeError: Cannot read properties of undefined (reading 'getInvoiceUrl')

Changes:
- Removed @service invoiceActions injection entirely
- Inlined invoiceUrl getter: `${window.location.origin}/~/invoice?id=${invoice.public_id}`
- Replaced invoiceActions.transition.view() with hostRouter.transitionTo() in
  openInLedger(), with a window.open() fallback if the Ledger route is not
  reachable from the current engine context
- Added a clear NOTE in the JSDoc warning that Ledger-only services must not
  be injected in this component
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