Skip to content

Commit cfccbfa

Browse files
barckcodeclaude
andcommitted
Fix Discord OAuth redirect URI, add Stripe subscription sync, and portal hover effects
- Fix Discord OAuth redirect URI mismatch (/auth → /api/auth/discord/callback) - Add /auth/discord/callback to middleware protected paths - Add syncSubscriptionFromStripe to handle missed webhooks and race conditions - Verify and subscribe pages now sync with Stripe API before redirecting - Add hover effects and transitions to portal dashboard cards - Add 5 new tests for syncSubscriptionFromStripe (72 total) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a84423c commit cfccbfa

8 files changed

Lines changed: 195 additions & 7 deletions

File tree

src/lib/stripe.test.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
33
const mockCreate = vi.fn().mockResolvedValue({ id: 'cs_test_123', url: 'https://checkout.stripe.com/...' });
44
const mockConstructEvent = vi.fn();
55
const mockCancel = vi.fn().mockResolvedValue({ id: 'sub_123', status: 'canceled' });
6+
const mockCustomersList = vi.fn().mockResolvedValue({ data: [] });
7+
const mockSubscriptionsList = vi.fn().mockResolvedValue({ data: [] });
68

79
vi.mock('stripe', () => ({
810
default: class {
911
checkout = { sessions: { create: mockCreate } };
1012
webhooks = { constructEvent: mockConstructEvent };
11-
subscriptions = { cancel: mockCancel };
13+
subscriptions = { cancel: mockCancel, list: mockSubscriptionsList };
14+
customers = { list: mockCustomersList };
1215
},
1316
}));
1417

@@ -34,9 +37,16 @@ function createMockDb() {
3437
limit: vi.fn().mockResolvedValue([]),
3538
};
3639

40+
const mockUpdateChain = {
41+
set: vi.fn().mockReturnThis(),
42+
where: vi.fn().mockResolvedValue(undefined),
43+
};
44+
3745
return {
3846
select: vi.fn().mockReturnValue(mockChain),
47+
update: vi.fn().mockReturnValue(mockUpdateChain),
3948
_chain: mockChain,
49+
_updateChain: mockUpdateChain,
4050
} as any;
4151
}
4252

