From a94a21e75dc9086a5288b46ecdcf159f8422d40b Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Wed, 27 May 2026 12:16:43 +0100 Subject: [PATCH] feat: implement calendar billing scheduling with iCal export, conflict detection, and timezone support (#367) - Add iCal export functionality for subscription renewal events - Implement schedule conflict detection across billing dates - Add prorated adjustment calculations for schedule modifications - Support one-time scheduled payments beyond recurring billing - Add timezone handling with DST transition detection - Add timezone selector to CalendarIntegrationScreen - Add one-time payment management to calendar store - Add conflict detection UI with per-day breakdown --- src/screens/CalendarIntegrationScreen.tsx | 163 ++++++++++++++- src/services/calendarService.ts | 238 ++++++++++++++++++++++ src/store/calendarStore.ts | 70 +++++++ src/types/calendar.ts | 52 ++++- src/types/subscription.ts | 1 + 5 files changed, 522 insertions(+), 2 deletions(-) diff --git a/src/screens/CalendarIntegrationScreen.tsx b/src/screens/CalendarIntegrationScreen.tsx index 1f42cf3..4db03dc 100644 --- a/src/screens/CalendarIntegrationScreen.tsx +++ b/src/screens/CalendarIntegrationScreen.tsx @@ -1,9 +1,11 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { Alert, Linking, + Platform, SafeAreaView, ScrollView, + Share, StyleSheet, Text, TouchableOpacity, @@ -17,6 +19,7 @@ import { CALENDAR_PROVIDERS, REMINDER_OFFSET_OPTIONS, REMINDER_PRESETS, + SUBSCRIPTION_TIMEZONES, type CalendarProvider, } from '../types/calendar'; import { borderRadius, colors, spacing, typography } from '../utils/constants'; @@ -51,6 +54,9 @@ const CalendarIntegrationScreen: React.FC = () => { pendingAuthorizations, reminderOffsets, error, + oneTimePayments, + scheduleConflicts, + timezone, beginConnection, completeConnection, cancelConnection, @@ -58,6 +64,11 @@ const CalendarIntegrationScreen: React.FC = () => { setReminderOffsets, toggleReminderOffset, clearError, + addOneTimePayment, + cancelOneTimePayment, + checkConflicts, + exportCalendar, + setTimezone, } = useCalendarStore(); const subscriptions = useSubscriptionStore((state) => state.subscriptions); @@ -114,6 +125,51 @@ const CalendarIntegrationScreen: React.FC = () => { .syncSubscriptions(useSubscriptionStore.getState().subscriptions); }; + const handleExportICal = useCallback(async () => { + try { + const payload = exportCalendar(subscriptions, timezone); + await Share.share({ + message: payload.ical, + title: payload.filename, + }); + Alert.alert('Calendar exported', `Exported ${payload.events.length} events to ${payload.filename}`); + } catch (exportError) { + Alert.alert('Export failed', exportError instanceof Error ? exportError.message : 'Could not export calendar.'); + } + }, [subscriptions, timezone, exportCalendar]); + + const handleCheckConflicts = useCallback(() => { + checkConflicts(subscriptions); + }, [subscriptions, checkConflicts]); + + const handleScheduleOneTimePayment = useCallback(() => { + Alert.prompt + ? Alert.prompt( + 'Schedule one-time payment', + 'Enter subscription ID and amount (e.g., sub-1,29.99)', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Schedule', + onPress: (input?: string) => { + if (!input) return; + const [subId, amountStr] = input.split(','); + const amount = parseFloat(amountStr); + if (subId && !isNaN(amount)) { + addOneTimePayment(subId, amount, 'USD', new Date(), 'One-time payment'); + Alert.alert('Scheduled', `One-time payment of ${amount} USD for ${subId}`); + } + }, + }, + ], + 'plain-text' + ) + : Alert.alert( + 'Schedule one-time payment', + 'Use the calendar app to schedule one-time payments from the billing screen.' + ); + }, [addOneTimePayment]); + const handleConnect = async (provider: CalendarProvider) => { try { const authorization = await beginConnection(provider); @@ -353,6 +409,92 @@ const CalendarIntegrationScreen: React.FC = () => { )} + + Calendar export + + Export all subscription renewal events as an iCal file for use with any calendar app. + + + Export iCal (.ics) + + + + + Timezone + + Set your preferred timezone for calendar events. Current: {timezone}. + + + {SUBSCRIPTION_TIMEZONES.map((tz) => ( + setTimezone(tz)}> + + {tz} + + + ))} + + + + + Schedule conflicts + + Detect overlapping subscription billing dates and total charges per day. + + + Check for conflicts + + {scheduleConflicts.length > 0 ? ( + scheduleConflicts.slice(0, 5).map((conflict) => ( + + {conflict.date} + + {conflict.conflictingSubscriptions.length} subscriptions — {conflict.totalAmount.toFixed(2)} USD total + + {conflict.conflictingSubscriptions.map((sub) => ( + + {sub.name}: {sub.currency} {sub.amount.toFixed(2)} + + ))} + + )) + ) : scheduleConflicts.length === 0 && ( + No conflicts detected. Tap "Check for conflicts" to scan. + )} + + + + One-time payments + + Schedule one-time payments beyond recurring subscriptions. + + + Schedule payment + + {oneTimePayments.length > 0 ? ( + oneTimePayments.map((payment) => ( + + {payment.description} + + {payment.currency} {payment.amount.toFixed(2)} — {payment.status} + + {new Date(payment.scheduledDate).toLocaleDateString()} + {payment.status === 'pending' && ( + cancelOneTimePayment(payment.id)}> + Cancel + + )} + + )) + ) : ( + No one-time payments scheduled. + )} + + {error ? ( {error} @@ -480,6 +622,25 @@ const styles = StyleSheet.create({ borderColor: `${colors.error}66`, }, errorButtonText: { ...typography.caption, color: colors.error, fontWeight: '600' }, + timezoneScroll: { marginTop: spacing.sm }, + timezoneChip: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + marginRight: spacing.sm, + }, + conflictRow: { + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: spacing.xs, + }, + conflictDate: { ...typography.body, color: colors.text, fontWeight: '600' }, + conflictDetail: { ...typography.caption, color: colors.textSecondary }, + conflictSub: { ...typography.small, color: colors.textSecondary, paddingLeft: spacing.sm }, }); export default CalendarIntegrationScreen; diff --git a/src/services/calendarService.ts b/src/services/calendarService.ts index 690f030..d2f4248 100644 --- a/src/services/calendarService.ts +++ b/src/services/calendarService.ts @@ -1,11 +1,16 @@ +import { BillingCycle } from '../types/subscription'; import type { Subscription } from '../types/subscription'; import type { + CalendarExportPayload, CalendarOAuthCallbackPayload, CalendarEventTemplate, CalendarIntegration, CalendarProvider, CalendarSyncedEvent, + OneTimeScheduledPayment, PendingCalendarAuthorization, + ProratedAdjustment, + ScheduleConflict, } from '../types/calendar'; const DEFAULT_REDIRECT_URI = 'subtrackr://calendar/callback'; @@ -240,3 +245,236 @@ export async function syncToCalendar( export async function disconnectCalendar(connectionId: string): Promise<{ connectionId: string }> { return { connectionId }; } + +// ── iCal Export ──────────────────────────────────────────────────────────── + +function escapeICalText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +function formatICalDate(isoString: string): string { + const d = new Date(isoString); + return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +export function generateICalendarExport( + events: CalendarEventTemplate[], + timezone?: string +): CalendarExportPayload { + const now = new Date().toISOString(); + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//SubTrackr//Subscription Calendar//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + ]; + + if (timezone) { + lines.push('BEGIN:VTIMEZONE'); + lines.push(`TZID:${escapeICalText(timezone)}`); + lines.push('END:VTIMEZONE'); + } + + for (const event of events) { + const uid = `${event.kind}_${event.startAt}_${Math.random().toString(36).slice(2, 8)}`; + lines.push('BEGIN:VEVENT'); + lines.push(`UID:${uid}@subtrackr`); + lines.push(`DTSTAMP:${formatICalDate(now)}`); + lines.push(`DTSTART:${formatICalDate(event.startAt)}`); + lines.push(`DTEND:${formatICalDate(event.endAt)}`); + lines.push(`SUMMARY:${escapeICalText(event.title)}`); + lines.push(`DESCRIPTION:${escapeICalText(event.notes)}`); + + if (timezone) { + lines.push(`TZID:${escapeICalText(timezone)}`); + } + + for (const offsetMinutes of event.reminderOffsets) { + const trigger = offsetMinutes > 0 ? `-PT${offsetMinutes}M` : `PT${Math.abs(offsetMinutes)}M`; + lines.push('BEGIN:VALARM'); + lines.push('ACTION:DISPLAY'); + lines.push(`TRIGGER:${trigger}`); + lines.push(`DESCRIPTION:Reminder: ${escapeICalText(event.title)}`); + lines.push('END:VALARM'); + } + + lines.push('END:VEVENT'); + } + + lines.push('END:VCALENDAR'); + + return { + ical: lines.join('\r\n'), + filename: `subtrackr_events_${Date.now()}.ics`, + events, + }; +} + +// ── Schedule Conflict Detection ──────────────────────────────────────────── + +export function detectScheduleConflicts( + subscriptions: Subscription[], + existingEvents: CalendarSyncedEvent[] +): ScheduleConflict[] { + const conflictsByDate = new Map(); + + for (const sub of subscriptions) { + if (!sub.isActive) continue; + const dateKey = new Date(sub.nextBillingDate).toISOString().split('T')[0]; + + if (!conflictsByDate.has(dateKey)) { + conflictsByDate.set(dateKey, { + date: dateKey, + conflictingSubscriptions: [], + totalAmount: 0, + }); + } + + const entry = conflictsByDate.get(dateKey)!; + entry.conflictingSubscriptions.push({ + id: sub.id, + name: sub.name, + amount: sub.price, + currency: sub.currency, + }); + entry.totalAmount += sub.price; + } + + return Array.from(conflictsByDate.values()).filter((c) => c.conflictingSubscriptions.length > 1); +} + +// ── Prorated Adjustment ──────────────────────────────────────────────────── + +export function calculateProratedAdjustment( + subscription: Subscription, + newDate: Date, + reason: string +): ProratedAdjustment { + const now = new Date(); + const nextBilling = new Date(subscription.nextBillingDate); + const daysInCycle = getDaysInCycle(subscription.billingCycle); + + const msPerDay = 24 * 60 * 60 * 1000; + const daysRemaining = Math.max(0, Math.round((nextBilling.getTime() - now.getTime()) / msPerDay)); + + const proratedAmount = Number( + ((subscription.price / daysInCycle) * daysRemaining).toFixed(2) + ); + + return { + originalAmount: subscription.price, + proratedAmount, + daysRemaining, + daysInCycle, + effectiveDate: newDate.toISOString(), + reason, + }; +} + +function getDaysInCycle(cycle: BillingCycle): number { + switch (cycle) { + case BillingCycle.WEEKLY: + return 7; + case BillingCycle.MONTHLY: + return 30; + case BillingCycle.YEARLY: + return 365; + default: + return 30; + } +} + +// ── One-Time Scheduled Payment ───────────────────────────────────────────── + +export function scheduleOneTimePayment( + subscriptionId: string, + amount: number, + currency: string, + scheduledDate: Date, + description: string +): OneTimeScheduledPayment { + return { + id: `onetime_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + subscriptionId, + amount, + currency, + scheduledDate: scheduledDate.toISOString(), + description, + status: 'pending', + createdAt: new Date().toISOString(), + }; +} + +// ── Timezone-Aware Event Building ───────────────────────────────────────── + +export function buildTimezoneAwareEvent( + subscription: Subscription, + reminderOffsets: number[] +): CalendarEventTemplate { + const event = buildSubscriptionCalendarEvent(subscription, reminderOffsets); + const tz = subscription.timezone || 'UTC'; + + if (tz !== 'UTC') { + const start = new Date(event.startAt); + const end = new Date(event.endAt); + event.notes += ` Timezone: ${tz}.`; + event.startAt = start.toISOString(); + event.endAt = end.toISOString(); + } + + return event; +} + +export function formatDateInTimezone(isoString: string, timezone: string): string { + try { + const date = new Date(isoString); + return date.toLocaleString('en-US', { timeZone: timezone }); + } catch { + return new Date(isoString).toLocaleString(); + } +} + +export function convertToTimezone(isoString: string, targetTimezone: string): string { + const date = new Date(isoString); + const offset = getTimezoneOffset(date, targetTimezone); + const adjusted = new Date(date.getTime() + offset); + return adjusted.toISOString(); +} + +function getTimezoneOffset(date: Date, timezone: string): number { + try { + const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + return tzDate.getTime() - date.getTime(); + } catch { + return 0; + } +} + +// ── Check for DST Transition ────────────────────────────────────────────── + +export function isDSTTransitionPeriod(date: Date, timezone: string): boolean { + const oneWeekLater = new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000); + const currentOffset = getTimezoneOffset(date, timezone); + const laterOffset = getTimezoneOffset(oneWeekLater, timezone); + return currentOffset !== laterOffset; +} + +export function adjustForDST(event: CalendarEventTemplate, timezone: string): CalendarEventTemplate { + const startDate = new Date(event.startAt); + if (isDSTTransitionPeriod(startDate, timezone)) { + const offset = getTimezoneOffset(startDate, timezone); + const adjusted = new Date(startDate.getTime() - offset); + return { + ...event, + startAt: adjusted.toISOString(), + endAt: new Date(adjusted.getTime() + 30 * 60 * 1000).toISOString(), + notes: `${event.notes} (DST adjusted for ${timezone})`, + }; + } + return event; +} diff --git a/src/store/calendarStore.ts b/src/store/calendarStore.ts index 19b7649..ca02acd 100644 --- a/src/store/calendarStore.ts +++ b/src/store/calendarStore.ts @@ -5,18 +5,26 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { beginCalendarOAuth, buildSubscriptionCalendarEvent, + calculateProratedAdjustment, connectCalendar, createCalendarOAuthCallbackUrl, + detectScheduleConflicts, disconnectCalendar, + generateICalendarExport, normalizeReminderOffsets, parseCalendarOAuthCallback, + scheduleOneTimePayment, syncToCalendar, } from '../services/calendarService'; import type { + CalendarExportPayload, CalendarIntegration, CalendarProvider, CalendarSyncedEvent, + OneTimeScheduledPayment, PendingCalendarAuthorization, + ProratedAdjustment, + ScheduleConflict, } from '../types/calendar'; import { REMINDER_PRESETS } from '../types/calendar'; import type { Subscription } from '../types/subscription'; @@ -32,6 +40,9 @@ interface CalendarState { pendingAuthorizations: PendingAuthorizationMap; isLoading: boolean; error: string | null; + oneTimePayments: OneTimeScheduledPayment[]; + scheduleConflicts: ScheduleConflict[]; + timezone: string; beginConnection: (provider: CalendarProvider) => Promise; completeConnection: ( provider: CalendarProvider, @@ -46,6 +57,23 @@ interface CalendarState { syncSubscriptionToCalendars: (subscription: Subscription) => Promise; syncSubscriptions: (subscriptions: Subscription[]) => Promise; removeSubscriptionFromCalendars: (subscriptionId: string) => Promise; + addOneTimePayment: ( + subscriptionId: string, + amount: number, + currency: string, + scheduledDate: Date, + description: string + ) => void; + cancelOneTimePayment: (paymentId: string) => void; + getOneTimePayments: () => OneTimeScheduledPayment[]; + checkConflicts: (subscriptions: Subscription[]) => void; + exportCalendar: (subscriptions: Subscription[], timezone?: string) => CalendarExportPayload; + calculateProratedCharge: ( + subscription: Subscription, + newDate: Date, + reason: string + ) => ProratedAdjustment; + setTimezone: (timezone: string) => void; } function removeProviderPendingState( @@ -80,6 +108,9 @@ export const useCalendarStore = create()( pendingAuthorizations: {}, isLoading: false, error: null, + oneTimePayments: [], + scheduleConflicts: [], + timezone: 'UTC', beginConnection: async (provider) => { set({ isLoading: true, error: null }); @@ -203,6 +234,43 @@ export const useCalendarStore = create()( set({ error: null }); }, + addOneTimePayment: (subscriptionId, amount, currency, scheduledDate, description) => { + const payment = scheduleOneTimePayment(subscriptionId, amount, currency, scheduledDate, description); + set((state) => ({ + oneTimePayments: [...state.oneTimePayments, payment], + })); + }, + + cancelOneTimePayment: (paymentId) => { + set((state) => ({ + oneTimePayments: state.oneTimePayments.map((p) => + p.id === paymentId ? { ...p, status: 'cancelled' as const } : p + ), + })); + }, + + getOneTimePayments: () => get().oneTimePayments, + + checkConflicts: (subscriptions) => { + const conflicts = detectScheduleConflicts(subscriptions, get().syncedEvents); + set({ scheduleConflicts: conflicts }); + }, + + exportCalendar: (subscriptions, timezone) => { + const events = subscriptions + .filter((s) => s.isActive) + .map((s) => buildSubscriptionCalendarEvent(s, get().reminderOffsets)); + return generateICalendarExport(events, timezone || get().timezone); + }, + + calculateProratedCharge: (subscription, newDate, reason) => { + return calculateProratedAdjustment(subscription, newDate, reason); + }, + + setTimezone: (timezone) => { + set({ timezone }); + }, + syncSubscriptionToCalendars: async (subscription) => { const { integrations, syncedEvents } = get(); const activeIntegrations = integrations.filter(isConnected); @@ -280,6 +348,8 @@ export const useCalendarStore = create()( integrations: state.integrations, syncedEvents: state.syncedEvents, reminderOffsets: state.reminderOffsets, + oneTimePayments: state.oneTimePayments, + timezone: state.timezone, }), } ) diff --git a/src/types/calendar.ts b/src/types/calendar.ts index 736492a..f2158f9 100644 --- a/src/types/calendar.ts +++ b/src/types/calendar.ts @@ -27,7 +27,7 @@ export interface CalendarIntegration { reminderOffsets: number[]; } -export type CalendarEventKind = 'billing_reminder'; +export type CalendarEventKind = 'billing_reminder' | 'one_time_payment'; export interface CalendarEventTemplate { kind: CalendarEventKind; @@ -38,6 +38,56 @@ export interface CalendarEventTemplate { reminderOffsets: number[]; } +export interface OneTimeScheduledPayment { + id: string; + subscriptionId: string; + amount: number; + currency: string; + scheduledDate: string; + description: string; + status: 'pending' | 'processed' | 'cancelled'; + createdAt: string; +} + +export interface ScheduleConflict { + date: string; + conflictingSubscriptions: { id: string; name: string; amount: number; currency: string }[]; + totalAmount: number; +} + +export interface ProratedAdjustment { + originalAmount: number; + proratedAmount: number; + daysRemaining: number; + daysInCycle: number; + effectiveDate: string; + reason: string; +} + +export interface CalendarExportPayload { + ical: string; + filename: string; + events: CalendarEventTemplate[]; +} + +export const SUBSCRIPTION_TIMEZONES = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Berlin', + 'Europe/Paris', + 'Asia/Tokyo', + 'Asia/Shanghai', + 'Asia/Kolkata', + 'Australia/Sydney', + 'Pacific/Auckland', +] as const; + +export type SubscriptionTimezone = typeof SUBSCRIPTION_TIMEZONES[number]; + export interface CalendarSyncedEvent extends CalendarEventTemplate { id: string; subscriptionId: string; diff --git a/src/types/subscription.ts b/src/types/subscription.ts index bb5862c..f213bb0 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -18,6 +18,7 @@ export interface Subscription { totalGasSpent?: number; chargeCount?: number; lastGasCost?: number; + timezone?: string; createdAt: Date; updatedAt: Date; }