- {formatPaymentAmount(
- subscription.amount,
- subscription.currency
- )}
+ {formatPaymentAmount(subscription.plan_amount, 'usd')}
- / {formatInterval(subscription.interval)}
+ / {formatInterval(subscription.plan_interval)}
@@ -302,115 +311,127 @@ export const SubscriptionManager: React.FC = ({
{/* Details */}
-
- Current Period:
-
- {new Date(
- subscription.current_period_start
- ).toLocaleDateString()}{' '}
- -{' '}
- {new Date(
- subscription.current_period_end
- ).toLocaleDateString()}
-
-
-
-
- Next Billing:
-
- {new Date(
- subscription.current_period_end
- ).toLocaleDateString()}
-
-
+ {subscription.current_period_start &&
+ subscription.current_period_end && (
+
+ Current Period:
+
+ {new Date(
+ subscription.current_period_start
+ ).toLocaleDateString()}{' '}
+ -{' '}
+ {new Date(
+ subscription.current_period_end
+ ).toLocaleDateString()}
+
+
+ )}
- {subscription.cancel_at_period_end && (
-
-
-
-
-
Will cancel at period end
+ {subscription.current_period_end && (
+
+ Next Billing:
+
+ {new Date(
+ subscription.current_period_end
+ ).toLocaleDateString()}
+
)}
- {subscription.status === 'past_due' && (
-
-
-
-
-
Payment failed. Please update payment method.
-
- )}
+ {(subscription.status === 'grace_period' ||
+ subscription.status === 'past_due') &&
+ (() => {
+ const daysLeft = graceDaysLeft(
+ subscription.grace_period_expires
+ );
+ return (
+
+
+
+
+
+ Payment failed — update your payment method
+ {daysLeft !== null && (
+ <>
+ {' '}
+ to keep your subscription.{' '}
+
+ Grace period: {daysLeft} day
+ {daysLeft === 1 ? '' : 's'} remaining.
+
+ >
+ )}
+ {daysLeft === null && '.'}
+
+
+ );
+ })()}
- {/* Actions */}
- {subscription.status === 'active' && (
+ {/* Actions — cancel a live subscription, or resume a canceled one. */}
+ {subscription.status === 'canceled' && (
- {subscription.cancel_at_period_end ? (
- handleResume(subscription.id)}
- disabled={actionLoading === subscription.id}
- aria-label="Resume subscription"
- >
- {actionLoading === subscription.id ? (
- <>
-
- Resuming...
- >
- ) : (
- 'Resume'
- )}
-
- ) : (
- {
- if (
- confirm(
- 'Are you sure you want to cancel this subscription? It will remain active until the end of the current billing period.'
- )
- ) {
- handleCancel(subscription.id);
- }
- }}
- disabled={actionLoading === subscription.id}
- aria-label="Cancel subscription"
- >
- {actionLoading === subscription.id ? (
- <>
-
- Canceling...
- >
- ) : (
- 'Cancel'
- )}
-
- )}
+ handleResume(subscription.id)}
+ disabled={actionLoading === subscription.id}
+ aria-label="Resume subscription"
+ >
+ {actionLoading === subscription.id ? (
+ <>
+
+ Resuming...
+ >
+ ) : (
+ 'Resume'
+ )}
+
+
+ )}
+
+ {(subscription.status === 'active' ||
+ subscription.status === 'past_due' ||
+ subscription.status === 'grace_period') && (
+
+ {
+ if (
+ confirm(
+ 'Are you sure you want to cancel this subscription? It will remain active until the end of the current billing period.'
+ )
+ ) {
+ handleCancel(subscription.id);
+ }
+ }}
+ disabled={actionLoading === subscription.id}
+ aria-label="Cancel subscription"
+ >
+ {actionLoading === subscription.id ? (
+ <>
+
+ Canceling...
+ >
+ ) : (
+ 'Cancel'
+ )}
+
)}
diff --git a/supabase/functions/paypal-webhook/index.ts b/supabase/functions/paypal-webhook/index.ts
index 4e983039..8cf16c07 100644
--- a/supabase/functions/paypal-webhook/index.ts
+++ b/supabase/functions/paypal-webhook/index.ts
@@ -13,6 +13,11 @@ const paypalClientId = Deno.env.get('NEXT_PUBLIC_PAYPAL_CLIENT_ID')!;
const paypalClientSecret = Deno.env.get('PAYPAL_CLIENT_SECRET')!;
const paypalWebhookId = Deno.env.get('PAYPAL_WEBHOOK_ID')!;
+// Days a past-due subscription stays usable before expiring. Mirrors
+// subscriptionConfig.gracePeriodDays in src/config/payment.ts (kept in sync
+// manually — Deno can't import that browser-oriented module).
+const GRACE_PERIOD_DAYS = 7;
+
serve(async (req) => {
try {
const transmissionId = req.headers.get('paypal-transmission-id');
@@ -120,6 +125,13 @@ serve(async (req) => {
webhookEvent.id
);
break;
+ case 'BILLING.SUBSCRIPTION.PAYMENT.FAILED':
+ processResult = await handleSubscriptionPaymentFailed(
+ supabase,
+ event,
+ webhookEvent.id
+ );
+ break;
default:
processResult = { handled: false };
}
@@ -280,7 +292,19 @@ async function handleSubscriptionEvent(
.select()
.single();
- if (error) throw error;
+ if (error) {
+ // idx_subscriptions_one_live_per_user rejects a second live subscription
+ // for a user who already has one. Acknowledge (don't 500 → no provider
+ // retry storm) and report the reason.
+ if (error.code === '23505') {
+ console.warn(
+ 'Duplicate live subscription rejected by unique index:',
+ error.message
+ );
+ return { handled: false, reason: 'duplicate_live_subscription' };
+ }
+ throw error;
+ }
return { handled: true, related_subscription_id: sub.id };
}
@@ -305,6 +329,52 @@ async function handleSubscriptionCancelled(
return { handled: true, related_subscription_id: sub.id };
}
+/**
+ * Handle a failed subscription payment (BILLING.SUBSCRIPTION.PAYMENT.FAILED).
+ * Mirrors the Stripe invoice.payment_failed handler: flip to grace_period,
+ * increment the failure count, and start the grace clock. The supabase-js
+ * client has no SQL-expression template tag, so the increment is a read-then-
+ * write (PayPal delivers events for one subscription serially).
+ */
+async function handleSubscriptionPaymentFailed(
+ supabase: any,
+ event: any,
+ _webhookEventId: string
+) {
+ const resource = event.resource;
+ const providerSubId = resource.id;
+
+ const { data: existing } = await supabase
+ .from('subscriptions')
+ .select('id, failed_payment_count')
+ .eq('provider_subscription_id', providerSubId)
+ .single();
+
+ if (!existing) {
+ return { handled: false };
+ }
+
+ const gracePeriodExpires = new Date(
+ Date.now() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000
+ )
+ .toISOString()
+ .split('T')[0];
+
+ const { data: sub, error } = await supabase
+ .from('subscriptions')
+ .update({
+ status: 'grace_period',
+ failed_payment_count: (existing.failed_payment_count ?? 0) + 1,
+ grace_period_expires: gracePeriodExpires,
+ })
+ .eq('provider_subscription_id', providerSubId)
+ .select()
+ .single();
+
+ if (error) throw error;
+ return { handled: true, related_subscription_id: sub.id };
+}
+
function mapPayPalSubscriptionStatus(status: string): string {
const statusMap: Record = {
ACTIVE: 'active',
diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts
index 5395fa69..71b5f911 100644
--- a/supabase/functions/stripe-webhook/index.ts
+++ b/supabase/functions/stripe-webhook/index.ts
@@ -16,6 +16,11 @@ const supabaseUrl = Deno.env.get('NEXT_PUBLIC_SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
+// Days a past-due subscription stays usable before expiring. Mirrors
+// subscriptionConfig.gracePeriodDays in src/config/payment.ts (kept in sync
+// manually — Deno can't import that browser-oriented module).
+const GRACE_PERIOD_DAYS = 7;
+
serve(async (req) => {
try {
// Get signature and body
@@ -347,6 +352,16 @@ async function handleSubscriptionEvent(
.single();
if (error) {
+ // idx_subscriptions_one_live_per_user (partial unique index) rejects a
+ // SECOND live subscription for a user who already has one. Don't 500 — the
+ // provider would retry forever; acknowledge and report the reason instead.
+ if (error.code === '23505') {
+ console.warn(
+ 'Duplicate live subscription rejected by unique index:',
+ error.message
+ );
+ return { handled: false, reason: 'duplicate_live_subscription' };
+ }
console.error('Failed to upsert subscription:', error);
throw error;
}
@@ -403,13 +418,41 @@ async function handleInvoicePaymentFailed(
return { handled: false };
}
+ const providerSubId = invoice.subscription as string;
+
+ // Read the current row so we can increment the failure count safely. The
+ // supabase-js client has no SQL-expression template tag, so the previous
+ // `supabase.sql\`failed_payment_count + 1\`` never incremented — it must be a
+ // plain read-then-write. Webhook events for one subscription are delivered
+ // serially, so this is not racy in practice.
+ const { data: existing } = await supabase
+ .from('subscriptions')
+ .select('id, failed_payment_count')
+ .eq('provider_subscription_id', providerSubId)
+ .single();
+
+ if (!existing) {
+ return { handled: false };
+ }
+
+ // Start the grace clock now (the canonical YYYY-MM-DD TEXT date format used
+ // elsewhere in this file). GRACE_PERIOD_DAYS mirrors
+ // subscriptionConfig.gracePeriodDays in src/config/payment.ts (Deno can't
+ // import that browser module, so the value is duplicated here).
+ const gracePeriodExpires = new Date(
+ Date.now() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000
+ )
+ .toISOString()
+ .split('T')[0];
+
const { data: sub, error } = await supabase
.from('subscriptions')
.update({
- status: 'past_due',
- failed_payment_count: supabase.sql`failed_payment_count + 1`,
+ status: 'grace_period',
+ failed_payment_count: (existing.failed_payment_count ?? 0) + 1,
+ grace_period_expires: gracePeriodExpires,
})
- .eq('provider_subscription_id', invoice.subscription as string)
+ .eq('provider_subscription_id', providerSubId)
.select()
.single();
diff --git a/supabase/migrations/20251006_complete_monolithic_setup.sql b/supabase/migrations/20251006_complete_monolithic_setup.sql
index 31936ed2..6988e2b9 100644
--- a/supabase/migrations/20251006_complete_monolithic_setup.sql
+++ b/supabase/migrations/20251006_complete_monolithic_setup.sql
@@ -128,6 +128,14 @@ CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_email ON subscriptions(cus
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
CREATE INDEX IF NOT EXISTS idx_subscriptions_next_billing_date ON subscriptions(next_billing_date) WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_provider_id ON subscriptions(provider, provider_subscription_id);
+-- Duplicate-subscription guard (#5): a user may hold at most ONE live
+-- subscription at a time. Server-side root-cause enforcement — the webhook
+-- upsert that would create a second live row hits this and is rejected (23505),
+-- which the handlers catch and acknowledge gracefully. No trigger / SECURITY
+-- DEFINER needed.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_subscriptions_one_live_per_user
+ ON subscriptions(template_user_id)
+ WHERE status IN ('active', 'grace_period', 'past_due');
COMMENT ON TABLE subscriptions IS 'Recurring payment subscriptions';
diff --git a/tests/e2e/payment/02-paypal-subscription.spec.ts b/tests/e2e/payment/02-paypal-subscription.spec.ts
index c7dd7841..0f4b2985 100644
--- a/tests/e2e/payment/02-paypal-subscription.spec.ts
+++ b/tests/e2e/payment/02-paypal-subscription.spec.ts
@@ -78,30 +78,56 @@ test.describe('PayPal Subscription Creation Flow', () => {
).toBeVisible();
});
- test.skip('should display subscription details correctly', async ({
+ test('subscription management route renders for an authed user (#5)', async ({
page,
}) => {
- // Skip: /payment/subscriptions route doesn't exist
- test.skip(true, 'Subscription management page not yet implemented');
+ // The /account/subscriptions route now exists (#5). With no seeded
+ // subscription the test user sees the empty-state; this asserts the route
+ // is wired (ProtectedRoute → SubscriptionManager) and reachable.
+ await page.goto('/account/subscriptions', { waitUntil: 'networkidle' });
+ if (page.url().includes('/sign-in')) {
+ await page.waitForTimeout(3000);
+ await page.goto('/account/subscriptions', { waitUntil: 'networkidle' });
+ }
+ await dismissCookieBanner(page);
+
+ await expect(
+ page.getByRole('heading', { name: 'Subscriptions', level: 1 })
+ ).toBeVisible({ timeout: 30000 });
+ // Either the empty-state card or at least one subscription card renders.
+ await expect(
+ page.getByText(/No active subscriptions|subscription\(s\)/i).first()
+ ).toBeVisible();
});
+ // The flows below assert behavior against a SEEDED subscription row (cancel,
+ // grace-period countdown, duplicate-prevention). They need the per-test
+ // subscription-seeding fixture (service-role insert + cleanup) which isn't
+ // wired yet — the route + grace UI + 23505 guard themselves are covered by
+ // SubscriptionManager.test.tsx and the migration. Un-skip once seeding lands.
test.skip('should allow subscription cancellation', async ({ page }) => {
- // Skip: /payment/subscriptions route doesn't exist
- test.skip(true, 'Subscription management page not yet implemented');
+ test.skip(
+ true,
+ 'Needs a seeded subscription row (per-test fixture) to drive cancel'
+ );
});
test.skip('should handle failed payment retry logic', async ({ page }) => {
- // Skip: /payment/subscriptions route doesn't exist
- test.skip(true, 'Subscription management page not yet implemented');
+ test.skip(true, 'Needs a seeded past_due/grace row + PayPal sandbox keys');
});
test.skip('should show grace period warning', async ({ page }) => {
- // Skip: Feature not yet implemented
- test.skip(true, 'Grace period feature not yet implemented');
+ // Covered as a component test in SubscriptionManager.test.tsx; an E2E here
+ // needs a seeded grace_period row.
+ test.skip(true, 'Needs a seeded grace_period subscription row');
});
test.skip('should prevent duplicate subscriptions', async ({ page }) => {
- // Skip: Feature not yet implemented
- test.skip(true, 'Duplicate subscription prevention not yet implemented');
+ // The DB-level guard (idx_subscriptions_one_live_per_user) + webhook 23505
+ // catch enforce this server-side; a browser E2E needs a seeded live row.
+ test.skip(
+ true,
+ 'Needs a seeded live subscription row to trigger the guard'
+ );
});
});