diff --git a/src/checkout/paypal-order.ts b/src/checkout/paypal-order.ts index 304214d..a71df1c 100644 --- a/src/checkout/paypal-order.ts +++ b/src/checkout/paypal-order.ts @@ -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) } : {}) }; }; diff --git a/tests/unit/paypal-order.test.ts b/tests/unit/paypal-order.test.ts index b659567..37891de 100644 --- a/tests/unit/paypal-order.test.ts +++ b/tests/unit/paypal-order.test.ts @@ -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] });