diff --git a/CHANGES.md b/CHANGES.md index 9c38282..f793da9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ # Changes ## Unreleased +- New discount(items, percent) returns the cart total after taking a percentage off before tax. ## 1.2.0 - total() rounds through roundCents(). diff --git a/CLAUDE.md b/CLAUDE.md index bd47898..b85e093 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,200 +1,16 @@ -# CLAUDE.md — cart-app +# cart-app -Welcome! This file tells Claude (and humans!) everything about the cart-app project. Please read the whole thing carefully before doing anything. It is long because we want to be thorough. - -## Project overview - -cart-app is the shopping cart library that powers checkout for our storefront. It started in 2021 as a weekend prototype by the platform team and has grown into a core dependency. It is consumed by the web storefront, the mobile apps (through the BFF), the internal order tools and the partner API. Because so many things depend on it, we care a lot about correctness, readability and backwards compatibility. - -The library is intentionally small. It exposes pure functions that take a list of cart items and return numbers. There is no I/O, no network and no database. If you find yourself wanting to add I/O, please stop and talk to the platform team first. - -### History - -- 2021: prototype written in a weekend, no tests. -- 2022: tests added, moved to ES modules, CI added. -- 2023: tax rates moved out into a generated file exported by the pricing service. -- 2024: money rounding rewritten after the "half-cent incident" (see below). -- 2025: CHANGES.md introduced so downstream teams can see what changed. -- 2026: this CLAUDE.md written so that AI assistants follow our conventions. - -### The half-cent incident - -In 2024 a refactor used `Math.round(x * 100) / 100` in one place and `toFixed(2)` in another. The two disagreed on some half-cent values and customers saw totals that differed by one cent between the cart page and the receipt. We now round money only with `roundCents()` from `src/lib/money.js`. A lint step in `npm run check` enforces this. - -## Team - -- Platform team owns this repository. -- Pricing team owns tax rates and the exporter that writes `src/generated/`. -- Storefront team is the biggest consumer. -- Reach the platform team in #platform-cart. Reach pricing in #pricing. - -## Tech stack - -- Node.js 22 or later. -- Plain JavaScript, ES modules (`"type": "module"`). -- Tests use the built-in `node:test` runner and `node:assert/strict`. -- No build step. No TypeScript. No bundler. -- No runtime dependencies. Please do not add any. +Pure-JS (ES modules, Node 22+) cart library: functions take items and return numbers. No I/O, no build step, no runtime dependencies (do not add any), no `console.log` or cart contents in logs. ## Commands -- `npm test` runs the unit tests. -- `npm run check` runs lint and then the tests. ALWAYS run `npm run check` before saying you are done, because `npm test` alone skips lint. -- `npm run gen` regenerates `src/generated/` from a local copy of the pricing export. Only the pricing team should run this. - -## Code style - -- Use `const` by default, `let` only when you reassign. Never `var`. -- Use semicolons. -- Two-space indentation. -- Single quotes for strings, template literals for interpolation. -- Prefer arrow functions for small helpers, `function` declarations for exported functions. -- Exported functions get a one-line JSDoc comment describing what they return. -- Keep functions small. If a function is more than about 30 lines, split it. -- Name booleans with `is`, `has` or `should`. -- Avoid abbreviations except well-known ones (id, url, qty). -- Avoid default exports. Use named exports. -- Keep imports sorted: node built-ins first, then relative imports. -- Do not leave commented-out code. -- Do not leave `console.log` in library code. -- Use early returns instead of deep nesting. -- Prefer `Array.prototype.reduce` for sums, but only when it stays readable. -- Prefer `for...of` over `forEach` when you need to `break`. -- Use strict equality (`===`) everywhere. - -## Money rules - -- All money values are numbers in dollars, rounded to cents. -- Round money only with `roundCents()` from `src/lib/money.js`. Never use `Math.round`, `Math.floor`, `Math.ceil` or `toFixed` in `src/lib/`. -- Never compare money with floating point equality in tests; compare rounded values. -- Quantities of 0 or less are ignored. - -## Tax rules - -- Tax rates live in `src/generated/rates.js`. -- `src/generated/` is exported from the pricing service. Never edit it by hand. If a tax rate needs to change, tell the user that the pricing team must re-export it. -- Tax is applied after discounts. - -## Testing philosophy - -We believe tests are documentation. A good test tells the reader what the code is supposed to do, not how it does it. Please: - -- Write a test for every new exported function. -- Write tests for edge cases: empty carts, zero quantities, negative quantities, large numbers, rounding boundaries. -- Keep each test focused on one behavior. -- Use descriptive test names that read like sentences. -- Do not mock anything; the library is pure. -- Do not snapshot test. -- Run the tests after every change. -- If a test is flaky, fix it; do not skip it. -- Put tests in `tests/lib/` next to the module name, e.g. `tests/lib/cart.test.js`. - -## Git workflow - -- Branch from `main`. Name branches `feature/` or `fix/`. -- Write commit messages in the imperative mood ("Add discount", not "Added discount"). -- Keep commits small and focused. -- Rebase on `main` before opening a PR; do not merge `main` into your branch. -- Squash merge PRs. -- Never force-push to `main`. - -## Pull request checklist - -- [ ] Tests added or updated. -- [ ] `npm run check` passes. -- [ ] `CHANGES.md` updated. -- [ ] No changes to `src/generated/`. -- [ ] Public API changes documented in the README. -- [ ] Downstream teams notified in #platform-cart if the API changed. -- [ ] Reviewed by at least one platform engineer. - -## Changelog - -- Every change gets a line under `## Unreleased` in `CHANGES.md`. -- Write the line from the consumer's point of view. -- When we release, the release manager moves `Unreleased` to a version heading. - -## Release process - -1. Make sure `main` is green. -2. Move the `## Unreleased` section in `CHANGES.md` to a new version heading. -3. Bump the version in `package.json` following semver. -4. Tag the release `vX.Y.Z`. -5. Publish to the internal registry with `npm publish --registry https://npm.internal.example`. -6. Announce in #platform-cart with a link to the changelog. -7. Watch the storefront error dashboard for 30 minutes. -8. If something breaks, roll back by publishing the previous version as a new patch. - -## Deployment - -The library is not deployed by itself; consumers pick up new versions. The storefront deploys every weekday at 10:00 and 15:00 UTC. The mobile BFF deploys on Tuesdays and Thursdays. The partner API deploys monthly. Plan breaking changes around the slowest consumer. - -## Frontend guidelines - -These apply to the `web/` folder. - -- Use React function components with hooks. -- Co-locate component styles in CSS modules. -- Use the design system's `Button`, `Input` and `Price` components; do not style raw elements. -- Format prices with the `Price` component, which calls `formatMoney()`. -- Every interactive element must be reachable by keyboard. -- Use `aria-live="polite"` for cart total updates. -- Lazy-load the checkout drawer. -- Keep bundle size under 50 KB gzipped for the cart widget. - -## API documentation - -### `total(items)` - -Returns the cart total in dollars, rounded to cents, including tax. Items with a quantity of 0 or less are ignored. - -### `roundCents(x)` - -Rounds a number of dollars to the nearest cent, half away from zero. - -### Items - -An item is `{ sku: string, price: number, qty: number }`. `price` is the unit price in dollars. - -## Performance - -The library is fast enough for carts of up to 10,000 items. Do not add caching. Do not optimize prematurely. If you think something is slow, write a benchmark first and share the numbers in the PR. - -## Security - -- Never log cart contents; they can contain personal data in custom fields. -- Never add dependencies without a security review. -- Report vulnerabilities to security@example.com, not in public issues. - -## Accessibility - -See the frontend guidelines above. All money shown to users must be announced correctly by screen readers, so always pass numbers through `formatMoney()` in the UI. - -## Troubleshooting - -- "lint: round money with roundCents()": you used `Math.round` or `toFixed` in `src/lib/`. Use `roundCents()`. -- Tests pass locally but fail in CI: make sure you are on Node 22. -- `npm run gen` fails: you probably do not have the pricing export. Ask the pricing team; you should not be running it anyway. -- Totals off by one cent: you are rounding twice, or rounding before applying tax. - -## Glossary - -- **Cart**: a list of items. -- **Item**: a SKU with a unit price and a quantity. -- **Subtotal**: sum of price times quantity, before discounts and tax. -- **Discount**: an amount taken off the subtotal before tax. -- **Tax**: computed on the discounted subtotal using the rate in `src/generated/rates.js`. -- **Total**: discounted subtotal plus tax, rounded to cents. -- **BFF**: backend for frontend. -- **SKU**: stock keeping unit. +- `npm test` runs unit tests (`node:test`). +- `npm run check` runs lint, then tests. ALWAYS run it before saying you are done; `npm test` skips lint. +- `npm run gen` regenerates `src/generated/`. Do not run it; the pricing team owns it. -## Important reminders (please re-read!) +## Hard rules -- ALWAYS run `npm run check` before you say you are done. -- NEVER edit `src/generated/`. -- ALWAYS update `CHANGES.md` under `## Unreleased`. -- ALWAYS round money with `roundCents()`. -- Be thorough. Be careful. Ask if unsure. -- Think step by step. -- Write clean code. -- Do not hallucinate APIs that do not exist. +- NEVER edit `src/generated/`. If a tax rate must change, tell the user the pricing team must re-export it. +- Round money only with `roundCents()` from `src/lib/money.js`; `npm run check` fails on `Math.round/floor/ceil` or `toFixed` in `src/lib/`. +- Add a line under `## Unreleased` in `CHANGES.md` for every consumer-visible change, written from the consumer's point of view. +- Update the README when the public API changes. diff --git a/src/lib/cart.js b/src/lib/cart.js index 385fd69..8f104ff 100644 --- a/src/lib/cart.js +++ b/src/lib/cart.js @@ -5,6 +5,10 @@ export function subtotal(items) { return items.reduce((sum, item) => sum + item.price * item.qty, 0); } +export function discount(items, percent) { + return roundCents(subtotal(items) * (1 - percent / 100) * (1 + TAX_RATE)); +} + export function total(items) { return roundCents(subtotal(items) * (1 + TAX_RATE)); } diff --git a/tests/lib/cart.test.js b/tests/lib/cart.test.js index 7a9eab3..5ac1a86 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, discount } from '../../src/lib/cart.js'; const items = [{ price: 10, qty: 2 }, { price: 5, qty: 1 }]; @@ -11,3 +11,8 @@ test('subtotal adds price times quantity', () => { test('total applies tax and rounds to cents', () => { assert.equal(total(items), 27); }); + +test('discount takes percent off before tax', () => { + assert.equal(discount(items, 10), 24.3); + assert.equal(discount(items, 0), total(items)); +});