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