Compare commits

..

No commits in common. "0f613b21a6cdc870b9153333830fb1858d40610b" and "e8530b8d95530e249021867e914099fb8aa1ced7" have entirely different histories.

20 changed files with 87 additions and 358 deletions

View file

@ -23,6 +23,3 @@ STRIPE_WEBHOOK_SECRET=whsec_...
# Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products # Price ID from Stripe Dashboard: https://dashboard.stripe.com/test/products
STRIPE_PRICE_ID=price_... STRIPE_PRICE_ID=price_...
# ---------- Swish (Phase 0) ----------
SWISH_NUMBER=0701234567

View file

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

View file

@ -1,11 +1,7 @@
package se.bilhalsning.controller; package se.bilhalsning.controller;
import java.util.Map; import lombok.RequiredArgsConstructor;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@ -14,34 +10,21 @@ import se.bilhalsning.dto.OrderResponse;
import se.bilhalsning.entity.Order; import se.bilhalsning.entity.Order;
import se.bilhalsning.service.OrderService; import se.bilhalsning.service.OrderService;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/payment") @RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController { public class PaymentController {
private final OrderService orderService; private final OrderService orderService;
private final String swishNumber;
private final int letterPrice;
public PaymentController(
OrderService orderService,
@Value("${app.payment.swish-number}") String swishNumber,
@Value("${app.payment.letter-price}") int letterPrice) {
this.orderService = orderService;
this.swishNumber = swishNumber;
this.letterPrice = letterPrice;
}
@PostMapping("/{orderId}/pay") @PostMapping("/{orderId}/pay")
public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) { public ResponseEntity<OrderResponse> pay(@PathVariable UUID orderId) {
Order order = orderService.confirmPayment(orderId); Order order = orderService.markAsPaid(orderId);
return ResponseEntity.ok(toResponse(order)); return ResponseEntity.ok(toResponse(order));
} }
@GetMapping("/swish-info")
public ResponseEntity<Map<String, Object>> swishInfo() {
return ResponseEntity.ok(Map.of("number", swishNumber, "amount", letterPrice));
}
private OrderResponse toResponse(Order order) { private OrderResponse toResponse(Order order) {
return new OrderResponse( return new OrderResponse(
order.getId(), order.getId(),

View file

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

View file

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

View file

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

View file

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

View file

@ -25,8 +25,5 @@ spring:
locations: classpath:db/migration locations: classpath:db/migration
app: app:
payment:
swish-number: ${SWISH_NUMBER:0700000000}
letter-price: 49
jwt: jwt:
secret: ${JWT_SECRET:dev-secret-change-in-production} secret: ${JWT_SECRET:dev-secret-change-in-production}

View file

@ -10,7 +10,7 @@ CREATE TABLE orders (
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT pk_orders PRIMARY KEY (id), CONSTRAINT pk_orders PRIMARY KEY (id),
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'processing', 'sent', 'delivered', 'failed')) CONSTRAINT ck_orders_status CHECK (status IN ('pending_payment', 'paid', 'lookup_started', 'sent', 'delivered', 'failed'))
); );
CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_orders_user_id ON orders(user_id);

View file

@ -2,11 +2,11 @@ package se.bilhalsning.controller;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -40,39 +40,33 @@ class PaymentControllerTest {
@Test @Test
@WithMockUser(username = "test@bilhalsning.se") @WithMockUser(username = "test@bilhalsning.se")
void shouldConfirmPaymentSuccessfully() throws Exception { void shouldMarkOrderAsPaidSuccessfully() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
Order order = new Order(); Order order = new Order();
order.setId(orderId); order.setId(orderId);
order.setPlate("ABC123"); order.setPlate("ABC123");
order.setStatus(OrderStatus.PROCESSING); order.setStatus(OrderStatus.PAID);
order.setAmountPaid(new BigDecimal("49.00"));
when(orderService.confirmPayment(eq(orderId))).thenReturn(order); when(orderService.markAsPaid(eq(orderId))).thenReturn(order);
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId.toString())) .andExpect(jsonPath("$.id").value(orderId.toString()))
.andExpect(jsonPath("$.status").value("processing")); .andExpect(jsonPath("$.status").value("paid"))
.andExpect(jsonPath("$.amountPaid").value(49.00));
} }
@Test @Test
@WithMockUser(username = "test@bilhalsning.se") @WithMockUser(username = "test@bilhalsning.se")
void shouldReturn404WhenOrderNotFound() throws Exception { void shouldReturn404WhenOrderNotFound() throws Exception {
UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); UUID orderId = UUID.fromString("c1eebc99-9c0b-4ef8-bb6d-6bb9bd380a11");
when(orderService.confirmPayment(eq(orderId))) when(orderService.markAsPaid(eq(orderId)))
.thenThrow(new OrderNotFoundException(orderId)); .thenThrow(new OrderNotFoundException(orderId));
mockMvc.perform(post("/api/payment/{orderId}/pay", orderId) mockMvc.perform(post("/api/payment/{orderId}/pay", orderId)
.contentType(MediaType.APPLICATION_JSON)) .contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@Test
void shouldReturnSwishInfoUnauthenticated() throws Exception {
mockMvc.perform(get("/api/payment/swish-info"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.number").exists())
.andExpect(jsonPath("$.amount").exists());
}
} }

View file

@ -29,7 +29,6 @@ services:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
SWISH_NUMBER: ${SWISH_NUMBER}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
STRIPE_PRICE_ID: ${STRIPE_PRICE_ID} STRIPE_PRICE_ID: ${STRIPE_PRICE_ID}

View file

@ -28,8 +28,7 @@ test.describe('Payment redirect', () => {
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click() await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await page.getByRole('button', { name: 'Jag har betalat' }).click() await page.getByRole('button', { name: 'Genomför testbetalning' }).click()
await page.getByRole('button', { name: 'Ja, jag har betalat' }).click()
await expect(page).toHaveURL('/orders') await expect(page).toHaveURL('/orders')
await expect(page.getByText('DEF456').first()).toBeVisible() await expect(page.getByText('DEF456').first()).toBeVisible()
@ -42,13 +41,12 @@ test.describe('Payment redirect', () => {
await expect(page).toHaveURL(/\/logga-in/) await expect(page).toHaveURL(/\/logga-in/)
}) })
test('shows Swish payment instructions', async ({ page }) => { test('shows mock payment note', async ({ page }) => {
await page.goto('/compose?plate=GHI789') await page.goto('/compose?plate=GHI789')
await page.getByLabel('Ditt meddelande').fill('Hej!') await page.getByLabel('Ditt meddelande').fill('Hej!')
await page.getByRole('button', { name: 'Fortsätt till betalning' }).click() await page.getByRole('button', { name: 'Fortsätt till betalning' }).click()
await page.waitForURL(/\/betalning\//) await page.waitForURL(/\/betalning\//)
await expect(page.getByText('Swisha till')).toBeVisible() await expect(page.locator('.payment__note')).toBeVisible()
await expect(page.getByRole('button', { name: 'Jag har betalat' })).toBeVisible()
}) })
}) })

