Skip to content
Merged
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
59 changes: 55 additions & 4 deletions addon/components/customer-invoice.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@
</div>
{{/if}}

{{#if this.pendingMessage}}
<div class="rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 p-6 flex items-start gap-4 mb-6">
<FaIcon @icon="clock" class="text-blue-500 mt-0.5 shrink-0" />
<p class="text-blue-700 dark:text-blue-300 font-medium">{{this.pendingMessage}}</p>
</div>
{{/if}}

{{! ── Payment section ──────────────────────────────────────────── }}
{{#if this.canAcceptPayment}}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
Expand Down Expand Up @@ -180,11 +187,55 @@
/>
</div>

{{! Redirecting to Stripe overlay }}
{{! Redirecting to payment provider overlay }}
{{#if this.isRedirectingToCheckout}}
<div class="flex flex-col items-center justify-center py-6 gap-3 text-indigo-600 dark:text-indigo-400">
<FaIcon @icon="spinner" @spin={{true}} @size="lg" />
<p class="text-sm font-medium">Redirecting to secure payment page…</p>
<p class="text-sm font-medium">Redirecting to payment provider...</p>
</div>
{{else if this.hasTalerPaymentUri}}
<div class="rounded-xl border border-indigo-200 dark:border-indigo-700 bg-indigo-50 dark:bg-indigo-900/20 p-4 flex flex-col gap-3">
<div class="flex flex-col sm:flex-row items-start gap-4">
{{#if this.paymentQrImageSrc}}
<div class="shrink-0 w-40 h-40 rounded-lg border border-white/70 dark:border-indigo-700 bg-white p-2 flex items-center justify-center">
<img src={{this.paymentQrImageSrc}} alt="GNU Taler payment QR code" class="w-full h-full object-contain" />
</div>
{{/if}}
<div class="min-w-0 flex-1">
<div class="flex items-start gap-3">
<FaIcon @icon="wallet" class="text-indigo-500 mt-0.5" />
<div>
<p class="text-sm font-semibold text-indigo-800 dark:text-indigo-200">GNU Taler payment ready</p>
<p class="text-sm text-indigo-700 dark:text-indigo-300 mt-1">
{{#if this.paymentQrImageSrc}}
Open your Taler wallet or scan the QR code with a wallet on another device.
{{else}}
Open your Taler wallet from this browser, or copy the payment URI into a wallet.
{{/if}}
</p>
</div>
</div>
{{#unless this.paymentQrImageSrc}}
<div class="mt-3 rounded-md border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
QR code unavailable for this payment response.
</div>
{{/unless}}
{{#if this.paymentQrText}}
<div class="mt-3 rounded-md bg-white/70 dark:bg-gray-900/30 border border-indigo-100 dark:border-indigo-800 px-3 py-2">
<p class="font-mono text-[11px] leading-4 text-indigo-700 dark:text-indigo-300 break-all">{{this.paymentQrText}}</p>
</div>
{{/if}}
</div>
</div>
<div class="flex items-center justify-end gap-3">
<a
href={{this.talerPaymentUri}}
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>
{{else}}
{{! Submit }}
Expand All @@ -199,8 +250,8 @@
@type="primary"
@icon={{if this.submitPayment.isRunning "spinner" "lock"}}
@iconSpin={{this.submitPayment.isRunning}}
@text={{if this.submitPayment.isRunning "Processing" (if this.isStripeGateway "Pay with Stripe" "Confirm Payment")}}
@disabled={{or this.submitPayment.isRunning (not this.selectedGatewayId)}}
@text={{if this.submitPayment.isRunning "Processing..." (if this.isStripeGateway "Pay with Stripe" "Continue to Payment")}}
@disabled={{this.submitPayment.isRunning}}
@onClick={{perform this.submitPayment}}
/>
</div>
Expand Down
89 changes: 83 additions & 6 deletions addon/components/customer-invoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,23 @@ export default class CustomerInvoiceComponent extends Component {
@tracked paymentReference = '';
@tracked error = null;
@tracked successMessage = null;
@tracked pendingMessage = null;
@tracked isRedirectingToCheckout = false;
@tracked talerPaymentUri = null;
@tracked paymentQrImage = null;
@tracked paymentQrText = null;

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

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

// ── Getters ───────────────────────────────────────────────────────────────

get invoiceId() {
Expand Down Expand Up @@ -72,6 +82,22 @@ export default class CustomerInvoiceComponent extends Component {
return this.selectedGateway?.driver === 'stripe';
}

get hasTalerPaymentUri() {
return typeof this.talerPaymentUri === 'string' && this.talerPaymentUri.startsWith('taler');
}

get paymentQrImageSrc() {
if (typeof this.paymentQrImage !== 'string' || this.paymentQrImage.length === 0) {
return null;
}

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

return `data:image/png;base64,${this.paymentQrImage}`;
}

// ── Tasks ─────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -133,9 +159,8 @@ export default class CustomerInvoiceComponent extends Component {
/**
* Submits a payment request.
*
* For Stripe: backend returns { checkout_url } and the browser is redirected
* to Stripe's hosted checkout page. isRedirectingToCheckout is set to true
* to show a loading state while the redirect happens.
* For redirect-capable gateways: backend returns { payment_url } or
* { checkout_url } and the browser is redirected to the hosted/wallet flow.
*
* For other gateways: backend records the payment immediately and returns
* the updated invoice.
Expand All @@ -159,10 +184,28 @@ export default class CustomerInvoiceComponent extends Component {
{ namespace: 'ledger/public' }
);

// Stripe Checkout Session — redirect the browser to Stripe's hosted page
if (data?.checkout_url) {
const paymentUrl = data?.payment_url ?? data?.payment_uri ?? data?.checkout_url ?? data?.data?.taler_pay_uri;
this.paymentQrImage = data?.qr_image ?? data?.data?.qr_image ?? null;
this.paymentQrText = data?.qr_text ?? data?.data?.qr_text ?? paymentUrl ?? null;

if (this.isTalerUri(paymentUrl)) {
this.talerPaymentUri = paymentUrl;
this.isRedirectingToCheckout = false;
this.pendingMessage = data?.message ?? 'Payment started. Open your GNU Taler wallet to complete it.';
return;
}

// Redirect payment sessions — Stripe Checkout, QPay app link, etc.
if (paymentUrl) {
this.isRedirectingToCheckout = true;
window.location.href = data.checkout_url;
this.pendingMessage = data?.message ?? 'Redirecting to payment provider...';
window.location.href = paymentUrl;
return;
}

if (data?.payment_status === 'pending') {
this.pendingMessage = data?.message ?? 'Payment started. Complete it in your payment app, then refresh this invoice.';
this.showPaymentForm = false;
return;
}

Expand All @@ -180,6 +223,10 @@ export default class CustomerInvoiceComponent extends Component {
@action togglePaymentForm() {
this.showPaymentForm = !this.showPaymentForm;
this.successMessage = null;
this.pendingMessage = null;
this.talerPaymentUri = null;
this.paymentQrImage = null;
this.paymentQrText = null;
this.error = null;
}

Expand All @@ -190,4 +237,34 @@ export default class CustomerInvoiceComponent extends Component {
@action updateReference(event) {
this.paymentReference = event.target.value;
}

isTalerUri(value) {
return typeof value === 'string' && (value.startsWith('taler://') || value.startsWith('taler+http://') || value.startsWith('taler+https://'));
}

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

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

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

meta.content = 'uri';
}

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

const meta = document.querySelector('meta[name="taler-support"][data-ledger-taler-support="true"]');
meta?.remove();
}
}
2 changes: 2 additions & 0 deletions docker/taler/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
generated-token.txt
*.local
45 changes: 45 additions & 0 deletions docker/taler/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
FROM debian:trixie-slim

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
jq \
postgresql-client \
procps \
wget \
&& install -d -m 0755 /etc/apt/keyrings \
&& wget -O /etc/apt/keyrings/taler-systems.gpg https://taler.net/taler-systems.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/taler-systems.gpg] https://deb.taler.net/apt/debian trixie main" > /etc/apt/sources.list.d/taler.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
libeufin-bank \
taler-exchange \
taler-exchange-database \
taler-exchange-offline \
taler-harness \
taler-merchant \
taler-wallet-cli \
&& rm -rf /var/lib/apt/lists/*

RUN install -d -m 0755 /etc/taler /var/lib/fleetbase-ledger/taler

COPY docker/taler/taler-local.conf /etc/taler/taler-local.conf
COPY docker/taler/entrypoint.sh /usr/local/bin/fleetbase-taler-entrypoint
COPY docker/taler/init-instance.sh /usr/local/bin/fleetbase-taler-init-instance
COPY docker/taler/init-stack.sh /usr/local/bin/fleetbase-taler-init-stack
COPY docker/taler/create-withdrawal.sh /usr/local/bin/fleetbase-taler-create-withdrawal
COPY docker/taler/smoke-test.sh /usr/local/bin/fleetbase-taler-smoke-test

RUN chmod +x \
/usr/local/bin/fleetbase-taler-entrypoint \
/usr/local/bin/fleetbase-taler-init-instance \
/usr/local/bin/fleetbase-taler-init-stack \
/usr/local/bin/fleetbase-taler-create-withdrawal \
/usr/local/bin/fleetbase-taler-smoke-test

EXPOSE 8081 8082 9966

ENTRYPOINT ["fleetbase-taler-entrypoint"]
109 changes: 109 additions & 0 deletions docker/taler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Fleetbase Ledger GNU Taler local stack

This directory owns the Docker artifacts for Ledger's local GNU Taler E2E test
stack. It runs matching local services instead of mixing a local merchant
backend with the public demo exchange:

- libeufin bank: `http://taler-bank.lvh.me:8082`
- Taler exchange: `http://taler-exchange.lvh.me:8081`
- Taler merchant backend: `http://taler-merchant.lvh.me:9966`

Start the stack from the Fleetbase repo root:

```sh
docker compose up -d --build taler-merchant application queue scheduler httpd console
```

After startup, use the generated merchant token in the Ledger Taler gateway
config:

```sh
cat packages/ledger/docker/taler/generated-token.txt
```

Use these local gateway values:

```txt
backend_url=http://taler-merchant.lvh.me:9966
instance_id=default
api_token=<exact contents of generated-token.txt>
```

## Validate the Taler stack

Run the built-in smoke test:

```sh
docker compose exec taler-merchant fleetbase-taler-smoke-test
```

Or check the services manually:

```sh
curl http://taler-bank.lvh.me:8082/config
curl http://taler-exchange.lvh.me:8081/keys
curl http://taler-merchant.lvh.me:9966/config
```

The merchant logs should not show `Failed to download .../keys` or `Could not
decode /keys response`. Those errors were symptoms of using the public demo
exchange with a local merchant package.

## Demo KUDOS invoice fixture

Fleetbase does not expose `KUDOS` as a normal currency option. Ledger provides
a dedicated local fixture so the Taler wallet flow can be tested without making
KUDOS a business currency.

Run Ledger migrations and seed the demo invoice from the Fleetbase repo root:

```sh
docker compose exec application php artisan migrate
docker compose exec application php artisan db:seed --class="Fleetbase\\Ledger\\Seeders\\Testing\\TalerDemoSeeder"
```

To seed for a specific company:

```sh
docker compose exec -e TALER_DEMO_COMPANY_UUID=<company_uuid> application php artisan db:seed --class="Fleetbase\\Ledger\\Seeders\\Testing\\TalerDemoSeeder"
```

The seeder creates an idempotent FleetOps-style payload, order, service quote,
purchase rate, tracking number, core transaction, transaction items, and sent
Ledger invoice in `KUDOS`. The order should appear in FleetOps Orders as
`TALER-DEMO-KUDOS`. Open the seeded public payment link shown by the seeder:

```txt
/~/invoice?id=<invoice_public_id>
```

## Wallet notes

The local bank suggests the local exchange to wallets. To add KUDOS to the GNU
Taler browser wallet, create a bank withdrawal operation:

```sh
docker compose exec taler-merchant fleetbase-taler-create-withdrawal KUDOS:5.00
```

Open the normal HTTP page printed by the command:

```txt
http://taler-bank.lvh.me:8082/webui/#/operation/<withdrawal_id>
```

Then click the wallet action from that page. Do not paste the raw
`taler+http://withdraw/...` URI into the browser address bar; browser extensions
generally intercept Taler wallet links from supported pages, not direct address
bar navigation.

The fake bank admin credentials are:

```txt
Username: admin
Password: admin-password
```

If your wallet blocks plain HTTP, use the wallet's development/test setting that
allows unsafe HTTP for local Taler services, or use `taler-wallet-cli --no-http`
for CLI testing.
Loading