Allow users to edit or cancel unpaid orders before payment.

Adds backend endpoints and frontend edit page so pending orders can be updated or soft-cancelled without admin intervention.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joakim Mörling 2026-05-22 11:21:47 +02:00
parent 082139d266
commit 3d0b7fe799
17 changed files with 1106 additions and 70 deletions

View file

@ -7,12 +7,15 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.CreateOrderRequest;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.dto.UpdateOrderRequest;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
@ -20,6 +23,7 @@ import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
@ -41,6 +45,21 @@ public class OrderController {
return ResponseEntity.ok(orders);
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> get(@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.getOrderById(id);
if (!order.getUserId().equals(user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(toResponse(order));
}
@PostMapping
public ResponseEntity<OrderResponse> create(
@Valid @RequestBody CreateOrderRequest request,
@ -57,6 +76,31 @@ public class OrderController {
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(order));
}
@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> update(
@PathVariable UUID id,
@Valid @RequestBody UpdateOrderRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.updatePendingOrder(id, user.getId(), request.letterText());
return ResponseEntity.ok(toResponse(order));
}
@PostMapping("/{id}/cancel")
public ResponseEntity<OrderResponse> cancel(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.cancelOrder(id, user.getId());
return ResponseEntity.ok(toResponse(order));
}
private OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),

View file

@ -5,6 +5,8 @@ import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@ -12,28 +14,38 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.InvalidCredentialsException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
private final OrderService orderService;
private final UserService userService;
private final String swishNumber;
private final int letterPrice;
public PaymentController(
OrderService orderService,
UserService userService,
@Value("${app.payment.swish-number}") String swishNumber,
@Value("${app.payment.letter-price}") int letterPrice) {
this.orderService = orderService;
this.userService = userService;
this.swishNumber = swishNumber;
this.letterPrice = letterPrice;
}
@PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
Order order = orderService.confirmPayment(orderId);
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId,
@AuthenticationPrincipal UserDetails userDetails) {
User user = userService.findByEmail(userDetails.getUsername())
.orElseThrow(InvalidCredentialsException::new);
Order order = orderService.confirmPayment(orderId, user.getId());
return ResponseEntity.ok(toResponse(order));
}

View file

@ -0,0 +1,10 @@
package se.bilhalsning.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record UpdateOrderRequest(
@NotBlank(message = "Brevtext krävs")
@Size(min = 1, max = 1000, message = "Brevtexten måste vara mellan 1 och 1000 tecken")
String letterText
) {}

View file

@ -6,7 +6,8 @@ public enum OrderStatus {
PROCESSING("processing"),
SENT("sent"),
DELIVERED("delivered"),
FAILED("failed");
FAILED("failed"),
CANCELLED("cancelled");
private final String value;

View file

@ -36,6 +36,13 @@ public class GlobalExceptionHandler {
.body(new ErrorResponse("E-postadressen är redan registrerad"));
}
@ExceptionHandler(InvalidOrderStateException.class)
public ResponseEntity<ErrorResponse> handleInvalidOrderState(InvalidOrderStateException ex) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
return ResponseEntity

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class InvalidOrderStateException extends RuntimeException {
public InvalidOrderStateException(String message) {
super(message);
}
}

View file

@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
@ -55,11 +56,37 @@ public class OrderService {
return orderRepository.save(order);
}
public Order confirmPayment(UUID orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
public Order confirmPayment(UUID orderId, UUID userId) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.PROCESSING);
return orderRepository.save(order);
}
public Order cancelOrder(UUID orderId, UUID userId) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setStatus(OrderStatus.CANCELLED);
return orderRepository.save(order);
}
public Order updatePendingOrder(UUID orderId, UUID userId, String letterText) {
Order order = requirePendingOwnedBy(orderId, userId);
order.setLetterText(letterText);
return orderRepository.save(order);
}
private Order requirePendingOwnedBy(UUID orderId, UUID userId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
if (!order.getUserId().equals(userId)) {
throw new OrderNotFoundException(orderId);
}
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
throw new InvalidOrderStateException(
"Beställningen kan inte ändras i detta tillstånd");
}
return order;
}
}

View file

