Skip to content
Open
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
54 changes: 54 additions & 0 deletions backend/src/services/monitoring-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ export interface SubscriptionMetrics {
total_monthly_revenue: number;
}

export interface TrialMetrics {
active_trials: number;
trials_expiring_in_7_days: number;
saved_by_syncro: number; // trials cancelled before auto-charge after receiving a reminder
intentional_conversions: number;
automatic_conversions: number;
}

export interface RenewalMetrics {
total_delivery_attempts: number;
success_rate: number;
Expand Down Expand Up @@ -164,6 +172,52 @@ export class MonitoringService {
};
})());
}

/**
* Get trial-specific metrics including "saved by SYNCRO" count
*/
async getTrialMetrics(): Promise<TrialMetrics> {
try {
const now = new Date().toISOString();
const in7Days = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();

const [
{ count: activeTrials },
{ count: expiringTrials },
{ data: conversionEvents },
] = await Promise.all([
supabase
.from('subscriptions')
.select('*', { count: 'exact', head: true })
.eq('is_trial', true)
.in('status', ['active', 'trial'])
.gt('trial_ends_at', now),
supabase
.from('subscriptions')
.select('*', { count: 'exact', head: true })
.eq('is_trial', true)
.in('status', ['active', 'trial'])
.gt('trial_ends_at', now)
.lte('trial_ends_at', in7Days),
supabase
.from('trial_conversion_events')
.select('conversion_type, saved_by_syncro'),
]);

const events = conversionEvents ?? [];

return {
active_trials: activeTrials ?? 0,
trials_expiring_in_7_days: expiringTrials ?? 0,
saved_by_syncro: events.filter((e) => e.saved_by_syncro).length,
intentional_conversions: events.filter((e) => e.conversion_type === 'intentional').length,
automatic_conversions: events.filter((e) => e.conversion_type === 'automatic').length,
};
} catch (error) {
logger.error('Error fetching trial metrics:', error);
throw error;
}
}
}

export const monitoringService = new MonitoringService();
4 changes: 3 additions & 1 deletion backend/src/services/reminder-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,9 @@ export class ReminderEngine {
return;
}

const renewalDate = subscription.active_until || new Date().toISOString();
const renewalDate = reminder.reminder_type === 'trial_expiry'
? (subscription.trial_ends_at || new Date().toISOString())
: (subscription.active_until || new Date().toISOString());
const payload: NotificationPayload = {
title: `${subscription.name} Renewal Reminder`,
body: `${subscription.name} will renew in ${reminder.days_before} day${reminder.days_before > 1 ? 's' : ''}`,
Expand Down
6 changes: 6 additions & 0 deletions backend/src/types/reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export interface Subscription {
credit_card_required: boolean;
created_at: string;
updated_at: string;
// Trial tracking
is_trial: boolean;
trial_ends_at: string | null;
trial_converts_to_price: number | null;
credit_card_required: boolean;
website_url: string | null;
}

export interface UserProfile {
Expand Down
15 changes: 15 additions & 0 deletions backend/src/types/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export interface Subscription {
paused_at: string | null;
resume_at: string | null;
pause_reason: string | null;
// Trial tracking fields
is_trial: boolean;
trial_ends_at: string | null;
trial_converts_to_price: number | null;
credit_card_required: boolean;
}

export interface SubscriptionCreateInput {
Expand All @@ -47,6 +52,11 @@ export interface SubscriptionCreateInput {
visibility?: 'private' | 'team';
tags?: string[];
email_account_id?: string;
// Trial fields
is_trial?: boolean;
trial_ends_at?: string;
trial_converts_to_price?: number;
credit_card_required?: boolean;
}

export interface SubscriptionUpdateInput {
Expand All @@ -67,6 +77,11 @@ export interface SubscriptionUpdateInput {
paused_at?: string | null;
resume_at?: string | null;
pause_reason?: string | null;
// Trial fields
is_trial?: boolean;
trial_ends_at?: string | null;
trial_converts_to_price?: number | null;
credit_card_required?: boolean;
}

/** Allowlist of fields a user is permitted to update.
Expand Down
98 changes: 98 additions & 0 deletions client/components/pages/subscriptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export default function SubscriptionsPage({
const hasNoSubscriptions = !subscriptions || subscriptions.length === 0
const hasNoResults = filtered.length === 0 && subscriptions && subscriptions.length > 0

// Active trials sorted by urgency (soonest expiry first)
const activeTrials = (subscriptions || [])
.filter((s: any) => s.isTrial && s.trialEndsAt)
.sort((a: any, b: any) => new Date(a.trialEndsAt).getTime() - new Date(b.trialEndsAt).getTime())

if (hasNoSubscriptions) {
return (
<EmptyState
Expand Down Expand Up @@ -454,6 +459,79 @@ export default function SubscriptionsPage({
: ""}
</div>

{/* Active Trials Section */}
{activeTrials.length > 0 && (
<div className="mb-8">
<h3 className={`text-lg font-bold mb-3 flex items-center gap-2 ${darkMode ? "text-white" : "text-gray-900"}`}>
<AlertTriangle aria-hidden="true" className="w-5 h-5 text-orange-500" />
Active Trials
<span className="text-sm font-normal text-orange-500">({activeTrials.length})</span>
</h3>
<div className="space-y-3">
{activeTrials.map((sub: any) => {
const daysLeft = Math.ceil((new Date(sub.trialEndsAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
const urgencyColor = daysLeft <= 1 ? "text-red-600" : daysLeft <= 3 ? "text-orange-500" : "text-yellow-600"
const urgencyBg = daysLeft <= 1 ? (darkMode ? "bg-red-900/20 border-red-700" : "bg-red-50 border-red-200") : daysLeft <= 3 ? (darkMode ? "bg-orange-900/20 border-orange-700" : "bg-orange-50 border-orange-200") : (darkMode ? "bg-yellow-900/20 border-yellow-700" : "bg-yellow-50 border-yellow-200")
return (
<div
key={sub.id}
className={`${urgencyBg} border rounded-xl p-5 flex items-center justify-between`}
aria-label={`${sub.name} trial, expires in ${daysLeft} days`}
>
<div className="flex items-center gap-4">
<div aria-hidden="true" className={`w-12 h-12 ${darkMode ? "bg-[#1E2A35]" : "bg-black"} rounded-lg flex items-center justify-center text-2xl`}>
{sub.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h4 className={`font-semibold ${darkMode ? "text-white" : "text-gray-900"}`}>{sub.name}</h4>
<span className="bg-[#007A5C] text-white text-xs px-2 py-0.5 rounded-full font-semibold">Trial</span>
</div>
<p className={`text-sm font-bold ${urgencyColor} mt-0.5`}>
{daysLeft === 0 ? "Expires TODAY at midnight" : `Expires in ${daysLeft} day${daysLeft > 1 ? "s" : ""}`}
</p>
{sub.priceAfterTrial && (
<p className={`text-xs ${darkMode ? "text-gray-400" : "text-gray-500"}`}>
Auto-charges ${sub.priceAfterTrial}/{sub.billingCycle || "month"} after trial
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className={`text-right mr-4`}>
<p className={`text-2xl font-bold tabular-nums ${urgencyColor}`}>
{daysLeft === 0 ? "Today" : `${daysLeft}d`}
</p>
<p className={`text-xs ${darkMode ? "text-gray-400" : "text-gray-500"}`}>remaining</p>
</div>
{onCancelTrial && (
<button
onClick={() => onCancelTrial(sub.id)}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-semibold transition-colors"
aria-label={`Cancel ${sub.name} trial`}
>
<X aria-hidden="true" className="w-4 h-4" />
Cancel Trial
</button>
)}
{onConvertTrial && (
<button
onClick={() => onConvertTrial(sub.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-semibold transition-colors ${darkMode ? "bg-[#2D3748] hover:bg-[#374151] text-white" : "bg-white hover:bg-gray-50 text-gray-900 border border-gray-200"}`}
aria-label={`Keep ${sub.name} subscription`}
>
<Check aria-hidden="true" className="w-4 h-4" />
Convert to Paid
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)}

{/* Subscriptions List */}
{!hasNoResults && (
<>
Expand Down Expand Up @@ -767,6 +845,26 @@ export function SubscriptionCard({


<div className="flex gap-2" role="group" aria-label={`Actions for ${sub.name}`}>
{sub.isTrial && onCancelTrial && (
<button
onClick={() => onCancelTrial(sub.id)}
aria-label={`Cancel ${sub.name} trial`}
className="flex items-center gap-1 px-2 py-1.5 rounded-lg bg-red-600 hover:bg-red-700 text-white text-xs font-semibold transition-colors"
>
<X aria-hidden="true" className="w-3 h-3" />
Cancel Trial
</button>
)}
{sub.isTrial && onConvertTrial && (
<button
onClick={() => onConvertTrial(sub.id)}
aria-label={`Keep ${sub.name} subscription`}
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-semibold transition-colors ${darkMode ? "bg-[#2D3748] hover:bg-[#374151] text-white" : "bg-gray-100 hover:bg-gray-200 text-gray-700"}`}
>
<Check aria-hidden="true" className="w-3 h-3" />
Keep
</button>
)}
<button
onClick={() => onManage && onManage(sub)}
aria-label={`Edit ${sub.name}`}
Expand Down
Loading