From 530c6b45d2d341b4c2f915f8befca739c5275b85 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Wed, 27 May 2026 12:16:43 +0100 Subject: [PATCH 1/2] 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 bce477f..6bd43cf 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -23,6 +23,7 @@ export interface Subscription { fiatCurrency?: string; fiatPriceUpdatedAt?: Date; oraclePriceDeviationBps?: number; + timezone?: string; createdAt: Date; updatedAt: Date; } From ee6992cd3ea2e2e9a8875c3cff838077320bdc21 Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Wed, 27 May 2026 14:33:47 +0100 Subject: [PATCH 2/2] feat: implement subscription API rate limiting and usage metering (#369) --- backend/services/index.ts | 1 + backend/services/rateLimitingService.ts | 304 ++++++++++++++++++++++++ src/types/rateLimiting.ts | 133 +++++++++++ 3 files changed, 438 insertions(+) create mode 100644 backend/services/rateLimitingService.ts create mode 100644 src/types/rateLimiting.ts diff --git a/backend/services/index.ts b/backend/services/index.ts index b57ac91..d34d60e 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -2,6 +2,7 @@ export { AuditService } from './auditService'; export { DunningService, dunningService } from './dunningService'; export { PricingService } from './pricingService'; export { OracleMonitorService, oracleMonitorService } from './oracleMonitorService'; +export { RateLimitingService, rateLimitingService } from './rateLimitingService'; export type { AuditAction, AuditEvent, diff --git a/backend/services/rateLimitingService.ts b/backend/services/rateLimitingService.ts new file mode 100644 index 0000000..378723b --- /dev/null +++ b/backend/services/rateLimitingService.ts @@ -0,0 +1,304 @@ +import { SubscriptionTier } from '../../src/types/subscription'; +import { + TIER_RATE_LIMITS, + SOFT_LIMIT_WARNINGS, + TIER_UPGRADE_THRESHOLDS, + getNextTier, + type ApiKeyUsage, + type RateLimitExceededError, + type SoftLimitWarning, + type TierRateLimit, + type UsageAnalytics, + type UsageMeteringEntry, + type TierUpgradeRecommendation, +} from '../../src/types/rateLimiting'; + +const ONE_HOUR_MS = 3_600_000; +const ONE_DAY_MS = 86_400_000; +const ONE_MONTH_MS = 2_592_000_000; + +const now = (): number => Date.now(); + +const createId = (prefix: string): string => + `${prefix}_${now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +function computeResetTime(periodMs: number): number { + return Math.floor((now() + periodMs) / periodMs) * periodMs; +} + +export class RateLimitingService { + private usages = new Map(); + private requestLog: UsageMeteringEntry[] = []; + private readonly maxLogEntries = 100_000; + + getOrCreateUsage(apiKey: string, tier: SubscriptionTier): ApiKeyUsage { + const existing = this.usages.get(apiKey); + if (existing) { + existing.tier = tier; + return existing; + } + + const usage: ApiKeyUsage = { + apiKey, + tier, + hourly: 0, + daily: 0, + monthly: 0, + hourlyResetAt: computeResetTime(ONE_HOUR_MS), + dailyResetAt: computeResetTime(ONE_DAY_MS), + monthlyResetAt: computeResetTime(ONE_MONTH_MS), + lastRequestAt: 0, + burstTokens: TIER_RATE_LIMITS[tier].burstLimit, + lastBurstRefill: now(), + concurrentRequests: 0, + }; + + this.usages.set(apiKey, usage); + return usage; + } + + checkRateLimit(apiKey: string, tier: SubscriptionTier): { allowed: boolean; retryAfterMs?: number } { + const usage = this.getOrCreateUsage(apiKey, tier); + const limits = TIER_RATE_LIMITS[tier]; + const now_ts = now(); + + this.resetIfExpired(usage); + + const hourlyRemaining = limits.hourlyLimit - usage.hourly; + const dailyRemaining = limits.dailyLimit - usage.daily; + const monthlyRemaining = limits.monthlyLimit - usage.monthly; + + if (monthlyRemaining <= 0) { + return { allowed: false, retryAfterMs: usage.monthlyResetAt - now_ts }; + } + if (dailyRemaining <= 0) { + return { allowed: false, retryAfterMs: usage.dailyResetAt - now_ts }; + } + if (hourlyRemaining <= 0) { + return { allowed: false, retryAfterMs: usage.hourlyResetAt - now_ts }; + } + + this.refillBurstTokens(usage, limits); + if (usage.burstTokens <= 0) { + return { allowed: false, retryAfterMs: 1_000 }; + } + + if (usage.concurrentRequests >= limits.concurrentLimit) { + return { allowed: false, retryAfterMs: 500 }; + } + + return { allowed: true }; + } + + recordRequest( + apiKey: string, + tier: SubscriptionTier, + endpoint: string, + statusCode: number, + latencyMs: number + ): { softWarning?: SoftLimitWarning; rateLimitError?: RateLimitExceededError } { + const usage = this.getOrCreateUsage(apiKey, tier); + const limits = TIER_RATE_LIMITS[tier]; + + this.resetIfExpired(usage); + + usage.hourly += 1; + usage.daily += 1; + usage.monthly += 1; + usage.lastRequestAt = now(); + usage.burstTokens -= 1; + usage.concurrentRequests += 1; + + setTimeout(() => { + usage.concurrentRequests = Math.max(0, usage.concurrentRequests - 1); + }, 0); + + const entry: UsageMeteringEntry = { + apiKey, + endpoint, + timestamp: now(), + statusCode, + latencyMs, + tier, + }; + + this.requestLog.push(entry); + if (this.requestLog.length > this.maxLogEntries) { + this.requestLog = this.requestLog.slice(-this.maxLogEntries / 2); + } + + const hourlyUsagePct = usage.hourly / limits.hourlyLimit; + const softWarning = SOFT_LIMIT_WARNINGS.find((w) => hourlyUsagePct >= w) + ? { + warning: 'soft_limit_reached' as const, + usagePercent: Math.round(hourlyUsagePct * 100), + limit: limits.hourlyLimit, + current: usage.hourly, + tier, + message: `API usage at ${Math.round(hourlyUsagePct * 100)}% of hourly limit (${usage.hourly}/${limits.hourlyLimit})`, + } + : undefined; + + let rateLimitError: RateLimitExceededError | undefined; + if (hourlyUsagePct >= 1) { + rateLimitError = { + status: 429, + error: 'rate_limit_exceeded', + message: `Hourly rate limit exceeded for ${tier} tier. Limit: ${limits.hourlyLimit} requests/hour.`, + retryAfterMs: usage.hourlyResetAt - now(), + limit: limits.hourlyLimit, + remaining: 0, + resetAt: usage.hourlyResetAt, + tier, + }; + } + + return { softWarning, rateLimitError }; + } + + getUsage(apiKey: string): ApiKeyUsage | undefined { + const usage = this.usages.get(apiKey); + if (usage) { + this.resetIfExpired(usage); + } + return usage; + } + + getAnalytics(tier?: SubscriptionTier): UsageAnalytics { + let entries = this.requestLog; + if (tier) { + entries = entries.filter((e) => e.tier === tier); + } + + const totalRequests = entries.length; + const requestsByTier: Record = { + [SubscriptionTier.FREE]: 0, + [SubscriptionTier.BASIC]: 0, + [SubscriptionTier.PREMIUM]: 0, + [SubscriptionTier.ENTERPRISE]: 0, + }; + + const requestsByEndpoint: Record = {}; + let totalLatencyMs = 0; + let errorCount = 0; + let rateLimitHits = 0; + + for (const entry of entries) { + requestsByTier[entry.tier] = (requestsByTier[entry.tier] ?? 0) + 1; + requestsByEndpoint[entry.endpoint] = (requestsByEndpoint[entry.endpoint] ?? 0) + 1; + totalLatencyMs += entry.latencyMs; + if (entry.statusCode >= 400) errorCount += 1; + if (entry.statusCode === 429) rateLimitHits += 1; + } + + const sortedLatencies = entries.map((e) => e.latencyMs).sort((a, b) => a - b); + const avgLatency = totalRequests > 0 ? totalLatencyMs / totalRequests : 0; + const p95Index = Math.floor(sortedLatencies.length * 0.95); + const p99Index = Math.floor(sortedLatencies.length * 0.99); + + const topEndpoints = Object.entries(requestsByEndpoint) + .sort(([, a], [, b]) => b - a) + .slice(0, 10) + .map(([endpoint, count]) => ({ endpoint, count })); + + return { + totalRequests, + requestsByTier, + requestsByEndpoint, + averageLatencyMs: Math.round(avgLatency), + p95LatencyMs: sortedLatencies[p95Index] ?? 0, + p99LatencyMs: sortedLatencies[p99Index] ?? 0, + errorRate: totalRequests > 0 ? errorCount / totalRequests : 0, + rateLimitHitCount: rateLimitHits, + topEndpoints, + hourlyBreakdown: [], + }; + } + + checkTierUpgrade(apiKey: string): TierUpgradeRecommendation | null { + const usage = this.usages.get(apiKey); + if (!usage) return null; + this.resetIfExpired(usage); + + const nextTier = getNextTier(usage.tier); + if (!nextTier) return null; + + const limits = TIER_RATE_LIMITS[usage.tier]; + const threshold = TIER_UPGRADE_THRESHOLDS[usage.tier]; + const hourlyUsagePct = usage.hourly / limits.hourlyLimit; + + if (hourlyUsagePct >= threshold.usagePercent) { + const nextLimits = TIER_RATE_LIMITS[nextTier]; + return { + currentTier: usage.tier, + recommendedTier: nextTier, + reason: `Sustained usage at ${Math.round(hourlyUsagePct * 100)}% of ${usage.tier} tier hourly limit`, + sustainedUsage: usage.hourly, + threshold: Math.round(limits.hourlyLimit * threshold.usagePercent), + estimatedSavings: nextLimits.hourlyLimit - limits.hourlyLimit, + }; + } + + return null; + } + + getRateLimitStatus(apiKey: string, tier: SubscriptionTier): { + limits: TierRateLimit; + current: { hourly: number; daily: number; monthly: number; burstTokens: number }; + remaining: { hourly: number; daily: number; monthly: number; burstTokens: number }; + resetAt: { hourly: number; daily: number; monthly: number }; + } { + const usage = this.getOrCreateUsage(apiKey, tier); + this.resetIfExpired(usage); + const limits = TIER_RATE_LIMITS[tier]; + + return { + limits, + current: { + hourly: usage.hourly, + daily: usage.daily, + monthly: usage.monthly, + burstTokens: usage.burstTokens, + }, + remaining: { + hourly: Math.max(0, limits.hourlyLimit - usage.hourly), + daily: Math.max(0, limits.dailyLimit - usage.daily), + monthly: Math.max(0, limits.monthlyLimit - usage.monthly), + burstTokens: Math.max(0, usage.burstTokens), + }, + resetAt: { + hourly: usage.hourlyResetAt, + daily: usage.dailyResetAt, + monthly: usage.monthlyResetAt, + }, + }; + } + + private resetIfExpired(usage: ApiKeyUsage): void { + const now_ts = now(); + if (now_ts >= usage.hourlyResetAt) { + usage.hourly = 0; + usage.hourlyResetAt = computeResetTime(ONE_HOUR_MS); + } + if (now_ts >= usage.dailyResetAt) { + usage.daily = 0; + usage.dailyResetAt = computeResetTime(ONE_DAY_MS); + } + if (now_ts >= usage.monthlyResetAt) { + usage.monthly = 0; + usage.monthlyResetAt = computeResetTime(ONE_MONTH_MS); + } + } + + private refillBurstTokens(usage: ApiKeyUsage, limits: TierRateLimit): void { + const now_ts = now(); + const elapsed = now_ts - usage.lastBurstRefill; + const tokensToAdd = Math.floor(elapsed / 1_000); + if (tokensToAdd > 0) { + usage.burstTokens = Math.min(limits.burstLimit, usage.burstTokens + tokensToAdd); + usage.lastBurstRefill = now_ts; + } + } +} + +export const rateLimitingService = new RateLimitingService(); diff --git a/src/types/rateLimiting.ts b/src/types/rateLimiting.ts new file mode 100644 index 0000000..645ef2e --- /dev/null +++ b/src/types/rateLimiting.ts @@ -0,0 +1,133 @@ +import { SubscriptionTier } from './subscription'; + +export interface TierRateLimit { + tier: SubscriptionTier; + hourlyLimit: number; + dailyLimit: number; + monthlyLimit: number; + burstLimit: number; + concurrentLimit: number; +} + +export interface ApiKeyUsage { + apiKey: string; + tier: SubscriptionTier; + hourly: number; + daily: number; + monthly: number; + hourlyResetAt: number; + dailyResetAt: number; + monthlyResetAt: number; + lastRequestAt: number; + burstTokens: number; + lastBurstRefill: number; + concurrentRequests: number; +} + +export interface UsageMeteringEntry { + apiKey: string; + endpoint: string; + timestamp: number; + statusCode: number; + latencyMs: number; + tier: SubscriptionTier; +} + +export interface RateLimitExceededError { + status: 429; + error: 'rate_limit_exceeded'; + message: string; + retryAfterMs: number; + limit: number; + remaining: number; + resetAt: number; + tier: SubscriptionTier; +} + +export interface SoftLimitWarning { + warning: 'soft_limit_reached'; + usagePercent: number; + limit: number; + current: number; + tier: SubscriptionTier; + message: string; +} + +export interface UsageAnalytics { + totalRequests: number; + requestsByTier: Record; + requestsByEndpoint: Record; + averageLatencyMs: number; + p95LatencyMs: number; + p99LatencyMs: number; + errorRate: number; + rateLimitHitCount: number; + topEndpoints: { endpoint: string; count: number }[]; + hourlyBreakdown: { hour: string; count: number }[]; +} + +export interface TierUpgradeRecommendation { + currentTier: SubscriptionTier; + recommendedTier: SubscriptionTier; + reason: string; + sustainedUsage: number; + threshold: number; + estimatedSavings: number; +} + +export const TIER_RATE_LIMITS: Record = { + [SubscriptionTier.FREE]: { + tier: SubscriptionTier.FREE, + hourlyLimit: 100, + dailyLimit: 500, + monthlyLimit: 10_000, + burstLimit: 20, + concurrentLimit: 2, + }, + [SubscriptionTier.BASIC]: { + tier: SubscriptionTier.BASIC, + hourlyLimit: 500, + dailyLimit: 2_500, + monthlyLimit: 50_000, + burstLimit: 50, + concurrentLimit: 5, + }, + [SubscriptionTier.PREMIUM]: { + tier: SubscriptionTier.PREMIUM, + hourlyLimit: 1_000, + dailyLimit: 10_000, + monthlyLimit: 200_000, + burstLimit: 100, + concurrentLimit: 10, + }, + [SubscriptionTier.ENTERPRISE]: { + tier: SubscriptionTier.ENTERPRISE, + hourlyLimit: 10_000, + dailyLimit: 100_000, + monthlyLimit: 2_000_000, + burstLimit: 500, + concurrentLimit: 50, + }, +}; + +export const SOFT_LIMIT_WARNINGS = [0.8, 0.95] as const; + +export const TIER_UPGRADE_THRESHOLDS: Record = { + [SubscriptionTier.FREE]: { usagePercent: 0.8, sustainedHours: 48 }, + [SubscriptionTier.BASIC]: { usagePercent: 0.8, sustainedHours: 48 }, + [SubscriptionTier.PREMIUM]: { usagePercent: 0.9, sustainedHours: 72 }, + [SubscriptionTier.ENTERPRISE]: { usagePercent: 0.95, sustainedHours: 168 }, +}; + +const TIER_ORDER: SubscriptionTier[] = [ + SubscriptionTier.FREE, + SubscriptionTier.BASIC, + SubscriptionTier.PREMIUM, + SubscriptionTier.ENTERPRISE, +]; + +export function getNextTier(currentTier: SubscriptionTier): SubscriptionTier | null { + const idx = TIER_ORDER.indexOf(currentTier); + if (idx < 0 || idx >= TIER_ORDER.length - 1) return null; + return TIER_ORDER[idx + 1]; +}