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
| Priority | Layer | Source | When to Use |
|---|---|---|---|
| 1 (Highest) | Category Default | transaction_categories.default_revenue_account_id | Accountant overrides for specific category |
| 2 | Policy Category Mapping | posting_policies.rules_json → category_mappings[categoryID] | Tenant-specific rules for a category |
| 3 (Lowest) | Policy Fallback | posting_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" -vTest 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