@@ -167,4 +177,117 @@ describe('stripe.ts', () => {
167177
expect(mockCancel).toHaveBeenCalledWith('sub_123');
168178
});
169179
});
180+
181+
describe('syncSubscriptionFromStripe', () => {
182+
it('should return true immediately when member is already active with subscription', async () => {
183+
const { getStripeClient, syncSubscriptionFromStripe } = await import('./stripe');
184+
const stripe = getStripeClient('sk_test_xxx');
185+
const db = createMockDb();
186+
187+
const member = {
188+
id: 'member-1',
189+
email: 'test@example.com',
190+
status: 'active',
191+
stripeCustomerId: 'cus_123',
192+
stripeSubscriptionId: 'sub_123',
193+
};
194+
195+
const result = await syncSubscriptionFromStripe(member, stripe, db);
196+
expect(result).toBe(true);
197+
expect(mockCustomersList).not.toHaveBeenCalled();
198+
expect(mockSubscriptionsList).not.toHaveBeenCalled();
199+
});
200+
201+
it('should return false when no Stripe customer found', async () => {
202+
const { getStripeClient, syncSubscriptionFromStripe } = await import('./stripe');
203+
const stripe = getStripeClient('sk_test_xxx');
204+
const db = createMockDb();
205+
mockCustomersList.mockResolvedValueOnce({ data: [] });
206+
207+
const member = {
208+
id: 'member-1',
209+
email: 'test@example.com',
210+
status: 'pending',
211+
stripeCustomerId: null,
212+
stripeSubscriptionId: null,
213+
};
214+
215+
const result = await syncSubscriptionFromStripe(member, stripe, db);
216+
expect(result).toBe(false);
217+
expect(mockCustomersList).toHaveBeenCalledWith({ email: 'test@example.com', limit: 1 });
218+
});
219+
220+
it('should sync and return true when Stripe has active subscription', async () => {
221+
const { getStripeClient, syncSubscriptionFromStripe } = await import('./stripe');
222+
const stripe = getStripeClient('sk_test_xxx');
223+
const db = createMockDb();
224+
// countActivePaidMembers query
225+
db._chain.where.mockReturnValueOnce([{ total: 5 }]);
226+
mockCustomersList.mockResolvedValueOnce({ data: [{ id: 'cus_456' }] });
227+
mockSubscriptionsList.mockResolvedValueOnce({ data: [{ id: 'sub_789' }] });
228+
229+
const member = {
230+
id: 'member-1',
231+
email: 'test@example.com',
232+
status: 'pending',
233+
stripeCustomerId: null,
234+
stripeSubscriptionId: null,
235+
};
236+
237+
const result = await syncSubscriptionFromStripe(member, stripe, db);
238+
expect(result).toBe(true);
239+
expect(db.update).toHaveBeenCalled();
240+
expect(db._updateChain.set).toHaveBeenCalledWith({
241+
stripeCustomerId: 'cus_456',
242+
stripeSubscriptionId: 'sub_789',
243+
tier: 'early',
244+
status: 'active',
245+
});
246+
});
247+
248+
it('should use existing stripeCustomerId without looking up by email', async () => {
249+
const { getStripeClient, syncSubscriptionFromStripe } = await import('./stripe');
250+
const stripe = getStripeClient('sk_test_xxx');
251+
const db = createMockDb();
252+
db._chain.where.mockReturnValueOnce([{ total: 5 }]);
253+
mockSubscriptionsList.mockResolvedValueOnce({ data: [{ id: 'sub_789' }] });
254+
255+
const member = {
256+
id: 'member-1',
257+
email: 'test@example.com',
258+
status: 'pending',
259+
stripeCustomerId: 'cus_existing',
260+
stripeSubscriptionId: null,
261+
};
262+
263+
const result = await syncSubscriptionFromStripe(member, stripe, db);
264+
expect(result).toBe(true);
265+
expect(mockCustomersList).not.toHaveBeenCalled();
266+
expect(mockSubscriptionsList).toHaveBeenCalledWith({
267+
customer: 'cus_existing',
268+
status: 'active',
269+
limit: 1,
270+
});
271+
});
272+
273+
it('should return false when customer exists but no active subscription', async () => {
274+
const { getStripeClient, syncSubscriptionFromStripe } = await import('./stripe');
275+
const stripe = getStripeClient('sk_test_xxx');
276+
const db = createMockDb();
277+
mockCustomersList.mockResolvedValueOnce({ data: [{ id: 'cus_456' }] });
278+
mockSubscriptionsList.mockResolvedValueOnce({ data: [] });
279+
280+
const member = {
281+
id: 'member-1',
282+
email: 'test@example.com',
283+
status: 'pending',
284+
stripeCustomerId: null,
285+
stripeSubscriptionId: null,
286+
};
287+
288+
const result = await syncSubscriptionFromStripe(member, stripe, db);
289+
expect(result).toBe(false);
290+
expect(db.update).not.toHaveBeenCalled();
291+
});
292+
});
170293
});

src/lib/stripe.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,53 @@ export async function cancelSubscription(
7272
): Promise<Stripe.Subscription> {
7373
return stripe.subscriptions.cancel(subscriptionId);
7474
}
75+
76+
/**
77+
* Syncs subscription status from Stripe for members whose local DB
78+
* may be out of date (e.g. webhook delay, local dev without webhook).
79+
* Looks up the customer by email and checks for active subscriptions.
80+
* Returns true if the member was found to have an active subscription.
81+
*/
82+
export async function syncSubscriptionFromStripe(
83+
member: { id: string; email: string; status: string; stripeCustomerId: string | null; stripeSubscriptionId: string | null },
84+
stripe: Stripe,
85+
db: Database,
86+
): Promise<boolean> {
87+
// Already active with subscription — nothing to sync
88+
if (member.status === 'active' && member.stripeSubscriptionId) {
89+
return true;
90+
}
91+
92+
// Look up customer by existing ID or by email
93+
let customerId = member.stripeCustomerId;
94+
95+
if (!customerId) {
96+
const customers = await stripe.customers.list({ email: member.email, limit: 1 });
97+
if (customers.data.length === 0) return false;
98+
customerId = customers.data[0].id;
99+
}
100+
101+
const subscriptions = await stripe.subscriptions.list({
102+
customer: customerId,
103+
status: 'active',
104+
limit: 1,
105+
});
106+
107+
if (subscriptions.data.length === 0) return false;
108+
109+
const sub = subscriptions.data[0];
110+
const activePaidCount = await countActivePaidMembers(db);
111+
const tier = selectTier(activePaidCount);
112+
113+
await db
114+
.update(members)
115+
.set({
116+
stripeCustomerId: customerId,
117+
stripeSubscriptionId: sub.id,
118+
tier,
119+
status: 'active',
120+
})
121+
.where(eq(members.id, member.id));
122+
123+
return true;
124+
}

