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
2 changes: 1 addition & 1 deletion .github/workflows/ember.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
tags:
- 'v*'
pull_request:
branches: [ main ]
branches: [ main, 'dev-v*', 'release/v*' ]

env:
NODE_VERSION: 22.x
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/postman.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, 'dev-v*', 'release/v*']
workflow_dispatch:
permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
tags:
- 'v*'
pull_request:
branches: [ main ]
branches: [ main, 'dev-v*', 'release/v*' ]

jobs:
build:
Expand Down
18 changes: 11 additions & 7 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
> v0.0.12 ~ "Extensions can add columns, actions and buttons to ledger tables and panels"
> v0.0.13 ~ "Transaction lists stay current when what they show changes"

---
## Highlights
## Bug Fixes

- **Resource view registries.** Extensions can add the following through `ledger:<resource>:table:<slot>` and `ledger:<resource>:details:<slot>`:
- columns, row actions, bulk actions and toolbar buttons on every ledger table;
- header buttons and "…" menu items on every details panel.
- **Stale transaction lists.** The cached transactions list holds its line items, journal entries with their debit and credit accounts, and each transaction's subject, payer, payee, initiator and context. Edits to any of those didn't invalidate the list, so they could show stale values until the cache expired. The list's cache key now carries the versions of those tables.

Resources: `account`, `gateway`, `invoice`, `invoice-template`, `journal`, `transaction`, `wallet`. Built-in items carry stable ids, so registered items can be placed before or after them.
Two exceptions: orders and drivers are written constantly, so a transaction's order or driver snapshot can lag by up to the five-minute list cache.

- **Public API requests weren't logged.** Writes to the public wallet API (`ledger/v1/...`) and the customer invoice routes (`ledger/public/...`, such as paying an invoice) are now written to the API request logs, like core's public API. Webhooks and the console's internal routes aren't logged.

