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).
Units of Measure
Section titled “Units of Measure”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)Conversion API
Section titled “Conversion API”All methods are on the UoM instance:
round_qty(qty)— round a quantity to this UoM’sroundingprecision (usesround_float). Returnsqtyunchanged whenroundingis 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. RaisesUserErroriffactoris 0.await convert(qty, to_uom, round_result=True)— convert between two UoMs in the same category, routing source -> base -> target. RaisesUserErrorif the two UoMs are not in the same category. Rounds with the target UoM’s precision.await is_compatible(other_uom)—Truewhen 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 categoriesqty = await dozen.convert(1, to_uom=units) # same category -> routed through baseconvert 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).
Multi-currency exchange rates
Section titled “Multi-currency exchange rates”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)Rate lookup & convert API
Section titled “Rate lookup & convert API”Both are classmethods on CurrencyRate:
await CurrencyRate.get_rate(currency_id, company_id, as_of_date=None)— the most recent rate on or beforeas_of_date(defaults to today). Returns1.0when 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). Returnsamountunchanged when source and target match; raisesUserErrorif 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)How Monetary fields relate
Section titled “How Monetary fields relate”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.
Attachments (the filestore)
Section titled “Attachments (the filestore)”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 thebase64column is cleared (it is never stored in the row). - Ref-counted delete — deleting an
Attachmentremoves 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")Creating & removing attachments
Section titled “Creating & removing attachments”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 base64file_content, hash, ensureFILESTORE_PATH/<db>/exists, write under the checksum, and create the row. Whenmodel/record/fieldare 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 (raisesUserErrorotherwise).
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")View / download endpoint
Section titled “View / download endpoint”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.
Relationship to File / Binary fields
Section titled “Relationship to File / Binary fields”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.)
Localization
Section titled “Localization”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")Countrylinks to its defaultCurrency(SET NULL), carries an ISOcodeand aflag(aFilefield, i.e. an attachment).CountryStateis the country’s subdivisions (CASCADEfrom country).CountryGroupgroups countries via an explicitManyToManythroughFKCountryGroup— 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.