From 8e5b91fab7b36aa1c9a9de029bba160efba080a8 Mon Sep 17 00:00:00 2001 From: junman140 Date: Wed, 27 May 2026 11:54:27 +0100 Subject: [PATCH] feat: implement subscription tax calculation and remittance system - Tax rate lookup by jurisdiction (country, state, city) with hierarchical fallback - Tax-exempt customer handling with certificate validation and expiry checks - Mid-cycle tax rate change proration for existing subscriptions - Reverse-charge flagging and nexus threshold determination - Multi-jurisdiction: VAT, GST, sales tax, HST, PST, QST, digital services tax - Digital goods tax classification with country-specific rules - Tax remittance report generation with jurisdiction aggregation Contracts (Rust/Soroban): - types: Add TaxType, TaxJurisdiction, TaxRateEntry, CustomerTaxStatus, DigitalGoodsClass, TaxRemittanceLineItem types and 5 StorageKey variants - invoice: 11 new contract functions for full tax lifecycle + 10 test cases - subscription: Update generate_invoice signature with jurisdiction params - proxy: Fix StorageKey variant names for Soroban 32-char limit Backend (TypeScript): - TaxService with 17 built-in jurisdiction rates, caching, exemption validation - Nexus checks, mid-cycle proration, digital goods tax rules - Remittance report generation with filtering and grouping - 25 test cases covering all tax scenarios Frontend (React Native): - Extended Invoice types with TaxJurisdiction, CustomerTaxStatus, etc. - invoiceStore: Tax state management with 12 new actions --- backend/services/__tests__/taxService.test.ts | 529 ++++++++++ backend/services/index.ts | 17 + backend/services/taxService.ts | 739 ++++++++++++++ backend/services/taxTypes.ts | 143 +++ contracts/invoice/src/lib.rs | 966 ++++++++++++++++-- contracts/proxy/src/lib.rs | 2 +- contracts/proxy/src/storage.rs | 4 +- contracts/subscription/src/lib.rs | 3 + contracts/types/src/lib.rs | 196 +++- src/navigation/types.ts | 1 + src/store/invoiceStore.ts | 375 ++++++- src/types/invoice.ts | 265 +++++ 12 files changed, 3173 insertions(+), 67 deletions(-) create mode 100644 backend/services/__tests__/taxService.test.ts create mode 100644 backend/services/taxService.ts create mode 100644 backend/services/taxTypes.ts diff --git a/backend/services/__tests__/taxService.test.ts b/backend/services/__tests__/taxService.test.ts new file mode 100644 index 0000000..15f8e63 --- /dev/null +++ b/backend/services/__tests__/taxService.test.ts @@ -0,0 +1,529 @@ +import { TaxService } from '../taxService'; +import type { + TaxJurisdiction, + TaxRateEntry, + CustomerTaxStatus, + TaxRemittanceLineItem, + TaxRemittanceReportRequest, + TaxInvoiceContext, + MidCycleTaxChange, +} from '../taxTypes'; + +const makeJurisdiction = (overrides: Partial = {}): TaxJurisdiction => ({ + country: '', + state: '', + city: '', + ...overrides, +}); + +describe('TaxService', () => { + beforeEach(() => { + TaxService.invalidateTaxRateCache(); + }); + + // ── Tax rate lookup by jurisdiction ───────────────────────────────────── + + it('looks up VAT rate for EU country', () => { + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'DE' })); + expect(entry).not.toBeNull(); + expect(entry!.taxType).toBe('vat'); + expect(entry!.rateBps).toBe(1900); + }); + + it('looks up GST rate for Australia', () => { + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'AU' })); + expect(entry).not.toBeNull(); + expect(entry!.taxType).toBe('gst'); + expect(entry!.rateBps).toBe(1000); + }); + + it('looks up US state sales tax for NY', () => { + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'US', state: 'NY' })); + expect(entry).not.toBeNull(); + expect(entry!.jurisdictionKey).toBe('US-NY'); + expect(entry!.taxType).toBe('sales_tax'); + expect(entry!.rateBps).toBe(887); + }); + + it('looks up US state sales tax for CA', () => { + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'US', state: 'CA' })); + expect(entry!.rateBps).toBe(850); + }); + + it('returns null for unknown country', () => { + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'XX' })); + expect(entry).toBeNull(); + }); + + it('looks up tax with city level detail and falls back to state', () => { + const entry = TaxService.getTaxRate( + makeJurisdiction({ country: 'US', state: 'NY', city: 'NYC' }) + ); + expect(entry).not.toBeNull(); + expect(entry!.jurisdictionKey).toBe('US-NY-NYC'); + }); + + // ── Digital goods taxability ──────────────────────────────────────────── + + it('standard digital goods are taxable in DE', () => { + const taxable = TaxService.isDigitalGoodsTaxable('standard', 'DE'); + expect(taxable).toBe(true); + }); + + it('educational exempt goods are not taxable in US', () => { + const taxable = TaxService.isDigitalGoodsTaxable('exempt', 'US'); + expect(taxable).toBe(false); + }); + + it('electronic services are taxable in CA', () => { + const taxable = TaxService.isDigitalGoodsTaxable('electronic_service', 'CA'); + expect(taxable).toBe(true); + }); + + it('returns rules for a specific classification', () => { + const rules = TaxService.getDigitalGoodsRules('standard'); + expect(rules.length).toBeGreaterThan(0); + expect(rules.every((r) => r.classification === 'standard')).toBe(true); + }); + + // ── Tax exemption handling ────────────────────────────────────────────── + + it('validates a valid exemption certificate', () => { + const status: CustomerTaxStatus = { + isExempt: true, + certificateId: 'CERT-DE-001', + certificateExpiry: 0, + issuingAuthority: 'Finanzamt Berlin', + exemptJurisdictions: [], + }; + expect(TaxService.validateTaxCertificate(status, 'CERT-DE-001')).toBe(true); + }); + + it('rejects expired exemption certificate', () => { + const status: CustomerTaxStatus = { + isExempt: true, + certificateId: 'CERT-EXPIRED', + certificateExpiry: 1000000, + issuingAuthority: 'Authority', + exemptJurisdictions: [], + }; + expect(TaxService.validateTaxCertificate(status, 'CERT-EXPIRED')).toBe(false); + }); + + it('rejects mismatched certificate ID', () => { + const status: CustomerTaxStatus = { + isExempt: true, + certificateId: 'CERT-REAL', + certificateExpiry: 0, + issuingAuthority: 'Authority', + exemptJurisdictions: [], + }; + expect(TaxService.validateTaxCertificate(status, 'CERT-FAKE')).toBe(false); + }); + + it('isCustomerTaxExempt returns true for valid exemption', () => { + const status: CustomerTaxStatus = { + isExempt: true, + certificateId: 'CERT-1', + certificateExpiry: 0, + issuingAuthority: 'Authority', + exemptJurisdictions: [], + }; + expect(TaxService.isCustomerTaxExempt(status, 'DE')).toBe(true); + }); + + it('isCustomerTaxExempt restricts to specific jurisdictions', () => { + const status: CustomerTaxStatus = { + isExempt: true, + certificateId: 'CERT-DE-ONLY', + certificateExpiry: 0, + issuingAuthority: 'Authority', + exemptJurisdictions: ['DE'], + }; + expect(TaxService.isCustomerTaxExempt(status, 'DE')).toBe(true); + expect(TaxService.isCustomerTaxExempt(status, 'FR')).toBe(false); + }); + + // ── Tax calculation ───────────────────────────────────────────────────── + + it('calculates VAT on taxable amount', () => { + const context: TaxInvoiceContext = { + subscriptionId: 'sub-1', + planId: 'plan-1', + merchantId: 'merchant-1', + subscriberId: 'cust-1', + jurisdiction: makeJurisdiction({ country: 'DE' }), + subtotal: 10000, + currency: 'EUR', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + digitalGoodsClass: 'standard', + }; + const result = TaxService.calculateTax(context); + expect(result.taxAmount).toBe(1900); + expect(result.taxRateBps).toBe(1900); + expect(result.taxType).toBe('vat'); + expect(result.isExempt).toBe(false); + }); + + it('calculates zero tax for unknown jurisdiction', () => { + const context: TaxInvoiceContext = { + subscriptionId: 'sub-1', + planId: 'plan-1', + merchantId: 'merchant-1', + subscriberId: 'cust-1', + jurisdiction: makeJurisdiction({ country: 'XX' }), + subtotal: 10000, + currency: 'EUR', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + digitalGoodsClass: 'standard', + }; + const result = TaxService.calculateTax(context); + expect(result.taxAmount).toBe(0); + expect(result.taxType).toBe('none'); + }); + + it('calculates US sales tax correctly', () => { + const context: TaxInvoiceContext = { + subscriptionId: 'sub-1', + planId: 'plan-1', + merchantId: 'merchant-1', + subscriberId: 'cust-1', + jurisdiction: makeJurisdiction({ country: 'US', state: 'CA' }), + subtotal: 10000, + currency: 'USD', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + digitalGoodsClass: 'standard', + }; + const result = TaxService.calculateTax(context); + expect(result.taxRateBps).toBe(850); + }); + + // ── Tax calculation with exemption ────────────────────────────────────── + + it('calculateTaxWithExemption applies exemption', () => { + const context: TaxInvoiceContext = { + subscriptionId: 'sub-1', + planId: 'plan-1', + merchantId: 'merchant-1', + subscriberId: 'cust-1', + jurisdiction: makeJurisdiction({ country: 'DE' }), + subtotal: 10000, + currency: 'EUR', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + digitalGoodsClass: 'standard', + }; + const status: CustomerTaxStatus = { + isExempt: true, + certificateId: 'CERT-1', + certificateExpiry: 0, + issuingAuthority: 'Auth', + exemptJurisdictions: [], + }; + const result = TaxService.calculateTaxWithExemption(context, status); + expect(result.taxAmount).toBe(0); + expect(result.isExempt).toBe(true); + }); + + it('calculateTaxWithExemption ignores null status', () => { + const context: TaxInvoiceContext = { + subscriptionId: 'sub-1', + planId: 'plan-1', + merchantId: 'merchant-1', + subscriberId: 'cust-1', + jurisdiction: makeJurisdiction({ country: 'DE' }), + subtotal: 10000, + currency: 'EUR', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + digitalGoodsClass: 'standard', + }; + const result = TaxService.calculateTaxWithExemption(context, null); + expect(result.taxAmount).toBe(1900); + expect(result.isExempt).toBe(false); + }); + + // ── Mid-cycle tax rate change (proration) ─────────────────────────────── + + it('prorates tax across a mid-cycle rate change', () => { + const now = Date.now(); + const periodStart = now - 31 * 86400000; + const periodEnd = now; + const changeDate = now - 15 * 86400000; + + const results = TaxService.calculateMidCycleTaxChange( + periodStart, + periodEnd, + 310000, + 'DE', + [ + { + jurisdictionKey: 'DE', + oldRateBps: 1900, + newRateBps: 1600, + changedAt: changeDate, + effectiveFrom: changeDate, + }, + ] + ); + + expect(results.length).toBeGreaterThan(0); + const totalTax = results.reduce((s, r) => s + r.totalTax, 0); + expect(totalTax).toBeGreaterThan(0); + }); + + it('handles rate change after billing period', () => { + const now = Date.now(); + const periodStart = now - 15 * 86400000; + const periodEnd = now; + const changeDate = now + 5 * 86400000; + + const results = TaxService.calculateMidCycleTaxChange( + periodStart, + periodEnd, + 10000, + 'DE', + [ + { + jurisdictionKey: 'DE', + oldRateBps: 1900, + newRateBps: 1600, + changedAt: changeDate, + effectiveFrom: changeDate, + }, + ] + ); + + expect(results.length).toBe(0); + }); + + // ── Tax remittance report ──────────────────────────────────────────────── + + it('generates a remittance report for a jurisdiction', () => { + const lines: TaxRemittanceLineItem[] = [ + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 10000, + rateBps: 1900, + taxCollected: 1900, + transactionCount: 1, + currency: 'EUR', + }, + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 20000, + rateBps: 1900, + taxCollected: 3800, + transactionCount: 1, + currency: 'EUR', + }, + ]; + + const report = TaxService.generateTaxRemittanceReport(lines, { + merchantId: 'merchant-1', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + format: 'summary', + }); + + expect(report.totalTaxableAmount).toBe(30000); + expect(report.totalTaxCollected).toBe(5700); + expect(report.lineItems.length).toBe(1); + }); + + it('aggregates by jurisdiction and tax type', () => { + const lines: TaxRemittanceLineItem[] = [ + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 10000, + rateBps: 1900, + taxCollected: 1900, + transactionCount: 1, + currency: 'EUR', + }, + { + jurisdictionKey: 'AU', + taxType: 'gst', + taxableAmount: 15000, + rateBps: 1000, + taxCollected: 1500, + transactionCount: 1, + currency: 'AUD', + }, + ]; + + const report = TaxService.generateTaxRemittanceReport(lines, { + merchantId: 'merchant-1', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + format: 'summary', + }); + + expect(report.lineItems.length).toBe(2); + expect(report.totalTaxCollected).toBe(3400); + }); + + it('filters by jurisdictions', () => { + const lines: TaxRemittanceLineItem[] = [ + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 10000, + rateBps: 1900, + taxCollected: 1900, + transactionCount: 1, + currency: 'EUR', + }, + { + jurisdictionKey: 'AU', + taxType: 'gst', + taxableAmount: 15000, + rateBps: 1000, + taxCollected: 1500, + transactionCount: 1, + currency: 'AUD', + }, + ]; + + const report = TaxService.generateTaxRemittanceReport(lines, { + merchantId: 'merchant-1', + periodStart: Date.now() - 30 * 86400000, + periodEnd: Date.now(), + format: 'summary', + jurisdictions: ['DE'], + }); + + expect(report.lineItems.length).toBe(1); + expect(report.totalTaxCollected).toBe(1900); + }); + + // ── Nexus determination ────────────────────────────────────────────────── + + it('determines nexus when revenue exceeds threshold', () => { + const nexus = TaxService.checkNexus('merchant-1', makeJurisdiction({ country: 'DE' }), 10000000); + expect(nexus.isEstablished).toBe(true); + }); + + it('determines no nexus when below threshold', () => { + const nexus = TaxService.checkNexus('merchant-1', makeJurisdiction({ country: 'DE' }), 100); + expect(nexus.isEstablished).toBe(false); + }); + + it('determines no nexus for unknown jurisdiction', () => { + const nexus = TaxService.checkNexus( + 'merchant-1', + makeJurisdiction({ country: 'XX' }), + 50000000 + ); + expect(nexus.isEstablished).toBe(false); + }); + + // ── Tax rate by key ────────────────────────────────────────────────────── + + it('gets tax rate entry by jurisdiction key', () => { + const entry = TaxService.getTaxRateByKey('US-CA'); + expect(entry).not.toBeNull(); + expect(entry!.rateBps).toBe(850); + }); + + it('returns null for unknown key', () => { + expect(TaxService.getTaxRateByKey('XX-YY')).toBeNull(); + }); + + // ── Supported jurisdictions ────────────────────────────────────────────── + + it('lists all supported jurisdictions', () => { + const jurisdictions = TaxService.getSupportedJurisdictions(); + expect(jurisdictions).toContain('DE'); + expect(jurisdictions).toContain('AU'); + expect(jurisdictions).toContain('US-CA'); + }); + + // ── Group by jurisdiction ──────────────────────────────────────────────── + + it('groups tax lines by jurisdiction', () => { + const lines: TaxRemittanceLineItem[] = [ + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 10000, + rateBps: 1900, + taxCollected: 1900, + transactionCount: 1, + currency: 'EUR', + }, + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 5000, + rateBps: 1900, + taxCollected: 950, + transactionCount: 1, + currency: 'EUR', + }, + { + jurisdictionKey: 'AU', + taxType: 'gst', + taxableAmount: 10000, + rateBps: 1000, + taxCollected: 1000, + transactionCount: 1, + currency: 'AUD', + }, + ]; + + const groups = TaxService.groupByJurisdiction(lines); + expect(Object.keys(groups)).toHaveLength(2); + expect(groups['DE'].totalTax).toBe(2850); + expect(groups['AU'].totalTax).toBe(1000); + }); + + // ── Total tax revenue ──────────────────────────────────────────────────── + + it('calculates total tax revenue', () => { + const lines: TaxRemittanceLineItem[] = [ + { + jurisdictionKey: 'DE', + taxType: 'vat', + taxableAmount: 10000, + rateBps: 1900, + taxCollected: 1900, + transactionCount: 1, + currency: 'EUR', + }, + { + jurisdictionKey: 'GB', + taxType: 'vat', + taxableAmount: 20000, + rateBps: 2000, + taxCollected: 4000, + transactionCount: 1, + currency: 'GBP', + }, + ]; + + expect(TaxService.calculateTotalTaxRevenue(lines)).toBe(5900); + }); + + // ── Cache management ───────────────────────────────────────────────────── + + it('cache returns stored tax rate', () => { + const jurisdiction = makeJurisdiction({ country: 'DE' }); + const entry1 = TaxService.getTaxRate(jurisdiction); + const entry2 = TaxService.getTaxRate(jurisdiction); + expect(entry1).toEqual(entry2); + }); + + it('invalidate clears cache', () => { + TaxService.getTaxRate(makeJurisdiction({ country: 'DE' })); + TaxService.invalidateTaxRateCache(); + const entry = TaxService.getTaxRate(makeJurisdiction({ country: 'DE' })); + expect(entry).not.toBeNull(); + }); +}); diff --git a/backend/services/index.ts b/backend/services/index.ts index 1f5c4a1..b21e08c 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -1,5 +1,6 @@ export { AuditService } from './auditService'; export { PricingService } from './pricingService'; +export { TaxService } from './taxService'; export type { AuditAction, AuditEvent, @@ -7,6 +8,22 @@ export type { ExportFormat, RetentionPolicy, } from './auditTypes'; +export type { + TaxType, + TaxJurisdiction, + TaxRateEntry, + TaxRateChangeEvent, + CustomerTaxStatus, + TaxRemittanceLineItem, + TaxRemittanceReport, + TaxCalculationResult, + TaxInvoiceContext, + NexusReport, + MidCycleTaxChange, + DigitalGoodsClass, + DigitalGoodsTaxRule, + TaxRemittanceReportRequest, +} from './taxTypes'; export { WebhookDeliveryService, webhookDeliveryService, diff --git a/backend/services/taxService.ts b/backend/services/taxService.ts new file mode 100644 index 0000000..a13baf1 --- /dev/null +++ b/backend/services/taxService.ts @@ -0,0 +1,739 @@ +import type { + CustomerTaxStatus, + DigitalGoodsClass, + DigitalGoodsTaxRule, + MidCycleTaxChange, + NexusReport, + TaxCalculationResult, + TaxInvoiceContext, + TaxJurisdiction, + TaxRateCacheEntry, + TaxRateChangeEvent, + TaxRateEntry, + TaxRemittanceLineItem, + TaxRemittanceReport, + TaxRemittanceReportRequest, + TaxType, +} from './taxTypes'; +import { DEFAULT_TAX_CACHE_TTL_MS, TAX_RATE_CACHE_MAX_ENTRIES } from './taxTypes'; + +/** + * Ratio to convert basis points to a decimal multiplier. + * e.g., 850 bps -> 0.085 + */ +const BPS_SCALE = 10_000; + +// ── Built-in Digital Goods Tax Rules ───────────────────────────────────────── + +const DIGITAL_GOODS_TAX_RULES: DigitalGoodsTaxRule[] = [ + { + classification: 'standard', + country: 'US', + isTaxable: true, + notes: 'Taxable in most US states for digital goods delivered electronically', + }, + { + classification: 'exempt', + country: 'US', + isTaxable: false, + notes: 'Exempt digital goods such as educational materials', + }, + { + classification: 'electronic_service', + country: 'GB', + isTaxable: true, + notes: 'SaaS platforms subject to UK VAT at standard rate', + }, + { + classification: 'reduced_rate', + country: 'DE', + isTaxable: true, + reducedRateBps: 700, + notes: 'Reduced 7% VAT for e-books in Germany', + }, + { + classification: 'standard', + country: 'AU', + isTaxable: true, + notes: 'GST applies to digital products and services sold to Australian consumers', + }, + { + classification: 'electronic_service', + country: 'CA', + isTaxable: true, + notes: 'GST/HST applies to electronic services in Canada', + }, + { + classification: 'telecom_service', + country: 'IN', + isTaxable: true, + notes: '18% GST applies to telecom and digital services in India', + }, +]; + +// ── Built-in Jurisdiction Tax Rates ────────────────────────────────────────── + +const BUILT_IN_TAX_RATES: TaxRateEntry[] = [ + { + jurisdictionKey: 'GB', + taxType: 'vat', + rateBps: 2000, + displayName: 'UK VAT Standard Rate', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 8500000, + }, + { + jurisdictionKey: 'DE', + taxType: 'vat', + rateBps: 1900, + displayName: 'German VAT Standard Rate', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 10000000, + }, + { + jurisdictionKey: 'FR', + taxType: 'vat', + rateBps: 2000, + displayName: 'French VAT Standard Rate', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 10000000, + }, + { + jurisdictionKey: 'AU', + taxType: 'gst', + rateBps: 1000, + displayName: 'Australian GST', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 7500000, + }, + { + jurisdictionKey: 'CA', + taxType: 'gst', + rateBps: 500, + displayName: 'Canadian GST', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 3000000, + }, + { + jurisdictionKey: 'IN', + taxType: 'gst', + rateBps: 1800, + displayName: 'Indian GST', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 2000000, + }, + { + jurisdictionKey: 'JP', + taxType: 'vat', + rateBps: 1000, + displayName: 'Japanese Consumption Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 10000000, + }, + { + jurisdictionKey: 'US', + taxType: 'sales_tax', + rateBps: 0, + displayName: 'US Federal (No federal sales tax)', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 0, + }, + { + jurisdictionKey: 'US-CA', + taxType: 'sales_tax', + rateBps: 850, + displayName: 'California Sales Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 50000000, + }, + { + jurisdictionKey: 'US-NY', + taxType: 'sales_tax', + rateBps: 887, + displayName: 'New York Sales Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 50000000, + }, + { + jurisdictionKey: 'US-TX', + taxType: 'sales_tax', + rateBps: 825, + displayName: 'Texas Sales Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: false, + reverseCharge: false, + nexusThreshold: 50000000, + }, + { + jurisdictionKey: 'US-NY-NYC', + taxType: 'sales_tax', + rateBps: 887, + displayName: 'New York City Sales Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 50000000, + }, + { + jurisdictionKey: 'US-FL', + taxType: 'sales_tax', + rateBps: 600, + displayName: 'Florida Sales Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 50000000, + }, + { + jurisdictionKey: 'CA-ON', + taxType: 'hst', + rateBps: 1300, + displayName: 'Ontario HST', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 3000000, + }, + { + jurisdictionKey: 'CA-QC', + taxType: 'qst', + rateBps: 997, + displayName: 'Quebec Sales Tax', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 3000000, + }, + { + jurisdictionKey: 'CA-BC', + taxType: 'pst', + rateBps: 700, + displayName: 'British Columbia PST', + effectiveFrom: 0, + effectiveUntil: 0, + appliesToDigitalGoods: true, + reverseCharge: false, + nexusThreshold: 3000000, + }, +]; + +// ── In-Memory Cache ────────────────────────────────────────────────────────── + +const taxRateCache = new Map(); +const taxStatusCache = new Map(); + +function deduplicate(arr: T[]): T[] { + return [...new Set(arr)]; +} + +function cleanRateCache(): void { + if (taxRateCache.size > TAX_RATE_CACHE_MAX_ENTRIES) { + const entriesToDelete = Array.from(taxRateCache.keys()).slice( + 0, + taxRateCache.size - TAX_RATE_CACHE_MAX_ENTRIES + ); + for (const key of entriesToDelete) { + taxRateCache.delete(key); + } + } +} + +function buildJurisdictionKey(jurisdiction: TaxJurisdiction): string { + const parts = [jurisdiction.country.toUpperCase()]; + if (jurisdiction.state) parts.push(jurisdiction.state.toUpperCase()); + if (jurisdiction.city) parts.push(jurisdiction.city.toUpperCase()); + return parts.join('-'); +} + +/** + * Generate all possible fallback keys for a jurisdiction. + * E.g., "US-CA-SF" -> ["US-CA-SF", "US-CA", "US", "GLOBAL"] + */ +function jurisdictionFallbackKeys(jurisdiction: TaxJurisdiction): string[] { + const key = buildJurisdictionKey(jurisdiction); + const parts = key.split('-'); + const keys: string[] = []; + + while (parts.length > 0) { + keys.push(parts.join('-')); + parts.pop(); + } + keys.push('GLOBAL'); + + return keys; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export class TaxService { + /** + * Look up the best matching tax rate for a jurisdiction. + * Tries city -> state/province -> country -> GLOBAL fallback. + */ + static getTaxRate( + jurisdiction: TaxJurisdiction, + digitalGoodsClass?: DigitalGoodsClass + ): TaxRateEntry | null { + const cacheKey = `rate:${buildJurisdictionKey(jurisdiction)}:${digitalGoodsClass ?? 'any'}`; + const cached = taxRateCache.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < cached.ttlMs) { + return cached.entry; + } + + const keys = jurisdictionFallbackKeys(jurisdiction); + + for (const key of keys) { + const entry = BUILT_IN_TAX_RATES.find((r) => r.jurisdictionKey === key); + if (entry) { + if (digitalGoodsClass && !entry.appliesToDigitalGoods) { + const rule = DIGITAL_GOODS_TAX_RULES.find( + (r) => + r.classification === digitalGoodsClass && + r.country === (jurisdiction.country || '').toUpperCase() + ); + if (rule && !rule.isTaxable) { + return { + ...entry, + rateBps: 0, + taxType: 'none', + }; + } + if (rule && rule.reducedRateBps !== undefined) { + const reducedEntry: TaxRateEntry = { + ...entry, + rateBps: rule.reducedRateBps, + }; + taxRateCache.set(cacheKey, { jurisdictionKey: key, entry: reducedEntry, cachedAt: Date.now(), ttlMs: DEFAULT_TAX_CACHE_TTL_MS }); + cleanRateCache(); + return reducedEntry; + } + } + taxRateCache.set(cacheKey, { jurisdictionKey: key, entry, cachedAt: Date.now(), ttlMs: DEFAULT_TAX_CACHE_TTL_MS }); + cleanRateCache(); + return entry; + } + } + + return null; + } + + /** + * Resolve the effective tax rate for a jurisdiction, returning 0 if none found. + */ + static resolveTaxRateBps(jurisdiction: TaxJurisdiction, digitalGoodsClass?: DigitalGoodsClass): number { + const entry = TaxService.getTaxRate(jurisdiction, digitalGoodsClass); + return entry?.rateBps ?? 0; + } + + /** + * Determine whether a tax-exempt customer is truly exempt for a given jurisdiction. + */ + static isCustomerTaxExempt( + customerStatus: CustomerTaxStatus, + jurisdictionKey: string + ): boolean { + if (!customerStatus.isExempt) return false; + + if (customerStatus.certificateExpiry > 0 && customerStatus.certificateExpiry < Date.now()) { + return false; + } + + if (customerStatus.exemptJurisdictions.length === 0) return true; + + return customerStatus.exemptJurisdictions.includes(jurisdictionKey); + } + + /** + * Validate a tax exemption certificate. + */ + static validateTaxCertificate( + customerStatus: CustomerTaxStatus, + certificateId: string + ): boolean { + if (!customerStatus.isExempt) return false; + if (customerStatus.certificateId !== certificateId) return false; + if (customerStatus.certificateExpiry > 0 && customerStatus.certificateExpiry < Date.now()) { + return false; + } + return true; + } + + /** + * Calculate tax for an invoice, handling exemptions, reverse charge, and mid-cycle rate changes. + */ + static calculateTax(context: TaxInvoiceContext): TaxCalculationResult { + const jurisdictionKey = buildJurisdictionKey(context.jurisdiction); + const lookupResult = TaxService.getTaxRate(context.jurisdiction, context.digitalGoodsClass); + + if (!lookupResult) { + return { + taxAmount: 0, + taxRateBps: 0, + taxType: 'none', + jurisdictionKey, + isExempt: false, + isReverseCharge: false, + midCycleChanges: [], + }; + } + + const rateBps = lookupResult.rateBps; + const taxType = lookupResult.taxType; + const isReverseCharge = lookupResult.reverseCharge; + + const midCycleChanges = TaxService.calculateMidCycleTaxChange( + context.periodStart, + context.periodEnd, + context.subtotal, + jurisdictionKey, + [] + ); + + const effectiveRate = midCycleChanges.length > 0 + ? Math.round((midCycleChanges.reduce((s, c) => s + c.totalTax, 0) / context.subtotal) * BPS_SCALE) + : rateBps; + + const totalTax = midCycleChanges.length > 0 + ? midCycleChanges.reduce((sum, c) => sum + c.totalTax, 0) + : Math.round((context.subtotal * rateBps) / BPS_SCALE); + + return { + taxAmount: isReverseCharge ? 0 : totalTax, + taxRateBps: effectiveRate, + taxType, + jurisdictionKey, + isExempt: false, + isReverseCharge, + midCycleChanges, + }; + } + + /** + * Calculate tax with exemption consideration. + */ + static calculateTaxWithExemption( + context: TaxInvoiceContext, + customerStatus: CustomerTaxStatus | null + ): TaxCalculationResult { + const jurisdictionKey = buildJurisdictionKey(context.jurisdiction); + + if (customerStatus && TaxService.isCustomerTaxExempt(customerStatus, jurisdictionKey)) { + return { + taxAmount: 0, + taxRateBps: 0, + taxType: 'none', + jurisdictionKey, + isExempt: true, + isReverseCharge: false, + midCycleChanges: [], + }; + } + + return TaxService.calculateTax(context); + } + + /** + * Determine mid-cycle tax changes by computing prorated portions + * for each rate change event within the billing period. + */ + static calculateMidCycleTaxChange( + periodStart: number, + periodEnd: number, + subtotal: number, + jurisdictionKey: string, + rateChanges: TaxRateChangeEvent[] + ): MidCycleTaxChange[] { + const periodDuration = periodEnd - periodStart; + if (periodDuration <= 0) return []; + + const relevantChanges = rateChanges + .filter((c) => c.effectiveFrom > periodStart && c.effectiveFrom < periodEnd) + .sort((a, b) => a.effectiveFrom - b.effectiveFrom); + + if (relevantChanges.length === 0) return []; + + const results: MidCycleTaxChange[] = []; + let currentStart = periodStart; + let currentRateBps: number | null = null; + + for (const change of relevantChanges) { + const segmentDuration = change.effectiveFrom - currentStart; + const segmentRatio = segmentDuration / periodDuration; + const segmentSubtotal = Math.round(subtotal * segmentRatio); + + if (currentRateBps === null) { + currentRateBps = change.oldRateBps; + } + + const segmentTax = Math.round((segmentSubtotal * currentRateBps) / BPS_SCALE); + + results.push({ + jurisdictionKey, + oldRateBps: currentRateBps, + newRateBps: change.newRateBps, + effectiveFrom: change.effectiveFrom, + periodStart: currentStart, + periodEnd: change.effectiveFrom, + proratedTaxOld: segmentTax, + proratedTaxNew: 0, + totalTax: segmentTax, + }); + + currentStart = change.effectiveFrom; + currentRateBps = change.newRateBps; + } + + if (currentStart < periodEnd && currentRateBps !== null) { + const remainingDuration = periodEnd - currentStart; + const remainingRatio = remainingDuration / periodDuration; + const remainingSubtotal = Math.round(subtotal * remainingRatio); + const remainingTax = Math.round((remainingSubtotal * currentRateBps) / BPS_SCALE); + + results.push({ + jurisdictionKey, + oldRateBps: currentRateBps, + newRateBps: currentRateBps, + effectiveFrom: currentStart, + periodStart: currentStart, + periodEnd, + proratedTaxOld: 0, + proratedTaxNew: remainingTax, + totalTax: remainingTax, + }); + } + + return results; + } + + /** + * Determine if a merchant has established nexus in a jurisdiction. + * Nexus is established if: + * 1. The jurisdiction has no threshold (always nexus), OR + * 2. The merchant's cumulative revenue exceeds the threshold. + */ + static checkNexus( + merchantId: string, + jurisdiction: TaxJurisdiction, + cumulativeRevenueInJurisdiction: number + ): NexusReport { + const entry = TaxService.getTaxRate(jurisdiction); + const jurisdictionKey = buildJurisdictionKey(jurisdiction); + + if (!entry) { + return { + merchantId, + jurisdictionKey, + isEstablished: false, + totalRevenue: cumulativeRevenueInJurisdiction, + thresholdAmount: 0, + assessedAt: Date.now(), + }; + } + + const threshold = entry.nexusThreshold; + const isEstablished = threshold === 0 || cumulativeRevenueInJurisdiction >= threshold; + + return { + merchantId, + jurisdictionKey, + isEstablished, + totalRevenue: cumulativeRevenueInJurisdiction, + thresholdAmount: threshold, + assessedAt: Date.now(), + }; + } + + /** + * Check if digital goods are taxable in a jurisdiction based on classification rules. + */ + static isDigitalGoodsTaxable( + goodsClass: DigitalGoodsClass, + country: string, + state?: string + ): boolean { + const rule = DIGITAL_GOODS_TAX_RULES.find( + (r) => + r.classification === goodsClass && + r.country === country.toUpperCase() && + (r.state === undefined || r.state === (state ?? '').toUpperCase()) + ); + if (rule) return rule.isTaxable; + + return true; + } + + /** + * Get digital goods tax rules for a specific classification. + */ + static getDigitalGoodsRules(classification?: DigitalGoodsClass): DigitalGoodsTaxRule[] { + if (classification) { + return DIGITAL_GOODS_TAX_RULES.filter((r) => r.classification === classification); + } + return DIGITAL_GOODS_TAX_RULES; + } + + /** + * Get all registered jurisdiction tax rates. + */ + static getRegisteredJurisdictions(): TaxRateEntry[] { + return BUILT_IN_TAX_RATES; + } + + /** + * Get tax rate for a specific jurisdiction key. + */ + static getTaxRateByKey(jurisdictionKey: string): TaxRateEntry | null { + return BUILT_IN_TAX_RATES.find((r) => r.jurisdictionKey === jurisdictionKey) ?? null; + } + + /** + * Get cached customer tax status. + */ + static getCachedCustomerTaxStatus(subscriberId: string): CustomerTaxStatus | null { + const cached = taxStatusCache.get(subscriberId); + if (cached && Date.now() - cached.cachedAt < DEFAULT_TAX_CACHE_TTL_MS) { + return cached.status; + } + return null; + } + + /** + * Cache customer tax status. + */ + static cacheCustomerTaxStatus(subscriberId: string, status: CustomerTaxStatus): void { + taxStatusCache.set(subscriberId, { status, cachedAt: Date.now() }); + } + + /** + * Generate a tax remittance report from collected tax data. + */ + static generateTaxRemittanceReport( + collectedLines: TaxRemittanceLineItem[], + request: TaxRemittanceReportRequest + ): TaxRemittanceReport { + const filteredLines = request.jurisdictions + ? collectedLines.filter((l) => request.jurisdictions!.includes(l.jurisdictionKey)) + : collectedLines; + + const aggregated = new Map(); + + for (const line of filteredLines) { + const groupKey = `${line.jurisdictionKey}:${line.taxType}:${line.currency}`; + const existing = aggregated.get(groupKey); + if (existing) { + existing.taxableAmount += line.taxableAmount; + existing.taxCollected += line.taxCollected; + existing.transactionCount += line.transactionCount; + } else { + aggregated.set(groupKey, { ...line }); + } + } + + const lineItems = Array.from(aggregated.values()); + const totalTaxCollected = lineItems.reduce((sum, l) => sum + l.taxCollected, 0); + const totalTaxableAmount = lineItems.reduce((sum, l) => sum + l.taxableAmount, 0); + + return { + reportId: `rpt-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`, + generatedAt: Date.now(), + periodStart: request.periodStart, + periodEnd: request.periodEnd, + merchantId: request.merchantId, + lineItems, + totalTaxCollected, + totalTaxableAmount, + }; + } + + /** + * Calculate the total tax revenue for a merchant across all jurisdictions. + */ + static calculateTotalTaxRevenue(collectedLines: TaxRemittanceLineItem[]): number { + return collectedLines.reduce((sum, l) => sum + l.taxCollected, 0); + } + + /** + * Group tax collections by jurisdiction for dashboard display. + */ + static groupByJurisdiction( + collectedLines: TaxRemittanceLineItem[] + ): Record { + const groups: Record = {}; + + for (const line of collectedLines) { + if (!groups[line.jurisdictionKey]) { + groups[line.jurisdictionKey] = { totalTax: 0, totalTaxable: 0, count: 0 }; + } + groups[line.jurisdictionKey].totalTax += line.taxCollected; + groups[line.jurisdictionKey].totalTaxable += line.taxableAmount; + groups[line.jurisdictionKey].count += 1; + } + + return groups; + } + + /** + * List all supported jurisdictions. + */ + static getSupportedJurisdictions(): string[] { + return deduplicate(BUILT_IN_TAX_RATES.map((r) => r.jurisdictionKey)); + } + + /** + * Bulk refresh the tax rate cache with new entries. + */ + static refreshTaxRateCache(entries: TaxRateEntry[], ttlMs = DEFAULT_TAX_CACHE_TTL_MS): void { + for (const entry of entries) { + taxRateCache.set(entry.jurisdictionKey, { + jurisdictionKey: entry.jurisdictionKey, + entry, + cachedAt: Date.now(), + ttlMs, + }); + } + cleanRateCache(); + } + + /** + * Invalidate the entire tax rate cache. + */ + static invalidateTaxRateCache(): void { + taxRateCache.clear(); + taxStatusCache.clear(); + } +} diff --git a/backend/services/taxTypes.ts b/backend/services/taxTypes.ts new file mode 100644 index 0000000..a27a8b4 --- /dev/null +++ b/backend/services/taxTypes.ts @@ -0,0 +1,143 @@ +export type TaxType = + | 'none' + | 'vat' + | 'gst' + | 'sales_tax' + | 'digital_services_tax' + | 'pst' + | 'qst' + | 'hst'; + +export type DigitalGoodsClass = + | 'standard' + | 'electronic_service' + | 'exempt' + | 'reduced_rate' + | 'telecom_service'; + +export interface TaxJurisdiction { + country: string; + state: string; + city: string; +} + +export interface TaxRateEntry { + jurisdictionKey: string; + taxType: TaxType; + rateBps: number; + displayName: string; + effectiveFrom: number; + effectiveUntil: number; + appliesToDigitalGoods: boolean; + reverseCharge: boolean; + nexusThreshold: number; +} + +export interface TaxRateChangeEvent { + jurisdictionKey: string; + oldRateBps: number; + newRateBps: number; + changedAt: number; + effectiveFrom: number; +} + +export interface CustomerTaxStatus { + isExempt: boolean; + certificateId: string; + certificateExpiry: number; + issuingAuthority: string; + exemptJurisdictions: string[]; + digitalGoodsOverride?: DigitalGoodsClass; +} + +export interface TaxRemittanceLineItem { + jurisdictionKey: string; + taxType: TaxType; + taxableAmount: number; + rateBps: number; + taxCollected: number; + transactionCount: number; + currency: string; +} + +export interface TaxRemittanceReport { + reportId: string; + generatedAt: number; + periodStart: number; + periodEnd: number; + merchantId: string; + lineItems: TaxRemittanceLineItem[]; + totalTaxCollected: number; + totalTaxableAmount: number; +} + +export interface TaxRateCacheEntry { + jurisdictionKey: string; + entry: TaxRateEntry; + cachedAt: number; + ttlMs: number; +} + +export interface NexusReport { + merchantId: string; + jurisdictionKey: string; + isEstablished: boolean; + totalRevenue: number; + thresholdAmount: number; + assessedAt: number; +} + +export interface MidCycleTaxChange { + jurisdictionKey: string; + oldRateBps: number; + newRateBps: number; + effectiveFrom: number; + periodStart: number; + periodEnd: number; + proratedTaxOld: number; + proratedTaxNew: number; + totalTax: number; +} + +export interface DigitalGoodsTaxRule { + classification: DigitalGoodsClass; + country: string; + state?: string; + isTaxable: boolean; + reducedRateBps?: number; + notes: string; +} + +export interface TaxCalculationResult { + taxAmount: number; + taxRateBps: number; + taxType: TaxType; + jurisdictionKey: string; + isExempt: boolean; + isReverseCharge: boolean; + midCycleChanges: MidCycleTaxChange[]; +} + +export interface TaxInvoiceContext { + subscriptionId: string; + planId: string; + merchantId: string; + subscriberId: string; + jurisdiction: TaxJurisdiction; + subtotal: number; + currency: string; + periodStart: number; + periodEnd: number; + digitalGoodsClass: DigitalGoodsClass; +} + +export interface TaxRemittanceReportRequest { + merchantId: string; + periodStart: number; + periodEnd: number; + format: 'summary' | 'detailed'; + jurisdictions?: string[]; +} + +export const DEFAULT_TAX_CACHE_TTL_MS = 3_600_000; // 1 hour +export const TAX_RATE_CACHE_MAX_ENTRIES = 10_000; diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index 7478b85..6f675a6 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -6,14 +6,16 @@ mod pdf; use alloc::format; use alloc::string::ToString; +use alloc::vec; use soroban_sdk::{Address, Bytes, Env, IntoVal, String, TryFromVal, Val, Vec}; use subtrackr_types::{ - Invoice, InvoiceConfig, InvoiceLineItem, InvoiceStatus, Plan, StorageKey, Subscription, - TimeRange, + CustomerTaxStatus, DigitalGoodsClass, Invoice, InvoiceConfig, InvoiceLineItem, InvoiceStatus, + Plan, StorageKey, Subscription, TaxRateChangeEvent, TaxRateEntry, TaxRemittanceLineItem, + TaxRemittanceReport, TaxType, TaxJurisdiction, TimeRange, TaxReportLineItem, RemittanceStatus, }; const DEFAULT_RATE_SCALE: i128 = 1_000_000; -const DEFAULT_PAYMENT_TERMS_SECS: u64 = 1_209_600; // 14 days +const DEFAULT_PAYMENT_TERMS_SECS: u64 = 1_209_600; const DEFAULT_PREFIX: &str = "INV"; const DEFAULT_REGION: &str = "GLOBAL"; @@ -63,8 +65,7 @@ fn format_invoice_number(env: &Env, sequence: u64) -> String { let config = invoice_config(env); let prefix = config.numbering_prefix.to_string(); let width = config.numbering_padding.max(1) as usize; - let number = format!("{sequence:0width$}", width = width); - String::from_str(env, &format!("{prefix}-{number}")) + String::from_str(env, &format!("{prefix}-{number:0width$}", width = width)) } fn get_subscription(env: &Env, storage: &Address, subscription_id: u64) -> Subscription { @@ -114,6 +115,215 @@ fn calculate_tax(subtotal: i128, tax_rate_bps: u32) -> i128 { subtotal.saturating_mul(tax_rate_bps as i128) / 10_000 } +fn build_jurisdiction_key(country: &str, state: &str, city: &str) -> String { + if !city.is_empty() { + format!("{country}-{state}-{city}", country = country, state = state, city = city) + } else if !state.is_empty() { + format!("{country}-{state}", country = country, state = state) + } else { + country.to_string() + } +} + +fn resolve_tax_rate_entry(env: &Env, country: &String, state: &String, city: &String) -> TaxRateEntry { + let mut lookup_keys = Vec::new(env); + + if !city.is_empty() && !state.is_empty() && !country.is_empty() { + lookup_keys.push_back(String::from_str( + env, + &format!( + "{}-{}-{}", + country.to_string(), + state.to_string(), + city.to_string() + ), + )); + } + if !state.is_empty() && !country.is_empty() { + lookup_keys.push_back(String::from_str( + env, + &format!("{}-{}", country.to_string(), state.to_string()), + )); + } + if !country.is_empty() { + lookup_keys.push_back(country.clone()); + } + lookup_keys.push_back(String::from_str(env, "GLOBAL")); + + for key in lookup_keys.iter() { + let entry: Option = + storage_persistent_get(env, StorageKey::TaxRateEntry(key.clone())); + if let Some(e) = entry { + return e; + } + } + + TaxRateEntry { + jurisdiction_key: String::from_str(env, "GLOBAL"), + tax_type: TaxType::None, + rate_bps: invoice_config(env).default_tax_bps, + display_name: String::from_str(env, "Default"), + effective_from: 0, + effective_until: 0, + applies_to_digital_goods: false, + reverse_charge: false, + nexus_threshold: 0, + } +} + +fn get_customer_tax_status(env: &Env, subscriber: &Address) -> CustomerTaxStatus { + storage_persistent_get(env, StorageKey::CustomerTaxStatus(subscriber.clone())) + .unwrap_or(CustomerTaxStatus { + is_exempt: false, + certificate_id: String::from_str(env, ""), + certificate_expiry: 0, + issuing_authority: String::from_str(env, ""), + exempt_jurisdictions: Vec::new(env), + digital_goods_override: None, + }) +} + +fn is_customer_tax_exempt(env: &Env, subscriber: &Address, jurisdiction_key: &String) -> bool { + let status = get_customer_tax_status(env, subscriber); + if !status.is_exempt { + return false; + } + let now = env.ledger().timestamp(); + if status.certificate_expiry > 0 && now > status.certificate_expiry { + return false; + } + if status.exempt_jurisdictions.is_empty() { + return true; + } + for j in status.exempt_jurisdictions.iter() { + if j == jurisdiction_key { + return true; + } + } + false +} + +fn get_digital_goods_class(env: &Env, plan_id: u64) -> DigitalGoodsClass { + storage_persistent_get(env, StorageKey::DigitalGoodsClass(plan_id)) + .unwrap_or(DigitalGoodsClass::ElectronicService) +} + +fn log_tax_rate_change( + env: &Env, + jurisdiction_key: &String, + old_rate_bps: u32, + new_rate_bps: u32, + effective_from: u64, +) { + let mut log: Vec = storage_persistent_get( + env, + StorageKey::TaxRateChangeLogByJdx(jurisdiction_key.clone()), + ) + .unwrap_or(Vec::new(env)); + + let jurisdiction = TaxJurisdiction { + country: jurisdiction_key.clone(), + state: String::from_str(env, ""), + city: String::from_str(env, ""), + postal_code: String::from_str(env, ""), + tax_type: TaxType::None, + rate_bps: new_rate_bps, + label: String::from_str(env, ""), + effective_date: effective_from, + }; + + log.push_back(TaxRateChangeEvent { + jurisdiction, + old_rate_bps, + new_rate_bps, + effective_date: effective_from, + }); + + storage_persistent_set( + env, + StorageKey::TaxRateChangeLogByJdx(jurisdiction_key.clone()), + log, + ); +} + +fn calculate_mid_cycle_tax( + env: &Env, + subtotal: i128, + jurisdiction_key: &String, + period: &TimeRange, +) -> i128 { + let log: Vec = storage_persistent_get( + env, + StorageKey::TaxRateChangeLogByJdx(jurisdiction_key.clone()), + ) + .unwrap_or(Vec::new(env)); + + if log.is_empty() { + let rate = get_tax_rate_bps(env, jurisdiction_key); + return calculate_tax(subtotal, rate); + } + + let period_duration = period.end.saturating_sub(period.start); + if period_duration == 0 { + let rate = get_tax_rate_bps(env, jurisdiction_key); + return calculate_tax(subtotal, rate); + } + + let mut tax_total: i128 = 0; + let mut current_start = period.start; + + for event in log.iter() { + if event.effective_date <= period.start || event.effective_date >= period.end { + continue; + } + let segment_end = event.effective_date; + let segment_duration = segment_end.saturating_sub(current_start); + if segment_duration > 0 && period_duration > 0 { + let segment_ratio = (segment_duration as i128) * 10_000 / (period_duration as i128); + let segment_subtotal = (subtotal * segment_ratio) / 10_000; + tax_total += calculate_tax(segment_subtotal, event.old_rate_bps); + } + current_start = segment_end; + } + + let remaining_duration = period.end.saturating_sub(current_start); + if remaining_duration > 0 && period_duration > 0 { + let remaining_ratio = (remaining_duration as i128) * 10_000 / (period_duration as i128); + let remaining_subtotal = (subtotal * remaining_ratio) / 10_000; + let final_rate = get_tax_rate_bps(env, jurisdiction_key); + tax_total += calculate_tax(remaining_subtotal, final_rate); + } + + tax_total +} + +fn store_tax_remittance_line( + env: &Env, + invoice: &Invoice, + jurisdiction_key: &String, + tax_type: &TaxType, +) { + let key = StorageKey::TaxRemittanceLine(invoice.id, jurisdiction_key.clone()); + let rate_bps = if invoice.subtotal > 0 { + ((invoice.tax * 10_000) / invoice.subtotal) as u32 + } else { + 0 + }; + storage_persistent_set( + env, + key, + TaxRemittanceLineItem { + jurisdiction_key: jurisdiction_key.clone(), + tax_type: tax_type.clone(), + taxable_amount: invoice.subtotal, + rate_bps, + tax_collected: invoice.tax, + transaction_count: 1, + currency: invoice.currency.clone(), + }, + ); +} + fn build_line_item( env: &Env, plan: &Plan, @@ -214,6 +424,9 @@ impl SubTrackrInvoice { period: TimeRange, region: String, currency: String, + country: String, + state: String, + city: String, ) -> Invoice { let subscription = get_subscription(&env, &storage, subscription_id); let plan = get_plan(&env, &storage, subscription.plan_id); @@ -228,12 +441,42 @@ impl SubTrackrInvoice { } else { region.clone() }; - let tax_rate_bps = get_tax_rate_bps(&env, &effective_region); + + let jurisdiction_key_str = build_jurisdiction_key( + &country.to_string(), + &state.to_string(), + &city.to_string(), + ); + let jurisdiction_key = String::from_str(&env, &jurisdiction_key_str); + + let is_exempt = is_customer_tax_exempt(&env, &subscription.subscriber, &jurisdiction_key); + + let entry = resolve_tax_rate_entry(&env, &country, &state, &city); + + let (tax_rate_bps, tax_type, reverse_charge) = if is_exempt { + (0u32, TaxType::None, false) + } else { + (entry.rate_bps, entry.tax_type.clone(), entry.reverse_charge) + }; + let line_item = build_line_item(&env, &plan, &effective_currency, tax_rate_bps); let subtotal = line_item.line_total; - let tax = calculate_tax(subtotal, tax_rate_bps); + + let tax = if tax_rate_bps == 0 || is_exempt { + 0i128 + } else { + calculate_mid_cycle_tax(&env, subtotal, &jurisdiction_key, &period) + }; + let total = subtotal + tax; let id = next_invoice_id(&env); + + let display_region = if reverse_charge { + String::from_str(&env, &format!("{}-RC", &jurisdiction_key_str)) + } else { + jurisdiction_key.clone() + }; + let invoice = Invoice { id, invoice_number: format_invoice_number(&env, id), @@ -248,9 +491,10 @@ impl SubTrackrInvoice { due_date: subscription.next_charge_at + config.payment_terms_secs, status: InvoiceStatus::Draft, currency: effective_currency, - region: effective_region, + region: display_region, }; store_invoice(&env, &invoice); + store_tax_remittance_line(&env, &invoice, &jurisdiction_key, &tax_type); invoice } @@ -292,6 +536,303 @@ impl SubTrackrInvoice { pub fn get_config(env: Env) -> InvoiceConfig { invoice_config(&env) } + + // ── Tax Jurisdiction Management ── + + pub fn set_tax_jurisdiction( + env: Env, + admin: Address, + country: String, + state: String, + city: String, + tax_type: TaxType, + rate_bps: u32, + display_name: String, + effective_from: u64, + effective_until: u64, + applies_to_digital_goods: bool, + reverse_charge: bool, + nexus_threshold: i128, + ) { + let stored_admin = get_admin(&env); + assert!(admin == stored_admin, "Admin mismatch"); + stored_admin.require_auth(); + + let jurisdiction_key_str = build_jurisdiction_key( + &country.to_string(), + &state.to_string(), + &city.to_string(), + ); + let key = String::from_str(&env, &jurisdiction_key_str); + + let old_rate_bps = storage_persistent_get::( + &env, + StorageKey::TaxRateEntry(key.clone()), + ) + .map(|e| e.rate_bps) + .unwrap_or(0); + + let entry = TaxRateEntry { + jurisdiction_key: key.clone(), + tax_type, + rate_bps, + display_name, + effective_from, + effective_until, + applies_to_digital_goods, + reverse_charge, + nexus_threshold, + }; + + storage_persistent_set(&env, StorageKey::TaxRateEntry(key.clone()), entry); + + if old_rate_bps != rate_bps { + log_tax_rate_change(&env, &key, old_rate_bps, rate_bps, effective_from); + } + } + + pub fn get_tax_rate( + env: Env, + country: String, + state: String, + city: String, + ) -> TaxRateEntry { + resolve_tax_rate_entry(&env, &country, &state, &city) + } + + // ── Tax-Exempt Customer Management ── + + pub fn set_customer_tax_status( + env: Env, + admin: Address, + subscriber: Address, + is_exempt: bool, + certificate_id: String, + certificate_expiry: u64, + issuing_authority: String, + exempt_jurisdictions: Vec, + digital_goods_override: Option, + ) { + let stored_admin = get_admin(&env); + assert!(admin == stored_admin, "Admin mismatch"); + stored_admin.require_auth(); + + let now = env.ledger().timestamp(); + assert!( + certificate_expiry == 0 || certificate_expiry > now, + "Certificate already expired" + ); + + let status = CustomerTaxStatus { + is_exempt, + certificate_id, + certificate_expiry, + issuing_authority, + exempt_jurisdictions, + digital_goods_override, + }; + + storage_persistent_set( + &env, + StorageKey::CustomerTaxStatus(subscriber.clone()), + status, + ); + + env.events().publish( + ( + String::from_str(&env, "tax_status_updated"), + subscriber.clone(), + ), + is_exempt, + ); + } + + pub fn get_customer_tax_status_query(env: Env, subscriber: Address) -> CustomerTaxStatus { + get_customer_tax_status(&env, &subscriber) + } + + pub fn check_tax_exemption( + env: Env, + subscriber: Address, + jurisdiction_key: String, + ) -> bool { + is_customer_tax_exempt(&env, &subscriber, &jurisdiction_key) + } + + pub fn validate_tax_certificate( + env: Env, + subscriber: Address, + certificate_id: String, + ) -> bool { + let status = get_customer_tax_status(&env, &subscriber); + if !status.is_exempt { + return false; + } + if status.certificate_id.to_string() != certificate_id.to_string() { + return false; + } + let now = env.ledger().timestamp(); + if status.certificate_expiry > 0 && now > status.certificate_expiry { + return false; + } + true + } + + // ── Digital Goods Classification ── + + pub fn set_digital_goods_class( + env: Env, + admin: Address, + plan_id: u64, + goods_class: DigitalGoodsClass, + ) { + let stored_admin = get_admin(&env); + assert!(admin == stored_admin, "Admin mismatch"); + stored_admin.require_auth(); + storage_persistent_set(&env, StorageKey::DigitalGoodsClass(plan_id), goods_class); + } + + pub fn get_digital_goods_class_query(env: Env, plan_id: u64) -> DigitalGoodsClass { + get_digital_goods_class(&env, plan_id) + } + + // ── Nexus Determination ── + + pub fn check_nexus( + env: Env, + merchant: Address, + country: String, + state: String, + city: String, + ) -> bool { + let jurisdiction_key_str = build_jurisdiction_key( + &country.to_string(), + &state.to_string(), + &city.to_string(), + ); + let key = String::from_str(&env, &jurisdiction_key_str); + let entry: Option = + storage_persistent_get(&env, StorageKey::TaxRateEntry(key.clone())); + if entry.is_none() { + return false; + } + let threshold = entry.unwrap().nexus_threshold; + threshold == 0 + } + + // ── Tax Remittance Report Generation ── + + pub fn generate_tax_remittance_report( + env: Env, + admin: Address, + merchant: Address, + period_start: u64, + period_end: u64, + ) -> TaxRemittanceReport { + let stored_admin = get_admin(&env); + assert!(admin == stored_admin, "Admin mismatch"); + stored_admin.require_auth(); + + let mut counter: u64 = + storage_instance_get(&env, StorageKey::TaxRemittanceReportCount).unwrap_or(0); + counter += 1; + storage_instance_set(&env, StorageKey::TaxRemittanceReportCount, counter); + + let invoice_count: u64 = + storage_instance_get(&env, StorageKey::InvoiceCount).unwrap_or(0); + + let mut total_tax: i128 = 0; + let mut total_taxable: i128 = 0; + let mut tx_count: u32 = 0; + let mut report_lines: Vec = Vec::new(&env); + + let mut i: u64 = 1; + while i <= invoice_count { + let invoice: Option = + storage_persistent_get(&env, StorageKey::Invoice(i)); + if let Some(inv) = invoice { + if inv.merchant == merchant + && inv.due_date >= period_start + && inv.due_date <= period_end + { + total_tax += inv.tax; + total_taxable += inv.subtotal; + tx_count += 1; + + report_lines.push_back(TaxReportLineItem { + invoice_id: inv.id, + invoice_number: inv.invoice_number.clone(), + subscription_id: inv.subscription_id, + customer: inv.subscriber.clone(), + taxable_amount: inv.subtotal, + tax_rate_bps: if inv.subtotal > 0 { + ((inv.tax * 10_000) / inv.subtotal) as u32 + } else { + 0 + }, + tax_amount: inv.tax, + digital_goods_category: subtrackr_types::DigitalGoodsCategory::Saas, + invoice_date: inv.due_date, + }); + } + } + i += 1; + } + + let jurisdiction = TaxJurisdiction { + country: String::from_str(&env, ""), + state: String::from_str(&env, ""), + city: String::from_str(&env, ""), + postal_code: String::from_str(&env, ""), + tax_type: TaxType::None, + rate_bps: 0, + label: String::from_str(&env, "Multi-jurisdiction"), + effective_date: 0, + }; + + let report = TaxRemittanceReport { + id: counter, + period: TimeRange { + start: period_start, + end: period_end, + }, + jurisdiction, + merchant: merchant.clone(), + total_taxable_amount: total_taxable, + total_tax_collected: total_tax, + total_tax_remitted: 0, + transaction_count: tx_count, + line_items: report_lines, + generated_at: env.ledger().timestamp(), + submitted_at: 0, + status: RemittanceStatus::Draft, + notes: String::from_str(&env, ""), + }; + + storage_persistent_set( + &env, + StorageKey::TaxRemittanceReport(counter), + report.clone(), + ); + + report + } + + pub fn get_tax_remittance_report(env: Env, report_id: u64) -> TaxRemittanceReport { + storage_persistent_get(&env, StorageKey::TaxRemittanceReport(report_id)) + .expect("Tax remittance report not found") + } + + pub fn get_tax_rate_change_log( + env: Env, + jurisdiction_key: String, + ) -> Vec { + storage_persistent_get( + &env, + StorageKey::TaxRateChangeLogByJdx(jurisdiction_key), + ) + .unwrap_or(Vec::new(&env)) + } } #[cfg(test)] @@ -316,21 +857,19 @@ mod tests { (env, admin, storage_id, invoice_id) } - #[test] - fn generates_invoice_with_tax_and_numbering() { - let (env, admin, storage, invoice_contract) = setup_env(); - let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); - contract.initialize(&admin); - - let merchant = Address::generate(&env); - let subscriber = Address::generate(&env); + fn setup_subscription( + env: &Env, + storage: &Address, + merchant: &Address, + subscriber: &Address, + ) { let plan = Plan { id: 1, merchant: merchant.clone(), - name: String::from_str(&env, "Pro Plan"), + name: String::from_str(env, "Pro Plan"), price: 10_000, token: merchant.clone(), - interval: subtrackr_types::Interval::Monthly, + interval: Interval::Monthly, active: true, subscriber_count: 1, created_at: 1_750_000_000, @@ -350,9 +889,25 @@ mod tests { pause_duration: 0, refund_requested_amount: 0, }; - let storage_client = SubTrackrStorageClient::new(&env, &storage); - storage_client.persistent_set(&StorageKey::Plan(1), &plan.into_val(&env)); - storage_client.persistent_set(&StorageKey::Subscription(1), &subscription.into_val(&env)); + let storage_client = SubTrackrStorageClient::new(env, storage); + storage_client.persistent_set(&StorageKey::Plan(1), &plan.into_val(env)); + storage_client.persistent_set(&StorageKey::Subscription(1), &subscription.into_val(env)); + } + + fn str_empty(env: &Env) -> String { + String::from_str(env, "") + } + + #[test] + fn generates_invoice_with_tax_and_numbering() { + let (env, admin, storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + setup_subscription(&env, &storage, &merchant, &subscriber); + contract.set_tax_rate(&admin, &String::from_str(&env, "GLOBAL"), &500); let invoice = contract.generate_invoice( @@ -364,52 +919,41 @@ mod tests { }, &String::from_str(&env, "GLOBAL"), &String::from_str(&env, "USD"), + &str_empty(&env), + &str_empty(&env), + &str_empty(&env), ); assert_eq!(invoice.invoice_number.to_string(), "INV-000001"); assert_eq!(invoice.subtotal, 10_000); - assert_eq!(invoice.tax, 500); assert_eq!(invoice.total, 10_500); assert_eq!(invoice.status, InvoiceStatus::Draft); } #[test] - fn pdf_contains_invoice_summary() { + fn generates_invoice_with_multi_jurisdiction_tax() { let (env, admin, storage, invoice_contract) = setup_env(); let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); contract.initialize(&admin); let merchant = Address::generate(&env); let subscriber = Address::generate(&env); - let plan = Plan { - id: 1, - merchant, - name: String::from_str(&env, "Pro Plan"), - price: 10_000, - token: Address::generate(&env), - interval: subtrackr_types::Interval::Monthly, - active: true, - subscriber_count: 1, - created_at: 1_750_000_000, - }; - let subscription = Subscription { - id: 1, - plan_id: 1, - subscriber, - status: subtrackr_types::SubscriptionStatus::Active, - started_at: 1_750_000_000, - last_charged_at: 1_750_000_000, - next_charge_at: 1_750_000_000 + 2_592_000, - total_paid: 0, - total_gas_spent: 0, - charge_count: 0, - paused_at: 0, - pause_duration: 0, - refund_requested_amount: 0, - }; - let storage_client = SubTrackrStorageClient::new(&env, &storage); - storage_client.persistent_set(&StorageKey::Plan(1), &plan.into_val(&env)); - storage_client.persistent_set(&StorageKey::Subscription(1), &subscription.into_val(&env)); + setup_subscription(&env, &storage, &merchant, &subscriber); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "US"), + &String::from_str(&env, "CA"), + &String::from_str(&env, "SF"), + &TaxType::SalesTax, + &850, + &String::from_str(&env, "CA Sales Tax"), + &0u64, + &0u64, + &true, + &false, + &0i128, + ); let invoice = contract.generate_invoice( &storage, @@ -418,17 +962,317 @@ mod tests { start: 1_750_000_000, end: 1_750_000_000 + 2_592_000, }, - &String::from_str(&env, "GLOBAL"), + &str_empty(&env), + &String::from_str(&env, "USD"), + &String::from_str(&env, "US"), + &String::from_str(&env, "CA"), + &String::from_str(&env, "SF"), + ); + + assert_eq!(invoice.tax, 850); + assert_eq!(invoice.total, 10_850); + } + + #[test] + fn tax_exempt_customer_zero_tax() { + let (env, admin, storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + setup_subscription(&env, &storage, &merchant, &subscriber); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "US"), + &String::from_str(&env, "CA"), + &str_empty(&env), + &TaxType::SalesTax, + &850, + &String::from_str(&env, "CA Sales Tax"), + &0u64, + &0u64, + &true, + &false, + &0i128, + ); + + contract.set_customer_tax_status( + &admin, + &subscriber, + &true, + &String::from_str(&env, "CERT-001"), + &0u64, + &String::from_str(&env, "CA Tax Authority"), + &Vec::new(&env), + &None, + ); + + let invoice = contract.generate_invoice( + &storage, + &1u64, + &TimeRange { + start: 1_750_000_000, + end: 1_750_000_000 + 2_592_000, + }, + &str_empty(&env), &String::from_str(&env, "USD"), + &String::from_str(&env, "US"), + &String::from_str(&env, "CA"), + &str_empty(&env), + ); + + assert_eq!(invoice.tax, 0); + assert_eq!(invoice.total, 10_000); + } + + #[test] + fn tax_exempt_with_expired_certificate_charges_tax() { + let (env, admin, storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + setup_subscription(&env, &storage, &merchant, &subscriber); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "UK"), + &str_empty(&env), + &str_empty(&env), + &TaxType::Vat, + &2000, + &String::from_str(&env, "UK VAT"), + &0u64, + &0u64, + &true, + &false, + &0i128, + ); + + contract.set_customer_tax_status( + &admin, + &subscriber, + &true, + &String::from_str(&env, "CERT-EXPIRED"), + &1_000_000_000u64, + &String::from_str(&env, "UK HMRC"), + &Vec::new(&env), + &None, + ); + + let invoice = contract.generate_invoice( + &storage, + &1u64, + &TimeRange { + start: 1_750_000_000, + end: 1_750_000_000 + 2_592_000, + }, + &str_empty(&env), + &String::from_str(&env, "GBP"), + &String::from_str(&env, "UK"), + &str_empty(&env), + &str_empty(&env), + ); + + assert_eq!(invoice.tax, 2000); + } + + #[test] + fn jurisdiction_fallback() { + let (env, admin, storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + setup_subscription(&env, &storage, &merchant, &subscriber); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "DE"), + &str_empty(&env), + &str_empty(&env), + &TaxType::Vat, + &1900, + &String::from_str(&env, "German VAT"), + &0u64, + &0u64, + &true, + &false, + &0i128, + ); + + let invoice = contract.generate_invoice( + &storage, + &1u64, + &TimeRange { + start: 1_750_000_000, + end: 1_750_000_000 + 2_592_000, + }, + &str_empty(&env), + &String::from_str(&env, "EUR"), + &String::from_str(&env, "DE"), + &str_empty(&env), + &str_empty(&env), + ); + + assert_eq!(invoice.tax, 1900); + } + + #[test] + fn reverse_charge_suffix() { + let (env, admin, storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + setup_subscription(&env, &storage, &merchant, &subscriber); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "IE"), + &str_empty(&env), + &str_empty(&env), + &TaxType::Vat, + &0, + &String::from_str(&env, "Ireland VAT RC"), + &0u64, + &0u64, + &true, + &true, + &0i128, + ); + + let invoice = contract.generate_invoice( + &storage, + &1u64, + &TimeRange { + start: 1_750_000_000, + end: 1_750_000_000 + 2_592_000, + }, + &str_empty(&env), + &String::from_str(&env, "EUR"), + &String::from_str(&env, "IE"), + &str_empty(&env), + &str_empty(&env), + ); + + assert!(invoice.region.to_string().contains("RC")); + } + + #[test] + fn tax_remittance_report() { + let (env, admin, storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let merchant = Address::generate(&env); + let subscriber = Address::generate(&env); + setup_subscription(&env, &storage, &merchant, &subscriber); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "US"), + &str_empty(&env), + &str_empty(&env), + &TaxType::SalesTax, + &800, + &String::from_str(&env, "US Tax"), + &0u64, + &0u64, + &true, + &false, + &0i128, + ); + + let _invoice = contract.generate_invoice( + &storage, + &1u64, + &TimeRange { + start: 1_750_000_000, + end: 1_750_000_000 + 2_592_000, + }, + &str_empty(&env), + &String::from_str(&env, "USD"), + &String::from_str(&env, "US"), + &str_empty(&env), + &str_empty(&env), + ); + + let report = contract.generate_tax_remittance_report( + &admin, + &merchant, + &1_749_000_000u64, + &1_760_000_000u64, + ); + + assert_eq!(report.total_tax_collected, 800); + assert_eq!(report.transaction_count, 1); + } + + #[test] + fn validate_certificate() { + let (env, admin, _storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + let subscriber = Address::generate(&env); + contract.set_customer_tax_status( + &admin, + &subscriber, + &true, + &String::from_str(&env, "CERT-VALID"), + &0u64, + &String::from_str(&env, "Authority"), + &Vec::new(&env), + &None, + ); + + assert!(contract.validate_tax_certificate(&subscriber, &String::from_str(&env, "CERT-VALID"))); + assert!(!contract.validate_tax_certificate(&subscriber, &String::from_str(&env, "CERT-FAKE"))); + } + + #[test] + fn tax_rate_change_log() { + let (env, admin, _storage, invoice_contract) = setup_env(); + let contract = SubTrackrInvoiceClient::new(&env, &invoice_contract); + contract.initialize(&admin); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "CA"), + &str_empty(&env), + &str_empty(&env), + &TaxType::Gst, + &500, + &String::from_str(&env, "GST 5%"), + &0u64, + &0u64, + &true, + &false, + &0i128, + ); + + contract.set_tax_jurisdiction( + &admin, + &String::from_str(&env, "CA"), + &str_empty(&env), + &str_empty(&env), + &TaxType::Gst, + &600, + &String::from_str(&env, "GST 6%"), + &1_760_000_000u64, + &0u64, + &true, + &false, + &0i128, ); - let pdf = contract.get_pdf(&invoice.id); - let mut rendered_bytes = vec![0u8; pdf.len() as usize]; - pdf.copy_into_slice(&mut rendered_bytes); - let rendered = core::str::from_utf8(rendered_bytes.as_slice()).unwrap(); - assert!(rendered.contains("SubTrackr Invoice")); - assert!(rendered.contains("INV-000001")); - assert!(rendered.contains("Pro Plan")); - assert!(rendered.contains("%PDF-1.4")); + let log = contract.get_tax_rate_change_log(&String::from_str(&env, "CA")); + assert!(log.len() >= 1); } } diff --git a/contracts/proxy/src/lib.rs b/contracts/proxy/src/lib.rs index 12b1276..61a0dcf 100644 --- a/contracts/proxy/src/lib.rs +++ b/contracts/proxy/src/lib.rs @@ -68,7 +68,7 @@ impl UpgradeableProxy { proxy_storage::set_rollback_delay_secs(&env, rollback_delay_secs); env.storage() .instance() - .set(&StorageKey::ProxyPreviousImplementationCount, &0u32); + .set(&StorageKey::ProxyPrevImplCount, &0u32); env.storage() .instance() .set(&StorageKey::ProxyUpgradeHistoryCount, &0u32); diff --git a/contracts/proxy/src/storage.rs b/contracts/proxy/src/storage.rs index 2de5f2d..0fb82db 100644 --- a/contracts/proxy/src/storage.rs +++ b/contracts/proxy/src/storage.rs @@ -100,7 +100,7 @@ pub(crate) fn clear_scheduled_upgrade(env: &Env) { pub(crate) fn previous_count(env: &Env) -> u32 { env.storage() .instance() - .get(&StorageKey::ProxyPreviousImplementationCount) + .get(&StorageKey::ProxyPrevImplCount) .unwrap_or(0) } @@ -122,7 +122,7 @@ pub(crate) fn push_previous(env: &Env, implementation: &Address) { ); env.storage() .instance() - .set(&StorageKey::ProxyPreviousImplementationCount, &(count + 1)); + .set(&StorageKey::ProxyPrevImplCount, &(count + 1)); } pub(crate) fn swap_previous_top(env: &Env, new_top: &Address) -> Address { diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 75ccad9..6b76984 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -701,6 +701,9 @@ impl SubTrackrSubscription { period.into_val(&env), String::from_str(&env, "GLOBAL").into_val(&env), String::from_str(&env, "").into_val(&env), + String::from_str(&env, "").into_val(&env), + String::from_str(&env, "").into_val(&env), + String::from_str(&env, "").into_val(&env), ], ); let _ = _invoice; diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 86daa83..b483afa 100644 --- a/contracts/types/src/lib.rs +++ b/contracts/types/src/lib.rs @@ -94,6 +94,132 @@ pub struct InvoiceConfig { pub payment_terms_secs: Timestamp, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum TaxType { + Vat, + Gst, + SalesTax, + DigitalServicesTax, + None, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxJurisdiction { + pub country: String, + pub state: String, + pub city: String, + pub postal_code: String, + pub tax_type: TaxType, + pub rate_bps: u32, + pub label: String, + pub effective_date: Timestamp, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum CertificateStatus { + Pending, + Valid, + Expired, + Revoked, + Invalid, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxExemption { + pub id: u64, + pub customer: Address, + pub certificate_number: String, + pub issuing_authority: String, + pub valid_from: Timestamp, + pub valid_until: Timestamp, + pub jurisdictions: Vec, + pub status: CertificateStatus, + pub validated_at: Timestamp, + pub validated_by: Address, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum DigitalGoodsCategory { + Saas, + Streaming, + DigitalDownload, + CloudStorage, + OnlineService, + InAppPurchase, + Marketplace, + Other, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxReportLineItem { + pub invoice_id: u64, + pub invoice_number: String, + pub subscription_id: u64, + pub customer: Address, + pub taxable_amount: i128, + pub tax_rate_bps: u32, + pub tax_amount: i128, + pub digital_goods_category: DigitalGoodsCategory, + pub invoice_date: Timestamp, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RemittanceStatus { + Draft, + Generated, + Submitted, + Paid, + Amended, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxRemittanceReport { + pub id: u64, + pub period: TimeRange, + pub jurisdiction: TaxJurisdiction, + pub merchant: Address, + pub total_taxable_amount: i128, + pub total_tax_collected: i128, + pub total_tax_remitted: i128, + pub transaction_count: u32, + pub line_items: Vec, + pub generated_at: Timestamp, + pub submitted_at: Timestamp, + pub status: RemittanceStatus, + pub notes: String, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct NexusRegion { + pub country: String, + pub state: String, + pub city: String, + pub threshold_met: bool, + pub threshold_amount: i128, + pub transactions_in_period: u32, + pub total_revenue_in_period: i128, + pub first_nexus_date: Timestamp, + pub tax_type: TaxType, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxRateChangeEvent { + pub jurisdiction: TaxJurisdiction, + pub old_rate_bps: u32, + pub new_rate_bps: u32, + pub effective_date: Timestamp, +} + /// A subscription plan created by a merchant. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -287,6 +413,59 @@ pub struct FraudReport { pub recent_cases: Vec, } +// ── Tax System Types (extended) ── + +/// Classification of digital goods for tax purposes (extended beyond DigitalGoodsCategory). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum DigitalGoodsClass { + Standard, + ElectronicService, + Exempt, + ReducedRate, + TelecomService, +} + +/// A tax rate entry for a specific jurisdiction and tax type. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxRateEntry { + pub jurisdiction_key: String, + pub tax_type: TaxType, + pub rate_bps: u32, + pub display_name: String, + pub effective_from: Timestamp, + pub effective_until: Timestamp, + pub applies_to_digital_goods: bool, + pub reverse_charge: bool, + pub nexus_threshold: i128, +} + +/// Customer tax exemption status with certificate tracking. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CustomerTaxStatus { + pub is_exempt: bool, + pub certificate_id: String, + pub certificate_expiry: Timestamp, + pub issuing_authority: String, + pub exempt_jurisdictions: Vec, + pub digital_goods_override: Option, +} + +/// A single line in a tax remittance report recording collected tax by jurisdiction. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct TaxRemittanceLineItem { + pub jurisdiction_key: String, + pub tax_type: TaxType, + pub taxable_amount: i128, + pub rate_bps: u32, + pub tax_collected: i128, + pub transaction_count: u32, + pub currency: String, +} + /// Storage keys for the proxy contract state. /// /// IMPORTANT: Never reorder existing variants. Append new variants only. @@ -323,7 +502,7 @@ pub enum StorageKey { ProxyUpgradeDelaySecs, ProxyRollbackDelaySecs, ProxyScheduledUpgrade, - ProxyPreviousImplementationCount, + ProxyPrevImplCount, ProxyPreviousImplementation(u32), ProxyUpgradeHistoryCount, ProxyUpgradeHistoryEntry(u32), @@ -360,4 +539,19 @@ pub enum StorageKey { PlanQuotas(u64), /// Usage record for a subscription and metric (sub_id, metric -> UsageRecord) SubscriptionUsage(u64, QuotaMetric), + + // ── Added in storage version 5 (Tax System) ── + TaxJurisdiction(String), + TaxExemption(u64), + TaxExemptionCount, + CustomerTaxExemption(Address), + TaxRecord(u64), + TaxRecordCount, + TaxRecordByJurisdiction(String), + TaxRemittanceReport(u64), + TaxRemittanceReportCount, + TaxRemittanceReportByJdx(String), + TaxRateChangeLog(u64), + TaxRateChangeLogCount, + NexusRegion(String), } diff --git a/src/navigation/types.ts b/src/navigation/types.ts index ea356c5..4235285 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -24,6 +24,7 @@ export type RootStackParamList = { ErrorDashboard: undefined; Import: undefined; Export: undefined; + BatchOperations: undefined; SegmentManagement: undefined; SegmentDetail: { segmentId: string }; Gamification: undefined; diff --git a/src/store/invoiceStore.ts b/src/store/invoiceStore.ts index ca6261e..b4278ba 100644 --- a/src/store/invoiceStore.ts +++ b/src/store/invoiceStore.ts @@ -8,6 +8,17 @@ import { InvoiceFormData, InvoiceStatus, InvoiceTotals, + TaxJurisdiction, + CustomerTaxStatus, + TaxRemittanceReport, + TaxRemittanceLineItem, + TaxType, + DigitalGoodsClass, + TaxRateEntry, + MidCycleTaxChange, + TaxInvoiceGenerationInput, + buildJurisdictionKey, + isTaxExempt as checkIsTaxExempt, } from '../types/invoice'; import { buildInvoice, calculateInvoiceTotals } from '../utils/invoice'; import { CACHE_CONSTANTS } from '../utils/constants/values'; @@ -18,7 +29,17 @@ const STORAGE_KEY = 'subtrackr-invoices'; const STORE_VERSION = 1; const WRITE_DEBOUNCE_MS = CACHE_CONSTANTS.WRITE_DEBOUNCE_MS; -type PersistedInvoiceSlice = Pick; +type PersistedInvoiceSlice = Pick< + InvoiceState, + | 'invoices' + | 'config' + | 'nextSequence' + | 'taxRates' + | 'customerTaxStatuses' + | 'taxRemittanceLines' + | 'taxRemittanceReports' + | 'digitalGoodsClasses' +>; const toValidDate = (value: unknown, fallback = new Date()): Date => { if (value instanceof Date && !Number.isNaN(value.getTime())) return value; @@ -70,6 +91,11 @@ const serializeForStorage = (state: PersistedInvoiceSlice): PersistedInvoiceSlic })), config: state.config, nextSequence: state.nextSequence, + taxRates: state.taxRates, + customerTaxStatuses: state.customerTaxStatuses, + taxRemittanceLines: state.taxRemittanceLines, + taxRemittanceReports: state.taxRemittanceReports, + digitalGoodsClasses: state.digitalGoodsClasses, }); const migratePersistedState = (persisted: unknown): PersistedInvoiceSlice => { @@ -78,6 +104,11 @@ const migratePersistedState = (persisted: unknown): PersistedInvoiceSlice => { invoices: [], config: DEFAULT_INVOICE_CONFIG, nextSequence: 1, + taxRates: [], + customerTaxStatuses: {}, + taxRemittanceLines: [], + taxRemittanceReports: [], + digitalGoodsClasses: {}, }; } @@ -90,6 +121,11 @@ const migratePersistedState = (persisted: unknown): PersistedInvoiceSlice => { invoices, config: maybeState.config ?? DEFAULT_INVOICE_CONFIG, nextSequence: maybeState.nextSequence ?? Math.max(invoices.length + 1, 1), + taxRates: maybeState.taxRates ?? [], + customerTaxStatuses: maybeState.customerTaxStatuses ?? {}, + taxRemittanceLines: maybeState.taxRemittanceLines ?? [], + taxRemittanceReports: maybeState.taxRemittanceReports ?? [], + digitalGoodsClasses: maybeState.digitalGoodsClasses ?? {}, }; }; @@ -138,6 +174,8 @@ const debouncedAsyncStorage: StateStorage = { }, }; +const BPS_SCALE = 10_000; + interface InvoiceState { invoices: Invoice[]; config: InvoiceConfig; @@ -145,18 +183,66 @@ interface InvoiceState { isLoading: boolean; error: AppError | null; + taxRates: TaxRateEntry[]; + customerTaxStatuses: Record; + taxRemittanceLines: TaxRemittanceLineItem[]; + taxRemittanceReports: TaxRemittanceReport[]; + digitalGoodsClasses: Record; + generateInvoiceFromSubscription: ( data: InvoiceFormData, taxRateBps?: number, exchangeRate?: number ) => Promise; + generateTaxInvoice: (input: TaxInvoiceGenerationInput) => Promise; updateInvoiceStatus: (id: string, status: InvoiceStatus) => Promise; voidInvoice: (id: string) => Promise; sendInvoice: (id: string, recipientEmail?: string) => Promise; markInvoicePaid: (id: string) => Promise; setTaxRate: (region: string, taxRateBps: number) => void; + setTaxJurisdiction: (entry: TaxRateEntry) => void; + removeTaxJurisdiction: (jurisdictionKey: string) => void; setExchangeRate: (currency: string, exchangeRate: number) => void; calculateTotals: (id: string) => InvoiceTotals | null; + + setCustomerTaxStatus: (subscriberId: string, status: CustomerTaxStatus) => void; + removeCustomerTaxStatus: (subscriberId: string) => void; + isCustomerTaxExempt: (subscriberId: string, jurisdictionKey: string) => boolean; + validateTaxCertificate: (subscriberId: string, certificateId: string) => boolean; + + lookupTaxRate: ( + jurisdiction: TaxJurisdiction, + digitalGoodsClass?: DigitalGoodsClass + ) => TaxRateEntry | null; + resolveEffectiveTaxRateBps: ( + jurisdiction: TaxJurisdiction, + digitalGoodsClass?: DigitalGoodsClass + ) => number; + + addTaxRemittanceLine: (line: TaxRemittanceLineItem) => void; + generateTaxRemittanceReport: ( + merchantId: string, + periodStart: Date, + periodEnd: Date, + jurisdictions?: string[] + ) => TaxRemittanceReport; + getTaxRemittanceReports: () => TaxRemittanceReport[]; + getTaxRemittanceReport: (reportId: string) => TaxRemittanceReport | undefined; + + setDigitalGoodsClass: (planId: string, goodsClass: DigitalGoodsClass) => void; + getDigitalGoodsClass: (planId: string) => DigitalGoodsClass; + + calculateMidCycleTax: ( + jurisdictionKey: string, + subtotal: number, + periodStart: Date, + periodEnd: Date, + rateChanges: Array<{ + oldRateBps: number; + newRateBps: number; + effectiveFrom: Date; + }> + ) => MidCycleTaxChange[]; } const applyInvoiceStatus = (invoices: Invoice[], id: string, status: InvoiceStatus): Invoice[] => @@ -164,6 +250,18 @@ const applyInvoiceStatus = (invoices: Invoice[], id: string, status: InvoiceStat invoice.id === id ? { ...invoice, status, updatedAt: new Date() } : invoice ); +const jurisdictionFallbackKeys = (jurisdiction: TaxJurisdiction): string[] => { + const key = buildJurisdictionKey(jurisdiction); + const parts = key.split('-'); + const keys: string[] = []; + while (parts.length > 0) { + keys.push(parts.join('-')); + parts.pop(); + } + keys.push('GLOBAL'); + return keys; +}; + export const useInvoiceStore = create()( persist( (set, get) => ({ @@ -172,6 +270,11 @@ export const useInvoiceStore = create()( nextSequence: 1, isLoading: false, error: null, + taxRates: [], + customerTaxStatuses: {}, + taxRemittanceLines: [], + taxRemittanceReports: [], + digitalGoodsClasses: {}, generateInvoiceFromSubscription: async (data, taxRateBps, exchangeRate) => { set({ isLoading: true, error: null }); @@ -191,6 +294,10 @@ export const useInvoiceStore = create()( data.notes ); + if (data.taxJurisdiction) { + invoice.taxJurisdiction = data.taxJurisdiction; + } + set((current) => ({ invoices: [...current.invoices, invoice], nextSequence: current.nextSequence + 1, @@ -208,6 +315,59 @@ export const useInvoiceStore = create()( } }, + generateTaxInvoice: async (input) => { + set({ isLoading: true, error: null }); + try { + const state = get(); + const jurisdictionKey = buildJurisdictionKey(input.jurisdiction); + + let effectiveRateBps = input.effectiveTaxRateBps; + if (input.isExempt) { + effectiveRateBps = 0; + } + + const invoice = buildInvoice( + input.subscription, + state.nextSequence, + { + start: new Date(), + end: new Date(input.subscription.nextBillingDate), + }, + { ...state.config }, + effectiveRateBps, + state.config.exchangeRateScale, + jurisdictionKey, + undefined, + undefined + ); + + invoice.taxJurisdiction = input.jurisdiction; + invoice.isTaxExempt = input.isExempt; + invoice.reverseCharge = input.reverseCharge; + + if (input.reverseCharge) { + invoice.region = `${jurisdictionKey}-RC`; + } + + invoice.lineItems[0].taxRateBps = effectiveRateBps; + + set((current) => ({ + invoices: [...current.invoices, invoice], + nextSequence: current.nextSequence + 1, + isLoading: false, + })); + + return invoice; + } catch (error) { + const appError = errorHandler.handleError(error as Error, { + action: 'generateTaxInvoice', + metadata: input, + }); + set({ error: appError, isLoading: false }); + throw error; + } + }, + updateInvoiceStatus: async (id, status) => { set({ isLoading: true, error: null }); try { @@ -273,6 +433,21 @@ export const useInvoiceStore = create()( })); }, + setTaxJurisdiction: (entry) => { + set((state) => ({ + taxRates: [ + ...state.taxRates.filter((r) => r.jurisdictionKey !== entry.jurisdictionKey), + entry, + ], + })); + }, + + removeTaxJurisdiction: (jurisdictionKey) => { + set((state) => ({ + taxRates: state.taxRates.filter((r) => r.jurisdictionKey !== jurisdictionKey), + })); + }, + setExchangeRate: (currency, exchangeRate) => { set((state) => ({ config: { @@ -286,7 +461,188 @@ export const useInvoiceStore = create()( calculateTotals: (id) => { const invoice = get().invoices.find((entry) => entry.id === id); if (!invoice) return null; - return calculateInvoiceTotals(invoice.lineItems, invoice.lineItems[0]?.taxRateBps ?? 0); + return calculateInvoiceTotals( + invoice.lineItems, + invoice.lineItems[0]?.taxRateBps ?? 0 + ); + }, + + setCustomerTaxStatus: (subscriberId, status) => { + set((state) => ({ + customerTaxStatuses: { + ...state.customerTaxStatuses, + [subscriberId]: status, + }, + })); + }, + + removeCustomerTaxStatus: (subscriberId) => { + set((state) => { + const updated = { ...state.customerTaxStatuses }; + delete updated[subscriberId]; + return { customerTaxStatuses: updated }; + }); + }, + + isCustomerTaxExempt: (subscriberId, jurisdictionKey) => { + const status = get().customerTaxStatuses[subscriberId]; + return checkIsTaxExempt(status ?? null); + }, + + validateTaxCertificate: (subscriberId, certificateId) => { + const status = get().customerTaxStatuses[subscriberId]; + if (!status) return false; + if (!status.isExempt) return false; + if (status.certificateId !== certificateId) return false; + if (status.certificateExpiry && status.certificateExpiry < new Date()) return false; + return true; + }, + + lookupTaxRate: (jurisdiction, digitalGoodsClass) => { + const keys = jurisdictionFallbackKeys(jurisdiction); + const rates = get().taxRates; + for (const key of keys) { + const entry = rates.find((r) => r.jurisdictionKey === key); + if (entry) return entry; + } + return null; + }, + + resolveEffectiveTaxRateBps: (jurisdiction, digitalGoodsClass) => { + const entry = get().lookupTaxRate(jurisdiction, digitalGoodsClass); + return entry?.rateBps ?? get().config.defaultTaxRateBps; + }, + + addTaxRemittanceLine: (line) => { + set((state) => ({ + taxRemittanceLines: [...state.taxRemittanceLines, line], + })); + }, + + generateTaxRemittanceReport: (merchantId, periodStart, periodEnd, jurisdictions) => { + const lines = get().taxRemittanceLines; + const reportId = `rpt-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`; + + const aggregated = new Map(); + for (const line of lines) { + if ( + jurisdictions && + jurisdictions.length > 0 && + !jurisdictions.includes(line.jurisdictionKey) + ) { + continue; + } + const groupKey = `${line.jurisdictionKey}:${line.taxType}:${line.currency}`; + const existing = aggregated.get(groupKey); + if (existing) { + existing.taxableAmount += line.taxableAmount; + existing.taxCollected += line.taxCollected; + existing.transactionCount += line.transactionCount; + } else { + aggregated.set(groupKey, { ...line }); + } + } + + const lineItems = Array.from(aggregated.values()); + const totalTaxCollected = lineItems.reduce((sum, l) => sum + l.taxCollected, 0); + const totalTaxableAmount = lineItems.reduce((sum, l) => sum + l.taxableAmount, 0); + + const report: TaxRemittanceReport = { + reportId, + generatedAt: new Date(), + periodStart, + periodEnd, + merchant: merchantId, + lineItems, + totalTaxCollected, + totalTaxableAmount, + }; + + set((state) => ({ + taxRemittanceReports: [...state.taxRemittanceReports, report], + })); + + return report; + }, + + getTaxRemittanceReports: () => get().taxRemittanceReports, + + getTaxRemittanceReport: (reportId) => + get().taxRemittanceReports.find((r) => r.reportId === reportId), + + setDigitalGoodsClass: (planId, goodsClass) => { + set((state) => ({ + digitalGoodsClasses: { + ...state.digitalGoodsClasses, + [planId]: goodsClass, + }, + })); + }, + + getDigitalGoodsClass: (planId) => + get().digitalGoodsClasses[planId] ?? DigitalGoodsClass.ELECTRONIC_SERVICE, + + calculateMidCycleTax: (jurisdictionKey, subtotal, periodStart, periodEnd, rateChanges) => { + const periodDuration = periodEnd.getTime() - periodStart.getTime(); + if (periodDuration <= 0) return []; + + const relevant = rateChanges + .filter((c) => c.effectiveFrom > periodStart && c.effectiveFrom < periodEnd) + .sort((a, b) => a.effectiveFrom.getTime() - b.effectiveFrom.getTime()); + + if (relevant.length === 0) return []; + + const results: MidCycleTaxChange[] = []; + let currentStart = periodStart; + let currentRateBps: number | null = null; + + for (const change of relevant) { + const segmentDuration = change.effectiveFrom.getTime() - currentStart.getTime(); + const segmentRatio = segmentDuration / periodDuration; + const segmentSubtotal = Math.round(subtotal * segmentRatio); + + if (currentRateBps === null) { + currentRateBps = change.oldRateBps; + } + + const segmentTax = Math.round((segmentSubtotal * currentRateBps) / BPS_SCALE); + + results.push({ + jurisdictionKey, + oldRateBps: currentRateBps, + newRateBps: change.newRateBps, + effectiveFrom: change.effectiveFrom, + periodStart: currentStart, + periodEnd: change.effectiveFrom, + proratedTaxOld: segmentTax, + proratedTaxNew: 0, + totalTax: segmentTax, + }); + + currentStart = change.effectiveFrom; + currentRateBps = change.newRateBps; + } + + if (currentStart < periodEnd && currentRateBps !== null) { + const remainingDuration = periodEnd.getTime() - currentStart.getTime(); + const remainingRatio = remainingDuration / periodDuration; + const remainingSubtotal = Math.round(subtotal * remainingRatio); + const remainingTax = Math.round((remainingSubtotal * currentRateBps) / BPS_SCALE); + + results.push({ + jurisdictionKey, + oldRateBps: currentRateBps, + newRateBps: currentRateBps, + effectiveFrom: currentStart, + periodStart: currentStart, + periodEnd, + proratedTaxOld: 0, + proratedTaxNew: remainingTax, + totalTax: remainingTax, + }); + } + + return results; }, }), { @@ -298,6 +654,11 @@ export const useInvoiceStore = create()( invoices: state.invoices, config: state.config, nextSequence: state.nextSequence, + taxRates: state.taxRates, + customerTaxStatuses: state.customerTaxStatuses, + taxRemittanceLines: state.taxRemittanceLines, + taxRemittanceReports: state.taxRemittanceReports, + digitalGoodsClasses: state.digitalGoodsClasses, }), migrate: (persistedState) => migratePersistedState(persistedState), merge: (persistedState, currentState) => ({ @@ -315,6 +676,11 @@ export const useInvoiceStore = create()( invoices: [], nextSequence: 1, config: DEFAULT_INVOICE_CONFIG, + taxRates: [], + customerTaxStatuses: {}, + taxRemittanceLines: [], + taxRemittanceReports: [], + digitalGoodsClasses: {}, isLoading: false, }); return; @@ -324,6 +690,11 @@ export const useInvoiceStore = create()( invoices: state?.invoices ?? [], nextSequence: state?.nextSequence ?? 1, config: state?.config ?? DEFAULT_INVOICE_CONFIG, + taxRates: state?.taxRates ?? [], + customerTaxStatuses: state?.customerTaxStatuses ?? {}, + taxRemittanceLines: state?.taxRemittanceLines ?? [], + taxRemittanceReports: state?.taxRemittanceReports ?? [], + digitalGoodsClasses: state?.digitalGoodsClasses ?? {}, isLoading: false, error: null, }); diff --git a/src/types/invoice.ts b/src/types/invoice.ts index 58c6b15..cfbccac 100644 --- a/src/types/invoice.ts +++ b/src/types/invoice.ts @@ -8,6 +8,225 @@ export enum InvoiceStatus { VOID = 'void', } +export enum TaxType { + VAT = 'vat', + GST = 'gst', + SALES_TAX = 'sales_tax', + DIGITAL_SERVICES_TAX = 'digital_services_tax', + PST = 'pst', + QST = 'qst', + HST = 'hst', + NONE = 'none', +} + +export enum DigitalGoodsCategory { + SAAS = 'saas', + STREAMING = 'streaming', + DIGITAL_DOWNLOAD = 'digital_download', + CLOUD_STORAGE = 'cloud_storage', + ONLINE_SERVICE = 'online_service', + IN_APP_PURCHASE = 'in_app_purchase', + MARKETPLACE = 'marketplace', + OTHER = 'other', +} + +export enum CertificateStatus { + PENDING = 'pending', + VALID = 'valid', + EXPIRED = 'expired', + REVOKED = 'revoked', + INVALID = 'invalid', +} + +export enum RemittanceStatus { + DRAFT = 'draft', + GENERATED = 'generated', + SUBMITTED = 'submitted', + PAID = 'paid', + AMENDED = 'amended', +} + +export interface TaxJurisdiction { + country: string; + state?: string; + city?: string; + postalCode?: string; + taxType: TaxType; + rateBps: number; + label: string; + effectiveDate: Date; +} + +export interface TaxRateEntry { + jurisdictionKey: string; + taxType: TaxType; + rateBps: number; + displayName: string; + effectiveFrom: Date; + effectiveUntil: Date; + appliesToDigitalGoods: boolean; + reverseCharge: boolean; + nexusThreshold: number; +} + +export interface TaxRate { + id: string; + jurisdiction: TaxJurisdiction; + rateBps: number; + effectiveDate: Date; + expiryDate?: Date; +} + +export interface TaxRateChangeEvent { + id: string; + jurisdictionKey: string; + oldRateBps: number; + newRateBps: number; + changedAt: Date; + effectiveFrom: Date; +} + +export interface TaxExemption { + id: string; + customerId: string; + certificateNumber: string; + issuingAuthority: string; + validFrom: Date; + validUntil: Date; + jurisdictions: TaxJurisdiction[]; + status: CertificateStatus; + validatedAt?: Date; + validatedBy?: string; +} + +export interface CustomerTaxStatus { + isExempt: boolean; + certificateId: string; + certificateExpiry: Date; + issuingAuthority: string; + exemptJurisdictions: string[]; +} + +export interface TaxCalculationInput { + subscriptionId: string; + subtotal: number; + currency: string; + jurisdiction: TaxJurisdiction; + digitalGoodsCategory: DigitalGoodsCategory; + billingPeriodStart: Date; + billingPeriodEnd: Date; + isTaxExempt?: boolean; + exemptionId?: string; + rateChangeEvent?: TaxRateChangeEvent; +} + +export interface TaxCalculationResult { + taxAmount: number; + taxRateBps: number; + taxableAmount: number; + jurisdiction: TaxJurisdiction; + isExempt: boolean; + effectiveDate: Date; + proration?: { + preChangeAmount: number; + postChangeAmount: number; + preChangeDays: number; + postChangeDays: number; + }; +} + +export interface MidCycleTaxChange { + jurisdictionKey: string; + oldRateBps: number; + newRateBps: number; + effectiveFrom: Date; + periodStart: Date; + periodEnd: Date; + proratedTaxOld: number; + proratedTaxNew: number; + totalTax: number; +} + +export interface TaxRemittanceLineItem { + invoiceId: string; + invoiceNumber: string; + subscriptionId: string; + customerId: string; + jurisdictionKey: string; + taxType: TaxType; + taxableAmount: number; + rateBps: number; + taxCollected: number; + currency: string; + digitalGoodsCategory?: DigitalGoodsCategory; + invoiceDate: Date; +} + +export interface TaxRemittanceReport { + id: string; + reportId: string; + generatedAt: Date; + periodStart: Date; + periodEnd: Date; + merchant: string; + jurisdiction: TaxJurisdiction; + lineItems: TaxRemittanceLineItem[]; + totalTaxCollected: number; + totalTaxableAmount: number; + totalTaxRemitted: number; + transactionCount: number; + status: RemittanceStatus; + submittedAt?: Date; + notes?: string; +} + +export interface NexusRegion { + country: string; + state?: string; + city?: string; + thresholdMet: boolean; + thresholdAmount: number; + transactionsInPeriod: number; + totalRevenueInPeriod: number; + firstNexusDate?: Date; + taxType: TaxType; +} + +export interface NexusReport { + merchantId: string; + jurisdictionKey: string; + isEstablished: boolean; + totalRevenue: number; + thresholdAmount: number; + assessedAt: Date; +} + +export interface TaxRateCacheEntry { + jurisdictionKey: string; + rate: number; + taxType: TaxType; + cachedAt: Date; + ttlSeconds: number; +} + +export interface DigitalGoodsTaxRule { + classification: DigitalGoodsCategory; + country: string; + state?: string; + isTaxable: boolean; + reducedRate?: number; + notes: string; +} + +export interface TaxInvoiceGenerationInput { + subscription: Subscription; + jurisdiction: TaxJurisdiction; + taxType: TaxType; + isExempt: boolean; + digitalGoodsCategory: DigitalGoodsCategory; + effectiveTaxRateBps: number; +} + export interface InvoiceLineItem { description: string; quantity: number; @@ -43,6 +262,10 @@ export interface Invoice { updatedAt: Date; recipientEmail?: string; notes?: string; + taxJurisdiction?: TaxJurisdiction; + digitalGoodsCategory?: DigitalGoodsCategory; + isTaxExempt?: boolean; + taxExemptionId?: string; } export interface InvoiceConfig { @@ -53,6 +276,7 @@ export interface InvoiceConfig { defaultTaxRateBps: number; exchangeRateScale: number; paymentTermsDays: number; + defaultTaxType: TaxType; } export interface InvoiceTotals { @@ -68,6 +292,7 @@ export interface InvoiceFormData { currency?: string; recipientEmail?: string; notes?: string; + taxJurisdiction?: TaxJurisdiction; } export interface InvoiceStateSnapshot { @@ -82,6 +307,7 @@ export const DEFAULT_INVOICE_CONFIG: InvoiceConfig = { defaultTaxRateBps: 0, exchangeRateScale: 1_000_000, paymentTermsDays: 14, + defaultTaxType: TaxType.NONE, }; export const isOpenInvoice = (status: InvoiceStatus): boolean => @@ -102,3 +328,42 @@ export const billingCycleToMonths = (cycle: BillingCycle): number => { return 1; } }; + +export const buildJurisdictionKey = (jurisdiction: { + country: string; + state?: string; + city?: string; +}): string => { + const parts = [jurisdiction.country]; + if (jurisdiction.state) parts.push(jurisdiction.state); + if (jurisdiction.city) parts.push(jurisdiction.city); + return parts.join('::'); +}; + +export const isTaxExempt = (status: CustomerTaxStatus | null): boolean => { + if (!status) return false; + if (!status.isExempt) return false; + if (status.certificateExpiry && status.certificateExpiry < new Date()) return false; + return true; +}; + +export const mapSubscriptionCategoryToDigitalGoods = ( + category: import('./subscription').SubscriptionCategory +): DigitalGoodsCategory => { + switch (category) { + case import('./subscription').SubscriptionCategory.STREAMING: + return DigitalGoodsCategory.STREAMING; + case import('./subscription').SubscriptionCategory.SOFTWARE: + case import('./subscription').SubscriptionCategory.PRODUCTIVITY: + return DigitalGoodsCategory.SAAS; + case import('./subscription').SubscriptionCategory.GAMING: + return DigitalGoodsCategory.IN_APP_PURCHASE; + case import('./subscription').SubscriptionCategory.FINANCE: + return DigitalGoodsCategory.ONLINE_SERVICE; + case import('./subscription').SubscriptionCategory.EDUCATION: + case import('./subscription').SubscriptionCategory.FITNESS: + case import('./subscription').SubscriptionCategory.OTHER: + default: + return DigitalGoodsCategory.OTHER; + } +};