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.
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:

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).
| Column | Meaning |
|---|---|
code | Unique identifier, e.g. VAT_DE. |
name | Human label, e.g. “VAT Germany”. |
rate | Percentage as Numeric(5,2), e.g. 19.00. |
country_code / region_code | ISO-3166 location the rate applies to (region preferred, country as fallback). |
tax_class | standard / reduced / zero label. |
is_inclusive | Whether the stored price already includes this tax. |
is_active | Soft 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.

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.0Countries
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.

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 currencyPrice 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).
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.

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 · D1 | One entry point — PriceFactory.get_price_from_object. The factory depends only on settings + CurrencyService. |
| S85 · D2 | Dispatch on the Priceable protocol, never a concrete type — keeps core agnostic. |
| S85 · D3 | Price is computed, not stored. |
| S85 · D4 | Prices are floats, never rounded in code; rounding lives only at display. |
| S85 · D5 | No currency on sellable tables — Price.currency is the global default. |
| S85 · D8 | The amount charged equals Price.brutto. |
| S85 · D9 | Business viewer ⇒ net display (display-only). |
| S84 · D1 | Active set + default live in settings; the catalogue + rates live on the table. |
| S84 · D3 | Promoting a default re-bases all rates behaviour-preservingly. |
| S84 · D5 | Deactivating is non-destructive; the default is protected (cannot be removed, rate fixed at 1.0). |