From d8fb238266a6a0d9892674559a60da5b5486ecb8 Mon Sep 17 00:00:00 2001 From: Abyan Jaigirdar Date: Tue, 25 Nov 2025 16:07:44 -0500 Subject: [PATCH 1/7] test --- src/api/package.json | 4 +- src/api/src/index.ts | 11 ++ src/api/src/routes/payments.ts | 164 ++++++++++++++++++ ...25_120000_create_payments_and_webhooks.sql | 32 ++++ src/database/src/overlays/index.ts | 1 + src/database/src/overlays/payments.ts | 20 +++ src/mobile/App.tsx | 14 +- src/mobile/navigation/RootNavigator.tsx | 2 + src/mobile/screens/TestPaymentScreen.tsx | 83 +++++++++ 9 files changed, 326 insertions(+), 5 deletions(-) create mode 100644 src/api/src/routes/payments.ts create mode 100644 src/database/drizzle/migrations/20251125_120000_create_payments_and_webhooks.sql create mode 100644 src/database/src/overlays/payments.ts create mode 100644 src/mobile/screens/TestPaymentScreen.tsx diff --git a/src/api/package.json b/src/api/package.json index 125d591..36a9e59 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -20,7 +20,9 @@ "@teamd/database": "workspace:*", "drizzle-orm": "^0.30.10", "fastify": "^5.6.2", - "jsonwebtoken": "^9.0.2" + "fastify-raw-body": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "stripe": "^20.0.0" }, "devDependencies": { "@types/jsonwebtoken": "^9.0.10", diff --git a/src/api/src/index.ts b/src/api/src/index.ts index b1c934b..10707e1 100644 --- a/src/api/src/index.ts +++ b/src/api/src/index.ts @@ -6,6 +6,7 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import cookie from '@fastify/cookie' +import rawBody from 'fastify-raw-body' import TeamDConfig from '../../../teamd.config.mjs' // Import routes @@ -13,6 +14,7 @@ import { authRoutes } from './routes/auth.js' import { healthRoutes } from './routes/health.js' import { userRoutes } from './routes/users.js' import { instanceRoutes } from './routes/instances.js' +import { paymentsRoutes } from './routes/payments.js' // Configuration from centralized config const PORT = parseInt(process.env.PORT || String(TeamDConfig.api.port)) @@ -48,11 +50,20 @@ await fastify.register(cookie, { parseOptions: {}, }) +// Make raw request body available on request.rawBody for webhook verification +await fastify.register(rawBody, { + field: 'rawBody', + global: true, + encoding: 'utf8', + runFirst: true, +}) + // Register routes await fastify.register(healthRoutes, { prefix: '/api' }) await fastify.register(authRoutes, { prefix: '/api' }) await fastify.register(userRoutes, { prefix: '/api' }) await fastify.register(instanceRoutes, { prefix: '/api' }) +await fastify.register(paymentsRoutes, { prefix: '/api' }) // Root endpoint fastify.get('/', async (request, reply) => { diff --git a/src/api/src/routes/payments.ts b/src/api/src/routes/payments.ts new file mode 100644 index 0000000..4271ad1 --- /dev/null +++ b/src/api/src/routes/payments.ts @@ -0,0 +1,164 @@ +import type { FastifyInstance } from 'fastify' +import Stripe from 'stripe' +import { db } from '@large-event/database' +import * as sharedSchema from '@large-event/database/schemas' +import * as overlaySchema from '@teamd/database/overlays' +import { eq } from 'drizzle-orm' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', { apiVersion: '2022-11-15' }) + +export async function paymentsRoutes(fastify: FastifyInstance) { + // POST /api/payments/create + fastify.post('/payments/create', async (request, reply) => { + const body = request.body as any + const userId = Number(body?.userId) + const eventId = Number(body?.eventId) + + if (!userId || !eventId) { + return reply.status(400).send({ success: false, error: { message: 'userId and eventId are required' } }) + } + + // Validate event and user exist + const existingEvent = await db.select().from((sharedSchema as any).events).where(eq((sharedSchema as any).events.id, eventId)).limit(1) + if (existingEvent.length === 0) { + return reply.status(404).send({ success: false, error: { message: 'Event not found' } }) + } + + const existingUser = await db.select().from((sharedSchema as any).users).where(eq((sharedSchema as any).users.id, userId)).limit(1) + if (existingUser.length === 0) { + return reply.status(404).send({ success: false, error: { message: 'User not found' } }) + } + + try { + // Create real PaymentIntent using Stripe + if (!process.env.STRIPE_SECRET_KEY) { + fastify.log.warn('STRIPE_SECRET_KEY not set, falling back to mock PaymentIntent') + } + + const pi = await stripe.paymentIntents.create({ + amount: 500, + currency: 'cad', + metadata: { userId: String(userId), eventId: String(eventId) }, + payment_method_types: ['card'], + }) + + const paymentIntentId = pi.id + const clientSecret = pi.client_secret || '' + + // Store pending payment in overlay payments table + await db.insert((overlaySchema as any).payments).values({ + userId, + eventId, + stripePaymentIntentId: paymentIntentId, + amount: 500, + currency: 'cad', + status: 'pending', + }) + + return { clientSecret, stripePaymentIntentId: paymentIntentId } + } catch (error) { + fastify.log.error('Failed to create PaymentIntent or DB row', error) + return reply.status(500).send({ success: false, error: { message: 'Failed to create payment' } }) + } + }) + + // POST /api/payments/webhook + // Stripe will POST events here. We verify signature when STRIPE_WEBHOOK_SECRET is set. + fastify.post('/payments/webhook', async (request, reply) => { + const sig = request.headers['stripe-signature'] as string | undefined + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET + let event: any + + try { + if (webhookSecret) { + const raw = (request as any).rawBody + if (!raw) throw new Error('Missing rawBody for signature verification') + event = stripe.webhooks.constructEvent(raw, sig || '', webhookSecret) + } else { + // No webhook secret configured — accept the parsed body but log a warning + fastify.log.warn('STRIPE_WEBHOOK_SECRET not set — webhook signature will not be verified') + event = request.body + } + } catch (err: any) { + fastify.log.error('Webhook signature verification failed:', err?.message || err) + return reply.status(400).send({ received: false }) + } + + // Store webhook payload for debugging + try { + await db.insert((overlaySchema as any).stripeWebhooks).values({ eventId: event.id || `evt_${Date.now()}`, payload: event, processed: false }) + } catch (err) { + fastify.log.warn('Failed to store webhook payload', err) + } + + // Handle events + try { + if (event.type === 'payment_intent.succeeded' || (event.type && event.type === 'checkout.session.completed')) { + const pi = event.data.object + const piId = pi.id + + const payments = await db.select().from((overlaySchema as any).payments).where(eq((overlaySchema as any).payments.stripePaymentIntentId, piId)).limit(1) + if (payments.length > 0) { + const payment = payments[0] + await db.update((overlaySchema as any).payments).set({ status: 'succeeded' }).where(eq((overlaySchema as any).payments.id, payment.id)) + + // create attendee + qr code if shared tables are present + try { + if ((sharedSchema as any).attendees) { + await db.insert((sharedSchema as any).attendees).values({ userId: payment.userId, eventId: payment.eventId }) + } + if ((sharedSchema as any).qrCodes) { + await db.insert((sharedSchema as any).qrCodes).values({ eventId: payment.eventId, userId: payment.userId, code: `QR-${piId}` }) + } + } catch (err) { + fastify.log.warn('Failed to create attendee/qr record', err) + } + } else { + fastify.log.warn('Payment row not found for PaymentIntent:', piId) + } + } + } catch (err) { + fastify.log.error('Error handling webhook event', err) + } + + return { received: true } + }) + + // POST /api/payments/simulate_confirm + // Keep a local helper for dev/mobile POC to simulate webhook processing + fastify.post('/payments/simulate_confirm', async (request, reply) => { + const body = request.body as any + const piId = body?.stripePaymentIntentId + if (!piId) { + return reply.status(400).send({ success: false, error: { message: 'stripePaymentIntentId required' } }) + } + + // Store webhook record + await db.insert((overlaySchema as any).stripeWebhooks).values({ eventId: `mock_evt_${Date.now()}`, payload: body, processed: true }) + + // Find payment + const payments = await db.select().from((overlaySchema as any).payments).where(eq((overlaySchema as any).payments.stripePaymentIntentId, piId)).limit(1) + if (payments.length === 0) { + return reply.status(404).send({ success: false, error: { message: 'Payment not found' } }) + } + + const payment = payments[0] + + // Update payment status + await db.update((overlaySchema as any).payments).set({ status: 'succeeded' }).where(eq((overlaySchema as any).payments.id, payment.id)) + + // Create attendee + QR code if shared tables exist + try { + if ((sharedSchema as any).attendees) { + await db.insert((sharedSchema as any).attendees).values({ userId: payment.userId, eventId: payment.eventId }) + } + if ((sharedSchema as any).qrCodes) { + await db.insert((sharedSchema as any).qrCodes).values({ eventId: payment.eventId, userId: payment.userId, code: `QR-${piId}` }) + } + } catch (err) { + fastify.log.warn('Could not create attendee/qr record (maybe tables missing in this environment)', err) + } + + return { success: true } + }) +} diff --git a/src/database/drizzle/migrations/20251125_120000_create_payments_and_webhooks.sql b/src/database/drizzle/migrations/20251125_120000_create_payments_and_webhooks.sql new file mode 100644 index 0000000..913f363 --- /dev/null +++ b/src/database/drizzle/migrations/20251125_120000_create_payments_and_webhooks.sql @@ -0,0 +1,32 @@ +-- Migration: create payments and stripe_webhooks tables and seed MacSync Test Event +-- Run with: pnpm --filter @teamd/database migrate (or drizzle-kit/migration tooling) + +BEGIN; + +-- Create payments table in team overlay +CREATE TABLE IF NOT EXISTS payments ( + id serial PRIMARY KEY, + user_id integer NOT NULL REFERENCES users(id), + event_id integer NOT NULL REFERENCES events(id), + stripe_payment_intent_id varchar(255) NOT NULL, + amount integer NOT NULL, + currency varchar(10) DEFAULT 'cad', + status varchar(50) DEFAULT 'pending', + created_at timestamp DEFAULT now() +); + +-- Create stripe_webhooks table for debugging +CREATE TABLE IF NOT EXISTS stripe_webhooks ( + id serial PRIMARY KEY, + event_id varchar(255) NOT NULL, + payload json, + processed boolean DEFAULT false, + created_at timestamp DEFAULT now() +); + +-- Seed a test event for the MacSync payments POC (id = 1 preferred) +INSERT INTO events (id, name, date, location) +VALUES (1, 'MacSync Test Event', NOW() + INTERVAL '7 days', 'Test Location') +ON CONFLICT (id) DO NOTHING; + +COMMIT; diff --git a/src/database/src/overlays/index.ts b/src/database/src/overlays/index.ts index 193db41..6e8dc52 100644 --- a/src/database/src/overlays/index.ts +++ b/src/database/src/overlays/index.ts @@ -7,6 +7,7 @@ */ // export * from './team-specific-tables'; +export * from './payments'; // Empty export to make this a module export {}; diff --git a/src/database/src/overlays/payments.ts b/src/database/src/overlays/payments.ts new file mode 100644 index 0000000..19cb5d6 --- /dev/null +++ b/src/database/src/overlays/payments.ts @@ -0,0 +1,20 @@ +import { pgTable, serial, integer, varchar, timestamp, json, boolean } from 'drizzle-orm/pg-core' + +export const payments = pgTable('payments', { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull(), + eventId: integer('event_id').notNull(), + stripePaymentIntentId: varchar('stripe_payment_intent_id', { length: 255 }).notNull(), + amount: integer('amount').notNull(), // cents + currency: varchar('currency', { length: 10 }).default('cad'), + status: varchar('status', { length: 50 }).default('pending'), + createdAt: timestamp('created_at').defaultNow(), +}) + +export const stripeWebhooks = pgTable('stripe_webhooks', { + id: serial('id').primaryKey(), + eventId: varchar('event_id', { length: 255 }).notNull(), + payload: json('payload'), + processed: boolean('processed').default(false), + createdAt: timestamp('created_at').defaultNow(), +}) diff --git a/src/mobile/App.tsx b/src/mobile/App.tsx index 7a191ae..a4346f3 100644 --- a/src/mobile/App.tsx +++ b/src/mobile/App.tsx @@ -3,6 +3,10 @@ import { StatusBar } from 'expo-status-bar'; import { NavigationContainer } from '@react-navigation/native'; import { AuthProvider } from './contexts/AuthContext'; import { RootNavigator } from './navigation/RootNavigator'; +import { StripeProvider } from '@stripe/stripe-react-native'; + +// STRIPE_PUBLISHABLE_KEY should be set in your environment (see src/mobile/ENV.md) +const STRIPE_PUBLISHABLE_KEY = process.env.STRIPE_PUBLISHABLE_KEY || ''; /** * Team D Standalone Mobile App @@ -15,10 +19,12 @@ import { RootNavigator } from './navigation/RootNavigator'; export function App() { return ( - - - - + + + + + + ); } diff --git a/src/mobile/navigation/RootNavigator.tsx b/src/mobile/navigation/RootNavigator.tsx index c1854ef..f35e258 100644 --- a/src/mobile/navigation/RootNavigator.tsx +++ b/src/mobile/navigation/RootNavigator.tsx @@ -6,6 +6,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useAuth } from '../contexts/AuthContext'; import { LoginScreen } from '../screens/LoginScreen'; import { HomeScreen } from '../screens/HomeScreen'; +import { TestPaymentScreen } from '../screens/TestPaymentScreen'; import { ProfileScreen } from '../screens/ProfileScreen'; const Stack = createNativeStackNavigator(); @@ -35,6 +36,7 @@ function TabNavigator() { component={HomeScreen} options={{ title: 'Team D' }} /> + ); diff --git a/src/mobile/screens/TestPaymentScreen.tsx b/src/mobile/screens/TestPaymentScreen.tsx new file mode 100644 index 0000000..8a67fff --- /dev/null +++ b/src/mobile/screens/TestPaymentScreen.tsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react' +import { View, Text, Button, StyleSheet, Alert } from 'react-native' +import { useAuth } from '../contexts/AuthContext' +import { useConfirmPayment } from '@stripe/stripe-react-native' + +export function TestPaymentScreen() { + const { user } = useAuth() + const [loading, setLoading] = useState(false) + const { confirmPayment } = useConfirmPayment() + + const handlePay = async () => { + setLoading(true) + const userId = user?.id || 1 + try { + // Create payment intent on server + const resp = await fetch('/api/payments/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId, eventId: 1 }), + }) + + const data = await resp.json() + if (!resp.ok) { + Alert.alert('Error', data?.error?.message || 'Failed to create payment') + setLoading(false) + return + } + + const { clientSecret, stripePaymentIntentId } = data + + // If stripe-react-native is configured, confirm the payment on device + if (confirmPayment) { + const { error, paymentIntent } = await confirmPayment(clientSecret, { + type: 'Card', + } as any) + + if (error) { + // If confirm fails locally, fall back to simulate endpoint for local testing + Alert.alert('Payment failed', error.message || 'Payment confirmation failed') + } else if (paymentIntent) { + Alert.alert('Payment Success', 'Payment was confirmed. Waiting for webhook to finalize.') + } + } else { + // Fallback: call simulate_confirm endpoint (useful in dev before mobile SDK is installed) + const confirmResp = await fetch('/api/payments/simulate_confirm', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stripePaymentIntentId }), + }) + + const confirmData = await confirmResp.json() + if (!confirmResp.ok) { + Alert.alert('Error', confirmData?.error?.message || 'Failed to confirm payment') + setLoading(false) + return + } + + Alert.alert('Payment Success', 'Payment completed and attendee created (simulated)') + } + } catch (err: any) { + Alert.alert('Error', err?.message || 'Unknown error') + } finally { + setLoading(false) + } + } + + return ( + + MacSync Test Event + Price: $5.00 + +