diff --git a/.env.example b/.env.example index 3c1dd89..e116584 100644 --- a/.env.example +++ b/.env.example @@ -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 + diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index 9a99694..2cb96c6 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -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()) diff --git a/backend/src/main/java/se/bilhalsning/controller/PaymentController.java b/backend/src/main/java/se/bilhalsning/controller/PaymentController.java index 43200cd..36bd58f 100644 --- a/backend/src/main/java/se/bilhalsning/controller/PaymentController.java +++ b/backend/src/main/java/se/bilhalsning/controller/PaymentController.java @@ -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 pay(@PathVariable UUID orderId) { - Order order = orderService.markAsPaid(orderId); + Order order = orderService.confirmPayment(orderId); return ResponseEntity.ok(toResponse(order)); } + @GetMapping("/swish-info") + public ResponseEntity> swishInfo() { + return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice)); + } + private OrderResponse toResponse(Order order) { return new OrderResponse( order.getId(), diff --git a/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java b/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java index 9cd6417..02044b1 100644 --- a/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java +++ b/backend/src/main/java/se/bilhalsning/dto/UpdateStatusRequest.java @@ -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 diff --git a/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java b/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java index d9fb8ed..092a9f5 100644 --- a/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java +++ b/backend/src/main/java/se/bilhalsning/entity/OrderStatus.java @@ -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"); diff --git a/backend/src/main/java/se/bilhalsning/service/OrderService.java b/backend/src/main/java/se/bilhalsning/service/OrderService.java index 5ac1b7e..e0fc3b4 100644 --- a/backend/src/main/java/se/bilhalsning/service/OrderService.java +++ b/backend/src/main/java/se/bilhalsning/service/OrderService.java @@ -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); } } diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 7424df4..36f992c 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -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} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 88453b5..14305a1 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -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} diff --git a/backend/src/main/resources/db/migration/V5__create_orders_table.sql b/backend/src/main/resources/db/migration/V5__create_orders_table.sql index 18a68fb..9348fd7 100644 --- a/backend/src/main/resources/db/migration/V5__create_orders_table.sql +++ b/backend/src/main/resources/db/migration/V5__create_orders_table.sql @@ -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); diff --git a/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java index a62e449..e078043 100644 --- a/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/PaymentControllerTest.java @@ -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()); + } } diff --git a/docker-compose.yml b/docker-compose.yml index 4dc48a0..149190a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/src/__tests__/AdminDashboard.spec.ts b/frontend/src/__tests__/AdminDashboard.spec.ts index d9b2cc1..8cffd4a 100644 --- a/frontend/src/__tests__/AdminDashboard.spec.ts +++ b/frontend/src/__tests__/AdminDashboard.spec.ts @@ -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') + }) }) diff --git a/frontend/src/__tests__/OrdersPage.spec.ts b/frontend/src/__tests__/OrdersPage.spec.ts index d4e82d2..e789298 100644 --- a/frontend/src/__tests__/OrdersPage.spec.ts +++ b/frontend/src/__tests__/OrdersPage.spec.ts @@ -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') + }) }) diff --git a/frontend/src/__tests__/PaymentRedirect.spec.ts b/frontend/src/__tests__/PaymentRedirect.spec.ts index 16bdf59..5ec71d5 100644 --- a/frontend/src/__tests__/PaymentRedirect.spec.ts +++ b/frontend/src/__tests__/PaymentRedirect.spec.ts @@ -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() - expect(wrapper.text()).toContain('Betalning') + 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) + }) + + 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({ 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') + }) }) }) diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index dbaa53a..9288eba 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -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 { return request(`/payment/${orderId}/pay`, { method: 'POST', }) } + +export function fetchSwishInfo(): Promise { + return request('/payment/swish-info') +} diff --git a/frontend/src/pages/AdminPage.vue b/frontend/src/pages/AdminPage.vue index cb366d7..f07f5a9 100644 --- a/frontend/src/pages/AdminPage.vue +++ b/frontend/src/pages/AdminPage.vue @@ -18,7 +18,7 @@ const trackingInputValues = reactive>({}) const statusLabels: Record = { 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 = { const statusBadge: Record = { 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 = { 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 () => { {{ stats.total }} Totalt +
+ {{ stats.todo }} + Att göra +
{{ stats.paid }} Betalda @@ -152,10 +154,6 @@ onMounted(async () => { {{ stats.pending }} Väntar
-
- {{ stats.sent }} - Skickade -

{ class="admin__row" :class="{ 'admin__row--expanded': expandedOrderId === order.id, + 'admin__row--todo': order.status === 'processing', }" > {{ formatDate(order.createdAt) }} @@ -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); diff --git a/frontend/src/pages/OrdersPage.vue b/frontend/src/pages/OrdersPage.vue index 283ea59..4b82d7a 100644 --- a/frontend/src/pages/OrdersPage.vue +++ b/frontend/src/pages/OrdersPage.vue @@ -10,7 +10,7 @@ const error = ref('') const statusLabels: Record = { 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 = { const statusBadge: Record = { 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', diff --git a/frontend/src/pages/PaymentRedirect.vue b/frontend/src/pages/PaymentRedirect.vue index 42d8868..2d84f89 100644 --- a/frontend/src/pages/PaymentRedirect.vue +++ b/frontend/src/pages/PaymentRedirect.vue @@ -1,16 +1,37 @@