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 # Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567

View file

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

View file

@ -1,7 +1,11 @@
package se.bilhalsning.controller; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -10,21 +14,34 @@ import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/payment") @RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController { public class PaymentController {
private final OrderService orderService; 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") @PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) { public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
Order order = orderService.markAsPaid(orderId); Order order = orderService.confirmPayment(orderId);
return ResponseEntity.ok(toResponse(order)); 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) { private OrderResponse toResponse(Order order) {
return new OrderResponse( return new OrderResponse(
order.getId(), order.getId(),

View file

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

View file

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

View file

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

View file

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

View file

@ -25,5 +25,8 @@ spring:
locations: classpath:db/migration locations: classpath:db/migration
app: app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt: jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production} 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, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id), CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(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); 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.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; 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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -40,33 +40,39 @@ class PaymentControllerTest {
@Test @Test
@WithMockUser(username = "test@bilhalsning.se") @WithMockUser(username = "test@bilhalsning.se")
void shouldMarkOrderAsPaidSuccessfully() throws Exception { void shouldConfirmPaymentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order(); Order order = new Order();
order.setId(orderId); order.setId(orderId);
order.setPlate("ABC123"); order.setPlate("ABC123");
order.setStatus(OrderStatus.PAID); order.setStatus(OrderStatus.PROCESSING);
order.setAmountPaid(new BigDecimal("49.00"));
when(orderService.markAsPaid(eq(orderId))).thenReturn(order); when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString())) .andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("paid")) .andExpect(jsonPath("$.status").value("processing"));
.andExpect(jsonPath("$.amountPaid").value(49.00));
} }
@Test @Test
@WithMockUser(username = "test@bilhalsning.se") @WithMockUser(username = "test@bilhalsning.se")
void shouldReturn404WhenOrderNotFound() throws Exception { void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.markAsPaid(eq(orderId))) when(orderService.confirmPayment(eq(orderId)))
.thenThrow(new OrderNotFoundException(orderId)); .thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound()); .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_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} 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.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//) 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).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible() await expect(page.getByText('DEF456').first()).toBeVisible()
@ -41,12 +42,13 @@ test.describe('Payment redirect', () => {
await expect(page).toHaveURL(/\/logga-in/) 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.goto('/compose?plate=GHI789')
await page.getByLabel('Ditt meddelande').fill('Hej!') await page.getByLabel('Ditt meddelande').fill('Hej!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click() await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//) 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', email: 'user@example.com',
plate: 'XYZ789', plate: 'XYZ789',
letterText: 'Vill köpa din bil.', letterText: 'Vill köpa din bil.',
status: 'pending_payment', status: 'processing',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
createdAt: '2026-05-14T13:00:00Z', createdAt: '2026-05-14T13:00:00Z',
@ -301,4 +301,19 @@ describe('AdminDashboard', () => {
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID') 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[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--muted') 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', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: vi.fn(),
})) }))
import { payOrder } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -53,11 +55,17 @@ async function mountPage(orderId = 'order-1', plate = 'ABC123') {
describe('PaymentRedirect', () => { describe('PaymentRedirect', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Betalning') await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('49 kr') expect(wrapper.text()).toContain('49 kr')
}) })
@ -66,71 +74,132 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('ABC123') expect(wrapper.text()).toContain('ABC123')
}) })
it('shows payment button', async () => { it('shows Swish payment button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const button = wrapper.find('.btn--primary') await vi.waitFor(() => {
expect(button.exists()).toBe(true) expect(wrapper.text()).toContain('Jag har betalat')
expect(button.text()).toBe('Genomför testbetalning') })
}) })
it('shows test payment note', async () => { it('shows confirmation dialog after clicking pay button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('testbetalning') 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('Jag bekräftar att jag har Swishat')
expect(wrapper.text()).toContain('0701234567')
expect(wrapper.text()).toContain('order-1')
})
}) })
it('calls payOrder on button click', async () => { 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({ mockPayOrder.mockResolvedValue({
id: 'order-1', id: 'order-1',
plate: 'ABC123', plate: 'ABC123',
status: 'paid', status: 'processing',
trackingId: null, trackingId: null,
amountPaid: 49.0, amountPaid: null,
createdAt: '2025-01-01T00:00:00Z', createdAt: '2025-01-01T00:00:00Z',
}) })
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click') 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') 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 () => { it('navigates to orders on success', async () => {
mockPayOrder.mockResolvedValue({ mockPayOrder.mockResolvedValue({
id: 'order-1', id: 'order-1',
plate: 'ABC123', plate: 'ABC123',
status: 'paid', status: 'processing',
trackingId: null, trackingId: null,
amountPaid: 49.0, amountPaid: null,
createdAt: '2025-01-01T00:00:00Z', createdAt: '2025-01-01T00:00:00Z',
}) })
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click') 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(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')
}) })
}) })
it('shows error on payment failure', async () => { it('displays Swish number from API', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte genomföra betalningen') expect(wrapper.text()).toContain('0701234567')
}) })
}) })
it('disables button while paying', async () => { it('shows error when swish info fetch fails', async () => {
mockPayOrder.mockImplementation(() => new Promise(() => {})) mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
const button = wrapper.find('.btn--primary') await vi.waitFor(() => {
await button.trigger('click') expect(wrapper.text()).toContain('Kunde inte ladda betalningsinformation')
})
expect(button.attributes('disabled')).toBeDefined()
expect(button.text()).toBe('Bearbetar...')
}) })
}) })

View file

