Adds an anonymous guest checkout flow so a customer can order a bilhälsning without creating an account. Payment via Swish (QR + payment link). Backend: - GuestOrderController: POST /api/guest-orders (public, no auth) - CreateGuestOrderRequest / GuestOrderResponse DTOs - Order entity: guest_email, guest_token (UUID), nullable user_id - OrderRepository: findByGuestToken, findByGuestEmail - OrderService: createGuestOrder, getGuestOrder by token - SecurityConfig: /api/guest-orders/** permitAll - V12 migration: drops user_id NOT NULL, adds guest_email + guest_token with partial unique index (backfill-safe for existing user orders) Frontend: - GuestCheckoutPage: plate lookup + order form (no login) - GuestPaymentRedirect: Swish QR + payment link + status polling - GuestOrderPage: order status by guest token - guestOrders.ts API client - router: /guest/* public routes - vite.config: dev proxy for /api/guest-orders Verification: - [x] vue-tsc type-check passes (exit 0) - [ ] Backend Java compiles (no JDK/docker in agent sandbox) - [ ] Flyway V12 migration applies cleanly - [ ] End-to-end POST /api/guest-orders -> 201 -> Swish -> status Frontend type-checks but backend has NOT been compiled or run yet. This PR is for review; backend smoke test pending in a docker environment.
128 lines
4.8 KiB
Java
128 lines
4.8 KiB
Java
package se.bilhalsning.service;
|
|
|
|
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;
|
|
|
|
import java.util.List;
|
|
import java.util.UUID;
|
|
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class OrderService {
|
|
|
|
private final OrderRepository orderRepository;
|
|
private final OrderNotificationService orderNotificationService;
|
|
|
|
public Order createOrder(UUID userId, String plate, String letterText) {
|
|
Order order = new Order();
|
|
order.setUserId(userId);
|
|
order.setPlate(plate.toUpperCase().trim());
|
|
order.setLetterText(letterText);
|
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
|
return orderRepository.save(order);
|
|
}
|
|
|
|
/**
|
|
* Guest checkout — creates an order with no registered user. The
|
|
* {@code guestToken} is generated in {@link Order#onCreate()} so the
|
|
* caller does not need to handle token creation logic. Returned
|
|
* order's {@link Order#getGuestToken()} is the only credential the
|
|
* client receives.
|
|
*/
|
|
public Order createGuestOrder(String plate, String letterText, String email) {
|
|
Order order = new Order();
|
|
// userId stays null — guest order. guestToken auto-generated in PrePersist.
|
|
order.setGuestEmail(email == null ? null : email.trim().toLowerCase());
|
|
order.setPlate(plate.toUpperCase().trim());
|
|
order.setLetterText(letterText);
|
|
order.setStatus(OrderStatus.PENDING_PAYMENT);
|
|
return orderRepository.save(order);
|
|
}
|
|
|
|
/**
|
|
* Guest-path order lookup by opaque token. Never exposes user-owned
|
|
* orders via this path: if a guest token resolves to an order with a
|
|
* non-null {@code userId} (corrupted data, manual SQL insert, etc.),
|
|
* treat as not-found rather than leak the order.
|
|
*/
|
|
public Order getOrderByGuestToken(UUID guestToken) {
|
|
Order order = orderRepository.findByGuestToken(guestToken)
|
|
.orElseThrow(() -> new OrderNotFoundException(guestToken));
|
|
if (order.getUserId() != null) {
|
|
// Token points at a user-owned order — refuse to serve it.
|
|
throw new OrderNotFoundException(guestToken);
|
|
}
|
|
return order;
|
|
}
|
|
|
|
/**
|
|
* Honor-system payment confirmation for guest orders. Mirrors
|
|
* {@link #confirmPayment(UUID, UUID)} but authenticates via the
|
|
* guest token instead of {@code userId}.
|
|
*/
|
|
public Order confirmGuestPayment(UUID guestToken) {
|
|
Order order = getOrderByGuestToken(guestToken);
|
|
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
|
|
throw new InvalidOrderStateException(
|
|
"Beställningen kan inte ändras i detta tillstånd");
|
|
}
|
|
order.setStatus(OrderStatus.PROCESSING);
|
|
Order saved = orderRepository.save(order);
|
|
orderNotificationService.notifyOrderProcessing(saved);
|
|
return saved;
|
|
}
|
|
|
|
public List<Order> getOrdersByUserId(UUID userId) {
|
|
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
|
}
|
|
|
|
public Order getOrderById(UUID id) {
|
|
return orderRepository.findById(id)
|
|
.orElseThrow(() -> new OrderNotFoundException(id));
|
|
}
|
|
|
|
public List<Order> getAllOrders() {
|
|
return orderRepository.findAllByOrderByCreatedAtDesc();
|
|
}
|
|
|
|
public Order confirmPayment(UUID orderId, UUID userId) {
|
|
Order order = requirePendingOwnedBy(orderId, userId);
|
|
order.setStatus(OrderStatus.PROCESSING);
|
|
Order saved = orderRepository.save(order);
|
|
orderNotificationService.notifyOrderProcessing(saved);
|
|
return saved;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|