Skip to content

Commit 107cd91

Browse files
committed
Refactor Stripe webhook to use dedicated email and purchase storage functions
1 parent 95fe042 commit 107cd91

3 files changed

Lines changed: 28 additions & 116 deletions

File tree

astro/src/pages/api/stripe-webhook.ts

Lines changed: 8 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { APIRoute } from 'astro';
22
import Stripe from 'stripe';
3-
import fs from 'fs/promises';
4-
import path from 'path';
3+
import { storePurchaseData } from '@lib/purchase-storage';
4+
import { sendConfirmationEmail } from '@lib/email';
55

66
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
77
const endpointSecret = import.meta.env.STRIPE_WEBHOOK_SECRET;
@@ -64,57 +64,17 @@ async function handleSuccessfulPayment(session: Stripe.Checkout.Session) {
6464

6565
// Send confirmation email with secure purchase code
6666
try {
67-
const emailResponse = await fetch('/api/send-confirmation-email', {
68-
method: 'POST',
69-
headers: {
70-
'Content-Type': 'application/json',
71-
},
72-
body: JSON.stringify({
73-
email: metadata.email,
74-
purchaseCode: metadata.purchaseCode,
75-
organization: metadata.organization,
76-
kitType: metadata.kitType,
77-
theme: metadata.theme,
78-
}),
67+
await sendConfirmationEmail({
68+
email: metadata.email,
69+
purchaseCode: metadata.purchaseCode,
70+
organization: metadata.organization,
71+
theme: metadata.theme,
7972
});
80-
81-
if (!emailResponse.ok) {
82-
console.error('Failed to send confirmation email');
83-
} else {
84-
console.log('Confirmation email sent successfully');
85-
}
73+
console.log('Confirmation email sent successfully');
8674
} catch (error) {
8775
console.error('Error sending confirmation email:', error);
8876
}
8977
}
9078

9179
// Export for use in save-purchase-data
9280
export { handleSuccessfulPayment };
93-
94-
async function storePurchaseData(purchaseData: any) {
95-
try {
96-
// TODO: For user accounts/login system, also store purchase by email:
97-
// - Link purchase codes to customer email addresses
98-
// - Store in database: { email, purchaseCode, productId, purchaseDate }
99-
// - Enable "view all my purchases" functionality
100-
101-
// TODO?: For Google OAuth integration:
102-
// - Add optional account linking after purchase
103-
// - Store OAuth user ID with purchase data
104-
// - Allow login to see purchase history
105-
106-
// Create local storage directory if it doesn't exist
107-
const storageDir = path.join(process.cwd(), '..', 'local-dev', 'purchases');
108-
await fs.mkdir(storageDir, { recursive: true });
109-
110-
// Store purchase data as JSON file
111-
const filename = `${purchaseData.purchaseCode}.json`;
112-
const filepath = path.join(storageDir, filename);
113-
114-
await fs.writeFile(filepath, JSON.stringify(purchaseData, null, 2));
115-
// Purchase data stored successfully
116-
} catch (error) {
117-
console.error('Error storing purchase data:', error);
118-
throw error;
119-
}
120-
}

astro/src/pages/api/verify-purchase.ts

Lines changed: 2 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,7 @@
11
import type { APIRoute } from 'astro';
2-
import fs from 'fs/promises';
3-
import path from 'path';
42
import { createSession, getCookieHeader, getAllSessions } from 'src/lib/session-store';
53
import type { PurchaseSession } from 'src/lib/session-store';
6-
7-
interface PurchaseRecord {
8-
purchaseCode: string;
9-
email: string;
10-
theme?: string;
11-
kitType?: string;
12-
organization?: string;
13-
[key: string]: unknown;
14-
}
4+
import { getPurchaseData } from '@lib/purchase-storage';
155

166
const GENERIC_ERROR_MESSAGE = 'Invalid purchase code or email address';
177

@@ -66,40 +56,6 @@ async function readBody(request: Request): Promise<{ purchaseCode?: string; emai
6656
return {};
6757
}
6858

69-
function getCandidatePurchaseDirs(): string[] {
70-
const env = import.meta.env as Record<string, string | undefined>;
71-
const candidates = [
72-
env.PURCHASES_DATA_DIR,
73-
process.env.PURCHASES_DATA_DIR,
74-
path.resolve(process.cwd(), '..', 'local-dev', 'purchases'),
75-
path.resolve(process.cwd(), 'local-dev', 'purchases')
76-
];
77-
return [...new Set(candidates.filter(Boolean) as string[])];
78-
}
79-
80-
async function loadPurchase(purchaseCode: string): Promise<PurchaseRecord | null> {
81-
const candidates = getCandidatePurchaseDirs();
82-
const filename = `${purchaseCode}.json`;
83-
84-
for (const dir of candidates) {
85-
const filePath = path.join(dir, filename);
86-
try {
87-
const fileContents = await fs.readFile(filePath, 'utf-8');
88-
const parsed = JSON.parse(fileContents) as PurchaseRecord;
89-
return parsed;
90-
} catch (error: any) {
91-
if (error?.code === 'ENOENT') {
92-
continue;
93-
}
94-
95-
console.error('[verify-purchase] Failed to read purchase file', { filePath, error });
96-
throw error;
97-
}
98-
}
99-
100-
return null;
101-
}
102-
10359
async function verifyAndCreateSession(options: {
10460
purchaseCode?: string | null;
10561
email?: string | null;
@@ -121,7 +77,7 @@ async function verifyAndCreateSession(options: {
12177
}
12278

12379
try {
124-
const purchase = await loadPurchase(normalisedCode);
80+
const purchase = await getPurchaseData(normalisedCode);
12581

12682
if (!purchase) {
12783
return jsonResponse({ error: GENERIC_ERROR_MESSAGE }, 404);

astro/src/test/api/stripe-webhook.test.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2-
import fs from 'fs/promises';
2+
import { storePurchaseData } from '@lib/purchase-storage';
33

4-
// Mock fs module
5-
vi.mock('fs/promises', () => ({
6-
default: {
7-
mkdir: vi.fn(),
8-
writeFile: vi.fn(),
9-
},
4+
// Mock purchase storage
5+
vi.mock('@lib/purchase-storage', () => ({
6+
storePurchaseData: vi.fn().mockResolvedValue(undefined),
107
}));
118

129
// Mock Stripe
@@ -27,10 +24,6 @@ describe('stripe-webhook API', () => {
2724
beforeEach(() => {
2825
vi.clearAllMocks();
2926

30-
// Mock successful file operations
31-
(fs.mkdir as any).mockResolvedValue(undefined);
32-
(fs.writeFile as any).mockResolvedValue(undefined);
33-
3427
// Mock successful email sending
3528
(global.fetch as any).mockResolvedValue({
3629
ok: true,
@@ -83,9 +76,11 @@ describe('stripe-webhook API', () => {
8376
expect(responseText).toBe('Webhook handled successfully');
8477

8578
// Verify purchase data was stored
86-
expect(fs.writeFile).toHaveBeenCalledWith(
87-
expect.stringMatching(/ESC-12345678\.json$/),
88-
expect.stringContaining('"purchaseCode":"ESC-12345678"')
79+
expect(storePurchaseData).toHaveBeenCalledWith(
80+
expect.objectContaining({
81+
purchaseCode: 'ESC-12345678',
82+
sessionId: 'cs_test_session123',
83+
})
8984
);
9085
});
9186

@@ -186,13 +181,13 @@ describe('stripe-webhook API', () => {
186181
expect(responseText).toBe('Webhook handled successfully');
187182

188183
// Should not store any purchase data for unhandled events
189-
expect(fs.writeFile).not.toHaveBeenCalled();
184+
expect(storePurchaseData).not.toHaveBeenCalled();
190185
});
191186

192187
it('should handle file system errors gracefully', async () => {
193188
const { POST } = await import('../../pages/api/stripe-webhook');
194189

195-
(fs.mkdir as any).mockRejectedValue(new Error('Permission denied'));
190+
(storePurchaseData as any).mockRejectedValueOnce(new Error('Permission denied'));
196191

197192
const mockEvent = {
198193
type: 'checkout.session.completed',
@@ -277,7 +272,7 @@ describe('stripe-webhook API', () => {
277272
expect(response.status).toBe(200);
278273

279274
// Purchase data should still be stored
280-
expect(fs.writeFile).toHaveBeenCalled();
275+
expect(storePurchaseData).toHaveBeenCalled();
281276
});
282277

283278
it('should store complete purchase data with all metadata', async () => {
@@ -316,13 +311,14 @@ describe('stripe-webhook API', () => {
316311
await POST({ request: mockRequest } as any);
317312

318313
// Verify all data is included
319-
expect(fs.writeFile).toHaveBeenCalledWith(
320-
expect.stringMatching(/ESC-12345678\.json$/),
321-
expect.stringMatching(/"purchaseCode":"ESC-12345678"/)
314+
expect(storePurchaseData).toHaveBeenCalledWith(
315+
expect.objectContaining({
316+
purchaseCode: 'ESC-12345678',
317+
})
322318
);
323319

324-
const writeCall = (fs.writeFile as any).mock.calls[0];
325-
const savedData = JSON.parse(writeCall[1]);
320+
const storeCall = (storePurchaseData as any).mock.calls[0];
321+
const savedData = storeCall[0];
326322

327323
expect(savedData).toMatchObject({
328324
sessionId: 'cs_test_session123',

0 commit comments

Comments
 (0)