@ -1,7 +1,9 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
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.patch;
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;
@ -163,4 +165,115 @@ class OrderControllerTest {
.content("{\"plate\":\"ABC123\",\"letterText\":\"" + longText + "\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldGetSingleOrderForOwner() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.plate").value("ABC123"))
.andExpect(jsonPath("$.status").value("pending_payment"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenGettingOtherUsersOrder() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(UUID.randomUUID());
when(orderService.getOrderById(orderId)).thenReturn(order);
mockMvc.perform(get("/api/orders/" + orderId))
.andExpect(status().isNotFound());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldPatchOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Updated text");
order.setStatus(se.bilhalsning.entity.OrderStatus.PENDING_PAYMENT);
when(orderService.updatePendingOrder(any(), any(), any())).thenReturn(order);
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"Updated text\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.letterText").value("Updated text"));
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldRejectPatchWithEmptyLetterText() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
mockMvc.perform(patch("/api/orders/" + orderId)
.contentType("application/json")
.content("{\"letterText\":\"\"}"))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(username = "test@bilhej.se")
void shouldCancelOrderSuccessfully() throws Exception {
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
se.bilhalsning.entity.Order order = new se.bilhalsning.entity.Order();
order.setId(orderId);
order.setUserId(userId);
order.setPlate("ABC123");
order.setLetterText("Test letter");
order.setStatus(se.bilhalsning.entity.OrderStatus.CANCELLED);
when(orderService.cancelOrder(orderId, userId)).thenReturn(order);
mockMvc.perform(post("/api/orders/" + orderId + "/cancel"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("cancelled"));
}
}

View file

@ -1,5 +1,6 @@
package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@ -7,6 +8,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -18,8 +20,10 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.entity.User;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.service.OrderService;
import se.bilhalsning.service.UserService;
@SpringBootTest
@AutoConfigureMockMvc
@ -31,6 +35,9 @@ class PaymentControllerTest {
@MockitoBean
private OrderService orderService;
@MockitoBean
private UserService userService;
@Test
void shouldReturn403WhenNotAuthenticated() throws Exception {
mockMvc.perform(post("/api/payment/{orderId}/pay",
@ -42,12 +49,19 @@ class PaymentControllerTest {
@WithMockUser(username = "test@bilhej.se")
void shouldConfirmPaymentSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
Order order = new Order();
order.setId(orderId);
order.setPlate("ABC123");
order.setStatus(OrderStatus.PROCESSING);
when(orderService.confirmPayment(eq(orderId))).thenReturn(order);
when(orderService.confirmPayment(eq(orderId), eq(userId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON))
@ -60,7 +74,14 @@ class PaymentControllerTest {
@WithMockUser(username = "test@bilhej.se")
void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.confirmPayment(eq(orderId)))
UUID userId = UUID.fromString("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
User user = new User();
user.setId(userId);
user.setEmail("test@bilhej.se");
when(userService.findByEmail("test@bilhej.se")).thenReturn(Optional.of(user));
when(orderService.confirmPayment(eq(orderId), eq(userId)))
.thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)

View file

@ -9,6 +9,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import se.bilhalsning.entity.Order;
import se.bilhalsning.entity.OrderStatus;
import se.bilhalsning.exception.InvalidOrderStateException;
import se.bilhalsning.exception.OrderNotFoundException;
import se.bilhalsning.repository.OrderRepository;
@ -127,4 +128,125 @@ class OrderServiceTest {
assertThrows(OrderNotFoundException.class,
() -> orderService.getOrderById(orderId));
}
@Test
void shouldCancelOrderWhenPendingPayment() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.cancelOrder(orderId, userId);
assertEquals(OrderStatus.CANCELLED, result.getStatus());
}
@Test
void shouldThrowWhenCancellingNonPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PROCESSING);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.cancelOrder(orderId, userId));
}
@Test
void shouldThrowWhenCancellingOtherUsersOrder() {
UUID orderId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherUserId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(ownerId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(OrderNotFoundException.class,
() -> orderService.cancelOrder(orderId, otherUserId));
}
@Test
void shouldUpdatePendingOrderLetterText() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
order.setLetterText("Old text");
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.updatePendingOrder(orderId, userId, "New text");
assertEquals("New text", result.getLetterText());
}
@Test
void shouldThrowWhenUpdatingNonPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PROCESSING);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.updatePendingOrder(orderId, userId, "New text"));
}
@Test
void shouldConfirmPaymentForPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
when(orderRepository.save(any(Order.class))).thenAnswer(inv -> inv.getArgument(0));
Order result = orderService.confirmPayment(orderId, userId);
assertEquals(OrderStatus.PROCESSING, result.getStatus());
}
@Test
void shouldThrowWhenConfirmingPaymentForNonPendingOrder() {
UUID orderId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(userId);
order.setStatus(OrderStatus.CANCELLED);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(InvalidOrderStateException.class,
() -> orderService.confirmPayment(orderId, userId));
}
@Test
void shouldThrowWhenConfirmingPaymentForOtherUsersOrder() {
UUID orderId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherUserId = UUID.randomUUID();
Order order = new Order();
order.setId(orderId);
order.setUserId(ownerId);
order.setStatus(OrderStatus.PENDING_PAYMENT);
when(orderRepository.findById(orderId)).thenReturn(Optional.of(order));
assertThrows(OrderNotFoundException.class,
() -> orderService.confirmPayment(orderId, otherUserId));
}
}

