From 573153b47ab9180b431ac160e2ac9ecc8e4cc56e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 16:22:27 +0000 Subject: [PATCH 1/2] 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); } -- 2.45.2 From f849f8a05acc687f916e9627402496bee0253458 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 19:44:07 +0000 Subject: [PATCH 2/2] test(payment): add unit tests for buildSwishPaymentUrl and number normalisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR added + stripping to normalizeSwishNumber and the PaymentRedirect regression assertion verifies QR options, but payment.ts had no dedicated test file — coverage was only 50% (only the payOrder path exercised via mocks in PaymentRedirect.spec.ts). This adds a focused spec covering every normalisation branch (Swedish national, international, + prefix, Swish Business, whitespace) and URL construction (amount formatting, message encoding, base URL). Coverage for payment.ts rises from 50% to ~90%. Why: jocke pointed out that CI was failing on this PR and that I should always verify CI passes before considering work done. Investigation showed all frontend steps pass locally (lint, vue-tsc, 277/277 tests, coverage). The 2h17m CI failure appears to be a transient runner issue in the backend-coverage step (backend code is unchanged from master, which passes CI; E2E also passes). This commit re-triggers CI and fills the + stripping test gap noticed during the investigation. Changes: - Add frontend/src/__tests__/payment.spec.ts (8 tests): - Number normalisation: Swedish national (07xx), international (4670xx), + prefix stripping, Swish Business (123xx), whitespace removal - URL construction: amount with two decimal places, message URL-encoding, correct Swish C2B base URL - payment.ts statement coverage: 50% to ~90% - Total frontend tests: 269 to 277 --- frontend/src/__tests__/payment.spec.ts | 52 ++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 frontend/src/__tests__/payment.spec.ts diff --git a/frontend/src/__tests__/payment.spec.ts b/frontend/src/__tests__/payment.spec.ts new file mode 100644 index 0000000..fd1cd25 --- /dev/null +++ b/frontend/src/__tests__/payment.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { buildSwishPaymentUrl } from '@/api/payment' + +describe('buildSwishPaymentUrl', () => { + it('normalises Swedish national format to international', () => { + expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain( + 'sw=46701234567', + ) + }) + + it('strips a leading + from international format', () => { + const url = buildSwishPaymentUrl('+46701234567', 49, 'test') + expect(url).toContain('sw=46701234567') + expect(url).not.toContain('sw=%2B') + expect(url).not.toContain('sw=+') + }) + + it('leaves already-international numbers unchanged', () => { + expect(buildSwishPaymentUrl('46701234567', 49, 'test')).toContain( + 'sw=46701234567', + ) + }) + + it('leaves Swish Business numbers (123…) unchanged', () => { + expect(buildSwishPaymentUrl('1234567890', 49, 'test')).toContain( + 'sw=1234567890', + ) + }) + + it('strips whitespace from the number', () => { + expect(buildSwishPaymentUrl('070 123 45 67', 49, 'test')).toContain( + 'sw=46701234567', + ) + }) + + it('includes the amount with two decimal places in amt', () => { + expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain( + 'amt=49.00', + ) + }) + + it('URL-encodes the message in the msg parameter', () => { + const url = buildSwishPaymentUrl('0701234567', 49, 'ABC 123') + expect(url).toContain('msg=ABC+123') + }) + + it('uses the correct Swish C2B base URL', () => { + expect(buildSwishPaymentUrl('0701234567', 49, 'test')).toContain( + 'https://app.swish.nu/1/p/sw/?', + ) + }) +}) -- 2.45.2