From 8005a1a4ac99e000819fc2cd1b462fff867086cc Mon Sep 17 00:00:00 2001 From: Ron Date: Thu, 3 Sep 2026 12:21:17 +0800 Subject: [PATCH 01/12] ci(release): accept release/v* branches alongside dev-v* Both prefixes are honoured so a release branch opened before the rename still releases. A merge that produces no tag and no publish looks exactly like a successful one, so the cutover is deliberately not a flag day. --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1476e7..06dd211 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ name: Release Tag -# Tags a release when a `dev-v*` branch is merged to main, and pushes the tag. Delegates +# Tags a release when a release branch is merged to main, and pushes the tag. Delegates # to the reusable workflow in fleetbase/fleetbase. Requires org secret _GITHUB_AUTH_TOKEN # (inherited); no-ops with a warning until it is set. # @@ -38,7 +38,8 @@ jobs: tag: if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'dev-v')) + (startsWith(github.event.pull_request.head.ref, 'release/v') || + startsWith(github.event.pull_request.head.ref, 'dev-v'))) uses: fleetbase/fleetbase/.github/workflows/release-tag.yml@main with: version-files: composer.json,package.json,extension.json From 0a2dae18940397adf85db32704a8313809ee51ef Mon Sep 17 00:00:00 2001 From: ispdomnet <95652995+ispdomnet@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:42:12 +0300 Subject: [PATCH 02/12] Add Ukrainian translation --- translations/uk-ua.yaml | 107 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 translations/uk-ua.yaml diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml new file mode 100644 index 0000000..a736f4c --- /dev/null +++ b/translations/uk-ua.yaml @@ -0,0 +1,107 @@ +ledger: + extension-name: Головна книга + +common: + ledger: Головна книга + overview: Огляд + transactions: Транзакції + +resource: + account: Рахунок + accounts: Рахунки + invoice: Рахунок-фактура + invoices: Рахунки-фактури + invoice-template: Шаблон рахунка-фактури + invoice-templates: Шаблони рахунків-фактур + journal: Журнальний запис + journal-entries: Журнальні записи + transaction: Транзакція + transactions: Транзакції + wallet: Гаманець + wallets: Гаманці + gateway: Шлюз + gateways: Шлюзи + wallet-transaction: Транзакція гаманця + wallet-transactions: Транзакції гаманця + +column: + id: ID + number: Номер + name: Назва + code: Код + type: Тип + status: Статус + direction: Напрямок + description: Опис + date: Дата + created-at: Створено + updated-at: Оновлено + amount: Сума + balance: Баланс + balance-after: Баланс після + total: Всього + net-amount: Чиста сума + fee: Комісія + tax: Податок + currency: Валюта + gateway: Шлюз + payment-method: Спосіб оплати + reference: Референс + memo: Примітка + period: Період + settled-at: Оплачено о + failure-reason: Причина помилки + due-date: Термін сплати + issued-at: Видано о + invoice-date: Дата рахунка + customer: Клієнт + debit-account: Дебетовий рахунок + credit-account: Кредитовий рахунок + source: Джерело + driver: Драйвер + environment: Середовище + default: За замовчуванням + owner: Власник + payer: Платник + payee: Отримувач платежу + frozen: Заморожено + orientation: Орієнтація + +menu: + dashboard: Панель керування + billing: Білінг + invoices: Рахунки-фактури + payments: Платежі + wallets: Гаманці + gateways: Шлюзи + accounting: Бухгалтерія + chart-of-accounts: План рахунків + journal-entries: Журнальні записи + general-ledger: Головна книга + reports: Звіти + income-statement: Звіт про прибутки та збитки + balance-sheet: Баланс + trial-balance: Оборотно-сальдова відомість + cash-flow: Рух коштів + ar-aging: Заборгованість за термінами (AR Aging) + wallet-summary: Зведення по гаманцях + settings: Налаштування + invoice-settings: Налаштування рахунків + payment-settings: Налаштування платежів + accounting-settings: Бухгалтерські налаштування + +invoice: + no-transactions: Для цього рахунка-фактури немає записаних транзакцій. + actions: + preview-invoice: "Попередній перегляд рахунка №{number}" + preview-invoice-fallback: Попередній перегляд рахунка + download-pdf: Завантажити PDF + record-payment: Зареєструвати платіж + record-payment-title: "Зареєструвати платіж — {number}" + copy-payment-link: Скопіювати посилання на оплату + copy-invoice-url: Скопіювати URL рахунка + payment-link-copied: URL рахунка скопійовано до буфера обміну. + payment-link-copy-failed: Не вдалося скопіювати URL рахунка. Будь ласка, скопіюйте його вручну. + send: Надіслати рахунок + void: Анулювати рахунок + edit: Редагувати рахунок \ No newline at end of file From 8aa52049a2a8df478e0e65b70a5b8d0159045e73 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 14:59:46 +0800 Subject: [PATCH 03/12] Place ledger's widgets on the shared default dashboard The default dashboard mixes every extension's default widgets, and the grid placed them in whatever order the extensions booted. Ledger's widgets on it now carry an `order` (see fleet-ops' registerWidgets for the whole layout): - Revenue (20) joins the top KPI row beside fleet-ops' Radar and Active Orders. - Expenses, Net Income, Outstanding AR and Overdue AR (110-140) form their own row of four; Overdue AR is new on the default dashboard. - Recent Financial Activity (150) fills the left half, 19 rows high: the console's Blog and GitHub card stack on the right at 13 + 6 rows. Ledger's own dashboard keeps its full widget set, unordered. --- addon/extension.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/addon/extension.js b/addon/extension.js index e12f985..d869e1b 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -335,14 +335,21 @@ export default { widgetService.registerDashboard('ledger'); widgetService.registerWidgets('ledger', widgets); + // Ledger's widgets on the shared default dashboard. `order` places them among every + // extension's widgets (see fleet-ops' registerWidgets for the whole layout): Revenue + // joins the top KPI row beside Radar (10) and Active Orders (30), the other four KPIs + // form their own row, and Recent Financial Activity fills the left half beside the + // console's Blog (160, h 13) and GitHub card (170, h 6) stacked on the right, so its + // height is theirs combined. Ledger's own dashboard above keeps its full set, unordered. widgetService.registerWidgets('dashboard', [ + getWidgetById('ledger-kpi-revenue', (widget) => widget.setOption('order', 20)), + getWidgetById('ledger-kpi-expenses', (widget) => widget.setOption('order', 110)), + getWidgetById('ledger-kpi-net-income', (widget) => widget.setOption('order', 120)), + getWidgetById('ledger-kpi-outstanding-ar', (widget) => widget.setOption('order', 130)), + getWidgetById('ledger-kpi-overdue-ar', (widget) => widget.setOption('order', 140)), getWidgetById('ledger-activity-feed', (widget) => { - widget.withGridOptions({ w: 6, minW: 6, h: 8, minH: 8 }); + widget.withGridOptions({ w: 6, minW: 6, h: 19, minH: 8 }).setOption('order', 150); }), - getWidgetById('ledger-kpi-revenue'), - getWidgetById('ledger-kpi-net-income'), - getWidgetById('ledger-kpi-outstanding-ar'), - getWidgetById('ledger-kpi-expenses'), ]); }, }; From f3d8ad2714fcf8229f310097211b9014376a1c98 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 15:13:31 +0800 Subject: [PATCH 04/12] Save the chosen base and invoice currencies in ledger settings Ledger > Settings > Accounting (base currency) and Invoice (default currency) never saved a chosen currency: CurrencySelect calls @onCurrencyChange with the ISO code first (then the currency object), but both handlers read `.code` off that argument. The string has no `.code`, so the field was set to null and saved as null, and after a reload the page fell back to the organisation default. Both handlers now take the code. Fixes fleetbase/fleetbase#678 --- addon/controllers/settings/accounting.js | 7 ++++--- addon/controllers/settings/invoice.js | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/addon/controllers/settings/accounting.js b/addon/controllers/settings/accounting.js index 3270e84..00f05df 100644 --- a/addon/controllers/settings/accounting.js +++ b/addon/controllers/settings/accounting.js @@ -141,10 +141,11 @@ export default class SettingsAccountingController extends Controller { // ── Actions ─────────────────────────────────────────────────────────────── - @action onSelectCurrency(currency) { - // CurrencySelect passes the full currency object; store the ISO code. + @action onSelectCurrency(code) { + // CurrencySelect calls @onCurrencyChange(code, currency): the ISO code comes first. + // Reading `.code` off it stored null, so a chosen currency never saved (#678). // Clearing the selection (null/undefined) resets to company default. - this.base_currency = currency?.code ?? null; + this.base_currency = code || null; } @action onSelectFiscalYearMonth(option) { diff --git a/addon/controllers/settings/invoice.js b/addon/controllers/settings/invoice.js index 873e2b1..617e018 100644 --- a/addon/controllers/settings/invoice.js +++ b/addon/controllers/settings/invoice.js @@ -135,10 +135,11 @@ export default class SettingsInvoiceController extends Controller { // ── Actions ─────────────────────────────────────────────────────────────── - @action onSelectCurrency(currency) { - // CurrencySelect passes the full currency object; store the ISO code. + @action onSelectCurrency(code) { + // CurrencySelect calls @onCurrencyChange(code, currency): the ISO code comes first. + // Reading `.code` off it stored null, so a chosen currency never saved (#678). // Clearing the selection (null/undefined) resets to company default. - this.default_currency = currency?.code ?? null; + this.default_currency = code || null; } @action onSelectPaymentTerms(option) { From 2bc461deea1eb697bded6b5d35a456864ab20b54 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 15:22:21 +0800 Subject: [PATCH 05/12] Let a wallet's currency be changed without touching its balance Editing a wallet (for example to change its currency) failed with a 400: "Column 'balance' cannot be null". The console sends the whole record back, including the balance, and the update wrote it straight into the wallet. - The wallet serializer no longer sends balance or formatted_balance. - The wallet update endpoint drops them too, so an edit can never overwrite a balance that should only move through transactions (credit, top-up, payout, transfer). Part of fleetbase/fleetbase#678 --- addon/serializers/ledger-wallet.js | 15 ++++++++- .../Internal/v1/WalletController.php | 31 +++++++++++++++++++ .../Http/Controllers/WalletControllerTest.php | 18 +++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/addon/serializers/ledger-wallet.js b/addon/serializers/ledger-wallet.js index 35354ab..afc4c32 100644 --- a/addon/serializers/ledger-wallet.js +++ b/addon/serializers/ledger-wallet.js @@ -1 +1,14 @@ -export { default } from './ledger'; +import LedgerSerializer from './ledger'; + +export default class LedgerWalletSerializer extends LedgerSerializer { + /** + * A wallet's balance only moves through its transactions, so saving an edit never sends + * it back (the server ignores it too). + */ + get attrs() { + return { + balance: { serialize: false }, + formatted_balance: { serialize: false }, + }; + } +} diff --git a/server/src/Http/Controllers/Internal/v1/WalletController.php b/server/src/Http/Controllers/Internal/v1/WalletController.php index 620ad19..5289091 100644 --- a/server/src/Http/Controllers/Internal/v1/WalletController.php +++ b/server/src/Http/Controllers/Internal/v1/WalletController.php @@ -12,6 +12,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Arr; class WalletController extends LedgerResourceController { @@ -33,6 +34,36 @@ public function __construct(WalletService $walletService) $this->walletService = $walletService; } + /** + * Update a wallet's details. + * + * A wallet's balance only moves through its transactions (credit, top-up, payout, + * transfer), never through an edit. The console sends the whole record back when a + * wallet is edited, so drop the balance fields rather than let an edit overwrite the + * balance (or fail with a null one). + */ + public function updateRecord(Request $request, string $id) + { + return parent::updateRecord($this->withoutReadOnlyFields($request), $id); + } + + /** + * Drop the fields an edit may not set, with or without the `wallet` payload key. + */ + protected function withoutReadOnlyFields(Request $request): Request + { + $readOnly = ['balance', 'formatted_balance']; + + // The body lives in the JSON bag for JSON requests, the request bag otherwise. + $input = $request->isJson() ? $request->json() : $request->request; + $input->replace(Arr::except($input->all(), $readOnly)); + if (is_array($request->input('wallet'))) { + $request->merge(['wallet' => Arr::except($request->input('wallet'), $readOnly)]); + } + + return $request; + } + // ========================================================================= // Financial Operations // ========================================================================= diff --git a/server/tests/Http/Controllers/WalletControllerTest.php b/server/tests/Http/Controllers/WalletControllerTest.php index 1120550..f592d04 100644 --- a/server/tests/Http/Controllers/WalletControllerTest.php +++ b/server/tests/Http/Controllers/WalletControllerTest.php @@ -374,6 +374,24 @@ function walletControllerJson(mixed $response): array expect($unchanged['corrected'])->toBeFalse(); }); +test('editing a wallet never sends its balance on to the update', function () { + $controller = new WalletController(new WalletControllerService()); + $strip = new ReflectionMethod($controller, 'withoutReadOnlyFields'); + + // The console sends the whole record back, including the balance it read (null when the + // list payload left it out), which used to fail the NOT NULL balance column. + $keyed = WalletControllerRequest::create('/ledger/int/v1/wallets/edited-wallet', 'PUT', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ + 'wallet' => ['name' => 'Renamed wallet', 'currency' => 'CAD', 'balance' => null, 'formatted_balance' => '$0.00'], + ])); + expect($strip->invoke($controller, $keyed)->input('wallet'))->toBe(['name' => 'Renamed wallet', 'currency' => 'CAD']); + + // A balance sent without the resource key is dropped too. + $bare = WalletControllerRequest::create('/ledger/int/v1/wallets/edited-wallet', 'PUT', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ + 'status' => 'active', 'balance' => 99999, + ])); + expect($strip->invoke($controller, $bare)->all())->toBe(['status' => 'active']); +}); + test('internal wallet resolution rejects cross-company identifiers', function () { $service = new WalletControllerService(); $controller = new WalletController($service); From c621adeaa25c2aae6fdab69a753a581c13b51966 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 15:30:02 +0800 Subject: [PATCH 06/12] Move ledger's KPI row up and add Cash Flow Summary to the default dashboard - Expenses, Net Income, Outstanding AR and Overdue AR (41-44) now form the second row, directly under the top KPI row. - Recent Financial Activity is 10 rows tall with Cash Flow Summary (165, 9 rows) under it, together as tall as the console's Blog and GitHub card beside them. Cash Flow is ordered after the Blog so the grid places it under Activity rather than beside it. --- addon/extension.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/addon/extension.js b/addon/extension.js index d869e1b..1d7fc98 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -336,19 +336,24 @@ export default { widgetService.registerDashboard('ledger'); widgetService.registerWidgets('ledger', widgets); // Ledger's widgets on the shared default dashboard. `order` places them among every - // extension's widgets (see fleet-ops' registerWidgets for the whole layout): Revenue - // joins the top KPI row beside Radar (10) and Active Orders (30), the other four KPIs - // form their own row, and Recent Financial Activity fills the left half beside the - // console's Blog (160, h 13) and GitHub card (170, h 6) stacked on the right, so its - // height is theirs combined. Ledger's own dashboard above keeps its full set, unordered. + // extension's widgets (see fleet-ops' registerWidgets for the whole layout): + // 20 Revenue, in the top KPI row beside Radar (10) and Active Orders (30) + // 41-44 Expenses, Net Income, Outstanding AR, Overdue AR: the second KPI row + // 150/165 Recent Financial Activity (10 rows) with Cash Flow Summary (9) under it, + // beside the console's Blog (160, 13 rows) and GitHub card (170, 6 rows). + // Cash Flow comes after the Blog so it lands under Activity, not beside it. + // Ledger's own dashboard above keeps its full set, unordered. widgetService.registerWidgets('dashboard', [ getWidgetById('ledger-kpi-revenue', (widget) => widget.setOption('order', 20)), - getWidgetById('ledger-kpi-expenses', (widget) => widget.setOption('order', 110)), - getWidgetById('ledger-kpi-net-income', (widget) => widget.setOption('order', 120)), - getWidgetById('ledger-kpi-outstanding-ar', (widget) => widget.setOption('order', 130)), - getWidgetById('ledger-kpi-overdue-ar', (widget) => widget.setOption('order', 140)), + getWidgetById('ledger-kpi-expenses', (widget) => widget.setOption('order', 41)), + getWidgetById('ledger-kpi-net-income', (widget) => widget.setOption('order', 42)), + getWidgetById('ledger-kpi-outstanding-ar', (widget) => widget.setOption('order', 43)), + getWidgetById('ledger-kpi-overdue-ar', (widget) => widget.setOption('order', 44)), getWidgetById('ledger-activity-feed', (widget) => { - widget.withGridOptions({ w: 6, minW: 6, h: 19, minH: 8 }).setOption('order', 150); + widget.withGridOptions({ w: 6, minW: 6, h: 10, minH: 8 }).setOption('order', 150); + }), + getWidgetById('ledger-cash-flow-summary', (widget) => { + widget.withGridOptions({ w: 6, minW: 5, h: 9, minH: 8 }).setOption('order', 165); }), ]); }, From b10e7d0fc9edad72f368939fe1f3d7fad03b122f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 25 Sep 2026 16:13:09 +0800 Subject: [PATCH 07/12] chore(release): v0.0.11 --- RELEASE.md | 15 +++++++++------ composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index ba3c198..92af8ea 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,18 +1,21 @@ -> v0.0.10 ~ "The public wallet API is reachable" +> v0.0.11 ~ "Currencies that save, and ledger on the default dashboard" --- ## Highlights -All four public wallet routes answered `401` to every credential — including a driver's own Sanctum token, which authenticates fine against every other public endpoint. The API was effectively unusable for wallet operations. +Ledger's widgets take a planned place on the console's Default Dashboard, and the base and invoice currencies chosen in settings are saved. + +--- +## Improvements +- **Ledger on the Default Dashboard.** Revenue sits in the top KPI row beside Fleet-Ops' Radar, Active Orders and Drivers Online. Expenses, Net Income, Outstanding AR and Overdue AR form the row under it; Overdue AR is new on the default dashboard. Recent Financial Activity and Cash Flow Summary sit in the lower left, as tall as the Blog and GitHub cards beside them. Ledger's own dashboard keeps its full widget set. The layout needs `@fleetbase/ember-ui` v0.4.4; on older versions the widgets appear as before. --- ## Bug Fixes -- **The four public wallet routes were unreachable by any credential.** The `fleetbase.api` middleware authenticates with `Auth::setSession()`, which writes the session keys but leaves `$login` false, so no user resolver is ever bound and `$request->user()` is null on every public API request. `WalletApiController` now falls back to the session identity that middleware actually records. -- **Unauthenticated requests returned an HTML page, not JSON.** `abort()` rendered Laravel's error page, so an API client parsing JSON received ~1.8 KB of markup titled "Unauthorized". The controller now throws `AuthenticationException`, which the API exception handler renders as `{"errors":["Unauthenticated."]}` with a 401. +- **The base and invoice currencies never saved** ([fleetbase/fleetbase#678](https://github.com/fleetbase/fleetbase/issues/678)). `CurrencySelect` passes the ISO code first, but the Accounting and Invoice settings read `.code` from it, so Save stored `null` and the page fell back to the default after a reload. +- **A wallet's currency couldn't be changed.** Editing a wallet failed with `Column 'balance' cannot be null`, because the console sent the whole record back. The serializer no longer sends `balance` or `formatted_balance`, and `WalletController::updateRecord` drops them, so a balance only moves through transactions. --- ## Continuous Integration -- The Postman API contract now runs against this branch's API code rather than the published package, so a release PR's own changes are actually exercised. -- The contract workflow tracks the current platform release instead of a pinned ref. +- The release workflow accepts `release/v*` branches alongside `dev-v*`. --- ## Need help? diff --git a/composer.json b/composer.json index 11721c5..3939a15 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/ledger-api", - "version": "0.0.10", + "version": "0.0.11", "description": "Accounting & Invoicing Extension for Fleetbase", "keywords": [ "fleetbase", diff --git a/extension.json b/extension.json index 258b975..90f741a 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Ledger", - "version": "0.0.10", + "version": "0.0.11", "description": "Accounting & Invoicing Extension for Fleetbase", "repository": "https://github.com/fleetbase/ledger", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 3b1983b..3f28d4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ledger-engine", - "version": "0.0.10", + "version": "0.0.11", "description": "Accounting & Invoicing Extension for Fleetbase", "keywords": [ "fleetbase-extension", From a300d7ffb5784940115a8e45188edee33d5bb3f1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 26 Sep 2026 13:51:50 +0800 Subject: [PATCH 08/12] fix(invoices): apply company invoice settings when creating invoices without a session Invoice::creating read ledger.invoice-settings through lookupCompany(), which needs session('company'). Invoices created from queued jobs, listeners and observers skipped the configured prefix, currency, terms and due date. Resolve the settings from the invoice's company, falling back to the session company. Requires fleetbase/core-api >=1.6.65 (fleetbase/core-api#262). --- composer.json | 2 +- server/src/Models/Invoice.php | 7 ++++--- server/tests/Models/ModelLifecycleTest.php | 21 +++++++++++++++++++++ vendor | 1 + 4 files changed, 27 insertions(+), 4 deletions(-) create mode 120000 vendor diff --git a/composer.json b/composer.json index 3939a15..6756be5 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "*", + "fleetbase/core-api": ">=1.6.65", "fleetbase/fleetops-api": "*", "guzzlehttp/guzzle": "^7.0", "php-http/guzzle7-adapter": "^1.0", diff --git a/server/src/Models/Invoice.php b/server/src/Models/Invoice.php index 6aef04c..c119309 100644 --- a/server/src/Models/Invoice.php +++ b/server/src/Models/Invoice.php @@ -162,9 +162,10 @@ public static function boot(): void static::creating(function (Invoice $invoice): void { // ── Load company invoice settings ────────────────────────────────── - // Setting::lookupCompany uses session('company') which is always set - // for authenticated internal requests. Returns [] when not yet saved. - $settings = Setting::lookupCompany('ledger.invoice-settings', []); + // Resolve from the invoice's company so invoices created from queued + // jobs, listeners and observers (no company session) still pick up + // the company settings. Returns [] when not yet saved. + $settings = Setting::lookupForCompany($invoice->company_uuid ?? session('company'), 'ledger.invoice-settings', []); if (!is_array($settings)) { $settings = []; } diff --git a/server/tests/Models/ModelLifecycleTest.php b/server/tests/Models/ModelLifecycleTest.php index c8d3bb0..c9bed24 100644 --- a/server/tests/Models/ModelLifecycleTest.php +++ b/server/tests/Models/ModelLifecycleTest.php @@ -152,6 +152,27 @@ expect($legacy->due_date->format('Y-m-d'))->toBe('2026-07-15'); }); +test('invoice creation applies the invoice company settings without a company session', function () { + Capsule::table('settings')->insert([ + 'key' => 'company.company-queued-invoice.ledger.invoice-settings', + 'value' => json_encode(['invoice_prefix' => 'QUE', 'default_currency' => 'SGD']), + ]); + Cache::flush(); + + // Invoices created from queued jobs and listeners have no company session + session(['company' => null]); + $invoice = new Invoice([ + 'uuid' => 'invoice-queued', + 'public_id' => 'invoice_queued', + 'company_uuid' => 'company-queued-invoice', + 'date' => '2026-07-01', + ]); + $invoice->save(); + + expect($invoice->number)->toStartWith('QUE-') + ->and($invoice->currency)->toBe('SGD'); +}); + test('invoice number generation retries collisions including soft deleted records', function () { mt_srand(1234); $first = mt_rand(1, 9); diff --git a/vendor b/vendor new file mode 120000 index 0000000..120a4bb --- /dev/null +++ b/vendor @@ -0,0 +1 @@ +/private/tmp/claude-501/-Users-ron-Development-fleetbase-oss-fleetbase-dev-packages-core-api--claude-worktrees-serene-davinci-9ff478/63716512-c69f-4a9c-b325-ec9a45ddc06e/scratchpad/ledger/server_vendor \ No newline at end of file From 984ed286bb4ebe341b603230e81238126f970e64 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 28 Sep 2026 16:56:56 +0800 Subject: [PATCH 09/12] fix: repair Ledger test setup and scoped invoice defaults --- .gitignore | 3 ++ scripts/pest-runner.php | 15 ++++++++-- server/src/Models/Invoice.php | 3 +- server/tests/Models/ModelLifecycleTest.php | 32 ++++++++++++++++++++++ vendor | 1 - 5 files changed, 50 insertions(+), 4 deletions(-) delete mode 120000 vendor diff --git a/.gitignore b/.gitignore index 8c54a5a..26b973b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ composer.lock *.swp *.swo .DS_Store + +# Temporary Pest compatibility link (server_vendor is the Composer vendor directory) +/vendor diff --git a/scripts/pest-runner.php b/scripts/pest-runner.php index dd59292..fc25106 100644 --- a/scripts/pest-runner.php +++ b/scripts/pest-runner.php @@ -24,8 +24,19 @@ $serverVendor = getcwd() . '/server_vendor'; $vendor = getcwd() . '/vendor'; -if (!file_exists($vendor) && is_dir($serverVendor) && function_exists('symlink')) { - @symlink($serverVendor, $vendor); +// Pest 1 resolves PHPUnit through vendor even when Composer uses server_vendor. +// Keep this compatibility link local to the test run so it cannot enter releases. +$createdVendorSymlink = false; +if (!file_exists($vendor) && !is_link($vendor) && is_dir($serverVendor)) { + $createdVendorSymlink = symlink('server_vendor', $vendor); +} + +if ($createdVendorSymlink) { + register_shutdown_function(static function () use ($vendor): void { + if (is_link($vendor) && readlink($vendor) === 'server_vendor') { + unlink($vendor); + } + }); } $bootstrap = getcwd() . '/scripts/pest-bootstrap.php'; diff --git a/server/src/Models/Invoice.php b/server/src/Models/Invoice.php index c119309..18b2b1a 100644 --- a/server/src/Models/Invoice.php +++ b/server/src/Models/Invoice.php @@ -165,7 +165,8 @@ public static function boot(): void // Resolve from the invoice's company so invoices created from queued // jobs, listeners and observers (no company session) still pick up // the company settings. Returns [] when not yet saved. - $settings = Setting::lookupForCompany($invoice->company_uuid ?? session('company'), 'ledger.invoice-settings', []); + $companyUuid = $invoice->company_uuid ?? session('company'); + $settings = $companyUuid ? Setting::lookup('company.' . $companyUuid . '.ledger.invoice-settings', []) : []; if (!is_array($settings)) { $settings = []; } diff --git a/server/tests/Models/ModelLifecycleTest.php b/server/tests/Models/ModelLifecycleTest.php index c9bed24..37991d4 100644 --- a/server/tests/Models/ModelLifecycleTest.php +++ b/server/tests/Models/ModelLifecycleTest.php @@ -173,6 +173,38 @@ ->and($invoice->currency)->toBe('SGD'); }); +test('invoice settings use the invoice company even when another company is signed in', function () { + Capsule::table('settings')->insert([ + ['key' => 'company.company-invoice.ledger.invoice-settings', 'value' => json_encode(['invoice_prefix' => 'OWN', 'default_currency' => 'SGD'])], + ['key' => 'company.company-session.ledger.invoice-settings', 'value' => json_encode(['invoice_prefix' => 'OTHER', 'default_currency' => 'USD'])], + ]); + Cache::flush(); + session(['company' => 'company-session']); + $invoice = new Invoice([ + 'uuid' => 'invoice-explicit-company', + 'public_id' => 'invoice_explicit_company', + 'company_uuid' => 'company-invoice', + 'date' => '2026-07-01', + ]); + $invoice->save(); + + expect($invoice->number)->toStartWith('OWN-') + ->and($invoice->currency)->toBe('SGD'); +}); + +test('invoice creation without any company context uses safe defaults', function () { + session(['company' => null]); + $invoice = new Invoice([ + 'uuid' => 'invoice-no-company', + 'public_id' => 'invoice_no_company', + 'date' => '2026-07-01', + ]); + $invoice->save(); + + expect($invoice->number)->toStartWith('INV-') + ->and($invoice->currency)->toBeNull(); +}); + test('invoice number generation retries collisions including soft deleted records', function () { mt_srand(1234); $first = mt_rand(1, 9); diff --git a/vendor b/vendor deleted file mode 120000 index 120a4bb..0000000 --- a/vendor +++ /dev/null @@ -1 +0,0 @@ -/private/tmp/claude-501/-Users-ron-Development-fleetbase-oss-fleetbase-dev-packages-core-api--claude-worktrees-serene-davinci-9ff478/63716512-c69f-4a9c-b325-ec9a45ddc06e/scratchpad/ledger/server_vendor \ No newline at end of file From f448b9bd1e36f6175848931ebd3399244d1caa6e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 28 Sep 2026 17:06:07 +0800 Subject: [PATCH 10/12] fix: retain Core API dependency for invoice settings --- server/src/Models/Invoice.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/src/Models/Invoice.php b/server/src/Models/Invoice.php index 18b2b1a..c119309 100644 --- a/server/src/Models/Invoice.php +++ b/server/src/Models/Invoice.php @@ -165,8 +165,7 @@ public static function boot(): void // Resolve from the invoice's company so invoices created from queued // jobs, listeners and observers (no company session) still pick up // the company settings. Returns [] when not yet saved. - $companyUuid = $invoice->company_uuid ?? session('company'); - $settings = $companyUuid ? Setting::lookup('company.' . $companyUuid . '.ledger.invoice-settings', []) : []; + $settings = Setting::lookupForCompany($invoice->company_uuid ?? session('company'), 'ledger.invoice-settings', []); if (!is_array($settings)) { $settings = []; } From 5dbf04954223bf9780c3d6cb54ab93d65874ad00 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 28 Sep 2026 17:08:24 +0800 Subject: [PATCH 11/12] test: verify wallet edit payload excludes balances --- .../Http/Controllers/WalletControllerTest.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/server/tests/Http/Controllers/WalletControllerTest.php b/server/tests/Http/Controllers/WalletControllerTest.php index f592d04..7d13b2c 100644 --- a/server/tests/Http/Controllers/WalletControllerTest.php +++ b/server/tests/Http/Controllers/WalletControllerTest.php @@ -392,6 +392,40 @@ function walletControllerJson(mixed $response): array expect($strip->invoke($controller, $bare)->all())->toBe(['status' => 'active']); }); +test('wallet update delegates only editable fields to validation and persistence', function () { + $wallet = walletControllerWallet(['uuid' => 'wallet-safe-edit']); + $request = WalletControllerRequest::create('/ledger/int/v1/wallets/wallet-safe-edit', 'PUT', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ + 'wallet' => ['name' => 'Renamed wallet', 'balance' => null, 'formatted_balance' => '$0.00'], + ])); + Container::getInstance()->instance('request', $request); + + // Validation and persistence belong to Core. Pin the payload crossing both + // boundaries so a full-record Console edit cannot overwrite a monetary balance. + $controller = $this->getMockBuilder(WalletController::class) + ->setConstructorArgs([new WalletControllerService()]) + ->onlyMethods(['validateRequest']) + ->getMock(); + $controller->expects($this->once())->method('validateRequest')->with($this->callback(function (Request $validated) use ($request) { + expect($validated)->toBe($request) + ->and($validated->input('wallet'))->toBe(['name' => 'Renamed wallet']); + + return true; + })); + $model = $this->getMockBuilder(Wallet::class)->onlyMethods(['updateRecordFromRequest'])->getMock(); + $model->expects($this->once())->method('updateRecordFromRequest')->willReturnCallback(function (Request $updated, $id) use ($request, $wallet) { + expect($updated)->toBe($request) + ->and($id)->toBe($wallet->uuid) + ->and($updated->input('wallet'))->toBe(['name' => 'Renamed wallet']); + + return $wallet; + }); + $controller->model = $model; + + $result = $controller->updateRecord($request, $wallet->uuid); + expect($result)->toBeInstanceOf(Fleetbase\Ledger\Http\Resources\v1\Wallet::class) + ->and($result->resource)->toBe($wallet); +}); + test('internal wallet resolution rejects cross-company identifiers', function () { $service = new WalletControllerService(); $controller = new WalletController($service); From b315c7fb31e46e291de37634ef559114925c2715 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 28 Sep 2026 17:57:42 +0800 Subject: [PATCH 12/12] ci: install released dependencies for Ledger API contracts --- .github/workflows/postman.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 1e4db90..68f8296 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -5,14 +5,9 @@ name: API Contract (Postman) # fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN # (inherited); no-ops until POSTMAN_API_KEY is set. # -# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main -# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and -# there is no ref here to remember to bump. Each run records the image digest it actually -# resolved in its job summary, so a result stays traceable. To reproduce an older run: -# -# with: -# fleetbase-ref: v0.7.53 -# api-image: fleetbase/fleetbase-api:v0.7.53 +# Pin the workflow and stack fixtures together. This runner installs the branch +# through Composer so its released dependencies, including Core API >=1.6.65, +# replace older versions baked into the published image. on: push: @@ -25,8 +20,9 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@880c7392c67c93e3a65f01916d7a04036c098935 with: + fleetbase-ref: 880c7392c67c93e3a65f01916d7a04036c098935 collections: "Fleetbase Ledger API" build-from-source: false # Without this the run tests the version of fleetbase/ledger-api baked into the