| 1 |
/** |
| 2 |
* Frontend Tax Service |
| 3 |
* |
| 4 |
* Handles tax calculations for booking forms and displays |
| 5 |
* |
| 6 |
* @package Yatra.Services |
| 7 |
* @since 3.0.0 |
| 8 |
*/ |
| 9 |
|
| 10 |
import { formatYatraMoney } from "../lib/currency-display"; |
| 11 |
import { apiClient } from "../lib/api-client"; |
| 12 |
import { API_ENDPOINTS } from "../lib/api-endpoints"; |
| 13 |
|
| 14 |
interface TaxDetails { |
| 15 |
tax_amount: number; |
| 16 |
tax_rate: number; |
| 17 |
tax_inclusive: boolean; |
| 18 |
taxes: Array<{ |
| 19 |
name: string; |
| 20 |
rate: number; |
| 21 |
amount: number; |
| 22 |
}>; |
| 23 |
subtotal?: number; |
| 24 |
total_amount?: number; |
| 25 |
} |
| 26 |
|
| 27 |
interface BookingTaxCalculation { |
| 28 |
subtotal: number; |
| 29 |
tax_amount: number; |
| 30 |
total_amount: number; |
| 31 |
tax_rate: number; |
| 32 |
tax_inclusive: boolean; |
| 33 |
taxes: Array<{ |
| 34 |
name: string; |
| 35 |
rate: number; |
| 36 |
amount: number; |
| 37 |
formatted_amount: string; |
| 38 |
formatted_rate: string; |
| 39 |
}>; |
| 40 |
tax_breakdown: string; |
| 41 |
} |
| 42 |
|
| 43 |
class TaxService { |
| 44 |
private static instance: TaxService; |
| 45 |
private taxSettings: any = null; |
| 46 |
|
| 47 |
private constructor() {} |
| 48 |
|
| 49 |
static getInstance(): TaxService { |
| 50 |
if (!TaxService.instance) { |
| 51 |
TaxService.instance = new TaxService(); |
| 52 |
} |
| 53 |
return TaxService.instance; |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* Load tax settings from API |
| 58 |
*/ |
| 59 |
async loadTaxSettings(): Promise<void> { |
| 60 |
try { |
| 61 |
// Through the shared client so the REST nonce is sent — a bare fetch() |
| 62 |
// was answered with 401 on every admin booking form, so tax settings |
| 63 |
// never actually loaded here. |
| 64 |
const settings = await apiClient.get(API_ENDPOINTS.SETTINGS); |
| 65 |
this.taxSettings = (settings as any)?.data ?? settings; |
| 66 |
} catch (error) { |
| 67 |
console.error("Failed to load tax settings:", error); |
| 68 |
} |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* Get tax settings |
| 73 |
*/ |
| 74 |
getTaxSettings(): any { |
| 75 |
return this.taxSettings; |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* Check if tax is enabled |
| 80 |
*/ |
| 81 |
isTaxEnabled(): boolean { |
| 82 |
return this.taxSettings?.enable_tax === true; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Calculate tax for booking |
| 87 |
*/ |
| 88 |
calculateTax(amount: number, country?: string): TaxDetails { |
| 89 |
if (!this.isTaxEnabled()) { |
| 90 |
return { |
| 91 |
tax_amount: 0, |
| 92 |
tax_rate: 0, |
| 93 |
tax_inclusive: false, |
| 94 |
taxes: [], |
| 95 |
}; |
| 96 |
} |
| 97 |
|
| 98 |
const taxInclusive = this.taxSettings?.tax_inclusive === true; |
| 99 |
const multipleTaxesEnabled = |
| 100 |
this.taxSettings?.multiple_taxes_enabled === true; |
| 101 |
|
| 102 |
if (multipleTaxesEnabled) { |
| 103 |
return this.calculateMultipleTaxes(amount, country, taxInclusive); |
| 104 |
} else { |
| 105 |
return this.calculateSingleTax(amount, country, taxInclusive); |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Calculate single tax (backward compatibility) |
| 111 |
*/ |
| 112 |
private calculateSingleTax( |
| 113 |
amount: number, |
| 114 |
country?: string, |
| 115 |
taxInclusive: boolean = false, |
| 116 |
): TaxDetails { |
| 117 |
let taxRate = this.taxSettings?.tax_rate || 0; |
| 118 |
const taxName = |
| 119 |
this.taxSettings?.tax_name || this.taxSettings?.tax_label || "Tax"; |
| 120 |
|
| 121 |
// Check for country-specific tax |
| 122 |
if ( |
| 123 |
this.taxSettings?.tax_by_country === true && |
| 124 |
country && |
| 125 |
this.taxSettings?.tax_rates?.[country] |
| 126 |
) { |
| 127 |
taxRate = this.taxSettings.tax_rates[country]; |
| 128 |
} |
| 129 |
|
| 130 |
let taxAmount: number; |
| 131 |
if (taxInclusive) { |
| 132 |
// Tax is included in the price |
| 133 |
taxAmount = amount - amount / (1 + taxRate / 100); |
| 134 |
} else { |
| 135 |
// Tax is added to the price |
| 136 |
taxAmount = amount * (taxRate / 100); |
| 137 |
} |
| 138 |
|
| 139 |
return { |
| 140 |
tax_amount: Math.round(taxAmount * 100) / 100, |
| 141 |
tax_rate: taxRate, |
| 142 |
tax_inclusive: taxInclusive, |
| 143 |
taxes: [ |
| 144 |
{ |
| 145 |
name: taxName, |
| 146 |
rate: taxRate, |
| 147 |
amount: Math.round(taxAmount * 100) / 100, |
| 148 |
}, |
| 149 |
], |
| 150 |
}; |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Calculate multiple taxes |
| 155 |
*/ |
| 156 |
private calculateMultipleTaxes( |
| 157 |
amount: number, |
| 158 |
country?: string, |
| 159 |
taxInclusive: boolean = false, |
| 160 |
): TaxDetails { |
| 161 |
let taxes = this.taxSettings?.multiple_taxes || []; |
| 162 |
|
| 163 |
// Check for country-specific taxes |
| 164 |
if (country && this.taxSettings?.multiple_taxes_by_country?.[country]) { |
| 165 |
taxes = this.taxSettings.multiple_taxes_by_country[country]; |
| 166 |
} |
| 167 |
|
| 168 |
const calculatedTaxes: Array<{ |
| 169 |
name: string; |
| 170 |
rate: number; |
| 171 |
amount: number; |
| 172 |
}> = []; |
| 173 |
let totalTaxAmount = 0; |
| 174 |
|
| 175 |
for (const tax of taxes) { |
| 176 |
const taxRate = tax.rate || 0; |
| 177 |
const taxName = tax.name || "Tax"; |
| 178 |
|
| 179 |
let taxAmount: number; |
| 180 |
if (taxInclusive) { |
| 181 |
// For tax-inclusive, calculate based on remaining amount |
| 182 |
const baseAmount = amount - totalTaxAmount; |
| 183 |
taxAmount = baseAmount * (taxRate / 100); |
| 184 |
} else { |
| 185 |
// Tax is added to the price |
| 186 |
taxAmount = amount * (taxRate / 100); |
| 187 |
} |
| 188 |
|
| 189 |
taxAmount = Math.round(taxAmount * 100) / 100; |
| 190 |
totalTaxAmount += taxAmount; |
| 191 |
|
| 192 |
calculatedTaxes.push({ |
| 193 |
name: taxName, |
| 194 |
rate: taxRate, |
| 195 |
amount: taxAmount, |
| 196 |
}); |
| 197 |
} |
| 198 |
|
| 199 |
return { |
| 200 |
tax_amount: Math.round(totalTaxAmount * 100) / 100, |
| 201 |
tax_rate: taxes.reduce( |
| 202 |
(sum: number, tax: any) => sum + (tax.rate || 0), |
| 203 |
0, |
| 204 |
), |
| 205 |
tax_inclusive: taxInclusive, |
| 206 |
taxes: calculatedTaxes, |
| 207 |
}; |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* Calculate complete booking tax breakdown |
| 212 |
*/ |
| 213 |
calculateBookingTax( |
| 214 |
subtotal: number, |
| 215 |
country?: string, |
| 216 |
): BookingTaxCalculation { |
| 217 |
const taxDetails = this.calculateTax(subtotal, country); |
| 218 |
|
| 219 |
let finalSubtotal: number; |
| 220 |
let finalTotal: number; |
| 221 |
|
| 222 |
if (taxDetails.tax_inclusive) { |
| 223 |
// Tax is included - extract tax from total |
| 224 |
finalSubtotal = subtotal - taxDetails.tax_amount; |
| 225 |
finalTotal = subtotal; |
| 226 |
} else { |
| 227 |
// Tax is added to subtotal |
| 228 |
finalSubtotal = subtotal; |
| 229 |
finalTotal = subtotal + taxDetails.tax_amount; |
| 230 |
} |
| 231 |
|
| 232 |
const formattedTaxes = taxDetails.taxes.map((tax) => ({ |
| 233 |
...tax, |
| 234 |
formatted_amount: this.formatPrice(tax.amount), |
| 235 |
formatted_rate: `${tax.rate.toFixed(2)}%`, |
| 236 |
})); |
| 237 |
|
| 238 |
const taxBreakdown = formattedTaxes |
| 239 |
.map( |
| 240 |
(tax) => `${tax.name} (${tax.formatted_rate}): ${tax.formatted_amount}`, |
| 241 |
) |
| 242 |
.join("\n"); |
| 243 |
|
| 244 |
return { |
| 245 |
subtotal: Math.round(finalSubtotal * 100) / 100, |
| 246 |
tax_amount: taxDetails.tax_amount, |
| 247 |
total_amount: Math.round(finalTotal * 100) / 100, |
| 248 |
tax_rate: taxDetails.tax_rate, |
| 249 |
tax_inclusive: taxDetails.tax_inclusive, |
| 250 |
taxes: formattedTaxes, |
| 251 |
tax_breakdown: taxBreakdown, |
| 252 |
}; |
| 253 |
} |
| 254 |
|
| 255 |
/** |
| 256 |
* Format price display |
| 257 |
*/ |
| 258 |
formatPrice(amount: number): string { |
| 259 |
const currency = this.taxSettings?.currency || "USD"; |
| 260 |
return formatYatraMoney(Number(amount) || 0, currency, { |
| 261 |
zeroAsUnknown: false, |
| 262 |
}); |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Get currency symbol |
| 267 |
*/ |
| 268 |
getCurrencySymbol(currency: string): string { |
| 269 |
const symbols: { [key: string]: string } = { |
| 270 |
USD: "$", |
| 271 |
EUR: "€", |
| 272 |
GBP: "£", |
| 273 |
JPY: "¥", |
| 274 |
AUD: "A$", |
| 275 |
CAD: "C$", |
| 276 |
CHF: "CHF", |
| 277 |
CNY: "¥", |
| 278 |
INR: "₹", |
| 279 |
}; |
| 280 |
|
| 281 |
return symbols[currency] || currency; |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* Get tax breakdown for display |
| 286 |
*/ |
| 287 |
getTaxBreakdown(taxDetails: TaxDetails): Array<{ |
| 288 |
name: string; |
| 289 |
rate: number; |
| 290 |
amount: number; |
| 291 |
formatted_amount: string; |
| 292 |
formatted_rate: string; |
| 293 |
formatted_line: string; |
| 294 |
}> { |
| 295 |
return taxDetails.taxes.map((tax) => ({ |
| 296 |
name: tax.name, |
| 297 |
rate: tax.rate, |
| 298 |
amount: tax.amount, |
| 299 |
formatted_amount: this.formatPrice(tax.amount), |
| 300 |
formatted_rate: `${tax.rate.toFixed(2)}%`, |
| 301 |
formatted_line: `${tax.name} (${tax.rate.toFixed(2)}%): ${this.formatPrice(tax.amount)}`, |
| 302 |
})); |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Format tax display for booking summary |
| 307 |
*/ |
| 308 |
formatTaxDisplay(taxDetails: TaxDetails): string { |
| 309 |
const breakdown = this.getTaxBreakdown(taxDetails); |
| 310 |
return breakdown.map((tax) => tax.formatted_line).join("\n"); |
| 311 |
} |
| 312 |
|
| 313 |
/** |
| 314 |
* Validate tax configuration |
| 315 |
*/ |
| 316 |
validateTaxConfiguration(): { valid: boolean; errors: string[] } { |
| 317 |
const errors: string[] = []; |
| 318 |
|
| 319 |
if (!this.isTaxEnabled()) { |
| 320 |
return { valid: true, errors: [] }; |
| 321 |
} |
| 322 |
|
| 323 |
const multipleTaxesEnabled = |
| 324 |
this.taxSettings?.multiple_taxes_enabled === true; |
| 325 |
|
| 326 |
if (multipleTaxesEnabled) { |
| 327 |
const multipleTaxes = this.taxSettings?.multiple_taxes || []; |
| 328 |
|
| 329 |
if (multipleTaxes.length === 0) { |
| 330 |
errors.push("At least one tax must be configured"); |
| 331 |
} |
| 332 |
|
| 333 |
let totalRate = 0; |
| 334 |
for (let i = 0; i < multipleTaxes.length; i++) { |
| 335 |
const tax = multipleTaxes[i]; |
| 336 |
|
| 337 |
if (!tax.name || tax.name.trim() === "") { |
| 338 |
errors.push(`Tax ${i + 1}: Name is required`); |
| 339 |
} |
| 340 |
|
| 341 |
if (typeof tax.rate !== "number" || tax.rate < 0 || tax.rate > 100) { |
| 342 |
errors.push(`Tax ${i + 1}: Rate must be between 0 and 100`); |
| 343 |
} else { |
| 344 |
totalRate += tax.rate; |
| 345 |
} |
| 346 |
} |
| 347 |
|
| 348 |
if (totalRate > 100) { |
| 349 |
errors.push("Total tax rate cannot exceed 100%"); |
| 350 |
} |
| 351 |
} else { |
| 352 |
const taxRate = this.taxSettings?.tax_rate || 0; |
| 353 |
const taxName = this.taxSettings?.tax_name || ""; |
| 354 |
|
| 355 |
if (typeof taxRate !== "number" || taxRate < 0 || taxRate > 100) { |
| 356 |
errors.push("Tax rate must be between 0 and 100"); |
| 357 |
} |
| 358 |
|
| 359 |
if (!taxName || taxName.trim() === "") { |
| 360 |
errors.push("Tax name is required"); |
| 361 |
} |
| 362 |
} |
| 363 |
|
| 364 |
return { |
| 365 |
valid: errors.length === 0, |
| 366 |
errors, |
| 367 |
}; |
| 368 |
} |
| 369 |
|
| 370 |
/** |
| 371 |
* Get tax summary for reporting |
| 372 |
*/ |
| 373 |
getTaxSummary(bookings: any[]): { |
| 374 |
total_tax_collected: number; |
| 375 |
total_bookings_with_tax: number; |
| 376 |
tax_breakdown: { |
| 377 |
[key: string]: { |
| 378 |
name: string; |
| 379 |
total_amount: number; |
| 380 |
count: number; |
| 381 |
average_rate: number; |
| 382 |
}; |
| 383 |
}; |
| 384 |
average_tax_rate: number; |
| 385 |
} { |
| 386 |
const summary = { |
| 387 |
total_tax_collected: 0, |
| 388 |
total_bookings_with_tax: 0, |
| 389 |
tax_breakdown: {} as { |
| 390 |
[key: string]: { |
| 391 |
name: string; |
| 392 |
total_amount: number; |
| 393 |
count: number; |
| 394 |
average_rate: number; |
| 395 |
}; |
| 396 |
}, |
| 397 |
average_tax_rate: 0, |
| 398 |
}; |
| 399 |
|
| 400 |
let totalTaxRate = 0; |
| 401 |
let taxRateCount = 0; |
| 402 |
|
| 403 |
for (const booking of bookings) { |
| 404 |
const taxAmount = booking.tax_amount || 0; |
| 405 |
|
| 406 |
if (taxAmount > 0) { |
| 407 |
summary.total_tax_collected += taxAmount; |
| 408 |
summary.total_bookings_with_tax++; |
| 409 |
|
| 410 |
const taxRate = booking.tax_rate || 0; |
| 411 |
if (taxRate > 0) { |
| 412 |
totalTaxRate += taxRate; |
| 413 |
taxRateCount++; |
| 414 |
} |
| 415 |
|
| 416 |
// Collect tax breakdown |
| 417 |
const taxBreakdown = booking.tax_breakdown || []; |
| 418 |
for (const tax of taxBreakdown) { |
| 419 |
const taxName = tax.name; |
| 420 |
if (!summary.tax_breakdown[taxName]) { |
| 421 |
summary.tax_breakdown[taxName] = { |
| 422 |
name: taxName, |
| 423 |
total_amount: 0, |
| 424 |
count: 0, |
| 425 |
average_rate: 0, |
| 426 |
}; |
| 427 |
} |
| 428 |
|
| 429 |
summary.tax_breakdown[taxName].total_amount += tax.amount; |
| 430 |
summary.tax_breakdown[taxName].count++; |
| 431 |
summary.tax_breakdown[taxName].average_rate += tax.rate; |
| 432 |
} |
| 433 |
} |
| 434 |
} |
| 435 |
|
| 436 |
// Calculate averages |
| 437 |
if (taxRateCount > 0) { |
| 438 |
summary.average_tax_rate = totalTaxRate / taxRateCount; |
| 439 |
} |
| 440 |
|
| 441 |
// Calculate average rates for tax breakdown |
| 442 |
for (const taxName in summary.tax_breakdown) { |
| 443 |
const taxData = summary.tax_breakdown[taxName]; |
| 444 |
if (taxData.count > 0) { |
| 445 |
taxData.average_rate = taxData.average_rate / taxData.count; |
| 446 |
} |
| 447 |
} |
| 448 |
|
| 449 |
return summary; |
| 450 |
} |
| 451 |
} |
| 452 |
|
| 453 |
// Export singleton instance |
| 454 |
export const taxService = TaxService.getInstance(); |
| 455 |
export default taxService; |
| 456 |
|