Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type RootStackParamList = {
DataExport: undefined;
DPALog: undefined;
Settings: undefined;
NotificationPreferences: undefined;
CalendarIntegration: undefined;
WebhookSettings: undefined;
AccountingExport: undefined;
Expand Down
38 changes: 38 additions & 0 deletions src/screens/RevenueReportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -62,6 +63,7 @@ const RevenueReportScreen: React.FC = () => {
generateRevenueSchedule,
getRevenueAnalyticsByPeriod,
exportWaterfall,
exportRecognitionEntries,
} = useAccountingStore();

const [periodRange, setPeriodRange] = useState<PeriodRange>('month');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -227,6 +248,23 @@ const RevenueReportScreen: React.FC = () => {
</TouchableOpacity>
</View>

{/* Recognition entries export (Issue #1743) */}
<View style={styles.exportRow}>
<Text style={styles.exportLabel}>Export recognition entries:</Text>
<TouchableOpacity
style={styles.exportBtn}
onPress={() => void handleRecognitionExport('csv')}
accessibilityLabel="Export revenue recognition entries as CSV">
<Text style={styles.exportBtnText}>CSV</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.exportBtn}
onPress={() => void handleRecognitionExport('json')}
accessibilityLabel="Export revenue recognition entries as JSON">
<Text style={styles.exportBtnText}>JSON</Text>
</TouchableOpacity>
</View>

{/* Period selector */}
<View style={styles.periodRow}>
{(['month', 'quarter', 'year'] as PeriodRange[]).map((p) => (
Expand Down
34 changes: 34 additions & 0 deletions src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ export const SettingsScreen = () => {
))}
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Notifications</Text>
<TouchableOpacity
style={styles.linkRow}
testID="notification-preferences-link"
accessibilityRole="button"
accessibilityLabel="Open notification preferences"
onPress={() => navigation.navigate('NotificationPreferences')}>
<View style={styles.modeTextWrap}>
<Text style={styles.rowLabel}>Notification Preferences</Text>
<Text style={styles.modeSubLabel}>
Categories, channels, digest schedule, and quiet hours
</Text>
</View>
<Text style={styles.chevron}>›</Text>
</TouchableOpacity>
</View>

<View style={styles.section}>
<Text style={styles.sectionTitle}>Delivery Channels</Text>
<View style={styles.row}>
Expand Down Expand Up @@ -161,6 +179,22 @@ function createStyles(colors: ReturnType<typeof useTheme>['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,
Expand Down
205 changes: 205 additions & 0 deletions src/services/__tests__/notificationDigest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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);
});
});
Loading
Loading