fix(payment): make Swish QR code scannable by the Swish app
Some checks failed
CI / Lint, type check, unit tests, coverage (pull_request) Failing after 2h17m43s
CI / E2E browser tests (pull_request) Successful in 4m4s

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).
This commit is contained in:
Hermes Agent 2026-06-19 16:22:27 +00:00
parent 1a9d2fe688
commit 573153b47a
3 changed files with 34 additions and 7 deletions

View file

@ -112,6 +112,16 @@ describe('PaymentRedirect', () => {
expect(wrapper.find('.payment__qr-img').exists()).toBe(true) expect(wrapper.find('.payment__qr-img').exists()).toBe(true)
}) })
expect(mockToDataURL).toHaveBeenCalledTimes(1) 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 () => { it('renders a Swish payment link', async () => {

View file

@ -48,9 +48,12 @@ export function buildSwishPaymentUrl(
* - 123 (Swish Business number) unchanged * - 123 (Swish Business number) unchanged
* - 46 (already international) unchanged * - 46 (already international) unchanged
* - 0 (Swedish national format) 46 + rest without leading 0 * - 0 (Swedish national format) 46 + rest without leading 0
* - +46 (international with plus) 46 (the plus is stripped first)
*/ */
function normalizeSwishNumber(number: string): string { 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('123')) return trimmed
if (trimmed.startsWith('46')) return trimmed if (trimmed.startsWith('46')) return trimmed
if (trimmed.startsWith('0')) return '46' + trimmed.slice(1) if (trimmed.startsWith('0')) return '46' + trimmed.slice(1)

View file

@ -27,16 +27,30 @@ onMounted(async () => {
const info = await fetchSwishInfo() const info = await fetchSwishInfo()
swishNumber.value = info.number swishNumber.value = info.number
swishAmount.value = info.amount 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) { if (swishPaymentUrl.value) {
qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, { qrDataUrl.value = await QRCode.toDataURL(swishPaymentUrl.value, {
width: 224, // Swish requires a reliably scannable black-on-white QR. The previous
margin: 2, // settings (margin 2, #111827, 224px) produced a 2-module quiet zone
color: { dark: '#111827', light: '#ffffff' }, // 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 { } 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 { .payment__qr-img {
width: 224px; width: 288px;
height: 224px; height: 288px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin: 0 auto var(--space-sm); margin: 0 auto var(--space-sm);
} }