Tax System Overview¶
Merchello's tax system is designed to handle everything from simple single-rate VAT to complex multi-jurisdiction sales tax with external providers like Avalara. At its core, the system revolves around Tax Groups, Tax Group Rates, and a clear rate lookup chain.
Core Concepts¶
Tax Groups¶
A Tax Group represents a category of taxation. You might have:
- Standard Rate (20% in the UK, varies by US state)
- Reduced Rate (5% for children's clothing in some jurisdictions)
- Zero Rate (0% for books in many EU countries)
- Exempt (0% for certain medical supplies)
Each tax group has:
| Property | Description |
|---|---|
Id |
Unique identifier |
Name |
Display name (e.g., "Standard Rate") |
TaxPercentage |
Default rate (0-100) used when no location-specific rate exists |
Products are linked to tax groups through ProductRoot.TaxGroupId. This is important because the tax group ID flows through the entire order pipeline -- from basket line items to invoice tax calculations.
Invariant (CLAUDE.md):
TaxGroupIdmust be preserved fromProductRoot-> basket line item -> order line item ->TaxableLineItempayload sent to the provider. External providers (Avalara, TaxJar, etc.) rely on this mapping to select the correct tax code.
Tax Group Rates (Geographic Overrides)¶
The default rate on a tax group is just the fallback. You can define location-specific rates using Tax Group Rates:
Tax Group: "Standard Rate" (default: 20%)
├── GB (country): 20%
├── US (country): no override (different states have different rates)
│ ├── US-CA (state): 7.25%
│ ├── US-NY (state): 8.0%
│ └── US-TX (state): 6.25%
└── DE (country): 19%
Each rate has:
- TaxGroupId -- which tax group it belongs to
- CountryCode -- ISO country code (e.g., "US", "GB")
- RegionCode -- state/province code (optional, e.g., "CA", "NY")
- TaxPercentage -- the rate for this location (0-100)
Rate Lookup Chain¶
When Merchello needs to calculate tax for a product, it uses ITaxService.GetApplicableRateAsync() which follows a strict priority:
1. State-specific rate --> Found? Use it.
2. Country-level rate --> Found? Use it.
3. TaxGroup default rate --> Always available as fallback.
For example, if a customer in California buys a product in the "Standard Rate" tax group:
- Look for a rate with
CountryCode = "US"andRegionCode = "CA"-- found 7.25%, use that.
If a customer in Florida buys the same product but there is no Florida rate:
- Look for
US+FL-- not found. - Look for
USwith no region -- found 6%, use that.
If a customer in a country with no overrides at all:
- State lookup -- nothing.
- Country lookup -- nothing.
- Fall back to the tax group's default
TaxPercentage.
Tip: Rates are cached for 5 minutes using the
ICacheServicewith the tag"tax". When you update rates through the API, the cache is automatically invalidated.
Tax-Inclusive vs Tax-Exclusive Pricing¶
Merchello supports both pricing models, controlled by your store settings:
- Tax-exclusive (default): Prices are shown without tax. Tax is added at checkout.
- Tax-inclusive: Prices include tax. The tax amount is calculated backwards from the displayed price.
The checkout basket DTO includes reactive fields for tax-inclusive display:
- DisplayPricesIncTax
- TaxInclusiveDisplaySubTotal
- FormattedTaxInclusiveDisplaySubTotal
- TaxIncludedMessage
How Tax Flows Through the System¶
- Product setup: You assign a
TaxGroupIdto eachProductRoot. - Basket creation: When a product is added to the basket, the
TaxGroupIdis captured on the line item by the line-item factory. - Checkout calculation:
CheckoutService.CalculateBasketAsync()triggers tax calculation. - Tax orchestration:
ITaxOrchestrationServicecoordinates with the active tax provider (TaxOrchestrationService). - Rate application: The provider (manual or external) returns rates per line item.
- Invoice creation: Tax amounts are locked into the invoice.
Invariant (CLAUDE.md): Controllers must never call a tax provider directly. Always go through
ITaxOrchestrationServiceorCheckoutService. The orchestration layer handles provider selection, fallback behavior, and caching. OnlyProduct,Custom, andAddonline types are sent to providers --Discountlines are not sent directly.
Managing Tax Groups via API¶
Create a tax group¶
POST /umbraco/api/v1/tax-groups
Content-Type: application/json
{
"name": "Standard Rate",
"rate": 20
}
Add a geographic rate¶
POST /umbraco/api/v1/tax-groups/{taxGroupId}/rates
Content-Type: application/json
{
"countryCode": "US",
"regionCode": "CA",
"taxPercentage": 7.25
}
Update a rate¶
PUT /umbraco/api/v1/tax-groups/rates/{rateId}
Content-Type: application/json
{
"taxPercentage": 8.0
}
Delete a tax group¶
You can only delete a tax group if no products are using it. If products reference the tax group, you will get an error.
Notifications¶
Tax group operations fire notifications that you can hook into:
| Operation | Before (Cancelable) | After (Informational) |
|---|---|---|
| Create | TaxGroupCreatingNotification |
TaxGroupCreatedNotification |
| Update | TaxGroupSavingNotification |
TaxGroupSavedNotification |
| Delete | TaxGroupDeletingNotification |
TaxGroupDeletedNotification |
Key Service Methods¶
| Method | Description |
|---|---|
TaxService.GetApplicableRateAsync() |
The primary rate lookup (follows the 3-tier chain) |
TaxService.GetTaxGroups() |
List all tax groups |
TaxService.GetRatesForTaxGroup() |
Get all geographic rates for a tax group |
TaxService.CreateTaxGroup() |
Create a new tax group |
TaxService.CreateTaxGroupRate() |
Add a location-specific rate |
Next Steps¶
- Shipping Tax -- how tax is applied to shipping costs
- Tax Providers -- manual rates vs Avalara AvaTax