Skip to content

Commit 3997dc5

Browse files
TortoiseWolfeTurtleWolfeclaude
authored
feat(payment): #5 subscription management surface end-to-end (#140)
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: TurtleWolfe <TurtleWolfe@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6eabeb8 commit 3997dc5

9 files changed

Lines changed: 485 additions & 165 deletions

File tree

src/app/account/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ export default function AccountPage() {
4646
>
4747
View payment dashboard
4848
</Link>
49+
<Link
50+
href="/account/subscriptions"
51+
className="btn btn-outline min-h-11 w-full"
52+
>
53+
Manage subscriptions
54+
</Link>
4955
</div>
5056
</div>
5157
</main>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useAuth } from '@/contexts/AuthContext';
5+
import { SubscriptionManager } from '@/components/payment/SubscriptionManager';
6+
import { featureFlags } from '@/config/payment';
7+
8+
/**
9+
* Client body of /account/subscriptions (#5). Shows the not-configured banner
10+
* when neither provider is set up (mirrors /payment-demo), then the
11+
* SubscriptionManager for the signed-in user.
12+
*/
13+
export function SubscriptionsContent() {
14+
const { user } = useAuth();
15+
const noProvidersConfigured =
16+
!featureFlags.stripeEnabled && !featureFlags.paypalEnabled;
17+
18+
return (
19+
<div className="flex flex-col gap-6">
20+
{noProvidersConfigured && (
21+
<div role="alert" className="alert alert-warning">
22+
<svg
23+
xmlns="http://www.w3.org/2000/svg"
24+
className="h-6 w-6 shrink-0 stroke-current"
25+
fill="none"
26+
viewBox="0 0 24 24"
27+
aria-hidden="true"
28+
>
29+
<path
30+
strokeLinecap="round"
31+
strokeLinejoin="round"
32+
strokeWidth="2"
33+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
34+
/>
35+
</svg>
36+
<div>
37+
<p className="font-semibold">Payment providers not configured</p>
38+
<p className="text-sm">
39+
New subscriptions can&apos;t be created until Stripe or PayPal is
40+
set up. Set <code>NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY</code> and/or{' '}
41+
<code>NEXT_PUBLIC_PAYPAL_CLIENT_ID</code> in <code>.env</code>.
42+
See <code>docs/PAYMENT-DEPLOYMENT.md</code>.
43+
</p>
44+
</div>
45+
</div>
46+
)}
47+
48+
{user && <SubscriptionManager userId={user.id} />}
49+
</div>
50+
);
51+
}
52+
53+
export default SubscriptionsContent;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
import type { Metadata } from 'next';
3+
import Link from 'next/link';
4+
import ProtectedRoute from '@/components/auth/ProtectedRoute';
5+
import { SubscriptionsContent } from './SubscriptionsContent';
6+
7+
export const metadata: Metadata = {
8+
title: 'Subscriptions - ScriptHammer',
9+
description: 'Manage your active subscriptions',
10+
robots: {
11+
index: false,
12+
follow: false,
13+
googleBot: {
14+
index: false,
15+
follow: false,
16+
},
17+
},
18+
};
19+
20+
/**
21+
* /account/subscriptions — user-facing subscription management (#5). Behind
22+
* ProtectedRoute; wraps the SubscriptionManager organism (cancel/resume +
23+
* grace-period countdown). Mirrors the /account/audit route (server page +
24+
* client content) and the /payment-demo not-configured banner.
25+
*/
26+
export default function AccountSubscriptionsPage() {
27+
return (
28+
<ProtectedRoute>
29+
<main className="container mx-auto px-4 py-12 sm:px-6 md:py-16 lg:px-8">
30+
<div className="mx-auto max-w-3xl">
31+
<div className="mb-6 flex items-center justify-between">
32+
<h1 className="text-3xl font-bold">Subscriptions</h1>
33+
<Link href="/account" className="btn btn-ghost min-h-11">
34+
Back to Account
35+
</Link>
36+
</div>
37+
38+
<SubscriptionsContent />
39+
</div>
40+
</main>
41+
</ProtectedRoute>
42+
);
43+
}

src/components/payment/SubscriptionManager/SubscriptionManager.test.tsx

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,38 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
66
import { render, screen, waitFor } from '@testing-library/react';
77
import { SubscriptionManager } from './SubscriptionManager';
88

