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); }