From 2adfccf58198922527075762a35e8b415582f82b Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Sat, 6 Jun 2026 02:46:10 +0000 Subject: [PATCH] feat(payment): #5 subscription management surface end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing subscription management plus the schema-drift fix the existing component was carrying. - SubscriptionManager: reconcile the interface to the REAL subscriptions schema (plan_amount/plan_interval/grace_period/grace_period_expires; status enum active|past_due|grace_period|canceled|expired). The old interface read phantom columns (amount/currency/interval/cancel_at_period_end/'paused') so cards rendered $undefined and cancel/resume flipped a non-existent field. Cancel/ resume now key off `status` (matching what the edge functions write). Adds a grace-period countdown ("N days remaining") + grace badge. - New /account/subscriptions route (ProtectedRoute → SubscriptionManager) with the not-configured banner; mirrors /account/audit. Linked from /account. - Grace-period population in BOTH webhooks on payment-failed: status → grace_period + grace_period_expires = now + 7d (YYYY-MM-DD). Adds the missing PayPal BILLING.SUBSCRIPTION.PAYMENT.FAILED branch. Fixes a real bug: the Stripe handler used `supabase.sql\`failed_payment_count + 1\`` (no such client API) so the counter never incremented — now a read-then-write. - Duplicate-subscription guard (root cause): partial unique index idx_subscriptions_one_live_per_user on (template_user_id) WHERE status in active/grace_period/past_due. Both webhook upserts catch 23505 and acknowledge (no 500 → no provider retry storm). No trigger / SECURITY DEFINER. - Tests: grace countdown + expired-clamp + resume-for-canceled (component); un-skip the subscription-route-renders E2E; the seed-dependent flows get honest skip reasons (route + guard exist; they need a seed fixture). Migration index + webhook redeploys applied to prod at merge time. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/account/page.tsx | 6 + .../subscriptions/SubscriptionsContent.tsx | 53 ++++ src/app/account/subscriptions/page.tsx | 43 +++ .../SubscriptionManager.test.tsx | 92 ++++-- .../SubscriptionManager.tsx | 279 ++++++++++-------- supabase/functions/paypal-webhook/index.ts | 72 ++++- supabase/functions/stripe-webhook/index.ts | 49 ++- .../20251006_complete_monolithic_setup.sql | 8 + .../payment/02-paypal-subscription.spec.ts | 48 ++- 9 files changed, 485 insertions(+), 165 deletions(-) create mode 100644 src/app/account/subscriptions/SubscriptionsContent.tsx create mode 100644 src/app/account/subscriptions/page.tsx diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index a6c78bb2..c5b00033 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -46,6 +46,12 @@ export default function AccountPage() { > View payment dashboard + + Manage subscriptions + diff --git a/src/app/account/subscriptions/SubscriptionsContent.tsx b/src/app/account/subscriptions/SubscriptionsContent.tsx new file mode 100644 index 00000000..33fc8eb2 --- /dev/null +++ b/src/app/account/subscriptions/SubscriptionsContent.tsx @@ -0,0 +1,53 @@ +'use client'; + +import React from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { SubscriptionManager } from '@/components/payment/SubscriptionManager'; +import { featureFlags } from '@/config/payment'; + +/** + * Client body of /account/subscriptions (#5). Shows the not-configured banner + * when neither provider is set up (mirrors /payment-demo), then the + * SubscriptionManager for the signed-in user. + */ +export function SubscriptionsContent() { + const { user } = useAuth(); + const noProvidersConfigured = + !featureFlags.stripeEnabled && !featureFlags.paypalEnabled; + + return ( +
+ {noProvidersConfigured && ( +
+ +
+

Payment providers not configured

+

+ New subscriptions can't be created until Stripe or PayPal is + set up. Set NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and/or{' '} + NEXT_PUBLIC_PAYPAL_CLIENT_ID in .env. + See docs/PAYMENT-DEPLOYMENT.md. +

+
+
+ )} + + {user && } +
+ ); +} + +export default SubscriptionsContent; diff --git a/src/app/account/subscriptions/page.tsx b/src/app/account/subscriptions/page.tsx new file mode 100644 index 00000000..1ea781cd --- /dev/null +++ b/src/app/account/subscriptions/page.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import type { Metadata } from 'next'; +import Link from 'next/link'; +import ProtectedRoute from '@/components/auth/ProtectedRoute'; +import { SubscriptionsContent } from './SubscriptionsContent'; + +export const metadata: Metadata = { + title: 'Subscriptions - ScriptHammer', + description: 'Manage your active subscriptions', + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + }, + }, +}; + +/** + * /account/subscriptions — user-facing subscription management (#5). Behind + * ProtectedRoute; wraps the SubscriptionManager organism (cancel/resume + + * grace-period countdown). Mirrors the /account/audit route (server page + + * client content) and the /payment-demo not-configured banner. + */ +export default function AccountSubscriptionsPage() { + return ( + +
+
+
+

Subscriptions

+ + Back to Account + +
+ + +
+
+
+ ); +} diff --git a/src/components/payment/SubscriptionManager/SubscriptionManager.test.tsx b/src/components/payment/SubscriptionManager/SubscriptionManager.test.tsx index 989cca17..9f17cf10 100644 --- a/src/components/payment/SubscriptionManager/SubscriptionManager.test.tsx +++ b/src/components/payment/SubscriptionManager/SubscriptionManager.test.tsx @@ -6,38 +6,38 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { SubscriptionManager } from './SubscriptionManager'; -// Mock Supabase client +// Mutable rows so individual tests can stage grace-period / canceled states. +let mockRows: Record[] = []; + +const activeRow = { + id: 'sub-1', + provider_subscription_id: 'stripe_sub_123', + provider: 'stripe', + status: 'active', + plan_amount: 999, + plan_interval: 'month', + current_period_start: '2025-01-01T00:00:00Z', + current_period_end: '2025-02-01T00:00:00Z', + grace_period_expires: null, + canceled_at: null, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', +}; + +// Mock Supabase client — returns the staged mockRows. vi.mock('@/lib/supabase/client', () => ({ supabase: { from: vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => ({ order: vi.fn(() => ({ - data: [ - { - id: 'sub-1', - provider_subscription_id: 'stripe_sub_123', - provider: 'stripe', - status: 'active', - amount: 999, - currency: 'usd', - interval: 'month', - current_period_start: '2025-01-01T00:00:00Z', - current_period_end: '2025-02-01T00:00:00Z', - cancel_at_period_end: false, - created_at: '2025-01-01T00:00:00Z', - updated_at: '2025-01-01T00:00:00Z', - }, - ], + data: mockRows, error: null, })), })), })), update: vi.fn(() => ({ - eq: vi.fn(() => ({ - data: null, - error: null, - })), + eq: vi.fn(() => ({ data: null, error: null })), })), })), }, @@ -54,6 +54,7 @@ describe('SubscriptionManager', () => { beforeEach(() => { vi.clearAllMocks(); + mockRows = [{ ...activeRow }]; }); it('should render loading state initially', () => { @@ -83,4 +84,53 @@ describe('SubscriptionManager', () => { ); expect(container.firstChild).toHaveClass('custom-class'); }); + + it('shows a grace-period countdown with days remaining', async () => { + const inFiveDays = new Date(Date.now() + 5 * 86_400_000) + .toISOString() + .split('T')[0]; + mockRows = [ + { + ...activeRow, + status: 'grace_period', + grace_period_expires: inFiveDays, + }, + ]; + render(); + await waitFor(() => { + expect(screen.getByText(/5 days remaining/i)).toBeInTheDocument(); + }); + // The grace badge renders too (status === 'grace_period'). + expect(screen.getAllByText(/Grace Period/i).length).toBeGreaterThan(0); + }); + + it('clamps an expired grace period to 0 days', async () => { + const yesterday = new Date(Date.now() - 86_400_000) + .toISOString() + .split('T')[0]; + mockRows = [ + { + ...activeRow, + status: 'grace_period', + grace_period_expires: yesterday, + }, + ]; + render(); + await waitFor(() => { + expect(screen.getByText(/0 days remaining/i)).toBeInTheDocument(); + }); + }); + + it('offers Resume (not Cancel) for a canceled subscription', async () => { + mockRows = [{ ...activeRow, status: 'canceled' }]; + render(); + await waitFor(() => { + expect( + screen.getByRole('button', { name: /resume subscription/i }) + ).toBeInTheDocument(); + }); + expect( + screen.queryByRole('button', { name: /cancel subscription/i }) + ).not.toBeInTheDocument(); + }); }); diff --git a/src/components/payment/SubscriptionManager/SubscriptionManager.tsx b/src/components/payment/SubscriptionManager/SubscriptionManager.tsx index 0cc5ead4..280e1b96 100644 --- a/src/components/payment/SubscriptionManager/SubscriptionManager.tsx +++ b/src/components/payment/SubscriptionManager/SubscriptionManager.tsx @@ -8,19 +8,24 @@ import React, { useState, useEffect } from 'react'; import { supabase } from '@/lib/supabase/client'; import { formatPaymentAmount } from '@/lib/payments/payment-service'; -import type { Currency, PaymentInterval } from '@/types/payment'; +/** + * Mirrors the `subscriptions` table in the monolithic migration. The table has + * no `currency` column (amounts are formatted as USD) and tracks cancellation + * via `status`/`canceled_at` — there is no `cancel_at_period_end` column on our + * row (the cancel/resume edge functions set status='canceled'/'active'). + */ export interface Subscription { id: string; provider_subscription_id: string; provider: 'stripe' | 'paypal'; - status: 'active' | 'canceled' | 'past_due' | 'paused'; - amount: number; - currency: Currency; - interval: PaymentInterval; - current_period_start: string; - current_period_end: string; - cancel_at_period_end: boolean; + status: 'active' | 'past_due' | 'grace_period' | 'canceled' | 'expired'; + plan_amount: number; + plan_interval: 'month' | 'year'; + current_period_start: string | null; + current_period_end: string | null; + grace_period_expires: string | null; + canceled_at: string | null; created_at: string; updated_at: string; } @@ -108,11 +113,15 @@ export const SubscriptionManager: React.FC = ({ throw new Error(error.error || 'Failed to cancel subscription'); } - // Update local state + // Update local state — the edge function marks our row canceled. setSubscriptions((prev) => prev.map((sub) => sub.id === subscriptionId - ? { ...sub, cancel_at_period_end: true } + ? { + ...sub, + status: 'canceled', + canceled_at: new Date().toISOString(), + } : sub ) ); @@ -154,11 +163,11 @@ export const SubscriptionManager: React.FC = ({ throw new Error(error.error || 'Failed to resume subscription'); } - // Update local state + // Update local state — the edge function reactivates our row. setSubscriptions((prev) => prev.map((sub) => sub.id === subscriptionId - ? { ...sub, cancel_at_period_end: false } + ? { ...sub, status: 'active', canceled_at: null } : sub ) ); @@ -173,15 +182,12 @@ export const SubscriptionManager: React.FC = ({ // Get status badge const getStatusBadge = (subscription: Subscription) => { - if (subscription.cancel_at_period_end) { - return Canceling; - } - - const badges = { + const badges: Record = { active: Active, canceled: Canceled, past_due: Past Due, - paused: Paused, + grace_period: Grace Period, + expired: Expired, }; return ( @@ -192,16 +198,22 @@ export const SubscriptionManager: React.FC = ({ }; // Format interval - const formatInterval = (interval: PaymentInterval) => { - const labels = { + const formatInterval = (interval: Subscription['plan_interval']) => { + const labels: Record = { month: 'Monthly', year: 'Yearly', - week: 'Weekly', - day: 'Daily', }; return labels[interval] || interval; }; + // Days remaining in the grace period (0 when expired/invalid). + const graceDaysLeft = (expires: string | null): number | null => { + if (!expires) return null; + const ms = new Date(expires).getTime(); + if (Number.isNaN(ms)) return null; + return Math.max(0, Math.ceil((ms - Date.now()) / 86_400_000)); + }; + if (loading) { return (
= ({

- {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 ? ( - - ) : ( - - )} + +
+ )} + + {(subscription.status === 'active' || + subscription.status === 'past_due' || + subscription.status === 'grace_period') && ( +
+
)}
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' + ); }); });