diff --git a/app/screens/ChurnPredictionScreen.tsx b/app/screens/ChurnPredictionScreen.tsx index d07b3f91..51b3fcfb 100644 --- a/app/screens/ChurnPredictionScreen.tsx +++ b/app/screens/ChurnPredictionScreen.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, ScrollView, ActivityIndicator } from 'react-native'; import { colors, spacing } from '../../src/utils/constants'; import { Card } from '../../src/components/common/Card'; -import { PredictionService, ChurnPrediction } from '../../backend/services/predictionService'; +import { PredictionService, ChurnPrediction, RiskFactor } from '../../backend/services/analytics/predictionService'; const ChurnPredictionScreen = () => { const [loading, setLoading] = useState(true); @@ -78,13 +78,13 @@ const ChurnPredictionScreen = () => { Key Risk Factors - {prediction.riskFactors.map((factor, index) => ( + {prediction.riskFactors.map((factor: RiskFactor, index: number) => ( {factor.factor .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(' ')} diff --git a/app/screens/InvoiceAnalyticsScreen.tsx b/app/screens/InvoiceAnalyticsScreen.tsx new file mode 100644 index 00000000..f66a95aa --- /dev/null +++ b/app/screens/InvoiceAnalyticsScreen.tsx @@ -0,0 +1,104 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; +import { InvoiceStatus } from '../../src/types/invoice'; + +export const InvoiceAnalyticsScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { invoices } = useInvoiceStore(); + + const totalInvoices = invoices.length; + const sentInvoices = invoices.filter(i => i.status === InvoiceStatus.SENT).length; + const paidInvoices = invoices.filter(i => i.status === InvoiceStatus.PAID).length; + const draftInvoices = invoices.filter(i => i.status === InvoiceStatus.DRAFT).length; + const voidInvoices = invoices.filter(i => i.status === InvoiceStatus.VOID).length; + + const totalRevenue = invoices + .filter(i => i.status === InvoiceStatus.PAID) + .reduce((sum, i) => sum + i.total, 0); + + return ( + + + + Invoice Analytics + Track delivery and payment performance + + + + + Total Collected + ${(totalRevenue / 100).toFixed(2)} + + + Total Invoices + {totalInvoices} + + + + + Status Breakdown + + Paid + {paidInvoices} + + + Sent (Pending) + {sentInvoices} + + + Drafts + {draftInvoices} + + + Voided + {voidInvoices} + + + + + Automated Delivery + + {sentInvoices + paidInvoices > 0 ? "100% of sent invoices were delivered automatically." : "No invoices delivered yet."} + + + + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + summaryContainer: { + flexDirection: 'row', + paddingHorizontal: spacing.lg, + marginBottom: spacing.md, + gap: spacing.md, + }, + summaryCard: { flex: 1, alignItems: 'center' }, + summaryLabel: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + summaryValue: { ...typography.h2, color: colors.text.primary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, + statRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + statLabel: { ...typography.body, color: colors.text.primary }, + statValue: { ...typography.body, fontWeight: 'bold', color: colors.text.primary }, + label: { ...typography.body, color: colors.textSecondary }, + }); +} diff --git a/app/screens/InvoiceCustomizationScreen.tsx b/app/screens/InvoiceCustomizationScreen.tsx new file mode 100644 index 00000000..6177c334 --- /dev/null +++ b/app/screens/InvoiceCustomizationScreen.tsx @@ -0,0 +1,141 @@ +import React, { useState, useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity, TextInput } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; + +export const InvoiceCustomizationScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const { config, templates, setInvoiceBranding, setDefaultTemplate } = useInvoiceStore(); + const branding = config.defaultBranding || { logoUrl: '', primaryColor: '#000000', fontFamily: 'Inter' }; + + const [logoUrl, setLogoUrl] = useState(branding.logoUrl || ''); + const [primaryColor, setPrimaryColor] = useState(branding.primaryColor || '#000000'); + const [fontFamily, setFontFamily] = useState(branding.fontFamily || 'Inter'); + const [selectedTemplate, setSelectedTemplate] = useState(config.defaultTemplateId || templates[0]?.id); + + const handleSave = () => { + setInvoiceBranding({ logoUrl, primaryColor, fontFamily }); + if (selectedTemplate) setDefaultTemplate(selectedTemplate); + alert('Invoice branding saved!'); + }; + + return ( + + + + Invoice Customization + Customize your per-tenant branding + + + + Branding + Logo URL + + + Primary Color + + + Font Family + + + + + Template Selection + {templates.map(tpl => ( + setSelectedTemplate(tpl.id)} + > + {tpl.name} + {tpl.layout} layout + + ))} + + + + Preview + + INVOICE + {logoUrl ? Logo: {logoUrl} : null} + This is a preview of your custom invoice styling. + + + + + Save Customization + + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + sectionTitle: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.md }, + label: { ...typography.caption, color: colors.textSecondary, marginBottom: spacing.xs }, + input: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: borderRadius.sm, + padding: spacing.sm, + marginBottom: spacing.md, + color: colors.text.primary, + }, + templateItem: { + borderWidth: 1, + borderColor: colors.border.default, + borderRadius: borderRadius.md, + padding: spacing.md, + marginBottom: spacing.sm, + }, + templateName: { ...typography.body, fontWeight: 'bold', color: colors.text.primary }, + previewBox: { + borderWidth: 2, + borderRadius: borderRadius.md, + padding: spacing.lg, + minHeight: 150, + backgroundColor: '#fff', + }, + actionButton: { + marginHorizontal: spacing.lg, + marginBottom: spacing.xl, + backgroundColor: colors.primary, + padding: spacing.md, + borderRadius: borderRadius.md, + alignItems: 'center' + }, + actionButtonText: { + ...typography.button, + color: colors.text.inverse + }, + }); +} diff --git a/app/screens/InvoiceMarketplaceScreen.tsx b/app/screens/InvoiceMarketplaceScreen.tsx new file mode 100644 index 00000000..323b2c78 --- /dev/null +++ b/app/screens/InvoiceMarketplaceScreen.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, ScrollView, SafeAreaView, TouchableOpacity } from 'react-native'; +import { spacing, typography, borderRadius } from '../../src/utils/constants'; +import { Card } from '../../src/components/common/Card'; +import { useThemeColors } from '../../src/hooks/useThemeColors'; +import { useInvoiceStore } from '../../src/store/invoiceStore'; + +const MARKETPLACE_TEMPLATES = [ + { id: 'tpl-1', name: 'Standard Layout', layout: 'standard', price: 'Free' }, + { id: 'tpl-2', name: 'Modern Minimalist', layout: 'modern', price: 'Free' }, + { id: 'tpl-3', name: 'Premium Corporate', layout: 'premium', price: '$4.99' }, + { id: 'tpl-4', name: 'Creative Studio', layout: 'creative', price: '$2.99' }, +]; + +export const InvoiceMarketplaceScreen: React.FC = () => { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { templates, addTemplate } = useInvoiceStore(); + + const handleInstall = (template: any) => { + const isInstalled = templates.some(t => t.id === template.id); + if (isInstalled) { + alert('Template already installed!'); + return; + } + + // Simulate purchase/installation + addTemplate({ id: template.id, name: template.name, layout: template.layout as any }); + alert(`${template.name} has been added to your templates!`); + }; + + return ( + + + + Template Marketplace + Discover and install new invoice layouts + + + {MARKETPLACE_TEMPLATES.map(tpl => { + const isInstalled = templates.some(t => t.id === tpl.id); + + return ( + + + + {tpl.name} + Style: {tpl.layout} + + + {tpl.price} + handleInstall(tpl)} + disabled={isInstalled} + > + {isInstalled ? 'Installed' : 'Get'} + + + + + ); + })} + + + ); +}; + +function createStyles(colors: any) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background.primary }, + scrollView: { flex: 1 }, + header: { padding: spacing.lg, paddingBottom: spacing.md }, + title: { ...typography.h1, color: colors.text.primary, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: colors.textSecondary }, + card: { marginHorizontal: spacing.lg, marginBottom: spacing.md, padding: spacing.md }, + row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + info: { flex: 1 }, + templateName: { ...typography.h3, color: colors.text.primary, marginBottom: spacing.xs }, + templateDesc: { ...typography.caption, color: colors.textSecondary }, + action: { alignItems: 'flex-end' }, + price: { ...typography.body, fontWeight: 'bold', color: colors.text.primary, marginBottom: spacing.xs }, + button: { + backgroundColor: colors.primary, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + }, + buttonInstalled: { + backgroundColor: colors.border.default, + }, + buttonText: { + ...typography.button, + color: colors.text.inverse, + fontSize: 12 + } + }); +} diff --git a/backend/services/billing/invoiceCustomizationService.ts b/backend/services/billing/invoiceCustomizationService.ts new file mode 100644 index 00000000..41e8bcbe --- /dev/null +++ b/backend/services/billing/invoiceCustomizationService.ts @@ -0,0 +1,52 @@ +import { Invoice, InvoiceBranding } from '../../../src/types/invoice'; +import { useInvoiceStore } from '../../../src/store/invoiceStore'; +import { presentLocalNotification } from '../../../src/services/notificationService'; + +export class InvoiceCustomizationService { + /** + * Simulates generating a PDF with per-tenant branding applied. + */ + static async generateInvoicePdf(invoice: Invoice, branding?: InvoiceBranding): Promise { + const configBranding = branding || invoice.branding || useInvoiceStore.getState().config.defaultBranding; + const templateId = invoice.templateId || 'tpl-1'; + + // In a real implementation, we would use pdfkit, puppeteer, or a service like PDFMonkey here. + // For now, we simulate generation. + console.log(`Generating PDF for ${invoice.invoiceNumber}...`); + console.log(`Applying Template ID: ${templateId}`); + if (configBranding) { + console.log(`Applying Branding: Logo=${configBranding.logoUrl}, PrimaryColor=${configBranding.primaryColor}, Font=${configBranding.fontFamily}`); + } + + // Simulate delay + await new Promise(resolve => setTimeout(resolve, 800)); + + const simulatedPdfUrl = `https://cdn.subtrackr.app/invoices/${invoice.id}.pdf`; + return simulatedPdfUrl; + } + + /** + * Automates the delivery of an invoice via email and pushes a notification. + */ + static async deliverInvoice(invoiceId: string, recipientEmail: string): Promise { + try { + const store = useInvoiceStore.getState(); + const invoice = store.invoices.find(i => i.id === invoiceId); + + if (!invoice) throw new Error('Invoice not found'); + + // Generate the branded PDF + const pdfUrl = await this.generateInvoicePdf(invoice); + + console.log(`Sending email to ${recipientEmail} with attachment ${pdfUrl}...`); + + // Update the invoice status + await store.sendInvoice(invoiceId, recipientEmail); + + return true; + } catch (e) { + console.error('Automated invoice delivery failed:', e); + return false; + } + } +} diff --git a/contracts/subscription/src/invoice_branding.rs b/contracts/subscription/src/invoice_branding.rs new file mode 100644 index 00000000..c3460095 --- /dev/null +++ b/contracts/subscription/src/invoice_branding.rs @@ -0,0 +1,24 @@ +use soroban_sdk::{contracttype, Env, String}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvoiceBranding { + pub logo_url: String, + pub primary_color: String, + pub font_family: String, + pub template_id: String, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BrandingStorageKey { + InvoiceBranding(String), // tenant_id -> InvoiceBranding +} + +pub fn set_invoice_branding(env: &Env, tenant_id: String, branding: InvoiceBranding) { + env.storage().persistent().set(&BrandingStorageKey::InvoiceBranding(tenant_id), &branding); +} + +pub fn get_invoice_branding(env: &Env, tenant_id: String) -> Option { + env.storage().persistent().get(&BrandingStorageKey::InvoiceBranding(tenant_id)) +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index b0d064ae..e777fcdd 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -3,6 +3,7 @@ mod gas_optimization; mod gas_profiler; mod gas_storage; +mod invoice_branding; mod revenue; #[cfg(test)] mod test; @@ -1431,6 +1432,17 @@ impl SubTrackrSubscription { // ── Extended APIs (disabled by default) ── // + pub fn set_invoice_branding(env: Env, proxy: Address, storage: Address, tenant_id: String, branding: invoice_branding::InvoiceBranding) { + proxy.require_auth(); + let admin: Address = storage_instance_get(&env, &storage, StorageKey::Admin).expect("Admin not set"); + require_permission(&env, &storage, &admin, Permission::SetInvoiceContract); + invoice_branding::set_invoice_branding(&env, tenant_id, branding); + } + + pub fn get_invoice_branding(env: Env, tenant_id: String) -> Option { + invoice_branding::get_invoice_branding(&env, tenant_id) + } + // These APIs depend on additional modules/types that are still evolving. // Enable with `--features extended` in the `subtrackr-subscription` crate. #[cfg(feature = "extended")] diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 75139780..b09eb45a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -100,6 +100,23 @@ const TrialDetailsScreen = lazyScreen(() => import('../screens/TrialDetailsScree // Issue #547: GDPR const PrivacyCenterScreen = lazyScreen(() => import('../screens/PrivacyCenterScreen')); +const ChurnPredictionScreen = lazyScreen(() => import('../../app/screens/ChurnPredictionScreen')); + +const InvoiceCustomizationScreen = lazyScreen(() => + import('../../app/screens/InvoiceCustomizationScreen').then((m) => ({ + default: m.InvoiceCustomizationScreen, + })) +); +const InvoiceMarketplaceScreen = lazyScreen(() => + import('../../app/screens/InvoiceMarketplaceScreen').then((m) => ({ + default: m.InvoiceMarketplaceScreen, + })) +); +const InvoiceAnalyticsScreen = lazyScreen(() => + import('../../app/screens/InvoiceAnalyticsScreen').then((m) => ({ + default: m.InvoiceAnalyticsScreen, + })) +); const DataExportScreen = lazyScreen(() => import('../screens/DataExportScreen')); // Issue #548: Push notifications const NotificationPreferencesScreen = lazyScreen( @@ -236,6 +253,10 @@ const linking: LinkingOptions = { ApiKeyManagement: 'api-keys', DocumentationPortal: 'docs', IntegrationGuides: 'integration-guides', + ChurnPrediction: 'churn-analytics', + InvoiceCustomization: 'invoice/customization', + InvoiceMarketplace: 'invoice/marketplace', + InvoiceAnalytics: 'invoice/analytics', }, }, AddTab: 'add', @@ -392,6 +413,26 @@ const HomeStack = () => ( component={IntegrationGuidesScreen} options={{ title: 'Integrations', headerShown: true }} /> + + + + ): Invoice => { updatedAt: toValidDate(raw.updatedAt, createdAt), recipientEmail: raw.recipientEmail, notes: raw.notes, + branding: raw.branding, + templateId: raw.templateId, }; }; @@ -145,6 +149,12 @@ interface InvoiceState { taxRemittanceReports: TaxRemittanceReport[]; digitalGoodsClasses: Record; + templates: InvoiceTemplate[]; + + setInvoiceBranding: (branding: InvoiceBranding) => void; + setDefaultTemplate: (templateId: string) => void; + addTemplate: (template: InvoiceTemplate) => void; + generateInvoiceFromSubscription: ( data: InvoiceFormData, taxRateBps?: number, @@ -232,6 +242,29 @@ export const useInvoiceStore = create()( taxRemittanceReports: [], digitalGoodsClasses: {}, + templates: [ + { id: 'tpl-1', name: 'Standard', layout: 'standard' }, + { id: 'tpl-2', name: 'Modern', layout: 'modern' }, + ], + + setInvoiceBranding: (branding) => { + set((state) => ({ + config: { ...state.config, defaultBranding: branding }, + })); + }, + + setDefaultTemplate: (templateId) => { + set((state) => ({ + config: { ...state.config, defaultTemplateId: templateId }, + })); + }, + + addTemplate: (template) => { + set((state) => ({ + templates: [...state.templates, template], + })); + }, + generateInvoiceFromSubscription: async (data, taxRateBps, exchangeRate) => { set({ isLoading: true, error: null }); try { @@ -254,6 +287,9 @@ export const useInvoiceStore = create()( invoice.taxJurisdiction = data.taxJurisdiction; } + invoice.branding = state.config.defaultBranding; + invoice.templateId = state.config.defaultTemplateId || state.templates[0]?.id; + set((current) => ({ invoices: [...current.invoices, invoice], nextSequence: current.nextSequence + 1, diff --git a/src/types/invoice.ts b/src/types/invoice.ts index 855bcc3e..1d95637e 100644 --- a/src/types/invoice.ts +++ b/src/types/invoice.ts @@ -247,6 +247,18 @@ export interface InvoicePeriod { end: Date; } +export interface InvoiceBranding { + logoUrl?: string; + primaryColor?: string; + fontFamily?: string; +} + +export interface InvoiceTemplate { + id: string; + name: string; + layout: 'standard' | 'modern' | 'minimalist'; +} + export interface Invoice { id: string; invoiceNumber: string; @@ -272,6 +284,8 @@ export interface Invoice { isTaxExempt?: boolean; taxExemptionId?: string; reverseCharge?: boolean; + branding?: InvoiceBranding; + templateId?: string; } export interface InvoiceConfig { @@ -283,6 +297,8 @@ export interface InvoiceConfig { exchangeRateScale: number; paymentTermsDays: number; defaultTaxType: TaxType; + defaultBranding?: InvoiceBranding; + defaultTemplateId?: string; } export interface InvoiceTotals {