From 573153b47ab9180b431ac160e2ac9ecc8e4cc56e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 16:22:27 +0000 Subject: [PATCH] fix(payment): make Swish QR code scannable by the Swish app The Swish QR on the payment page could not be scanned by the Swish app in production. The QR encoded the correct C2B pre-fill URL (verified against https://developer.swish.nu and the live /payment/swish-info endpoint returning the real Swish number), and the Swish app does support scanning C2B pre-fill QR codes per the "Swish C2B flow with QR code" guide - so the failure was in the QR *rendering*, not the URL or approach. Root cause: the qrcode options used a 2-module quiet zone (margin: 2), half the ISO/IEC 18004 minimum of 4 modules. The Swish app's scanner is stricter than a phone camera and failed to lock onto the finder patterns, especially when scanning the QR off a screen. Compounded by an off-black fill (#111827 vs pure black; the Swish spec says "black and white") and small ~5px modules at width 224. Changes: - PaymentRedirect.vue: QR options margin 2->4, dark #111827->#000000, width 224->288, explicit errorCorrectionLevel 'M'; .payment__qr-img CSS width/height 224->288 to match. - PaymentRedirect.vue: isolate QR generation in its own try/catch so a QR failure degrades gracefully (Swish link + manual fallback remain) instead of surfacing the "Kunde inte ladda betalningsinformation" error from the shared fetchSwishInfo catch. - payment.ts: normalizeSwishNumber strips a leading "+" (+46... -> 46...), so a number stored in international-with-plus form no longer leaks a "+" into the sw param. - PaymentRedirect.spec.ts: regression assertion that toDataURL is called with margin 4, errorCorrectionLevel 'M', and pure black/white. Verified locally: eslint clean on the 3 files, 269/269 vitest tests pass, vue-tsc clean for changed files (the lone tsc error on this machine is the unrelated untracked useSeo.ts WIP, not committed). --- .../src/__tests__/PaymentRedirect.spec.ts | 10 +++++++ frontend/src/api/payment.ts | 5 +++- frontend/src/pages/PaymentRedirect.vue | 26 ++++++++++++++----- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/frontend/src/__tests__/PaymentRedirect.spec.ts b/frontend/src/__tests__/PaymentRedirect.spec.ts index b13a395..dad5ea9 100644 --- a/frontend/src/__tests__/PaymentRedirect.spec.ts +++ b/frontend/src/__tests__/PaymentRedirect.spec.ts @@ -112,6 +112,16 @@ describe('PaymentRedirect', () => { expect(wrapper.find('.payment__qr-img').exists()).toBe(true) }) expect(mockToDataURL).toHaveBeenCalledTimes(1) + // Regression guard: the QR must use a spec-compliant 4-module quiet zone + // and pure black-on-white so the Swish app can scan it off a screen. + expect(mockToDataURL).toHaveBeenCalledWith( + expect.stringContaining('app.swish.nu'), + expect.objectContaining({ + margin: 4, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, + }), + ) }) it('renders a Swish payment link', async () => { diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 7d930e6..7a48541 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -48,9 +48,12 @@ export function buildSwishPaymentUrl( * - 123… (Swish Business number) → unchanged * - 46… (already international) → unchanged * - 0… (Swedish national format) → 46 + rest without leading 0 + * - +46… (international with plus) → 46… (the plus is stripped first) */ function normalizeSwishNumber(number: string): string { - const trimmed = number.replace(/\s/g, '') + // Strip whitespace and a leading "+": a number stored as "+46 70 …" would + // otherwise miss every prefix check and leak a "+" into the `sw` param. + const trimmed = number.replace(/[\s+]/g, '') if (trimmed.startsWith('123')) return trimmed if (trimmed.startsWith('46')) return trimmed if (trimmed.startsWith('0')) return '46' + trimmed.slice(1) diff --git a/frontend/src/pages/PaymentRedirect.vue b/frontend/src/pages/PaymentRedirect.vue index d85fcaa..059a3a8 100644 --- a/frontend/src/pages/PaymentRedirect.vue +++ b/frontend/src/pages/PaymentRedirect.vue @@ -27,16 +27,30 @@ onMounted(async () => { const info = await fetchSwishInfo() swishNumber.value = info.number swishAmount.value = info.amount + } catch { + error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.' + return + } + // QR generation is best-effort and isolated from fetchSwishInfo: if the QR + // library throws, the Swish payment link and manual fallback still render + // instead of surfacing a misleading "could not load payment info" error. + try { if (swishPaymentUrl.value) { qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, { - width: 224, - margin: 2, - color: { dark: '#111827', light: '#ffffff' }, + // Swish requires a reliably scannable black-on-white QR. The previous + // settings (margin 2, #111827, 224px) produced a 2-module quiet zone + // — half the QR spec minimum — which the Swish app's scanner fails to + // read when scanning off a screen. Use the spec-compliant 4-module + // quiet zone, pure black, and larger modules. + width: 288, + margin: 4, + errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#ffffff' }, }) } } catch { - error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.' + // ignored: payment link + manual fallback remain usable } }) @@ -239,8 +253,8 @@ async function confirmPayment() { } .payment__qr-img { - width: 224px; - height: 224px; + width: 288px; + height: 288px; border-radius: var(--radius-md); margin: 0 auto var(--space-sm); }