Date: 2026-04-02 Status: Design Complete — v3 (composable variables, math-free rules) Customer Catalyst: Dan @ MortgageAutomator — 60-day Liquidity Coverage Ratio Brainstorm Session: product + technical perspectives with Gemini
TreasuryPath's financial control rules are limited to 6 preset types that only evaluate bank account balances. Customers like Dan (MortgageAutomator, a loan lending firm) need to monitor custom financial ratios like Liquidity Coverage Ratio (LCR) that combine multiple data sources — cash balances, LOC availability, and projected cash flows by category over a forward-looking window.
Dan's requested formula:
LCR = (Cash + LOC Availability + Expected Payoffs + Debenture Fundings + Interest Income)
÷
(Loan Fundings + Draws + Debenture Redemptions + Opex + Debt Service)
Variables ARE the expression engine. A single composable system:
- Primitive Variables — Resolve to a monetary value from a data source (static value, bank balance, projected cash flows, LOC availability)
- Computed Variables — Mathematical expressions composed of other variables. Can reference both primitive and other computed variables. This is where ALL math lives.
- Rules — Simply compare a single variable against a threshold. Zero math in rules. The existing rule model already does this — we just point it at a variable instead of a template-based evaluator.
- Math belongs in variables, not rules: A rule is a policy ("alert when X drops below 1.2"). The formula that defines X is the variable.
- Layered composition: Primitive → intermediate computed → final computed.
LCR = Liquid Sources / Total OutflowswhereLiquid Sources = Cash + LOC + Payoffs + ... - Maximum reusability:
LCRvariable used in 3 rules (breach at 1.0, warning at 1.2, target at 1.5).Liquid Sourcesreused in LCR and Quick Ratio. - Simpler rules: No
formula_definitionJSON on the rule. Justfinancial_control_variable_id+threshold_value. The rule model barely changes. - Transparency: Every layer is inspectable. User can see: LCR=1.44, Liquid Sources=$2M, Total Outflows=$1.39M, Current Cash=$1.2M, LOC=$500K...
- No expression logic in rules or GraphQL rule types: Expression builder is purely a variable concern
CREATE TABLE `financial_control_variables` (
`id` bigint NOT NULL AUTO_INCREMENT,
`uuid` varchar(255) NOT NULL,
`company_id` bigint NOT NULL,
`name` varchar(255) NOT NULL,
`description` text,
`variable_type` enum('static', 'balance', 'cash_flow', 'loc', 'computed') NOT NULL,
`configuration` json NOT NULL,
`currency` varchar(3) NOT NULL,
`archived_at` datetime DEFAULT NULL,
`created_at` datetime(6) NOT NULL,
`updated_at` datetime(6) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `index_fc_variables_on_uuid` (`uuid`),
KEY `index_fc_variables_on_company_id` (`company_id`),
UNIQUE KEY `index_fc_variables_on_company_and_name` (`company_id`, `name`),
KEY `index_fc_variables_on_variable_type` (`company_id`, `variable_type`)
);ALTER TABLE `financial_control_rules`
ADD COLUMN `financial_control_variable_id` bigint DEFAULT NULL,
ADD INDEX `index_fc_rules_on_variable_id` (`financial_control_variable_id`);No formula_definition needed. A custom_formula rule just points to one computed variable. The variable contains the expression. The rule contains the threshold.
The computed variable type stores a mathematical expression in its configuration JSON. The expression references other variables (both primitive and computed) by UUID.
Configuration schema for computed variables:
{
"expression": [
{ "type": "group", "items": [
{ "type": "var", "id": "uuid-cash" },
{ "type": "op", "value": "+" },
{ "type": "var", "id": "uuid-loc" },
{ "type": "op", "value": "+" },
{ "type": "var", "id": "uuid-payoffs" }
]},
{ "type": "op", "value": "/" },
{ "type": "group", "items": [
{ "type": "var", "id": "uuid-loans" },
{ "type": "op", "value": "+" },
{ "type": "var", "id": "uuid-draws" }
]}
]
}Expression node types:
var— references anotherFinancialControlVariableby UUID (any type, including other computed)number— inline numeric literal (e.g.,{ "type": "number", "value": 100 })op— operator:+,-,*,/group— parenthesized sub-expression (contains anitemsarray of nodes, can be nested)
Resolution: Recursively resolves referenced variables first, then walks the expression tree with PEMDAS. If a referenced variable is also computed, it resolves recursively (with circular dependency detection).
Examples using layered composition:
# Primitive variables
Current Cash = balance { scope: all_accounts, balance_type: total_cash }
LOC Availability = loc { scope: all_loc_accounts }
Expected Payoffs 60d = cash_flow { categories: [Loan Repayment, Balloon], days: 60 }
Interest Income 60d = cash_flow { categories: [Interest Income], days: 60 }
Loan Fundings 60d = cash_flow { categories: [Loan Disbursement], days: 60 }
Opex 60d = cash_flow { categories: [Operating Expenses], days: 60 }
# Intermediate computed variables
Liquid Sources = computed { Cash + LOC + Payoffs + Interest }
Total Outflows = computed { Loans + Opex + ... }
# Final computed variable
LCR = computed { Liquid Sources / Total Outflows }
# Rule: LCR >= 1.2
Validations:
- Expression must be syntactically valid (alternating terms and operators, groups balanced)
- All
varUUIDs must reference active variables belonging to the same company - Expression must contain at least one
varnode - No circular dependencies — a computed variable cannot reference itself or form a cycle
- Max nesting depth: 5 levels
- Max expression length: 50 nodes
- Max dependency chain depth: 10 (computed → computed → computed → ... → primitive)
Add custom_formula to the logic_type enum. Seed one system template:
FinancialControlTemplate.find_or_create_by!(logic_type: 'custom_formula') do |t|
t.name = 'Custom Formula (Ratio)'
t.description = 'Define a custom ratio using variables from your Variable Pool'
t.requires_time_period = false # time windows defined per-variable
t.active = true
endFormula rules use existing decimal columns (no schema change needed):
financial_control_rules.threshold_value→ threshold (e.g.,1.20)financial_control_rules.financial_control_variable_id→ the variable to monitorfinancial_control_evaluations.evaluated_value→ resolved variable value (e.g.,1.35)financial_control_evaluations.contributing_accounts→ JSON with per-variable breakdown (full dependency tree)
Manual value entered by user.
Configuration:
{
"value": 5000000.00
}Resolution: Returns Money.new(value * 100, variable.currency)
Validation: value must be numeric.
Sum of current balances for scoped bank accounts.
Configuration:
{
"scope_type": "specific_accounts",
"scope_ids": ["bank-account-uuid-1", "bank-account-uuid-2"],
"balance_type": "total_cash"
}scope_type:all_accounts|specific_accounts|by_entity|by_institutionscope_ids: required when scope_type is specific (max 250)balance_type:total_cash|available_cash
Resolution: ScopeResolver → BalanceAggregator (reuses existing infrastructure)
Currency enforcement: All resolved accounts must share the same currency (validated at creation and evaluation). Variable's currency field set from accounts.
Sum of projected cash flows by category over a forward-looking window.
Configuration:
{
"cash_category_ids": ["category-uuid-1", "category-uuid-2"],
"days_forward": 60
}Resolution:
- Expand
cash_category_idsto include all descendants via recursive CTE - Deduplicate the full set (prevents double-counting if parent + child both selected)
- Query
expected_cash_flowsfiltered by:cash_category_id IN (deduplicated_set)company_id = variable.company_idexpected_date BETWEEN today AND today + days_forwardamount_currency = variable.currency(filter, not convert — multi-currency handled by creating separate variables)
SUM(amount_cents)→ return as Money
Recursive CTE:
WITH RECURSIVE category_tree (id) AS (
SELECT id FROM cash_categories WHERE uuid IN (:root_category_uuids) AND company_id = :company_id
UNION ALL
SELECT c.id FROM cash_categories c JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT SUM(ecf.amount_cents)
FROM expected_cash_flows ecf
WHERE ecf.cash_category_id IN (SELECT id FROM category_tree)
AND ecf.company_id = :company_id
AND ecf.amount_currency = :currency
AND ecf.expected_date BETWEEN :start_date AND :end_date;Validation: cash_category_ids must belong to company. days_forward ≥ 1.
Total available credit across LOC accounts.
Configuration:
{
"scope_type": "all_loc_accounts",
"scope_ids": []
}scope_type:all_loc_accounts|specific_loc_accountsscope_ids: required whenspecific_loc_accounts(max 250)
Resolution: Resolve LOC-enabled BankAccounts → sum available_to_draw_on
Currency enforcement: Same as Balance — all resolved accounts must share currency.
Mathematical expression composed of other variables.
Configuration:
{
"expression": [
{ "type": "var", "id": "uuid-liquid-sources" },
{ "type": "op", "value": "/" },
{ "type": "var", "id": "uuid-total-outflows" }
]
}Resolution:
- Extract all referenced variable UUIDs from expression
- Detect circular dependencies — build dependency graph, reject cycles
- Recursively resolve all referenced variables (which may themselves be computed)
- Walk expression tree with PEMDAS operator precedence
- Return result as Money (for monetary computed) or plain decimal (for ratios)
Currency handling: Computed variables that produce ratios (e.g., a / b where both are Money) return a dimensionless decimal. The variable's currency field indicates the working currency for intermediate calculations.
Validation:
- Expression structure must be syntactically valid
- All referenced variables must exist and be active
- No circular dependencies (A→B→C→A)
- Max dependency chain depth: 10
Location: app/services/financial_controls/evaluators/variable_evaluator.rb
The evaluator is dead simple — resolve the variable's value and compare against the threshold. ALL math is already done by the variable resolution engine.
Registration in Resolver:
# app/services/financial_controls/evaluators/resolver.rb
EVALUATORS = {
# ... existing
'custom_formula' => VariableEvaluator
}Evaluation flow:
1. Get rule.financial_control_variable
2. Check variable exists → error_result("Variable not found")
3. Check variable not archived → error_result("Variable is archived")
4. Call FormulaDataProvider.resolve_single(variable, target_currency)
→ This recursively resolves computed variables, including their sub-expressions
5. Get the resolved decimal value
6. Compare against rule.threshold_value using breach/warning/ok logic
7. Return result with:
- evaluated_value: resolved decimal
- contributing_accounts: full dependency tree breakdown from FormulaDataProvider
Warning threshold: Uses BaseEvaluator#within_warning_threshold? — 10% proximity to threshold.
Location: app/services/financial_controls/formula_data_provider.rb
Responsibilities:
- Resolve any variable to a value (including computed variables recursively)
- Delegates to type-specific resolvers for primitives
- For
computedvariables: resolves all referenced variables first, then evaluates the expression - Circular dependency detection via resolution stack
- Redis caching with implicit invalidation via
updated_atin cache key
Cache strategy:
- Key:
fc_var:#{variable.uuid}:#{variable.updated_at.to_i}:#{target_currency} - TTL: 10 minutes
- Implicit invalidation: when variable is updated,
updated_atchanges → new cache key
class FormulaDataProvider
CACHE_TTL = 10.minutes
MAX_DEPTH = 10
RESOLVERS = {
'static' => VariableResolvers::StaticResolver,
'balance' => VariableResolvers::BalanceResolver,
'cash_flow' => VariableResolvers::CashFlowResolver,
'loc' => VariableResolvers::LocResolver
}.freeze
def self.resolve_single(variable, target_currency:)
new(target_currency).resolve(variable, resolution_stack: [])
end
def initialize(target_currency)
@target_currency = target_currency
@resolved_cache = {} # in-memory per-evaluation to avoid re-resolving same variable
end
def resolve(variable, resolution_stack:)
# Circular dependency detection
if resolution_stack.include?(variable.uuid)
raise CircularDependencyError, "Circular dependency: #{(resolution_stack + [variable.uuid]).join(' → ')}"
end
if resolution_stack.length >= MAX_DEPTH
raise MaxDepthError, "Variable dependency chain exceeds max depth of #{MAX_DEPTH}"
end
return @resolved_cache[variable.uuid] if @resolved_cache.key?(variable.uuid)
value = resolve_with_cache(variable, resolution_stack)
@resolved_cache[variable.uuid] = value
value
end
private
def resolve_with_cache(variable, resolution_stack)
cache_key = "fc_var:#{variable.uuid}:#{variable.updated_at.to_i}:#{@target_currency}"
Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
if variable.variable_type == 'computed'
resolve_computed(variable, resolution_stack)
else
resolver = RESOLVERS[variable.variable_type]
value = resolver.resolve(variable)
value.currency.iso_code == @target_currency ? value : value.smart_exchange_to(@target_currency)
end
end
end
def resolve_computed(variable, resolution_stack)
expression = variable.configuration['expression']
new_stack = resolution_stack + [variable.uuid]
# Resolve all referenced variables first
var_ids = FinancialControlVariable.extract_variable_ids_from_expression(expression)
variables = variable.company.financial_control_variables.where(uuid: var_ids).index_by(&:uuid)
resolved_values = {}
variables.each do |uuid, var|
resolved_values[uuid] = resolve(var, resolution_stack: new_stack)
end
# Evaluate expression tree with PEMDAS
ExpressionEvaluator.evaluate(expression, resolved_values)
end
endLocation: app/services/financial_controls/expression_evaluator.rb
Pure math — takes an expression tree and a hash of resolved values, returns a numeric result. No DB access, no caching. Just PEMDAS arithmetic.
class ExpressionEvaluator
def self.evaluate(expression, resolved_values)
tokens = tokenize(expression, resolved_values)
tokens = apply_operators(tokens, ['*', '/'])
tokens = apply_operators(tokens, ['+', '-'])
tokens.first.to_f
end
end# FinancialControlVariable
before_destroy :ensure_not_referenced_by_rules
def ensure_not_referenced_by_rules
referencing_rules = company.financial_control_rules
.where.not(formula_definition: nil)
.select { |r| r.formula_expression_variable_ids.include?(uuid) }
# formula_expression_variable_ids recursively extracts all var UUIDs from expression tree
if referencing_rules.any?
errors.add(:base, "Cannot delete: used by #{referencing_rules.count} rule(s)")
throw(:abort)
end
end# CashCategory
before_destroy :ensure_not_referenced_by_variables
def ensure_not_referenced_by_variables
referencing = company.financial_control_variables
.where(variable_type: 'cash_flow')
.select { |v| v.configuration['cash_category_ids']&.include?(uuid) }
if referencing.any?
errors.add(:base, "Cannot delete: referenced by #{referencing.count} variable(s)")
throw(:abort)
end
endSame pattern — check balance and LOC variables referencing the account.
FinancialControlRule exposes referenced_variable_ids (parsed from formula_definition).
Variable Pool UI shows usage count per variable (how many rules reference it).
The entire formula and variable configuration schema is expressed as typed GraphQL inputs/types. Frontend holds zero formula logic — backend owns validation, parsing, and evaluation. FE just renders the typed schema.
# --- Expression tree (what the API returns) ---
union FormulaExpressionNode = FormulaVariableNode | FormulaNumberNode | FormulaOperatorNode | FormulaGroupNode
type FormulaVariableNode {
type: FormulaNodeType! # VARIABLE
variableId: ID!
variable: FinancialControlVariable # resolved reference
}
type FormulaNumberNode {
type: FormulaNodeType! # NUMBER
value: Float!
}
type FormulaOperatorNode {
type: FormulaNodeType! # OPERATOR
operator: FormulaOperator!
}
type FormulaGroupNode {
type: FormulaNodeType! # GROUP
items: [FormulaExpressionNode!]!
}
enum FormulaNodeType {
VARIABLE
NUMBER
OPERATOR
GROUP
}
enum FormulaOperator {
ADD # +
SUBTRACT # -
MULTIPLY # *
DIVIDE # /
}# --- Expression tree (what FE sends to create/update) ---
input FormulaExpressionNodeInput {
# Exactly one of these must be provided (validated server-side)
variableNode: FormulaVariableNodeInput
numberNode: FormulaNumberNodeInput
operatorNode: FormulaOperatorNodeInput
groupNode: FormulaGroupNodeInput
}
input FormulaVariableNodeInput {
variableId: ID!
}
input FormulaNumberNodeInput {
value: Float!
}
input FormulaOperatorNodeInput {
operator: FormulaOperator!
}
input FormulaGroupNodeInput {
items: [FormulaExpressionNodeInput!]!
}type FinancialControlVariable {
id: ID!
name: String!
description: String
variableType: FinancialControlVariableTypeEnum!
configuration: FinancialControlVariableConfiguration!
currency: String!
currentValue: Money
usageCount: Int!
archivedAt: ISO8601DateTime
createdAt: ISO8601DateTime!
updatedAt: ISO8601DateTime!
}
enum FinancialControlVariableTypeEnum {
STATIC
BALANCE
CASH_FLOW
LOC
}
union FinancialControlVariableConfiguration =
StaticVariableConfig
| BalanceVariableConfig
| CashFlowVariableConfig
| LocVariableConfig
type StaticVariableConfig {
value: Float!
}
type BalanceVariableConfig {
scopeType: VariableScopeType!
scopeIds: [ID!]
balanceType: BalanceTypeEnum!
}
type CashFlowVariableConfig {
cashCategoryIds: [ID!]!
cashCategories: [CashCategory!]! # resolved references
daysForward: Int!
}
type LocVariableConfig {
scopeType: LocScopeType!
scopeIds: [ID!]
}
enum VariableScopeType {
ALL_ACCOUNTS
SPECIFIC_ACCOUNTS
BY_ENTITY
BY_INSTITUTION
}
enum LocScopeType {
ALL_LOC_ACCOUNTS
SPECIFIC_LOC_ACCOUNTS
}
enum BalanceTypeEnum {
TOTAL_CASH
AVAILABLE_CASH
}input FinancialControlVariableConfigurationInput {
# Exactly one must be provided, matching variableType
staticConfig: StaticVariableConfigInput
balanceConfig: BalanceVariableConfigInput
cashFlowConfig: CashFlowVariableConfigInput
locConfig: LocVariableConfigInput
}
input StaticVariableConfigInput {
value: Float!
}
input BalanceVariableConfigInput {
scopeType: VariableScopeType!
scopeIds: [ID!]
balanceType: BalanceTypeEnum!
}
input CashFlowVariableConfigInput {
cashCategoryIds: [ID!]!
daysForward: Int!
}
input LocVariableConfigInput {
scopeType: LocScopeType!
scopeIds: [ID!]
}mutation financialControlVariableCreate(
companyId: ID!
name: String!
description: String
variableType: FinancialControlVariableTypeEnum!
configuration: FinancialControlVariableConfigurationInput!
currency: String!
): FinancialControlVariablePayload
mutation financialControlVariableUpdate(
id: ID!
name: String
description: String
configuration: FinancialControlVariableConfigurationInput
): FinancialControlVariablePayload
mutation financialControlVariableArchive(id: ID!): FinancialControlVariablePayload
mutation financialControlVariableDelete(id: ID!): FinancialControlVariablePayloadFormula rule creation — expression is fully typed:
mutation createFinancialControlRule(
# ... existing args ...
formulaExpression: [FormulaExpressionNodeInput!] # the expression tree
thresholdValue: Float # e.g., 1.2
): FinancialControlRulePayload# Extended fields on FinancialControlRuleType
type FinancialControlRule {
# ... existing fields ...
formulaExpression: [FormulaExpressionNode!] # typed expression tree
resolvedVariables: [ResolvedVariable!] # per-variable values from latest eval
expressionResult: Float # computed result from latest eval
expressionReadable: String # human-readable: "(1.2M + 500K) / (800K + 200K) = 1.70"
}
type ResolvedVariable {
variable: FinancialControlVariable!
resolvedValue: Money!
}query previewFormulaResult(
companyId: ID!
expression: [FormulaExpressionNodeInput!]!
targetCurrency: String!
): FormulaPreview
type FormulaPreview {
result: Float!
variableBreakdown: [ResolvedVariable!]!
expressionReadable: String!
validationErrors: [FieldError!]! # empty if valid
}Live preview — called as user builds the expression. Backend validates the expression structure, resolves all variables, computes result. FE never parses or validates the expression itself — it only renders the typed nodes and sends them back.
- FE holds zero formula logic — no parsing, no validation, no expression tree walking on client
- Schema is the contract — FE auto-generates TypeScript types from GraphQL schema
- Backend owns all validation — expression structure, variable references, operator placement, nesting depth
- Type safety end-to-end — impossible to send malformed expression through typed inputs
- Codegen-friendly —
graphql-codegenproduces exact TS types for every node, enum, and input
No changes needed. FinancialControlEvaluationScheduler dispatches FinancialControlEvaluationJob for all evaluatable rules including custom_formula ones. 15-minute cadence.
For custom_formula rules:
FinancialControlRuleScopeis not used (rule has no scopes)FormulaEvaluatorreceivesbank_accounts: []fromScopeResolver(empty, ignored)- Each variable resolves its own scope internally
Standard: pending → ok/warning/breach/error
ok: ratio meets thresholdwarning: ratio within 10% of thresholdbreach: ratio below threshold (for min-type) or above (for max-type)error: missing variable, archived variable, division by zero, resolution failure
No changes needed. FinancialControlStatusNotifier fires on status transitions. Formula rules produce standard statuses → email + Slack notifications work automatically.
No changes needed. ClientHealthCalculator counts breach/warning across all rule types. Formula rule breaches automatically affect client health score.
Uses existing permission model:
PermissionKeys::FINANCIAL_CONTROL_KEYS[:MANAGE]— create/update/archive/delete variables and formula rulesPermissionKeys::FINANCIAL_CONTROL_KEYS[:VIEW]— view variables and formula rules
Feature flag: financial_control (existing)
New page: /companies/:id/financial-controls/variables
- List all variables with: name, type badge, currency, current value, usage count, last updated
- Create variable: name, type selector → type-specific configuration form
- Static: value + currency
- Balance: scope selector (all/specific/entity/institution) + balance type
- Cash Flow: category multi-select (from company's cash category tree) + days forward + currency
- LOC: LOC account selector
- Edit: update configuration (triggers cache invalidation via
updated_atchange) - Archive: soft delete (if not referenced by rules, or show warning with rule list)
- Delete: hard delete (blocked if referenced by any rule)
UI Approach: Custom React component (no external library)
Existing libraries don't fit this use case:
- Math editors (MathLive, MathQuill) — text input for math notation, not visual builders
- Query builders (react-awesome-query-builder) — boolean logic, not arithmetic
- Spreadsheet engines (HyperFormula) — headless, no UI
- Block builders (Blockly) — too playful for treasury managers
- Commercial spreadsheets (Syncfusion, SpreadJS) — overkill, licensed
Build a custom <FormulaExpressionBuilder /> component that:
- Renders the typed GraphQL expression tree directly — no client-side parsing
- Sends
[FormulaExpressionNodeInput!]back to the API — no client-side validation - Backend owns all logic: structure validation, variable resolution, operator rules, nesting limits
Component design:
┌─────────────────────────────────────────────────────────┐
│ Formula Expression │
│ │
│ ┌─── Group ──────────────────────────┐ ┌─────────┐ │
│ │ [Current Cash ▾] [+] [LOC ▾] [+] │ [/] │ Group + │ │
│ │ [Payoffs 60d ▾] [+] [Fundings ▾] │ │ [Loans] │ │
│ │ [+] [Interest ▾] │ │ [Draws] │ │
│ └────────────────────────────────────┘ │ [Opex] │ │
│ └─────────┘ │
│ [+ Add Variable] [+ Add Group] [+ Add Number] │
│ │
│ ┌── Live Preview ─────────────────────────────────────┐ │
│ │ (1,200,000 + 500,000 + 300,000 + 150,000 + 50,000) │ │
│ │ ÷ (800,000 + 200,000 + 100,000 + 350,000 + 75,000) │ │
│ │ = 1.44 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
UX flow:
- User selects "Custom Formula Rule" template
- Standard scope section is hidden — replaced with: "Scope is defined within each variable."
- Expression builder renders:
- Variable chips — dropdown picks from Variable Pool, shows name + type badge + resolved value
- Operator pills — click to cycle through
+,-,*,/ - Group wrappers — visual parentheses, click "Add Group" to wrap selection
- "Add Number" — inline numeric literal
- Drag handles on each node for reorder
- Expression renders as readable formula below:
(Cash + LOC + Payoffs) / (Loans + Draws + Opex)
- Live Preview: debounced call to
previewFormulaResult→ shows computed result + per-variable breakdown - Validation: backend returns
validationErrorsfrom preview — FE just displays them (red border on invalid nodes) - Threshold input: decimal value (e.g., 1.2) + comparison direction (alert when below/above)
- Name, description
Key principle: FE is a dumb renderer of the typed GraphQL schema. All expression validation, variable resolution, and computation happens server-side. FE maps FormulaExpressionNode union types to React components and collects FormulaExpressionNodeInput to send back.
- Shows: current expression result vs. threshold, status badge
- Expression View: rendered formula with each variable's resolved value inline
- Variable Breakdown: expandable section showing each variable's name, type, configuration, and resolved value
- Evaluation History: timeline of past evaluations with result + status
- If error: clear message ("Variable 'X' is archived", "Division by zero", "Variable not found")
fc.formula.evaluation.duration— per-rule evaluation timefc.formula.cache.hit_rate— FormulaDataProvider cache hit/missfc.formula.cache.regeneration_time— time to resolve a variable from DBfc.formula.lock.contention— Redlock contention eventsfc.formula.errors— by error type (missing_variable, division_by_zero, resolution_failure)
FormulaDataProvider— each variable type resolution, caching behavior, currency handlingFormulaEvaluator— ratio calculation, division by zero, missing variables, archived variablesCashFlowResolver— recursive CTE, deduplication, currency filtering, date range
FinancialControlVariable— validations, before_destroy guards, configuration schemasFinancialControlRule— formula_definition validations, variable reference integrity
- Variable CRUD mutations
- Formula rule creation with variables
previewFormulaRatioresolver- Deletion protection (variable used by rule, category used by variable)
- End-to-end: create variables → create formula rule → scheduler evaluates → notification fires
- Cache invalidation: update variable → next evaluation uses new value
- Advisor health: formula rule breach → client health score updates
| Variable Name | Type | Configuration |
|---|---|---|
| Current Cash | Balance | scope: all_accounts, balance_type: total_cash |
| LOC Availability | LOC | scope: all_loc_accounts |
| Expected Payoffs (60d) | Cash Flow | categories: [Loan Repayment, Balloon Payment], days: 60 |
| Debenture Fundings (60d) | Cash Flow | categories: [Debenture Funding], days: 60 |
| Interest Income (60d) | Cash Flow | categories: [Interest Income], days: 60 |
| Loan Fundings (60d) | Cash Flow | categories: [Loan Disbursement], days: 60 |
| Draws (60d) | Cash Flow | categories: [LOC Draws], days: 60 |
| Debenture Redemptions (60d) | Cash Flow | categories: [Debenture Redemption], days: 60 |
| Opex (60d) | Cash Flow | categories: [Operating Expenses], days: 60 |
| Debt Service (60d) | Cash Flow | categories: [Debt Service], days: 60 |
| Variable Name | Type | Expression |
|---|---|---|
| Liquid Sources | Computed | Current Cash + LOC Availability + Expected Payoffs + Debenture Fundings + Interest Income |
| Total Outflows | Computed | Loan Fundings + Draws + Debenture Redemptions + Opex + Debt Service |
| Variable Name | Type | Expression |
|---|---|---|
| 60-Day LCR | Computed | Liquid Sources / Total Outflows |
- Name: 60-Day LCR Minimum
- Variable: 60-Day LCR
- Threshold: 1.20 (alert when below 1.2x)
That's it. The rule is one line. All the math is in the variables.
Rule: 60-Day LCR Minimum — BREACH (1.08 < 1.20)
60-Day LCR = 1.08
├── Liquid Sources = $2,150,000
│ ├── Current Cash = $1,200,000 (balance)
│ ├── LOC Availability = $500,000 (loc)
│ ├── Expected Payoffs (60d) = $300,000 (cash_flow)
│ ├── Debenture Fundings (60d) = $100,000 (cash_flow)
│ └── Interest Income (60d) = $50,000 (cash_flow)
└── Total Outflows = $1,990,000
├── Loan Fundings (60d) = $800,000 (cash_flow)
├── Draws (60d) = $200,000 (cash_flow)
├── Debenture Redemptions (60d) = $340,000 (cash_flow)
├── Opex (60d) = $350,000 (cash_flow)
└── Debt Service (60d) = $300,000 (cash_flow)
Dan can now create additional rules with zero extra variables:
- Quick Ratio Rule — new computed variable
Quick Ratio = Liquid Sources / Opex (60d), threshold: 3.0 - LCR Target Rule — same
60-Day LCRvariable, threshold: 1.50 (target, not breach) - LCR Critical Rule — same
60-Day LCRvariable, threshold: 1.00 (critical alert)
Liquid Sources is reused across LCR and Quick Ratio. No duplication.