diff --git a/README.md b/README.md index e5601ea5..4723b6e8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ SubTrackr is a mobile application for managing recurring payments and subscripti - Price change alerts and spending insights - AI-powered savings suggestions +**Invoice Management with Branding** + +- Custom invoice branding with company logo and colors +- Multiple invoice templates (Modern, Classic, Minimal, Professional) +- PDF generation with full branding customization +- Comprehensive invoice analytics and revenue tracking +- Invoice preview before generation +- Detailed payment history and status tracking + **Wallet Integration** - Native Freighter wallet connection for Stellar transactions diff --git a/docs/invoice-api.md b/docs/invoice-api.md new file mode 100644 index 00000000..0e54f884 --- /dev/null +++ b/docs/invoice-api.md @@ -0,0 +1,384 @@ +# Invoice API Documentation + +## Overview + +The Invoice API provides comprehensive invoice management capabilities including branding customization, template management, PDF generation, and analytics. + +## Invoice Management + +### Create Invoice + +```typescript +createInvoice(data: InvoiceFormData): Promise +``` + +Creates a new invoice with the specified data. + +**Parameters:** +- `data.subscriptionId` (string, required): ID of the associated subscription +- `data.amount` (number, required): Invoice amount +- `data.currency` (string, required): Currency code (e.g., 'USD', 'EUR') +- `data.dueDate` (Date, required): Payment due date +- `data.brandingId` (string, optional): ID of branding to apply +- `data.templateId` (string, optional): ID of template to use +- `data.notes` (string, optional): Additional notes +- `data.paymentTerms` (string, optional): Payment terms description +- `data.customerEmail` (string, optional): Customer email address +- `data.customerName` (string, optional): Customer name +- `data.lineItems` (InvoiceLineItem[], required): Invoice line items +- `data.taxAmount` (number, optional): Tax amount +- `data.discountAmount` (number, optional): Discount amount + +**Returns:** Created invoice object + +**Example:** +```typescript +const invoice = await createInvoice({ + subscriptionId: 'sub-123', + amount: 99.99, + currency: 'USD', + dueDate: new Date('2026-08-26'), + lineItems: [ + { + id: 'item-1', + description: 'Monthly Subscription', + quantity: 1, + unitPrice: 99.99, + amount: 99.99, + }, + ], +}); +``` + +### Update Invoice + +```typescript +updateInvoice(id: string, updates: Partial): Promise +``` + +Updates an existing invoice. + +**Parameters:** +- `id` (string, required): Invoice ID +- `updates` (object, required): Fields to update + +**Returns:** Updated invoice object + +### Get All Invoices + +```typescript +getAllInvoices(filters?: InvoiceFilters): Promise +``` + +Retrieves all invoices with optional filtering. + +**Filter Parameters:** +- `status` (InvoiceStatus[], optional): Filter by status +- `subscriptionId` (string, optional): Filter by subscription +- `dateFrom` (Date, optional): Start date filter +- `dateTo` (Date, optional): End date filter +- `minAmount` (number, optional): Minimum amount filter +- `maxAmount` (number, optional): Maximum amount filter + +**Returns:** Array of invoices + +### Get Invoice by ID + +```typescript +getInvoiceById(id: string): Promise +``` + +Retrieves a specific invoice by ID. + +**Parameters:** +- `id` (string, required): Invoice ID + +**Returns:** Invoice object or null if not found + +### Delete Invoice + +```typescript +deleteInvoice(id: string): Promise +``` + +Deletes an invoice. + +**Parameters:** +- `id` (string, required): Invoice ID + +## Branding Management + +### Save Branding + +```typescript +saveBranding(branding: Omit): Promise +``` + +Creates or updates invoice branding settings. + +**Parameters:** +- `companyName` (string, required): Company name +- `companyLogo` (string, optional): Logo URL +- `primaryColor` (string, required): Primary brand color (hex) +- `secondaryColor` (string, required): Secondary brand color (hex) +- `accentColor` (string, optional): Accent color (hex) +- `fontFamily` (string, optional): Font family CSS string +- `logoPosition` ('left' | 'center' | 'right', optional): Logo alignment + +**Returns:** Saved branding object + +**Example:** +```typescript +const branding = await saveBranding({ + companyName: 'Acme Inc', + companyLogo: 'https://example.com/logo.png', + primaryColor: '#4F46E5', + secondaryColor: '#6B7280', + logoPosition: 'left', +}); +``` + +### Get Branding + +```typescript +getBranding(): Promise +``` + +Retrieves current invoice branding settings. + +**Returns:** Branding object or null if not configured + +### Delete Branding + +```typescript +deleteBranding(): Promise +``` + +Removes invoice branding settings. + +## Template Management + +### Create Template + +```typescript +createTemplate(template: Omit): Promise +``` + +Creates a new invoice template. + +**Parameters:** +- `name` (string, required): Template name +- `description` (string, optional): Template description +- `layout` (InvoiceLayout, required): Layout style +- `headerContent` (string, optional): Custom header HTML +- `footerContent` (string, optional): Custom footer HTML +- `includePaymentTerms` (boolean, required): Show payment terms +- `includeNotes` (boolean, required): Show notes section +- `includeSignature` (boolean, required): Show signature line +- `isDefault` (boolean, required): Set as default template + +**Returns:** Created template object + +**Example:** +```typescript +const template = await createTemplate({ + name: 'Professional Invoice', + layout: InvoiceLayout.PROFESSIONAL, + includePaymentTerms: true, + includeNotes: true, + includeSignature: true, + isDefault: false, +}); +``` + +### Update Template + +```typescript +updateTemplate(id: string, updates: Partial): Promise +``` + +Updates an existing template. + +**Parameters:** +- `id` (string, required): Template ID +- `updates` (object, required): Fields to update + +**Returns:** Updated template object + +### Get All Templates + +```typescript +getAllTemplates(): Promise +``` + +Retrieves all invoice templates. + +**Returns:** Array of templates + +### Delete Template + +```typescript +deleteTemplate(id: string): Promise +``` + +Deletes a template. + +**Parameters:** +- `id` (string, required): Template ID + +## PDF Generation + +### Generate Invoice PDF + +```typescript +generateInvoicePDF(options: PDFGenerationOptions): Promise +``` + +Generates a PDF version of an invoice with branding. + +**Parameters:** +- `invoiceId` (string, required): Invoice ID +- `includeWatermark` (boolean, optional): Add watermark +- `paperSize` ('A4' | 'Letter', optional): Paper size +- `orientation` ('portrait' | 'landscape', optional): Page orientation +- `quality` ('low' | 'medium' | 'high', optional): PDF quality + +**Returns:** PDF URL + +**Example:** +```typescript +const pdfUrl = await generateInvoicePDF({ + invoiceId: 'inv-123', + paperSize: 'A4', + orientation: 'portrait', + quality: 'high', +}); +``` + +### Preview Invoice + +```typescript +previewInvoice(invoiceId: string): Promise +``` + +Generates HTML preview of an invoice. + +**Parameters:** +- `invoiceId` (string, required): Invoice ID + +**Returns:** Preview object with HTML content + +## Analytics + +### Get Invoice Analytics + +```typescript +getInvoiceAnalytics(): Promise +``` + +Retrieves comprehensive invoice analytics. + +**Returns:** Analytics object containing: +- `totalInvoices` (number): Total invoice count +- `totalRevenue` (number): Total revenue from paid invoices +- `paidInvoices` (number): Count of paid invoices +- `pendingInvoices` (number): Count of pending invoices +- `overdueInvoices` (number): Count of overdue invoices +- `averageInvoiceAmount` (number): Average invoice value +- `revenueByMonth` (object): Monthly revenue breakdown +- `statusBreakdown` (object): Invoice count by status +- `paymentMethodBreakdown` (object): Invoice count by payment method +- `topSubscriptions` (array): Top revenue-generating subscriptions + +**Example Response:** +```typescript +{ + totalInvoices: 150, + totalRevenue: 14999.50, + paidInvoices: 120, + pendingInvoices: 20, + overdueInvoices: 10, + averageInvoiceAmount: 124.99, + revenueByMonth: { + '2026-07': 2500.00, + '2026-06': 3200.00, + }, + statusBreakdown: { + draft: 5, + pending: 20, + paid: 120, + overdue: 10, + cancelled: 3, + refunded: 2, + }, + paymentMethodBreakdown: { + 'credit_card': 80, + 'crypto': 40, + }, + topSubscriptions: [ + { + subscriptionId: 'sub-123', + subscriptionName: 'Netflix Premium', + revenue: 1499.88, + invoiceCount: 12, + }, + ], +} +``` + +## Invoice Status Workflow + +``` +DRAFT → PENDING → PAID + ↓ + OVERDUE + ↓ + CANCELLED or REFUNDED +``` + +- **DRAFT**: Invoice created but not sent +- **PENDING**: Invoice sent, awaiting payment +- **PAID**: Payment received +- **OVERDUE**: Past due date without payment +- **CANCELLED**: Invoice cancelled +- **REFUNDED**: Payment refunded + +## Layout Types + +- **MODERN**: Clean and contemporary design +- **CLASSIC**: Traditional professional layout +- **MINIMAL**: Simple and straightforward +- **PROFESSIONAL**: Formal business style + +## Storage + +All invoice data is stored locally using AsyncStorage with the following keys: +- `@SubTrackr:invoices`: Invoice records +- `@SubTrackr:invoiceBranding`: Branding configuration +- `@SubTrackr:invoiceTemplates`: Invoice templates + +## Error Handling + +All API methods throw errors with descriptive messages. Always wrap calls in try-catch blocks: + +```typescript +try { + const invoice = await createInvoice(data); +} catch (error) { + console.error('Invoice creation failed:', error.message); +} +``` + +## Integration with Subscriptions + +Invoices are linked to subscriptions via `subscriptionId`. When a subscription is charged, an invoice should be created to track the transaction. + +## Best Practices + +1. **Always set branding** before generating PDFs for professional appearance +2. **Use templates** to maintain consistent invoice styling +3. **Track analytics regularly** to monitor revenue and payment status +4. **Update invoice status** when payments are received +5. **Generate PDFs** for record-keeping and customer delivery +6. **Filter invoices** when displaying large lists to improve performance +7. **Include line items** with clear descriptions for transparency diff --git a/src/screens/InvoiceAnalyticsScreen.tsx b/src/screens/InvoiceAnalyticsScreen.tsx new file mode 100644 index 00000000..20d3aa47 --- /dev/null +++ b/src/screens/InvoiceAnalyticsScreen.tsx @@ -0,0 +1,315 @@ +import React, { useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + ActivityIndicator, + Dimensions, +} from 'react-native'; +import { useInvoiceStore } from '../store/invoiceStore'; +import { useTheme } from '../theme/useTheme'; + +const { width } = Dimensions.get('window'); + +export default function InvoiceAnalyticsScreen() { + const { theme } = useTheme(); + const { analytics, isLoading, loadAnalytics } = useInvoiceStore(); + + useEffect(() => { + loadAnalytics(); + }, []); + + if (isLoading || !analytics) { + return ( + + + + ); + } + + const MetricCard = ({ title, value, subtitle, color }: any) => ( + + {title} + {value} + {subtitle && {subtitle}} + + ); + + const StatusCard = ({ status, count, color }: any) => ( + + + + {status} + {count} + + + ); + + const statusColors = { + draft: '#3B82F6', + pending: '#F59E0B', + paid: '#10B981', + overdue: '#EF4444', + cancelled: '#6B7280', + refunded: '#8B5CF6', + }; + + return ( + + + Overview + + + + + + + + + + + Status Breakdown + + {Object.entries(analytics.statusBreakdown).map(([status, count]) => ( + + ))} + + + + {analytics.paymentMethodBreakdown && Object.keys(analytics.paymentMethodBreakdown).length > 0 && ( + + Payment Methods + {Object.entries(analytics.paymentMethodBreakdown).map(([method, count]) => ( + + + {method || 'Not specified'} + + + {count} invoices + + + ))} + + )} + + {analytics.topSubscriptions.length > 0 && ( + + Top Subscriptions + {analytics.topSubscriptions.map((sub, index) => ( + + + + #{index + 1} + + + + + {sub.subscriptionName} + + + ${sub.revenue.toFixed(2)} revenue • {sub.invoiceCount} invoices + + + + ))} + + )} + + {analytics.revenueByMonth && Object.keys(analytics.revenueByMonth).length > 0 && ( + + Monthly Revenue + {Object.entries(analytics.revenueByMonth) + .sort(([a], [b]) => b.localeCompare(a)) + .slice(0, 6) + .map(([month, revenue]) => ( + + {month} + + ${revenue.toFixed(2)} + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + section: { + padding: 16, + marginBottom: 8, + }, + sectionTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 16, + }, + metricsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + metricCard: { + width: (width - 44) / 2, + padding: 16, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + metricTitle: { + fontSize: 12, + fontWeight: '600', + marginBottom: 8, + }, + metricValue: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 4, + }, + metricSubtitle: { + fontSize: 12, + }, + statusGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + }, + statusCard: { + width: (width - 44) / 2, + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + statusIndicator: { + width: 8, + height: 40, + borderRadius: 4, + marginRight: 12, + }, + statusContent: { + flex: 1, + }, + statusLabel: { + fontSize: 14, + fontWeight: '500', + marginBottom: 4, + }, + statusCount: { + fontSize: 20, + fontWeight: 'bold', + }, + paymentMethodRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderRadius: 8, + marginBottom: 8, + }, + paymentMethodLabel: { + fontSize: 16, + fontWeight: '500', + }, + paymentMethodCount: { + fontSize: 14, + }, + topSubCard: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + marginBottom: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + topSubRank: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: '#F3F4F6', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + topSubRankText: { + fontSize: 16, + fontWeight: 'bold', + }, + topSubContent: { + flex: 1, + }, + topSubName: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + topSubRevenue: { + fontSize: 14, + }, + revenueRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 16, + borderRadius: 8, + marginBottom: 8, + }, + revenueMonth: { + fontSize: 16, + fontWeight: '500', + }, + revenueAmount: { + fontSize: 18, + fontWeight: 'bold', + }, +}); diff --git a/src/screens/InvoiceBrandingScreen.tsx b/src/screens/InvoiceBrandingScreen.tsx new file mode 100644 index 00000000..499f6b34 --- /dev/null +++ b/src/screens/InvoiceBrandingScreen.tsx @@ -0,0 +1,344 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; +import { useInvoiceStore } from '../store/invoiceStore'; +import { useTheme } from '../theme/useTheme'; +import type { InvoiceBranding } from '../types/invoice'; + +export default function InvoiceBrandingScreen({ navigation }: any) { + const { theme } = useTheme(); + const { branding, isLoading, loadBranding, saveBranding } = useInvoiceStore(); + + const [formData, setFormData] = useState({ + companyName: '', + companyLogo: '', + primaryColor: '#4F46E5', + secondaryColor: '#6B7280', + accentColor: '#10B981', + fontFamily: 'Arial, sans-serif', + logoPosition: 'left' as 'left' | 'center' | 'right', + }); + + useEffect(() => { + loadBranding(); + }, []); + + useEffect(() => { + if (branding) { + setFormData({ + companyName: branding.companyName, + companyLogo: branding.companyLogo || '', + primaryColor: branding.primaryColor, + secondaryColor: branding.secondaryColor, + accentColor: branding.accentColor || '#10B981', + fontFamily: branding.fontFamily || 'Arial, sans-serif', + logoPosition: branding.logoPosition || 'left', + }); + } + }, [branding]); + + const handleSave = async () => { + if (!formData.companyName.trim()) { + Alert.alert('Error', 'Company name is required'); + return; + } + + try { + await saveBranding(formData); + Alert.alert('Success', 'Branding saved successfully'); + navigation.goBack(); + } catch (error) { + Alert.alert('Error', 'Failed to save branding'); + } + }; + + const colorPresets = [ + { name: 'Indigo', primary: '#4F46E5', secondary: '#6B7280' }, + { name: 'Blue', primary: '#3B82F6', secondary: '#6B7280' }, + { name: 'Green', primary: '#10B981', secondary: '#6B7280' }, + { name: 'Purple', primary: '#8B5CF6', secondary: '#6B7280' }, + { name: 'Red', primary: '#EF4444', secondary: '#6B7280' }, + { name: 'Orange', primary: '#F97316', secondary: '#6B7280' }, + ]; + + if (isLoading && !branding) { + return ( + + + + ); + } + + return ( + + + Company Information + + Company Name * + setFormData({ ...formData, companyName: text })} + placeholder="Enter company name" + placeholderTextColor={theme.colors.textSecondary} + /> + + Company Logo URL + setFormData({ ...formData, companyLogo: text })} + placeholder="https://example.com/logo.png" + placeholderTextColor={theme.colors.textSecondary} + /> + + Logo Position + + {['left', 'center', 'right'].map(position => ( + + setFormData({ ...formData, logoPosition: position as 'left' | 'center' | 'right' }) + } + > + + {position.charAt(0).toUpperCase() + position.slice(1)} + + + ))} + + + + + Color Scheme + + Color Presets + + {colorPresets.map(preset => ( + + setFormData({ + ...formData, + primaryColor: preset.primary, + secondaryColor: preset.secondary, + }) + } + > + + + + + {preset.name} + + ))} + + + Primary Color + + setFormData({ ...formData, primaryColor: text })} + placeholder="#4F46E5" + placeholderTextColor={theme.colors.textSecondary} + /> + + + + Secondary Color + + setFormData({ ...formData, secondaryColor: text })} + placeholder="#6B7280" + placeholderTextColor={theme.colors.textSecondary} + /> + + + + Accent Color + + setFormData({ ...formData, accentColor: text })} + placeholder="#10B981" + placeholderTextColor={theme.colors.textSecondary} + /> + + + + + + Typography + + Font Family + setFormData({ ...formData, fontFamily: text })} + placeholder="Arial, sans-serif" + placeholderTextColor={theme.colors.textSecondary} + /> + + + + navigation.goBack()} + > + Cancel + + + {isLoading ? ( + + ) : ( + Save Branding + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + section: { + padding: 16, + marginBottom: 8, + }, + sectionTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 16, + }, + label: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + marginTop: 12, + }, + input: { + padding: 12, + borderRadius: 8, + fontSize: 16, + }, + radioGroup: { + flexDirection: 'row', + gap: 8, + }, + radioButton: { + flex: 1, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + }, + radioButtonText: { + fontSize: 14, + fontWeight: '600', + }, + presetGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 12, + marginBottom: 12, + }, + presetCard: { + width: '30%', + padding: 12, + borderRadius: 8, + alignItems: 'center', + }, + presetColors: { + flexDirection: 'row', + gap: 4, + marginBottom: 8, + }, + colorCircle: { + width: 24, + height: 24, + borderRadius: 12, + }, + presetName: { + fontSize: 12, + fontWeight: '500', + }, + colorInputContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + colorPreview: { + width: 48, + height: 48, + borderRadius: 8, + borderWidth: 1, + borderColor: '#E5E7EB', + }, + buttonContainer: { + flexDirection: 'row', + padding: 16, + gap: 12, + }, + button: { + flex: 1, + paddingVertical: 14, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + cancelButton: { + borderWidth: 1, + borderColor: '#E5E7EB', + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/src/screens/InvoiceManagementScreen.tsx b/src/screens/InvoiceManagementScreen.tsx new file mode 100644 index 00000000..11b53634 --- /dev/null +++ b/src/screens/InvoiceManagementScreen.tsx @@ -0,0 +1,330 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + ActivityIndicator, + Alert, +} from 'react-native'; +import { useInvoiceStore } from '../store/invoiceStore'; +import type { Invoice, InvoiceStatus } from '../types/invoice'; +import { useTheme } from '../theme/useTheme'; + +export default function InvoiceManagementScreen({ navigation }: any) { + const { theme } = useTheme(); + const { invoices, isLoading, error, loadInvoices, deleteInvoice, generatePDF } = useInvoiceStore(); + const [filterStatus, setFilterStatus] = useState('all'); + + useEffect(() => { + loadInvoices(); + }, []); + + const filteredInvoices = invoices.filter( + inv => filterStatus === 'all' || inv.status === filterStatus + ); + + const handleDeleteInvoice = (id: string) => { + Alert.alert( + 'Delete Invoice', + 'Are you sure you want to delete this invoice?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + try { + await deleteInvoice(id); + Alert.alert('Success', 'Invoice deleted successfully'); + } catch (err) { + Alert.alert('Error', 'Failed to delete invoice'); + } + }, + }, + ] + ); + }; + + const handleGeneratePDF = async (invoiceId: string) => { + try { + const pdfUrl = await generatePDF({ invoiceId }); + Alert.alert('Success', `PDF generated: ${pdfUrl}`); + } catch (err) { + Alert.alert('Error', 'Failed to generate PDF'); + } + }; + + const getStatusColor = (status: InvoiceStatus) => { + switch (status) { + case 'paid': + return '#10B981'; + case 'pending': + return '#F59E0B'; + case 'overdue': + return '#EF4444'; + case 'cancelled': + return '#6B7280'; + case 'draft': + return '#3B82F6'; + default: + return theme.colors.text; + } + }; + + const renderInvoiceItem = ({ item }: { item: Invoice }) => ( + navigation.navigate('InvoiceDetail', { invoiceId: item.id })} + > + + + {item.invoiceNumber} + + + + {item.status.toUpperCase()} + + + + + + {item.subscriptionName} + + + + + Amount: + + {item.currency} {item.totalAmount.toFixed(2)} + + + + Due Date: + + {item.dueDate.toLocaleDateString()} + + + + + + handleGeneratePDF(item.id)} + > + Generate PDF + + handleDeleteInvoice(item.id)} + > + Delete + + + + ); + + const renderFilterButtons = () => { + const filters: Array = ['all', 'draft', 'pending', 'paid', 'overdue']; + return ( + + {filters.map(filter => ( + setFilterStatus(filter)} + > + + {filter.toUpperCase()} + + + ))} + + ); + }; + + if (isLoading && invoices.length === 0) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + {renderFilterButtons()} + + + + Invoices ({filteredInvoices.length}) + + navigation.navigate('CreateInvoice')} + > + + New Invoice + + + + item.id} + contentContainerStyle={styles.listContainer} + ListEmptyComponent={ + + + No invoices found + + + } + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + centerContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + filterContainer: { + flexDirection: 'row', + padding: 12, + gap: 8, + }, + filterButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + }, + filterButtonText: { + fontSize: 12, + fontWeight: '600', + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, + title: { + fontSize: 24, + fontWeight: 'bold', + }, + addButton: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + addButtonText: { + color: '#FFFFFF', + fontWeight: '600', + }, + listContainer: { + padding: 16, + }, + invoiceCard: { + padding: 16, + borderRadius: 12, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + invoiceHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + invoiceNumber: { + fontSize: 18, + fontWeight: 'bold', + }, + statusBadge: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 12, + }, + statusText: { + fontSize: 12, + fontWeight: '600', + }, + subscriptionName: { + fontSize: 16, + marginBottom: 12, + }, + invoiceDetails: { + marginBottom: 12, + }, + detailRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 4, + }, + detailLabel: { + fontSize: 14, + }, + detailValue: { + fontSize: 14, + fontWeight: '500', + }, + actionButtons: { + flexDirection: 'row', + gap: 8, + marginTop: 8, + }, + actionButton: { + flex: 1, + paddingVertical: 8, + borderRadius: 8, + alignItems: 'center', + }, + deleteButton: { + backgroundColor: '#EF4444', + }, + actionButtonText: { + color: '#FFFFFF', + fontWeight: '600', + fontSize: 14, + }, + emptyContainer: { + alignItems: 'center', + marginTop: 40, + }, + emptyText: { + fontSize: 16, + }, + errorText: { + fontSize: 16, + textAlign: 'center', + }, +}); diff --git a/src/services/__tests__/invoiceService.test.ts b/src/services/__tests__/invoiceService.test.ts new file mode 100644 index 00000000..0be513a5 --- /dev/null +++ b/src/services/__tests__/invoiceService.test.ts @@ -0,0 +1,474 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as invoiceService from '../invoiceService'; +import { InvoiceLayout, InvoiceStatus } from '../../types/invoice'; +import type { InvoiceFormData } from '../../types/invoice'; + +jest.mock('@react-native-async-storage/async-storage'); + +describe('invoiceService', () => { + beforeEach(() => { + jest.clearAllMocks(); + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null); + (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined); + }); + + describe('Branding Management', () => { + it('should save new branding', async () => { + const brandingData = { + companyName: 'Test Company', + primaryColor: '#4F46E5', + secondaryColor: '#6B7280', + }; + + const branding = await invoiceService.saveBranding(brandingData); + + expect(branding).toMatchObject(brandingData); + expect(branding.id).toBeDefined(); + expect(branding.createdAt).toBeInstanceOf(Date); + expect(branding.updatedAt).toBeInstanceOf(Date); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + '@SubTrackr:invoiceBranding', + expect.any(String) + ); + }); + + it('should update existing branding', async () => { + const existingBranding = { + id: 'brand-123', + companyName: 'Old Company', + primaryColor: '#000000', + secondaryColor: '#FFFFFF', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(existingBranding)); + + const updatedData = { + companyName: 'New Company', + primaryColor: '#4F46E5', + secondaryColor: '#6B7280', + }; + + const branding = await invoiceService.saveBranding(updatedData); + + expect(branding.id).toBe(existingBranding.id); + expect(branding.companyName).toBe('New Company'); + expect(branding.createdAt).toEqual(existingBranding.createdAt); + }); + + it('should get branding', async () => { + const brandingData = { + id: 'brand-123', + companyName: 'Test Company', + primaryColor: '#4F46E5', + secondaryColor: '#6B7280', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(brandingData)); + + const branding = await invoiceService.getBranding(); + + expect(branding).toBeDefined(); + expect(branding?.companyName).toBe('Test Company'); + expect(branding?.createdAt).toBeInstanceOf(Date); + }); + + it('should return null when no branding exists', async () => { + const branding = await invoiceService.getBranding(); + expect(branding).toBeNull(); + }); + + it('should delete branding', async () => { + await invoiceService.deleteBranding(); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith('@SubTrackr:invoiceBranding'); + }); + }); + + describe('Template Management', () => { + it('should create new template', async () => { + const templateData = { + name: 'Test Template', + description: 'A test template', + layout: InvoiceLayout.MODERN, + includePaymentTerms: true, + includeNotes: true, + includeSignature: false, + isDefault: false, + }; + + const template = await invoiceService.createTemplate(templateData); + + expect(template).toMatchObject(templateData); + expect(template.id).toBeDefined(); + expect(template.createdAt).toBeInstanceOf(Date); + }); + + it('should set template as default and unset others', async () => { + const existingTemplates = [ + { + id: 'temp-1', + name: 'Template 1', + layout: InvoiceLayout.MODERN, + includePaymentTerms: true, + includeNotes: true, + includeSignature: false, + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(existingTemplates)); + + const newTemplate = await invoiceService.createTemplate({ + name: 'Template 2', + layout: InvoiceLayout.CLASSIC, + includePaymentTerms: true, + includeNotes: true, + includeSignature: true, + isDefault: true, + }); + + expect(newTemplate.isDefault).toBe(true); + + const savedData = (AsyncStorage.setItem as jest.Mock).mock.calls[0][1]; + const savedTemplates = JSON.parse(savedData); + const oldTemplate = savedTemplates.find((t: any) => t.id === 'temp-1'); + expect(oldTemplate.isDefault).toBe(false); + }); + + it('should update template', async () => { + const templates = [ + { + id: 'temp-1', + name: 'Old Name', + layout: InvoiceLayout.MODERN, + includePaymentTerms: true, + includeNotes: true, + includeSignature: false, + isDefault: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(templates)); + + const updated = await invoiceService.updateTemplate('temp-1', { name: 'New Name' }); + + expect(updated.name).toBe('New Name'); + expect(updated.updatedAt).toBeInstanceOf(Date); + }); + + it('should throw error when updating non-existent template', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify([])); + + await expect( + invoiceService.updateTemplate('non-existent', { name: 'Test' }) + ).rejects.toThrow('Template with id non-existent not found'); + }); + + it('should delete template', async () => { + const templates = [ + { + id: 'temp-1', + name: 'Template 1', + layout: InvoiceLayout.MODERN, + includePaymentTerms: true, + includeNotes: true, + includeSignature: false, + isDefault: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + (AsyncStorage.getItem as jest.Mock).mockResolvedValue(JSON.stringify(templates)); + + await invoiceService.deleteTemplate('temp-1'); + + const savedData = (AsyncStorage.setItem as jest.Mock).mock.calls[0][1]; + const savedTemplates = JSON.parse(savedData); + expect(savedTemplates).toHaveLength(0); + }); + + it('should get all templates', async () => { + const templates = await invoiceService.getAllTemplates(); + expect(templates).toBeDefined(); + expect(templates.length).toBeGreaterThan(0); + expect(templates[0].layout).toBeDefined(); + }); + + it('should get default template', async () => { + const template = await invoiceService.getDefaultTemplate(); + expect(template).toBeDefined(); + expect(template.isDefault).toBe(true); + }); + }); + + describe('Invoice Management', () => { + const mockInvoiceData: InvoiceFormData = { + subscriptionId: 'sub-123', + amount: 99.99, + currency: 'USD', + dueDate: new Date('2026-08-26'), + lineItems: [ + { + id: 'item-1', + description: 'Monthly Subscription', + quantity: 1, + unitPrice: 99.99, + amount: 99.99, + }, + ], + }; + + it('should create new invoice', async () => { + const invoice = await invoiceService.createInvoice(mockInvoiceData); + + expect(invoice).toMatchObject({ + subscriptionId: 'sub-123', + amount: 99.99, + currency: 'USD', + status: InvoiceStatus.DRAFT, + }); + expect(invoice.id).toBeDefined(); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{6}-\d{4}$/); + expect(invoice.totalAmount).toBe(99.99); + }); + + it('should calculate total with tax and discount', async () => { + const dataWithTaxAndDiscount = { + ...mockInvoiceData, + taxAmount: 10, + discountAmount: 5, + }; + + const invoice = await invoiceService.createInvoice(dataWithTaxAndDiscount); + + expect(invoice.totalAmount).toBe(104.99); // 99.99 + 10 - 5 + }); + + it('should update invoice', async () => { + const invoice = await invoiceService.createInvoice(mockInvoiceData); + + const updated = await invoiceService.updateInvoice(invoice.id, { + status: InvoiceStatus.PAID, + }); + + expect(updated.status).toBe(InvoiceStatus.PAID); + expect(updated.updatedAt).toBeInstanceOf(Date); + }); + + it('should recalculate total when line items updated', async () => { + const invoice = await invoiceService.createInvoice(mockInvoiceData); + + const newLineItems = [ + { + id: 'item-1', + description: 'Item 1', + quantity: 2, + unitPrice: 50, + amount: 100, + }, + { + id: 'item-2', + description: 'Item 2', + quantity: 1, + unitPrice: 25, + amount: 25, + }, + ]; + + const updated = await invoiceService.updateInvoice(invoice.id, { + lineItems: newLineItems, + }); + + expect(updated.totalAmount).toBe(125); + }); + + it('should get all invoices', async () => { + await invoiceService.createInvoice(mockInvoiceData); + await invoiceService.createInvoice(mockInvoiceData); + + const invoices = await invoiceService.getAllInvoices(); + + expect(invoices).toHaveLength(2); + }); + + it('should filter invoices by status', async () => { + const invoice1 = await invoiceService.createInvoice(mockInvoiceData); + const invoice2 = await invoiceService.createInvoice(mockInvoiceData); + + await invoiceService.updateInvoice(invoice1.id, { status: InvoiceStatus.PAID }); + + const paidInvoices = await invoiceService.getAllInvoices({ + status: [InvoiceStatus.PAID], + }); + + expect(paidInvoices).toHaveLength(1); + expect(paidInvoices[0].status).toBe(InvoiceStatus.PAID); + }); + + it('should filter invoices by date range', async () => { + const invoice1 = await invoiceService.createInvoice(mockInvoiceData); + + // Create invoice with past date + const oldInvoice = await invoiceService.createInvoice(mockInvoiceData); + await invoiceService.updateInvoice(oldInvoice.id, { + issueDate: new Date('2026-01-01'), + }); + + const recentInvoices = await invoiceService.getAllInvoices({ + dateFrom: new Date('2026-07-01'), + }); + + expect(recentInvoices.length).toBeGreaterThan(0); + expect(recentInvoices.every(inv => inv.issueDate >= new Date('2026-07-01'))).toBe(true); + }); + + it('should delete invoice', async () => { + const invoice = await invoiceService.createInvoice(mockInvoiceData); + await invoiceService.deleteInvoice(invoice.id); + + const invoices = await invoiceService.getAllInvoices(); + expect(invoices.find(inv => inv.id === invoice.id)).toBeUndefined(); + }); + + it('should get invoice by id', async () => { + const created = await invoiceService.createInvoice(mockInvoiceData); + const found = await invoiceService.getInvoiceById(created.id); + + expect(found).toBeDefined(); + expect(found?.id).toBe(created.id); + }); + }); + + describe('PDF Generation', () => { + it('should generate PDF and update invoice', async () => { + const invoice = await invoiceService.createInvoice({ + subscriptionId: 'sub-123', + amount: 99.99, + currency: 'USD', + dueDate: new Date('2026-08-26'), + lineItems: [ + { + id: 'item-1', + description: 'Test Item', + quantity: 1, + unitPrice: 99.99, + amount: 99.99, + }, + ], + }); + + const pdfUrl = await invoiceService.generateInvoicePDF({ + invoiceId: invoice.id, + }); + + expect(pdfUrl).toContain('mock://invoice-'); + expect(pdfUrl).toContain('.pdf'); + + const updated = await invoiceService.getInvoiceById(invoice.id); + expect(updated?.pdfUrl).toBe(pdfUrl); + }); + + it('should throw error when generating PDF for non-existent invoice', async () => { + await expect( + invoiceService.generateInvoicePDF({ invoiceId: 'non-existent' }) + ).rejects.toThrow('Invoice with id non-existent not found'); + }); + }); + + describe('Invoice Preview', () => { + it('should generate HTML preview', async () => { + const invoice = await invoiceService.createInvoice({ + subscriptionId: 'sub-123', + amount: 99.99, + currency: 'USD', + dueDate: new Date('2026-08-26'), + customerName: 'John Doe', + customerEmail: 'john@example.com', + lineItems: [ + { + id: 'item-1', + description: 'Test Item', + quantity: 1, + unitPrice: 99.99, + amount: 99.99, + }, + ], + }); + + const preview = await invoiceService.previewInvoice(invoice.id); + + expect(preview.html).toContain(''); + expect(preview.html).toContain(invoice.invoiceNumber); + expect(preview.html).toContain('Test Item'); + expect(preview.invoiceId).toBe(invoice.id); + }); + }); + + describe('Invoice Analytics', () => { + beforeEach(async () => { + // Create test invoices + const invoice1 = await invoiceService.createInvoice({ + subscriptionId: 'sub-1', + amount: 100, + currency: 'USD', + dueDate: new Date('2026-08-26'), + lineItems: [{ id: '1', description: 'Item 1', quantity: 1, unitPrice: 100, amount: 100 }], + }); + + const invoice2 = await invoiceService.createInvoice({ + subscriptionId: 'sub-2', + amount: 200, + currency: 'USD', + dueDate: new Date('2026-08-26'), + lineItems: [{ id: '2', description: 'Item 2', quantity: 1, unitPrice: 200, amount: 200 }], + }); + + await invoiceService.updateInvoice(invoice1.id, { + status: InvoiceStatus.PAID, + subscriptionName: 'Subscription 1', + }); + + await invoiceService.updateInvoice(invoice2.id, { + status: InvoiceStatus.PENDING, + subscriptionName: 'Subscription 2', + }); + }); + + it('should calculate total invoices', async () => { + const analytics = await invoiceService.getInvoiceAnalytics(); + expect(analytics.totalInvoices).toBeGreaterThanOrEqual(2); + }); + + it('should calculate total revenue from paid invoices', async () => { + const analytics = await invoiceService.getInvoiceAnalytics(); + expect(analytics.totalRevenue).toBeGreaterThanOrEqual(100); + }); + + it('should count paid, pending, and overdue invoices', async () => { + const analytics = await invoiceService.getInvoiceAnalytics(); + expect(analytics.paidInvoices).toBeGreaterThanOrEqual(1); + expect(analytics.pendingInvoices).toBeGreaterThanOrEqual(1); + }); + + it('should provide status breakdown', async () => { + const analytics = await invoiceService.getInvoiceAnalytics(); + expect(analytics.statusBreakdown).toHaveProperty('paid'); + expect(analytics.statusBreakdown).toHaveProperty('pending'); + expect(analytics.statusBreakdown).toHaveProperty('draft'); + }); + + it('should track top subscriptions by revenue', async () => { + const analytics = await invoiceService.getInvoiceAnalytics(); + expect(analytics.topSubscriptions).toBeDefined(); + expect(analytics.topSubscriptions.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/services/invoiceService.ts b/src/services/invoiceService.ts new file mode 100644 index 00000000..8ca855ca --- /dev/null +++ b/src/services/invoiceService.ts @@ -0,0 +1,512 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { + Invoice, + InvoiceBranding, + InvoiceTemplate, + InvoiceAnalytics, + InvoicePreview, + InvoiceFormData, + PDFGenerationOptions, + InvoiceFilters, + InvoiceStatus, + InvoiceLineItem, +} from '../types/invoice'; +import { InvoiceLayout } from '../types/invoice'; + +const STORAGE_KEYS = { + INVOICES: '@SubTrackr:invoices', + BRANDING: '@SubTrackr:invoiceBranding', + TEMPLATES: '@SubTrackr:invoiceTemplates', +} as const; + +// Branding Management +export async function saveBranding(branding: Omit): Promise { + const existing = await getBranding(); + const now = new Date(); + + const newBranding: InvoiceBranding = { + ...branding, + id: existing?.id || generateId(), + createdAt: existing?.createdAt || now, + updatedAt: now, + }; + + await AsyncStorage.setItem(STORAGE_KEYS.BRANDING, JSON.stringify(newBranding)); + return newBranding; +} + +export async function getBranding(): Promise { + try { + const data = await AsyncStorage.getItem(STORAGE_KEYS.BRANDING); + if (!data) return null; + const branding = JSON.parse(data); + return { + ...branding, + createdAt: new Date(branding.createdAt), + updatedAt: new Date(branding.updatedAt), + }; + } catch (error) { + console.error('Failed to load invoice branding:', error); + return null; + } +} + +export async function deleteBranding(): Promise { + await AsyncStorage.removeItem(STORAGE_KEYS.BRANDING); +} + +// Template Management +export async function createTemplate(template: Omit): Promise { + const templates = await getAllTemplates(); + const now = new Date(); + + const newTemplate: InvoiceTemplate = { + ...template, + id: generateId(), + createdAt: now, + updatedAt: now, + }; + + // If this is set as default, unset others + if (newTemplate.isDefault) { + templates.forEach(t => t.isDefault = false); + } + + templates.push(newTemplate); + await AsyncStorage.setItem(STORAGE_KEYS.TEMPLATES, JSON.stringify(templates)); + return newTemplate; +} + +export async function updateTemplate(id: string, updates: Partial): Promise { + const templates = await getAllTemplates(); + const index = templates.findIndex(t => t.id === id); + + if (index === -1) { + throw new Error(`Template with id ${id} not found`); + } + + const updatedTemplate: InvoiceTemplate = { + ...templates[index], + ...updates, + updatedAt: new Date(), + }; + + // If this is set as default, unset others + if (updatedTemplate.isDefault) { + templates.forEach((t, i) => { + if (i !== index) t.isDefault = false; + }); + } + + templates[index] = updatedTemplate; + await AsyncStorage.setItem(STORAGE_KEYS.TEMPLATES, JSON.stringify(templates)); + return updatedTemplate; +} + +export async function deleteTemplate(id: string): Promise { + const templates = await getAllTemplates(); + const filtered = templates.filter(t => t.id !== id); + await AsyncStorage.setItem(STORAGE_KEYS.TEMPLATES, JSON.stringify(filtered)); +} + +export async function getAllTemplates(): Promise { + try { + const data = await AsyncStorage.getItem(STORAGE_KEYS.TEMPLATES); + if (!data) return getDefaultTemplates(); + const templates = JSON.parse(data); + return templates.map((t: any) => ({ + ...t, + createdAt: new Date(t.createdAt), + updatedAt: new Date(t.updatedAt), + })); + } catch (error) { + console.error('Failed to load invoice templates:', error); + return getDefaultTemplates(); + } +} + +export async function getTemplateById(id: string): Promise { + const templates = await getAllTemplates(); + return templates.find(t => t.id === id) || null; +} + +export async function getDefaultTemplate(): Promise { + const templates = await getAllTemplates(); + return templates.find(t => t.isDefault) || templates[0]; +} + +// Invoice Management +export async function createInvoice(data: InvoiceFormData): Promise { + const invoices = await getAllInvoices(); + const now = new Date(); + + const lineTotal = data.lineItems.reduce((sum, item) => sum + item.amount, 0); + const totalAmount = lineTotal + (data.taxAmount || 0) - (data.discountAmount || 0); + + const newInvoice: Invoice = { + ...data, + id: generateId(), + invoiceNumber: generateInvoiceNumber(invoices.length + 1), + subscriptionName: '', // Should be fetched from subscription + status: 'draft' as InvoiceStatus, + issueDate: now, + billingPeriodStart: now, + billingPeriodEnd: new Date(data.dueDate), + totalAmount, + createdAt: now, + updatedAt: now, + }; + + invoices.push(newInvoice); + await saveInvoices(invoices); + return newInvoice; +} + +export async function updateInvoice(id: string, updates: Partial): Promise { + const invoices = await getAllInvoices(); + const index = invoices.findIndex(inv => inv.id === id); + + if (index === -1) { + throw new Error(`Invoice with id ${id} not found`); + } + + const updatedInvoice: Invoice = { + ...invoices[index], + ...updates, + updatedAt: new Date(), + }; + + // Recalculate total if line items changed + if (updates.lineItems || updates.taxAmount || updates.discountAmount) { + const lineTotal = updatedInvoice.lineItems.reduce((sum, item) => sum + item.amount, 0); + updatedInvoice.totalAmount = lineTotal + (updatedInvoice.taxAmount || 0) - (updatedInvoice.discountAmount || 0); + } + + invoices[index] = updatedInvoice; + await saveInvoices(invoices); + return updatedInvoice; +} + +export async function deleteInvoice(id: string): Promise { + const invoices = await getAllInvoices(); + const filtered = invoices.filter(inv => inv.id !== id); + await saveInvoices(filtered); +} + +export async function getAllInvoices(filters?: InvoiceFilters): Promise { + try { + const data = await AsyncStorage.getItem(STORAGE_KEYS.INVOICES); + if (!data) return []; + + let invoices: Invoice[] = JSON.parse(data); + invoices = invoices.map((inv: any) => ({ + ...inv, + issueDate: new Date(inv.issueDate), + dueDate: new Date(inv.dueDate), + billingPeriodStart: new Date(inv.billingPeriodStart), + billingPeriodEnd: new Date(inv.billingPeriodEnd), + createdAt: new Date(inv.createdAt), + updatedAt: new Date(inv.updatedAt), + })); + + return applyFilters(invoices, filters); + } catch (error) { + console.error('Failed to load invoices:', error); + return []; + } +} + +export async function getInvoiceById(id: string): Promise { + const invoices = await getAllInvoices(); + return invoices.find(inv => inv.id === id) || null; +} + +export async function getInvoicesBySubscription(subscriptionId: string): Promise { + return getAllInvoices({ subscriptionId }); +} + +// PDF Generation +export async function generateInvoicePDF(options: PDFGenerationOptions): Promise { + const invoice = await getInvoiceById(options.invoiceId); + if (!invoice) { + throw new Error(`Invoice with id ${options.invoiceId} not found`); + } + + // Get branding and template + const branding = invoice.brandingId ? await getBranding() : null; + const template = invoice.templateId ? await getTemplateById(invoice.templateId) : await getDefaultTemplate(); + + // Generate HTML + const html = await generateInvoiceHTML(invoice, branding, template); + + // In a real implementation, this would call a PDF generation service + // For now, we'll return a mock PDF URL + const pdfUrl = `mock://invoice-${invoice.invoiceNumber}.pdf`; + + // Update invoice with PDF URL + await updateInvoice(invoice.id, { pdfUrl }); + + return pdfUrl; +} + +// Invoice Preview +export async function previewInvoice(invoiceId: string): Promise { + const invoice = await getInvoiceById(invoiceId); + if (!invoice) { + throw new Error(`Invoice with id ${invoiceId} not found`); + } + + const branding = invoice.brandingId ? await getBranding() : null; + const template = invoice.templateId ? await getTemplateById(invoice.templateId) : await getDefaultTemplate(); + + const html = await generateInvoiceHTML(invoice, branding, template); + + return { + invoiceId, + html, + brandingApplied: !!branding, + templateApplied: !!template, + }; +} + +// Invoice Analytics +export async function getInvoiceAnalytics(): Promise { + const invoices = await getAllInvoices(); + + const totalInvoices = invoices.length; + const paidInvoices = invoices.filter(inv => inv.status === 'paid').length; + const pendingInvoices = invoices.filter(inv => inv.status === 'pending').length; + const overdueInvoices = invoices.filter(inv => inv.status === 'overdue').length; + + const totalRevenue = invoices + .filter(inv => inv.status === 'paid') + .reduce((sum, inv) => sum + inv.totalAmount, 0); + + const averageInvoiceAmount = totalInvoices > 0 ? totalRevenue / paidInvoices || 0 : 0; + + // Revenue by month + const revenueByMonth: Record = {}; + invoices.filter(inv => inv.status === 'paid').forEach(inv => { + const monthKey = `${inv.issueDate.getFullYear()}-${String(inv.issueDate.getMonth() + 1).padStart(2, '0')}`; + revenueByMonth[monthKey] = (revenueByMonth[monthKey] || 0) + inv.totalAmount; + }); + + // Status breakdown + const statusBreakdown: Record = { + draft: 0, + pending: 0, + paid: 0, + overdue: 0, + cancelled: 0, + refunded: 0, + }; + invoices.forEach(inv => { + statusBreakdown[inv.status]++; + }); + + // Payment method breakdown + const paymentMethodBreakdown: Record = {}; + invoices.filter(inv => inv.paymentMethod).forEach(inv => { + const method = inv.paymentMethod!; + paymentMethodBreakdown[method] = (paymentMethodBreakdown[method] || 0) + 1; + }); + + // Top subscriptions + const subscriptionMap = new Map(); + invoices.filter(inv => inv.status === 'paid').forEach(inv => { + const existing = subscriptionMap.get(inv.subscriptionId) || { revenue: 0, invoiceCount: 0, name: inv.subscriptionName }; + subscriptionMap.set(inv.subscriptionId, { + revenue: existing.revenue + inv.totalAmount, + invoiceCount: existing.invoiceCount + 1, + name: inv.subscriptionName, + }); + }); + + const topSubscriptions = Array.from(subscriptionMap.entries()) + .map(([subscriptionId, data]) => ({ + subscriptionId, + subscriptionName: data.name, + revenue: data.revenue, + invoiceCount: data.invoiceCount, + })) + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 10); + + return { + totalInvoices, + totalRevenue, + paidInvoices, + pendingInvoices, + overdueInvoices, + averageInvoiceAmount, + revenueByMonth, + statusBreakdown, + paymentMethodBreakdown, + topSubscriptions, + }; +} + +// Helper Functions +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +function generateInvoiceNumber(sequence: number): string { + const year = new Date().getFullYear(); + const month = String(new Date().getMonth() + 1).padStart(2, '0'); + const num = String(sequence).padStart(4, '0'); + return `INV-${year}${month}-${num}`; +} + +async function saveInvoices(invoices: Invoice[]): Promise { + await AsyncStorage.setItem(STORAGE_KEYS.INVOICES, JSON.stringify(invoices)); +} + +function applyFilters(invoices: Invoice[], filters?: InvoiceFilters): Invoice[] { + if (!filters) return invoices; + + return invoices.filter(inv => { + if (filters.status && !filters.status.includes(inv.status)) return false; + if (filters.subscriptionId && inv.subscriptionId !== filters.subscriptionId) return false; + if (filters.dateFrom && inv.issueDate < filters.dateFrom) return false; + if (filters.dateTo && inv.issueDate > filters.dateTo) return false; + if (filters.minAmount && inv.totalAmount < filters.minAmount) return false; + if (filters.maxAmount && inv.totalAmount > filters.maxAmount) return false; + return true; + }); +} + +async function generateInvoiceHTML( + invoice: Invoice, + branding: InvoiceBranding | null, + template: InvoiceTemplate +): Promise { + const primaryColor = branding?.primaryColor || '#4F46E5'; + const secondaryColor = branding?.secondaryColor || '#6B7280'; + const fontFamily = branding?.fontFamily || 'Arial, sans-serif'; + + const lineItemsHTML = invoice.lineItems + .map( + item => ` + + ${item.description} + ${item.quantity} + ${invoice.currency} ${item.unitPrice.toFixed(2)} + ${invoice.currency} ${item.amount.toFixed(2)} + + ` + ) + .join(''); + + return ` + + + + + Invoice ${invoice.invoiceNumber} + + + +
+
+ ${branding?.companyLogo ? `${branding.companyName}` : ''} +

