diff --git a/.env.example b/.env.example index 2e45c6d..45ae079 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,7 @@ SLACK_ENCRYPTION_KEY= FRONTEND_URL= DISCORD_WEBHOOK_URL= + +STRIPE_WEBHOOK_SECRET= +STRIPE_SECRET_KEY= +STRIPE_SANBOX= \ No newline at end of file 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/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/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/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/config/products.ts b/config/products.ts new file mode 100644 index 0000000..dc0370d --- /dev/null +++ b/config/products.ts @@ -0,0 +1,73 @@ +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 + // } +} + +// 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' + } +} + +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): 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): boolean => { + const configs = isSandboxMode() ? SANDBOX_PRODUCT_CONFIGS : PRODUCT_CONFIGS + return licenseType in 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 new file mode 100644 index 0000000..04013c2 --- /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 && existingLicense.license_type !== 'free_trial') { + throw new ApiError('User already has an active license', 422) + } + + const checkoutUrl = await StripeService.createCheckoutSession({ + userId: req.user.id, + userEmail: req.user.email, + productConfig + }) + + 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/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 a194d2e..2f29a6f 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() @@ -42,40 +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) - } - - 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 - }) - - 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/controllers/WebhookController.ts b/controllers/WebhookController.ts new file mode 100644 index 0000000..18cf6e2 --- /dev/null +++ b/controllers/WebhookController.ts @@ -0,0 +1,82 @@ +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. 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? + + 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 'invoice.payment_failed': { + const invoice = event.data.object as Stripe.Invoice + console.log('invoice.payment_failed', invoice.id) + await WebhookService.handleInvoicePaymentFailed(invoice) + 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/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 +``` diff --git a/index.ts b/index.ts index 8f00711..2c87d2d 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,9 @@ 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 { LicenseController } from './controllers/LicenseController' +import { WebhookController } from './controllers/WebhookController' import { GeoLocationService } from './services/GeoLocationService' import { jobQueueService } from './services/JobQueueService' import { slackCleanupQueueService } from './services/SlackCleanupQueueService' @@ -15,6 +18,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 +55,12 @@ 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) +app.use('/api/license', LicenseController.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 +196,11 @@ 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(`๐ŸŽซ 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 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/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/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/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 ca1e53b..76aab06 100644 --- a/repos/License.ts +++ b/repos/License.ts @@ -1,13 +1,15 @@ 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; + expiration_date?: Date; stripe_customer_id?: string; stripe_payment_id?: string; created_at: Date; @@ -17,7 +19,19 @@ export interface License { export interface CreateLicenseData { user_id: string; license_type: LicenseType; - expiration_date: Date; + 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; } @@ -26,20 +40,45 @@ const db = getDb() const tableName = 'license' -const getLicenseByUserId = async (userId: string): Promise => { +const getActiveLicenseByUserId = async (userId: string): Promise => { const result = await db(tableName) - .where({ user_id: userId }) - .first() + .where({ user_id: userId, status: 'active' }) + .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 +} + +const getFreeTrialLicenseByUserId = async (userId: string): Promise => { + const result = await db(tableName) + .where({ user_id: userId, license_type: 'free_trial' }) + .first('*') return result || null } +const getExistingSubscriptionLicenseByUserId = 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({ 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, @@ -49,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() @@ -69,9 +108,26 @@ const deleteLicense = async (userId: string): Promise => { return deletedCount > 0 } + + +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, + getActiveLicenseByUserId, + getFreeTrialLicenseByUserId, + getExistingSubscriptionLicenseByUserId, createLicense, updateLicense, deleteLicense, + 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..4cc41df --- /dev/null +++ b/repos/__tests__/License.test.ts @@ -0,0 +1,115 @@ +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 user1Id: string + let defaultLicense: License + + beforeAll(async () => { + + 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 + }) + + 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() + }) + + 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 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() + await db('license').update({ status: 'expired' }).where({ user_id: user1Id }) + 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('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/EmailService.ts b/services/EmailService.ts new file mode 100644 index 0000000..f36edc6 --- /dev/null +++ b/services/EmailService.ts @@ -0,0 +1,84 @@ +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 = { + 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/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`) diff --git a/services/LicenseService.ts b/services/LicenseService.ts new file mode 100644 index 0000000..66b7cf2 --- /dev/null +++ b/services/LicenseService.ts @@ -0,0 +1,27 @@ +import { LicenseRepo } from "../repos/License" +import { ApiError } from "../middleware/errorHandler.js" + +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/StripeService.ts b/services/StripeService.ts new file mode 100644 index 0000000..33eeb1f --- /dev/null +++ b/services/StripeService.ts @@ -0,0 +1,96 @@ +import Stripe from 'stripe' +import { ApiError } from '../middleware/errorHandler.js' +import { getProductConfig, type ProductConfig } 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 + productConfig: ProductConfig +} + +const createCheckoutSession = async (params: CreateCheckoutSessionParams): Promise => { + const stripe = getStripeClient() + + 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(productConfig.id, { + expand: ['default_price'] + }) + + if (!product.default_price) { + throw new ApiError(`Product ${productConfig.id} 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, + ...(userEmail ? { customer_email: userEmail } : {}), + allow_promotion_codes: true, + metadata: { + user_id: userId, + product_id: productConfig.id + } + }) + + if (!session.url) { + throw new ApiError('Failed to create checkout session', 500) + } + + 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 new file mode 100644 index 0000000..61dff39 --- /dev/null +++ b/services/WebhookService.ts @@ -0,0 +1,196 @@ +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 + + 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') { + // 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: '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', + 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}`) + + // Send Discord notification for successful checkout + try { + const notificationEngine = getNotificationEngine() + + await notificationEngine.sendNotification({ + type: 'checkout_completed', + user: { + id: userId, + email: 'N/A' // Email not available in checkout session + }, + referenceId: `checkout_completed_${session.id}`, + data: { + session_id: session.id, + customer_id: customerId, + subscription_id: session.subscription, + product_id: productId, + license_type: productConfig.licenseType, + amount_total: session.amount_total, + currency: session.currency + } + }, ['discord']) + + console.log(`Discord notification sent for successful checkout: ${session.id}`) + } catch (error) { + console.error('Failed to send Discord notification for checkout completion:', error) + // Don't throw - we don't want Discord failures to break webhook processing + } + } +} + + +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}`) + + // Send Discord notification for subscription cancellation + try { + const notificationEngine = getNotificationEngine() + + await notificationEngine.sendNotification({ + type: 'subscription_cancelled', + user: { + id: updatedLicense.user_id || 'unknown', + email: 'N/A' // Email not available in subscription object + }, + referenceId: `subscription_cancelled_${subscription.id}`, + data: { + subscription_id: subscription.id, + customer_id: subscription.customer, + license_id: updatedLicense.id, + cancelled_at: new Date(), + status: subscription.status + } + }, ['discord']) + + console.log(`Discord notification sent for subscription cancellation: ${subscription.id}`) + } catch (error) { + console.error('Failed to send Discord notification for subscription cancellation:', error) + // Don't throw - we don't want Discord failures to break webhook processing + } + } +} + +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, + handleSubscriptionDeleted, + handleInvoicePaymentFailed, +} \ 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/services/providers/DiscordNotificationProvider.ts b/services/providers/DiscordNotificationProvider.ts index aff6cc4..e5831c0 100644 --- a/services/providers/DiscordNotificationProvider.ts +++ b/services/providers/DiscordNotificationProvider.ts @@ -66,6 +66,12 @@ export class DiscordNotificationProvider implements NotificationProvider { return this.formatInactiveUserNotification(payload) case 'weekly_report': return this.formatWeeklyReportNotification(payload) + case 'payment_failed': + return this.formatPaymentFailedNotification(payload) + case 'checkout_completed': + return this.formatCheckoutCompletedNotification(payload) + case 'subscription_cancelled': + return this.formatSubscriptionCancelledNotification(payload) default: return this.formatGenericNotification(payload) } @@ -201,6 +207,167 @@ 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 formatCheckoutCompletedNotification(payload: NotificationPayload): DiscordWebhookPayload { + const embed: DiscordEmbed = { + title: '๐Ÿ’ณ Checkout Completed!', + description: `A user has successfully completed their checkout`, + color: 0x00C851, // Success green color + fields: [ + { + name: '๐Ÿ‘ค User ID', + value: payload.user.id, + inline: true + }, + { + name: '๐ŸŽซ License Type', + value: payload.data?.license_type || 'Unknown', + inline: true + }, + { + name: '๐Ÿ†” Session ID', + value: payload.data?.session_id || 'Unknown', + inline: false + } + ], + footer: { + text: 'Ebb Platform Notifications' + }, + timestamp: new Date().toISOString() + } + + // Add optional fields if available + if (payload.data?.subscription_id) { + embed.fields?.push({ + name: '๐Ÿ“‹ Subscription ID', + value: payload.data.subscription_id, + inline: true + }) + } + + if (payload.data?.amount_total && payload.data?.currency) { + const amount = (payload.data.amount_total / 100).toFixed(2) + embed.fields?.push({ + name: '๐Ÿ’ฐ Amount', + value: `${payload.data.currency.toUpperCase()} $${amount}`, + inline: true + }) + } + + if (payload.data?.product_id) { + embed.fields?.push({ + name: '๐Ÿท๏ธ Product ID', + value: payload.data.product_id, + inline: true + }) + } + + return { + embeds: [embed], + username: 'Ebb Notifications', + } + } + + private formatSubscriptionCancelledNotification(payload: NotificationPayload): DiscordWebhookPayload { + const embed: DiscordEmbed = { + title: 'โŒ Subscription Cancelled', + description: `A user's subscription has been cancelled`, + color: 0xFF4444, // Warning red color + fields: [ + { + name: '๐Ÿ‘ค User ID', + value: payload.user.id, + inline: true + }, + { + name: '๐Ÿ—“๏ธ Cancelled At', + value: payload.data?.cancelled_at ? `` : '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/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/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..9cacc33 --- /dev/null +++ b/supabase/migrations/20250830165210_add_free_trial_to_license.sql @@ -0,0 +1,28 @@ +-- Add 'free_trial' to the license_type enum +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/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/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 - }) - }) - }) }) 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/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 { diff --git a/tests/services/WebhookService.test.ts b/tests/services/WebhookService.test.ts new file mode 100644 index 0000000..083f06b --- /dev/null +++ b/tests/services/WebhookService.test.ts @@ -0,0 +1,187 @@ +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' })), + getFreeTrialLicenseByUserId: mock(() => Promise.resolve(null)), + getExistingSubscriptionLicenseByUserId: mock(() => Promise.resolve(null)), + updateLicense: mock(() => Promise.resolve({ id: 'trial-license-456' })) + } +})) + +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(() => { + // 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' + }) + + describe('handleCheckoutSessionCompleted', () => { + test('should create license for valid checkout session with metadata', async () => { + + await WebhookService.handleCheckoutSessionCompleted(defaultMockSession) + + 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 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') + }) + + 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', 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'); + + // Should expire the trial license + expect(LicenseRepo.updateLicense).toHaveBeenCalledWith('trial-license-456', { + 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).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', + stripe_payment_id: 'sub_1S1wGyAuvxsld26UyyEWlujr', + updated_at: expect.any(Date) + }) + + // 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', + 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); + // 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() + + // 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 diff --git a/types/notifications.ts b/types/notifications.ts index 93423dd..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' +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 {