Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion rest/nodejs/src/api/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
}
Expand Down
61 changes: 61 additions & 0 deletions rest/nodejs/test/discount.test.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \
},
{
"type": "discount",
"amount": 650
"amount": -650
},
{
"type": "total",
Expand Down Expand Up @@ -724,7 +724,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \
},
{
"type": "discount",
"amount": 650
"amount": -650
},
{
"type": "total",
Expand Down Expand Up @@ -930,7 +930,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \
},
{
"type": "discount",
"amount": 650
"amount": -650
},
{
"type": "total",
Expand Down Expand Up @@ -1190,7 +1190,7 @@ export RESPONSE=$(curl -s -X PUT $SERVER_URL/checkout-sessions/$CHECKOUT_ID \
},
{
"type": "discount",
"amount": 650
"amount": -650
},
{
"type": "total",
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 56 additions & 1 deletion rest/python/server/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 4 additions & 1 deletion rest/python/server/services/checkout_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading