diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index cc1a9d23..d21a225d 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -360,7 +360,10 @@ export class CheckoutService { amount: discountAmount, allocations: [{ path: "subtotal", amount: discountAmount }], }); - checkout.totals.push({ type: "discount", amount: discountAmount }); + // totals[] carries the signed effect on the receipt: negative for a + // discount (total.json exclusiveMaximum: 0). The applied[].amount + // above stays positive as the magnitude (discount.md). + checkout.totals.push({ type: "discount", amount: -discountAmount }); } } } diff --git a/rest/nodejs/test/discount.test.ts b/rest/nodejs/test/discount.test.ts new file mode 100644 index 00000000..2b8c7969 --- /dev/null +++ b/rest/nodejs/test/discount.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { test, before } from "node:test"; + +import { CheckoutService } from "../src/api/checkout"; +import { initDbs, getProductsDb } from "../src/data/db"; + +// Seed an in-memory catalog once so recalculateTotals can resolve the product. +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); +}); + +function checkoutWithDiscount() { + return { + id: "chk_test", + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + discounts: { codes: ["10OFF"] }, + totals: [], + } as never; +} + +// Per discount.md, the applied[].amount is the magnitude (positive) while the +// totals[] entry is its signed effect on the receipt (negative for a discount); +// total.json constrains discount amounts with exclusiveMaximum: 0. +test("discount totals[] entry is negative and the receipt reconciles", () => { + const checkout = checkoutWithDiscount(); + new CheckoutService()["recalculateTotals"](checkout); + + const totals: Array<{ type: string; amount: number }> = ( + checkout as unknown as { totals: Array<{ type: string; amount: number }> } + ).totals; + const by = (t: string) => totals.find((x) => x.type === t)!; + + assert.ok( + by("discount").amount < 0, + "discount totals[] entry must be negative" + ); + assert.equal( + by("subtotal").amount + by("discount").amount, + by("total").amount, + "subtotal plus the signed discount must equal the total" + ); +}); + +test("applied[].amount stays the positive magnitude", () => { + const checkout = checkoutWithDiscount(); + new CheckoutService()["recalculateTotals"](checkout); + + const applied = ( + checkout as unknown as { discounts: { applied: Array<{ amount: number }> } } + ).discounts.applied; + assert.ok( + applied.length > 0 && applied.every((a) => a.amount > 0), + "applied[].amount is the positive magnitude, not the signed receipt effect" + ); +}); diff --git a/rest/python/client/flower_shop/sample_output/happy_path_dialog.md b/rest/python/client/flower_shop/sample_output/happy_path_dialog.md index 24da250e..fce0f6f5 100644 --- a/rest/python/client/flower_shop/sample_output/happy_path_dialog.md +++ b/rest/python/client/flower_shop/sample_output/happy_path_dialog.md @@ -580,7 +580,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \ }, { "type": "discount", - "amount": 650 + "amount": -650 }, { "type": "total", @@ -724,7 +724,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \ }, { "type": "discount", - "amount": 650 + "amount": -650 }, { "type": "total", @@ -930,7 +930,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \ }, { "type": "discount", - "amount": 650 + "amount": -650 }, { "type": "total", @@ -1190,7 +1190,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \ }, { "type": "discount", - "amount": 650 + "amount": -650 }, { "type": "total", @@ -1454,7 +1454,7 @@ export RESPONSE=$(curl -s -X POST $SERVER_URL/checkout-sessions/$CHECKOUT_ID/com }, { "type": "discount", - "amount": 650 + "amount": -650 }, { "type": "total", diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index e2b32155..d91fc56d 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -463,7 +463,62 @@ async def seed_discount() -> None: discount_totals = [ t["amount"] for t in data.get("totals", []) if t["type"] == "discount" ] - self.assertEqual(discount_totals, [100], "10% of the 1000 subtotal") + self.assertEqual(discount_totals, [-100], "10% of the 1000 subtotal") + + def test_discount_total_is_negative_and_receipt_reconciles(self) -> None: + """A discount totals[] entry is negative and the receipt sums to total. + + Per discount.md, applied[].amount is the magnitude (always positive) while + the corresponding totals[] entry is its signed effect on the receipt + (negative for discounts); total.json constrains discount/items_discount + amounts with exclusiveMaximum: 0. The subtotal plus the (negative) discount + must therefore reconcile to the total. + """ + + async def seed_discount() -> None: + async with self.transactions_session_factory() as session: + await session.execute(delete(db.Discount)) + session.add( + db.Discount( + code="10OFF", type="percentage", value=10, description="10% Off" + ) + ) + await session.commit() + + asyncio.run(seed_discount()) + + with self.client: + payload = self._create_checkout_payload( + "test_checkout_discount_sign", [("rose", "Red Rose", 1000, 1)] + ) + body = payload.model_dump(mode="json", exclude_none=True) + body["discounts"] = {"codes": ["10OFF"]} + response = self.client.post( + "/checkout-sessions", + headers=self._get_headers(idempotency_key="ds-1", request_id="ds-1"), + json=body, + ) + self.assertEqual(response.status_code, 201, f"Response: {response.text}") + data = response.json() + totals = {t["type"]: t["amount"] for t in data.get("totals", [])} + + # 1. The discount totals[] entry is strictly negative (total.json + # exclusiveMaximum: 0). + self.assertLess( + totals["discount"], 0, "discount totals[] entry must be negative" + ) + # 2. applied[].amount stays positive (the magnitude, per discount.md). + applied = (data.get("discounts") or {}).get("applied") or [] + self.assertTrue( + applied and all(a["amount"] > 0 for a in applied), + "applied[].amount is the positive magnitude", + ) + # 3. The receipt reconciles: subtotal + discount == total. + self.assertEqual( + totals["subtotal"] + totals["discount"], + totals["total"], + "subtotal plus the signed discount must equal the total", + ) def test_cancel_checkout(self) -> None: """Tests the checkout cancellation flow.""" diff --git a/rest/python/server/services/checkout_service.py b/rest/python/server/services/checkout_service.py index f7b81332..9b07a3da 100644 --- a/rest/python/server/services/checkout_service.py +++ b/rest/python/server/services/checkout_service.py @@ -1149,8 +1149,11 @@ async def _recalculate_totals( ], ) ) + # totals[] carries the signed effect on the receipt: negative for + # a discount (total.json exclusiveMaximum: 0). applied[].amount + # above stays positive as the magnitude (discount.md). checkout.totals.append( - TotalResponse(type="discount", amount=discount_amount) + TotalResponse(type="discount", amount=-discount_amount) ) checkout.totals.append(TotalResponse(type="total", amount=grand_total))