Tax Development Skill

Implement multi-country tax engines in ERP systems using Claude Code.

Version License

Development-time skill for implementing multi-country tax engines (SST, GST, VAT) in ERP systems using Claude Code.

Version: 1.0.0
Status: ✅ Production Ready (Malaysia, Singapore), 🟡 Beta (Thailand)
License: MIT

CRITICAL: This is a development-time skill only — it helps Claude write correct tax code. It does NOT include runtime code that ships to production.


When to Use This Skill

Auto-trigger this skill when:

  • Working on files in src/lib/tax/ or src/app/api/tax*/
  • User mentions: "tax engine", "SST", "GST", "VAT", "withholding tax", "tax calculation"
  • Implementing bill/invoice tax calculations
  • Adding support for a new country (see guides/adding-sweden-vat.md)

Scope: Transactional Taxes Only

This skill covers sales-related taxes (SST, GST, VAT) calculated on invoices and bills.

In Scope: Malaysia SST, Singapore GST, Thailand VAT, Sweden MOMS, etc.

Out of Scope: Income tax, payroll tax, corporate tax - see @scope-income-tax.md for why.

Real-World Example: Adding Sweden VAT

"I need to add Sweden VAT support"

  1. Skill provides @thailand-vat.md as research template
  2. Copy @malaysia-engine.ts pattern for Sweden engine
  3. Use @currency-rounding.md (SEK = half-up like MYR)
  4. Cross-validate with node-sales-tax: SalesTax.getSalesTax('SE') // 25%
  5. Add tests using existing test pattern
  6. Generate confidence report

Time saved: ~5 hours (skill) vs ~7 hours (from scratch)

See guides/adding-sweden-vat.md for complete walkthrough.

Confidence Levels

Each reference file includes confidence ratings (🟢 A - Verified to ⚪ X - Unknown):

  • 🟢 A - Verified: Multiple authoritative sources agree
  • 🟡 B - High: Authoritative source, no contradictions
  • 🟠 C - Medium: Single authoritative source or incomplete
  • 🔴 D - Low: Conflicting or unofficial sources
  • ⚪ X - Unknown: Placeholder, requires research

See @confidence-framework.md for detailed methodology.


Domain Overview

Tax Types by Country

CountryTax TypeRateNatureInput Tax Recovery
MalaysiaSST (Service Tax)8%Single-stageNo
MalaysiaSST (Sales Tax)5-10%Single-stageNo
MalaysiaWHT0-15%WithholdingN/A
SingaporeGST9%Multi-stage VATYes
ThailandVAT7%Multi-stage VATYes

Key Distinctions

SST (Malaysia) - Single-stage tax, charged once in supply chain:

  • Service Tax: Tax on services provided by registered businesses
  • Sales Tax: Tax on manufactured/imported goods
  • No input tax recovery (tax is cost, not credit)

GST/VAT (Singapore, Thailand) - Multi-stage tax with credit mechanism:

  • Tax charged at each stage of supply chain
  • Businesses can recover input tax (tax paid on purchases)
  • Net tax = Output tax - Input tax

WHT (Withholding Tax) - Tax deducted at source:

  • Applied to payments to non-residents
  • Rates vary by payment type (0-15%)
  • See @malaysia-sst.md for detailed WHT rules

Core Patterns

TaxEngine Interface (Async Only)

All tax engines MUST implement this interface:

export interface TaxEngine {
  countryCode: string;
  
  // ALWAYS async — DB lookups for tax configs are inherently async
  calculate(params: TaxCalculationParams): Promise<TaxCalculation[]>;
  
  validateRegistration(taxId: string): boolean;
  getApplicableTaxes(context: TaxContext): Promise<TaxConfig[]>;
  
  // Optional: Generate tax filing summary
  generateFiling?(periodId: string): Promise<{
    outputTax: number;
    inputTax: number;
    netPayable: number;
  }>;
}

CRITICAL: The calculate() method is ALWAYS async. Never create a sync variant. Tax configuration lookups from the database are inherently asynchronous.

TaxCalculationParams

export interface TaxCalculationParams {
  amount: number;           // Amount in local currency
  taxType: string;          // 'SST_SERVICE', 'GST', 'VAT', etc.
  isTaxInclusive: boolean;  // Is amount inclusive of tax?
  transactionDate: Date;    // CRITICAL: For effective date lookups
  vendorId?: string;        // For WHT determination
  customerId?: string;      // For reverse charge checks
  itemCategory?: string;    // For exempt/zero-rated checks
}

TaxCalculation Result

export interface TaxCalculation {
  taxConfigId: string;      // Reference to TaxConfig
  taxType: string;          // 'SST_SERVICE', 'GST', etc.
  taxRate: number;          // Rate at transaction time (snapshot)
  taxableAmount: number;    // Amount subject to tax
  taxAmount: number;        // Calculated tax
  direction: 'INPUT' | 'OUTPUT';  // INPUT = tax paid, OUTPUT = tax collected
  roundingAdjustment?: number;    // Track rounding differences
}

Calculation Rules

Tax-Inclusive vs Tax-Exclusive

// Tax Exclusive: Amount does NOT include tax
// Formula: tax = amount × rate
// Total = amount + tax

// Tax Inclusive: Amount INCLUDES tax
// Formula: tax = amount - (amount / (1 + rate))
// Taxable = amount / (1 + rate)

function calculateTax(amount: number, rate: number, isInclusive: boolean): {
  taxableAmount: number;
  taxAmount: number;
} {
  if (isInclusive) {
    const taxableAmount = amount / (1 + rate);
    const taxAmount = amount - taxableAmount;
    return { taxableAmount, taxAmount };
  } else {
    const taxAmount = amount * rate;
    return { taxableAmount: amount, taxAmount };
  }
}

Effective Date Handling (CRITICAL)

Example: Malaysia SST rate changed from 6% to 8% in March 2024.

// ALWAYS filter by effective date
const taxConfig = await prisma.taxConfig.findFirst({
  where: {
    countryCode: 'MY',
    taxType: 'SST_SERVICE',
    isActive: true,
    effectiveFrom: { lte: transactionDate },
    OR: [
      { effectiveTo: null },
      { effectiveTo: { gt: transactionDate } }
    ]
  },
  orderBy: { effectiveFrom: 'desc' }
});

// Result:
// - Transaction Jan 2024: Rate = 6%
// - Transaction March 2024: Rate = 8%

Why this matters: Audits will check historical transactions used correct rates.

Rounding Rules

Rounding varies by country — see @currency-rounding.md:

  • Malaysia (MYR): Round half-up to 2 decimal places
  • Singapore (SGD): Round half-up to 2 decimal places
  • Thailand (THB): Truncate to 2 decimal places
function roundTax(amount: number, countryCode: string): number {
  switch (countryCode) {
    case 'MY':
    case 'SG':
      return Math.round(amount * 100) / 100;
    case 'TH':
      return Math.trunc(amount * 100) / 100;
    default:
      return Math.round(amount * 100) / 100;
  }
}

Safety Rules (CRITICAL)

  1. NEVER hardcode rates

    • ❌ Bad: const tax = amount * 0.08
    • ✅ Good: Fetch from TaxConfig with effective date filter
  2. ALWAYS store snapshots

    • TaxLine must store taxRate at transaction time
    • Future rate changes must not affect historical transactions
  3. Effective dates matter

    • Example: Malaysia SST was 6% pre-March 2024, 8% after
    • Always filter by transactionDate when looking up rates
  4. Use async/await

    • TaxEngine.calculate() always returns Promise
    • No sync variants allowed
  5. Rounding varies by country

    • See @currency-rounding.md
    • Wrong rounding = wrong tax amounts = compliance issues
  6. Validate tax IDs

    • Use country-specific validation in validateRegistration()
    • Malaysia: SS-YYMM-NNNNNNNN format
    • Singapore: Valid GST registration number

Adding a New Country

Step-by-step guide for adding tax support for a new country:

Step 1: Research Tax Framework

Create a reference file at references/{country}-{tax}.md:

# {Country} {Tax}

## Framework
- Legal basis
- Tax authority
- Current rate(s)

## Registration
- Threshold
- Tax ID format
- Validation rules

## Filing
- Form name
- Frequency
- Due dates

## Special Rules
- Exemptions
- Zero-rated items
- Reverse charge
- WHT if applicable

See @thailand-vat.md for a skeleton template.

Step 2: Define TaxConfig Records

Add seed data for the country:

await prisma.taxConfig.create({
  data: {
    countryCode: 'TH',
    taxType: 'VAT',
    rate: 0.07,
    name: 'Thailand VAT',
    effectiveFrom: new Date('1992-01-01'),
    isActive: true,
    // GL account mappings
    outputTaxAccountId: '...',
    inputTaxAccountId: '...',
  }
});

Step 3: Implement TaxEngine

Create engine following the pattern in examples/malaysia-engine.ts:

export class ThailandVATEngine implements TaxEngine {
  countryCode = 'TH';
  
  async calculate(params: TaxCalculationParams): Promise<TaxCalculation[]> {
    // 1. Fetch applicable tax config with effective date
    // 2. Validate if transaction is taxable
    // 3. Calculate tax with correct rounding
    // 4. Return TaxCalculation array
  }
  
  validateRegistration(taxId: string): boolean {
    // Validate Thai VAT ID format
  }
  
  async getApplicableTaxes(context: TaxContext): Promise<TaxConfig[]> {
    // Return active VAT configs for Thailand
  }
}

Step 4: Register the Engine

Add to the tax engine registry:

import { ThailandVATEngine } from './engines/thailand-vat';

taxRegistry.register(new ThailandVATEngine());

Step 5: Add Test Cases

Add validation test cases to scripts/validate-tax-calc.ts:

{
  name: 'Thailand VAT 7% on 1000 THB',
  country: 'TH',
  taxType: 'VAT',
  amount: 1000,
  expectedTax: 70,
  expectedTotal: 1070,
}

Step 6: Test End-to-End

  1. Create a test bill/invoice with the new country's tax
  2. Verify TaxLine is created with correct amounts
  3. Verify GL entries are correct
  4. Run validation script

Testing Tax Calculations

Using the Validation Script

# Run all test cases
npx ts-node .claude/skills/tax-development/scripts/validate-tax-calc.ts

# Run specific test case
npx ts-node .claude/skills/tax-development/scripts/validate-tax-calc.ts --case="Malaysia SST 8%"

# Test custom scenario
npx ts-node .claude/skills/tax-development/scripts/validate-tax-calc.ts \
  --country=MY \
  --taxType=SST_SERVICE \
  --amount=1000 \
  --rate=0.08

Unit Test Patterns

describe('MalaysiaSSTEngine', () => {
  it('should calculate 8% SST on service amount', async () => {
    const engine = new MalaysiaSSTEngine();
    const result = await engine.calculate({
      amount: 1000,
      taxType: 'SST_SERVICE',
      isTaxInclusive: false,
      transactionDate: new Date('2024-06-01'), // Post-rate change
    });
    
    expect(result[0].taxRate).toBe(0.08);
    expect(result[0].taxAmount).toBe(80);
    expect(result[0].taxableAmount).toBe(1000);
  });
  
  it('should use 6% rate for pre-March 2024 transactions', async () => {
    const result = await engine.calculate({
      amount: 1000,
      taxType: 'SST_SERVICE',
      isTaxInclusive: false,
      transactionDate: new Date('2024-01-15'), // Pre-rate change
    });
    
    expect(result[0].taxRate).toBe(0.06);
    expect(result[0].taxAmount).toBe(60);
  });
});

References

  • @malaysia-sst.md — Malaysia SST detailed rules (🟢 A - Verified)
  • @singapore-gst.md — Singapore GST detailed rules (🟢 A - Verified)
  • @thailand-vat.md — Thailand VAT skeleton (🟠 C - Incomplete)
  • @tax-engine-patterns.md — Architecture patterns
  • @currency-rounding.md — Rounding rules by country
  • @confidence-framework.md — Source trustworthiness & confidence ratings
  • @third-party-apis.md — External tax API services (Avalara, Stripe Tax, etc.)
  • @free-tax-apis.md — Free and low-cost alternatives ($0-$9/month options)
  • @github-tax-repositories.md — Open-source libraries (node-sales-tax, etc.)
  • @scope-income-tax.md — Why income tax is out of scope

Guides

  • guides/adding-sweden-vat.md — Example: Adding a new country (Sweden VAT)

Examples

  • examples/types.ts — TaxEngine interface definitions
  • examples/registry.ts — Tax engine registry pattern
  • examples/malaysia-engine.ts — Complete Malaysia implementation

Scripts

  • scripts/validate-tax-calc.ts — Tax calculation validation tool