Tax System
1. Overview
| Property | Value |
|---|---|
| Status | Stable (canonical pricing input) |
| Owner | pricing-team |
| Services | TaxSetService (CRUD), PricingTaxCalculatorService (v1), TaxCalculatorService (v2) |
| Controllers / Routes | TaxController /taxes, TaxSetController /tax-sets, TaxTypeController /tax-types |
| Depends on | Tax, 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
| Mode | percentage | amount | Calculation |
|---|---|---|---|
| Percentage | set (e.g. 0.1) | null | tax = taxableAmount × percentage |
| Fixed | null | set (e.g. 5000) | tax = amount |
| Combined | set | set | tax = (taxableAmount × percentage) + amount |
2.2. TaxType Scoping
| Scope | merchantId | Description |
|---|---|---|
| System | null | Platform-wide tax types (VAT, GST) — from seeded data |
| Merchant | set | Custom tax types created by a specific merchant |
2.3. Inclusive vs Exclusive Taxes
| Type | isInclusive | Behavior |
|---|---|---|
| Exclusive | false (default) | Tax added on top of price — increases total |
| Inclusive | true | Tax embedded in price — back-calculated, does not increase total |
Inclusive back-calculation formulas:
| Mode | Formula |
|---|---|
| Percentage | taxAmount = price − (price / (1 + rate)) |
| Fixed amount | taxAmount = fixedAmount (deducted from price, not additional) |
Note: Inclusive taxes only apply at
ITEMscope.
2.4. Tax Scope
Each tax has a scope field that controls where it is applied:
| Scope | Applied to | Allowed on |
|---|---|---|
ITEM (default) | Each product variant's subtotal | Any TaxSet |
ORDER | Total order subtotal after all items are priced | Merchant 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
| Field | Type | Default | Description |
|---|---|---|---|
isInclusive | boolean | false | Tax embedded in price (inclusive) or added on top (exclusive) |
isCompound | boolean | true | Compounds on cumulative tax from previous priority groups |
shouldApplyOnDiscounted | boolean | true | Use discounted subtotal as base (true) or original subtotal (false) |
scope | ITEM | ORDER | ITEM | Whether applied at item level or order level |
minQuantity | integer? | null | Skip this tax if item quantity is below this threshold |
maxQuantity | integer? | null | Skip this tax if item quantity exceeds this threshold |
2.6. Quantity-Based Conditions
Taxes can be conditionally skipped based on the order quantity:
| Condition | Outcome |
|---|---|
minQuantity set and quantity < minQuantity | Tax is skipped |
maxQuantity set and quantity > maxQuantity | Tax is skipped |
| Neither set | Tax always applies |
3. Tax Calculation Flow
3.1. Calculation Result
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:
interface OrderTaxesResponse {
totalOrderTax: string;
totalExclusiveOrderTax: string;
totalInclusiveOrderTax: string; // Always "0.0000" — ORDER taxes must be exclusive
appliedOrderTaxes: AppliedTaxResponse[];
}Note:
totalInclusiveOrderTaxis 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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
ACTIVATEDstatus - Default VAT tax with
percentage: 0.1(10%)
Step 2: Calculate Tax
// 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:
{
"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
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
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-001',
taxableAmount: '100000',
context: { traceId: 'trace-002' }
});Result:
{
"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
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)
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-premium-001',
taxableAmount: '500000',
context: { traceId: 'trace-003' }
});Result:
{
"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):
// 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):
// 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
# 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)
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-001',
taxableAmount: '200000',
context: { traceId: 'trace-005' }
});Result:
{
"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):
- VAT (priority 0):
200,000 × 0.1 = 20,000 - Service Fee (priority 1):
5,000(fixed) - Luxury Tax (priority 2):
200,000 × 0.05 = 10,000 - 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
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
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)
const resultBefore = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-001',
taxableAmount: '100000',
context: {
traceId: 'trace-006',
calculatingAt: '2026-03-30T10:00:00Z'
}
});Result:
{
"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)
const resultAfter = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-001',
taxableAmount: '100000',
context: {
traceId: 'trace-007',
calculatingAt: '2026-04-02T10:00:00Z'
}
});Result:
{
"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
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:
{
"id": "taxtype-abc-handling",
"type": "HANDLING_FEE",
"merchantId": "merchant-abc-001",
"status": "ACTIVATED"
}Step 2: Add Custom Tax to Product Variant
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)
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-abc-001',
taxableAmount: '150000',
context: { traceId: 'trace-008' }
});Result:
{
"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:
POST /taxes
Content-Type: application/json
{
"taxSetId": "taxset-001",
"taxTypeId": "taxtype-invalid",
"percentage": null,
"amount": null,
"status": "ACTIVATED"
}Calculation Attempt:
try {
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-001',
taxableAmount: '100000',
context: { traceId: 'trace-009' }
});
} catch (error) {
console.error(error);
}Error Response:
{
"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:
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:
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-inclusive-001',
taxableAmount: '110000', // Display price (already includes VAT)
context: { traceId: 'trace-009' }
});Result:
{
"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:
totalTaxis0because inclusive taxes do NOT add to the total — the tax is already embedded in the display price. TheamountinappliedTaxesis 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)"
| Tax | Priority | Rate | isCompound |
|---|---|---|---|
| VAT | 0 | 10% | true |
| Service Charge | 1 | 2% | true |
Tax configurations:
# 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:
const result = await pricingTaxCalculatorService.calculateTax({
productVariantId: 'pv-compound-001',
taxableAmount: '100000',
context: { traceId: 'trace-010' }
});Result:
{
"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):
- Priority 0 — VAT:
taxableBase = 100,000(base, no prior cumulative) →tax = 100,000 × 0.1 = 10,000→cumulativeTax = 10,000 - Priority 1 — Service Charge:
isCompound = true→taxableBase = 100,000 + 10,000 = 110,000→tax = 110,000 × 0.02 = 2,200 - 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:
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:
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):
{
"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:
totalInclusiveOrderTaxis always"0.0000"because ORDER-scoped taxes must be exclusive by design.
8. Related Documentation
- Pricing Service — Package overview
- Fare System — Fare calculation
- Cost Tracking — Cost management
- Promotions — Promotion system