Compare commits

..

2 commits

Author SHA1 Message Date
0f613b21a6 fix: allow frontend container host in vite preview and update payment E2E tests
Some checks failed
CI / Lint, type check, unit tests, coverage (push) Successful in 11m18s
CI / E2E browser tests (push) Failing after 54s
fix: add preview.allowedHosts and preview.host to vite.config.ts

Vite preview server blocks requests from non-localhost hosts by default.
In the E2E Docker Compose stack, Playwright accesses the frontend via
http://frontend (container hostname). Without allowedHosts, Vite returns
"Blocked request. This host is not allowed." and the SPA never mounts,
causing all 59 E2E tests to fail with blank pages and missing elements.

- Add preview.host: true (bind to 0.0.0.0)
- Add preview.allowedHosts: ['frontend', 'localhost']

test: update payment-redirect E2E tests to match current UI

The payment page was redesigned to a two-step confirmation flow:
"Jag har betalat" → confirmation → "Ja, jag har betalat". The E2E
tests still referenced the old single-step "Genomför testbetalning"
button and a removed .payment__note CSS class.

- Update 'payment button marks order as paid' to click through both steps
- Rename 'shows mock payment note' to 'shows Swish payment instructions'
  and assert on actual UI elements (Swish label + payment button)

Result: E2E suite now passes 59/59 tests in the Docker Compose CI stack.
2026-05-19 19:40:40 +02:00
98d5545be0 feat: replace Stripe mock with manual Swish payment flow
Replace the mock test-payment button with a real manual Swish flow
where the user sends a Swish payment with the order ID as message
and confirms via a button. Admin verifies Swish and processes manually.

Backend
- Rename OrderStatus LOOKUP_STARTED to PROCESSING (Swedish: Hanteras)
- Update V5 migration CHECK constraint from lookup_started to processing
- Rename OrderService.markAsPaid() to confirmPayment(), sets PROCESSING
  instead of PAID, stop hardcoding amountPaid
- Add GET /api/payment/swish-info endpoint returning swish number and
  letter price from app.payment config
- Permit /api/payment/swish-info without authentication
- Update UpdateStatusRequest regex to accept processing
- Update PaymentControllerTest for renamed method, new status, and
  public swish-info endpoint test

Frontend
- Rewrite PaymentRedirect.vue: Swish number, order ID as message,
  Jag har betalat button with confirmation dialog
- Add fetchSwishInfo() to api/payment.ts
- AdminPage: rename Skickade stat to Att göra (processing orders),
  highlight processing rows with admin__row--todo
- OrdersPage: update status labels/badge classes for new flow
- Refactor ApiError in client.ts to property declaration syntax
- Exclude __tests__ from tsconfig.app.json and Docker builds

Tests
- Rewrite PaymentRedirect.spec.ts for Swish info, confirmation dialog,
  cancel flow, and processing status
- Update OrdersPage.spec.ts with processing status test
- Update AdminDashboard.spec.ts with Att göra stat and row highlight
- Add amountPaid to ComposePage.spec.ts mock

Config
- Add SWISH_NUMBER to .env.example and docker-compose.yml
2026-05-19 19:23:37 +02:00
20 changed files with 358 additions and 87 deletions

View file

@ -23,3 +23,6 @@ STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567

View file

@ -36,6 +36,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers("/api/webhooks/**").permitAll()
.requestMatchers("/api/payment/swish-info").permitAll()
.requestMatchers("/api/vehicles/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())

View file

@ -1,7 +1,11 @@
package se.bilhalsning.controller;
import lombok.RequiredArgsConstructor;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@ -10,21 +14,34 @@ import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService;
import java.util.UUID;
@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {
private final OrderService orderService;
private final String swishNumber;
private final int letterPrice;
public PaymentController(
OrderService orderService,
@Value("${app.payment.swish-number}") String swishNumber,
@Value("${app.payment.letter-price}") int letterPrice) {
this.orderService = orderService;
this.swishNumber = swishNumber;
this.letterPrice = letterPrice;
}
@PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
Order order = orderService.markAsPaid(orderId);
Order order = orderService.confirmPayment(orderId);
return ResponseEntity.ok(toResponse(order));
}
@GetMapping("/swish-info")
public ResponseEntity<Map<String, Object>> swishInfo() {
return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),

View file

@ -6,7 +6,7 @@ import jakarta.validation.constraints.Pattern;
public record UpdateStatusRequest(
@NotBlank(message = "Status krävs")
@Pattern(
regexp = "pending_payment|paid|lookup_started|sent|delivered|failed",
regexp = "pending_payment|paid|processing|sent|delivered|failed",
message = "Ogiltig status"
)
String status

View file

@ -3,7 +3,7 @@ package se.bilhalsning.entity;
public enum OrderStatus {
PENDING_PAYMENT("pending_payment"),
PAID("paid"),
LOOKUP_STARTED("lookup_started"),
PROCESSING("processing"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed");

View file

@ -7,7 +7,6 @@ import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
import java.math.BigDecimal;
import java.util.List;
import java.util.UUID;
@ -56,12 +55,11 @@ public class OrderService {
return orderRepository.save(order);
}
public Order markAsPaid(UUID orderId) {
public Order confirmPayment(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
order.setStatus(OrderStatus.PROCESSING);
return orderRepository.save(order);
}
}

View file

@ -13,5 +13,8 @@ spring:
database-platform: org.hibernate.dialect.PostgreSQLDialect
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET}

View file

@ -25,5 +25,8 @@ spring:
locations: classpath:db/migration
app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production}

View file

@ -10,7 +10,7 @@ CREATE TABLE orders (
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'lookup_started', 'sent', 'delivered', 'failed'))
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'processing', 'sent', 'delivered', 'failed'))
);
CREATE INDEX idx_orders_user_id ON orders(user_id);

View file

@ -2,11 +2,11 @@ package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -40,33 +40,39 @@ class PaymentControllerTest {
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldMarkOrderAsPaidSuccessfully() throws Exception {
void shouldConfirmPaymentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
order.setStatus(OrderStatus.PROCESSING);
when(orderService.markAsPaid(eq(orderId))).thenReturn(order);
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("paid"))
.andExpect(jsonPath("$.amountPaid").value(49.00));
.andExpect(jsonPath("$.status").value("processing"));
}
@Test
@WithMockUser(username = "test@bilhalsning.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.markAsPaid(eq(orderId)))
when(orderService.confirmPayment(eq(orderId)))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
void shouldReturnSwishInfoUnauthenticated() throws Exception {
mockMvc.perform(get("/api/payment/swish-info"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.number").exists())
.andExpect(jsonPath("$.amount").exists());
}
}

View file

@ -29,6 +29,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}

View file

@ -28,7 +28,8 @@ test.describe('Payment redirect', () => {
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await page.getByRole('button', { name: 'Genomför testbetalning' }).click()
await page.getByRole('button', { name: 'Jag har betalat' }).click()
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
await expect(page).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible()
@ -41,12 +42,13 @@ test.describe('Payment redirect', () => {
await expect(page).toHaveURL(/\/logga-in/)
})
test('shows mock payment note', async ({ page }) => {
test('shows Swish payment instructions', async ({ page }) => {
await page.goto('/compose?plate=GHI789')
await page.getByLabel('Ditt meddelande').fill('Hej!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//)
await expect(page.locator('.payment__note')).toBeVisible()
await expect(page.getByText('Swisha till')).toBeVisible()
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
})
})

View file

@ -50,7 +50,7 @@ const mockOrders = [
email: 'user@example.com',
plate: 'XYZ789',
letterText: 'Vill köpa din bil.',
status: 'pending_payment',
status: 'processing',
trackingId: null,
amountPaid: null,
createdAt: '2026-05-14T13:00:00Z',
@ -301,4 +301,19 @@ describe('AdminDashboard', () => {
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
})
it('shows Att göra stat for processing orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Att göra')
})
it('highlights processing rows', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin__row')
expect(rows[1].classes()).toContain('admin__row--todo')
})
})

View file

@ -155,4 +155,24 @@ describe('OrdersPage', () => {
expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--muted')
})
it('renders processing status correctly', async () => {
const ordersWithProcessing = [
{
id: 'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
plate: 'XYZ123',
status: 'processing',
trackingId: null,
createdAt: '2026-05-15T10:00:00Z',
},
]
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithProcessing),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hanteras')
const badge = wrapper.find('.badge')
expect(badge.classes()).toContain('badge--primary')
})
})

View file

