Snapshotting Tax at Checkout

Snapshotting Tax at Checkout

Congratulations on making it to the final lesson in this path! 🎉 You have come a long way—from building foundational APIs, to managing carts and orders, to applying country-based taxes before checkout. This last step brings everything together in one of the most important moments in an e-commerce backend: turning a live cart into a permanent order snapshot.

In this lesson, we will focus on preserving tax history at checkout time. That means the order should store the exact tax_country, tax_rate_bps, tax_cents, and total_cents that were true at the moment checkout happened. Even if tax rates change later, old orders must not change with them. By the end of this lesson, you will see how the checkout transaction captures those values, why reads must return the stored snapshot instead of recomputing tax, and how the repository keeps order data consistent across both list and get-by-id reads.

Previously, we made carts tax-aware by allowing a cart to store a tax_country and by deriving cart totals from the current tax_rates configuration. That was the right behavior for a cart, because carts are still editable and should reflect current pricing rules. Orders are different: once checkout succeeds, their financial values must become permanent.

Why Checkout Needs a Tax Snapshot

A cart is allowed to change. A user can add items, remove items, switch quantities, or even change the country used for tax calculation. Because of that, cart totals should always reflect the current state of the cart and the current tax configuration.

An order is not like that. Once checkout succeeds, the order becomes a historical record of what the customer actually agreed to pay. If a tax rate for US changes tomorrow, that should affect future carts and future checkouts, but it should never rewrite an existing order from yesterday.

That is why checkout must snapshot tax data. Instead of storing only the final tax_cents, we also persist the tax_country and the exact tax_rate_bps used during checkout. That gives us both the result and the context behind the result, which is useful for receipts, order history, debugging, and accounting.

The Order Row Must Include Tax Snapshot Fields

The first place this lesson touches is src/lib/repositories/ordersRepo.ts. Since orders are supposed to preserve tax details, the repository’s database row type must include those fields explicitly.

This is important because the repository is the layer that converts raw database rows into domain objects. If the row type omits tax_country or tax_rate_bps, those values will never make it into the Order returned by the rest of the application.

export interface OrderRow {
  id: string;
  cart_id: string;
  status: string;
  subtotal_cents: number;
  tax_cents: number;
  total_cents: number;
  tax_country: string | null;
  tax_rate_bps: number;
  currency: string;
  created_at: string;
  updated_at: string;
}
  • This row type mirrors the important persisted columns in the orders table, including both tax_country and tax_rate_bps. That matters because a checkout snapshot is only useful if the repository actually reads those stored values back out of the database.

  • Including tax_country gives clients the geographic context used for tax resolution at checkout time. Including tax_rate_bps gives clients the exact rate that produced the stored tax_cents, which is especially helpful when explaining old orders after tax policies have changed.

  • These fields are not optional extras in this workflow. They are part of the order’s financial record, and keeping them in the row type ensures the repository remains aligned with the database schema and the domain model.

Mapping Stored Tax Fields into the Order Domain Object

Once OrderRow includes the snapshot fields, the repository needs to carry them forward into the returned Order. That work happens inside mapOrderRow(...) in src/lib/repositories/ordersRepo.ts.

This mapping step is where the repository turns a raw SQL result into a stable domain object used elsewhere in the app. If the mapping forgets the tax snapshot fields, clients will lose access to that data even though it exists in the database.

