Developer Docs · Money

Taxes & Currencies

How VBWD computes money: the tax model, the currency catalogue, country tax-zones, and the one PriceFactory that turns any sellable into a net / tax / gross Price.

What this feature does

Every price in VBWD — a subscription plan, a shop product, a bookable resource, a token bundle — is computed by one shared pipeline. That pipeline answers four questions: how much tax applies, in which currency, net or gross, and for which country. This page documents the whole machinery, from the admin screens an operator uses down to the value objects the backend computes.

One factory, one truth. A single entry point — PriceFactory.get_price_from_object(sellable) — turns any sellable into a computed Price carrying net, per-rate tax and gross in one operating currency. Nothing else multiplies a rate by a price.

Operators configure all of this from one admin page with three tabs — Taxes, Currencies and Countries:

Admin → Settings → Tax & Countries — the Taxes tab. Tax rates are created, edited and linked to sellables here (/admin/settings/tax-and-countries).
Admin → Settings → Tax & Countries — the Taxes tab. Tax rates are created, edited and linked to sellables here (/admin/settings/tax-and-countries).

Taxes

A tax rate is a row in the vbwd_tax table, exposed through TaxService and the admin routes under /api/v1/admin/tax/rates (read gated by settings.view, writes by settings.manage).

ColumnMeaning
codeUnique identifier, e.g. VAT_DE.
nameHuman label, e.g. “VAT Germany”.
ratePercentage as Numeric(5,2), e.g. 19.00.
country_code / region_codeISO-3166 location the rate applies to (region preferred, country as fallback).
tax_classstandard / reduced / zero label.
is_inclusiveWhether the stored price already includes this tax.
is_activeSoft availability flag.

The Tax model carries the arithmetic itself — calculate(net), calculate_gross(net), extract_net(gross) and extract_tax(gross) — all in Decimal. A sibling vbwd_tax_rate table keeps historical rates (valid_from / valid_to) so an old invoice can always be recomputed at the rate that was in force when it was issued.

Linking a tax to a sellable

Taxes attach to sellables through per-plugin many-to-many join tables — subscription_tarif_plan_tax, shop_product_tax, booking_resource_tax, token_bundle_tax. A sellable can carry several rates; the computed price keeps each one as its own line.

GET    /api/v1/admin/tax/rates        # list (filter: country, is_active, tax_class)
POST   /api/v1/admin/tax/rates        # create
PUT    /api/v1/admin/tax/rates/<id>   # update
DELETE /api/v1/admin/tax/rates/<id>   # delete (blocked while in use)

Currencies

Currencies are split deliberately into two halves. The catalogue — name, symbol, decimal places and the exchange rate — lives in the vbwd_currency table. Which currencies are active and which one is the default lives in the core settings JSON, the single source of truth (${VBWD_VAR_DIR}/core). CurrencyService joins the two and derives is_active / is_default from settings on read — they are never columns.

The Currencies tab — the active set, the default currency, the cross-rate base and an editable rate sheet.
The Currencies tab — the active set, the default currency, the cross-rate base and an editable rate sheet.

Exchange rates are default-relative: the default currency always has rate 1.0, and every other rate is “units of that currency per one unit of the default”. Promoting a new default re-bases every other rate (new_X = old_X / old_rate_<newdefault>) so prices do not move. Conversion runs end-to-end in Decimal with no intermediate rounding.

GET   /api/v1/admin/currencies                 # catalogue (+ derived active/default)
POST  /api/v1/admin/currencies                 # add a catalogue row
POST  /api/v1/admin/currencies/<code>/activate
POST  /api/v1/admin/currencies/<code>/deactivate   # default is protected
POST  /api/v1/admin/currencies/<code>/set-default   # re-bases all rates
PUT   /api/v1/admin/currencies/<code>/rate         # default rate is fixed at 1.0

Countries

The vbwd_country table is the checkout country list. Each row has an ISO code, an is_enabled flag and a position for ordering. Enabled countries appear in checkout, ordered by position; disabling one drops it to the bottom. Countries are also the location key tax lookup matches against — a tax with country_code = "DE" applies to buyers in Germany.

The Countries tab — an active/available selector plus drag ordering for the enabled set.
The Countries tab — an active/available selector plus drag ordering for the enabled set.
GET   /api/v1/admin/countries/            # all, enabled-first
POST  /api/v1/admin/countries/<code>/enable
POST  /api/v1/admin/countries/<code>/disable
PUT   /api/v1/admin/countries/reorder     # { codes: [...] }

The Price value object

Nothing in the platform multiplies a rate by a price by hand. Everything goes through PriceFactory:

from vbwd.pricing import PriceFactory

price = PriceFactory.get_price_from_object(sellable)

price.netto     # net amount (full precision)
price.taxes     # [PriceTax(code, name, rate, amount), ...]
price.brutto    # gross == netto + sum(tax.amount)
price.currency  # the single operating currency

Price is a frozen value object — it is computed, never stored. The factory depends only on the core settings and CurrencyService; it accepts anything that satisfies the structural Priceable protocol (a stored raw_price plus its linked taxes), so core never imports a plugin’s concrete model.

Net or gross in the database

A single global setting, prices_mode_in_db (NETTO or BRUTTO), tells the factory how to read the stored number:

  • NETTO (default) — stored value is net; brutto = netto + Σ(netto × rateₙ / 100).
  • BRUTTO — stored value is gross; netto = brutto / (1 + Σ rateₙ / 100).
Money is never rounded in code. The factory works in full precision and casts back to a float untouched. Rounding happens at exactly two boundaries: the display layer (the frontend money formatter) and the moment a value is written onto an invoice line — never in the middle of a calculation.

Display modes & the public config

Storage mode and display mode are orthogonal. prices_display_mode (netto / brutto) chooses which side a storefront shows, independent of how the number is stored. Both modes, the currency settings and the rate table are surfaced to every frontend through one unauthenticated endpoint:

GET /api/v1/config
{
  "default_currency": "EUR",
  "base_currency": "EUR",
  "active_currencies": ["EUR"],
  "currency_rates": { "EUR": "1.00000000" },
  "prices_display_mode": "brutto",
  "prices_mode_in_db": "NETTO"
}

These two price-mode switches sit at the top of the Taxes tab and round-trip through GET|PUT /api/v1/admin/settings with strict validation — the default currency must be a member of the active set, and every active code must exist in the catalogue.

The price display mode and the in-database price mode — global switches on the Taxes tab, read by the PriceFactory and surfaced at /api/v1/config.
The price display mode and the in-database price mode — global switches on the Taxes tab, read by the PriceFactory and surfaced at /api/v1/config.
D9 — the business viewer rule. A logged-in business account always sees net, regardless of the item or global display mode. This is display-only — it never changes what is stored or what the buyer is charged. Precedence: business user → net, then per-item override, then the global mode.

Invoices carry a per-line tax breakdown

When a price lands on an invoice it is quantised to cents — the one legitimate rounding boundary for a financial record — and each rate is recorded individually in the line item’s tax_breakdown JSONB column:

"tax_breakdown": [
  { "code": "VAT_DE", "name": "VAT Germany", "rate": 19.0, "amount": 1.90 }
]

The list always sums to the line’s tax_amount, and net_amount + Σ(amounts) == gross. Because invoice views aggregate this per-line breakdown, a discount line must carry a negative per-rate breakdown so the totals still reconcile: the discount reduces net and tax is recomputed against the reduced base. See Discounts & referral for that interaction.

Design decisions

The two sprints that built this each left a numbered decision log. The load-bearing ones:

#Decision
S85 · D1One entry point — PriceFactory.get_price_from_object. The factory depends only on settings + CurrencyService.
S85 · D2Dispatch on the Priceable protocol, never a concrete type — keeps core agnostic.
S85 · D3Price is computed, not stored.
S85 · D4Prices are floats, never rounded in code; rounding lives only at display.
S85 · D5No currency on sellable tables — Price.currency is the global default.
S85 · D8The amount charged equals Price.brutto.
S85 · D9Business viewer ⇒ net display (display-only).
S84 · D1Active set + default live in settings; the catalogue + rates live on the table.
S84 · D3Promoting a default re-bases all rates behaviour-preservingly.
S84 · D5Deactivating is non-destructive; the default is protected (cannot be removed, rate fixed at 1.0).