src/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { validateSession, clearSessionCookie } from './lib/auth';
66
export const onRequest = defineMiddleware(async (context, next) => {
77
const { pathname } = context.url;
88

9-
const protectedPaths = ['/portal', '/api/auth/discord/link'];
9+
const protectedPaths = ['/portal', '/api/auth/discord/link', '/auth/discord/callback'];
1010
const isProtected = protectedPaths.some((p) => pathname.startsWith(p));
1111

1212
if (!isProtected) {

src/pages/api/auth/discord/link.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function GET(context: APIContext) {
99
}
1010

1111
const baseUrl = env.PUBLIC_URL || context.url.origin;
12-
const redirectUri = `${baseUrl}/auth/discord/callback`;
12+
const redirectUri = `${baseUrl}/api/auth/discord/callback`;
1313

1414
const state = generateOAuthState();
1515

src/pages/auth/discord/callback.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ if (!code || !state || !storedState || state !== storedState) {
2424
error = 'invalid_state';
2525
} else {
2626
const baseUrl = env.PUBLIC_URL || Astro.url.origin;
27-
const redirectUri = `${baseUrl}/auth/discord/callback`;
27+
const redirectUri = `${baseUrl}/api/auth/discord/callback`;
2828
2929
try {
3030
const accessToken = await exchangeCode({

src/pages/auth/verify.astro

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { env } from 'cloudflare:workers';
33
import { getDb } from '../../lib/db';
44
import { validateMagicToken, markTokenUsed, createSession, setSessionCookie } from '../../lib/auth';
5+
import { getStripeClient, syncSubscriptionFromStripe } from '../../lib/stripe';
56
import { members, sessions } from '../../../db/schema';
67
import { eq } from 'drizzle-orm';
78
@@ -34,7 +35,14 @@ let isActive = false;
3435
3536
if (existingMember.length > 0) {
3637
memberId = existingMember[0].id;
37-
isActive = existingMember[0].status === 'active';
38+
39+
if (existingMember[0].status === 'active') {
40+
isActive = true;
41+
} else {
42+
// Sync with Stripe in case webhook was missed
43+
const stripe = getStripeClient(env.STRIPE_SECRET_KEY);
44+
isActive = await syncSubscriptionFromStripe(existingMember[0], stripe, db);
45+
}
3846
} else {
3947
const { nanoid } = await import('nanoid');
4048
memberId = nanoid(21);

src/pages/portal/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const joinedDate = member?.joinedAt
5555
</h1>
5656

5757
<div class="grid gap-6 sm:grid-cols-2">
58-
<div class="bg-neutral-900/50 border border-neutral-800 rounded-xl p-6">
58+
<div class="bg-neutral-900/50 border border-neutral-800 rounded-xl p-6 hover:border-neutral-700 hover:shadow-[0_0_30px_rgba(0,0,0,0.4)] transition-all duration-300">
5959
<h2 class="text-xs text-violet-400 uppercase tracking-wide mb-4 font-mono">Account</h2>
6060
<dl class="space-y-4">
6161
<div>

src/pages/subscribe.astro

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { env } from 'cloudflare:workers';
33
import Base from '../layouts/Base.astro';
44
import { getDb } from '../lib/db';
55
import { validateSession } from '../lib/auth';
6-
import { countActivePaidMembers, EARLY_MEMBER_LIMIT } from '../lib/stripe';
6+
import { countActivePaidMembers, EARLY_MEMBER_LIMIT, getStripeClient, syncSubscriptionFromStripe } from '../lib/stripe';
77
88
// Auth check — must be logged in
99
const sessionId = Astro.cookies.get('session')?.value;
@@ -29,6 +29,13 @@ if (member.status === 'active' && member.stripeSubscriptionId) {
2929
return Astro.redirect('/portal');
3030
}
3131
32+
// Sync with Stripe in case webhook was missed (local dev, race condition)
33+
const stripe = getStripeClient(env.STRIPE_SECRET_KEY);
34+
const isSynced = await syncSubscriptionFromStripe(member, stripe, db);
35+
if (isSynced) {
36+
return Astro.redirect('/portal');
37+
}
38+
3239
// Pricing
3340
const activePaidCount = await countActivePaidMembers(db);
3441
const earlySpots = Math.max(0, EARLY_MEMBER_LIMIT - activePaidCount);

0 commit comments

Comments
 (0)