diff --git a/CHANGES.md b/CHANGES.md index 9c38282..6c3f8aa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ # Changes ## Unreleased +- Add checkout(items, { coupon }) with SAVE10/FLAT5 coupons, returning subtotal, discount, tax and total. ## 1.2.0 - total() rounds through roundCents(). diff --git a/src/lib/cart.js b/src/lib/cart.js index 385fd69..844d2f8 100644 --- a/src/lib/cart.js +++ b/src/lib/cart.js @@ -8,3 +8,26 @@ export function subtotal(items) { export function total(items) { return roundCents(subtotal(items) * (1 + TAX_RATE)); } + +function couponDiscount(coupon, amount) { + const code = (coupon ?? '').trim(); + switch (code.toUpperCase()) { + case '': + return 0; + case 'SAVE10': + return amount * 0.1; + case 'FLAT5': + return Math.min(5, amount); + default: + throw new Error(`Unknown coupon: ${code}`); + } +} + +export function checkout(items, { coupon } = {}) { + const sub = roundCents(subtotal(items.filter((item) => item.qty > 0))); + const discount = roundCents(couponDiscount(coupon, sub)); + const tax = roundCents((sub - discount) * TAX_RATE); + // The parts are already whole cents; roundCents only strips float noise from the sum. + const total = roundCents(sub - discount + tax); + return { subtotal: sub, discount, tax, total }; +} diff --git a/tests/lib/cart.test.js b/tests/lib/cart.test.js index 7a9eab3..2c49fb2 100644 --- a/tests/lib/cart.test.js +++ b/tests/lib/cart.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { subtotal, total } from '../../src/lib/cart.js'; +import { subtotal, total, checkout } from '../../src/lib/cart.js'; const items = [{ price: 10, qty: 2 }, { price: 5, qty: 1 }]; @@ -11,3 +11,56 @@ test('subtotal adds price times quantity', () => { test('total applies tax and rounds to cents', () => { assert.equal(total(items), 27); }); + +test('checkout without coupon', () => { + assert.deepEqual(checkout(items), { subtotal: 25, discount: 0, tax: 2, total: 27 }); +}); + +test('checkout ignores items with qty 0 or less', () => { + const cart = [...items, { price: 100, qty: 0 }, { price: 100, qty: -2 }]; + assert.deepEqual(checkout(cart), { subtotal: 25, discount: 0, tax: 2, total: 27 }); +}); + +test('checkout SAVE10 takes 10% off before tax', () => { + assert.deepEqual(checkout(items, { coupon: 'SAVE10' }), { + subtotal: 25, discount: 2.5, tax: 1.8, total: 24.3, + }); +}); + +test('checkout FLAT5 takes $5 off before tax', () => { + assert.deepEqual(checkout(items, { coupon: 'FLAT5' }), { + subtotal: 25, discount: 5, tax: 1.6, total: 21.6, + }); +}); + +test('checkout FLAT5 never exceeds the subtotal', () => { + assert.deepEqual(checkout([{ price: 3, qty: 1 }], { coupon: 'FLAT5' }), { + subtotal: 3, discount: 3, tax: 0, total: 0, + }); + assert.deepEqual(checkout([], { coupon: 'FLAT5' }), { subtotal: 0, discount: 0, tax: 0, total: 0 }); +}); + +test('checkout coupons are case-insensitive and trimmed', () => { + assert.deepEqual(checkout(items, { coupon: ' save10 ' }), checkout(items, { coupon: 'SAVE10' })); + assert.deepEqual(checkout(items, { coupon: ' Flat5\t' }), checkout(items, { coupon: 'FLAT5' })); +}); + +test('checkout empty or missing coupon means no discount', () => { + for (const coupon of [undefined, null, '', ' ']) { + assert.equal(checkout(items, { coupon }).discount, 0); + } +}); + +test('checkout rejects unknown coupons with the trimmed code', () => { + assert.throws(() => checkout(items, { coupon: ' Bogus ' }), { + name: 'Error', + message: 'Unknown coupon: Bogus', + }); +}); + +test('checkout rounds subtotal, discount and tax to cents', () => { + const cart = [{ price: 0.1, qty: 3 }, { price: 3.33, qty: 1 }]; + assert.deepEqual(checkout(cart, { coupon: 'save10' }), { + subtotal: 3.63, discount: 0.36, tax: 0.26, total: 3.53, + }); +});