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).
This commit is contained in:
parent
1a9d2fe688
commit
573153b47a
3 changed files with 34 additions and 7 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue