skilldrop-cli 0.16.30 → 0.16.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/catalogue.json +4 -2
  2. package/model-routing.json +8 -0
  3. package/package.json +1 -1
  4. package/packs/dev-team/loops/release/LOOP.md +7 -5
  5. package/packs/dev-team/loops/release/loop.json +2 -2
  6. package/packs/dev-team/skills/refactor-plan/SKILL.md +168 -0
  7. package/packs/dev-team/skills/refactor-plan/evals/eval_queries.json +46 -0
  8. package/packs/dev-team/skills/refactor-plan/evals/evals.json +106 -0
  9. package/packs/dev-team/skills/refactor-plan/evals/files/1/debt-register-row.md +5 -0
  10. package/packs/dev-team/skills/refactor-plan/evals/files/1/in-flight-prs.md +7 -0
  11. package/packs/dev-team/skills/refactor-plan/evals/files/1/order_service.py +74 -0
  12. package/packs/dev-team/skills/refactor-plan/evals/files/1/test-coverage.txt +14 -0
  13. package/packs/dev-team/skills/refactor-plan/evals/files/3/imports.txt +22 -0
  14. package/packs/dev-team/skills/refactor-plan/evals/files/3/register.md +3 -0
  15. package/packs/dev-team/skills/refactor-plan/evals/files/4/docs/open-prs.md +6 -0
  16. package/packs/dev-team/skills/refactor-plan/evals/files/4/docs/tech-debt-register.md +7 -0
  17. package/packs/dev-team/skills/refactor-plan/evals/files/4/reports/monthly_revenue.py +44 -0
  18. package/packs/dev-team/skills/refactor-plan/evals/files/4/src/billing/invoice.py +233 -0
  19. package/packs/dev-team/skills/refactor-plan/evals/files/4/src/partners/statement.py +29 -0
  20. package/packs/dev-team/skills/refactor-plan/evals/files/4/tests/billing/test_payments.py +13 -0
  21. package/packs/dev-team/skills/refactor-plan/evals/files/5/ci-notes.md +5 -0
  22. package/packs/dev-team/skills/refactor-plan/evals/files/5/open-prs.txt +4 -0
  23. package/packs/dev-team/skills/refactor-plan/evals/files/5/scripts/nightly-digest.js +36 -0
  24. package/packs/dev-team/skills/refactor-plan/evals/files/5/src/auth/passwordReset.ts +16 -0
  25. package/packs/dev-team/skills/refactor-plan/evals/files/5/src/notify/index.ts +2 -0
  26. package/packs/dev-team/skills/refactor-plan/evals/files/5/src/notify/legacyMailer.ts +149 -0
  27. package/packs/dev-team/skills/refactor-plan/evals/files/5/src/orders/onOrderShipped.ts +15 -0
  28. package/packs/dev-team/skills/refactor-plan/evals/files/5/tests/notify/legacyMailer.test.ts +7 -0
  29. package/packs/dev-team/skills/refactor-plan/evals/files/6/billing-service/src/main/kotlin/billing/DunningJob.kt +29 -0
  30. package/packs/dev-team/skills/refactor-plan/evals/files/6/billing-service/src/main/kotlin/billing/InvoiceService.kt +157 -0
  31. package/packs/dev-team/skills/refactor-plan/evals/files/6/billing-service/src/main/kotlin/billing/pdf/InvoicePdfRenderer.kt +36 -0
  32. package/packs/dev-team/skills/refactor-plan/evals/files/6/billing-service/src/test/kotlin/billing/InvoiceServiceTest.kt +40 -0
  33. package/packs/dev-team/skills/refactor-plan/evals/files/6/docs/adr-007-persistence.md +26 -0
  34. package/packs/dev-team/skills/refactor-plan/evals/files/6/docs/open-prs.md +9 -0
  35. package/packs/dev-team/skills/refactor-plan/evals/files/7/notes/open-work.md +19 -0
  36. package/packs/dev-team/skills/refactor-plan/evals/files/7/web/src/components/CheckoutPanel.tsx +207 -0
  37. package/packs/dev-team/skills/refactor-plan/evals/files/7/web/src/components/MiniCart.tsx +33 -0
  38. package/packs/dev-team/skills/refactor-plan/evals/files/7/web/src/components/__tests__/CheckoutPanel.test.tsx +13 -0
  39. package/packs/dev-team/skills/refactor-plan/evals/files/7/web/src/pages/OrderReview.tsx +24 -0
  40. package/packs/dev-team/skills/refactor-plan/examples/search-ranker.md +104 -0
  41. package/packs/dev-team/skills/refactor-plan/manifest.json +36 -0
  42. package/packs/dev-team/skills/refactor-plan/reference.md +149 -0
  43. package/packs/dev-team/skills/refactor-plan/templates/refactor-plan.md +82 -0
  44. package/packs/dev-team/skills/rollout-plan/SKILL.md +157 -0
  45. package/packs/dev-team/skills/rollout-plan/evals/eval_queries.json +12 -0
  46. package/packs/dev-team/skills/rollout-plan/evals/evals.json +109 -0
  47. package/packs/dev-team/skills/rollout-plan/evals/files/2/metrics-snapshot.txt +11 -0
  48. package/packs/dev-team/skills/rollout-plan/evals/files/2/pr-description.md +28 -0
  49. package/packs/dev-team/skills/rollout-plan/evals/files/4/PR_DESCRIPTION.md +24 -0
  50. package/packs/dev-team/skills/rollout-plan/evals/files/4/metrics_snapshot.txt +7 -0
  51. package/packs/dev-team/skills/rollout-plan/evals/files/5/docs/tax-engine-v2.md +24 -0
  52. package/packs/dev-team/skills/rollout-plan/evals/files/6/flags/smart_reminders_v2.yaml +14 -0
  53. package/packs/dev-team/skills/rollout-plan/evals/files/6/notes/push-pipeline.md +10 -0
  54. package/packs/dev-team/skills/rollout-plan/evals/files/7/docs/ratelimiter-v2.md +21 -0
  55. package/packs/dev-team/skills/rollout-plan/evals/files/7/ops/traffic-summary.txt +9 -0
  56. package/packs/dev-team/skills/rollout-plan/examples/sync-engine-workspaces.md +102 -0
  57. package/packs/dev-team/skills/rollout-plan/manifest.json +35 -0
  58. package/packs/dev-team/skills/rollout-plan/reference.md +100 -0
  59. package/packs/dev-team/skills/rollout-plan/scripts/canary_math.py +261 -0
  60. package/packs/dev-team/skills/rollout-plan/templates/rollout-plan.md +50 -0