@ -1,8 +1,17 @@
import { request } from './client' import { request } from './client'
import type { Order } from './orders' import type { Order } from './orders'
export interface SwishInfo {
number: string
amount: number
}
export function payOrder(orderId: string): Promise<Order> { export function payOrder(orderId: string): Promise<Order> {
return request<Order>(`/payment/${orderId}/pay`, { return request<Order>(`/payment/${orderId}/pay`, {
method: 'POST', 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> = { const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning', pending_payment: 'Väntar på betalning',
paid: 'Betalad', paid: 'Betalad',
lookup_started: 'Hanteras', processing: 'Hanteras',
sent: 'Skickat', sent: 'Skickat',
delivered: 'Levererat', delivered: 'Levererat',
failed: 'Misslyckad', failed: 'Misslyckad',
@ -26,8 +26,8 @@ const statusLabels: Record<string, string> = {
const statusBadge: Record<string, string> = { const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted', pending_payment: 'badge--muted',
paid: 'badge--primary', paid: 'badge--success',
lookup_started: 'badge--primary', processing: 'badge--primary',
sent: 'badge--success', sent: 'badge--success',
delivered: 'badge--success', delivered: 'badge--success',
failed: 'badge--danger', failed: 'badge--danger',
@ -36,7 +36,7 @@ const statusBadge: Record<string, string> = {
const allStatuses = [ const allStatuses = [
'pending_payment', 'pending_payment',
'paid', 'paid',
'lookup_started', 'processing',
'sent', 'sent',
'delivered', 'delivered',
'failed', 'failed',
@ -44,16 +44,14 @@ const allStatuses = [
const stats = computed(() => { const stats = computed(() => {
const total = orders.value.length const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) => const paid = orders.value.filter((o) =>
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status), ['paid', 'sent', 'delivered'].includes(o.status),
).length ).length
const pending = orders.value.filter( const pending = orders.value.filter(
(o) => o.status === 'pending_payment', (o) => o.status === 'pending_payment',
).length ).length
const sent = orders.value.filter( return { total, todo, paid, pending }
(o) => o.status === 'sent' || o.status === 'delivered',
).length
return { total, paid, pending, sent }
}) })
function formatDate(iso: string): string { function formatDate(iso: string): string {
@ -144,6 +142,10 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.total }}</span> <span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span> <span class="admin__stat-label">Totalt</span>
</div> </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"> <div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span> <span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</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-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span> <span class="admin__stat-label">Väntar</span>
</div> </div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.sent }}</span>
<span class="admin__stat-label">Skickade</span>
</div>
</div> </div>
<p <p
@ -183,6 +181,7 @@ onMounted(async () => {
class="admin__row" class="admin__row"
:class="{ :class="{
'admin__row--expanded': expandedOrderId === order.id, 'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}" }"
> >
<td>{{ formatDate(order.createdAt) }}</td> <td>{{ formatDate(order.createdAt) }}</td>
@ -346,6 +345,11 @@ onMounted(async () => {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.admin__stat--todo {
background: var(--color-primary-soft);
border-color: var(--color-primary);
}
.admin__stat-value { .admin__stat-value {
display: block; display: block;
font-size: 1.5rem; font-size: 1.5rem;
@ -410,6 +414,10 @@ onMounted(async () => {
background: var(--color-primary-soft) !important; background: var(--color-primary-soft) !important;
} }
.admin__row--todo {
border-left: 3px solid var(--color-primary);
}
.admin__row td { .admin__row td {
padding: 0.75rem var(--space-md); padding: 0.75rem var(--space-md);
color: var(--color-ink); color: var(--color-ink);

View file

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

View file

@ -1,16 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { payOrder } from '@/api/payment' import { payOrder, fetchSwishInfo } from '@/api/payment'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const orderId = route.params.orderId as string const orderId = route.params.orderId as string
const swishNumber = ref('')
const swishAmount = ref(49)
const paying = ref(false) const paying = ref(false)
const error = ref('') 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 paying.value = true
error.value = '' error.value = ''
@ -18,7 +39,7 @@ async function handlePay() {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch { } catch {
error.value = 'Kunde inte genomföra betalningen. Försök igen.' error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
} finally { } finally {
paying.value = false paying.value = false
} }
@ -36,7 +57,7 @@ async function handlePay() {
<div class="payment__summary"> <div class="payment__summary">
<div class="payment__row"> <div class="payment__row">
<span class="payment__label">Att betala</span> <span class="payment__label">Att betala</span>
<span class="payment__amount">49 kr</span> <span class="payment__amount">{{ swishAmount }} kr</span>
</div> </div>
</div> </div>
@ -48,17 +69,51 @@ async function handlePay() {
{{ error }} {{ error }}
</div> </div>
<button <template v-if="!showConfirmation">
class="btn btn--primary btn--lg payment__submit" <div class="payment__swish">
:disabled="paying" <p class="payment__swish-label">Swisha till</p>
@click="handlePay" <p class="payment__swish-number">{{ swishNumber }}</p>
> <p class="payment__swish-instruction">
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }} Ange <strong class="payment__order-id">{{ orderId }}</strong>
</button> som meddelande i Swish-appen.
</p>
<p class="payment__swish-instruction">
Tryck sedan knappen nedan för att bekräfta.
</p>
</div>
<p class="payment__note"> <button
Detta är en testbetalning i utvecklingsmiljön. class="btn btn--primary btn--lg payment__submit"
</p> @click="startPayment"
>
Jag har betalat
</button>
</template>
<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>
</div> </div>
</template> </template>
@ -113,14 +168,73 @@ async function handlePay() {
color: var(--color-ink); 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 { .payment__submit {
width: 100%; width: 100%;
} }
.payment__note { .payment__confirm {
margin: var(--space-md) 0 0 0; padding: var(--space-md) 0;
font-size: 0.75rem; }
color: var(--color-soft);
text-align: center; .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> </style>

View file

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