View file

@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import EditOrderPage from '@/pages/EditOrderPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
vi.mock('@/api/orders', () => ({
fetchOrder: vi.fn(),
updateOrder: vi.fn(),
}))
import { fetchOrder, updateOrder } from '@/api/orders'
const mockFetchOrder = vi.mocked(fetchOrder)
const mockUpdateOrder = vi.mocked(updateOrder)
const pendingOrder = {
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
plate: 'DEF456',
letterText: 'Vill köpa din bil.',
status: 'pending_payment',
trackingId: null,
amountPaid: null,
createdAt: '2026-05-14T13:00:00Z',
}
function createTestRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/orders',
name: 'orders',
component: { template: '<div>Orders</div>' },
},
{
path: '/bestallning/:orderId/redigera',
name: 'edit-order',
component: EditOrderPage,
},
{
path: '/betalning/:orderId',
name: 'payment',
component: PaymentRedirect,
},
],
})
}
async function mountPage(orderId = pendingOrder.id) {
const pinia = createPinia()
setActivePinia(pinia)
const router = createTestRouter()
await router.push({ name: 'edit-order', params: { orderId } })
await router.isReady()
const wrapper = mount(EditOrderPage, {
global: {
plugins: [router, pinia],
},
})
return { wrapper, router }
}
describe('EditOrderPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchOrder.mockResolvedValue(pendingOrder)
mockUpdateOrder.mockResolvedValue(pendingOrder)
})
it('shows loading state while fetching', async () => {
mockFetchOrder.mockImplementation(() => new Promise(() => {}))
const { wrapper } = await mountPage()
expect(wrapper.text()).toContain('Laddar beställning...')
})
it('loads order and pre-fills textarea', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(mockFetchOrder).toHaveBeenCalledWith(pendingOrder.id)
})
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toBe('Vill köpa din bil.')
expect(wrapper.text()).toContain('DEF456')
expect(wrapper.text()).toContain('Redigera brev')
})
it('shows error when order is not pending_payment', async () => {
mockFetchOrder.mockResolvedValue({
...pendingOrder,
status: 'sent',
})
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain(
'Den här beställningen kan inte redigeras',
)
})
expect(wrapper.find('textarea').exists()).toBe(false)
expect(wrapper.text()).toContain('Tillbaka till beställningar')
})
it('submit calls updateOrder and navigates to payment', async () => {
const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Uppdaterat meddelande')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(mockUpdateOrder).toHaveBeenCalledWith(
pendingOrder.id,
'Uppdaterat meddelande',
)
expect(router.currentRoute.value.name).toBe('payment')
expect(router.currentRoute.value.params.orderId).toBe(pendingOrder.id)
expect(router.currentRoute.value.query.plate).toBe('DEF456')
})
})
it('shows error message on update failure', async () => {
mockUpdateOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('textarea').exists()).toBe(true)
})
const textarea = wrapper.find('textarea')
await textarea.setValue('Uppdaterat meddelande')
const button = wrapper.find('button[type="submit"]')
await button.trigger('submit')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte spara ändringarna')
})
})
})

View file

