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:
2 files changed
+62
-21
No files matched your search
@@ -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) } : {})
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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] });
|
||||
|
||||
|
||||
Reference in new issue
Block a user