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 4b29373..9872bed 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -8,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 de3b336..d129361 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -872,6 +872,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; @@ -1290,4 +1293,145 @@ impl SubTrackrSubscription { .expect("Subscription not found"); usage::check_quota(&env, &storage, subscription_id, sub.plan_id, metric) } + + // ── Payment Method API ── + // Added in storage version 6 + + pub fn add_payment_method( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + token_type: TokenType, + token_address: Address, + chain_id: u64, + label: String, + priority: PaymentPriority, + max_spend_per_interval: i128, + ) -> PaymentMethodId { + proxy.require_auth(); + user.require_auth(); + payment_methods::add_payment_method( + &env, &user, token_type, token_address, chain_id, label, priority, max_spend_per_interval, + ) + } + + pub fn remove_payment_method( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + method_id: PaymentMethodId, + ) { + proxy.require_auth(); + user.require_auth(); + payment_methods::remove_payment_method(&env, &user, method_id); + } + + pub fn verify_payment_method( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + method_id: PaymentMethodId, + ) { + proxy.require_auth(); + user.require_auth(); + payment_methods::verify_payment_method(&env, &user, method_id); + } + + pub fn set_payment_method_priority( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + method_id: PaymentMethodId, + priority: PaymentPriority, + ) { + proxy.require_auth(); + user.require_auth(); + payment_methods::set_payment_method_priority(&env, &user, method_id, priority); + } + + pub fn set_payment_method_expiry( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + method_id: PaymentMethodId, + expires_at: u64, + ) { + proxy.require_auth(); + user.require_auth(); + payment_methods::set_payment_method_expiry(&env, &user, method_id, expires_at); + } + + pub fn charge_with_fallback( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + merchant: Address, + token_address: Address, + amount: i128, + subscription_id: u64, + ) -> bool { + proxy.require_auth(); + user.require_auth(); + payment_methods::charge_with_fallback( + &env, &user, &merchant, &token_address, amount, subscription_id, + ) + } + + pub fn get_payment_method( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + method_id: PaymentMethodId, + ) -> PaymentMethod { + proxy.require_auth(); + payment_methods::get_payment_method(&env, &user, method_id) + } + + pub fn list_payment_methods( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + ) -> Vec { + proxy.require_auth(); + payment_methods::list_payment_methods(&env, &user) + } + + pub fn get_expired_methods( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + ) -> Vec { + proxy.require_auth(); + payment_methods::get_expired_methods(&env, &user) + } + + pub fn get_expiring_soon_methods( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + ) -> Vec { + proxy.require_auth(); + payment_methods::get_expiring_soon_methods(&env, &user) + } + + pub fn deactivate_expired_methods( + env: Env, + proxy: Address, + _storage: Address, + user: Address, + ) -> u32 { + proxy.require_auth(); + user.require_auth(); + payment_methods::deactivate_expired_methods(&env, &user) + } } diff --git a/contracts/subscription/src/payment_methods.rs b/contracts/subscription/src/payment_methods.rs new file mode 100644 index 0000000..e3a7184 --- /dev/null +++ b/contracts/subscription/src/payment_methods.rs @@ -0,0 +1,455 @@ +use soroban_sdk::{token, Address, Env, String, Symbol, Vec}; +use subtrackr_types::{ + PaymentMethod, PaymentMethodId, PaymentPriority, TokenType, +}; + +const MAX_PAYMENT_METHODS: u32 = 10; +const DEFAULT_EXPIRY_WARNING_DAYS: u64 = 30 * 24 * 60 * 60; + +fn priority_weight(priority: &PaymentPriority) -> u32 { + match priority { + PaymentPriority::Primary => 0, + PaymentPriority::Backup => 1, + PaymentPriority::Fallback => 2, + } +} + +fn user_method_list_key(env: &Env, user: &Address) -> Symbol { + let formatted = format!("pm_list_{}", user); + Symbol::new(env, &formatted) +} + +fn method_key(env: &Env, user: &Address, method_id: PaymentMethodId) -> Symbol { + let formatted = format!("pm_{}_{}", user, method_id); + Symbol::new(env, &formatted) +} + +fn user_count_key(env: &Env, user: &Address) -> Symbol { + let formatted = format!("pm_count_{}", user); + Symbol::new(env, &formatted) +} + +fn get_user_count(env: &Env, user: &Address) -> u64 { + env.storage() + .persistent() + .get::<_, u64>(&user_count_key(env, user)) + .unwrap_or(0) +} + +fn set_user_count(env: &Env, user: &Address, count: u64) { + env.storage().persistent().set(&user_count_key(env, user), &count); +} + +fn get_user_method_ids(env: &Env, user: &Address) -> Vec { + env.storage() + .persistent() + .get::<_, Vec>(&user_method_list_key(env, user)) + .unwrap_or(Vec::new(env)) +} + +fn set_user_method_ids(env: &Env, user: &Address, ids: Vec) { + env.storage() + .persistent() + .set(&user_method_list_key(env, user), &ids); +} + +fn get_method(env: &Env, user: &Address, method_id: PaymentMethodId) -> Option { + env.storage() + .persistent() + .get(&method_key(env, user, method_id)) +} + +fn set_method(env: &Env, user: &Address, method_id: PaymentMethodId, method: &PaymentMethod) { + env.storage() + .persistent() + .set(&method_key(env, user, method_id), method); +} + +fn remove_method(env: &Env, user: &Address, method_id: PaymentMethodId) { + env.storage() + .persistent() + .remove(&method_key(env, user, method_id)); +} + +fn sort_by_priority(env: &Env, storage: &Address, user: &Address) -> Vec { + let method_ids = get_user_method_ids(env, user); + let mut methods: Vec = Vec::new(env); + + for id in method_ids.iter() { + if let Some(method) = get_method(env, user, id) { + if method.is_active && method.is_verified { + methods.push_back(method); + } + } + } + let _ = storage; + + let mut i = 0u32; + let len = methods.len(); + while i < len { + let mut j = i + 1; + while j < len { + let a = methods.get(i).unwrap(); + let b = methods.get(j).unwrap(); + let a_prio = priority_weight(&a.priority); + let b_prio = priority_weight(&b.priority); + if a_prio > b_prio || (a_prio == b_prio && a.last_used_at < b.last_used_at) { + let temp = a; + methods.set(i, b); + methods.set(j, temp); + } + j += 1; + } + i += 1; + } + + methods +} + +fn check_expired(method: &PaymentMethod, env: &Env) -> bool { + if method.expires_at == 0 { + return false; + } + env.ledger().timestamp() >= method.expires_at +} + +fn check_expiring_soon(method: &PaymentMethod, env: &Env) -> bool { + if method.expires_at == 0 { + return false; + } + let now = env.ledger().timestamp(); + if now >= method.expires_at { + return false; + } + method.expires_at - now <= DEFAULT_EXPIRY_WARNING_DAYS +} + +pub(crate) fn add_payment_method( + env: &Env, + user: &Address, + token_type: TokenType, + token_address: Address, + chain_id: u64, + label: String, + priority: PaymentPriority, + max_spend_per_interval: i128, +) -> PaymentMethodId { + let count = get_user_count(env, user); + assert!( + count < MAX_PAYMENT_METHODS as u64, + "Maximum payment methods reached (10)" + ); + assert!( + max_spend_per_interval > 0, + "Max spend per interval must be positive" + ); + + let now = env.ledger().timestamp(); + let new_id = count + 1; + + let method = PaymentMethod { + id: new_id, + user: user.clone(), + token_type, + token_address, + chain_id, + label, + priority, + max_spend_per_interval, + is_verified: false, + is_active: true, + expires_at: 0, + last_used_at: 0, + created_at: now, + updated_at: now, + metadata: Vec::new(env), + }; + + set_method(env, user, new_id, &method); + + let mut user_methods = get_user_method_ids(env, user); + user_methods.push_back(new_id); + set_user_method_ids(env, user, user_methods); + set_user_count(env, user, new_id); + + env.events().publish( + ( + String::from_str(env, "payment_method_added"), + user.clone(), + ), + (new_id, method.token_type, priority_weight(&method.priority), now), + ); + + new_id +} + +pub(crate) fn remove_payment_method( + env: &Env, + user: &Address, + method_id: PaymentMethodId, +) { + let method = get_method(env, user, method_id).expect("Payment method not found"); + assert!(method.user == *user, "Only owner can remove payment method"); + + remove_method(env, user, method_id); + + let user_methods = get_user_method_ids(env, user); + let mut updated: Vec = Vec::new(env); + for id in user_methods.iter() { + if id != method_id { + updated.push_back(id); + } + } + set_user_method_ids(env, user, updated); + + env.events().publish( + ( + String::from_str(env, "payment_method_removed"), + user.clone(), + ), + method_id, + ); +} + +pub(crate) fn verify_payment_method( + env: &Env, + user: &Address, + method_id: PaymentMethodId, +) { + let mut method = get_method(env, user, method_id).expect("Payment method not found"); + assert!(method.user == *user, "Only owner can verify"); + + let now = env.ledger().timestamp(); + method.is_verified = true; + method.updated_at = now; + + set_method(env, user, method_id, &method); + + env.events().publish( + ( + String::from_str(env, "payment_method_verified"), + user.clone(), + ), + (method_id, method.token_type, now), + ); +} + +pub(crate) fn set_payment_method_priority( + env: &Env, + user: &Address, + method_id: PaymentMethodId, + priority: PaymentPriority, +) { + let mut method = get_method(env, user, method_id).expect("Payment method not found"); + assert!(method.user == *user, "Only owner can change priority"); + + let now = env.ledger().timestamp(); + method.priority = priority; + method.updated_at = now; + + set_method(env, user, method_id, &method); + + env.events().publish( + ( + String::from_str(env, "payment_method_priority_updated"), + user.clone(), + ), + (method_id, priority_weight(&priority), now), + ); +} + +pub(crate) fn set_payment_method_expiry( + env: &Env, + user: &Address, + method_id: PaymentMethodId, + expires_at: u64, +) { + let mut method = get_method(env, user, method_id).expect("Payment method not found"); + assert!(method.user == *user, "Only owner can set expiry"); + + let now = env.ledger().timestamp(); + method.expires_at = expires_at; + method.updated_at = now; + + set_method(env, user, method_id, &method); + + env.events().publish( + ( + String::from_str(env, "payment_method_expiry_set"), + user.clone(), + ), + (method_id, expires_at, now), + ); +} + +pub(crate) fn charge_with_fallback( + env: &Env, + user: &Address, + merchant: &Address, + token_address: &Address, + amount: i128, + subscription_id: u64, +) -> bool { + let sorted = sort_by_priority(env, &user.clone(), user); + + if sorted.len() == 0 { + env.events().publish( + (String::from_str(env, "payment_fallback_exhausted"), user.clone()), + (subscription_id, amount), + ); + return false; + } + + let mut i = 0u32; + let len = sorted.len(); + while i < len { + let method = sorted.get(i).unwrap(); + let now = env.ledger().timestamp(); + + if check_expired(&method, env) { + env.events().publish( + (String::from_str(env, "payment_method_expired_skipped"), user.clone()), + (method.id, subscription_id), + ); + i += 1; + continue; + } + + if amount > method.max_spend_per_interval { + env.events().publish( + (String::from_str(env, "payment_method_limit_exceeded"), user.clone()), + (method.id, amount, method.max_spend_per_interval), + ); + i += 1; + continue; + } + + let balance = token::Client::new(env, &method.token_address).balance(user); + if balance < amount { + env.events().publish( + (String::from_str(env, "payment_method_insufficient_balance"), user.clone()), + (method.id, subscription_id, balance, amount), + ); + i += 1; + continue; + } + + token::Client::new(env, &method.token_address).transfer( + user, + merchant, + &amount, + ); + + let mut updated = get_method(env, user, method.id).unwrap_or(method); + updated.last_used_at = now; + updated.updated_at = now; + set_method(env, user, method.id, &updated); + + env.events().publish( + (String::from_str(env, "payment_charge_success"), user.clone()), + (subscription_id, amount, method.id, method.token_type, now), + ); + + return true; + } + + env.events().publish( + (String::from_str(env, "payment_fallback_exhausted"), user.clone()), + (subscription_id, amount), + ); + + false +} + +pub(crate) fn get_payment_method( + env: &Env, + user: &Address, + method_id: PaymentMethodId, +) -> PaymentMethod { + get_method(env, user, method_id).expect("Payment method not found") +} + +pub(crate) fn list_payment_methods( + env: &Env, + user: &Address, +) -> Vec { + sort_by_priority(env, &user.clone(), user) +} + +pub(crate) fn get_expired_methods( + env: &Env, + user: &Address, +) -> Vec { + let method_ids = get_user_method_ids(env, user); + let mut expired: Vec = Vec::new(env); + + for id in method_ids.iter() { + if let Some(method) = get_method(env, user, id) { + if check_expired(&method, env) { + expired.push_back(id); + } + } + } + + expired +} + +pub(crate) fn get_expiring_soon_methods( + env: &Env, + user: &Address, +) -> Vec { + let method_ids = get_user_method_ids(env, user); + let mut expiring: Vec = Vec::new(env); + + for id in method_ids.iter() { + if let Some(method) = get_method(env, user, id) { + if check_expiring_soon(&method, env) { + expiring.push_back(id); + } + } + } + + expiring +} + +pub(crate) fn deactivate_expired_methods( + env: &Env, + user: &Address, +) -> u32 { + let expired_ids = get_expired_methods(env, user); + let count = expired_ids.len() as u32; + let now = env.ledger().timestamp(); + + for id in expired_ids.iter() { + if let Some(mut method) = get_method(env, user, id) { + method.is_active = false; + method.updated_at = now; + + let mut meta = match method.metadata.is_empty() { + true => Vec::new(env), + false => method.metadata.clone(), + }; + meta.push_back(( + String::from_str(env, "deactivated_reason"), + String::from_str(env, "expired"), + )); + meta.push_back(( + String::from_str(env, "deactivated_at"), + String::from_str(env, &now.to_string()), + )); + method.metadata = meta; + + set_method(env, user, id, &method); + } + } + + env.events().publish( + ( + String::from_str(env, "payment_methods_deactivated"), + user.clone(), + ), + (count, now), + ); + + count +} diff --git a/contracts/types/src/lib.rs b/contracts/types/src/lib.rs index 9a324e2..808ef5a 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)] @@ -203,6 +329,71 @@ pub struct UpgradeEvent { pub type SubscriptionId = u64; pub type MerchantId = Address; +pub type PaymentMethodId = u64; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum TokenType { + XLM, + USDC, + ETH, + Native, + MATIC, + ARB, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum PaymentPriority { + Primary, + Backup, + Fallback, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PaymentMethod { + pub id: PaymentMethodId, + pub user: Address, + pub token_type: TokenType, + pub token_address: Address, + pub chain_id: u64, + pub label: String, + pub priority: PaymentPriority, + pub max_spend_per_interval: i128, + pub is_verified: bool, + pub is_active: bool, + pub expires_at: u64, + pub last_used_at: u64, + pub created_at: u64, + pub updated_at: u64, + pub metadata: Vec<(String, String)>, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum PaymentAttemptStatus { + Pending, + Success, + Failed, + FallbackTriggered, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PaymentAttempt { + pub id: u64, + pub payment_method_id: PaymentMethodId, + pub subscription_id: u64, + pub amount: i128, + pub token_type: TokenType, + pub status: PaymentAttemptStatus, + pub failure_reason: String, + pub gas_price: i128, + pub gas_used: u64, + pub attempted_at: u64, + pub resolved_at: u64, +} #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -287,6 +478,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 +567,7 @@ pub enum StorageKey { ProxyUpgradeDelaySecs, ProxyRollbackDelaySecs, ProxyScheduledUpgrade, - ProxyPreviousImplementationCount, + ProxyPrevImplCount, ProxyPreviousImplementation(u32), ProxyUpgradeHistoryCount, ProxyUpgradeHistoryEntry(u32), diff --git a/src/navigation/types.ts b/src/navigation/types.ts index f47966f..5d59998 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/services/walletService.ts b/src/services/walletService.ts index 8767bbe..5477803 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -9,6 +9,14 @@ import { CHAIN_IDS, ADDRESS_CONSTANTS, } from '../utils/constants/values'; +import { + PaymentMethod, + PaymentPriority, + TokenType, + PaymentMethodValidationResult, + PaymentAttempt, + GasEstimate, +} from '../types/wallet'; // ── Structured error handling ────────────────────────────────────── @@ -707,6 +715,452 @@ export class WalletServiceManager { } } +// ── Payment method management ─────────────────────────────────────── + +export enum PaymentMethodErrorCode { + DUPLICATE = 'PAYMENT_METHOD_DUPLICATE', + INVALID_TOKEN = 'PAYMENT_METHOD_INVALID_TOKEN', + INVALID_CHAIN = 'PAYMENT_METHOD_INVALID_CHAIN', + MAX_METHODS = 'PAYMENT_METHOD_MAX_REACHED', + VERIFICATION_FAILED = 'PAYMENT_METHOD_VERIFICATION_FAILED', + EXPIRED = 'PAYMENT_METHOD_EXPIRED', + INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE', + GAS_PRICE_SPIKE = 'GAS_PRICE_SPIKE', + TOKEN_CONTRACT_UPGRADED = 'TOKEN_CONTRACT_UPGRADED', + FALLBACK_FAILED = 'FALLBACK_FAILED', +} + +export class PaymentMethodError extends Error { + readonly code: PaymentMethodErrorCode; + readonly userMessage: string; + readonly recovery?: string; + + constructor( + code: PaymentMethodErrorCode, + userMessage: string, + recovery?: string, + cause?: unknown + ) { + super(userMessage); + this.name = 'PaymentMethodError'; + this.code = code; + this.userMessage = userMessage; + this.recovery = recovery; + if (cause instanceof Error && cause.stack) { + this.stack = `${this.stack}\nCaused by: ${cause.stack}`; + } + } +} + +const MAX_PAYMENT_METHODS_PER_USER = 10; +const EXPIRY_WARNING_DAYS = 30; +const TOKEN_TYPE_TO_NATIVE_SYMBOL: Record> = { + [CHAIN_IDS.ETHEREUM]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'ETH', MATIC: '', ARB: '' }, + [CHAIN_IDS.POLYGON]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'MATIC', MATIC: 'MATIC', ARB: '' }, + [CHAIN_IDS.ARBITRUM]: { XLM: '', USDC: 'USDC', ETH: 'ETH', NATIVE: 'ETH', MATIC: '', ARB: 'ARB' }, +}; + +const PRIORITY_ORDER: Record = { + [PaymentPriority.PRIMARY]: 0, + [PaymentPriority.BACKUP]: 1, + [PaymentPriority.FALLBACK]: 2, +}; + +export interface PaymentMethodExpiryCheck { + method: PaymentMethod; + daysUntilExpiry: number | null; + isExpired: boolean; + isExpiringSoon: boolean; +} + +export class PaymentMethodService { + private static instance: PaymentMethodService; + private readonly walletManager: WalletServiceManager; + + static getInstance(): PaymentMethodService { + if (!PaymentMethodService.instance) { + PaymentMethodService.instance = new PaymentMethodService(); + } + return PaymentMethodService.instance; + } + + private constructor() { + this.walletManager = WalletServiceManager.getInstance(); + } + + generateId(): string { + return `pm_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + validatePaymentMethodForm(data: { + tokenType: TokenType; + tokenAddress: string; + chainId: number; + label: string; + priority: PaymentPriority; + maxSpendPerInterval: string; + }): PaymentMethodValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!Object.values(TokenType).includes(data.tokenType)) { + errors.push(`Unsupported token type: ${data.tokenType}`); + } + + if (data.tokenType !== TokenType.NATIVE && !ethers.utils.isAddress(data.tokenAddress)) { + errors.push('Invalid token address'); + } + + const validChainIds = Object.values(CHAIN_IDS) as number[]; + if (!validChainIds.includes(data.chainId)) { + errors.push(`Unsupported chain ID: ${data.chainId}`); + } + + if (!data.label || data.label.trim().length === 0) { + errors.push('Label is required'); + } + + if (!data.maxSpendPerInterval || isNaN(Number(data.maxSpendPerInterval)) || Number(data.maxSpendPerInterval) <= 0) { + errors.push('Max spend per interval must be a positive number'); + } + + const nativeSymbol = TOKEN_TYPE_TO_NATIVE_SYMBOL[data.chainId]?.[data.tokenType]; + if (nativeSymbol === '') { + warnings.push(`Token type ${data.tokenType} may not be supported on chain ${data.chainId}`); + } + + if (Number(data.maxSpendPerInterval) > 1e12) { + warnings.push('Max spend per interval is very high; consider setting a lower cap'); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + requiresVerification: data.tokenType !== TokenType.NATIVE, + estimatedGas: null, + }; + } + + async verifyPaymentMethod(method: PaymentMethod): Promise { + const conn = this.walletManager.getConnection(); + if (!conn || !conn.isConnected) { + throw new PaymentMethodError( + PaymentMethodErrorCode.VERIFICATION_FAILED, + 'Wallet not connected.', + 'Connect your wallet to verify payment methods.' + ); + } + + if (method.tokenType === TokenType.NATIVE) { + return true; + } + + try { + const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(method.chainId)); + const erc20Abi = ['function decimals() view returns (uint8)', 'function symbol() view returns (string)']; + const contract = new ethers.Contract(method.tokenAddress, erc20Abi, provider); + + const decimals = await contract.decimals(); + if (decimals < 0 || decimals > 18) { + throw new Error('Invalid decimals'); + } + + const symbol = await contract.symbol(); + const expectedSymbol = method.tokenType.toString(); + if (symbol.toUpperCase() !== expectedSymbol.toUpperCase() && expectedSymbol !== 'NATIVE') { + throw new Error(`Symbol mismatch: expected ${expectedSymbol}, got ${symbol}`); + } + + return true; + } catch (error) { + throw new PaymentMethodError( + PaymentMethodErrorCode.VERIFICATION_FAILED, + `Failed to verify token ${method.tokenAddress}.`, + 'Check the token address and try again.', + error + ); + } + } + + sortByPriority(methods: PaymentMethod[]): PaymentMethod[] { + return [...methods].sort((a, b) => { + const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; + if (priorityDiff !== 0) return priorityDiff; + + const aTime = a.lastUsedAt?.getTime() ?? a.createdAt.getTime(); + const bTime = b.lastUsedAt?.getTime() ?? b.createdAt.getTime(); + return bTime - aTime; + }); + } + + getPrimaryMethods(methods: PaymentMethod[]): PaymentMethod[] { + return methods.filter((m) => m.priority === PaymentPriority.PRIMARY && m.isActive && m.isVerified); + } + + getBackupMethods(methods: PaymentMethod[]): PaymentMethod[] { + return methods.filter((m) => m.priority === PaymentPriority.BACKUP && m.isActive && m.isVerified); + } + + getFallbackMethods(methods: PaymentMethod[]): PaymentMethod[] { + return methods.filter((m) => m.priority === PaymentPriority.FALLBACK && m.isActive && m.isVerified); + } + + getActiveVerifiedMethods(methods: PaymentMethod[]): PaymentMethod[] { + return this.sortByPriority(methods.filter((m) => m.isActive && m.isVerified)); + } + + calculateFallbackOrder(methods: PaymentMethod[]): PaymentMethod[] { + const active = this.getActiveVerifiedMethods(methods); + return this.sortByPriority(active); + } + + canAddMethod(currentCount: number): { canAdd: boolean; reason?: string } { + if (currentCount >= MAX_PAYMENT_METHODS_PER_USER) { + return { + canAdd: false, + reason: `Maximum of ${MAX_PAYMENT_METHODS_PER_USER} payment methods reached.`, + }; + } + return { canAdd: true }; + } + + isDuplicateMethod( + existingMethods: PaymentMethod[], + tokenAddress: string, + chainId: number, + tokenType: TokenType + ): boolean { + return existingMethods.some( + (m) => + m.tokenAddress.toLowerCase() === tokenAddress.toLowerCase() && + m.chainId === chainId && + m.tokenType === tokenType + ); + } + + ensurePriorityBalance(methods: PaymentMethod[]): void { + const priorities = [PaymentPriority.PRIMARY, PaymentPriority.BACKUP, PaymentPriority.FALLBACK]; + const present = new Set(methods.map((m) => m.priority)); + + for (const priority of priorities) { + if (!present.has(priority)) { + throw new PaymentMethodError( + PaymentMethodErrorCode.INVALID_TOKEN, + `No payment method with priority "${priority}" exists. Add a method with this priority level.`, + 'Configure at least one payment method per priority level.' + ); + } + } + } + + async checkBalance( + method: PaymentMethod, + requiredAmount: string, + chainId: number + ): Promise<{ sufficient: boolean; balance: string; symbol: string }> { + try { + const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(chainId)); + const conn = this.walletManager.getConnection(); + if (!conn) { + return { sufficient: false, balance: '0', symbol: method.tokenType }; + } + + let balance: ethers.BigNumber; + + if (method.tokenType === TokenType.NATIVE) { + balance = await provider.getBalance(conn.address); + } else { + const erc20Abi = ['function balanceOf(address) view returns (uint256)']; + const contract = new ethers.Contract(method.tokenAddress, erc20Abi, provider); + balance = await contract.balanceOf(conn.address); + } + + const required = ethers.utils.parseUnits(requiredAmount, method.tokenType === TokenType.USDC ? 6 : 18); + return { + sufficient: balance.gte(required), + balance: balance.toString(), + symbol: method.tokenType.toString(), + }; + } catch { + return { sufficient: false, balance: '0', symbol: method.tokenType.toString() }; + } + } + + async validateGasPrice( + chainId: number, + maxGasPriceGwei: number + ): Promise<{ acceptable: boolean; currentGasPrice: string }> { + try { + const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(chainId)); + const gasPrice = await provider.getGasPrice(); + const gasPriceGwei = parseFloat(ethers.utils.formatUnits(gasPrice, 'gwei')); + + return { + acceptable: gasPriceGwei <= maxGasPriceGwei, + currentGasPrice: gasPriceGwei.toFixed(2), + }; + } catch { + return { acceptable: false, currentGasPrice: '0' }; + } + } + + checkExpiry(method: PaymentMethod): PaymentMethodExpiryCheck { + if (!method.expiresAt) { + return { method, daysUntilExpiry: null, isExpired: false, isExpiringSoon: false }; + } + + const now = Date.now(); + const expiryTime = method.expiresAt.getTime(); + const daysUntilExpiry = Math.ceil((expiryTime - now) / (1000 * 60 * 60 * 24)); + const isExpired = daysUntilExpiry <= 0; + const isExpiringSoon = !isExpired && daysUntilExpiry <= EXPIRY_WARNING_DAYS; + + return { method, daysUntilExpiry, isExpired, isExpiringSoon }; + } + + getExpiredMethods(methods: PaymentMethod[]): PaymentMethod[] { + return methods.filter((m) => { + const check = this.checkExpiry(m); + return check.isExpired; + }); + } + + getExpiringSoonMethods(methods: PaymentMethod[]): PaymentMethod[] { + return methods.filter((m) => { + const check = this.checkExpiry(m); + return check.isExpiringSoon; + }); + } + + async processPaymentWithFallback( + paymentMethods: PaymentMethod[], + subscriptionId: string, + amount: string, + chainId: number, + maxGasPriceGwei: number = 500 + ): Promise<{ success: boolean; attempt: PaymentAttempt; fallbackAttempts: PaymentAttempt[] }> { + const sorted = this.calculateFallbackOrder(paymentMethods); + if (sorted.length === 0) { + throw new PaymentMethodError( + PaymentMethodErrorCode.FALLBACK_FAILED, + 'No active payment methods available.', + 'Add at least one verified payment method.' + ); + } + + const fallbackAttempts: PaymentAttempt[] = []; + + for (const method of sorted) { + const attempt: PaymentAttempt = { + id: `attempt_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`, + paymentMethodId: method.id, + subscriptionId, + amount, + tokenType: method.tokenType, + status: 'pending', + attemptedAt: new Date(), + }; + + try { + const expiry = this.checkExpiry(method); + if (expiry.isExpired) { + attempt.status = 'failed'; + attempt.failureReason = `Payment method expired ${expiry.daysUntilExpiry} days ago`; + attempt.resolvedAt = new Date(); + fallbackAttempts.push(attempt); + continue; + } + + const gasCheck = await this.validateGasPrice(chainId, maxGasPriceGwei); + if (!gasCheck.acceptable) { + attempt.status = 'failed'; + attempt.failureReason = `Gas price ${gasCheck.currentGasPrice} gwei exceeds max ${maxGasPriceGwei} gwei`; + attempt.gasPrice = gasCheck.currentGasPrice; + attempt.resolvedAt = new Date(); + fallbackAttempts.push(attempt); + continue; + } + + const balanceCheck = await this.checkBalance(method, amount, chainId); + if (!balanceCheck.sufficient) { + attempt.status = 'failed'; + attempt.failureReason = `Insufficient ${method.tokenType} balance: have ${balanceCheck.balance}, need ${amount}`; + attempt.resolvedAt = new Date(); + fallbackAttempts.push(attempt); + continue; + } + + if (method.maxSpendPerInterval && ethers.BigNumber.from(amount).gt(method.maxSpendPerInterval)) { + attempt.status = 'failed'; + attempt.failureReason = `Amount ${amount} exceeds max spend per interval ${method.maxSpendPerInterval}`; + attempt.resolvedAt = new Date(); + fallbackAttempts.push(attempt); + continue; + } + + attempt.status = 'success'; + attempt.gasPrice = gasCheck.currentGasPrice; + attempt.resolvedAt = new Date(); + method.lastUsedAt = new Date(); + + return { success: true, attempt, fallbackAttempts }; + } catch (error) { + attempt.status = 'failed'; + attempt.failureReason = error instanceof Error ? error.message : 'Unknown error'; + attempt.resolvedAt = new Date(); + fallbackAttempts.push(attempt); + } + } + + throw new PaymentMethodError( + PaymentMethodErrorCode.FALLBACK_FAILED, + `All ${sorted.length} payment methods failed.`, + 'Check your balances, gas prices, and payment method configurations.', + new Error( + `Failed attempts: ${fallbackAttempts.map((a) => `${a.tokenType}: ${a.failureReason}`).join('; ')}` + ) + ); + } + + async detectTokenContractUpgrade( + method: PaymentMethod, + previousHash: string | null + ): Promise<{ upgraded: boolean; newHash?: string }> { + if (method.tokenType === TokenType.NATIVE || !method.tokenAddress) { + return { upgraded: false }; + } + + try { + const provider = new ethers.providers.JsonRpcProvider(getEvmRpcUrl(method.chainId)); + const code = await provider.getCode(method.tokenAddress); + const newHash = ethers.utils.keccak256(code); + + if (previousHash && newHash !== previousHash) { + return { upgraded: true, newHash }; + } + + return { upgraded: false, newHash }; + } catch { + return { upgraded: false }; + } + } + + markPaymentMethodExpired(method: PaymentMethod): PaymentMethod { + return { + ...method, + isActive: false, + metadata: { + ...method.metadata, + deactivated_reason: 'expired', + deactivated_at: new Date().toISOString(), + }, + updatedAt: new Date(), + }; + } +} + // Export singleton instance export const walletServiceManager = WalletServiceManager.getInstance(); +export const paymentMethodService = PaymentMethodService.getInstance(); export default walletServiceManager; 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/store/walletStore.ts b/src/store/walletStore.ts index 1422535..9723466 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -1,12 +1,29 @@ import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Wallet, CryptoStream, StreamSetup } from '../types/wallet'; +import { + Wallet, + CryptoStream, + StreamSetup, + PaymentMethod, + PaymentMethodFormData, + PaymentPriority, + PaymentAttempt, + PaymentMethodValidationResult, +} from '../types/wallet'; +import { + PaymentMethodService, + PaymentMethodError, + PaymentMethodErrorCode, + PaymentMethodExpiryCheck, +} from '../services/walletService'; interface WalletState { wallet: Wallet | null; address: string | null; network: string | null; cryptoStreams: CryptoStream[]; + paymentMethods: PaymentMethod[]; + paymentAttempts: PaymentAttempt[]; isLoading: boolean; error: string | null; @@ -21,15 +38,43 @@ interface WalletState { createCryptoStream: (setup: StreamSetup) => Promise; cancelCryptoStream: (streamId: string) => Promise; fetchCryptoStreams: () => Promise; + + addPaymentMethod: (data: PaymentMethodFormData) => Promise; + removePaymentMethod: (id: string) => Promise; + updatePaymentMethod: (id: string, updates: Partial) => Promise; + verifyPaymentMethod: (id: string) => Promise; + setPaymentMethodPriority: (id: string, priority: PaymentPriority) => Promise; + processPayment: ( + subscriptionId: string, + amount: string, + chainId: number, + maxGasPriceGwei?: number + ) => Promise<{ success: boolean; attempt: PaymentAttempt; fallbackAttempts: PaymentAttempt[] }>; + getExpiryInfo: () => { + expired: PaymentMethodExpiryCheck[]; + expiringSoon: PaymentMethodExpiryCheck[]; + }; + getPaymentMethodsByPriority: () => { + primary: PaymentMethod[]; + backup: PaymentMethod[]; + fallback: PaymentMethod[]; + }; + checkTokenContractUpgrade: (id: string) => Promise; } const WALLET_STORAGE_KEY = '@subtrackr_wallet'; +const PAYMENT_METHODS_STORAGE_KEY = '@subtrackr_payment_methods'; +const PAYMENT_ATTEMPTS_STORAGE_KEY = '@subtrackr_payment_attempts'; + +const paymentService = PaymentMethodService.getInstance(); export const useWalletStore = create((set, get) => ({ wallet: null, address: null, network: null, cryptoStreams: [], + paymentMethods: [], + paymentAttempts: [], isLoading: false, error: null, @@ -46,6 +91,17 @@ export const useWalletStore = create((set, get) => ({ wallet: parsed.wallet, isLoading: false, }); + + const savedMethods = await AsyncStorage.getItem(PAYMENT_METHODS_STORAGE_KEY); + if (savedMethods) { + set({ paymentMethods: JSON.parse(savedMethods) }); + } + + const savedAttempts = await AsyncStorage.getItem(PAYMENT_ATTEMPTS_STORAGE_KEY); + if (savedAttempts) { + set({ paymentAttempts: JSON.parse(savedAttempts) }); + } + return; } @@ -120,7 +176,14 @@ export const useWalletStore = create((set, get) => ({ disconnect: async () => { try { await AsyncStorage.removeItem(WALLET_STORAGE_KEY); - set({ wallet: null, address: null, network: null, cryptoStreams: [] }); + set({ + wallet: null, + address: null, + network: null, + cryptoStreams: [], + paymentMethods: [], + paymentAttempts: [], + }); } catch (error) { set({ error: 'Failed to disconnect wallet' }); } @@ -198,4 +261,283 @@ export const useWalletStore = create((set, get) => ({ }); } }, + + addPaymentMethod: async (data: PaymentMethodFormData) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods, address } = get(); + if (!address) { + throw new PaymentMethodError( + PaymentMethodErrorCode.VERIFICATION_FAILED, + 'Wallet not connected.', + 'Connect your wallet first.' + ); + } + + const canAdd = paymentService.canAddMethod(paymentMethods.length); + if (!canAdd.canAdd) { + throw new PaymentMethodError( + PaymentMethodErrorCode.MAX_METHODS, + canAdd.reason!, + 'Remove an existing payment method first.' + ); + } + + const validation = paymentService.validatePaymentMethodForm(data); + if (!validation.isValid) { + throw new PaymentMethodError( + PaymentMethodErrorCode.INVALID_TOKEN, + validation.errors.join('; '), + 'Fix the validation errors and try again.' + ); + } + + const isDup = paymentService.isDuplicateMethod( + paymentMethods, + data.tokenAddress, + data.chainId, + data.tokenType + ); + if (isDup) { + throw new PaymentMethodError( + PaymentMethodErrorCode.DUPLICATE, + 'A payment method with this token and chain already exists.', + 'Use a different token or chain.' + ); + } + + const newMethod: PaymentMethod = { + id: paymentService.generateId(), + userId: address, + tokenType: data.tokenType, + tokenAddress: data.tokenAddress, + chainId: data.chainId, + label: data.label, + priority: data.priority, + maxSpendPerInterval: data.maxSpendPerInterval, + isVerified: data.tokenType === 'NATIVE', + isActive: true, + expiresAt: null, + lastUsedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + + if (!newMethod.isVerified) { + await paymentService.verifyPaymentMethod(newMethod); + newMethod.isVerified = true; + } + + const updatedMethods = [...paymentMethods, newMethod]; + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + + set({ + paymentMethods: updatedMethods, + isLoading: false, + }); + + return newMethod; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to add payment method', + isLoading: false, + }); + throw error; + } + }, + + removePaymentMethod: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const updatedMethods = paymentMethods.filter((m) => m.id !== id); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to remove payment method', + isLoading: false, + }); + } + }, + + updatePaymentMethod: async (id: string, updates: Partial) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const updatedMethods = paymentMethods.map((m) => + m.id === id ? { ...m, ...updates, updatedAt: new Date() } : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to update payment method', + isLoading: false, + }); + } + }, + + verifyPaymentMethod: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const method = paymentMethods.find((m) => m.id === id); + if (!method) { + throw new Error('Payment method not found'); + } + + const verified = await paymentService.verifyPaymentMethod(method); + if (verified) { + const updatedMethods = paymentMethods.map((m) => + m.id === id ? { ...m, isVerified: true, updatedAt: new Date() } : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } + return verified; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to verify payment method', + isLoading: false, + }); + throw error; + } + }, + + setPaymentMethodPriority: async (id: string, priority: PaymentPriority) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const method = paymentMethods.find((m) => m.id === id); + if (!method) { + throw new Error('Payment method not found'); + } + + const updatedMethods = paymentMethods.map((m) => + m.id === id ? { ...m, priority, updatedAt: new Date() } : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to update payment method priority', + isLoading: false, + }); + } + }, + + processPayment: async ( + subscriptionId: string, + amount: string, + chainId: number, + maxGasPriceGwei: number = 500 + ) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const result = await paymentService.processPaymentWithFallback( + paymentMethods, + subscriptionId, + amount, + chainId, + maxGasPriceGwei + ); + + const updatedMethods = paymentMethods.map((m) => { + if (m.id === result.attempt.paymentMethodId) { + return { ...m, lastUsedAt: new Date(), updatedAt: new Date() }; + } + return m; + }); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + + const newAttempts = [...get().paymentAttempts, result.attempt, ...result.fallbackAttempts]; + await AsyncStorage.setItem(PAYMENT_ATTEMPTS_STORAGE_KEY, JSON.stringify(newAttempts)); + + set({ + paymentMethods: updatedMethods, + paymentAttempts: newAttempts, + isLoading: false, + }); + + return result; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Payment processing failed', + isLoading: false, + }); + throw error; + } + }, + + getExpiryInfo: () => { + const { paymentMethods } = get(); + const expired = paymentService.getExpiredMethods(paymentMethods); + const expiringSoon = paymentService.getExpiringSoonMethods(paymentMethods); + + return { + expired: expired.map((m) => paymentService.checkExpiry(m)), + expiringSoon: expiringSoon.map((m) => paymentService.checkExpiry(m)), + }; + }, + + getPaymentMethodsByPriority: () => { + const { paymentMethods } = get(); + return { + primary: paymentService.getPrimaryMethods(paymentMethods), + backup: paymentService.getBackupMethods(paymentMethods), + fallback: paymentService.getFallbackMethods(paymentMethods), + }; + }, + + checkTokenContractUpgrade: async (id: string) => { + set({ isLoading: true, error: null }); + try { + const { paymentMethods } = get(); + const method = paymentMethods.find((m) => m.id === id); + if (!method) { + throw new Error('Payment method not found'); + } + + const previousHash = method.metadata.token_code_hash ?? null; + const result = await paymentService.detectTokenContractUpgrade(method, previousHash); + + if (result.upgraded && result.newHash) { + const updatedMethods = paymentMethods.map((m) => + m.id === id + ? { + ...m, + metadata: { ...m.metadata, token_code_hash: result.newHash }, + updatedAt: new Date(), + } + : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } else if (result.newHash && !previousHash) { + const updatedMethods = paymentMethods.map((m) => + m.id === id + ? { + ...m, + metadata: { ...m.metadata, token_code_hash: result.newHash }, + updatedAt: new Date(), + } + : m + ); + await AsyncStorage.setItem(PAYMENT_METHODS_STORAGE_KEY, JSON.stringify(updatedMethods)); + set({ paymentMethods: updatedMethods, isLoading: false }); + } + + set({ isLoading: false }); + return result.upgraded; + } catch (error) { + set({ + error: error instanceof Error ? error.message : 'Failed to check token contract upgrade', + isLoading: false, + }); + return false; + } + }, })); 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; + } +}; diff --git a/src/types/wallet.ts b/src/types/wallet.ts index 71bac70..d51e51a 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -73,3 +73,67 @@ export interface ChainInfo { decimals: number; }; } + +export enum TokenType { + XLM = 'XLM', + USDC = 'USDC', + ETH = 'ETH', + NATIVE = 'NATIVE', + MATIC = 'MATIC', + ARB = 'ARB', +} + +export enum PaymentPriority { + PRIMARY = 'primary', + BACKUP = 'backup', + FALLBACK = 'fallback', +} + +export interface PaymentMethod { + id: string; + userId: string; + tokenType: TokenType; + tokenAddress: string; + chainId: number; + label: string; + priority: PaymentPriority; + maxSpendPerInterval: string; + isVerified: boolean; + isActive: boolean; + expiresAt: Date | null; + lastUsedAt: Date | null; + createdAt: Date; + updatedAt: Date; + metadata: Record; +} + +export interface PaymentMethodFormData { + tokenType: TokenType; + tokenAddress: string; + chainId: number; + label: string; + priority: PaymentPriority; + maxSpendPerInterval: string; +} + +export interface PaymentAttempt { + id: string; + paymentMethodId: string; + subscriptionId: string; + amount: string; + tokenType: TokenType; + status: 'pending' | 'success' | 'failed' | 'fallback_triggered'; + failureReason?: string; + gasPrice?: string; + gasUsed?: string; + attemptedAt: Date; + resolvedAt?: Date; +} + +export interface PaymentMethodValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; + requiresVerification: boolean; + estimatedGas: GasEstimate | null; +}