@ -22,6 +22,11 @@ function createTestRouter() {
name: 'payment',
component: { template: '<div>Payment</div>' },
},
{
path: '/bestallning/:orderId/redigera',
name: 'edit-order',
component: { template: '<div>Edit</div>' },
},
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
],
})
@ -178,11 +183,14 @@ describe('OrdersPage', () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const payLinks = wrapper.findAll('.orders__pay-btn')
expect(payLinks).toHaveLength(1)
expect(payLinks[0].text()).toBe('Betala nu')
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
const payLink = pendingCard?.find('a.orders__action-btn')
expect(payLink?.exists()).toBe(true)
expect(payLink?.text()).toBe('Betala nu')
const href = payLinks[0].attributes('href')
const href = payLink?.attributes('href')
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
expect(href).toContain('plate=DEF456')
})
@ -194,7 +202,84 @@ describe('OrdersPage', () => {
const sentCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('ABC123'))
expect(sentCard?.find('.orders__pay-btn').exists()).toBe(false)
expect(sentCard?.find('a.orders__action-btn').exists()).toBe(false)
})
it('shows edit link for pending payment orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
const editLinks = pendingCard?.findAll('a.orders__action-btn') ?? []
const editLink = editLinks.find((link) => link.text() === 'Redigera')
expect(editLink?.exists()).toBe(true)
const href = editLink?.attributes('href')
expect(href).toContain('c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12')
expect(href).toContain('redigera')
})
it('shows cancel button for pending payment orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
const cancelBtn = pendingCard?.find('.orders__cancel-btn')
expect(cancelBtn?.exists()).toBe(true)
expect(cancelBtn?.text()).toBe('Avbryt beställning')
})
it('calls cancel API and updates status to Avbruten', async () => {
vi.stubGlobal(
'confirm',
vi.fn(() => true),
)
vi.mocked(globalThis.fetch)
.mockResolvedValueOnce(mockFetchResponse(200, mockOrders))
.mockResolvedValueOnce(
mockFetchResponse(200, {
id: 'c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12',
plate: 'DEF456',
letterText: 'Vill köpa din bil.',
status: 'cancelled',
trackingId: null,
createdAt: '2026-05-14T13:00:00Z',
}),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const pendingCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('DEF456'))
await pendingCard?.find('.orders__cancel-btn').trigger('click')
await new Promise((r) => setTimeout(r, 50))
expect(globalThis.fetch).toHaveBeenCalledWith(
'/api/orders/c2eebc99-9c0b-4ef8-bb6d-6bb9bd380a12/cancel',
expect.objectContaining({ method: 'POST' }),
)
expect(wrapper.text()).toContain('Avbruten')
vi.unstubAllGlobals()
})
it('does not show edit or cancel actions for non-pending orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const sentCard = wrapper
.findAll('.orders__card')
.find((card) => card.text().includes('ABC123'))
expect(sentCard?.find('.orders__cancel-btn').exists()).toBe(false)
expect(sentCard?.text()).not.toContain('Redigera')
expect(sentCard?.text()).not.toContain('Avbryt beställning')
})
it('renders processing status correctly', async () => {

View file

@ -14,9 +14,26 @@ export function fetchOrders(): Promise<Order[]> {
return request<Order[]>('/orders')
}
export function fetchOrder(id: string): Promise<Order> {
return request<Order>(`/orders/${id}`)
}
export function createOrder(plate: string, letterText: string): Promise<Order> {
return request<Order>('/orders', {
method: 'POST',
body: JSON.stringify({ plate, letterText }),
})
}
export function updateOrder(id: string, letterText: string): Promise<Order> {
return request<Order>(`/orders/${id}`, {
method: 'PATCH',
body: JSON.stringify({ letterText }),
})
}
export function cancelOrder(id: string): Promise<Order> {
return request<Order>(`/orders/${id}/cancel`, {
method: 'POST',
})
}

View file

@ -27,6 +27,7 @@ const statusLabels: Record<string, string> = {
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
cancelled: 'Avbruten',
}
const statusBadge: Record<string, string> = {
@ -36,6 +37,7 @@ const statusBadge: Record<string, string> = {
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',
cancelled: 'badge--muted',
}
const allStatuses = [
@ -45,6 +47,7 @@ const allStatuses = [
'sent',
'delivered',
'failed',
'cancelled',
]
const stats = computed(() => {

View file

@ -0,0 +1,339 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { fetchOrder, updateOrder, type Order } from '@/api/orders'
import { type LetterTemplate } from '@/data/templates'
import TemplatePicker from '@/components/TemplatePicker.vue'
const router = useRouter()
const route = useRoute()
const order = ref<Order | null>(null)
const letterText = ref('')
const loading = ref(true)
const loadError = ref('')
const submitting = ref(false)
const errorMessage = ref('')
const showPicker = ref(false)
const orderId = computed(() => route.params.orderId as string)
const plate = computed(() => order.value?.plate ?? '')
const canEdit = computed(() => order.value?.status === 'pending_payment')
const charCount = computed(() => letterText.value.length)
const maxChars = 1000
const canSubmit = computed(
() =>
canEdit.value && letterText.value.trim().length > 0 && !submitting.value,
)
const GDPR_FOOTER =
'Detta brev skickades via Bilhej. Din adress hämtades från Transportstyrelsens fordonsregister och har raderats efter utskick. För frågor: hej@bilhalsning.se'
function handleTemplateSelect(template: LetterTemplate) {
letterText.value = template.body
}
async function loadOrder() {
loading.value = true
loadError.value = ''
try {
const fetched = await fetchOrder(orderId.value)
order.value = fetched
if (fetched.status === 'pending_payment') {
letterText.value = fetched.letterText
}
} catch {
loadError.value = 'Kunde inte hämta beställningen. Försök igen senare.'
} finally {
loading.value = false
}
}
async function handleSubmit() {
if (!canSubmit.value || !order.value) return
submitting.value = true
errorMessage.value = ''
try {
await updateOrder(order.value.id, letterText.value)
await router.push({
name: 'payment',
params: { orderId: order.value.id },
query: { plate: order.value.plate },
})
} catch {
errorMessage.value = 'Kunde inte spara ändringarna. Försök igen senare.'
} finally {
submitting.value = false
}
}
onMounted(loadOrder)
</script>
<template>
<div class="compose">
<p
v-if="loading"
class="text-muted text-center compose__loading"
role="status"
>
Laddar beställning...
</p>
<div v-else-if="loadError" class="message message--error compose__error">
{{ loadError }}
<RouterLink to="/orders">Tillbaka till beställningar</RouterLink>
</div>
<div
v-else-if="order && !canEdit"
class="message message--error compose__error"
>
Den här beställningen kan inte redigeras.
<RouterLink to="/orders">Tillbaka till beställningar</RouterLink>
</div>
<div v-else-if="order && canEdit" class="compose__layout">
<div class="compose__editor">
<h1 class="compose__title">Redigera brev</h1>
<p class="compose__plate-badge">
<span class="compose__plate-label">Regnr</span>
<span class="compose__plate-value">{{ plate }}</span>
</p>
<form class="compose__form" @submit.prevent="handleSubmit">
<div class="field">
<div class="compose__label-row">
<label for="letter" class="field__label">Ditt meddelande</label>
<button
type="button"
class="compose__templates-btn"
@click="showPicker = true"
>
Visa mallar
</button>
</div>
<textarea
id="letter"
v-model="letterText"
class="field__input compose__textarea"
:maxlength="maxChars"
rows="12"
placeholder="Skriv ditt meddelande här..."
></textarea>
<p
class="field__hint compose__counter"
:class="{ 'compose__counter--warn': charCount > 900 }"
>
{{ charCount }} / {{ maxChars }} tecken
</p>
</div>
<div v-if="errorMessage" class="message message--error">
{{ errorMessage }}
</div>
<button
type="submit"
class="btn btn--primary btn--lg compose__submit"
:disabled="!canSubmit"
>
{{ submitting ? 'Sparar...' : 'Spara och fortsätt till betalning' }}
</button>
</form>
</div>
<div class="compose__preview">
<h2 class="compose__preview-title">Förhandsvisning</h2>
<div class="compose__preview-page">
<p class="compose__preview-plate-label">
Registreringsnummer: {{ plate }}
</p>
<p class="compose__preview-body">
{{ letterText }}
</p>
<hr class="compose__preview-divider" />
<p class="compose__preview-footer-text">{{ GDPR_FOOTER }}</p>
</div>
</div>
</div>
<TemplatePicker
v-if="showPicker"
@select="handleTemplateSelect"
@close="showPicker = false"
/>
</div>
</template>
<style scoped>
.compose__loading {
margin: var(--space-2xl) auto;
}
.compose__layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
align-items: start;
max-width: 56rem;
margin: var(--space-2xl) auto 0;
padding: 0 var(--space-lg);
}
.compose__title {
margin: 0 0 var(--space-lg) 0;
font-size: 1.5rem;
color: var(--color-ink);
}
.compose__plate-badge {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
background: var(--color-primary-soft);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-full);
margin: 0 0 var(--space-lg) 0;
}
.compose__plate-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-muted);
}
.compose__plate-value {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--color-primary-dark);
}
.compose__form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.compose__label-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.compose__templates-btn {
background: var(--color-primary-soft);
border: 1px solid #ddd6fe;
color: var(--color-primary-dark);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
padding: 0.3rem 0.75rem;
border-radius: var(--radius-full);
transition:
background var(--transition-fast),
border-color var(--transition-fast);
}
.compose__templates-btn:hover {
background: #e9d5ff;
border-color: #c4b5fd;
}
.compose__textarea {
resize: vertical;
min-height: 10rem;
font-family: inherit;
}
.compose__counter {
text-align: right;
}
.compose__counter--warn {
color: var(--color-danger) !important;
}
.compose__submit {
width: 100%;
}
.compose__error {
max-width: 28rem;
margin: var(--space-2xl) auto;
}
.compose__preview-body {
white-space: pre-wrap;
}
.compose__preview {
position: sticky;
top: 5rem;
}
.compose__preview-title {
margin: 0 0 var(--space-md) 0;
font-size: 1rem;
color: var(--color-muted);
}
.compose__preview-page {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-xl);
box-shadow: var(--shadow-lg);
font-family: var(--font-serif);
font-size: 0.9375rem;
line-height: 1.7;
color: var(--color-ink);
min-height: 20rem;
}
.compose__preview-plate-label {
margin: 0 0 var(--space-lg) 0;
font-family: var(--font-sans);
font-size: 0.8125rem;
color: var(--color-muted);
}
.compose__preview-body {
margin: 0 0 var(--space-lg) 0;
}
.compose__preview-divider {
margin: var(--space-lg) 0;
border: none;
border-top: 1px solid var(--color-border);
}
.compose__preview-footer-text {
margin: 0;
font-size: 0.75rem;
color: var(--color-soft);
font-family: var(--font-sans);
line-height: 1.5;
}
.message a {
color: var(--color-primary);
text-decoration: underline;
}
@media (max-width: 768px) {
.compose__layout {
grid-template-columns: 1fr;
}
.compose__preview {
position: static;
}
}
</style>

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchOrders, type Order } from '@/api/orders'
import { fetchOrders, cancelOrder, type Order } from '@/api/orders'
import { RouterLink } from 'vue-router'
const orders = ref<Order[]>([])
const loading = ref(true)
const error = ref('')
const actionError = ref('')
const cancellingId = ref<string | null>(null)
const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning',
@ -14,6 +16,7 @@ const statusLabels: Record<string, string> = {
sent: 'Skickat',
delivered: 'Levererat',
failed: 'Misslyckad',
cancelled: 'Avbruten',
}
const statusBadge: Record<string, string> = {
@ -23,6 +26,7 @@ const statusBadge: Record<string, string> = {
sent: 'badge--success',
delivered: 'badge--success',
failed: 'badge--danger',
cancelled: 'badge--muted',
}
function formatDate(iso: string): string {
@ -33,7 +37,7 @@ function formatDate(iso: string): string {
})
}
onMounted(async () => {
async function loadOrders() {
try {
orders.value = await fetchOrders()
} catch {
@ -41,7 +45,31 @@ onMounted(async () => {
} finally {
loading.value = false
}
})
}
async function handleCancel(order: Order) {
if (
!window.confirm(
'Vill du avbryta beställningen? Den kan inte återställas efteråt.',
)
) {
return
}
actionError.value = ''
cancellingId.value = order.id
try {
const updated = await cancelOrder(order.id)
orders.value = orders.value.map((o) => (o.id === updated.id ? updated : o))
} catch {
actionError.value = 'Kunde inte avbryta beställningen. Försök igen senare.'
} finally {
cancellingId.value = null
}
}
onMounted(loadOrders)
</script>
<template>
@ -73,64 +101,93 @@ onMounted(async () => {
</div>
</div>
<div v-else class="orders__list">
<div v-for="order in orders" :key="order.id" class="orders__card">
<div class="orders__card-top">
<span class="orders__plate">{{ order.plate }}</span>
<span
class="badge"
:class="statusBadge[order.status] || 'badge--muted'"
>
{{ statusLabels[order.status] || order.status }}
</span>
</div>
<template v-else>
<div
v-if="actionError"
class="message message--error orders__action-error"
role="alert"
>
{{ actionError }}
</div>
<div class="orders__card-meta">
<span class="orders__meta-label">Beställnings-ID</span>
<span class="orders__meta-value orders__order-id">{{
order.id
}}</span>
<span class="orders__meta-label">Meddelande</span>
<span class="orders__meta-value orders__message">{{
order.letterText
}}</span>
<span class="orders__meta-label">Datum</span>
<span class="orders__meta-value">{{
formatDate(order.createdAt)
}}</span>
<template v-if="order.trackingId">
<span class="orders__meta-label">Spårning</span>
<a
class="orders__tracking"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
<div class="orders__list">
<div v-for="order in orders" :key="order.id" class="orders__card">
<div class="orders__card-top">
<span class="orders__plate">{{ order.plate }}</span>
<span
class="badge"
:class="statusBadge[order.status] || 'badge--muted'"
>
{{ order.trackingId }}
</a>
</template>
</div>
{{ statusLabels[order.status] || order.status }}
</span>
</div>
<div
v-if="order.status === 'pending_payment'"
class="orders__card-actions"
>
<RouterLink
:to="{
name: 'payment',
params: { orderId: order.id },
query: { plate: order.plate },
}"
class="btn btn--primary orders__pay-btn"
<div class="orders__card-meta">
<span class="orders__meta-label">Beställnings-ID</span>
<span class="orders__meta-value orders__order-id">{{
order.id
}}</span>
<span class="orders__meta-label">Meddelande</span>
<span class="orders__meta-value orders__message">{{
order.letterText
}}</span>
<span class="orders__meta-label">Datum</span>
<span class="orders__meta-value">{{
formatDate(order.createdAt)
}}</span>
<template v-if="order.trackingId">
<span class="orders__meta-label">Spårning</span>
<a
class="orders__tracking"
:href="`https://www.postnord.se/verktyg/spara/?id=${order.trackingId}`"
target="_blank"
rel="noopener noreferrer"
>
{{ order.trackingId }}
</a>
</template>
</div>
<div
v-if="order.status === 'pending_payment'"
class="orders__card-actions"
>
Betala nu
</RouterLink>
<RouterLink
:to="{
name: 'payment',
params: { orderId: order.id },
query: { plate: order.plate },
}"
class="btn btn--primary orders__action-btn"
>
Betala nu
</RouterLink>
<RouterLink
:to="{
name: 'edit-order',
params: { orderId: order.id },
}"
class="btn btn--ghost orders__action-btn"
>
Redigera
</RouterLink>
<button
type="button"
class="btn btn--ghost orders__action-btn orders__cancel-btn"
:disabled="cancellingId === order.id"
@click="handleCancel(order)"
>
{{
cancellingId === order.id ? 'Avbryter...' : 'Avbryt beställning'
}}
</button>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
@ -153,6 +210,10 @@ onMounted(async () => {
color: var(--color-muted);
}
.orders__action-error {
margin-bottom: var(--space-md);
}
.orders__list {
display: flex;
flex-direction: column;
@ -226,16 +287,27 @@ onMounted(async () => {
}
.orders__card-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
border-top: 1px solid var(--color-border);
background: var(--color-border-light);
}
.orders__pay-btn {
.orders__action-btn {
width: 100%;
justify-content: center;
}
.orders__cancel-btn {
color: var(--color-danger);
}
.orders__cancel-btn:hover:not(:disabled) {
background: #fef2f2;
}
.orders__empty {
padding: var(--space-2xl) 0;
text-align: center;

View file

@ -9,6 +9,7 @@ import ForgotPasswordPage from '@/pages/ForgotPasswordPage.vue'
import ResetPasswordPage from '@/pages/ResetPasswordPage.vue'
import ChangePasswordPage from '@/pages/ChangePasswordPage.vue'
import OrdersPage from '@/pages/OrdersPage.vue'
import EditOrderPage from '@/pages/EditOrderPage.vue'
import AdminPage from '@/pages/AdminPage.vue'
import PaymentRedirect from '@/pages/PaymentRedirect.vue'
import { useAuthStore } from '@/stores/authStore'
@ -34,6 +35,12 @@ const router = createRouter({
component: OrdersPage,
meta: { requiresAuth: true },
},
{
path: '/bestallning/:orderId/redigera',
name: 'edit-order',
component: EditOrderPage,
meta: { requiresAuth: true },
},
{
path: '/andra-losenord',
name: 'change-password',