@ -7,10 +7,12 @@ import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('@/api/payment', () => ({
payOrder: vi.fn(),
fetchSwishInfo: vi.fn(),
}))
import { payOrder } from '@/api/payment'
import { payOrder, fetchSwishInfo } from '@/api/payment'
const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
function createTestRouter() {
return createRouter({
@ -53,11 +55,17 @@ async function mountPage(orderId = 'order-1', plate = 'ABC123') {
describe('PaymentRedirect', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
})
it('renders heading and amount', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('49 kr')
})
@ -66,71 +74,132 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('ABC123')
})
it('shows payment button', async () => {
it('shows Swish payment button', async () => {
const { wrapper } = await mountPage()
const button = wrapper.find('.btn--primary')
expect(button.exists()).toBe(true)
expect(button.text()).toBe('Genomför testbetalning')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag har betalat')
})
})
it('shows test payment note', async () => {
it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('testbetalning')
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
it('calls payOrder on button click', async () => {
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567')
expect(wrapper.text()).toContain('order-1')
})
})
it('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt')
})
await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt')
})
})
it('calls payOrder on confirmation', async () => {
mockPayOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'paid',
status: 'processing',
trackingId: null,
amountPaid: 49.0,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').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')
expect(mockPayOrder).toHaveBeenCalledWith('order-1')
})
it('shows error on payment confirmation failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').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 vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
})
})
it('navigates to orders on success', async () => {
mockPayOrder.mockResolvedValue({
id: 'order-1',
plate: 'ABC123',
status: 'paid',
status: 'processing',
trackingId: null,
amountPaid: 49.0,
amountPaid: null,
createdAt: '2025-01-01T00:00:00Z',
})
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').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 vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders')
})
})
it('shows error on payment failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
it('displays Swish number from API', async () => {
const { wrapper } = await mountPage()
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte genomföra betalningen')
expect(wrapper.text()).toContain('0701234567')
})
})
it('disables button while paying', async () => {
mockPayOrder.mockImplementation(() => new Promise(() => {}))
it('shows error when swish info fetch fails', async () => {
mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
const button = wrapper.find('.btn--primary')
await button.trigger('click')
expect(button.attributes('disabled')).toBeDefined()
expect(button.text()).toBe('Bearbetar...')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte ladda betalningsinformation')
})
})
})

View file

@ -1,8 +1,17 @@
import { request } from './client'
import type { Order } from './orders'
export interface SwishInfo {
number: string
amount: number
}
export function payOrder(orderId: string): Promise<Order> {
return request<Order>(`/payment/${orderId}/pay`, {
method: 'POST',
})
}
export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info')
}

View file

