Welcome back! 👋 Now that we have a dedicated Tax Rates API, it is time to actually apply those tax settings to a cart. This is the moment where tax stops being just configuration data and starts affecting what a customer sees during shopping.
In this lesson, we will connect a cart’s selected country to its computed totals. That means we will validate incoming tax-country updates, store the selected country on the cart, look up the matching tax rate when loading the cart, and return updated totals so the UI can immediately reflect the new subtotal, tax, and grand total. This makes the cart itself tax-aware long before checkout happens.
Previously, we finished polishing the Orders API: Checkout Workflow and Order State Transitions and then began building the Tax API. That gave us a central place to manage country-based tax rates. Now we are wiring that configuration into the cart flow, so a cart can resolve the correct tax rate and show accurate totals before an order is ever created.
A cart is where customers see pricing take shape. They add items, change quantities, and decide whether they want to continue toward checkout. If tax is only considered at the very end, the customer does not get a realistic view of what they are about to pay.
That is why this lesson focuses on making the cart totals depend on a selected tax_country. Once that country is stored on the cart, the backend can look up the corresponding rate from tax_rates, compute the tax from the cart subtotal, and return totals that already include the correct tax amount.
Just as importantly, this logic belongs on the server. The UI should not guess or calculate tax on its own. Instead, the backend should derive totals from the cart items plus the resolved tax rate, so every client sees the same numbers and the calculations remain consistent.
The first piece of this feature lives in src/lib/services/cartsService.ts. This file already contains service-level validation and business rules for cart operations, so it is the right place to validate the incoming tax-country payload as well.
In this lesson, the client is expected to send JSON shaped like { "country_code": "US" }. The service validates that shape, normalizes the value, and returns a clean validation result that the route can turn into a friendly 400 response.
-
This function expects an object-shaped payload and specifically looks for
country_code. That matches the API contract for this route, which is important because it keeps the backend strict and predictable instead of silently accepting many different request shapes. -
The validation first ensures
country_codeis actually a string. That prevents invalid values like numbers, booleans, arrays, or missing fields from moving further into the service layer and causing confusing behavior later. -
The
trim().toUpperCase()normalization step makes the API more forgiving without making it loose. A value like" us "will still become"US", which is useful because it lets the server accept minor formatting mistakes while still storing a clean canonical value.
The next important piece also lives in src/lib/services/cartsService.ts. Once the country code has been validated, the service needs to update the cart, but only if that cart is still editable.
A checked-out cart should not be changed anymore, so this service reuses the existing cart-state guardrails. It also re-fetches the cart after the update so the response includes the newly recalculated totals, not just the raw updated row.
-
The service begins by loading the cart with
getCartById(cartId). That is necessary because the feature is not just “write a column”; it is “update the tax country only if this cart is still in a valid editable state.” -
The call to
ensureCartOpen(cart)enforces that business rule in one place. If the cart does not exist, the service returns a404 NOT_FOUND; if the cart exists but is no longer open, it returns a409 CONFLICT, which protects checked-out carts from further mutation. -
The repository update itself is intentionally simple:
updateCartTaxCountry(cartId, countryCode). The service does not perform SQL work directly because the repository is responsible for database access, while the service stays focused on validation, orchestration, and business rules.
The service calls into src/lib/repositories/cartsRepo.ts to actually update the cart row. This repository function is intentionally narrow: it updates tax_country, bumps updated_at, and reports whether a row was changed.
That narrow contract is useful because it lets the service decide how to interpret the result. For example, a false return becomes a 404 at the service layer instead of forcing HTTP concerns into the repository.
-
This query updates exactly one concern on the cart: the
tax_countryfield. Keeping this repository function focused makes it easier to test, reason about, and reuse from the service layer. -
The query uses parameterized SQL with
$1and$2instead of interpolating values into the string. That is the correct pattern throughout this codebase because it protects against SQL injection and keeps database interactions consistent. -
Updating
updated_at = now()is not just a nice extra detail. It ensures the cart reflects that something meaningful changed, which is useful for debugging, auditing, and generally keeping row metadata accurate. -
The
RETURNING idclause lets the repository know whether the update actually matched a row. If no cart with that exists, will be missing, and the function returns .
The heart of this lesson lives in src/lib/repositories/cartsRepo.ts, inside getCartById(id). This function does much more than fetch the base cart row. It also loads cart items, derives the subtotal, resolves the applicable tax rate, computes tax, and returns a fully assembled cart domain object.
That means whenever the cart is fetched after a tax-country update, the totals are already recalculated on the backend. This keeps the logic deterministic and prevents the UI from doing its own version of the math.
-
The function starts by loading the cart row itself. If the cart does not exist, it returns
null, which is important because the service layer relies on that to produce a clean404instead of a low-level failure. -
The
baseobject captures the cart-level fields before items and totals are added. At this stage,tax_countryis already included, which matters because later tax resolution depends on whether the cart currently has a selected country. -
The items query orders rows by
created_at ASC. That ordering keeps the item list deterministic, which helps both API clients and tests because the same cart will always come back in a predictable order.
Next, the function loads the products referenced by the cart items and attaches product data to each item.
Now we expose this behavior through app/routes/api.carts.$id.tax.ts. This route is responsible for accepting a request to change the cart’s tax country, validating the request, delegating to the service layer, and returning the refreshed cart.
Since this endpoint changes existing cart state, it uses a Remix action(...) handler and only allows the PATCH method.
-
These imports tell us almost the full story of the route. It needs Remix action arguments, shared HTTP response helpers, cart service logic, and UUID validation for the
:idroute parameter. -
Pulling in
error,success, andparseJsonfrom the shared response utilities keeps the endpoint consistent with the rest of the API. That consistency matters because clients can then rely on the same{ data, meta }and{ error, meta }response shapes across routes.
Here is the action itself.
One subtle but very important part of this lesson is the decision to re-fetch the cart after updating tax_country. That is not just an implementation detail; it directly affects what the API can promise to clients.
If the service returned only the base cart row after the update, the response would know the new tax_country, but it would not automatically include recomputed totals. By reloading the cart through getCartById(...), the response now includes the updated subtotal_cents, tax_cents, total_cents, tax_rate_bps, and tax_country inside totals.
That design makes the route much more useful. The frontend can issue one PATCH request and immediately receive everything it needs to redraw the cart pricing without doing any local tax math of its own.
In this lesson, we made carts tax-aware by connecting a selected country to server-side total calculation.
We implemented validation in src/lib/services/cartsService.ts so the API accepts a strict payload shaped like { "country_code": "US" }, normalizes it, and rejects invalid values with clear messages. We also added setCartTaxCountryService(...), which ensures the cart exists, remains open, updates the stored country, and re-fetches the cart so the response includes recalculated totals.
In src/lib/repositories/cartsRepo.ts, we saw how getCartById(...) resolves the tax rate from tax_rates, falls back to DEFAULT_TAX_RATE_BPS when needed, computes tax_cents with computeTaxCents(...), and returns tax metadata as part of totals. Finally, in app/routes/api.carts.$id.tax.ts, we wired everything into a PATCH endpoint that validates the cart ID, parses JSON, validates the payload, delegates to the service, and returns the refreshed cart in a consistent success envelope.
At this point, the cart can display tax-aware totals before checkout. In the next step of the workflow, that same tax context will become even more important when checkout snapshots the applied rate and amount onto the final order.
