feat(payment): Swish QR code and pre-filled payment link #15
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/swish-qr-payment"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
The current payment flow asks customers to manually type the Swish number and order ID into the Swish app — friction-heavy and error-prone. Personal phone number as the Swish recipient also looks unprofessional.
What this does
Replaces the manual flow with Swish's C2B pre-fill URL scheme (
app.swish.nu). No Swish Commerce API certificate or bank agreement required — just a URL format that pre-fills amount and message.Desktop users
A QR code is generated client-side (via
qrcodenpm package). Customer scans it with the Swish app — everything is pre-filled.Mobile users
A "Betala med Swish" link opens the Swish app directly with amount and message pre-filled.
Fallback
Manual number display + "Jag har betalat" confirmation button are kept as fallback.
Changes
frontend/src/api/payment.ts— newbuildSwishPaymentUrl()constructs the pre-filled Swish URL, with number normalization (070... → 46..., 123... business numbers pass through)frontend/src/pages/PaymentRedirect.vue— QR code for desktop, payment link for mobile, manual fallback retainedfrontend/src/__tests__/PaymentRedirect.spec.ts— updated for new flow, 13 tests covering QR visibility, link href, fallbackfrontend/e2e/payment-redirect.spec.ts— E2E tests for QR code and link validationfrontend/package.json— addedqrcode+@types/qrcode.env.example— documented Swish number formats (phone vs 123-business number)Setup
Set
SWISH_NUMBERin.envto a Swish Företag business number (e.g.123XXXXXXXX). The normalization handles both phone format and business number format.Limitations
This is still the honor system — no automatic payment verification. The user clicks "I've paid" and the order moves to processing. For verified payments, a future phase would add Swish Commerce API (requires bank agreement + mTLS certificate) or Stripe (currently paused for Swish on new businesses).
Test plan
The tests fails on ci
Root cause
The new E2E test
shows QR code for desktop scanningused plate JKL012 — the same plate seeded as a processing order inV7__seed_processing_order.sqland used byadmin-dashboard.spec.tsandadmin-fulfillment.spec.tsasPROCESSING_PLATE.Because the
chromium(parallel) tests run before thechromium-serialtests, the QR test created a secondJKL012order in the database. When the serial admin tests then searched for rows matchingJKL012, Playwright found 2 matching rows instead of 1 — triggering a strict mode violation.This caused 4 failures + 2 skipped tests (all admin tests, not payment tests):
Fix
Changed the plate from
JKL012→QRA222— not used in any seed data or other E2E test.Verification
Full E2E suite run locally via
docker compose -f docker-compose.e2e.yml up:Frontend unit tests (269/269), lint, type check, and coverage also verified clean.
Advisory review — looks good with one warning
Solid implementation of the Swish pre-fill URL scheme. URL construction uses
URLSearchParamsfor proper encoding, the manual fallback is retained, and the E2E tests use unique plates to avoid collisions. A few items below.⚠️ Warnings
1. QR generation failure hides the entire payment UI (
PaymentRedirect.vue)QRCode.toDataURL()is inside the sametry/catchasfetchSwishInfo(). If QR generation throws (e.g. the URL is too long for QR capacity, or a transient library error), the catch setserror.value = 'Kunde inte ladda betalningsinformation…'— even though the Swish info was fetched fine. Thev-if="error"block shows, and the entirev-if="!showConfirmation"payment section is still rendered but the error message is misleading. More importantly, theqrDataUrlstays empty so the QR won't show, but the manual fallback does still render (it's outside the error guard). So the real impact is a confusing error message, not a total block.Fix: wrap the QR generation in its own try/catch so a QR failure degrades gracefully — no QR code, no error message, manual fallback still visible.
💡 Suggestions
2.
normalizeSwishNumberdoesn't handle+46prefix (payment.ts)A number stored as
+46701234567(common international format) doesn't match any of the three prefix checks (123,46,0) and passes through unchanged. The+then gets URL-encoded to%2BbyURLSearchParams, which may break the Swish URL. Consider stripping a leading+before the prefix checks, e.g.const trimmed = number.replace(/[\s+]/g, '').3. No unit tests for
normalizeSwishNumber/buildSwishPaymentUrl(payment.ts)The normalization logic has 4 branches but is only exercised indirectly through component tests that mock
buildSwishPaymentUrl. Edge cases like+46…,0046…, and business numbers should be tested directly in apayment.spec.ts.✅ Looks Good
URLSearchParamsfor all query params — proper encoding, no injection risk.computedforswishPaymentUrlreacts correctly toswishNumber/swishAmountchanges..env.exampledocumentation is clear about accepted number formats.qrcodeis a well-maintained, widely-used package; browser build works with Vite.💡
+46prefix not handledA number like
+46701234567doesn't match123,46, or0and passes through unchanged. The+gets URL-encoded to%2BbyURLSearchParams, which may break the Swish URL.Consider:
const trimmed = number.replace(/[\s+]/g, '')to strip both spaces and leading+.⚠️ QR generation failure masks the payment UI
QRCode.toDataURL()shares the sametry/catchasfetchSwishInfo(). If QR generation throws, the catch fireserror.value = 'Kunde inte ladda betalningsinformation…'even though the Swish info was fetched successfully. The user sees a misleading error.Suggested fix:
Could you spin it up and take a screenshot and paste here so i can see how it looks?
Here's the payment page spun up and screenshotted 👇
What's visible:
qrcode, encodes the pre-filled Swish URL (app.swish.nu/1/p/sw/?sw=…&amt=49.00&msg=…)1234567890, a Swish Företag business number format)How I ran it: Vite dev server on port 3001, with the
/api/payment/swish-infoendpoint mocked to return the business number + amount. The QR code, Swish link, and fallback all render correctly on a desktop viewport.