---
## Upgrading
Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5.
The transaction list fix takes effect with fleetbase/core-api v1.6.69, which adds cache dependency support ([fleetbase/core-api#301](https://github.com/fleetbase/core-api/pull/301)). With an older core-api the change is inert.

API request logging takes effect with fleetbase/core-api v1.6.69 ([fleetbase/core-api#302](https://github.com/fleetbase/core-api/pull/302)), which logs every route that runs `LogApiRequests` rather than only `v1/...`. With an older core-api, ledger requests are still not logged.

The console engine now depends on fleetbase/ember-ui v0.4.6 and fleetbase/ember-core v0.3.26.

---
## Need help?
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/ledger-api",
"version": "0.0.12",
"version": "0.0.13",
"description": "Accounting & Invoicing Extension for Fleetbase",
"keywords": [
"fleetbase",
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.12",
"version": "0.0.13",
"description": "Accounting & Invoicing Extension for Fleetbase",
"repository": "https://github.com/fleetbase/ledger",
"license": "AGPL-3.0-or-later",
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/ledger-engine",
"version": "0.0.12",
"version": "0.0.13",
"description": "Accounting & Invoicing Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
Expand Down Expand Up @@ -44,8 +44,8 @@
},
"dependencies": {
"@babel/core": "^7.23.2",
"@fleetbase/ember-core": "^0.3.25",
"@fleetbase/ember-ui": "^0.4.5",
"@fleetbase/ember-core": "^0.3.26",
"@fleetbase/ember-ui": "^0.4.6",
"@fleetbase/fleetops-data": "^0.1.37",
"@fortawesome/ember-fontawesome": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
Expand Down
4,286 changes: 2,138 additions & 2,148 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion scripts/pest-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function config(?string $key = null, mixed $default = null): mixed

if (!$app->bound('router')) {
if (!class_exists('Fleetbase\TestSupport\RouteRegistrar')) {
eval('namespace Fleetbase\TestSupport; class RouteRegistrar { public static array $routes = []; public static function reset(): void { self::$routes = []; } public function prefix(string $prefix): self { return $this; } public function namespace(string $namespace): self { return $this; } public function group(array|\Closure $attributes, ?\Closure $callback = null): self { ($callback ?? $attributes)($this); return $this; } public function get(string $uri, mixed $action): self { self::$routes[] = ["GET", $uri, $action]; return $this; } public function post(string $uri, mixed $action): self { self::$routes[] = ["POST", $uri, $action]; return $this; } public function fleetbaseRoutes(string $resource, ?\Closure $callback = null): self { self::$routes[] = ["RESOURCE", $resource, null]; if ($callback) { $callback($this, fn (string $method): string => $resource . "Controller@" . $method); } return $this; } }');
eval('namespace Fleetbase\TestSupport; class RouteRegistrar { public static array $routes = []; public static array $middleware = []; public static array $pending = []; public static function reset(): void { self::$routes = []; self::$middleware = []; self::$pending = []; } public function prefix(string $prefix): self { return $this; } public function namespace(string $namespace): self { return $this; } public function middleware(array|string $middleware): self { self::$pending = array_merge(self::$pending, (array) $middleware); return $this; } public function group(array|\Closure $attributes, ?\Closure $callback = null): self { $previous = self::$middleware; self::$middleware = array_merge(self::$middleware, self::$pending); self::$pending = []; if (is_array($attributes)) { self::$middleware = array_merge(self::$middleware, (array) ($attributes["middleware"] ?? [])); } ($callback ?? $attributes)($this); self::$middleware = $previous; return $this; } public function get(string $uri, mixed $action): self { self::$routes[] = ["GET", $uri, $action, self::$middleware]; return $this; } public function post(string $uri, mixed $action): self { self::$routes[] = ["POST", $uri, $action, self::$middleware]; return $this; } public function fleetbaseRoutes(string $resource, ?\Closure $callback = null): self { self::$routes[] = ["RESOURCE", $resource, null]; if ($callback) { $callback($this, fn (string $method): string => $resource . "Controller@" . $method); } return $this; } }');
}

$app->singleton('router', fn () => new Fleetbase\TestSupport\RouteRegistrar());
Expand Down
26 changes: 26 additions & 0 deletions server/src/Models/Transaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,32 @@ class Transaction extends BaseTransaction
'expires_at',
];

/**
* Tables the cached transactions list embeds, so writes to them invalidate it.
*
* TransactionFilter::queryForInternal eager loads the line items, the journal
* with its debit and credit accounts, and the subject, payer, payee, initiator
* and context morphs. Morph targets have no single table, so the ones ledger
* writes are listed: ledger invoices, the invoice customer types (contacts, vendors)
* and companies. Orders and drivers are left out on purpose: they are written
* constantly, so declaring them would leave the list almost never cached; their
* snapshots in the list may lag by up to the list cache TTL.
*
* Requires fleetbase/core-api's cache dependency support; inert before it.
*
* @var string[]
*/
protected $apiCacheDependencies = [
'items',
'journal',
'journal.debitAccount',
'journal.creditAccount',
'ledger_invoices',
'contacts',
'vendors',
'companies',
];

/**
* Merge ledger-specific fillable fields into the parent's list.
*/
Expand Down
5 changes: 3 additions & 2 deletions server/src/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ function ($router) {
|--------------------------------------------------------------------------
|
| These routes allow customers to view and pay invoices without logging in.
| They are scoped by the globally-unique invoice public_id / uuid.
| They are scoped by the globally-unique invoice public_id / uuid. Writes
| are logged to the API request logs like the public API's.
|
| GET /ledger/public/invoices/{public_id}
| GET /ledger/public/invoices/{public_id}/gateways
| POST /ledger/public/invoices/{public_id}/pay
*/
$router->prefix('public')->namespace('Public')->group(function ($router) {
$router->prefix('public')->namespace('Public')->middleware([Fleetbase\Http\Middleware\LogApiRequests::class])->group(function ($router) {
$router->get('invoices/{public_id}', 'PublicInvoiceController@show');
$router->get('invoices/{public_id}/gateways', 'PublicInvoiceController@gateways');
$router->post('invoices/{public_id}/pay', 'PublicInvoiceController@pay');
Expand Down
25 changes: 25 additions & 0 deletions server/tests/Models/ModelContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,31 @@ public function where(...$arguments): self
->and($gateway->toArray())->not->toHaveKey('config');
});

test('ledger transaction declares the relations its cached list eager loads as cache dependencies', function () {
$transaction = new Transaction();
$dependencies = (fn () => $this->apiCacheDependencies)->call($transaction);

expect($dependencies)->toBe(['items', 'journal', 'journal.debitAccount', 'journal.creditAccount', 'ledger_invoices', 'contacts', 'vendors', 'companies'])
// Raw entries name the morph targets' tables; contacts and vendors belong to FleetOps
->and((new Invoice())->getTable())->toBe('ledger_invoices')
->and((new Fleetbase\Models\Company())->getTable())->toBe('companies');

// Every relation path must resolve, or core's cache rejects the declaration
$tables = [];
foreach (array_filter($dependencies, fn ($path) => method_exists($transaction, explode('.', $path)[0])) as $path) {
$model = $transaction;
foreach (explode('.', $path) as $segment) {
$relation = $model->{$segment}();
expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\Relation::class)
->and($relation)->not->toBeInstanceOf(MorphTo::class);
$model = $relation->getRelated();
$tables[] = $model->getTable();
}
}

expect(array_values(array_unique($tables)))->toBe(['transaction_items', 'ledger_journals', 'ledger_accounts']);
});

test('wallet relationships computed values and state rules cover every owner type', function () {
$wallet = new WalletStateProbe(['balance' => 1050, 'status' => Wallet::STATUS_ACTIVE]);

Expand Down
22 changes: 22 additions & 0 deletions server/tests/Routes/RouteRegistrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,25 @@
'GET|reports/wallet-summary|ReportController@walletSummary',
);
});

test('Ledger logs requests to its public API and customer invoice routes but not webhooks or internal routes', function () {
RouteRegistrar::reset();
Facade::clearResolvedInstance('router');
Container::getInstance()->instance('router', new RouteRegistrar());

require __DIR__ . '/../../src/routes.php';

$middleware = [];
foreach (RouteRegistrar::$routes as $route) {
if (is_string($route[2])) {
$middleware[$route[0] . '|' . $route[1] . '|' . $route[2]] = $route[3] ?? [];
}
}

// The public API's fleetbase.api group runs LogApiRequests
expect($middleware['POST|wallet/topup|Api\v1\WalletApiController@topUp'])->toBe(['fleetbase.api'])
->and($middleware['POST|invoices/{public_id}/pay|PublicInvoiceController@pay'])->toBe([Fleetbase\Http\Middleware\LogApiRequests::class])
->and($middleware['GET|invoices/{public_id}|PublicInvoiceController@show'])->toBe([Fleetbase\Http\Middleware\LogApiRequests::class])
->and($middleware['POST|webhooks/{driver}|WebhookController@handle'])->toBe([])
->and($middleware['GET|gateways/drivers|GatewayController@drivers'])->toBe(['fleetbase.protected']);
});
Loading