Files
lunarfront-app/packages/backend/src/services/tax.service.ts
ryan 8256380cd1
All checks were successful
CI / ci (pull_request) Successful in 20s
CI / e2e (pull_request) Successful in 50s
feat: add cash rounding, POS test suite, and fix test harness port cleanup
- Add Swedish rounding (nearest nickel) for cash payments at locations with cash_rounding enabled
- Add rounding_adjustment column to transactions, cash_rounding to locations
- Add POS schema to database plugin for relational query support
- Complete/void routes now return full transaction with line items via getById
- Test harness killPort falls back to fuser when lsof unavailable (fixes stale process bug)
- Add 35-test POS API suite covering discounts, drawer, transactions, tax, rounding, e2e flow
- Add unit tests for tax service and POS Zod schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:23:05 +00:00

87 lines
2.7 KiB
TypeScript

import { eq } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { locations } from '../db/schema/stores.js'
import { AppError } from '../lib/errors.js'
export type TaxCategory = 'goods' | 'service' | 'exempt'
export const TaxService = {
/**
* Get the tax rate for a location, resolved by tax category:
* - "goods" → location.taxRate (default)
* - "service" → location.serviceTaxRate, falls back to taxRate
* - "exempt" → 0
*
* Returns 0 with no warning for exempt items.
* Returns 0 with warning if no rate is configured on the location.
*/
async getRateForLocation(
db: PostgresJsDatabase<any>,
locationId: string,
taxCategory: TaxCategory = 'goods',
): Promise<number> {
if (taxCategory === 'exempt') return 0
const [location] = await db
.select({ taxRate: locations.taxRate, serviceTaxRate: locations.serviceTaxRate })
.from(locations)
.where(eq(locations.id, locationId))
.limit(1)
if (!location) return 0
if (taxCategory === 'service') {
// Use service rate if set, otherwise fall back to goods rate
const rate = location.serviceTaxRate ?? location.taxRate
return rate ? parseFloat(rate) : 0
}
// Default: goods rate
return location.taxRate ? parseFloat(location.taxRate) : 0
},
calculateTax(amount: number, rate: number): number {
return Math.round(amount * rate * 100) / 100
},
/**
* Swedish rounding: round to nearest $0.05 for cash payments.
* Only affects the final total — tax and line items stay exact.
*/
roundToNickel(amount: number): number {
return Math.round(amount * 20) / 20
},
/**
* Map repair line item types to tax categories:
* - "part" → goods (taxable)
* - "labor" → service (may be taxed differently)
* - "flat_rate" → goods (conservative — includes parts)
* - "misc" → goods (default)
*/
repairItemTypeToTaxCategory(itemType: string): TaxCategory {
switch (itemType) {
case 'labor':
return 'service'
case 'part':
case 'flat_rate':
case 'misc':
default:
return 'goods'
}
},
// TODO: Integrate with a real tax rate API (TaxJar, Avalara, etc.)
// Set TAX_API_KEY env var when ready.
async lookupByZip(
_zip: string,
): Promise<{ zip: string; rate: number; state_rate: number; county_rate: number; city_rate: number }> {
if (!process.env.TAX_API_KEY) {
throw new AppError('Tax rate lookup is not configured. Set TAX_API_KEY to enable automatic lookup.', 501)
}
// Placeholder — replace with real API call
throw new AppError('Tax rate lookup not yet implemented', 501)
},
}