Margin Calculation Engine — Developer Reference
Technical deep-dive into Jules' three margin systems — Container Margin, Dashboard Margin, and Bulk Margin. Covers data models, SQL view architecture, incoterm logic, weighted average aggregation, FX handling, and how to extend the margin engine with a new cost component.
Margin Calculation Engine — Developer Reference
Developer documentation — How Jules computes trading margins across three distinct systems. Intended for engineers working on the
harold-apibackend, Prisma migrations, or the P&L epic.
Table of Contents
- Architecture overview
- Shared concepts
- Container Margin
- Dashboard Margin
- Bulk Margin
- Performance characteristics
- Adding a new cost component
- Testing margin logic
Architecture overview
Jules uses three margin systems that differ in granularity, data source, and purpose:
| System | Granularity | Data source | Primary use |
|---|---|---|---|
| Container Margin | Per container | ContainerInvoicingLine rows | Operational tracking, month-end close |
| Dashboard Margin | Per allocation (buy/sell pair) | Aggregated from invoicing lines | Advanced P&L reporting |
| Bulk Margin | Per stockpile | Stockpile weighted average + sale revenue | Warehouse / bulk operations |
Computation strategy
All three margin systems are computed on-the-fly from normalized invoicing data — there are no separate margin fact tables for the existing system. The upcoming Margin & P&L epic introduces ContainerMarginSnapshot as an immutable snapshot store (see New data models below), but the current system reads live from ContainerInvoicingLine.
Multi-tenancy note
All margin queries are scoped to the organization's PostgreSQL schema ({client}_lotr_extr). Prisma switches the schema at connection time based on the JWT claim. There is no cross-schema margin aggregation.
Shared concepts
The monetary value triple
Every monetary amount in the margin engine is represented as a triple: { quantity, currency, volume }.
// A margin of 150 USD per metric tonne:
{ quantity: 150.00, currency: "USD", volume: "T" }
This structure exists across invoicing lines, rate cards, and computed margin outputs. It ensures that the unit of measure (tonne, kg, short tonne) and the currency are always co-located with the amount, preventing silent unit-mismatch bugs.
Estimated vs final
The engine maintains two parallel margin values for every container:
| Variant | Source | Populated when |
|---|---|---|
estimatedMargin | Contractual prices + rate-card logistics costs | From allocation creation |
finalMargin | Actual amounts from posted invoices and bills | After invoicing lines are closed |
Both fields live directly on the Container model:
// On Container model
estimatedMargin Decimal // Pre-invoicing estimate
finalMargin Decimal // Post-invoicing actual
The estimated margin uses estimatedAmount from each ContainerInvoicingLine; the final margin uses actualAmount.
Currency normalization
Before any margin arithmetic, all component amounts are converted to the sale operation's currency. The conversion uses the FX rate applicable at the time of each transaction (event-date FX for operational stages, document-date FX for posted invoices).
Normalized amount = txAmount × fxRate → sale currency
This conversion happens at the invoicing line level. Dashboard margin then aggregates already-normalized values.
Per-tonne normalization
After currency conversion, all amounts are expressed per metric tonne (/ netWeight). This makes margins comparable across containers of different sizes.
Container Margin
When it applies
Container margin applies to export operations where commodities travel in physical shipping containers. It is the most common margin type — produced for every container linked to an allocation.
Formula
Container margin (per tonne) =
Sale price/T
− Purchase price/T
− Logistics cost/T
For totals: margin/T × netWeight.
Incoterm gate: when logistics is required
The logistics deduction is conditional on the incoterm pair. It is only included when the organization bears the transport cost:
Include logistics cost IF:
sale_incoterm ≠ EXW
AND purchase_incoterm = EXW
Rationale: If the buyer buys EXW (ex-works, the organization picks up from the supplier), but sells at CFR/CIF/FOB (the organization delivers to port or destination), the organization incurs the freight. That cost must appear in the margin.
If both sides are EXW, or if the purchase incoterm already includes transport (CFR, CIF), the logistics line is excluded from the deduction.
This logic is evaluated in the resolver when computing isLogisticRequired and controls whether the FREIGHT_COST and PRECARRIAGE lines are factored into the margin.
Data source: ContainerInvoicingLine
The container margin reads directly from ContainerInvoicingLine rows. Each row represents one cost or revenue element for one container:
| Field | Description |
|---|---|
containerId | FK → Container |
elementType | BUY, SELL, or PROVIDER |
costElement | Enum: FREIGHT_COST, PRECARRIAGE, AGENT_COMMISSION, INSPECTION, CUSTOMS, BL_FEE, UNEXPECTED_COST, etc. |
estimatedAmount | Rate-card amount at creation |
actualAmount | Invoiced amount (populated after billing) |
currency | Transaction currency |
status | PLANNED → LOADED → DELIVERED → CLOSED |
invoicingStatus | PENDING or INVOICED |
The margin computation groups lines by containerId and sums:
SELLlines → revenueBUYlines → purchase costPROVIDERlines matching the active logistics elements → logistics cost
Weighted average aggregation
When rolling up container margins to the operation or allocation level, Jules uses a quantity-weighted average rather than a simple average:
Aggregate margin/T = Sum(margin_i × quantity_i) / Sum(quantity_i)
This ensures that heavier containers (e.g., 25 T) have proportionally more influence on the aggregate than lighter ones (e.g., 18 T). A simple average would overweight small containers.
Grouping dimensions
The resolver groups containers along 5 dimensions for the margin matrix:
buyOperationId— purchase operationsellOperationId— sale operationbuyOperationQualityId— material grade on the buy sidesellOperationQualityId— material grade on the sell sidecontainerId— individual container
The UI can display margin at any level of this hierarchy (per container, per quality, per operation pair).
Computability conditions
A container margin can only be computed when:
- A sale price exists on the sell-side
OperationQuality - A purchase price exists on the buy-side
OperationQuality - If
isLogisticRequired = true: a logistics cost line exists and has a non-null amount
Missing any of these conditions results in a null margin (not zero). The resolver surfaces this as isComputable: false with explicit blockingReasons.
New data models — Margin & P&L epic
The in-progress Margin & P&L epic introduces ContainerMarginSnapshot as a point-in-time, immutable record of a margin computation:
model ContainerMarginSnapshot {
id String @id @default(uuid())
containerId String
stage MarginStage // FORECAST | OPERATIONAL | ACCRUED | ACTUAL | FINAL
triggerEvent String // e.g. "loading_confirmed", "posting", "manual_refresh"
// Computed totals in base currency
totalRevenue Decimal @db.Decimal(15, 2)
totalCogs Decimal @db.Decimal(15, 2)
totalExpenses Decimal @db.Decimal(15, 2)
totalWorkingCap Decimal @db.Decimal(15, 2)
totalAdjustments Decimal @db.Decimal(15, 2)
totalMargin Decimal @db.Decimal(15, 2)
marginPerUnit Decimal @db.Decimal(15, 4)
currency String
// FX at snapshot level (one rate per stage)
fxRate Decimal? @db.Decimal(15, 8)
fxSource FxSource? // ESTIMATED | EVENT | DOCUMENT
quantity Decimal @db.Decimal(15, 4)
quantityUnit String
isComputable Boolean @default(true)
blockingReasons String[]
isCurrent Boolean @default(true) // latest for this stage
isFrozen Boolean @default(false) // true for FINAL snapshots
lines ContainerMarginSnapshotLine[]
adjustments MarginAdjustment[]
@@index([containerId, stage, isCurrent])
@@map("container_margin_snapshots")
}
Each snapshot has child ContainerMarginSnapshotLine rows, one per MarginComponent:
enum MarginComponent {
REVENUE
COGS
FREIGHT
INSURANCE
BUNKERING
FEES
WORKING_CAPITAL
ADJUSTMENT
}
The five stages progress as the container moves through its lifecycle:
FORECAST → OPERATIONAL → ACCRUED → ACTUAL → FINAL
- FORECAST: contract signed — expected quantity, contract price, estimated FX
- OPERATIONAL: loading confirmed — loaded quantity, provisional price, event-date FX
- ACCRUED: delivery confirmed — delivered quantity, resolved pricing, event-date FX
- ACTUAL: all invoices and payables posted — from actual document amounts; adjustments happen at this stage
- FINAL: frozen/immutable — explicit freeze action; no further changes
Dashboard Margin
When it applies
Dashboard margin is used in the advanced reporting layer — the margin dashboard and P&L views. It provides the full cost breakdown per allocation (buy/sell pair), not just the three-line container view.
Formula
Dashboard margin (per tonne) =
Sale price/T
− Purchase price/T
− Logistics cost/T
− Pre-carriage cost/T
− Customs fees/T
− BL (Bill of Lading) fees/T
− Inspection cost/T
− Buy agent commission/T
− Sell agent commission/T
− Goal admin fees/T
− Payment term fees/T
− Other costs/T
That is 11+ deducted components versus the 1 logistics deduction in the container margin. The two systems can diverge — the container margin is a fast operational indicator, the dashboard margin is the reconciled P&L view.
Data flow
The resolver:
- Fetches all
ContainerInvoicingLinerows for the allocation - Converts each amount to the sale currency using the applicable FX rate
- Groups them by
costElementinto the 11+ categories - Divides by total net weight to produce per-tonne values
- Returns both
estimatedAmountandactualAmountviews
Component mapping
| Dashboard component | ContainerInvoicingLine costElement(s) |
|---|---|
| Purchase price | BUY elementType lines |
| Sale price | SELL elementType lines |
| Logistics | FREIGHT_COST, CARGO_BULK_COST, LOGISTIC_COST |
| Pre-carriage | PRECARRIAGE |
| Customs | CUSTOMS |
| BL fee | BL_FEE |
| Inspection | INSPECTOR, STERILE, DECLASSIFICATION |
| Buy agent commission | BUY_AGENT, AGENT_COMMISSION (on BUY side) |
| Sell agent commission | SELL_AGENT, AGENT_COMMISSION (on SELL side) |
| Goal admin fees | GOAL_ADMIN |
| Payment term fees | INTEREST, ADVANCE_PAYMENT |
| Other costs | UNEXPECTED_COST, PENALTY, GLOBAL_DISCOUNT, ELEMENT_DISCOUNT, and any unclassified PROVIDER lines |
Currency conversion in the dashboard
All components are converted to the sale operation's currency before aggregation. The FX rate used depends on the stage:
| Stage | FX source |
|---|---|
| Estimated | Estimated/assumed rate at contract date |
| Operational | Event-date rate (loading or delivery date) |
| Actual | Per-document rate from the posted invoice/payable |
At the Actual stage, different lines within the same container can use different FX rates (each reflecting the rate at the time the document was posted). The snapshot model captures this with fxRate/fxSource/fxDate at the line level.
Bulk Margin
When it applies
Bulk margin applies to warehouse operations where commodities are stored in stockpiles before resale. Unlike container operations, there is no direct link between a specific purchase contract and a specific sale — the material from multiple purchases is blended in the stockpile.
Formula
Bulk margin =
Net sale revenue
− Material purchase cost (from stockpile weighted average)
− Loading cost
Stockpile weighted average cost
The key distinction of bulk margin is how the purchase cost is determined. Instead of reading from a specific OperationQuality.price, the system uses the weighted average cost (mean_purchase_cost) of the stockpile:
mean_purchase_cost =
Sum(purchase_price_i × quantity_i)
/ Sum(quantity_i)
for all purchase receipts that fed this stockpile
Example:
| Receipt | Quantity | Price/T |
|---|---|---|
| Receipt 1 | 100 T | $200/T |
| Receipt 2 | 50 T | $250/T |
mean_purchase_cost = (100 × 200 + 50 × 250) / (100 + 50)
= (20,000 + 12,500) / 150
= 32,500 / 150
= $216.67/T
This weighted average is updated each time new material is received into the stockpile. It is stored on the Stockpile model as meanPurchaseCost.
Bulk margin data flow
Loading cost
The loading cost covers third-party costs incurred at the stockpile/warehouse to prepare the material for dispatch (e.g., front-end loaders, handling). It is tracked as PROVIDER invoicing lines against the bulk sale operation, not against individual containers.
Performance characteristics
Current system: on-the-fly computation
In the current implementation, all three margin systems compute their values at query time from live ContainerInvoicingLine data. There are no precomputed margin tables or materialized views.
Implications:
- Margins are always up-to-date with the latest invoicing data
- For large operations (50+ containers, 11+ cost elements each), the resolver must aggregate hundreds of rows per query
- Query-time performance is acceptable for typical operations sizes on the demo (~70 org schemas)
Current mitigations:
- Prisma queries use indexed
containerIdandoperationIdlookups - The allocation-level grouping is done in TypeScript after fetching the relevant rows (not a single complex SQL join)
- DataLoaders batch container fetches within a single GraphQL request to avoid N+1 queries
Upcoming: snapshot-based caching
The Margin & P&L epic introduces ContainerMarginSnapshot as a precomputed cache that is written on trigger events:
| Trigger event | Stage updated |
|---|---|
| Contract created | FORECAST |
| Loading confirmed | OPERATIONAL |
| Delivery confirmed | ACCRUED |
| Invoice/payable posted | ACTUAL |
| Manual freeze | FINAL |
This moves the heavy computation out of the GraphQL read path and into the write path (triggered as side effects of mutations). The dashboard reads the most recent isCurrent = true snapshot per stage, which is a single indexed lookup.
-- Fast snapshot read (indexed on containerId + stage + isCurrent)
SELECT * FROM container_margin_snapshots
WHERE container_id = $1
AND stage = 'ACTUAL'
AND is_current = true;
Adding a new cost component
This section walks through every layer you must touch to introduce a new cost element into the margin calculation — for example, a new STORAGE_FEE charge for warehouse dwell time.
Step 1 — Add the cost element enum value
In harold-api/prisma/schema.prisma, add the new value to the CostElement enum:
enum CostElement {
// existing values ...
UNEXPECTED_COST
STORAGE_FEE // new
}
Step 2 — Generate and run the migration
# In harold-api/
npx prisma migrate dev --name add-storage-fee-cost-element
This generates a migration file and applies it to your local database. The migration will include an ALTER TYPE statement for the PostgreSQL enum.
Important: PostgreSQL enum additions are append-only. You can add a value but cannot remove or rename one without a more complex migration.
Step 3 — Update the Dashboard Margin component mapping
In the Dashboard Margin resolver (look for the function that maps costElement to dashboard component categories), add the new element to the appropriate bucket:
// In the dashboard margin aggregation logic
const OTHER_COSTS_ELEMENTS = [
CostElement.UNEXPECTED_COST,
CostElement.PENALTY,
CostElement.GLOBAL_DISCOUNT,
CostElement.ELEMENT_DISCOUNT,
CostElement.STORAGE_FEE, // add here — or create a new bucket if warranted
];
If STORAGE_FEE warrants its own top-level line in the dashboard (rather than being folded into "Other costs"), add a new mapping entry and update the GraphQL schema accordingly.
Step 4 — Update the GraphQL schema (if adding a new dashboard line)
In the Dashboard module's SDL schema, add the new field to the margin output type:
type DashboardMargin {
# ... existing fields
storageFee: MonetaryValue
storageFeePerTon: MonetaryValue
}
Step 5 — Update the Container Invoicing UI
In harold-web, the container invoicing matrix must allow users to create lines of the new costElement type. Find the costElement dropdown configuration and add the new value with its display label.
Step 6 — Update the MarginComponent mapping (Margin & P&L epic)
If you are working within the new snapshot architecture, decide which MarginComponent the new element maps to:
const componentForElement: Record<CostElement, MarginComponent> = {
// ...
[CostElement.STORAGE_FEE]: MarginComponent.FEES, // or a new component value
};
If a new MarginComponent value is needed, add it to the enum and update snapshot computation logic accordingly.
Step 7 — Write a test
The pattern in the codebase uses snapshot tests for SQL queries and unit tests for resolver logic. Add a test that:
- Creates a container with a
STORAGE_FEEinvoicing line - Asserts the margin calculation correctly deducts the storage fee
- Asserts the dashboard margin surfaces it in the expected bucket
yarn jest --testPathPattern ContainerMargin
Step 8 — Verify
yarn lint
yarn type:check
Both must pass before opening a PR.
Testing margin logic
Snapshot tests for SQL
The codebase uses Jest snapshot tests for raw SQL queries. These live in test/__snapshots__/ directories alongside the SQL modules. When changing SQL, run:
yarn jest --updateSnapshot
Review the diff carefully — snapshot changes are the actual SQL that will run against production.
Unit tests for aggregation logic
Margin aggregation logic (weighted average, component bucketing, FX conversion) is unit-testable without a database. Inject mock ContainerInvoicingLine arrays and assert on the output shape.
Key edge cases to always test
| Scenario | Expected behavior |
|---|---|
| No logistics cost, both incoterms EXW | isLogisticRequired = false, logistics excluded from margin |
| Sale incoterm ≠ EXW, purchase incoterm = EXW, no freight bill yet | isComputable = false, explicit blocking reason |
| Mixed currencies (buy USD, sell EUR) | All amounts converted to EUR before margin arithmetic |
| Container with two qualities | Margin computed per quality, then weighted averaged |
| Zero quantity container | Guard against divide-by-zero in per-tonne calculation |
| Stockpile with single receipt | mean_purchase_cost = receipt_price (no averaging) |
Temporary price (isTemporaryPrice = true) | Margin is computed but flagged as provisional |
Related documentation
- Data Model Reference — Container, ContainerInvoicingLine, Allocation entity details
- Technical Stack — GraphQL module structure, Prisma conventions, TypeScript rules
- Margin Calculations (user doc) — Business-level explanation of the three systems
- Workflow: Month-End Margin Close — How Finance closes margins at period end
- Containers (user doc) — Container invoicing matrix and cost element catalogue
Last updated today
Built with Documentation.AI