From e87b1ba2c03ef38ceec994724c31b1332d51d110 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 26 Jul 2026 21:38:27 +0800 Subject: [PATCH] Complete GNU Taler refund lifecycle --- addon/components/customer-invoice.js | 2 +- addon/components/customer-taler-refund.hbs | 61 ++++ addon/components/customer-taler-refund.js | 118 ++++++++ addon/components/gateway/details.hbs | 2 +- addon/components/gateway/form.hbs | 12 +- addon/components/gateway/form.js | 86 ++++-- addon/components/invoice/form.hbs | 2 +- addon/components/modals/refund-history.hbs | 69 +++++ addon/components/modals/refund-history.js | 27 ++ addon/components/modals/refund-result.hbs | 43 ++- addon/components/modals/refund-result.js | 30 +- addon/controllers/billing/invoices/index.js | 2 + .../billing/invoices/index/details.js | 109 +++++++- addon/extension.js | 17 ++ addon/models/ledger-invoice.js | 2 + app/components/customer-taler-refund.js | 1 + app/components/modals/refund-history.js | 1 + .../views/mail/refund-uri-available.blade.php | 84 ++++++ server/src/Auth/Schemas/Ledger.php | 2 +- .../Console/Commands/VerifyTalerRefunds.php | 46 +++ server/src/Gateways/TalerDriver.php | 126 +++++++-- .../Internal/v1/GatewayController.php | 32 +++ .../Internal/v1/InvoiceController.php | 228 ++++++++++++++- .../Public/PublicInvoiceController.php | 92 +++++- .../src/Listeners/HandleProcessedRefund.php | 35 ++- .../src/Listeners/HandleSuccessfulPayment.php | 28 ++ .../src/Notifications/RefundUriAvailable.php | 126 +++++++++ .../src/Providers/LedgerServiceProvider.php | 10 + server/src/Services/PaymentService.php | 18 +- .../TalerRefundVerificationService.php | 262 ++++++++++++++++++ server/src/routes.php | 4 + server/tests/FeatureTest.php | 237 +++++++++++++++- server/tests/Gateways/TalerDriverTest.php | 92 +++++- 33 files changed, 1906 insertions(+), 100 deletions(-) create mode 100644 addon/components/customer-taler-refund.hbs create mode 100644 addon/components/customer-taler-refund.js create mode 100644 addon/components/modals/refund-history.hbs create mode 100644 addon/components/modals/refund-history.js create mode 100644 app/components/customer-taler-refund.js create mode 100644 app/components/modals/refund-history.js create mode 100644 server/resources/views/mail/refund-uri-available.blade.php create mode 100644 server/src/Console/Commands/VerifyTalerRefunds.php create mode 100644 server/src/Notifications/RefundUriAvailable.php create mode 100644 server/src/Services/TalerRefundVerificationService.php diff --git a/addon/components/customer-invoice.js b/addon/components/customer-invoice.js index 02a75a1..64e8d03 100644 --- a/addon/components/customer-invoice.js +++ b/addon/components/customer-invoice.js @@ -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() { diff --git a/addon/components/customer-taler-refund.hbs b/addon/components/customer-taler-refund.hbs new file mode 100644 index 0000000..5f88b74 --- /dev/null +++ b/addon/components/customer-taler-refund.hbs @@ -0,0 +1,61 @@ +
+
+ {{#if this.loadRefund.isRunning}} +
+ +

Loading refund...

+
+ {{else if this.error}} +
+ +

{{this.error}}

+
+ {{else if this.refund}} +
+
+

GNU Taler Refund

+

{{format-currency this.refund.amount this.refund.currency}}

+
+ +
+
+

Open this refund with your GNU Taler wallet.

+

+ 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. +

+
+ + {{#if this.qrImageSrc}} +
+
+ GNU Taler refund QR code +
+
+ {{/if}} + +
+ + {{this.talerRefundUri}} + + + Copy + +
+ + +
+
+ {{/if}} +
+
diff --git a/addon/components/customer-taler-refund.js b/addon/components/customer-taler-refund.js new file mode 100644 index 0000000..7fc8a56 --- /dev/null +++ b/addon/components/customer-taler-refund.js @@ -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()); + } +} diff --git a/addon/components/gateway/details.hbs b/addon/components/gateway/details.hbs index a9c4379..4668ca6 100644 --- a/addon/components/gateway/details.hbs +++ b/addon/components/gateway/details.hbs @@ -65,7 +65,7 @@ {{/if}}