From 099fe74b944ae8da98ba0afbf9e6d88672f7523e Mon Sep 17 00:00:00 2001 From: vishkaty Date: Tue, 14 Jul 2026 12:54:07 -0400 Subject: [PATCH 1/3] fix(rest): emit discount totals[] entry as a negative amount The flower-shop checkout appended the discount to totals[] with a positive amount, so the receipt did not reconcile (subtotal + discount != total) and the entry violated total.json, which constrains discount/items_discount amounts with exclusiveMaximum: 0. Per discount.md the applied[].amount is the magnitude (always positive) while the totals[] entry is its signed effect on the receipt (negative for a discount). Negate only the totals[] entry; applied[].amount and the allocation stay positive. Adds a test asserting the discount total is negative, applied amounts stay positive, and subtotal + discount == total; corrects the existing case-insensitive test, which asserted the old positive value. --- rest/python/server/integration_test.py | 57 ++++++++++++++++++- .../server/services/checkout_service.py | 5 +- 2 files changed, 60 insertions(+), 2 deletions(-) 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)) From 2b1e59d44bf7a491e3780f871bcd21b5d61726d2 Mon Sep 17 00:00:00 2001 From: vishkaty Date: Thu, 16 Jul 2026 11:28:24 -0400 Subject: [PATCH 2/3] docs(rest): update sample output discount totals to the negative sign Regenerates the discount totals[] entries in the happy-path dialog to match the corrected sign (now -650). The applied[].amount entries stay positive (the magnitude). Scoped to the discount lines only; the file is otherwise still on the 2026-01-23 profile shape (predates #129) and is out of scope for this PR. --- .../flower_shop/sample_output/happy_path_dialog.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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", From 922f776595caef6205e93c5ca1af0a9b80ab8512 Mon Sep 17 00:00:00 2001 From: vishkaty Date: Thu, 16 Jul 2026 11:28:24 -0400 Subject: [PATCH 3/3] fix(nodejs): emit discount totals[] entry as a negative amount The Node.js server had the same sign bug as the Python server: the discount was pushed to totals[] with a positive amount, so the receipt did not reconcile and the entry violated total.json (exclusiveMaximum: 0). Negate only the totals[] entry; applied[].amount and the allocation stay positive (the magnitude, per discount.md). Adds a test driving recalculateTotals against an in-memory catalog: the discount total is negative, the receipt reconciles (subtotal + discount == total), and applied[].amount stays positive. --- rest/nodejs/src/api/checkout.ts | 5 ++- rest/nodejs/test/discount.test.ts | 61 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 rest/nodejs/test/discount.test.ts 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" + ); +});