From 00d1f48218b07aa59fce44cee1ffef4732e1a36e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 19 Jun 2026 12:06:29 +0000 Subject: [PATCH 1/2] feat(payment): Swish QR code and pre-filled payment link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual "type the number and order ID" flow with: - Client-side QR code (qrcode npm package) for desktop users - Pre-filled Swish payment URL (app.swish.nu) for mobile users - Manual number fallback + "Jag har betalat" confirmation The Swish C2B URL scheme pre-fills amount and message (order ID) without requiring any Swish Commerce API certificate or bank agreement. Supports both personal phone numbers (070...) and Swish Företag business numbers (123...) via number normalization in buildSwishPaymentUrl(). Set SWISH_NUMBER in .env to a Företags number once set up. --- .env.example | 6 + frontend/e2e/payment-redirect.spec.ts | 27 +- frontend/package-lock.json | 356 ++++++++++++++++-- frontend/package.json | 2 + .../src/__tests__/PaymentRedirect.spec.ts | 63 +++- frontend/src/api/payment.ts | 41 ++ frontend/src/pages/PaymentRedirect.vue | 73 +++- 7 files changed, 507 insertions(+), 61 deletions(-) diff --git a/.env.example b/.env.example index ceb4463..45b0e59 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,12 @@ STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRICE_ID=price_... # ---------- Swish (Phase 0) ---------- +# The Swish number customers pay to. Two formats accepted: +# - Swedish phone number: 0701234567 (normalised to 46… for the payment URL) +# - Swish Business number: 1234567890 (starts with 123, used as-is) +# A Swish Business number (123…) is recommended — get one from your bank +# via a "Swish Företag" agreement. No Swish Commerce API certificate needed; +# the frontend generates a pre-filled QR code + payment link automatically. SWISH_NUMBER=0701234567 # ---------- App URL (password reset links in email) ---------- diff --git a/frontend/e2e/payment-redirect.spec.ts b/frontend/e2e/payment-redirect.spec.ts index 14fe2a6..e465d51 100644 --- a/frontend/e2e/payment-redirect.spec.ts +++ b/frontend/e2e/payment-redirect.spec.ts @@ -49,6 +49,31 @@ test.describe('Payment redirect', () => { await page.waitForURL(/\/betalning\//) await expect(page.getByText('Swisha till')).toBeVisible() - await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Jag har betalat' }), + ).toBeVisible() + }) + + test('shows QR code for desktop scanning', async ({ page }) => { + await page.goto('/compose?plate=JKL012') + await page.getByLabel('Ditt meddelande').fill('Fin bil!') + await page.getByRole('button', { name: 'Fortsätt till betalning' }).click() + + await page.waitForURL(/\/betalning\//) + await expect(page.getByRole('img', { name: 'Swish QR-kod' })).toBeVisible() + await expect(page.getByText('Skanna QR-koden')).toBeVisible() + }) + + test('shows Swish payment link with pre-filled data', async ({ page }) => { + await page.goto('/compose?plate=MNO345') + await page.getByLabel('Ditt meddelande').fill('Hej där!') + await page.getByRole('button', { name: 'Fortsätt till betalning' }).click() + + await page.waitForURL(/\/betalning\//) + const swishLink = page.getByRole('link', { name: 'Betala med Swish' }) + await expect(swishLink).toBeVisible() + const href = await swishLink.getAttribute('href') + expect(href).toContain('app.swish.nu') + expect(href).toContain('amt=49.00') }) }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 44821fc..f09b573 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "pinia": "^3.0.4", + "qrcode": "^1.5.4", "vue": "^3.5.32", "vue-router": "^5.0.6" }, @@ -16,6 +17,7 @@ "@playwright/test": "^1.60.0", "@rushstack/eslint-patch": "^1.16.1", "@types/node": "^24.12.2", + "@types/qrcode": "^1.5.5", "@vitejs/plugin-vue": "^6.0.6", "@vitest/coverage-v8": "^4.1.6", "@vue/eslint-config-prettier": "^10.2.0", @@ -791,9 +793,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -811,9 +810,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -831,9 +827,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -851,9 +844,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -871,9 +861,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -891,9 +878,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1054,6 +1038,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", @@ -1962,6 +1956,15 @@ "node": ">=8" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1987,11 +1990,91 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2004,7 +2087,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -2136,6 +2218,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2160,6 +2251,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2718,6 +2815,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2863,7 +2969,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3285,9 +3390,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3309,9 +3411,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3333,9 +3432,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3357,9 +3453,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3743,6 +3836,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -3787,7 +3889,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3936,6 +4037,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", @@ -4034,6 +4144,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -4084,6 +4211,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4094,6 +4230,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4208,6 +4350,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5094,6 +5242,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -5236,6 +5390,12 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", @@ -5251,6 +5411,134 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ec3c316..7e62518 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "pinia": "^3.0.4", + "qrcode": "^1.5.4", "vue": "^3.5.32", "vue-router": "^5.0.6" }, @@ -24,6 +25,7 @@ "@playwright/test": "^1.60.0", "@rushstack/eslint-patch": "^1.16.1", "@types/node": "^24.12.2", + "@types/qrcode": "^1.5.5", "@vitejs/plugin-vue": "^6.0.6", "@vitest/coverage-v8": "^4.1.6", "@vue/eslint-config-prettier": "^10.2.0", diff --git a/frontend/src/__tests__/PaymentRedirect.spec.ts b/frontend/src/__tests__/PaymentRedirect.spec.ts index 37b531e..b13a395 100644 --- a/frontend/src/__tests__/PaymentRedirect.spec.ts +++ b/frontend/src/__tests__/PaymentRedirect.spec.ts @@ -5,14 +5,26 @@ import { createRouter, createMemoryHistory } from 'vue-router' import PaymentRedirect from '@/pages/PaymentRedirect.vue' import OrdersPage from '@/pages/OrdersPage.vue' +vi.mock('qrcode', () => ({ + default: { + toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,mock-qr'), + }, +})) + vi.mock('@/api/payment', () => ({ payOrder: vi.fn(), fetchSwishInfo: vi.fn(), + buildSwishPaymentUrl: vi.fn( + (number: string, amount: number, message: string) => + `https://app.swish.nu/1/p/sw/?sw=${number}&amt=${amount.toFixed(2)}&msg=${message}`, + ), })) import { payOrder, fetchSwishInfo } from '@/api/payment' +import QRCode from 'qrcode' const mockPayOrder = vi.mocked(payOrder) const mockFetchSwishInfo = vi.mocked(fetchSwishInfo) +const mockToDataURL = vi.mocked(QRCode.toDataURL) function createTestRouter() { return createRouter({ @@ -59,6 +71,7 @@ describe('PaymentRedirect', () => { number: '0701234567', amount: 49, }) + mockToDataURL.mockResolvedValue('data:image/png;base64,mock-qr') }) it('renders heading and amount', async () => { @@ -81,7 +94,7 @@ describe('PaymentRedirect', () => { expect(wrapper.text()).toContain('Beställnings-ID') expect(wrapper.text()).toContain(orderId) expect(wrapper.text()).toContain( - 'Ange beställnings-ID ovan som meddelande i Swish-appen', + 'fylls i automatiskt via QR-kod eller länk', ) }) }) @@ -93,13 +106,30 @@ describe('PaymentRedirect', () => { }) }) + it('renders QR code after fetching swish info', async () => { + const { wrapper } = await mountPage() + await vi.waitFor(() => { + expect(wrapper.find('.payment__qr-img').exists()).toBe(true) + }) + expect(mockToDataURL).toHaveBeenCalledTimes(1) + }) + + it('renders a Swish payment link', async () => { + const { wrapper } = await mountPage('test-order', 'ABC123') + await vi.waitFor(() => { + const link = wrapper.find('.payment__swish-link') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toContain('app.swish.nu') + }) + }) + it('shows confirmation dialog after clicking pay button', async () => { const { wrapper } = await mountPage() await vi.waitFor(() => { - expect(wrapper.find('.btn--primary').exists()).toBe(true) + expect(wrapper.find('.payment__submit').exists()).toBe(true) }) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.payment__submit').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat') expect(wrapper.text()).toContain('0701234567') @@ -110,15 +140,15 @@ describe('PaymentRedirect', () => { it('can cancel confirmation dialog', async () => { const { wrapper } = await mountPage() await vi.waitFor(() => { - expect(wrapper.find('.btn--primary').exists()).toBe(true) + expect(wrapper.find('.payment__submit').exists()).toBe(true) }) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.payment__submit').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Avbryt') }) - await wrapper.find('.btn--ghost').trigger('click') + await wrapper.find('.payment__confirm-cancel').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Swisha till') expect(wrapper.text()).not.toContain('Avbryt') @@ -137,16 +167,15 @@ describe('PaymentRedirect', () => { const { wrapper } = await mountPage() await vi.waitFor(() => { - expect(wrapper.find('.btn--primary').exists()).toBe(true) + expect(wrapper.find('.payment__submit').exists()).toBe(true) }) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.payment__submit').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Ja, jag har betalat') }) - const confirmButtons = wrapper.findAll('.btn--primary') - await confirmButtons[confirmButtons.length - 1].trigger('click') + await wrapper.find('.payment__confirm .btn--primary').trigger('click') expect(mockPayOrder).toHaveBeenCalledWith('order-1') }) @@ -156,16 +185,15 @@ describe('PaymentRedirect', () => { const { wrapper } = await mountPage() await vi.waitFor(() => { - expect(wrapper.find('.btn--primary').exists()).toBe(true) + expect(wrapper.find('.payment__submit').exists()).toBe(true) }) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.payment__submit').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Ja, jag har betalat') }) - const confirmButtons = wrapper.findAll('.btn--primary') - await confirmButtons[confirmButtons.length - 1].trigger('click') + await wrapper.find('.payment__confirm .btn--primary').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen') @@ -184,16 +212,15 @@ describe('PaymentRedirect', () => { const { wrapper, router } = await mountPage() await vi.waitFor(() => { - expect(wrapper.find('.btn--primary').exists()).toBe(true) + expect(wrapper.find('.payment__submit').exists()).toBe(true) }) - await wrapper.find('.btn--primary').trigger('click') + await wrapper.find('.payment__submit').trigger('click') await vi.waitFor(() => { expect(wrapper.text()).toContain('Ja, jag har betalat') }) - const confirmButtons = wrapper.findAll('.btn--primary') - await confirmButtons[confirmButtons.length - 1].trigger('click') + await wrapper.find('.payment__confirm .btn--primary').trigger('click') await vi.waitFor(() => { expect(router.currentRoute.value.name).toBe('orders') diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 9288eba..7d930e6 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -15,3 +15,44 @@ export function payOrder(orderId: string): Promise { export function fetchSwishInfo(): Promise { return request('/payment/swish-info') } + +/** + * Build a pre-filled Swish payment URL. + * + * On mobile, tapping this URL opens the Swish app with the amount and + * message pre-filled. On desktop, embed it in a QR code for the user + * to scan with their phone. + * + * Uses the Swish "C2B pre-fill" URL scheme documented at + * https://developer.swish.nu — no Swish Commerce API certificate required. + * The `sw` parameter accepts either a phone number or a Swish Business + * number (123…). Phone numbers in Swedish national format (leading 0) + * are normalised to international format (46…). + */ +export function buildSwishPaymentUrl( + swishNumber: string, + amount: number, + message: string, +): string { + const payee = normalizeSwishNumber(swishNumber) + const params = new URLSearchParams({ + sw: payee, + amt: amount.toFixed(2), + msg: message, + }) + return `https://app.swish.nu/1/p/sw/?${params.toString()}` +} + +/** + * Normalise a Swish number to the format the Swish URL expects. + * - 123… (Swish Business number) → unchanged + * - 46… (already international) → unchanged + * - 0… (Swedish national format) → 46 + rest without leading 0 + */ +function normalizeSwishNumber(number: string): string { + 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) + return trimmed +} diff --git a/frontend/src/pages/PaymentRedirect.vue b/frontend/src/pages/PaymentRedirect.vue index fd05de0..d85fcaa 100644 --- a/frontend/src/pages/PaymentRedirect.vue +++ b/frontend/src/pages/PaymentRedirect.vue @@ -1,7 +1,8 @@