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:
Joakim Mörling 2026-05-19 19:23:37 +02:00
parent e8530b8d95
commit 98d5545be0
18 changed files with 351 additions and 84 deletions

View file

@ -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

View file

@ -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())

View file

@ -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(),

View file

@ -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

View file

@ -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");

View file

@ -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);
}
}

View file

@ -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}

View file

@ -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}

View file

@ -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);

View file

@ -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());
}
}

View file

@ -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}

View file

@ -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')
})
})

View file

@ -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')
})
})

View file

@ -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')
})
})
})

View file

@ -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')
}

View file

@ -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);

View file

@ -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',

View file

@ -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 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>