From a2164aca4c300c7d33a6a0970d4fbde485fcd465 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 23 Aug 2025 14:23:18 -0600 Subject: [PATCH 01/13] License repo with stripe --- .env.example | 2 + bun.lock | 3 + index.ts | 13 +++- package.json | 3 +- repos/License.ts | 62 +++++++++++++++++- repos/__tests__/License.test.ts | 111 ++++++++++++++++++++++++++++++++ 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 repos/__tests__/License.test.ts diff --git a/.env.example b/.env.example index 2e45c6d..7ac2b07 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,5 @@ SLACK_ENCRYPTION_KEY= FRONTEND_URL= DISCORD_WEBHOOK_URL= + +STRIPE_WEBHOOK_SECRET= diff --git a/bun.lock b/bun.lock index 811d570..b82dce8 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "ioredis": "^5.3.2", "knex": "^2.5.1", "pg": "^8.11.3", + "stripe": "^18.4.0", }, "devDependencies": { "@types/bun": "latest", @@ -347,6 +348,8 @@ "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "stripe": ["stripe@18.4.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ=="], + "superagent": ["superagent@8.1.2", "", { "dependencies": { "component-emitter": "^1.3.0", "cookiejar": "^2.1.4", "debug": "^4.3.4", "fast-safe-stringify": "^2.1.1", "form-data": "^4.0.0", "formidable": "^2.1.2", "methods": "^1.1.2", "mime": "2.6.0", "qs": "^6.11.0", "semver": "^7.3.8" } }, "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA=="], "supertest": ["supertest@6.3.4", "", { "dependencies": { "methods": "^1.1.2", "superagent": "^8.1.2" } }, "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw=="], diff --git a/index.ts b/index.ts index 8f00711..09081ff 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import express, { json, urlencoded } from 'express' +import express, { json, urlencoded, raw } from 'express' import { UserController } from './controllers/UserController' import { GeoLocationController } from './controllers/GeoLocationController' import { FriendsController } from './controllers/FriendsController' @@ -7,6 +7,8 @@ import { MarketingController } from './controllers/MarketingController' import { JobQueueController } from './controllers/JobQueueController' import { SlackController } from './controllers/SlackController' import { NotificationTestController } from './controllers/NotificationTestController' +import { CheckoutController } from './controllers/CheckoutController' +import { WebhookController } from './controllers/WebhookController' import { GeoLocationService } from './services/GeoLocationService' import { jobQueueService } from './services/JobQueueService' import { slackCleanupQueueService } from './services/SlackCleanupQueueService' @@ -15,6 +17,9 @@ import { ApiError } from './middleware/errorHandler' const app = express() const PORT = parseInt(process.env.PORT || '8001', 10) +// Webhook middleware (needs raw body for signature verification) +app.use('/api/webhooks/stripe', raw({ type: 'application/json' })) + // Middleware app.use(json()) app.use(urlencoded({ extended: true })) @@ -49,9 +54,11 @@ app.use('/api/rollup', RollupController.router) app.use('/api/jobs', JobQueueController.router) app.use('/api/slack', SlackController.router) app.use('/api/notifications', NotificationTestController.router) +app.use('/api/checkout', CheckoutController.router) // Public API Routes (no authentication required) app.use('/api/marketing', MarketingController.router) +app.use('/api/webhooks', WebhookController.router) // 404 handler app.use('*', (req, res) => { @@ -187,8 +194,10 @@ export const startServer = async (port: number = PORT) => { console.log(`๐Ÿ“ˆ Marketing API (public): http://localhost:${port}/api/marketing`) console.log(`โš™๏ธ Job Queue API (auth required): http://localhost:${port}/api/jobs`) console.log(`๐Ÿ’ฌ Slack API (auth required): http://localhost:${port}/api/slack`) + console.log(`๐Ÿ’ณ Checkout API (auth required): http://localhost:${port}/api/checkout`) + console.log(`๐Ÿ”— Webhooks API (public): http://localhost:${port}/api/webhooks`) console.log(`โš™๏ธ Job Queue: User monitoring jobs scheduled (requires Redis)`) - console.log('๐Ÿ” Authentication: Supabase JWT required for /api routes (except marketing and slack/events)') + console.log('๐Ÿ” Authentication: Supabase JWT required for /api routes (except marketing, webhooks, and slack/events)') }) } catch (error) { console.error('โŒ Failed to start server:', error) diff --git a/package.json b/package.json index d3663a1..ffe9731 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "express": "^4.18.2", "ioredis": "^5.3.2", "knex": "^2.5.1", - "pg": "^8.11.3" + "pg": "^8.11.3", + "stripe": "^18.4.0" }, "devDependencies": { "@types/bun": "latest", diff --git a/repos/License.ts b/repos/License.ts index ca1e53b..1fdafd0 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -1,11 +1,13 @@ import { getDb } from "../config/database"; export type LicenseType = 'perpetual' | 'subscription' | 'free_trial'; +export type LicenseStatus = 'active' | 'expired'; export interface License { id: string; user_id: string; license_type: LicenseType; + status?: LicenseStatus; purchase_date: Date; expiration_date: Date; stripe_customer_id?: string; @@ -17,6 +19,18 @@ export interface License { export interface CreateLicenseData { user_id: string; license_type: LicenseType; + status?: LicenseStatus; + expiration_date: Date; + purchase_date?: Date; + stripe_customer_id?: string; + stripe_payment_id?: string; +} + +export interface UpsertLicenseData { + user_id: string; + license_type: LicenseType; + status: LicenseStatus; + purchase_date: Date; expiration_date: Date; stripe_customer_id?: string; stripe_payment_id?: string; @@ -39,7 +53,8 @@ const createLicense = async (data: CreateLicenseData): Promise => { .insert({ user_id: data.user_id, license_type: data.license_type, - purchase_date: new Date(), + status: data.status || 'active', + purchase_date: data.purchase_date || new Date(), expiration_date: data.expiration_date, stripe_customer_id: data.stripe_customer_id, stripe_payment_id: data.stripe_payment_id, @@ -69,9 +84,54 @@ const deleteLicense = async (userId: string): Promise => { return deletedCount > 0 } +const upsertLicense = async (data: UpsertLicenseData): Promise => { + const existingLicense = await getLicenseByUserId(data.user_id) + + if (existingLicense) { + const [updated] = await db(tableName) + .where({ user_id: data.user_id }) + .update({ + license_type: data.license_type, + status: data.status, + purchase_date: data.purchase_date, + expiration_date: data.expiration_date, + stripe_customer_id: data.stripe_customer_id, + stripe_payment_id: data.stripe_payment_id, + updated_at: new Date() + }) + .returning('*') + + return updated + } else { + return await createLicense({ + user_id: data.user_id, + license_type: data.license_type, + status: data.status, + purchase_date: data.purchase_date, + expiration_date: data.expiration_date, + stripe_customer_id: data.stripe_customer_id, + stripe_payment_id: data.stripe_payment_id, + }) + } +} + +const updateLicenseByStripePaymentId = async (stripePaymentId: string, status: LicenseStatus): Promise => { + const [license] = await db(tableName) + .where({ stripe_payment_id: stripePaymentId }) + .update({ + status, + updated_at: new Date() + }) + .returning('*') + + return license || null +} + export const LicenseRepo = { getLicenseByUserId, createLicense, updateLicense, deleteLicense, + upsertLicense, + updateLicenseByStripePaymentId, } \ No newline at end of file diff --git a/repos/__tests__/License.test.ts b/repos/__tests__/License.test.ts new file mode 100644 index 0000000..317afa8 --- /dev/null +++ b/repos/__tests__/License.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test' +import { startTestDatabase, stopTestDatabase, resetTestDatabase, getTestDatabase } from '../../tests/helpers/testDatabase' +import { randomUUID } from 'crypto' + +describe('LicenseRepo', () => { + let LicenseRepo: any + + beforeAll(async () => { + await startTestDatabase() + // Import after test database is set up to ensure it uses the test connection + const { LicenseRepo: ImportedLicenseRepo } = await import('../License') + LicenseRepo = ImportedLicenseRepo + }) + + afterAll(async () => { + await stopTestDatabase() + }) + + beforeEach(async () => { + await resetTestDatabase() + }) + + describe('getLicenseByUserId', () => { + it('should return null when no license exists for user', async () => { + const userId = randomUUID() + const result = await LicenseRepo.getLicenseByUserId(userId) + + expect(result).toBeNull() + }) + + it('should return license when user has a license', async () => { + const db = getTestDatabase() + const userId = randomUUID() + + // Insert user first (required for foreign key) + await db('auth.users').insert({ id: userId }) + + const licenseData = { + id: randomUUID(), + user_id: userId, + license_type: 'subscription' as const, + status: 'active' as const, + purchase_date: new Date(), + expiration_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + stripe_customer_id: 'cus_test123', + stripe_payment_id: 'pi_test123', + created_at: new Date(), + updated_at: new Date() + } + + // Insert test license directly into database + await db('license').insert(licenseData) + + const result = await LicenseRepo.getLicenseByUserId(userId) + + expect(result).toBeDefined() + expect(result).not.toBeNull() + expect(result?.user_id).toBe(userId) + expect(result?.license_type).toBe('subscription') + expect(result?.status).toBe('active') + expect(result?.stripe_customer_id).toBe('cus_test123') + }) + + it('should return license for different license types', async () => { + const db = getTestDatabase() + const userId1 = randomUUID() + const userId2 = randomUUID() + + // Insert users first + await db('auth.users').insert([{ id: userId1 }, { id: userId2 }]) + + // Insert perpetual license + await db('license').insert({ + id: randomUUID(), + user_id: userId1, + license_type: 'perpetual', + status: 'active', + purchase_date: new Date(), + expiration_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year + created_at: new Date(), + updated_at: new Date() + }) + + // Insert subscription license + await db('license').insert({ + id: randomUUID(), + user_id: userId2, + license_type: 'subscription', + status: 'expired', + purchase_date: new Date('2023-01-01'), + expiration_date: new Date('2023-12-31'), + created_at: new Date(), + updated_at: new Date() + }) + + const perpetualResult = await LicenseRepo.getLicenseByUserId(userId1) + const subscriptionResult = await LicenseRepo.getLicenseByUserId(userId2) + + expect(perpetualResult?.license_type).toBe('perpetual') + expect(perpetualResult?.status).toBe('active') + + expect(subscriptionResult?.license_type).toBe('subscription') + expect(subscriptionResult?.status).toBe('expired') + }) + + it('should handle database errors gracefully', async () => { + // Test with invalid UUID format + expect(() => LicenseRepo.getLicenseByUserId('invalid-uuid')).toThrow() + }) + }) +}) \ No newline at end of file From 62cc1bfba67bd15bb21b9e854470f53a268c5721 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 24 Aug 2025 15:18:35 -0600 Subject: [PATCH 02/13] Add license and checkout tests --- config/products.ts | 45 +++++++ controllers/CheckoutController.ts | 50 +++++++ middleware/auth.ts | 6 +- repos/ActivityDayRollup.ts | 2 +- repos/License.ts | 21 ++- repos/__tests__/License.test.ts | 148 +++++++++------------ services/LicenseService.ts | 7 + services/StripeService.ts | 83 ++++++++++++ supabase/functions/stripe-webhook/index.ts | 116 ++++++++-------- tests/e2e/checkout.test.ts | 135 +++++++++++++++++++ tests/helpers/testDatabase.ts | 10 +- 11 files changed, 467 insertions(+), 156 deletions(-) create mode 100644 config/products.ts create mode 100644 controllers/CheckoutController.ts create mode 100644 services/LicenseService.ts create mode 100644 services/StripeService.ts create mode 100644 tests/e2e/checkout.test.ts diff --git a/config/products.ts b/config/products.ts new file mode 100644 index 0000000..b39a907 --- /dev/null +++ b/config/products.ts @@ -0,0 +1,45 @@ +import type { LicenseType } from '../repos/License.js' + +export interface ProductConfig { + id: string + name: string + licenseType: LicenseType + billingType: 'one_time' | 'recurring' + expirationMonths?: number // For perpetual licenses, how long they last +} + +// Product configuration mapping Stripe product IDs to license types +export const PRODUCT_CONFIGS: Record = { + 'monthly_subscription': { + id: 'prod_SuYkFqTzEpW78s', + name: 'Ebb Pro Monthly Subscription', + licenseType: 'subscription', + billingType: 'recurring' + }, + 'annual_subscription': { + id: 'prod_SuYlSMSfhzbVi6', + name: 'Ebb Pro Annual Subscription', + licenseType: 'subscription', + billingType: 'recurring' + }, + // Add perpetual license product if you have one + // 'prod_PerpetualLicense': { + // id: 'prod_PerpetualLicense', + // name: 'Ebb Pro Perpetual License', + // licenseType: 'perpetual', + // billingType: 'one_time', + // expirationMonths: 12 + // } +} + +export const getProductConfig = (productId: string): ProductConfig | null => { + return PRODUCT_CONFIGS[productId] || null +} + +export const isValidProductId = (productId: string): boolean => { + return productId in PRODUCT_CONFIGS +} + +export const getValidProductIds = (): string[] => { + return Object.keys(PRODUCT_CONFIGS) +} \ No newline at end of file diff --git a/controllers/CheckoutController.ts b/controllers/CheckoutController.ts new file mode 100644 index 0000000..d4e1e81 --- /dev/null +++ b/controllers/CheckoutController.ts @@ -0,0 +1,50 @@ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { StripeService } from '../services/StripeService.js' +import { AuthMiddleware } from '../middleware/auth.js' +import { asyncHandler, ApiError } from '../middleware/errorHandler.js' +import { getProductConfig } from '../config/products.js' +import { LicenseService } from '../services/LicenseService.js' + +const router = Router() + +const createCheckout = async (req: Request, res: Response): Promise => { + if (!req.user) { + throw new ApiError('User authentication required', 401) + } + + const { licenseType } = req.body + + if(!licenseType) { + throw new ApiError('licenseType is required', 422) + } + + const productConfig = getProductConfig(licenseType) + + if (!productConfig) { + throw new ApiError(`Invalid licenseType: ${licenseType}`, 422) + } + + const existingLicense = await LicenseService.getActiveLicense(req.user.id) + if (existingLicense) { + throw new ApiError('User already has an active license', 422) + } + + const checkoutUrl = await StripeService.createCheckoutSession({ + userId: req.user.id, + userEmail: req.user.email, + productId: productConfig.id + }) + + res.json({ + success: true, + data: { url: checkoutUrl } + }) +} + +router.post('/create', AuthMiddleware.authenticateToken, asyncHandler(createCheckout)) + +export const CheckoutController = { + router, + createCheckout +} \ No newline at end of file diff --git a/middleware/auth.ts b/middleware/auth.ts index 20cd410..b9dd406 100644 --- a/middleware/auth.ts +++ b/middleware/auth.ts @@ -13,10 +13,10 @@ declare global { } } -// Test mode mock user - only used when NODE_ENV === 'test' +// founder user const TEST_USER = { - id: 'test-user-123', - email: 'test@example.com', + id: '409cf9b9-7aae-4a13-aca9-1bcd7b9a4209', + email: 'founder@example.com', role: 'user' } diff --git a/repos/ActivityDayRollup.ts b/repos/ActivityDayRollup.ts index 8e2284d..8743d91 100644 --- a/repos/ActivityDayRollup.ts +++ b/repos/ActivityDayRollup.ts @@ -101,7 +101,7 @@ export const ActivityDayRollupRepo = { .select('user_id', db.raw('SUM(total_duration_minutes) as total_minutes')) .groupBy('user_id') - return result.map(row => ({ + return result.map((row: any) => ({ userId: row.user_id, totalMinutes: parseInt(row.total_minutes as string) || 0 })) diff --git a/repos/License.ts b/repos/License.ts index 1fdafd0..388503d 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -43,7 +43,23 @@ const tableName = 'license' const getLicenseByUserId = async (userId: string): Promise => { const result = await db(tableName) .where({ user_id: userId }) - .first() + .first('*') + + return result || null +} + +const getLicensesByUserId = async (userId: string): Promise => { + const result = await db(tableName) + .where({ user_id: userId }) + .select('*') + + return result || [] +} + +const getActiveLicenseByUserId = async (userId: string): Promise => { + const result = await db(tableName) + .where({ user_id: userId, status: 'active' }) + .first('*') return result || null } @@ -128,7 +144,8 @@ const updateLicenseByStripePaymentId = async (stripePaymentId: string, status: L } export const LicenseRepo = { - getLicenseByUserId, + getLicensesByUserId, + getActiveLicenseByUserId, createLicense, updateLicense, deleteLicense, diff --git a/repos/__tests__/License.test.ts b/repos/__tests__/License.test.ts index 317afa8..76f0741 100644 --- a/repos/__tests__/License.test.ts +++ b/repos/__tests__/License.test.ts @@ -1,111 +1,89 @@ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test' -import { startTestDatabase, stopTestDatabase, resetTestDatabase, getTestDatabase } from '../../tests/helpers/testDatabase' +import { describe, it, expect, beforeAll } from 'bun:test' import { randomUUID } from 'crypto' +import { getDb } from '../../config/database' +import { LicenseRepo, type License, type LicenseStatus } from '../License' describe('LicenseRepo', () => { - let LicenseRepo: any + let user1Id: string + let defaultLicense: License beforeAll(async () => { - await startTestDatabase() - // Import after test database is set up to ensure it uses the test connection - const { LicenseRepo: ImportedLicenseRepo } = await import('../License') - LicenseRepo = ImportedLicenseRepo + + user1Id = randomUUID() + const db = getDb() + await db('auth.users').insert({ id: user1Id, email: 'test@license.com' }) + const licenseData = { + id: randomUUID(), + user_id: user1Id, + license_type: 'subscription' as const, + status: 'active' as const, + purchase_date: new Date(), + expiration_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + stripe_customer_id: 'cus_test123', + stripe_payment_id: 'pi_test123', + created_at: new Date(), + updated_at: new Date() + } + await db('license').insert(licenseData) + defaultLicense = licenseData }) - afterAll(async () => { - await stopTestDatabase() - }) - - beforeEach(async () => { - await resetTestDatabase() - }) describe('getLicenseByUserId', () => { it('should return null when no license exists for user', async () => { const userId = randomUUID() - const result = await LicenseRepo.getLicenseByUserId(userId) + const result = await LicenseRepo.getLicensesByUserId(userId) - expect(result).toBeNull() + expect(result).toEqual([]) }) it('should return license when user has a license', async () => { - const db = getTestDatabase() - const userId = randomUUID() - // Insert user first (required for foreign key) - await db('auth.users').insert({ id: userId }) + const result = await LicenseRepo.getLicensesByUserId(user1Id) - const licenseData = { - id: randomUUID(), - user_id: userId, - license_type: 'subscription' as const, - status: 'active' as const, - purchase_date: new Date(), - expiration_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now - stripe_customer_id: 'cus_test123', - stripe_payment_id: 'pi_test123', - created_at: new Date(), - updated_at: new Date() - } - - // Insert test license directly into database - await db('license').insert(licenseData) - - const result = await LicenseRepo.getLicenseByUserId(userId) - - expect(result).toBeDefined() - expect(result).not.toBeNull() - expect(result?.user_id).toBe(userId) - expect(result?.license_type).toBe('subscription') - expect(result?.status).toBe('active') - expect(result?.stripe_customer_id).toBe('cus_test123') + expect(result.length).toBe(1) + const [license] = result + expect(license).toBeDefined() + expect(license?.user_id).toBe(user1Id) + expect(license?.license_type).toBe(defaultLicense.license_type) + expect(license?.status).toBe(defaultLicense.status as LicenseStatus) + expect(license?.stripe_customer_id).toBe(defaultLicense.stripe_customer_id as string) }) - it('should return license for different license types', async () => { - const db = getTestDatabase() - const userId1 = randomUUID() - const userId2 = randomUUID() - - // Insert users first - await db('auth.users').insert([{ id: userId1 }, { id: userId2 }]) - - // Insert perpetual license - await db('license').insert({ - id: randomUUID(), - user_id: userId1, - license_type: 'perpetual', - status: 'active', - purchase_date: new Date(), - expiration_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year - created_at: new Date(), - updated_at: new Date() - }) + it('should handle database errors gracefully', async () => { + // Test with invalid UUID format + expect(() => LicenseRepo.getLicensesByUserId('invalid-uuid')).toThrow() + }) + }) - // Insert subscription license - await db('license').insert({ - id: randomUUID(), - user_id: userId2, - license_type: 'subscription', - status: 'expired', - purchase_date: new Date('2023-01-01'), - expiration_date: new Date('2023-12-31'), - created_at: new Date(), - updated_at: new Date() - }) + describe('getActiveLicenseByUserId', () => { + it('should return null when no active license exists for user', async () => { + const userId = randomUUID() + const result = await LicenseRepo.getActiveLicenseByUserId(userId) + expect(result).toBeNull() + }) - const perpetualResult = await LicenseRepo.getLicenseByUserId(userId1) - const subscriptionResult = await LicenseRepo.getLicenseByUserId(userId2) - - expect(perpetualResult?.license_type).toBe('perpetual') - expect(perpetualResult?.status).toBe('active') - - expect(subscriptionResult?.license_type).toBe('subscription') - expect(subscriptionResult?.status).toBe('expired') + it('should return the active license when user has an active license', async () => { + const result = await LicenseRepo.getActiveLicenseByUserId(user1Id) + expect(result).toBeDefined() + expect(result?.user_id).toBe(user1Id) + expect(result?.status).toBe('active') }) - it('should handle database errors gracefully', async () => { - // Test with invalid UUID format - expect(() => LicenseRepo.getLicenseByUserId('invalid-uuid')).toThrow() + it('should filter out inactive licenses', async () => { + const db = getDb() + await db('license').update({ status: 'expired' }).where({ user_id: user1Id }) + const result = await LicenseRepo.getActiveLicenseByUserId(user1Id) + expect(result).toBeNull() }) }) + // describe('upsertLicense', () => { + // it('should upsert a license', async () => { + // const license = await LicenseRepo.upsertLicense({ + // user_id: user1Id, + // license_type: 'subscription', + // status: 'active', + // expiration_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + // }) + // }) }) \ No newline at end of file diff --git a/services/LicenseService.ts b/services/LicenseService.ts new file mode 100644 index 0000000..9e2df90 --- /dev/null +++ b/services/LicenseService.ts @@ -0,0 +1,7 @@ +import { LicenseRepo } from "../repos/License" + +export const LicenseService = { + async getActiveLicense(userId: string) { + return LicenseRepo.getActiveLicenseByUserId(userId) + } +} \ No newline at end of file diff --git a/services/StripeService.ts b/services/StripeService.ts new file mode 100644 index 0000000..d99d740 --- /dev/null +++ b/services/StripeService.ts @@ -0,0 +1,83 @@ +import Stripe from 'stripe' +import { ApiError } from '../middleware/errorHandler.js' +import { getProductConfig } from '../config/products.js' + +let stripeClient: Stripe | null = null + +const getStripeClient = (): Stripe => { + if (!stripeClient) { + const secretKey = process.env.STRIPE_SECRET_KEY + if (!secretKey) { + throw new ApiError('Stripe secret key not configured', 500) + } + + stripeClient = new Stripe(secretKey, { + apiVersion: '2025-07-30.basil' + }) + } + return stripeClient +} + +export interface CreateCheckoutSessionParams { + userId: string + userEmail?: string + productId: string +} + +const createCheckoutSession = async (params: CreateCheckoutSessionParams): Promise => { + const stripe = getStripeClient() + + const { userId, userEmail, productId } = params + + // Validate product ID and get configuration + const productConfig = getProductConfig(productId) + if (!productConfig) { + throw new ApiError(`Invalid product ID: ${productId}`, 400) + } + + // For products that use existing Stripe products, we need to fetch the default price + // This approach gives you maximum flexibility to manage pricing in Stripe Dashboard + const product = await stripe.products.retrieve(productId, { + expand: ['default_price'] + }) + + if (!product.default_price) { + throw new ApiError(`Product ${productId} has no default price configured`, 422) + } + + const priceId = typeof product.default_price === 'string' + ? product.default_price + : product.default_price.id + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card', 'link'], + line_items: [ + { + price: priceId, + quantity: 1 + } + ], + mode: 'subscription', + success_url: 'https://ebb.cool/license/callback?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://ebb.cool/license/callback', + client_reference_id: userId, + customer_creation: 'always', + ...(userEmail ? { customer_email: userEmail } : {}), + allow_promotion_codes: true, + metadata: { + user_id: userId, + product_id: productId + } + }) + + if (!session.url) { + throw new ApiError('Failed to create checkout session', 500) + } + + return session.url +} + +export const StripeService = { + createCheckoutSession, + getStripeClient +} \ No newline at end of file diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index c7e090b..fa53f99 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -21,39 +21,39 @@ Deno.serve(async (req) => { } switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as Stripe.Checkout.Session - const customerId = session.customer as string - const userId = session.client_reference_id - - if (!userId) { - console.error('Webhook Error: No user ID (client_reference_id) found in checkout session.') - throw new Error('No user ID found in session') - } - - if (session.mode === 'payment') { - const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000 - const expirationDate = new Date(Date.now() + ONE_YEAR_IN_MS) - - const license: License = { - user_id: userId, - status: 'active', - license_type: 'perpetual', - purchase_date: new Date().toISOString(), - expiration_date: expirationDate.toISOString(), - stripe_customer_id: customerId, - stripe_payment_id: session.payment_intent as string - } - - const { error: licenseError } = await licenseRepo.upsertLicense(license) - - if (licenseError) { - console.error('Supabase upsert error (perpetual):', licenseError) - throw licenseError - } - } - break - } + // case 'checkout.session.completed': { + // const session = event.data.object as Stripe.Checkout.Session + // const customerId = session.customer as string + // const userId = session.client_reference_id + + // if (!userId) { + // console.error('Webhook Error: No user ID (client_reference_id) found in checkout session.') + // throw new Error('No user ID found in session') + // } + + // if (session.mode === 'payment') { + // const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000 + // const expirationDate = new Date(Date.now() + ONE_YEAR_IN_MS) + + // const license: License = { + // user_id: userId, + // status: 'active', + // license_type: 'perpetual', + // purchase_date: new Date().toISOString(), + // expiration_date: expirationDate.toISOString(), + // stripe_customer_id: customerId, + // stripe_payment_id: session.payment_intent as string + // } + + // const { error: licenseError } = await licenseRepo.upsertLicense(license) + + // if (licenseError) { + // console.error('Supabase upsert error (perpetual):', licenseError) + // throw licenseError + // } + // } + // break + // } // Note: Subscription creation handled by customer.subscription.created/updated case 'customer.subscription.created': case 'customer.subscription.updated': { @@ -61,25 +61,25 @@ Deno.serve(async (req) => { const customerId = subscription.customer as string let userId: string | null | undefined = subscription.metadata?.user_id - - // Fallback: Retrieve customer if metadata isn't directly on subscription event if (!userId) { - try { - const customer = await StripeApi.getStripeClient().customers.retrieve(customerId) as Stripe.Customer - if (customer.deleted) { - console.warn(`Customer ${customerId} is deleted. Cannot retrieve user_id.`) - } else { - userId = customer.metadata?.user_id - } - } catch (custError) { - console.error(`Failed to retrieve Stripe customer ${customerId}:`, custError) - } + console.error('Webhook Error: No user ID found in subscription metadata.') + console.log('subscription', JSON.stringify(subscription, null, 2)) + throw new Error('No user ID found in subscription metadata') } - if (!userId) { - console.error(`Webhook Error: No user ID found in subscription metadata or retrieved customer for customer ID: ${customerId}`) - throw new Error('No user ID found for subscription') - } + // Fallback: Retrieve customer if metadata isn't directly on subscription event + // if (!userId) { + // try { + // const customer = await StripeApi.getStripeClient().customers.retrieve(customerId) as Stripe.Customer + // if (customer.deleted) { + // console.warn(`Customer ${customerId} is deleted. Cannot retrieve user_id.`) + // } else { + // userId = customer.metadata?.user_id + // } + // } catch (custError) { + // console.error(`Failed to retrieve Stripe customer ${customerId}:`, custError) + // } + // } const status = subscription.status === 'active' || subscription.status === 'trialing' ? 'active' : 'expired' const expirationDate = new Date(subscription.current_period_end * 1000) @@ -104,17 +104,17 @@ Deno.serve(async (req) => { break } - case 'customer.subscription.deleted': { - const subscription = event.data.object as Stripe.Subscription + // case 'customer.subscription.deleted': { + // const subscription = event.data.object as Stripe.Subscription - const { error: updateError } = await licenseRepo.updateLicense(subscription.id, 'expired') + // const { error: updateError } = await licenseRepo.updateLicense(subscription.id, 'expired') - if (updateError) { - console.error('Supabase update error (sub deleted):', updateError) - console.warn(`Failed to update license status for deleted subscription ${subscription.id}. Maybe it didn't exist?`) - } - break - } + // if (updateError) { + // console.error('Supabase update error (sub deleted):', updateError) + // console.warn(`Failed to update license status for deleted subscription ${subscription.id}. Maybe it didn't exist?`) + // } + // break + // } } return new Response(JSON.stringify({ received: true }), { diff --git a/tests/e2e/checkout.test.ts b/tests/e2e/checkout.test.ts new file mode 100644 index 0000000..b7cc6b9 --- /dev/null +++ b/tests/e2e/checkout.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'bun:test' +import request from 'supertest' +import app from '../../index' +import { StripeService } from '../../services/StripeService' +import { LicenseService } from '../../services/LicenseService' + +StripeService.createCheckoutSession = async () => 'https://checkout.stripe.com/test-session' + +describe('Checkout API', () => { + describe('POST /api/checkout/create', () => { + describe('Authentication Required', () => { + it('should return 401 when no authorization header is provided', async () => { + const response = await request(app) + .post('/api/checkout/create') + .send({ licenseType: 'monthly_subscription' }) + .expect(401) + + expect(response.body).toEqual({ + success: false, + error: 'Access token required' + }) + }) + + it('should return 401 when token is invalid', async () => { + const response = await request(app) + .post('/api/checkout/create') + .set('Authorization', 'Bearer invalid_token') + .send({ licenseType: 'monthly_subscription' }) + .expect(401) + + expect(response.body).toEqual({ + success: false, + error: 'Invalid or expired token' + }) + }) + }) + + describe('Request Validation', () => { + it('should return 422 when licenseType is missing', async () => { + const response = await request(app) + .post('/api/checkout/create') + .set('Authorization', 'Bearer valid_test_token') + .send({}) + .expect(422) + + expect(response.body.success).toBe(false) + expect(response.body.error).toContain('licenseType is required') + }) + + it('should return 422 when licenseType is invalid', async () => { + const response = await request(app) + .post('/api/checkout/create') + .set('Authorization', 'Bearer valid_test_token') + .send({ licenseType: 'invalid_license_type' }) + .expect(422) + + expect(response.body.success).toBe(false) + expect(response.body.error).toContain('Invalid licenseType:') + }) + + it('should accept valid monthly subscription product ID', async () => { + let originalGetLicenseByUserId = LicenseService.getActiveLicense + let originalCreateCheckoutSession = StripeService.createCheckoutSession + + LicenseService.getActiveLicense = async () => null + StripeService.createCheckoutSession = async () => 'https://checkout.stripe.com/test-session' + + const response = await request(app) + .post('/api/checkout/create') + .set('Authorization', 'Bearer valid_test_token') + .send({ licenseType: 'monthly_subscription' }) + .expect(200) + + expect(response.body.success).toBe(true) + expect(response.body.data).toHaveProperty('url') + + LicenseService.getActiveLicense = originalGetLicenseByUserId + StripeService.createCheckoutSession = originalCreateCheckoutSession + }) + + it('should accept valid annual subscription product ID', async () => { + let originalGetLicenseByUserId = LicenseService.getActiveLicense + let originalCreateCheckoutSession = StripeService.createCheckoutSession + + LicenseService.getActiveLicense = async () => null + StripeService.createCheckoutSession = async () => 'https://checkout.stripe.com/test-session' + + const response = await request(app) + .post('/api/checkout/create') + .set('Authorization', 'Bearer valid_test_token') + .send({ licenseType: 'annual_subscription' }) + .expect(200) + + expect(response.body.success).toBe(true) + expect(response.body.data).toHaveProperty('url') + + LicenseService.getActiveLicense = originalGetLicenseByUserId + StripeService.createCheckoutSession = originalCreateCheckoutSession + }) + }) + + describe('return validation', () => { + it('should return 200 when licenseType is valid', async () => { + + + const response = await request(app) + .post('/api/checkout/create') + .set('Authorization', 'Bearer valid_test_token') + .send({ licenseType: 'monthly_subscription' }) + .expect(200) + + expect(response.body.success).toBe(true) + expect(response.body.data).toHaveProperty('url') + }) + }) + + describe('CORS Support', () => { + it('should handle OPTIONS requests for CORS preflight', async () => { + await request(app) + .options('/api/checkout/create') + .expect(200) + }) + + it('should include proper CORS headers on OPTIONS', async () => { + const response = await request(app) + .options('/api/checkout/create') + .expect(200) + + expect(response.headers['access-control-allow-origin']).toBe('*') + expect(response.headers['access-control-allow-methods']).toContain('POST') + expect(response.headers['access-control-allow-headers']).toContain('Authorization') + }) + }) + }) +}) \ No newline at end of file diff --git a/tests/helpers/testDatabase.ts b/tests/helpers/testDatabase.ts index f988ec3..a1898de 100644 --- a/tests/helpers/testDatabase.ts +++ b/tests/helpers/testDatabase.ts @@ -6,6 +6,7 @@ import { getDb, stopDb } from '../../config/database' export const startTestDatabase = async (): Promise => { const db = getDb() + console.log('Starting test database...') await setupTestDatabase(db) return db } @@ -34,13 +35,6 @@ export const resetTestDatabase = async (): Promise => { DROP ROLE IF EXISTS service_role; `) - - // Recreate empty schemas - await db.raw(` - CREATE SCHEMA public; - CREATE SCHEMA auth; - `) - } catch (error) { console.error('Failed to drop database tables:', error instanceof Error ? error.message : error) throw new Error(`Database reset failed: ${error instanceof Error ? error.message : 'Unknown error'}`) @@ -51,8 +45,10 @@ export const resetTestDatabase = async (): Promise => { // Set up the test database with migrations and test data const setupTestDatabase = async (db: Knex): Promise => { + console.log('Setting up test database...') // Create auth schema and minimal auth.users table await db.raw('CREATE SCHEMA IF NOT EXISTS auth') + await db.raw('CREATE SCHEMA IF NOT EXISTS public') // Create the authenticated role that Supabase uses (if it doesn't exist) try { From c8993c695dc7cecb4372089f5e3085c1fbf0190e Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 30 Aug 2025 10:44:57 -0600 Subject: [PATCH 03/13] free trial tests --- controllers/UserController.ts | 16 +-- repos/License.ts | 27 ++--- repos/__tests__/License.test.ts | 73 ++++++++----- services/LicenseService.ts | 26 ++++- services/__tests__/LicenseService.test.ts | 49 +++++++++ tests/e2e/users.test.ts | 122 ---------------------- 6 files changed, 125 insertions(+), 188 deletions(-) create mode 100644 services/__tests__/LicenseService.test.ts diff --git a/controllers/UserController.ts b/controllers/UserController.ts index a194d2e..64b33da 100644 --- a/controllers/UserController.ts +++ b/controllers/UserController.ts @@ -3,7 +3,7 @@ import type { Request, Response } from 'express' import { UserProfileService } from '../services/UserProfileService.js' import { AuthMiddleware } from '../middleware/auth.js' import { asyncHandler, ApiError } from '../middleware/errorHandler.js' -import { LicenseRepo } from '../repos/License.js' +import { LicenseService } from '../services/LicenseService.js' const router = Router() @@ -47,19 +47,7 @@ const startTrial = async (req: Request, res: Response): Promise => { throw new ApiError('User authentication required', 401) } - const existingLicense = await LicenseRepo.getLicenseByUserId(req.user.id) - if (existingLicense) { - throw new ApiError('User already has a license', 422) - } - - const expirationDate = new Date() - expirationDate.setDate(expirationDate.getDate() + 14) - - await LicenseRepo.createLicense({ - user_id: req.user.id, - license_type: 'free_trial', - expiration_date: expirationDate - }) + await LicenseService.startFreeTrial(req.user.id) res.json({ success: true, diff --git a/repos/License.ts b/repos/License.ts index 388503d..c0a6764 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -31,7 +31,7 @@ export interface UpsertLicenseData { license_type: LicenseType; status: LicenseStatus; purchase_date: Date; - expiration_date: Date; + expiration_date?: Date; stripe_customer_id?: string; stripe_payment_id?: string; } @@ -40,25 +40,11 @@ const db = getDb() const tableName = 'license' -const getLicenseByUserId = async (userId: string): Promise => { - const result = await db(tableName) - .where({ user_id: userId }) - .first('*') - - return result || null -} - -const getLicensesByUserId = async (userId: string): Promise => { - const result = await db(tableName) - .where({ user_id: userId }) - .select('*') - - return result || [] -} - const getActiveLicenseByUserId = async (userId: string): Promise => { const result = await db(tableName) .where({ user_id: userId, status: 'active' }) + .where('expiration_date', '>', new Date()) + .orderBy('expiration_date', 'desc') .first('*') return result || null @@ -101,7 +87,7 @@ const deleteLicense = async (userId: string): Promise => { } const upsertLicense = async (data: UpsertLicenseData): Promise => { - const existingLicense = await getLicenseByUserId(data.user_id) + const existingLicense = await getActiveLicenseByUserId(data.user_id) if (existingLicense) { const [updated] = await db(tableName) @@ -119,12 +105,12 @@ const upsertLicense = async (data: UpsertLicenseData): Promise => { return updated } else { - return await createLicense({ + return createLicense({ user_id: data.user_id, license_type: data.license_type, status: data.status, purchase_date: data.purchase_date, - expiration_date: data.expiration_date, + expiration_date: data.expiration_date || new Date(), stripe_customer_id: data.stripe_customer_id, stripe_payment_id: data.stripe_payment_id, }) @@ -144,7 +130,6 @@ const updateLicenseByStripePaymentId = async (stripePaymentId: string, status: L } export const LicenseRepo = { - getLicensesByUserId, getActiveLicenseByUserId, createLicense, updateLicense, diff --git a/repos/__tests__/License.test.ts b/repos/__tests__/License.test.ts index 76f0741..836978c 100644 --- a/repos/__tests__/License.test.ts +++ b/repos/__tests__/License.test.ts @@ -28,34 +28,6 @@ describe('LicenseRepo', () => { defaultLicense = licenseData }) - - describe('getLicenseByUserId', () => { - it('should return null when no license exists for user', async () => { - const userId = randomUUID() - const result = await LicenseRepo.getLicensesByUserId(userId) - - expect(result).toEqual([]) - }) - - it('should return license when user has a license', async () => { - - const result = await LicenseRepo.getLicensesByUserId(user1Id) - - expect(result.length).toBe(1) - const [license] = result - expect(license).toBeDefined() - expect(license?.user_id).toBe(user1Id) - expect(license?.license_type).toBe(defaultLicense.license_type) - expect(license?.status).toBe(defaultLicense.status as LicenseStatus) - expect(license?.stripe_customer_id).toBe(defaultLicense.stripe_customer_id as string) - }) - - it('should handle database errors gracefully', async () => { - // Test with invalid UUID format - expect(() => LicenseRepo.getLicensesByUserId('invalid-uuid')).toThrow() - }) - }) - describe('getActiveLicenseByUserId', () => { it('should return null when no active license exists for user', async () => { const userId = randomUUID() @@ -76,6 +48,51 @@ describe('LicenseRepo', () => { const result = await LicenseRepo.getActiveLicenseByUserId(user1Id) expect(result).toBeNull() }) + + it('should filter out expired licenses even if status is active', async () => { + const db = getDb() + const expiredUserId = randomUUID() + await db('auth.users').insert({ id: expiredUserId, email: 'expired@test.com' }) + + // Create a license with active status but expired date + await db('license').insert({ + id: randomUUID(), + user_id: expiredUserId, + license_type: 'subscription', + status: 'active', + purchase_date: new Date(), + expiration_date: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago + created_at: new Date(), + updated_at: new Date() + }) + + const result = await LicenseRepo.getActiveLicenseByUserId(expiredUserId) + expect(result).toBeNull() + }) + + it('should return license that expires in the future', async () => { + const db = getDb() + const futureUserId = randomUUID() + await db('auth.users').insert({ id: futureUserId, email: 'future@test.com' }) + + // Create a license with active status and future expiration + const futureExpiration = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days from now + await db('license').insert({ + id: randomUUID(), + user_id: futureUserId, + license_type: 'subscription', + status: 'active', + purchase_date: new Date(), + expiration_date: futureExpiration, + created_at: new Date(), + updated_at: new Date() + }) + + const result = await LicenseRepo.getActiveLicenseByUserId(futureUserId) + expect(result).not.toBeNull() + expect(result?.user_id).toBe(futureUserId) + expect(new Date(result?.expiration_date || '')).toEqual(futureExpiration) + }) }) // describe('upsertLicense', () => { // it('should upsert a license', async () => { diff --git a/services/LicenseService.ts b/services/LicenseService.ts index 9e2df90..66b7cf2 100644 --- a/services/LicenseService.ts +++ b/services/LicenseService.ts @@ -1,7 +1,27 @@ import { LicenseRepo } from "../repos/License" +import { ApiError } from "../middleware/errorHandler.js" -export const LicenseService = { - async getActiveLicense(userId: string) { - return LicenseRepo.getActiveLicenseByUserId(userId) +const getActiveLicense = async (userId: string) => { + return LicenseRepo.getActiveLicenseByUserId(userId) +} + +const startFreeTrial = async (userId: string) => { + const existingLicense = await LicenseRepo.getActiveLicenseByUserId(userId) + if (existingLicense) { + throw new ApiError('User already has a license', 422) } + + const expirationDate = new Date() + expirationDate.setDate(expirationDate.getDate() + 14) + + return LicenseRepo.createLicense({ + user_id: userId, + license_type: 'free_trial', + expiration_date: expirationDate + }) +} + +export const LicenseService = { + getActiveLicense, + startFreeTrial } \ No newline at end of file diff --git a/services/__tests__/LicenseService.test.ts b/services/__tests__/LicenseService.test.ts new file mode 100644 index 0000000..f596143 --- /dev/null +++ b/services/__tests__/LicenseService.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, mock } from 'bun:test' +import { LicenseService } from "../LicenseService" +import { LicenseRepo, type License } from "../../repos/License" + +// Mock the LicenseRepo module +mock.module("../../repos/License", () => ({ + LicenseRepo: { + getActiveLicenseByUserId: mock(), + createLicense: mock() + } +})) + +const mockLicense = { + + id: 'license-123', + user_id: 'test-user-id', + license_type: 'free_trial', + status: 'active', + expiration_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) +} as License +describe('LicenseService', () => { + beforeEach(() => { + // Reset all mocks before each test + mock.restore() + }) + + describe('startFreeTrial', () => { + it('should throw error when user already has an active license', async () => { + // Mock getActiveLicenseByUserId to return an existing license + LicenseRepo.getActiveLicenseByUserId = mock(() => Promise.resolve(mockLicense)) + + await expect(LicenseService.startFreeTrial('test-user-id')).rejects.toThrow('User already has a license') + + expect(LicenseRepo.getActiveLicenseByUserId).toHaveBeenCalledWith('test-user-id') + }) + it('should start a free trial when user has no existing license', async () => { + LicenseRepo.getActiveLicenseByUserId = mock(() => Promise.resolve(null)) + LicenseRepo.createLicense = mock(() => Promise.resolve(mockLicense)) + const result = await LicenseService.startFreeTrial('test-user-id') + expect(result).toBeDefined() + expect(LicenseRepo.getActiveLicenseByUserId).toHaveBeenCalledWith('test-user-id') + expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ + user_id: 'test-user-id', + license_type: 'free_trial', + expiration_date: expect.any(Date) + }) + }) + }) +}) \ No newline at end of file diff --git a/tests/e2e/users.test.ts b/tests/e2e/users.test.ts index 9b5c7dc..eea249b 100644 --- a/tests/e2e/users.test.ts +++ b/tests/e2e/users.test.ts @@ -175,126 +175,4 @@ describe('Users API', () => { expect(response.headers['access-control-allow-headers']).toContain('Authorization') }) }) - - describe('POST /api/users/start-trial', () => { - describe('Authentication Required', () => { - it('should return 401 when no authorization header is provided', async () => { - const response = await request(app) - .post('/api/users/start-trial') - .expect(401) - - expect(response.body).toEqual({ - success: false, - error: 'Access token required' - }) - }) - - it('should return 401 when token is invalid', async () => { - const response = await request(app) - .post('/api/users/start-trial') - .set('Authorization', 'Bearer invalid_token') - .expect(401) - - expect(response.body).toEqual({ - success: false, - error: 'Invalid or expired token' - }) - }) - }) - - describe('Successful Authentication', () => { - let originalGetLicenseByUserId: typeof LicenseRepo.getLicenseByUserId - let originalCreateLicense: typeof LicenseRepo.createLicense - - beforeEach(() => { - originalGetLicenseByUserId = LicenseRepo.getLicenseByUserId - originalCreateLicense = LicenseRepo.createLicense - }) - - afterEach(() => { - LicenseRepo.getLicenseByUserId = originalGetLicenseByUserId - LicenseRepo.createLicense = originalCreateLicense - }) - - it('should create a free trial when user has no existing license', async () => { - LicenseRepo.getLicenseByUserId = async () => null - LicenseRepo.createLicense = async (data) => ({ - id: 'test-license-id', - user_id: data.user_id, - license_type: data.license_type, - purchase_date: new Date(), - expiration_date: data.expiration_date, - created_at: new Date(), - updated_at: new Date(), - }) - - const response = await request(app) - .post('/api/users/start-trial') - .set('Authorization', 'Bearer valid_test_token') - .expect(200) - - expect(response.body).toEqual({ - success: true, - message: 'Free trial started successfully' - }) - }) - - it('should return 422 when user already has a license', async () => { - LicenseRepo.getLicenseByUserId = async () => ({ - id: 'existing-license-id', - user_id: 'test-user-123', - license_type: 'perpetual', - purchase_date: new Date(), - expiration_date: new Date(), - created_at: new Date(), - updated_at: new Date(), - }) - - const response = await request(app) - .post('/api/users/start-trial') - .set('Authorization', 'Bearer valid_test_token') - .expect(422) - - expect(response.body).toEqual({ - success: false, - error: 'User already has a license' - }) - }) - - it('should set expiration date to 2 weeks from now', async () => { - let capturedLicenseData: any = null - - LicenseRepo.getLicenseByUserId = async () => null - LicenseRepo.createLicense = async (data) => { - capturedLicenseData = data - return { - id: 'test-license-id', - user_id: data.user_id, - license_type: data.license_type, - purchase_date: new Date(), - expiration_date: data.expiration_date, - created_at: new Date(), - updated_at: new Date(), - } - } - - await request(app) - .post('/api/users/start-trial') - .set('Authorization', 'Bearer valid_test_token') - .expect(200) - - expect(capturedLicenseData).toBeTruthy() - expect(capturedLicenseData.license_type).toBe('free_trial') - expect(capturedLicenseData.user_id).toBe('test-user-123') - - const now = new Date() - const twoWeeksFromNow = new Date() - twoWeeksFromNow.setDate(twoWeeksFromNow.getDate() + 14) - - const expirationDate = new Date(capturedLicenseData.expiration_date) - const timeDiff = Math.abs(expirationDate.getTime() - twoWeeksFromNow.getTime()) - expect(timeDiff).toBeLessThan(1000) // Should be within 1 second - }) - }) - }) }) From 26ef62bee3067ae6189cd736023fb8c4d8fc61b9 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 30 Aug 2025 12:01:20 -0600 Subject: [PATCH 04/13] create annual and monthly checkout --- CLAUDE.md | 12 +++++++ config/products.ts | 36 +++++++++++++++---- controllers/CheckoutController.ts | 6 ++-- services/StripeService.ts | 20 ++++------- ...250830165210_add_free_trial_to_license.sql | 2 ++ 5 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 supabase/migrations/20250830165210_add_free_trial_to_license.sql diff --git a/CLAUDE.md b/CLAUDE.md index cf66507..3b144f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,12 +45,24 @@ The application follows a layered architecture: 3. **Repos** handle database queries and data persistence 4. **Middleware** handles cross-cutting concerns (auth, errors) +#### Service Conventions +Services should follow these patterns: +- Export individual functions as `const` declarations +- Export a single service object at the end: `export const ServiceName = { func1, func2 }` +- Import dependencies at the top (repos, config, middleware) +- Use proper error handling with `ApiError` for business logic errors +- Functions should be focused on single responsibilities +- Use descriptive function names that clearly indicate their purpose + ### Key Services - **JobQueueService**: Manages background jobs with BullMQ (user monitoring, notifications) - **GeoLocationService**: IP-based location detection using MaxMind database - **UserProfileService**: User data management and location tracking - **FriendsService**: Social features and friend connections - **NotificationService**: User notification system +- **WebhookService**: Handles Stripe webhook event processing +- **StripeService**: Stripe API interactions and checkout session creation +- **LicenseService**: User license management and validation ### Authentication - Uses Supabase JWT tokens for API authentication diff --git a/config/products.ts b/config/products.ts index b39a907..8483e68 100644 --- a/config/products.ts +++ b/config/products.ts @@ -32,14 +32,38 @@ export const PRODUCT_CONFIGS: Record = { // } } -export const getProductConfig = (productId: string): ProductConfig | null => { - return PRODUCT_CONFIGS[productId] || null +// Sandbox/Test mode product configurations +export const SANDBOX_PRODUCT_CONFIGS: Record = { + 'monthly_subscription': { + id: 'prod_SxoURnahKiKlhm', // Replace with your actual sandbox monthly product ID + name: 'Ebb Pro Monthly Subscription (Test)', + licenseType: 'subscription', + billingType: 'recurring' + }, + 'annual_subscription': { + id: 'prod_SxoT12GtQeAlIE', // Replace with your actual sandbox annual product ID + name: 'Ebb Pro Annual Subscription (Test)', + licenseType: 'subscription', + billingType: 'recurring' + } +} + +export const getProductConfig = (licenseType: string, sandbox = false): ProductConfig | null => { + const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS + return configs[licenseType] || null +} + +export const getProductConfigByProductId = (productId: string, sandbox = false): ProductConfig | null => { + const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS + return Object.values(configs).find(config => config.id === productId) || null } -export const isValidProductId = (productId: string): boolean => { - return productId in PRODUCT_CONFIGS +export const isValidProductId = (licenseType: string, sandbox = false): boolean => { + const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS + return licenseType in configs } -export const getValidProductIds = (): string[] => { - return Object.keys(PRODUCT_CONFIGS) +export const getValidProductIds = (sandbox = false): string[] => { + const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS + return Object.keys(configs) } \ No newline at end of file diff --git a/controllers/CheckoutController.ts b/controllers/CheckoutController.ts index d4e1e81..cbd902c 100644 --- a/controllers/CheckoutController.ts +++ b/controllers/CheckoutController.ts @@ -13,13 +13,13 @@ const createCheckout = async (req: Request, res: Response): Promise => { throw new ApiError('User authentication required', 401) } - const { licenseType } = req.body + const { licenseType, sandbox } = req.body if(!licenseType) { throw new ApiError('licenseType is required', 422) } - const productConfig = getProductConfig(licenseType) + const productConfig = getProductConfig(licenseType, sandbox) if (!productConfig) { throw new ApiError(`Invalid licenseType: ${licenseType}`, 422) @@ -33,7 +33,7 @@ const createCheckout = async (req: Request, res: Response): Promise => { const checkoutUrl = await StripeService.createCheckoutSession({ userId: req.user.id, userEmail: req.user.email, - productId: productConfig.id + productConfig }) res.json({ diff --git a/services/StripeService.ts b/services/StripeService.ts index d99d740..ad7fb17 100644 --- a/services/StripeService.ts +++ b/services/StripeService.ts @@ -1,6 +1,6 @@ import Stripe from 'stripe' import { ApiError } from '../middleware/errorHandler.js' -import { getProductConfig } from '../config/products.js' +import { getProductConfig, type ProductConfig } from '../config/products.js' let stripeClient: Stripe | null = null @@ -21,28 +21,23 @@ const getStripeClient = (): Stripe => { export interface CreateCheckoutSessionParams { userId: string userEmail?: string - productId: string + productConfig: ProductConfig } const createCheckoutSession = async (params: CreateCheckoutSessionParams): Promise => { const stripe = getStripeClient() - const { userId, userEmail, productId } = params - - // Validate product ID and get configuration - const productConfig = getProductConfig(productId) - if (!productConfig) { - throw new ApiError(`Invalid product ID: ${productId}`, 400) - } + const { userId, userEmail, productConfig } = params + // For products that use existing Stripe products, we need to fetch the default price // This approach gives you maximum flexibility to manage pricing in Stripe Dashboard - const product = await stripe.products.retrieve(productId, { + const product = await stripe.products.retrieve(productConfig.id, { expand: ['default_price'] }) if (!product.default_price) { - throw new ApiError(`Product ${productId} has no default price configured`, 422) + throw new ApiError(`Product ${productConfig.id} has no default price configured`, 422) } const priceId = typeof product.default_price === 'string' @@ -61,12 +56,11 @@ const createCheckoutSession = async (params: CreateCheckoutSessionParams): Promi success_url: 'https://ebb.cool/license/callback?session_id={CHECKOUT_SESSION_ID}', cancel_url: 'https://ebb.cool/license/callback', client_reference_id: userId, - customer_creation: 'always', ...(userEmail ? { customer_email: userEmail } : {}), allow_promotion_codes: true, metadata: { user_id: userId, - product_id: productId + product_id: productConfig.id } }) diff --git a/supabase/migrations/20250830165210_add_free_trial_to_license.sql b/supabase/migrations/20250830165210_add_free_trial_to_license.sql new file mode 100644 index 0000000..1c0b0b7 --- /dev/null +++ b/supabase/migrations/20250830165210_add_free_trial_to_license.sql @@ -0,0 +1,2 @@ +-- Add 'free_trial' to the license_type enum +ALTER TYPE public.license_type ADD VALUE 'free_trial'; \ No newline at end of file From df491550757cfaa57dc4a54bd15d430580362932 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 30 Aug 2025 15:37:58 -0600 Subject: [PATCH 05/13] handle creating a subscription --- .env.example | 2 + config/products.ts | 20 +++--- controllers/CheckoutController.ts | 4 +- controllers/WebhookController.ts | 81 +++++++++++++++++++++++ repos/License.ts | 36 +--------- repos/__tests__/License.test.ts | 18 ++--- services/WebhookService.ts | 94 +++++++++++++++++++++++++++ tests/helpers/mockCleanup.ts | 23 ------- tests/services/WebhookService.test.ts | 52 +++++++++++++++ 9 files changed, 255 insertions(+), 75 deletions(-) create mode 100644 controllers/WebhookController.ts create mode 100644 services/WebhookService.ts delete mode 100644 tests/helpers/mockCleanup.ts create mode 100644 tests/services/WebhookService.test.ts diff --git a/.env.example b/.env.example index 7ac2b07..45ae079 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,5 @@ FRONTEND_URL= DISCORD_WEBHOOK_URL= STRIPE_WEBHOOK_SECRET= +STRIPE_SECRET_KEY= +STRIPE_SANBOX= \ No newline at end of file diff --git a/config/products.ts b/config/products.ts index 8483e68..dc0370d 100644 --- a/config/products.ts +++ b/config/products.ts @@ -48,22 +48,26 @@ export const SANDBOX_PRODUCT_CONFIGS: Record = { } } -export const getProductConfig = (licenseType: string, sandbox = false): ProductConfig | null => { - const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS +const isSandboxMode = (): boolean => { + return process.env.STRIPE_SANDBOX === 'true' +} + +export const getProductConfig = (licenseType: string): ProductConfig | null => { + const configs = isSandboxMode() ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS return configs[licenseType] || null } -export const getProductConfigByProductId = (productId: string, sandbox = false): ProductConfig | null => { - const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS +export const getProductConfigByProductId = (productId: string): ProductConfig | null => { + const configs = isSandboxMode() ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS return Object.values(configs).find(config => config.id === productId) || null } -export const isValidProductId = (licenseType: string, sandbox = false): boolean => { - const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS +export const isValidProductId = (licenseType: string): boolean => { + const configs = isSandboxMode() ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS return licenseType in configs } -export const getValidProductIds = (sandbox = false): string[] => { - const configs = sandbox ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS +export const getValidProductIds = (): string[] => { + const configs = isSandboxMode() ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS return Object.keys(configs) } \ No newline at end of file diff --git a/controllers/CheckoutController.ts b/controllers/CheckoutController.ts index cbd902c..6c23c92 100644 --- a/controllers/CheckoutController.ts +++ b/controllers/CheckoutController.ts @@ -13,13 +13,13 @@ const createCheckout = async (req: Request, res: Response): Promise => { throw new ApiError('User authentication required', 401) } - const { licenseType, sandbox } = req.body + const { licenseType } = req.body if(!licenseType) { throw new ApiError('licenseType is required', 422) } - const productConfig = getProductConfig(licenseType, sandbox) + const productConfig = getProductConfig(licenseType) if (!productConfig) { throw new ApiError(`Invalid licenseType: ${licenseType}`, 422) diff --git a/controllers/WebhookController.ts b/controllers/WebhookController.ts new file mode 100644 index 0000000..c646198 --- /dev/null +++ b/controllers/WebhookController.ts @@ -0,0 +1,81 @@ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { StripeService } from '../services/StripeService.js' +import { WebhookService } from '../services/WebhookService.js' +import { asyncHandler, ApiError } from '../middleware/errorHandler.js' +import type Stripe from 'stripe' + +const router = Router() + +const handleStripeWebhook = async (req: Request, res: Response): Promise => { + const signature = req.headers['stripe-signature'] as string + + if (!signature) { + throw new ApiError('No signature header received', 400) + } + + const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET + if (!endpointSecret) { + throw new ApiError('Stripe webhook secret not configured', 500) + } + + let event: Stripe.Event + try { + const stripe = StripeService.getStripeClient() + event = await stripe.webhooks.constructEventAsync( + req.body, + signature, + endpointSecret + ) + } catch (err) { + console.error(`Webhook signature verification failed: ${(err as Error).message}`) + throw new ApiError(`Webhook signature verification failed: ${(err as Error).message}`, 400) + } + + try { + + // what events occur during the subscription lifecycle for someone purchasing a subscription (annual or monthly) + // 1. customer.subscription.created + // 2. customer.subscription.updated + // 3. customer.subscription.deleted + + // can a user cancel their own subscription through stripe or does it have to be done by the admin? + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session + console.log('checkout.session.completed', session) + await WebhookService.handleCheckoutSessionCompleted(session) + break + } + + case 'customer.subscription.updated': { + const subscription = event.data.object as Stripe.Subscription + await WebhookService.handleSubscriptionUpdated(subscription) + break + } + + case 'customer.subscription.deleted': { + const subscription = event.data.object as Stripe.Subscription + await WebhookService.handleSubscriptionDeleted(subscription) + break + } + } + + res.json({ received: true }) + } catch (error) { + console.error('Webhook processing error:', error) + if (error instanceof ApiError) { + throw error + } + throw new ApiError('Webhook processing failed', 400) + } +} + +// Note: This endpoint should NOT use authentication middleware since it's called by Stripe +router.post('/stripe', asyncHandler(handleStripeWebhook)) + +export const WebhookController = { + router, + handleStripeWebhook +} \ No newline at end of file diff --git a/repos/License.ts b/repos/License.ts index c0a6764..df98036 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -9,7 +9,7 @@ export interface License { license_type: LicenseType; status?: LicenseStatus; purchase_date: Date; - expiration_date: Date; + expiration_date?: Date; stripe_customer_id?: string; stripe_payment_id?: string; created_at: Date; @@ -20,7 +20,7 @@ export interface CreateLicenseData { user_id: string; license_type: LicenseType; status?: LicenseStatus; - expiration_date: Date; + expiration_date?: Date; purchase_date?: Date; stripe_customer_id?: string; stripe_payment_id?: string; @@ -86,36 +86,7 @@ const deleteLicense = async (userId: string): Promise => { return deletedCount > 0 } -const upsertLicense = async (data: UpsertLicenseData): Promise => { - const existingLicense = await getActiveLicenseByUserId(data.user_id) - - if (existingLicense) { - const [updated] = await db(tableName) - .where({ user_id: data.user_id }) - .update({ - license_type: data.license_type, - status: data.status, - purchase_date: data.purchase_date, - expiration_date: data.expiration_date, - stripe_customer_id: data.stripe_customer_id, - stripe_payment_id: data.stripe_payment_id, - updated_at: new Date() - }) - .returning('*') - - return updated - } else { - return createLicense({ - user_id: data.user_id, - license_type: data.license_type, - status: data.status, - purchase_date: data.purchase_date, - expiration_date: data.expiration_date || new Date(), - stripe_customer_id: data.stripe_customer_id, - stripe_payment_id: data.stripe_payment_id, - }) - } -} + const updateLicenseByStripePaymentId = async (stripePaymentId: string, status: LicenseStatus): Promise => { const [license] = await db(tableName) @@ -134,6 +105,5 @@ export const LicenseRepo = { createLicense, updateLicense, deleteLicense, - upsertLicense, updateLicenseByStripePaymentId, } \ No newline at end of file diff --git a/repos/__tests__/License.test.ts b/repos/__tests__/License.test.ts index 836978c..2c1ce38 100644 --- a/repos/__tests__/License.test.ts +++ b/repos/__tests__/License.test.ts @@ -94,13 +94,13 @@ describe('LicenseRepo', () => { expect(new Date(result?.expiration_date || '')).toEqual(futureExpiration) }) }) - // describe('upsertLicense', () => { - // it('should upsert a license', async () => { - // const license = await LicenseRepo.upsertLicense({ - // user_id: user1Id, - // license_type: 'subscription', - // status: 'active', - // expiration_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - // }) - // }) + + describe('updateLicenseByStripePaymentId', () => { + it('should update a license', async () => { + const result = await LicenseRepo.updateLicenseByStripePaymentId('pi_test123', 'expired') + expect(result).not.toBeNull() + expect(result?.user_id).toBe(user1Id) + expect(result?.status).toBe('expired') + }) + }) }) \ No newline at end of file diff --git a/services/WebhookService.ts b/services/WebhookService.ts new file mode 100644 index 0000000..12b1f4f --- /dev/null +++ b/services/WebhookService.ts @@ -0,0 +1,94 @@ +import { LicenseRepo, type LicenseStatus } from '../repos/License.js' +import { getProductConfigByProductId } from '../config/products.js' +import { ApiError } from '../middleware/errorHandler.js' +import type Stripe from 'stripe' + +const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session): Promise => { + const customerId = session.customer as string + const userId = session.client_reference_id || session.metadata?.user_id + const productId = session.metadata?.product_id + + console.log('handleCheckoutSessionCompleted', session) + console.log('session', JSON.stringify(session, null, 2)) + console.log('customerId', customerId) + console.log('userId', userId) + console.log('productId', productId) + + if (!userId) { + console.error('Webhook Error: No user ID found in checkout session.') + throw new ApiError('No user ID found in session', 422) + } + + if (!productId) { + console.error('Webhook Error: No product ID found in session metadata.') + throw new ApiError('No product ID found in session', 422) + } + + const productConfig = getProductConfigByProductId(productId) + if (!productConfig) { + console.error(`Webhook Error: Unknown product ID: ${productId}`) + throw new ApiError(`Unknown product ID: ${productId}`, 422) + } + + // Only create license for subscription mode (one-time payments handled elsewhere) + if (session.mode === 'subscription') { + await LicenseRepo.createLicense({ + user_id: userId, + status: 'active', + license_type: productConfig.licenseType, + purchase_date: new Date(), + stripe_customer_id: customerId, + stripe_payment_id: session.subscription as string, + }) + + console.log(`License created for user ${userId} with checkout session ${session.id}`) + } +} + +const handleSubscriptionUpdated = async (subscription: Stripe.Subscription): Promise => { + const customerId = subscription.customer as string + let userId: string | null | undefined = subscription.metadata?.user_id + + if (!userId) { + console.warn(`No user_id found in subscription metadata for subscription ${subscription.id}`) + throw new ApiError('No user ID found for subscription', 422) + } + + const firstItem = subscription.items.data[0] + if (!firstItem || !firstItem.price?.product) { + console.error('Webhook Error: No product found in subscription items.') + throw new ApiError('No product found in subscription', 422) + } + + const productId = typeof firstItem.price.product === 'string' + ? firstItem.price.product + : firstItem.price.product.id + + const productConfig = getProductConfigByProductId(productId) + if (!productConfig) { + console.error(`Webhook Error: Unknown product ID in subscription: ${productId}`) + throw new ApiError(`Unknown product ID in subscription: ${productId}`, 422) + } + + const status = subscription.status === 'active' || subscription.status === 'trialing' ? 'active' : 'expired' + + await LicenseRepo.updateLicenseByStripePaymentId(subscription.id, status as LicenseStatus) + + console.log(`License updated for user ${userId} with subscription ${subscription.id}, status: ${status}`) +} + +const handleSubscriptionDeleted = async (subscription: Stripe.Subscription): Promise => { + const updatedLicense = await LicenseRepo.updateLicenseByStripePaymentId(subscription.id, 'expired') + + if (!updatedLicense) { + console.warn(`Failed to update license status for deleted subscription ${subscription.id}. Maybe it didn't exist?`) + } else { + console.log(`License expired for subscription ${subscription.id}`) + } +} + +export const WebhookService = { + handleCheckoutSessionCompleted, + handleSubscriptionUpdated, + handleSubscriptionDeleted, +} \ No newline at end of file diff --git a/tests/helpers/mockCleanup.ts b/tests/helpers/mockCleanup.ts deleted file mode 100644 index 052d61c..0000000 --- a/tests/helpers/mockCleanup.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { UserProfileRepo } from '../../repos/UserProfile' -import { LicenseRepo } from '../../repos/License' - -// Store original methods at module load time -const originalMethods = { - UserProfileRepo: { - getUserStatusCounts: UserProfileRepo.getUserStatusCounts, - }, - LicenseRepo: { - getLicenseByUserId: LicenseRepo.getLicenseByUserId, - createLicense: LicenseRepo.createLicense, - }, -} - -/** - * Restores all repository methods to their original implementations - * Should be called between test files to ensure clean state - */ -export const restoreAllMocks = () => { - UserProfileRepo.getUserStatusCounts = originalMethods.UserProfileRepo.getUserStatusCounts - LicenseRepo.getLicenseByUserId = originalMethods.LicenseRepo.getLicenseByUserId - LicenseRepo.createLicense = originalMethods.LicenseRepo.createLicense -} \ No newline at end of file diff --git a/tests/services/WebhookService.test.ts b/tests/services/WebhookService.test.ts new file mode 100644 index 0000000..124e975 --- /dev/null +++ b/tests/services/WebhookService.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, test, mock } from 'bun:test' +import { WebhookService } from '../../services/WebhookService.js' +import { LicenseRepo } from '../../repos/License.js' +import type Stripe from 'stripe' + +mock.module('../../repos/License.js', () => ({ + LicenseRepo: { + createLicense: mock(() => Promise.resolve({ id: 'license-123' })), + updateLicenseByStripePaymentId: mock(() => Promise.resolve({ id: 'license-123' })) + } +})) + + +describe('WebhookService', () => { + beforeEach(() => { + mock.restore() + process.env.STRIPE_SANDBOX = 'true' + }) + + describe('handleCheckoutSessionCompleted', () => { + test('should create license for valid checkout session with metadata', async () => { + const mockSession = { + id: 'cs_test_b17yWHosFRVEk62aEegAVsREUlB415PcJqoS1B2ZNEGnVMgVm0Bfpnyz1C', + object: 'checkout.session', + customer: 'cus_Sxs0V3mlJjxzg7', + client_reference_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + created: 1756589109, + mode: 'subscription', + status: 'complete', + payment_status: 'paid', + subscription: 'sub_1S1wGyAuvxsld26UyyEWlujr', + metadata: { + user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + product_id: 'prod_SxoT12GtQeAlIE' + }, + success_url: 'https://ebb.cool/license/callback?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://ebb.cool/license/callback' + } as unknown as Stripe.Checkout.Session + + await WebhookService.handleCheckoutSessionCompleted(mockSession) + + expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ + user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + status: 'active', + license_type: 'subscription', + purchase_date: expect.any(Date), + stripe_customer_id: 'cus_Sxs0V3mlJjxzg7', + stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr' + }) + }) + }) +}) \ No newline at end of file From 8a9206c4984b5d035bf6f087523f8e37a1d1908f Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 30 Aug 2025 15:45:57 -0600 Subject: [PATCH 06/13] added tests for webhook checkout --- tests/services/WebhookService.test.ts | 73 ++++++++++++++++++++------- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/tests/services/WebhookService.test.ts b/tests/services/WebhookService.test.ts index 124e975..12810e4 100644 --- a/tests/services/WebhookService.test.ts +++ b/tests/services/WebhookService.test.ts @@ -10,6 +10,23 @@ mock.module('../../repos/License.js', () => ({ } })) +let defaultMockSession = { + id: 'cs_test_b17yWHosFRVEk62aEegAVsREUlB415PcJqoS1B2ZNEGnVMgVm0Bfpnyz1C', + object: 'checkout.session', + customer: 'cus_Sxs0V3mlJjxzg7', + client_reference_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + created: 1756589109, + mode: 'subscription', + status: 'complete', + payment_status: 'paid', + subscription: 'sub_1S1wGyAuvxsld26UyyEWlujr', + metadata: { + user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + product_id: 'prod_SxoT12GtQeAlIE' + }, + success_url: 'https://ebb.cool/license/callback?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://ebb.cool/license/callback' +} as unknown as Stripe.Checkout.Session describe('WebhookService', () => { beforeEach(() => { @@ -19,25 +36,8 @@ describe('WebhookService', () => { describe('handleCheckoutSessionCompleted', () => { test('should create license for valid checkout session with metadata', async () => { - const mockSession = { - id: 'cs_test_b17yWHosFRVEk62aEegAVsREUlB415PcJqoS1B2ZNEGnVMgVm0Bfpnyz1C', - object: 'checkout.session', - customer: 'cus_Sxs0V3mlJjxzg7', - client_reference_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', - created: 1756589109, - mode: 'subscription', - status: 'complete', - payment_status: 'paid', - subscription: 'sub_1S1wGyAuvxsld26UyyEWlujr', - metadata: { - user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', - product_id: 'prod_SxoT12GtQeAlIE' - }, - success_url: 'https://ebb.cool/license/callback?session_id={CHECKOUT_SESSION_ID}', - cancel_url: 'https://ebb.cool/license/callback' - } as unknown as Stripe.Checkout.Session - await WebhookService.handleCheckoutSessionCompleted(mockSession) + await WebhookService.handleCheckoutSessionCompleted(defaultMockSession) expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', @@ -48,5 +48,42 @@ describe('WebhookService', () => { stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr' }) }) + + test('should throw error when no user ID found', async () => { + const mockSession = { + ...defaultMockSession, + client_reference_id: null, + metadata: { + ...defaultMockSession.metadata, + user_id: undefined as any + } + } + await expect(WebhookService.handleCheckoutSessionCompleted(mockSession)) + .rejects.toThrow('No user ID found in session') + }) + + test('should throw error when no product ID found in metadata', async () => { + const mockSession = { + ...defaultMockSession, + metadata: { + ...defaultMockSession.metadata, + product_id: undefined as any + } + } + await expect(WebhookService.handleCheckoutSessionCompleted(mockSession)) + .rejects.toThrow('No product ID found in session') + }) + + test('should throw error for unknown product ID', async () => { + const mockSession = { + ...defaultMockSession, + metadata: { + ...defaultMockSession.metadata, + product_id: 'prod_UNKNOWN' + } + } + await expect(WebhookService.handleCheckoutSessionCompleted(mockSession)) + .rejects.toThrow('Unknown product ID: prod_UNKNOWN') + }) }) }) \ No newline at end of file From c687f3bb00cb545f79feb3339f6b1ee025f46572 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sat, 30 Aug 2025 15:57:12 -0600 Subject: [PATCH 07/13] skip sending paid user notification in sandbox --- services/JobProcessors.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/services/JobProcessors.ts b/services/JobProcessors.ts index dd5b69b..ed5ac50 100644 --- a/services/JobProcessors.ts +++ b/services/JobProcessors.ts @@ -183,6 +183,16 @@ export const processPaidUserCheck = async (job: Job): Prom try { console.log('๐Ÿ’ณ Processing paid user check job...') + // Skip notifications in sandbox/development mode + if (process.env.STRIPE_SANDBOX === 'true') { + console.log('โญ๏ธ Skipping paid user notifications in sandbox mode') + return { + success: true, + message: 'Skipped notifications in sandbox mode', + processedAt: new Date() + } + } + // Get users who upgraded to paid in the last 10 minutes const paidUsers = await UserMonitoringRepo.getPaidUsers(10) console.log(`๐Ÿ“Š Found ${paidUsers.length} recently paid users`) From 89589482a8afd671350a2db2ce3b366d2124399f Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 31 Aug 2025 12:12:29 -0600 Subject: [PATCH 08/13] send email and discord when payment failed --- controllers/WebhookController.ts | 9 +- services/EmailService.ts | 85 ++++++++++++++++ services/FriendsService.ts | 46 ++------- services/WebhookService.ts | 97 +++++++++++-------- .../providers/DiscordNotificationProvider.ts | 44 +++++++++ types/notifications.ts | 2 +- 6 files changed, 201 insertions(+), 82 deletions(-) create mode 100644 services/EmailService.ts diff --git a/controllers/WebhookController.ts b/controllers/WebhookController.ts index c646198..18cf6e2 100644 --- a/controllers/WebhookController.ts +++ b/controllers/WebhookController.ts @@ -36,7 +36,7 @@ const handleStripeWebhook = async (req: Request, res: Response): Promise = // what events occur during the subscription lifecycle for someone purchasing a subscription (annual or monthly) // 1. customer.subscription.created - // 2. customer.subscription.updated + // 2. invoice.payment_failed // 3. customer.subscription.deleted // can a user cancel their own subscription through stripe or does it have to be done by the admin? @@ -49,9 +49,10 @@ const handleStripeWebhook = async (req: Request, res: Response): Promise = break } - case 'customer.subscription.updated': { - const subscription = event.data.object as Stripe.Subscription - await WebhookService.handleSubscriptionUpdated(subscription) + case 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + console.log('invoice.payment_failed', invoice.id) + await WebhookService.handleInvoicePaymentFailed(invoice) break } diff --git a/services/EmailService.ts b/services/EmailService.ts new file mode 100644 index 0000000..f992e2b --- /dev/null +++ b/services/EmailService.ts @@ -0,0 +1,85 @@ +interface LoopsEmailPayload { + transactionalId: string + email: string + dataVariables: Record +} + +interface PaymentFailureEmailData { + customerEmail: string + customerName?: string + amountDue: number + currency: string +} + +interface FriendRequestEmailData { + toEmail: string + fromEmail: string + requestId: string + existingUser?: boolean +} + +const sendLoopsEmail = async (payload: LoopsEmailPayload): Promise => { + try { + const loopsApiKey = process.env.LOOPS_API_KEY + if (!loopsApiKey) { + console.error('LOOPS_API_KEY not configured, skipping email send') + return + } + + const response = await fetch('https://app.loops.so/api/v1/transactional', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${loopsApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Failed to send email via Loops:', response.status, errorText) + return + } + + const result = await response.json() + console.log('Email sent successfully via Loops:', result) + + } catch (error) { + console.error('Failed to send email:', error) + // Don't throw here - we don't want email failures to prevent other operations + } +} + +const sendPaymentFailureEmail = async (data: PaymentFailureEmailData): Promise => { + const payload: LoopsEmailPayload = { + transactionalId: 'YOUR_PAYMENT_FAILURE_TEMPLATE_ID', // Replace with your Loops template ID + email: data.customerEmail, + dataVariables: { + customer_email: data.customerEmail, + customer_name: data.customerName || '', + formatted_amount: `${data.currency.toUpperCase()} $${(data.amountDue / 100).toFixed(2)}` + } + } + + await sendLoopsEmail(payload) +} + +const sendFriendRequestEmail = async (data: FriendRequestEmailData): Promise => { + const payload: LoopsEmailPayload = { + transactionalId: data.existingUser ? 'cmc3u8e020700z00iason0m0f' : 'cmc6k356p2tf0zq0jg9y0atvr', + email: data.toEmail, + dataVariables: { + to_email: data.toEmail, + from_email: data.fromEmail, + request_id: data.requestId + } + } + + await sendLoopsEmail(payload) +} + +export const EmailService = { + sendPaymentFailureEmail, + sendFriendRequestEmail, + sendLoopsEmail +} \ No newline at end of file diff --git a/services/FriendsService.ts b/services/FriendsService.ts index a09a1e5..0101342 100644 --- a/services/FriendsService.ts +++ b/services/FriendsService.ts @@ -1,6 +1,7 @@ import { FriendsRepo, type FriendRequest, type Friend, type FriendRequestWithUser } from '../repos/Friends.js' import { ApiError } from '../middleware/errorHandler.js' import { getDb } from '../config/database.js' +import { EmailService } from './EmailService.js' import type { Request } from 'express' const db = getDb() @@ -15,45 +16,12 @@ interface AcceptRejectFriendRequest { } const sendFriendRequestEmail = async (toEmail: string, fromUserEmail: string, friendRequestId: string, existingUser: boolean = false): Promise => { - try { - const loopsApiKey = process.env.LOOPS_API_KEY - if (!loopsApiKey) { - console.error('LOOPS_API_KEY not configured, skipping email send') - return - } - - const payload = { - transactionalId: existingUser ? 'cmc3u8e020700z00iason0m0f' : 'cmc6k356p2tf0zq0jg9y0atvr', // Your friend request template ID - email: toEmail, - dataVariables: { - to_email: toEmail, - from_email: fromUserEmail, - request_id: friendRequestId - } - } - - const response = await fetch('https://app.loops.so/api/v1/transactional', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${loopsApiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload) - }) - - if (!response.ok) { - const errorText = await response.text() - console.error('Failed to send email via Loops:', response.status, errorText) - return - } - - const result = await response.json() - console.log('Friend request email sent successfully via Loops:', result) - - } catch (error) { - console.error('Failed to send friend request email:', error) - // Don't throw here - we don't want email failures to prevent friend request creation - } + await EmailService.sendFriendRequestEmail({ + toEmail, + fromEmail: fromUserEmail, + requestId: friendRequestId, + existingUser + }) } const inviteFriend = async (req: Request): Promise => { diff --git a/services/WebhookService.ts b/services/WebhookService.ts index 12b1f4f..69d6d48 100644 --- a/services/WebhookService.ts +++ b/services/WebhookService.ts @@ -1,18 +1,21 @@ import { LicenseRepo, type LicenseStatus } from '../repos/License.js' import { getProductConfigByProductId } from '../config/products.js' import { ApiError } from '../middleware/errorHandler.js' +import { EmailService } from './EmailService.js' +import { NotificationEngine } from './NotificationEngine.js' +import { getNotificationConfig } from '../config/notifications.js' import type Stripe from 'stripe' +// Shared notification engine instance +const getNotificationEngine = (): NotificationEngine => { + const config = getNotificationConfig() + return new NotificationEngine(config) +} + const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session): Promise => { const customerId = session.customer as string const userId = session.client_reference_id || session.metadata?.user_id const productId = session.metadata?.product_id - - console.log('handleCheckoutSessionCompleted', session) - console.log('session', JSON.stringify(session, null, 2)) - console.log('customerId', customerId) - console.log('userId', userId) - console.log('productId', productId) if (!userId) { console.error('Webhook Error: No user ID found in checkout session.') @@ -45,37 +48,6 @@ const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session): } } -const handleSubscriptionUpdated = async (subscription: Stripe.Subscription): Promise => { - const customerId = subscription.customer as string - let userId: string | null | undefined = subscription.metadata?.user_id - - if (!userId) { - console.warn(`No user_id found in subscription metadata for subscription ${subscription.id}`) - throw new ApiError('No user ID found for subscription', 422) - } - - const firstItem = subscription.items.data[0] - if (!firstItem || !firstItem.price?.product) { - console.error('Webhook Error: No product found in subscription items.') - throw new ApiError('No product found in subscription', 422) - } - - const productId = typeof firstItem.price.product === 'string' - ? firstItem.price.product - : firstItem.price.product.id - - const productConfig = getProductConfigByProductId(productId) - if (!productConfig) { - console.error(`Webhook Error: Unknown product ID in subscription: ${productId}`) - throw new ApiError(`Unknown product ID in subscription: ${productId}`, 422) - } - - const status = subscription.status === 'active' || subscription.status === 'trialing' ? 'active' : 'expired' - - await LicenseRepo.updateLicenseByStripePaymentId(subscription.id, status as LicenseStatus) - - console.log(`License updated for user ${userId} with subscription ${subscription.id}, status: ${status}`) -} const handleSubscriptionDeleted = async (subscription: Stripe.Subscription): Promise => { const updatedLicense = await LicenseRepo.updateLicenseByStripePaymentId(subscription.id, 'expired') @@ -87,8 +59,57 @@ const handleSubscriptionDeleted = async (subscription: Stripe.Subscription): Pro } } +const handleInvoicePaymentFailed = async (invoice: Stripe.Invoice): Promise => { + console.log('Payment failed for invoice:', invoice.id) + + // Send email notification if customer email is available + if (invoice.customer_email) { + try { + await EmailService.sendPaymentFailureEmail({ + customerEmail: invoice.customer_email, + customerName: invoice.customer_name as string | undefined, + amountDue: invoice.amount_due, + currency: invoice.currency + }) + + console.log(`Payment failure email sent to ${invoice.customer_email}`) + } catch (error) { + console.error('Failed to send payment failure email:', error) + // Don't throw - we don't want email failures to break webhook processing + } + } else { + console.warn('No customer email found for failed payment, skipping email notification') + } + + // Send Discord notification using NotificationEngine + try { + const notificationEngine = getNotificationEngine() + + await notificationEngine.sendNotification({ + type: 'payment_failed', + user: { + id: invoice.customer as string || 'unknown', + email: invoice.customer_email || 'No email available' + }, + referenceId: `payment_failed_${invoice.id}`, + data: { + invoice_id: invoice.id, + amount_due: invoice.amount_due, + currency: invoice.currency, + customer_name: invoice.customer_name, + formatted_amount: `${invoice.currency.toUpperCase()} $${(invoice.amount_due / 100).toFixed(2)}` + } + }, ['discord']) + + console.log(`Discord notification sent for payment failure: ${invoice.id}`) + } catch (error) { + console.error('Failed to send Discord notification for payment failure:', error) + // Don't throw - we don't want Discord failures to break webhook processing + } +} + export const WebhookService = { handleCheckoutSessionCompleted, - handleSubscriptionUpdated, handleSubscriptionDeleted, + handleInvoicePaymentFailed, } \ No newline at end of file diff --git a/services/providers/DiscordNotificationProvider.ts b/services/providers/DiscordNotificationProvider.ts index aff6cc4..228d4b1 100644 --- a/services/providers/DiscordNotificationProvider.ts +++ b/services/providers/DiscordNotificationProvider.ts @@ -66,6 +66,8 @@ export class DiscordNotificationProvider implements NotificationProvider { return this.formatInactiveUserNotification(payload) case 'weekly_report': return this.formatWeeklyReportNotification(payload) + case 'payment_failed': + return this.formatPaymentFailedNotification(payload) default: return this.formatGenericNotification(payload) } @@ -201,6 +203,48 @@ export class DiscordNotificationProvider implements NotificationProvider { } } + private formatPaymentFailedNotification(payload: NotificationPayload): DiscordWebhookPayload { + const embed: DiscordEmbed = { + title: 'โŒ Payment Failed', + description: `Payment failed for customer`, + color: 0xFF0000, // Red color + fields: [ + { + name: '๐Ÿ“ง Customer', + value: payload.user.email, + inline: true + }, + { + name: '๐Ÿ’ฐ Amount Due', + value: payload.data?.formatted_amount || 'Unknown', + inline: true + }, + { + name: '๐Ÿงพ Invoice', + value: payload.data?.invoice_id || 'Unknown', + inline: true + } + ], + footer: { + text: 'Ebb Platform Notifications' + }, + timestamp: new Date().toISOString() + } + + if (payload.data?.customer_name) { + embed.fields?.push({ + name: '๐Ÿ‘ค Customer Name', + value: payload.data.customer_name, + inline: true + }) + } + + return { + embeds: [embed], + username: 'Ebb Notifications', + } + } + private formatGenericNotification(payload: NotificationPayload): DiscordWebhookPayload { const embed: DiscordEmbed = { title: '๐Ÿ”” Notification', diff --git a/types/notifications.ts b/types/notifications.ts index 93423dd..404a008 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -2,7 +2,7 @@ import type { PaidUserRecord, NewUserRecord, InactiveUserRecord } from './jobs' export type { PaidUserRecord, NewUserRecord, InactiveUserRecord } from './jobs' -export type NotificationType = 'paid_user' | 'new_user' | 'inactive_user' | 'weekly_report' +export type NotificationType = 'paid_user' | 'new_user' | 'inactive_user' | 'weekly_report' | 'payment_failed' export type NotificationChannel = 'discord' | 'email' | 'slack' | 'sms' export interface BaseUserRecord { From 260a121478b083f92f9b6829a5d1de5b6f00c01b Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 31 Aug 2025 14:34:31 -0600 Subject: [PATCH 09/13] cancel subscription endpoint and update free trial on subscription --- controllers/LicenseController.ts | 69 ++++++++++++++++++++++++ controllers/UserController.ts | 13 ----- index.ts | 3 ++ repos/License.ts | 9 ++++ services/StripeService.ts | 19 +++++++ services/WebhookService.ts | 10 +++- tests/services/WebhookService.test.ts | 77 ++++++++++++++++++++++++++- 7 files changed, 185 insertions(+), 15 deletions(-) create mode 100644 controllers/LicenseController.ts diff --git a/controllers/LicenseController.ts b/controllers/LicenseController.ts new file mode 100644 index 0000000..c25fda2 --- /dev/null +++ b/controllers/LicenseController.ts @@ -0,0 +1,69 @@ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { StripeService } from '../services/StripeService.js' +import { LicenseService } from '../services/LicenseService.js' +import { AuthMiddleware } from '../middleware/auth.js' +import { asyncHandler, ApiError } from '../middleware/errorHandler.js' + +const router = Router() + +const cancelLicense = async (req: Request, res: Response): Promise => { + if (!req.user) { + throw new ApiError('User authentication required', 401) + } + + try { + // Get user's active license to find subscription ID + const activeLicense = await LicenseService.getActiveLicense(req.user.id) + + if (!activeLicense) { + throw new ApiError('No active license found', 404) + } + + if (!activeLicense.stripe_payment_id) { + throw new ApiError('No Stripe subscription ID found for license', 400) + } + + // Cancel the subscription in Stripe + const canceledSubscription = await StripeService.cancelSubscription(activeLicense.stripe_payment_id) + + res.json({ + success: true, + message: 'License cancellation scheduled at period end', + data: { + license_id: activeLicense.id, + stripe_subscription_id: canceledSubscription.id, + canceled_at: canceledSubscription.canceled_at, + cancel_at_period_end: canceledSubscription.cancel_at_period_end + } + }) + } catch (error) { + if (error instanceof ApiError) { + throw error + } + console.error('Error canceling license:', error) + throw new ApiError('Failed to cancel license', 500) + } +} + +const startTrial = async (req: Request, res: Response): Promise => { + if (!req.user) { + throw new ApiError('User authentication required', 401) + } + + await LicenseService.startFreeTrial(req.user.id) + + res.json({ + success: true, + message: 'Free trial started successfully' + }) +} + +router.post('/cancel', AuthMiddleware.authenticateToken, asyncHandler(cancelLicense)) +router.post('/start-trial', AuthMiddleware.authenticateToken, asyncHandler(startTrial)) + +export const LicenseController = { + router, + cancelLicense, + startTrial +} \ No newline at end of file diff --git a/controllers/UserController.ts b/controllers/UserController.ts index 64b33da..2f29a6f 100644 --- a/controllers/UserController.ts +++ b/controllers/UserController.ts @@ -42,28 +42,15 @@ const getUserProfile = async (req: Request, res: Response): Promise => { }) } -const startTrial = async (req: Request, res: Response): Promise => { - if (!req.user) { - throw new ApiError('User authentication required', 401) - } - - await LicenseService.startFreeTrial(req.user.id) - res.json({ - success: true, - message: 'Free trial started successfully' - }) -} router.get('/status-counts', AuthMiddleware.authenticateToken, asyncHandler(getStatusCounts)) router.post('/location', AuthMiddleware.authenticateToken, asyncHandler(saveUserLocation)) router.get('/locations', AuthMiddleware.authenticateToken, asyncHandler(getUserLocations)) router.get('/profile/me', AuthMiddleware.authenticateToken, asyncHandler(getUserProfile)) -router.post('/start-trial', AuthMiddleware.authenticateToken, asyncHandler(startTrial)) export const UserController = { router, getStatusCounts, saveUserLocation, - startTrial } diff --git a/index.ts b/index.ts index 09081ff..2c87d2d 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import { JobQueueController } from './controllers/JobQueueController' import { SlackController } from './controllers/SlackController' import { NotificationTestController } from './controllers/NotificationTestController' import { CheckoutController } from './controllers/CheckoutController' +import { LicenseController } from './controllers/LicenseController' import { WebhookController } from './controllers/WebhookController' import { GeoLocationService } from './services/GeoLocationService' import { jobQueueService } from './services/JobQueueService' @@ -55,6 +56,7 @@ app.use('/api/jobs', JobQueueController.router) app.use('/api/slack', SlackController.router) app.use('/api/notifications', NotificationTestController.router) app.use('/api/checkout', CheckoutController.router) +app.use('/api/license', LicenseController.router) // Public API Routes (no authentication required) app.use('/api/marketing', MarketingController.router) @@ -195,6 +197,7 @@ export const startServer = async (port: number = PORT) => { console.log(`โš™๏ธ Job Queue API (auth required): http://localhost:${port}/api/jobs`) console.log(`๐Ÿ’ฌ Slack API (auth required): http://localhost:${port}/api/slack`) console.log(`๐Ÿ’ณ Checkout API (auth required): http://localhost:${port}/api/checkout`) + console.log(`๐ŸŽซ License API (auth required): http://localhost:${port}/api/license`) console.log(`๐Ÿ”— Webhooks API (public): http://localhost:${port}/api/webhooks`) console.log(`โš™๏ธ Job Queue: User monitoring jobs scheduled (requires Redis)`) console.log('๐Ÿ” Authentication: Supabase JWT required for /api routes (except marketing, webhooks, and slack/events)') diff --git a/repos/License.ts b/repos/License.ts index df98036..a59552c 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -50,6 +50,14 @@ const getActiveLicenseByUserId = async (userId: string): Promise return result || null } +const getFreeTrialLicenseByUserId = async (userId: string): Promise => { + const result = await db(tableName) + .where({ user_id: userId, license_type: 'free_trial' }) + .first('*') + + return result || null +} + const createLicense = async (data: CreateLicenseData): Promise => { const [license] = await db(tableName) .insert({ @@ -102,6 +110,7 @@ const updateLicenseByStripePaymentId = async (stripePaymentId: string, status: L export const LicenseRepo = { getActiveLicenseByUserId, + getFreeTrialLicenseByUserId, createLicense, updateLicense, deleteLicense, diff --git a/services/StripeService.ts b/services/StripeService.ts index ad7fb17..33eeb1f 100644 --- a/services/StripeService.ts +++ b/services/StripeService.ts @@ -71,7 +71,26 @@ const createCheckoutSession = async (params: CreateCheckoutSessionParams): Promi return session.url } +const cancelSubscription = async (subscriptionId: string): Promise => { + const stripe = getStripeClient() + + try { + // Cancel subscription at period end (graceful cancellation) + const subscription = await stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true + }) + + console.log(`Subscription ${subscriptionId} marked for cancellation at period end`) + return subscription + } catch (error) { + console.error(`Failed to cancel subscription ${subscriptionId}:`, error) + throw new ApiError(`Failed to cancel subscription: ${error}`, 500) + } +} + + export const StripeService = { createCheckoutSession, + cancelSubscription, getStripeClient } \ No newline at end of file diff --git a/services/WebhookService.ts b/services/WebhookService.ts index 69d6d48..eea4f7d 100644 --- a/services/WebhookService.ts +++ b/services/WebhookService.ts @@ -35,6 +35,15 @@ const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session): // Only create license for subscription mode (one-time payments handled elsewhere) if (session.mode === 'subscription') { + // if we have a free trial license, we need to update it to end now + const existingTrialLicense = await LicenseRepo.getFreeTrialLicenseByUserId(userId) + if (existingTrialLicense) { + await LicenseRepo.updateLicense(existingTrialLicense.id, { + status: 'expired', + expiration_date: new Date(), + updated_at: new Date() + }) + } await LicenseRepo.createLicense({ user_id: userId, status: 'active', @@ -43,7 +52,6 @@ const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session): stripe_customer_id: customerId, stripe_payment_id: session.subscription as string, }) - console.log(`License created for user ${userId} with checkout session ${session.id}`) } } diff --git a/tests/services/WebhookService.test.ts b/tests/services/WebhookService.test.ts index 12810e4..be1870e 100644 --- a/tests/services/WebhookService.test.ts +++ b/tests/services/WebhookService.test.ts @@ -6,7 +6,9 @@ import type Stripe from 'stripe' mock.module('../../repos/License.js', () => ({ LicenseRepo: { createLicense: mock(() => Promise.resolve({ id: 'license-123' })), - updateLicenseByStripePaymentId: mock(() => Promise.resolve({ id: 'license-123' })) + updateLicenseByStripePaymentId: mock(() => Promise.resolve({ id: 'license-123' })), + getFreeTrialLicenseByUserId: mock(() => Promise.resolve(null)), + updateLicense: mock(() => Promise.resolve({ id: 'trial-license-456' })) } })) @@ -85,5 +87,78 @@ describe('WebhookService', () => { await expect(WebhookService.handleCheckoutSessionCompleted(mockSession)) .rejects.toThrow('Unknown product ID: prod_UNKNOWN') }) + + test('should expire existing free trial license when user upgrades to paid subscription', async () => { + // Mock that user has an existing free trial license + const existingTrialLicense = { id: 'trial-license-456', status: 'active' }; + (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValue(existingTrialLicense) + + await WebhookService.handleCheckoutSessionCompleted(defaultMockSession) + + // Should check for existing trial license + expect(LicenseRepo.getFreeTrialLicenseByUserId).toHaveBeenCalledWith('9a4f2bc8-3cac-4345-81a1-78da85dd1dc6') + + // Should expire the trial license + expect(LicenseRepo.updateLicense).toHaveBeenCalledWith('trial-license-456', { + status: 'expired', + expiration_date: expect.any(Date), + updated_at: expect.any(Date) + }) + + // Should still create the new subscription license + expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ + user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + status: 'active', + license_type: 'subscription', + purchase_date: expect.any(Date), + stripe_customer_id: 'cus_Sxs0V3mlJjxzg7', + stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr' + }) + }) + + test('should not call updateLicense when user has no existing free trial license', async () => { + // Clear the mock call history from previous tests + (LicenseRepo.updateLicense as any).mockClear() + + // Create a fresh mock session object for this test + const mockSession = { + id: 'cs_test_different_session', + object: 'checkout.session', + customer: 'cus_DifferentCustomer', + client_reference_id: 'different-user-id', + created: 1756589200, + mode: 'subscription', + status: 'complete', + payment_status: 'paid', + subscription: 'sub_DifferentSubscription', + metadata: { + user_id: 'different-user-id', + product_id: 'prod_SxoT12GtQeAlIE' + }, + success_url: 'https://ebb.cool/license/callback?session_id={CHECKOUT_SESSION_ID}', + cancel_url: 'https://ebb.cool/license/callback' + } as unknown as Stripe.Checkout.Session + + // Mock that user has no existing free trial license + (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValue(null) + + await WebhookService.handleCheckoutSessionCompleted(mockSession) + + // Should check for existing trial license + expect(LicenseRepo.getFreeTrialLicenseByUserId).toHaveBeenCalledWith('different-user-id') + + // Should NOT expire any trial license + expect(LicenseRepo.updateLicense).not.toHaveBeenCalled() + + // Should still create the new subscription license + expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ + user_id: 'different-user-id', + status: 'active', + license_type: 'subscription', + purchase_date: expect.any(Date), + stripe_customer_id: 'cus_DifferentCustomer', + stripe_payment_id: 'sub_DifferentSubscription' + }) + }) }) }) \ No newline at end of file From c15a96c24e42092f78ffd95816e3bde85f6fc3f3 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Sun, 31 Aug 2025 15:15:56 -0600 Subject: [PATCH 10/13] return licenses with no expiration date that are active --- repos/License.ts | 10 ++++++++-- repos/__tests__/License.test.ts | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/repos/License.ts b/repos/License.ts index a59552c..c8ec709 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -43,8 +43,14 @@ const tableName = 'license' const getActiveLicenseByUserId = async (userId: string): Promise => { const result = await db(tableName) .where({ user_id: userId, status: 'active' }) - .where('expiration_date', '>', new Date()) - .orderBy('expiration_date', 'desc') + .where(function() { + this.whereNull('expiration_date') + .orWhere('expiration_date', '>', new Date()) + }) + .orderBy([ + { column: 'expiration_date', order: 'desc', nulls: 'first' }, + { column: 'created_at', order: 'desc' } + ]) .first('*') return result || null diff --git a/repos/__tests__/License.test.ts b/repos/__tests__/License.test.ts index 2c1ce38..4cc41df 100644 --- a/repos/__tests__/License.test.ts +++ b/repos/__tests__/License.test.ts @@ -41,6 +41,14 @@ describe('LicenseRepo', () => { expect(result?.user_id).toBe(user1Id) expect(result?.status).toBe('active') }) + it('should return the active license when user has an active license and expiration date is null', async () => { + const db = getDb() + await db('license').update({ expiration_date: null }).where({ user_id: user1Id }) + const result = await LicenseRepo.getActiveLicenseByUserId(user1Id) + expect(result).toBeDefined() + expect(result?.user_id).toBe(user1Id) + expect(result?.status).toBe('active') + }) it('should filter out inactive licenses', async () => { const db = getDb() @@ -94,6 +102,7 @@ describe('LicenseRepo', () => { expect(new Date(result?.expiration_date || '')).toEqual(futureExpiration) }) }) + describe('updateLicenseByStripePaymentId', () => { it('should update a license', async () => { From ff46f26455e5764b28cf9af772b7581e8af0be9f Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Mon, 22 Sep 2025 19:56:12 -0600 Subject: [PATCH 11/13] fix upgrading a trial license to a pro license --- controllers/CheckoutController.ts | 2 +- repos/License.ts | 13 +++- services/WebhookService.ts | 23 +++++++- ...250830165210_add_free_trial_to_license.sql | 28 ++++++++- tests/services/WebhookService.test.ts | 59 +++++++++++++------ 5 files changed, 101 insertions(+), 24 deletions(-) diff --git a/controllers/CheckoutController.ts b/controllers/CheckoutController.ts index 6c23c92..04013c2 100644 --- a/controllers/CheckoutController.ts +++ b/controllers/CheckoutController.ts @@ -26,7 +26,7 @@ const createCheckout = async (req: Request, res: Response): Promise => { } const existingLicense = await LicenseService.getActiveLicense(req.user.id) - if (existingLicense) { + if (existingLicense && existingLicense.license_type !== 'free_trial') { throw new ApiError('User already has an active license', 422) } diff --git a/repos/License.ts b/repos/License.ts index c8ec709..76aab06 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -64,6 +64,14 @@ const getFreeTrialLicenseByUserId = async (userId: string): Promise => { + const result = await db(tableName) + .where({ user_id: userId, license_type: 'subscription' }) + .first('*') + + return result || null +} + const createLicense = async (data: CreateLicenseData): Promise => { const [license] = await db(tableName) .insert({ @@ -80,9 +88,9 @@ const createLicense = async (data: CreateLicenseData): Promise => { return license } -const updateLicense = async (userId: string, updates: Partial): Promise => { +const updateLicense = async (licenseId: string, updates: Partial): Promise => { const [license] = await db(tableName) - .where({ user_id: userId }) + .where({ id: licenseId }) .update({ ...updates, updated_at: new Date() @@ -117,6 +125,7 @@ const updateLicenseByStripePaymentId = async (stripePaymentId: string, status: L export const LicenseRepo = { getActiveLicenseByUserId, getFreeTrialLicenseByUserId, + getExistingSubscriptionLicenseByUserId, createLicense, updateLicense, deleteLicense, diff --git a/services/WebhookService.ts b/services/WebhookService.ts index eea4f7d..3133d88 100644 --- a/services/WebhookService.ts +++ b/services/WebhookService.ts @@ -37,13 +37,32 @@ const handleCheckoutSessionCompleted = async (session: Stripe.Checkout.Session): if (session.mode === 'subscription') { // if we have a free trial license, we need to update it to end now const existingTrialLicense = await LicenseRepo.getFreeTrialLicenseByUserId(userId) + console.log('Existing trial license:', existingTrialLicense) if (existingTrialLicense) { + console.log('Updating existing trial license') await LicenseRepo.updateLicense(existingTrialLicense.id, { - status: 'expired', - expiration_date: new Date(), + status: 'active', + license_type: productConfig.licenseType, + stripe_payment_id: session.subscription as string, updated_at: new Date() }) + return } + + console.log('Checking for existing subscription license') + const existingSubscriptionLicense = await LicenseRepo.getExistingSubscriptionLicenseByUserId(userId) + console.log('Existing subscription license:', existingSubscriptionLicense) + if (existingSubscriptionLicense) { + console.log('Updating existing subscription license') + await LicenseRepo.updateLicense(existingSubscriptionLicense.id, { + status: 'active', + stripe_payment_id: session.subscription as string, + updated_at: new Date() + }) + console.log('Existing subscription license updated') + return + } + console.log('Creating new subscription license') await LicenseRepo.createLicense({ user_id: userId, status: 'active', diff --git a/supabase/migrations/20250830165210_add_free_trial_to_license.sql b/supabase/migrations/20250830165210_add_free_trial_to_license.sql index 1c0b0b7..9cacc33 100644 --- a/supabase/migrations/20250830165210_add_free_trial_to_license.sql +++ b/supabase/migrations/20250830165210_add_free_trial_to_license.sql @@ -1,2 +1,28 @@ -- Add 'free_trial' to the license_type enum -ALTER TYPE public.license_type ADD VALUE 'free_trial'; \ No newline at end of file +ALTER TYPE public.license_type ADD VALUE 'free_trial'; + +-- Make expiration_date nullable since subscription status is managed by Stripe +ALTER TABLE public.license ALTER COLUMN expiration_date DROP NOT NULL; + + + -- -- Rollback: Remove 'free_trial' from license_type enum + -- -- Step 1: Drop the default constraint temporarily + -- ALTER TABLE public.license ALTER COLUMN license_type DROP + -- DEFAULT; + + -- -- Step 2: Create new enum without 'free_trial' + -- CREATE TYPE public.license_type_new AS ENUM ('perpetual', + -- 'subscription'); + + -- -- Step 3: Update the column to use new enum type + -- ALTER TABLE public.license ALTER COLUMN license_type TYPE + -- license_type_new USING + -- license_type::text::license_type_new; + + -- -- Step 4: Drop old enum and rename new one + -- DROP TYPE public.license_type; + -- ALTER TYPE public.license_type_new RENAME TO license_type; + + -- -- Step 5: Restore the default constraint + -- ALTER TABLE public.license ALTER COLUMN license_type SET + -- DEFAULT 'perpetual'; \ No newline at end of file diff --git a/tests/services/WebhookService.test.ts b/tests/services/WebhookService.test.ts index be1870e..083f06b 100644 --- a/tests/services/WebhookService.test.ts +++ b/tests/services/WebhookService.test.ts @@ -8,6 +8,7 @@ mock.module('../../repos/License.js', () => ({ createLicense: mock(() => Promise.resolve({ id: 'license-123' })), updateLicenseByStripePaymentId: mock(() => Promise.resolve({ id: 'license-123' })), getFreeTrialLicenseByUserId: mock(() => Promise.resolve(null)), + getExistingSubscriptionLicenseByUserId: mock(() => Promise.resolve(null)), updateLicense: mock(() => Promise.resolve({ id: 'trial-license-456' })) } })) @@ -32,7 +33,12 @@ let defaultMockSession = { describe('WebhookService', () => { beforeEach(() => { - mock.restore() + // Clear call history for all mocks + (LicenseRepo.createLicense as any).mockClear(); + (LicenseRepo.updateLicense as any).mockClear(); + (LicenseRepo.updateLicenseByStripePaymentId as any).mockClear(); + (LicenseRepo.getFreeTrialLicenseByUserId as any).mockClear(); + (LicenseRepo.getExistingSubscriptionLicenseByUserId as any).mockClear(); process.env.STRIPE_SANDBOX = 'true' }) @@ -88,38 +94,52 @@ describe('WebhookService', () => { .rejects.toThrow('Unknown product ID: prod_UNKNOWN') }) - test('should expire existing free trial license when user upgrades to paid subscription', async () => { + test('should update existing free trial license when user upgrades to paid subscription', async () => { // Mock that user has an existing free trial license - const existingTrialLicense = { id: 'trial-license-456', status: 'active' }; - (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValue(existingTrialLicense) + const existingTrialLicense = { id: 'trial-license-456', status: 'active', license_type: 'free_trial', stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr' }; + (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValue(existingTrialLicense); await WebhookService.handleCheckoutSessionCompleted(defaultMockSession) // Should check for existing trial license - expect(LicenseRepo.getFreeTrialLicenseByUserId).toHaveBeenCalledWith('9a4f2bc8-3cac-4345-81a1-78da85dd1dc6') + expect(LicenseRepo.getFreeTrialLicenseByUserId).toHaveBeenCalledWith('9a4f2bc8-3cac-4345-81a1-78da85dd1dc6'); // Should expire the trial license expect(LicenseRepo.updateLicense).toHaveBeenCalledWith('trial-license-456', { - status: 'expired', - expiration_date: expect.any(Date), + status: 'active', + license_type: 'subscription', + stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr', updated_at: expect.any(Date) }) // Should still create the new subscription license - expect(LicenseRepo.createLicense).toHaveBeenCalledWith({ - user_id: '9a4f2bc8-3cac-4345-81a1-78da85dd1dc6', + expect(LicenseRepo.createLicense).not.toHaveBeenCalled() + }) + + test('Should update an expired license that\'s a subscription. Should not create a new license', async () => { + // Mock that user has an existing expired license + const existingExpiredLicense = { id: 'expired-license-456', status: 'expired', stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr' }; + (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValueOnce(null); + (LicenseRepo.getExistingSubscriptionLicenseByUserId as any).mockResolvedValueOnce(existingExpiredLicense); + + await WebhookService.handleCheckoutSessionCompleted(defaultMockSession) + + // Should check for existing trial license + expect(LicenseRepo.getExistingSubscriptionLicenseByUserId).toHaveBeenCalledWith('9a4f2bc8-3cac-4345-81a1-78da85dd1dc6') + + // Should update the expired license + expect(LicenseRepo.updateLicense).toHaveBeenCalledWith('expired-license-456', { status: 'active', - license_type: 'subscription', - purchase_date: expect.any(Date), - stripe_customer_id: 'cus_Sxs0V3mlJjxzg7', - stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr' + stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr', + updated_at: expect.any(Date) }) - }) - test('should not call updateLicense when user has no existing free trial license', async () => { - // Clear the mock call history from previous tests - (LicenseRepo.updateLicense as any).mockClear() + // Should not create a new license + expect(LicenseRepo.createLicense).not.toHaveBeenCalled() + }) + + test('should not call updateLicense when user has no existing free trial license and no existing expired license', async () => { // Create a fresh mock session object for this test const mockSession = { id: 'cs_test_different_session', @@ -140,12 +160,15 @@ describe('WebhookService', () => { } as unknown as Stripe.Checkout.Session // Mock that user has no existing free trial license - (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValue(null) + (LicenseRepo.getFreeTrialLicenseByUserId as any).mockResolvedValue(null); + // Mock that user has no existing expired license + (LicenseRepo.getExistingSubscriptionLicenseByUserId as any).mockResolvedValue(null); await WebhookService.handleCheckoutSessionCompleted(mockSession) // Should check for existing trial license expect(LicenseRepo.getFreeTrialLicenseByUserId).toHaveBeenCalledWith('different-user-id') + expect(LicenseRepo.getExistingSubscriptionLicenseByUserId).toHaveBeenCalledWith('different-user-id') // Should NOT expire any trial license expect(LicenseRepo.updateLicense).not.toHaveBeenCalled() From f3a5ae921e9a3e6c777e9bd1520cd9750777f2de Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Tue, 23 Sep 2025 16:36:04 -0600 Subject: [PATCH 12/13] add stripe testing docs --- README.md | 4 +++- docs/stripe-testing.md | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 docs/stripe-testing.md diff --git a/README.md b/README.md index 4b7d85a..7259061 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,6 @@ bun dev ## Getting Started - copy .env.example to .env - get values for .env files (supabase for example) -= \ No newline at end of file + +## Documentation +- [Stripe Testing Guide](./docs/stripe-testing.md) - How to test Stripe integrations with real responses \ No newline at end of file diff --git a/docs/stripe-testing.md b/docs/stripe-testing.md new file mode 100644 index 0000000..e22ffd4 --- /dev/null +++ b/docs/stripe-testing.md @@ -0,0 +1,49 @@ +# Stripe API Testing Guide + +This guide helps you test Stripe integrations with real Stripe responses instead of mocked data. Essential for testing webhooks, subscription lifecycle events, and payment flows. + +## Quick Setup + +### 1. Install Stripe CLI +```bash +# macOS +brew install stripe/stripe-cli/stripe + +# Other platforms: https://stripe.com/docs/stripe-cli#install +``` + +### 2. Login to Stripe Account +```bash +stripe login +``` + +### 3. Forward Webhooks to Local Server +```bash +# Forward all events to your local webhook endpoint +stripe listen --forward-to localhost:3000/api/webhooks/stripe +``` + +## Testing Webhook Events + +### Manual Testing +1) stand up a local server +2) run the stripe forwarding agent to forward stripe events to your local server +3) Test the following scenarios + 1) new user + a) free trial acceptance - validate when they have the free trial they get pro feature access + b) upgrade to pro - validate upgrade works and have access to pro and notification in discord + 2) user cancels subscription - validate discord notification and loss of access + 3) existing "expired" license upgrade - validate discord notification and pro access + +### Trigger Specific Events +```bash +# Subscription deleted after max retries or user manually cancels +stripe trigger customer.subscription.deleted + +# Payment failure (sends email notification) +# Note: CLI generates test data automatically, but you can specify customer details +stripe trigger invoice.payment_failed --add customer:email=test@example.com --add customer:name="Test Customer" + +# To see available fixtures for any event: +stripe trigger invoice.payment_failed --help +``` From 8e37d727fba2abbf8edc50be06b33dad53397346 Mon Sep 17 00:00:00 2001 From: Paul Hovley Date: Tue, 23 Sep 2025 18:24:48 -0600 Subject: [PATCH 13/13] add discord notifications for stripe webhooks --- config/notifications.ts | 5 +- services/EmailService.ts | 25 ++-- services/WebhookService.ts | 86 +++++++++--- .../providers/DiscordNotificationProvider.ts | 123 ++++++++++++++++++ types/notifications.ts | 2 +- 5 files changed, 210 insertions(+), 31 deletions(-) diff --git a/config/notifications.ts b/config/notifications.ts index 9a4f155..b9a4649 100644 --- a/config/notifications.ts +++ b/config/notifications.ts @@ -24,7 +24,10 @@ export const notificationConfig: NotificationConfig = { paid_user: ['discord'], new_user: ['discord'], inactive_user: ['discord'], - weekly_report: ['discord'] + weekly_report: ['discord'], + payment_failed: ['discord'], + checkout_completed: ['discord'], + subscription_cancelled: ['discord'] } } diff --git a/services/EmailService.ts b/services/EmailService.ts index f992e2b..f36edc6 100644 --- a/services/EmailService.ts +++ b/services/EmailService.ts @@ -50,19 +50,19 @@ const sendLoopsEmail = async (payload: LoopsEmailPayload): Promise => { } } -const sendPaymentFailureEmail = async (data: PaymentFailureEmailData): Promise => { - const payload: LoopsEmailPayload = { - transactionalId: 'YOUR_PAYMENT_FAILURE_TEMPLATE_ID', // Replace with your Loops template ID - email: data.customerEmail, - dataVariables: { - customer_email: data.customerEmail, - customer_name: data.customerName || '', - formatted_amount: `${data.currency.toUpperCase()} $${(data.amountDue / 100).toFixed(2)}` - } - } +// const sendPaymentFailureEmail = async (data: PaymentFailureEmailData): Promise => { +// const payload: LoopsEmailPayload = { +// transactionalId: 'YOUR_PAYMENT_FAILURE_TEMPLATE_ID', // Replace with your Loops template ID +// email: data.customerEmail, +// dataVariables: { +// customer_email: data.customerEmail, +// customer_name: data.customerName || '', +// formatted_amount: `${data.currency.toUpperCase()} $${(data.amountDue / 100).toFixed(2)}` +// } +// } - await sendLoopsEmail(payload) -} +// await sendLoopsEmail(payload) +// } const sendFriendRequestEmail = async (data: FriendRequestEmailData): Promise => { const payload: LoopsEmailPayload = { @@ -79,7 +79,6 @@ const sendFriendRequestEmail = async (data: FriendRequestEmailData): Promise` : 'Just now', + inline: true + }, + { + name: '๐Ÿ“‹ Subscription ID', + value: payload.data?.subscription_id || 'Unknown', + inline: false + } + ], + footer: { + text: 'Ebb Platform Notifications' + }, + timestamp: new Date().toISOString() + } + + // Add optional fields if available + if (payload.data?.license_id) { + embed.fields?.push({ + name: '๐ŸŽซ License ID', + value: payload.data.license_id, + inline: true + }) + } + + if (payload.data?.customer_id) { + embed.fields?.push({ + name: '๐Ÿช Customer ID', + value: payload.data.customer_id, + inline: true + }) + } + + if (payload.data?.status) { + embed.fields?.push({ + name: '๐Ÿ“Š Status', + value: payload.data.status, + inline: true + }) + } + + return { + embeds: [embed], + username: 'Ebb Notifications', + } + } + private formatGenericNotification(payload: NotificationPayload): DiscordWebhookPayload { const embed: DiscordEmbed = { title: '๐Ÿ”” Notification', diff --git a/types/notifications.ts b/types/notifications.ts index 404a008..81c2a01 100644 --- a/types/notifications.ts +++ b/types/notifications.ts @@ -2,7 +2,7 @@ import type { PaidUserRecord, NewUserRecord, InactiveUserRecord } from './jobs' export type { PaidUserRecord, NewUserRecord, InactiveUserRecord } from './jobs' -export type NotificationType = 'paid_user' | 'new_user' | 'inactive_user' | 'weekly_report' | 'payment_failed' +export type NotificationType = 'paid_user' | 'new_user' | 'inactive_user' | 'weekly_report' | 'payment_failed' | 'checkout_completed' | 'subscription_cancelled' export type NotificationChannel = 'discord' | 'email' | 'slack' | 'sms' export interface BaseUserRecord {