9-
// Mock Supabase client
9+
// Mutable rows so individual tests can stage grace-period / canceled states.
10+
let mockRows: Record<string, unknown>[] = [];
11+
12+
const activeRow = {
13+
id: 'sub-1',
14+
provider_subscription_id: 'stripe_sub_123',
15+
provider: 'stripe',
16+
status: 'active',
17+
plan_amount: 999,
18+
plan_interval: 'month',
19+
current_period_start: '2025-01-01T00:00:00Z',
20+
current_period_end: '2025-02-01T00:00:00Z',
21+
grace_period_expires: null,
22+
canceled_at: null,
23+
created_at: '2025-01-01T00:00:00Z',
24+
updated_at: '2025-01-01T00:00:00Z',
25+
};
26+
27+
// Mock Supabase client — returns the staged mockRows.
1028
vi.mock('@/lib/supabase/client', () => ({
1129
supabase: {
1230
from: vi.fn(() => ({
1331
select: vi.fn(() => ({
1432
eq: vi.fn(() => ({
1533
order: vi.fn(() => ({
16-
data: [
17-
{
18-
id: 'sub-1',
19-
provider_subscription_id: 'stripe_sub_123',
20-
provider: 'stripe',
21-
status: 'active',
22-
amount: 999,
23-
currency: 'usd',
24-
interval: 'month',
25-
current_period_start: '2025-01-01T00:00:00Z',
26-
current_period_end: '2025-02-01T00:00:00Z',
27-
cancel_at_period_end: false,
28-
created_at: '2025-01-01T00:00:00Z',
29-
updated_at: '2025-01-01T00:00:00Z',
30-
},
31-
],
34+
data: mockRows,
3235
error: null,
3336
})),
3437
})),
3538
})),
3639
update: vi.fn(() => ({
37-
eq: vi.fn(() => ({
38-
data: null,
39-
error: null,
40-
})),
40+
eq: vi.fn(() => ({ data: null, error: null })),
4141
})),
4242
})),
4343
},
@@ -54,6 +54,7 @@ describe('SubscriptionManager', () => {
5454

5555
beforeEach(() => {
5656
vi.clearAllMocks();
57+
mockRows = [{ ...activeRow }];
5758
});
5859

5960
it('should render loading state initially', () => {
@@ -83,4 +84,53 @@ describe('SubscriptionManager', () => {
8384
);
8485
expect(container.firstChild).toHaveClass('custom-class');
8586
});
87+
88+
it('shows a grace-period countdown with days remaining', async () => {
89+
const inFiveDays = new Date(Date.now() + 5 * 86_400_000)
90+
.toISOString()
91+
.split('T')[0];
92+
mockRows = [
93+
{
94+
...activeRow,
95+
status: 'grace_period',
96+
grace_period_expires: inFiveDays,
97+
},
98+
];
99+
render(<SubscriptionManager {...defaultProps} />);
100+
await waitFor(() => {
101+
expect(screen.getByText(/5 days remaining/i)).toBeInTheDocument();
102+
});
103+
// The grace badge renders too (status === 'grace_period').
104+
expect(screen.getAllByText(/Grace Period/i).length).toBeGreaterThan(0);
105+
});
106+
107+
it('clamps an expired grace period to 0 days', async () => {
108+
const yesterday = new Date(Date.now() - 86_400_000)
109+
.toISOString()
110+
.split('T')[0];
111+
mockRows = [
112+
{
113+
...activeRow,
114+
status: 'grace_period',
115+
grace_period_expires: yesterday,
116+
},
117+
];
118+
render(<SubscriptionManager {...defaultProps} />);
119+
await waitFor(() => {
120+
expect(screen.getByText(/0 days remaining/i)).toBeInTheDocument();
121+
});
122+
});
123+
124+
it('offers Resume (not Cancel) for a canceled subscription', async () => {
125+
mockRows = [{ ...activeRow, status: 'canceled' }];
126+
render(<SubscriptionManager {...defaultProps} />);
127+
await waitFor(() => {
128+
expect(
129+
screen.getByRole('button', { name: /resume subscription/i })
130+
).toBeInTheDocument();
131+
});
132+
expect(
133+
screen.queryByRole('button', { name: /cancel subscription/i })
134+
).not.toBeInTheDocument();
135+
});
86136
});

0 commit comments

Comments
 (0)