Welcome back! 👋 In the last lesson, you built a Tax Rates Configuration API so the backend can store and manage tax rates per country. Now we’re going to apply those rates where they matter: inside the shopping cart totals.
In this lesson, you’ll make carts “tax-aware” by storing a cart’s tax_country, resolving an appropriate tax_rate_bps (configured rate or a default), and recalculating totals every time the cart is fetched. You’ll also add a focused PATCH endpoint that lets clients update the cart’s tax country and immediately see refreshed totals.
At a high level, the flow looks like this:
A cart stores an optional tax_country (like "US"). When we fetch the cart, the repository computes subtotal_cents from the cart item snapshot prices, resolves a tax_rate_bps using the stored tax_country (falling back to DEFAULT_TAX_RATE_BPS when needed), and calculates tax_cents + total_cents. Separately, a small route handler lets clients set the cart’s tax_country via PATCH /api/carts/:id/tax, and the service ensures the cart exists and is still open before saving.
This project treats tax_country as cart-level configuration: it’s stored on the carts table and used later when totals are computed. You can see this reflected in the cart row type and in how the cart domain object is built.
The repository definitions live in src/lib/repositories/cartsRepo.ts.
tax_countryis explicitly nullable, which matches real usage: a user might not have provided a country yet, but the cart still needs to work.- Keeping
tax_countryon the cart means the totals computation can be deterministic: it always uses the cart’s stored value rather than “guessing” from headers, IP, or frontend defaults. - This structure also makes the “set tax country” endpoint simple: it updates a single column and then returns a refreshed cart view.
The cart totals code must always use a stable tax rate. The key rule in this project is:
Totals should not guess the rate — they should either use a configured tax rate or a default.
That logic is centralized in resolveTaxRateBps(...), which lives in src/lib/repositories/cartsRepo.ts.
- The first guard (
if (!countryCode)) ensures carts without a selected country still get consistent totals by usingDEFAULT_TAX_RATE_BPS. - The query is parameterized (
$1) to keep it safe and consistent with the rest of the repo layer. - If the country code is set but no
tax_ratesrow exists, we still fall back toDEFAULT_TAX_RATE_BPS. This keeps totals stable even when configuration is incomplete. - This function is intentionally “boring”: it does not validate country codes or throw 404s. Validation happens in services/routes; the repo just resolves “best available” for totals.
Cart totals are computed inside getCartById(id) so every cart fetch returns a complete, ready-to-display cart object. This is where snapshot pricing matters: subtotal is derived from cart item snapshots (unit_price_cents), not from current product prices.
The totals computation is in src/lib/repositories/cartsRepo.ts.
- The cart is built in two layers:
baseis the cart row fields, thenitemsare loaded and enriched with product metadata for display purposes. - The subtotal uses
quantity * unit_price_centsfromcart_items. That’s an intentional snapshot rule: cart totals shouldn’t change just because a product price changes later. taxRateBpsis derived from the stored on the cart. If the country is missing or the rate isn’t configured, we fall back to through .
To let clients change where taxes apply, we need a repository function that updates the cart’s tax_country. This function lives in src/lib/repositories/cartsRepo.ts and returns null when no row is updated (meaning the cart didn’t exist).
- The query is parameterized and updates
updated_at = now()so the database remains the source of truth for timestamps. - Returning
nullwhenrows[0]is missing is important: services can turn that into a 404 without needing extra queries. - The function returns a
Cartwithout computed totals, which is consistent with repository layering: totals are computed ingetCartById, not in update helpers. - Accepting
countryCode: string | nullkeeps the function flexible for future behavior (like clearing the tax country).
The tax update endpoint accepts JSON like:
Validation for this payload lives in src/lib/services/cartsService.ts. It expects an object with country_code, then normalizes and validates it using the shared country-code validator from the tax rates service.
- The validator accepts
unknownso it can safely handle any JSON input shape without crashing. - It specifically looks for
country_codeand passes it throughvalidateCountryCode, which trims, uppercases, and enforces exactly two uppercase letters. - By reusing
validateCountryCode, the codebase ensures country validation rules are consistent across tax endpoints and cart tax updates. - The result is a simple
{ ok, value/message }shape, which routes can use to return clean 400 validation responses.
Setting the tax country is not just an update—it’s a business action with a key rule: you can only update open carts. This logic lives in src/lib/services/cartsService.ts, and it uses a shared guard helper (ensureCartOpen) to return proper error codes.
- This helper turns two common checks into one reusable rule: missing carts become
NOT_FOUND (404), non-open carts becomeCONFLICT (409). - Returning a
ServiceResultkeeps route handlers thin: they don’t re-implement these rules or statuses. - The rule is intentionally strict: once a cart is checked out (or abandoned), we no longer allow mutation like changing tax configuration.
Now here is the service function that applies that rule, persists the tax country, and returns a refreshed cart with updated totals:
- The service starts by loading the cart and enforcing “open cart” rules via . That’s what produces the correct 404 vs 409 outcomes.
Finally, we expose this feature via a focused endpoint. The route lives at src/app/api/carts/[id]/tax/route.ts and follows the project’s standard structure: validate path params, parse JSON safely, validate payload, delegate to service, forward the service result.
const { id } = await context.params;is the codebase’s pattern for reading dynamic params becauseparamsis typed as a Promise.isUUID(id)is a fast guardrail that ensures malformed IDs return a clean400 VALIDATION_ERRORbefore any DB work happens.parseJson(req)ensures invalid JSON becomes a controlled 400 error response rather than throwing and falling into a generic 500.validateTaxCountryInput(...)enforces the expected payload shape and country code rules, returning another clean 400 when input is wrong.
In this lesson, you connected your tax configuration system to cart totals in a way that’s consistent, deterministic, and easy for clients to use:
- Carts store
tax_country, andgetCartByIdcomputes totals using snapshot prices plus a tax rate resolved from configuration orDEFAULT_TAX_RATE_BPS. - The repository resolves tax rates with a safe fallback rule—no guessing—and updates
tax_countrywith a parameterizedUPDATE ... RETURNING *. - The service enforces cart state rules (must exist and be open), persists the tax country, then returns a refreshed cart so totals are immediately correct.
- The
PATCH /api/carts/:id/taxroute stays thin: it validates input and forwards the service result using consistentsuccess/errorenvelopes.
You’re now ready for the practices where you’ll implement and reinforce these exact pieces: payload validation, open-cart enforcement, repository update logic, tax rate fallback resolution, and the thin PATCH route handler.