function mapOrderRow(r: OrderRow): Order {
  return {
    id: r.id,
    cart_id: r.cart_id,
    status: ["pending", "paid", "shipped", "cancelled"].includes(r.status)
      ? (r.status as Order["status"])
      : "pending",
    subtotal_cents: r.subtotal_cents,
    tax_cents: r.tax_cents,
    total_cents: r.total_cents,
    tax_country: r.tax_country,
    tax_rate_bps: r.tax_rate_bps,
    currency: r.currency === "USD" ? "USD" : "USD",
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}
  • The mapping copies the stored tax_country and tax_rate_bps directly from the database row into the Order domain object. That is exactly what we want for snapshot behavior: the order should reflect what was persisted, not some recalculated or inferred value.

  • Notice that tax_cents and total_cents are also copied directly from the row. This reinforces the accounting rule for orders: reads should expose the stored financial values as-is, because those are the values that were finalized during checkout.

  • This mapper is also important for consistency between different repository readers. Both list-based reads and single-order reads depend on mapOrderRow(...), so once the tax snapshot fields are mapped here, they can flow through the rest of the order API uniformly.

Listing Orders Must Return the Snapshot Fields Too

Reading a Single Order Must Not Recompute Tax

The most important reading rule in this lesson is simple: orders must never recompute tax from the current tax table. A cart can do that because it is still “live.” An order cannot.

The single-order reader in src/lib/repositories/ordersRepo.ts gets this right by selecting the order row, mapping it, and then attaching the order items. It does not go back to tax_rates, and it does not rerun tax calculations during reads.

export async function getOrderById(id: string): Promise<Order | null> {
  const rows = await query<OrderRow>("SELECT * FROM orders WHERE id = $1", [
    id,
  ]);
  if (!rows[0]) return null;
  const order = mapOrderRow(rows[0]);
  const items = await query<OrderItemRow>(
    `SELECT * FROM order_items WHERE order_id = $1 ORDER BY created_at ASC`,
    [id],
  );
  order.items = items.map(mapItemRow);
  return order;
}
  • The function reads the order row as persisted and immediately maps it with mapOrderRow(...). That means tax_cents, total_cents, tax_country, and tax_rate_bps all come from the stored order snapshot, not from the current tax configuration.

  • This is a critical accounting guarantee. If the code recomputed tax during reads using the latest tax_rates entry, old orders could silently drift over time and stop matching what the customer was actually charged.

  • The only additional read performed here is for order_items, which are also stored snapshots of the purchased items. That keeps the whole order response historically accurate: both the order-level tax values and the per-item unit prices represent what was true at checkout time.

  • After this change, tax rate updates continue to matter for future carts and future orders, but not for existing ones. That boundary is exactly what you want in a real commerce system.

Checkout Must Resolve Tax Inside the Same Transaction

Resolving and Snapshotting the Tax Rate at Checkout

Now we reach the tax-specific heart of the checkout flow. At this point, the transaction has a locked cart, locked items, and validated inventory. That means it can safely compute the final order totals for this exact checkout moment.

This logic also needs a fallback: if the cart has no tax_country, or if the chosen country does not exist in tax_rates, checkout should use DEFAULT_TAX_RATE_BPS.

    const subtotal = itemsResult.rows.reduce(
      (acc, r) => acc + r.quantity * r.unit_price_cents,
      0,
    );
    const taxRateRows = cart.tax_country
      ? await client.query<TaxRateLookupRow>(
          "SELECT rate_bps FROM tax_rates WHERE country_code = $1",
          [cart.tax_country],
        )
      : { rows: [] as TaxRateLookupRow[] };
    const resolvedTaxRateBps =
      taxRateRows.rows[0]?.rate_bps ?? DEFAULT_TAX_RATE_BPS;
    const tax = computeTaxCents(subtotal, resolvedTaxRateBps);
    const total = subtotal + tax;
    const currency = (productMap.get(itemsResult.rows[0].product_id)
      ?.currency ?? "USD") as Order["currency"];
  • The subtotal is derived from the locked cart item snapshot, not from any frontend calculation. That keeps the financial result deterministic and ensures checkout totals always come from persisted server-side data.

  • If cart.tax_country exists, the transaction looks up rate_bps in tax_rates. If no row is found, or if the cart never had a country set, the code falls back to DEFAULT_TAX_RATE_BPS, which lets checkout continue with a predictable default rate instead of failing unexpectedly.

  • The resolved rate is then used to compute tax_cents with computeTaxCents(...), and the final total_cents is produced by adding subtotal and tax. These are the exact values that must be stored onto the order and preserved forever.

  • Importantly, the transaction now has both the result (tax_cents, total_cents) and the explanation (tax_country, tax_rate_bps). Storing both is what makes the snapshot useful to downstream clients and to future order reads.

Inserting the Order with the Tax Snapshot

Snapshotting the Purchased Items and Finalizing Checkout

Why Future Tax Changes Must Not Affect Existing Orders

This lesson closes an important conceptual loop in the course.

Earlier, we made cart totals depend on the current tax setup. That was correct, because carts are live, editable objects. But after checkout, the order must stop following current tax configuration and start representing historical truth.

That is why this lesson insists on two complementary rules:

  1. Checkout resolves and stores tax snapshot fields during the transaction.
  2. Order reads return the stored values exactly as persisted, without recomputing tax.

Together, those rules create the right behavior for a production-style commerce system. Tax updates remain useful because they affect future customers and future carts, but they do not rewrite financial history.

Final Recap

You made it to the end of the path—great work. 🎉 In this final lesson, we pulled together the cart, tax, and order workflows into a proper checkout snapshot.

In src/lib/repositories/ordersRepo.ts, we saw how OrderRow and mapOrderRow(...) include tax_country and tax_rate_bps, so those fields flow into the Order domain object consistently. We also confirmed that both listOrders(...) and getOrderById(...) return orders with the stored tax context, which keeps list views and detail views aligned.

Most importantly, we studied createOrderFromCart(...) and saw how it locks the cart and products, validates inventory, resolves the tax rate from tax_country, falls back to DEFAULT_TAX_RATE_BPS when needed, computes tax_cents and total_cents, and inserts the order with both tax_country and tax_rate_bps persisted. Because all of that happens inside one transaction, the order snapshot reflects the exact truth of the checkout moment.

That is the final piece of the system: carts can change, tax rates can change, inventory can change—but a completed order should not.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal