Skip to content

Tax System

1. Overview

PropertyValue
StatusStable (canonical pricing input)
Ownerpricing-team
ServicesTaxSetService (CRUD), PricingTaxCalculatorService (v1), TaxCalculatorService (v2)
Controllers / RoutesTaxController /taxes, TaxSetController /tax-sets, TaxTypeController /tax-types
Depends onTax, TaxSet, TaxType schemas (@nx/core)

Manages tax calculation through TaxSets (container per variant or merchant), Taxes (percentage / fixed / per-unit entries), and TaxTypes (system or merchant-scoped categories like VAT). Taxes apply in priority order with temporal windows, inclusive/exclusive calc, compound tax-on-tax, quantity conditions, and order-level taxes for merchant-scoped sets.

Service catalogue and identity card: see Pricing Service index. REST reference: live at /v1/api/pricing/doc/openapi.json.

2. Data Model

2.1. Tax Modes

ModepercentageamountCalculation
Percentageset (e.g. 0.1)nulltax = taxableAmount × percentage
Fixednullset (e.g. 5000)tax = amount
Combinedsetsettax = (taxableAmount × percentage) + amount

2.2. TaxType Scoping

ScopemerchantIdDescription
SystemnullPlatform-wide tax types (VAT, GST) — from seeded data
MerchantsetCustom tax types created by a specific merchant

2.3. Inclusive vs Exclusive Taxes

TypeisInclusiveBehavior
Exclusivefalse (default)Tax added on top of price — increases total
InclusivetrueTax embedded in price — back-calculated, does not increase total

Inclusive back-calculation formulas:

ModeFormula
PercentagetaxAmount = price − (price / (1 + rate))
Fixed amounttaxAmount = fixedAmount (deducted from price, not additional)

Note: Inclusive taxes only apply at ITEM scope.

2.4. Tax Scope

Each tax has a scope field that controls where it is applied:

ScopeApplied toAllowed on
ITEM (default)Each product variant's subtotalAny TaxSet
ORDERTotal order subtotal after all items are pricedMerchant TaxSets only

Constraint: ORDER-scoped taxes must be exclusive (isInclusive = false). The system throws a validation error if this rule is violated.

2.5. Tax Behavior Flags

FieldTypeDefaultDescription
isInclusivebooleanfalseTax embedded in price (inclusive) or added on top (exclusive)
isCompoundbooleantrueCompounds on cumulative tax from previous priority groups
shouldApplyOnDiscountedbooleantrueUse discounted subtotal as base (true) or original subtotal (false)
scopeITEM | ORDERITEMWhether applied at item level or order level
minQuantityinteger?nullSkip this tax if item quantity is below this threshold
maxQuantityinteger?nullSkip this tax if item quantity exceeds this threshold

2.6. Quantity-Based Conditions

Taxes can be conditionally skipped based on the order quantity:

ConditionOutcome
minQuantity set and quantity < minQuantityTax is skipped
maxQuantity set and quantity > maxQuantityTax is skipped
Neither setTax always applies

3. Tax Calculation Flow

3.1. Calculation Result

typescript
interface TaxCalculationResult {
  taxSetId: string;
  totalTax: string;                // Sum of all applied exclusive taxes
  appliedTaxes: AppliedTaxResponse[];
}

interface AppliedTaxResponse {
  taxId: string;
  amount: string;                  // Individual tax amount
  taxableBase: string;             // Base amount used for calculation
  taxTypeId: string;
  isInclusive: boolean;            // Whether the tax is inclusive
  isVat: boolean;                  // true if taxType.type === 'VAT'
  isCompound: boolean;             // Whether the tax compounds on previous groups
}

3.2. Order-Level Taxes

After all item-level taxes are calculated, the system applies order-level taxes when a merchantId is provided in the context:

OrderTaxesResponse structure:

typescript
interface OrderTaxesResponse {
  totalOrderTax: string;
  totalExclusiveOrderTax: string;
  totalInclusiveOrderTax: string;    // Always "0.0000" — ORDER taxes must be exclusive
  appliedOrderTaxes: AppliedTaxResponse[];
}

Note: totalInclusiveOrderTax is always "0.0000" because ORDER-scoped taxes are required to be exclusive (isInclusive = false).

4. Automatic VAT Setup

When a product variant is created, the tax system automatically creates a TaxSet with default 10% VAT:

