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
This commit is contained in:
parent
e8530b8d95
commit
98d5545be0
18 changed files with 351 additions and 84 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package se.bilhalsning.controller;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
|
@ -10,21 +14,34 @@ import se.bilhalsning.dto.OrderResponse;
|
|||
import se.bilhalsning.entity.Order;
|
||||
import se.bilhalsning.service.OrderService;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/payment")
|
||||
@RequiredArgsConstructor
|
||||
public class PaymentController {
|
||||
|
||||
private final OrderService orderService;
|
||||
private final String swishNumber;
|
||||
private final int letterPrice;
|
||||
|
||||
public PaymentController(
|
||||
OrderService orderService,
|
||||
@Value("${app.payment.swish-number}") String swishNumber,
|
||||
@Value("${app.payment.letter-price}") int letterPrice) {
|
||||
this.orderService = orderService;
|
||||
this.swishNumber = swishNumber;
|
||||
this.letterPrice = letterPrice;
|
||||
}
|
||||
|
||||
@PostMapping("/{orderId}/pay")
|
||||
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
|
||||
Order order = orderService.markAsPaid(orderId);
|
||||
Order order = orderService.confirmPayment(orderId);
|
||||
return ResponseEntity.ok(toResponse(order));
|
||||
}
|
||||
|
||||
@GetMapping("/swish-info")
|
||||
public ResponseEntity<Map<String, Object>> swishInfo() {
|
||||
return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice));
|
||||
}
|
||||
|
||||
private OrderResponse toResponse(Order order) {
|
||||
return new OrderResponse(
|
||||
order.getId(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { request } from './client'
|
||||
import type { Order } from './orders'
|
||||
|
||||
export interface SwishInfo {
|
||||
number: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export function payOrder(orderId: string): Promise<Order> {
|
||||
return request<Order>(`/payment/${orderId}/pay`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSwishInfo(): Promise<SwishInfo> {
|
||||
return request<SwishInfo>('/payment/swish-info')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const trackingInputValues = reactive<Record<string, string>>({})
|
|||
const statusLabels: Record<string, string> = {
|
||||
pending_payment: 'Väntar på betalning',
|
||||
paid: 'Betalad',
|
||||
lookup_started: 'Hanteras',
|
||||
processing: 'Hanteras',
|
||||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
|
|
@ -26,8 +26,8 @@ const statusLabels: Record<string, string> = {
|
|||
|
||||
const statusBadge: Record<string, string> = {
|
||||
pending_payment: 'badge--muted',
|
||||
paid: 'badge--primary',
|
||||
lookup_started: 'badge--primary',
|
||||
paid: 'badge--success',
|
||||
processing: 'badge--primary',
|
||||
sent: 'badge--success',
|
||||
delivered: 'badge--success',
|
||||
failed: 'badge--danger',
|
||||
|
|
@ -36,7 +36,7 @@ const statusBadge: Record<string, string> = {
|
|||
const allStatuses = [
|
||||
'pending_payment',
|
||||
'paid',
|
||||
'lookup_started',
|
||||
'processing',
|
||||
'sent',
|
||||
'delivered',
|
||||
'failed',
|
||||
|
|
@ -44,16 +44,14 @@ const allStatuses = [
|
|||
|
||||
const stats = computed(() => {
|
||||
const total = orders.value.length
|
||||
const todo = orders.value.filter((o) => o.status === 'processing').length
|
||||
const paid = orders.value.filter((o) =>
|
||||
['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
|
||||
['paid', 'sent', 'delivered'].includes(o.status),
|
||||
).length
|
||||
const pending = orders.value.filter(
|
||||
(o) => o.status === 'pending_payment',
|
||||
).length
|
||||
const sent = orders.value.filter(
|
||||
(o) => o.status === 'sent' || o.status === 'delivered',
|
||||
).length
|
||||
return { total, paid, pending, sent }
|
||||
return { total, todo, paid, pending }
|
||||
})
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
|
|
@ -144,6 +142,10 @@ onMounted(async () => {
|
|||
<span class="admin__stat-value">{{ stats.total }}</span>
|
||||
<span class="admin__stat-label">Totalt</span>
|
||||
</div>
|
||||
<div class="admin__stat admin__stat--todo">
|
||||
<span class="admin__stat-value">{{ stats.todo }}</span>
|
||||
<span class="admin__stat-label">Att göra</span>
|
||||
</div>
|
||||
<div class="admin__stat">
|
||||
<span class="admin__stat-value">{{ stats.paid }}</span>
|
||||
<span class="admin__stat-label">Betalda</span>
|
||||
|
|
@ -152,10 +154,6 @@ onMounted(async () => {
|
|||
<span class="admin__stat-value">{{ stats.pending }}</span>
|
||||
<span class="admin__stat-label">Väntar</span>
|
||||
</div>
|
||||
<div class="admin__stat">
|
||||
<span class="admin__stat-value">{{ stats.sent }}</span>
|
||||
<span class="admin__stat-label">Skickade</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
|
|
@ -183,6 +181,7 @@ onMounted(async () => {
|
|||
class="admin__row"
|
||||
:class="{
|
||||
'admin__row--expanded': expandedOrderId === order.id,
|
||||
'admin__row--todo': order.status === 'processing',
|
||||
}"
|
||||
>
|
||||
<td>{{ formatDate(order.createdAt) }}</td>
|
||||
|
|
@ -346,6 +345,11 @@ onMounted(async () => {
|
|||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin__stat--todo {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.admin__stat-value {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
|
|
@ -410,6 +414,10 @@ onMounted(async () => {
|
|||
background: var(--color-primary-soft) !important;
|
||||
}
|
||||
|
||||
.admin__row--todo {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.admin__row td {
|
||||
padding: 0.75rem var(--space-md);
|
||||
color: var(--color-ink);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const error = ref('')
|
|||
const statusLabels: Record<string, string> = {
|
||||
pending_payment: 'Väntar på betalning',
|
||||
paid: 'Betalad',
|
||||
lookup_started: 'Hanteras',
|
||||
processing: 'Hanteras',
|
||||
sent: 'Skickat',
|
||||
delivered: 'Levererat',
|
||||
failed: 'Misslyckad',
|
||||
|
|
@ -18,8 +18,8 @@ const statusLabels: Record<string, string> = {
|
|||
|
||||
const statusBadge: Record<string, string> = {
|
||||
pending_payment: 'badge--muted',
|
||||
paid: 'badge--primary',
|
||||
lookup_started: 'badge--primary',
|
||||
paid: 'badge--success',
|
||||
processing: 'badge--primary',
|
||||
sent: 'badge--success',
|
||||
delivered: 'badge--success',
|
||||
failed: 'badge--danger',
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { payOrder } from '@/api/payment'
|
||||
import { payOrder, fetchSwishInfo } from '@/api/payment'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const orderId = route.params.orderId as string
|
||||
const swishNumber = ref('')
|
||||
const swishAmount = ref(49)
|
||||
const paying = ref(false)
|
||||
const error = ref('')
|
||||
const showConfirmation = ref(false)
|
||||
|
||||
async function handlePay() {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const info = await fetchSwishInfo()
|
||||
swishNumber.value = info.number
|
||||
swishAmount.value = info.amount
|
||||
} catch {
|
||||
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
|
||||
}
|
||||
})
|
||||
|
||||
function startPayment() {
|
||||
showConfirmation.value = true
|
||||
}
|
||||
|
||||
function cancelPayment() {
|
||||
showConfirmation.value = false
|
||||
}
|
||||
|
||||
async function confirmPayment() {
|
||||
paying.value = true
|
||||
error.value = ''
|
||||
|
||||
|
|
@ -18,7 +39,7 @@ async function handlePay() {
|
|||
await payOrder(orderId)
|
||||
await router.push({ name: 'orders' })
|
||||
} catch {
|
||||
error.value = 'Kunde inte genomföra betalningen. Försök igen.'
|
||||
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
|
|
@ -36,7 +57,7 @@ async function handlePay() {
|
|||
<div class="payment__summary">
|
||||
<div class="payment__row">
|
||||
<span class="payment__label">Att betala</span>
|
||||
<span class="payment__amount">49 kr</span>
|
||||
<span class="payment__amount">{{ swishAmount }} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -48,17 +69,51 @@ async function handlePay() {
|
|||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn--primary btn--lg payment__submit"
|
||||
:disabled="paying"
|
||||
@click="handlePay"
|
||||
>
|
||||
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
|
||||
</button>
|
||||
<template v-if="!showConfirmation">
|
||||
<div class="payment__swish">
|
||||
<p class="payment__swish-label">Swisha till</p>
|
||||
<p class="payment__swish-number">{{ swishNumber }}</p>
|
||||
<p class="payment__swish-instruction">
|
||||
Ange <strong class="payment__order-id">{{ orderId }}</strong>
|
||||
som meddelande i Swish-appen.
|
||||
</p>
|
||||
<p class="payment__swish-instruction">
|
||||
Tryck sedan på knappen nedan för att bekräfta.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="payment__note">
|
||||
Detta är en testbetalning i utvecklingsmiljön.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn--primary btn--lg payment__submit"
|
||||
@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>
|
||||
</template>
|
||||
|
|
@ -113,14 +168,73 @@ async function handlePay() {
|
|||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.payment__swish {
|
||||
background: var(--color-border-light);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
margin-bottom: var(--space-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.payment__swish-label {
|
||||
margin: 0 0 var(--space-xs) 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.payment__swish-number {
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.payment__swish-instruction {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.payment__swish-instruction + .payment__swish-instruction {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.payment__order-id {
|
||||
word-break: break-all;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.payment__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment__note {
|
||||
margin: var(--space-md) 0 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-soft);
|
||||
text-align: center;
|
||||
.payment__confirm {
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.payment__confirm-text {
|
||||
margin: 0 0 var(--space-lg) 0;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-ink);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.payment__confirm-actions {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.payment__confirm-cancel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.payment__confirm-actions .btn--primary {
|
||||
flex: 2;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in a new issue