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 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 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/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/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) { diff --git a/addon/extension.js b/addon/extension.js index e12f985..1d7fc98 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -335,14 +335,26 @@ 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): + // 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', 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: 8, minH: 8 }); + 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); }), - getWidgetById('ledger-kpi-revenue'), - getWidgetById('ledger-kpi-net-income'), - getWidgetById('ledger-kpi-outstanding-ar'), - getWidgetById('ledger-kpi-expenses'), ]); }, }; 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/composer.json b/composer.json index 11721c5..6756be5 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", @@ -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/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", 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/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/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/Http/Controllers/WalletControllerTest.php b/server/tests/Http/Controllers/WalletControllerTest.php index 1120550..7d13b2c 100644 --- a/server/tests/Http/Controllers/WalletControllerTest.php +++ b/server/tests/Http/Controllers/WalletControllerTest.php @@ -374,6 +374,58 @@ 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('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); diff --git a/server/tests/Models/ModelLifecycleTest.php b/server/tests/Models/ModelLifecycleTest.php index c8d3bb0..37991d4 100644 --- a/server/tests/Models/ModelLifecycleTest.php +++ b/server/tests/Models/ModelLifecycleTest.php @@ -152,6 +152,59 @@ 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 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/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