From 96daa669f7639ebbb3e62c8c3145fdfc40dfeef5 Mon Sep 17 00:00:00 2001 From: Ebuka321 Date: Mon, 19 Oct 2026 03:47:39 -0700 Subject: [PATCH] feat(notifications): revenue recognition export, tz-aware quiet hours, digest batching, and preferences entry point - Export recognised revenue per period as CSV/JSON with accrual or cash basis (#1743) - Evaluate quiet hours in the subscriber's configured timezone with DST-safe UTC scheduling (#1744) - Batch non-critical notifications into daily/weekly digests with grouped content and window-based flushing (#1746) - Surface the notification preferences center from Settings and register its navigation route (#1745) Closes #1743 Closes #1744 Closes #1745 Closes #1746 --- src/navigation/types.ts | 1 + src/screens/RevenueReportScreen.tsx | 38 +++ src/screens/SettingsScreen.tsx | 34 +++ .../__tests__/notificationDigest.test.ts | 205 +++++++++++++++++ .../__tests__/pushScheduleEngine.test.ts | 217 ++++++++++++++++++ .../revenueRecognitionExport.test.ts | 178 ++++++++++++++ src/services/notificationService.ts | 154 +++++++++++-- src/services/pushScheduleEngine.ts | 146 ++++++++++-- src/services/revenueRecognitionExport.ts | 189 +++++++++++++++ src/store/accountingStore.ts | 20 +- 10 files changed, 1140 insertions(+), 42 deletions(-) create mode 100644 src/services/__tests__/notificationDigest.test.ts create mode 100644 src/services/__tests__/pushScheduleEngine.test.ts create mode 100644 src/services/__tests__/revenueRecognitionExport.test.ts create mode 100644 src/services/revenueRecognitionExport.ts diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 3c095ad4..86666d27 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -22,6 +22,7 @@ export type RootStackParamList = { DataExport: undefined; DPALog: undefined; Settings: undefined; + NotificationPreferences: undefined; CalendarIntegration: undefined; WebhookSettings: undefined; AccountingExport: undefined; diff --git a/src/screens/RevenueReportScreen.tsx b/src/screens/RevenueReportScreen.tsx index 3caa9188..bbaae12c 100644 --- a/src/screens/RevenueReportScreen.tsx +++ b/src/screens/RevenueReportScreen.tsx @@ -22,6 +22,7 @@ import { splitRecognisedDeferred, } from '../store/accountingStore'; import { useThemeColors } from '../hooks/useThemeColors'; +import { recentPeriodWindow } from '../services/revenueRecognitionExport'; const { width: screenWidth } = Dimensions.get('window'); const CHART_WIDTH = screenWidth - spacing.xl * 2; @@ -62,6 +63,7 @@ const RevenueReportScreen: React.FC = () => { generateRevenueSchedule, getRevenueAnalyticsByPeriod, exportWaterfall, + exportRecognitionEntries, } = useAccountingStore(); const [periodRange, setPeriodRange] = useState('month'); @@ -171,6 +173,25 @@ const RevenueReportScreen: React.FC = () => { [subscriptions, exportWaterfall] ); + const handleRecognitionExport = useCallback( + async (format: 'csv' | 'json') => { + const window = recentPeriodWindow(Math.round(PERIOD_RANGE_MS[periodRange] / MS_PER_DAY)); + const content = exportRecognitionEntries({ format, ...window }); + const mimeType = format === 'csv' ? 'text/csv' : 'application/json'; + const filename = `revenue_recognition_entries.${format}`; + try { + await Share.share({ message: content, title: filename }); + } catch { + Alert.alert( + `Recognition export (${format.toUpperCase()})`, + `Content type: ${mimeType}\n\n${content.slice(0, 400)}${content.length > 400 ? '\n…' : ''}`, + [{ text: 'Close' }] + ); + } + }, + [periodRange, exportRecognitionEntries] + ); + // ── Render ──────────────────────────────────────────────────────────────── if (!subscriptions.length) { @@ -227,6 +248,23 @@ const RevenueReportScreen: React.FC = () => { + {/* Recognition entries export (Issue #1743) */} + + Export recognition entries: + void handleRecognitionExport('csv')} + accessibilityLabel="Export revenue recognition entries as CSV"> + CSV + + void handleRecognitionExport('json')} + accessibilityLabel="Export revenue recognition entries as JSON"> + JSON + + + {/* Period selector */} {(['month', 'quarter', 'year'] as PeriodRange[]).map((p) => ( diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index f09ff43c..0bec1895 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -50,6 +50,24 @@ export const SettingsScreen = () => { ))} + + Notifications + navigation.navigate('NotificationPreferences')}> + + Notification Preferences + + Categories, channels, digest schedule, and quiet hours + + + › + + + Delivery Channels @@ -161,6 +179,22 @@ function createStyles(colors: ReturnType['colors']) { alignItems: 'center', marginBottom: 12, }, + linkRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + backgroundColor: colors.background.secondary, + borderWidth: 1, + borderColor: colors.border.default, + }, + chevron: { + fontSize: 22, + fontWeight: '600', + color: colors.text.secondary, + paddingLeft: 8, + }, rowLabel: { flex: 1, paddingRight: 12, diff --git a/src/services/__tests__/notificationDigest.test.ts b/src/services/__tests__/notificationDigest.test.ts new file mode 100644 index 00000000..a6a6e0bc --- /dev/null +++ b/src/services/__tests__/notificationDigest.test.ts @@ -0,0 +1,205 @@ +/** + * Issue #1746 — digest batching on the client delivery path. + * + * When a subscriber chooses a daily/weekly digest, non-critical notifications + * are buffered and flushed as one grouped `digest` notification. + */ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import { useNotificationPreferencesStore } from '../../store/notificationPreferencesStore'; +import { + deliverNotification, + registerChannelTransport, + clearChannelTransports, + flushDigestNow, + maybeFlushDueDigest, + pendingDigestCount, + resetDigestBuffer, +} from '../notificationService'; +import type { NotificationChannel } from '../../types/notification'; + +jest.mock('@react-native-async-storage/async-storage', () => { + const store = new Map(); + return { + setItem: jest.fn((key: string, value: string) => { + store.set(key, value); + return Promise.resolve(); + }), + getItem: jest.fn((key: string) => Promise.resolve(store.get(key) ?? null)), + removeItem: jest.fn((key: string) => { + store.delete(key); + return Promise.resolve(); + }), + }; +}); + +jest.mock('expo-notifications', () => ({ + PermissionStatus: { GRANTED: 'granted', DENIED: 'denied' }, + AndroidImportance: { HIGH: 4 }, + AndroidNotificationVisibility: { PUBLIC: 1 }, + SchedulableTriggerInputTypes: { DATE: 'date' }, + setNotificationHandler: jest.fn(), + setNotificationChannelAsync: jest.fn(() => Promise.resolve()), + getPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'denied' })), + requestPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'denied' })), + scheduleNotificationAsync: jest.fn(() => Promise.resolve()), + getAllScheduledNotificationsAsync: jest.fn(() => Promise.resolve([])), + cancelScheduledNotificationAsync: jest.fn(() => Promise.resolve()), + addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })), +})); + +jest.mock('../../navigation/navigationRef', () => ({ + navigationRef: { isReady: () => false, navigate: jest.fn() }, +})); + +const store = () => useNotificationPreferencesStore.getState(); + +const delivered: { channel: NotificationChannel; subject: string; body: string }[] = []; + +const acceptAll = () => { + (['email', 'push', 'sms', 'in_app'] as NotificationChannel[]).forEach((channel) => + registerChannelTransport(channel, async ({ subject, body }) => { + delivered.push({ channel, subject, body }); + return true; + }) + ); +}; + +beforeEach(() => { + delivered.length = 0; + clearChannelTransports(); + store().resetToDefaults(); + store().clearHistory(); + resetDigestBuffer(); + acceptAll(); +}); + +describe('digest batching (#1746)', () => { + it('buffers non-critical notifications instead of sending them', async () => { + store().updatePreferences({ digestFrequency: 'daily' }); + + const result = await deliverNotification({ + type: 'renewal_reminder', + subject: 'Netflix renews tomorrow', + body: 'Your plan goes up to $15.99.', + at: new Date('2026-03-01T10:00:00.000Z'), + }); + + expect(result.scheduled).toBe(true); + expect(result.delivered).toEqual([]); + expect(result.records).toHaveLength(1); + expect(result.records[0].status).toBe('scheduled'); + // Daily digest window: next 09:00 UTC after 10:00 on Mar 1. + expect(result.scheduledFor).toBe('2026-03-02T09:00:00.000Z'); + expect(pendingDigestCount('me')).toBe(1); + expect(delivered).toEqual([]); + }); + + it('flushes buffered items as one grouped digest notification', async () => { + store().updatePreferences({ digestFrequency: 'daily' }); + + await deliverNotification({ + type: 'renewal_reminder', + subject: 'Netflix renews tomorrow', + body: 'Plan goes up to $15.99.', + at: new Date('2026-03-01T10:00:00.000Z'), + }); + await deliverNotification({ + type: 'trial_ending', + subject: 'Spotify trial ends Friday', + body: 'You will be charged $10.99.', + at: new Date('2026-03-01T11:00:00.000Z'), + }); + expect(pendingDigestCount('me')).toBe(2); + + const flush = await flushDigestNow('me', new Date('2026-03-02T09:00:00.000Z')); + + expect(flush).not.toBeNull(); + expect(flush!.records[0].type).toBe('digest'); + expect(pendingDigestCount('me')).toBe(0); + + // Grouped content: both items appear in the single digest body. + const digest = delivered.find((d) => d.subject.includes('digest')); + expect(digest).toBeDefined(); + expect(digest!.subject).toContain('2 updates'); + expect(digest!.body).toContain('Netflix renews tomorrow'); + expect(digest!.body).toContain('Spotify trial ends Friday'); + }); + + it('sends critical notifications immediately, bypassing the digest', async () => { + store().updatePreferences({ digestFrequency: 'daily' }); + + const result = await deliverNotification({ + type: 'security_alert', + subject: 'Suspicious sign-in', + body: 'New login from Chrome on Windows.', + at: new Date('2026-03-01T23:30:00.000Z'), + }); + + expect(result.delivered.length).toBeGreaterThan(0); + expect(result.scheduled).toBe(false); + expect(pendingDigestCount('me')).toBe(0); + }); + + it('auto-flushes the previous batch once the digest window has passed', async () => { + store().updatePreferences({ digestFrequency: 'daily' }); + + await deliverNotification({ + type: 'renewal_reminder', + subject: 'Netflix renews tomorrow', + body: 'Plan goes up to $15.99.', + at: new Date('2026-03-01T10:00:00.000Z'), + }); + expect(pendingDigestCount('me')).toBe(1); + + // A later delivery on the other side of the window flushes the old batch. + await deliverNotification({ + type: 'renewal_reminder', + subject: 'Hulu renews today', + body: 'Annual plan.', + at: new Date('2026-03-03T12:00:00.000Z'), + }); + + // Old batch flushed as a grouped digest; the new item is buffered. + const digest = delivered.find((d) => d.subject.includes('digest')); + expect(digest).toBeDefined(); + expect(digest!.body).toContain('Netflix renews tomorrow'); + expect(digest!.body).not.toContain('Hulu renews today'); + expect(pendingDigestCount('me')).toBe(1); + }); + + it('does not buffer when the subscriber wants immediate delivery', async () => { + // Default digestFrequency is 'immediate'. + const result = await deliverNotification({ + type: 'renewal_reminder', + subject: 'Netflix renews tomorrow', + body: 'Plan goes up to $15.99.', + at: new Date('2026-03-01T10:00:00.000Z'), + }); + + expect(result.scheduled).toBe(false); + expect(result.delivered.length).toBeGreaterThan(0); + expect(pendingDigestCount('me')).toBe(0); + }); + + it('maybeFlushDueDigest is a no-op with an empty buffer', async () => { + const out = await maybeFlushDueDigest(); + expect(out).toBeNull(); + }); + + it('does not batch digest notifications themselves', async () => { + store().updatePreferences({ digestFrequency: 'daily' }); + await deliverNotification({ + type: 'renewal_reminder', + subject: 'Netflix renews tomorrow', + body: 'Plan goes up.', + at: new Date('2026-03-01T10:00:00.000Z'), + }); + const flush = await flushDigestNow('me', new Date('2026-03-02T09:00:00.000Z')); + + // The digest went out immediately (was not re-buffered). + expect(flush!.scheduled).toBe(false); + expect(flush!.records[0].type).toBe('digest'); + expect(pendingDigestCount('me')).toBe(0); + }); +}); diff --git a/src/services/__tests__/pushScheduleEngine.test.ts b/src/services/__tests__/pushScheduleEngine.test.ts new file mode 100644 index 00000000..799ce11c --- /dev/null +++ b/src/services/__tests__/pushScheduleEngine.test.ts @@ -0,0 +1,217 @@ +/** + * Issue #1744 (UTC-scheduled pushes, quiet hours with timezone) and #1746 + * (digest windows) — tests for the push schedule engine. + */ + +import { describe, it, expect, afterEach, jest } from '@jest/globals'; +import { + PushScheduleEngine, + isWithinQuietHoursDate, + nextQuietHoursEnd, + localHourOf, + utcInstantForLocalHour, + digestWindowFor, + DigestBuilder, + type QuietHoursWindow, +} from '../pushScheduleEngine'; + +const BERLIN: QuietHoursWindow = { + enabled: true, + startHour: 22, + endHour: 8, + timezone: 'Europe/Berlin', +}; +const UTC_QUIET: QuietHoursWindow = { + enabled: true, + startHour: 22, + endHour: 8, + timezone: 'UTC', +}; + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('quiet hours with a configured timezone (#1744)', () => { + it('treats 23:30 UTC as inside a 22→8 UTC window', () => { + expect(isWithinQuietHoursDate(new Date('2026-03-01T23:30:00.000Z'), UTC_QUIET)).toBe(true); + }); + + it('treats 12:00 UTC as outside a 22→8 UTC window', () => { + expect(isWithinQuietHoursDate(new Date('2026-03-01T12:00:00.000Z'), UTC_QUIET)).toBe(false); + }); + + it('reads the window in the subscriber timezone, not UTC', () => { + // 22:00 Berlin on 2026-03-01 = 21:00 UTC (CET, UTC+1) → 21:30 UTC is inside, + // even though 21 would be outside a 22→8 UTC window. + expect(isWithinQuietHoursDate(new Date('2026-03-01T21:30:00.000Z'), BERLIN)).toBe(true); + // 20:30 UTC = 21:30 Berlin → outside. + expect(isWithinQuietHoursDate(new Date('2026-03-01T20:30:00.000Z'), BERLIN)).toBe(false); + }); + + it('defers a delivery to the end of the local quiet window', () => { + // 22:30 Berlin (21:30 UTC) → next quiet end is 08:00 Berlin on Mar 2 + // = 07:00 UTC (still CET). + const at = new Date('2026-03-01T21:30:00.000Z'); + expect(nextQuietHoursEnd(at, BERLIN).toISOString()).toBe('2026-03-02T07:00:00.000Z'); + }); + + it('returns the delivery time unchanged outside quiet hours', () => { + const at = new Date('2026-03-01T12:00:00.000Z'); + expect(nextQuietHoursEnd(at, UTC_QUIET).getTime()).toBe(at.getTime()); + }); + + it('crosses DST boundaries with the correct UTC offset (Berlin)', () => { + // CEST (UTC+2) in July: 08:00 local → 06:00 UTC. + expect( + utcInstantForLocalHour(new Date('2026-07-01T00:00:00.000Z'), 8, 'Europe/Berlin').toISOString() + ).toBe('2026-07-01T06:00:00.000Z'); + // CET (UTC+1) in March: 08:00 local → 07:00 UTC. + expect( + utcInstantForLocalHour(new Date('2026-03-01T00:00:00.000Z'), 8, 'Europe/Berlin').toISOString() + ).toBe('2026-03-01T07:00:00.000Z'); + }); + + it('localHourOf returns the wall-clock hour of a UTC instant', () => { + expect(localHourOf(new Date('2026-03-01T21:30:00.000Z'), 'Europe/Berlin')).toBe(22); + expect(localHourOf(new Date('2026-03-01T23:30:00.000Z'), 'UTC')).toBe(23); + }); + + it('falls back to UTC for an unknown timezone', () => { + expect(localHourOf(new Date('2026-03-01T23:30:00.000Z'), 'Not/AZone')).toBe(23); + }); +}); + +describe('PushScheduleEngine.schedule (#1744)', () => { + const engine = new PushScheduleEngine(); + + it('delivers at the current UTC instant when outside quiet hours', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-03-01T12:00:00.000Z')); + + const out = engine.schedule( + 'me', + 'informative', + 'billing', + 'Netflix renews', + 'Tomorrow', + UTC_QUIET, + ['billing'] + ); + + expect(out).not.toBeNull(); + expect(out!.deliverAt).toBe('2026-03-01T12:00:00.000Z'); + }); + + it('defers a non-critical notification to the quiet-hours end (UTC)', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-03-01T23:30:00.000Z')); + + const out = engine.schedule( + 'me', + 'informative', + 'billing', + 'Netflix renews', + 'Tomorrow', + UTC_QUIET, + ['billing'] + ); + + expect(out).not.toBeNull(); + expect(out!.deliverAt).toBe('2026-03-02T08:00:00.000Z'); + }); + + it('defers using the subscriber timezone (Europe/Berlin)', () => { + // 21:30 UTC = 22:30 Berlin → inside the Berlin quiet window. + jest.useFakeTimers().setSystemTime(new Date('2026-03-01T21:30:00.000Z')); + + const out = engine.schedule( + 'me', + 'informative', + 'billing', + 'Spotify renews', + 'Tomorrow', + BERLIN, + ['billing'] + ); + + expect(out).not.toBeNull(); + expect(out!.deliverAt).toBe('2026-03-02T07:00:00.000Z'); + }); + + it('bypasses quiet hours for critical notifications', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-03-01T23:30:00.000Z')); + + const out = engine.schedule( + 'me', + 'critical', + 'security', + 'Suspicious sign-in', + 'Check your account', + UTC_QUIET, + ['billing', 'security'] + ); + + expect(out).not.toBeNull(); + expect(out!.deliverAt).toBe('2026-03-01T23:30:00.000Z'); + }); + + it('drops non-critical notifications the subscriber has not opted into', () => { + jest.useFakeTimers().setSystemTime(new Date('2026-03-01T12:00:00.000Z')); + + const out = engine.schedule( + 'me', + 'marketing', + 'marketing', + '20% off', + 'Upgrade today', + UTC_QUIET, + ['billing'] + ); + + expect(out).toBeNull(); + }); +}); + +describe('digest windows (#1746)', () => { + it('uses 09:00 UTC today when the daily window has not passed', () => { + expect(digestWindowFor('daily', new Date('2026-03-01T07:00:00.000Z')).toISOString()).toBe( + '2026-03-01T09:00:00.000Z' + ); + }); + + it('uses 09:00 UTC on the next day once the daily window has passed', () => { + expect(digestWindowFor('daily', new Date('2026-03-01T10:00:00.000Z')).toISOString()).toBe( + '2026-03-02T09:00:00.000Z' + ); + }); + + it('uses the next Monday 09:00 UTC for a weekly digest', () => { + // 2026-03-01 is a Sunday → next day is Monday Mar 2. + expect(digestWindowFor('weekly', new Date('2026-03-01T10:00:00.000Z')).toISOString()).toBe( + '2026-03-02T09:00:00.000Z' + ); + // A Monday before the window rolls to the following Monday. + expect(digestWindowFor('weekly', new Date('2026-03-02T10:00:00.000Z')).toISOString()).toBe( + '2026-03-09T09:00:00.000Z' + ); + }); +}); + +describe('DigestBuilder (#1746)', () => { + it('groups pending items into one digest entry and clears the buffer', () => { + const b = new DigestBuilder(); + b.enqueue('me', 'Netflix renews', 'Plan goes up to $15.99', 'billing'); + b.enqueue('me', 'Spotify renews', 'Annual renewal on Friday', 'billing'); + expect(b.getPendingCount('me')).toBe(2); + + const entry = b.buildDigest('me', 'daily'); + expect(entry).not.toBeNull(); + expect(entry!.notifications).toHaveLength(2); + expect(entry!.notifications[0]).toMatchObject({ title: 'Netflix renews', category: 'billing' }); + expect(entry!.frequency).toBe('daily'); + expect(b.getPendingCount('me')).toBe(0); + }); + + it('returns null when nothing is buffered', () => { + expect(new DigestBuilder().buildDigest('me', 'daily')).toBeNull(); + }); +}); diff --git a/src/services/__tests__/revenueRecognitionExport.test.ts b/src/services/__tests__/revenueRecognitionExport.test.ts new file mode 100644 index 00000000..a39f31a5 --- /dev/null +++ b/src/services/__tests__/revenueRecognitionExport.test.ts @@ -0,0 +1,178 @@ +/** + * Issue #1743 — revenue recognition export (CSV/JSON, accrual basis, periods). + */ + +import { + buildRecognitionRows, + exportRecognitionEntries, + recognitionRowsToCsv, + recentPeriodWindow, + type RecognitionExportRow, +} from '../revenueRecognitionExport'; +import type { RevenueSchedule } from '../../store/accountingStore'; + +const DAY = 24 * 60 * 60 * 1000; +const CHARGE = Date.UTC(2026, 0, 1); // 2026-01-01T00:00:00Z + +function makeSchedule(overrides: Partial = {}): RevenueSchedule { + return { + subscriptionId: 'sub-1', + totalAmount: 90, + chargeDate: CHARGE, + entries: [ + { + periodStart: CHARGE, + periodEnd: CHARGE + 30 * DAY, + recognisedAmount: 30, + isRecognised: false, + }, + { + periodStart: CHARGE + 30 * DAY, + periodEnd: CHARGE + 60 * DAY, + recognisedAmount: 30, + isRecognised: false, + }, + { + periodStart: CHARGE + 60 * DAY, + periodEnd: CHARGE + 90 * DAY, + recognisedAmount: 30, + isRecognised: false, + }, + ], + ...overrides, + }; +} + +describe('buildRecognitionRows — accrual basis', () => { + it('recognises a fully elapsed period in full and defers the rest', () => { + const rows = buildRecognitionRows([makeSchedule()], { asOf: CHARGE + 30 * DAY }); + expect(rows).toHaveLength(3); + + expect(rows[0].recognisedAmount).toBe(30); + expect(rows[0].deferredAmount).toBe(0); + expect(rows[0].status).toBe('recognised'); + + expect(rows[1].recognisedAmount).toBe(0); + expect(rows[1].deferredAmount).toBe(30); + expect(rows[1].status).toBe('deferred'); + }); + + it('pro-rates a period elapsed by one day', () => { + const rows = buildRecognitionRows([makeSchedule()], { asOf: CHARGE + 31 * DAY }); + expect(rows[1].status).toBe('partial'); + expect(rows[1].recognisedAmount).toBe(1); + expect(rows[1].deferredAmount).toBe(29); + }); + + it('pro-rates a partially elapsed period', () => { + // Half-way through period two (15 of 30 days elapsed). + const rows = buildRecognitionRows([makeSchedule()], { asOf: CHARGE + 45 * DAY }); + const partial = rows[1]; + expect(partial.status).toBe('partial'); + expect(partial.recognisedAmount).toBe(15); + expect(partial.deferredAmount).toBe(15); + }); + + it('defaults to the accrual basis and stamps it on every row', () => { + const rows = buildRecognitionRows([makeSchedule()], { asOf: CHARGE }); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) expect(row.basis).toBe('accrual'); + }); + + it('filters rows to the requested period window', () => { + const from = CHARGE + 30 * DAY; + const to = CHARGE + 30 * DAY; + const rows = buildRecognitionRows([makeSchedule()], { from, to, asOf: CHARGE + 91 * DAY }); + expect(rows).toHaveLength(1); + expect(rows[0].periodStart).toBe('2026-01-31'); + }); + + it('sorts rows by period start then subscription id', () => { + const b = makeSchedule({ + subscriptionId: 'sub-b', + chargeDate: CHARGE + 5 * DAY, + entries: [ + { + periodStart: CHARGE + 10 * DAY, + periodEnd: CHARGE + 40 * DAY, + recognisedAmount: 50, + isRecognised: false, + }, + ], + }); + const a = makeSchedule({ subscriptionId: 'sub-a' }); + const rows = buildRecognitionRows([b, a], { asOf: CHARGE + 100 * DAY }); + const keys = rows.map((r) => `${r.subscriptionId}:${r.periodStart}`); + expect(keys).toEqual([ + 'sub-a:2026-01-01', + 'sub-b:2026-01-11', + 'sub-a:2026-01-31', + 'sub-a:2026-03-02', + ]); + }); +}); + +describe('buildRecognitionRows — cash basis', () => { + it('recognises the whole charge in the period containing the charge date', () => { + const rows = buildRecognitionRows([makeSchedule()], { basis: 'cash', asOf: CHARGE + 10 * DAY }); + expect(rows).toHaveLength(1); + expect(rows[0].recognisedAmount).toBe(90); + expect(rows[0].deferredAmount).toBe(0); + expect(rows[0].status).toBe('recognised'); + expect(rows[0].basis).toBe('cash'); + expect(rows[0].periodStart).toBe('2026-01-01'); + }); + + it('recognises nothing before the charge date', () => { + const rows = buildRecognitionRows([makeSchedule()], { basis: 'cash', asOf: CHARGE - DAY }); + expect(rows[0].recognisedAmount).toBe(0); + expect(rows[0].status).toBe('deferred'); + }); +}); + +describe('exportRecognitionEntries', () => { + it('emits CSV with a header row and one line per period', () => { + const csv = exportRecognitionEntries([makeSchedule()], { + format: 'csv', + asOf: CHARGE + 31 * DAY, + }); + const lines = csv.trim().split('\n'); + expect(lines[0]).toContain('SubscriptionId'); + expect(lines[0]).toContain('Basis'); + expect(lines).toHaveLength(4); // header + 3 periods + expect(lines[1]).toContain('"sub-1"'); + expect(lines[1]).toContain('"accrual"'); + }); + + it('emits JSON carrying the basis, timestamp and entries', () => { + const json = exportRecognitionEntries([makeSchedule()], { + format: 'json', + basis: 'cash', + asOf: CHARGE, + }); + const parsed = JSON.parse(json) as { + basis: string; + periodCount: number; + entries: RecognitionExportRow[]; + }; + expect(parsed.basis).toBe('cash'); + expect(parsed.periodCount).toBe(1); + expect(parsed.entries[0].subscriptionId).toBe('sub-1'); + expect(parsed.entries[0].basis).toBe('cash'); + }); + + it('escapes quotes in CSV fields', () => { + const schedule = makeSchedule({ subscriptionId: 'sub "quoted"' }); + const csv = recognitionRowsToCsv( + buildRecognitionRows([schedule], { asOf: CHARGE + 100 * DAY }) + ); + expect(csv).toContain('"sub ""quoted"""'); + }); + + it('produces a stable window for the trailing N days', () => { + const asOf = Date.UTC(2026, 5, 30); + const window = recentPeriodWindow(30, asOf); + expect(window.to).toBe(asOf); + expect(window.from).toBe(asOf - 30 * DAY); + }); +}); diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 8ef11320..3206596f 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -15,6 +15,12 @@ import { useNotificationPreferencesStore, type NotificationPreferences, } from '../store/notificationPreferencesStore'; +import { + digestBuilder, + digestWindowFor, + isWithinQuietHoursDate, + nextQuietHoursEnd, +} from './pushScheduleEngine'; import { navigationRef } from '../navigation/navigationRef'; export const NOTIFICATION_DATA_TYPE = { @@ -411,30 +417,91 @@ function transportFor(channel: NotificationChannel): ChannelTransport | undefine return transports.get(channel); } -/** True when `date` falls inside the subscriber's quiet window. */ +/** True when `date` falls inside the subscriber's quiet window (Issue #1744: + * evaluated against the configured timezone, not hard-coded UTC). */ function isInQuietHours( date: Date, - quietHours: { enabled: boolean; startHour: number; endHour: number } + quietHours: { enabled: boolean; startHour: number; endHour: number; timezone: string } ): boolean { - if (!quietHours.enabled) return false; - const hour = date.getUTCHours(); - return quietHours.startHour < quietHours.endHour - ? hour >= quietHours.startHour && hour < quietHours.endHour - : hour >= quietHours.startHour || hour < quietHours.endHour; + return isWithinQuietHoursDate(date, { ...quietHours, timezone: quietHours.timezone || 'UTC' }); } /** The next moment outside the quiet window, or `date` if already outside. */ function nextDeliveryTime( date: Date, - quietHours: { enabled: boolean; startHour: number; endHour: number } + quietHours: { enabled: boolean; startHour: number; endHour: number; timezone: string } ): Date { if (!isInQuietHours(date, quietHours)) return date; - const result = new Date(date); - result.setUTCHours(quietHours.endHour, 0, 0, 0); - if (result <= date) { - result.setUTCDate(result.getUTCDate() + 1); - } - return result; + return nextQuietHoursEnd(date, { ...quietHours, timezone: quietHours.timezone || 'UTC' }); +} + +// ── Email digest batching (Issue #1746) ────────────────────────────────────── +// +// When a subscriber picks a daily or weekly digest, non-critical notifications +// are buffered instead of going out one by one. The buffer is flushed as a +// single grouped notification (Issue #1746: "content grouped") once its UTC +// window has passed, or when the subscriber switches back to immediate. + +/** Unix ms of the first notification currently sitting in the digest buffer. */ +let digestBufferedSinceMs: number | null = null; + +function digestFrequencyOf(preferences: NotificationPreferences): 'daily' | 'weekly' { + return preferences.digestFrequency === 'weekly' ? 'weekly' : 'daily'; +} + +/** Deliver every buffered item as one grouped `digest` notification. */ +async function flushBufferedDigest( + userId: string, + frequency: 'daily' | 'weekly', + now: Date +): Promise { + const entry = digestBuilder.buildDigest(userId, frequency); + digestBufferedSinceMs = null; + if (!entry) return null; + + const subject = `Your ${frequency} digest — ${entry.notifications.length} updates`; + const body = entry.notifications.map((n) => `• ${n.title}: ${n.body}`).join('\n'); + return deliverNotification({ type: 'digest', userId, subject, body, at: now }); +} + +/** + * Flush the digest buffer if its scheduled UTC window has passed. + * Called at the top of every delivery, and exported so the app can call it on + * foreground/resume. + */ +export async function maybeFlushDueDigest( + preferences: NotificationPreferences = useNotificationPreferencesStore.getState().preferences, + now: Date = new Date(), + userId: string = 'me' +): Promise { + if (digestBufferedSinceMs === null) return null; + const frequency = digestFrequencyOf(preferences); + const due = + preferences.digestFrequency === 'immediate' || + digestWindowFor(frequency, new Date(digestBufferedSinceMs)).getTime() <= now.getTime(); + if (!due) return null; + return flushBufferedDigest(userId, frequency, now); +} + +/** Build and send the buffered digest right now, regardless of the window. */ +export async function flushDigestNow( + userId: string = 'me', + now: Date = new Date() +): Promise { + const preferences = useNotificationPreferencesStore.getState().preferences; + if (digestBufferedSinceMs === null) return null; + return flushBufferedDigest(userId, digestFrequencyOf(preferences), now); +} + +/** Number of notifications waiting in the digest buffer. */ +export function pendingDigestCount(userId: string): number { + return digestBuilder.getPendingCount(userId); +} + +/** Drop everything buffered for the digest without delivering it. */ +export function resetDigestBuffer(): void { + digestBufferedSinceMs = null; + digestBuilder.clear(); } export interface DeliverNotificationInput { @@ -468,6 +535,12 @@ export async function deliverNotification( const meta = NOTIFICATION_TYPE_META[input.type]; const userId = input.userId ?? 'me'; const now = input.at ?? new Date(); + + // Send any digest whose UTC window has already passed before this delivery. + if (input.type !== 'digest') { + await maybeFlushDueDigest(store.preferences, now, userId); + } + const channels = store.channelsFor(input.type); const result: DeliveryResult = { @@ -500,6 +573,39 @@ export async function deliverNotification( return result; } + // Digest batching (Issue #1746): buffer non-critical notifications when the + // subscriber asked for a daily/weekly digest instead of discrete sends. + if ( + input.type !== 'digest' && + store.preferences.digestFrequency !== 'immediate' && + meta.priority !== 'critical' + ) { + const frequency = digestFrequencyOf(store.preferences); + const bufferedChannel = channels[0]; + const rendered = store.renderFor(input.type, bufferedChannel, input.variables, { + subject: input.subject, + body: input.body, + }); + digestBuilder.enqueue(userId, rendered.subject, rendered.body, meta.category); + if (digestBufferedSinceMs === null) digestBufferedSinceMs = now.getTime(); + + const scheduledFor = digestWindowFor(frequency, now); + const buffered = record({ + channel: bufferedChannel, + title: rendered.subject, + body: rendered.body, + status: 'scheduled', + createdAt: now.toISOString(), + scheduledFor: scheduledFor.toISOString(), + reason: `Batched into the ${frequency} email digest`, + data: input.data, + }); + result.records.push(buffered); + result.scheduled = true; + result.scheduledFor = scheduledFor.toISOString(); + return result; + } + const deferUntil = meta.priority === 'critical' ? now : nextDeliveryTime(now, store.preferences.quietHours); const deferred = deferUntil.getTime() !== now.getTime(); @@ -588,7 +694,8 @@ export function attachNotificationResponseListeners(): () => void { const sub = Notifications.addNotificationResponseReceivedListener((response) => { const data = response.notification.request.content.data as - { subscriptionId?: string } | undefined; + | { subscriptionId?: string } + | undefined; if (data?.subscriptionId) { navigateToSubscriptionFromNotification(data.subscriptionId); } @@ -614,20 +721,17 @@ export interface NotificationPreferenceSummary { mutedTypes: NotificationType[]; } -/** True when `now` falls inside the configured quiet-hours window. */ +/** True when `now` falls inside the configured quiet-hours window, read in + * the subscriber's configured timezone (Issue #1744). */ export function isWithinQuietHours( quietHours: NotificationPreferences['quietHours'], now: Date = new Date() ): boolean { - const { startHour, endHour } = quietHours; - if (startHour === endHour) return false; - - const hour = now.getHours(); - // A window that wraps midnight (22 → 8) is the common case and has to be - // read as "at or after start, or before end". - return startHour < endHour - ? hour >= startHour && hour < endHour - : hour >= startHour || hour < endHour; + if (!quietHours.enabled || quietHours.startHour === quietHours.endHour) return false; + return isWithinQuietHoursDate(now, { + ...quietHours, + timezone: quietHours.timezone || 'UTC', + }); } /** diff --git a/src/services/pushScheduleEngine.ts b/src/services/pushScheduleEngine.ts index d30f677d..eaea6a74 100644 --- a/src/services/pushScheduleEngine.ts +++ b/src/services/pushScheduleEngine.ts @@ -154,22 +154,9 @@ export class PushScheduleEngine { date: Date, qh: { startHour: number; endHour: number; timezone: string } ): Date { - const result = new Date(date); - // Simple UTC-based check (production: use Intl.DateTimeFormat with timezone) - const hour = result.getUTCHours(); - const inQuiet = - qh.startHour < qh.endHour - ? hour >= qh.startHour && hour < qh.endHour - : hour >= qh.startHour || hour < qh.endHour; - - if (inQuiet) { - // Push to end of quiet period - result.setUTCHours(qh.endHour, 0, 0, 0); - if (result <= date) { - result.setUTCDate(result.getUTCDate() + 1); - } - } - return result; + if (!isWithinQuietHoursDate(date, { ...qh, enabled: true })) return date; + // Push to the end of the quiet period, read in the subscriber's timezone. + return utcInstantForLocalHour(date, qh.endHour, qh.timezone); } private _setHour(date: Date, hour: number): Date { @@ -182,8 +169,129 @@ export class PushScheduleEngine { } } +// ─── Timezone helpers (Issue #1744) ────────────────────────────────────────── + +export interface QuietHoursWindow { + enabled: boolean; + startHour: number; // 0-23, local to `timezone` + endHour: number; // 0-23, local to `timezone` + timezone: string; // IANA zone, e.g. 'Europe/Berlin' +} + +function timeZoneOffsetMs(at: Date, timezone: string): number { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(at); + const get = (type: string): number => Number(parts.find((p) => p.type === type)?.value ?? '0'); + const truncated = Math.floor(at.getTime() / 1000) * 1000; + const asUtc = Date.UTC( + get('year'), + get('month') - 1, + get('day'), + get('hour'), + get('minute'), + get('second') + ); + return asUtc - truncated; +} + +/** The wall-clock hour (0-23) `date` falls on in `timezone`, or UTC if unknown. */ +export function localHourOf(date: Date, timezone: string): number { + try { + const hour = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + hour: '2-digit', + hourCycle: 'h23', + }).format(date); + return Number(hour) % 24; + } catch { + return date.getUTCHours(); + } +} + +/** + * The first UTC instant at or after `date` whose wall-clock time in + * `timezone` is `hour:00`. Two passes handle DST offsets correctly. + */ +export function utcInstantForLocalHour(date: Date, hour: number, timezone: string): Date { + try { + const local = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const get = (type: string): number => Number(local.find((p) => p.type === type)?.value ?? '0'); + + const base = Date.UTC(get('year'), get('month') - 1, get('day'), hour); + let candidate = base - timeZoneOffsetMs(new Date(base), timezone); + candidate = base - timeZoneOffsetMs(new Date(candidate), timezone); + if (candidate <= date.getTime()) { + const nextDay = new Date(candidate); + const shifted = Date.UTC( + nextDay.getUTCFullYear(), + nextDay.getUTCMonth(), + nextDay.getUTCDate() + 1, + hour + ); + candidate = shifted - timeZoneOffsetMs(new Date(shifted), timezone); + } + return new Date(candidate); + } catch { + const fallback = new Date(date); + fallback.setUTCHours(hour, 0, 0, 0); + if (fallback <= date) fallback.setUTCDate(fallback.getUTCDate() + 1); + return fallback; + } +} + +/** True when `date` sits inside the quiet window, evaluated in `timezone`. */ +export function isWithinQuietHoursDate(date: Date, qh: QuietHoursWindow): boolean { + if (!qh.enabled || qh.startHour === qh.endHour) return false; + const hour = localHourOf(date, qh.timezone); + return qh.startHour < qh.endHour + ? hour >= qh.startHour && hour < qh.endHour + : hour >= qh.startHour || hour < qh.endHour; +} + +/** The next UTC instant outside the quiet window (or `date` when outside). */ +export function nextQuietHoursEnd(date: Date, qh: QuietHoursWindow): Date { + if (!isWithinQuietHoursDate(date, qh)) return date; + return utcInstantForLocalHour(date, qh.endHour, qh.timezone); +} + // ─── DigestBuilder ──────────────────────────────────────────────────────────── +/** + * The UTC instant a digest for items buffered at `now` should go out (Issue + * #1746): 09:00 UTC on the next day (daily) or next Monday (weekly). + */ +export function digestWindowFor(frequency: 'daily' | 'weekly', now: Date = new Date()): Date { + const window = new Date(now.getTime()); + if (frequency === 'daily') { + window.setUTCHours(9, 0, 0, 0); + if (window.getTime() <= now.getTime()) { + window.setUTCDate(window.getUTCDate() + 1); + } + return window; + } + // Weekly: next Monday at 09:00 UTC (strictly after `now`). + const daysUntilMonday = (8 - window.getUTCDay()) % 7 || 7; + window.setUTCDate(window.getUTCDate() + daysUntilMonday); + window.setUTCHours(9, 0, 0, 0); + if (window.getTime() <= now.getTime()) { + window.setUTCDate(window.getUTCDate() + 7); + } + return window; +} + export class DigestBuilder { private queue = new Map(); @@ -221,6 +329,12 @@ export class DigestBuilder { getPendingCount(userId: string): number { return this.queue.get(userId)?.length ?? 0; } + + /** Drop buffered items (all users, or just one). */ + clear(userId?: string): void { + if (userId === undefined) this.queue.clear(); + else this.queue.delete(userId); + } } export const pushScheduleEngine = new PushScheduleEngine(); diff --git a/src/services/revenueRecognitionExport.ts b/src/services/revenueRecognitionExport.ts new file mode 100644 index 00000000..1852911e --- /dev/null +++ b/src/services/revenueRecognitionExport.ts @@ -0,0 +1,189 @@ +/** + * Revenue recognition export (Issue #1743). + * + * Flattens per-subscription revenue schedules into period rows so finance can + * export recognised revenue for any window in CSV or JSON. + * + * Two bases are supported: + * - accrual (default): revenue is recognised as the service period is + * consumed — the ASC 606 / IFRS 15 treatment. A period partially elapsed + * at `asOf` recognises its pro-rated amount; the rest stays deferred. + * - cash: the full charge is recognised in the period that contains the + * charge date, because the cash was received up front. + */ + +import type { RevenueSchedule } from '../store/accountingStore'; + +export type RecognitionBasis = 'accrual' | 'cash'; +export type RecognitionExportFormat = 'csv' | 'json'; +export type RecognitionStatus = 'recognised' | 'partial' | 'deferred'; + +export interface RecognitionExportRow { + subscriptionId: string; + /** ISO-8601 date (YYYY-MM-DD) of the period start. */ + periodStart: string; + /** ISO-8601 date (YYYY-MM-DD) of the period end. */ + periodEnd: string; + basis: RecognitionBasis; + recognisedAmount: number; + deferredAmount: number; + status: RecognitionStatus; +} + +export interface RecognitionExportOptions { + format: RecognitionExportFormat; + /** Inclusive window start (Unix ms). Omit for no lower bound. */ + from?: number; + /** Inclusive window end (Unix ms). Omit for no upper bound. */ + to?: number; + /** Reporting basis. Defaults to 'accrual'. */ + basis?: RecognitionBasis; + /** Point in time used to split partially-elapsed periods. Defaults to now. */ + asOf?: number; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function toIsoDate(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function statusFor(recognised: number, total: number): RecognitionStatus { + if (recognised <= 0) return 'deferred'; + if (recognised >= total) return 'recognised'; + return 'partial'; +} + +/** + * Build one export row per schedule period, filtered to the requested window. + * Rows are sorted by period start then subscription id for stable output. + */ +export function buildRecognitionRows( + schedules: RevenueSchedule[], + options: Omit = {} +): RecognitionExportRow[] { + const basis: RecognitionBasis = options.basis ?? 'accrual'; + const asOf = options.asOf ?? Date.now(); + const rows: RecognitionExportRow[] = []; + + for (const schedule of schedules) { + if (basis === 'cash') { + const first = schedule.entries[0]; + if (!first) continue; + if (!withinWindow(first.periodStart, options)) continue; + // Cash basis: the whole charge lands in the period containing chargeDate. + const recognised = asOf >= schedule.chargeDate ? schedule.totalAmount : 0; + rows.push({ + subscriptionId: schedule.subscriptionId, + periodStart: toIsoDate(first.periodStart), + periodEnd: toIsoDate(first.periodEnd), + basis, + recognisedAmount: round2(recognised), + deferredAmount: round2(schedule.totalAmount - recognised), + status: statusFor(recognised, schedule.totalAmount), + }); + continue; + } + + for (const entry of schedule.entries) { + if (!withinWindow(entry.periodStart, options)) continue; + + let recognised: number; + if (asOf >= entry.periodEnd) { + recognised = entry.recognisedAmount; + } else if (asOf <= entry.periodStart) { + recognised = 0; + } else { + const elapsed = asOf - entry.periodStart; + const duration = Math.max(entry.periodEnd - entry.periodStart, 1); + recognised = (entry.recognisedAmount * elapsed) / duration; + } + + rows.push({ + subscriptionId: schedule.subscriptionId, + periodStart: toIsoDate(entry.periodStart), + periodEnd: toIsoDate(entry.periodEnd), + basis, + recognisedAmount: round2(recognised), + deferredAmount: round2(entry.recognisedAmount - recognised), + status: statusFor(recognised, entry.recognisedAmount), + }); + } + } + + rows.sort((a, b) => + a.periodStart === b.periodStart + ? a.subscriptionId.localeCompare(b.subscriptionId) + : a.periodStart.localeCompare(b.periodStart) + ); + return rows; +} + +function withinWindow(periodStart: number, options: { from?: number; to?: number }): boolean { + if (options.from !== undefined && periodStart < options.from) return false; + if (options.to !== undefined && periodStart > options.to) return false; + return true; +} + +function csvEscape(value: string | number): string { + const text = String(value).normalize('NFC'); + return `"${text.replace(/"/g, '""')}"`; +} + +const CSV_HEADER = [ + 'SubscriptionId', + 'PeriodStart', + 'PeriodEnd', + 'Basis', + 'RecognisedAmount', + 'DeferredAmount', + 'Status', +]; + +export function recognitionRowsToCsv(rows: RecognitionExportRow[]): string { + const lines = rows.map((r) => + [ + csvEscape(r.subscriptionId), + csvEscape(r.periodStart), + csvEscape(r.periodEnd), + csvEscape(r.basis), + csvEscape(r.recognisedAmount.toFixed(2)), + csvEscape(r.deferredAmount.toFixed(2)), + csvEscape(r.status), + ].join(',') + ); + return [CSV_HEADER.map(csvEscape).join(','), ...lines].join('\n'); +} + +/** Export recognition entries for a period as CSV or JSON. */ +export function exportRecognitionEntries( + schedules: RevenueSchedule[], + options: RecognitionExportOptions +): string { + const rows = buildRecognitionRows(schedules, options); + if (options.format === 'json') { + return JSON.stringify( + { + basis: options.basis ?? 'accrual', + generatedAt: new Date(options.asOf ?? Date.now()).toISOString(), + periodCount: rows.length, + entries: rows, + }, + null, + 2 + ); + } + return recognitionRowsToCsv(rows); +} + +/** Convenience: last `days` days ending at `asOf`, for quick "this period" exports. */ +export function recentPeriodWindow( + days: number, + asOf: number = Date.now() +): { from: number; to: number } { + return { from: asOf - days * MS_PER_DAY, to: asOf }; +} diff --git a/src/store/accountingStore.ts b/src/store/accountingStore.ts index 2c4e9433..48dc6cf4 100644 --- a/src/store/accountingStore.ts +++ b/src/store/accountingStore.ts @@ -18,6 +18,10 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { asyncStorageAdapter } from '../utils/storage'; import { BillingCycle } from '../types/subscription'; +import { + exportRecognitionEntries, + type RecognitionExportOptions, +} from '../services/revenueRecognitionExport'; // ── Domain types ────────────────────────────────────────────────────────────── @@ -68,7 +72,12 @@ export interface RevenueJournalEntry { subscriptionId: string; merchantId: string; type: - 'charge' | 'recognition' | 'modification' | 'termination' | 'acceleration' | 'trial_conversion'; + | 'charge' + | 'recognition' + | 'modification' + | 'termination' + | 'acceleration' + | 'trial_conversion'; amount: number; debitAccount: string; creditAccount: string; @@ -214,6 +223,12 @@ interface AccountingState { subscriptionIds?: string[], subscriptionNames?: Record ) => string; + + /** + * Export revenue recognition entries per period as CSV or JSON. + * Supports accrual and cash bases and an optional period window (Issue #1743). + */ + exportRecognitionEntries: (options: RecognitionExportOptions) => string; } // ── Pure helpers ────────────────────────────────────────────────────────────── @@ -686,6 +701,9 @@ export const useAccountingStore = create()( ); return [header, ...lines].join('\n'); }, + + exportRecognitionEntries: (options) => + exportRecognitionEntries(Object.values(get().schedules), options), }), { name: STORAGE_KEY,