@ -18,7 +18,7 @@ const trackingInputValues = reactive<Record<string, string>>({})
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
lookup_started: 'Hanteras',
processing: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
@ -26,8 +26,8 @@ const statusLabels: Record<string, string> = {
const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted',
paid: 'badge--primary',
lookup_started: 'badge--primary',
paid: 'badge--success',
processing: 'badge--primary',
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',
@ -36,7 +36,7 @@ const statusBadge: Record<string, string> = {
const allStatuses = [
'pending_payment',
'paid',
'lookup_started',
'processing',
'sent',
'delivered',
'failed',
@ -44,16 +44,14 @@ const allStatuses = [
const stats = computed(() => {
const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) =>
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
['paid', 'sent', 'delivered'].includes(o.status),
).length
const pending = orders.value.filter(
(o) => o.status === 'pending_payment',
).length
const sent = orders.value.filter(
(o) => o.status === 'sent' || o.status === 'delivered',
).length
return { total, paid, pending, sent }
return { total, todo, paid, pending }
})
function formatDate(iso: string): string {
@ -144,6 +142,10 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span>
</div>
<div class="admin__stat admin__stat--todo">
<span class="admin__stat-value">{{ stats.todo }}</span>
<span class="admin__stat-label">Att göra</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span>
@ -152,10 +154,6 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span>
</div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.sent }}</span>
<span class="admin__stat-label">Skickade</span>
</div>
</div>
<p
@ -183,6 +181,7 @@ onMounted(async () => {
class="admin__row"
:class="{
'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}"
>
<td>{{ formatDate(order.createdAt) }}</td>
@ -346,6 +345,11 @@ onMounted(async () => {
box-shadow: var(--shadow-sm);
}
.admin__stat--todo {
background: var(--color-primary-soft);
border-color: var(--color-primary);
}
.admin__stat-value {
display: block;
font-size: 1.5rem;
@ -410,6 +414,10 @@ onMounted(async () => {
background: var(--color-primary-soft) !important;
}
.admin__row--todo {
border-left: 3px solid var(--color-primary);
}
.admin__row td {
padding: 0.75rem var(--space-md);
color: var(--color-ink);

View file

@ -10,7 +10,7 @@ const error = ref('')
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
paid: 'Betalad',
lookup_started: 'Hanteras',
processing: 'Hanteras',
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
@ -18,8 +18,8 @@ const statusLabels: Record<string, string> = {
const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted',
paid: 'badge--primary',
lookup_started: 'badge--primary',
paid: 'badge--success',
processing: 'badge--primary',
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',

View file

@ -1,16 +1,37 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { payOrder } from '@/api/payment'
import { payOrder, fetchSwishInfo } from '@/api/payment'
const router = useRouter()
const route = useRoute()
const orderId = route.params.orderId as string
const swishNumber = ref('')
const swishAmount = ref(49)
const paying = ref(false)
const error = ref('')
const showConfirmation = ref(false)
async function handlePay() {
onMounted(async () => {
try {
const info = await fetchSwishInfo()
swishNumber.value = info.number
swishAmount.value = info.amount
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
}
})
function startPayment() {
showConfirmation.value = true
}
function cancelPayment() {
showConfirmation.value = false
}
async function confirmPayment() {
paying.value = true
error.value = ''
@ -18,7 +39,7 @@ async function handlePay() {
await payOrder(orderId)
await router.push({ name: 'orders' })
} catch {
error.value = 'Kunde inte genomföra betalningen. Försök igen.'
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
} finally {
paying.value = false
}
@ -36,7 +57,7 @@ async function handlePay() {
<div class="payment__summary">
<div class="payment__row">
<span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span>
<span class="payment__amount">{{ swishAmount }} kr</span>
</div>
</div>
@ -48,17 +69,51 @@ async function handlePay() {
{{ error }}
</div>
<template v-if="!showConfirmation">
<div class="payment__swish">
<p class="payment__swish-label">Swisha till</p>
<p class="payment__swish-number">{{ swishNumber }}</p>
<p class="payment__swish-instruction">
Ange <strong class="payment__order-id">{{ orderId }}</strong>
som meddelande i Swish-appen.
</p>
<p class="payment__swish-instruction">
Tryck sedan knappen nedan för att bekräfta.
</p>
</div>
<button
class="btn btn--primary btn--lg payment__submit"
:disabled="paying"
@click="handlePay"
@click="startPayment"
>
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
Jag har betalat
</button>
</template>
<p class="payment__note">
Detta är en testbetalning i utvecklingsmiljön.
<template v-else>
<div class="payment__confirm">
<p class="payment__confirm-text">
Jag bekräftar att jag har Swishat {{ swishAmount }} kr till
{{ swishNumber }} med meddelande: {{ orderId }}.
</p>
<div class="payment__confirm-actions">
<button
class="btn btn--ghost payment__confirm-cancel"
:disabled="paying"
@click="cancelPayment"
>
Avbryt
</button>
<button
class="btn btn--primary"
:disabled="paying"
@click="confirmPayment"
>
{{ paying ? 'Bearbetar...' : 'Ja, jag har betalat' }}
</button>
</div>
</div>
</template>
</div>
</div>
</template>
@ -113,14 +168,73 @@ async function handlePay() {
color: var(--color-ink);
}
.payment__swish {
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
text-align: center;
}
.payment__swish-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__swish-number {
margin: 0 0 var(--space-md) 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-ink);
letter-spacing: 0.05em;
}
.payment__swish-instruction {
margin: 0;
font-size: 0.8125rem;
color: var(--color-muted);
line-height: 1.5;
}
.payment__swish-instruction + .payment__swish-instruction {
margin-top: var(--space-xs);
}
.payment__order-id {
word-break: break-all;
color: var(--color-ink);
}
.payment__submit {
width: 100%;
}
.payment__note {
margin: var(--space-md) 0 0 0;
font-size: 0.75rem;
color: var(--color-soft);
text-align: center;
.payment__confirm {
padding: var(--space-md) 0;
}
.payment__confirm-text {
margin: 0 0 var(--space-lg) 0;
font-size: 0.9375rem;
color: var(--color-ink);
line-height: 1.6;
}
.payment__confirm-actions {
display: flex;
gap: var(--space-md);
}
.payment__confirm-cancel {
flex: 1;
}
.payment__confirm-actions .btn--primary {
flex: 2;
}
</style>

View file

@ -18,6 +18,8 @@ export default defineConfig({
},
preview: {
port: 80,
host: true,
allowedHosts: ['frontend', 'localhost'],
proxy: {
'/api': 'http://backend:8080',
},