Two-for-X ignored and capped free shipping over-waived

Five rows of the dev coupon test sheet failed. They turned out to be three separate defects — two in calculatecost2, one in the website. villacoupon3 returned correct amounts in every case.

Sheet rowCouponReported
7, 8two-for-X 1260002784, 1260002613no discount
10specific SKU HACOD150no discount
11shipping discount TSHIPDISwaived all 90 baht instead of 50

Two-for-X was counted but never charged

Order.grandTotal listed each discount component by hand and omitted two4Discount, while totalDiscount included it. The response therefore advertised a discount that was never taken off:

subTotal 2851 · totalDiscount 17 · deliveryFee 115 · grandTotal 2966   ← 2851 + 115, no 17

two4Discount was also missing from the top level of toDict() — it existed only inside summary — so the website read undefined.

Fixed by expressing grandTotal in terms of totalDiscount so the two cannot drift apart again, and by exposing two4Discount alongside the other discount fields.

total = subTotal + discountedDeliveryFee + expressShippingCost - totalDiscount

A capped free-shipping coupon waived the whole fee

TSHIPDIS is a free_shipping coupon with maxDiscountAmount: 50. VillaCoupon3 handles that correctly — _calculate_free_shipping compares the capped amount against the fee and reports a partial waiver:

{"discountType": "free_shipping", "shippingDiscount": 50, "freeShipping": false}

calculatecost2 then ORed the explicit flag with the type:

freeshipping=bool(
    raw.get("freeshipping") or raw.get("freeShipping")
    or discount_type in ("free_shipping", "freeshipping")   # ← always wins
)

discount_type alone forced True, and Order.shippingDiscount returns the full deliveryFee whenever freeShipping is set. The explicit boolean now wins; the type is consulted only when no boolean is present. See discount-fields.

Shop-by-brand was correct but invisible

Row 10 was already right at the API level — specificSkuDiscount: 150 and grandTotal reduced accordingly. The website’s Discount line summed only cartDiscount + bogoDiscount + voucherDiscount, so the Total dropped while the Discount line read 0. A tester reasonably calls that “no discount”. Fixed in villaEcommerceWeb#315, which adds two4Discount and specificSkuDiscount to that sum in both cart and checkout.

Verification

All 12 sheet rows re-run against dev after deploy 094bd0c, checking grandTotal == subTotal + deliveryFee + expressShippingCost − totalDiscount − shippingDiscount:

RowCouponBeforeAfter
1, 2, 5, 6bogopasspass
71260002784no discounttwo4=17, grand 2949
81260002613no discounttwo4=33, grand 2879
9MINDIS50passpass
10HACOD150no discountsku=150, grand 5017
11TSHIPDIS90 of 90 waived50 of 90 waived
11bTSHIPDIS express90 of 90 waived50 of 90 waived
12, 13FREESHIPpass90 of 90 waived

Regression tests added in test/coupon/test_discount_mapping.py and test/order/test_grandtotal_discounts.py.

Worth remembering

Two of the three defects were the same mistake in different clothes: a total assembled in two places. grandTotal and totalDiscount each maintained their own list of components; the frontend maintained a third. Any new discount type has to be added to all three or it half-works. Deriving one from another removes the class of bug — which is why grandTotal now subtracts totalDiscount instead of re-listing.

The third defect is the recurring lesson about typed enums that carry a hidden qualifier. free_shipping describes a category, not a behaviour, once maxDiscountAmount can change what it does. When a type can be modified by another field, the derived boolean is the contract.