5. Repository Operations

5.1. TaxRepository

MethodDescription
findActivated()Load taxes by taxSetId, filter by effectiveDate, order by priority
findByTaxTypeId()Find taxes using a specific tax type
findFutureTaxes()Find taxes with effectiveFrom in the future
findExpiredTaxes()Find taxes with effectiveTo in the past

5.2. TaxSetRepository

MethodDescription
findActivated()Find activated TaxSet for product variant (strict/non-strict)
findActiveTaxSetByPrincipal()Find by principalId + principalType with taxes included
deactivateAllForPrincipal()Deactivate all TaxSets before activating new one

5.3. TaxTypeRepository

MethodDescription
findSystemTaxTypes()System types (merchantId = null)
findByMerchantId()Merchant-specific types
findAvailableTaxTypes()System + merchant types combined

6. Controller API

TaxController /taxes and TaxTypeController /tax-types are standard CRUD. TaxSetController /tax-sets adds a custom PATCH /{id}/aggregate (update set + its taxes in one call). Full request/response reference is rendered live from /v1/api/pricing/doc/openapi.json — not hand-maintained here.


7. Practical Examples

7.1. Scenario 1: Single Percentage Tax (VAT)

Business Rule: "Apply 10% VAT to all products"

Step 1: Create Tax Set (Automatic on Product Variant Creation)

When a product variant is created, the system automatically creates:

  • TaxSet with ACTIVATED status
  • Default VAT tax with percentage: 0.1 (10%)

Step 2: Calculate Tax

typescript
// Internal API call from PricingService
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-001',
  taxableAmount: '100000',  // Base price after discount
  context: {
    traceId: 'trace-001',
    calculatingAt: '2026-02-25T10:00:00Z'
  }
});

Result:

json
{
  "taxSetId": "taxset-001",
  "totalTax": "10000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "10000.0000"
    }
  ]
}

Calculation: 100,000 × 0.1 = 10,000


7.2. Scenario 2: Fixed Amount Tax (Service Fee)

Business Rule: "Add 5,000 VND service fee to all purchases"

Step 1: Create Service Fee Tax

http
POST /taxes
Content-Type: application/json
Authorization: Bearer <token>

{
  "taxSetId": "taxset-001",
  "taxTypeId": "taxtype-service-fee",
  "amount": "5000",
  "percentage": null,
  "effectiveFrom": "2026-02-01T00:00:00Z",
  "effectiveTo": null,
  "priority": 1,
  "status": "ACTIVATED"
}

Step 2: Calculate Tax with VAT + Service Fee

typescript
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-001',
  taxableAmount: '100000',
  context: { traceId: 'trace-002' }
});

Result:

json
{
  "taxSetId": "taxset-001",
  "totalTax": "15000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "10000.0000"
    },
    {
      "taxId": "tax-service-fee-001",
      "amount": "5000.0000"
    }
  ]
}

Calculation:

  • VAT: 100,000 × 0.1 = 10,000
  • Service Fee: 5,000 (fixed)
  • Total: 10,000 + 5,000 = 15,000

7.3. Scenario 3: Combined Tax (Percentage + Fixed)

Business Rule: "Apply 8% luxury tax + 10,000 VND environmental fee on premium items"

Step 1: Create Combined Tax

http
POST /taxes
Content-Type: application/json

{
  "taxSetId": "taxset-premium-001",
  "taxTypeId": "taxtype-luxury",
  "percentage": "0.08",
  "amount": "10000",
  "effectiveFrom": "2026-01-01T00:00:00Z",
  "priority": 2,
  "status": "ACTIVATED"
}

Step 2: Calculate Tax (VAT + Luxury + Environmental)

typescript
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-premium-001',
  taxableAmount: '500000',
  context: { traceId: 'trace-003' }
});

Result:

json
{
  "taxSetId": "taxset-premium-001",
  "totalTax": "100000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "50000.0000"
    },
    {
      "taxId": "tax-luxury-001",
      "amount": "50000.0000"
    }
  ]
}

Calculation:

  • VAT (priority 0): 500,000 × 0.1 = 50,000
  • Luxury + Environmental (priority 2): (500,000 × 0.08) + 10,000 = 40,000 + 10,000 = 50,000
  • Total: 50,000 + 50,000 = 100,000

7.4. Scenario 4: Tax-Inclusive vs Tax-Exclusive Pricing

Business Rule: "Display price includes VAT (tax-inclusive), but calculate tax separately for reporting"

Tax-Exclusive Approach (Current Implementation):

typescript
// 1. Calculate fare (base price)
const fareResult = await pricingFareCalculatorService.selectFare({
  productVariantId: 'pv-001',
  context: { quantity: '1' }
});
// fareResult.selectedFare.amount = '100000'

// 2. Calculate tax on fare amount
const taxResult = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-001',
  taxableAmount: '100000',  // Use fare amount as taxable base
  context: { traceId: 'trace-004' }
});
// taxResult.totalTax = '10000'

// 3. Final price
const finalPrice = {
  subtotal: '100000',      // Pre-tax amount
  tax: '10000',            // Tax amount
  total: '110000'          // Customer pays this
};

Tax-Inclusive Approach (Reverse Calculation):

typescript
// Given: Tax-inclusive price = 110,000 VND (includes 10% VAT)
// Calculate: Tax amount and pre-tax amount

const taxInclusivePrice = 110000;
const vatRate = 0.1;

// Reverse formula: preTaxAmount = inclusivePrice / (1 + vatRate)
const preTaxAmount = taxInclusivePrice / (1 + vatRate);
// preTaxAmount = 110,000 / 1.1 = 100,000

const taxAmount = taxInclusivePrice - preTaxAmount;
// taxAmount = 110,000 - 100,000 = 10,000

const breakdown = {
  displayPrice: '110000',   // What customer sees
  subtotal: '100000',       // Pre-tax amount
  tax: '10000',             // Tax amount
  total: '110000'           // Same as display price
};

7.5. Scenario 5: Priority-Based Tax Ordering

Business Rule: "Apply taxes in specific order: VAT (priority 0) → Service fee (priority 1) → Luxury tax (priority 2)"

Step 1: Create Taxes with Different Priorities

http
# VAT (highest priority)
POST /taxes
{
  "taxSetId": "taxset-001",
  "taxTypeId": "taxtype-vat",
  "percentage": "0.1",
  "priority": 0,
  "status": "ACTIVATED"
}

# Service Fee (medium priority)
POST /taxes
{
  "taxSetId": "taxset-001",
  "taxTypeId": "taxtype-service",
  "amount": "5000",
  "priority": 1,
  "status": "ACTIVATED"
}

# Luxury Tax (lowest priority)
POST /taxes
{
  "taxSetId": "taxset-001",
  "taxTypeId": "taxtype-luxury",
  "percentage": "0.05",
  "priority": 2,
  "status": "ACTIVATED"
}

Step 2: Calculate Tax (Applied in Priority Order)

typescript
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-001',
  taxableAmount: '200000',
  context: { traceId: 'trace-005' }
});

Result:

json
{
  "taxSetId": "taxset-001",
  "totalTax": "35000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "20000.0000"
    },
    {
      "taxId": "tax-service-001",
      "amount": "5000.0000"
    },
    {
      "taxId": "tax-luxury-001",
      "amount": "10000.0000"
    }
  ]
}

Calculation (in priority order):

  1. VAT (priority 0): 200,000 × 0.1 = 20,000
  2. Service Fee (priority 1): 5,000 (fixed)
  3. Luxury Tax (priority 2): 200,000 × 0.05 = 10,000
  4. Total: 20,000 + 5,000 + 10,000 = 35,000

Note: Same-priority taxes share the same cumulative base. Taxes with isCompound = true compound on the cumulative tax from all lower-priority groups. See Scenario 7.10 for a full example.


7.6. Scenario 6: Temporal Tax Changes (Tax Rate Increase)

Business Rule: "VAT increases from 10% to 12% starting April 1, 2026"

Step 1: Update Existing VAT Tax to Expire

http
PUT /taxes/tax-vat-001
Content-Type: application/json

{
  "effectiveTo": "2026-03-31T23:59:59Z"
}

Step 2: Create New VAT Tax with Future Effective Date

http
POST /taxes
Content-Type: application/json

{
  "taxSetId": "taxset-001",
  "taxTypeId": "taxtype-vat",
  "percentage": "0.12",
  "effectiveFrom": "2026-04-01T00:00:00Z",
  "effectiveTo": null,
  "priority": 0,
  "status": "ACTIVATED"
}

Step 3: Calculate Tax Before Transition (March 30, 2026)

typescript
const resultBefore = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-001',
  taxableAmount: '100000',
  context: {
    traceId: 'trace-006',
    calculatingAt: '2026-03-30T10:00:00Z'
  }
});

Result:

json
{
  "taxSetId": "taxset-001",
  "totalTax": "10000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "10000.0000"
    }
  ]
}

Calculation: 100,000 × 0.1 = 10,000 (old rate)


Step 4: Calculate Tax After Transition (April 2, 2026)

typescript
const resultAfter = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-001',
  taxableAmount: '100000',
  context: {
    traceId: 'trace-007',
    calculatingAt: '2026-04-02T10:00:00Z'
  }
});

Result:

json
{
  "taxSetId": "taxset-001",
  "totalTax": "12000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-002",
      "amount": "12000.0000"
    }
  ]
}

Calculation: 100,000 × 0.12 = 12,000 (new rate)


7.7. Scenario 7: Merchant-Specific Custom Tax

Business Rule: "Merchant 'ABC Corp' adds custom 2% handling fee to their products"

Step 1: Create Merchant-Specific Tax Type

http
POST /tax-types
Content-Type: application/json
Authorization: Bearer <merchant-abc-token>

{
  "type": "HANDLING_FEE",
  "name": "ABC Handling Fee",
  "merchantId": "merchant-abc-001",
  "status": "ACTIVATED"
}

Response:

json
{
  "id": "taxtype-abc-handling",
  "type": "HANDLING_FEE",
  "merchantId": "merchant-abc-001",
  "status": "ACTIVATED"
}

Step 2: Add Custom Tax to Product Variant

http
POST /taxes
Content-Type: application/json

{
  "taxSetId": "taxset-abc-product-001",
  "taxTypeId": "taxtype-abc-handling",
  "percentage": "0.02",
  "priority": 3,
  "status": "ACTIVATED"
}

Step 3: Calculate Tax (System VAT + Merchant Handling Fee)

typescript
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-abc-001',
  taxableAmount: '150000',
  context: { traceId: 'trace-008' }
});

Result:

json
{
  "taxSetId": "taxset-abc-product-001",
  "totalTax": "18000.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "15000.0000"
    },
    {
      "taxId": "tax-abc-handling-001",
      "amount": "3000.0000"
    }
  ]
}

Calculation:

  • System VAT (priority 0): 150,000 × 0.1 = 15,000
  • Merchant Handling Fee (priority 3): 150,000 × 0.02 = 3,000
  • Total: 15,000 + 3,000 = 18,000

7.8. Scenario 8: Error Handling - Invalid Tax Configuration

Business Rule: "Tax MUST have either percentage OR amount (or both)"

Invalid Tax Creation Attempt:

http
POST /taxes
Content-Type: application/json

{
  "taxSetId": "taxset-001",
  "taxTypeId": "taxtype-invalid",
  "percentage": null,
  "amount": null,
  "status": "ACTIVATED"
}

Calculation Attempt:

typescript
try {
  const result = await pricingTaxCalculatorService.calculateTax({
    productVariantId: 'pv-001',
    taxableAmount: '100000',
    context: { traceId: 'trace-009' }
  });
} catch (error) {
  console.error(error);
}

Error Response:

json
{
  "statusCode": 500,
  "message": "[PricingTaxCalculatorService][_validateTaxConfiguration] Invalid tax configuration: tax must have either percentage or amount value | Tax ID: tax-invalid-001 | Tax Set ID: taxset-001",
  "details": {
    "taxId": "tax-invalid-001",
    "taxSetId": "taxset-001",
    "percentage": null,
    "amount": null
  }
}

7.9. Scenario 9: Inclusive VAT

Business Rule: "Product is priced at 110,000 VND including 10% VAT — extract the embedded tax for reporting"

The tax has isInclusive: true and percentage: 0.1. The system back-calculates the embedded tax without increasing the total.

Tax configuration:

http
POST /taxes
Content-Type: application/json

{
  "taxSetId": "taxset-inclusive-001",
  "taxTypeId": "taxtype-vat",
  "percentage": "0.1",
  "isInclusive": true,
  "priority": 0,
  "status": "ACTIVATED"
}

Calculate tax on the inclusive price:

