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/__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/?', + ) + }) +}) 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); }