Skip to content

Core Business Primitives

The engine ships four reusable business primitives every application module leans on: units of measure, multi-currency exchange rates, attachments (the filestore), and localization (geo + locale formats). Reuse these rather than rebuilding them — they live under fullfinity/modules/core/models/ (currency rate logic lives in modules/invoicing, see below).

Two models in modules/core/models/uom.py: UoM and UoMCategory.

A UoM belongs to a UoMCategory and carries a factor (how many base units one of this unit equals) and a rounding precision. Conversion within a category always routes through the category’s base unit, so every category must have exactly one is_base_uom UoM — the model enforces this invariant on create and update, raising ValidationError if you try to add a second base UoM to a category.

class UoM(Model):
_verbose_name = "Unit of Measure"
name = Char(max_length=50, required=True)
category = ManyToOne("UoMCategory", related_name="uoms", on_delete="SET NULL")
factor = Float(default=1.0) # FROM this UoM TO base; Dozen has factor=12
rounding = Float(default=0.001, precision=6)
is_base_uom = Boolean(default=False)

All methods are on the UoM instance:

  • round_qty(qty) — round a quantity to this UoM’s rounding precision (uses round_float). Returns qty unchanged when rounding is unset/zero.
  • convert_to_base(qty, round_result=True)qty * factor, e.g. 2 dozen -> 24 units.
  • convert_from_base(qty, round_result=True)qty / factor, e.g. 24 units -> 2 dozen. Raises UserError if factor is 0.
  • await convert(qty, to_uom, round_result=True) — convert between two UoMs in the same category, routing source -> base -> target. Raises UserError if the two UoMs are not in the same category. Rounds with the target UoM’s precision.
  • await is_compatible(other_uom)True when both are in the same category (or both have no category).
UoM = get_model("UoM")
dozen = await UoM.filter(name="Dozen").get()
gram = await UoM.filter(name="g").get()
await dozen.convert_to_base(2) # 24.0 (units)
await gram.is_compatible(dozen) # False — different categories
qty = await dozen.convert(1, to_uom=units) # same category -> routed through base

convert and is_compatible are async because they fetch the related category; convert_to_base, convert_from_base, and round_qty are plain (no I/O).

The engine ships a deliberately bare Currency model in modules/core/models/currency.py. The rate logic lives in modules/invoicing/models/currency_rate.py (CurrencyRate) — rates are an accounting concern, so the engine stays minimal and invoicing adds the dated rate table and conversion API.

class Currency(Model):
_verbose_name = "Currency"
name = Char(max_length=3, required=True) # ISO code, e.g. "USD"
symbol = Char(max_length=10)
rounding = Float(default=0.01, precision=6)
active = Boolean(default=False) # currencies must be explicitly enabled
position = Selection(choices=["Before", "After"], default="Before")
plural = Char(max_length=255)

CurrencyRate stores a dated rate per currency per company. A rate is expressed as 1 unit of this currency = rate units of company currency; inverse_rate is computed and stored for convenience. The triple (currency, company, date) is unique.

class CurrencyRate(Model):
_verbose_name = "Currency Rate"
_unique_together = [("currency", "company", "date")]
currency = ManyToOne("Currency", related_name="rates", required=True, on_delete="CASCADE")
company = ManyToOne("Company", related_name="currency_rates", required=True, on_delete="CASCADE")
date = Date(required=True, default=lambda self: date.today(), index=True)
rate = Float(required=True, precision=10)
inverse_rate = Float(calculate="compute_inverse_rate", store=True, precision=10)

Both are classmethods on CurrencyRate:

  • await CurrencyRate.get_rate(currency_id, company_id, as_of_date=None) — the most recent rate on or before as_of_date (defaults to today). Returns 1.0 when the currency is the company’s own currency, or when no rate is on file.
  • await CurrencyRate.convert(amount, from_currency_id, to_currency_id, company_id, as_of_date=None) — convert an amount between two currencies, going through the company currency (amount * from_rate / to_rate). Returns amount unchanged when source and target match; raises UserError if the target rate is zero.
CurrencyRate = get_model("CurrencyRate")
rate = await CurrencyRate.get_rate(eur.id, company.id, as_of_date=date(2026, 6, 26))
amount_usd = await CurrencyRate.convert(100.0, eur.id, usd.id, company.id)

A Monetary field stores a value as NUMERIC and takes its display precision from a related currency via currency_field:

amount = Monetary(currency_field="currency") # precision & symbol come from `currency.rounding`
currency = ManyToOne("Currency", related_name="...", on_delete="RESTRICT")

Monetary itself does not convert — it just stores and formats one amount in one currency. Cross-currency arithmetic (e.g. a foreign-currency invoice posted to a company-currency ledger) goes through CurrencyRate.convert / get_rate. Use round_float for sums and comparisons of monetary values.

modules/core/models/attachment.py defines Attachment — the single model behind every uploaded file. Files are content-addressed: the SHA-256 checksum of the bytes is both the dedup key and the on-disk filename.

  • Filestore layout — bytes are written to FILESTORE_PATH/<db_name>/<checksum>. Two records with identical content share one physical file.
  • SHA-256 dedup — on create/update, base64 content is decoded, hashed, written under its checksum, and the base64 column is cleared (it is never stored in the row).
  • Ref-counted delete — deleting an Attachment removes the physical file only if no other row references the same checksum, so dedup is safe.
class Attachment(Model):
_verbose_name = "Attachment"
name = Char(max_length=255, required=True)
checksum = Char(max_length=64, index=True)
filepath = Char(max_length=1024)
is_public = Boolean(default=False)
mimetype = Char(max_length=255)
file_size = Integer()
model = Char(max_length=255) # owning model name
record = Integer() # owning record id
field = Char(max_length=255) # owning field name
base64 = Text() # input channel only — cleared on store
url = Char(store=False, calculate="get_url")

Two classmethods cover the common targeted-file cases (one file bound to a record’s field):

  • await Attachment.create_from_file(file_content, filename, model=None, record=None, field=None, is_public=False) — decode base64 file_content, hash, ensure FILESTORE_PATH/<db>/ exists, write under the checksum, and create the row. When model/record/field are all given, any existing attachment on that field is deleted first (so it replaces rather than accumulates). Returns the new attachment’s id.
  • await Attachment.remove_from_attachment(model=None, record=None, field=None) — remove the attachment bound to that field; deletes the physical file when it is the last reference. Requires all three arguments (raises UserError otherwise).
Attachment = get_model("Attachment")
att_id = await Attachment.create_from_file(b64_string, "invoice.pdf",
model="FinancialDocument", record=doc.id, field="pdf")
await Attachment.remove_from_attachment(model="FinancialDocument", record=doc.id, field="pdf")

The route is GET /filestore/{action}/{attachment_id} where action is view (serves inline) or download (serves as an attachment download). Access is granted when the attachment is public, the URL carries a valid signed expires/sig pair, or the authenticated user can access the owning parent record. The computed url field on an attachment points at /api/filestore/view/<id>; signed, time-limited URLs come from GET /api/files/{attachment_id}/url.

A model field of type File stores its value as an attachment id — it converts uploaded file data into an Attachment and clears the attachment when the field is cleared. So a File field is the model-level handle; Attachment is the storage behind it. (Binary stores raw bytes inline in the row instead, for small blobs that should live with the record.)

Geo models — modules/core/models/localization.py

Section titled “Geo models — modules/core/models/localization.py”
class Country(Model):
_verbose_name = "Country"
name = Char(max_length=255, index=True, required=True)
currency = ManyToOne("Currency", related_name="countries", on_delete="SET NULL")
code = Char(max_length=10)
flag = File(description="Flag")
class CountryState(Model):
_verbose_name = "State"
name = Char(max_length=255, index=True)
country = ManyToOne("Country", related_name="states", on_delete="CASCADE")
code = Char(max_length=10)
class CountryGroup(Model):
_verbose_name = "Country Group"
name = Char(max_length=255, index=True)
countries = ManyToMany("Country", related_name="groups", through="FKCountryGroup")
  • Country links to its default Currency (SET NULL), carries an ISO code and a flag (a File field, i.e. an attachment).
  • CountryState is the country’s subdivisions (CASCADE from country).
  • CountryGroup groups countries via an explicit ManyToMany through FKCountryGroup — handy for “EU”, “GCC”, tax zones, etc.

Language & locale formats — modules/core/models/language.py

Section titled “Language & locale formats — modules/core/models/language.py”

Language (table syslanguage) holds both the translation enablement and the locale display formats. The source language is "en" and cannot be modified; activating a language loads its i18n/*.json translations.

class Language(Model):
_verbose_name = "Language"
code = Char(max_length=10, index=True, required=True)
name = Char(max_length=255, required=True)
active = Boolean(default=False) # languages must be explicitly enabled
direction = Selection(choices=["ltr", "rtl"], default="ltr") # external standard tokens
# Locale formats
date_format = Char(max_length=20, default="DD/MM/YYYY", required=True)
time_format = Char(max_length=20, default="HH:mm")
decimal_separator = Char(max_length=5, default=".")
thousands_separator = Char(max_length=5, default=",")

The locale fields — date_format, time_format, decimal_separator, thousands_separator, and direction (RTL support) — drive how dates, times, and numbers are rendered for a user on that language. direction keeps the genuine external standard tokens ltr/rtl (an exception to the human-readable-Selection rule, like ISO weekday numbers). For the translation pipeline itself (English-text-as-key, generation, reload), see the Translations guide.