${branding?.companyName || 'SubTrackr'}

+
+ +
+
+

Invoice Details

+

Invoice Number: ${invoice.invoiceNumber}

+

Issue Date: ${invoice.issueDate.toLocaleDateString()}

+

Due Date: ${invoice.dueDate.toLocaleDateString()}

+

Status: ${invoice.status.toUpperCase()}

+
+
+

Bill To

+

${invoice.customerName || 'Customer'}

+

${invoice.customerEmail || ''}

+
+
+ + ${template.headerContent ? `
${template.headerContent}
` : ''} + + + + + + + + + + + + ${lineItemsHTML} + + + ${invoice.discountAmount ? `` : ''} + ${invoice.taxAmount ? `` : ''} + + + + + +
DescriptionQuantityUnit PriceAmount
Discount:-${invoice.currency} ${invoice.discountAmount.toFixed(2)}
Tax:${invoice.currency} ${invoice.taxAmount.toFixed(2)}
Total:${invoice.currency} ${invoice.totalAmount.toFixed(2)}
+ + ${template.includeNotes && invoice.notes ? `

Notes

${invoice.notes}

` : ''} + ${template.includePaymentTerms && invoice.paymentTerms ? `

Payment Terms

${invoice.paymentTerms}

` : ''} + ${template.footerContent ? `
${template.footerContent}
` : ''} +
+ + + `; +} + +function getDefaultTemplates(): InvoiceTemplate[] { + const now = new Date(); + return [ + { + id: 'default-modern', + name: 'Modern', + description: 'Clean and contemporary invoice design', + layout: InvoiceLayout.MODERN, + includePaymentTerms: true, + includeNotes: true, + includeSignature: false, + isDefault: true, + createdAt: now, + updatedAt: now, + }, + { + id: 'default-classic', + name: 'Classic', + description: 'Traditional professional invoice layout', + layout: InvoiceLayout.CLASSIC, + includePaymentTerms: true, + includeNotes: true, + includeSignature: true, + isDefault: false, + createdAt: now, + updatedAt: now, + }, + { + id: 'default-minimal', + name: 'Minimal', + description: 'Simple and straightforward design', + layout: InvoiceLayout.MINIMAL, + includePaymentTerms: false, + includeNotes: false, + includeSignature: false, + isDefault: false, + createdAt: now, + updatedAt: now, + }, + ]; +}