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 @@