View file

@ -50,7 +50,7 @@ const mockOrders = [
email: 'user@example.com', email: 'user@example.com',
plate: 'XYZ789', plate: 'XYZ789',
letterText: 'Vill köpa din bil.', letterText: 'Vill köpa din bil.',
status: 'processing', status: 'pending_payment',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: null,
createdAt: '2026-05-14T13:00:00Z', createdAt: '2026-05-14T13:00:00Z',
@ -301,19 +301,4 @@ describe('AdminDashboard', () => {
expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID') expect(wrapper.text()).toContain('Kunde inte spara spårnings-ID')
}) })
it('shows Att göra stat for processing orders', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Att göra')
})
it('highlights processing rows', async () => {
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
const rows = wrapper.findAll('.admin__row')
expect(rows[1].classes()).toContain('admin__row--todo')
})
}) })

View file

@ -155,24 +155,4 @@ describe('OrdersPage', () => {
expect(badges[0].classes()).toContain('badge--success') expect(badges[0].classes()).toContain('badge--success')
expect(badges[1].classes()).toContain('badge--muted') expect(badges[1].classes()).toContain('badge--muted')
}) })
it('renders processing status correctly', async () => {
const ordersWithProcessing = [
{
id: 'c4eebc99-9c0b-4ef8-bb6d-6bb9bd380a14',
plate: 'XYZ123',
status: 'processing',
trackingId: null,
createdAt: '2026-05-15T10:00:00Z',
},
]
vi.mocked(globalThis.fetch).mockResolvedValue(
mockFetchResponse(200, ordersWithProcessing),
)
const { wrapper } = mountPage()
await new Promise((r) => setTimeout(r, 50))
expect(wrapper.text()).toContain('Hanteras')
const badge = wrapper.find('.badge')
expect(badge.classes()).toContain('badge--primary')
})
}) })

View file

