Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ SLACK_ENCRYPTION_KEY=

FRONTEND_URL=
DISCORD_WEBHOOK_URL=

STRIPE_WEBHOOK_SECRET=
STRIPE_SECRET_KEY=
STRIPE_SANBOX=
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ bun dev
## Getting Started
- copy .env.example to .env
- get values for .env files (supabase for example)
=

## Documentation
- [Stripe Testing Guide](./docs/stripe-testing.md) - How to test Stripe integrations with real responses
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion config/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
}

Expand Down
73 changes: 73 additions & 0 deletions config/products.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProductConfig> = {
'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<string, ProductConfig> = {
'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)
}
50 changes: 50 additions & 0 deletions controllers/CheckoutController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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
}
69 changes: 69 additions & 0 deletions controllers/LicenseController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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
}
27 changes: 1 addition & 26 deletions controllers/UserController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -42,40 +42,15 @@ const getUserProfile = async (req: Request, res: Response): Promise<void> => {
})
}

const startTrial = async (req: Request, res: Response): Promise<void> => {
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
}
Loading
Loading