Technical docs

Tax Engine

Multi-jurisdiction tax calculation with Nigeria implementation.

Overview

The Tax Engine provides jurisdiction-specific tax calculations. Each jurisdiction has its own engine implementing the TaxEngine interface. Currently, Nigeria (NG) is fully implemented with support for VAT, WHT, Stamp Duty, and NITDA Levy.

File Locations

backend/internal/services/tax/
├── engine.go              # TaxEngine interface, TaxContext, TaxResult
├── nigeria_engine.go      # Nigeria-specific implementation
└── tax_calculation_service.go  # Service wrapper

backend/internal/taxserver/
└── tax_server.go          # gRPC server with currency conversion

backend/internal/paymentv3server/
└── payment_v3_server.go   # Tax preview with currency conversion

Dependencies

  • CurrencyService - For multi-currency conversion to NGN (injected into PaymentV3Server and TaxServer)
  • models.Jurisdiction - Jurisdiction configuration
  • models.VATRule - VAT rules from database
  • models.WHTRule - WHT rules from database
  • models.StampDutyRule - Stamp duty rules from database

Architecture

TaxEngine Interface

type TaxEngine interface {
    Calculate(ctx *TaxContext) (*TaxResult, error)
    ValidateCompliance(ctx *TaxContext, result *TaxResult) []string
    IncorporateProviderCollectedTax(result *TaxResult, providerTax *ProviderTaxInfo) error
}

Engine Registration

func NewTaxCalculationService(db *gorm.DB) (*TaxCalculationService, error) {
    nigeriaEngine, err := NewNigeriaTaxEngine(db)
    if err != nil {
        return nil, err
    }

    engines := map[string]TaxEngine{
        "NG": nigeriaEngine,
        // Future: "US": usEngine, "UK": ukEngine
    }

    return &TaxCalculationService{
        db:      db,
        engines: engines,
    }, nil
}

Nigeria Tax Engine

The Nigeria engine implements FIRS (Federal Inland Revenue Service) tax rules including VAT, WHT, Stamp Duty, and NITDA Levy.

Supported Tax Types

VAT (Value Added Tax)

7.5% on goods and services

Authority: FIRS

WHT (Withholding Tax)

5-10% depending on service type

Authority: FIRS

Stamp Duty

₦50 flat on receipts ≥ ₦10,000

Authority: FIRS

NITDA Levy

1% on digital services

Authority: NITDA

Tax Determination Flow

TaxContext Structure

TaxContext carries all information needed for tax calculation.

Core Fields

type TaxContext struct {
    // Transaction identity
    ContextType     string    // "sale" or "expense"
    OccurredAt      time.Time // Transaction date
    
    // Amounts
    Amount          float64   // Original amount
    Currency        string    // Original currency (e.g., "USD", "EUR")
    AmountNGN       float64   // Amount converted to NGN (for thresholds)
    FXRate          float64   // Exchange rate used for conversion
    
    // Jurisdiction
    SellerJurisdictionCode   string
    CustomerJurisdictionCode string
    PlaceOfSupplyCode        string
    
    // Item classification
    ItemType        string    // "goods", "services", "digital_services", etc.
    InstrumentType  *string   // For stamp duty: "receipt", "contract", etc.
    
    // Counterparty
    CounterpartyType     string  // "individual", "company"
    CounterpartyResident bool    // Nigerian resident?
    
    // Workspace tax profile
    WorkspaceProfile *WorkspaceProfileContext
    
    // Provider fees (e.g., Paystack)
    HasProviderFee     bool
    ProviderFeeAmount  float64
}

WorkspaceProfileContext

type WorkspaceProfileContext struct {
    VATRegistered        bool     // Registered for VAT?
    WHTAgentRegistered   bool     // WHT withholding agent?
    AnnualTurnover       float64  // In NGN
    SellsDigitalServices bool     // For NITDA levy
    ImportsServices      bool     // For reverse charge VAT
}

Tax Components

The engine returns a TaxResult containing multiple TaxComponents.

TaxResult Structure

