Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save adamdilek/4d30f5b32dfa4095fd97080c279a6814 to your computer and use it in GitHub Desktop.

Select an option

Save adamdilek/4d30f5b32dfa4095fd97080c279a6814 to your computer and use it in GitHub Desktop.
PRD: Custom Formula-Based Control Rules (Variable Pool + Formula Engine) for TreasuryPath

PRD: Custom Formula-Based Control Rules (Composable Variable Pool)

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


1. Problem Statement

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)

2. Solution: Composable Variable Pool

Core Concept

Variables ARE the expression engine. A single composable system:

  1. Primitive Variables — Resolve to a monetary value from a data source (static value, bank balance, projected cash flows, LOC availability)
  2. Computed Variables — Mathematical expressions composed of other variables. Can reference both primitive and other computed variables. This is where ALL math lives.
  3. 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.

Why composable variables (not formula rules)

  • 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 Outflows where Liquid Sources = Cash + LOC + Payoffs + ...
  • Maximum reusability: LCR variable used in 3 rules (breach at 1.0, warning at 1.2, target at 1.5). Liquid Sources reused in LCR and Quick Ratio.
  • Simpler rules: No formula_definition JSON on the rule. Just financial_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

3. Data Model

3.1 New Table: financial_control_variables

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`)
);

3.2 Changes to financial_control_rules

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.

3.3 Computed Variable Type

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 another FinancialControlVariable by UUID (any type, including other computed)
  • number — inline numeric literal (e.g., { "type": "number", "value": 100 })
  • op — operator: +, -, *, /
  • group — parenthesized sub-expression (contains an items array 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 var UUIDs must reference active variables belonging to the same company
  • Expression must contain at least one var node
  • 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)

3.4 Changes to financial_control_templates

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
end

3.5 Evaluation Storage

Formula 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 monitor
  • financial_control_evaluations.evaluated_value → resolved variable value (e.g., 1.35)
  • financial_control_evaluations.contributing_accounts → JSON with per-variable breakdown (full dependency tree)

4. Variable Types

4.1 Static

Manual value entered by user.

Configuration:

{
  "value": 5000000.00
}

Resolution: Returns Money.new(value * 100, variable.currency)

Validation: value must be numeric.

4.2 Balance

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_institution
  • scope_ids: required when scope_type is specific (max 250)
  • balance_type: total_cash | available_cash

Resolution: ScopeResolverBalanceAggregator (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.

4.3 Cash Flow

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:

  1. Expand cash_category_ids to include all descendants via recursive CTE
  2. Deduplicate the full set (prevents double-counting if parent + child both selected)
  3. Query expected_cash_flows filtered by:
    • cash_category_id IN (deduplicated_set)
    • company_id = variable.company_id
    • expected_date BETWEEN today AND today + days_forward
    • amount_currency = variable.currency (filter, not convert — multi-currency handled by creating separate variables)
  4. 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.

4.4 LOC (Line of Credit)

Total available credit across LOC accounts.

Configuration:

{
  "scope_type": "all_loc_accounts",
  "scope_ids": []
}
  • scope_type: all_loc_accounts | specific_loc_accounts
  • scope_ids: required when specific_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.

4.5 Computed

Mathematical expression composed of other variables.

Configuration:

{
  "expression": [
    { "type": "var", "id": "uuid-liquid-sources" },
    { "type": "op", "value": "/" },
    { "type": "var", "id": "uuid-total-outflows" }
  ]
}

Resolution:

  1. Extract all referenced variable UUIDs from expression
  2. Detect circular dependencies — build dependency graph, reject cycles
  3. Recursively resolve all referenced variables (which may themselves be computed)
  4. Walk expression tree with PEMDAS operator precedence
  5. 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

5. Variable Resolution Engine (replaces Formula Engine)

5.1 VariableEvaluator (replaces FormulaEvaluator)

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.

5.2 FormulaDataProvider

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 computed variables: resolves all referenced variables first, then evaluates the expression
  • Circular dependency detection via resolution stack
  • Redis caching with implicit invalidation via updated_at in 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_at changes → 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
end

5.3 ExpressionEvaluator

Location: 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

6. Lifecycle & Data Integrity

6.1 Variable Deletion Protection

# 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

6.2 CashCategory Archive Protection

# 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
end

6.3 BankAccount Archive Protection

Same pattern — check balance and LOC variables referencing the account.

6.4 Variable Dependency Graph

FinancialControlRule exposes referenced_variable_ids (parsed from formula_definition). Variable Pool UI shows usage count per variable (how many rules reference it).


7. GraphQL API — Fully Typed (No Raw JSON)

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.

7.1 Expression Types (Output)

# --- 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     # /
}

7.2 Expression Input Types (What FE Sends)

# --- 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!]!
}

7.3 Variable Configuration Types (Output)

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
}

7.4 Variable Configuration Input Types (What FE Sends)

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!]
}

7.5 Mutations

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!): FinancialControlVariablePayload

Formula rule creation — expression is fully typed:

mutation createFinancialControlRule(
  # ... existing args ...
  formulaExpression: [FormulaExpressionNodeInput!]  # the expression tree
  thresholdValue: Float                              # e.g., 1.2
): FinancialControlRulePayload

7.6 Rule Type Extensions

# 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!
}

7.7 Formula Preview Resolver

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.

7.8 Why Fully Typed (No JSON!)

  • 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-friendlygraphql-codegen produces exact TS types for every node, enum, and input

8. Evaluation Pipeline Integration

8.1 Scheduling

No changes needed. FinancialControlEvaluationScheduler dispatches FinancialControlEvaluationJob for all evaluatable rules including custom_formula ones. 15-minute cadence.

8.2 Scoping

For custom_formula rules:

  • FinancialControlRuleScope is not used (rule has no scopes)
  • FormulaEvaluator receives bank_accounts: [] from ScopeResolver (empty, ignored)
  • Each variable resolves its own scope internally

8.3 Status Transitions

Standard: pending → ok/warning/breach/error

  • ok: ratio meets threshold
  • warning: ratio within 10% of threshold
  • breach: ratio below threshold (for min-type) or above (for max-type)
  • error: missing variable, archived variable, division by zero, resolution failure

8.4 Notification Integration

No changes needed. FinancialControlStatusNotifier fires on status transitions. Formula rules produce standard statuses → email + Slack notifications work automatically.

8.5 Advisor Dashboard Integration

No changes needed. ClientHealthCalculator counts breach/warning across all rule types. Formula rule breaches automatically affect client health score.


9. Permissions

Uses existing permission model:

  • PermissionKeys::FINANCIAL_CONTROL_KEYS[:MANAGE] — create/update/archive/delete variables and formula rules
  • PermissionKeys::FINANCIAL_CONTROL_KEYS[:VIEW] — view variables and formula rules

Feature flag: financial_control (existing)


10. UX Flow

10.1 Variable Pool Management

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_at change)
  • Archive: soft delete (if not referenced by rules, or show warning with rule list)
  • Delete: hard delete (blocked if referenced by any rule)

10.2 Formula Rule Creation — Expression Builder

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:

  1. User selects "Custom Formula Rule" template
  2. Standard scope section is hidden — replaced with: "Scope is defined within each variable."
  3. 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)
  4. Live Preview: debounced call to previewFormulaResult → shows computed result + per-variable breakdown
  5. Validation: backend returns validationErrors from preview — FE just displays them (red border on invalid nodes)
  6. Threshold input: decimal value (e.g., 1.2) + comparison direction (alert when below/above)
  7. 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.

10.3 Rule Detail & Debugging

  • 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")

11. Observability

Metrics to track (via Sentry/StatsD):

  • fc.formula.evaluation.duration — per-rule evaluation time
  • fc.formula.cache.hit_rate — FormulaDataProvider cache hit/miss
  • fc.formula.cache.regeneration_time — time to resolve a variable from DB
  • fc.formula.lock.contention — Redlock contention events
  • fc.formula.errors — by error type (missing_variable, division_by_zero, resolution_failure)

12. Testing Strategy

Service Specs

  • FormulaDataProvider — each variable type resolution, caching behavior, currency handling
  • FormulaEvaluator — ratio calculation, division by zero, missing variables, archived variables
  • CashFlowResolver — recursive CTE, deduplication, currency filtering, date range

Model Specs

  • FinancialControlVariable — validations, before_destroy guards, configuration schemas
  • FinancialControlRule — formula_definition validations, variable reference integrity

Request Specs (GraphQL)

  • Variable CRUD mutations
  • Formula rule creation with variables
  • previewFormulaRatio resolver
  • Deletion protection (variable used by rule, category used by variable)

Integration Specs

  • 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

13. Dan's LCR Setup (Concrete Example)

Layer 1: Primitive Variables

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

Layer 2: Intermediate Computed Variables

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

Layer 3: Final Computed Variable

Variable Name Type Expression
60-Day LCR Computed Liquid Sources / Total Outflows

The Rule

  • 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.

What the user sees when debugging

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)

Reusability

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 LCR variable, threshold: 1.50 (target, not breach)
  • LCR Critical Rule — same 60-Day LCR variable, threshold: 1.00 (critical alert)

Liquid Sources is reused across LCR and Quick Ratio. No duplication.

Composable Variable Pool — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Enable users to create composable financial variables (primitive + computed) and monitor them via rules with threshold alerts. All math lives in variables. Rules are just "variable vs threshold".

Architecture: Variables are the expression engine. Five primitive types (static, balance, cash_flow, loc) resolve to monetary values from data sources. A fifth type (computed) composes other variables with math operators (+, -, *, /). Rules point to a single variable and compare its resolved value against a threshold. The existing evaluator strategy pattern gets one new evaluator (VariableEvaluator) that simply resolves a variable and compares.

Tech Stack: Rails 7.1, MySQL 8, Sidekiq, Redis, GraphQL (graphql-ruby), React 19/Next.js 15, Apollo Client, TypeScript, Tailwind CSS, Radix UI, React Hook Form + Zod.

PRD: docs/superpowers/plans/2026-04-02-custom-formula-control-rules-design.md


File Map

Backend — New Files

File Responsibility
db/migrate/TIMESTAMP_create_financial_control_variables.rb Variables table (5 types incl. computed)
db/migrate/TIMESTAMP_add_variable_id_to_financial_control_rules.rb FK to variable on rules
app/models/financial_control_variable.rb Model: validations, circular dep detection, guards
app/services/financial_controls/variable_resolvers/static_resolver.rb Resolves static value
app/services/financial_controls/variable_resolvers/balance_resolver.rb Resolves bank balance
app/services/financial_controls/variable_resolvers/cash_flow_resolver.rb Resolves projected cash flows (recursive CTE)
app/services/financial_controls/variable_resolvers/loc_resolver.rb Resolves LOC availability
app/services/financial_controls/variable_resolvers/computed_resolver.rb Resolves computed expressions recursively
app/services/financial_controls/formula_data_provider.rb Caching orchestrator, circular dep detection
app/services/financial_controls/expression_evaluator.rb Pure PEMDAS math on token arrays
app/services/financial_controls/expression_validator.rb Validates expression structure + deps
app/services/financial_controls/evaluators/variable_evaluator.rb Resolve variable → compare threshold
app/graphql/types/financial_control_variable_type.rb Variable GQL type
app/graphql/types/formula_expression_node_type.rb Expression node union + concrete types
app/graphql/types/formula_operator_enum.rb ADD/SUBTRACT/MULTIPLY/DIVIDE
app/graphql/types/formula_node_type_enum.rb VARIABLE/NUMBER/OPERATOR/GROUP
app/graphql/types/financial_control_variable_type_enum.rb STATIC/BALANCE/CASH_FLOW/LOC/COMPUTED
app/graphql/types/variable_scope_type_enum.rb Scope enums
app/graphql/types/loc_scope_type_enum.rb LOC scope enums
app/graphql/types/variable_configuration_type.rb Union of 5 config types
app/graphql/types/static_variable_config_type.rb Static config
app/graphql/types/balance_variable_config_type.rb Balance config
app/graphql/types/cash_flow_variable_config_type.rb Cash flow config
app/graphql/types/loc_variable_config_type.rb LOC config
app/graphql/types/computed_variable_config_type.rb Computed config (expression)
app/graphql/types/formula_preview_type.rb Preview result type
app/graphql/types/resolved_variable_type.rb Resolved variable value type
app/graphql/inputs/formula_expression_node_input.rb Expression node input
app/graphql/inputs/variable_configuration_input.rb Variable config input
app/graphql/mutations/create_financial_control_variable.rb Create variable
app/graphql/mutations/update_financial_control_variable.rb Update variable
app/graphql/mutations/archive_financial_control_variable.rb Archive variable
app/graphql/mutations/delete_financial_control_variable.rb Delete variable
app/graphql/resolvers/financial_control_variables_resolver.rb List variables
app/graphql/resolvers/preview_variable_result_resolver.rb Live variable preview
spec/factories/financial_control_variables.rb Factory with all type traits
spec/models/financial_control_variable_spec.rb Model spec
spec/services/financial_controls/variable_resolvers/*_spec.rb One per resolver
spec/services/financial_controls/formula_data_provider_spec.rb Provider spec
spec/services/financial_controls/expression_evaluator_spec.rb PEMDAS spec
spec/services/financial_controls/expression_validator_spec.rb Validator spec
spec/services/financial_controls/evaluators/variable_evaluator_spec.rb Evaluator spec
spec/services/financial_controls/composable_integration_spec.rb End-to-end Dan's LCR
spec/requests/graphql/mutations/create_financial_control_variable_spec.rb GQL spec
spec/requests/graphql/mutations/delete_financial_control_variable_spec.rb GQL spec
spec/requests/graphql/resolvers/preview_variable_result_resolver_spec.rb GQL spec

Backend — Modified Files

File Change
app/models/financial_control_template.rb Add custom_formula to logic_type enum
app/models/financial_control_rule.rb Add belongs_to :financial_control_variable, helpers
app/models/company.rb Add has_many :financial_control_variables
app/models/cash_category.rb Add before_destroy guard
app/services/financial_controls/evaluators/resolver.rb Register VariableEvaluator
app/graphql/types/financial_control_rule_type.rb Add variable + resolved value fields
app/graphql/mutations/create_financial_control_rule.rb Add variable_id + threshold_value args
app/graphql/types/mutation_type.rb Register new mutations
app/graphql/types/query_type.rb Register new resolvers
spec/factories/financial_control_rules.rb Add :with_custom_formula trait
spec/factories/financial_control_templates.rb Add :custom_formula trait
db/seeds/financial_control_templates.rb Add custom_formula template

Frontend — New Files

File Responsibility
frontend/graphql/queries/financial-control-variables.graphql List variables query
frontend/graphql/mutations/create-financial-control-variable.graphql Create mutation
frontend/graphql/mutations/update-financial-control-variable.graphql Update mutation
frontend/graphql/mutations/archive-financial-control-variable.graphql Archive mutation
frontend/graphql/mutations/delete-financial-control-variable.graphql Delete mutation
frontend/graphql/queries/preview-variable-result.graphql Live preview query
frontend/app/(authenticated)/companies/[companyId]/financial-controls/variables/page.tsx Variable Pool page
frontend/app/(authenticated)/companies/[companyId]/financial-controls/variables/components/VariablesList.tsx Variables table
frontend/app/(authenticated)/companies/[companyId]/financial-controls/variables/components/CreateVariableDialog.tsx Create dialog
frontend/app/(authenticated)/companies/[companyId]/financial-controls/variables/components/VariableConfigForm.tsx Type-specific forms
frontend/components/financial-controls/FormulaExpressionBuilder/index.tsx Expression builder (for computed variables)
frontend/components/financial-controls/FormulaExpressionBuilder/ExpressionNode.tsx Single node renderer
frontend/components/financial-controls/FormulaExpressionBuilder/GroupNode.tsx Group (parentheses)
frontend/components/financial-controls/FormulaExpressionBuilder/VariableChip.tsx Variable picker chip
frontend/components/financial-controls/FormulaExpressionBuilder/OperatorPill.tsx Operator selector
frontend/components/financial-controls/FormulaExpressionBuilder/FormulaPreview.tsx Live preview
frontend/components/financial-controls/VariableDependencyTree/index.tsx Tree view for debugging

Frontend — Modified Files

File Change
frontend/app/(authenticated)/companies/[companyId]/financial-controls/components/CreateRuleForm/ Add variable-based rule path
frontend/app/(authenticated)/companies/[companyId]/financial-controls/[ruleId]/ Show variable tree
frontend/graphql/mutations/create-financial-control-rule.graphql Add variable_id arg

Task 1: Database Migration — Variables Table

Files:

  • Create: db/migrate/TIMESTAMP_create_financial_control_variables.rb

  • Step 1: Generate migration

Run: cd /Users/adam/workspace/treasury-path/app && bundle exec rails generate migration CreateFinancialControlVariables

  • Step 2: Write migration
class CreateFinancialControlVariables < ActiveRecord::Migration[7.1]
  def change
    create_table :financial_control_variables do |t|
      t.string :uuid, null: false
      t.references :company, null: false, foreign_key: true
      t.string :name, null: false
      t.text :description
      t.string :variable_type, null: false
      t.json :configuration, null: false
      t.string :currency, limit: 3, null: false
      t.datetime :archived_at

      t.timestamps
    end

    add_index :financial_control_variables, :uuid, unique: true
    add_index :financial_control_variables, [:company_id, :name], unique: true, name: 'index_fc_variables_on_company_and_name'
    add_index :financial_control_variables, [:company_id, :variable_type], name: 'index_fc_variables_on_company_and_type'
  end
end
  • Step 3: Run migration

Run: bundle exec rails db:migrate

  • Step 4: Commit
git add db/migrate/*_create_financial_control_variables.rb db/schema.rb
git commit -m "feat: add financial_control_variables table"

Task 2: Database Migration — Variable FK on Rules

Files:

  • Create: db/migrate/TIMESTAMP_add_variable_id_to_financial_control_rules.rb

  • Step 1: Generate migration

Run: bundle exec rails generate migration AddVariableIdToFinancialControlRules

  • Step 2: Write migration
class AddVariableIdToFinancialControlRules < ActiveRecord::Migration[7.1]
  def change
    add_column :financial_control_rules, :financial_control_variable_id, :bigint
    add_index :financial_control_rules, :financial_control_variable_id, name: 'index_fc_rules_on_variable_id'
  end
end
  • Step 3: Run migration

Run: bundle exec rails db:migrate

  • Step 4: Commit
git add db/migrate/*_add_variable_id_to_financial_control_rules.rb db/schema.rb
git commit -m "feat: add financial_control_variable_id FK to rules"

Task 3: FinancialControlVariable Model + Factory

Files:

  • Create: app/models/financial_control_variable.rb

  • Create: spec/factories/financial_control_variables.rb

  • Create: spec/models/financial_control_variable_spec.rb

  • Step 1: Write factory

# spec/factories/financial_control_variables.rb
FactoryBot.define do
  factory :financial_control_variable do
    sequence(:name) { |n| "Variable #{n}" }
    company
    variable_type { 'static' }
    configuration { { 'value' => 100_000.0 } }
    currency { 'USD' }

    trait :static do
      variable_type { 'static' }
      configuration { { 'value' => 100_000.0 } }
    end

    trait :balance do
      variable_type { 'balance' }
      configuration { { 'scope_type' => 'all_accounts', 'balance_type' => 'total_cash' } }
    end

    trait :cash_flow do
      variable_type { 'cash_flow' }
      configuration { { 'cash_category_ids' => [], 'days_forward' => 60 } }
    end

    trait :loc do
      variable_type { 'loc' }
      configuration { { 'scope_type' => 'all_loc_accounts' } }
    end

    trait :computed do
      variable_type { 'computed' }
      # expression must reference real variables — set by caller
      configuration { { 'expression' => [] } }
    end

    trait :archived do
      archived_at { Time.current }
    end
  end
end
  • Step 2: Write model spec
# spec/models/financial_control_variable_spec.rb
require 'rails_helper'

RSpec.describe FinancialControlVariable, type: :model do
  subject { build(:financial_control_variable) }

  describe 'associations' do
    it { is_expected.to belong_to(:company) }
  end

  describe 'validations' do
    it { is_expected.to validate_presence_of(:name) }
    it { is_expected.to validate_presence_of(:variable_type) }
    it { is_expected.to validate_presence_of(:currency) }
    it { is_expected.to validate_presence_of(:configuration) }
    it { is_expected.to validate_inclusion_of(:variable_type).in_array(%w[static balance cash_flow loc computed]) }

    it 'validates name uniqueness per company' do
      existing = create(:financial_control_variable, name: 'Total Cash')
      dup = build(:financial_control_variable, name: 'Total Cash', company: existing.company)
      expect(dup).not_to be_valid
    end

    describe 'static config' do
      it 'requires numeric value' do
        var = build(:financial_control_variable, :static, configuration: { 'value' => 'abc' })
        expect(var).not_to be_valid
      end
    end

    describe 'cash_flow config' do
      it 'requires days_forward >= 1' do
        var = build(:financial_control_variable, :cash_flow, configuration: { 'cash_category_ids' => ['x'], 'days_forward' => 0 })
        expect(var).not_to be_valid
      end

      it 'requires at least one category' do
        var = build(:financial_control_variable, :cash_flow, configuration: { 'cash_category_ids' => [], 'days_forward' => 60 })
        expect(var).not_to be_valid
      end
    end

    describe 'scope_ids max' do
      it 'rejects > 250 scope_ids' do
        ids = Array.new(251) { SecureRandom.uuid }
        var = build(:financial_control_variable, :balance, configuration: { 'scope_type' => 'specific_accounts', 'scope_ids' => ids, 'balance_type' => 'total_cash' })
        expect(var).not_to be_valid
      end
    end

    describe 'computed config' do
      it 'requires expression array' do
        var = build(:financial_control_variable, :computed, configuration: {})
        expect(var).not_to be_valid
      end
    end
  end

  describe '#before_destroy guard' do
    it 'blocks deletion when referenced by another computed variable' do
      primitive = create(:financial_control_variable, :static)
      computed = create(:financial_control_variable, :computed,
        company: primitive.company,
        configuration: { 'expression' => [{ 'type' => 'var', 'id' => primitive.uuid }] }
      )

      expect { primitive.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
    end

    it 'blocks deletion when referenced by a rule' do
      variable = create(:financial_control_variable)
      template = create(:financial_control_template, :custom_formula)
      create(:financial_control_rule, :with_custom_formula,
        company: variable.company,
        financial_control_variable: variable
      )

      expect { variable.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
    end

    it 'allows deletion when unreferenced' do
      variable = create(:financial_control_variable)
      expect { variable.destroy! }.not_to raise_error
    end
  end

  describe '.extract_variable_ids_from_expression' do
    it 'extracts all var UUIDs from nested expression' do
      expression = [
        { 'type' => 'group', 'items' => [
          { 'type' => 'var', 'id' => 'uuid-1' },
          { 'type' => 'op', 'value' => '+' },
          { 'type' => 'var', 'id' => 'uuid-2' }
        ] },
        { 'type' => 'op', 'value' => '/' },
        { 'type' => 'var', 'id' => 'uuid-3' }
      ]

      ids = described_class.extract_variable_ids_from_expression(expression)
      expect(ids).to match_array(%w[uuid-1 uuid-2 uuid-3])
    end
  end
end
  • Step 3: Run to verify fails

Run: bundle exec rspec spec/models/financial_control_variable_spec.rb

  • Step 4: Write model
# app/models/financial_control_variable.rb
class FinancialControlVariable < ApplicationRecord
  include HasUuid

  belongs_to :company
  has_many :financial_control_rules, dependent: :nullify

  validates :name, presence: true, uniqueness: { scope: :company_id, case_sensitive: false }
  validates :variable_type, presence: true, inclusion: { in: %w[static balance cash_flow loc computed] }
  validates :currency, presence: true
  validates :configuration, presence: true
  validate :validate_configuration

  scope :active, -> { where(archived_at: nil) }
  scope :primitives, -> { where.not(variable_type: 'computed') }
  scope :computed, -> { where(variable_type: 'computed') }

  before_destroy :ensure_not_referenced

  def archived?
    archived_at.present?
  end

  def archive!
    update!(archived_at: Time.current)
  end

  def computed?
    variable_type == 'computed'
  end

  def expression
    return nil unless computed?
    configuration['expression']
  end

  def referenced_variable_ids
    return [] unless computed?
    self.class.extract_variable_ids_from_expression(configuration['expression'])
  end

  def self.extract_variable_ids_from_expression(expression)
    ids = []
    Array.wrap(expression).each do |node|
      case node['type']
      when 'var'
        ids << node['id']
      when 'group'
        ids.concat(extract_variable_ids_from_expression(node['items']))
      end
    end
    ids.uniq
  end

  private

  def validate_configuration
    return if configuration.blank?

    case variable_type
    when 'static' then validate_static_config
    when 'balance' then validate_balance_config
    when 'cash_flow' then validate_cash_flow_config
    when 'loc' then validate_loc_config
    when 'computed' then validate_computed_config
    end
  end

  def validate_static_config
    errors.add(:configuration, 'requires a numeric value') unless configuration['value'].is_a?(Numeric)
  end

  def validate_balance_config
    unless %w[all_accounts specific_accounts by_entity by_institution].include?(configuration['scope_type'])
      errors.add(:configuration, 'invalid scope_type')
    end
    unless %w[total_cash available_cash].include?(configuration['balance_type'])
      errors.add(:configuration, 'invalid balance_type')
    end
    validate_scope_ids_length
  end

  def validate_cash_flow_config
    cats = configuration['cash_category_ids']
    errors.add(:configuration, 'requires at least one cash_category_id') unless cats.is_a?(Array) && cats.any?
    days = configuration['days_forward']
    errors.add(:configuration, 'days_forward must be >= 1') unless days.is_a?(Integer) && days >= 1
  end

  def validate_loc_config
    unless %w[all_loc_accounts specific_loc_accounts].include?(configuration['scope_type'])
      errors.add(:configuration, 'invalid scope_type')
    end
    validate_scope_ids_length
  end

  def validate_computed_config
    expr = configuration['expression']
    errors.add(:configuration, 'computed variable requires an expression array') unless expr.is_a?(Array)
  end

  def validate_scope_ids_length
    ids = configuration['scope_ids']
    errors.add(:configuration, 'scope_ids cannot exceed 250') if ids.is_a?(Array) && ids.length > 250
  end

  def ensure_not_referenced
    # Check if referenced by other computed variables
    referencing_vars = company.financial_control_variables.computed.select do |v|
      v.referenced_variable_ids.include?(uuid)
    end

    # Check if referenced by rules
    referencing_rules = company.financial_control_rules.where(financial_control_variable_id: id)

    total = referencing_vars.count + referencing_rules.count
    if total > 0
      errors.add(:base, "Cannot delete: referenced by #{referencing_vars.count} variable(s) and #{referencing_rules.count} rule(s)")
      throw(:abort)
    end
  end
end
  • Step 5: Run tests

Run: bundle exec rspec spec/models/financial_control_variable_spec.rb

  • Step 6: Rubocop + Commit

Run: bundle exec rubocop app/models/financial_control_variable.rb

git add app/models/financial_control_variable.rb spec/models/financial_control_variable_spec.rb spec/factories/financial_control_variables.rb
git commit -m "feat: add FinancialControlVariable model with 5 types incl. computed"

Task 4: Update Existing Models

Files:

  • Modify: app/models/financial_control_template.rb — add custom_formula enum

  • Modify: app/models/financial_control_rule.rb — add belongs_to :financial_control_variable

  • Modify: app/models/company.rb — add has_many :financial_control_variables

  • Modify: app/models/cash_category.rb — add before_destroy guard

  • Modify: spec/factories/financial_control_templates.rb — add :custom_formula trait

  • Modify: spec/factories/financial_control_rules.rb — add :with_custom_formula trait

  • Modify: db/seeds/financial_control_templates.rb — seed custom_formula template

  • Step 1: Template enum

Add custom_formula: 'custom_formula' to FinancialControlTemplate.logic_type enum.

  • Step 2: Rule association

Add to FinancialControlRule:

belongs_to :financial_control_variable, optional: true

def variable_rule?
  financial_control_template&.logic_type == 'custom_formula'
end
  • Step 3: Company association

Add to Company:

has_many :financial_control_variables, dependent: :destroy
  • Step 4: CashCategory guard

Add to CashCategory:

before_destroy :ensure_not_referenced_by_fc_variables

def ensure_not_referenced_by_fc_variables
  return unless company
  refs = company.financial_control_variables.where(variable_type: 'cash_flow')
    .select { |v| v.configuration['cash_category_ids']&.include?(uuid) }
  if refs.any?
    errors.add(:base, "Cannot delete: referenced by #{refs.count} variable(s)")
    throw(:abort)
  end
end
  • Step 5: Factory traits

Template factory: add trait :custom_formula with logic_type { 'custom_formula' }, requires_time_period { false }.

Rule factory: add trait :with_custom_formula with template association and threshold_value { 1.2 }.

  • Step 6: Seed

Add to db/seeds/financial_control_templates.rb:

FinancialControlTemplate.find_or_initialize_by(logic_type: 'custom_formula').tap do |t|
  t.name = 'Custom Formula'
  t.description = 'Monitor a computed variable against a threshold'
  t.requires_time_period = false
  t.active = true
  t.save!
end
  • Step 7: Run existing specs

Run: bundle exec rspec spec/models/financial_control_rule_spec.rb spec/models/financial_control_template_spec.rb

  • Step 8: Commit
git add app/models/ spec/factories/ db/seeds/
git commit -m "feat: add custom_formula support to templates, rules, and lifecycle guards"

Task 5: Primitive Variable Resolvers (Static, Balance, CashFlow, LOC)

Files: 4 resolver files + 4 spec files in app/services/financial_controls/variable_resolvers/

  • Step 1: Write specs for all 4 resolvers (see PRD sections 4.1-4.4 for exact resolution logic)

  • Step 2: Implement StaticResolverMoney.new((BigDecimal(value.to_s) * 100).to_i, currency)

  • Step 3: Implement BalanceResolver — resolves accounts via scope config, delegates to BalanceAggregator

  • Step 4: Implement CashFlowResolver — recursive CTE for category tree, dedup, filter by currency and date range

  • Step 5: Implement LocResolver — resolves LOC accounts, sums available_to_draw_on

  • Step 6: Run all resolver specs

Run: bundle exec rspec spec/services/financial_controls/variable_resolvers/

  • Step 7: Commit
git add app/services/financial_controls/variable_resolvers/ spec/services/financial_controls/variable_resolvers/
git commit -m "feat: add 4 primitive variable resolvers (static, balance, cash_flow, loc)"

Task 6: ExpressionEvaluator (Pure PEMDAS Math)

Files:

  • Create: app/services/financial_controls/expression_evaluator.rb

  • Create: spec/services/financial_controls/expression_evaluator_spec.rb

  • Step 1: Write spec

# spec/services/financial_controls/expression_evaluator_spec.rb
require 'rails_helper'

RSpec.describe FinancialControls::ExpressionEvaluator do
  let(:values) { { 'a' => Money.new(100_00, 'USD'), 'b' => Money.new(200_00, 'USD'), 'c' => Money.new(50_00, 'USD') } }

  describe '.evaluate' do
    it 'handles simple addition' do
      expr = [{ 'type' => 'var', 'id' => 'a' }, { 'type' => 'op', 'value' => '+' }, { 'type' => 'var', 'id' => 'b' }]
      expect(described_class.evaluate(expr, values)).to eq(300.0)
    end

    it 'handles groups: (a + b) / c' do
      expr = [
        { 'type' => 'group', 'items' => [
          { 'type' => 'var', 'id' => 'a' }, { 'type' => 'op', 'value' => '+' }, { 'type' => 'var', 'id' => 'b' }
        ] },
        { 'type' => 'op', 'value' => '/' },
        { 'type' => 'var', 'id' => 'c' }
      ]
      expect(described_class.evaluate(expr, values)).to eq(6.0)
    end

    it 'respects PEMDAS: a + b * c = 100 + 200*50 = 10100' do
      expr = [
        { 'type' => 'var', 'id' => 'a' },
        { 'type' => 'op', 'value' => '+' },
        { 'type' => 'var', 'id' => 'b' },
        { 'type' => 'op', 'value' => '*' },
        { 'type' => 'var', 'id' => 'c' }
      ]
      expect(described_class.evaluate(expr, values)).to eq(10_100.0)
    end

    it 'handles inline numbers' do
      expr = [{ 'type' => 'var', 'id' => 'a' }, { 'type' => 'op', 'value' => '*' }, { 'type' => 'number', 'value' => 3 }]
      expect(described_class.evaluate(expr, values)).to eq(300.0)
    end

    it 'raises on division by zero' do
      expr = [{ 'type' => 'var', 'id' => 'a' }, { 'type' => 'op', 'value' => '/' }, { 'type' => 'number', 'value' => 0 }]
      expect { described_class.evaluate(expr, values) }.to raise_error(ZeroDivisionError)
    end
  end
end
  • Step 2: Implement
# app/services/financial_controls/expression_evaluator.rb
module FinancialControls
  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

    def self.tokenize(nodes, resolved)
      Array.wrap(nodes).map do |node|
        case node['type']
        when 'var'
          val = resolved[node['id']]
          val.is_a?(Money) ? (val.cents.to_f / 100) : val.to_f
        when 'number'
          node['value'].to_f
        when 'op'
          node['value']
        when 'group'
          evaluate(node['items'], resolved)
        end
      end
    end

    def self.apply_operators(tokens, operators)
      result = []
      i = 0
      while i < tokens.length
        if tokens[i].is_a?(String) && operators.include?(tokens[i])
          left = result.pop
          right = tokens[i + 1]
          raise ZeroDivisionError, 'Division by zero' if tokens[i] == '/' && right.zero?
          result << compute(left, tokens[i], right)
          i += 2
        else
          result << tokens[i]
          i += 1
        end
      end
      result
    end

    def self.compute(left, op, right)
      case op
      when '+' then left + right
      when '-' then left - right
      when '*' then left * right
      when '/' then left / right
      end
    end

    private_class_method :tokenize, :apply_operators, :compute
  end
end
  • Step 3: Run spec

Run: bundle exec rspec spec/services/financial_controls/expression_evaluator_spec.rb

  • Step 4: Commit
git add app/services/financial_controls/expression_evaluator.rb spec/services/financial_controls/expression_evaluator_spec.rb
git commit -m "feat: add ExpressionEvaluator with PEMDAS operator precedence"

Task 7: FormulaDataProvider + ComputedResolver

Files:

  • Create: app/services/financial_controls/formula_data_provider.rb

  • Create: app/services/financial_controls/variable_resolvers/computed_resolver.rb

  • Create: spec/services/financial_controls/formula_data_provider_spec.rb

  • Step 1: Write spec

Test cases:

  • Resolves static variable with caching

  • Resolves computed variable that references 2 static variables

  • Resolves layered computed → computed → primitive (3 levels)

  • Detects circular dependency (A→B→A) and raises

  • Detects max depth exceeded and raises

  • Cache invalidation via updated_at change

  • Step 2: Implement FormulaDataProvider

See PRD section 5.2 for full implementation. Key: recursive resolution with resolution_stack for cycle detection, Rails.cache.fetch with updated_at in key.

  • Step 3: Implement ComputedResolver
# app/services/financial_controls/variable_resolvers/computed_resolver.rb
module FinancialControls
  module VariableResolvers
    class ComputedResolver
      def self.resolve(variable, provider:, resolution_stack:)
        expression = variable.configuration['expression']
        var_ids = FinancialControlVariable.extract_variable_ids_from_expression(expression)
        variables = variable.company.financial_control_variables.where(uuid: var_ids).index_by(&:uuid)

        resolved = {}
        variables.each do |uuid, var|
          resolved[uuid] = provider.resolve(var, resolution_stack: resolution_stack + [variable.uuid])
        end

        result = ExpressionEvaluator.evaluate(expression, resolved)
        # Return as decimal for ratios, Money for monetary sums
        # Convention: if expression contains / → decimal, else → Money in variable currency
        Money.new((result * 100).round, variable.currency)
      end
    end
  end
end
  • Step 4: Run specs

Run: bundle exec rspec spec/services/financial_controls/formula_data_provider_spec.rb

  • Step 5: Commit
git add app/services/financial_controls/formula_data_provider.rb app/services/financial_controls/variable_resolvers/computed_resolver.rb spec/services/financial_controls/formula_data_provider_spec.rb
git commit -m "feat: add FormulaDataProvider with recursive computed variable resolution"

Task 8: ExpressionValidator

Files:

  • Create: app/services/financial_controls/expression_validator.rb

  • Create: spec/services/financial_controls/expression_validator_spec.rb

  • Step 1: Write spec — covers: valid simple, valid grouped, empty, consecutive ops, starts/ends with op, unknown var, archived var, nesting > 5, nodes > 50, invalid operator, circular dependency detection

  • Step 2: Implement — validates syntax + structure + variable references + circular deps (builds dependency graph)

  • Step 3: Run spec + commit

git add app/services/financial_controls/expression_validator.rb spec/services/financial_controls/expression_validator_spec.rb
git commit -m "feat: add ExpressionValidator with syntax, reference, and circular dep checks"

Task 9: VariableEvaluator (the simple one)

Files:

  • Create: app/services/financial_controls/evaluators/variable_evaluator.rb

  • Create: spec/services/financial_controls/evaluators/variable_evaluator_spec.rb

  • Modify: app/services/financial_controls/evaluators/resolver.rb

  • Step 1: Write spec

# spec/services/financial_controls/evaluators/variable_evaluator_spec.rb
require 'rails_helper'

RSpec.describe FinancialControls::Evaluators::VariableEvaluator do
  let_it_be(:company) { create(:company) }
  let_it_be(:template) { create(:financial_control_template, :custom_formula) }

  it 'returns ok when resolved value meets threshold' do
    var = create(:financial_control_variable, :static, company: company,
      configuration: { 'value' => 1.5 }, currency: 'USD')
    rule = create(:financial_control_rule, :with_custom_formula,
      company: company, financial_control_variable: var,
      threshold_value: 1.2, threshold_currency: 'USD')

    result = described_class.new(rule: rule, bank_accounts: []).evaluate
    expect(result[:status]).to eq(:ok)
    expect(result[:value]).to eq(1.5)
  end

  it 'returns breach when below threshold' do
    var = create(:financial_control_variable, :static, company: company,
      configuration: { 'value' => 0.8 }, currency: 'USD')
    rule = create(:financial_control_rule, :with_custom_formula,
      company: company, financial_control_variable: var,
      threshold_value: 1.2, threshold_currency: 'USD')

    result = described_class.new(rule: rule, bank_accounts: []).evaluate
    expect(result[:status]).to eq(:breach)
  end

  it 'returns error for missing variable' do
    rule = create(:financial_control_rule, :with_custom_formula,
      company: company, financial_control_variable_id: nil,
      threshold_value: 1.2, threshold_currency: 'USD')

    result = described_class.new(rule: rule, bank_accounts: []).evaluate
    expect(result[:status]).to eq(:error)
  end

  it 'returns error for archived variable' do
    var = create(:financial_control_variable, :archived, company: company)
    rule = create(:financial_control_rule, :with_custom_formula,
      company: company, financial_control_variable: var,
      threshold_value: 1.2, threshold_currency: 'USD')

    result = described_class.new(rule: rule, bank_accounts: []).evaluate
    expect(result[:status]).to eq(:error)
  end
end
  • Step 2: Implement
# app/services/financial_controls/evaluators/variable_evaluator.rb
module FinancialControls
  module Evaluators
    class VariableEvaluator < BaseEvaluator
      def evaluate
        variable = rule.financial_control_variable
        return error_result('No variable assigned to rule') unless variable
        return error_result("Variable '#{variable.name}' is archived") if variable.archived?

        resolved = FormulaDataProvider.resolve_single(variable, target_currency: rule.threshold_currency)
        value = resolved.is_a?(Money) ? (resolved.cents.to_f / 100) : resolved.to_f
        threshold = rule.threshold_value.to_f

        build_comparison_result(value, threshold, variable, resolved)
      rescue FinancialControls::FormulaDataProvider::CircularDependencyError => e
        error_result("Circular dependency: #{e.message}")
      rescue ZeroDivisionError
        error_result('Division by zero in variable expression')
      rescue StandardError => e
        error_result("Variable evaluation failed: #{e.message}")
      end

      private

      def build_comparison_result(value, threshold, variable, resolved)
        contributing = [{ variable_uuid: variable.uuid, variable_name: variable.name,
                          resolved_value_cents: resolved.is_a?(Money) ? resolved.cents : nil,
                          currency: rule.threshold_currency, value: value }]

        if value < threshold
          if within_formula_warning?(value, threshold)
            warning_result(value_cents: nil, currency: rule.threshold_currency, value: value.round(4),
                           contributing_accounts: contributing)
          else
            breach_result(value_cents: nil, currency: rule.threshold_currency, value: value.round(4),
                          contributing_accounts: contributing)
          end
        else
          success_result(value_cents: nil, currency: rule.threshold_currency, value: value.round(4),
                         contributing_accounts: contributing)
        end
      end

      def within_formula_warning?(value, threshold)
        return false if threshold.zero?
        ((value - threshold).abs / threshold) <= 0.1
      end
    end
  end
end
  • Step 3: Register in Resolver

Add to EVALUATORS hash: 'custom_formula' => VariableEvaluator

  • Step 4: Run specs

Run: bundle exec rspec spec/services/financial_controls/evaluators/variable_evaluator_spec.rb

  • Step 5: Run all existing evaluator specs

Run: bundle exec rspec spec/services/financial_controls/

  • Step 6: Commit
git add app/services/financial_controls/evaluators/variable_evaluator.rb app/services/financial_controls/evaluators/resolver.rb spec/services/financial_controls/evaluators/variable_evaluator_spec.rb
git commit -m "feat: add VariableEvaluator — resolves variable, compares threshold"

Task 10: GraphQL Types & Inputs

Create all GraphQL enum, type, input, and union files. See PRD section 7 for exact schema. Key difference from previous plan: expression types live on variable configuration, not on rules. Rule just gets variable field.

  • Step 1: Create enums (FormulaOperatorEnum, FormulaNodeTypeEnum, FinancialControlVariableTypeEnum incl. COMPUTED, VariableScopeTypeEnum, LocScopeTypeEnum)

  • Step 2: Create variable config types (Static, Balance, CashFlow, Loc, Computed — with expression nodes)

  • Step 3: Create expression node types (FormulaVariableNodeType, FormulaNumberNodeType, FormulaOperatorNodeType, FormulaGroupNodeType, FormulaExpressionNodeUnion)

  • Step 4: Create FinancialControlVariableType with currentValue, usageCount, configuration (union)

  • Step 5: Create input types (FormulaExpressionNodeInput, VariableConfigurationInput incl. ComputedConfigInput)

  • Step 6: Create FormulaPreviewType, ResolvedVariableType

  • Step 7: Update FinancialControlRuleType — add variable field, resolvedValue, expressionReadable

  • Step 8: Dump schema

Run: bundle exec rake graphql:dump_schema

  • Step 9: Commit
git add app/graphql/
git commit -m "feat: add fully-typed GraphQL schema for composable Variable Pool"

Task 11: GraphQL Mutations & Resolvers

  • Step 1: Variable CRUD mutations (Create, Update, Archive, Delete) — follow existing CreateFinancialControlRule patterns

  • Step 2: Variables list resolver — company-scoped, permission-checked

  • Step 3: Preview resolver — resolves a variable on-demand, returns value + dependency tree

  • Step 4: Update CreateFinancialControlRule — add variableId: ID and thresholdValue: Float args for custom_formula rules. Skip scope creation for variable rules.

  • Step 5: Register in mutation_type and query_type

  • Step 6: Dump schema + commit

git add app/graphql/ schema.graphql schema.json
git commit -m "feat: add variable CRUD mutations, preview resolver, rule variable integration"

Task 12: Request Specs

  • Step 1: Create variable mutation spec — create static, cash_flow, computed; reject invalid; check permissions
  • Step 2: Delete variable mutation spec — block when referenced, allow when free
  • Step 3: Preview resolver spec — compute result, show tree, return validation errors
  • Step 4: Run + commit

Task 13: End-to-End Integration Spec

  • Step 1: Write Dan's LCR as layered computed variables
RSpec.describe 'Composable Variable End-to-End' do
  it "evaluates Dan's 60-day LCR through full pipeline" do
    # Layer 1: primitives
    cash = create(:financial_control_variable, :static, name: 'Cash', configuration: { 'value' => 1_200_000.0 }, ...)
    loc = create(:financial_control_variable, :static, name: 'LOC', configuration: { 'value' => 500_000.0 }, ...)
    payoffs = create(:financial_control_variable, :static, name: 'Payoffs', configuration: { 'value' => 300_000.0 }, ...)
    loans = create(:financial_control_variable, :static, name: 'Loans', configuration: { 'value' => 800_000.0 }, ...)
    opex = create(:financial_control_variable, :static, name: 'Opex', configuration: { 'value' => 400_000.0 }, ...)

    # Layer 2: intermediate
    liquid = create(:financial_control_variable, :computed, name: 'Liquid Sources',
      configuration: { 'expression' => [
        { 'type' => 'var', 'id' => cash.uuid }, { 'type' => 'op', 'value' => '+' },
        { 'type' => 'var', 'id' => loc.uuid }, { 'type' => 'op', 'value' => '+' },
        { 'type' => 'var', 'id' => payoffs.uuid }
      ] }, ...)

    outflows = create(:financial_control_variable, :computed, name: 'Total Outflows',
      configuration: { 'expression' => [
        { 'type' => 'var', 'id' => loans.uuid }, { 'type' => 'op', 'value' => '+' },
        { 'type' => 'var', 'id' => opex.uuid }
      ] }, ...)

    # Layer 3: final
    lcr = create(:financial_control_variable, :computed, name: 'LCR',
      configuration: { 'expression' => [
        { 'type' => 'var', 'id' => liquid.uuid }, { 'type' => 'op', 'value' => '/' },
        { 'type' => 'var', 'id' => outflows.uuid }
      ] }, ...)

    # Rule: LCR >= 1.2
    rule = create(:financial_control_rule, :with_custom_formula,
      financial_control_variable: lcr, threshold_value: 1.2, ...)

    # LCR = (1.2M + 500K + 300K) / (800K + 400K) = 2M / 1.2M = 1.667
    result = FinancialControls::RuleEvaluationService.evaluate(rule)
    expect(result[:status].to_s).to eq('ok')
  end
end
  • Step 2: Run + commit

Task 14: Frontend — GraphQL Documents + Codegen

  • Step 1: Write all .graphql files for variable queries/mutations and preview
  • Step 2: Update create-financial-control-rule.graphql with variableId arg
  • Step 3: Run codegennpx graphql-codegen
  • Step 4: Commit

Task 15: Frontend — Variable Pool Page

  • Step 1: Variables list page with table (name, type badge, currency, value, usage count)
  • Step 2: CreateVariableDialog — type selector → type-specific config form
  • Step 3: VariableConfigForm — switches on type: Static (number input), Balance (scope + balance_type), CashFlow (category tree + days), LOC (scope), Computed (ExpressionBuilder)
  • Step 4: Navigation link from financial controls page
  • Step 5: Commit

Task 16: Frontend — FormulaExpressionBuilder (for Computed Variables)

  • Step 1: Main component — manages expression state as FormulaExpressionNodeInput[]
  • Step 2: VariableChip — dropdown picks from company variables (primitives + other computed)
  • Step 3: OperatorPill — click to cycle +, -, *, /
  • Step 4: GroupNode — visual parentheses wrapper
  • Step 5: FormulaPreview — debounced call to previewVariableResult, shows resolved value + dependency tree
  • Step 6: Commit

Task 17: Frontend — VariableDependencyTree

  • Step 1: Tree component — renders the debugging tree view from PRD section 13:
LCR = 1.67
├── Liquid Sources = $2,000,000
│   ├── Cash = $1,200,000
│   ├── LOC = $500,000
│   └── Payoffs = $300,000
└── Total Outflows = $1,200,000
    ├── Loans = $800,000
    └── Opex = $400,000
  • Step 2: Commit

Task 18: Frontend — Rule Creation + Detail

  • Step 1: CreateRuleForm — when "Custom Formula" template selected: hide scope, show variable picker (dropdown of all company variables), show threshold input + direction
  • Step 2: Rule detail — show variable name, resolved value vs threshold, VariableDependencyTree
  • Step 3: Commit

Task 19: Final Verification

  • Step 1: Full backend specsbundle exec rspec spec/services/financial_controls/ spec/models/financial_control*
  • Step 2: Rubocopbundle exec rubocop app/models/financial_control_variable.rb app/services/financial_controls/ app/graphql/
  • Step 3: Frontend codegen + typechecknpx graphql-codegen && npx tsc --noEmit
  • Step 4: Seed templatebundle exec rails db:seed:financial_control_templates
  • Step 5: Schema dumpbundle exec rake graphql:dump_schema
  • Step 6: Final commit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment