From a32690a5a0d4344191fe2f0c36d1f951f4729d6c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 30 Mar 2026 04:17:32 -0400 Subject: [PATCH] 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'); +});