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/4] 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/4] 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 315946459884478e2fe1faedce692bec6419a979 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 22 Jun 2026 19:18:45 +0800 Subject: [PATCH 3/4] 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 4/4] 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'); });