Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addon/components/customer-invoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default class CustomerInvoiceComponent extends Component {
}

get isPaid() {
return this.invoice?.status === 'paid';
return ['paid', 'refunded', 'refund_pending', 'partial_refund_pending'].includes(this.invoice?.status);
}

get isVoid() {
Expand Down
61 changes: 61 additions & 0 deletions addon/components/customer-taler-refund.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<div class="customer-invoice-page min-h-screen bg-gray-50 px-4 py-10 dark:bg-gray-900">
<div class="mx-auto max-w-2xl pt-10">
{{#if this.loadRefund.isRunning}}
<div class="flex flex-col items-center justify-center py-24 text-gray-500 dark:text-gray-400">
<FaIcon @icon="spinner" @spin={{true}} @size="2x" class="mb-4" />
<p class="text-sm">Loading refund...</p>
</div>
{{else if this.error}}
<div class="rounded-xl border border-red-200 bg-red-50 p-8 text-center dark:border-red-700 dark:bg-red-900/20">
<FaIcon @icon="triangle-exclamation" @size="2x" class="mb-4 text-red-400" />
<p class="font-medium text-red-700 dark:text-red-300">{{this.error}}</p>
</div>
{{else if this.refund}}
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div class="bg-indigo-600 px-8 py-5">
<p class="mb-1 text-xs font-semibold uppercase tracking-widest text-indigo-200">GNU Taler Refund</p>
<h1 class="text-2xl font-bold text-white">{{format-currency this.refund.amount this.refund.currency}}</h1>
</div>

<div class="space-y-5 px-8 py-6">
<div>
<p class="text-sm font-semibold text-gray-900 dark:text-white">Open this refund with your GNU Taler wallet.</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
This refund is for invoice
{{n-a this.refund.invoice.number}}.
If your wallet does not open automatically, scan the QR code or copy the wallet URI.
</p>
</div>

{{#if this.qrImageSrc}}
<div class="flex justify-center">
<div class="h-48 w-48 rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700">
<img src={{this.qrImageSrc}} alt="GNU Taler refund QR code" class="h-full w-full object-contain" />
</div>
</div>
{{/if}}

<div class="flex min-w-0 items-center justify-between gap-3 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900/40">
<ClickToCopy @value={{this.talerRefundUri}} class="min-w-0 flex-1">
<span class="block truncate font-mono text-xs text-blue-600 dark:text-blue-400">{{this.talerRefundUri}}</span>
</ClickToCopy>
<ClickToCopy @value={{this.talerRefundUri}} class="flex-shrink-0">
<span class="text-xs font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400">Copy</span>
</ClickToCopy>
</div>

<div class="flex items-center justify-end gap-3">
<Button @icon="copy" @text="Copy URI" @size="sm" @type="default" @onClick={{this.copyRefundUri}} />
<a
href={{this.talerRefundUri}}
class="inline-flex items-center justify-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
<FaIcon @icon="wallet" class="mr-2" />
Open GNU Taler Wallet
</a>
</div>
</div>
</div>
{{/if}}
</div>
</div>
118 changes: 118 additions & 0 deletions addon/components/customer-taler-refund.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';

export default class CustomerTalerRefundComponent extends Component {
@service fetch;
@service notifications;
@service urlSearchParams;

@tracked refund = null;
@tracked error = null;

constructor() {
super(...arguments);
this.installTalerSupportMeta();
this.loadRefund.perform();
}

willDestroy() {
super.willDestroy(...arguments);
this.removeTalerSupportMeta();
}

get refundId() {
return this.urlSearchParams.get('id');
}

get talerRefundUri() {
return this.refund?.taler_refund_uri;
}

get qrImageSrc() {
const qrImage = this.refund?.qr_image;

if (typeof qrImage !== 'string' || qrImage.length === 0) {
return null;
}

if (qrImage.startsWith('data:') || qrImage.startsWith('http://') || qrImage.startsWith('https://')) {
return qrImage;
}

return `data:image/png;base64,${qrImage}`;
}

@task({ restartable: true })
*loadRefund() {
this.error = null;

if (!this.refundId) {
this.error = 'No refund identifier provided. Please check the link and try again.';
return;
}

try {
const result = yield this.fetch.get(`refunds/${this.refundId}`, {}, { namespace: 'ledger/public' });
this.refund = result?.refund ?? result;
this.updateTalerUriMeta();
} catch (error) {
const status = error?.status ?? error?.response?.status;
this.error = status === 404 ? 'Refund not found. Please check the link and try again.' : (error?.message ?? 'Failed to load refund. Please try again later.');
}
}

@action copyRefundUri() {
if (!this.talerRefundUri) {
return;
}

navigator.clipboard?.writeText(this.talerRefundUri);
this.notifications.success('Refund URI copied.');
}

installTalerSupportMeta() {
if (typeof document === 'undefined') {
return;
}

let support = document.querySelector('meta[name="taler-support"]');

if (!support) {
support = document.createElement('meta');
support.name = 'taler-support';
support.dataset.ledgerTalerSupport = 'true';
document.head.appendChild(support);
}

support.content = 'uri';
this.updateTalerUriMeta();
}

updateTalerUriMeta() {
if (typeof document === 'undefined' || !this.talerRefundUri) {
return;
}

let uri = document.querySelector('meta[name="taler-uri"]');

if (!uri) {
uri = document.createElement('meta');
uri.name = 'taler-uri';
uri.dataset.ledgerTalerSupport = 'true';
document.head.appendChild(uri);
}

uri.content = this.talerRefundUri;
}

removeTalerSupportMeta() {
if (typeof document === 'undefined') {
return;
}

document.querySelectorAll('meta[data-ledger-taler-support="true"]').forEach((meta) => meta.remove());
}
}
2 changes: 1 addition & 1 deletion addon/components/gateway/details.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
{{/if}}
</div>
<Button
class="w-full whitespace-nowrap md:w-44 md:flex-shrink-0"
class="inline-flex flex-shrink-0 whitespace-nowrap"
@size="sm"
@icon={{gatewayAction.icon}}
@iconSpin={{gatewayAction.isLoading}}
Expand Down
12 changes: 11 additions & 1 deletion addon/components/gateway/form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,17 @@
</div>
</div>

<Button @icon="plug" @text={{if (eq this.connectionState "success") "Test Again" "Test Credentials"}} @size="xs" @isLoading={{this.testCredentials.isRunning}} @disabled={{or this.testCredentials.isRunning (not @resource.id)}} @onClick={{perform this.testCredentials}} />
<div class="flex flex-shrink-0 justify-end">
<Button
@icon="plug"
@text={{if (eq this.connectionState "success") "Test Again" "Test Credentials"}}
@size="xs"
@isLoading={{this.testCredentials.isRunning}}
@disabled={{or this.testCredentials.isRunning (not this.canTestCredentials)}}
@onClick={{perform this.testCredentials}}
class="whitespace-nowrap"
/>
</div>
</div>
</div>
</div>
Expand Down
86 changes: 60 additions & 26 deletions addon/components/gateway/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export default class GatewayFormComponent extends Component {
return 'idle';
}

return this.connectionTestResult.success ? 'success' : 'failed';
return (this.connectionTestResult.success ?? this.connectionTestResult.ok) ? 'success' : 'failed';
}

get connectionStateTitle() {
Expand All @@ -94,17 +94,13 @@ export default class GatewayFormComponent extends Component {
case 'failed':
return 'Gateway test failed';
default:
return this.args.resource?.id ? 'Ready to verify' : 'Save to verify';
return this.canTestCredentials ? 'Ready to verify' : 'Complete credentials to verify';
}
}

get connectionStateMessage() {
if (!this.args.resource?.id) {
return 'Create the gateway first, then run live credential checks and webhook registration from the gateway diagnostics screen.';
}

if (this.connectionState === 'idle') {
return 'Run a credential test before using this gateway for invoice payments.';
return this.args.resource?.id ? 'Run a credential test before using this gateway for invoice payments.' : 'Test the entered provider credentials before creating this gateway.';
}

if (this.connectionState === 'testing') {
Expand All @@ -114,6 +110,10 @@ export default class GatewayFormComponent extends Component {
return this.connectionTestResult?.message ?? 'The gateway credential check completed.';
}

get canTestCredentials() {
return Boolean(this.args.resource?.driver) && this.hasRequiredConfig;
}

get connectionMetadataEntries() {
return Object.entries(this.connectionTestResult?.metadata ?? {}).map(([key, value]) => ({
label: key.replaceAll('_', ' '),
Expand All @@ -136,8 +136,8 @@ export default class GatewayFormComponent extends Component {

if (this.args.resource?.driver) {
yield this.loadSchema.perform(this.args.resource.driver);
} else {
const initialDriver = this.args.initialDriverCode ? this.availableDrivers.find((driver) => driver.code === this.args.initialDriverCode) : this.availableDrivers[0];
} else if (this.args.initialDriverCode) {
const initialDriver = this.availableDrivers.find((driver) => driver.code === this.args.initialDriverCode);
if (initialDriver) {
this.selectDriver(initialDriver);
}
Expand All @@ -147,7 +147,7 @@ export default class GatewayFormComponent extends Component {
}
}

@task *loadSchema(driverCode) {
@task *loadSchema(driverCode, syncResourceConfig = false) {
yield Promise.resolve();
const driver = this.availableDrivers.find((d) => d.code === driverCode);
this.configSchema = driver?.config_schema ?? [];
Expand All @@ -160,13 +160,8 @@ export default class GatewayFormComponent extends Component {
});
this.configValues = values;

// Default webhook_url to the system-computed handler URL when not already set.
// driver.webhook_url is the full URL returned by the backend (e.g. https://api.example.com/ledger/webhooks/stripe).
// We never fall back to a relative path here — if the manifest does not include a full URL yet,
// the user can copy it from the "System webhook URL" hint shown below the field.
const resource = this.args.resource;
if (resource && !resource.webhook_url && driver?.webhook_url) {
resource.webhook_url = driver.webhook_url;
if (syncResourceConfig) {
this.args.resource?.set?.('config', values);
}
}

Expand All @@ -175,17 +170,47 @@ export default class GatewayFormComponent extends Component {
return;
}

this.args.resource.set?.('driver', driver.code);
this.args.resource.set?.('name', this.args.resource?.name || driver.name);
this.args.resource.set?.('code', this.args.resource?.code || driver.code);
// Sync the driver's capabilities to the resource so they are persisted
const resource = this.args.resource;
const previousDriver = this.selectedDriver;

resource.set?.('driver', driver.code);

if (!resource.name || resource.name === previousDriver?.name) {
resource.set?.('name', driver.name);
}

if (!resource.code || resource.code === previousDriver?.code) {
resource.set?.('code', driver.code);
}

if (this.shouldReplaceWebhookUrl(resource.webhook_url, previousDriver, driver)) {
resource.set?.('webhook_url', driver.webhook_url);
}

if (driver.capabilities) {
this.args.resource.set?.('capabilities', driver.capabilities);
resource.set?.('capabilities', driver.capabilities);
}
this.loadSchema.perform(driver.code);

this.loadSchema.perform(driver.code, true);
this.connectionTestResult = null;
}

shouldReplaceWebhookUrl(currentWebhookUrl, previousDriver, nextDriver) {
if (!nextDriver?.webhook_url) {
return false;
}

if (!currentWebhookUrl) {
return true;
}

if (currentWebhookUrl === previousDriver?.webhook_url) {
return true;
}

return this.availableDrivers.some((driver) => driver.code !== nextDriver.code && driver.webhook_url === currentWebhookUrl);
}

@action updateConfigField(key, value) {
// When bound via {{on "input" (fn this.updateConfigField field.key)}} the second
// argument is a DOM InputEvent, not the raw string value. Extract the actual
Expand All @@ -198,13 +223,22 @@ export default class GatewayFormComponent extends Component {
}

@task *testCredentials() {
if (!this.args.resource?.id) {
this.notifications.info('Save this gateway before testing credentials.');
if (!this.canTestCredentials) {
this.notifications.info('Choose a gateway and complete the required credentials before testing.');
return;
}

try {
this.connectionTestResult = yield this.fetch.post(`gateways/${this.args.resource.id}/test-credentials`, {}, { namespace: 'ledger/int/v1' });
const endpoint = this.args.resource?.id ? `gateways/${this.args.resource.id}/test-credentials` : 'gateways/test-credentials';
const payload = this.args.resource?.id
? {}
: {
driver: this.args.resource?.driver,
environment: this.args.resource?.environment ?? 'sandbox',
config: this.configValues,
};

this.connectionTestResult = yield this.fetch.post(endpoint, payload, { namespace: 'ledger/int/v1' });
} catch (error) {
this.connectionTestResult = {
success: false,
Expand Down
2 changes: 1 addition & 1 deletion addon/components/invoice/form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<InputGroup @name="Status">
<div class="fleetbase-model-select fleetbase-power-select ember-model-select">
<PowerSelect
@options={{array "draft" "sent" "viewed" "partial" "paid" "refunded" "overdue" "cancelled"}}
@options={{array "draft" "sent" "viewed" "partial" "paid" "partial_refund_pending" "refund_pending" "refunded" "overdue" "cancelled"}}
@selected={{@resource.status}}
@onChange={{fn (mut @resource.status)}}
@placeholder="Select status"
Expand Down
Loading
Loading