@@ -0,0 +1,157 @@
1
+ package billing
2
+
3
+ import jakarta.persistence.EntityManager
4
+ import jakarta.persistence.PersistenceContext
5
+ import org.springframework.stereotype.Service
6
+ import org.springframework.transaction.annotation.Transactional
7
+ import java.math.BigDecimal
8
+ import java.math.RoundingMode
9
+ import java.time.Clock
10
+ import java.time.LocalDate
11
+
12
+ @Service
13
+ class InvoiceService(
14
+ private val invoiceRepository: InvoiceRepository,
15
+ private val taxRateRepository: TaxRateRepository,
16
+ private val customerRepository: CustomerRepository,
17
+ private val clock: Clock,
18
+ ) {
19
+
20
+ @PersistenceContext
21
+ private lateinit var em: EntityManager
22
+
23
+ // ---------------------------------------------------------------
24
+ // Creation
25
+ // ---------------------------------------------------------------
26
+
27
+ @Transactional
28
+ fun createInvoice(customerId: Long, lines: List<LineRequest>): Invoice {
29
+ val customer = customerRepository.findById(customerId)
30
+ .orElseThrow { IllegalArgumentException("unknown customer $customerId") }
31
+ require(lines.isNotEmpty()) { "invoice needs at least one line" }
32
+
33
+ val invoice = Invoice(
34
+ customer = customer,
35
+ issuedOn = LocalDate.now(clock),
36
+ dueOn = LocalDate.now(clock).plusDays(customer.paymentTermsDays.toLong()),
37
+ status = InvoiceStatus.OPEN,
38
+ )
39
+ lines.forEach { req ->
40
+ val rate = taxRateRepository.findByRegionAndCategory(customer.region, req.category)
41
+ ?: taxRateRepository.findDefault(customer.region)
42
+ invoice.lines.add(
43
+ InvoiceLine(
44
+ invoice = invoice,
45
+ description = req.description,
46
+ quantity = req.quantity,
47
+ unitPrice = req.unitPrice,
48
+ taxRate = rate.percent,
49
+ )
50
+ )
51
+ }
52
+ recalculate(invoice)
53
+ return invoiceRepository.save(invoice)
54
+ }
55
+
56
+ // ---------------------------------------------------------------
57
+ // Totals and tax
58
+ // ---------------------------------------------------------------
59
+
60
+ fun recalculate(invoice: Invoice) {
61
+ var net = BigDecimal.ZERO
62
+ var tax = BigDecimal.ZERO
63
+ invoice.lines.forEach { line ->
64
+ val lineNet = line.unitPrice.multiply(BigDecimal(line.quantity))
65
+ net = net.add(lineNet)
66
+ tax = tax.add(computeLineTax(lineNet, line.taxRate))
67
+ }
68
+ invoice.netTotal = net.setScale(2, RoundingMode.HALF_EVEN)
69
+ invoice.taxTotal = tax
70
+ invoice.grossTotal = invoice.netTotal.add(invoice.taxTotal).add(invoice.lateFeeTotal)
71
+ }
72
+
73
+ // Tax is rounded per line (HALF_EVEN), not on the invoice total.
74
+ // Finance signed this off in 2023; do not change without them.
75
+ fun computeLineTax(lineNet: BigDecimal, percent: BigDecimal): BigDecimal =
76
+ lineNet.multiply(percent).divide(BigDecimal(100)).setScale(2, RoundingMode.HALF_EVEN)
77
+
78
+ // ---------------------------------------------------------------
79
+ // Overdue and late fees
80
+ // ---------------------------------------------------------------
81
+
82
+ fun findOverdue(asOf: LocalDate): List<Invoice> =
83
+ em.createQuery(
84
+ "select i from Invoice i join fetch i.customer " +
85
+ "where i.status in (:open, :dunning) and i.dueOn < :asOf",
86
+ Invoice::class.java,
87
+ )
88
+ .setParameter("open", InvoiceStatus.OPEN)
89
+ .setParameter("dunning", InvoiceStatus.IN_DUNNING)
90
+ .setParameter("asOf", asOf)
91
+ .resultList
92
+
93
+ @Transactional
94
+ fun applyLateFees(asOf: LocalDate): Int {
95
+ var count = 0
96
+ findOverdue(asOf).forEach { invoice ->
97
+ val daysLate = asOf.toEpochDay() - invoice.dueOn.toEpochDay()
98
+ if (daysLate < invoice.customer.lateFeeGraceDays) return@forEach
99
+ if (invoice.customer.lateFeeExempt) return@forEach
100
+
101
+ val fee = invoice.netTotal
102
+ .multiply(BigDecimal("0.015"))
103
+ .multiply(BigDecimal(daysLate / 30 + 1))
104
+ .setScale(2, RoundingMode.HALF_UP)
105
+ if (fee > invoice.lateFeeTotal) {
106
+ invoice.lateFeeTotal = fee
107
+ recalculate(invoice)
108
+ em.merge(invoice)
109
+ count++
110
+ }
111
+ }
112
+ return count
113
+ }
114
+
115
+ // ---------------------------------------------------------------
116
+ // Payment
117
+ // ---------------------------------------------------------------
118
+
119
+ @Transactional
120
+ fun markPaid(invoiceId: Long, amount: BigDecimal, paidOn: LocalDate) {
121
+ val invoice = invoiceRepository.findById(invoiceId)
122
+ .orElseThrow { IllegalArgumentException("unknown invoice $invoiceId") }
123
+ check(invoice.status != InvoiceStatus.VOID) { "cannot pay a void invoice" }
124
+
125
+ invoice.amountPaid = invoice.amountPaid.add(amount)
126
+ if (invoice.amountPaid >= invoice.grossTotal) {
127
+ invoice.status = InvoiceStatus.PAID
128
+ invoice.paidOn = paidOn
129
+ }
130
+ // native update keeps the customer balance in step; the ORM mapping
131
+ // for Customer.balance is read-only (see V31__customer_balance.sql)
132
+ em.createNativeQuery(
133
+ "update customer set balance = balance - :amount where id = :id"
134
+ )
135
+ .setParameter("amount", amount)
136
+ .setParameter("id", invoice.customer.id)
137
+ .executeUpdate()
138
+ invoiceRepository.save(invoice)
139
+ }
140
+
141
+ @Transactional
142
+ fun void(invoiceId: Long, reason: String) {
143
+ val invoice = invoiceRepository.findById(invoiceId)
144
+ .orElseThrow { IllegalArgumentException("unknown invoice $invoiceId") }
145
+ check(invoice.amountPaid.signum() == 0) { "cannot void a part-paid invoice" }
146
+ invoice.status = InvoiceStatus.VOID
147
+ invoice.voidReason = reason
148
+ invoiceRepository.save(invoice)
149
+ }
150
+ }
151
+
152
+ data class LineRequest(
153
+ val description: String,
154
+ val quantity: Int,
155
+ val unitPrice: BigDecimal,
156
+ val category: String,
157
+ )
@@ -0,0 +1,36 @@
1
+ package billing.pdf
2
+
3
+ import billing.Invoice
4
+ import org.springframework.stereotype.Component
5
+ import java.math.BigDecimal
6
+ import java.math.RoundingMode
7
+
8
+ @Component
9
+ class InvoicePdfRenderer(private val templates: PdfTemplates) {
10
+
11
+ fun render(invoice: Invoice): ByteArray {
12
+ val rows = invoice.lines.map { line ->
13
+ val lineNet = line.unitPrice.multiply(BigDecimal(line.quantity))
14
+ PdfRow(
15
+ description = line.description,
16
+ quantity = line.quantity,
17
+ unitPrice = line.unitPrice,
18
+ net = lineNet,
19
+ tax = taxFor(lineNet, line.taxRate),
20
+ )
21
+ }
22
+ return templates.invoice(
23
+ number = invoice.number,
24
+ customerName = invoice.customer.name,
25
+ rows = rows,
26
+ net = invoice.netTotal,
27
+ tax = rows.fold(BigDecimal.ZERO) { acc, r -> acc.add(r.tax) },
28
+ lateFees = invoice.lateFeeTotal,
29
+ gross = invoice.grossTotal,
30
+ )
31
+ }
32
+
33
+ // copied from InvoiceService.computeLineTax so the renderer has no service dependency
34
+ private fun taxFor(lineNet: BigDecimal, percent: BigDecimal): BigDecimal =
35
+ lineNet.multiply(percent).divide(BigDecimal(100)).setScale(2, RoundingMode.HALF_UP)
36
+ }
@@ -0,0 +1,40 @@
1
+ package billing
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.jupiter.api.Test
5
+ import org.springframework.beans.factory.annotation.Autowired
6
+ import org.springframework.boot.test.context.SpringBootTest
7
+ import org.springframework.transaction.annotation.Transactional
8
+ import java.math.BigDecimal
9
+
10
+ @SpringBootTest
11
+ @Transactional
12
+ class InvoiceServiceTest @Autowired constructor(
13
+ private val invoiceService: InvoiceService,
14
+ private val fixtures: TestFixtures,
15
+ ) {
16
+
17
+ @Test
18
+ fun `creates an open invoice with totals`() {
19
+ val customer = fixtures.customer(region = "DE", paymentTermsDays = 30)
20
+ fixtures.taxRate(region = "DE", category = "service", percent = "19")
21
+
22
+ val invoice = invoiceService.createInvoice(
23
+ customer.id,
24
+ listOf(LineRequest("Consulting", 2, BigDecimal("100.00"), "service")),
25
+ )
26
+
27
+ assertThat(invoice.status).isEqualTo(InvoiceStatus.OPEN)
28
+ assertThat(invoice.netTotal).isEqualByComparingTo("200.00")
29
+ assertThat(invoice.taxTotal).isEqualByComparingTo("38.00")
30
+ assertThat(invoice.grossTotal).isEqualByComparingTo("238.00")
31
+ }
32
+
33
+ @Test
34
+ fun `rejects an invoice with no lines`() {
35
+ val customer = fixtures.customer(region = "DE", paymentTermsDays = 30)
36
+ org.junit.jupiter.api.assertThrows<IllegalArgumentException> {
37
+ invoiceService.createInvoice(customer.id, emptyList())
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,26 @@
1
+ # ADR-007: Move billing-service persistence from Hibernate to jOOQ
2
+
3
+ Status: Accepted (2026-09-14)
4
+
5
+ ## Context
6
+
7
+ Hibernate has bitten us three times this year: lazy-loading surprises in the
8
+ PDF renderer, a dirty-checking flush that wrote a half-updated invoice during an
9
+ exception, and slow startup from entity scanning. Most of our queries are
10
+ already hand-written JPQL or native SQL.
11
+
12
+ ## Decision
13
+
14
+ Replace Hibernate/Spring Data JPA with jOOQ in billing-service during Q1 2027.
15
+
16
+ ## Precondition
17
+
18
+ The ORM swap must be a persistence-only change. Before it starts, invoice
19
+ business rules (totals, tax, late fees, payment state transitions) must live in
20
+ code that does not import `jakarta.persistence` or depend on `EntityManager`,
21
+ and must be testable without a database.
22
+
23
+ ## Out of scope
24
+
25
+ - Changing tax or late-fee rules.
26
+ - The reporting database (separate service).
@@ -0,0 +1,9 @@
1
+ # Open PRs touching billing-service (as of 2026-10-19)
2
+
3
+ | PR | Title | Author | Files | State |
4
+ |------|-----------------------------------------|---------|----------------------------------------------------|-------------------------------|
5
+ | #412 | Cap late fees at 10% of net for EU | priya.n | InvoiceService.kt (applyLateFees), InvoiceServiceTest.kt | Approved, waiting on finance sign-off, expected to merge this week |
6
+ | #415 | Credit notes | tomasz | InvoiceService.kt (new issueCreditNote, changes void), CreditNote.kt, V44__credit_notes.sql | Draft, ~3 weeks out |
7
+ | #409 | Bump Spring Boot 3.3.4 -> 3.3.5 | bot | build.gradle.kts | Green, ready |
8
+
9
+ Feature work continues on billing-service through Q4; no freeze is planned.
@@ -0,0 +1,19 @@
1
+ # web: open work (updated Mon 19 Oct 2026)
2
+
3
+ ## Release
4
+ - **v4.8 ships Tue 3 Nov.** Code freeze Fri 30 Oct, 17:00. After freeze only
5
+ release-blocker fixes go to main.
6
+
7
+ ## Open PRs
8
+ - **#231 Gift cards at checkout** (dana) - adds gift card entry + balance
9
+ deduction to `CheckoutPanel.tsx` (new state, changes the totals block and
10
+ `submit()`). In v4.8 scope. Review this week, expected merge by Wed 28 Oct.
11
+ - **#236 Address autocomplete** (lee) - swaps the address inputs in
12
+ `CheckoutPanel.tsx` for a lookup widget. NOT in v4.8; parked until after release.
13
+ - **#238 Bump vite 5.4 -> 5.5** (bot) - unrelated.
14
+
15
+ ## Tickets
16
+ - **WEB-512** Checkout total in header cart sometimes 1p different from the
17
+ checkout page. Reported twice by support. Unassigned.
18
+ - **WEB-530** Second payment provider (pay-by-bank) in Q1 2027. Needs the
19
+ payment step to stop assuming card fields.
@@ -0,0 +1,207 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { api } from "../lib/api";
3
+ import { CartItem, Address, ShippingOption } from "../types";
4
+
5
+ type Props = {
6
+ items: CartItem[];
7
+ onComplete: (orderId: string) => void;
8
+ };
9
+
10
+ export default function CheckoutPanel({ items, onComplete }: Props) {
11
+ const [step, setStep] = useState<"address" | "shipping" | "payment">("address");
12
+ const [email, setEmail] = useState("");
13
+ const [address, setAddress] = useState<Address>({
14
+ line1: "", line2: "", city: "", postcode: "", country: "GB",
15
+ });
16
+ const [addressErrors, setAddressErrors] = useState<Record<string, string>>({});
17
+ const [shippingOptions, setShippingOptions] = useState<ShippingOption[]>([]);
18
+ const [shipping, setShipping] = useState<ShippingOption | null>(null);
19
+ const [promoCode, setPromoCode] = useState("");
20
+ const [promoPercent, setPromoPercent] = useState(0);
21
+ const [promoError, setPromoError] = useState("");
22
+ const [cardNumber, setCardNumber] = useState("");
23
+ const [cardExpiry, setCardExpiry] = useState("");
24
+ const [cardCvc, setCardCvc] = useState("");
25
+ const [submitting, setSubmitting] = useState(false);
26
+ const [submitError, setSubmitError] = useState("");
27
+
28
+ // ---- totals (all money in pence) ----------------------------------
29
+ const subtotal = items.reduce((sum, i) => sum + i.unitPence * i.qty, 0);
30
+ const discount = Math.round(subtotal * (promoPercent / 100));
31
+ const afterDiscount = subtotal - discount;
32
+ const freeShipping = afterDiscount > 5000;
33
+ const shippingPence = freeShipping ? 0 : shipping ? shipping.pence : 0;
34
+ const total = afterDiscount + shippingPence;
35
+
36
+ // ---- draft persistence ---------------------------------------------
37
+ // keeps the form if the user navigates away
38
+ useEffect(() => {
39
+ localStorage.setItem(
40
+ "checkout_draft",
41
+ JSON.stringify({ email, address, promoCode, total })
42
+ );
43
+ }, [email, address, promoCode, total]);
44
+
45
+ useEffect(() => {
46
+ const raw = localStorage.getItem("checkout_draft");
47
+ if (raw) {
48
+ const d = JSON.parse(raw);
49
+ setEmail(d.email || "");
50
+ if (d.address) setAddress(d.address);
51
+ if (d.promoCode) setPromoCode(d.promoCode);
52
+ }
53
+ }, []);
54
+
55
+ // ---- address --------------------------------------------------------
56
+ function validateAddress(): boolean {
57
+ const errs: Record<string, string> = {};
58
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errs.email = "Enter a valid email";
59
+ if (!address.line1.trim()) errs.line1 = "Required";
60
+ if (!address.city.trim()) errs.city = "Required";
61
+ if (address.country === "GB" && !/^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i.test(address.postcode)) {
62
+ errs.postcode = "Enter a valid UK postcode";
63
+ }
64
+ if (address.country !== "GB" && !address.postcode.trim()) errs.postcode = "Required";
65
+ setAddressErrors(errs);
66
+ return Object.keys(errs).length === 0;
67
+ }
68
+
69
+ async function goToShipping() {
70
+ if (!validateAddress()) return;
71
+ const res = await api.get(`/shipping/options?country=${address.country}&postcode=${address.postcode}`);
72
+ setShippingOptions(res.data);
73
+ setShipping(res.data[0] ?? null);
74
+ setStep("shipping");
75
+ }
76
+
77
+ // ---- promo ----------------------------------------------------------
78
+ async function applyPromo() {
79
+ setPromoError("");
80
+ if (!promoCode.trim()) return;
81
+ try {
82
+ const res = await api.get(`/promos/${encodeURIComponent(promoCode.trim().toUpperCase())}`);
83
+ if (res.data.expired) {
84
+ setPromoError("That code has expired");
85
+ setPromoPercent(0);
86
+ } else {
87
+ setPromoPercent(res.data.percent);
88
+ }
89
+ } catch {
90
+ setPromoError("Code not recognised");
91
+ setPromoPercent(0);
92
+ }
93
+ }
94
+
95
+ // ---- payment --------------------------------------------------------
96
+ function validateCard(): string | null {
97
+ const digits = cardNumber.replace(/\s/g, "");
98
+ if (!/^\d{13,19}$/.test(digits)) return "Card number looks wrong";
99
+ let sum = 0;
100
+ for (let i = 0; i < digits.length; i++) {
101
+ let d = parseInt(digits[digits.length - 1 - i], 10);
102
+ if (i % 2 === 1) { d *= 2; if (d > 9) d -= 9; }
103
+ sum += d;
104
+ }
105
+ if (sum % 10 !== 0) return "Card number looks wrong";
106
+ if (!/^(0[1-9]|1[0-2])\/\d{2}$/.test(cardExpiry)) return "Expiry should be MM/YY";
107
+ if (!/^\d{3,4}$/.test(cardCvc)) return "CVC should be 3 or 4 digits";
108
+ return null;
109
+ }
110
+
111
+ async function submit() {
112
+ const cardErr = validateCard();
113
+ if (cardErr) { setSubmitError(cardErr); return; }
114
+ setSubmitting(true);
115
+ setSubmitError("");
116
+ try {
117
+ const tokenRes = await api.post("/payments/tokenise", {
118
+ number: cardNumber.replace(/\s/g, ""), expiry: cardExpiry, cvc: cardCvc,
119
+ });
120
+ const orderRes = await api.post("/orders", {
121
+ email, address, shippingId: shipping?.id, promoCode: promoPercent ? promoCode : null,
122
+ items: items.map((i) => ({ sku: i.sku, qty: i.qty })),
123
+ expectedTotalPence: total,
124
+ paymentToken: tokenRes.data.token,
125
+ });
126
+ onComplete(orderRes.data.id);
127
+ } catch (e: any) {
128
+ setSubmitError(e?.response?.data?.message || "Payment failed, please try again");
129
+ } finally {
130
+ setSubmitting(false);
131
+ }
132
+ }
133
+
134
+ const fmt = (p: number) => `£${(p / 100).toFixed(2)}`;
135
+
136
+ // ---- render ---------------------------------------------------------
137
+ return (
138
+ <div className="checkout">
139
+ <aside className="checkout__summary">
140
+ {items.map((i) => (
141
+ <div key={i.sku} className="row">
142
+ <span>{i.name} x {i.qty}</span>
143
+ <span>{fmt(i.unitPence * i.qty)}</span>
144
+ </div>
145
+ ))}
146
+ {discount > 0 && <div className="row"><span>Discount</span><span>-{fmt(discount)}</span></div>}
147
+ <div className="row"><span>Shipping</span><span>{freeShipping ? "Free" : fmt(shippingPence)}</span></div>
148
+ <div className="row row--total"><span>Total</span><span>{fmt(total)}</span></div>
149
+ <div className="promo">
150
+ <input value={promoCode} onChange={(e) => setPromoCode(e.target.value)} placeholder="Promo code" />
151
+ <button onClick={applyPromo}>Apply</button>
152
+ {promoError && <p className="error">{promoError}</p>}
153
+ </div>
154
+ </aside>
155
+
156
+ {step === "address" && (
157
+ <section>
158
+ <h2>Where should we send it?</h2>
159
+ <label>Email<input value={email} onChange={(e) => setEmail(e.target.value)} /></label>
160
+ {addressErrors.email && <p className="error">{addressErrors.email}</p>}
161
+ <label>Address line 1<input value={address.line1} onChange={(e) => setAddress({ ...address, line1: e.target.value })} /></label>
162
+ {addressErrors.line1 && <p className="error">{addressErrors.line1}</p>}
163
+ <label>Address line 2<input value={address.line2} onChange={(e) => setAddress({ ...address, line2: e.target.value })} /></label>
164
+ <label>City<input value={address.city} onChange={(e) => setAddress({ ...address, city: e.target.value })} /></label>
165
+ {addressErrors.city && <p className="error">{addressErrors.city}</p>}
166
+ <label>Postcode<input value={address.postcode} onChange={(e) => setAddress({ ...address, postcode: e.target.value })} /></label>
167
+ {addressErrors.postcode && <p className="error">{addressErrors.postcode}</p>}
168
+ <label>Country
169
+ <select value={address.country} onChange={(e) => setAddress({ ...address, country: e.target.value })}>
170
+ <option value="GB">United Kingdom</option>
171
+ <option value="IE">Ireland</option>
172
+ <option value="FR">France</option>
173
+ <option value="DE">Germany</option>
174
+ </select>
175
+ </label>
176
+ <button onClick={goToShipping}>Continue to shipping</button>
177
+ </section>
178
+ )}
179
+
180
+ {step === "shipping" && (
181
+ <section>
182
+ <h2>Delivery</h2>
183
+ {shippingOptions.map((o) => (
184
+ <label key={o.id} className="radio">
185
+ <input type="radio" checked={shipping?.id === o.id} onChange={() => setShipping(o)} />
186
+ {o.label} - {freeShipping ? "Free" : fmt(o.pence)}
187
+ </label>
188
+ ))}
189
+ <button onClick={() => setStep("address")}>Back</button>
190
+ <button onClick={() => setStep("payment")} disabled={!shipping}>Continue to payment</button>
191
+ </section>
192
+ )}
193
+
194
+ {step === "payment" && (
195
+ <section>
196
+ <h2>Payment</h2>
197
+ <label>Card number<input value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} inputMode="numeric" /></label>
198
+ <label>Expiry (MM/YY)<input value={cardExpiry} onChange={(e) => setCardExpiry(e.target.value)} /></label>
199
+ <label>CVC<input value={cardCvc} onChange={(e) => setCardCvc(e.target.value)} inputMode="numeric" /></label>
200
+ {submitError && <p className="error">{submitError}</p>}
201
+ <button onClick={() => setStep("shipping")}>Back</button>
202
+ <button onClick={submit} disabled={submitting}>{submitting ? "Paying..." : `Pay ${fmt(total)}`}</button>
203
+ </section>
204
+ )}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import { CartItem } from "../types";
3
+
4
+ type Props = {
5
+ items: CartItem[];
6
+ promoPercent: number;
7
+ shippingPence: number;
8
+ };
9
+
10
+ // header dropdown cart; same maths as CheckoutPanel
11
+ export default function MiniCart({ items, promoPercent, shippingPence }: Props) {
12
+ const afterDiscount = items.reduce(
13
+ (sum, i) => sum + Math.round(i.unitPence * i.qty * (1 - promoPercent / 100)),
14
+ 0
15
+ );
16
+ const discount = items.reduce((s, i) => s + i.unitPence * i.qty, 0) - afterDiscount;
17
+ const freeShipping = afterDiscount >= 5000;
18
+ const total = afterDiscount + (freeShipping ? 0 : shippingPence);
19
+ const fmt = (p: number) => `£${(p / 100).toFixed(2)}`;
20
+
21
+ return (
22
+ <div className="minicart">
23
+ {items.map((i) => (
24
+ <div key={i.sku} className="minicart__row">
25
+ {i.name} x {i.qty} <span>{fmt(i.unitPence * i.qty)}</span>
26
+ </div>
27
+ ))}
28
+ {discount > 0 && <div className="minicart__row">Discount -{fmt(discount)}</div>}
29
+ <div className="minicart__total">Total {fmt(total)}</div>
30
+ {freeShipping && <p className="minicart__hint">You've got free delivery</p>}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { render } from "@testing-library/react";
3
+ import CheckoutPanel from "../CheckoutPanel";
4
+
5
+ const items = [
6
+ { sku: "MUG-01", name: "Mug", unitPence: 1299, qty: 2 },
7
+ { sku: "TEA-04", name: "Loose tea", unitPence: 850, qty: 1 },
8
+ ];
9
+
10
+ it("renders", () => {
11
+ const { container } = render(<CheckoutPanel items={items} onComplete={() => {}} />);
12
+ expect(container).toMatchSnapshot();
13
+ });
@@ -0,0 +1,24 @@
1
+ import React from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+
4
+ // Shown when a returning visitor lands on /checkout/review from the
5
+ // "you left something behind" email.
6
+ export default function OrderReview() {
7
+ const navigate = useNavigate();
8
+ const raw = localStorage.getItem("checkout_draft");
9
+ const draft = raw ? JSON.parse(raw) : null;
10
+
11
+ if (!draft || !draft.email) {
12
+ navigate("/cart");
13
+ return null;
14
+ }
15
+
16
+ return (
17
+ <main className="review">
18
+ <h1>Pick up where you left off</h1>
19
+ <p>We saved your details for {draft.email}.</p>
20
+ <p>Your total was <strong>£{(draft.total / 100).toFixed(2)}</strong>.</p>
21
+ <button onClick={() => navigate("/checkout")}>Continue to checkout</button>
22
+ </main>
23
+ );
24
+ }