From b5cee88da39e00b84df6dce515452ccfd13789c1 Mon Sep 17 00:00:00 2001 From: agenes01 Date: Fri, 24 Jul 2026 16:48:46 +0100 Subject: [PATCH 1/2] feat: implement invoice customization with per-tenant branding and templates (#709) --- app/screens/ChurnPredictionScreen.tsx | 6 +- app/screens/InvoiceAnalyticsScreen.tsx | 104 +++++++++++++ app/screens/InvoiceCustomizationScreen.tsx | 141 ++++++++++++++++++ app/screens/InvoiceMarketplaceScreen.tsx | 98 ++++++++++++ .../billing/invoiceCustomizationService.ts | 52 +++++++ .../subscription/src/invoice_branding.rs | 24 +++ contracts/subscription/src/lib.rs | 12 ++ src/navigation/AppNavigator.tsx | 41 +++++ src/navigation/types.ts | 4 + src/store/invoiceStore.ts | 36 +++++ src/types/invoice.ts | 16 ++ 11 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 app/screens/InvoiceAnalyticsScreen.tsx create mode 100644 app/screens/InvoiceCustomizationScreen.tsx create mode 100644 app/screens/InvoiceMarketplaceScreen.tsx create mode 100644 backend/services/billing/invoiceCustomizationService.ts create mode 100644 contracts/subscription/src/invoice_branding.rs 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 { From 5eca90cf785e6308b9affb3f2aebb0d0996574e1 Mon Sep 17 00:00:00 2001 From: agenes01 Date: Thu, 27 Aug 2026 19:33:43 +0100 Subject: [PATCH 2/2] feat: implement account lockout with progressive delays (#1008) --- .../__tests__/accountLockoutService.test.ts | 133 +++++++++++++ .../services/shared/accountLockoutService.ts | 184 ++++++++++++++++++ .../__tests__/accountLockoutClient.test.ts | 118 +++++++++++ src/services/auth/accountLockoutClient.ts | 132 +++++++++++++ 4 files changed, 567 insertions(+) create mode 100644 backend/services/shared/__tests__/accountLockoutService.test.ts create mode 100644 backend/services/shared/accountLockoutService.ts create mode 100644 src/services/auth/__tests__/accountLockoutClient.test.ts create mode 100644 src/services/auth/accountLockoutClient.ts diff --git a/backend/services/shared/__tests__/accountLockoutService.test.ts b/backend/services/shared/__tests__/accountLockoutService.test.ts new file mode 100644 index 00000000..d55725c9 --- /dev/null +++ b/backend/services/shared/__tests__/accountLockoutService.test.ts @@ -0,0 +1,133 @@ +import { AccountLockoutService } from '../accountLockoutService'; + +describe('AccountLockoutService', () => { + let lockoutService: AccountLockoutService; + + beforeEach(() => { + // Override Date.now for predictable time assertions where necessary, + // though here we mainly rely on real time with mocked advances if needed, + // or we just use realistic thresholds. + lockoutService = new AccountLockoutService({ + tiers: [ + { threshold: 3, lockoutMinutes: 5 }, + { threshold: 5, lockoutMinutes: 15 }, + ], + retentionMs: 1000 * 60 * 60, // 1 hour + }); + }); + + afterEach(() => { + lockoutService.clearStore(); + jest.restoreAllMocks(); + }); + + it('initially has no lockout', async () => { + const status = await lockoutService.checkLockout('user1@example.com'); + expect(status.locked).toBe(false); + expect(status.remainingMs).toBe(0); + expect(status.failedAttempts).toBe(0); + }); + + it('records failures but does not lock until threshold', async () => { + const email = 'user2@example.com'; + let status = await lockoutService.recordFailure(email); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(1); + + status = await lockoutService.recordFailure(email); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(2); + }); + + it('locks out the account when the first threshold is reached', async () => { + const email = 'user3@example.com'; + await lockoutService.recordFailure(email); + await lockoutService.recordFailure(email); + const status = await lockoutService.recordFailure(email); // 3rd failure + + expect(status.locked).toBe(true); + expect(status.failedAttempts).toBe(3); + // 5 minutes in ms + expect(status.remainingMs).toBe(5 * 60 * 1000); + + const checkStatus = await lockoutService.checkLockout(email); + expect(checkStatus.locked).toBe(true); + expect(checkStatus.failedAttempts).toBe(3); + expect(checkStatus.remainingMs).toBeLessThanOrEqual(5 * 60 * 1000); + expect(checkStatus.remainingMs).toBeGreaterThan(0); + }); + + it('does not increase failures while locked out', async () => { + const email = 'user4@example.com'; + await lockoutService.recordFailure(email); + await lockoutService.recordFailure(email); + await lockoutService.recordFailure(email); // locks out + + // attempting to record failure while locked + const status = await lockoutService.recordFailure(email); + expect(status.locked).toBe(true); + // should still be 3, not 4 + expect(status.failedAttempts).toBe(3); + }); + + it('progresses to the next tier if failures continue after lockout expires', async () => { + const email = 'user5@example.com'; + + // Mock Date.now to control time + let currentTime = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => currentTime); + + // Trigger first lockout + await lockoutService.recordFailure(email); + await lockoutService.recordFailure(email); + await lockoutService.recordFailure(email); // 3 failures, locked for 5 mins + + // Advance time past 5 minutes + currentTime += 5 * 60 * 1000 + 1000; + + // Now unlocked + const unlockStatus = await lockoutService.checkLockout(email); + expect(unlockStatus.locked).toBe(false); + + // Next failure (4th) will trigger the 3-failure tier again because 4 >= 3 + const status4 = await lockoutService.recordFailure(email); + expect(status4.locked).toBe(true); + expect(status4.failedAttempts).toBe(4); + expect(status4.remainingMs).toBe(5 * 60 * 1000); + + // Advance time past the new 5-minute lockout + currentTime += 5 * 60 * 1000 + 1000; + + const status5 = await lockoutService.recordFailure(email); // 5 failures -> next tier (15 min) + + expect(status5.locked).toBe(true); + expect(status5.failedAttempts).toBe(5); + expect(status5.remainingMs).toBe(15 * 60 * 1000); + }); + + it('resets failures on successful authentication', async () => { + const email = 'user6@example.com'; + await lockoutService.recordFailure(email); + await lockoutService.recordFailure(email); // 2 failures + + await lockoutService.resetFailures(email); + + const checkStatus = await lockoutService.checkLockout(email); + expect(checkStatus.locked).toBe(false); + expect(checkStatus.failedAttempts).toBe(0); + }); + + it('clears expired retentions', async () => { + const email = 'user7@example.com'; + let currentTime = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => currentTime); + + await lockoutService.recordFailure(email); + + // Advance time past retentionMs (1 hour) + currentTime += 2 * 60 * 60 * 1000; + + const checkStatus = await lockoutService.checkLockout(email); + expect(checkStatus.failedAttempts).toBe(0); + }); +}); diff --git a/backend/services/shared/accountLockoutService.ts b/backend/services/shared/accountLockoutService.ts new file mode 100644 index 00000000..74c16072 --- /dev/null +++ b/backend/services/shared/accountLockoutService.ts @@ -0,0 +1,184 @@ +import { logger } from './logging'; + +/** + * Configuration for progressive delay tiers. + * Each tier specifies the number of consecutive failures required to trigger + * the associated lockout duration in minutes. + * Tiers should be ordered in ascending order of thresholds. + */ +export interface LockoutTier { + threshold: number; + lockoutMinutes: number; +} + +export interface LockoutStatus { + locked: boolean; + remainingMs: number; + failedAttempts: number; +} + +export interface LockoutConfig { + tiers: LockoutTier[]; + /** Maximum time (in ms) to retain failure counts after the last activity before resetting */ + retentionMs: number; +} + +const DEFAULT_CONFIG: LockoutConfig = { + tiers: [ + { threshold: 3, lockoutMinutes: 5 }, + { threshold: 6, lockoutMinutes: 15 }, + { threshold: 9, lockoutMinutes: 60 }, + ], + retentionMs: 24 * 60 * 60 * 1000, // 24 hours +}; + +interface AccountLockoutData { + failedAttempts: number; + lockoutUntil: number; + lastUpdated: number; +} + +/** + * Provides progressive account lockout mechanisms to mitigate brute force + * and credential stuffing attacks on authentication endpoints. + */ +export class AccountLockoutService { + // In-memory store for tracking lockouts. + // In a multi-node production setup, this would be backed by Redis or Memcached. + private store = new Map(); + private readonly config: LockoutConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + // Sort tiers in descending order to easily find the highest applicable tier + this.config.tiers = [...this.config.tiers].sort((a, b) => b.threshold - a.threshold); + } + + /** + * Cleans up expired entries from the store. + */ + private cleanup(): void { + const now = Date.now(); + for (const [key, data] of this.store.entries()) { + if (now - data.lastUpdated > this.config.retentionMs) { + this.store.delete(key); + } + } + } + + /** + * Retrieves current data for an identifier, resetting if the retention period has passed. + */ + private getValidData(identifier: string, now: number): AccountLockoutData { + const data = this.store.get(identifier); + if (!data) { + return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: now }; + } + + if (now - data.lastUpdated > this.config.retentionMs) { + this.store.delete(identifier); + return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: now }; + } + + return data; + } + + /** + * Checks the current lockout status for a given identifier (e.g. email or IP). + * @param identifier The account identifier to check. + */ + async checkLockout(identifier: string): Promise { + const now = Date.now(); + const data = this.getValidData(identifier, now); + + if (data.lockoutUntil > now) { + return { + locked: true, + remainingMs: data.lockoutUntil - now, + failedAttempts: data.failedAttempts, + }; + } + + return { + locked: false, + remainingMs: 0, + failedAttempts: data.failedAttempts, + }; + } + + /** + * Records a failed authentication attempt and calculates progressive delays. + * @param identifier The account identifier. + */ + async recordFailure(identifier: string): Promise { + const now = Date.now(); + // Run cleanup periodically (approx 1 in 100 calls) to avoid memory leaks + if (Math.random() < 0.01) { + this.cleanup(); + } + + const data = this.getValidData(identifier, now); + + // If currently locked out, we don't increase failures, we just return the active lockout + if (data.lockoutUntil > now) { + logger.warn('Lockout bypassed failure recording', { identifier, remainingMs: data.lockoutUntil - now }); + return { + locked: true, + remainingMs: data.lockoutUntil - now, + failedAttempts: data.failedAttempts, + }; + } + + data.failedAttempts += 1; + data.lastUpdated = now; + + // Determine if a lockout tier was reached + let applyLockoutMinutes = 0; + for (const tier of this.config.tiers) { + if (data.failedAttempts >= tier.threshold) { + applyLockoutMinutes = tier.lockoutMinutes; + break; // found the highest tier because tiers are sorted descending + } + } + + if (applyLockoutMinutes > 0) { + data.lockoutUntil = now + applyLockoutMinutes * 60 * 1000; + logger.warn('Account locked out due to excessive failures', { + identifier, + failedAttempts: data.failedAttempts, + lockoutMinutes: applyLockoutMinutes, + }); + } + + this.store.set(identifier, data); + + const locked = applyLockoutMinutes > 0; + return { + locked, + remainingMs: locked ? applyLockoutMinutes * 60 * 1000 : 0, + failedAttempts: data.failedAttempts, + }; + } + + /** + * Resets the failed attempts and lockout status for an identifier. + * Should be called upon successful authentication. + * @param identifier The account identifier. + */ + async resetFailures(identifier: string): Promise { + const data = this.store.get(identifier); + if (data) { + this.store.delete(identifier); + logger.info('Account lockout reset', { identifier }); + } + } + + /** + * Manually clears the internal store (useful for testing). + */ + clearStore(): void { + this.store.clear(); + } +} + +export const accountLockoutService = new AccountLockoutService(); diff --git a/src/services/auth/__tests__/accountLockoutClient.test.ts b/src/services/auth/__tests__/accountLockoutClient.test.ts new file mode 100644 index 00000000..a5c7b625 --- /dev/null +++ b/src/services/auth/__tests__/accountLockoutClient.test.ts @@ -0,0 +1,118 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AccountLockoutClient } from '../accountLockoutClient'; + +const store: Record = {}; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(async (key: string) => store[key] ?? null), + setItem: jest.fn(async (key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn(async (key: string) => { + delete store[key]; + }), +})); + +beforeEach(() => Object.keys(store).forEach((k) => delete store[k])); + +describe('AccountLockoutClient', () => { + let client: AccountLockoutClient; + + beforeEach(() => { + // Clear the mock storage before each test + (AsyncStorage.getItem as jest.Mock).mockClear(); + (AsyncStorage.setItem as jest.Mock).mockClear(); + (AsyncStorage.removeItem as jest.Mock).mockClear(); + + // Use a custom configuration for faster thresholds in tests + client = new AccountLockoutClient({ + tiers: [ + { threshold: 3, lockoutMinutes: 5 }, + { threshold: 5, lockoutMinutes: 15 }, + ], + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('initially has no lockout', async () => { + const status = await client.checkLockout('user@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(0); + expect(status.remainingMs).toBe(0); + }); + + it('records failures incrementally without locking prematurely', async () => { + let status = await client.recordFailure('user@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(1); + + status = await client.recordFailure('user@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(2); + }); + + it('locks out when the first threshold is reached', async () => { + await client.recordFailure('user2@example.com'); + await client.recordFailure('user2@example.com'); + const status = await client.recordFailure('user2@example.com'); // 3rd failure + + expect(status.locked).toBe(true); + expect(status.failedAttempts).toBe(3); + expect(status.remainingMs).toBeGreaterThan(0); + expect(status.remainingMs).toBeLessThanOrEqual(5 * 60 * 1000); + }); + + it('does not increase failure counts while locked out', async () => { + await client.recordFailure('user3@example.com'); + await client.recordFailure('user3@example.com'); + await client.recordFailure('user3@example.com'); // locked out + + const status = await client.recordFailure('user3@example.com'); + expect(status.locked).toBe(true); + expect(status.failedAttempts).toBe(3); // still 3 + }); + + it('resets failures properly', async () => { + await client.recordFailure('user4@example.com'); + await client.recordFailure('user4@example.com'); + + await client.resetFailures('user4@example.com'); + + const status = await client.checkLockout('user4@example.com'); + expect(status.locked).toBe(false); + expect(status.failedAttempts).toBe(0); + }); + + it('progresses to the next tier when failures continue after a lockout', async () => { + let currentTime = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => currentTime); + + await client.recordFailure('user5@example.com'); + await client.recordFailure('user5@example.com'); + await client.recordFailure('user5@example.com'); // locked (3) + + // Advance time past the 5-minute lockout + currentTime += 5 * 60 * 1000 + 1000; + + // Next failure shouldn't trigger the second tier yet (needs 5) + const status4 = await client.recordFailure('user5@example.com'); + // Actually, wait, when failures hit 4, tier threshold 3 applies again because 4 >= 3, + // so it locks out for 5 minutes again, UNLESS we specifically configure it otherwise. + // In our implementation, `applyLockoutMinutes` checks `data.failedAttempts >= tier.threshold`. + // For 4, 4 >= 3, so it WILL apply 5 mins lockout again. + expect(status4.locked).toBe(true); + expect(status4.failedAttempts).toBe(4); + expect(status4.remainingMs).toBe(5 * 60 * 1000); + + // Advance time past the new 5-minute lockout + currentTime += 5 * 60 * 1000 + 1000; + + const status5 = await client.recordFailure('user5@example.com'); // 5th failure + expect(status5.locked).toBe(true); + expect(status5.failedAttempts).toBe(5); + expect(status5.remainingMs).toBe(15 * 60 * 1000); // next tier! + }); +}); diff --git a/src/services/auth/accountLockoutClient.ts b/src/services/auth/accountLockoutClient.ts new file mode 100644 index 00000000..a024410f --- /dev/null +++ b/src/services/auth/accountLockoutClient.ts @@ -0,0 +1,132 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STORAGE_KEY_PREFIX = '@subtrackr_account_lockout_'; + +export interface LockoutTier { + threshold: number; + lockoutMinutes: number; +} + +export interface LockoutStatus { + locked: boolean; + remainingMs: number; + failedAttempts: number; +} + +export interface AccountLockoutClientConfig { + tiers: LockoutTier[]; +} + +const DEFAULT_CONFIG: AccountLockoutClientConfig = { + tiers: [ + { threshold: 3, lockoutMinutes: 5 }, + { threshold: 6, lockoutMinutes: 15 }, + { threshold: 9, lockoutMinutes: 60 }, + ], +}; + +interface StoredLockoutData { + failedAttempts: number; + lockoutUntil: number; + lastUpdated: number; +} + +export class AccountLockoutClient { + private readonly config: AccountLockoutClientConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.config.tiers = [...this.config.tiers].sort((a, b) => b.threshold - a.threshold); + } + + private getStorageKey(identifier: string): string { + return `${STORAGE_KEY_PREFIX}${identifier}`; + } + + private async getStoredData(identifier: string): Promise { + const raw = await AsyncStorage.getItem(this.getStorageKey(identifier)); + if (!raw) { + return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: Date.now() }; + } + try { + return JSON.parse(raw) as StoredLockoutData; + } catch { + return { failedAttempts: 0, lockoutUntil: 0, lastUpdated: Date.now() }; + } + } + + private async saveStoredData(identifier: string, data: StoredLockoutData): Promise { + await AsyncStorage.setItem(this.getStorageKey(identifier), JSON.stringify(data)); + } + + /** + * Checks if the account identifier is currently locked out on this device. + */ + async checkLockout(identifier: string): Promise { + const data = await this.getStoredData(identifier); + const now = Date.now(); + + if (data.lockoutUntil > now) { + return { + locked: true, + remainingMs: data.lockoutUntil - now, + failedAttempts: data.failedAttempts, + }; + } + + return { + locked: false, + remainingMs: 0, + failedAttempts: data.failedAttempts, + }; + } + + /** + * Records a failed authentication attempt locally to update lockout status. + */ + async recordFailure(identifier: string): Promise { + const now = Date.now(); + const data = await this.getStoredData(identifier); + + if (data.lockoutUntil > now) { + return { + locked: true, + remainingMs: data.lockoutUntil - now, + failedAttempts: data.failedAttempts, + }; + } + + data.failedAttempts += 1; + data.lastUpdated = now; + + let applyLockoutMinutes = 0; + for (const tier of this.config.tiers) { + if (data.failedAttempts >= tier.threshold) { + applyLockoutMinutes = tier.lockoutMinutes; + break; + } + } + + if (applyLockoutMinutes > 0) { + data.lockoutUntil = now + applyLockoutMinutes * 60 * 1000; + } + + await this.saveStoredData(identifier, data); + + const locked = applyLockoutMinutes > 0; + return { + locked, + remainingMs: locked ? applyLockoutMinutes * 60 * 1000 : 0, + failedAttempts: data.failedAttempts, + }; + } + + /** + * Resets local lockout tracking on successful login. + */ + async resetFailures(identifier: string): Promise { + await AsyncStorage.removeItem(this.getStorageKey(identifier)); + } +} + +export const accountLockoutClient = new AccountLockoutClient();