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
|
# 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ 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())
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package se.bilhalsning.controller;
|
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.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;
|
||||||
|
|
@ -10,21 +14,34 @@ 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.markAsPaid(orderId);
|
Order order = orderService.confirmPayment(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(),
|
||||||
|
|
|
||||||
|
|
@ -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|lookup_started|sent|delivered|failed",
|
regexp = "pending_payment|paid|processing|sent|delivered|failed",
|
||||||
message = "Ogiltig status"
|
message = "Ogiltig status"
|
||||||
)
|
)
|
||||||
String status
|
String status
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
LOOKUP_STARTED("lookup_started"),
|
PROCESSING("processing"),
|
||||||
SENT("sent"),
|
SENT("sent"),
|
||||||
DELIVERED("delivered"),
|
DELIVERED("delivered"),
|
||||||
FAILED("failed");
|
FAILED("failed");
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -56,12 +55,11 @@ public class OrderService {
|
||||||
return orderRepository.save(order);
|
return orderRepository.save(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Order markAsPaid(UUID orderId) {
|
public Order confirmPayment(UUID orderId) {
|
||||||
Order order = orderRepository.findById(orderId)
|
Order order = orderRepository.findById(orderId)
|
||||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||||
|
|
||||||
order.setStatus(OrderStatus.PAID);
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
order.setAmountPaid(new BigDecimal("49.00"));
|
|
||||||
return orderRepository.save(order);
|
return orderRepository.save(order);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,8 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,8 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -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', '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);
|
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.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,33 +40,39 @@ class PaymentControllerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "test@bilhalsning.se")
|
@WithMockUser(username = "test@bilhalsning.se")
|
||||||
void shouldMarkOrderAsPaidSuccessfully() throws Exception {
|
void shouldConfirmPaymentSuccessfully() 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.PAID);
|
order.setStatus(OrderStatus.PROCESSING);
|
||||||
order.setAmountPaid(new BigDecimal("49.00"));
|
|
||||||
|
|
||||||
when(orderService.markAsPaid(eq(orderId))).thenReturn(order);
|
when(orderService.confirmPayment(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("paid"))
|
.andExpect(jsonPath("$.status").value("processing"));
|
||||||
.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.markAsPaid(eq(orderId)))
|
when(orderService.confirmPayment(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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ 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}
|
||||||
|
|
|
||||||
|
|
@ -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: 'pending_payment',
|
status: 'processing',
|
||||||
trackingId: null,
|
trackingId: null,
|
||||||
amountPaid: null,
|
amountPaid: null,
|
||||||
createdAt: '2026-05-14T13:00:00Z',
|
createdAt: '2026-05-14T13:00:00Z',
|
||||||
|
|
@ -301,4 +301,19 @@ 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -155,4 +155,24 @@ 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@ 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 } from '@/api/payment'
|
import { payOrder, fetchSwishInfo } 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({
|
||||||
|
|
@ -53,11 +55,17 @@ 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()
|
||||||
expect(wrapper.text()).toContain('Betalning')
|
await vi.waitFor(() => {
|
||||||
|
expect(wrapper.text()).toContain('Betalning')
|
||||||
|
})
|
||||||
expect(wrapper.text()).toContain('49 kr')
|
expect(wrapper.text()).toContain('49 kr')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -66,71 +74,132 @@ describe('PaymentRedirect', () => {
|
||||||
expect(wrapper.text()).toContain('ABC123')
|
expect(wrapper.text()).toContain('ABC123')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows payment button', async () => {
|
it('shows Swish payment button', async () => {
|
||||||
const { wrapper } = await mountPage()
|
const { wrapper } = await mountPage()
|
||||||
const button = wrapper.find('.btn--primary')
|
await vi.waitFor(() => {
|
||||||
expect(button.exists()).toBe(true)
|
expect(wrapper.text()).toContain('Jag har betalat')
|
||||||
expect(button.text()).toBe('Genomför testbetalning')
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows test payment note', async () => {
|
it('shows confirmation dialog after clicking pay button', async () => {
|
||||||
const { wrapper } = await mountPage()
|
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({
|
mockPayOrder.mockResolvedValue({
|
||||||
id: 'order-1',
|
id: 'order-1',
|
||||||
plate: 'ABC123',
|
plate: 'ABC123',
|
||||||
status: 'paid',
|
status: 'processing',
|
||||||
trackingId: null,
|
trackingId: null,
|
||||||
amountPaid: 49.0,
|
amountPaid: null,
|
||||||
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: 'paid',
|
status: 'processing',
|
||||||
trackingId: null,
|
trackingId: null,
|
||||||
amountPaid: 49.0,
|
amountPaid: null,
|
||||||
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('shows error on payment failure', async () => {
|
it('displays Swish number from API', 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('Kunde inte genomföra betalningen')
|
expect(wrapper.text()).toContain('0701234567')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables button while paying', async () => {
|
it('shows error when swish info fetch fails', async () => {
|
||||||
mockPayOrder.mockImplementation(() => new Promise(() => {}))
|
mockFetchSwishInfo.mockRejectedValue(new Error('Network error'))
|
||||||
|
|
||||||
const { wrapper } = await mountPage()
|
const { wrapper } = await mountPage()
|
||||||
const button = wrapper.find('.btn--primary')
|
await vi.waitFor(() => {
|
||||||
await button.trigger('click')
|
expect(wrapper.text()).toContain('Kunde inte ladda betalningsinformation')
|
||||||
|
})
|
||||||
expect(button.attributes('disabled')).toBeDefined()
|
|
||||||
expect(button.text()).toBe('Bearbetar...')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
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')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
lookup_started: 'Hanteras',
|
processing: '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--primary',
|
paid: 'badge--success',
|
||||||
lookup_started: 'badge--primary',
|
processing: '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',
|
||||||
'lookup_started',
|
'processing',
|
||||||
'sent',
|
'sent',
|
||||||
'delivered',
|
'delivered',
|
||||||
'failed',
|
'failed',
|
||||||
|
|
@ -44,16 +44,14 @@ 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', 'lookup_started', 'sent', 'delivered'].includes(o.status),
|
['paid', '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
|
||||||
const sent = orders.value.filter(
|
return { total, todo, paid, pending }
|
||||||
(o) => o.status === 'sent' || o.status === 'delivered',
|
|
||||||
).length
|
|
||||||
return { total, paid, pending, sent }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
|
|
@ -144,6 +142,10 @@ 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>
|
||||||
|
|
@ -152,10 +154,6 @@ 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
|
||||||
|
|
@ -183,6 +181,7 @@ 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>
|
||||||
|
|
@ -346,6 +345,11 @@ 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;
|
||||||
|
|
@ -410,6 +414,10 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
lookup_started: 'Hanteras',
|
processing: '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--primary',
|
paid: 'badge--success',
|
||||||
lookup_started: 'badge--primary',
|
processing: 'badge--primary',
|
||||||
sent: 'badge--success',
|
sent: 'badge--success',
|
||||||
delivered: 'badge--success',
|
delivered: 'badge--success',
|
||||||
failed: 'badge--danger',
|
failed: 'badge--danger',
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,37 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { payOrder } from '@/api/payment'
|
import { payOrder, fetchSwishInfo } 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)
|
||||||
|
|
||||||
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
|
paying.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
|
|
@ -18,7 +39,7 @@ async function handlePay() {
|
||||||
await payOrder(orderId)
|
await payOrder(orderId)
|
||||||
await router.push({ name: 'orders' })
|
await router.push({ name: 'orders' })
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'Kunde inte genomföra betalningen. Försök igen.'
|
error.value = 'Kunde inte bekräfta betalningen. Försök igen.'
|
||||||
} finally {
|
} finally {
|
||||||
paying.value = false
|
paying.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +57,7 @@ async function handlePay() {
|
||||||
<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">49 kr</span>
|
<span class="payment__amount">{{ swishAmount }} kr</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -48,17 +69,51 @@ async function handlePay() {
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<template v-if="!showConfirmation">
|
||||||
class="btn btn--primary btn--lg payment__submit"
|
<div class="payment__swish">
|
||||||
:disabled="paying"
|
<p class="payment__swish-label">Swisha till</p>
|
||||||
@click="handlePay"
|
<p class="payment__swish-number">{{ swishNumber }}</p>
|
||||||
>
|
<p class="payment__swish-instruction">
|
||||||
{{ paying ? 'Bearbetar...' : 'Genomför testbetalning' }}
|
Ange <strong class="payment__order-id">{{ orderId }}</strong>
|
||||||
</button>
|
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">
|
<button
|
||||||
Detta är en testbetalning i utvecklingsmiljön.
|
class="btn btn--primary btn--lg payment__submit"
|
||||||
</p>
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -113,14 +168,73 @@ async function handlePay() {
|
||||||
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__note {
|
.payment__confirm {
|
||||||
margin: var(--space-md) 0 0 0;
|
padding: var(--space-md) 0;
|
||||||
font-size: 0.75rem;
|
}
|
||||||
color: var(--color-soft);
|
|
||||||
text-align: center;
|
.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>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue