Iurii RoguliaIurii Rogulia
AboutAI EngineeringServicesProjectsAI SkillsStackTestimonialsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
Hourly ratesChecklistsPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

  1. Home
  2. /
  3. Blog
  4. /
  5. Checkout Totals: Why the Server Recalculates Every Time, Not Once

Iurii Recalculates: Checkout Totals: Why the Server Recalculates Every Time, Not Once

A cached VAT rate and a display-only FX conversion are not a trust model. The server still has to derive the total from product data on every single order.

October 21, 2026· 11 min read

A cached VAT table and a courtesy FX conversion don’t make a checkout trustworthy – recalculating the total server-side does. How a B2B checkout separates the price a customer sees from the amount actually charged, and why a VAT lookup that throws on an unknown country beats one defaulting to 0%.

Stack

TypeScriptNext.js

Services

Stripe

Topics

E-commerceTax/VATPaymentsSecurityArchitecture
Checkout Totals: Why the Server Recalculates Every Time, Not Once

A checkout page shows a subtotal, a VAT line, a total. Somewhere in the request that submits the order, a server-side checkout total calculation has to confirm those numbers are correct before charging a card. The wrong answer to ‘where does that decision happen’ is a well-known exploit category: if the server trusts a total the client sent along with the order, changing that total is a browser dev-tools exercise, not a hack.

The right answer is simple: the server recomputes the total from its own product data, every time, on every order, rather than accepting a total the client already computed. What needs unpacking is what that requires once the storefront sells in multiple markets, multiple currencies, and multiple VAT regimes. ‘Recalculate server-side’ quietly picks up two more decisions once currency and tax enter the picture, and getting either one wrong looks fine until it doesn’t.

This is drawn from a B2B e-commerce platform I built, selling waterless urinal systems across 32 European countries – the pi-pi.ee project card covers the wider VAT and payments architecture. A companion piece, Caching VAT and FX Rates at Build Time, Not on the Request Path – call it the caching article – covers a different question from the same codebase: how the VAT table and FX rates get into the app without a live API call on checkout. This article is about what happens once they’re there – the trust model at the moment someone actually pays.

What ‘Trust the Client’s Total’ Actually Lets Someone Do

Start with the naive version. It isn’t a strawman – it’s what a checkout looks like before anyone thinks about this specifically. The cart page computes a subtotal, applies a VAT rate, shows a total, and the ‘pay’ button posts that total (or a Stripe amount built from it) straight to the charge call. The server’s job is just to charge whatever number arrived.

That version has an exploit for every input the client controls: edit a product’s unit price in the request payload before it’s used to build the Stripe amount, tamper with a quantity so a discount tier applies that shouldn’t, pass a coupon code the server never re-validates, or simply zero out the VAT line by claiming a country the buyer isn’t shipping to. None of these need sophisticated tooling – a browser’s network tab and a replayed, edited request are enough. And this isn’t a typical UI bug. A manipulated price isn’t a rendering glitch, it’s a customer paying less than the product costs, with the difference coming straight out of the seller’s margin, order after order, until someone notices the numbers don’t reconcile.

The fix is stated in the comment at the top of the actual calculateOrder function on this project:

// src/lib/stripe/calculate-order.ts
 
/**
 * Calculate order totals server-side.
 * Never trust client-provided amounts - always recalculate from product data.
 */
export function calculateOrder(
  items: CartItem[],
  country: Country,
  isBusiness: boolean,
  viesValidated: boolean,
  shippingCostNet: number
): OrderCalculation {
  const isReverseCharge = isBusiness && viesValidated && country !== SELLER_COUNTRY;
  const vatRate = isReverseCharge ? 0 : getVatRate(country);
 
  const productCharge = chargeCart(
    items.map((item) => ({
      netCents: Math.round(getItemPrice(item) * 100),
      quantity: item.quantity,
    })),
    vatRate
  );
 
  const shippingCharge = chargeUnit(Math.round(shippingCostNet * 100), vatRate);
 
  return {
    subtotal: productCharge.net,
    shippingCost: shippingCharge.net,
    vatRate,
    vatAmount: productCharge.vat + shippingCharge.vat,
    total: productCharge.gross + shippingCharge.gross,
    isReverseCharge,
  };
}

The function takes a cart of { productId, quantity } pairs – not a price and not a total. getItemPrice(item) looks up the unit price from the server’s own product catalogue (products.json) keyed by productId. Whatever price the client displayed on the cart page never enters this function at all, because CartItem doesn’t carry one: only productId, color, quantity, and an isSample flag.

The boundary isn’t ‘trust nothing from the client.’ quantity, country, and isBusiness are exactly that – client-supplied inputs, and there’s no way around taking them from the request. shippingCostNet is client-supplied too, in the same category: the client tells the server which shipping method it wants.

Where shipping differs from the product price is what ‘trusted’ should mean for a value like this. A shipping cost only earns that word once it’s resolved server-side from an ID against a catalogue of valid methods and their real costs – the same discipline getItemPrice gives the product price – rather than accepted as a raw number handed over with the order. A downstream sanity check – a minimum-charge floor, say – isn’t a substitute for that lookup: it catches gross abuse of the combined total, not manipulation of the shipping figure on its own. That’s a narrower scope than the guarantee this article is mostly about, and worth checking for in any checkout where some money fields are resolved from a catalogue and others aren’t.

The specific, non-negotiable part is narrower still: a product’s price is never a client-supplied input. It’s resolved from the catalogue, by ID, every time. Every order – the first one and the ten-thousandth – runs this same function from scratch. There’s no ‘trust the last calculation’ shortcut for the line items that make up the bulk of the total, because the product side of the total is a derived value, not a stored or forwarded one.

That single design decision is what makes everything downstream – the VAT rate, the reverse-charge flag, the currency display – safe to build without re-litigating trust at every layer. Get this one wrong and no amount of correct VAT-rate data fixes it, because the attacker was never going through the VAT lookup in the first place.

Two Different Numbers Wearing the Same Currency Symbol

Once a storefront serves buyers outside the eurozone, it’s tempting to call what it does ‘multi-currency checkout.’ That phrase implies more than this system does, and the gap between the phrase and the reality is where mistakes happen – both in how a buyer reads the price and in how an engineer might over-build the next feature.

What actually happens for a UK or Norwegian buyer here: the storefront shows a price in GBP or NOK next to the EUR price, computed by getApproxLocal from a currency table cached at build time. That’s it. The charge itself – the number Stripe actually moves – is always EUR, for every country, every currency shown on the page. The code that produces the local-currency number says so in a comment that’s doing architectural work, not decoration:

// src/lib/pricing/fx.ts
 
/**
 * Approximate local-currency reference for non-euro countries.
 *
 * The site prices everything in EUR and Stripe charges in EUR
 * (src/lib/stripe/config.ts). For the non-euro shipping countries we show an
 * APPROXIMATE local amount next to the EUR price ("≈ £25") to remove the
 * mental-conversion friction — always labelled "charged in EUR". Rates are the
 * ECB reference rates cached at build time in src/data/fx-rates.json.
 */
export function getApproxLocal(eurAmount: number, countryCode: string): ApproxLocal | null {
  const currency = COUNTRY_CURRENCY[countryCode];
  if (currency === undefined) return null;
  const rate = FX_RATES[currency];
  if (rate === undefined) return null;
  return { currency, amount: eurAmount * rate };
}

Two things collapse into one mental model easily, and shouldn’t:

  • The presentation layer – ‘here’s roughly what €29 is in your currency’ – is a UX courtesy. It removes the mental-arithmetic tax on a UK buyer trying to figure out if €29 is a good price. It’s approximate by design, refreshed on a build cadence, and never appears anywhere near an actual charge amount.
  • The settlement layer – the number Stripe actually debits – is always EUR here, regardless of which currency the storefront just showed. calculateOrder above never touches a currency other than EUR; there’s no branch in it for ‘this buyer sees GBP.’

Conflating the two is the mistake. ‘Multi-currency checkout’ sounds like real multi-currency settlement: a ledger that carries balances in ten currencies, FX risk sitting on the seller’s book because today’s rate isn’t tomorrow’s, reconciliation against bank statements in multiple currencies, maybe even hedging. None of that exists in this system, and none of it needs to – the actual charge never leaves EUR. What looks like multi-currency support from the customer’s side is a single-currency settlement system with a translation layer bolted onto the storefront. That’s a legitimate, much simpler thing to build. Be precise about which one you’re building before a spec says ‘multi-currency’ and someone starts scoping ledger entries that were never the requirement.

(A separate project I built, Pikkuna, also sells across 32 countries and 13 currencies – a reminder that this isn’t a one-off pattern for a single client. Its checkout isn’t the evidence for the code shown here.)

The Cache Feeds the Calculation, It Doesn’t Replace It

The caching article on this same codebase covers why the VAT table and FX rates are fetched once per build instead of once per request: a live tax-authority or FX call sitting in the checkout path adds latency and a third-party failure mode to the one part of the app where a customer is actively trying to pay. That’s a caching decision, and a good one on its own terms – but it answers a different question. The cache doesn’t do the job the request-time recalculation does.

getVatRate(country) reads from VAT_RATES, a table built once at module load from getAllRates() in the eu-vat-rates-data package – the same package and the same build-time refresh the caching article covers in detail, so it isn’t repeated here.

That lookup is fast and has no network dependency – the caching article’s whole point. But calculateOrder still calls getVatRate(country) fresh, inside the function, on every order. It doesn’t read a VAT amount that was computed once and stored; it reads a rate, then does the multiplication itself, against the product prices it just looked up from its own data, for this specific order. The cache saves the trip to fetch the rate table. It does not – and was never meant to – save the step of applying that rate to this order’s actual line items. Two separate jobs. A fast, cached rate lookup doesn’t mean the total is already computed and just needs forwarding: the recalculation described in the first section runs regardless of how the rate got there.

A VAT Lookup That Refuses to Guess

One more decision. The interesting part isn’t the choice itself – the caching article already covers why getVatRate throws on an unrecognized country instead of defaulting to 0%, and why that’s the opposite failure direction from the FX side of the same file. What matters is where that throw sits. getVatRate doesn’t default to a rate when it doesn’t recognize the country code:

// src/lib/pricing/config.ts
export function getVatRate(countryCode: string): number {
  const rateInfo = VAT_RATES[countryCode];
  if (rateInfo !== undefined) return rateInfo.rate;
  if (countryCode in ZERO_VAT_TERRITORIES) return 0;
  // Fail fast on a fully unknown code rather than silently charging 0% VAT.
  throw new Error(`getVatRate: unknown country code "${countryCode}"`);
}

This function runs inside calculateOrder, on every single order, as part of the same from-scratch recalculation covered in the first section. That placement is the point. A bad or missing rate can’t silently corrupt a stored total, because there’s no stored total for it to corrupt. calculateOrder either produces a fresh, correct total from a rate it just looked up, or it throws before producing a total at all. There’s no intermediate state – no order sitting half-priced in the database with a wrong VAT amount, waiting to be found on a filing – because the VAT amount is never computed once and kept. It’s computed at the moment of the charge, every time, from whatever getVatRate returns right then.

Where This Doesn’t Apply

This is a storefront that settles in one currency and taxes according to a well-defined, slow-changing table of EU standard rates. The two places that stops being true – a system genuinely settling in multiple currencies, and a VAT rate changing mid-cycle faster than a rebuild catches it – are exactly the boundaries the caching article walks through.

Server-side recalculation from trusted data has to be right regardless of any of that. It’s the layer that turns ‘the customer typed something’ into ‘the amount we actually charge,’ and nothing about caching, currency display, or tax-table freshness changes what that layer’s job is.


If your checkout trusts a number that came from the browser anywhere between the cart and the charge – a price, a total, a discount, a tax line – that’s worth finding before a manipulated request finds it first. Get in touch and let’s look at where the trust boundary actually sits. That’s the kind of build I cover on the E-commerce page.


Further reading:

  • Caching VAT and FX Rates at Build Time, Not on the Request Path – how the VAT table and FX rates this article reads from get into the app without a runtime API call
  • pi-pi.ee – B2B E-commerce for Waterless Urinal Systems – the project this checkout logic is drawn from
  • Idempotency Keys: Building Retries That Don’t Double-Charge – the companion trust problem on the payment-retry side of the same kind of checkout
Iurii RoguliaAvailable

E-commerce

Selling across multiple markets and want a checkout where the total the customer sees is the same number your accountant sees on the invoice? That’s the kind of e-commerce build I do.

More about this service

Relevant client work

View all projects
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products

E-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun → PDF.

pi-pi.ee – B2B E-commerce for Waterless Urinal Systems
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems
January 17, 2026
pi-pi.ee – B2B E-commerce for Waterless Urinal Systems

i18n B2B e-commerce platform for waterless urinal products across 32 European countries with automated VAT handling, PDF invoicing, and CRM integration.

kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
kri.rocks – Booking Website and Online Scheduling SaaS for Beauty Businesses
September 28, 2026

Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes.

What clients say

“

Iurii built our entire B2B platform from scratch in a remarkably short time – a fully multilingual webshop covering 32 European markets in 28 languages, with automated VAT calculation, VIES

Mark Tamm 🇪🇪

Account Manager, pi-pi.ee

Services

StripeNotion

Topics

E-commerceB2Bi18nPaymentsPDFTax/VAT
“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time.

Bram de Vries 🇳🇱

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026· 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access
July 24, 2026· 9 min
Gated B2B Pricing in Next.js: Hiding Prices Behind Per-Customer Access

Gated B2B pricing in Next.js: per-account price lists and server-side access control so prices never leak to crawlers.

Stack

Next.jsReactTypeScript

Databases

PostgreSQL
Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026· 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow
February 22, 2026· 10 min
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

E-commerce order automation that cut 160+ hours/month of manual work to zero: Stripe webhook to VAT invoice and tracking in under 2 minutes.

Stack

Next.jsTypeScriptNode.js

Databases

Redis
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026· 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, a BullMQ queue, and signature verification in Next.js App Router.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis