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)The full ISO currency set ships inactive — because Currency has an active field it is
archivable, so every currency picker (a DataCombo bound to Currency) automatically lists only
the enabled ones. A currency becomes enabled either by being assigned as a company’s base currency
(setting Company.currency activates it) or by being switched on directly in the Currencies
management screen. Foreign-currency inputs (the currency selector and exchange-rate/gain-loss
fields on invoices, payments, purchase orders, and journals) are additionally gated behind a
Multi-Currency setting — author your own such gate with visible_setting: Q(enable_multi_currency=True)
on a field. To offer every currency in a picker regardless of enabled state (as the base-currency
field on the company form does), give it filter: Q(active__in=[True, False]) — explicitly
referencing active opts the query out of the archivable default.
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 stored under
<db_name>/ab/cd/<checksum>, fanned out by the first two byte-pairs of the checksum. Two records with identical content share one stored file. That key is relative, and it is the same string whether the backend is local disk (a path underFILESTORE_PATH) or an S3-compatible bucket (an object key) — which is why switching between them is a copy of the files and not a database migration. See Configuration. -
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, deferred delete — deleting an
Attachmentremoves the stored file only if no other row references the same checksum, so dedup is safe. The removal happens after the transaction commits, not during it: the delete queues the checksum, and a sweep reclaims the bytes shortly afterwards. Two reasons, and both matter to you:- It is atomic. The queue entry is written in the same transaction as the delete, so a rollback takes it with it and the bytes are never touched. Unlinking inline meant a transaction that failed after the delete left a live row whose file was already destroyed.
- It keeps storage off the request path. With attachments in object storage, unlinking is a network round trip; deferring it means deleting a record with many files does not wait on it, and a storage outage cannot fail the delete.
So immediately after deleting an attachment the file may still be present. Anything asserting otherwise — a test, a cleanup script — should run the sweep first. The sweep re-checks that nothing references the checksum at the moment it deletes, so a byte-identical file uploaded in between is safe.
-
Cloned databases share, never delete — a database cloned from another reads its source’s files, so a clone dropping an inherited attachment removes its own row and leaves the bytes where its source and its siblings still see them.
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, store the bytes under that 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 /filestore/view/<id>; signed, time-limited URLs come from
GET /api/files/{attachment_id}/url. The route is the same address on either storage backend —
with a bucket it answers with a short-lived redirect straight to the object rather than serving
the bytes through the application, so the filename, inline/download choice and caching policy are
carried into that redirect.
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.