typescript
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-inclusive-001',
  taxableAmount: '110000',  // Display price (already includes VAT)
  context: { traceId: 'trace-009' }
});

Result:

json
{
  "taxSetId": "taxset-inclusive-001",
  "totalTax": "0.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-inclusive-001",
      "amount": "10000.0000",
      "taxableBase": "110000.0000",
      "isInclusive": true,
      "isVat": true,
      "isCompound": true
    }
  ]
}

Calculation: 110,000 − (110,000 / (1 + 0.1)) = 110,000 − 100,000 = 10,000

Important: totalTax is 0 because inclusive taxes do NOT add to the total — the tax is already embedded in the display price. The amount in appliedTaxes is provided for reporting/breakdown purposes only.


7.10. Scenario 10: Compound Tax (Tax-on-Tax)

Business Rule: "Apply 10% VAT (priority 0), then 2% service charge on the price including VAT (priority 1, compounded)"

TaxPriorityRateisCompound
VAT010%true
Service Charge12%true

Tax configurations:

http
# VAT — priority 0
POST /taxes
{
  "taxSetId": "taxset-compound-001",
  "taxTypeId": "taxtype-vat",
  "percentage": "0.1",
  "isCompound": true,
  "priority": 0,
  "status": "ACTIVATED"
}

# Service Charge — priority 1, compounds on VAT
POST /taxes
{
  "taxSetId": "taxset-compound-001",
  "taxTypeId": "taxtype-service",
  "percentage": "0.02",
  "isCompound": true,
  "priority": 1,
  "status": "ACTIVATED"
}

Calculate compound tax:

typescript
const result = await pricingTaxCalculatorService.calculateTax({
  productVariantId: 'pv-compound-001',
  taxableAmount: '100000',
  context: { traceId: 'trace-010' }
});

Result:

json
{
  "taxSetId": "taxset-compound-001",
  "totalTax": "12200.0000",
  "appliedTaxes": [
    {
      "taxId": "tax-vat-001",
      "amount": "10000.0000",
      "taxableBase": "100000.0000",
      "isInclusive": false,
      "isVat": true,
      "isCompound": true
    },
    {
      "taxId": "tax-service-001",
      "amount": "2200.0000",
      "taxableBase": "110000.0000",
      "isInclusive": false,
      "isVat": false,
      "isCompound": true
    }
  ]
}

Calculation (compound algorithm):

  1. Priority 0 — VAT: taxableBase = 100,000 (base, no prior cumulative) → tax = 100,000 × 0.1 = 10,000cumulativeTax = 10,000
  2. Priority 1 — Service Charge: isCompound = truetaxableBase = 100,000 + 10,000 = 110,000tax = 110,000 × 0.02 = 2,200
  3. Total: 10,000 + 2,200 = 12,200

7.11. Scenario 11: Order-Level Tax (Merchant Service Fee)

Business Rule: "Merchant applies a 1% platform service fee on the full order subtotal after all item taxes"

The merchant has a TaxSet with an ORDER-scoped tax. After all items are priced, the system calculates this fee on the order subtotal.

Merchant TaxSet tax configuration:

http
POST /taxes
Content-Type: application/json

{
  "taxSetId": "taxset-merchant-order-001",
  "taxTypeId": "taxtype-platform-fee",
  "percentage": "0.01",
  "scope": "ORDER",
  "isInclusive": false,
  "priority": 0,
  "status": "ACTIVATED"
}

Simulation context with merchantId:

typescript
const result = await simulationService.calculate({
  items: [/* ... */],
  context: {
    merchantId: 'merchant-abc-001',   // Triggers order-level tax lookup
    traceId: 'trace-011'
  }
});

Order-level tax result (SimulationCalculateResponse.orderTaxes):

json
{
  "totalOrderTax": "5000.0000",
  "totalExclusiveOrderTax": "5000.0000",
  "totalInclusiveOrderTax": "0.0000",
  "appliedOrderTaxes": [
    {
      "taxId": "tax-platform-fee-001",
      "amount": "5000.0000",
      "taxableBase": "500000.0000",
      "isInclusive": false,
      "isVat": false,
      "isCompound": true
    }
  ]
}

Calculation: orderSubtotal (500,000) × 0.01 = 5,000

Note: totalInclusiveOrderTax is always "0.0000" because ORDER-scoped taxes must be exclusive by design.


Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.