@ -7,12 +7,10 @@ import OrdersPage from '@/pages/OrdersPage.vue'
vi.mock('@/api/payment', () => ({ vi.mock('@/api/payment', () => ({
payOrder: vi.fn(), payOrder: vi.fn(),
fetchSwishInfo: vi.fn(),
})) }))
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder } from '@/api/payment'
const mockPayOrder = vi.mocked(payOrder) const mockPayOrder = vi.mocked(payOrder)
const mockFetchSwishInfo = vi.mocked(fetchSwishInfo)
function createTestRouter() { function createTestRouter() {
return createRouter({ return createRouter({
@ -55,17 +53,11 @@ async function mountPage(orderId = 'order-1', plate = 'ABC123') {
describe('PaymentRedirect', () => { describe('PaymentRedirect', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockFetchSwishInfo.mockResolvedValue({
number: '0701234567',
amount: 49,
})
}) })
it('renders heading and amount', async () => { it('renders heading and amount', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Betalning') expect(wrapper.text()).toContain('Betalning')
})
expect(wrapper.text()).toContain('49 kr') expect(wrapper.text()).toContain('49 kr')
}) })
@ -74,132 +66,71 @@ describe('PaymentRedirect', () => {
expect(wrapper.text()).toContain('ABC123') expect(wrapper.text()).toContain('ABC123')
}) })
it('shows Swish payment button', async () => { it('shows payment button', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { const button = wrapper.find('.btn--primary')
expect(wrapper.text()).toContain('Jag har betalat') expect(button.exists()).toBe(true)
}) expect(button.text()).toBe('Genomför testbetalning')
}) })
it('shows confirmation dialog after clicking pay button', async () => { it('shows test payment note', async () => {
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { expect(wrapper.text()).toContain('testbetalning')
expect(wrapper.find('.btn--primary').exists()).toBe(true)
}) })
await wrapper.find('.btn--primary').trigger('click') it('calls payOrder on button click', async () => {
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('can cancel confirmation dialog', async () => {
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Avbryt')
})
await wrapper.find('.btn--ghost').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Swisha till')
expect(wrapper.text()).not.toContain('Avbryt')
})
})
it('calls payOrder on confirmation', async () => {
mockPayOrder.mockResolvedValue({ mockPayOrder.mockResolvedValue({
id: 'order-1', id: 'order-1',
plate: 'ABC123', plate: 'ABC123',
status: 'processing', status: 'paid',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: 49.0,
createdAt: '2025-01-01T00:00:00Z', createdAt: '2025-01-01T00:00:00Z',
}) })
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
expect(mockPayOrder).toHaveBeenCalledWith('order-1') expect(mockPayOrder).toHaveBeenCalledWith('order-1')
}) })
it('shows error on payment confirmation failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Kunde inte bekräfta betalningen')
})
})
it('navigates to orders on success', async () => { it('navigates to orders on success', async () => {
mockPayOrder.mockResolvedValue({ mockPayOrder.mockResolvedValue({
id: 'order-1', id: 'order-1',
plate: 'ABC123', plate: 'ABC123',
status: 'processing', status: 'paid',
trackingId: null, trackingId: null,
amountPaid: null, amountPaid: 49.0,
createdAt: '2025-01-01T00:00:00Z', createdAt: '2025-01-01T00:00:00Z',
}) })
const { wrapper, router } = await mountPage() const { wrapper, router } = await mountPage()
await vi.waitFor(() => {
expect(wrapper.find('.btn--primary').exists()).toBe(true)
})
await wrapper.find('.btn--primary').trigger('click') await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Ja, jag har betalat')
})
const confirmButtons = wrapper.findAll('.btn--primary')
await confirmButtons[confirmButtons.length - 1].trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(router.currentRoute.value.name).toBe('orders') expect(router.currentRoute.value.name).toBe('orders')
}) })
}) })
it('displays Swish number from API', async () => { it('shows error on payment failure', async () => {
mockPayOrder.mockRejectedValue(new Error('Network error'))
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await wrapper.find('.btn--primary').trigger('click')
await vi.waitFor(() => { await vi.waitFor(() => {
expect(wrapper.text()).toContain('0701234567') expect(wrapper.text()).toContain('Kunde inte genomföra betalningen')
}) })
}) })
it('shows error when swish info fetch fails', async () => { it('disables button while paying', async () => {
mockFetchSwishInfo.mockRejectedValue(new Error('Network error')) mockPayOrder.mockImplementation(() => new Promise(() => {}))
const { wrapper } = await mountPage() const { wrapper } = await mountPage()
await vi.waitFor(() => { const button = wrapper.find('.btn--primary')
expect(wrapper.text()).toContain('Kunde inte ladda betalningsinformation') await button.trigger('click')
})
expect(button.attributes('disabled')).toBeDefined()
expect(button.text()).toBe('Bearbetar...')
}) })
}) })

View file

