- Published on
Building a Low-Latency GST Tax Engine: Rules, Caching, and Edge Cases at Cogoport
- Authors

- Name
- Anil Jaiswal
- @anil_jaiswal
The tax engine was the service I was most nervous about. Every other service in the platform could have a bug and we'd catch it, fix it, and move on. A wrong GST rate is different. It goes out on a signed invoice. The buyer files their returns based on it. If the rate was wrong, we've created a compliance problem for both Cogoport and the buyer. Get enough of those wrong and the GST department comes knocking.
The requirements were strict. The engine had to respond in under 20ms on the critical path of invoice creation. It had to be available even if the database was having a bad day. And the rules had to be updatable without a service restart because the GST Council changes rates, new HSN codes get added, and SEZ area notifications come in without warning.
This is how we built it.
What correct means in Indian GST
Indian GST looks simple on paper. There are five tax slabs: 0%, 5%, 12%, 18%, and 28%. But the details underneath are where the complexity lives.
The rate depends on the HSN code. HSN (Harmonized System of Nomenclature) codes classify goods. SAC codes classify services. The rate you charge depends on which code applies to what you're selling. Cogoport dealt in freight services, so the codes were mostly SAC. But vendor invoices coming into the platform covered everything from shipping containers to customs clearance to warehouse rent, each with its own code.
CGST, SGST, or IGST depends on where the supply happens. If the supplier and buyer are in the same state, the tax splits into CGST (central) and SGST (state), each at half the total rate. If they're in different states, the full rate goes as IGST. So an 18% rate becomes either 9% CGST + 9% SGST or 18% IGST depending on the state codes on both sides.
Exports and SEZ supplies are zero-rated. Goods and services going outside India, or to a Special Economic Zone unit, attract 0% GST. But there are two ways to handle this: export under a Letter of Undertaking (LUT) and pay zero tax, or pay IGST and claim a refund later. We needed to handle both.
Reverse Charge Mechanism (RCM) flips who pays. For certain categories, the buyer pays the GST instead of the seller. This shows up a lot in vendor invoices: import of services, goods transport agencies, and a few other categories. The invoice looks different and the accounting treatment is different.
Union territories use UTGST, not SGST. Delhi, Chandigarh, Dadra and Nagar Haveli, and a few others are union territories, not states. Intra-UT supplies use UTGST instead of SGST. The rate is the same; the tax head is different.
Each of these rules could change. The GST Council meets periodically. Rates get revised, exemptions get added, and new categories get clarified. When a change happens, we needed it live within minutes, not in the next deployment.
First cut: a DB query per invoice line
The first version was straightforward. Each invoice line item triggered a database query to find the applicable tax rule.
@Singleton
class TaxEngine(private val ruleRepository: TaxRuleRepository) {
fun calculate(request: TaxCalculationRequest): TaxBreakdown {
val supplyType = determineSupplyType(request)
val rule = ruleRepository.findApplicableRule(
hsnCode = request.hsnCode,
supplyType = supplyType,
onDate = request.invoiceDate,
) ?: throw TaxRuleNotFoundException(request.hsnCode, supplyType)
return computeBreakdown(request.taxableAmount, rule, supplyType)
}
}
The repository did a parameterised query:
@JdbcRepository(dialect = Dialect.POSTGRES)
interface TaxRuleRepository : CrudRepository<TaxRule, UUID> {
@Query("""
SELECT * FROM tax_rules
WHERE (hsn_code = :hsnCode OR :hsnCode LIKE hsn_code || '%')
AND supply_type = :supplyType
AND effective_from <= :onDate
AND (effective_to IS NULL OR effective_to >= :onDate)
ORDER BY LENGTH(hsn_code) DESC, effective_from DESC
LIMIT 1
""")
fun findApplicableRule(hsnCode: String, supplyType: String, onDate: LocalDate): TaxRule?
}
This worked in development. In production it fell apart fast. A typical invoice had 5-15 line items. Each line was a separate query. Under bulk upload, where customers pushed hundreds of invoices at once, we were firing thousands of queries per second at a table with 3,000+ rules.
Average tax calculation latency: 8.4ms per line item. For a 10-line invoice, that's 84ms just on tax. Invoice creation felt slow. The database connection pool started backing up under load.
The fix was obvious: the rules table is small, relatively static, and read constantly. It should live in memory.
In-memory rule cache
We loaded the entire rules table into a ConcurrentHashMap on startup. The map key was the HSN code. Lookups were nanoseconds, not milliseconds.
@Singleton
class TaxRuleCache(private val ruleRepository: TaxRuleRepository) {
private val cache = ConcurrentHashMap<String, List<TaxRule>>()
@PostConstruct
fun warm() {
val allRules = ruleRepository.findAll()
allRules.groupBy { it.hsnCode }.forEach { (code, rules) ->
cache[code] = rules.sortedByDescending { it.effectiveFrom }
}
log.info("Tax rule cache warmed: ${allRules.size} rules, ${cache.size} HSN codes")
}
fun findApplicableRule(
hsnCode: String,
supplyType: SupplyType,
onDate: LocalDate,
): TaxRule? {
// Try exact match first, then progressively shorter prefixes
for (length in listOf(8, 6, 4, 2)) {
if (hsnCode.length < length) continue
val prefix = hsnCode.take(length)
val candidates = cache[prefix] ?: continue
val match = candidates.firstOrNull { rule ->
rule.supplyType == supplyType &&
!onDate.isBefore(rule.effectiveFrom) &&
(rule.effectiveTo == null || !onDate.isAfter(rule.effectiveTo))
}
if (match != null) return match
}
return null
}
fun refresh(hsnCode: String) {
val updated = ruleRepository.findByHsnCode(hsnCode)
if (updated.isEmpty()) {
cache.remove(hsnCode)
} else {
cache[hsnCode] = updated.sortedByDescending { it.effectiveFrom }
}
}
}
The HSN prefix matching is important. Not every 8-digit HSN code has its own row in the table. A chapter-level entry (2-digit prefix like 99 for services) often covers all codes under it unless a more specific rule exists. The lookup tries the longest match first and falls back to shorter prefixes. That's how the GST tariff schedule actually works too.
After the cache:
| Metric | Before (DB query) | After (in-memory) |
|---|---|---|
| Tax calculation per line | 8.4ms | 0.28ms |
| 10-line invoice total | ~84ms | ~3ms |
| DB queries per invoice | 10-15 | 0 |
| Cache memory | - | ~4MB for 3,200 rules |
| Cache warm time on startup | - | 180ms |
The 4MB cache warmed in 180ms on startup. For a GraalVM native binary starting in 35ms, the warm-up was the longest part of initialisation. We logged it explicitly so we could track if the rules table grew significantly over time.
Keeping rules up to date without restarting
The cache solved latency. It created a new problem: when rules change, all pods need to pick up the change immediately.
We built a simple admin API to manage rules. When someone added or updated a rule, the service published an event to a Kafka topic:
@Singleton
class TaxRuleAdminService(
private val ruleRepository: TaxRuleRepository,
private val ruleCache: TaxRuleCache,
private val kafkaProducer: TaxRuleEventProducer,
) {
fun addRule(request: CreateTaxRuleRequest): TaxRule {
val rule = ruleRepository.save(request.toEntity())
kafkaProducer.publishRuleUpdated(rule.hsnCode)
return rule
}
fun updateRule(id: UUID, request: UpdateTaxRuleRequest): TaxRule {
val rule = ruleRepository.update(id, request)
kafkaProducer.publishRuleUpdated(rule.hsnCode)
return rule
}
}
Every pod subscribed to the topic and refreshed its local cache entry on receipt:
@KafkaListener(topics = ["tax.rule-updates"], groupId = "tax-engine-cache-refresh")
class TaxRuleUpdateListener(private val ruleCache: TaxRuleCache) {
fun onRuleUpdate(event: TaxRuleUpdateEvent) {
ruleCache.refresh(event.hsnCode)
log.info("Tax rule cache refreshed for HSN: ${event.hsnCode}")
}
}
Because each pod has its own consumer in the same consumer group, every pod gets every message. A rule change published once propagates to all pods in under two seconds. No restarts, no rolling deploys just to add a tax rate.
We stored every rule change with a timestamp and the user who made it. When the GST Council revised a rate mid-year, we had a full audit trail showing exactly when the new rate went live in our system and who approved it.
Determining supply type
The biggest source of correctness bugs in tax engines is supply type determination. Get this wrong and you'll charge IGST on an intra-state transaction or CGST+SGST on an inter-state one. Both are wrong. Both create reconciliation problems in the buyer's returns.
@Singleton
class SupplyTypeResolver {
fun resolve(
supplierStateCode: String,
buyerStateCode: String,
buyerGstin: String?,
exportDetails: ExportDetails?,
sezDetails: SezDetails?,
): SupplyType {
// Exports and SEZ take priority over state comparison
if (exportDetails?.isExport == true) {
return if (exportDetails.underLut) SupplyType.EXPORT_LUT
else SupplyType.EXPORT_WITH_IGST
}
if (sezDetails?.isSez == true) {
return SupplyType.SEZ_SUPPLY
}
// Unregistered buyer: treat as B2C, IGST if inter-state
if (buyerGstin == null) {
return if (supplierStateCode == buyerStateCode) SupplyType.INTRA_STATE_B2C
else SupplyType.INTER_STATE_B2C
}
return if (supplierStateCode == buyerStateCode) SupplyType.INTRA_STATE
else SupplyType.INTER_STATE
}
}
Then the engine computes the actual amounts:
private fun computeBreakdown(
taxableAmount: BigDecimal,
rule: TaxRule,
supplyType: SupplyType,
): TaxBreakdown {
val scale = { n: BigDecimal -> n.setScale(2, RoundingMode.HALF_UP) }
val pct = 100.toBigDecimal()
return when (supplyType) {
SupplyType.INTRA_STATE, SupplyType.INTRA_STATE_B2C -> TaxBreakdown(
taxableAmount = taxableAmount,
cgst = scale(taxableAmount * rule.cgstRate / pct),
sgst = scale(taxableAmount * rule.sgstRate / pct),
igst = BigDecimal.ZERO,
cess = scale(taxableAmount * rule.cessRate / pct),
)
SupplyType.INTER_STATE, SupplyType.INTER_STATE_B2C -> TaxBreakdown(
taxableAmount = taxableAmount,
cgst = BigDecimal.ZERO,
sgst = BigDecimal.ZERO,
igst = scale(taxableAmount * rule.igstRate / pct),
cess = scale(taxableAmount * rule.cessRate / pct),
)
SupplyType.EXPORT_LUT, SupplyType.SEZ_SUPPLY -> TaxBreakdown(
taxableAmount = taxableAmount,
cgst = BigDecimal.ZERO,
sgst = BigDecimal.ZERO,
igst = BigDecimal.ZERO,
cess = BigDecimal.ZERO,
zeroRated = true,
zeroRatedReason = supplyType.name,
)
SupplyType.EXPORT_WITH_IGST -> TaxBreakdown(
taxableAmount = taxableAmount,
cgst = BigDecimal.ZERO,
sgst = BigDecimal.ZERO,
igst = scale(taxableAmount * rule.igstRate / pct),
cess = BigDecimal.ZERO,
zeroRated = false,
)
}
}
The zeroRated flag and reason are stored on the invoice. The IRN (Invoice Reference Number) generation on the IRP portal needs to know the supply is zero-rated, not just that the tax amounts are zero. Those are treated differently in the e-invoice schema.
Effective dating
Tax rates are date-sensitive. The GST Council announces rate changes with an effective date. For a few weeks after the announcement, you might be creating invoices under both the old and new rate depending on the invoice date.
Credit notes add another layer. A credit note against an invoice must use the tax rate that was in effect on the original invoice date, not today's date. If you void a March invoice in May after a rate change, the credit note carries March's rate.
The cache handles this through the effectiveFrom and effectiveTo fields on each rule:
data class TaxRule(
val id: UUID,
val hsnCode: String,
val supplyType: SupplyType,
val cgstRate: BigDecimal,
val sgstRate: BigDecimal,
val igstRate: BigDecimal,
val cessRate: BigDecimal,
val effectiveFrom: LocalDate,
val effectiveTo: LocalDate?, // null means currently active
val rcmApplicable: Boolean,
val description: String,
)
When a rate changes, we don't update the existing rule. We set effectiveTo on it and insert a new rule with the new rate and the new effectiveFrom. The old rule stays in the cache. A credit note created in May for a March invoice will pick up the March rule because the lookup filters by date.
This keeps the full history intact. We never delete rules from the cache or the database.
Reverse charge mechanism
RCM invoices need different handling. The seller charges zero tax on the invoice. The buyer accounts for the GST themselves when filing returns.
We kept RCM as a flag on the rule, not a separate supply type. The engine checks it after computing the breakdown:
fun calculate(request: TaxCalculationRequest): TaxBreakdown {
val supplyType = supplyTypeResolver.resolve(request)
val rule = ruleCache.findApplicableRule(request.hsnCode, supplyType, request.invoiceDate)
?: throw TaxRuleNotFoundException(request.hsnCode, supplyType, request.invoiceDate)
val breakdown = computeBreakdown(request.taxableAmount, rule, supplyType)
return if (rule.rcmApplicable) {
breakdown.copy(
cgst = BigDecimal.ZERO,
sgst = BigDecimal.ZERO,
igst = BigDecimal.ZERO,
cess = BigDecimal.ZERO,
rcm = true,
)
} else {
breakdown
}
}
The rcm = true flag on the response tells the invoice service to add the RCM declaration to the invoice. That declaration is a legal requirement under GST for reverse charge transactions.
Union territory tax (UTGST)
This one was easy to miss. Union territories like Delhi, Chandigarh, and Puducherry are not states. Intra-UT supplies use UTGST instead of SGST, but the rate is the same.
We handled it by storing SGST and UTGST as the same field in the breakdown but tagging the invoice with the correct tax head name based on the supplier's state code:
val unionTerritories = setOf("07", "04", "26", "25", "34", "33") // state codes
fun taxHeadLabel(stateCode: String, component: TaxComponent): String = when {
component == TaxComponent.SGST && stateCode in unionTerritories -> "UTGST"
else -> component.name
}
Small thing. But the IRP portal validates the tax head name. Getting this wrong means e-invoice generation fails.
What we got right
Rules as data, not code. Every new HSN code, every SEZ notification, every rate change went through the admin API. No deployment needed. The Kafka invalidation meant all pods picked it up in seconds. We added rules for new export countries multiple times during the six months and it never required a code change.
Effective dating was non-negotiable. We built it from day one because the alternative (keeping only current rates) would have broken credit notes within weeks of the first rate change. The rule history also gave us an audit trail the compliance team could read.
The cache made the engine invisible. Tax calculation was no longer the slow part of invoice creation. It ran in under 1ms per line item. The whole invoice service p99 stayed under 180ms including DB writes.
Where we hit friction
The SEZ verification gap. We trusted the isSez flag on the buyer's profile in our system. We had no way to verify in real time that the buyer was actually an SEZ unit under the Ministry of Commerce list. If a buyer was incorrectly tagged as SEZ, we'd zero-rate their invoices without catching it. We mitigated this with a one-time onboarding verification step, but it wasn't automated.
RCM categories kept changing. New categories got added to the RCM list periodically through government notifications. Each one required someone to add the right rule with rcmApplicable = true. We missed one category for about a week before the compliance team flagged it. The Kafka-based rule update fixed it in minutes, but we shouldn't have missed it in the first place. We added a subscription to CBIC's official gazette notification feed as a manual monitoring step after that.
HSN prefix matching had edge cases. Some 6-digit codes had specific rules that overrode the 4-digit chapter rule, and some 8-digit codes were missing from our table. When the engine fell through all prefix levels without a match, it threw TaxRuleNotFoundException. We tracked these in Datadog and triaged them manually. About 30 codes were missing at launch and had to be added in the first two weeks.
What we'd do the same
The in-memory cache with Kafka invalidation is the right design for this problem. Rules are small, reads are constant, and writes are rare. Keeping them in memory eliminates an entire category of latency and availability risk. If the database goes down, the engine keeps working on the cached rules.
Rules as data with effective dating is also something I'd keep. The alternative is baking rates into code, which means a deployment for every rate change, a rollback risk if the change is wrong, and no clean history for audits.
The one thing I'd do earlier is automate the detection of missing HSN codes. We caught them reactively through TaxRuleNotFoundException logs. A better approach would be a daily job that scans incoming vendor invoices for HSN codes not in the cache and alerts the ops team before they become invoice failures.
Next in this series: the vendor invoice OCR pipeline, where we extracted structured GST data from unstructured PDF invoices and validated it against exactly these tax rules.