Checkout: only customer fields on order updates, verified PayPal capture, safe logging #6
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