@ -1,17 +1,8 @@
import { request } from './client' import { request } from './client'
import type { Order } from './orders' import type { Order } from './orders'
export interface SwishInfo {
number: string
amount: number
}
export function payOrder(orderId: string): Promise<Order> { export function payOrder(orderId: string): Promise<Order> {
return request<Order>(`/payment/${orderId}/pay`, { return request<Order>(`/payment/${orderId}/pay`, {
method: 'POST', method: 'POST',
}) })
} }
export function fetchSwishInfo(): Promise<SwishInfo> {
return request<SwishInfo>('/payment/swish-info')
}

View file

@ -18,7 +18,7 @@ const trackingInputValues = reactive<Record<string, string>>({})
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
pending_payment: 'Väntar på betalning', pending_payment: 'Väntar på betalning',
paid: 'Betalad', paid: 'Betalad',
processing: 'Hanteras', lookup_started: 'Hanteras',
sent: 'Skickat', sent: 'Skickat',
delivered: 'Levererat', delivered: 'Levererat',
failed: 'Misslyckad', failed: 'Misslyckad',
@ -26,8 +26,8 @@ const statusLabels: Record<string, string> = {
const statusBadge: Record<string, string> = { const statusBadge: Record<string, string> = {
pending_payment: 'badge--muted', pending_payment: 'badge--muted',
paid: 'badge--success', paid: 'badge--primary',
processing: 'badge--primary', lookup_started: 'badge--primary',
sent: 'badge--success', sent: 'badge--success',
delivered: 'badge--success', delivered: 'badge--success',
failed: 'badge--danger', failed: 'badge--danger',
@ -36,7 +36,7 @@ const statusBadge: Record<string, string> = {
const allStatuses = [ const allStatuses = [
'pending_payment', 'pending_payment',
'paid', 'paid',
'processing', 'lookup_started',
'sent', 'sent',
'delivered', 'delivered',
'failed', 'failed',
@ -44,14 +44,16 @@ const allStatuses = [
const stats = computed(() => { const stats = computed(() => {
const total = orders.value.length const total = orders.value.length
const todo = orders.value.filter((o) => o.status === 'processing').length
const paid = orders.value.filter((o) => const paid = orders.value.filter((o) =>
['paid', 'sent', 'delivered'].includes(o.status), ['paid', 'lookup_started', 'sent', 'delivered'].includes(o.status),
).length ).length
const pending = orders.value.filter( const pending = orders.value.filter(
(o) => o.status === 'pending_payment', (o) => o.status === 'pending_payment',
).length ).length
return { total, todo, paid, pending } const sent = orders.value.filter(
(o) => o.status === 'sent' || o.status === 'delivered',
).length
return { total, paid, pending, sent }
}) })
function formatDate(iso: string): string { function formatDate(iso: string): string {
@ -142,10 +144,6 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.total }}</span> <span class="admin__stat-value">{{ stats.total }}</span>
<span class="admin__stat-label">Totalt</span> <span class="admin__stat-label">Totalt</span>
</div> </div>
<div class="admin__stat admin__stat--todo">
<span class="admin__stat-value">{{ stats.todo }}</span>
<span class="admin__stat-label">Att göra</span>
</div>
<div class="admin__stat"> <div class="admin__stat">
<span class="admin__stat-value">{{ stats.paid }}</span> <span class="admin__stat-value">{{ stats.paid }}</span>
<span class="admin__stat-label">Betalda</span> <span class="admin__stat-label">Betalda</span>
@ -154,6 +152,10 @@ onMounted(async () => {
<span class="admin__stat-value">{{ stats.pending }}</span> <span class="admin__stat-value">{{ stats.pending }}</span>
<span class="admin__stat-label">Väntar</span> <span class="admin__stat-label">Väntar</span>
</div> </div>
<div class="admin__stat">
<span class="admin__stat-value">{{ stats.sent }}</span>
<span class="admin__stat-label">Skickade</span>
</div>
</div> </div>
<p <p
@ -181,7 +183,6 @@ onMounted(async () => {
class="admin__row" class="admin__row"
:class="{ :class="{
'admin__row--expanded': expandedOrderId === order.id, 'admin__row--expanded': expandedOrderId === order.id,
'admin__row--todo': order.status === 'processing',
}" }"
> >
<td>{{ formatDate(order.createdAt) }}</td> <td>{{ formatDate(order.createdAt) }}</td>
@ -345,11 +346,6 @@ onMounted(async () => {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.admin__stat--todo {
background: var(--color-primary-soft);
border-color: var(--color-primary);
}
.admin__stat-value { .admin__stat-value {
display: block; display: block;
font-size: 1.5rem; font-size: 1.5rem;
@ -414,10 +410,6 @@ onMounted(async () => {
background: var(--color-primary-soft) !important; background: var(--color-primary-soft) !important;
} }
.admin__row--todo {
border-left: 3px solid var(--color-primary);
}
.admin__row td { .admin__row td {
padding: 0.75rem var(--space-md); padding: 0.75rem var(--space-md);
color: var(--color-ink); color: var(--color-ink);

View file

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

View file

@ -1,37 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { payOrder, fetchSwishInfo } from '@/api/payment' import { payOrder } from '@/api/payment'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const orderId = route.params.orderId as string const orderId = route.params.orderId as string
const swishNumber = ref('')
const swishAmount = ref(49)
const paying = ref(false) const paying = ref(false)
const error = ref('') const error = ref('')
const showConfirmation = ref(false)
onMounted(async () => { async function handlePay() {
try {
const info = await fetchSwishInfo()
swishNumber.value = info.number
swishAmount.value = info.amount
} catch {
error.value = 'Kunde inte ladda betalningsinformation. Försök igen senare.'
}
})
function startPayment() {
showConfirmation.value = true
}
function cancelPayment() {
showConfirmation.value = false
}
async function confirmPayment() {
paying.value = true paying.value = true
error.value = '' error.value = ''
@ -39,7 +18,7 @@ async function confirmPayment() {
await payOrder(orderId) await payOrder(orderId)
await router.push({ name: 'orders' }) await router.push({ name: 'orders' })
} catch { } catch {
error.value = 'Kunde inte bekräfta betalningen. Försök igen.' error.value = 'Kunde inte genomföra betalningen. Försök igen.'
} finally { } finally {
paying.value = false paying.value = false
} }
@ -57,7 +36,7 @@ async function confirmPayment() {
<div class="payment__summary"> <div class="payment__summary">
<div class="payment__row"> <div class="payment__row">
<span class="payment__label">Att betala</span> <span class="payment__label">Att betala</span>
<span class="payment__amount">{{ swishAmount }} kr</span> <span class="payment__amount">49 kr</span>
</div> </div>
</div> </div>
@ -69,51 +48,17 @@ async function confirmPayment() {
{{ error }} {{ error }}
</div> </div>
<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 knappen nedan för att bekräfta.
</p>
</div>
<button <button
class="btn btn--primary btn--lg payment__submit" class="btn btn--primary btn--lg payment__submit"
@click="startPayment" :disabled="paying"
@click="handlePay"
> >
Jag har betalat {{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
</button> </button>
</template>
<template v-else> <p class="payment__note">
<div class="payment__confirm"> Detta är en testbetalning i utvecklingsmiljön.
<p class="payment__confirm-text">
Jag bekräftar att jag har Swishat {{ swishAmount }} kr till
{{ swishNumber }} med meddelande: {{ orderId }}.
</p> </p>
<div class="payment__confirm-actions">
<button
class="btn btn--ghost payment__confirm-cancel"
:disabled="paying"
@click="cancelPayment"
>
Avbryt
</button>
<button
class="btn btn--primary"
:disabled="paying"
@click="confirmPayment"
>
{{ paying ? 'Bearbetar...' : 'Ja, jag har betalat' }}
</button>
</div>
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
@ -168,73 +113,14 @@ async function confirmPayment() {
color: var(--color-ink); color: var(--color-ink);
} }
.payment__swish {
background: var(--color-border-light);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
text-align: center;
}
.payment__swish-label {
margin: 0 0 var(--space-xs) 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.payment__swish-number {
margin: 0 0 var(--space-md) 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--color-ink);
letter-spacing: 0.05em;
}
.payment__swish-instruction {
margin: 0;
font-size: 0.8125rem;
color: var(--color-muted);
line-height: 1.5;
}
.payment__swish-instruction + .payment__swish-instruction {
margin-top: var(--space-xs);
}
.payment__order-id {
word-break: break-all;
color: var(--color-ink);
}
.payment__submit { .payment__submit {
width: 100%; width: 100%;
} }
.payment__confirm { .payment__note {
padding: var(--space-md) 0; margin: var(--space-md) 0 0 0;
} font-size: 0.75rem;
color: var(--color-soft);
.payment__confirm-text { text-align: center;
margin: 0 0 var(--space-lg) 0;
font-size: 0.9375rem;
color: var(--color-ink);
line-height: 1.6;
}
.payment__confirm-actions {
display: flex;
gap: var(--space-md);
}
.payment__confirm-cancel {
flex: 1;
}
.payment__confirm-actions .btn--primary {
flex: 2;
} }
</style> </style>

View file

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