fix(checkout): send a payment surcharge to PayPal as handling

The order service adds the price of the order's payment method to the
total, but the PayPal breakdown left it out, so an order with a
surcharge was sent an amount its breakdown did not add up to, which
PayPal rejects (D5). Since the breakdown is now checked against the
total, such an order was answered 400.

A payment price above 0 is now sent as handling, in whole cents, and
item total, tax, shipping and handling add up to the amount to the
cent. A payment method that costs nothing sends no handling.

Every payment method and the pickup cost 0.00 today, and the shop
cannot set a payment method (parseCustomerUpdate rejects the field),
so this is robustness, not a live defect.

The D5 test is a regular test, and a test checks the sum with a
surcharge, also with a pickup without a price, for every whole-cent
subtotal up to 300.00.

Refs libretech/mp#71
Refs libretech/mp#67
This commit is contained in:
libretech committed 2026-10-09 01:50:02 +02:00
1 parent 9e94d69e2e
commit ba110a11d8
2 files changed
+58 -17

No files matched your search

+12 -6
View File
@@ -23,6 +23,8 @@ export type PayPalBreakdown = {
taxTotal: PayPalMoney; taxTotal: PayPalMoney;
/** Undefined for an order without a delivery method. */ /** Undefined for an order without a delivery method. */
shipping: PayPalMoney | undefined; shipping: PayPalMoney | undefined;
/** The surcharge of the payment method, only when it is above 0. */
handling?: PayPalMoney;
}; };
export type PayPalOrderRequest = { export type PayPalOrderRequest = {
@@ -54,6 +56,7 @@ export type PayPalOrderSource = {
total?: number | string | null; total?: number | string | null;
VAT?: number | string | null; VAT?: number | string | null;
delivery?: { price?: number | string | null } | null; delivery?: { price?: number | string | null } | null;
payment?: { price?: number | string | null } | null;
cart?: ({ product?: unknown } | null)[] | null; cart?: ({ product?: unknown } | null)[] | null;
deliveryAddress?: string | null; deliveryAddress?: string | null;
deliveryAddressStructured?: CustomerStructuredAddress | null; deliveryAddressStructured?: CustomerStructuredAddress | null;
@@ -110,9 +113,10 @@ const money = (cents: number): PayPalMoney => ({
const VAT_INCLUDED_PERCENT = Math.round(vatIncludedDecimal * 100); 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 * 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
* is not set. PayPal rejects a breakdown that does not add up to the amount, which happens when a price changed after the order * set, and the surcharge of its payment method as handling, which the order service adds to the total. PayPal rejects a breakdown
* service computed the totals, so such an order is rejected here. * 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 breakdownFor = (order: PayPalOrderSource, totalCents: number): PayPalBreakdown => {
const subtotalCents = toCents(order.subtotal); 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 itemTotalCents = Math.round((subtotalCents * 100) / VAT_INCLUDED_PERCENT);
const shippingCents = order.delivery ? (toCents(order.delivery.price) ?? 0) : undefined; 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); const sum = parts.reduce((a, b) => a + b, 0);
if (parts.some((cents) => cents < 0) || sum !== totalCents) { if (parts.some((cents) => cents < 0) || sum !== totalCents) {
throw new CheckoutNotReadyError( 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 { return {
itemTotal: money(itemTotalCents), itemTotal: money(itemTotalCents),
taxTotal: money(taxCents), taxTotal: money(taxCents),
shipping: shippingCents === undefined ? undefined : money(shippingCents) shipping: shippingCents === undefined ? undefined : money(shippingCents),
...(surchargeCents > 0 ? { handling: money(surchargeCents) } : {})
}; };
}; };
+46 -11
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). // 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) => ({ const cart = lines.map(([price, count], index) => ({
id: index + 1, id: index + 1,
count, count,
@@ -43,9 +43,10 @@ const orderFor = (lines: [number, number][], deliveryPrice: number | null) => {
return validOrder({ return validOrder({
cart, cart,
subtotal, 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, 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)); assert.throws(() => buildSession(validOrder({ cart: [{ id: 1, count: 1, product: null }] })), isClearError(/product/i));
}); });
test( test("D5: with a payment surcharge the breakdown still adds up to the amount", () => {
"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 // 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]; 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(amount.value, "24.35");
assert.equal(breakdownCents(amount.breakdown), cents(amount)); 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. // 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", () => { 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. // 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 } })), { assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: 5.95 } })), {
name: "CheckoutNotReadyError", 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 })), { assert.throws(() => buildSession(validOrder({ delivery: { id: 1, name: "DHL", price: -4.95 }, total: 12.95 })), {
name: "CheckoutNotReadyError", 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" }]) { 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" }); 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", () => { 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] }); const order = validOrder({ cart: [{ id: 1, count: 1, product: notebook }, { id: 2, count: 1, product: null }, null] });