Skip to content
Open
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
9 changes: 9 additions & 0 deletions .github/workflows/server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,12 @@ jobs:
name: ledger-coverage-clover
path: coverage/clover.xml
if-no-files-found: error

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage/clover.xml
disable_search: true
flags: backend
fail_ci_if_error: false
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<a href="https://github.com/fleetbase/ledger/blob/main/LICENSE.md"><img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue.svg" alt="License: AGPL-3.0-or-later" /></a>
<a href="https://github.com/fleetbase/ledger/actions/workflows/server.yml"><img src="https://github.com/fleetbase/ledger/actions/workflows/server.yml/badge.svg" alt="PHP CI" /></a>
<a href="https://github.com/fleetbase/ledger/actions/workflows/ember.yml"><img src="https://github.com/fleetbase/ledger/actions/workflows/ember.yml/badge.svg" alt="Ember CI" /></a>
<a href="https://codecov.io/gh/fleetbase/ledger"><img src="https://codecov.io/gh/fleetbase/ledger/branch/main/graph/badge.svg" alt="Coverage" /></a>
<a href="https://packagist.org/packages/fleetbase/ledger-api"><img src="https://img.shields.io/packagist/v/fleetbase/ledger-api.svg" alt="Packagist version" /></a>
<a href="https://www.npmjs.com/package/@fleetbase/ledger-engine"><img src="https://img.shields.io/npm/v/@fleetbase/ledger-engine.svg" alt="npm version" /></a>
<a href="https://www.fleetbase.io/docs/ledger"><img src="https://img.shields.io/badge/docs-ledger-111827.svg" alt="Ledger documentation" /></a>
Expand Down
2 changes: 1 addition & 1 deletion phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="false">
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./server/src</directory>
</whitelist>
</filter>
Expand Down
19 changes: 19 additions & 0 deletions scripts/coverage-summary.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ function intMetric(SimpleXMLElement $node, string $name): int
$classes = (int) ($metrics['classes'] ?? 0);
$coveredClasses = (int) ($metrics['coveredclasses'] ?? 0);

if (!isset($metrics['coveredclasses'])) {
$classes = 0;
$coveredClasses = 0;

foreach ($project->xpath('.//class') ?: [] as $class) {
$classStatements = intMetric($class, 'statements');
$coveredClassStatements = intMetric($class, 'coveredstatements');

if ($classStatements === 0) {
continue;
}

$classes++;
if ($coveredClassStatements === $classStatements) {
$coveredClasses++;
}
}
}

$files = [];
$directories = [];

Expand Down
142 changes: 140 additions & 2 deletions scripts/pest-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,123 @@
}
}

if (class_exists('Illuminate\Support\Str') && !Illuminate\Support\Str::hasMacro('humanize')) {
Illuminate\Support\Str::macro('humanize', function (string $value, bool $title = true): string {
$humanized = str_replace(['-', '_'], ' ', Illuminate\Support\Str::snake($value));

return $title ? Illuminate\Support\Str::title($humanized) : $humanized;
});
}

if (!function_exists('config')) {
function config(?string $key = null, mixed $default = null): mixed
{
if (class_exists('Illuminate\Container\Container')) {
$container = Illuminate\Container\Container::getInstance();

if ($container->bound('config')) {
$repository = $container->make('config');

return $key === null ? $repository : $repository->get($key, $default);
}
}

return $default;
}
}

if (class_exists('Illuminate\Container\Container') && class_exists('Illuminate\Support\Facades\Facade')) {
$app = Illuminate\Container\Container::getInstance();

if (!method_exists($app, 'environment')) {
if (!class_exists('Fleetbase\TestSupport\TestContainer')) {
eval('namespace Fleetbase\TestSupport; class TestContainer extends \Illuminate\Container\Container { public array $registeredProviders = []; public function environment(array|string ...$environments): bool|string { return $environments === [] ? "testing" : in_array("testing", is_array($environments[0] ?? null) ? $environments[0] : $environments, true); } public function runningUnitTests(): bool { return true; } public function runningInConsole(): bool { return true; } public function register($provider, $force = false) { $this->registeredProviders[] = $provider; return $provider; } }');
}

$app = new Fleetbase\TestSupport\TestContainer();
Illuminate\Container\Container::setInstance($app);
}

Illuminate\Support\Facades\Facade::setFacadeApplication($app);

if (!$app->bound('http') && class_exists('Illuminate\Http\Client\Factory')) {
$app->singleton('http', fn () => new Illuminate\Http\Client\Factory());
}

if (!$app->bound('cache') && class_exists('Illuminate\Cache\Repository') && class_exists('Illuminate\Cache\ArrayStore')) {
$app->singleton('cache', fn () => new Illuminate\Cache\Repository(new Illuminate\Cache\ArrayStore()));
}

if (!$app->bound('responsecache')) {
if (!class_exists('Fleetbase\TestSupport\ResponseCacheManager')) {
eval('namespace Fleetbase\TestSupport; class ResponseCacheManager { public function clear(): bool { return true; } }');
}

$app->singleton('responsecache', fn () => new Fleetbase\TestSupport\ResponseCacheManager());
}

if (!$app->bound('config') && class_exists('Illuminate\Config\Repository')) {
$app->singleton('config', fn () => new Illuminate\Config\Repository([
'app' => ['url' => 'https://api.example.test'],
'api' => ['cache' => ['enabled' => false]],
'fleetbase' => ['connection' => ['db' => 'testing']],
]));
}

if (!$app->bound('log') && class_exists('Psr\Log\NullLogger')) {
if (!class_exists('Fleetbase\TestSupport\LoggerManager')) {
eval('namespace Fleetbase\TestSupport; class LoggerManager extends \Psr\Log\NullLogger { public function channel(?string $name = null): self { return $this; } }');
eval('namespace Fleetbase\TestSupport; class LoggerManager extends \Psr\Log\NullLogger { public static array $records = []; public function channel(?string $name = null): self { return $this; } public function log($level, string|\Stringable $message, array $context = []): void { self::$records[] = compact("level", "message", "context"); } }');
}

$app->singleton('log', fn () => new Fleetbase\TestSupport\LoggerManager());
}

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; } }');
}

$app->singleton('router', fn () => new Fleetbase\TestSupport\RouteRegistrar());
}
}

if (!function_exists('url')) {
function url(?string $path = null, mixed $parameters = [], ?bool $secure = null): string
{
$base = $secure === false ? 'http://api.example.test' : 'https://api.example.test';

return rtrim($base, '/') . '/' . ltrim((string) $path, '/');
}
}

if (!function_exists('response')) {
function response(): object
{
return new class {
public function json(mixed $data = [], int $status = 200, array $headers = []): Illuminate\Http\JsonResponse
{
return new Illuminate\Http\JsonResponse($data, $status, $headers);
}
};
}
}

if (!function_exists('abort')) {
function abort(int $code, string $message = '', array $headers = []): never
{
throw new Symfony\Component\HttpKernel\Exception\HttpException($code, $message, null, $headers);
}
}

if (!function_exists('event')) {
function event(object $event): object
{
if (class_exists('Fleetbase\TestSupport\EventRecorder')) {
Fleetbase\TestSupport\EventRecorder::record($event);
}

return $event;
}
}

if (!function_exists('app')) {
Expand Down Expand Up @@ -71,7 +166,30 @@ function session(array|string|null $key = null, mixed $default = null): mixed
return null;
}

return $key === null ? $values : ($values[$key] ?? $default);
if ($key !== null) {
return $values[$key] ?? $default;
}

return new class($values) {
public function __construct(private array $values)
{
}

public function missing(string $key): bool
{
return !array_key_exists($key, $this->values);
}

public function has(string $key): bool
{
return array_key_exists($key, $this->values);
}

public function get(string $key, mixed $default = null): mixed
{
return $this->values[$key] ?? $default;
}
};
}
}

Expand All @@ -86,6 +204,18 @@ function now($tz = null): Illuminate\Support\Carbon
eval('namespace Illuminate\Foundation\Auth\Access; trait AuthorizesRequests {}');
}

if (!class_exists('Illuminate\Foundation\Auth\User')) {
eval('namespace Illuminate\Foundation\Auth; class User extends \Illuminate\Database\Eloquent\Model {}');
}

if (!class_exists('Fleetbase\Models\Customer') && class_exists('Illuminate\Database\Eloquent\Model')) {
eval('namespace Fleetbase\Models; class Customer extends \Illuminate\Database\Eloquent\Model { protected $table = "customers"; protected $primaryKey = "uuid"; public $incrementing = false; protected $keyType = "string"; }');
}

if (!class_exists('Illuminate\Pagination\Paginator')) {
eval('namespace Illuminate\Pagination; class Paginator implements \JsonSerializable { protected $items; public function __construct($items, protected int $perPage, protected ?int $currentPage = null, protected array $options = []) { $this->items = $items instanceof \Illuminate\Support\Collection ? $items : collect($items); } public static function resolveCurrentPage($pageName = "page", $default = 1): int { return $default; } public static function resolveCurrentPath($default = "/"): string { return $default; } public function first() { return $this->items->first(); } public function mapInto(string $class) { return $this->items->mapInto($class); } public function toBase() { return $this->items->toBase(); } public function jsonSerialize(): mixed { return $this->toArray(); } public function toArray(): array { return ["data" => $this->items->values()->all(), "per_page" => $this->perPage, "current_page" => $this->currentPage ?? 1]; } } class LengthAwarePaginator extends Paginator { public function __construct($items, protected int $total, int $perPage, ?int $currentPage = null, array $options = []) { parent::__construct($items, $perPage, $currentPage, $options); } public function toArray(): array { return array_merge(parent::toArray(), ["total" => $this->total, "last_page" => max(1, (int) ceil($this->total / $this->perPage))]); } }');
}

if (!trait_exists('Illuminate\Foundation\Bus\Dispatchable')) {
eval('namespace Illuminate\Foundation\Bus; trait Dispatchable {}');
}
Expand All @@ -94,6 +224,14 @@ function now($tz = null): Illuminate\Support\Carbon
eval('namespace Illuminate\Foundation\Bus; trait DispatchesJobs {}');
}

if (!trait_exists('Illuminate\Foundation\Events\Dispatchable')) {
if (!class_exists('Fleetbase\TestSupport\EventRecorder')) {
eval('namespace Fleetbase\TestSupport; class EventRecorder { public static array $events = []; public static function record(object $event): object { self::$events[] = $event; return $event; } public static function reset(): void { self::$events = []; } }');
}

eval('namespace Illuminate\Foundation\Events; trait Dispatchable { public static function dispatch(...$arguments): object { return \Fleetbase\TestSupport\EventRecorder::record(new static(...$arguments)); } }');
}

if (!trait_exists('Illuminate\Foundation\Validation\ValidatesRequests')) {
eval('namespace Illuminate\Foundation\Validation; trait ValidatesRequests {}');
}
Expand Down
3 changes: 1 addition & 2 deletions server/src/Console/Commands/BackfillTransactionDirection.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ public function handle(): int

DB::table('transactions')
->whereNull('direction')
->orderBy('id')
->chunk($chunk, function ($rows) use ($bar, &$processed) {
->chunkById($chunk, function ($rows) use ($bar, &$processed) {
foreach ($rows as $row) {
$direction = in_array(strtolower((string) $row->type), self::DEBIT_TYPES, true)
? 'debit'
Expand Down
11 changes: 10 additions & 1 deletion server/src/Console/Commands/ProvisionLedgerDefaults.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function handle(WalletService $walletService): int
return self::SUCCESS;
}

$seeder = new LedgerSeeder();
$seeder = $this->makeLedgerSeeder();
$accountsProvisioned = 0;
$companyWallets = 0;
$userWallets = 0;
Expand Down Expand Up @@ -136,4 +136,13 @@ public function handle(WalletService $walletService): int

return self::SUCCESS;
}

/**
* Resolve the account seeder behind a narrow seam so command behavior can
* be tested without coupling tests to the seeder's database implementation.
*/
protected function makeLedgerSeeder(): LedgerSeeder
{
return new LedgerSeeder();
}
}
5 changes: 5 additions & 0 deletions server/src/Console/Commands/RepairRevenueLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ public function handle(): int

$orderClass = 'Fleetbase\\FleetOps\\Models\\Order';
if (!class_exists($orderClass)) {
// FleetOps API is a required Composer dependency of Ledger. This
// fallback only protects partially installed runtime environments.
// @codeCoverageIgnoreStart
$this->warn('[Ledger] FleetOps Order model is unavailable; skipping order-linked repairs.');
// @codeCoverageIgnoreEnd
} else {
$this->reportOrders($orderClass, $apply, $limit);
}
Expand Down Expand Up @@ -70,6 +74,7 @@ private function reportOrders(string $orderClass, bool $apply, int $limit): void
private function reportInvoices(bool $apply, int $limit): void
{
$query = Invoice::withTrashed()
->without(['customer', 'items', 'template', 'order.trackingNumber'])
->where(function ($query) {
$query->whereNotNull('deleted_at')
->orWhereIn('status', ['void', 'voided', 'cancelled', 'canceled']);
Expand Down
21 changes: 16 additions & 5 deletions server/src/Console/Commands/TalerSandboxE2E.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ class TalerSandboxE2E extends Command

public function handle(): int
{
if (!filter_var(env('TALER_E2E_ENABLED', false), FILTER_VALIDATE_BOOLEAN)) {
if (!filter_var($this->environment('TALER_E2E_ENABLED', false), FILTER_VALIDATE_BOOLEAN)) {
$this->warn('[Ledger/Taler] E2E skipped. Set TALER_E2E_ENABLED=true to run against a live sandbox.');

return self::SUCCESS;
}

$backendUrl = env('TALER_E2E_BACKEND_URL');
$instanceId = env('TALER_E2E_INSTANCE_ID', 'default');
$apiToken = env('TALER_E2E_API_TOKEN');
$backendUrl = $this->environment('TALER_E2E_BACKEND_URL');
$instanceId = $this->environment('TALER_E2E_INSTANCE_ID', 'default');
$apiToken = $this->environment('TALER_E2E_API_TOKEN');

if (!$backendUrl || !$apiToken) {
$this->error('[Ledger/Taler] TALER_E2E_BACKEND_URL and TALER_E2E_API_TOKEN are required.');
Expand All @@ -49,7 +49,7 @@ public function handle(): int
'currency' => strtoupper((string) $this->option('currency')),
'description' => 'Ledger GNU Taler sandbox E2E order',
'metadata' => [
'company_uuid' => env('TALER_E2E_COMPANY_UUID'),
'company_uuid' => $this->environment('TALER_E2E_COMPANY_UUID'),
'e2e' => true,
],
]);
Expand All @@ -67,4 +67,15 @@ public function handle(): int

return self::SUCCESS;
}

/**
* Read opt-in E2E configuration without requiring Laravel's Dotenv
* repository to be bootstrapped by the console test harness.
*/
protected function environment(string $key, mixed $default = null): mixed
{
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);

return $value === false ? $default : $value;
}
}
7 changes: 0 additions & 7 deletions server/src/Gateways/StripeDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,6 @@ public function handleWebhook(Request $request): GatewayResponse
? $object->payment_intent
: ($object->payment_intent->id ?? $gatewayTransactionId);
}
// Amount for checkout sessions is in amount_total (cents)
if ($amount === null && isset($object->amount_total)) {
$amount = $object->amount_total;
}
if ($currency === null && isset($object->currency)) {
$currency = strtoupper($object->currency);
}
}

$this->logInfo('Webhook received', [
Expand Down
3 changes: 2 additions & 1 deletion server/src/Http/Controllers/Api/v1/WalletApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Fleetbase\Ledger\Http\Controllers\Api\v1;

use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Resources\FleetbaseResourceCollection;
use Fleetbase\Ledger\Http\Resources\v1\Transaction as TransactionResource;
use Fleetbase\Ledger\Http\Resources\v1\Wallet as WalletResource;
use Fleetbase\Ledger\Models\Transaction;
Expand Down Expand Up @@ -77,7 +78,7 @@ public function getBalance(Request $request): JsonResponse
* Supports filtering by: type, direction, status, date_from, date_to
* Supports pagination via limit/page.
*/
public function getTransactions(Request $request): AnonymousResourceCollection
public function getTransactions(Request $request): AnonymousResourceCollection|FleetbaseResourceCollection
{
$subject = $this->resolveSubject($request);
$wallet = $this->walletService->getOrCreateWallet($subject);
Expand Down
4 changes: 2 additions & 2 deletions server/src/Http/Controllers/Internal/v1/InvoiceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ public function markAsSent(string $id, Request $request): InvoiceResource
/**
* Send an invoice to the customer via email and mark it as sent.
*/
public function send(string $id, Request $request): InvoiceResource
public function send(string $id, Request $request): InvoiceResource|JsonResponse
{
$invoice = Invoice::where('company_uuid', session('company'))
->where(fn ($q) => $q->where('uuid', $id)->orWhere('public_id', $id))
Expand Down Expand Up @@ -661,7 +661,7 @@ private function invoicePaymentReferences(Invoice $invoice)
* 3. For each incoming item: update if UUID exists, create if not.
* 4. Call calculateAmount() on each item before saving.
*/
protected function _syncItems(Invoice $invoice, array $items): void
protected function _syncItems(Invoice $invoice, mixed $items): void
{
if (!is_array($items)) {
return;
Expand Down
Loading