type TaxResult struct {
    TaxableBase     float64
    TimeOfSupply    time.Time
    Components      []models.TaxComponent
    Compliance      Compliance
    ProfileStatus   string   // "complete", "incomplete", "threshold_exempt"
    RequiredActions []string // Warnings/prompts for user
}

TaxComponent Structure

type TaxComponent struct {
    Code             string   // "VAT_OUTPUT", "WHT_PAYABLE", "STAMP_DUTY"
    JurisdictionCode string   // "NG"
    Authority        string   // "Federal Inland Revenue Service (FIRS)"
    Rate             *float64 // 7.5 for VAT
    Amount           float64  // Calculated tax amount
    Direction        string   // "payable", "receivable", "informational"
    Basis            string   // "gross", "net", "instrument", "fee"
    RuleRef          *string  // UUID of the rule applied
    IsFinalTax       *bool    // For WHT: is this a final tax?
    CertificateRef   *string  // WHT certificate reference
}

Component Codes

CodeDescriptionDirection
VAT_OUTPUTVAT on salespayable
VAT_INPUTVAT on provider feesreceivable
VAT_REVERSE_CHARGEVAT on imported servicespayable
WHT_PAYABLEWHT to withhold on expensespayable
WHT_RECEIVABLEWHT withheld on salesreceivable
STAMP_DUTYStamp duty on receiptspayable
NITDA_LEVYNITDA levy on digital servicespayable

Multi-Currency Support

Nigerian tax law requires all thresholds to be evaluated in NGN. For transactions in foreign currencies, the system converts amounts using the CBN rate at the transaction date.

CBN Rate Requirement

Nigerian tax law requires using the Central Bank of Nigeria (CBN) official rate for all foreign currency conversions. The system uses the rate from the transaction date, not the current rate.

PaymentV3Server Tax Preview (Primary Entry Point)

The payment form uses CalculateTaxPreview to show tax calculations before submission.

func (s *PaymentV3Server) CalculateTaxPreview(ctx context.Context, req *pb.CalculateTaxPreviewRequest) {
    taxCtx := &tax.TaxContext{
        Amount:   amount,
        Currency: req.Currency,
        // ... other fields
    }
    
    // Convert to NGN for Nigerian jurisdiction with foreign currency
    if req.Currency != "" && req.Currency != "NGN" && jurisdictionCode == "NG" && s.currencyService != nil {
        transactionDate := req.TransactionDate.AsTime()
        amountNGN, err := s.currencyService.ConvertAmountForDate(
            amount, req.Currency, "NGN", transactionDate,
        )
        if err == nil {
            taxCtx.AmountNGN = amountNGN
        }
        rate, _ := s.currencyService.GetRateForDate(req.Currency, "NGN", transactionDate)
        taxCtx.FXRate = rate
    }
    
    engine, _ := s.taxCalculationService.GetEngine(jurisdictionCode)
    result, _ := engine.Calculate(taxCtx)
}

TaxServer Currency Conversion

The gRPC TaxServer also performs conversion for direct API calls.

func (s *TaxServer) CalculateTax(ctx context.Context, req *pb.CalculateTaxRequest) {
    taxCtx := &tax.TaxContext{
        Amount:   req.Amount,
        Currency: req.Currency,
    }
    
    // Convert to NGN for threshold comparisons
    if req.Currency != "" && req.Currency != "NGN" {
        amountNGN, err := s.currencyService.ConvertAmountForDate(
            req.Amount, req.Currency, "NGN", transactionDate,
        )
        if err == nil {
            taxCtx.AmountNGN = amountNGN
        }
        
        rate, _ := s.currencyService.GetRateForDate(req.Currency, "NGN", transactionDate)
        taxCtx.FXRate = rate
    }
    
    result, err := engine.Calculate(taxCtx)
}

Engine Amount Helper

// getAmountInNGN returns the NGN equivalent for threshold comparisons
func (e *NigeriaTaxEngine) getAmountInNGN(ctx *TaxContext) float64 {
    // If already NGN or no currency specified, use original
    if ctx.Currency == "NGN" || ctx.Currency == "" {
        return ctx.Amount
    }
    // If converted amount available, use it
    if ctx.AmountNGN > 0 {
        return ctx.AmountNGN
    }
    // Fallback to original (should not happen)
    return ctx.Amount
}

