Technical docs

GL Account Resolution

The 3-layer system that determines which GL account receives each journal line.

Overview

When posting an event to the journal, the system needs to know which GL account to hit. This is resolved through a 3-layer priority system that allows both tenant-wide defaults and category-specific overrides.

Key Principle

Category defaults (set by accountants) always override policy mappings. This gives precise control over where specific categories post without modifying the global policy.

The 3 Layers

PriorityLayerSourceWhen to Use
1 (Highest)Category Defaulttransaction_categories.default_revenue_account_idAccountant overrides for specific category
2Policy Category Mappingposting_policies.rules_json → category_mappings[categoryID]Tenant-specific rules for a category
3 (Lowest)Policy Fallbackposting_policies.rules_json → category_mappings["sales"]Default for all sale categories

Resolution Flow

During journal generation, the service loads the category and passes it to the policy resolver.

Layer 1

Category has DefaultRevenueAccountID set? Use it.

Layer 2

Policy has specific mapping for this category UUID? Use it.

Layer 3

Use generic "sales" or "expense" fallback.

Implementation Code

Main Resolver (with Category Defaults)

Located in backend/internal/models/posting_policy.go

func (r *PostingRules) ResolveCategoryMappingWithCategoryDefaults(
    cat *TransactionCategory,
) (CategoryMapping, bool) {
    // Layer 2 & 3: Get base mapping from policy
    mapping, ok := r.ResolveCategoryMappingWithFallback(
        cat.ID, 
        cat.AppliesTo,
    )

    // Layer 1: Category defaults OVERRIDE policy
    if cat.DefaultRevenueAccountID != nil && 
       *cat.DefaultRevenueAccountID != uuid.Nil {
        mapping.RevenueAccountID = *cat.DefaultRevenueAccountID
        ok = true
    }
    if cat.DefaultExpenseAccountID != nil && 
       *cat.DefaultExpenseAccountID != uuid.Nil {
        mapping.ExpenseAccountID = *cat.DefaultExpenseAccountID
        ok = true
    }

    return mapping, ok
}

Fallback Resolver (Policy Only)

Called by the main resolver for Layer 2 and 3 resolution.

func (r *PostingRules) ResolveCategoryMappingWithFallback(
    categoryID uuid.UUID,
    appliesTo string,
) (CategoryMapping, bool) {
    // Layer 2: Try specific category mapping
    if mapping, ok := r.CategoryMappings[categoryID.String()]; ok {
        return mapping, true
    }

    // Layer 3: Fall back to generic "sales" or "expense"
    var fallbackKey string
    switch appliesTo {
    case "sale":
        fallbackKey = "sales"
    case "expense":
        fallbackKey = "expense"
    default:
        return CategoryMapping{}, false
    }

    if mapping, ok := r.CategoryMappings[fallbackKey]; ok {
        return mapping, true
    }

    return CategoryMapping{}, false
}

Real Examples

Example 1: Consulting Services (with override)

Category: "Consulting Services" with DefaultRevenueAccountID = 4100

Policy fallback: sales → 4000

Result: Journal posts to 4100 (Layer 1 wins)

Example 2: Product Sales (no override)

Category: "Product Sales" with DefaultRevenueAccountID = NULL

Policy fallback: sales → 4000

Result: Journal posts to 4000 (Layer 3 fallback)

Testing

Integration tests verify the complete resolution flow.

# Test GL resolution respects category defaults
go test ./tests/... -run "TestJournalGenerationService_UsesCategoryDefaultGLAccounts" -v

# Test full posting policy integration  
go test ./tests/... -run "TestEventService_ResolveGLAccountCode_Integration" -v

# Test fallback resolution
go test ./tests/... -run "TestPostingRules_ResolveCategoryMappingWithFallback" -v

Test Scenarios

  • ✓ Category with DefaultRevenueAccountID → Journal uses category's GL (Layer 1)
  • ✓ Category without override → Journal uses policy fallback (Layer 3)
  • ✓ Missing GL at all layers → Event flagged no_gl_account