Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions .github/workflows/postman.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ composer.lock
*.swp
*.swo
.DS_Store

# Temporary Pest compatibility link (server_vendor is the Composer vendor directory)
/vendor
15 changes: 9 additions & 6 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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?
Expand Down
7 changes: 4 additions & 3 deletions addon/controllers/settings/accounting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 4 additions & 3 deletions addon/controllers/settings/invoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
22 changes: 17 additions & 5 deletions addon/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
},
};
15 changes: 14 additions & 1 deletion addon/serializers/ledger-wallet.js
Original file line number Diff line number Diff line change
@@ -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 },
};
}
}
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/ledger-api",
"version": "0.0.10",
"version": "0.0.11",
"description": "Accounting & Invoicing Extension for Fleetbase",
"keywords": [
"fleetbase",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion extension.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 13 additions & 2 deletions scripts/pest-runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
31 changes: 31 additions & 0 deletions server/src/Http/Controllers/Internal/v1/WalletController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
// =========================================================================
Expand Down
7 changes: 4 additions & 3 deletions server/src/Models/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
}
Expand Down
52 changes: 52 additions & 0 deletions server/tests/Http/Controllers/WalletControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions server/tests/Models/ModelLifecycleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading