From a32690a5a0d4344191fe2f0c36d1f951f4729d6c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 30 Mar 2026 04:17:32 -0400 Subject: [PATCH 1/9] feat(ledger): add GNU Taler payment gateway driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements TalerDriver as a first-class payment gateway option in the Fleetbase Ledger module, following the existing driver-based architecture. ## What was added ### server/src/Gateways/TalerDriver.php - Extends AbstractGatewayDriver and implements GatewayDriverInterface - getCode(): 'taler' - getName(): 'GNU Taler' - getCapabilities(): ['purchase', 'refund', 'webhooks'] - getConfigSchema(): dynamic form fields for backend_url, instance_id, api_token — rendered automatically by the Fleetbase gateway UI - purchase(): POSTs to Taler Merchant Backend /private/orders, embeds invoice_uuid in contract terms, returns GatewayResponse::pending() with taler_pay_uri for wallet redirect - handleWebhook(): receives order_id, re-queries private API to verify payment status (prevents spoofing), returns GatewayResponse::success() which triggers the existing HandleSuccessfulPayment listener - refund(): POSTs to /private/orders/{order_id}/refund, returns GatewayResponse::success() with EVENT_REFUND_PROCESSED - toTalerAmount() / fromTalerAmount(): bidirectional conversion between Fleetbase integer cents and Taler 'CURRENCY:UNITS.FRACTION' strings ### server/src/PaymentGatewayManager.php - Added TalerDriver import - Registered 'taler' in getRegisteredDriverCodes() - Added createTalerDriver() factory method ### server/tests/Gateways/TalerDriverTest.php - Full Pest test suite covering: - Driver metadata (code, name, capabilities, config schema) - purchase() happy path and failure paths - handleWebhook() happy path, unpaid order, missing order_id - refund() happy path and failure paths - Amount conversion edge cases (zero, single-digit fraction) ## Integration notes - Webhook route is automatically served at POST /ledger/webhooks/taler by the existing WebhookController — no route changes needed - All monetary values follow the Fleetbase standard: integers in the smallest currency unit (cents), converted to/from Taler string format - API token is stored encrypted via the existing Gateway model cast - Sandbox mode is handled by pointing backend_url at a test instance Refs: https://docs.taler.net/core/api-merchant.html --- server/src/Gateways/TalerDriver.php | 583 ++++++++++++++++++++++ server/src/PaymentGatewayManager.php | 11 +- server/tests/Gateways/TalerDriverTest.php | 419 ++++++++++++++++ 3 files changed, 1012 insertions(+), 1 deletion(-) create mode 100644 server/src/Gateways/TalerDriver.php create mode 100644 server/tests/Gateways/TalerDriverTest.php diff --git a/server/src/Gateways/TalerDriver.php b/server/src/Gateways/TalerDriver.php new file mode 100644 index 0000000..9b71ef6 --- /dev/null +++ b/server/src/Gateways/TalerDriver.php @@ -0,0 +1,583 @@ + 'backend_url', + 'label' => 'Merchant Backend URL', + 'type' => 'text', + 'required' => true, + 'hint' => 'Base URL of your Taler Merchant Backend, e.g. https://backend.demo.taler.net/', + ], + [ + 'key' => 'instance_id', + 'label' => 'Instance ID', + 'type' => 'text', + 'required' => true, + 'hint' => 'The Taler merchant instance identifier. Defaults to "default".', + ], + [ + 'key' => 'api_token', + 'label' => 'API Token', + 'type' => 'password', + 'required' => true, + 'hint' => 'Bearer token for authenticating against the private Merchant API.', + ], + ]; + } + + // ------------------------------------------------------------------------- + // Purchase + // ------------------------------------------------------------------------- + + /** + * {@inheritdoc} + * + * Creates a new Taler order via the Merchant Backend and returns a pending + * response containing the taler_pay_uri that the customer's Taler wallet + * must open to complete the payment. + * + * The Fleetbase invoice UUID is embedded in the order's contract terms + * under the key "invoice_uuid" so that it can be recovered during webhook + * processing without any additional storage. + * + * Steps: + * 1. Convert integer cents → Taler amount string. + * 2. POST to /instances/{id}/private/orders. + * 3. GET /instances/{id}/private/orders/{order_id} to obtain taler_pay_uri. + * 4. Return GatewayResponse::pending() with order_id and taler_pay_uri. + * + * @param PurchaseRequest $request Immutable purchase request DTO + * + * @return GatewayResponse Pending response with taler_pay_uri in data[] + */ + public function purchase(PurchaseRequest $request): GatewayResponse + { + $backendUrl = $this->backendUrl(); + $instanceId = $this->instanceId(); + + $talerAmount = $this->toTalerAmount($request->amount, $request->currency); + + // Build the order payload. The invoice_uuid is stored as a top-level + // field in the order object so it is included in the signed contract + // terms and can be retrieved verbatim when the webhook fires. + $payload = [ + 'order' => [ + 'amount' => $talerAmount, + 'summary' => $request->description, + 'invoice_uuid' => $request->invoiceUuid, + ], + ]; + + // Append fulfillment / return URLs when provided by the caller. + if ($request->returnUrl) { + $payload['order']['fulfillment_url'] = $request->returnUrl; + } + + $this->logInfo('Creating Taler order', [ + 'amount' => $talerAmount, + 'invoice_uuid' => $request->invoiceUuid, + ]); + + try { + $createResponse = $this->privateRequest('POST', "instances/{$instanceId}/private/orders", $payload); + } catch (\Throwable $e) { + $this->logError('Order creation HTTP error', ['error' => $e->getMessage()]); + + return GatewayResponse::failure( + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler order creation failed: ' . $e->getMessage(), + rawResponse: ['error' => $e->getMessage()], + ); + } + + if (!$createResponse->successful()) { + $this->logError('Order creation failed', [ + 'status' => $createResponse->status(), + 'body' => $createResponse->body(), + ]); + + return GatewayResponse::failure( + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler order creation failed: ' . $createResponse->body(), + rawResponse: $createResponse->json() ?? [], + ); + } + + $orderId = $createResponse->json('order_id'); + + if (!$orderId) { + return GatewayResponse::failure( + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler returned no order_id in creation response.', + rawResponse: $createResponse->json() ?? [], + ); + } + + // Retrieve the order status to obtain the taler_pay_uri. The URI is + // only available on the status endpoint, not the creation response. + $talerPayUri = null; + $orderStatusRaw = []; + + try { + $statusResponse = $this->privateRequest('GET', "instances/{$instanceId}/private/orders/{$orderId}"); + + if ($statusResponse->successful()) { + $talerPayUri = $statusResponse->json('taler_pay_uri'); + $orderStatusRaw = $statusResponse->json() ?? []; + } + } catch (\Throwable $e) { + // Non-fatal: we still return the pending response with the order_id. + $this->logError('Could not retrieve taler_pay_uri after order creation', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + } + + $this->logInfo('Taler order created', [ + 'order_id' => $orderId, + 'invoice_uuid' => $request->invoiceUuid, + ]); + + return GatewayResponse::pending( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_PENDING, + message: 'Taler order created. Redirect customer to taler_pay_uri.', + rawResponse: array_merge($createResponse->json() ?? [], ['status' => $orderStatusRaw]), + data: [ + 'taler_pay_uri' => $talerPayUri, + 'order_id' => $orderId, + 'invoice_uuid' => $request->invoiceUuid, + ], + ); + } + + // ------------------------------------------------------------------------- + // Webhook + // ------------------------------------------------------------------------- + + /** + * {@inheritdoc} + * + * Handles an inbound webhook notification from the Taler Merchant Backend. + * + * Taler does not sign webhook payloads with an HMAC secret the way Stripe + * does. Instead, the recommended security practice is to verify the payment + * by re-querying the private Merchant API using the order_id received in + * the webhook body. This prevents replay and spoofing attacks because only + * the backend — authenticated with the API token — can confirm the status. + * + * Expected webhook body (JSON): + * { "order_id": "2024-001-XYZ" } + * + * Steps: + * 1. Extract order_id from request body. + * 2. GET /instances/{id}/private/orders/{order_id} to verify status. + * 3. If order_status == "paid", parse amount and invoice_uuid. + * 4. Return GatewayResponse::success() or ::failure() accordingly. + * + * @param Request $request Incoming HTTP request from Taler + * + * @return GatewayResponse Normalized response for event dispatching + * + * @throws WebhookSignatureException Never thrown by this driver (verification + * is done via API re-query, not signature). + */ + public function handleWebhook(Request $request): GatewayResponse + { + $orderId = $request->input('order_id'); + + if (!$orderId) { + $this->logError('Webhook received without order_id', [ + 'payload' => $request->all(), + ]); + + return GatewayResponse::failure( + eventType: GatewayResponse::EVENT_UNKNOWN, + message: 'Taler webhook missing order_id.', + rawResponse: $request->all(), + ); + } + + $instanceId = $this->instanceId(); + + $this->logInfo('Webhook received, verifying order', ['order_id' => $orderId]); + + try { + $response = $this->privateRequest('GET', "instances/{$instanceId}/private/orders/{$orderId}"); + } catch (\Throwable $e) { + $this->logError('Webhook order verification HTTP error', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler webhook verification failed: ' . $e->getMessage(), + rawResponse: ['error' => $e->getMessage()], + ); + } + + if (!$response->successful()) { + $this->logError('Webhook order verification returned non-2xx', [ + 'order_id' => $orderId, + 'status' => $response->status(), + ]); + + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler order verification failed: ' . $response->body(), + rawResponse: $response->json() ?? [], + ); + } + + $data = $response->json() ?? []; + $orderStatus = $data['order_status'] ?? null; + + if ($orderStatus !== 'paid') { + $this->logInfo('Webhook received for unpaid order, ignoring', [ + 'order_id' => $orderId, + 'order_status' => $orderStatus, + ]); + + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: "Taler order [{$orderId}] is not paid (status: {$orderStatus}).", + rawResponse: $data, + ); + } + + // Extract the invoice_uuid that was embedded in the contract terms + // during order creation. The HandleSuccessfulPayment listener uses this + // to locate and mark the Fleetbase invoice as paid. + $contractTerms = $data['contract_terms'] ?? []; + $invoiceUuid = $contractTerms['invoice_uuid'] ?? null; + + // Parse the deposit_total amount back to Fleetbase integer cents. + $depositTotal = $data['deposit_total'] ?? null; + [$currency, $amountCents] = $this->fromTalerAmount($depositTotal); + + $this->logInfo('Webhook verified: payment confirmed', [ + 'order_id' => $orderId, + 'invoice_uuid' => $invoiceUuid, + 'amount_cents' => $amountCents, + 'currency' => $currency, + ]); + + return GatewayResponse::success( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_SUCCEEDED, + message: 'GNU Taler payment confirmed.', + amount: $amountCents, + currency: $currency, + rawResponse: $data, + data: [ + 'invoice_uuid' => $invoiceUuid, + 'order_id' => $orderId, + 'wired' => $data['wired'] ?? false, + 'last_payment' => $data['last_payment'] ?? null, + ], + ); + } + + // ------------------------------------------------------------------------- + // Refund + // ------------------------------------------------------------------------- + + /** + * {@inheritdoc} + * + * Issues a refund against a previously paid Taler order. + * + * Taler refunds are cumulative: each call to the refund endpoint increases + * the total refunded amount up to the original order total. The backend + * will reject a refund that exceeds the original amount. + * + * Steps: + * 1. Convert integer cents → Taler amount string. + * 2. POST to /instances/{id}/private/orders/{order_id}/refund. + * 3. Return GatewayResponse::success() with EVENT_REFUND_PROCESSED. + * + * @param RefundRequest $request Immutable refund request DTO + * + * @return GatewayResponse Success or failure response + */ + public function refund(RefundRequest $request): GatewayResponse + { + $backendUrl = $this->backendUrl(); + $instanceId = $this->instanceId(); + $orderId = $request->gatewayTransactionId; + + $talerAmount = $this->toTalerAmount($request->amount, $request->currency); + + $payload = [ + 'refund' => $talerAmount, + 'reason' => $request->reason ?? 'Customer requested refund', + ]; + + $this->logInfo('Issuing Taler refund', [ + 'order_id' => $orderId, + 'amount' => $talerAmount, + 'invoice_uuid' => $request->invoiceUuid, + ]); + + try { + $response = $this->privateRequest( + 'POST', + "instances/{$instanceId}/private/orders/{$orderId}/refund", + $payload + ); + } catch (\Throwable $e) { + $this->logError('Refund HTTP error', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_REFUND_FAILED, + message: 'Taler refund failed: ' . $e->getMessage(), + rawResponse: ['error' => $e->getMessage()], + ); + } + + if (!$response->successful()) { + $this->logError('Refund request failed', [ + 'order_id' => $orderId, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_REFUND_FAILED, + message: 'Taler refund failed: ' . $response->body(), + rawResponse: $response->json() ?? [], + ); + } + + $this->logInfo('Taler refund issued successfully', [ + 'order_id' => $orderId, + 'amount' => $talerAmount, + ]); + + return GatewayResponse::success( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_REFUND_PROCESSED, + message: 'GNU Taler refund processed.', + amount: $request->amount, + currency: $request->currency, + rawResponse: $response->json() ?? [], + data: [ + 'invoice_uuid' => $request->invoiceUuid, + 'order_id' => $orderId, + 'refund_amount' => $talerAmount, + ], + ); + } + + // ------------------------------------------------------------------------- + // Private Helpers + // ------------------------------------------------------------------------- + + /** + * Return the trimmed Merchant Backend base URL from config. + */ + private function backendUrl(): string + { + return rtrim($this->config('backend_url', ''), '/'); + } + + /** + * Return the configured Taler merchant instance ID, defaulting to "default". + */ + private function instanceId(): string + { + return $this->config('instance_id', 'default'); + } + + /** + * Execute an authenticated HTTP request against the Taler Merchant Backend. + * + * All private API endpoints require a Bearer token. In sandbox mode the + * driver still uses the configured token; the sandbox distinction is + * handled entirely by the backend_url pointing to a test instance. + * + * @param string $method HTTP method (GET, POST, PATCH, DELETE) + * @param string $path Relative API path (no leading slash) + * @param array $payload Optional JSON body for POST/PATCH requests + * + * @return HttpResponse Laravel HTTP client response + */ + private function privateRequest(string $method, string $path, array $payload = []): HttpResponse + { + $url = $this->backendUrl() . '/' . ltrim($path, '/'); + $token = $this->config('api_token', ''); + $pending = Http::withToken($token) + ->acceptJson() + ->contentType('application/json'); + + return match (strtoupper($method)) { + 'POST' => $pending->post($url, $payload), + 'PATCH' => $pending->patch($url, $payload), + 'DELETE' => $pending->delete($url, $payload), + default => $pending->get($url), + }; + } + + /** + * Convert a Fleetbase integer amount (smallest currency unit) to a Taler + * amount string in the format "CURRENCY:UNITS.FRACTION". + * + * Examples: + * toTalerAmount(1050, 'USD') → "USD:10.50" + * toTalerAmount(100, 'JPY') → "JPY:100.00" + * toTalerAmount(0, 'EUR') → "EUR:0.00" + * + * @param int $amountCents Integer amount in smallest currency unit + * @param string $currency ISO 4217 currency code + * + * @return string Taler amount string + */ + private function toTalerAmount(int $amountCents, string $currency): string + { + $units = (int) floor($amountCents / 100); + $fraction = $amountCents % 100; + + return sprintf('%s:%d.%02d', strtoupper($currency), $units, $fraction); + } + + /** + * Parse a Taler amount string back into a [currency, integer cents] tuple. + * + * Returns ['USD', 0] if the string is null, empty, or malformed. + * + * Examples: + * fromTalerAmount("USD:10.50") → ['USD', 1050] + * fromTalerAmount("EUR:0.99") → ['EUR', 99] + * fromTalerAmount(null) → ['USD', 0] + * + * @param string|null $talerAmount Taler amount string + * + * @return array{0: string, 1: int} [currency, amountCents] + */ + private function fromTalerAmount(?string $talerAmount): array + { + if (!$talerAmount) { + return ['USD', 0]; + } + + // Match "CURRENCY:UNITS.FRACTION" — fraction is optional. + if (!preg_match('/^([A-Z]{2,8}):(\d+)(?:\.(\d{1,2}))?$/', $talerAmount, $m)) { + $this->logError('Could not parse Taler amount string', ['value' => $talerAmount]); + + return ['USD', 0]; + } + + $currency = $m[1]; + $units = (int) $m[2]; + $fractionStr = $m[3] ?? '00'; + + // Normalise fraction to exactly 2 digits (pad right if needed). + $fraction = (int) str_pad($fractionStr, 2, '0'); + + return [$currency, ($units * 100) + $fraction]; + } +} diff --git a/server/src/PaymentGatewayManager.php b/server/src/PaymentGatewayManager.php index 8c609cd..8b53c94 100644 --- a/server/src/PaymentGatewayManager.php +++ b/server/src/PaymentGatewayManager.php @@ -6,6 +6,7 @@ use Fleetbase\Ledger\Gateways\CashDriver; use Fleetbase\Ledger\Gateways\QPayDriver; use Fleetbase\Ledger\Gateways\StripeDriver; +use Fleetbase\Ledger\Gateways\TalerDriver; use Fleetbase\Ledger\Models\Gateway; use Fleetbase\Support\Utils; use Illuminate\Support\Manager; @@ -89,7 +90,7 @@ public function driverForWebhook(string $driverCode, string $companyUuid): Gatew */ public function getRegisteredDriverCodes(): array { - return ['stripe', 'qpay', 'cash']; + return ['stripe', 'qpay', 'cash', 'taler']; } /** @@ -155,4 +156,12 @@ protected function createCashDriver(): CashDriver { return $this->container->make(CashDriver::class); } + + /** + * Create the GNU Taler driver instance. + */ + protected function createTalerDriver(): TalerDriver + { + return $this->container->make(TalerDriver::class); + } } diff --git a/server/tests/Gateways/TalerDriverTest.php b/server/tests/Gateways/TalerDriverTest.php new file mode 100644 index 0000000..6e3461e --- /dev/null +++ b/server/tests/Gateways/TalerDriverTest.php @@ -0,0 +1,419 @@ +_ + */ + +use Fleetbase\Ledger\DTO\GatewayResponse; +use Fleetbase\Ledger\DTO\PurchaseRequest; +use Fleetbase\Ledger\DTO\RefundRequest; +use Fleetbase\Ledger\Gateways\TalerDriver; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Http; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a fully-initialised TalerDriver using the given config overrides. + */ +function talerDriver(array $config = []): TalerDriver +{ + $defaults = [ + 'backend_url' => 'https://backend.example.taler.net', + 'instance_id' => 'testmerchant', + 'api_token' => 'secret-token-abc', + ]; + + $driver = new TalerDriver(); + $driver->initialize(array_merge($defaults, $config)); + + return $driver; +} + +// --------------------------------------------------------------------------- +// Driver metadata +// --------------------------------------------------------------------------- + +test('driver returns correct code', function () { + expect(talerDriver()->getCode())->toBe('taler'); +}); + +test('driver returns correct name', function () { + expect(talerDriver()->getName())->toBe('GNU Taler'); +}); + +test('driver advertises purchase, refund, and webhooks capabilities', function () { + $caps = talerDriver()->getCapabilities(); + + expect($caps)->toContain('purchase') + ->toContain('refund') + ->toContain('webhooks'); +}); + +test('driver config schema contains required fields', function () { + $schema = talerDriver()->getConfigSchema(); + $keys = array_column($schema, 'key'); + + expect($keys)->toContain('backend_url') + ->toContain('instance_id') + ->toContain('api_token'); +}); + +// --------------------------------------------------------------------------- +// purchase() — happy path +// --------------------------------------------------------------------------- + +test('purchase_creates_order_and_returns_pending_response', function () { + Http::fake([ + // Step 1: order creation + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['order_id' => 'TALER-ORDER-001'], + 200 + ), + // Step 2: status fetch for taler_pay_uri + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-001' => Http::response( + [ + 'order_status' => 'unpaid', + 'taler_pay_uri' => 'taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001', + ], + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 2500, + currency: 'USD', + description: 'Invoice #INV-001', + invoiceUuid: 'invoice-uuid-abc', + ); + + $response = talerDriver()->purchase($request); + + expect($response->isSuccessful())->toBeTrue() + ->and($response->isPending())->toBeTrue() + ->and($response->status)->toBe(GatewayResponse::STATUS_PENDING) + ->and($response->eventType)->toBe(GatewayResponse::EVENT_PAYMENT_PENDING) + ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001') + ->and($response->data['taler_pay_uri'])->toBe('taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001') + ->and($response->data['invoice_uuid'])->toBe('invoice-uuid-abc'); +}); + +test('purchase_sends_correct_taler_amount_format', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['order_id' => 'TALER-ORDER-002'], + 200 + ), + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-002' => Http::response( + ['order_status' => 'unpaid', 'taler_pay_uri' => 'taler://pay/...'], + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 1050, // USD 10.50 + currency: 'USD', + description: 'Test', + invoiceUuid: 'inv-001', + ); + + talerDriver()->purchase($request); + + // Assert the POST body contained the correct Taler amount string + Http::assertSent(function ($httpRequest) { + $body = $httpRequest->data(); + return isset($body['order']['amount']) && $body['order']['amount'] === 'USD:10.50'; + }); +}); + +test('purchase_embeds_invoice_uuid_in_order_payload', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['order_id' => 'TALER-ORDER-003'], + 200 + ), + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-003' => Http::response( + ['order_status' => 'unpaid', 'taler_pay_uri' => 'taler://pay/...'], + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 500, + currency: 'EUR', + description: 'Test', + invoiceUuid: 'my-invoice-uuid', + ); + + talerDriver()->purchase($request); + + Http::assertSent(function ($httpRequest) { + $body = $httpRequest->data(); + return isset($body['order']['invoice_uuid']) && $body['order']['invoice_uuid'] === 'my-invoice-uuid'; + }); +}); + +// --------------------------------------------------------------------------- +// purchase() — failure paths +// --------------------------------------------------------------------------- + +test('purchase_returns_failure_when_backend_returns_error', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['error' => 'UNAUTHORIZED'], + 401 + ), + ]); + + $request = new PurchaseRequest( + amount: 1000, + currency: 'USD', + description: 'Test', + ); + + $response = talerDriver()->purchase($request); + + expect($response->isFailed())->toBeTrue() + ->and($response->eventType)->toBe(GatewayResponse::EVENT_PAYMENT_FAILED); +}); + +test('purchase_returns_failure_when_order_id_missing', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + [], // no order_id + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 1000, + currency: 'USD', + description: 'Test', + ); + + $response = talerDriver()->purchase($request); + + expect($response->isFailed())->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// handleWebhook() — happy path +// --------------------------------------------------------------------------- + +test('handleWebhook_verifies_paid_order_and_returns_success', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-001' => Http::response( + [ + 'order_status' => 'paid', + 'deposit_total' => 'USD:25.00', + 'contract_terms' => [ + 'invoice_uuid' => 'invoice-uuid-abc', + 'summary' => 'Invoice #INV-001', + ], + 'wired' => true, + 'last_payment' => '2024-01-15T10:30:00Z', + ], + 200 + ), + ]); + + $request = Request::create('/ledger/webhooks/taler', 'POST', [ + 'order_id' => 'TALER-ORDER-001', + ]); + + $response = talerDriver()->handleWebhook($request); + + expect($response->isSuccessful())->toBeTrue() + ->and($response->eventType)->toBe(GatewayResponse::EVENT_PAYMENT_SUCCEEDED) + ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001') + ->and($response->amount)->toBe(2500) + ->and($response->currency)->toBe('USD') + ->and($response->data['invoice_uuid'])->toBe('invoice-uuid-abc'); +}); + +// --------------------------------------------------------------------------- +// handleWebhook() — failure paths +// --------------------------------------------------------------------------- + +test('handleWebhook_returns_failure_when_order_id_missing', function () { + $request = Request::create('/ledger/webhooks/taler', 'POST', []); + + $response = talerDriver()->handleWebhook($request); + + expect($response->isFailed())->toBeTrue() + ->and($response->eventType)->toBe(GatewayResponse::EVENT_UNKNOWN); +}); + +test('handleWebhook_returns_failure_when_order_not_paid', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-002' => Http::response( + ['order_status' => 'unpaid'], + 200 + ), + ]); + + $request = Request::create('/ledger/webhooks/taler', 'POST', [ + 'order_id' => 'TALER-ORDER-002', + ]); + + $response = talerDriver()->handleWebhook($request); + + expect($response->isFailed())->toBeTrue() + ->and($response->eventType)->toBe(GatewayResponse::EVENT_PAYMENT_FAILED); +}); + +test('handleWebhook_returns_failure_when_backend_returns_error', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-003' => Http::response( + ['error' => 'NOT_FOUND'], + 404 + ), + ]); + + $request = Request::create('/ledger/webhooks/taler', 'POST', [ + 'order_id' => 'TALER-ORDER-003', + ]); + + $response = talerDriver()->handleWebhook($request); + + expect($response->isFailed())->toBeTrue(); +}); + +// --------------------------------------------------------------------------- +// refund() — happy path +// --------------------------------------------------------------------------- + +test('refund_issues_refund_and_returns_success', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-001/refund' => Http::response( + ['taler_refund_uri' => 'taler://refund/...'], + 200 + ), + ]); + + $request = new RefundRequest( + gatewayTransactionId: 'TALER-ORDER-001', + amount: 2500, + currency: 'USD', + reason: 'Customer requested refund', + invoiceUuid: 'invoice-uuid-abc', + ); + + $response = talerDriver()->refund($request); + + expect($response->isSuccessful())->toBeTrue() + ->and($response->eventType)->toBe(GatewayResponse::EVENT_REFUND_PROCESSED) + ->and($response->amount)->toBe(2500) + ->and($response->currency)->toBe('USD') + ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001'); +}); + +test('refund_sends_correct_taler_amount_format', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-001/refund' => Http::response( + [], + 200 + ), + ]); + + $request = new RefundRequest( + gatewayTransactionId: 'TALER-ORDER-001', + amount: 999, // USD 9.99 + currency: 'USD', + ); + + talerDriver()->refund($request); + + Http::assertSent(function ($httpRequest) { + $body = $httpRequest->data(); + return isset($body['refund']) && $body['refund'] === 'USD:9.99'; + }); +}); + +// --------------------------------------------------------------------------- +// refund() — failure paths +// --------------------------------------------------------------------------- + +test('refund_returns_failure_when_backend_returns_error', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-001/refund' => Http::response( + ['error' => 'REFUND_EXCEEDS_PAYMENT'], + 409 + ), + ]); + + $request = new RefundRequest( + gatewayTransactionId: 'TALER-ORDER-001', + amount: 99999, + currency: 'USD', + ); + + $response = talerDriver()->refund($request); + + expect($response->isFailed())->toBeTrue() + ->and($response->eventType)->toBe(GatewayResponse::EVENT_REFUND_FAILED); +}); + +// --------------------------------------------------------------------------- +// Amount conversion edge cases +// --------------------------------------------------------------------------- + +test('purchase_converts_zero_amount_correctly', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['order_id' => 'TALER-ORDER-ZERO'], + 200 + ), + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-ZERO' => Http::response( + ['order_status' => 'unpaid', 'taler_pay_uri' => 'taler://pay/...'], + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 0, + currency: 'EUR', + description: 'Zero amount test', + ); + + talerDriver()->purchase($request); + + Http::assertSent(function ($httpRequest) { + $body = $httpRequest->data(); + return isset($body['order']['amount']) && $body['order']['amount'] === 'EUR:0.00'; + }); +}); + +test('webhook_parses_taler_amount_with_single_digit_fraction', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-FRAC' => Http::response( + [ + 'order_status' => 'paid', + 'deposit_total' => 'EUR:5.9', // single-digit fraction + 'contract_terms' => ['invoice_uuid' => 'inv-frac'], + ], + 200 + ), + ]); + + $request = Request::create('/ledger/webhooks/taler', 'POST', [ + 'order_id' => 'TALER-ORDER-FRAC', + ]); + + $response = talerDriver()->handleWebhook($request); + + // EUR:5.9 should be parsed as 590 cents + expect($response->isSuccessful())->toBeTrue() + ->and($response->amount)->toBe(590) + ->and($response->currency)->toBe('EUR'); +}); From 5445be48db03816523677cddf9501b4f5621af3a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 19 Jun 2026 21:24:47 +0800 Subject: [PATCH 2/9] feat: complete Taler invoice payment flow --- addon/components/customer-invoice.hbs | 15 +- addon/components/customer-invoice.js | 22 ++- server/src/Gateways/TalerDriver.php | 151 ++++++++++++++--- .../Public/PublicInvoiceController.php | 154 ++++++++++++++---- .../Http/Controllers/WebhookController.php | 11 ++ server/tests/Gateways/TalerDriverTest.php | 70 +++++++- 6 files changed, 357 insertions(+), 66 deletions(-) diff --git a/addon/components/customer-invoice.hbs b/addon/components/customer-invoice.hbs index 91a6779..0be1bc4 100644 --- a/addon/components/customer-invoice.hbs +++ b/addon/components/customer-invoice.hbs @@ -125,6 +125,13 @@ {{/if}} + {{#if this.pendingMessage}} +
+ +

{{this.pendingMessage}}

+
+ {{/if}} + {{! ── Payment section ──────────────────────────────────────────── }} {{#if this.canAcceptPayment}}
@@ -185,11 +192,11 @@ />
- {{! Redirecting to Stripe overlay }} + {{! Redirecting to payment provider overlay }} {{#if this.isRedirectingToCheckout}}
-

Redirecting to secure payment page…

+

Redirecting to payment provider...

{{else}} {{! Submit }} @@ -204,7 +211,7 @@ @type="primary" @icon={{if this.submitPayment.isRunning "spinner" "lock"}} @iconSpin={{this.submitPayment.isRunning}} - @text={{if this.submitPayment.isRunning "Processing…" (if this.isStripeGateway "Pay with Stripe" "Confirm Payment")}} + @text={{if this.submitPayment.isRunning "Processing..." (if this.isStripeGateway "Pay with Stripe" "Continue to Payment")}} @disabled={{this.submitPayment.isRunning}} @onClick={{perform this.submitPayment}} /> @@ -251,4 +258,4 @@ {{/if}} - \ No newline at end of file + diff --git a/addon/components/customer-invoice.js b/addon/components/customer-invoice.js index 48fe6b0..7206f51 100644 --- a/addon/components/customer-invoice.js +++ b/addon/components/customer-invoice.js @@ -31,6 +31,7 @@ export default class CustomerInvoiceComponent extends Component { @tracked paymentReference = ''; @tracked error = null; @tracked successMessage = null; + @tracked pendingMessage = null; @tracked isRedirectingToCheckout = false; constructor() { @@ -126,9 +127,8 @@ export default class CustomerInvoiceComponent extends Component { /** * Submits a payment request. * - * For Stripe: backend returns { checkout_url } and the browser is redirected - * to Stripe's hosted checkout page. isRedirectingToCheckout is set to true - * to show a loading state while the redirect happens. + * For redirect-capable gateways: backend returns { payment_url } or + * { checkout_url } and the browser is redirected to the hosted/wallet flow. * * For other gateways: backend records the payment immediately and returns * the updated invoice. @@ -147,10 +147,19 @@ export default class CustomerInvoiceComponent extends Component { { namespace: 'ledger/public' } ); - // Stripe Checkout Session — redirect the browser to Stripe's hosted page - if (data?.checkout_url) { + const paymentUrl = data?.payment_url ?? data?.payment_uri ?? data?.checkout_url ?? data?.data?.taler_pay_uri; + + // Redirect payment sessions — Stripe Checkout, Taler wallet URI, QPay app link, etc. + if (paymentUrl) { this.isRedirectingToCheckout = true; - window.location.href = data.checkout_url; + this.pendingMessage = data?.message ?? 'Redirecting to payment provider...'; + window.location.href = paymentUrl; + return; + } + + if (data?.payment_status === 'pending') { + this.pendingMessage = data?.message ?? 'Payment started. Complete it in your payment app, then refresh this invoice.'; + this.showPaymentForm = false; return; } @@ -168,6 +177,7 @@ export default class CustomerInvoiceComponent extends Component { @action togglePaymentForm() { this.showPaymentForm = !this.showPaymentForm; this.successMessage = null; + this.pendingMessage = null; this.error = null; } diff --git a/server/src/Gateways/TalerDriver.php b/server/src/Gateways/TalerDriver.php index 9b71ef6..d352075 100644 --- a/server/src/Gateways/TalerDriver.php +++ b/server/src/Gateways/TalerDriver.php @@ -6,10 +6,10 @@ use Fleetbase\Ledger\DTO\PurchaseRequest; use Fleetbase\Ledger\DTO\RefundRequest; use Fleetbase\Ledger\Exceptions\WebhookSignatureException; +use Illuminate\Http\Client\Response as HttpResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; -use Illuminate\Http\Client\Response as HttpResponse; +use Illuminate\Support\Str; /** * TalerDriver. @@ -84,6 +84,7 @@ public function getCapabilities(): array 'purchase', 'refund', 'webhooks', + 'sandbox', ]; } @@ -97,25 +98,29 @@ public function getConfigSchema(): array { return [ [ - 'key' => 'backend_url', - 'label' => 'Merchant Backend URL', - 'type' => 'text', - 'required' => true, - 'hint' => 'Base URL of your Taler Merchant Backend, e.g. https://backend.demo.taler.net/', + 'key' => 'backend_url', + 'label' => 'Merchant Backend URL', + 'type' => 'text', + 'required' => true, + 'hint' => 'Base URL of your Taler Merchant Backend, e.g. https://backend.demo.taler.net/', + 'description' => 'Base URL of your Taler Merchant Backend, e.g. https://backend.demo.taler.net/', ], [ - 'key' => 'instance_id', - 'label' => 'Instance ID', - 'type' => 'text', - 'required' => true, - 'hint' => 'The Taler merchant instance identifier. Defaults to "default".', + 'key' => 'instance_id', + 'label' => 'Instance ID', + 'type' => 'text', + 'required' => true, + 'default' => 'default', + 'hint' => 'The Taler merchant instance identifier. Defaults to "default".', + 'description' => 'The Taler merchant instance identifier. Defaults to "default".', ], [ - 'key' => 'api_token', - 'label' => 'API Token', - 'type' => 'password', - 'required' => true, - 'hint' => 'Bearer token for authenticating against the private Merchant API.', + 'key' => 'api_token', + 'label' => 'API Token', + 'type' => 'password', + 'required' => true, + 'hint' => 'Bearer token for authenticating against the private Merchant API.', + 'description' => 'Bearer token for authenticating against the private Merchant API.', ], ]; } @@ -147,19 +152,33 @@ public function getConfigSchema(): array */ public function purchase(PurchaseRequest $request): GatewayResponse { - $backendUrl = $this->backendUrl(); + if ($configurationFailure = $this->configurationFailureResponse()) { + return $configurationFailure; + } + $instanceId = $this->instanceId(); $talerAmount = $this->toTalerAmount($request->amount, $request->currency); + $orderId = $this->orderIdForPurchase($request); // Build the order payload. The invoice_uuid is stored as a top-level // field in the order object so it is included in the signed contract // terms and can be retrieved verbatim when the webhook fires. $payload = [ + 'order_id' => $orderId, 'order' => [ 'amount' => $talerAmount, 'summary' => $request->description, 'invoice_uuid' => $request->invoiceUuid, + 'metadata' => array_filter([ + 'invoice_uuid' => $request->invoiceUuid, + 'invoice_public_id' => $request->metadata['invoice_public_id'] ?? null, + 'invoice_number' => $request->metadata['invoice_number'] ?? null, + 'order_uuid' => $request->orderUuid, + 'gateway_public_id' => $request->metadata['gateway_public_id'] ?? null, + 'gateway_uuid' => $request->metadata['gateway_uuid'] ?? null, + 'company_uuid' => $request->metadata['company_uuid'] ?? null, + ]), ], ]; @@ -170,6 +189,7 @@ public function purchase(PurchaseRequest $request): GatewayResponse $this->logInfo('Creating Taler order', [ 'amount' => $talerAmount, + 'order_id' => $orderId, 'invoice_uuid' => $request->invoiceUuid, ]); @@ -221,11 +241,26 @@ public function purchase(PurchaseRequest $request): GatewayResponse $orderStatusRaw = $statusResponse->json() ?? []; } } catch (\Throwable $e) { - // Non-fatal: we still return the pending response with the order_id. $this->logError('Could not retrieve taler_pay_uri after order creation', [ 'order_id' => $orderId, 'error' => $e->getMessage(), ]); + + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler order created, but payment URI retrieval failed: ' . $e->getMessage(), + rawResponse: ['error' => $e->getMessage(), 'order_id' => $orderId], + ); + } + + if (!$talerPayUri) { + return GatewayResponse::failure( + gatewayTransactionId: $orderId, + eventType: GatewayResponse::EVENT_PAYMENT_FAILED, + message: 'Taler order created, but no taler_pay_uri was returned.', + rawResponse: array_merge($createResponse->json() ?? [], ['status' => $orderStatusRaw]), + ); } $this->logInfo('Taler order created', [ @@ -240,8 +275,10 @@ public function purchase(PurchaseRequest $request): GatewayResponse rawResponse: array_merge($createResponse->json() ?? [], ['status' => $orderStatusRaw]), data: [ 'taler_pay_uri' => $talerPayUri, + 'payment_url' => $talerPayUri, 'order_id' => $orderId, 'invoice_uuid' => $request->invoiceUuid, + 'status' => $orderStatusRaw['order_status'] ?? null, ], ); } @@ -279,6 +316,10 @@ public function purchase(PurchaseRequest $request): GatewayResponse */ public function handleWebhook(Request $request): GatewayResponse { + if ($configurationFailure = $this->configurationFailureResponse()) { + return $configurationFailure; + } + $orderId = $request->input('order_id'); if (!$orderId) { @@ -348,7 +389,8 @@ public function handleWebhook(Request $request): GatewayResponse // during order creation. The HandleSuccessfulPayment listener uses this // to locate and mark the Fleetbase invoice as paid. $contractTerms = $data['contract_terms'] ?? []; - $invoiceUuid = $contractTerms['invoice_uuid'] ?? null; + $metadata = $contractTerms['metadata'] ?? []; + $invoiceUuid = $contractTerms['invoice_uuid'] ?? $metadata['invoice_uuid'] ?? null; // Parse the deposit_total amount back to Fleetbase integer cents. $depositTotal = $data['deposit_total'] ?? null; @@ -371,6 +413,7 @@ public function handleWebhook(Request $request): GatewayResponse data: [ 'invoice_uuid' => $invoiceUuid, 'order_id' => $orderId, + 'metadata' => $metadata, 'wired' => $data['wired'] ?? false, 'last_payment' => $data['last_payment'] ?? null, ], @@ -401,7 +444,10 @@ public function handleWebhook(Request $request): GatewayResponse */ public function refund(RefundRequest $request): GatewayResponse { - $backendUrl = $this->backendUrl(); + if ($configurationFailure = $this->configurationFailureResponse(GatewayResponse::EVENT_REFUND_FAILED)) { + return $configurationFailure; + } + $instanceId = $this->instanceId(); $orderId = $request->gatewayTransactionId; @@ -458,17 +504,22 @@ public function refund(RefundRequest $request): GatewayResponse 'amount' => $talerAmount, ]); + $rawResponse = $response->json() ?? []; + $refundUri = $rawResponse['taler_refund_uri'] ?? null; + return GatewayResponse::success( gatewayTransactionId: $orderId, eventType: GatewayResponse::EVENT_REFUND_PROCESSED, message: 'GNU Taler refund processed.', amount: $request->amount, currency: $request->currency, - rawResponse: $response->json() ?? [], + rawResponse: $rawResponse, data: [ - 'invoice_uuid' => $request->invoiceUuid, - 'order_id' => $orderId, - 'refund_amount' => $talerAmount, + 'invoice_uuid' => $request->invoiceUuid, + 'order_id' => $orderId, + 'taler_refund_uri' => $refundUri, + 'refund_url' => $refundUri, + 'refund_amount' => $talerAmount, ], ); } @@ -493,6 +544,49 @@ private function instanceId(): string return $this->config('instance_id', 'default'); } + private function configurationFailureResponse(string $eventType = GatewayResponse::EVENT_PAYMENT_FAILED): ?GatewayResponse + { + if (!$this->backendUrl()) { + return GatewayResponse::failure( + eventType: $eventType, + message: 'Taler Merchant Backend URL is not configured.', + ); + } + + if (!$this->apiToken()) { + return GatewayResponse::failure( + eventType: $eventType, + message: 'Taler API token is not configured.', + ); + } + + return null; + } + + private function orderIdForPurchase(PurchaseRequest $request): string + { + if (!empty($request->metadata['taler_order_id'])) { + return $this->sanitizeOrderId($request->metadata['taler_order_id']); + } + + $source = implode('|', array_filter([ + 'ledger', + $request->invoiceUuid, + $request->metadata['invoice_public_id'] ?? null, + $request->amount, + strtoupper($request->currency), + ])); + + return 'ledger-' . substr(hash('sha256', $source ?: Str::uuid()->toString()), 0, 32); + } + + private function sanitizeOrderId(string $orderId): string + { + $sanitized = preg_replace('/[^A-Za-z0-9_.:-]/', '-', $orderId) ?: Str::uuid()->toString(); + + return Str::limit($sanitized, 64, ''); + } + /** * Execute an authenticated HTTP request against the Taler Merchant Backend. * @@ -509,7 +603,7 @@ private function instanceId(): string private function privateRequest(string $method, string $path, array $payload = []): HttpResponse { $url = $this->backendUrl() . '/' . ltrim($path, '/'); - $token = $this->config('api_token', ''); + $token = $this->apiToken(); $pending = Http::withToken($token) ->acceptJson() ->contentType('application/json'); @@ -544,6 +638,11 @@ private function toTalerAmount(int $amountCents, string $currency): string return sprintf('%s:%d.%02d', strtoupper($currency), $units, $fraction); } + private function apiToken(): string + { + return preg_replace('/^Bearer\s+/i', '', trim((string) $this->config('api_token', ''))); + } + /** * Parse a Taler amount string back into a [currency, integer cents] tuple. * diff --git a/server/src/Http/Controllers/Public/PublicInvoiceController.php b/server/src/Http/Controllers/Public/PublicInvoiceController.php index aa35793..7e2a27d 100644 --- a/server/src/Http/Controllers/Public/PublicInvoiceController.php +++ b/server/src/Http/Controllers/Public/PublicInvoiceController.php @@ -2,7 +2,9 @@ namespace Fleetbase\Ledger\Http\Controllers\Public; +use Fleetbase\Ledger\DTO\GatewayResponse; use Fleetbase\Ledger\DTO\PurchaseRequest; +use Fleetbase\Ledger\Gateways\CashDriver; use Fleetbase\Ledger\Gateways\StripeDriver; use Fleetbase\Ledger\Http\Resources\v1\Gateway as GatewayResource; use Fleetbase\Ledger\Http\Resources\v1\Invoice as InvoiceResource; @@ -10,6 +12,7 @@ use Fleetbase\Ledger\Models\Invoice; use Fleetbase\Ledger\PaymentGatewayManager; use Fleetbase\Ledger\Services\InvoiceService; +use Fleetbase\Ledger\Services\PaymentService; use Fleetbase\Support\Utils; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -30,11 +33,13 @@ class PublicInvoiceController extends Controller { protected InvoiceService $invoiceService; protected PaymentGatewayManager $gatewayManager; + protected PaymentService $paymentService; - public function __construct(InvoiceService $invoiceService, PaymentGatewayManager $gatewayManager) + public function __construct(InvoiceService $invoiceService, PaymentGatewayManager $gatewayManager, PaymentService $paymentService) { $this->invoiceService = $invoiceService; $this->gatewayManager = $gatewayManager; + $this->paymentService = $paymentService; } // ------------------------------------------------------------------------- @@ -109,9 +114,12 @@ public function gateways(string $publicId): JsonResponse * a checkout.session.completed webhook when the customer pays, * which HandleSuccessfulPayment will process. * - * cash / bank_transfer / other non-redirect gateways - * → Records the payment immediately via InvoiceService::recordPayment. - * Returns HTTP 200 with the updated invoice. + * taler/qpay/other pending gateways + * → Creates a gateway transaction and returns { payment_url } or + * gateway-specific payment data. The invoice is only marked paid + * after the gateway webhook confirms payment. + * + * cash → Records the payment immediately via InvoiceService::recordPayment. * * Request body: * gateway_id string required public_id of the Gateway model @@ -132,19 +140,66 @@ public function pay(Request $request, string $publicId): JsonResponse ], 422); } - // Resolve the gateway driver - try { - $driver = $this->gatewayManager->gateway($request->input('gateway_id')); - } catch (\Exception $e) { + $gateway = Gateway::query() + ->where('company_uuid', $invoice->company_uuid) + ->where('status', 'active') + ->where(function ($query) use ($request) { + $gatewayId = $request->input('gateway_id'); + + $query->where('uuid', $gatewayId) + ->orWhere('public_id', $gatewayId); + }) + ->first(); + + if (!$gateway) { return response()->json(['error' => 'Payment gateway not found or unavailable.'], 422); } + $driver = $this->gatewayManager->driver($gateway->driver) + ->initialize($gateway->decryptedConfig(), $gateway->is_sandbox); + // ── Stripe: hosted Checkout Session ─────────────────────────────────── if ($driver instanceof StripeDriver) { return $this->initiateStripeCheckout($driver, $invoice, $request); } - // ── All other gateways: immediate manual record ─────────────────────── + // ── Cash/manual: immediate local record ──────────────────────────────── + if ($driver instanceof CashDriver) { + return $this->recordManualPayment($driver, $invoice, $request); + } + + // ── Redirect / asynchronous gateways: create gateway charge ─────────── + $response = $this->paymentService->charge( + $gateway->public_id ?? $gateway->uuid, + $this->buildPurchaseRequest($invoice, [ + 'gateway_public_id' => $gateway->public_id, + 'gateway_uuid' => $gateway->uuid, + 'gateway_driver' => $gateway->driver, + 'company_uuid' => $invoice->company_uuid, + ]) + ); + + if ($response->isFailed()) { + return response()->json([ + 'error' => $response->message ?? 'Failed to initiate payment. Please try again.', + ], 422); + } + + if ($response->isPending()) { + return response()->json($this->pendingPaymentPayload($response, $gateway)); + } + + return response()->json([ + 'status' => $response->status, + 'payment_status' => $response->status, + 'gateway_transaction_id' => $response->gatewayTransactionId, + 'message' => $response->message ?? 'Payment processed successfully.', + 'invoice' => (new InvoiceResource($invoice->fresh(['customer', 'items'])))->resolve(), + ]); + } + + private function recordManualPayment(CashDriver $driver, Invoice $invoice, Request $request): JsonResponse + { $invoice = $this->invoiceService->recordPayment($invoice, $invoice->balance, [ 'payment_method' => $driver->getCode(), 'reference' => $request->input('reference'), @@ -182,25 +237,7 @@ private function initiateStripeCheckout(StripeDriver $driver, Invoice $invoice, 'payment' => 'cancelled', ]); - // Resolve customer email if available - $customerEmail = null; - if ($invoice->customer && method_exists($invoice->customer, 'getAttribute')) { - $customerEmail = $invoice->customer->email ?? $invoice->customer->contact_email ?? null; - } - - $purchaseRequest = new PurchaseRequest( - amount: (int) $invoice->balance, - currency: $invoice->currency ?? 'USD', - description: 'Invoice ' . $invoice->number, - customerEmail: $customerEmail, - invoiceUuid: $invoice->uuid, - returnUrl: $successUrl, - cancelUrl: $cancelUrl, - metadata: [ - 'invoice_public_id' => $invoice->public_id, - 'invoice_number' => $invoice->number, - ], - ); + $purchaseRequest = $this->buildPurchaseRequest($invoice); try { $response = $driver->createCheckoutSession($purchaseRequest, $successUrl, $cancelUrl); @@ -217,10 +254,69 @@ private function initiateStripeCheckout(StripeDriver $driver, Invoice $invoice, ], 422); } - return response()->json([ + return response()->json(array_merge($this->pendingPaymentPayload($response, null), [ 'checkout_url' => $response->data['checkout_url'], 'checkout_session_id' => $response->data['checkout_session_id'] ?? null, + ])); + } + + private function buildPurchaseRequest(Invoice $invoice, array $metadata = []): PurchaseRequest + { + $successUrl = Utils::consoleUrl('~/invoice', [ + 'id' => $invoice->public_id, + 'payment' => 'success', + ]); + $cancelUrl = Utils::consoleUrl('~/invoice', [ + 'id' => $invoice->public_id, + 'payment' => 'cancelled', ]); + + $customerEmail = null; + if ($invoice->customer && method_exists($invoice->customer, 'getAttribute')) { + $customerEmail = $invoice->customer->email ?? $invoice->customer->contact_email ?? null; + } + + return new PurchaseRequest( + amount: (int) $invoice->balance, + currency: $invoice->currency ?? 'USD', + description: 'Invoice ' . $invoice->number, + customerEmail: $customerEmail, + invoiceUuid: $invoice->uuid, + returnUrl: $successUrl, + cancelUrl: $cancelUrl, + metadata: array_merge([ + 'invoice_public_id' => $invoice->public_id, + 'invoice_number' => $invoice->number, + ], $metadata), + ); + } + + private function pendingPaymentPayload(GatewayResponse $response, ?Gateway $gateway): array + { + $paymentUrl = $response->data['checkout_url'] + ?? $response->data['payment_url'] + ?? $response->data['taler_pay_uri'] + ?? data_get($response->data, 'urls.0.link'); + + $payload = [ + 'status' => GatewayResponse::STATUS_PENDING, + 'payment_status' => GatewayResponse::STATUS_PENDING, + 'gateway_transaction_id' => $response->gatewayTransactionId, + 'payment_url' => $paymentUrl, + 'payment_uri' => $paymentUrl, + 'message' => $response->message, + 'data' => $response->data, + ]; + + if ($gateway) { + $payload['gateway'] = [ + 'id' => $gateway->public_id, + 'driver' => $gateway->driver, + 'name' => $gateway->name, + ]; + } + + return $payload; } /** diff --git a/server/src/Http/Controllers/WebhookController.php b/server/src/Http/Controllers/WebhookController.php index 49d08aa..05ec56a 100644 --- a/server/src/Http/Controllers/WebhookController.php +++ b/server/src/Http/Controllers/WebhookController.php @@ -62,11 +62,22 @@ public function handle(Request $request, string $driver): JsonResponse $companyUuid = $request->input('company_uuid') ?? $request->header('X-Company-UUID') ?? null; + $gatewayIdentifier = $request->input('gateway_id') + ?? $request->input('gateway_uuid') + ?? $request->input('gateway_public_id') + ?? $request->header('X-Gateway-ID') + ?? null; // Find the active gateway for this driver $gateway = Gateway::query() ->when($companyUuid, fn ($q) => $q->where('company_uuid', $companyUuid)) ->where('driver', $driver) + ->when($gatewayIdentifier, function ($q) use ($gatewayIdentifier) { + $q->where(function ($q) use ($gatewayIdentifier) { + $q->where('uuid', $gatewayIdentifier) + ->orWhere('public_id', $gatewayIdentifier); + }); + }) ->where('status', 'active') ->first(); diff --git a/server/tests/Gateways/TalerDriverTest.php b/server/tests/Gateways/TalerDriverTest.php index 6e3461e..17a42d3 100644 --- a/server/tests/Gateways/TalerDriverTest.php +++ b/server/tests/Gateways/TalerDriverTest.php @@ -105,6 +105,7 @@ function talerDriver(array $config = []): TalerDriver ->and($response->eventType)->toBe(GatewayResponse::EVENT_PAYMENT_PENDING) ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001') ->and($response->data['taler_pay_uri'])->toBe('taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001') + ->and($response->data['payment_url'])->toBe('taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001') ->and($response->data['invoice_uuid'])->toBe('invoice-uuid-abc'); }); @@ -163,6 +164,35 @@ function talerDriver(array $config = []): TalerDriver }); }); +test('purchase_sends_deterministic_order_id', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['order_id' => 'ledger-returned-order-id'], + 200 + ), + 'https://backend.example.taler.net/instances/testmerchant/private/orders/ledger-returned-order-id' => Http::response( + ['order_status' => 'unpaid', 'taler_pay_uri' => 'taler://pay/...'], + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 500, + currency: 'EUR', + description: 'Test', + invoiceUuid: 'invoice-uuid-for-order-id', + ); + + talerDriver()->purchase($request); + + Http::assertSent(function ($httpRequest) { + $body = $httpRequest->data(); + return isset($body['order_id']) + && str_starts_with($body['order_id'], 'ledger-') + && strlen($body['order_id']) === 39; + }); +}); + // --------------------------------------------------------------------------- // purchase() — failure paths // --------------------------------------------------------------------------- @@ -206,6 +236,43 @@ function talerDriver(array $config = []): TalerDriver expect($response->isFailed())->toBeTrue(); }); +test('purchase_returns_failure_when_payment_uri_missing', function () { + Http::fake([ + 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response( + ['order_id' => 'TALER-ORDER-NO-URI'], + 200 + ), + 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-NO-URI' => Http::response( + ['order_status' => 'unpaid'], + 200 + ), + ]); + + $request = new PurchaseRequest( + amount: 1000, + currency: 'USD', + description: 'Test', + ); + + $response = talerDriver()->purchase($request); + + expect($response->isFailed())->toBeTrue() + ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-NO-URI'); +}); + +test('purchase_returns_failure_when_required_config_missing', function () { + $request = new PurchaseRequest( + amount: 1000, + currency: 'USD', + description: 'Test', + ); + + $response = talerDriver(['backend_url' => ''])->purchase($request); + + expect($response->isFailed())->toBeTrue() + ->and($response->message)->toContain('Backend URL'); +}); + // --------------------------------------------------------------------------- // handleWebhook() — happy path // --------------------------------------------------------------------------- @@ -315,7 +382,8 @@ function talerDriver(array $config = []): TalerDriver ->and($response->eventType)->toBe(GatewayResponse::EVENT_REFUND_PROCESSED) ->and($response->amount)->toBe(2500) ->and($response->currency)->toBe('USD') - ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001'); + ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001') + ->and($response->data['taler_refund_uri'])->toBe('taler://refund/...'); }); test('refund_sends_correct_taler_amount_format', function () { From bdefa324ea9379aef2d0bfd28307e723949eddfa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 22 Jun 2026 12:02:05 +0800 Subject: [PATCH 3/9] feat: add revenue lifecycle safeguards --- .../Commands/RepairRevenueLifecycle.php | 135 +++++ server/src/Observers/InvoiceObserver.php | 19 + .../src/Observers/OrderAccountingObserver.php | 290 +-------- .../src/Providers/LedgerServiceProvider.php | 3 + .../src/Services/RevenueLifecycleService.php | 569 ++++++++++++++++++ server/tests/Feature.php | 32 +- 6 files changed, 769 insertions(+), 279 deletions(-) create mode 100644 server/src/Console/Commands/RepairRevenueLifecycle.php create mode 100644 server/src/Services/RevenueLifecycleService.php diff --git a/server/src/Console/Commands/RepairRevenueLifecycle.php b/server/src/Console/Commands/RepairRevenueLifecycle.php new file mode 100644 index 0000000..0ae42f3 --- /dev/null +++ b/server/src/Console/Commands/RepairRevenueLifecycle.php @@ -0,0 +1,135 @@ +option('apply'); + $limit = max(1, (int) $this->option('limit')); + + $this->info($apply ? '[Ledger] Applying revenue lifecycle repairs...' : '[Ledger] Dry run: revenue lifecycle repair audit.'); + + $orderClass = 'Fleetbase\\FleetOps\\Models\\Order'; + if (!class_exists($orderClass)) { + $this->warn('[Ledger] FleetOps Order model is unavailable; skipping order-linked repairs.'); + } else { + $this->reportOrders($orderClass, $apply, $limit); + } + + $this->reportInvoices($apply, $limit); + $this->reportMissingReversals(); + + $this->info($apply ? '[Ledger] Repair run complete.' : '[Ledger] Dry run complete. Re-run with --apply to repair eligible records.'); + + return self::SUCCESS; + } + + private function reportOrders(string $orderClass, bool $apply, int $limit): void + { + $query = $orderClass::withTrashed() + ->where(function ($query) { + $query->whereNotNull('deleted_at') + ->orWhereIn('status', ['canceled', 'cancelled', 'order_canceled']); + }); + + $total = (clone $query)->count(); + $orders = $query + ->limit($limit) + ->get(); + + $this->line("[Ledger] Inactive/deleted FleetOps orders found: {$total}"); + $this->sample($orders); + + if (!$apply) { + return; + } + + foreach ($orders as $order) { + $this->revenueLifecycleService->repairOrder($order, 'repair_command'); + } + } + + private function reportInvoices(bool $apply, int $limit): void + { + $query = Invoice::withTrashed() + ->where(function ($query) { + $query->whereNotNull('deleted_at') + ->orWhereIn('status', ['void', 'voided', 'cancelled', 'canceled']); + }); + + $total = (clone $query)->count(); + $invoices = $query + ->limit($limit) + ->get(); + + $this->line("[Ledger] Deleted/void/cancelled invoices found: {$total}"); + $this->sample($invoices); + + if (!$apply) { + return; + } + + foreach ($invoices as $invoice) { + $this->revenueLifecycleService->repairInvoice($invoice, 'repair_command'); + } + } + + private function reportMissingReversals(): void + { + $missing = DB::table('ledger_journals as journals') + ->where('journals.type', 'revenue_recognition') + ->where('journals.status', 'posted') + ->whereNull('journals.deleted_at') + ->whereNotExists(function ($query) { + $query->selectRaw('1') + ->from('ledger_journals as reversals') + ->where('reversals.type', 'revenue_recognition_reversal') + ->whereNull('reversals.deleted_at') + ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(reversals.meta, '$.reverses_journal_uuid')) = journals.uuid"); + }) + ->whereExists(function ($query) { + $query->selectRaw('1') + ->from('ledger_invoices') + ->whereRaw("ledger_invoices.uuid = JSON_UNQUOTE(JSON_EXTRACT(journals.meta, '$.invoice_uuid'))") + ->where(function ($invoiceQuery) { + $invoiceQuery->whereNotNull('ledger_invoices.deleted_at') + ->orWhereIn('ledger_invoices.status', ['void', 'voided', 'cancelled', 'canceled']); + }); + }) + ->count(); + + $this->line("[Ledger] Revenue-recognition journals missing reversals for inactive invoices: {$missing}"); + } + + private function sample($records): void + { + $ids = $records + ->take(5) + ->map(fn ($record) => $record->public_id ?? $record->uuid) + ->filter() + ->values() + ->all(); + + if ($ids) { + $this->line('[Ledger] Sample: ' . implode(', ', $ids)); + } + } +} diff --git a/server/src/Observers/InvoiceObserver.php b/server/src/Observers/InvoiceObserver.php index 7eb70e7..ff20c5d 100644 --- a/server/src/Observers/InvoiceObserver.php +++ b/server/src/Observers/InvoiceObserver.php @@ -5,9 +5,14 @@ use Fleetbase\Ledger\Events\InvoiceCreated; use Fleetbase\Ledger\Events\InvoicePaid; use Fleetbase\Ledger\Models\Invoice; +use Fleetbase\Ledger\Services\RevenueLifecycleService; class InvoiceObserver { + public function __construct(protected RevenueLifecycleService $revenueLifecycleService) + { + } + /** * Handle the Invoice "created" event. * @@ -30,4 +35,18 @@ public function updated(Invoice $invoice) event(new InvoicePaid($invoice)); } } + + public function deleting(Invoice $invoice): void + { + if ($invoice->isForceDeleting()) { + return; + } + + $this->revenueLifecycleService->handleInvoiceDeleting($invoice); + } + + public function restored(Invoice $invoice): void + { + $this->revenueLifecycleService->handleInvoiceRestored($invoice); + } } diff --git a/server/src/Observers/OrderAccountingObserver.php b/server/src/Observers/OrderAccountingObserver.php index f9536fa..057a6a6 100644 --- a/server/src/Observers/OrderAccountingObserver.php +++ b/server/src/Observers/OrderAccountingObserver.php @@ -3,9 +3,9 @@ namespace Fleetbase\Ledger\Observers; use Fleetbase\Ledger\Models\Account; -use Fleetbase\Ledger\Models\Invoice; use Fleetbase\Ledger\Models\Journal; use Fleetbase\Ledger\Services\LedgerService; +use Fleetbase\Ledger\Services\RevenueLifecycleService; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -24,8 +24,10 @@ class OrderAccountingObserver { private const CANCELED_STATUSES = ['canceled', 'cancelled', 'order_canceled']; - public function __construct(protected LedgerService $ledgerService) - { + public function __construct( + protected LedgerService $ledgerService, + protected RevenueLifecycleService $revenueLifecycleService, + ) { } /** @@ -136,296 +138,28 @@ public function updated($order): void $currentStatus = $this->normalizeStatus((string) $order->status); if (!$this->isCanceledStatus($previousStatus) && $this->isCanceledStatus($currentStatus)) { - $this->handleOrderCanceled($order, $previousStatus, $currentStatus); + $this->revenueLifecycleService->handleOrderCanceled($order, $previousStatus, $currentStatus); return; } if ($this->isCanceledStatus($previousStatus) && !$this->isCanceledStatus($currentStatus)) { - $this->handleOrderRestored($order, $previousStatus, $currentStatus); - } - } - - private function handleOrderCanceled($order, string $previousStatus, string $currentStatus): void - { - try { - DB::transaction(function () use ($order, $previousStatus, $currentStatus) { - $reversedStorefrontSales = $this->reverseStorefrontSaleJournals($order, $previousStatus, $currentStatus); - $reversedInvoiceRevenue = $this->reverseOrderInvoiceRevenue($order, $previousStatus, $currentStatus); - - if ($reversedStorefrontSales === 0 && $reversedInvoiceRevenue === 0) { - Log::channel('ledger')->info('[Ledger] OrderAccountingObserver: order canceled with no linked Ledger revenue to reverse.', [ - 'order_uuid' => $order->uuid, - ]); - } - }); - } catch (\Throwable $e) { - // Never let a Ledger failure abort the order cancellation flow. - Log::channel('ledger')->error('[Ledger] OrderAccountingObserver: failed to reverse order revenue.', [ - 'error' => $e->getMessage(), - 'order_uuid' => $order->uuid ?? null, - ]); - } - } - - private function handleOrderRestored($order, string $previousStatus, string $currentStatus): void - { - try { - DB::transaction(function () use ($order, $previousStatus, $currentStatus) { - $reinstatedStorefrontSales = $this->reinstateReversedJournals( - $order, - 'storefront_sale_reversal', - 'storefront_sale_reinstatement', - "Storefront sale reinstatement - Order {$order->public_id}", - $previousStatus, - $currentStatus - ); - - $reinstatedInvoiceRevenue = $this->reinstateReversedJournals( - $order, - 'revenue_recognition_reversal', - 'revenue_recognition_reinstatement', - "Invoice revenue reinstatement - Order {$order->public_id}", - $previousStatus, - $currentStatus - ); - - $this->restoreOrderInvoices($order); - - if ($reinstatedStorefrontSales === 0 && $reinstatedInvoiceRevenue === 0) { - Log::channel('ledger')->info('[Ledger] OrderAccountingObserver: order restored with no reversed Ledger revenue to reinstate.', [ - 'order_uuid' => $order->uuid, - ]); - } - }); - } catch (\Throwable $e) { - // Never let a Ledger failure abort the order restoration flow. - Log::channel('ledger')->error('[Ledger] OrderAccountingObserver: failed to reinstate order revenue.', [ - 'error' => $e->getMessage(), - 'order_uuid' => $order->uuid ?? null, - ]); + $this->revenueLifecycleService->handleOrderRestored($order, $previousStatus, $currentStatus); } } - private function reverseStorefrontSaleJournals($order, string $previousStatus, string $currentStatus): int - { - $journals = Journal::with(['debitAccount', 'creditAccount']) - ->where('type', 'storefront_sale') - ->where('status', 'posted') - ->where('meta->order_uuid', $order->uuid) - ->get(); - - return $this->reverseJournals( - $journals, - $order, - 'storefront_sale_reversal', - "Storefront sale reversal - Order {$order->public_id} canceled", - $previousStatus, - $currentStatus - ); - } - - private function reverseOrderInvoiceRevenue($order, string $previousStatus, string $currentStatus): int + public function deleted($order): void { - $invoices = Invoice::where('order_uuid', $order->uuid)->get(); - - foreach ($invoices as $invoice) { - $this->cancelOpenInvoice($invoice); - } - - if ($invoices->isEmpty()) { - return 0; - } - - $journals = Journal::with(['debitAccount', 'creditAccount']) - ->where('type', 'revenue_recognition') - ->where('status', 'posted') - ->whereIn('meta->invoice_uuid', $invoices->pluck('uuid')->all()) - ->get(); - - return $this->reverseJournals( - $journals, - $order, - 'revenue_recognition_reversal', - "Invoice revenue reversal - Order {$order->public_id} canceled", - $previousStatus, - $currentStatus - ); + $this->revenueLifecycleService->handleOrderDeleted($order); } - private function reverseJournals($journals, $order, string $reversalType, string $description, string $previousStatus, string $currentStatus): int + public function restored($order): void { - $created = 0; - - foreach ($journals as $journal) { - if (!$journal->debitAccount || !$journal->creditAccount || $journal->amount <= 0) { - continue; - } - - if ($this->journalIsCurrentlyReversed($journal->uuid, $reversalType)) { - continue; - } - - $meta = $this->correctionMeta($order, $journal, [ - 'reverses_journal_uuid' => $journal->uuid, - 'reverses_journal_id' => $journal->public_id, - 'original_journal_type' => $journal->type, - 'original_status' => $previousStatus, - 'canceled_status' => $currentStatus, - ]); - - $this->ledgerService->createJournalEntry( - $journal->creditAccount, - $journal->debitAccount, - (int) $journal->amount, - $description, - [ - 'company_uuid' => $journal->company_uuid, - 'currency' => $journal->currency, - 'journal_type' => $reversalType, - 'entry_date' => now(), - 'meta' => $meta, - ] - ); - - $created++; - } - - return $created; - } - - private function reinstateReversedJournals($order, string $reversalType, string $reinstatementType, string $description, string $previousStatus, string $currentStatus): int - { - $reversals = Journal::with(['debitAccount', 'creditAccount']) - ->where('type', $reversalType) - ->where('status', 'posted') - ->where('meta->order_uuid', $order->uuid) - ->get(); - - $created = 0; - - foreach ($reversals as $reversal) { - if (!$reversal->debitAccount || !$reversal->creditAccount || $reversal->amount <= 0) { - continue; - } - - if ($this->journalExists($reinstatementType, 'reinstates_journal_uuid', $reversal->uuid)) { - continue; - } - - $meta = $this->correctionMeta($order, $reversal, [ - 'reinstates_journal_uuid' => $reversal->uuid, - 'reinstates_journal_id' => $reversal->public_id, - 'reverses_journal_uuid' => $reversal->getMeta('reverses_journal_uuid'), - 'reverses_journal_id' => $reversal->getMeta('reverses_journal_id'), - 'reversal_journal_type' => $reversal->type, - 'restored_from_status' => $previousStatus, - 'restored_status' => $currentStatus, - ]); - - $this->ledgerService->createJournalEntry( - $reversal->creditAccount, - $reversal->debitAccount, - (int) $reversal->amount, - $description, - [ - 'company_uuid' => $reversal->company_uuid, - 'currency' => $reversal->currency, - 'journal_type' => $reinstatementType, - 'entry_date' => now(), - 'meta' => $meta, - ] - ); - - $created++; - } - - return $created; - } - - private function cancelOpenInvoice(Invoice $invoice): void - { - if (in_array($invoice->status, ['paid', 'void', 'cancelled'], true)) { + if ($this->isCanceledStatus($this->normalizeStatus((string) $order->status))) { return; } - $invoice->updateMeta([ - 'order_cancellation_previous_status' => $invoice->status, - 'order_cancellation_status_changed' => true, - ]); - - $invoice->updateQuietly([ - 'status' => $invoice->status === 'draft' ? 'void' : 'cancelled', - ]); - } - - private function restoreOrderInvoices($order): void - { - Invoice::where('order_uuid', $order->uuid) - ->whereIn('status', ['void', 'cancelled']) - ->get() - ->each(function (Invoice $invoice) { - if (!$invoice->getMeta('order_cancellation_status_changed', false)) { - return; - } - - $previousInvoiceStatus = $invoice->getMeta('order_cancellation_previous_status', 'draft'); - - $invoice->updateMeta([ - 'order_cancellation_status_changed' => false, - 'order_cancellation_restored_at' => now()->toIso8601String(), - 'order_cancellation_restored_status' => $previousInvoiceStatus, - ]); - - $invoice->updateQuietly([ - 'status' => $previousInvoiceStatus, - ]); - }); - } - - private function correctionMeta($order, Journal $journal, array $meta): array - { - $journalMeta = $journal->getMeta(); - - $baseMeta = [ - 'order_uuid' => $order->uuid, - 'order_id' => $order->public_id, - 'subject_uuid' => $order->uuid, - 'subject_type' => get_class($order), - ]; - - foreach (['invoice_uuid', 'seed', 'seed_id'] as $metaKey) { - if (isset($journalMeta[$metaKey])) { - $baseMeta[$metaKey] = $journalMeta[$metaKey]; - } elseif ($order->hasMeta($metaKey)) { - $baseMeta[$metaKey] = $order->getMeta($metaKey); - } - } - - return array_merge($baseMeta, $meta); - } - - private function journalExists(string $journalType, string $metaKey, string $journalUuid): bool - { - return Journal::where('type', $journalType) - ->where("meta->{$metaKey}", $journalUuid) - ->exists(); - } - - private function journalIsCurrentlyReversed(string $journalUuid, string $reversalType): bool - { - $latestReversal = Journal::where('type', $reversalType) - ->where('meta->reverses_journal_uuid', $journalUuid) - ->orderBy('created_at', 'desc') - ->first(); - - if (!$latestReversal) { - return false; - } - - $reinstatementType = str_replace('_reversal', '_reinstatement', $reversalType); - - return !$this->journalExists($reinstatementType, 'reinstates_journal_uuid', $latestReversal->uuid); + $this->revenueLifecycleService->handleOrderRestoredFromDelete($order); } private function normalizeStatus(string $status): string diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php index c435340..8829cb0 100644 --- a/server/src/Providers/LedgerServiceProvider.php +++ b/server/src/Providers/LedgerServiceProvider.php @@ -12,6 +12,7 @@ use Fleetbase\Ledger\Services\InvoiceService; use Fleetbase\Ledger\Services\LedgerService; use Fleetbase\Ledger\Services\PaymentService; +use Fleetbase\Ledger\Services\RevenueLifecycleService; use Fleetbase\Ledger\Services\WalletService; use Fleetbase\Providers\CoreServiceProvider; use Fleetbase\Services\TemplateRenderService; @@ -65,6 +66,7 @@ public function register() $this->app->singleton(LedgerService::class); $this->app->singleton(WalletService::class); $this->app->singleton(InvoiceService::class); + $this->app->singleton(RevenueLifecycleService::class); // Payment gateway system // The PaymentGatewayManager is bound as a singleton and also aliased @@ -111,6 +113,7 @@ public function boot() \Fleetbase\Ledger\Console\Commands\ProvisionLedgerDefaults::class, \Fleetbase\Ledger\Console\Commands\BackfillTransactionDirection::class, \Fleetbase\Ledger\Console\Commands\UpdateOverdueInvoices::class, + \Fleetbase\Ledger\Console\Commands\RepairRevenueLifecycle::class, ]); } } diff --git a/server/src/Services/RevenueLifecycleService.php b/server/src/Services/RevenueLifecycleService.php new file mode 100644 index 0000000..fb29e6e --- /dev/null +++ b/server/src/Services/RevenueLifecycleService.php @@ -0,0 +1,569 @@ +reverseStorefrontSaleJournals($order, $previousStatus, $currentStatus, $reason); + $reversedInvoiceRevenue = $this->reverseOrderInvoiceRevenue($order, $previousStatus, $currentStatus, $reason); + $this->voidOrderTransactionIfUnpaid($order, $reason); + + if ($reversedStorefrontSales === 0 && $reversedInvoiceRevenue === 0) { + Log::channel('ledger')->info('[Ledger] RevenueLifecycleService: order lifecycle change had no linked Ledger revenue to reverse.', [ + 'order_uuid' => $order->uuid, + 'reason' => $reason, + ]); + } + }); + } catch (\Throwable $e) { + Log::channel('ledger')->error('[Ledger] RevenueLifecycleService: failed to reverse order revenue.', [ + 'error' => $e->getMessage(), + 'order_uuid' => $order->uuid ?? null, + 'reason' => $reason, + ]); + } + } + + public function handleOrderRestored($order, string $previousStatus, string $currentStatus, string $reason = 'order_restored'): void + { + try { + DB::transaction(function () use ($order, $previousStatus, $currentStatus, $reason) { + $reinstatedStorefrontSales = $this->reinstateReversedJournals( + $order, + 'storefront_sale_reversal', + 'storefront_sale_reinstatement', + "Storefront sale reinstatement - Order {$order->public_id}", + $previousStatus, + $currentStatus, + $reason + ); + + $reinstatedInvoiceRevenue = $this->reinstateReversedJournals( + $order, + 'revenue_recognition_reversal', + 'revenue_recognition_reinstatement', + "Invoice revenue reinstatement - Order {$order->public_id}", + $previousStatus, + $currentStatus, + $reason + ); + + $this->restoreOrderInvoices($order, $reason); + $this->restoreOrderTransactionIfLifecycleVoided($order, $reason); + + if ($reinstatedStorefrontSales === 0 && $reinstatedInvoiceRevenue === 0) { + Log::channel('ledger')->info('[Ledger] RevenueLifecycleService: order restore had no reversed Ledger revenue to reinstate.', [ + 'order_uuid' => $order->uuid, + 'reason' => $reason, + ]); + } + }); + } catch (\Throwable $e) { + Log::channel('ledger')->error('[Ledger] RevenueLifecycleService: failed to reinstate order revenue.', [ + 'error' => $e->getMessage(), + 'order_uuid' => $order->uuid ?? null, + 'reason' => $reason, + ]); + } + } + + public function handleOrderDeleted($order): void + { + $this->handleOrderCanceled($order, (string) ($order->status ?? 'active'), 'deleted', 'order_deleted'); + } + + public function handleOrderRestoredFromDelete($order): void + { + $this->handleOrderRestored($order, 'deleted', (string) ($order->status ?? 'restored'), 'order_restored_from_delete'); + } + + public function handleInvoiceDeleting(Invoice $invoice): void + { + try { + DB::transaction(function () use ($invoice) { + if ($invoice->status === 'paid') { + $this->flagPaidInvoiceForReview($invoice, 'invoice_deleted'); + + return; + } + + $this->cancelOpenInvoice($invoice, 'invoice_deleted'); + $this->reverseInvoiceRevenue($invoice, 'invoice_deleted'); + $this->voidInvoiceTransactionIfUnpaid($invoice, 'invoice_deleted'); + }); + } catch (\Throwable $e) { + Log::channel('ledger')->error('[Ledger] RevenueLifecycleService: failed to process invoice deletion.', [ + 'error' => $e->getMessage(), + 'invoice_uuid' => $invoice->uuid ?? null, + ]); + } + } + + public function handleInvoiceRestored(Invoice $invoice): void + { + try { + DB::transaction(function () use ($invoice) { + if ($invoice->getMeta('revenue_lifecycle_status_changed', false)) { + $previousInvoiceStatus = $invoice->getMeta('revenue_lifecycle_previous_status', 'draft'); + + $invoice->updateMeta([ + 'revenue_lifecycle_status_changed' => false, + 'revenue_lifecycle_restored_at' => now()->toIso8601String(), + 'revenue_lifecycle_restored_status' => $previousInvoiceStatus, + 'revenue_lifecycle_restore_reason' => 'invoice_restored', + ]); + + $invoice->updateQuietly([ + 'status' => $previousInvoiceStatus, + ]); + } + + $this->restoreInvoiceTransactionIfLifecycleVoided($invoice, 'invoice_restored'); + }); + } catch (\Throwable $e) { + Log::channel('ledger')->error('[Ledger] RevenueLifecycleService: failed to process invoice restoration.', [ + 'error' => $e->getMessage(), + 'invoice_uuid' => $invoice->uuid ?? null, + ]); + } + } + + public function repairOrder($order, string $reason = 'repair'): void + { + $status = strtolower((string) ($order->status ?? '')); + + if ($order->deleted_at || in_array($status, self::CANCELED_STATUSES, true)) { + $this->handleOrderCanceled($order, $status ?: 'unknown', $order->deleted_at ? 'deleted' : $status, $reason); + } + } + + public function repairInvoice(Invoice $invoice, string $reason = 'repair'): void + { + if ($invoice->deleted_at || in_array($invoice->status, ['void', 'voided', 'cancelled', 'canceled'], true)) { + if ($invoice->status === 'paid') { + $this->flagPaidInvoiceForReview($invoice, $reason); + + return; + } + + $this->cancelOpenInvoice($invoice, $reason); + $this->reverseInvoiceRevenue($invoice, $reason); + $this->voidInvoiceTransactionIfUnpaid($invoice, $reason); + } + } + + private function reverseStorefrontSaleJournals($order, string $previousStatus, string $currentStatus, string $reason): int + { + $journals = Journal::with(['debitAccount', 'creditAccount']) + ->where('type', 'storefront_sale') + ->where('status', 'posted') + ->where('meta->order_uuid', $order->uuid) + ->get(); + + return $this->reverseJournals( + $journals, + $order, + 'storefront_sale_reversal', + "Storefront sale reversal - Order {$order->public_id} canceled", + $previousStatus, + $currentStatus, + $reason + ); + } + + private function reverseOrderInvoiceRevenue($order, string $previousStatus, string $currentStatus, string $reason): int + { + $invoices = Invoice::withTrashed()->where('order_uuid', $order->uuid)->get(); + + foreach ($invoices as $invoice) { + if ($invoice->status === 'paid') { + $this->flagPaidInvoiceForReview($invoice, $reason); + continue; + } + + $this->cancelOpenInvoice($invoice, $reason); + $this->voidInvoiceTransactionIfUnpaid($invoice, $reason); + } + + if ($invoices->isEmpty()) { + return 0; + } + + $journals = Journal::with(['debitAccount', 'creditAccount']) + ->where('type', 'revenue_recognition') + ->where('status', 'posted') + ->whereIn('meta->invoice_uuid', $invoices->pluck('uuid')->all()) + ->get(); + + return $this->reverseJournals( + $journals, + $order, + 'revenue_recognition_reversal', + "Invoice revenue reversal - Order {$order->public_id} canceled", + $previousStatus, + $currentStatus, + $reason + ); + } + + private function reverseInvoiceRevenue(Invoice $invoice, string $reason): int + { + $journals = Journal::with(['debitAccount', 'creditAccount']) + ->where('type', 'revenue_recognition') + ->where('status', 'posted') + ->where('meta->invoice_uuid', $invoice->uuid) + ->get(); + + return $this->reverseJournalsForInvoice($journals, $invoice, $reason); + } + + private function reverseJournals($journals, $order, string $reversalType, string $description, string $previousStatus, string $currentStatus, string $reason): int + { + $created = 0; + + foreach ($journals as $journal) { + if (!$journal->debitAccount || !$journal->creditAccount || $journal->amount <= 0) { + continue; + } + + if ($this->journalIsCurrentlyReversed($journal->uuid, $reversalType)) { + continue; + } + + $meta = $this->correctionMeta($order, $journal, [ + 'reverses_journal_uuid' => $journal->uuid, + 'reverses_journal_id' => $journal->public_id, + 'original_journal_type' => $journal->type, + 'original_status' => $previousStatus, + 'canceled_status' => $currentStatus, + 'revenue_lifecycle_reason' => $reason, + ]); + + $this->ledgerService->createJournalEntry( + $journal->creditAccount, + $journal->debitAccount, + (int) $journal->amount, + $description, + [ + 'company_uuid' => $journal->company_uuid, + 'currency' => $journal->currency, + 'journal_type' => $reversalType, + 'entry_date' => now(), + 'meta' => $meta, + ] + ); + + $created++; + } + + return $created; + } + + private function reverseJournalsForInvoice($journals, Invoice $invoice, string $reason): int + { + $created = 0; + + foreach ($journals as $journal) { + if (!$journal->debitAccount || !$journal->creditAccount || $journal->amount <= 0) { + continue; + } + + if ($this->journalIsCurrentlyReversed($journal->uuid, 'revenue_recognition_reversal')) { + continue; + } + + $this->ledgerService->createJournalEntry( + $journal->creditAccount, + $journal->debitAccount, + (int) $journal->amount, + "Invoice revenue reversal - Invoice {$invoice->number}", + [ + 'company_uuid' => $journal->company_uuid, + 'currency' => $journal->currency, + 'journal_type' => 'revenue_recognition_reversal', + 'entry_date' => now(), + 'meta' => [ + 'invoice_uuid' => $invoice->uuid, + 'invoice_id' => $invoice->public_id, + 'reverses_journal_uuid' => $journal->uuid, + 'reverses_journal_id' => $journal->public_id, + 'original_journal_type' => $journal->type, + 'revenue_lifecycle_reason' => $reason, + ], + ] + ); + + $created++; + } + + return $created; + } + + private function reinstateReversedJournals($order, string $reversalType, string $reinstatementType, string $description, string $previousStatus, string $currentStatus, string $reason): int + { + $reversals = Journal::with(['debitAccount', 'creditAccount']) + ->where('type', $reversalType) + ->where('status', 'posted') + ->where('meta->order_uuid', $order->uuid) + ->get(); + + $created = 0; + + foreach ($reversals as $reversal) { + if (!$reversal->debitAccount || !$reversal->creditAccount || $reversal->amount <= 0) { + continue; + } + + if ($this->journalExists($reinstatementType, 'reinstates_journal_uuid', $reversal->uuid)) { + continue; + } + + $meta = $this->correctionMeta($order, $reversal, [ + 'reinstates_journal_uuid' => $reversal->uuid, + 'reinstates_journal_id' => $reversal->public_id, + 'reverses_journal_uuid' => $reversal->getMeta('reverses_journal_uuid'), + 'reverses_journal_id' => $reversal->getMeta('reverses_journal_id'), + 'reversal_journal_type' => $reversal->type, + 'restored_from_status' => $previousStatus, + 'restored_status' => $currentStatus, + 'revenue_lifecycle_reason' => $reason, + ]); + + $this->ledgerService->createJournalEntry( + $reversal->creditAccount, + $reversal->debitAccount, + (int) $reversal->amount, + $description, + [ + 'company_uuid' => $reversal->company_uuid, + 'currency' => $reversal->currency, + 'journal_type' => $reinstatementType, + 'entry_date' => now(), + 'meta' => $meta, + ] + ); + + $created++; + } + + return $created; + } + + private function cancelOpenInvoice(Invoice $invoice, string $reason): void + { + if (in_array($invoice->status, ['paid', 'void', 'voided', 'cancelled', 'canceled'], true)) { + return; + } + + $previousStatus = $invoice->status; + + $invoice->updateMeta([ + 'revenue_lifecycle_previous_status' => $previousStatus, + 'revenue_lifecycle_status_changed' => true, + 'revenue_lifecycle_reason' => $reason, + 'revenue_lifecycle_changed_at' => now()->toIso8601String(), + ]); + + $invoice->updateQuietly([ + 'status' => $previousStatus === 'draft' ? 'void' : 'cancelled', + ]); + } + + private function restoreOrderInvoices($order, string $reason): void + { + Invoice::withTrashed() + ->where('order_uuid', $order->uuid) + ->whereIn('status', ['void', 'voided', 'cancelled', 'canceled']) + ->get() + ->each(function (Invoice $invoice) use ($reason) { + if (!$invoice->getMeta('revenue_lifecycle_status_changed', false)) { + return; + } + + $previousInvoiceStatus = $invoice->getMeta('revenue_lifecycle_previous_status', 'draft'); + + $invoice->updateMeta([ + 'revenue_lifecycle_status_changed' => false, + 'revenue_lifecycle_restored_at' => now()->toIso8601String(), + 'revenue_lifecycle_restored_status' => $previousInvoiceStatus, + 'revenue_lifecycle_restore_reason' => $reason, + ]); + + $invoice->updateQuietly([ + 'status' => $previousInvoiceStatus, + ]); + }); + } + + private function flagPaidInvoiceForReview(Invoice $invoice, string $reason): void + { + $invoice->updateMeta([ + 'revenue_lifecycle_requires_review' => true, + 'revenue_lifecycle_review_reason' => $reason, + 'revenue_lifecycle_review_at' => now()->toIso8601String(), + ]); + } + + private function voidInvoiceTransactionIfUnpaid(Invoice $invoice, string $reason): void + { + if ((int) $invoice->amount_paid > 0 || !$invoice->transaction_uuid) { + return; + } + + $this->voidTransaction($invoice->transaction_uuid, $reason, [ + 'invoice_uuid' => $invoice->uuid, + 'invoice_id' => $invoice->public_id, + ]); + } + + private function voidOrderTransactionIfUnpaid($order, string $reason): void + { + if (!$order->transaction_uuid) { + return; + } + + $hasPaidInvoice = Invoice::withTrashed() + ->where('order_uuid', $order->uuid) + ->where(function ($query) { + $query->where('status', 'paid')->orWhere('amount_paid', '>', 0); + }) + ->exists(); + + if ($hasPaidInvoice) { + return; + } + + $this->voidTransaction($order->transaction_uuid, $reason, [ + 'order_uuid' => $order->uuid, + 'order_id' => $order->public_id, + ]); + } + + private function restoreOrderTransactionIfLifecycleVoided($order, string $reason): void + { + if (!$order->transaction_uuid) { + return; + } + + $transaction = Transaction::where('uuid', $order->transaction_uuid)->first(); + if (!$transaction || !$transaction->getMeta('revenue_lifecycle_voided', false)) { + return; + } + + $transaction->updateMeta([ + 'revenue_lifecycle_voided' => false, + 'revenue_lifecycle_restored_at' => now()->toIso8601String(), + 'revenue_lifecycle_restore_reason' => $reason, + ]); + + $transaction->updateQuietly([ + 'status' => $transaction->getMeta('revenue_lifecycle_previous_status', 'success'), + 'voided_at' => null, + ]); + } + + private function restoreInvoiceTransactionIfLifecycleVoided(Invoice $invoice, string $reason): void + { + if (!$invoice->transaction_uuid) { + return; + } + + $transaction = Transaction::where('uuid', $invoice->transaction_uuid)->first(); + if (!$transaction || !$transaction->getMeta('revenue_lifecycle_voided', false)) { + return; + } + + $transaction->updateMeta([ + 'revenue_lifecycle_voided' => false, + 'revenue_lifecycle_restored_at' => now()->toIso8601String(), + 'revenue_lifecycle_restore_reason' => $reason, + ]); + + $transaction->updateQuietly([ + 'status' => $transaction->getMeta('revenue_lifecycle_previous_status', 'success'), + 'voided_at' => null, + ]); + } + + private function voidTransaction(string $transactionUuid, string $reason, array $meta = []): void + { + $transaction = Transaction::where('uuid', $transactionUuid)->first(); + + if (!$transaction || $transaction->voided_at || in_array($transaction->status, ['void', 'voided'], true)) { + return; + } + + $transaction->updateMeta(array_merge($meta, [ + 'revenue_lifecycle_voided' => true, + 'revenue_lifecycle_previous_status' => $transaction->status, + 'revenue_lifecycle_void_reason' => $reason, + 'revenue_lifecycle_voided_at' => now()->toIso8601String(), + ])); + + $transaction->updateQuietly([ + 'status' => Transaction::STATUS_VOIDED, + 'voided_at' => now(), + ]); + } + + private function correctionMeta($order, Journal $journal, array $meta): array + { + $journalMeta = $journal->getMeta(); + + $baseMeta = [ + 'order_uuid' => $order->uuid, + 'order_id' => $order->public_id, + 'subject_uuid' => $order->uuid, + 'subject_type' => get_class($order), + ]; + + foreach (['invoice_uuid', 'seed', 'seed_id'] as $metaKey) { + if (isset($journalMeta[$metaKey])) { + $baseMeta[$metaKey] = $journalMeta[$metaKey]; + } elseif ($order->hasMeta($metaKey)) { + $baseMeta[$metaKey] = $order->getMeta($metaKey); + } + } + + return array_merge($baseMeta, $meta); + } + + private function journalExists(string $journalType, string $metaKey, string $journalUuid): bool + { + return Journal::where('type', $journalType) + ->where("meta->{$metaKey}", $journalUuid) + ->exists(); + } + + private function journalIsCurrentlyReversed(string $journalUuid, string $reversalType): bool + { + $latestReversal = Journal::where('type', $reversalType) + ->where('meta->reverses_journal_uuid', $journalUuid) + ->orderBy('created_at', 'desc') + ->first(); + + if (!$latestReversal) { + return false; + } + + $reinstatementType = str_replace('_reversal', '_reinstatement', $reversalType); + + return !$this->journalExists($reinstatementType, 'reinstates_journal_uuid', $latestReversal->uuid); + } +} diff --git a/server/tests/Feature.php b/server/tests/Feature.php index 7475344..9c4a6eb 100644 --- a/server/tests/Feature.php +++ b/server/tests/Feature.php @@ -53,6 +53,7 @@ test('order accounting observer handles cancellation and reinstatement journal corrections', function () { $observer = file_get_contents(__DIR__ . '/../src/Observers/OrderAccountingObserver.php'); + $service = file_get_contents(__DIR__ . '/../src/Services/RevenueLifecycleService.php'); $provider = file_get_contents(__DIR__ . '/../src/Providers/LedgerServiceProvider.php'); expect($provider) @@ -60,13 +61,42 @@ ->toContain('OrderAccountingObserver::class'); expect($observer) + ->toContain('RevenueLifecycleService') + ->toContain('handleOrderCanceled') + ->toContain('handleOrderRestored') + ->toContain('handleOrderDeleted') + ->toContain('handleOrderRestoredFromDelete'); + + expect($service) ->toContain('storefront_sale_reversal') ->toContain('storefront_sale_reinstatement') ->toContain('revenue_recognition_reversal') ->toContain('revenue_recognition_reinstatement') ->toContain('reverses_journal_uuid') ->toContain('reinstates_journal_uuid') - ->toContain('order_cancellation_previous_status'); + ->toContain('revenue_lifecycle_previous_status'); +}); + +test('invoice deletion and repair command use the revenue lifecycle service', function () { + $invoiceObserver = file_get_contents(__DIR__ . '/../src/Observers/InvoiceObserver.php'); + $provider = file_get_contents(__DIR__ . '/../src/Providers/LedgerServiceProvider.php'); + $command = file_get_contents(__DIR__ . '/../src/Console/Commands/RepairRevenueLifecycle.php'); + + expect($invoiceObserver) + ->toContain('RevenueLifecycleService') + ->toContain('handleInvoiceDeleting') + ->toContain('handleInvoiceRestored') + ->toContain('isForceDeleting'); + + expect($provider) + ->toContain('RevenueLifecycleService::class') + ->toContain('RepairRevenueLifecycle::class'); + + expect($command) + ->toContain('fleetops:repair-revenue-lifecycle') + ->toContain('{--apply') + ->toContain('repairOrder') + ->toContain('repairInvoice'); }); test('profit and loss deduplication preserves reversal and reinstatement journals', function () { From 040e3140721e371fc950ce1ba839b17980dcf263 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 22 Jun 2026 13:20:58 +0800 Subject: [PATCH 4/9] Add invoice filters and settlement status handling --- addon/controllers/billing/invoices/index.js | 43 ++++++++++- .../payments/transactions/index.js | 16 +++- addon/models/ledger-transaction.js | 1 + addon/routes/billing/invoices/index.js | 6 ++ addon/routes/payments/transactions/index.js | 1 + ...ettlement_status_to_transactions_table.php | 66 ++++++++++++++++ server/src/Http/Filter/InvoiceFilter.php | 75 ++++++++++++++++++- server/src/Http/Filter/TransactionFilter.php | 5 ++ server/src/Http/Resources/v1/Transaction.php | 1 + server/src/Models/Transaction.php | 1 + server/src/Observers/InvoiceObserver.php | 6 +- server/src/Services/InvoiceService.php | 48 ++++++------ .../src/Services/RevenueLifecycleService.php | 28 +++++++ server/src/Services/WalletService.php | 68 ++++++++++------- server/tests/Feature.php | 56 ++++++++++++++ .../billing/invoices/index-test.js | 73 ++++++++++++++++++ .../routes/billing/invoices/index-test.js | 50 +++++++++++++ 17 files changed, 490 insertions(+), 54 deletions(-) create mode 100644 server/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php create mode 100644 tests/unit/controllers/billing/invoices/index-test.js create mode 100644 tests/unit/routes/billing/invoices/index-test.js diff --git a/addon/controllers/billing/invoices/index.js b/addon/controllers/billing/invoices/index.js index b4344a4..23198dc 100644 --- a/addon/controllers/billing/invoices/index.js +++ b/addon/controllers/billing/invoices/index.js @@ -9,13 +9,18 @@ export default class BillingInvoicesIndexController extends Controller { @service hostRouter; @service intl; - @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'customer_uuid']; + @tracked queryParams = ['page', 'limit', 'sort', 'query', 'status', 'currency', 'order', 'customer', 'created_at', 'due_date', 'amount']; @tracked page = 1; @tracked limit = 30; @tracked sort = '-created_at'; @tracked query = null; @tracked status = null; - @tracked customer_uuid = null; + @tracked currency = null; + @tracked order = null; + @tracked customer = null; + @tracked created_at = null; + @tracked due_date = null; + @tracked amount = null; @tracked table = null; get actionButtons() { @@ -64,6 +69,11 @@ export default class BillingInvoicesIndexController extends Controller { valuePath: 'customerName', resizable: true, sortable: false, + filterable: true, + filterParam: 'customer', + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select customer', + model: 'customer', }, { label: 'Order', @@ -72,6 +82,12 @@ export default class BillingInvoicesIndexController extends Controller { action: this.viewOrder, resizable: true, sortable: false, + filterable: true, + filterParam: 'order', + filterComponent: 'filter/model', + filterComponentPlaceholder: 'Select order', + model: 'order', + modelNamePath: 'tracking', }, { label: this.intl.t('column.status'), @@ -100,6 +116,23 @@ export default class BillingInvoicesIndexController extends Controller { currencyPath: 'currency', resizable: true, sortable: true, + filterable: true, + filterParam: 'amount', + filterComponent: 'filter/range', + min: 0, + max: 10000000, + step: 100, + minLabel: 'Min', + maxLabel: 'Max', + }, + { + label: this.intl.t('column.currency'), + valuePath: 'currency', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'currency', + filterComponent: 'filter/string', }, { label: this.intl.t('column.balance'), @@ -114,6 +147,9 @@ export default class BillingInvoicesIndexController extends Controller { valuePath: 'dueDate', resizable: true, sortable: true, + filterable: true, + filterParam: 'due_date', + filterComponent: 'filter/date', }, { label: this.intl.t('column.invoice-date'), @@ -126,6 +162,9 @@ export default class BillingInvoicesIndexController extends Controller { valuePath: 'createdAt', resizable: true, sortable: true, + filterable: true, + filterParam: 'created_at', + filterComponent: 'filter/date', }, { label: '', diff --git a/addon/controllers/payments/transactions/index.js b/addon/controllers/payments/transactions/index.js index 13f937d..5d959bd 100644 --- a/addon/controllers/payments/transactions/index.js +++ b/addon/controllers/payments/transactions/index.js @@ -16,6 +16,7 @@ export default class PaymentsTransactionsIndexController extends Controller { 'description', 'type', 'status', + 'settlement_status', 'direction', 'currency', 'gateway', @@ -35,6 +36,7 @@ export default class PaymentsTransactionsIndexController extends Controller { @tracked description = null; @tracked type = null; @tracked status = null; + @tracked settlement_status = null; @tracked direction = null; @tracked currency = null; @tracked gateway = null; @@ -141,7 +143,19 @@ export default class PaymentsTransactionsIndexController extends Controller { filterable: true, filterParam: 'status', filterComponent: 'filter/multi-option', - filterOptions: ['pending', 'succeeded', 'failed', 'refunded', 'voided', 'reversed', 'expired'], + filterOptions: ['pending', 'success', 'failed', 'cancelled', 'voided', 'reversed', 'expired'], + }, + { + label: 'Settlement', + valuePath: 'settlement_status', + cellComponent: 'table/cell/status', + hidden: true, + resizable: true, + sortable: true, + filterable: true, + filterParam: 'settlement_status', + filterComponent: 'filter/multi-option', + filterOptions: ['unpaid', 'partially_paid', 'paid', 'partially_refunded', 'refunded'], }, // ── Hidden / toggleable columns ────────────────────────────────── { diff --git a/addon/models/ledger-transaction.js b/addon/models/ledger-transaction.js index 3bf2525..e7e9e50 100644 --- a/addon/models/ledger-transaction.js +++ b/addon/models/ledger-transaction.js @@ -10,6 +10,7 @@ export default class LedgerTransactionModel extends Model { @attr('string') type; @attr('string') direction; @attr('string') status; + @attr('string') settlement_status; @computed('direction') get direction_sign() { return this.direction === 'debit' ? '-' : '+'; diff --git a/addon/routes/billing/invoices/index.js b/addon/routes/billing/invoices/index.js index b22d3cc..ef50a00 100644 --- a/addon/routes/billing/invoices/index.js +++ b/addon/routes/billing/invoices/index.js @@ -10,6 +10,12 @@ export default class BillingInvoicesIndexRoute extends Route { sort: { refreshModel: true }, query: { refreshModel: true }, status: { refreshModel: true }, + currency: { refreshModel: true }, + order: { refreshModel: true }, + customer: { refreshModel: true }, + created_at: { refreshModel: true }, + due_date: { refreshModel: true }, + amount: { refreshModel: true }, }; model(params) { diff --git a/addon/routes/payments/transactions/index.js b/addon/routes/payments/transactions/index.js index 62e08a9..d54c8b3 100644 --- a/addon/routes/payments/transactions/index.js +++ b/addon/routes/payments/transactions/index.js @@ -10,6 +10,7 @@ export default class PaymentsTransactionsIndexRoute extends Route { sort: { refreshModel: true }, query: { refreshModel: true }, status: { refreshModel: true }, + settlement_status: { refreshModel: true }, }; model(params) { diff --git a/server/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php b/server/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php new file mode 100644 index 0000000..6bef2cb --- /dev/null +++ b/server/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php @@ -0,0 +1,66 @@ +string('settlement_status', 32) + ->default(Transaction::SETTLEMENT_STATUS_UNPAID) + ->after('status') + ->index('transactions_settlement_status_index'); + } + }); + + DB::table('transactions') + ->whereNull('settlement_status') + ->update(['settlement_status' => Transaction::SETTLEMENT_STATUS_UNPAID]); + + DB::table('transactions') + ->where('status', 'completed') + ->update(['status' => Transaction::STATUS_SUCCESS]); + + DB::table('transactions') + ->where('status', 'paid') + ->update([ + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => DB::raw('COALESCE(settled_at, updated_at, created_at)'), + ]); + + DB::table('transactions') + ->whereIn('type', [ + Transaction::TYPE_INVOICE_PAYMENT, + Transaction::TYPE_WALLET_DEPOSIT, + Transaction::TYPE_WALLET_WITHDRAWAL, + Transaction::TYPE_WALLET_TRANSFER_IN, + Transaction::TYPE_WALLET_TRANSFER_OUT, + 'deposit', + 'withdrawal', + 'transfer_in', + 'transfer_out', + ]) + ->where('status', Transaction::STATUS_SUCCESS) + ->where('settlement_status', Transaction::SETTLEMENT_STATUS_UNPAID) + ->update([ + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => DB::raw('COALESCE(settled_at, updated_at, created_at)'), + ]); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + if (Schema::hasColumn('transactions', 'settlement_status')) { + $table->dropIndex('transactions_settlement_status_index'); + $table->dropColumn('settlement_status'); + } + }); + } +}; diff --git a/server/src/Http/Filter/InvoiceFilter.php b/server/src/Http/Filter/InvoiceFilter.php index 2339782..5185d53 100644 --- a/server/src/Http/Filter/InvoiceFilter.php +++ b/server/src/Http/Filter/InvoiceFilter.php @@ -3,6 +3,7 @@ namespace Fleetbase\Ledger\Http\Filter; use Fleetbase\Http\Filter\Filter; +use Fleetbase\Support\Utils; class InvoiceFilter extends Filter { @@ -28,14 +29,84 @@ public function query(?string $searchQuery): void public function status(?string $status): void { + if (!$status) { + return; + } + $this->builder->where('status', $status); } + public function currency(?string $currency): void + { + if (!$currency) { + return; + } + + $this->builder->where('currency', strtoupper($currency)); + } + public function customer(?string $customer): void { + if (!$customer) { + return; + } + $this->builder->where('customer_uuid', $customer); } + public function customerUuid(?string $customer): void + { + $this->customer($customer); + } + + public function order(?string $order): void + { + if (!$order) { + return; + } + + $this->builder->where(function ($query) use ($order) { + $query->where('order_uuid', $order) + ->orWhereHas('order', function ($orderQuery) use ($order) { + $orderQuery->where('uuid', $order) + ->orWhere('public_id', $order) + ->orWhereHas('trackingNumber', function ($trackingQuery) use ($order) { + $trackingQuery->where('tracking_number', $order); + }); + }); + }); + } + + public function orderUuid(?string $order): void + { + $this->order($order); + } + + public function amount(?string $amount): void + { + if (!$amount) { + return; + } + + [$min, $max] = array_pad(array_map('trim', explode(',', $amount, 2)), 2, null); + $min = is_numeric($min) ? (int) $min : null; + $max = is_numeric($max) ? (int) $max : null; + + if ($min !== null && $max !== null) { + $this->builder->whereBetween('total_amount', [$min, $max]); + + return; + } + + if ($min !== null) { + $this->builder->where('total_amount', '>=', $min); + } + + if ($max !== null) { + $this->builder->where('total_amount', '<=', $max); + } + } + public function publicId(?string $publicId): void { $this->builder->searchWhere('public_id', $publicId); @@ -43,7 +114,7 @@ public function publicId(?string $publicId): void public function createdAt($createdAt): void { - $createdAt = \Fleetbase\Support\Utils::dateRange($createdAt); + $createdAt = Utils::dateRange($createdAt); if (is_array($createdAt)) { $this->builder->whereBetween('created_at', $createdAt); } else { @@ -53,7 +124,7 @@ public function createdAt($createdAt): void public function dueDate($dueDate): void { - $dueDate = \Fleetbase\Support\Utils::dateRange($dueDate); + $dueDate = Utils::dateRange($dueDate); if (is_array($dueDate)) { $this->builder->whereBetween('due_date', $dueDate); } else { diff --git a/server/src/Http/Filter/TransactionFilter.php b/server/src/Http/Filter/TransactionFilter.php index 8d50ba1..79f25c8 100644 --- a/server/src/Http/Filter/TransactionFilter.php +++ b/server/src/Http/Filter/TransactionFilter.php @@ -52,6 +52,11 @@ public function status(?string $status): void $this->builder->where('status', $status); } + public function settlementStatus(?string $settlementStatus): void + { + $this->builder->where('settlement_status', $settlementStatus); + } + public function gateway(?string $gateway): void { $this->builder->where('gateway', $gateway); diff --git a/server/src/Http/Resources/v1/Transaction.php b/server/src/Http/Resources/v1/Transaction.php index a9142b2..dc25591 100644 --- a/server/src/Http/Resources/v1/Transaction.php +++ b/server/src/Http/Resources/v1/Transaction.php @@ -77,6 +77,7 @@ public function toArray($request): array 'type' => $this->type, 'direction' => $this->direction, 'status' => $this->status, + 'settlement_status' => $this->settlement_status, // ── Monetary ─────────────────────────────────────────────────── 'amount' => $this->amount, diff --git a/server/src/Models/Transaction.php b/server/src/Models/Transaction.php index 63606e1..9cd6586 100644 --- a/server/src/Models/Transaction.php +++ b/server/src/Models/Transaction.php @@ -33,6 +33,7 @@ class Transaction extends BaseTransaction 'initiator_type', 'context_uuid', 'context_type', + 'settlement_status', 'fee_amount', 'tax_amount', 'net_amount', diff --git a/server/src/Observers/InvoiceObserver.php b/server/src/Observers/InvoiceObserver.php index ff20c5d..b990a6b 100644 --- a/server/src/Observers/InvoiceObserver.php +++ b/server/src/Observers/InvoiceObserver.php @@ -31,9 +31,13 @@ public function created(Invoice $invoice) public function updated(Invoice $invoice) { // Fire InvoicePaid event if status changed to paid - if ($invoice->isDirty('status') && $invoice->status === 'paid') { + if ($invoice->wasChanged('status') && $invoice->status === 'paid') { event(new InvoicePaid($invoice)); } + + if ($invoice->wasChanged('status') && in_array($invoice->status, ['void', 'voided', 'cancelled', 'canceled'], true)) { + $this->revenueLifecycleService->handleInvoiceCanceled($invoice, (string) $invoice->getOriginal('status')); + } } public function deleting(Invoice $invoice): void diff --git a/server/src/Services/InvoiceService.php b/server/src/Services/InvoiceService.php index 082f8f5..eafe1ff 100644 --- a/server/src/Services/InvoiceService.php +++ b/server/src/Services/InvoiceService.php @@ -338,31 +338,35 @@ public function recordPayment(Invoice $invoice, int $amount, array $options = [] $paymentMethod = data_get($options, 'payment_method', 'manual'); $reference = data_get($options, 'reference'); $transaction = Transaction::create([ - 'company_uuid' => $invoice->company_uuid, - 'owner_uuid' => $invoice->customer_uuid, - 'owner_type' => $invoice->customer_type, - 'customer_uuid' => $invoice->customer_uuid, - 'customer_type' => $invoice->customer_type, + 'company_uuid' => $invoice->company_uuid, + 'owner_uuid' => $invoice->customer_uuid, + 'owner_type' => $invoice->customer_type, + 'customer_uuid' => $invoice->customer_uuid, + 'customer_type' => $invoice->customer_type, // Payer = the customer settling the invoice - 'payer_uuid' => $invoice->customer_uuid, - 'payer_type' => $invoice->customer_type, + 'payer_uuid' => $invoice->customer_uuid, + 'payer_type' => $invoice->customer_type, // Payee = the company receiving the funds - 'payee_uuid' => $invoice->company_uuid, - 'payee_type' => \Fleetbase\Models\Company::class, - 'amount' => $amount, - 'net_amount' => $amount, - 'currency' => $invoice->currency ?? 'USD', - 'description' => "Payment for invoice {$invoice->number}", - 'type' => 'invoice_payment', + 'payee_uuid' => $invoice->company_uuid, + 'payee_type' => \Fleetbase\Models\Company::class, + 'amount' => $amount, + 'net_amount' => $amount, + 'currency' => $invoice->currency ?? 'USD', + 'description' => "Payment for invoice {$invoice->number}", + 'type' => 'invoice_payment', // credit = inbound money (company receives funds from the customer) - 'direction' => 'credit', - 'status' => 'completed', - 'payment_method' => $paymentMethod, - 'reference' => $reference, - 'subject_uuid' => $invoice->uuid, - 'subject_type' => Invoice::class, - 'context_uuid' => $invoice->uuid, - 'context_type' => Invoice::class, + 'direction' => 'credit', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'payment_method' => $paymentMethod, + 'reference' => $reference, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $invoice->currency ?? 'USD', + 'subject_uuid' => $invoice->uuid, + 'subject_type' => Invoice::class, + 'context_uuid' => $invoice->uuid, + 'context_type' => Invoice::class, ]); // DEBIT Cash (asset increases — money received), CREDIT Accounts Receivable (asset decreases — AR settled) diff --git a/server/src/Services/RevenueLifecycleService.php b/server/src/Services/RevenueLifecycleService.php index fb29e6e..3d64b3e 100644 --- a/server/src/Services/RevenueLifecycleService.php +++ b/server/src/Services/RevenueLifecycleService.php @@ -115,6 +115,34 @@ public function handleInvoiceDeleting(Invoice $invoice): void } } + public function handleInvoiceCanceled(Invoice $invoice, string $previousStatus, string $reason = 'invoice_cancelled'): void + { + try { + DB::transaction(function () use ($invoice, $previousStatus, $reason) { + if ((int) $invoice->amount_paid > 0 || $invoice->status === 'paid') { + $this->flagPaidInvoiceForReview($invoice, $reason); + + return; + } + + $invoice->updateMeta([ + 'revenue_lifecycle_previous_status' => $previousStatus, + 'revenue_lifecycle_status_changed' => true, + 'revenue_lifecycle_reason' => $reason, + 'revenue_lifecycle_changed_at' => now()->toIso8601String(), + ]); + + $this->reverseInvoiceRevenue($invoice, $reason); + $this->voidInvoiceTransactionIfUnpaid($invoice, $reason); + }); + } catch (\Throwable $e) { + Log::channel('ledger')->error('[Ledger] RevenueLifecycleService: failed to process invoice cancellation.', [ + 'error' => $e->getMessage(), + 'invoice_uuid' => $invoice->uuid ?? null, + ]); + } + } + public function handleInvoiceRestored(Invoice $invoice): void { try { diff --git a/server/src/Services/WalletService.php b/server/src/Services/WalletService.php index 1dd3938..e6b2cf6 100644 --- a/server/src/Services/WalletService.php +++ b/server/src/Services/WalletService.php @@ -131,10 +131,14 @@ public function deposit( 'gateway_transaction_id' => $options['gateway_transaction_uuid'] ?? null, 'type' => $type, 'direction' => 'credit', - 'status' => 'completed', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, 'amount' => $amount, 'balance_after' => $newBalance, 'currency' => $wallet->currency, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $wallet->currency, 'description' => $desc, 'reference' => $options['reference'] ?? null, 'subject_uuid' => $options['subject_uuid'] ?? null, @@ -222,10 +226,14 @@ public function withdraw( 'gateway_transaction_id' => $options['gateway_transaction_uuid'] ?? null, 'type' => $type, 'direction' => 'debit', - 'status' => 'completed', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, 'amount' => $amount, 'balance_after' => $newBalance, 'currency' => $wallet->currency, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $wallet->currency, 'description' => $desc, 'reference' => $options['reference'] ?? null, 'subject_uuid' => $options['subject_uuid'] ?? null, @@ -313,36 +321,44 @@ public function transfer( // Create the authoritative Transaction records for both sides first $fromTransaction = Transaction::create([ - 'company_uuid' => $fromWallet->company_uuid, - 'owner_uuid' => $fromWallet->uuid, - 'owner_type' => Wallet::class, - 'type' => 'transfer_out', - 'direction' => 'debit', - 'status' => 'completed', - 'amount' => $amount, - 'balance_after' => $fromNewBalance, - 'currency' => $fromWallet->currency, - 'description' => $desc, - 'reference' => $reference, - 'meta' => array_merge($options['meta'] ?? [], [ + 'company_uuid' => $fromWallet->company_uuid, + 'owner_uuid' => $fromWallet->uuid, + 'owner_type' => Wallet::class, + 'type' => 'transfer_out', + 'direction' => 'debit', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'amount' => $amount, + 'balance_after' => $fromNewBalance, + 'currency' => $fromWallet->currency, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $fromWallet->currency, + 'description' => $desc, + 'reference' => $reference, + 'meta' => array_merge($options['meta'] ?? [], [ 'to_wallet_uuid' => $toWallet->uuid, 'to_wallet_public_id' => $toWallet->public_id, ]), ]); $toTransaction = Transaction::create([ - 'company_uuid' => $toWallet->company_uuid, - 'owner_uuid' => $toWallet->uuid, - 'owner_type' => Wallet::class, - 'type' => 'transfer_in', - 'direction' => 'credit', - 'status' => 'completed', - 'amount' => $amount, - 'balance_after' => $toNewBalance, - 'currency' => $toWallet->currency, - 'description' => $desc, - 'reference' => $reference, - 'meta' => array_merge($options['meta'] ?? [], [ + 'company_uuid' => $toWallet->company_uuid, + 'owner_uuid' => $toWallet->uuid, + 'owner_type' => Wallet::class, + 'type' => 'transfer_in', + 'direction' => 'credit', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'amount' => $amount, + 'balance_after' => $toNewBalance, + 'currency' => $toWallet->currency, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $toWallet->currency, + 'description' => $desc, + 'reference' => $reference, + 'meta' => array_merge($options['meta'] ?? [], [ 'from_wallet_uuid' => $fromWallet->uuid, 'from_wallet_public_id' => $fromWallet->public_id, ]), diff --git a/server/tests/Feature.php b/server/tests/Feature.php index 9c4a6eb..35a2bf3 100644 --- a/server/tests/Feature.php +++ b/server/tests/Feature.php @@ -99,6 +99,62 @@ ->toContain('repairInvoice'); }); +test('transactions use two-axis lifecycle and settlement status contract', function () { + $coreTransaction = file_get_contents(dirname(__DIR__, 3) . '/core-api/src/Models/Transaction.php'); + $invoiceService = file_get_contents(__DIR__ . '/../src/Services/InvoiceService.php'); + $walletService = file_get_contents(__DIR__ . '/../src/Services/WalletService.php'); + $filter = file_get_contents(__DIR__ . '/../src/Http/Filter/TransactionFilter.php'); + $resource = file_get_contents(__DIR__ . '/../src/Http/Resources/v1/Transaction.php'); + $controller = file_get_contents(__DIR__ . '/../../addon/controllers/payments/transactions/index.js'); + + expect($coreTransaction) + ->toContain("public const SETTLEMENT_STATUS_UNPAID") + ->toContain("public const SETTLEMENT_STATUS_PAID") + ->toContain("public const SETTLEMENT_STATUS_REFUNDED") + ->toContain("'settlement_status'"); + + expect($invoiceService) + ->toContain("'status' => Transaction::STATUS_SUCCESS") + ->toContain("'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID") + ->toContain("'settled_at' => now()"); + + expect($walletService) + ->toContain("'status' => Transaction::STATUS_SUCCESS") + ->toContain("'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID") + ->not->toContain("'status' => 'completed'"); + + expect($filter)->toContain('function settlementStatus(?string $settlementStatus)'); + expect($resource)->toContain("'settlement_status' => \$this->settlement_status"); + expect($controller) + ->toContain("'settlement_status'") + ->toContain("filterOptions: ['pending', 'success', 'failed', 'cancelled', 'voided', 'reversed', 'expired']") + ->not->toContain("'succeeded'"); +}); + +test('invoice filter supports table filter params', function () { + $filter = file_get_contents(__DIR__ . '/../src/Http/Filter/InvoiceFilter.php'); + + expect($filter) + ->toContain('function status(?string $status)') + ->toContain('function currency(?string $currency)') + ->toContain('function order(?string $order)') + ->toContain('function orderUuid(?string $order)') + ->toContain('function customer(?string $customer)') + ->toContain('function customerUuid(?string $customer)') + ->toContain('function amount(?string $amount)') + ->toContain('function createdAt($createdAt)') + ->toContain('function dueDate($dueDate)') + ->toContain('Utils::dateRange($createdAt)') + ->toContain('Utils::dateRange($dueDate)') + ->toContain("whereBetween('total_amount'") + ->toContain("where('total_amount', '>=', \$min)") + ->toContain("where('total_amount', '<=', \$max)") + ->toContain("where('currency', strtoupper(\$currency))") + ->toContain("where('order_uuid', \$order)") + ->toContain("where('public_id', \$order)") + ->toContain("where('tracking_number', \$order)"); +}); + test('profit and loss deduplication preserves reversal and reinstatement journals', function () { $service = file_get_contents(__DIR__ . '/../src/Services/LedgerService.php'); diff --git a/tests/unit/controllers/billing/invoices/index-test.js b/tests/unit/controllers/billing/invoices/index-test.js new file mode 100644 index 0000000..8716c8c --- /dev/null +++ b/tests/unit/controllers/billing/invoices/index-test.js @@ -0,0 +1,73 @@ +import { module, test } from 'qunit'; +import Service from '@ember/service'; +import { setupTest } from 'fleetbase-ledger-engine/tests/helpers'; + +class IntlStub extends Service { + t(key) { + return key; + } +} + +class InvoiceActionsStub extends Service { + transition = { + view() {}, + create() {}, + edit() {}, + }; + + refresh() {} + bulkDelete() {} + recordPayment() {} + previewInvoice() {} + copyInvoiceUrl() {} + delete() {} +} + +class TableContextStub extends Service { + getSelectedRows() { + return []; + } +} + +class HostRouterStub extends Service { + transitionTo() {} +} + +module('Unit | Controller | billing/invoices/index', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:intl', IntlStub); + this.owner.register('service:invoice-actions', InvoiceActionsStub); + this.owner.register('service:table-context', TableContextStub); + this.owner.register('service:host-router', HostRouterStub); + }); + + test('it declares invoice filter query params', function (assert) { + const controller = this.owner.lookup('controller:billing/invoices/index'); + + assert.deepEqual(controller.queryParams, ['page', 'limit', 'sort', 'query', 'status', 'currency', 'order', 'customer', 'created_at', 'due_date', 'amount']); + }); + + test('it exposes filterable invoice table columns', function (assert) { + const controller = this.owner.lookup('controller:billing/invoices/index'); + const filters = controller.columns + .filter((column) => column.filterable) + .reduce((map, column) => { + map[column.filterParam] = column; + return map; + }, {}); + + assert.strictEqual(filters.status.filterComponent, 'filter/select'); + assert.strictEqual(filters.currency.filterComponent, 'filter/string'); + assert.strictEqual(filters.order.filterComponent, 'filter/model'); + assert.strictEqual(filters.order.model, 'order'); + assert.strictEqual(filters.customer.filterComponent, 'filter/model'); + assert.strictEqual(filters.customer.model, 'customer'); + assert.strictEqual(filters.created_at.filterComponent, 'filter/date'); + assert.strictEqual(filters.due_date.filterComponent, 'filter/date'); + assert.strictEqual(filters.amount.filterComponent, 'filter/range'); + assert.strictEqual(filters.amount.min, 0); + assert.strictEqual(filters.amount.step, 100); + }); +}); diff --git a/tests/unit/routes/billing/invoices/index-test.js b/tests/unit/routes/billing/invoices/index-test.js new file mode 100644 index 0000000..c2fb40e --- /dev/null +++ b/tests/unit/routes/billing/invoices/index-test.js @@ -0,0 +1,50 @@ +import { module, test } from 'qunit'; +import Service from '@ember/service'; +import { setupTest } from 'fleetbase-ledger-engine/tests/helpers'; + +class StoreStub extends Service { + requests = []; + + query(modelName, params) { + this.requests.push({ modelName, params }); + return Promise.resolve([]); + } +} + +module('Unit | Route | billing/invoices/index', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:store', StoreStub); + }); + + test('it refreshes the model for invoice filter query params', function (assert) { + const route = this.owner.lookup('route:billing/invoices/index'); + + assert.deepEqual(Object.keys(route.queryParams), ['page', 'limit', 'sort', 'query', 'status', 'currency', 'order', 'customer', 'created_at', 'due_date', 'amount']); + assert.true(route.queryParams.currency.refreshModel); + assert.true(route.queryParams.order.refreshModel); + assert.true(route.queryParams.customer.refreshModel); + assert.true(route.queryParams.created_at.refreshModel); + assert.true(route.queryParams.due_date.refreshModel); + assert.true(route.queryParams.amount.refreshModel); + }); + + test('it queries ledger invoices with the active params', async function (assert) { + const route = this.owner.lookup('route:billing/invoices/index'); + const store = this.owner.lookup('service:store'); + const params = { + status: 'paid', + currency: 'usd', + order: 'order_123', + customer: 'contact_123', + created_at: '2026-06-01,2026-06-30', + due_date: '2026-07-01,2026-07-31', + amount: '1000,5000', + }; + + await route.model(params); + + assert.deepEqual(store.requests, [{ modelName: 'ledger-invoice', params }]); + }); +}); From 51f353036b69fbf60bb5622a732a515d4ea06d08 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 22 Jun 2026 13:31:28 +0800 Subject: [PATCH 5/9] bump version to v0.0.6 --- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 09a2ba7..3098211 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/ledger-api", - "version": "0.0.5", + "version": "0.0.6", "description": "Accounting & Invoicing Extension for Fleetbase", "keywords": [ "fleetbase", diff --git a/extension.json b/extension.json index 22a6f7c..7149bc0 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Ledger", - "version": "0.0.5", + "version": "0.0.6", "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 cc62ff0..164559f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ledger-engine", - "version": "0.0.5", + "version": "0.0.6", "description": "Accounting & Invoicing Extension for Fleetbase", "keywords": [ "fleetbase-extension", From 315946459884478e2fe1faedce692bec6419a979 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 22 Jun 2026 19:18:45 +0800 Subject: [PATCH 6/9] Add local Taler E2E test stack --- addon/components/customer-invoice.hbs | 21 + addon/components/customer-invoice.js | 51 +- docker/taler/.gitignore | 2 + docker/taler/Dockerfile | 45 ++ docker/taler/README.md | 109 ++++ docker/taler/create-withdrawal.sh | 48 ++ docker/taler/entrypoint.sh | 86 +++ docker/taler/init-instance.sh | 148 +++++ docker/taler/init-stack.sh | 89 +++ docker/taler/smoke-test.sh | 51 ++ docker/taler/taler-local.conf | 187 ++++++ ...iden_ledger_currency_columns_for_taler.php | 51 ++ server/seeders/Testing/TalerDemoSeeder.php | 536 ++++++++++++++++++ .../Internal/v1/InvoiceController.php | 2 +- server/tests/Feature.php | 8 + 15 files changed, 1432 insertions(+), 2 deletions(-) create mode 100644 docker/taler/.gitignore create mode 100644 docker/taler/Dockerfile create mode 100644 docker/taler/README.md create mode 100644 docker/taler/create-withdrawal.sh create mode 100644 docker/taler/entrypoint.sh create mode 100644 docker/taler/init-instance.sh create mode 100644 docker/taler/init-stack.sh create mode 100644 docker/taler/smoke-test.sh create mode 100644 docker/taler/taler-local.conf create mode 100644 server/migrations/2026_06_22_000001_widen_ledger_currency_columns_for_taler.php create mode 100644 server/seeders/Testing/TalerDemoSeeder.php diff --git a/addon/components/customer-invoice.hbs b/addon/components/customer-invoice.hbs index a448944..8c5869a 100644 --- a/addon/components/customer-invoice.hbs +++ b/addon/components/customer-invoice.hbs @@ -193,6 +193,27 @@

Redirecting to payment provider...

+ {{else if this.hasTalerPaymentUri}} +
+
+ +
+

GNU Taler payment ready

+

+ Open your Taler wallet to approve the payment, then return here to refresh the invoice. +

+
+
+ +
{{else}} {{! Submit }}
diff --git a/addon/components/customer-invoice.js b/addon/components/customer-invoice.js index 9b97bdd..6bd74ae 100644 --- a/addon/components/customer-invoice.js +++ b/addon/components/customer-invoice.js @@ -33,12 +33,19 @@ export default class CustomerInvoiceComponent extends Component { @tracked successMessage = null; @tracked pendingMessage = null; @tracked isRedirectingToCheckout = false; + @tracked talerPaymentUri = null; constructor() { super(...arguments); + this.installTalerSupportMeta(); this.loadInvoice.perform(); } + willDestroy() { + super.willDestroy(...arguments); + this.removeTalerSupportMeta(); + } + // ── Getters ─────────────────────────────────────────────────────────────── get invoiceId() { @@ -73,6 +80,10 @@ export default class CustomerInvoiceComponent extends Component { return this.selectedGateway?.driver === 'stripe'; } + get hasTalerPaymentUri() { + return typeof this.talerPaymentUri === 'string' && this.talerPaymentUri.startsWith('taler'); + } + // ── Tasks ───────────────────────────────────────────────────────────────── /** @@ -161,7 +172,14 @@ export default class CustomerInvoiceComponent extends Component { const paymentUrl = data?.payment_url ?? data?.payment_uri ?? data?.checkout_url ?? data?.data?.taler_pay_uri; - // Redirect payment sessions — Stripe Checkout, Taler wallet URI, QPay app link, etc. + if (this.isTalerUri(paymentUrl)) { + this.talerPaymentUri = paymentUrl; + this.isRedirectingToCheckout = false; + this.pendingMessage = data?.message ?? 'Payment started. Open your GNU Taler wallet to complete it.'; + return; + } + + // Redirect payment sessions — Stripe Checkout, QPay app link, etc. if (paymentUrl) { this.isRedirectingToCheckout = true; this.pendingMessage = data?.message ?? 'Redirecting to payment provider...'; @@ -190,6 +208,7 @@ export default class CustomerInvoiceComponent extends Component { this.showPaymentForm = !this.showPaymentForm; this.successMessage = null; this.pendingMessage = null; + this.talerPaymentUri = null; this.error = null; } @@ -200,4 +219,34 @@ export default class CustomerInvoiceComponent extends Component { @action updateReference(event) { this.paymentReference = event.target.value; } + + isTalerUri(value) { + return typeof value === 'string' && (value.startsWith('taler://') || value.startsWith('taler+http://') || value.startsWith('taler+https://')); + } + + installTalerSupportMeta() { + if (typeof document === 'undefined') { + return; + } + + let meta = document.querySelector('meta[name="taler-support"]'); + + if (!meta) { + meta = document.createElement('meta'); + meta.name = 'taler-support'; + meta.dataset.ledgerTalerSupport = 'true'; + document.head.appendChild(meta); + } + + meta.content = 'uri'; + } + + removeTalerSupportMeta() { + if (typeof document === 'undefined') { + return; + } + + const meta = document.querySelector('meta[name="taler-support"][data-ledger-taler-support="true"]'); + meta?.remove(); + } } diff --git a/docker/taler/.gitignore b/docker/taler/.gitignore new file mode 100644 index 0000000..619d2a0 --- /dev/null +++ b/docker/taler/.gitignore @@ -0,0 +1,2 @@ +generated-token.txt +*.local diff --git a/docker/taler/Dockerfile b/docker/taler/Dockerfile new file mode 100644 index 0000000..a8ce8b8 --- /dev/null +++ b/docker/taler/Dockerfile @@ -0,0 +1,45 @@ +FROM debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + jq \ + postgresql-client \ + procps \ + wget \ + && install -d -m 0755 /etc/apt/keyrings \ + && wget -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main" > /etc/apt/sources.list.d/taler.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + libeufin-bank \ + taler-exchange \ + taler-exchange-database \ + taler-exchange-offline \ + taler-harness \ + taler-merchant \ + taler-wallet-cli \ + && rm -rf /var/lib/apt/lists/* + +RUN install -d -m 0755 /etc/taler /var/lib/fleetbase-ledger/taler + +COPY docker/taler/taler-local.conf /etc/taler/taler-local.conf +COPY docker/taler/entrypoint.sh /usr/local/bin/fleetbase-taler-entrypoint +COPY docker/taler/init-instance.sh /usr/local/bin/fleetbase-taler-init-instance +COPY docker/taler/init-stack.sh /usr/local/bin/fleetbase-taler-init-stack +COPY docker/taler/create-withdrawal.sh /usr/local/bin/fleetbase-taler-create-withdrawal +COPY docker/taler/smoke-test.sh /usr/local/bin/fleetbase-taler-smoke-test + +RUN chmod +x \ + /usr/local/bin/fleetbase-taler-entrypoint \ + /usr/local/bin/fleetbase-taler-init-instance \ + /usr/local/bin/fleetbase-taler-init-stack \ + /usr/local/bin/fleetbase-taler-create-withdrawal \ + /usr/local/bin/fleetbase-taler-smoke-test + +EXPOSE 8081 8082 9966 + +ENTRYPOINT ["fleetbase-taler-entrypoint"] diff --git a/docker/taler/README.md b/docker/taler/README.md new file mode 100644 index 0000000..5502f9c --- /dev/null +++ b/docker/taler/README.md @@ -0,0 +1,109 @@ +# Fleetbase Ledger GNU Taler local stack + +This directory owns the Docker artifacts for Ledger's local GNU Taler E2E test +stack. It runs matching local services instead of mixing a local merchant +backend with the public demo exchange: + +- libeufin bank: `http://taler-bank.lvh.me:8082` +- Taler exchange: `http://taler-exchange.lvh.me:8081` +- Taler merchant backend: `http://taler-merchant.lvh.me:9966` + +Start the stack from the Fleetbase repo root: + +```sh +docker compose up -d --build taler-merchant application queue scheduler httpd console +``` + +After startup, use the generated merchant token in the Ledger Taler gateway +config: + +```sh +cat packages/ledger/docker/taler/generated-token.txt +``` + +Use these local gateway values: + +```txt +backend_url=http://taler-merchant.lvh.me:9966 +instance_id=default +api_token= +``` + +## Validate the Taler stack + +Run the built-in smoke test: + +```sh +docker compose exec taler-merchant fleetbase-taler-smoke-test +``` + +Or check the services manually: + +```sh +curl http://taler-bank.lvh.me:8082/config +curl http://taler-exchange.lvh.me:8081/keys +curl http://taler-merchant.lvh.me:9966/config +``` + +The merchant logs should not show `Failed to download .../keys` or `Could not +decode /keys response`. Those errors were symptoms of using the public demo +exchange with a local merchant package. + +## Demo KUDOS invoice fixture + +Fleetbase does not expose `KUDOS` as a normal currency option. Ledger provides +a dedicated local fixture so the Taler wallet flow can be tested without making +KUDOS a business currency. + +Run Ledger migrations and seed the demo invoice from the Fleetbase repo root: + +```sh +docker compose exec application php artisan migrate +docker compose exec application php artisan db:seed --class="Fleetbase\\Ledger\\Seeders\\Testing\\TalerDemoSeeder" +``` + +To seed for a specific company: + +```sh +docker compose exec -e TALER_DEMO_COMPANY_UUID= application php artisan db:seed --class="Fleetbase\\Ledger\\Seeders\\Testing\\TalerDemoSeeder" +``` + +The seeder creates an idempotent FleetOps-style payload, order, service quote, +purchase rate, tracking number, core transaction, transaction items, and sent +Ledger invoice in `KUDOS`. The order should appear in FleetOps Orders as +`TALER-DEMO-KUDOS`. Open the seeded public payment link shown by the seeder: + +```txt +/~/invoice?id= +``` + +## Wallet notes + +The local bank suggests the local exchange to wallets. To add KUDOS to the GNU +Taler browser wallet, create a bank withdrawal operation: + +```sh +docker compose exec taler-merchant fleetbase-taler-create-withdrawal KUDOS:5.00 +``` + +Open the normal HTTP page printed by the command: + +```txt +http://taler-bank.lvh.me:8082/webui/#/operation/ +``` + +Then click the wallet action from that page. Do not paste the raw +`taler+http://withdraw/...` URI into the browser address bar; browser extensions +generally intercept Taler wallet links from supported pages, not direct address +bar navigation. + +The fake bank admin credentials are: + +```txt +Username: admin +Password: admin-password +``` + +If your wallet blocks plain HTTP, use the wallet's development/test setting that +allows unsafe HTTP for local Taler services, or use `taler-wallet-cli --no-http` +for CLI testing. diff --git a/docker/taler/create-withdrawal.sh b/docker/taler/create-withdrawal.sh new file mode 100644 index 0000000..42b9dd0 --- /dev/null +++ b/docker/taler/create-withdrawal.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +AMOUNT="${1:-KUDOS:5.00}" +BANK_BASE_URL="${TALER_BANK_BASE_URL:-http://127.0.0.1:8082}" +BANK_PUBLIC_BASE_URL="${TALER_BANK_PUBLIC_BASE_URL:-http://taler-bank.lvh.me:8082}" +BANK_USER="${TALER_WITHDRAW_BANK_USER:-admin}" +BANK_PASSWORD="${TALER_WITHDRAW_BANK_PASSWORD:-admin-password}" + +if [[ "${AMOUNT}" != *:* ]]; then + AMOUNT="KUDOS:${AMOUNT}" +fi + +for _ in $(seq 1 60); do + if curl -fsS "${BANK_BASE_URL}/config" >/dev/null 2>&1; then + break + fi + + sleep 1 +done + +response="$(curl -fsS \ + -u "${BANK_USER}:${BANK_PASSWORD}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n --arg amount "${AMOUNT}" '{amount: $amount}')" \ + "${BANK_BASE_URL}/accounts/${BANK_USER}/withdrawals")" + +withdrawal_id="$(printf '%s' "${response}" | jq -r '.withdrawal_id')" +withdraw_uri="$(printf '%s' "${response}" | jq -r '.taler_withdraw_uri')" + +if [ -z "${withdrawal_id}" ] || [ "${withdrawal_id}" = "null" ]; then + echo "Unable to create Taler withdrawal operation." >&2 + printf '%s\n' "${response}" >&2 + exit 1 +fi + +cat </dev/null 2>&1; then + kill "${pid}" >/dev/null 2>&1 || true + fi + done +} + +wait_json_endpoint() { + local name="$1" + local url="$2" + local attempts="${3:-120}" + + for _ in $(seq 1 "${attempts}"); do + if curl -fsS "${url}" | jq -e type >/dev/null 2>&1; then + echo "${name} is ready at ${url}." + return 0 + fi + + sleep 1 + done + + echo "Timed out waiting for ${name} at ${url}." >&2 + return 1 +} + +trap cleanup EXIT INT TERM + +fleetbase-taler-init-stack + +start_service "libeufin bank" libeufin-bank serve -c "${CONFIG_FILE}" -L INFO +wait_json_endpoint "libeufin bank" "${BANK_BASE_URL}/config" + +start_service "exchange secmod eddsa" taler-exchange-secmod-eddsa -c "${CONFIG_FILE}" -L INFO +start_service "exchange secmod rsa" taler-exchange-secmod-rsa -c "${CONFIG_FILE}" -L INFO +start_service "exchange secmod cs" taler-exchange-secmod-cs -c "${CONFIG_FILE}" -L INFO +start_service "exchange wirewatch" taler-exchange-wirewatch -c "${CONFIG_FILE}" --longpoll-timeout=5s -L INFO +start_service "exchange transfer" taler-exchange-transfer -c "${CONFIG_FILE}" -L INFO +start_service "exchange aggregator" taler-exchange-aggregator -c "${CONFIG_FILE}" -L INFO +start_service "exchange httpd" taler-exchange-httpd -c "${CONFIG_FILE}" -L INFO +wait_json_endpoint "exchange management" "${EXCHANGE_BASE_URL}/management/keys" + +taler-exchange-offline -c "${CONFIG_FILE}" download sign upload +taler-exchange-offline -c "${CONFIG_FILE}" enable-account "${EXCHANGE_PAYTO_URI}" upload +for year in $(seq "$(date +%Y)" "$(( $(date +%Y) + 4 ))"); do + taler-exchange-offline -c "${CONFIG_FILE}" wire-fee "${year}" x-taler-bank "${TALER_CURRENCY}:0.00" "${TALER_CURRENCY}:0.00" upload +done +taler-exchange-offline -c "${CONFIG_FILE}" global-fee now "${TALER_CURRENCY}:0.00" "${TALER_CURRENCY}:0.00" "${TALER_CURRENCY}:0.00" 1h 1year 5 upload +wait_json_endpoint "exchange" "${EXCHANGE_BASE_URL}/keys" + +start_service "merchant webhook worker" taler-merchant-webhook -c "${CONFIG_FILE}" -L INFO +start_service "merchant wirewatch" taler-merchant-wirewatch -c "${CONFIG_FILE}" -L INFO +start_service "merchant depositcheck" taler-merchant-depositcheck -c "${CONFIG_FILE}" -L INFO +start_service "merchant exchangekeyupdate" taler-merchant-exchangekeyupdate -c "${CONFIG_FILE}" -L INFO +start_service "merchant reconciliation" taler-merchant-reconciliation -c "${CONFIG_FILE}" -L INFO +start_service "merchant httpd" taler-merchant-httpd -c "${CONFIG_FILE}" -L INFO +merchant_httpd_pid="${last_pid}" +wait_json_endpoint "merchant" "${MERCHANT_BASE_URL}/config" + +fleetbase-taler-init-instance + +echo "Fleetbase Ledger local Taler stack is ready." +wait "${merchant_httpd_pid}" diff --git a/docker/taler/init-instance.sh b/docker/taler/init-instance.sh new file mode 100644 index 0000000..53d49ff --- /dev/null +++ b/docker/taler/init-instance.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${TALER_MERCHANT_BASE_URL:-http://127.0.0.1:9966}" +PUBLIC_BASE_URL="${TALER_MERCHANT_PUBLIC_BASE_URL:-http://taler-merchant.localhost:9966}" +ADMIN_TOKEN="${TALER_MERCHANT_ADMIN_TOKEN:-secret-token:fleetbase-taler-admin-dev}" +INSTANCE_ID="${TALER_MERCHANT_INSTANCE_ID:-default}" +INSTANCE_PASSWORD="${TALER_MERCHANT_INSTANCE_PASSWORD:-fleetbase-taler-dev-password}" +TOKEN_FILE="${TALER_MERCHANT_TOKEN_FILE:-/var/lib/fleetbase-ledger/taler/generated-token.txt}" +WEBHOOK_URL="${TALER_MERCHANT_WEBHOOK_URL:-http://httpd/ledger/webhooks/taler}" +PAYTO_URI="${TALER_MERCHANT_PAYTO_URI:-payto://x-taler-bank/taler-bank.lvh.me/default?receiver-name=Fleetbase%20Ledger}" +MERCHANT_BANK_USER="${TALER_MERCHANT_BANK_USER:-default}" +MERCHANT_BANK_PASSWORD="${TALER_MERCHANT_BANK_PASSWORD:-fleetbase-merchant-bank-password}" +MERCHANT_BANK_REVENUE_URL="${TALER_MERCHANT_BANK_REVENUE_URL:-http://taler-bank.lvh.me:8082/accounts/${MERCHANT_BANK_USER}/taler-revenue/}" + +api() { + local method="$1" + local path="$2" + local data="${3:-}" + local auth="${4:-Bearer ${ADMIN_TOKEN}}" + local code + local body_file + local args + + body_file="$(mktemp)" + args=(-sS -o "${body_file}" -w "%{http_code}" -X "${method}") + if [ -n "${auth}" ]; then + args+=(-H "Authorization: ${auth}") + fi + if [ -n "${data}" ]; then + args+=(-H "Content-Type: application/json" --data "${data}") + fi + args+=("${BASE_URL}${path}") + + code="$(curl "${args[@]}")" + + cat "${body_file}" + rm -f "${body_file}" + printf '\n%s' "${code}" +} + +until curl -fsS "${BASE_URL}/config" >/dev/null 2>&1; do + echo "Waiting for Taler merchant backend at ${BASE_URL}..." + sleep 2 +done + +instance_payload="$(jq -n \ + --arg id "${INSTANCE_ID}" \ + --arg name "Fleetbase Ledger Local Test Merchant" \ + --arg website "${PUBLIC_BASE_URL}" \ + --arg password "${INSTANCE_PASSWORD}" \ + '{ + id: $id, + name: $name, + email: "dev@fleetbase.io", + website: $website, + auth: { + method: "token", + password: $password + }, + address: { + country: "US", + town: "Local Development", + address_lines: ["Fleetbase Ledger Taler test merchant"] + }, + jurisdiction: { + country: "US", + town: "Local Development", + address_lines: ["Fleetbase Ledger Taler test merchant"] + }, + use_stefan: true + }')" + +instance_response="$(api POST /instances "${instance_payload}" "")" +instance_code="$(printf '%s' "${instance_response}" | tail -n1)" +if [ "${instance_code}" != "200" ] && [ "${instance_code}" != "204" ] && [ "${instance_code}" != "409" ]; then + echo "Unable to create Taler merchant instance '${INSTANCE_ID}' (HTTP ${instance_code})." >&2 + printf '%s\n' "${instance_response}" >&2 + exit 1 +fi + +token_payload="$(jq -n '{scope: "all", description: "Fleetbase Ledger local development"}')" +token_response="$(api POST "/instances/${INSTANCE_ID}/private/token" "${token_payload}" "Basic $(printf '%s:%s' "${INSTANCE_ID}" "${INSTANCE_PASSWORD}" | base64 | tr -d '\n')")" +token_code="$(printf '%s' "${token_response}" | tail -n1)" +token_body="$(printf '%s' "${token_response}" | sed '$d')" +if [ "${token_code}" != "200" ]; then + echo "Unable to create Taler merchant API token (HTTP ${token_code})." >&2 + printf '%s\n' "${token_body}" >&2 + exit 1 +fi + +mkdir -p "$(dirname "${TOKEN_FILE}")" +token="$(printf '%s\n' "${token_body}" | jq -r '.access_token // .token')" +case "${token}" in + Bearer\ *) + ;; + *) + token="Bearer ${token}" + ;; +esac +printf '%s\n' "${token}" > "${TOKEN_FILE}" +chmod 0600 "${TOKEN_FILE}" + +auth_header="$(cat "${TOKEN_FILE}")" + +account_payload="$(jq -n \ + --arg payto_uri "${PAYTO_URI}" \ + --arg credit_facade_url "${MERCHANT_BANK_REVENUE_URL}" \ + --arg bank_user "${MERCHANT_BANK_USER}" \ + --arg bank_password "${MERCHANT_BANK_PASSWORD}" \ + '{ + payto_uri: $payto_uri, + credit_facade_url: $credit_facade_url, + credit_facade_credentials: { + type: "basic", + username: $bank_user, + password: $bank_password + } + }')" +account_response="$(api POST "/instances/${INSTANCE_ID}/private/accounts" "${account_payload}" "${auth_header}")" +account_code="$(printf '%s' "${account_response}" | tail -n1)" +if [ "${account_code}" != "200" ] && [ "${account_code}" != "409" ]; then + echo "Unable to add Taler merchant payto account (HTTP ${account_code})." >&2 + printf '%s\n' "${account_response}" >&2 + exit 1 +fi + +webhook_payload="$(jq -n \ + --arg url "${WEBHOOK_URL}" \ + '{ + webhook_id: "fleetbase-ledger-pay", + event_type: "pay", + url: $url, + http_method: "POST", + header_template: "Content-Type: application/json", + body_template: "{\"order_id\":\"${order_id}\",\"event_type\":\"pay\"}" + }')" + +webhook_response="$(api POST "/instances/${INSTANCE_ID}/private/webhooks" "${webhook_payload}" "${auth_header}")" +webhook_code="$(printf '%s' "${webhook_response}" | tail -n1)" +if [ "${webhook_code}" != "204" ] && [ "${webhook_code}" != "409" ]; then + echo "Unable to register Fleetbase Ledger Taler webhook (HTTP ${webhook_code})." >&2 + printf '%s\n' "${webhook_response}" >&2 + exit 1 +fi + +echo "Taler merchant instance '${INSTANCE_ID}' is ready." +echo "Ledger gateway API token written to ${TOKEN_FILE}." diff --git a/docker/taler/init-stack.sh b/docker/taler/init-stack.sh new file mode 100644 index 0000000..1ecbb51 --- /dev/null +++ b/docker/taler/init-stack.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_TEMPLATE="${TALER_CONFIG_TEMPLATE:-/etc/taler/taler-local.conf}" +CONFIG_FILE="${TALER_CONFIG_FILE:-/var/lib/fleetbase-ledger/taler/taler.conf}" +POSTGRES_HOST="${TALER_POSTGRES_HOST:-taler-merchant-db}" +POSTGRES_PORT="${TALER_POSTGRES_PORT:-5432}" +POSTGRES_USER="${TALER_POSTGRES_USER:-taler_merchant}" +POSTGRES_PASSWORD="${TALER_POSTGRES_PASSWORD:-taler_merchant}" +TALER_CURRENCY="${TALER_CURRENCY:-KUDOS}" +EXCHANGE_BANK_USER="${TALER_EXCHANGE_BANK_USER:-exchange}" +EXCHANGE_BANK_PASSWORD="${TALER_EXCHANGE_BANK_PASSWORD:-fleetbase-exchange-bank-password}" +MERCHANT_BANK_USER="${TALER_MERCHANT_BANK_USER:-default}" +MERCHANT_BANK_PASSWORD="${TALER_MERCHANT_BANK_PASSWORD:-fleetbase-merchant-bank-password}" +EXCHANGE_PAYTO_URI="${TALER_EXCHANGE_PAYTO_URI:-payto://x-taler-bank/taler-bank.lvh.me/exchange?receiver-name=Fleetbase%20Local%20Exchange}" +MERCHANT_PAYTO_URI="${TALER_MERCHANT_PAYTO_URI:-payto://x-taler-bank/taler-bank.lvh.me/default?receiver-name=Fleetbase%20Ledger}" + +export PGPASSWORD="${POSTGRES_PASSWORD}" + +postgres_url() { + local db="$1" + printf 'postgres://%s:%s@%s:%s/%s' "${POSTGRES_USER}" "${POSTGRES_PASSWORD}" "${POSTGRES_HOST}" "${POSTGRES_PORT}" "${db}" +} + +create_database() { + local db="$1" + + if ! psql "$(postgres_url postgres)" -Atqc "SELECT 1 FROM pg_database WHERE datname = '${db}'" | grep -q 1; then + psql "$(postgres_url postgres)" -v ON_ERROR_STOP=1 -qc "CREATE DATABASE ${db} OWNER ${POSTGRES_USER}" + fi +} + +until pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -U "${POSTGRES_USER}" >/dev/null 2>&1; do + echo "Waiting for Taler PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." + sleep 2 +done + +install -d -m 0755 \ + "$(dirname "${CONFIG_FILE}")" \ + /var/lib/fleetbase-ledger/taler/data/exchange/offline \ + /var/lib/fleetbase-ledger/taler/data/exchange/revocations \ + /var/lib/fleetbase-ledger/taler/run/secmod-eddsa \ + /var/lib/fleetbase-ledger/taler/run/secmod-rsa \ + /var/lib/fleetbase-ledger/taler/run/secmod-cs \ + /var/lib/fleetbase-ledger/taler/tmp + +cp "${CONFIG_TEMPLATE}" "${CONFIG_FILE}" + +master_pub="$(taler-exchange-offline -c "${CONFIG_FILE}" setup | tail -n1 | tr -d '[:space:]')" +if [ -z "${master_pub}" ]; then + echo "Unable to create or read local exchange master public key." >&2 + exit 1 +fi + +sed -i "s/SET_BY_BOOTSTRAP/${master_pub}/g" "${CONFIG_FILE}" +printf '%s\n' "${master_pub}" > /var/lib/fleetbase-ledger/taler/local-exchange-master-public-key.txt + +create_database taler_bank +create_database taler_exchange +create_database taler_merchant + +libeufin-bank dbinit -c "${CONFIG_FILE}" +libeufin-bank passwd -c "${CONFIG_FILE}" admin admin-password || true +libeufin-bank edit-account -c "${CONFIG_FILE}" admin --debit_threshold="${TALER_CURRENCY}:1000000" || true + +libeufin-bank create-account \ + -c "${CONFIG_FILE}" \ + --user "${EXCHANGE_BANK_USER}" \ + --password "${EXCHANGE_BANK_PASSWORD}" \ + --name "Fleetbase Local Exchange" \ + --exchange \ + --public \ + --payto_uri "${EXCHANGE_PAYTO_URI}" \ + --debit_threshold "${TALER_CURRENCY}:1000000" >/dev/null 2>&1 || true + +libeufin-bank create-account \ + -c "${CONFIG_FILE}" \ + --user "${MERCHANT_BANK_USER}" \ + --password "${MERCHANT_BANK_PASSWORD}" \ + --name "Fleetbase Ledger" \ + --public \ + --payto_uri "${MERCHANT_PAYTO_URI}" \ + --debit_threshold "${TALER_CURRENCY}:1000000" >/dev/null 2>&1 || true + +taler-exchange-dbinit -c "${CONFIG_FILE}" +taler-merchant-dbinit -c "${CONFIG_FILE}" + +echo "Local Taler stack configuration initialized." +echo "Local exchange master public key: ${master_pub}" diff --git a/docker/taler/smoke-test.sh b/docker/taler/smoke-test.sh new file mode 100644 index 0000000..6f31c5e --- /dev/null +++ b/docker/taler/smoke-test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +TOKEN_FILE="${TALER_MERCHANT_TOKEN_FILE:-/ledger/docker/taler/generated-token.txt}" +MERCHANT_BASE_URL="${TALER_MERCHANT_PUBLIC_BASE_URL:-http://taler-merchant.lvh.me:9966}" +EXCHANGE_BASE_URL="${TALER_EXCHANGE_PUBLIC_BASE_URL:-http://taler-exchange.lvh.me:8081}" +BANK_BASE_URL="${TALER_BANK_PUBLIC_BASE_URL:-http://taler-bank.lvh.me:8082}" +INSTANCE_ID="${TALER_MERCHANT_INSTANCE_ID:-default}" + +wait_json_endpoint() { + local name="$1" + local url="$2" + + for _ in $(seq 1 120); do + if curl -fsS "${url}" | jq -e type >/dev/null 2>&1; then + return 0 + fi + + sleep 1 + done + + echo "Timed out waiting for ${name} at ${url}." >&2 + return 1 +} + +wait_json_endpoint bank "${BANK_BASE_URL}/config" +wait_json_endpoint exchange "${EXCHANGE_BASE_URL}/keys" +wait_json_endpoint merchant "${MERCHANT_BASE_URL}/config" + +echo "Checking bank config..." +curl -fsS "${BANK_BASE_URL}/config" | jq '.name, .currency' + +echo "Checking exchange keys..." +curl -fsS "${EXCHANGE_BASE_URL}/keys" | jq '.base_url, .currency, .master_public_key' + +echo "Checking merchant config..." +curl -fsS "${MERCHANT_BASE_URL}/config" | jq '.currency, .exchanges' + +echo "Checking merchant instance accounts..." +token="$(cat "${TOKEN_FILE}")" +curl -fsS -H "Authorization: ${token}" "${MERCHANT_BASE_URL}/instances/${INSTANCE_ID}/private/accounts" | jq '.accounts' + +echo "Creating a minimal merchant order..." +order_id="fleetbase-smoke-$(date +%s)" +curl -fsS \ + -H "Authorization: ${token}" \ + -H "Content-Type: application/json" \ + --data "$(jq -n --arg order_id "${order_id}" '{order_id: $order_id, order: {amount: "KUDOS:0.01", summary: "Fleetbase Taler smoke test"}}')" \ + "${MERCHANT_BASE_URL}/instances/${INSTANCE_ID}/private/orders" | jq '.' + +echo "Smoke test passed." diff --git a/docker/taler/taler-local.conf b/docker/taler/taler-local.conf new file mode 100644 index 0000000..945bee2 --- /dev/null +++ b/docker/taler/taler-local.conf @@ -0,0 +1,187 @@ +[PATHS] +TALER_HOME = /var/lib/fleetbase-ledger/taler +TALER_DATA_HOME = /var/lib/fleetbase-ledger/taler/data/ +TALER_CONFIG_HOME = /var/lib/fleetbase-ledger/taler/config/ +TALER_CACHE_HOME = /var/lib/fleetbase-ledger/taler/cache/ +TALER_RUNTIME_DIR = /var/lib/fleetbase-ledger/taler/run/ +TALER_TMP = /var/lib/fleetbase-ledger/taler/tmp/ + +[libeufin-bank] +CURRENCY = KUDOS +WIRE_TYPE = x-taler-bank +BASE_URL = http://taler-bank.lvh.me:8082/ +X_TALER_BANK_PAYTO_HOSTNAME = taler-bank.lvh.me +NAME = "Fleetbase Ledger Taler Test Bank" +REGISTRATION_BONUS = KUDOS:100 +DEFAULT_DEBT_LIMIT = KUDOS:999999 +ALLOW_REGISTRATION = yes +ALLOW_ACCOUNT_DELETION = yes +ALLOW_EDIT_NAME = yes +ALLOW_EDIT_CASHOUT_PAYTO_URI = yes +PWD_HASH_ALGORITHM = bcrypt +PWD_HASH_CONFIG = { "cost": 4 } +PWD_AUTH_COMPAT = yes +PWD_CHECK = no +SERVE = tcp +PORT = 8082 +BIND_TO = 0.0.0.0 +SUGGESTED_WITHDRAWAL_EXCHANGE = http://taler-exchange.lvh.me:8081/ + +[libeufin-bankdb-postgres] +CONFIG = jdbc:postgresql://taler-merchant-db:5432/taler_bank?user=taler_merchant&password=taler_merchant + +[exchange] +CURRENCY = KUDOS +CURRENCY_ROUND_UNIT = KUDOS:0.01 +CURRENCY_FRACTION_DIGITS = 2 +TINY_AMOUNT = KUDOS:0.01 +STEFAN_ABS = KUDOS:1 +STEFAN_LOG = KUDOS:1 +STEFAN_LIN = 0.0 +ATTRIBUTE_ENCRYPTION_KEY = 0123456789012345678901234567890123456789012345678901 +ENABLE_KYC = NO +DISABLE_DIRECT_DEPOSIT = NO +DB = postgres +SERVE = tcp +PORT = 8081 +BIND_TO = 0.0.0.0 +BASE_URL = http://taler-exchange.lvh.me:8081/ +MAX_KEYS_CACHING = forever +WIREWATCH_IDLE_SLEEP_INTERVAL = 1 s +TRANSFER_IDLE_SLEEP_INTERVAL = 1 s +AGGREGATOR_IDLE_SLEEP_INTERVAL = 1 s +AGGREGATOR_SHIFT = 1 s +SIGNKEY_LEGAL_DURATION = 2 years +MASTER_PUBLIC_KEY = SET_BY_BOOTSTRAP + +[exchange-offline] +MASTER_PRIV_FILE = /var/lib/fleetbase-ledger/taler/data/exchange/offline/master.priv + +[exchangedb-postgres] +CONFIG = postgres://taler_merchant:taler_merchant@taler-merchant-db:5432/taler_exchange +SQL_DIR = /usr/share/taler-exchange/sql/ + +[exchange-account-1] +PAYTO_URI = payto://x-taler-bank/taler-bank.lvh.me/exchange?receiver-name=Fleetbase%20Local%20Exchange +WIRE_RESPONSE = /var/lib/fleetbase-ledger/taler/data/exchange/account-1.json +ENABLE_CREDIT = YES +ENABLE_DEBIT = YES + +[exchange-accountcredentials-1] +WIRE_GATEWAY_URL = http://taler-bank.lvh.me:8082/accounts/exchange/taler-wire-gateway/ +WIRE_GATEWAY_AUTH_METHOD = basic +USERNAME = exchange +PASSWORD = fleetbase-exchange-bank-password + +[taler-exchange-secmod-eddsa] +LOOKAHEAD_SIGN = 20 s +KEY_DIR = /var/lib/fleetbase-ledger/taler/data/exchange/secmod-eddsa/keys +UNIXPATH = /var/lib/fleetbase-ledger/taler/run/secmod-eddsa/server.sock +CLIENT_DIR = /var/lib/fleetbase-ledger/taler/run/secmod-eddsa/clients +SM_PRIV_KEY = /var/lib/fleetbase-ledger/taler/data/exchange/secmod-eddsa/secmod-private-key +DURATION = 12 weeks + +[taler-exchange-secmod-rsa] +LOOKAHEAD_SIGN = 20 s +KEY_DIR = /var/lib/fleetbase-ledger/taler/data/exchange/secmod-rsa/keys +UNIXPATH = /var/lib/fleetbase-ledger/taler/run/secmod-rsa/server.sock +CLIENT_DIR = /var/lib/fleetbase-ledger/taler/run/secmod-rsa/clients +SM_PRIV_KEY = /var/lib/fleetbase-ledger/taler/data/exchange/secmod-rsa/secmod-private-key +OVERLAP_DURATION = 0 m + +[taler-exchange-secmod-cs] +LOOKAHEAD_SIGN = 20 s +KEY_DIR = /var/lib/fleetbase-ledger/taler/data/exchange/secmod-cs/keys +UNIXPATH = /var/lib/fleetbase-ledger/taler/run/secmod-cs/server.sock +CLIENT_DIR = /var/lib/fleetbase-ledger/taler/run/secmod-cs/clients +SM_PRIV_KEY = /var/lib/fleetbase-ledger/taler/data/exchange/secmod-cs/secmod-private-key +OVERLAP_DURATION = 5 m + +[coin_kudos_ct_1] +CIPHER = RSA +VALUE = KUDOS:0.01 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = KUDOS:0.00 +FEE_DEPOSIT = KUDOS:0.00 +FEE_REFRESH = KUDOS:0.00 +FEE_REFUND = KUDOS:0.00 +RSA_KEYSIZE = 1024 + +[coin_kudos_ct_10] +CIPHER = RSA +VALUE = KUDOS:0.10 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = KUDOS:0.00 +FEE_DEPOSIT = KUDOS:0.00 +FEE_REFRESH = KUDOS:0.00 +FEE_REFUND = KUDOS:0.00 +RSA_KEYSIZE = 1024 + +[coin_kudos_ct_50] +CIPHER = RSA +VALUE = KUDOS:0.50 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = KUDOS:0.00 +FEE_DEPOSIT = KUDOS:0.00 +FEE_REFRESH = KUDOS:0.00 +FEE_REFUND = KUDOS:0.00 +RSA_KEYSIZE = 1024 + +[coin_kudos_1] +CIPHER = RSA +VALUE = KUDOS:1 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = KUDOS:0.00 +FEE_DEPOSIT = KUDOS:0.00 +FEE_REFRESH = KUDOS:0.00 +FEE_REFUND = KUDOS:0.00 +RSA_KEYSIZE = 1024 + +[coin_kudos_5] +CIPHER = RSA +VALUE = KUDOS:5 +DURATION_WITHDRAW = 7 days +DURATION_SPEND = 2 years +DURATION_LEGAL = 3 years +FEE_WITHDRAW = KUDOS:0.00 +FEE_DEPOSIT = KUDOS:0.00 +FEE_REFRESH = KUDOS:0.00 +FEE_REFUND = KUDOS:0.00 +RSA_KEYSIZE = 1024 + +[currency-kudos] +ENABLED = YES +name = "Kudos (Fleetbase Local Taler Test)" +code = "KUDOS" +fractional_input_digits = 2 +fractional_normal_digits = 2 +fractional_trailing_zero_digits = 2 +alt_unit_names = {"0":"KUDOS"} +common_amounts = "KUDOS:0.50 KUDOS:1 KUDOS:5" + +[merchant] +CURRENCY = KUDOS +SERVE = tcp +PORT = 9966 +BIND_TO = 0.0.0.0 +BASE_URL = http://taler-merchant.lvh.me:9966/ +DATABASE = postgres +DB = postgres +ENABLE_SELF_PROVISIONING = YES + +[merchantdb-postgres] +CONFIG = postgres://taler_merchant:taler_merchant@taler-merchant-db:5432/taler_merchant +SQL_DIR = /usr/share/taler-merchant/sql/ + +[merchant-exchange-kudos] +EXCHANGE_BASE_URL = http://taler-exchange.lvh.me:8081/ +MASTER_KEY = SET_BY_BOOTSTRAP +CURRENCY = KUDOS diff --git a/server/migrations/2026_06_22_000001_widen_ledger_currency_columns_for_taler.php b/server/migrations/2026_06_22_000001_widen_ledger_currency_columns_for_taler.php new file mode 100644 index 0000000..ebd9a9d --- /dev/null +++ b/server/migrations/2026_06_22_000001_widen_ledger_currency_columns_for_taler.php @@ -0,0 +1,51 @@ +changeCurrencyColumn('ledger_accounts', false, 'USD', 10); + $this->changeCurrencyColumn('ledger_journals', false, 'USD', 10); + $this->changeCurrencyColumn('ledger_invoices', false, 'USD', 10); + $this->changeCurrencyColumn('ledger_wallets', false, 'USD', 10); + $this->changeCurrencyColumn('ledger_gateway_transactions', true, null, 10); + } + + public function down(): void + { + $this->changeCurrencyColumn('ledger_accounts', false, 'USD', 3); + $this->changeCurrencyColumn('ledger_journals', false, 'USD', 3); + $this->changeCurrencyColumn('ledger_invoices', false, 'USD', 3); + $this->changeCurrencyColumn('ledger_wallets', false, 'USD', 3); + $this->changeCurrencyColumn('ledger_gateway_transactions', true, null, 3); + } + + private function changeCurrencyColumn(string $tableName, bool $nullable, ?string $default, int $length): void + { + if (!Schema::hasTable($tableName) || !Schema::hasColumn($tableName, 'currency')) { + return; + } + + Schema::table($tableName, function (Blueprint $table) use ($length, $nullable, $default) { + $column = $table->string('currency', $length); + + if ($nullable) { + $column->nullable(); + } + + if ($default !== null) { + $column->default($default); + } + + $column->change(); + }); + } +}; diff --git a/server/seeders/Testing/TalerDemoSeeder.php b/server/seeders/Testing/TalerDemoSeeder.php new file mode 100644 index 0000000..89f6572 --- /dev/null +++ b/server/seeders/Testing/TalerDemoSeeder.php @@ -0,0 +1,536 @@ +resolveCompany(); + + if (!$company) { + $this->command?->error('[Ledger/Taler] No company found. Create a company or set TALER_DEMO_COMPANY_UUID/TALER_DEMO_COMPANY_PUBLIC_ID.'); + + return; + } + + session(['company' => $company->uuid]); + $this->ids = $this->stableIds($company->uuid); + $now = Carbon::now(); + + DB::transaction(function () use ($company, $now) { + $this->seedFleetOpsRoutePlaces($company, $now); + $this->seedFleetOpsQuoteFixture($company, $now); + $this->seedCoreTransaction($company, $now); + $this->seedFleetOpsOrderFixture($company, $now); + $this->seedFleetOpsTrackingFixture($company, $now); + $this->seedInvoice($company, $now); + }); + + $publicId = $this->invoicePublicId($company->uuid); + $orderId = $this->publicId('order', $company->uuid); + $this->command?->info("[Ledger/Taler] Seeded KUDOS demo invoice {$publicId} for company {$company->public_id}."); + $this->command?->info("[Ledger/Taler] FleetOps order: {$orderId} / TALER-DEMO-KUDOS"); + $this->command?->info("[Ledger/Taler] Public payment link: /~/invoice?id={$publicId}"); + $this->command?->info("[Ledger/Taler] Ledger route: /ledger/invoice/{$publicId}"); + $this->command?->info('[Ledger/Taler] Use backend_url=http://taler-merchant.lvh.me:9966 and the generated token from packages/ledger/docker/taler/generated-token.txt.'); + } + + protected function resolveCompany(): ?Company + { + return $this->resolveSeedCompany( + 'TALER_DEMO_COMPANY_UUID', + 'TALER_DEMO_COMPANY_PUBLIC_ID' + ); + } + + private function stableIds(string $companyUuid): array + { + return [ + 'pickup' => $this->stableUuid($companyUuid . ':pickup'), + 'dropoff' => $this->stableUuid($companyUuid . ':dropoff'), + 'payload' => $this->stableUuid($companyUuid . ':payload'), + 'order' => $this->stableUuid($companyUuid . ':order'), + 'tracking_number' => $this->stableUuid($companyUuid . ':tracking-number'), + 'tracking_status' => $this->stableUuid($companyUuid . ':tracking-status-created'), + 'service_quote' => $this->stableUuid($companyUuid . ':service-quote'), + 'quote_item_base' => $this->stableUuid($companyUuid . ':quote-item-base'), + 'quote_item_fee' => $this->stableUuid($companyUuid . ':quote-item-fee'), + 'purchase_rate' => $this->stableUuid($companyUuid . ':purchase-rate'), + 'transaction' => $this->stableUuid($companyUuid . ':transaction'), + 'transaction_base' => $this->stableUuid($companyUuid . ':transaction-item-base'), + 'transaction_fee' => $this->stableUuid($companyUuid . ':transaction-item-fee'), + 'invoice' => $this->stableUuid($companyUuid . ':invoice'), + 'invoice_base' => $this->stableUuid($companyUuid . ':invoice-item-base'), + 'invoice_fee' => $this->stableUuid($companyUuid . ':invoice-item-fee'), + ]; + } + + private function seedFleetOpsRoutePlaces(Company $company, Carbon $now): void + { + if (!class_exists(Place::class) || !Schema::hasTable('places')) { + return; + } + + $this->upsertPlace('pickup', $company, [ + 'name' => 'Taler Demo Pickup - Tanjong Pagar', + 'street1' => '1 Raffles Quay', + 'city' => 'Singapore', + 'country' => 'SG', + 'postal_code' => '048583', + 'lat' => 1.2816, + 'lng' => 103.8510, + ], $now); + + $this->upsertPlace('dropoff', $company, [ + 'name' => 'Taler Demo Dropoff - Orchard', + 'street1' => '2 Orchard Turn', + 'city' => 'Singapore', + 'country' => 'SG', + 'postal_code' => '238801', + 'lat' => 1.3048, + 'lng' => 103.8318, + ], $now); + } + + private function seedFleetOpsQuoteFixture(Company $company, Carbon $now): void + { + if (!Schema::hasTable('payloads') || !Schema::hasTable('service_quotes')) { + return; + } + + DB::table('payloads')->updateOrInsert( + ['uuid' => $this->ids['payload']], + [ + '_key' => $this->fixtureKey('payload'), + 'public_id' => $this->publicId('payload', $company->uuid), + 'company_uuid' => $company->uuid, + 'pickup_uuid' => Schema::hasTable('places') ? $this->ids['pickup'] : null, + 'dropoff_uuid' => Schema::hasTable('places') ? $this->ids['dropoff'] : null, + 'provider' => 'fleetbase', + 'payment_method' => 'taler', + 'cod_amount' => self::AMOUNT, + 'cod_currency' => self::CURRENCY, + 'cod_payment_method' => 'taler', + 'type' => 'transport', + 'meta' => $this->meta('payload', [ + 'description' => 'GNU Taler KUDOS demo payload', + ]), + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + DB::table('service_quotes')->updateOrInsert( + ['uuid' => $this->ids['service_quote']], + [ + '_key' => $this->fixtureKey('service_quote'), + 'public_id' => $this->publicId('quote', $company->uuid), + 'request_id' => self::SEED, + 'company_uuid' => $company->uuid, + 'payload_uuid' => $this->ids['payload'], + 'amount' => self::AMOUNT, + 'currency' => self::CURRENCY, + 'meta' => $this->meta('service_quote', [ + 'description' => 'GNU Taler KUDOS demo service quote', + ]), + 'expired_at' => $now->copy()->addWeek()->toDateTimeString(), + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + $this->seedServiceQuoteItem('quote_item_base', 'taler-demo-base', 'Taler demo delivery service', self::BASE_AMOUNT, $now); + $this->seedServiceQuoteItem('quote_item_fee', 'taler-demo-fee', 'Taler demo handling fee', self::FEE_AMOUNT, $now); + } + + private function seedCoreTransaction(Company $company, Carbon $now): void + { + if (!Schema::hasTable('transactions')) { + return; + } + + DB::table('transactions')->updateOrInsert( + ['uuid' => $this->ids['transaction']], + $this->columns('transactions', [ + '_key' => $this->fixtureKey('transaction'), + 'public_id' => $this->publicId('txn', $company->uuid), + 'company_uuid' => $company->uuid, + 'subject_uuid' => $this->ids['order'], + 'subject_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'context_uuid' => $this->ids['purchase_rate'], + 'context_type' => 'Fleetbase\\FleetOps\\Models\\PurchaseRate', + 'gateway_transaction_id' => 'taler-demo-' . substr(hash('sha256', $company->uuid), 0, 16), + 'gateway' => 'internal', + 'amount' => self::AMOUNT, + 'fee_amount' => 0, + 'tax_amount' => 0, + 'net_amount' => self::AMOUNT, + 'currency' => self::CURRENCY, + 'exchange_rate' => 1, + 'description' => 'GNU Taler KUDOS demo dispatch order', + 'reference' => 'taler-demo-' . substr(hash('sha256', $company->uuid), 0, 24), + 'type' => 'dispatch', + 'direction' => 'credit', + 'status' => 'success', + 'settlement_status' => Schema::hasColumn('transactions', 'settlement_status') ? 'unpaid' : null, + 'meta' => $this->meta('transaction', [ + 'invoice_uuid' => $this->ids['invoice'], + ]), + 'period' => $now->format('Y-m'), + 'created_at' => $now, + 'updated_at' => $now, + ]) + ); + + $this->seedTransactionItem('transaction_base', 'taler-demo-base', 'Taler demo delivery service', self::BASE_AMOUNT, 0, $now); + $this->seedTransactionItem('transaction_fee', 'taler-demo-fee', 'Taler demo handling fee', self::FEE_AMOUNT, 1, $now); + } + + private function seedFleetOpsOrderFixture(Company $company, Carbon $now): void + { + if (!Schema::hasTable('orders') || !Schema::hasTable('purchase_rates')) { + return; + } + + $orderConfigUuid = null; + if (class_exists(FleetOps::class)) { + $orderConfigUuid = FleetOps::createTransportConfig($company)->uuid; + } + + DB::table('purchase_rates')->updateOrInsert( + ['uuid' => $this->ids['purchase_rate']], + [ + '_key' => $this->fixtureKey('purchase_rate'), + 'public_id' => $this->publicId('rate', $company->uuid), + 'meta' => $this->meta('purchase_rate', [ + 'currency' => self::CURRENCY, + 'amount' => self::AMOUNT, + ]), + 'company_uuid' => $company->uuid, + 'transaction_uuid' => Schema::hasTable('transactions') ? $this->ids['transaction'] : null, + 'service_quote_uuid' => Schema::hasTable('service_quotes') ? $this->ids['service_quote'] : null, + 'payload_uuid' => Schema::hasTable('payloads') ? $this->ids['payload'] : null, + 'status' => 'success', + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + DB::table('orders')->updateOrInsert( + ['uuid' => $this->ids['order']], + $this->columns('orders', [ + '_key' => $this->fixtureKey('order'), + 'public_id' => $this->publicId('order', $company->uuid), + 'internal_id' => 'TALER-DEMO-KUDOS', + 'company_uuid' => $company->uuid, + 'payload_uuid' => Schema::hasTable('payloads') ? $this->ids['payload'] : null, + 'transaction_uuid' => Schema::hasTable('transactions') ? $this->ids['transaction'] : null, + 'purchase_rate_uuid' => $this->ids['purchase_rate'], + 'order_config_uuid' => $orderConfigUuid, + 'meta' => $this->meta('order', [ + 'currency' => self::CURRENCY, + 'total' => self::AMOUNT, + ]), + 'notes' => 'GNU Taler KUDOS demo order for local invoice payment testing.', + 'pod_method' => 'scan', + 'pod_required' => false, + 'type' => 'transport', + 'status' => 'created', + 'created_at' => $now, + 'updated_at' => $now, + ]) + ); + } + + private function seedFleetOpsTrackingFixture(Company $company, Carbon $now): void + { + if (!Schema::hasTable('tracking_numbers') || !Schema::hasTable('orders')) { + return; + } + + DB::table('tracking_numbers')->updateOrInsert( + ['uuid' => $this->ids['tracking_number']], + [ + '_key' => $this->fixtureKey('tracking_number'), + 'public_id' => $this->publicId('track', $company->uuid), + 'company_uuid' => $company->uuid, + 'owner_uuid' => $this->ids['order'], + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'tracking_number' => 'TALER-DEMO-' . strtoupper(substr(hash('sha256', $company->uuid), 0, 10)), + 'region' => 'SG', + 'qr_code' => DNS2D::getBarcodePNG($this->ids['order'], 'QRCODE'), + 'barcode' => DNS2D::getBarcodePNG($this->ids['order'], 'PDF417'), + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + DB::table('orders') + ->where('uuid', $this->ids['order']) + ->update([ + 'tracking_number_uuid' => $this->ids['tracking_number'], + 'updated_at' => $now, + ]); + + $this->seedTrackingStatus($company, $now); + } + + private function seedTrackingStatus(Company $company, Carbon $now): void + { + if (!class_exists(TrackingStatus::class) || !Schema::hasTable('tracking_statuses')) { + return; + } + + TrackingStatus::withoutEvents(function () use ($company, $now) { + $status = TrackingStatus::where('uuid', $this->ids['tracking_status'])->first() ?? new TrackingStatus(); + $status->forceFill([ + '_key' => $this->fixtureKey('tracking_status_created'), + 'uuid' => $this->ids['tracking_status'], + 'public_id' => $this->publicId('status', $company->uuid), + 'company_uuid' => $company->uuid, + 'tracking_number_uuid' => $this->ids['tracking_number'], + 'status' => 'Order Created', + 'details' => 'Taler demo order created for KUDOS invoice payment testing.', + 'code' => 'created', + 'complete' => false, + 'city' => 'Singapore', + 'country' => 'SG', + 'location' => new Point(1.2816, 103.8510), + 'meta' => [ + 'seed' => self::SEED, + 'seed_id' => 'tracking_status_created', + 'currency' => self::CURRENCY, + ], + 'created_at' => $now, + 'updated_at' => $now, + ])->save(); + }); + + DB::table('tracking_numbers') + ->where('uuid', $this->ids['tracking_number']) + ->update([ + 'status_uuid' => $this->ids['tracking_status'], + 'updated_at' => $now, + ]); + } + + private function seedServiceQuoteItem(string $idKey, string $code, string $details, int $amount, Carbon $now): void + { + if (!Schema::hasTable('service_quote_items')) { + return; + } + + DB::table('service_quote_items')->updateOrInsert( + ['uuid' => $this->ids[$idKey]], + [ + '_key' => $this->fixtureKey($idKey), + 'service_quote_uuid' => $this->ids['service_quote'], + 'amount' => $amount, + 'currency' => self::CURRENCY, + 'details' => $details, + 'code' => $code, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + private function seedTransactionItem(string $idKey, string $code, string $description, int $amount, int $sortOrder, Carbon $now): void + { + if (!Schema::hasTable('transaction_items')) { + return; + } + + DB::table('transaction_items')->updateOrInsert( + ['uuid' => $this->ids[$idKey]], + $this->columns('transaction_items', [ + '_key' => $this->fixtureKey($idKey), + 'public_id' => Schema::hasColumn('transaction_items', 'public_id') ? $this->publicId('txni-' . $sortOrder, $this->ids[$idKey]) : null, + 'transaction_uuid' => $this->ids['transaction'], + 'quantity' => Schema::hasColumn('transaction_items', 'quantity') ? 1 : null, + 'unit_price' => Schema::hasColumn('transaction_items', 'unit_price') ? $amount : null, + 'amount' => $amount, + 'currency' => self::CURRENCY, + 'tax_rate' => Schema::hasColumn('transaction_items', 'tax_rate') ? 0 : null, + 'tax_amount' => Schema::hasColumn('transaction_items', 'tax_amount') ? 0 : null, + 'details' => $description, + 'description' => Schema::hasColumn('transaction_items', 'description') ? $description : null, + 'code' => $code, + 'sort_order' => Schema::hasColumn('transaction_items', 'sort_order') ? $sortOrder : null, + 'meta' => $this->meta($idKey), + 'created_at' => $now, + 'updated_at' => $now, + ]) + ); + } + + private function seedInvoice(Company $company, Carbon $now): void + { + $invoicePublicId = $this->invoicePublicId($company->uuid); + + DB::table('ledger_invoices')->updateOrInsert( + ['uuid' => $this->ids['invoice']], + [ + '_key' => $this->fixtureKey('invoice'), + 'public_id' => $invoicePublicId, + 'company_uuid' => $company->uuid, + 'order_uuid' => Schema::hasTable('orders') ? $this->ids['order'] : null, + 'transaction_uuid' => Schema::hasTable('transactions') ? $this->ids['transaction'] : null, + 'number' => 'TALER-DEMO-' . strtoupper(substr(hash('sha256', $company->uuid), 0, 8)), + 'date' => $now->toDateString(), + 'due_date' => $now->copy()->addDays(7)->toDateString(), + 'subtotal' => self::AMOUNT, + 'tax' => 0, + 'total_amount' => self::AMOUNT, + 'amount_paid' => 0, + 'balance' => self::AMOUNT, + 'currency' => self::CURRENCY, + 'status' => 'sent', + 'notes' => 'GNU Taler KUDOS demo invoice for local payment testing.', + 'terms' => 'Demo currency only. Not for production settlement.', + 'meta' => $this->meta('invoice', [ + 'service_quote_uuid' => $this->ids['service_quote'], + 'purchase_rate_uuid' => $this->ids['purchase_rate'], + ]), + 'sent_at' => $now, + 'paid_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + $this->seedInvoiceItem('invoice_base', 'Taler demo delivery service', self::BASE_AMOUNT, $now); + $this->seedInvoiceItem('invoice_fee', 'Taler demo handling fee', self::FEE_AMOUNT, $now); + + Invoice::where('uuid', $this->ids['invoice'])->first()?->calculateTotals(); + DB::table('ledger_invoices') + ->where('uuid', $this->ids['invoice']) + ->update([ + 'amount_paid' => 0, + 'balance' => self::AMOUNT, + 'status' => 'sent', + 'sent_at' => $now, + 'updated_at' => $now, + ]); + } + + private function seedInvoiceItem(string $idKey, string $description, int $amount, Carbon $now): void + { + DB::table('ledger_invoice_items')->updateOrInsert( + ['uuid' => $this->ids[$idKey]], + [ + '_key' => $this->fixtureKey($idKey), + 'invoice_uuid' => $this->ids['invoice'], + 'description' => $description, + 'quantity' => 1, + 'unit_price' => $amount, + 'amount' => $amount, + 'tax_rate' => 0, + 'tax_amount' => 0, + 'meta' => $this->meta($idKey), + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + private function upsertPlace(string $idKey, Company $company, array $place, Carbon $now): void + { + Place::withoutEvents(function () use ($idKey, $company, $place, $now) { + $model = Place::where('uuid', $this->ids[$idKey])->first() ?? new Place(); + $model->forceFill([ + '_key' => $this->fixtureKey($idKey), + 'uuid' => $this->ids[$idKey], + 'public_id' => $this->publicId('place-' . $idKey, $company->uuid), + 'company_uuid' => $company->uuid, + 'name' => $place['name'], + 'street1' => $place['street1'], + 'city' => $place['city'], + 'country' => $place['country'], + 'postal_code' => $place['postal_code'], + 'location' => new Point($place['lat'], $place['lng']), + 'latitude' => (string) $place['lat'], + 'longitude' => (string) $place['lng'], + 'type' => 'address', + 'meta' => [ + 'seed' => self::SEED, + 'seed_id' => $idKey, + 'currency' => self::CURRENCY, + ], + 'created_at' => $now, + 'updated_at' => $now, + ])->save(); + }); + } + + private function stableUuid(string $value): string + { + $hash = md5(self::SEED . ':' . $value); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hash, 0, 8), + substr($hash, 8, 4), + substr($hash, 12, 4), + substr($hash, 16, 4), + substr($hash, 20, 12) + ); + } + + private function publicId(string $prefix, string $value): string + { + return $prefix . '_taler_demo_' . substr(hash('sha256', self::SEED . ':' . $value), 0, 12); + } + + private function invoicePublicId(string $companyUuid): string + { + return $this->publicId('invoice', $companyUuid); + } + + private function fixtureKey(string $seedId): string + { + return self::SEED . ':' . $seedId; + } + + private function meta(string $seedId, array $extra = []): string + { + return json_encode(array_merge([ + 'seed' => self::SEED, + 'seed_id' => $seedId, + 'currency' => self::CURRENCY, + ], $extra)); + } + + private function columns(string $tableName, array $values): array + { + return array_filter( + $values, + fn ($value, $column) => $value !== null && Schema::hasColumn($tableName, $column), + ARRAY_FILTER_USE_BOTH + ); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/InvoiceController.php b/server/src/Http/Controllers/Internal/v1/InvoiceController.php index ca8816f..db6dc90 100644 --- a/server/src/Http/Controllers/Internal/v1/InvoiceController.php +++ b/server/src/Http/Controllers/Internal/v1/InvoiceController.php @@ -169,7 +169,7 @@ public function send(string $id, Request $request): InvoiceResource try { $invoice = app(InvoiceService::class)->send($invoice); } catch (\InvalidArgumentException $e) { - abort(422, $e->getMessage()); + return response()->json(['error' => $e->getMessage()], 422); } return new InvoiceResource($invoice->load(['customer', 'items', 'template'])); diff --git a/server/tests/Feature.php b/server/tests/Feature.php index 7475344..22df437 100644 --- a/server/tests/Feature.php +++ b/server/tests/Feature.php @@ -42,6 +42,14 @@ ->toContain("'models' => [\$journal->uuid]"); }); +test('invoice send validation failures return json errors', function () { + $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/InvoiceController.php'); + + expect($controller) + ->not->toContain('abort(422, $e->getMessage())') + ->toContain("return response()->json(['error' => \$e->getMessage()], 422);"); +}); + test('order accounting observer preserves seed metadata on storefront sale journal entries', function () { $observer = file_get_contents(__DIR__ . '/../src/Observers/OrderAccountingObserver.php'); From e0e64b4f69c404a0e27a8757e4e7f404f4e49560 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 23 Jun 2026 18:53:02 +0800 Subject: [PATCH 7/9] Add Taler invoice QR checkout support --- addon/components/customer-invoice.hbs | 37 +++++++++++++++---- addon/components/customer-invoice.js | 18 +++++++++ server/src/Gateways/TalerDriver.php | 18 ++++++++- .../Public/PublicInvoiceController.php | 2 + server/tests/Gateways/TalerDriverTest.php | 2 + 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/addon/components/customer-invoice.hbs b/addon/components/customer-invoice.hbs index 8c5869a..7e3d311 100644 --- a/addon/components/customer-invoice.hbs +++ b/addon/components/customer-invoice.hbs @@ -195,13 +195,36 @@
{{else if this.hasTalerPaymentUri}}
-
- -
-

GNU Taler payment ready

-

- Open your Taler wallet to approve the payment, then return here to refresh the invoice. -

+
+ {{#if this.paymentQrImageSrc}} +
+ GNU Taler payment QR code +
+ {{/if}} +
+
+ +
+

GNU Taler payment ready

+

+ {{#if this.paymentQrImageSrc}} + Open your Taler wallet or scan the QR code with a wallet on another device. + {{else}} + Open your Taler wallet from this browser, or copy the payment URI into a wallet. + {{/if}} +

+
+
+ {{#unless this.paymentQrImageSrc}} +
+ QR code unavailable for this payment response. +
+ {{/unless}} + {{#if this.paymentQrText}} +
+

{{this.paymentQrText}}

+
+ {{/if}}
diff --git a/addon/components/customer-invoice.js b/addon/components/customer-invoice.js index 6bd74ae..02a75a1 100644 --- a/addon/components/customer-invoice.js +++ b/addon/components/customer-invoice.js @@ -34,6 +34,8 @@ export default class CustomerInvoiceComponent extends Component { @tracked pendingMessage = null; @tracked isRedirectingToCheckout = false; @tracked talerPaymentUri = null; + @tracked paymentQrImage = null; + @tracked paymentQrText = null; constructor() { super(...arguments); @@ -84,6 +86,18 @@ export default class CustomerInvoiceComponent extends Component { return typeof this.talerPaymentUri === 'string' && this.talerPaymentUri.startsWith('taler'); } + get paymentQrImageSrc() { + if (typeof this.paymentQrImage !== 'string' || this.paymentQrImage.length === 0) { + return null; + } + + if (this.paymentQrImage.startsWith('data:') || this.paymentQrImage.startsWith('http://') || this.paymentQrImage.startsWith('https://')) { + return this.paymentQrImage; + } + + return `data:image/png;base64,${this.paymentQrImage}`; + } + // ── Tasks ───────────────────────────────────────────────────────────────── /** @@ -171,6 +185,8 @@ export default class CustomerInvoiceComponent extends Component { ); const paymentUrl = data?.payment_url ?? data?.payment_uri ?? data?.checkout_url ?? data?.data?.taler_pay_uri; + this.paymentQrImage = data?.qr_image ?? data?.data?.qr_image ?? null; + this.paymentQrText = data?.qr_text ?? data?.data?.qr_text ?? paymentUrl ?? null; if (this.isTalerUri(paymentUrl)) { this.talerPaymentUri = paymentUrl; @@ -209,6 +225,8 @@ export default class CustomerInvoiceComponent extends Component { this.successMessage = null; this.pendingMessage = null; this.talerPaymentUri = null; + this.paymentQrImage = null; + this.paymentQrText = null; this.error = null; } diff --git a/server/src/Gateways/TalerDriver.php b/server/src/Gateways/TalerDriver.php index d352075..acba982 100644 --- a/server/src/Gateways/TalerDriver.php +++ b/server/src/Gateways/TalerDriver.php @@ -10,6 +10,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; +use Milon\Barcode\Facades\DNS2DFacade as DNS2D; /** * TalerDriver. @@ -271,11 +272,13 @@ public function purchase(PurchaseRequest $request): GatewayResponse return GatewayResponse::pending( gatewayTransactionId: $orderId, eventType: GatewayResponse::EVENT_PAYMENT_PENDING, - message: 'Taler order created. Redirect customer to taler_pay_uri.', + message: 'Taler order created. Open the GNU Taler wallet to complete payment.', rawResponse: array_merge($createResponse->json() ?? [], ['status' => $orderStatusRaw]), data: [ 'taler_pay_uri' => $talerPayUri, 'payment_url' => $talerPayUri, + 'qr_image' => $this->qrImageForUri($talerPayUri), + 'qr_text' => $talerPayUri, 'order_id' => $orderId, 'invoice_uuid' => $request->invoiceUuid, 'status' => $orderStatusRaw['order_status'] ?? null, @@ -643,6 +646,19 @@ private function apiToken(): string return preg_replace('/^Bearer\s+/i', '', trim((string) $this->config('api_token', ''))); } + private function qrImageForUri(string $uri): ?string + { + try { + return DNS2D::getBarcodePNG($uri, 'QRCODE', 8, 8); + } catch (\Throwable $e) { + $this->logError('Unable to generate Taler QR code', [ + 'error' => $e->getMessage(), + ]); + + return null; + } + } + /** * Parse a Taler amount string back into a [currency, integer cents] tuple. * diff --git a/server/src/Http/Controllers/Public/PublicInvoiceController.php b/server/src/Http/Controllers/Public/PublicInvoiceController.php index 7e2a27d..faf92aa 100644 --- a/server/src/Http/Controllers/Public/PublicInvoiceController.php +++ b/server/src/Http/Controllers/Public/PublicInvoiceController.php @@ -305,6 +305,8 @@ private function pendingPaymentPayload(GatewayResponse $response, ?Gateway $gate 'payment_url' => $paymentUrl, 'payment_uri' => $paymentUrl, 'message' => $response->message, + 'qr_image' => $response->data['qr_image'] ?? null, + 'qr_text' => $response->data['qr_text'] ?? $paymentUrl, 'data' => $response->data, ]; diff --git a/server/tests/Gateways/TalerDriverTest.php b/server/tests/Gateways/TalerDriverTest.php index 17a42d3..e22d8c6 100644 --- a/server/tests/Gateways/TalerDriverTest.php +++ b/server/tests/Gateways/TalerDriverTest.php @@ -106,6 +106,8 @@ function talerDriver(array $config = []): TalerDriver ->and($response->gatewayTransactionId)->toBe('TALER-ORDER-001') ->and($response->data['taler_pay_uri'])->toBe('taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001') ->and($response->data['payment_url'])->toBe('taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001') + ->and($response->data['qr_text'])->toBe('taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001') + ->and(array_key_exists('qr_image', $response->data))->toBeTrue() ->and($response->data['invoice_uuid'])->toBe('invoice-uuid-abc'); }); From 0f16f96f18923a905a47e61f35705bcac1cb9de4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 23 Jun 2026 23:26:33 +0800 Subject: [PATCH 8/9] ran php-cs-fixer --- server/src/Gateways/TalerDriver.php | 14 ++++---------- server/tests/Feature.php | 6 +++--- server/tests/Gateways/TalerDriverTest.php | 13 +++++++++---- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/server/src/Gateways/TalerDriver.php b/server/src/Gateways/TalerDriver.php index acba982..87e956f 100644 --- a/server/src/Gateways/TalerDriver.php +++ b/server/src/Gateways/TalerDriver.php @@ -57,17 +57,11 @@ class TalerDriver extends AbstractGatewayDriver // Driver Identity & Metadata // ------------------------------------------------------------------------- - /** - * {@inheritdoc} - */ public function getName(): string { return 'GNU Taler'; } - /** - * {@inheritdoc} - */ public function getCode(): string { return 'taler'; @@ -167,7 +161,7 @@ public function purchase(PurchaseRequest $request): GatewayResponse // terms and can be retrieved verbatim when the webhook fires. $payload = [ 'order_id' => $orderId, - 'order' => [ + 'order' => [ 'amount' => $talerAmount, 'summary' => $request->description, 'invoice_uuid' => $request->invoiceUuid, @@ -314,8 +308,8 @@ public function purchase(PurchaseRequest $request): GatewayResponse * * @return GatewayResponse Normalized response for event dispatching * - * @throws WebhookSignatureException Never thrown by this driver (verification - * is done via API re-query, not signature). + * @throws WebhookSignatureException never thrown by this driver (verification + * is done via API re-query, not signature) */ public function handleWebhook(Request $request): GatewayResponse { @@ -396,7 +390,7 @@ public function handleWebhook(Request $request): GatewayResponse $invoiceUuid = $contractTerms['invoice_uuid'] ?? $metadata['invoice_uuid'] ?? null; // Parse the deposit_total amount back to Fleetbase integer cents. - $depositTotal = $data['deposit_total'] ?? null; + $depositTotal = $data['deposit_total'] ?? null; [$currency, $amountCents] = $this->fromTalerAmount($depositTotal); $this->logInfo('Webhook verified: payment confirmed', [ diff --git a/server/tests/Feature.php b/server/tests/Feature.php index 2aa1dc9..a55e5d7 100644 --- a/server/tests/Feature.php +++ b/server/tests/Feature.php @@ -116,9 +116,9 @@ $controller = file_get_contents(__DIR__ . '/../../addon/controllers/payments/transactions/index.js'); expect($coreTransaction) - ->toContain("public const SETTLEMENT_STATUS_UNPAID") - ->toContain("public const SETTLEMENT_STATUS_PAID") - ->toContain("public const SETTLEMENT_STATUS_REFUNDED") + ->toContain('public const SETTLEMENT_STATUS_UNPAID') + ->toContain('public const SETTLEMENT_STATUS_PAID') + ->toContain('public const SETTLEMENT_STATUS_REFUNDED') ->toContain("'settlement_status'"); expect($invoiceService) diff --git a/server/tests/Gateways/TalerDriverTest.php b/server/tests/Gateways/TalerDriverTest.php index e22d8c6..7052e50 100644 --- a/server/tests/Gateways/TalerDriverTest.php +++ b/server/tests/Gateways/TalerDriverTest.php @@ -1,7 +1,7 @@ Http::response( [ - 'order_status' => 'unpaid', + 'order_status' => 'unpaid', 'taler_pay_uri' => 'taler://pay/backend.example.taler.net/testmerchant/TALER-ORDER-001', ], 200 @@ -135,6 +135,7 @@ function talerDriver(array $config = []): TalerDriver // Assert the POST body contained the correct Taler amount string Http::assertSent(function ($httpRequest) { $body = $httpRequest->data(); + return isset($body['order']['amount']) && $body['order']['amount'] === 'USD:10.50'; }); }); @@ -162,6 +163,7 @@ function talerDriver(array $config = []): TalerDriver Http::assertSent(function ($httpRequest) { $body = $httpRequest->data(); + return isset($body['order']['invoice_uuid']) && $body['order']['invoice_uuid'] === 'my-invoice-uuid'; }); }); @@ -189,6 +191,7 @@ function talerDriver(array $config = []): TalerDriver Http::assertSent(function ($httpRequest) { $body = $httpRequest->data(); + return isset($body['order_id']) && str_starts_with($body['order_id'], 'ledger-') && strlen($body['order_id']) === 39; @@ -283,8 +286,8 @@ function talerDriver(array $config = []): TalerDriver Http::fake([ 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-001' => Http::response( [ - 'order_status' => 'paid', - 'deposit_total' => 'USD:25.00', + 'order_status' => 'paid', + 'deposit_total' => 'USD:25.00', 'contract_terms' => [ 'invoice_uuid' => 'invoice-uuid-abc', 'summary' => 'Invoice #INV-001', @@ -406,6 +409,7 @@ function talerDriver(array $config = []): TalerDriver Http::assertSent(function ($httpRequest) { $body = $httpRequest->data(); + return isset($body['refund']) && $body['refund'] === 'USD:9.99'; }); }); @@ -460,6 +464,7 @@ function talerDriver(array $config = []): TalerDriver Http::assertSent(function ($httpRequest) { $body = $httpRequest->data(); + return isset($body['order']['amount']) && $body['order']['amount'] === 'EUR:0.00'; }); }); From fdf3501afeef110a97ba926e4e12896d655b3a27 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 23 Jun 2026 23:38:38 +0800 Subject: [PATCH 9/9] upgraded ember-ui to v0.3.38 --- package.json | 2 +- pnpm-lock.yaml | 319 +++++++++++++++++++------------------------------ 2 files changed, 126 insertions(+), 195 deletions(-) diff --git a/package.json b/package.json index 164559f..3b412ec 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.22", - "@fleetbase/ember-ui": "^0.3.34", + "@fleetbase/ember-ui": "^0.3.38", "@fleetbase/fleetops-data": "^0.1.37", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 785a56d..f2ad504 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,13 +10,13 @@ importers: dependencies: '@babel/core': specifier: ^7.23.2 - version: 7.29.0 + version: 7.23.2 '@fleetbase/ember-core': specifier: ^0.3.22 version: 0.3.22(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(eslint@8.52.0)(webpack@5.89.0) '@fleetbase/ember-ui': - specifier: ^0.3.34 - version: 0.3.34(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(postcss@8.5.8)(rollup@2.79.1)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.89.0) + specifier: ^0.3.38 + version: 0.3.38(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(postcss@8.5.8)(rollup@2.79.1)(tracked-built-ins@3.4.0(@babel/core@7.23.2))(webpack@5.89.0) '@fleetbase/fleetops-data': specifier: ^0.1.37 version: 0.1.37(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(eslint@8.52.0)(webpack@5.89.0) @@ -71,13 +71,13 @@ importers: version: 2.0.0 '@ember/test-helpers': specifier: ^3.2.0 - version: 3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + version: 3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) '@embroider/test-setup': specifier: ^3.0.2 version: 3.0.2 '@glimmer/component': specifier: ^1.1.2 - version: 1.1.2(@babel/core@7.29.0) + version: 1.1.2(@babel/core@7.23.2) '@glimmer/tracking': specifier: ^1.1.2 version: 1.1.2 @@ -110,7 +110,7 @@ importers: version: 5.0.0 ember-concurrency: specifier: ^4.0.6 - version: 4.0.6(@babel/core@7.29.0) + version: 4.0.6(@babel/core@7.23.2) ember-data: specifier: ^4.12.5 version: 4.12.5(@babel/core@7.23.2)(@ember/string@3.1.1)(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) @@ -131,10 +131,10 @@ importers: version: 8.0.1(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(qunit@2.20.0) ember-resolver: specifier: ^11.0.1 - version: 11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + version: 11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-source: specifier: ~5.4.0 - version: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + version: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) ember-source-channel-url: specifier: ^3.0.0 version: 3.0.0 @@ -1973,8 +1973,8 @@ packages: resolution: {integrity: sha512-tCYgxJoemUXgjsUztzeJZSwU2ejDp5x2WYI3FsBO55AlcC+mfWm0FsYtuPd9xd9PoO1jZ3T3VyvQQ06HRPtqWw==} engines: {node: '>= 18'} - '@fleetbase/ember-ui@0.3.34': - resolution: {integrity: sha512-l67lrbpeh0JRLDHr1sJ3QnxP6vPKwVW614CVWEASs6ZImGCBr6CK3DjsT3h+1uslQa51dpixN/stiQ+C/Z1etA==} + '@fleetbase/ember-ui@0.3.38': + resolution: {integrity: sha512-8nDYCKSTGtkFk0DQGwrxIljkrp/ANrZk/YOGiyBYSQ20/Uzkn6hciFxrUCUgxMOL511XRPWFdfdXJ6G8uJH0MA==} engines: {node: '>= 18'} '@fleetbase/fleetops-data@0.1.37': @@ -8206,8 +8206,8 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -12486,16 +12486,6 @@ snapshots: - '@babel/core' - supports-color - '@ember/render-modifiers@2.1.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))': - dependencies: - '@embroider/macros': 1.20.0(@babel/core@7.23.2) - ember-cli-babel: 7.26.11 - ember-modifier-manager-polyfill: 1.2.0(@babel/core@7.23.2) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - transitivePeerDependencies: - - '@babel/core' - - supports-color - '@ember/string@3.1.1': dependencies: ember-cli-babel: 7.26.11 @@ -12533,6 +12523,7 @@ snapshots: - '@glint/template' - supports-color - webpack + optional: true '@ember/test-waiters@3.1.0': dependencies: @@ -12557,7 +12548,7 @@ snapshots: '@embroider/shared-internals': 3.1.1 broccoli-funnel: 3.0.8 common-ancestor-path: 1.0.1 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -12682,7 +12673,7 @@ snapshots: pkg-entry-points: 1.1.2 resolve-package-path: 4.0.3 resolve.exports: 2.0.3 - semver: 7.8.4 + semver: 7.8.5 typescript-memoize: 1.1.1 transitivePeerDependencies: - supports-color @@ -12702,32 +12693,22 @@ snapshots: - '@babel/core' - supports-color - '@embroider/util@1.12.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))': - dependencies: - '@embroider/macros': 1.20.0(@babel/core@7.23.2) - broccoli-funnel: 3.0.8 - ember-cli-babel: 7.26.11 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - transitivePeerDependencies: - - '@babel/core' - - supports-color - - '@embroider/util@1.13.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))': + '@embroider/util@1.13.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))': dependencies: '@embroider/macros': 1.20.0(@babel/core@7.23.2) broccoli-funnel: 3.0.8 ember-cli-babel: 7.26.11 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - '@babel/core' - supports-color - '@embroider/util@1.13.5(@babel/core@7.29.0)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))': + '@embroider/util@1.13.5(@babel/core@7.29.0)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))': dependencies: '@embroider/macros': 1.20.0(@babel/core@7.29.0) broccoli-funnel: 3.0.8 ember-cli-babel: 7.26.11 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -12761,12 +12742,12 @@ snapshots: transitivePeerDependencies: - '@typescript-eslint/types' - '@fleetbase/ember-accounting@0.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))': + '@fleetbase/ember-accounting@0.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))': dependencies: '@babel/core': 7.23.2 ember-cli-babel: 8.2.0(@babel/core@7.23.2) ember-cli-htmlbars: 6.3.0 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - supports-color @@ -12803,17 +12784,17 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/ember-ui@0.3.34(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(postcss@8.5.8)(rollup@2.79.1)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.89.0)': + '@fleetbase/ember-ui@0.3.38(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(postcss@8.5.8)(rollup@2.79.1)(tracked-built-ins@3.4.0(@babel/core@7.23.2))(webpack@5.89.0)': dependencies: '@babel/core': 7.23.2 - '@ember/render-modifiers': 2.1.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + '@ember/render-modifiers': 2.1.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) '@ember/string': 3.1.1 '@embroider/addon': 0.30.0 '@embroider/macros': 1.20.0(@babel/core@7.23.2) '@event-calendar/core': 5.7.1 - '@fleetbase/ember-accounting': 0.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + '@fleetbase/ember-accounting': 0.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) '@floating-ui/dom': 1.7.6 - '@fortawesome/ember-fontawesome': 2.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(rollup@2.79.1)(webpack@5.89.0) + '@fortawesome/ember-fontawesome': 2.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(rollup@2.79.1)(webpack@5.89.0) '@fortawesome/fontawesome-svg-core': 6.4.0 '@fortawesome/free-brands-svg-icons': 6.4.0 '@fortawesome/free-solid-svg-icons': 6.4.0 @@ -12846,35 +12827,35 @@ snapshots: chart.js: 4.5.1 chartjs-adapter-date-fns: 3.0.0(chart.js@4.5.1)(date-fns@2.30.0) date-fns: 2.30.0 - ember-animated: 1.1.4(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-animated: 1.1.4(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-auto-import: 2.8.1(webpack@5.89.0) - ember-basic-dropdown: 8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-can: 6.0.0(@babel/core@7.23.2)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-basic-dropdown: 8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-can: 6.0.0(@babel/core@7.23.2)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-cli-babel: 8.3.1(@babel/core@7.23.2) ember-cli-htmlbars: 6.3.0 ember-cli-postcss: 8.2.0 ember-cli-string-helpers: 6.1.0 ember-composable-helpers: 5.0.0 - ember-concurrency: 4.0.6(@babel/core@7.29.0) - ember-drag-sort: 4.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) - ember-file-upload: 8.4.0(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-modifier@4.3.0(@babel/core@7.23.2))(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.89.0) + ember-concurrency: 4.0.6(@babel/core@7.23.2) + ember-drag-sort: 4.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + ember-file-upload: 8.4.0(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-modifier@4.3.0(@babel/core@7.23.2))(tracked-built-ins@3.4.0(@babel/core@7.23.2))(webpack@5.89.0) ember-focus-trap: 1.2.0(@babel/core@7.23.2) ember-get-config: 2.1.1(@babel/core@7.23.2) - ember-gridstack: 4.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) - ember-inflector: 4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-leaflet: 5.1.3(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(leaflet@1.9.4)(webpack@5.89.0) + ember-gridstack: 4.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + ember-inflector: 4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-leaflet: 5.1.3(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(leaflet@1.9.4)(webpack@5.89.0) ember-loading: 2.0.0(@babel/core@7.23.2) - ember-math-helpers: 4.0.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-modifier: 4.3.0(@babel/core@7.29.0) + ember-math-helpers: 4.0.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-modifier: 4.3.0(@babel/core@7.23.2) ember-on-helper: 0.1.0 - ember-power-calendar: 0.18.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-power-select: 8.6.2(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-basic-dropdown@8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-concurrency@4.0.6(@babel/core@7.23.2))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-power-calendar: 0.18.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-power-select: 8.6.2(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-basic-dropdown@8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-concurrency@4.0.6(@babel/core@7.23.2))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-ref-bucket: 4.1.0(@babel/core@7.23.2) ember-responsive: 5.0.0 ember-style-modifier: 3.1.1(@babel/core@7.23.2)(@ember/string@3.1.1)(webpack@5.89.0) ember-tag-input: 3.1.0 - ember-truth-helpers: 4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-window-mock: 0.9.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-truth-helpers: 4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-window-mock: 0.9.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-wormhole: 0.6.0 gridstack: 7.3.0 imask: 6.6.3 @@ -13012,32 +12993,6 @@ snapshots: - supports-color - webpack - '@fortawesome/ember-fontawesome@2.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(rollup@2.79.1)(webpack@5.89.0)': - dependencies: - '@fortawesome/fontawesome-svg-core': 6.4.0 - '@rollup/plugin-node-resolve': 15.2.3(rollup@2.79.1) - array-unique: 0.3.2 - broccoli-file-creator: 2.1.1 - broccoli-merge-trees: 4.2.0 - broccoli-plugin: 4.0.7 - broccoli-rollup: 5.0.0 - broccoli-source: 3.0.1 - camel-case: 4.1.2 - ember-ast-helpers: 0.4.0 - ember-auto-import: 2.8.1(webpack@5.89.0) - ember-cli-babel: 7.26.11 - ember-cli-htmlbars: 6.3.0 - ember-get-config: 2.1.1(@babel/core@7.23.2) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - find-yarn-workspace-root: 2.0.0 - glob: 10.3.10 - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - rollup - - supports-color - - webpack - '@fortawesome/fontawesome-common-types@6.4.0': {} '@fortawesome/fontawesome-svg-core@6.4.0': @@ -15940,13 +15895,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - decorator-transforms@1.2.1(@babel/core@7.29.0): - dependencies: - '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.0) - babel-import-util: 2.1.1 - transitivePeerDependencies: - - '@babel/core' - decorator-transforms@2.3.1(@babel/core@7.23.2): dependencies: '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.23.2) @@ -15961,9 +15909,9 @@ snapshots: transitivePeerDependencies: - '@babel/core' - decorator-transforms@2.3.2(@babel/core@7.29.0): + decorator-transforms@2.3.2(@babel/core@7.23.2): dependencies: - '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.23.2) babel-import-util: 3.0.1 transitivePeerDependencies: - '@babel/core' @@ -16107,15 +16055,15 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - ember-animated@1.1.4(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-animated@1.1.4(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: '@embroider/addon-shim': 1.10.2 '@embroider/macros': 1.20.0(@babel/core@7.23.2) - '@embroider/util': 1.12.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + '@embroider/util': 1.12.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) assert-never: 1.4.0 - ember-element-helper: 0.8.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-element-helper: 0.8.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) optionalDependencies: - '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) transitivePeerDependencies: - '@babel/core' - '@glint/environment-ember-loose' @@ -16272,22 +16220,22 @@ snapshots: - supports-color - webpack - ember-basic-dropdown@8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-basic-dropdown@8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: '@babel/core': 7.29.0 - '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) '@embroider/addon-shim': 1.10.2 '@embroider/macros': 1.20.0(@babel/core@7.29.0) - '@embroider/util': 1.13.5(@babel/core@7.29.0)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - '@glimmer/component': 1.1.2(@babel/core@7.29.0) + '@embroider/util': 1.13.5(@babel/core@7.29.0)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + '@glimmer/component': 1.1.2(@babel/core@7.23.2) '@glimmer/tracking': 1.1.2 decorator-transforms: 2.3.1(@babel/core@7.29.0) ember-element-helper: 0.8.8 - ember-lifeline: 7.0.0(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0)) + ember-lifeline: 7.0.0(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0)) ember-modifier: 4.3.0(@babel/core@7.29.0) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) ember-style-modifier: 4.5.1(@babel/core@7.29.0)(@ember/string@3.1.1) - ember-truth-helpers: 4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-truth-helpers: 4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) transitivePeerDependencies: - '@ember/string' - '@glint/environment-ember-loose' @@ -16318,6 +16266,18 @@ snapshots: - '@glint/template' - supports-color + ember-can@6.0.0(@babel/core@7.23.2)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): + dependencies: + '@ember/string': 3.1.1 + '@embroider/addon-shim': 1.10.2 + decorator-transforms: 2.3.1(@babel/core@7.23.2) + ember-inflector: 4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-resolver: 11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-can@6.0.0(@babel/core@7.23.2)(@ember/string@3.1.1)(ember-inflector@4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: '@ember/string': 3.1.1 @@ -16970,22 +16930,6 @@ snapshots: - supports-color - webpack - ember-composability-tools@1.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0): - dependencies: - '@babel/core': 7.23.2 - '@ember/render-modifiers': 2.1.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - '@glimmer/component': 1.1.2(@babel/core@7.23.2) - ember-auto-import: 2.8.1(webpack@5.89.0) - ember-cli-babel: 8.2.0(@babel/core@7.23.2) - ember-cli-htmlbars: 6.3.0 - ember-element-helper: 0.8.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - transitivePeerDependencies: - - '@glint/environment-ember-loose' - - '@glint/template' - - supports-color - - webpack - ember-composable-helpers@5.0.0: dependencies: '@babel/core': 7.23.2 @@ -17039,17 +16983,6 @@ snapshots: - '@babel/core' - supports-color - ember-concurrency@4.0.6(@babel/core@7.29.0): - dependencies: - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - '@babel/types': 7.29.7 - '@embroider/addon-shim': 1.10.2 - decorator-transforms: 1.2.1(@babel/core@7.29.0) - transitivePeerDependencies: - - '@babel/core' - - supports-color - ember-cookies@1.3.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: '@embroider/addon-shim': 1.10.2 @@ -17109,26 +17042,26 @@ snapshots: - '@babel/core' - supports-color - ember-drag-sort@4.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0): + ember-drag-sort@4.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0): dependencies: '@babel/core': 7.29.0 ember-auto-import: 2.12.1(webpack@5.89.0) ember-cli-babel: 8.2.0(@babel/core@7.29.0) ember-cli-htmlbars: 6.3.0 ember-element-helper: 0.8.8 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) ember-template-imports: 4.4.0 transitivePeerDependencies: - '@glint/template' - supports-color - webpack - ember-element-helper@0.6.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-element-helper@0.6.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: - '@embroider/util': 1.12.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + '@embroider/util': 1.12.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-cli-babel: 7.26.11 ember-cli-htmlbars: 6.3.0 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - '@babel/core' - '@glint/environment-ember-loose' @@ -17146,17 +17079,6 @@ snapshots: - '@glint/template' - supports-color - ember-element-helper@0.8.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): - dependencies: - '@embroider/addon-shim': 1.8.3 - '@embroider/util': 1.12.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - transitivePeerDependencies: - - '@babel/core' - - '@glint/environment-ember-loose' - - '@glint/template' - - supports-color - ember-element-helper@0.8.8: dependencies: '@embroider/addon-shim': 1.10.2 @@ -17187,17 +17109,17 @@ snapshots: - '@glint/template' - supports-color - ember-file-upload@8.4.0(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-modifier@4.3.0(@babel/core@7.23.2))(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.89.0): + ember-file-upload@8.4.0(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-modifier@4.3.0(@babel/core@7.23.2))(tracked-built-ins@3.4.0(@babel/core@7.23.2))(webpack@5.89.0): dependencies: - '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) '@ember/test-waiters': 3.1.0 '@embroider/addon-shim': 1.10.2 '@embroider/macros': 1.20.0(@babel/core@7.23.2) - '@glimmer/component': 1.1.2(@babel/core@7.29.0) + '@glimmer/component': 1.1.2(@babel/core@7.23.2) '@glimmer/tracking': 1.1.2 ember-auto-import: 2.8.1(webpack@5.89.0) - ember-modifier: 4.3.0(@babel/core@7.29.0) - tracked-built-ins: 3.4.0(@babel/core@7.29.0) + ember-modifier: 4.3.0(@babel/core@7.23.2) + tracked-built-ins: 3.4.0(@babel/core@7.23.2) transitivePeerDependencies: - '@babel/core' - '@glint/template' @@ -17213,12 +17135,12 @@ snapshots: - '@babel/core' - supports-color - ember-functions-as-helper-polyfill@2.1.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-functions-as-helper-polyfill@2.1.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: ember-cli-babel: 7.26.11 ember-cli-typescript: 5.2.1 ember-cli-version-checker: 5.1.2 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - supports-color @@ -17231,14 +17153,14 @@ snapshots: - '@glint/template' - supports-color - ember-gridstack@4.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0): + ember-gridstack@4.0.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0): dependencies: - '@ember/render-modifiers': 2.1.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + '@ember/render-modifiers': 2.1.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-auto-import: 2.8.1(webpack@5.89.0) ember-cli-babel: 7.26.11 ember-cli-htmlbars: 6.3.0 - ember-modifier: 4.3.0(@babel/core@7.29.0) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-modifier: 4.3.0(@babel/core@7.23.2) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) gridstack: 7.3.0 transitivePeerDependencies: - '@babel/core' @@ -17325,7 +17247,7 @@ snapshots: - supports-color - webpack - ember-leaflet@5.1.3(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(leaflet@1.9.4)(webpack@5.89.0): + ember-leaflet@5.1.3(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(leaflet@1.9.4)(webpack@5.89.0): dependencies: '@glimmer/component': 1.1.2(@babel/core@7.23.2) '@glimmer/tracking': 1.1.2 @@ -17333,10 +17255,10 @@ snapshots: broccoli-merge-trees: 4.2.0 ember-cli-babel: 7.26.11 ember-cli-htmlbars: 6.3.0 - ember-composability-tools: 1.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + ember-composability-tools: 1.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) ember-in-element-polyfill: 1.0.1 ember-render-helpers: 0.2.0 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) fastboot-transform: 0.1.3 leaflet: 1.9.4 resolve: 1.22.11 @@ -17347,11 +17269,11 @@ snapshots: - supports-color - webpack - ember-lifeline@7.0.0(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0)): + ember-lifeline@7.0.0(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0)): dependencies: '@embroider/addon-shim': 1.10.2 optionalDependencies: - '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) transitivePeerDependencies: - supports-color @@ -17397,13 +17319,6 @@ snapshots: transitivePeerDependencies: - supports-color - ember-math-helpers@4.0.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): - dependencies: - '@embroider/addon-shim': 1.8.7 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - transitivePeerDependencies: - - supports-color - ember-modifier-manager-polyfill@1.2.0(@babel/core@7.23.2): dependencies: ember-cli-babel: 7.26.11 @@ -17424,7 +17339,7 @@ snapshots: - '@babel/core' - supports-color - ember-modifier@4.3.0(@babel/core@7.29.0): + ember-modifier@4.3.0(@babel/core@7.23.2): dependencies: '@embroider/addon-shim': 1.10.2 decorator-transforms: 2.3.1(@babel/core@7.23.2) @@ -17432,6 +17347,14 @@ snapshots: - '@babel/core' - supports-color + ember-modifier@4.3.0(@babel/core@7.29.0): + dependencies: + '@embroider/addon-shim': 1.10.2 + decorator-transforms: 2.3.1(@babel/core@7.29.0) + transitivePeerDependencies: + - '@babel/core' + - supports-color + ember-on-helper@0.1.0: dependencies: ember-cli-babel: 7.26.11 @@ -17444,7 +17367,7 @@ snapshots: transitivePeerDependencies: - supports-color - ember-power-calendar@0.18.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-power-calendar@0.18.0(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: ember-assign-helper: 0.4.0 ember-cli-babel: 7.26.11 @@ -17452,7 +17375,7 @@ snapshots: ember-cli-htmlbars: 6.3.0 ember-concurrency: 2.3.7(@babel/core@7.23.2) ember-decorators: 6.1.1 - ember-element-helper: 0.6.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-element-helper: 0.6.1(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) ember-truth-helpers: 3.1.1 transitivePeerDependencies: - '@babel/core' @@ -17461,21 +17384,21 @@ snapshots: - ember-source - supports-color - ember-power-select@8.6.2(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-basic-dropdown@8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)))(ember-concurrency@4.0.6(@babel/core@7.23.2))(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-power-select@8.6.2(@babel/core@7.23.2)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-basic-dropdown@8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)))(ember-concurrency@4.0.6(@babel/core@7.23.2))(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: - '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) + '@ember/test-helpers': 3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0) '@embroider/addon-shim': 1.10.2 - '@embroider/util': 1.13.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - '@glimmer/component': 1.1.2(@babel/core@7.29.0) + '@embroider/util': 1.13.5(@babel/core@7.23.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + '@glimmer/component': 1.1.2(@babel/core@7.23.2) '@glimmer/tracking': 1.1.2 decorator-transforms: 2.3.1(@babel/core@7.23.2) ember-assign-helper: 0.5.1 - ember-basic-dropdown: 8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-concurrency: 4.0.6(@babel/core@7.29.0) - ember-lifeline: 7.0.0(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0)) - ember-modifier: 4.3.0(@babel/core@7.29.0) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) - ember-truth-helpers: 4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) + ember-basic-dropdown: 8.4.0(@ember/string@3.1.1)(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0))(@glimmer/component@1.1.2(@babel/core@7.23.2))(@glimmer/tracking@1.1.2)(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-concurrency: 4.0.6(@babel/core@7.23.2) + ember-lifeline: 7.0.0(@ember/test-helpers@3.2.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0))(webpack@5.89.0)) + ember-modifier: 4.3.0(@babel/core@7.23.2) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) + ember-truth-helpers: 4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) transitivePeerDependencies: - '@babel/core' - '@glint/environment-ember-loose' @@ -17522,6 +17445,14 @@ snapshots: transitivePeerDependencies: - supports-color + ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): + dependencies: + ember-cli-babel: 7.26.11 + optionalDependencies: + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) + transitivePeerDependencies: + - supports-color + ember-resolver@11.0.1(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: ember-cli-babel: 7.26.11 @@ -17572,7 +17503,7 @@ snapshots: ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0): dependencies: - '@babel/helper-module-imports': 7.22.15 + '@babel/helper-module-imports': 7.29.7 '@babel/plugin-transform-block-scoping': 7.23.4(@babel/core@7.23.2) '@ember/edition-utils': 1.2.0 '@glimmer/compiler': 0.84.3 @@ -17685,7 +17616,7 @@ snapshots: '@ember/string': 3.1.1 ember-auto-import: 2.8.1(webpack@5.89.0) ember-cli-babel: 7.26.11 - ember-modifier: 4.3.0(@babel/core@7.29.0) + ember-modifier: 4.3.0(@babel/core@7.23.2) transitivePeerDependencies: - '@babel/core' - '@glint/template' @@ -17783,11 +17714,11 @@ snapshots: transitivePeerDependencies: - supports-color - ember-truth-helpers@4.0.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-truth-helpers@4.0.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: '@embroider/addon-shim': 1.10.2 - ember-functions-as-helper-polyfill: 2.1.3(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)) - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-functions-as-helper-polyfill: 2.1.3(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - supports-color @@ -17818,11 +17749,11 @@ snapshots: - encoding - supports-color - ember-window-mock@0.9.0(ember-source@5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0)): + ember-window-mock@0.9.0(ember-source@5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0)): dependencies: ember-cli-babel: 7.26.11 ember-cli-htmlbars: 6.3.0 - ember-source: 5.4.0(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.89.0) + ember-source: 5.4.0(@babel/core@7.23.2)(@glimmer/component@1.1.2(@babel/core@7.23.2))(rsvp@4.8.5)(webpack@5.89.0) transitivePeerDependencies: - supports-color @@ -21508,7 +21439,7 @@ snapshots: semver@7.7.4: {} - semver@7.8.4: {} + semver@7.8.5: {} send@0.18.0: dependencies: @@ -22399,10 +22330,10 @@ snapshots: tr46@0.0.3: {} - tracked-built-ins@3.4.0(@babel/core@7.29.0): + tracked-built-ins@3.4.0(@babel/core@7.23.2): dependencies: '@embroider/addon-shim': 1.10.3 - decorator-transforms: 2.3.2(@babel/core@7.29.0) + decorator-transforms: 2.3.2(@babel/core@7.23.2) ember-tracked-storage-polyfill: 1.0.1 transitivePeerDependencies: - '@babel/core'