Stamp Duty with Currency Conversion

func (e *NigeriaTaxEngine) calculateStampDuty(ctx *TaxContext, result *TaxResult) error {
    // Get NGN amount for threshold comparison
    amountNGN := e.getAmountInNGN(ctx)
    
    for _, rule := range e.stampRules {
        // Compare threshold in NGN (₦10,000 minimum)
        if rule.Threshold != nil && amountNGN < *rule.Threshold {
            return nil // Below threshold, no stamp duty
        }
        
        // Calculate stamp duty on NGN amount
        if rule.AdValoremRate != nil {
            stampAmount = amountNGN * (*rule.AdValoremRate)
        }
    }
}

Usage Examples

Basic Tax Calculation

taxCtx := &tax.TaxContext{
    ContextType:            "sale",
    Amount:                 100000.0,
    Currency:               "NGN",
    SellerJurisdictionCode: "NG",
    ItemType:               "services",
    OccurredAt:             time.Now(),
    WorkspaceProfile: &tax.WorkspaceProfileContext{
        VATRegistered:  true,
        AnnualTurnover: 50000000.0,
    },
}

result, err := engine.Calculate(taxCtx)
// result.Components contains:
// - VAT_OUTPUT: 7500.0 (7.5% of 100000)
// - STAMP_DUTY: 50.0 (flat fee for receipt >= 10000)

Foreign Currency Transaction

taxCtx := &tax.TaxContext{
    ContextType:            "sale",
    Amount:                 1000.0,   // USD
    Currency:               "USD",
    AmountNGN:              1550000.0, // Converted at 1550 rate
    FXRate:                 1550.0,
    SellerJurisdictionCode: "NG",
    ItemType:               "digital_services",
    WorkspaceProfile: &tax.WorkspaceProfileContext{
        VATRegistered:        true,
        SellsDigitalServices: true,
    },
}

result, err := engine.Calculate(taxCtx)
// Thresholds checked against 1,550,000 NGN
// VAT calculated on original USD amount
// NITDA levy calculated on original USD amount

Expense with WHT

taxCtx := &tax.TaxContext{
    ContextType:            "expense",
    Amount:                 500000.0,
    Currency:               "NGN",
    SellerJurisdictionCode: "NG",
    ItemType:               "professional_services",
    CounterpartyType:       "company",
    CounterpartyResident:   true,
    WorkspaceProfile: &tax.WorkspaceProfileContext{
        WHTAgentRegistered: true,
    },
}

result, err := engine.Calculate(taxCtx)
// result.Components contains:
// - WHT_PAYABLE: 50000.0 (10% for professional services)
// - STAMP_DUTY: 50.0

Using TaxCalculationService

service, err := tax.NewTaxCalculationService(db)

// Calculate and persist to database
determination, err := service.CalculateAndStore(
    ctx,
    taxCtx,
    transactionID,
)

// determination is saved to tax_determinations table
// with all components serialized as JSON

Rules and Thresholds

VAT Registration Threshold

Annual turnover exceeding ₦25,000,000 requires mandatory VAT registration.

const NGVATRegistrationThreshold = 25_000_000

Stamp Duty Threshold

Receipts of ₦10,000 or more attract stamp duty (typically ₦50 flat).

const NGStampDutyReceiptThreshold = 10_000

VAT Rates by Item Type

Item TypeRateMode
Standard goods/services7.5%standard
Exported goods0%zero_rated
Basic food, medical, educationExemptexempt

WHT Rates by Service Type

Service TypeResident RateNon-Resident Rate
Professional services10%10%
Technical services10%10%
Consultancy10%10%
Commission10%10%
Rent10%10%

Immutability Rule

Tax determinations stored in the database are IMMUTABLE. Once a transaction is posted, its tax calculation cannot be modified. For corrections, create a new adjusting transaction.

Currency Conversion Rule

All threshold comparisons MUST use NGN equivalents. Foreign currency amounts are converted using the CBN rate at the transaction date, not the current rate. This is a legal requirement for Nigerian tax compliance.