+
Wallet Status
{{n-a @options.walletStatus}}
diff --git a/addon/components/modals/refund-result.js b/addon/components/modals/refund-result.js
index 4616731..14d3580 100644
--- a/addon/components/modals/refund-result.js
+++ b/addon/components/modals/refund-result.js
@@ -1,3 +1,31 @@
import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
-export default class ModalsRefundResultComponent extends Component {}
+export default class ModalsRefundResultComponent extends Component {
+ @tracked email = this.args.options.customerEmail ?? '';
+
+ get isCompleted() {
+ return this.args.options.walletStatus === 'accepted' || this.args.options.refundStatus === 'refunded';
+ }
+
+ get statusTitle() {
+ return this.isCompleted ? 'Refund completed' : 'Refund URI issued';
+ }
+
+ get statusMessage() {
+ if (this.isCompleted) {
+ return 'The refund was accepted by the customer wallet and marked complete.';
+ }
+
+ return 'Share this refund URI with the customer so they can open it with their GNU Taler wallet and accept the refund.';
+ }
+
+ @action setEmail(event) {
+ this.email = event.target.value;
+ }
+
+ @action sendRefundUri() {
+ return this.args.options.sendRefundUri?.(this.args.options.refund, this.email);
+ }
+}
diff --git a/addon/controllers/billing/invoices/index.js b/addon/controllers/billing/invoices/index.js
index 1461405..fcaccfa 100644
--- a/addon/controllers/billing/invoices/index.js
+++ b/addon/controllers/billing/invoices/index.js
@@ -104,6 +104,8 @@ export default class BillingInvoicesIndexController extends Controller {
{ label: 'Viewed', value: 'viewed' },
{ label: 'Partial', value: 'partial' },
{ label: 'Paid', value: 'paid' },
+ { label: 'Partial Refund Pending', value: 'partial_refund_pending' },
+ { label: 'Refund Pending', value: 'refund_pending' },
{ label: 'Refunded', value: 'refunded' },
{ label: 'Overdue', value: 'overdue' },
{ label: 'Void', value: 'void' },
diff --git a/addon/controllers/billing/invoices/index/details.js b/addon/controllers/billing/invoices/index/details.js
index 7c56b97..a72a74d 100644
--- a/addon/controllers/billing/invoices/index/details.js
+++ b/addon/controllers/billing/invoices/index/details.js
@@ -12,6 +12,7 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
@service intl;
@tracked overlay = null;
+ @tracked refundRows = [];
/**
* Tab navigation for the details panel.
@@ -42,8 +43,8 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
});
}
- // Edit — individual button, available for all statuses except paid / void / cancelled.
- if (!['paid', 'void', 'cancelled'].includes(invoice?.status)) {
+ // Edit — individual button, available for open invoice statuses.
+ if (!['paid', 'refunded', 'refund_pending', 'partial_refund_pending', 'void', 'cancelled'].includes(invoice?.status)) {
buttons.push({
label: 'Edit',
icon: 'pencil',
@@ -74,6 +75,14 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
});
}
+ if (this.hasRefunds) {
+ dropdownItems.push({
+ text: 'View Refunds',
+ icon: 'receipt',
+ fn: () => this.viewRefunds(),
+ });
+ }
+
// Refund - paid or partially refunded invoices with remaining paid funds.
if (this.canIssueRefund) {
dropdownItems.push({
@@ -85,7 +94,7 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
}
// Void — for any non-terminal status.
- if (!['paid', 'void', 'cancelled'].includes(invoice?.status)) {
+ if (!['paid', 'refunded', 'refund_pending', 'partial_refund_pending', 'void', 'cancelled'].includes(invoice?.status)) {
dropdownItems.push({
text: this.intl.t('invoice.actions.void'),
icon: 'ban',
@@ -129,7 +138,11 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
get canIssueRefund() {
const status = this.model?.status;
- return ['paid', 'partial', 'refunded'].includes(status) && this.remainingRefundableAmount > 0;
+ return ['paid', 'partial', 'partial_refund_pending', 'refund_pending'].includes(status) && this.remainingRefundableAmount > 0;
+ }
+
+ get hasRefunds() {
+ return Boolean(this.refundRows.length > 0 || this.model?.meta?.last_taler_refund_uri || this.refundedAmount > 0);
}
@action async sendInvoice() {
@@ -205,7 +218,8 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
const invoice = this.model;
try {
- const result = await this.fetch.get(`invoices/${invoice.id}/refund-options`, {}, { namespace: 'ledger/int/v1' });
+ const result = await this.loadRefundOptions(invoice);
+ this.refundRows = result.refunds ?? [];
const refundOptions = (result.options ?? []).map((option) => {
const gatewayName = option.gateway?.name ?? option.gateway?.driver ?? 'Gateway';
const driver = option.gateway?.driver ? ` (${option.gateway.driver})` : '';
@@ -276,7 +290,7 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
try {
const response = await this.refundInvoice(invoice, options);
const responseData = response.data ?? {};
- const refundUrl = responseData.taler_refund_uri ?? responseData.refund_url;
+ const refundUrl = response.refund?.refund_url ?? responseData.refund_url ?? responseData.taler_refund_uri;
this.notifications.success('Refund issued successfully.');
await invoice.reload();
@@ -284,6 +298,10 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
confirmationModal.done();
refundModal.done();
+ if (response.refund) {
+ this.refundRows = [response.refund, ...this.refundRows.filter((refund) => refund.id !== response.refund.id)];
+ }
+
if (refundUrl) {
this.showRefundResult(response, refundUrl);
}
@@ -307,19 +325,98 @@ export default class BillingInvoicesIndexDetailsController extends Controller {
);
}
+ loadRefundOptions(invoice) {
+ return this.fetch.get(`invoices/${invoice.id}/refund-options`, {}, { namespace: 'ledger/int/v1' });
+ }
+
+ @action async viewRefunds() {
+ const invoice = this.model;
+
+ try {
+ const result = await this.loadRefundOptions(invoice);
+ this.refundRows = result.refunds ?? [];
+
+ this.modalsManager.show('modals/refund-history', {
+ title: `Refunds ${invoice.number}`,
+ acceptButtonText: 'Done',
+ acceptButtonIcon: 'check',
+ refunds: this.refundRows,
+ customerEmail: invoice.customerEmail,
+ sendRefundUri: (refund, email) => this.sendRefundUri(invoice, refund, email),
+ verifyRefundStatus: (refund) => this.verifyRefundStatus(invoice, refund),
+ confirm: (modal) => modal.done(),
+ });
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
showRefundResult(response, refundUrl) {
this.modalsManager.show('modals/refund-result', {
title: 'Refund Issued',
acceptButtonText: 'Done',
acceptButtonIcon: 'check',
refundUrl,
+ talerRefundUri: response.refund?.taler_refund_uri ?? response.data?.taler_refund_uri,
refundStatus: response.data?.refund_status ?? response.status,
walletStatus: response.data?.wallet_status,
gatewayTransactionId: response.gateway_transaction_id,
+ refund: response.refund ?? { id: response.gateway_transaction_id },
+ customerEmail: this.model?.customerEmail,
+ sendRefundUri: (refund, email) => this.sendRefundUri(this.model, refund, email),
confirm: (modal) => modal.done(),
});
}
+ async sendRefundUri(invoice, refund, email) {
+ const refundId = refund?.id ?? refund?.uuid ?? refund?.gateway_transaction_id;
+
+ if (!refundId) {
+ this.notifications.warning('Unable to resolve the refund transaction.');
+ return;
+ }
+
+ try {
+ const response = await this.fetch.post(
+ `invoices/${invoice.id}/refunds/${refundId}/send-refund-uri`,
+ {
+ email: email || null,
+ },
+ { namespace: 'ledger/int/v1' }
+ );
+
+ this.notifications.success(response?.message ?? 'Refund URI sent to customer.');
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ async verifyRefundStatus(invoice, refund) {
+ const refundId = refund?.id ?? refund?.uuid ?? refund?.gateway_transaction_id;
+
+ if (!refundId) {
+ this.notifications.warning('Unable to resolve the refund transaction.');
+ return;
+ }
+
+ try {
+ const response = await this.fetch.post(`invoices/${invoice.id}/refunds/${refundId}/verify-status`, {}, { namespace: 'ledger/int/v1' });
+
+ if (response?.refund) {
+ this.refundRows = [response.refund, ...this.refundRows.filter((row) => row.id !== response.refund.id)];
+ }
+
+ if (response?.invoice) {
+ await invoice.reload();
+ this.hostRouter.refresh();
+ }
+
+ this.notifications.success(response?.result?.message ?? 'Refund status verified.');
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
@action async voidInvoice() {
const invoice = this.model;
this.modalsManager.confirm({
diff --git a/addon/extension.js b/addon/extension.js
index 9e93eb7..e12f985 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -84,6 +84,23 @@ export default {
})
);
+ // ── Public GNU Taler refund handoff ──────────────────────────────────
+ // URL pattern: /~/taler-refund?id=
+ menuService.registerMenuItem(
+ 'auth:login',
+ new MenuItem({
+ title: 'Taler Refund',
+ slug: 'taler-refund',
+ route: 'virtual',
+ type: 'link',
+ wrapperClass: 'hidden',
+ component: new ExtensionComponent('@fleetbase/ledger-engine', 'customer-taler-refund'),
+ onClick: (menuItem) => {
+ universe.transitionMenuItem('virtual', menuItem);
+ },
+ })
+ );
+
// ── Fleet-Ops order details tab: Invoice ──────────────────────────────
// Injects an "Invoice" tab into the Fleet-Ops order details panel.
// The tab renders the order-invoice component which fetches and displays
diff --git a/addon/models/ledger-invoice.js b/addon/models/ledger-invoice.js
index 6139a87..39fea4f 100644
--- a/addon/models/ledger-invoice.js
+++ b/addon/models/ledger-invoice.js
@@ -259,7 +259,9 @@ export default class LedgerInvoiceModel extends Model {
sent: 'blue',
viewed: 'indigo',
partial: 'yellow',
+ partial_refund_pending: 'yellow',
paid: 'green',
+ refund_pending: 'yellow',
refunded: 'green',
overdue: 'red',
cancelled: 'red',
diff --git a/app/components/customer-taler-refund.js b/app/components/customer-taler-refund.js
new file mode 100644
index 0000000..232697d
--- /dev/null
+++ b/app/components/customer-taler-refund.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ledger-engine/components/customer-taler-refund';
diff --git a/app/components/modals/refund-history.js b/app/components/modals/refund-history.js
new file mode 100644
index 0000000..4e2802b
--- /dev/null
+++ b/app/components/modals/refund-history.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/ledger-engine/components/modals/refund-history';
diff --git a/server/resources/views/mail/refund-uri-available.blade.php b/server/resources/views/mail/refund-uri-available.blade.php
new file mode 100644
index 0000000..337895d
--- /dev/null
+++ b/server/resources/views/mail/refund-uri-available.blade.php
@@ -0,0 +1,84 @@
+
+
+
+
+
+ Refund available for invoice {{ $invoiceNumber }}
+
+
+
+
+
+
+
+
+ @if ($companyLogoUrl)
+
+ @else
+ {{ $companyName }}
+ @endif
+ |
+
+
+
+
+
+ |
+ Refund available
+ {{ $refundAmount }}
+
+ @if ($customerName)
+ Hi {{ $customerName }},
+ @else
+ Hello,
+ @endif
+ {{ $companyName }} issued a refund for invoice {{ $invoiceNumber }}.
+ Open the refund link with your GNU Taler wallet to accept it.
+
+ |
+
+
+
+
+ @if ($orderLabel)
+
+ | Order |
+ {{ $orderLabel }} |
+
+ @endif
+ @if ($issuedAt)
+
+ | Issued |
+ {{ $issuedAt }} |
+
+ @endif
+
+ |
+
+
+ |
+ Open refund
+ |
+
+
+ |
+ If the button does not open, copy this refund page URL:
+ {{ $refundUrl }}
+ Advanced wallet URI:
+ {{ $refundUri }}
+ |
+
+
+ |
+
+
+ |
+ This refund notice was sent by {{ $companyName }}.
+ |
+
+
+ |
+
+
+
+
diff --git a/server/src/Auth/Schemas/Ledger.php b/server/src/Auth/Schemas/Ledger.php
index 058d92e..8d16f41 100644
--- a/server/src/Auth/Schemas/Ledger.php
+++ b/server/src/Auth/Schemas/Ledger.php
@@ -31,7 +31,7 @@ class Ledger
// ── Billing ──────────────────────────────────────────────────────────────
[
'name' => 'invoice',
- 'actions' => ['send', 'preview', 'record-payment', 'refund', 'refund-options', 'mark-as-sent', 'render-pdf', 'export', 'import', 'create-from-order'],
+ 'actions' => ['send', 'preview', 'record-payment', 'refund', 'refund-options', 'send-refund-uri', 'verify-refund-status', 'mark-as-sent', 'render-pdf', 'export', 'import', 'create-from-order'],
],
[
'name' => 'invoice-item',
diff --git a/server/src/Console/Commands/VerifyTalerRefunds.php b/server/src/Console/Commands/VerifyTalerRefunds.php
new file mode 100644
index 0000000..d47cccd
--- /dev/null
+++ b/server/src/Console/Commands/VerifyTalerRefunds.php
@@ -0,0 +1,46 @@
+verifyPending([
+ 'company' => $this->option('company'),
+ 'gateway' => $this->option('gateway'),
+ 'refund' => $this->option('refund'),
+ 'limit' => (int) $this->option('limit'),
+ ]);
+
+ $this->info(sprintf(
+ '[Ledger/Taler] Refund verification complete. Checked %d; accepted %d; pending %d; errors %d.',
+ $summary['checked'],
+ $summary['accepted'],
+ $summary['pending'],
+ $summary['errors'],
+ ));
+
+ foreach ($summary['results'] as $result) {
+ $this->line(sprintf(
+ '- %s: %s%s',
+ $result['id'] ?? 'refund',
+ $result['status'] ?? 'unknown',
+ isset($result['message']) ? ' - ' . $result['message'] : ''
+ ));
+ }
+
+ return ($summary['errors'] ?? 0) > 0 ? self::FAILURE : self::SUCCESS;
+ }
+}
diff --git a/server/src/Gateways/TalerDriver.php b/server/src/Gateways/TalerDriver.php
index 5066237..4a4c407 100644
--- a/server/src/Gateways/TalerDriver.php
+++ b/server/src/Gateways/TalerDriver.php
@@ -30,7 +30,7 @@
* Configuration (stored encrypted in ledger_gateways.config):
* - backend_url : Base URL of the Taler Merchant Backend (e.g. https://backend.demo.taler.net/)
* - instance_id : Merchant instance ID (defaults to "default")
- * - api_token : Bearer token for authenticating against the private API
+ * - api_token : Bearer token body for authenticating against the private API
*
* Amount encoding:
* Fleetbase stores all monetary values as integers in the smallest currency
@@ -118,8 +118,8 @@ public function getConfigSchema(): array
'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.',
+ 'hint' => 'Paste the instance token as secret-token:... or Bearer secret-token:.... The token must belong to the configured Merchant Backend instance.',
+ 'description' => 'Private Merchant API token. Accepted formats: secret-token:... or Bearer secret-token:....',
],
];
}
@@ -393,9 +393,16 @@ public function handleWebhook(Request $request): GatewayResponse
$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;
- [$currency, $amountCents] = $this->fromTalerAmount($depositTotal);
+ // Prefer the exchange deposit total when available, but fall back to
+ // the paid order amount. Some Merchant Backend responses report
+ // deposit_total as KUDOS:0 while the paid contract_terms.amount is the
+ // customer-facing amount Ledger must apply to the invoice.
+ $paidAmount = $data['deposit_total'] ?? $contractTerms['amount'] ?? null;
+ [$currency, $amountCents] = $this->fromTalerAmount($paidAmount);
+
+ if (($amountCents ?? 0) <= 0 && !empty($contractTerms['amount'])) {
+ [$currency, $amountCents] = $this->fromTalerAmount($contractTerms['amount']);
+ }
$this->logInfo('Webhook verified: payment confirmed', [
'order_id' => $orderId,
@@ -537,9 +544,10 @@ public function testCredentials(): array
{
if ($configurationFailure = $this->configurationFailureResponse()) {
return [
- 'ok' => false,
- 'status' => 'failed',
- 'message' => $configurationFailure->message,
+ 'ok' => false,
+ 'status' => 'failed',
+ 'message' => $configurationFailure->message,
+ 'metadata' => $this->credentialMetadata(),
];
}
@@ -549,18 +557,22 @@ public function testCredentials(): array
$response = $this->privateRequest('GET', "instances/{$instanceId}/private/orders");
} catch (\Throwable $e) {
return [
- 'ok' => false,
- 'status' => 'failed',
- 'message' => 'Taler credential check failed: ' . $e->getMessage(),
+ 'ok' => false,
+ 'status' => 'failed',
+ 'message' => 'Taler credential check failed: ' . $e->getMessage(),
+ 'metadata' => $this->credentialMetadata(),
];
}
+ $successful = $response->successful();
+ $metadata = $this->credentialMetadata($response);
+
return [
- 'ok' => $response->successful(),
- 'status' => $response->successful() ? 'ok' : 'failed',
+ 'ok' => $successful,
+ 'status' => $successful ? 'ok' : 'failed',
'http_status' => $response->status(),
- 'message' => $response->successful() ? 'Taler credentials accepted.' : 'Taler credentials rejected.',
- 'raw_response'=> $response->json() ?? [],
+ 'message' => $successful ? 'Taler credentials accepted.' : $this->credentialFailureMessage($response, $metadata),
+ 'metadata' => $metadata,
'checked_at' => now()->toISOString(),
];
}
@@ -607,8 +619,8 @@ public function registerWebhook(array $options = []): array
'http_method' => 'POST',
'header_template' => 'Content-Type: application/json',
'body_template' => json_encode([
- 'order_id' => '${ORDER_ID}',
- 'event_type' => '${EVENT_TYPE}',
+ 'order_id' => '{{ order_id }}',
+ 'event_type' => '{{ webhook_type }}',
'company_uuid' => $companyUuid,
'gateway_id' => $gatewayId,
'gateway_uuid' => $gatewayId,
@@ -641,10 +653,10 @@ public function registerWebhook(array $options = []): array
];
}
- public function fetchOrderStatus(string $orderId): array
+ public function fetchOrderStatus(string $orderId, array $query = []): array
{
$instanceId = $this->instanceId();
- $response = $this->privateRequest('GET', "instances/{$instanceId}/private/orders/{$orderId}");
+ $response = $this->privateRequest('GET', "instances/{$instanceId}/private/orders/{$orderId}", $query);
return [
'ok' => $response->successful(),
@@ -653,6 +665,20 @@ public function fetchOrderStatus(string $orderId): array
];
}
+ public function fetchRefundStatus(string $orderId, ?int $refundAmount = null, ?string $currency = null, array $query = []): array
+ {
+ $params = array_merge([
+ 'await_refund_obtained' => 'yes',
+ 'timeout_ms' => 3000,
+ ], $query);
+
+ if ($refundAmount !== null && $currency) {
+ $params['refund'] = $this->toTalerAmount($refundAmount, $currency);
+ }
+
+ return $this->fetchOrderStatus($orderId, $params);
+ }
+
// -------------------------------------------------------------------------
// Private Helpers
// -------------------------------------------------------------------------
@@ -751,7 +777,7 @@ private function privateRequest(string $method, string $path, array $payload = [
'POST' => $pending->post($url, $payload),
'PATCH' => $pending->patch($url, $payload),
'DELETE' => $pending->delete($url, $payload),
- default => $pending->get($url),
+ default => $pending->get($url, $payload),
};
}
@@ -779,7 +805,63 @@ private function toTalerAmount(int $amountCents, string $currency): string
private function apiToken(): string
{
- return preg_replace('/^Bearer\s+/i', '', trim((string) $this->config('api_token', '')));
+ return trim(preg_replace('/^Bearer\s+/i', '', trim((string) $this->config('api_token', ''))));
+ }
+
+ private function credentialMetadata(?HttpResponse $response = null): array
+ {
+ $metadata = [
+ 'backend_url' => $this->backendUrl(),
+ 'instance_id' => $this->instanceId(),
+ ];
+
+ if ($response) {
+ $metadata['http_status'] = $response->status();
+ $metadata = array_merge($metadata, $this->talerErrorMetadata($response));
+ }
+
+ return array_filter($metadata, fn ($value) => $value !== null && $value !== '');
+ }
+
+ private function talerErrorMetadata(HttpResponse $response): array
+ {
+ $body = $response->json();
+
+ if (!is_array($body)) {
+ return [];
+ }
+
+ $details = $body['details'] ?? $body['detail'] ?? null;
+
+ if (is_array($details)) {
+ $details = json_encode($details);
+ }
+
+ return array_filter([
+ 'taler_error_code' => $body['code'] ?? $body['error_code'] ?? null,
+ 'hint' => $body['hint'] ?? null,
+ 'detail' => $details,
+ 'request_uid' => $body['request_uid'] ?? null,
+ ], fn ($value) => $value !== null && $value !== '');
+ }
+
+ private function credentialFailureMessage(HttpResponse $response, array $metadata): string
+ {
+ $message = 'Taler credentials rejected.';
+
+ if ($response->status()) {
+ $message .= ' HTTP ' . $response->status() . '.';
+ }
+
+ if (!empty($metadata['hint'])) {
+ return $message . ' ' . $metadata['hint'];
+ }
+
+ if (!empty($metadata['detail'])) {
+ return $message . ' ' . $metadata['detail'];
+ }
+
+ return $message . ' Check the API token and Merchant Backend instance ID.';
}
private function qrImageForUri(string $uri): ?string
diff --git a/server/src/Http/Controllers/Internal/v1/GatewayController.php b/server/src/Http/Controllers/Internal/v1/GatewayController.php
index 3c97817..0f4c044 100644
--- a/server/src/Http/Controllers/Internal/v1/GatewayController.php
+++ b/server/src/Http/Controllers/Internal/v1/GatewayController.php
@@ -206,12 +206,44 @@ public function testCredentials(Request $request, string $id): JsonResponse
'successful' => (bool) ($result['ok'] ?? false),
'message' => $result['message'] ?? null,
'http_status' => $result['http_status'] ?? null,
+ 'metadata' => $result['metadata'] ?? null,
'checked_at' => now()->toISOString(),
]);
return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
}
+ public function testDraftCredentials(Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'driver' => ['required', 'string'],
+ 'environment' => ['nullable', 'string', 'in:sandbox,live'],
+ 'config' => ['required', 'array'],
+ ]);
+
+ try {
+ $driver = app(\Fleetbase\Ledger\PaymentGatewayManager::class)
+ ->driver($validated['driver'])
+ ->initialize($validated['config'], ($validated['environment'] ?? 'sandbox') === 'sandbox');
+ } catch (\Throwable) {
+ return response()->json([
+ 'status' => 'unsupported',
+ 'message' => "Gateway driver [{$validated['driver']}] is not available.",
+ ], 422);
+ }
+
+ if (!method_exists($driver, 'testCredentials')) {
+ return response()->json([
+ 'status' => 'unsupported',
+ 'message' => "Gateway driver [{$validated['driver']}] does not support credential diagnostics.",
+ ], 422);
+ }
+
+ $result = $this->sanitizeProviderResult($driver->testCredentials());
+
+ return response()->json($result, ($result['ok'] ?? false) ? 200 : 422);
+ }
+
public function createTestOrder(Request $request, string $id): JsonResponse
{
$gateway = $this->resolveGateway($id);
diff --git a/server/src/Http/Controllers/Internal/v1/InvoiceController.php b/server/src/Http/Controllers/Internal/v1/InvoiceController.php
index 76688ac..7268302 100644
--- a/server/src/Http/Controllers/Internal/v1/InvoiceController.php
+++ b/server/src/Http/Controllers/Internal/v1/InvoiceController.php
@@ -10,12 +10,16 @@
use Fleetbase\Ledger\Models\Invoice;
use Fleetbase\Ledger\Models\InvoiceItem;
use Fleetbase\Ledger\Models\Transaction;
+use Fleetbase\Ledger\Notifications\RefundUriAvailable;
use Fleetbase\Ledger\Services\InvoiceService;
use Fleetbase\Ledger\Services\PaymentService;
+use Fleetbase\Ledger\Services\TalerRefundVerificationService;
use Fleetbase\Services\TemplateRenderService;
+use Fleetbase\Support\Utils;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
+use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
class InvoiceController extends LedgerResourceController
@@ -166,6 +170,7 @@ public function refundOptions(string $id, Request $request): JsonResponse
'remaining_refundable_amount' => $this->invoiceRemainingRefundableAmount($invoice),
],
'options' => $options,
+ 'refunds' => $this->refundGatewayTransactions($invoice),
]);
}
@@ -223,6 +228,7 @@ public function refund(string $id, Request $request, PaymentService $paymentServ
'message' => $response->message,
'data' => $response->data,
'refund_kind' => $refundKind,
+ 'refund' => $this->latestRefundGatewayTransaction($invoice, $response->gatewayTransactionId),
'invoice' => (new InvoiceResource($invoice->fresh(['customer', 'items', 'template'])))->resolve(),
], $response->isSuccessful() ? 200 : 422);
}
@@ -260,6 +266,56 @@ public function send(string $id, Request $request): InvoiceResource
return new InvoiceResource($invoice->load(['customer', 'items', 'template']));
}
+ /**
+ * Email a customer-facing refund URI for a gateway refund.
+ */
+ public function sendRefundUri(string $id, string $gatewayTransactionId, Request $request): JsonResponse
+ {
+ $request->validate([
+ 'email' => 'nullable|email',
+ ]);
+
+ $invoice = $this->resolveInvoice($id);
+ $refund = $this->resolveRefundGatewayTransaction($invoice, $gatewayTransactionId);
+ $uri = $this->refundUri($refund);
+
+ if (!$uri) {
+ return response()->json(['error' => 'This refund does not have a customer refund URI.'], 422);
+ }
+
+ $email = $request->input('email') ?: $this->customerEmail($invoice);
+
+ if (!$email) {
+ return response()->json(['error' => 'Invoice customer does not have a valid email address.'], 422);
+ }
+
+ Notification::route('mail', $email)->notify(new RefundUriAvailable($invoice, $refund, $uri));
+
+ return response()->json([
+ 'ok' => true,
+ 'message' => 'Refund URI sent to customer.',
+ 'sent_to' => $email,
+ 'refund' => $this->serializeRefundGatewayTransaction($refund),
+ ]);
+ }
+
+ /**
+ * Manually verify whether a Taler refund has been accepted by the wallet.
+ */
+ public function verifyRefundStatus(string $id, string $gatewayTransactionId, TalerRefundVerificationService $verifier): JsonResponse
+ {
+ $invoice = $this->resolveInvoice($id);
+ $refund = $this->resolveRefundGatewayTransaction($invoice, $gatewayTransactionId);
+ $result = $verifier->verifyRefund($refund);
+
+ return response()->json([
+ 'ok' => ($result['status'] ?? null) !== 'error',
+ 'result' => $result,
+ 'refund' => $this->serializeRefundGatewayTransaction($refund->fresh(['gateway'])),
+ 'invoice' => (new InvoiceResource($invoice->fresh(['customer', 'items', 'template'])))->resolve(),
+ ], ($result['status'] ?? null) === 'error' ? 422 : 200);
+ }
+
/**
* Render the invoice using its assigned template and return the HTML.
*
@@ -358,9 +414,153 @@ private function invoiceRemainingRefundableAmount(Invoice $invoice): int
return max(0, (int) $invoice->amount_paid - $this->invoiceRefundedAmount($invoice));
}
+ private function refundGatewayTransactions(Invoice $invoice): array
+ {
+ $lastRefundUuid = data_get($invoice->meta, 'last_refund_gateway_transaction_uuid');
+ $paymentReferences = $this->invoicePaymentReferences($invoice);
+ $coreRefundReferences = Transaction::where('company_uuid', $invoice->company_uuid)
+ ->where('context_uuid', $invoice->uuid)
+ ->where('type', 'gateway_refund')
+ ->whereNotNull('reference')
+ ->pluck('reference')
+ ->filter()
+ ->values();
+
+ return GatewayTransaction::query()
+ ->with('gateway')
+ ->where('company_uuid', $invoice->company_uuid)
+ ->where('type', 'refund')
+ ->where(function ($query) use ($invoice, $lastRefundUuid, $paymentReferences, $coreRefundReferences) {
+ $query->where('raw_response->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->data->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->metadata->invoice_uuid', $invoice->uuid);
+
+ if ($lastRefundUuid) {
+ $query->orWhere('uuid', $lastRefundUuid);
+ }
+
+ if ($coreRefundReferences->isNotEmpty()) {
+ $query->orWhereIn('uuid', $coreRefundReferences->all())
+ ->orWhereIn('public_id', $coreRefundReferences->all())
+ ->orWhereIn('gateway_reference_id', $coreRefundReferences->all());
+ }
+
+ if ($paymentReferences->isNotEmpty()) {
+ $query->orWhereIn('raw_response->original_gateway_reference_id', $paymentReferences->all())
+ ->orWhereIn('raw_response->order_id', $paymentReferences->all())
+ ->orWhereIn('raw_response->data->order_id', $paymentReferences->all());
+ }
+ })
+ ->orderByDesc('created_at')
+ ->get()
+ ->map(fn (GatewayTransaction $transaction) => $this->serializeRefundGatewayTransaction($transaction))
+ ->values()
+ ->all();
+ }
+
+ private function resolveRefundGatewayTransaction(Invoice $invoice, string $gatewayTransactionId): GatewayTransaction
+ {
+ return GatewayTransaction::query()
+ ->with('gateway')
+ ->where('company_uuid', $invoice->company_uuid)
+ ->where('type', 'refund')
+ ->where(function ($query) use ($gatewayTransactionId) {
+ $query->where('uuid', $gatewayTransactionId)
+ ->orWhere('public_id', $gatewayTransactionId)
+ ->orWhere('gateway_reference_id', $gatewayTransactionId)
+ ->orWhere('raw_response->original_gateway_reference_id', $gatewayTransactionId);
+ })
+ ->where(function ($query) use ($invoice) {
+ $query->where('raw_response->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->data->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->metadata->invoice_uuid', $invoice->uuid);
+ })
+ ->firstOrFail();
+ }
+
+ private function latestRefundGatewayTransaction(Invoice $invoice, ?string $gatewayReferenceId = null): ?array
+ {
+ $transaction = GatewayTransaction::query()
+ ->with('gateway')
+ ->where('company_uuid', $invoice->company_uuid)
+ ->where('type', 'refund')
+ ->where(function ($query) use ($invoice) {
+ $query->where('raw_response->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->data->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->metadata->invoice_uuid', $invoice->uuid);
+ })
+ ->when($gatewayReferenceId, function ($query) use ($gatewayReferenceId) {
+ $query->where(function ($q) use ($gatewayReferenceId) {
+ $q->where('gateway_reference_id', $gatewayReferenceId)
+ ->orWhere('raw_response->original_gateway_reference_id', $gatewayReferenceId);
+ });
+ })
+ ->orderByDesc('created_at')
+ ->first();
+
+ return $transaction ? $this->serializeRefundGatewayTransaction($transaction) : null;
+ }
+
+ private function serializeRefundGatewayTransaction(GatewayTransaction $transaction): array
+ {
+ $refundUri = $this->refundUri($transaction);
+ $handoffUrl = $refundUri ? $this->refundHandoffUrl($transaction) : null;
+
+ return [
+ 'id' => $transaction->public_id ?? $transaction->uuid,
+ 'uuid' => $transaction->uuid,
+ 'gateway_transaction_id' => $transaction->gateway_reference_id,
+ 'amount' => (int) $transaction->amount,
+ 'currency' => $transaction->currency,
+ 'status' => $transaction->status,
+ 'refund_status' => $transaction->refund_status ?? data_get($transaction->raw_response, 'data.refund_status'),
+ 'wallet_status' => data_get($transaction->raw_response, 'data.wallet_status'),
+ 'refund_url' => $handoffUrl,
+ 'refund_handoff_url' => $handoffUrl,
+ 'taler_refund_uri' => $refundUri,
+ 'created_at' => optional($transaction->created_at)->toISOString(),
+ 'processed_at' => optional($transaction->processed_at)->toISOString(),
+ 'refund_accepted_at' => optional($transaction->refund_accepted_at)->toISOString(),
+ 'refund_expires_at' => optional($transaction->refund_expires_at)->toISOString(),
+ 'gateway' => $transaction->gateway ? [
+ 'id' => $transaction->gateway->public_id ?? $transaction->gateway->uuid,
+ 'uuid' => $transaction->gateway->uuid,
+ 'public_id' => $transaction->gateway->public_id,
+ 'name' => $transaction->gateway->name,
+ 'driver' => $transaction->gateway->driver,
+ ] : null,
+ ];
+ }
+
+ private function refundUri(GatewayTransaction $transaction): ?string
+ {
+ return data_get($transaction->raw_response, 'data.taler_refund_uri')
+ ?: data_get($transaction->raw_response, 'data.refund_url')
+ ?: data_get($transaction->raw_response, 'taler_refund_uri')
+ ?: data_get($transaction->raw_response, 'refund_url');
+ }
+
+ private function refundHandoffUrl(GatewayTransaction $transaction): string
+ {
+ return Utils::consoleUrl('~/taler-refund', [
+ 'id' => $transaction->public_id ?? $transaction->uuid,
+ ]);
+ }
+
+ private function customerEmail(Invoice $invoice): ?string
+ {
+ $invoice->loadMissing('customer');
+ $customer = $invoice->customer;
+
+ return $customer?->email
+ ?? $customer?->contact_email
+ ?? $customer?->billing_email
+ ?? null;
+ }
+
private function refundableGatewayTransactions(Invoice $invoice): array
{
- if (in_array($invoice->status, ['draft', 'void', 'cancelled'], true)) {
+ if (in_array($invoice->status, ['draft', 'void', 'cancelled', 'refunded'], true)) {
return [];
}
@@ -370,13 +570,7 @@ private function refundableGatewayTransactions(Invoice $invoice): array
return [];
}
- $paymentReferences = Transaction::where('company_uuid', $invoice->company_uuid)
- ->where('context_uuid', $invoice->uuid)
- ->where('type', 'invoice_payment')
- ->whereNotNull('reference')
- ->pluck('reference')
- ->filter()
- ->values();
+ $paymentReferences = $this->invoicePaymentReferences($invoice);
$gatewayTransactions = GatewayTransaction::query()
->with('gateway')
@@ -402,9 +596,14 @@ private function refundableGatewayTransactions(Invoice $invoice): array
$paidAmount = (int) ($transaction->amount ?: $invoice->amount_paid);
$refundedAmount = (int) GatewayTransaction::where('company_uuid', $invoice->company_uuid)
->where('gateway_uuid', $transaction->gateway_uuid)
- ->where('gateway_reference_id', $transaction->gateway_reference_id)
->where('type', 'refund')
->whereNotIn('status', ['failed'])
+ ->where(function ($query) use ($transaction) {
+ $query->where('gateway_reference_id', $transaction->gateway_reference_id)
+ ->orWhere('raw_response->original_gateway_reference_id', $transaction->gateway_reference_id)
+ ->orWhere('raw_response->order_id', $transaction->gateway_reference_id)
+ ->orWhere('raw_response->data->order_id', $transaction->gateway_reference_id);
+ })
->sum('amount');
$remainingPaymentAmount = max(0, $paidAmount - $refundedAmount);
$refundableAmount = min($remainingInvoiceAmount, $remainingPaymentAmount);
@@ -438,6 +637,17 @@ private function refundableGatewayTransactions(Invoice $invoice): array
->all();
}
+ private function invoicePaymentReferences(Invoice $invoice)
+ {
+ return Transaction::where('company_uuid', $invoice->company_uuid)
+ ->where('context_uuid', $invoice->uuid)
+ ->where('type', 'invoice_payment')
+ ->whereNotNull('reference')
+ ->pluck('reference')
+ ->filter()
+ ->values();
+ }
+
// -------------------------------------------------------------------------
// Item sync helper
// -------------------------------------------------------------------------
diff --git a/server/src/Http/Controllers/Public/PublicInvoiceController.php b/server/src/Http/Controllers/Public/PublicInvoiceController.php
index faf92aa..7641151 100644
--- a/server/src/Http/Controllers/Public/PublicInvoiceController.php
+++ b/server/src/Http/Controllers/Public/PublicInvoiceController.php
@@ -9,14 +9,17 @@
use Fleetbase\Ledger\Http\Resources\v1\Gateway as GatewayResource;
use Fleetbase\Ledger\Http\Resources\v1\Invoice as InvoiceResource;
use Fleetbase\Ledger\Models\Gateway;
+use Fleetbase\Ledger\Models\GatewayTransaction;
use Fleetbase\Ledger\Models\Invoice;
use Fleetbase\Ledger\PaymentGatewayManager;
use Fleetbase\Ledger\Services\InvoiceService;
use Fleetbase\Ledger\Services\PaymentService;
+use Fleetbase\Models\Company;
use Fleetbase\Support\Utils;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
+use Milon\Barcode\Facades\DNS2DFacade as DNS2D;
/**
* Public (unauthenticated) invoice controller.
@@ -28,6 +31,7 @@
* GET /ledger/public/invoices/{public_id}
* GET /ledger/public/invoices/{public_id}/gateways
* POST /ledger/public/invoices/{public_id}/pay
+ * GET /ledger/public/refunds/{refund_id}
*/
class PublicInvoiceController extends Controller
{
@@ -134,7 +138,7 @@ public function pay(Request $request, string $publicId): JsonResponse
$invoice = $this->resolvePublicInvoice($publicId);
- if (in_array($invoice->status, ['paid', 'void', 'cancelled'])) {
+ if (in_array($invoice->status, ['paid', 'refunded', 'refund_pending', 'partial_refund_pending', 'void', 'cancelled']) || (int) $invoice->balance <= 0) {
return response()->json([
'error' => 'This invoice cannot accept payments in its current status.',
], 422);
@@ -198,6 +202,62 @@ public function pay(Request $request, string $publicId): JsonResponse
]);
}
+ // -------------------------------------------------------------------------
+ // GET /ledger/public/refunds/{refund_id}
+ // -------------------------------------------------------------------------
+
+ /**
+ * Return a public-safe GNU Taler refund handoff payload.
+ */
+ public function refund(string $refundId): JsonResponse
+ {
+ $refund = GatewayTransaction::query()
+ ->with('gateway')
+ ->where('type', 'refund')
+ ->where(function ($query) use ($refundId) {
+ $query->where('uuid', $refundId)
+ ->orWhere('public_id', $refundId)
+ ->orWhere('gateway_reference_id', $refundId);
+ })
+ ->firstOrFail();
+
+ $refundUri = $this->refundUri($refund);
+
+ if (!$refundUri) {
+ return response()->json(['error' => 'This refund does not have a customer refund URI.'], 404);
+ }
+
+ $invoice = $this->resolveRefundInvoice($refund);
+ $companyName = Company::where('uuid', $refund->company_uuid)->value('name');
+
+ return response()->json([
+ 'refund' => [
+ 'id' => $refund->public_id ?? $refund->uuid,
+ 'amount' => (int) $refund->amount,
+ 'currency' => $refund->currency ?? $invoice?->currency,
+ 'status' => $refund->status,
+ 'refund_status' => $refund->refund_status ?? data_get($refund->raw_response, 'data.refund_status'),
+ 'wallet_status' => data_get($refund->raw_response, 'data.wallet_status'),
+ 'taler_refund_uri' => $refundUri,
+ 'qr_image' => $this->qrImageForUri($refundUri),
+ 'qr_text' => $refundUri,
+ 'gateway_transaction_id' => $refund->gateway_reference_id,
+ 'created_at' => optional($refund->created_at)->toISOString(),
+ 'processed_at' => optional($refund->processed_at)->toISOString(),
+ 'refund_accepted_at' => optional($refund->refund_accepted_at)->toISOString(),
+ 'refund_expires_at' => optional($refund->refund_expires_at)->toISOString(),
+ 'invoice' => $invoice ? [
+ 'id' => $invoice->public_id,
+ 'number' => $invoice->number,
+ 'status' => $invoice->status,
+ ] : null,
+ 'company' => [
+ 'name' => $companyName,
+ ],
+ ],
+ ]);
+ }
+
private function recordManualPayment(CashDriver $driver, Invoice $invoice, Request $request): JsonResponse
{
$invoice = $this->invoiceService->recordPayment($invoice, $invoice->balance, [
@@ -334,4 +394,34 @@ private function resolvePublicInvoice(string $identifier): Invoice
->orWhere('uuid', $identifier);
})->firstOrFail();
}
+
+ private function refundUri(GatewayTransaction $transaction): ?string
+ {
+ return data_get($transaction->raw_response, 'data.taler_refund_uri')
+ ?: data_get($transaction->raw_response, 'data.refund_url')
+ ?: data_get($transaction->raw_response, 'taler_refund_uri')
+ ?: data_get($transaction->raw_response, 'refund_url');
+ }
+
+ private function resolveRefundInvoice(GatewayTransaction $refund): ?Invoice
+ {
+ $invoiceUuid = data_get($refund->raw_response, 'invoice_uuid')
+ ?: data_get($refund->raw_response, 'data.invoice_uuid')
+ ?: data_get($refund->raw_response, 'metadata.invoice_uuid');
+
+ if (!$invoiceUuid) {
+ return null;
+ }
+
+ return Invoice::where('uuid', $invoiceUuid)->orWhere('public_id', $invoiceUuid)->first();
+ }
+
+ private function qrImageForUri(string $uri): ?string
+ {
+ try {
+ return DNS2D::getBarcodePNG($uri, 'QRCODE', 8, 8);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
}
diff --git a/server/src/Listeners/HandleProcessedRefund.php b/server/src/Listeners/HandleProcessedRefund.php
index d5bf1de..6fac2f7 100644
--- a/server/src/Listeners/HandleProcessedRefund.php
+++ b/server/src/Listeners/HandleProcessedRefund.php
@@ -57,6 +57,8 @@ public function handle(RefundProcessed $event): void
$currency = $response->currency ?? $invoice?->currency ?? 'USD';
if ($amount > 0) {
+ $refundReference = $gatewayTransaction->public_id ?: $gatewayTransaction->uuid;
+
$transaction = Transaction::create([
'company_uuid' => $gateway->company_uuid,
'owner_uuid' => $invoice?->customer_uuid,
@@ -76,7 +78,7 @@ public function handle(RefundProcessed $event): void
'status' => Transaction::STATUS_SUCCESS,
'settlement_status' => data_get($response->data, 'refund_kind') === 'full' ? Transaction::SETTLEMENT_STATUS_REFUNDED : Transaction::SETTLEMENT_STATUS_PARTIALLY_REFUNDED,
'payment_method' => $gateway->driver,
- 'reference' => $response->gatewayTransactionId,
+ 'reference' => $refundReference,
'settled_at' => now(),
'settled_amount' => $amount,
'settled_currency' => $currency,
@@ -93,7 +95,7 @@ public function handle(RefundProcessed $event): void
$refundExpense,
$cashAccount,
$amount,
- sprintf('Refund issued via %s - Ref: %s', $gateway->name, $response->gatewayTransactionId),
+ sprintf('Refund issued via %s - Ref: %s', $gateway->name, $refundReference),
[
'company_uuid' => $gateway->company_uuid,
'currency' => $currency,
@@ -105,6 +107,7 @@ public function handle(RefundProcessed $event): void
'gateway_driver' => $gateway->driver,
'gateway_transaction_id' => $response->gatewayTransactionId,
'gateway_transaction_uuid' => $gatewayTransaction->uuid,
+ 'refund_reference' => $refundReference,
'invoice_uuid' => $invoice?->uuid,
'taler_refund_uri' => data_get($response->data, 'taler_refund_uri'),
],
@@ -118,11 +121,24 @@ public function handle(RefundProcessed $event): void
$previousRefunded = (int) data_get($invoice->meta, 'refunded_amount', 0);
$refundedAmount = min((int) $invoice->total_amount, $previousRefunded + $amount);
$meta = $invoice->meta ?? [];
+ $walletStatus = data_get($response->data, 'wallet_status');
+ $refundUri = data_get($response->data, 'taler_refund_uri')
+ ?: data_get($response->data, 'refund_url')
+ ?: data_get($response->rawResponse, 'taler_refund_uri')
+ ?: data_get($response->rawResponse, 'refund_url');
+ $requiresWallet = $gateway->driver === 'taler' && $walletStatus !== 'accepted';
+ $pendingAmount = (int) data_get($meta, 'pending_wallet_refund_amount', 0);
+
data_set($meta, 'refunded_amount', $refundedAmount);
data_set($meta, 'last_refund_gateway_transaction_uuid', $gatewayTransaction->uuid);
- data_set($meta, 'last_taler_refund_uri', data_get($response->data, 'taler_refund_uri'));
+ data_set($meta, 'pending_wallet_refund_amount', $requiresWallet ? min((int) $invoice->total_amount, $pendingAmount + $amount) : max(0, $pendingAmount - $amount));
+
+ if ($refundUri) {
+ data_set($meta, 'last_taler_refund_uri', $refundUri);
+ }
+
$invoice->meta = $meta;
- $invoice->status = $refundedAmount >= (int) $invoice->total_amount ? 'refunded' : 'partial';
+ $invoice->status = $this->invoiceRefundStatus($invoice, $refundedAmount, $requiresWallet);
$invoice->save();
}
@@ -156,6 +172,17 @@ private function resolveInvoiceUuid($response, $gatewayTransaction): ?string
?: data_get($gatewayTransaction->raw_response, 'data.invoice_uuid');
}
+ private function invoiceRefundStatus(Invoice $invoice, int $refundedAmount, bool $requiresWallet): string
+ {
+ $fullyRefunded = $refundedAmount >= (int) $invoice->total_amount;
+
+ if ($requiresWallet) {
+ return $fullyRefunded ? 'refund_pending' : 'partial_refund_pending';
+ }
+
+ return $fullyRefunded ? 'refunded' : 'partial';
+ }
+
private function systemAccount(string $companyUuid, string $code, string $name, string $type, string $description): Account
{
return Account::updateOrCreate(
diff --git a/server/src/Listeners/HandleSuccessfulPayment.php b/server/src/Listeners/HandleSuccessfulPayment.php
index b0c8178..fa339fd 100644
--- a/server/src/Listeners/HandleSuccessfulPayment.php
+++ b/server/src/Listeners/HandleSuccessfulPayment.php
@@ -221,6 +221,34 @@ private function resolveInvoiceUuid(
?: data_get($raw, 'data.object.metadata.invoice_uuid')
// 4. Normalised GatewayResponse data bag (driver-specific)
?: ($response->data['invoice_uuid'] ?? null)
+ // 5. Original purchase transaction for webhook-only confirmations
+ ?: $this->resolveInvoiceUuidFromPurchaseTransaction($gatewayTransaction)
?: null;
}
+
+ /**
+ * Resolve the invoice UUID from Ledger's original purchase transaction.
+ *
+ * GNU Taler's private order status response may omit custom order metadata
+ * from contract_terms, but Ledger always persists invoice_uuid when the
+ * purchase transaction is created. Webhook confirmations use the same
+ * gateway_reference_id, so this is the reliable local fallback.
+ */
+ private function resolveInvoiceUuidFromPurchaseTransaction(GatewayTransaction $gatewayTransaction): ?string
+ {
+ if (!$gatewayTransaction->gateway_reference_id) {
+ return null;
+ }
+
+ $purchaseTransaction = GatewayTransaction::query()
+ ->where('company_uuid', $gatewayTransaction->company_uuid)
+ ->where('gateway_uuid', $gatewayTransaction->gateway_uuid)
+ ->where('gateway_reference_id', $gatewayTransaction->gateway_reference_id)
+ ->where('type', 'purchase')
+ ->latest()
+ ->first();
+
+ return data_get($purchaseTransaction?->raw_response, 'invoice_uuid')
+ ?: data_get($purchaseTransaction?->raw_response, 'data.invoice_uuid');
+ }
}
diff --git a/server/src/Notifications/RefundUriAvailable.php b/server/src/Notifications/RefundUriAvailable.php
new file mode 100644
index 0000000..997a4c9
--- /dev/null
+++ b/server/src/Notifications/RefundUriAvailable.php
@@ -0,0 +1,126 @@
+invoice->loadMissing(['customer', 'order.trackingNumber']);
+ $companyName = $this->companyName();
+
+ return (new MailMessage())
+ ->from(config('mail.from.address'), $companyName)
+ ->subject('Refund available for invoice ' . $this->invoiceNumber())
+ ->view('ledger::mail.refund-uri-available', [
+ 'companyName' => $companyName,
+ 'companyLogoUrl' => $this->companyLogoUrl(),
+ 'customerName' => $this->customerName(),
+ 'invoiceNumber' => $this->invoiceNumber(),
+ 'orderLabel' => $this->orderLabel(),
+ 'refundAmount' => $this->formatMoney($this->refund->amount, strtoupper((string) ($this->refund->currency ?: $this->invoice->currency ?: 'USD'))),
+ 'refundUrl' => $this->refundUrl(),
+ 'refundUri' => $this->refundUri,
+ 'invoiceUrl' => $this->invoiceUrl(),
+ 'issuedAt' => $this->formatDate($this->refund->created_at),
+ ]);
+ }
+
+ protected function invoiceNumber(): string
+ {
+ return $this->invoice->number ?: $this->invoice->public_id;
+ }
+
+ protected function companyName(): string
+ {
+ return $this->company()?->name ?: 'Your service provider';
+ }
+
+ protected function companyLogoUrl(): ?string
+ {
+ return $this->company()?->logo_url;
+ }
+
+ protected function customerName(): ?string
+ {
+ $customer = $this->invoice->customer;
+
+ return $customer?->name
+ ?? $customer?->display_name
+ ?? $customer?->email
+ ?? null;
+ }
+
+ protected function orderLabel(): ?string
+ {
+ $order = $this->invoice->order;
+
+ return $order?->tracking_number
+ ?? $order?->trackingNumber?->tracking_number
+ ?? $order?->public_id
+ ?? $order?->uuid;
+ }
+
+ protected function invoiceUrl(): string
+ {
+ return Utils::consoleUrl('~/invoice', [
+ 'id' => $this->invoice->public_id,
+ ]);
+ }
+
+ protected function refundUrl(): string
+ {
+ return Utils::consoleUrl('~/taler-refund', [
+ 'id' => $this->refund->public_id ?? $this->refund->uuid,
+ ]);
+ }
+
+ protected function formatMoney($amount, string $currency): string
+ {
+ return $currency . ' ' . number_format(((int) $amount) / 100, 2);
+ }
+
+ protected function formatDate($date): ?string
+ {
+ if (!$date) {
+ return null;
+ }
+
+ return Carbon::parse($date)->format('M j, Y H:i');
+ }
+
+ protected function company(): ?Company
+ {
+ if ($this->company === null) {
+ $this->company = Company::where('uuid', $this->invoice->company_uuid)->first();
+ }
+
+ return $this->company;
+ }
+}
diff --git a/server/src/Providers/LedgerServiceProvider.php b/server/src/Providers/LedgerServiceProvider.php
index 9e9aa77..ec949cb 100644
--- a/server/src/Providers/LedgerServiceProvider.php
+++ b/server/src/Providers/LedgerServiceProvider.php
@@ -13,6 +13,7 @@
use Fleetbase\Ledger\Services\LedgerService;
use Fleetbase\Ledger\Services\PaymentService;
use Fleetbase\Ledger\Services\RevenueLifecycleService;
+use Fleetbase\Ledger\Services\TalerRefundVerificationService;
use Fleetbase\Ledger\Services\WalletService;
use Fleetbase\Providers\CoreServiceProvider;
use Fleetbase\Services\TemplateRenderService;
@@ -67,6 +68,7 @@ public function register()
$this->app->singleton(WalletService::class);
$this->app->singleton(InvoiceService::class);
$this->app->singleton(RevenueLifecycleService::class);
+ $this->app->singleton(TalerRefundVerificationService::class);
// Payment gateway system
// The PaymentGatewayManager is bound as a singleton and also aliased
@@ -115,8 +117,16 @@ public function boot()
\Fleetbase\Ledger\Console\Commands\UpdateOverdueInvoices::class,
\Fleetbase\Ledger\Console\Commands\RepairRevenueLifecycle::class,
\Fleetbase\Ledger\Console\Commands\VerifyTalerSettlements::class,
+ \Fleetbase\Ledger\Console\Commands\VerifyTalerRefunds::class,
\Fleetbase\Ledger\Console\Commands\TalerSandboxE2E::class,
]);
+
+ $this->scheduleCommands(function ($schedule) {
+ $schedule->command('ledger:taler:verify-refunds')
+ ->everyFifteenMinutes()
+ ->name('ledger-taler-verify-refunds')
+ ->withoutOverlapping();
+ });
}
}
diff --git a/server/src/Services/PaymentService.php b/server/src/Services/PaymentService.php
index 731c9ad..b63e82a 100644
--- a/server/src/Services/PaymentService.php
+++ b/server/src/Services/PaymentService.php
@@ -12,6 +12,7 @@
use Fleetbase\Ledger\Models\GatewayTransaction;
use Fleetbase\Ledger\PaymentGatewayManager;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Str;
/**
* PaymentService.
@@ -185,10 +186,23 @@ private function persistTransaction(
?string $invoiceUuid = null,
): GatewayTransaction {
try {
+ $gatewayReferenceId = $response->gatewayTransactionId;
+ $rawResponse = $response->rawResponse;
+
+ if ($type === 'refund') {
+ $rawResponse = array_merge([
+ 'original_gateway_reference_id' => $response->gatewayTransactionId,
+ ], $rawResponse);
+
+ if (GatewayTransaction::where('gateway_reference_id', $gatewayReferenceId)->where('type', $type)->where('event_type', $response->eventType)->exists()) {
+ $gatewayReferenceId .= '-refund-' . Str::lower(Str::random(8));
+ }
+ }
+
return GatewayTransaction::create([
'company_uuid' => $gateway->company_uuid,
'gateway_uuid' => $gateway->uuid,
- 'gateway_reference_id' => $response->gatewayTransactionId,
+ 'gateway_reference_id' => $gatewayReferenceId,
'type' => $type,
'event_type' => $response->eventType,
'amount' => $response->amount,
@@ -196,7 +210,7 @@ private function persistTransaction(
'status' => $response->status,
'message' => $response->message,
'raw_response' => array_merge(
- $response->rawResponse,
+ $rawResponse,
array_filter([
'invoice_uuid' => $invoiceUuid,
'data' => $response->data,
diff --git a/server/src/Services/TalerRefundVerificationService.php b/server/src/Services/TalerRefundVerificationService.php
new file mode 100644
index 0000000..00861d2
--- /dev/null
+++ b/server/src/Services/TalerRefundVerificationService.php
@@ -0,0 +1,262 @@
+where('status', 'active');
+
+ if ($companyUuid = data_get($options, 'company')) {
+ $gatewayQuery->where('company_uuid', $companyUuid);
+ }
+
+ if ($gatewayId = data_get($options, 'gateway')) {
+ $gatewayQuery->where(fn ($q) => $q->where('uuid', $gatewayId)->orWhere('public_id', $gatewayId));
+ }
+
+ $checked = 0;
+ $accepted = 0;
+ $pending = 0;
+ $errors = 0;
+ $results = [];
+ $limit = (int) data_get($options, 'limit', 100);
+
+ foreach ($gatewayQuery->get() as $gateway) {
+ $query = $this->pendingRefundQuery($gateway);
+
+ if ($refundId = data_get($options, 'refund')) {
+ $query->where(fn ($q) => $q->where('uuid', $refundId)->orWhere('public_id', $refundId)->orWhere('gateway_reference_id', $refundId));
+ }
+
+ foreach ($query->limit($limit)->get() as $refund) {
+ $result = $this->verifyRefund($refund, $gateway);
+ $results[] = $result;
+ $checked++;
+
+ if (($result['status'] ?? null) === 'accepted') {
+ $accepted++;
+ } elseif (($result['status'] ?? null) === 'pending') {
+ $pending++;
+ } else {
+ $errors++;
+ }
+ }
+ }
+
+ return compact('checked', 'accepted', 'pending', 'errors', 'results');
+ }
+
+ public function verifyRefund(GatewayTransaction $refund, ?Gateway $gateway = null): array
+ {
+ $gateway ??= $refund->gateway;
+
+ if (!$gateway || $gateway->driver !== 'taler') {
+ return $this->markVerificationError($refund, 'Refund transaction is not attached to a GNU Taler gateway.');
+ }
+
+ $orderId = $this->resolveOrderId($refund);
+
+ if (!$orderId) {
+ return $this->markVerificationError($refund, 'Unable to resolve original Taler order id for refund.');
+ }
+
+ try {
+ $driver = $this->gatewayManager->driver('taler')->initialize($gateway->decryptedConfig(), $gateway->is_sandbox);
+
+ if (!method_exists($driver, 'fetchRefundStatus')) {
+ return $this->markVerificationError($refund, 'GNU Taler driver does not support refund status polling.');
+ }
+
+ $targetAmount = $this->cumulativeRefundAmountForOrder($refund, $orderId);
+ $result = $driver->fetchRefundStatus($orderId, $targetAmount, $refund->currency);
+ $data = $result['data'] ?? [];
+ $accepted = $this->isRefundAccepted($data, $targetAmount, $refund->currency);
+
+ $this->storeVerificationResult($refund, $result, $accepted);
+ $invoice = $this->resolveInvoice($refund);
+
+ if ($invoice) {
+ $this->recalculateInvoiceRefundState($invoice);
+ }
+
+ return [
+ 'id' => $refund->public_id ?? $refund->uuid,
+ 'order_id' => $orderId,
+ 'status' => $accepted ? 'accepted' : 'pending',
+ 'http_status' => $result['http_status'] ?? null,
+ 'target_amount' => $targetAmount,
+ 'message' => $accepted ? 'Taler refund was accepted by the wallet.' : 'Taler refund is still pending wallet acceptance.',
+ ];
+ } catch (\Throwable $e) {
+ Log::channel('ledger')->warning('[Ledger/Taler] Refund verification failed.', [
+ 'gateway_transaction_uuid' => $refund->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->markVerificationError($refund, $e->getMessage());
+ }
+ }
+
+ protected function pendingRefundQuery(Gateway $gateway)
+ {
+ return GatewayTransaction::query()
+ ->where('company_uuid', $gateway->company_uuid)
+ ->where('gateway_uuid', $gateway->uuid)
+ ->where('type', 'refund')
+ ->where('status', 'succeeded')
+ ->whereNull('refund_accepted_at')
+ ->where(function ($query) {
+ $query->whereNull('refund_status')
+ ->orWhereIn('refund_status', ['wallet_uri_returned', 'backend_approved', 'pending_wallet_acceptance', 'succeeded']);
+ })
+ ->orderBy('created_at');
+ }
+
+ protected function storeVerificationResult(GatewayTransaction $refund, array $result, bool $accepted): void
+ {
+ $raw = $refund->raw_response ?? [];
+ data_set($raw, 'refund_verification', [
+ 'checked_at' => now()->toISOString(),
+ 'http_status' => $result['http_status'] ?? null,
+ 'refund_pending' => data_get($result, 'data.refund_pending'),
+ 'refund_amount' => data_get($result, 'data.refund_amount'),
+ 'refund_taken' => data_get($result, 'data.refund_taken'),
+ 'order_status' => data_get($result, 'data.order_status'),
+ ]);
+
+ if ($accepted) {
+ data_set($raw, 'data.wallet_status', 'accepted');
+ data_set($raw, 'data.refund_status', 'accepted');
+ }
+
+ $refund->refund_status = $accepted ? 'accepted' : 'pending_wallet_acceptance';
+ $refund->refund_accepted_at = $accepted ? now() : null;
+ $refund->raw_response = $raw;
+ $refund->save();
+ }
+
+ protected function markVerificationError(GatewayTransaction $refund, string $message): array
+ {
+ $raw = $refund->raw_response ?? [];
+ data_set($raw, 'refund_verification', [
+ 'checked_at' => now()->toISOString(),
+ 'error' => $message,
+ ]);
+ $refund->raw_response = $raw;
+ $refund->save();
+
+ return [
+ 'id' => $refund->public_id ?? $refund->uuid,
+ 'status' => 'error',
+ 'message' => $message,
+ ];
+ }
+
+ protected function recalculateInvoiceRefundState(Invoice $invoice): void
+ {
+ $pendingAmount = GatewayTransaction::where('company_uuid', $invoice->company_uuid)
+ ->whereHas('gateway', fn ($query) => $query->where('driver', 'taler'))
+ ->where('type', 'refund')
+ ->whereNull('refund_accepted_at')
+ ->where(function ($query) use ($invoice) {
+ $query->where('raw_response->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->data->invoice_uuid', $invoice->uuid)
+ ->orWhere('raw_response->metadata->invoice_uuid', $invoice->uuid);
+ })
+ ->sum('amount');
+
+ $refundedAmount = (int) data_get($invoice->meta, 'refunded_amount', 0);
+ $meta = $invoice->meta ?? [];
+ data_set($meta, 'pending_wallet_refund_amount', (int) $pendingAmount);
+ $invoice->meta = $meta;
+
+ if ((int) $pendingAmount <= 0) {
+ $invoice->status = $refundedAmount >= (int) $invoice->total_amount ? 'refunded' : 'partial';
+ } elseif (in_array($invoice->status, ['refund_pending', 'partial_refund_pending', 'refunded', 'partial'], true)) {
+ $invoice->status = $refundedAmount >= (int) $invoice->total_amount ? 'refund_pending' : 'partial_refund_pending';
+ }
+
+ $invoice->save();
+ }
+
+ protected function isRefundAccepted(array $data, int $amount, ?string $currency): bool
+ {
+ if (filter_var(data_get($data, 'refund_pending'), FILTER_VALIDATE_BOOLEAN)) {
+ return false;
+ }
+
+ [$takenCurrency, $takenAmount] = $this->parseTalerAmount(data_get($data, 'refund_taken'));
+
+ if ($takenAmount <= 0) {
+ return false;
+ }
+
+ if ($currency && $takenCurrency && strtoupper($currency) !== $takenCurrency) {
+ return false;
+ }
+
+ return $takenAmount >= $amount;
+ }
+
+ protected function resolveInvoice(GatewayTransaction $refund): ?Invoice
+ {
+ $invoiceUuid = data_get($refund->raw_response, 'invoice_uuid')
+ ?: data_get($refund->raw_response, 'data.invoice_uuid')
+ ?: data_get($refund->raw_response, 'metadata.invoice_uuid');
+
+ return $invoiceUuid ? Invoice::where('uuid', $invoiceUuid)->orWhere('public_id', $invoiceUuid)->first() : null;
+ }
+
+ protected function resolveOrderId(GatewayTransaction $refund): ?string
+ {
+ return data_get($refund->raw_response, 'original_gateway_reference_id')
+ ?: data_get($refund->raw_response, 'data.order_id')
+ ?: data_get($refund->raw_response, 'order_id')
+ ?: $refund->gateway_reference_id;
+ }
+
+ protected function cumulativeRefundAmountForOrder(GatewayTransaction $refund, string $orderId): int
+ {
+ return (int) GatewayTransaction::where('company_uuid', $refund->company_uuid)
+ ->where('gateway_uuid', $refund->gateway_uuid)
+ ->where('type', 'refund')
+ ->where('status', 'succeeded')
+ ->where(function ($query) use ($orderId) {
+ $query->where('gateway_reference_id', $orderId)
+ ->orWhere('raw_response->original_gateway_reference_id', $orderId)
+ ->orWhere('raw_response->order_id', $orderId)
+ ->orWhere('raw_response->data->order_id', $orderId);
+ })
+ ->where(function ($query) use ($refund) {
+ $query->where('created_at', '<', $refund->created_at)
+ ->orWhere(function ($q) use ($refund) {
+ $q->where('created_at', $refund->created_at)
+ ->where('uuid', '<=', $refund->uuid);
+ });
+ })
+ ->sum('amount');
+ }
+
+ protected function parseTalerAmount(?string $amount): array
+ {
+ if (!$amount || !preg_match('/^([A-Z]{2,8}):(\d+)(?:\.(\d{1,2}))?$/', $amount, $matches)) {
+ return [null, 0];
+ }
+
+ $fraction = str_pad($matches[3] ?? '00', 2, '0');
+
+ return [$matches[1], ((int) $matches[2] * 100) + (int) $fraction];
+ }
+}
diff --git a/server/src/routes.php b/server/src/routes.php
index 23de757..f81bb53 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -43,6 +43,7 @@ function ($router) {
$router->get('invoices/{public_id}', 'PublicInvoiceController@show');
$router->get('invoices/{public_id}/gateways', 'PublicInvoiceController@gateways');
$router->post('invoices/{public_id}/pay', 'PublicInvoiceController@pay');
+ $router->get('refunds/{refund_id}', 'PublicInvoiceController@refund');
});
/*
@@ -97,6 +98,8 @@ function ($router, $controller) {
$router->post('{id}/record-payment', $controller('recordPayment'));
$router->get('{id}/refund-options', $controller('refundOptions'));
$router->post('{id}/refund', $controller('refund'));
+ $router->post('{id}/refunds/{gatewayTransactionId}/send-refund-uri', $controller('sendRefundUri'));
+ $router->post('{id}/refunds/{gatewayTransactionId}/verify-status', $controller('verifyRefundStatus'));
$router->get('{id}/transactions', $controller('transactions'));
$router->post('{id}/mark-as-sent', $controller('markAsSent'));
$router->post('{id}/send', $controller('send'));
@@ -144,6 +147,7 @@ function ($router, $controller) {
// to avoid being swallowed by the /{id} find route.
$router->get('gateways/drivers', 'GatewayController@drivers');
$router->get('gateways/summary', 'GatewayController@summary');
+ $router->post('gateways/test-credentials', 'GatewayController@testDraftCredentials');
$router->fleetbaseRoutes(
'gateways',
diff --git a/server/tests/FeatureTest.php b/server/tests/FeatureTest.php
index 3ed0063..ba45a16 100644
--- a/server/tests/FeatureTest.php
+++ b/server/tests/FeatureTest.php
@@ -220,15 +220,22 @@
expect($routes)
->toContain("'gateways/summary'")
+ ->toContain("\$router->post('gateways/test-credentials', 'GatewayController@testDraftCredentials');")
->toContain("'{id}/test-credentials'")
->toContain("'{id}/create-test-order'")
->toContain("'{id}/register-webhook'")
- ->toContain("'{id}/diagnostics'");
+ ->toContain("'{id}/diagnostics'")
+ ->toContain("\$router->post('gateways/test-credentials', 'GatewayController@testDraftCredentials');\n\n \$router->fleetbaseRoutes");
expect($controller)
->toContain('public function summary')
->toContain("'webhook_warnings'")
->toContain("'last_payment_at'")
+ ->toContain('public function testDraftCredentials')
+ ->toContain("'driver' => ['required', 'string']")
+ ->toContain("'config' => ['required', 'array']")
+ ->toContain("->driver(\$validated['driver'])")
+ ->toContain('sanitizeProviderResult($driver->testCredentials())')
->toContain('public function testCredentials')
->toContain('public function createTestOrder')
->toContain('public function registerWebhook')
@@ -240,6 +247,7 @@
->toContain("'last_webhook_registration_at'")
->toContain("'last_test_order_at'")
->toContain("'last_test_order_id'")
+ ->toContain("'metadata' => \$result['metadata'] ?? null")
->not->toContain("'credential_status' => 'not_checked'");
expect($gateway)
@@ -351,6 +359,7 @@
$newTemplate = file_get_contents(__DIR__ . '/../../addon/templates/payments/gateways/new.hbs');
$editTemplate = file_get_contents(__DIR__ . '/../../addon/templates/payments/gateways/edit.hbs');
$editController = file_get_contents(__DIR__ . '/../../addon/controllers/payments/gateways/edit.js');
+ $formComponent = file_get_contents(__DIR__ . '/../../addon/components/gateway/form.js');
$formTemplate = file_get_contents(__DIR__ . '/../../addon/components/gateway/form.hbs');
expect($newTemplate)
@@ -374,6 +383,24 @@
->toContain('Routing And Status')
->toContain('Review Gateway Setup');
+ expect($formComponent)
+ ->toContain('this.args.initialDriverCode')
+ ->toContain('shouldReplaceWebhookUrl')
+ ->toContain("resource.set?.('webhook_url', driver.webhook_url)")
+ ->toContain('this.loadSchema.perform(driver.code, true)')
+ ->toContain('get canTestCredentials()')
+ ->toContain("const endpoint = this.args.resource?.id ? `gateways/\${this.args.resource.id}/test-credentials` : 'gateways/test-credentials'")
+ ->toContain('driver: this.args.resource?.driver')
+ ->toContain('environment: this.args.resource?.environment ??')
+ ->toContain('config: this.configValues')
+ ->not->toContain('Save this gateway before testing credentials')
+ ->not->toContain('this.availableDrivers[0]');
+
+ expect($formTemplate)
+ ->toContain('class="whitespace-nowrap"')
+ ->toContain('@disabled={{or this.testCredentials.isRunning (not this.canTestCredentials)}}')
+ ->not->toContain('(not @resource.id)');
+
expect($detailsComponent)
->toContain('Provider Status')
->toContain('Recent Gateway Activity')
@@ -403,44 +430,80 @@
});
test('invoice refund workflow routes controller and ui are registered', function () {
- $routes = file_get_contents(__DIR__ . '/../src/routes.php');
- $authSchema = file_get_contents(__DIR__ . '/../src/Auth/Schemas/Ledger.php');
- $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/InvoiceController.php');
- $ui = file_get_contents(__DIR__ . '/../../addon/controllers/billing/invoices/index/details.js');
- $modal = file_get_contents(__DIR__ . '/../../addon/components/modals/issue-refund.hbs');
- $result = file_get_contents(__DIR__ . '/../../addon/components/modals/refund-result.hbs');
- $modalReexport = file_get_contents(__DIR__ . '/../../app/components/modals/issue-refund.js');
- $resultReexport = file_get_contents(__DIR__ . '/../../app/components/modals/refund-result.js');
+ $routes = file_get_contents(__DIR__ . '/../src/routes.php');
+ $authSchema = file_get_contents(__DIR__ . '/../src/Auth/Schemas/Ledger.php');
+ $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/InvoiceController.php');
+ $paymentService = file_get_contents(__DIR__ . '/../src/Services/PaymentService.php');
+ $ui = file_get_contents(__DIR__ . '/../../addon/controllers/billing/invoices/index/details.js');
+ $modal = file_get_contents(__DIR__ . '/../../addon/components/modals/issue-refund.hbs');
+ $result = file_get_contents(__DIR__ . '/../../addon/components/modals/refund-result.hbs');
+ $history = file_get_contents(__DIR__ . '/../../addon/components/modals/refund-history.hbs');
+ $handoff = file_get_contents(__DIR__ . '/../../addon/components/customer-taler-refund.hbs');
+ $handoffJs = file_get_contents(__DIR__ . '/../../addon/components/customer-taler-refund.js');
+ $extension = file_get_contents(__DIR__ . '/../../addon/extension.js');
+ $modalReexport = file_get_contents(__DIR__ . '/../../app/components/modals/issue-refund.js');
+ $resultReexport = file_get_contents(__DIR__ . '/../../app/components/modals/refund-result.js');
+ $historyReexport = file_get_contents(__DIR__ . '/../../app/components/modals/refund-history.js');
+ $handoffReexport = file_get_contents(__DIR__ . '/../../app/components/customer-taler-refund.js');
+ $listener = file_get_contents(__DIR__ . '/../src/Listeners/HandleProcessedRefund.php');
+ $publicInvoice = file_get_contents(__DIR__ . '/../src/Http/Controllers/Public/PublicInvoiceController.php');
+ $notification = file_get_contents(__DIR__ . '/../src/Notifications/RefundUriAvailable.php');
+ $mailTemplate = file_get_contents(__DIR__ . '/../resources/views/mail/refund-uri-available.blade.php');
+ $invoiceModel = file_get_contents(__DIR__ . '/../../addon/models/ledger-invoice.js');
+ $customerInvoice = file_get_contents(__DIR__ . '/../../addon/components/customer-invoice.js');
expect($routes)
->toContain("'{id}/refund-options'")
- ->toContain("'{id}/refund'");
+ ->toContain("'{id}/refund'")
+ ->toContain("'{id}/refunds/{gatewayTransactionId}/send-refund-uri'")
+ ->toContain("'refunds/{refund_id}'");
expect($authSchema)
->toContain("'refund'")
- ->toContain("'refund-options'");
+ ->toContain("'refund-options'")
+ ->toContain("'send-refund-uri'");
expect($controller)
->toContain('public function refundOptions')
->toContain('public function refund(string $id, Request $request, PaymentService $paymentService)')
+ ->toContain('public function sendRefundUri')
+ ->toContain("'refunds' => \$this->refundGatewayTransactions(\$invoice)")
+ ->toContain('serializeRefundGatewayTransaction')
+ ->toContain('resolveRefundGatewayTransaction')
+ ->toContain('new RefundUriAvailable($invoice, $refund, $uri)')
+ ->toContain('refundHandoffUrl')
+ ->toContain("'refund_handoff_url'")
+ ->toContain("'taler_refund_uri' => \$refundUri")
+ ->toContain('invoicePaymentReferences')
->toContain('refundableGatewayTransactions')
->toContain('invoiceRemainingRefundableAmount')
+ ->toContain('raw_response->original_gateway_reference_id')
+ ->toContain('raw_response->data->order_id')
->toContain('new RefundRequest(')
->toContain("'refund_kind'")
->toContain('$refundKind')
->toContain("'data' => \$response->data");
+ expect($paymentService)
+ ->toContain("'original_gateway_reference_id' => \$response->gatewayTransactionId")
+ ->toContain("\$gatewayReferenceId .= '-refund-' . Str::lower(Str::random(8))");
+
expect($ui)
->toContain('Issue Refund')
->toContain('invoices/${invoice.id}/refund-options')
->toContain('invoices/${invoice.id}/refund')
+ ->toContain("'paid', 'refunded', 'refund_pending', 'partial_refund_pending', 'void', 'cancelled'")
->toContain('confirmRefund(invoice, options, selected, modal)')
->toContain('this.modalsManager.confirm')
->toContain('Confirm Refund')
->toContain('refundInvoice(invoice, options)')
->toContain('showRefundResult')
- ->toContain('responseData.taler_refund_uri ?? responseData.refund_url')
- ->toContain('this.hostRouter.refresh()');
+ ->toContain('response.refund?.refund_url ?? responseData.refund_url ?? responseData.taler_refund_uri')
+ ->toContain('this.hostRouter.refresh()')
+ ->toContain('View Refunds')
+ ->toContain('modals/refund-history')
+ ->toContain('@tracked refundRows = []')
+ ->toContain('sendRefundUri(invoice, refund, email)');
expect($modal)
->toContain('Remaining refundable')
@@ -450,15 +513,85 @@
->toContain('toContain('Taler Refund URI')
+ ->toContain('Customer Refund Link')
+ ->toContain('Wallet URI')
+ ->toContain('toContain('Gateway refund transaction')
+ ->toContain('mb-4 rounded-md')
+ ->toContain('Send Email')
+ ->not->toContain('@type="text" class="w-full form-input" readonly={{true}}');
+
+ expect($history)
+ ->toContain('Customer Email')
+ ->toContain('toContain('Send Email')
+ ->toContain('refund.taler_refund_uri')
+ ->toContain('No refund URI has been issued');
+
+ expect($handoff)
+ ->toContain('GNU Taler Refund')
+ ->toContain('Open GNU Taler Wallet')
->toContain('toContain('Gateway refund transaction');
+ ->toContain('this.qrImageSrc')
+ ->toContain('href={{this.talerRefundUri}}');
+
+ expect($handoffJs)
+ ->toContain('this.fetch.get(`refunds/${this.refundId}`')
+ ->toContain('meta[name="taler-support"]')
+ ->toContain('meta[name="taler-uri"]')
+ ->toContain("support.content = 'uri'")
+ ->toContain('uri.content = this.talerRefundUri');
+
+ expect($extension)
+ ->toContain("slug: 'taler-refund'")
+ ->toContain("new ExtensionComponent('@fleetbase/ledger-engine', 'customer-taler-refund')");
expect($modalReexport)
->toContain('@fleetbase/ledger-engine/components/modals/issue-refund');
expect($resultReexport)
->toContain('@fleetbase/ledger-engine/components/modals/refund-result');
+
+ expect($historyReexport)
+ ->toContain('@fleetbase/ledger-engine/components/modals/refund-history');
+
+ expect($handoffReexport)
+ ->toContain('@fleetbase/ledger-engine/components/customer-taler-refund');
+
+ expect($listener)
+ ->toContain('$refundReference = $gatewayTransaction->public_id ?: $gatewayTransaction->uuid')
+ ->toContain("'reference' => \$refundReference")
+ ->toContain("'refund_reference' => \$refundReference")
+ ->toContain("'pending_wallet_refund_amount'")
+ ->toContain('invoiceRefundStatus')
+ ->toContain("\$gateway->driver === 'taler'")
+ ->toContain("'refund_pending'")
+ ->toContain("'partial_refund_pending'")
+ ->not->toContain("\$invoice->status = \$refundedAmount >= (int) \$invoice->total_amount ? 'refunded' : 'partial'");
+
+ expect($publicInvoice)
+ ->toContain('public function refund(string $refundId)')
+ ->toContain("'taler_refund_uri'")
+ ->toContain("'qr_image'")
+ ->toContain("'refund_pending'")
+ ->toContain("'partial_refund_pending'")
+ ->toContain('(int) $invoice->balance <= 0');
+
+ expect($notification)
+ ->toContain("Utils::consoleUrl('~/taler-refund'")
+ ->toContain("'refundUrl'");
+
+ expect($mailTemplate)
+ ->toContain('{{ $refundUrl }}')
+ ->toContain('{{ $refundUri }}');
+
+ expect($invoiceModel)
+ ->toContain('partial_refund_pending')
+ ->toContain('refund_pending');
+
+ expect($customerInvoice)
+ ->toContain("'refund_pending'")
+ ->toContain("'partial_refund_pending'");
});
test('taler webhook unresolved routing is audited', function () {
@@ -471,6 +604,80 @@
->toContain('Taler webhook could not resolve an active gateway.');
});
+test('taler refund verification command scheduler and ui are registered', function () {
+ $routes = file_get_contents(__DIR__ . '/../src/routes.php');
+ $authSchema = file_get_contents(__DIR__ . '/../src/Auth/Schemas/Ledger.php');
+ $controller = file_get_contents(__DIR__ . '/../src/Http/Controllers/Internal/v1/InvoiceController.php');
+ $provider = file_get_contents(__DIR__ . '/../src/Providers/LedgerServiceProvider.php');
+ $command = file_get_contents(__DIR__ . '/../src/Console/Commands/VerifyTalerRefunds.php');
+ $service = file_get_contents(__DIR__ . '/../src/Services/TalerRefundVerificationService.php');
+ $driver = file_get_contents(__DIR__ . '/../src/Gateways/TalerDriver.php');
+ $ui = file_get_contents(__DIR__ . '/../../addon/controllers/billing/invoices/index/details.js');
+ $history = file_get_contents(__DIR__ . '/../../addon/components/modals/refund-history.hbs');
+
+ expect($routes)
+ ->toContain("'{id}/refunds/{gatewayTransactionId}/verify-status'");
+
+ expect($authSchema)
+ ->toContain("'verify-refund-status'");
+
+ expect($controller)
+ ->toContain('public function verifyRefundStatus')
+ ->toContain('TalerRefundVerificationService $verifier')
+ ->toContain('resolveRefundGatewayTransaction($invoice, $gatewayTransactionId)')
+ ->toContain('$verifier->verifyRefund($refund)');
+
+ expect($provider)
+ ->toContain('TalerRefundVerificationService::class')
+ ->toContain('VerifyTalerRefunds::class')
+ ->toContain("command('ledger:taler:verify-refunds')")
+ ->toContain('everyFifteenMinutes()')
+ ->toContain('withoutOverlapping()');
+
+ expect($command)
+ ->toContain('ledger:taler:verify-refunds')
+ ->toContain('{--refund=')
+ ->toContain('verifyPending')
+ ->toContain('Refund verification complete');
+
+ expect($service)
+ ->toContain('class TalerRefundVerificationService')
+ ->toContain('public function verifyPending')
+ ->toContain('public function verifyRefund')
+ ->toContain('protected function pendingRefundQuery')
+ ->toContain('fetchRefundStatus($orderId, $targetAmount, $refund->currency)')
+ ->toContain('cumulativeRefundAmountForOrder')
+ ->toContain('refund_taken')
+ ->toContain("'pending_wallet_acceptance'")
+ ->toContain("'accepted'");
+
+ expect($driver)
+ ->toContain('fetchOrderStatus(string $orderId, array $query = [])')
+ ->toContain('fetchRefundStatus')
+ ->toContain("'await_refund_obtained' => 'yes'")
+ ->toContain("'timeout_ms'")
+ ->toContain('$pending->get($url, $payload)');
+
+ expect($ui)
+ ->toContain('verifyRefundStatus(invoice, refund)')
+ ->toContain('verify-status')
+ ->toContain('Refund status verified.');
+
+ expect($history)
+ ->toContain('Verify Status')
+ ->toContain('this.verifyRefundStatus');
+});
+
+test('successful payment listener can resolve taler invoice from purchase transaction fallback', function () {
+ $listener = file_get_contents(__DIR__ . '/../src/Listeners/HandleSuccessfulPayment.php');
+
+ expect($listener)
+ ->toContain('resolveInvoiceUuidFromPurchaseTransaction')
+ ->toContain("->where('type', 'purchase')")
+ ->toContain("data_get(\$purchaseTransaction?->raw_response, 'invoice_uuid')")
+ ->toContain("data_get(\$purchaseTransaction?->raw_response, 'data.invoice_uuid')");
+});
+
test('taler settlement and e2e commands are registered', function () {
$provider = file_get_contents(__DIR__ . '/../src/Providers/LedgerServiceProvider.php');
$settlementCommand = file_get_contents(__DIR__ . '/../src/Console/Commands/VerifyTalerSettlements.php');
diff --git a/server/tests/Gateways/TalerDriverTest.php b/server/tests/Gateways/TalerDriverTest.php
index 96ecc86..700ee91 100644
--- a/server/tests/Gateways/TalerDriverTest.php
+++ b/server/tests/Gateways/TalerDriverTest.php
@@ -55,6 +55,13 @@ function talerDriver(array $config = [], bool $sandbox = false): TalerDriver
return $driver;
}
+function talerAuthorizationHeader($httpRequest): ?string
+{
+ $header = $httpRequest->headers()['Authorization'] ?? null;
+
+ return is_array($header) ? ($header[0] ?? null) : $header;
+}
+
// ---------------------------------------------------------------------------
// Driver metadata
// ---------------------------------------------------------------------------
@@ -356,6 +363,33 @@ function talerDriver(array $config = [], bool $sandbox = false): TalerDriver
->and($response->data['invoice_uuid'])->toBe('invoice-uuid-abc');
});
+test('handleWebhook_uses_contract_amount_when_deposit_total_is_zero', function () {
+ fakeTalerHttp([
+ 'https://backend.example.taler.net/instances/testmerchant/private/orders/TALER-ORDER-DEPOSIT-ZERO' => Http::response(
+ [
+ 'order_status' => 'paid',
+ 'deposit_total' => 'KUDOS:0',
+ 'contract_terms' => [
+ 'amount' => 'KUDOS:0.5',
+ 'summary' => 'Invoice TALER-DEMO-20A32F23F9DA',
+ ],
+ ],
+ 200
+ ),
+ ]);
+
+ $request = Request::create('/ledger/webhooks/taler', 'POST', [
+ 'order_id' => 'TALER-ORDER-DEPOSIT-ZERO',
+ ]);
+
+ $response = talerDriver()->handleWebhook($request);
+
+ expect($response->isSuccessful())->toBeTrue()
+ ->and($response->eventType)->toBe(GatewayResponse::EVENT_PAYMENT_SUCCEEDED)
+ ->and($response->amount)->toBe(50)
+ ->and($response->currency)->toBe('KUDOS');
+});
+
// ---------------------------------------------------------------------------
// handleWebhook() — failure paths
// ---------------------------------------------------------------------------
@@ -551,6 +585,61 @@ function talerDriver(array $config = [], bool $sandbox = false): TalerDriver
->and($result['http_status'])->toBe(200);
});
+test('testCredentials_sends_secret_token_as_bearer_token', function () {
+ fakeTalerHttp([
+ 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response(['orders' => []], 200),
+ ]);
+
+ talerDriver(['api_token' => 'secret-token:abc'])->testCredentials();
+
+ Http::assertSent(fn ($httpRequest) => talerAuthorizationHeader($httpRequest) === 'Bearer secret-token:abc');
+});
+
+test('testCredentials_accepts_pasted_bearer_secret_token', function () {
+ fakeTalerHttp([
+ 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response(['orders' => []], 200),
+ ]);
+
+ talerDriver(['api_token' => 'Bearer secret-token:abc'])->testCredentials();
+
+ Http::assertSent(fn ($httpRequest) => talerAuthorizationHeader($httpRequest) === 'Bearer secret-token:abc');
+});
+
+test('testCredentials_trims_token_whitespace', function () {
+ fakeTalerHttp([
+ 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response(['orders' => []], 200),
+ ]);
+
+ talerDriver(['api_token' => ' Bearer secret-token:abc '])->testCredentials();
+
+ Http::assertSent(fn ($httpRequest) => talerAuthorizationHeader($httpRequest) === 'Bearer secret-token:abc');
+});
+
+test('testCredentials_returns_sanitized_taler_failure_metadata', function () {
+ fakeTalerHttp([
+ 'https://backend.example.taler.net/instances/testmerchant/private/orders' => Http::response([
+ 'code' => 2000,
+ 'hint' => 'token does not grant access to this instance',
+ 'detail' => 'wrong instance',
+ 'request_uid' => 'req-123',
+ ], 403),
+ ]);
+
+ $result = talerDriver(['api_token' => 'secret-token:abc'])->testCredentials();
+
+ expect($result['ok'])->toBeFalse()
+ ->and($result['status'])->toBe('failed')
+ ->and($result['http_status'])->toBe(403)
+ ->and($result['metadata']['backend_url'])->toBe('https://backend.example.taler.net')
+ ->and($result['metadata']['instance_id'])->toBe('testmerchant')
+ ->and($result['metadata']['http_status'])->toBe(403)
+ ->and($result['metadata']['taler_error_code'])->toBe(2000)
+ ->and($result['metadata']['hint'])->toBe('token does not grant access to this instance')
+ ->and($result['metadata']['detail'])->toBe('wrong instance')
+ ->and($result['metadata']['request_uid'])->toBe('req-123')
+ ->and($result)->not->toHaveKey('raw_response');
+});
+
test('registerWebhook_posts_tenant_safe_body_template', function () {
fakeTalerHttp([
'https://backend.example.taler.net/instances/testmerchant/private/webhooks' => Http::response([], 204),
@@ -572,7 +661,8 @@ function talerDriver(array $config = [], bool $sandbox = false): TalerDriver
return str_contains($template, 'company-uuid-1')
&& str_contains($template, 'gateway_public_1')
- && str_contains($template, '${ORDER_ID}');
+ && str_contains($template, '{{ order_id }}')
+ && str_contains($template, '{{ webhook_type }}');
});
});