Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6

Merged
libretech merged 16 commits from fix/paypal-request-logging into main 2026-10-15 10:23:04 +02:00
2 changed files with 62 additions and 21 deletions
Showing only changes of commit ba110a11d8 - Show all commits

No files matched your search

+12 -6
View File
@@ -23,6 +23,8 @@ export type PayPalBreakdown = {
taxTotal: PayPalMoney;
/** Undefined for an order without a delivery method. */
shipping: PayPalMoney | undefined;
/** The surcharge of the payment method, only when it is above 0. */
handling?: PayPalMoney;
};
export type PayPalOrderRequest = {
@@ -54,6 +56,7 @@ export type PayPalOrderSource = {
total?: number | string | null;
VAT?: number | string | null;
delivery?: { price?: number | string | null } | null;
payment?: { price?: number | string | null } | null;
cart?: ({ product?: unknown } | null)[] | null;
deliveryAddress?: string | null;
deliveryAddressStructured?: CustomerStructuredAddress | null;
@@ -110,9 +113,10 @@ const money = (cents: number): PayPalMoney => ({
const VAT_INCLUDED_PERCENT = Math.round(vatIncludedDecimal * 100);
/**
* The breakdown of the order's total: the net of its subtotal, its VAT and the price of its delivery method, 0 when that price
* is not set. PayPal rejects a breakdown that does not add up to the amount, which happens when a price changed after the order
* service computed the totals, so such an order is rejected here.
* The breakdown of the order's total: the net of its subtotal, its VAT, the price of its delivery method, 0 when that price is not
* set, and the surcharge of its payment method as handling, which the order service adds to the total. PayPal rejects a breakdown
* that does not add up to the amount, which happens when a price changed after the order service computed the totals, so such an
* order is rejected here.
*/
const breakdownFor = (order: PayPalOrderSource, totalCents: number): PayPalBreakdown => {
const subtotalCents = toCents(order.subtotal);
@@ -121,19 +125,21 @@ const breakdownFor = (order: PayPalOrderSource, totalCents: number): PayPalBreak
const itemTotalCents = Math.round((subtotalCents * 100) / VAT_INCLUDED_PERCENT);
const shippingCents = order.delivery ? (toCents(order.delivery.price) ?? 0) : undefined;
const surchargeCents = toCents(order.payment?.price) ?? 0;
const parts = [itemTotalCents, taxCents, shippingCents ?? 0];
const parts = [itemTotalCents, taxCents, shippingCents ?? 0, surchargeCents];
const sum = parts.reduce((a, b) => a + b, 0);
if (parts.some((cents) => cents < 0) || sum !== totalCents) {
throw new CheckoutNotReadyError(
`The order's totals do not add up: total ${totalCents} cents, items ${itemTotalCents} + VAT ${taxCents} + shipping ${shippingCents ?? 0}`
`The order's totals do not add up: total ${totalCents} cents, items ${itemTotalCents} + VAT ${taxCents} + shipping ${shippingCents ?? 0} + surcharge ${surchargeCents}`
);
}
return {
itemTotal: money(itemTotalCents),
taxTotal: money(taxCents),
shipping: shippingCents === undefined ? undefined : money(shippingCents)
shipping: shippingCents === undefined ? undefined : money(shippingCents),
...(surchargeCents > 0 ? { handling: money(surchargeCents) } : {})
};
};
+50 -15
View File
@@ -33,7 +33,7 @@ const validOrder = (overrides = {}) => ({
});
// A cart of [price, count] lines with the totals the order service computes in update() (src/api/order/services/order.ts:151-173).
const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
const orderFor = (lines: [number, number][], deliveryPrice: number | null, paymentPrice: number | null = null) => {
const cart = lines.map(([price, count], index) => ({
id: index + 1,
count,
@@ -43,9 +43,10 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
return validOrder({
cart,
subtotal,
total: Math.round((subtotal + (deliveryPrice ?? 0)) * 100) / 100,
total: Math.round((subtotal + (deliveryPrice ?? 0) + (paymentPrice ?? 0)) * 100) / 100,
VAT: Math.round((subtotal / vatIncludedDecimal) * vatDecimal * 100) / 100,
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice }
delivery: deliveryPrice === null ? null : { id: 1, name: "DHL", price: deliveryPrice },
payment: paymentPrice === null ? null : { id: 1, name: "Vorkasse", price: paymentPrice }
});
};
@@ -175,17 +176,13 @@ test("D4: a cart line whose product was deleted is rejected with a clear error",
assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
});
test(
"D5: with a payment surcharge the breakdown still adds up to the amount",
{ todo: "the surcharge is in the amount but not in the breakdown — https://git.librete.ch/libretech/mp/issues/67" },
() => {
// total = 17.90 + 4.95 shipping + 1.50 surcharge
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
test("D5: with a payment surcharge the breakdown still adds up to the amount", () => {
// total = 17.90 + 4.95 shipping + 1.50 surcharge
const { amount } = buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })).purchaseUnits[0];
assert.equal(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount));
}
);
assert.equal(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount));
});
// This held before the fixes too, for every whole-cent subtotal (Strapi stores prices as decimal(10,2)). It guards the fixes above.
test("D6: the breakdown adds up to the amount to the cent, also for the subtotals whose net is closest to half a cent", () => {
@@ -331,17 +328,55 @@ test("rejects totals that do not add up, as after a price change, instead of sen
// The delivery method costs 5.95 now; the order's total was computed when it cost 4.95.
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595"
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 595 + surcharge 0"
});
assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: -4.95 }, total: 12.95 })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 1295 cents, items 1504 + VAT 286 + shipping -495"
message: "The order's totals do not add up: total 1295 cents, items 1504 + VAT 286 + shipping -495 + surcharge 0"
});
for (const overrides of [{ VAT: null }, { subtotal: undefined }, { VAT: "n/a" }]) {
assert.throws(() => buildSession(validOrder(overrides)), { name: "CheckoutNotReadyError", message: "The order has no subtotal or no VAT" });
}
});
test("sends a payment surcharge as handling, and no handling for a payment method that costs nothing", () => {
const breakdownOf = (order) => buildSession(order).purchaseUnits[0].amount.breakdown;
assert.deepEqual(breakdownOf(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 }, total: 24.35 })), {
itemTotal: { currencyCode: "EUR", value: "15.04" },
taxTotal: { currencyCode: "EUR", value: "2.86" },
shipping: { currencyCode: "EUR", value: "4.95" },
handling: { currencyCode: "EUR", value: "1.50" }
});
for (const payment of [null, undefined, { id: 1, name: "PayPal", price: 0 }, { id: 1, name: "PayPal", price: null }]) {
assert.equal("handling" in breakdownOf(validOrder({ payment })), false);
}
// A surcharge the total lacks, as when the price of the payment method changed after the totals were computed.
assert.throws(() => buildSession(validOrder({ payment: { id: 1, name: "PayPal", price: 1.5 } })), {
name: "CheckoutNotReadyError",
message: "The order's totals do not add up: total 2285 cents, items 1504 + VAT 286 + shipping 495 + surcharge 150"
});
});
test("adds up to the cent with a surcharge, with a pickup without a price, for every whole-cent subtotal up to 300.00", () => {
const mismatches: string[] = [];
for (let cent = 1; cent <= 30000; cent++) {
for (const [delivery, payment] of [
[4.95, 0.35],
[0, 1.5]
]) {
const order = orderFor([[cent / 100, 1]], delivery, payment);
if (delivery === 0) order.delivery = { id: 2, name: "Abholung", price: null };
const { amount } = buildSession(order).purchaseUnits[0];
if (breakdownCents(amount.breakdown) !== cents(amount) || cents(amount.breakdown.handling) !== Math.round(payment * 100)) {
mismatches.push(`${cent}/${delivery}/${payment}`);
}
}
}
assert.equal(mismatches.length, 0, `${mismatches.length} carts do not add up, e.g. ${mismatches.slice(0, 3).join(", ")}`);
});
test("rejects a deleted product next to available ones as unavailable", () => {
const order = validOrder({ cart: [{ id: 1, count: 1, product: notebook }, { id: 2, count: 1, product: null }, null] });