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)

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)

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 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 under FILESTORE_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 the base64 column is cleared (it is never stored in the row).

  • Ref-counted, deferred delete — deleting an Attachment removes 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")

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, store the bytes under that 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 /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.

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.