Compare commits
2 commits
master
...
fix/swish-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f849f8a05a | ||
|
|
573153b47a |
4 changed files with 86 additions and 7 deletions
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
52
frontend/src/__tests__/payment.spec.ts
Normal file
52
frontend/src/__tests__/payment.spec.ts
Normal file
|
|
@ -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/?',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue