From 231fc43fab63bb9e20ffbcc7a6f756fe5811ca8a Mon Sep 17 00:00:00 2001 From: omarkobro Date: Thu, 20 Aug 2026 18:06:39 +0300 Subject: [PATCH] (feat)billing/subscriptions: added paymob integration and subscribtion logic --- coachhub/services/core-api/.env.example | 12 + .../services/core-api/src/ai/ai.module.ts | 2 + .../src/ai/plan-suggestions.service.spec.ts | 12 + .../src/ai/plan-suggestions.service.ts | 3 + coachhub/services/core-api/src/app.module.ts | 2 + .../core-api/src/billing/billing.constants.ts | 48 ++++ .../src/billing/billing.controller.ts | 65 ++++++ .../core-api/src/billing/billing.module.ts | 23 ++ .../src/billing/billing.service.spec.ts | 142 ++++++++++++ .../core-api/src/billing/billing.service.ts | 208 ++++++++++++++++++ .../src/billing/dto/create-checkout.dto.ts | 11 + .../entities/payment-attempt.entity.ts | 63 ++++++ .../src/billing/entitlement.service.spec.ts | 83 +++++++ .../src/billing/entitlement.service.ts | 97 ++++++++ .../enums/payment-attempt-status.enum.ts | 5 + .../billing/enums/subscription-plan.enum.ts | 5 + .../src/billing/paymob.service.spec.ts | 74 +++++++ .../core-api/src/billing/paymob.service.ts | 151 +++++++++++++ .../core-api/src/config/config.interface.ts | 13 ++ .../core-api/src/config/config.service.ts | 4 + .../core-api/src/config/config.validation.ts | 11 + .../core-api/src/config/configuration.ts | 20 ++ .../src/invitation/invitation.module.ts | 2 + .../src/invitation/invitation.service.ts | 4 + .../src/join-requests/join-request.module.ts | 9 +- .../src/join-requests/join-request.service.ts | 3 + .../src/onboarding/onboarding.module.ts | 8 +- .../src/onboarding/onboarding.service.ts | 4 + .../src/tenant/entities/tenant.entity.ts | 17 ++ 29 files changed, 1099 insertions(+), 2 deletions(-) create mode 100644 coachhub/services/core-api/src/billing/billing.constants.ts create mode 100644 coachhub/services/core-api/src/billing/billing.controller.ts create mode 100644 coachhub/services/core-api/src/billing/billing.module.ts create mode 100644 coachhub/services/core-api/src/billing/billing.service.spec.ts create mode 100644 coachhub/services/core-api/src/billing/billing.service.ts create mode 100644 coachhub/services/core-api/src/billing/dto/create-checkout.dto.ts create mode 100644 coachhub/services/core-api/src/billing/entities/payment-attempt.entity.ts create mode 100644 coachhub/services/core-api/src/billing/entitlement.service.spec.ts create mode 100644 coachhub/services/core-api/src/billing/entitlement.service.ts create mode 100644 coachhub/services/core-api/src/billing/enums/payment-attempt-status.enum.ts create mode 100644 coachhub/services/core-api/src/billing/enums/subscription-plan.enum.ts create mode 100644 coachhub/services/core-api/src/billing/paymob.service.spec.ts create mode 100644 coachhub/services/core-api/src/billing/paymob.service.ts diff --git a/coachhub/services/core-api/.env.example b/coachhub/services/core-api/.env.example index 89d0d69..10f02ef 100644 --- a/coachhub/services/core-api/.env.example +++ b/coachhub/services/core-api/.env.example @@ -5,6 +5,7 @@ FRONTEND_URL=http://localhost:5173 # PostgreSQL (TypeORM) DATABASE_URL=postgresql://coachhub:secret@localhost:5432/coachhub +DB_SYNCHRONIZE=true # RabbitMQ RABBITMQ_URL=amqp://coachhub:secret@localhost:5672 @@ -28,3 +29,14 @@ AWS_S3_BASE_URL= # Google OAuth GOOGLE_OAUTH_CLIENT_ID= + +# Paymob sandbox +PAYMOB_BASE_URL=https://accept.paymob.com +PAYMOB_API_KEY= +PAYMOB_PUBLIC_KEY= +PAYMOB_SECRET_KEY= +PAYMOB_HMAC_SECRET= +PAYMOB_INTEGRATION_ID_CARD= +PAYMOB_NOTIFICATION_URL=http://localhost:3000/billing/paymob/webhook +PAYMOB_REDIRECTION_URL=http://localhost:5173/billing/result +PAYMOB_REQUEST_TIMEOUT_MS=15000 diff --git a/coachhub/services/core-api/src/ai/ai.module.ts b/coachhub/services/core-api/src/ai/ai.module.ts index 6183c98..3836c1a 100644 --- a/coachhub/services/core-api/src/ai/ai.module.ts +++ b/coachhub/services/core-api/src/ai/ai.module.ts @@ -23,12 +23,14 @@ import { Food } from '../plans/nutrition/entities/food.entity'; import { Meal } from '../plans/nutrition/entities/meal.entity'; import { ConfigService } from '../config'; import { AuthModule } from '../auth/auth.module'; +import { BillingModule } from '../billing/billing.module'; @Module({ imports: [ ConfigModule, MessagingModule, AuthModule, + BillingModule, TypeOrmModule.forFeature([ AiPlanSuggestion, ClientIntake, diff --git a/coachhub/services/core-api/src/ai/plan-suggestions.service.spec.ts b/coachhub/services/core-api/src/ai/plan-suggestions.service.spec.ts index 3cf9597..0e07c56 100644 --- a/coachhub/services/core-api/src/ai/plan-suggestions.service.spec.ts +++ b/coachhub/services/core-api/src/ai/plan-suggestions.service.spec.ts @@ -19,6 +19,7 @@ import { PlanAcceptanceService } from './plan-acceptance.service'; import { PlanContextService } from './plan-context.service'; import { PlanSuggestionsService } from './plan-suggestions.service'; import { PlanGenerationContext } from './types/plan-suggestion.types'; +import { EntitlementService } from '../billing/entitlement.service'; const TENANT = 'tenant-1'; const COACH = 'coach-1'; @@ -112,6 +113,7 @@ describe('PlanSuggestionsService', () => { let planContext: { build: jest.Mock }; let acceptance: { accept: jest.Mock }; let events: { publish: jest.Mock }; + let entitlements: { assertCanGenerateAiPlan: jest.Mock }; let service: PlanSuggestionsService; beforeEach(() => { @@ -139,6 +141,9 @@ describe('PlanSuggestionsService', () => { accept: jest.fn().mockResolvedValue({ programId: 'program-1' }), }; events = { publish: jest.fn().mockResolvedValue('correlation-1') }; + entitlements = { + assertCanGenerateAiPlan: jest.fn().mockResolvedValue(undefined), + }; service = new PlanSuggestionsService( suggestionRepository as unknown as Repository, @@ -146,6 +151,7 @@ describe('PlanSuggestionsService', () => { planContext as unknown as PlanContextService, acceptance as unknown as PlanAcceptanceService, events as unknown as EventPublisherService, + entitlements as unknown as EntitlementService, ); }); @@ -156,6 +162,12 @@ describe('PlanSuggestionsService', () => { expect(membershipRepository.findOne).not.toHaveBeenCalled(); }); + it('checks AI access before starting a new generation', async () => { + await service.request(TENANT, COACH, DTO); + + expect(entitlements.assertCanGenerateAiPlan).toHaveBeenCalledWith(TENANT); + }); + it('looks the membership up inside the caller’s own tenant', async () => { await service.request(TENANT, COACH, DTO); diff --git a/coachhub/services/core-api/src/ai/plan-suggestions.service.ts b/coachhub/services/core-api/src/ai/plan-suggestions.service.ts index 2e9ffe6..55bdbf6 100644 --- a/coachhub/services/core-api/src/ai/plan-suggestions.service.ts +++ b/coachhub/services/core-api/src/ai/plan-suggestions.service.ts @@ -31,6 +31,7 @@ import { PlanSuggestionDetail, PlanSuggestionSummary, } from './utils/plan-suggestion.utils'; +import { EntitlementService } from '../billing/entitlement.service'; const PENDING_TIMEOUT_MS = 10 * 60 * 1_000; @@ -62,6 +63,7 @@ export class PlanSuggestionsService { private readonly planContext: PlanContextService, private readonly acceptance: PlanAcceptanceService, private readonly events: EventPublisherService, + private readonly entitlementService: EntitlementService, ) {} /** @@ -74,6 +76,7 @@ export class PlanSuggestionsService { dto: CreatePlanSuggestionDto, ) { const activeTenantId = this.assertActiveTenant(tenantId); + await this.entitlementService.assertCanGenerateAiPlan(activeTenantId); const membership = await this.resolveActiveMembership( activeTenantId, dto.membershipId, diff --git a/coachhub/services/core-api/src/app.module.ts b/coachhub/services/core-api/src/app.module.ts index 43dbe9d..fef3fe4 100644 --- a/coachhub/services/core-api/src/app.module.ts +++ b/coachhub/services/core-api/src/app.module.ts @@ -23,6 +23,7 @@ import { PlansModule } from './plans/plans.module'; import { ReviewsModule } from './reviews/reviews.module'; import { TenantModule } from './tenant/tenant.module'; import { ExercisesModule } from './exercises/exercises.module'; +import { BillingModule } from './billing/billing.module'; @Module({ imports: [ @@ -34,6 +35,7 @@ import { ExercisesModule } from './exercises/exercises.module'; }, ]), DatabaseModule, + BillingModule, ActivityModule, AnalyticsModule, AuthModule, diff --git a/coachhub/services/core-api/src/billing/billing.constants.ts b/coachhub/services/core-api/src/billing/billing.constants.ts new file mode 100644 index 0000000..c423cbe --- /dev/null +++ b/coachhub/services/core-api/src/billing/billing.constants.ts @@ -0,0 +1,48 @@ +import { SubscriptionPlan } from './enums/subscription-plan.enum'; + +export interface subscriptionPlanDefinition { + plan: SubscriptionPlan; + displayName: string; + priceCents: number; + currency: 'EGP'; + durationDays: number | null; + activeClientLimit: number | null; + aiPlanBuilderEnabled: boolean; +} + +export const SUBSCRIPTION_DURATION_DAYS = 30; + +export const PLAN_DEFINITIONS: Record< + SubscriptionPlan, + subscriptionPlanDefinition +> = { + [SubscriptionPlan.FREE]: { + plan: SubscriptionPlan.FREE, + displayName: 'Free', + priceCents: 0, + currency: 'EGP', + durationDays: null, + activeClientLimit: 3, + aiPlanBuilderEnabled: false, + }, + [SubscriptionPlan.SOLO]: { + plan: SubscriptionPlan.SOLO, + displayName: 'Solo', + priceCents: 29_900, + currency: 'EGP', + durationDays: SUBSCRIPTION_DURATION_DAYS, + activeClientLimit: 20, + aiPlanBuilderEnabled: true, + }, + [SubscriptionPlan.STUDIO]: { + plan: SubscriptionPlan.STUDIO, + displayName: 'Studio', + priceCents: 59_900, + currency: 'EGP', + durationDays: SUBSCRIPTION_DURATION_DAYS, + activeClientLimit: null, + aiPlanBuilderEnabled: true, + }, +}; + +export const PAID_PLANS = [SubscriptionPlan.SOLO, SubscriptionPlan.STUDIO]; diff --git a/coachhub/services/core-api/src/billing/billing.controller.ts b/coachhub/services/core-api/src/billing/billing.controller.ts new file mode 100644 index 0000000..f918720 --- /dev/null +++ b/coachhub/services/core-api/src/billing/billing.controller.ts @@ -0,0 +1,65 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentTenant, CurrentUser, Public } from '../auth'; +import { BillingService } from './billing.service'; +import { CreateCheckoutDto } from './dto/create-checkout.dto'; +import { PaymobTransaction } from './paymob.service'; + +@ApiTags('Billing') +@ApiBearerAuth() +@Controller('billing') +export class BillingController { + constructor(private readonly billingService: BillingService) {} + + @Get('plans') + @ApiOperation({ summary: 'List the available CoachHub subscription plans' }) + getPlans() { + return this.billingService.getPlans(); + } + + @Get('me') + @ApiOperation({ summary: 'Get this tenant subscription and feature access' }) + getMyBilling(@CurrentTenant() tenantId: string) { + return this.billingService.getBillingSummary(tenantId); + } + + @Post('checkout') + @ApiOperation({ summary: 'Create a Paymob sandbox checkout' }) + createCheckout( + @CurrentUser('userId') coachId: string, + @CurrentTenant() tenantId: string, + @Body() dto: CreateCheckoutDto, + ) { + return this.billingService.createCheckout(coachId, tenantId, dto); + } + + @Get('payments/:id') + @ApiOperation({ summary: 'Check one payment attempt status' }) + getPaymentAttempt( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) attemptId: string, + ) { + return this.billingService.getPaymentAttempt(tenantId, attemptId); + } + + @Public() + @Post('paymob/webhook') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Receive Paymob transaction callbacks' }) + handlePaymobWebhook( + @Body() body: { obj?: PaymobTransaction }, + @Query('hmac') hmac: string, + ) { + return this.billingService.handlePaymobWebhook(body, hmac); + } +} diff --git a/coachhub/services/core-api/src/billing/billing.module.ts b/coachhub/services/core-api/src/billing/billing.module.ts new file mode 100644 index 0000000..9a89321 --- /dev/null +++ b/coachhub/services/core-api/src/billing/billing.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ClientMembership } from '../clients/entities/client-membership.entity'; +import { CoachesModule } from '../coaches/coaches.module'; +import { ConfigModule } from '../config'; +import { Tenant } from '../tenant/entities/tenant.entity'; +import { BillingController } from './billing.controller'; +import { BillingService } from './billing.service'; +import { PaymentAttempt } from './entities/payment-attempt.entity'; +import { EntitlementService } from './entitlement.service'; +import { PaymobService } from './paymob.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([PaymentAttempt, Tenant, ClientMembership]), + CoachesModule, + ConfigModule, + ], + controllers: [BillingController], + providers: [BillingService, EntitlementService, PaymobService], + exports: [EntitlementService], +}) +export class BillingModule {} diff --git a/coachhub/services/core-api/src/billing/billing.service.spec.ts b/coachhub/services/core-api/src/billing/billing.service.spec.ts new file mode 100644 index 0000000..c7c3a50 --- /dev/null +++ b/coachhub/services/core-api/src/billing/billing.service.spec.ts @@ -0,0 +1,142 @@ +import { DataSource, Repository } from 'typeorm'; + +jest.mock('../coaches/coaches.service', () => ({ + CoachesService: class CoachesService {}, +})); + +import { CoachesService } from '../coaches/coaches.service'; +import { Tenant } from '../tenant/entities/tenant.entity'; +import { BillingService } from './billing.service'; +import { PaymentAttempt } from './entities/payment-attempt.entity'; +import { PaymentAttemptStatus } from './enums/payment-attempt-status.enum'; +import { SubscriptionPlan } from './enums/subscription-plan.enum'; +import { EntitlementService } from './entitlement.service'; +import { PaymobService, PaymobTransaction } from './paymob.service'; + +const transaction: PaymobTransaction = { + amount_cents: 29900, + created_at: '2026-08-20T05:00:00.000000', + currency: 'EGP', + error_occured: false, + has_parent_transaction: false, + id: 12345, + integration_id: 5863435, + is_3d_secure: true, + is_auth: false, + is_capture: false, + is_refunded: false, + is_standalone_payment: true, + is_voided: false, + order: { id: 9876, merchant_order_id: 'attempt-1' }, + owner: 111, + pending: false, + source_data: { pan: '2346', sub_type: 'MasterCard', type: 'card' }, + success: true, +}; + +describe('BillingService Paymob webhook', () => { + let attempt: PaymentAttempt; + let attemptRepository: { + createQueryBuilder: jest.Mock; + save: jest.Mock; + }; + let tenantRepository: { update: jest.Mock }; + let paymobService: { + verifyTransactionHmac: jest.Mock; + isConfiguredCardIntegration: jest.Mock; + }; + let service: BillingService; + + beforeEach(() => { + attempt = { + id: 'attempt-1', + plan: SubscriptionPlan.SOLO, + amountCents: 29900, + currency: 'EGP', + status: PaymentAttemptStatus.PENDING, + paymobTransactionId: null, + paidAt: null, + tenant: { + id: 'tenant-1', + subscriptionPlan: SubscriptionPlan.FREE, + subscriptionExpiresAt: null, + } as Tenant, + } as PaymentAttempt; + const queryBuilder = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setLock: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(attempt), + }; + attemptRepository = { + createQueryBuilder: jest.fn().mockReturnValue(queryBuilder), + save: jest.fn().mockImplementation((value) => Promise.resolve(value)), + }; + tenantRepository = { update: jest.fn().mockResolvedValue({ affected: 1 }) }; + paymobService = { + verifyTransactionHmac: jest.fn(), + isConfiguredCardIntegration: jest.fn().mockReturnValue(true), + }; + + const manager = { + getRepository: jest.fn((entity) => + entity === PaymentAttempt ? attemptRepository : tenantRepository, + ), + }; + const dataSource = { + transaction: jest.fn((work) => work(manager)), + }; + + service = new BillingService( + {} as Repository, + {} as Repository, + {} as CoachesService, + paymobService as unknown as PaymobService, + {} as EntitlementService, + dataSource as unknown as DataSource, + ); + }); + + it('activates the plan only after signature and payment checks pass', async () => { + const before = Date.now(); + + await service.handlePaymobWebhook({ obj: transaction }, 'valid-hmac'); + + expect(paymobService.verifyTransactionHmac).toHaveBeenCalledWith( + transaction, + 'valid-hmac', + ); + expect(attempt.status).toBe(PaymentAttemptStatus.SUCCEEDED); + expect(attempt.paymobTransactionId).toBe('12345'); + expect(tenantRepository.update).toHaveBeenCalledWith( + 'tenant-1', + expect.objectContaining({ subscriptionPlan: SubscriptionPlan.SOLO }), + ); + const update = tenantRepository.update.mock.calls[0][1]; + const expectedDurationMs = 30 * 24 * 60 * 60 * 1000; + expect(update.subscriptionExpiresAt.getTime()).toBeGreaterThanOrEqual( + before + expectedDurationMs, + ); + }); + + it('does not extend access twice for a repeated successful callback', async () => { + attempt.status = PaymentAttemptStatus.SUCCEEDED; + + const result = await service.handlePaymobWebhook( + { obj: transaction }, + 'valid-hmac', + ); + + expect(result).toEqual({ received: true, duplicate: true }); + expect(tenantRepository.update).not.toHaveBeenCalled(); + }); + + it('records a verified failed payment without unlocking the plan', async () => { + const failedTransaction = { ...transaction, success: false }; + + await service.handlePaymobWebhook({ obj: failedTransaction }, 'valid-hmac'); + + expect(attempt.status).toBe(PaymentAttemptStatus.FAILED); + expect(tenantRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/coachhub/services/core-api/src/billing/billing.service.ts b/coachhub/services/core-api/src/billing/billing.service.ts new file mode 100644 index 0000000..51a3bb5 --- /dev/null +++ b/coachhub/services/core-api/src/billing/billing.service.ts @@ -0,0 +1,208 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { CoachesService } from '../coaches/coaches.service'; +import { Tenant } from '../tenant/entities/tenant.entity'; +import { + PAID_PLANS, + PLAN_DEFINITIONS, + SUBSCRIPTION_DURATION_DAYS, +} from './billing.constants'; +import { CreateCheckoutDto } from './dto/create-checkout.dto'; +import { PaymentAttempt } from './entities/payment-attempt.entity'; +import { EntitlementService } from './entitlement.service'; +import { PaymentAttemptStatus } from './enums/payment-attempt-status.enum'; +import { SubscriptionPlan } from './enums/subscription-plan.enum'; +import { PaymobService, PaymobTransaction } from './paymob.service'; + +@Injectable() +export class BillingService { + constructor( + @InjectRepository(PaymentAttempt) + private readonly paymentAttemptRepository: Repository, + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + private readonly coachesService: CoachesService, + private readonly paymobService: PaymobService, + private readonly entitlementService: EntitlementService, + private readonly dataSource: DataSource, + ) {} + + getPlans() { + return Object.values(PLAN_DEFINITIONS); + } + + getBillingSummary(tenantId: string) { + return this.entitlementService.getBillingSummary(tenantId); + } + + async createCheckout( + coachId: string, + tenantId: string, + dto: CreateCheckoutDto, + ) { + if (!PAID_PLANS.includes(dto.plan)) { + throw new BadRequestException('Only Solo and Studio can be purchased'); + } + + const [tenant, coach] = await Promise.all([ + this.tenantRepository.findOne({ where: { id: tenantId } }), + this.coachesService.findProfileById(coachId), + ]); + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + if (!coach || !coach.tenants.some((item) => item.id === tenantId)) { + throw new ForbiddenException('This tenant does not belong to this coach'); + } + + const currentPlan = this.entitlementService.getEffectivePlan(tenant); + if ( + currentPlan === SubscriptionPlan.STUDIO && + dto.plan === SubscriptionPlan.SOLO + ) { + throw new ConflictException( + 'An active Studio subscription cannot be changed to Solo', + ); + } + + const plan = PLAN_DEFINITIONS[dto.plan]; + const attempt = await this.paymentAttemptRepository.save( + this.paymentAttemptRepository.create({ + tenant: { id: tenantId }, + plan: dto.plan, + amountCents: plan.priceCents, + currency: plan.currency, + status: PaymentAttemptStatus.PENDING, + }), + ); + + try { + const checkout = await this.paymobService.createCheckout( + attempt.id, + plan, + coach, + ); + await this.paymentAttemptRepository.update(attempt.id, { + paymobIntentionId: checkout.intentionId, + }); + return { + paymentAttemptId: attempt.id, + checkoutUrl: checkout.checkoutUrl, + }; + } catch (error) { + await this.paymentAttemptRepository.update(attempt.id, { + status: PaymentAttemptStatus.FAILED, + }); + throw error; + } + } + + async getPaymentAttempt(tenantId: string, attemptId: string) { + const attempt = await this.paymentAttemptRepository.findOne({ + where: { id: attemptId, tenant: { id: tenantId } }, + }); + if (!attempt) { + throw new NotFoundException('Payment attempt not found'); + } + return { + id: attempt.id, + plan: attempt.plan, + amountCents: attempt.amountCents, + currency: attempt.currency, + status: attempt.status, + paidAt: attempt.paidAt, + createdAt: attempt.createdAt, + }; + } + + async handlePaymobWebhook(body: { obj?: PaymobTransaction }, hmac: string) { + const transaction = body?.obj; + if (!transaction) { + throw new BadRequestException('Missing Paymob transaction'); + } + this.paymobService.verifyTransactionHmac(transaction, hmac); + + const attemptId = transaction.order?.merchant_order_id; + if (!attemptId) { + throw new BadRequestException('Missing payment reference'); + } + + return this.dataSource.transaction(async (manager) => { + const attemptRepository = manager.getRepository(PaymentAttempt); + const attempt = await attemptRepository + .createQueryBuilder('attempt') + .innerJoinAndSelect('attempt.tenant', 'tenant') + .where('attempt.id = :attemptId', { attemptId }) + .setLock('pessimistic_write') + .getOne(); + if (!attempt) { + throw new NotFoundException('Payment attempt not found'); + } + + this.assertTransactionMatchesAttempt(transaction, attempt); + if (attempt.status === PaymentAttemptStatus.SUCCEEDED) { + return { received: true, duplicate: true }; + } + + const paymentSucceeded = + transaction.success === true && + transaction.pending === false && + transaction.is_refunded !== true && + transaction.is_voided !== true; + + if (!paymentSucceeded) { + attempt.status = PaymentAttemptStatus.FAILED; + await attemptRepository.save(attempt); + return { received: true, subscriptionActivated: false }; + } + + const now = new Date(); + const currentExpiry = attempt.tenant.subscriptionExpiresAt; + const extensionStartsAt = + currentExpiry && currentExpiry.getTime() > now.getTime() + ? currentExpiry + : now; + const newExpiry = new Date( + extensionStartsAt.getTime() + + SUBSCRIPTION_DURATION_DAYS * 24 * 60 * 60 * 1000, + ); + + attempt.status = PaymentAttemptStatus.SUCCEEDED; + attempt.paymobTransactionId = String(transaction.id); + attempt.paidAt = now; + await attemptRepository.save(attempt); + await manager.getRepository(Tenant).update(attempt.tenant.id, { + subscriptionPlan: attempt.plan, + subscriptionExpiresAt: newExpiry, + }); + + return { received: true, subscriptionActivated: true }; + }); + } + + private assertTransactionMatchesAttempt( + transaction: PaymobTransaction, + attempt: PaymentAttempt, + ): void { + if ( + !this.paymobService.isConfiguredCardIntegration( + transaction.integration_id, + ) + ) { + throw new BadRequestException('Unexpected Paymob integration'); + } + if (Number(transaction.amount_cents) !== attempt.amountCents) { + throw new BadRequestException('Payment amount does not match checkout'); + } + if (transaction.currency !== attempt.currency) { + throw new BadRequestException('Payment currency does not match checkout'); + } + } +} diff --git a/coachhub/services/core-api/src/billing/dto/create-checkout.dto.ts b/coachhub/services/core-api/src/billing/dto/create-checkout.dto.ts new file mode 100644 index 0000000..0aa497c --- /dev/null +++ b/coachhub/services/core-api/src/billing/dto/create-checkout.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; +import { SubscriptionPlan } from '../enums/subscription-plan.enum'; + +export class CreateCheckoutDto { + @ApiProperty({ enum: [SubscriptionPlan.SOLO, SubscriptionPlan.STUDIO] }) + @IsIn([SubscriptionPlan.SOLO, SubscriptionPlan.STUDIO], { + message: 'plan must be either solo or studio', + }) + plan: SubscriptionPlan.SOLO | SubscriptionPlan.STUDIO; +} diff --git a/coachhub/services/core-api/src/billing/entities/payment-attempt.entity.ts b/coachhub/services/core-api/src/billing/entities/payment-attempt.entity.ts new file mode 100644 index 0000000..7593304 --- /dev/null +++ b/coachhub/services/core-api/src/billing/entities/payment-attempt.entity.ts @@ -0,0 +1,63 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Tenant } from '../../tenant/entities/tenant.entity'; +import { PaymentAttemptStatus } from '../enums/payment-attempt-status.enum'; +import { SubscriptionPlan } from '../enums/subscription-plan.enum'; + +@Entity('payment_attempts') +export class PaymentAttempt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Tenant, { nullable: false, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @Column({ + type: 'enum', + enum: SubscriptionPlan, + enumName: 'subscription_plan', + }) + plan: SubscriptionPlan; + + @Column({ name: 'amount_cents', type: 'int' }) + amountCents: number; + + @Column({ type: 'char', length: 3, default: 'EGP' }) + currency: string; + + @Column({ + type: 'enum', + enum: PaymentAttemptStatus, + enumName: 'payment_attempt_status', + default: PaymentAttemptStatus.PENDING, + }) + status: PaymentAttemptStatus; + + @Column({ name: 'paymob_intention_id', type: 'text', nullable: true }) + paymobIntentionId: string | null; + + @Column({ + name: 'paymob_transaction_id', + type: 'text', + nullable: true, + unique: true, + }) + paymobTransactionId: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/coachhub/services/core-api/src/billing/entitlement.service.spec.ts b/coachhub/services/core-api/src/billing/entitlement.service.spec.ts new file mode 100644 index 0000000..b936009 --- /dev/null +++ b/coachhub/services/core-api/src/billing/entitlement.service.spec.ts @@ -0,0 +1,83 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ClientMembership } from '../clients/entities/client-membership.entity'; +import { MembershipStatus } from '../common'; +import { Tenant } from '../tenant/entities/tenant.entity'; +import { EntitlementService } from './entitlement.service'; +import { SubscriptionPlan } from './enums/subscription-plan.enum'; + +describe('EntitlementService', () => { + let tenantRepository: { findOne: jest.Mock }; + let membershipRepository: { count: jest.Mock }; + let service: EntitlementService; + + beforeEach(() => { + tenantRepository = { + findOne: jest.fn().mockResolvedValue({ + id: 'tenant-1', + subscriptionPlan: SubscriptionPlan.FREE, + subscriptionExpiresAt: null, + }), + }; + membershipRepository = { count: jest.fn().mockResolvedValue(0) }; + service = new EntitlementService( + tenantRepository as unknown as Repository, + membershipRepository as unknown as Repository, + ); + }); + + it('counts only active clients inside the tenant', async () => { + await service.getBillingSummary('tenant-1'); + + expect(membershipRepository.count).toHaveBeenCalledWith({ + where: { + tenant: { id: 'tenant-1' }, + status: MembershipStatus.ACTIVE, + }, + }); + }); + + it('blocks a fourth active client on Free', async () => { + membershipRepository.count.mockResolvedValue(3); + + await expect( + service.assertCanAddActiveClient('tenant-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows unlimited active clients on an active Studio subscription', async () => { + tenantRepository.findOne.mockResolvedValue({ + id: 'tenant-1', + subscriptionPlan: SubscriptionPlan.STUDIO, + subscriptionExpiresAt: new Date(Date.now() + 60_000), + }); + + await service.assertCanAddActiveClient('tenant-1'); + + expect(membershipRepository.count).not.toHaveBeenCalled(); + }); + + it('treats an expired paid subscription as Free', async () => { + tenantRepository.findOne.mockResolvedValue({ + id: 'tenant-1', + subscriptionPlan: SubscriptionPlan.SOLO, + subscriptionExpiresAt: new Date(Date.now() - 60_000), + }); + + await expect( + service.assertCanGenerateAiPlan('tenant-1'), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('allows AI generation on an active Solo subscription', async () => { + tenantRepository.findOne.mockResolvedValue({ + id: 'tenant-1', + subscriptionPlan: SubscriptionPlan.SOLO, + subscriptionExpiresAt: new Date(Date.now() + 60_000), + }); + + await expect( + service.assertCanGenerateAiPlan('tenant-1'), + ).resolves.toBeUndefined(); + }); +}); diff --git a/coachhub/services/core-api/src/billing/entitlement.service.ts b/coachhub/services/core-api/src/billing/entitlement.service.ts new file mode 100644 index 0000000..57003d2 --- /dev/null +++ b/coachhub/services/core-api/src/billing/entitlement.service.ts @@ -0,0 +1,97 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ClientMembership } from '../clients/entities/client-membership.entity'; +import { MembershipStatus } from '../common'; +import { Tenant } from '../tenant/entities/tenant.entity'; +import { PLAN_DEFINITIONS } from './billing.constants'; +import { SubscriptionPlan } from './enums/subscription-plan.enum'; + +@Injectable() +export class EntitlementService { + constructor( + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + @InjectRepository(ClientMembership) + private readonly membershipRepository: Repository, + ) {} + + async getBillingSummary(tenantId: string) { + const tenant = await this.findTenant(tenantId); + const effectivePlan = this.getEffectivePlan(tenant); + const plan = PLAN_DEFINITIONS[effectivePlan]; + const activeClientCount = await this.countActiveClients(tenantId); + + return { + plan: effectivePlan, + storedPlan: tenant.subscriptionPlan, + subscriptionExpiresAt: tenant.subscriptionExpiresAt, + isPaidSubscriptionActive: effectivePlan !== SubscriptionPlan.FREE, + activeClientCount, + activeClientLimit: plan.activeClientLimit, + canAddActiveClient: + plan.activeClientLimit === null || + activeClientCount < plan.activeClientLimit, + aiPlanBuilderEnabled: plan.aiPlanBuilderEnabled, + }; + } + + async assertCanAddActiveClient(tenantId: string): Promise { + const tenant = await this.findTenant(tenantId); + const plan = PLAN_DEFINITIONS[this.getEffectivePlan(tenant)]; + if (plan.activeClientLimit === null) { + return; + } + + const activeClientCount = await this.countActiveClients(tenantId); + if (activeClientCount >= plan.activeClientLimit) { + throw new ForbiddenException( + `Your ${plan.displayName} plan allows ${plan.activeClientLimit} active clients. Upgrade your subscription to add another client.`, + ); + } + } + + async assertCanGenerateAiPlan(tenantId: string): Promise { + const tenant = await this.findTenant(tenantId); + const plan = PLAN_DEFINITIONS[this.getEffectivePlan(tenant)]; + if (!plan.aiPlanBuilderEnabled) { + throw new ForbiddenException( + 'The AI plan builder requires an active Solo or Studio subscription.', + ); + } + } + + getEffectivePlan(tenant: Tenant, now = new Date()): SubscriptionPlan { + if ( + tenant.subscriptionPlan !== SubscriptionPlan.FREE && + tenant.subscriptionExpiresAt && + tenant.subscriptionExpiresAt.getTime() > now.getTime() + ) { + return tenant.subscriptionPlan; + } + return SubscriptionPlan.FREE; + } + + private countActiveClients(tenantId: string): Promise { + return this.membershipRepository.count({ + where: { + tenant: { id: tenantId }, + status: MembershipStatus.ACTIVE, + }, + }); + } + + private async findTenant(tenantId: string): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId }, + }); + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + return tenant; + } +} diff --git a/coachhub/services/core-api/src/billing/enums/payment-attempt-status.enum.ts b/coachhub/services/core-api/src/billing/enums/payment-attempt-status.enum.ts new file mode 100644 index 0000000..d807a0e --- /dev/null +++ b/coachhub/services/core-api/src/billing/enums/payment-attempt-status.enum.ts @@ -0,0 +1,5 @@ +export enum PaymentAttemptStatus { + PENDING = 'pending', + SUCCEEDED = 'succeeded', + FAILED = 'failed', +} diff --git a/coachhub/services/core-api/src/billing/enums/subscription-plan.enum.ts b/coachhub/services/core-api/src/billing/enums/subscription-plan.enum.ts new file mode 100644 index 0000000..1d72745 --- /dev/null +++ b/coachhub/services/core-api/src/billing/enums/subscription-plan.enum.ts @@ -0,0 +1,5 @@ +export enum SubscriptionPlan { + FREE = 'free', + SOLO = 'solo', + STUDIO = 'studio', +} diff --git a/coachhub/services/core-api/src/billing/paymob.service.spec.ts b/coachhub/services/core-api/src/billing/paymob.service.spec.ts new file mode 100644 index 0000000..a196591 --- /dev/null +++ b/coachhub/services/core-api/src/billing/paymob.service.spec.ts @@ -0,0 +1,74 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { createHmac } from 'node:crypto'; +import { ConfigService } from '../config'; +import { PaymobService, PaymobTransaction } from './paymob.service'; + +const transaction: PaymobTransaction = { + amount_cents: 29900, + created_at: '2026-08-20T05:00:00.000000', + currency: 'EGP', + error_occured: false, + has_parent_transaction: false, + id: 12345, + integration_id: 5863435, + is_3d_secure: true, + is_auth: false, + is_capture: false, + is_refunded: false, + is_standalone_payment: true, + is_voided: false, + order: { id: 9876, merchant_order_id: 'attempt-1' }, + owner: 111, + pending: false, + source_data: { pan: '2346', sub_type: 'MasterCard', type: 'card' }, + success: true, +}; + +describe('PaymobService', () => { + const hmacSecret = 'test-hmac-secret'; + const service = new PaymobService({ + paymobConfig: { hmacSecret, cardIntegrationId: 5863435 }, + } as unknown as ConfigService); + + it('accepts a valid Paymob transaction HMAC', () => { + const concatenatedValues = + '29900' + + '2026-08-20T05:00:00.000000' + + 'EGP' + + 'false' + + 'false' + + '12345' + + '5863435' + + 'true' + + 'false' + + 'false' + + 'false' + + 'true' + + 'false' + + '9876' + + '111' + + 'false' + + '2346' + + 'MasterCard' + + 'card' + + 'true'; + const hmac = createHmac('sha512', hmacSecret) + .update(concatenatedValues) + .digest('hex'); + + expect(() => + service.verifyTransactionHmac(transaction, hmac), + ).not.toThrow(); + }); + + it('rejects a callback with a false HMAC', () => { + expect(() => + service.verifyTransactionHmac(transaction, 'not-valid'), + ).toThrow(UnauthorizedException); + }); + + it('checks that callbacks use the configured card integration', () => { + expect(service.isConfiguredCardIntegration(5863435)).toBe(true); + expect(service.isConfiguredCardIntegration(999)).toBe(false); + }); +}); diff --git a/coachhub/services/core-api/src/billing/paymob.service.ts b/coachhub/services/core-api/src/billing/paymob.service.ts new file mode 100644 index 0000000..35dfd24 --- /dev/null +++ b/coachhub/services/core-api/src/billing/paymob.service.ts @@ -0,0 +1,151 @@ +import { + BadGatewayException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { ConfigService } from '../config'; +import { subscriptionPlanDefinition } from './billing.constants'; + +interface PaymobIntentionResponse { + id: string; + client_secret: string; +} + +export interface PaymobTransaction { + amount_cents: number | string; + created_at: string; + currency: string; + error_occured: boolean; + has_parent_transaction: boolean; + id: number | string; + integration_id: number | string; + is_3d_secure: boolean; + is_auth: boolean; + is_capture: boolean; + is_refunded: boolean; + is_standalone_payment: boolean; + is_voided: boolean; + order: { id: number | string; merchant_order_id: string }; + owner: number | string; + pending: boolean; + source_data: { pan: string; sub_type: string; type: string }; + success: boolean; +} + +@Injectable() +export class PaymobService { + constructor(private readonly configService: ConfigService) {} + + async createCheckout( + attemptId: string, + plan: subscriptionPlanDefinition, + coach: { + firstName: string; + lastName: string; + email: string; + phone: string | null; + }, + ) { + const config = this.configService.paymobConfig; + const response = await fetch(`${config.baseUrl}/v1/intention/`, { + method: 'POST', + headers: { + Authorization: `Token ${config.secretKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amount: plan.priceCents, + currency: plan.currency, + payment_methods: [config.cardIntegrationId], + items: [ + { + name: `CoachHub ${plan.displayName}`, + amount: plan.priceCents, + description: `${plan.durationDays}-day CoachHub subscription`, + quantity: 1, + }, + ], + billing_data: { + first_name: coach.firstName, + last_name: coach.lastName, + email: coach.email, + // Paymob requires a phone. This fallback is sandbox-only for demo + // coaches whose optional profile phone has not been filled in. + phone_number: coach.phone || '+201000000000', + }, + special_reference: attemptId, + notification_url: config.notificationUrl, + redirection_url: config.redirectionUrl, + }), + signal: AbortSignal.timeout(config.requestTimeoutMs), + }); + + if (!response.ok) { + throw new BadGatewayException( + `Paymob could not create a checkout (status ${response.status})`, + ); + } + + const intention = (await response.json()) as PaymobIntentionResponse; + if (!intention.id || !intention.client_secret) { + throw new BadGatewayException('Paymob returned an incomplete checkout'); + } + + const checkoutUrl = new URL('/unifiedcheckout/', config.baseUrl); + checkoutUrl.searchParams.set('publicKey', config.publicKey); + checkoutUrl.searchParams.set('clientSecret', intention.client_secret); + + return { intentionId: String(intention.id), checkoutUrl: checkoutUrl.href }; + } + + verifyTransactionHmac(transaction: PaymobTransaction, receivedHmac: string) { + const value = [ + transaction.amount_cents, + transaction.created_at, + transaction.currency, + transaction.error_occured, + transaction.has_parent_transaction, + transaction.id, + transaction.integration_id, + transaction.is_3d_secure, + transaction.is_auth, + transaction.is_capture, + transaction.is_refunded, + transaction.is_standalone_payment, + transaction.is_voided, + transaction.order?.id, + transaction.owner, + transaction.pending, + transaction.source_data?.pan, + transaction.source_data?.sub_type, + transaction.source_data?.type, + transaction.success, + ] + .map((part) => String(part ?? '')) + .join(''); + + const expectedHmac = createHmac( + 'sha512', + this.configService.paymobConfig.hmacSecret, + ) + .update(value) + .digest('hex'); + const received = Buffer.from((receivedHmac || '').toLowerCase(), 'utf8'); + const expected = Buffer.from(expectedHmac, 'utf8'); + + if ( + received.length !== expected.length || + !timingSafeEqual(received, expected) + ) { + throw new UnauthorizedException('Invalid Paymob callback signature'); + } + } + + isConfiguredCardIntegration(integrationId: number | string): boolean { + return ( + Number(integrationId) === + this.configService.paymobConfig.cardIntegrationId + ); + } +} diff --git a/coachhub/services/core-api/src/config/config.interface.ts b/coachhub/services/core-api/src/config/config.interface.ts index 90c6306..bbc8d99 100644 --- a/coachhub/services/core-api/src/config/config.interface.ts +++ b/coachhub/services/core-api/src/config/config.interface.ts @@ -80,6 +80,18 @@ export interface AnalyticsConfig { timeoutMs: number; } +export interface PaymobConfig { + baseUrl: string; + apiKey: string; + publicKey: string; + secretKey: string; + hmacSecret: string; + cardIntegrationId: number; + notificationUrl: string; + redirectionUrl: string; + requestTimeoutMs: number; +} + export interface Config { app: AppConfig; database: DatabaseConfig; @@ -92,4 +104,5 @@ export interface Config { googleOauth: GoogleOAuthConfig; ai: AiConfig; analytics: AnalyticsConfig; + paymob: PaymobConfig; } diff --git a/coachhub/services/core-api/src/config/config.service.ts b/coachhub/services/core-api/src/config/config.service.ts index e23c102..b8674c5 100644 --- a/coachhub/services/core-api/src/config/config.service.ts +++ b/coachhub/services/core-api/src/config/config.service.ts @@ -47,4 +47,8 @@ export class ConfigService { get aiConfig(): Config['ai'] { return this.configService.getOrThrow('ai'); } + + get paymobConfig(): Config['paymob'] { + return this.configService.getOrThrow('paymob'); + } } diff --git a/coachhub/services/core-api/src/config/config.validation.ts b/coachhub/services/core-api/src/config/config.validation.ts index 74df4da..e07edd6 100644 --- a/coachhub/services/core-api/src/config/config.validation.ts +++ b/coachhub/services/core-api/src/config/config.validation.ts @@ -26,6 +26,17 @@ export const ConfigSchema = z.object({ baseUrl: z.string().url(), timeoutMs: z.coerce.number().default(10000), }), + paymob: z.object({ + baseUrl: z.string().url(), + apiKey: z.string().min(1), + publicKey: z.string().min(1), + secretKey: z.string().min(1), + hmacSecret: z.string().min(1), + cardIntegrationId: z.number().int().positive(), + notificationUrl: z.string().url(), + redirectionUrl: z.string().url(), + requestTimeoutMs: z.number().int().positive(), + }), jwt: z.object({ accessToken: z.object({ secret: z.string(), diff --git a/coachhub/services/core-api/src/config/configuration.ts b/coachhub/services/core-api/src/config/configuration.ts index c32b285..8b3d75e 100644 --- a/coachhub/services/core-api/src/config/configuration.ts +++ b/coachhub/services/core-api/src/config/configuration.ts @@ -63,6 +63,26 @@ export default () => ({ parseInt(process.env.ANALYTICS_REQUEST_TIMEOUT_MS as string, 10) || 10000, }, + paymob: { + baseUrl: process.env.PAYMOB_BASE_URL || 'https://accept.paymob.com', + apiKey: process.env.PAYMOB_API_KEY, + publicKey: process.env.PAYMOB_PUBLIC_KEY, + secretKey: process.env.PAYMOB_SECRET_KEY, + hmacSecret: process.env.PAYMOB_HMAC_SECRET, + cardIntegrationId: parseInt( + process.env.PAYMOB_INTEGRATION_ID_CARD as string, + 10, + ), + notificationUrl: + process.env.PAYMOB_NOTIFICATION_URL || + 'http://localhost:3000/billing/paymob/webhook', + redirectionUrl: + process.env.PAYMOB_REDIRECTION_URL || + `${process.env.FRONTEND_URL || 'http://localhost:5173'}/billing/result`, + requestTimeoutMs: + parseInt(process.env.PAYMOB_REQUEST_TIMEOUT_MS as string, 10) || 15000, + }, + aws: { region: process.env.AWS_REGION || 'us-east-1', accessKeyId: process.env.AWS_ACCESS_KEY_ID, diff --git a/coachhub/services/core-api/src/invitation/invitation.module.ts b/coachhub/services/core-api/src/invitation/invitation.module.ts index 680519a..f087ef8 100644 --- a/coachhub/services/core-api/src/invitation/invitation.module.ts +++ b/coachhub/services/core-api/src/invitation/invitation.module.ts @@ -6,6 +6,7 @@ import { Invitation } from './entities/invitation.entity'; import { MessagingModule } from '../messaging/messaging.module'; import { CoachesModule } from '../coaches/coaches.module'; import { OtpProvider } from '../common'; +import { BillingModule } from '../billing/billing.module'; @Module({ controllers: [InvitationController], @@ -14,6 +15,7 @@ import { OtpProvider } from '../common'; TypeOrmModule.forFeature([Invitation]), MessagingModule, CoachesModule, + BillingModule, ], exports: [InvitationService], }) diff --git a/coachhub/services/core-api/src/invitation/invitation.service.ts b/coachhub/services/core-api/src/invitation/invitation.service.ts index c1298b4..7026e80 100644 --- a/coachhub/services/core-api/src/invitation/invitation.service.ts +++ b/coachhub/services/core-api/src/invitation/invitation.service.ts @@ -14,6 +14,7 @@ import { EventPublisherService } from '../messaging/event-publisher.service'; import { EventType } from '../messaging/events'; import { CoachesService } from '../coaches/coaches.service'; import { OtpProvider } from '../common'; +import { EntitlementService } from '../billing/entitlement.service'; const INVITATION_TTL_DAYS = 7; @@ -31,6 +32,7 @@ export class InvitationService { private readonly eventPublisherService: EventPublisherService, private readonly coachesService: CoachesService, private readonly otpProvider: OtpProvider, + private readonly entitlementService: EntitlementService, ) {} async create( @@ -53,6 +55,8 @@ export class InvitationService { ); } + await this.entitlementService.assertCanAddActiveClient(tenantId); + const coach = await this.coachesService.findOne(coachId); if (!coach) { throw new NotFoundException('Inviting coach not found'); diff --git a/coachhub/services/core-api/src/join-requests/join-request.module.ts b/coachhub/services/core-api/src/join-requests/join-request.module.ts index 1796917..8080389 100644 --- a/coachhub/services/core-api/src/join-requests/join-request.module.ts +++ b/coachhub/services/core-api/src/join-requests/join-request.module.ts @@ -7,11 +7,18 @@ import { TenantModule } from '../tenant/tenant.module'; import { MessagingModule } from '../messaging/messaging.module'; import { ConfigModule } from '../config'; import { OtpProvider } from '../common'; +import { BillingModule } from '../billing/billing.module'; @Module({ controllers: [ClientJoinRequestController, CoachJoinRequestController], providers: [JoinRequestService, OtpProvider], - imports: [ClientModule, TenantModule, MessagingModule, ConfigModule], + imports: [ + ClientModule, + TenantModule, + MessagingModule, + ConfigModule, + BillingModule, + ], exports: [JoinRequestService], }) export class JoinRequestModule {} diff --git a/coachhub/services/core-api/src/join-requests/join-request.service.ts b/coachhub/services/core-api/src/join-requests/join-request.service.ts index e487584..e1a77ca 100644 --- a/coachhub/services/core-api/src/join-requests/join-request.service.ts +++ b/coachhub/services/core-api/src/join-requests/join-request.service.ts @@ -14,6 +14,7 @@ import { EventPublisherService } from '../messaging/event-publisher.service'; import { EventType } from '../messaging/events'; import { CreateJoinRequestDto } from './dto/create-join-request.dto'; import { OtpProvider } from '../common'; +import { EntitlementService } from '../billing/entitlement.service'; /** Approval codes live as long as coach invites — a week to open the app. */ const APPROVAL_OTP_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -36,6 +37,7 @@ export class JoinRequestService { private readonly eventPublisherService: EventPublisherService, private readonly configService: ConfigService, private readonly otpProvider: OtpProvider, + private readonly entitlementService: EntitlementService, ) {} private buildUrl(path: string): string { @@ -132,6 +134,7 @@ export class JoinRequestService { let otp: string | null = null; let decided; if (approved) { + await this.entitlementService.assertCanAddActiveClient(tenantId); otp = this.otpProvider.generateOtp(); decided = await this.membershipService.approveWithOtp( membership, diff --git a/coachhub/services/core-api/src/onboarding/onboarding.module.ts b/coachhub/services/core-api/src/onboarding/onboarding.module.ts index 6c19d8b..4fe9cad 100644 --- a/coachhub/services/core-api/src/onboarding/onboarding.module.ts +++ b/coachhub/services/core-api/src/onboarding/onboarding.module.ts @@ -6,10 +6,16 @@ import { Invitation } from '../invitation/entities/invitation.entity'; import { ClientModule } from '../clients/client.module'; import { AuthModule } from '../auth/auth.module'; import { OtpProvider } from '../common'; +import { BillingModule } from '../billing/billing.module'; @Module({ controllers: [OnboardingController], providers: [OnboardingService, OtpProvider], - imports: [TypeOrmModule.forFeature([Invitation]), ClientModule, AuthModule], + imports: [ + TypeOrmModule.forFeature([Invitation]), + ClientModule, + AuthModule, + BillingModule, + ], }) export class OnboardingModule {} diff --git a/coachhub/services/core-api/src/onboarding/onboarding.service.ts b/coachhub/services/core-api/src/onboarding/onboarding.service.ts index ee21214..6fc8758 100644 --- a/coachhub/services/core-api/src/onboarding/onboarding.service.ts +++ b/coachhub/services/core-api/src/onboarding/onboarding.service.ts @@ -15,6 +15,7 @@ import { ClientIntakeService } from '../clients/client-intake.service'; import { ClientAuthService } from '../auth/services/client-auth.service'; import { MembershipStatus, OtpProvider } from '../common'; import { ConfirmOnboardingDto } from './dto/confirm-onboarding.dto'; +import { EntitlementService } from '../billing/entitlement.service'; /** What a valid code resolved to — the app shows this before asking for intake. */ export interface OnboardingCodeInfo { @@ -48,6 +49,7 @@ export class OnboardingService { private readonly intakeService: ClientIntakeService, private readonly clientAuthService: ClientAuthService, private readonly otpProvider: OtpProvider, + private readonly entitlementService: EntitlementService, ) {} /** Check a code without consuming it — lets the app gate the intake screen. */ @@ -68,6 +70,7 @@ export class OnboardingService { if (resolved.kind === 'invitation') { const { invitation } = resolved; tenantId = invitation.tenant.id; + await this.entitlementService.assertCanAddActiveClient(tenantId); membership = await this.membershipService.createMembership( clientId, tenantId, @@ -80,6 +83,7 @@ export class OnboardingService { await this.invitationRepository.save(invitation); } else { tenantId = resolved.membership.tenant.id; + await this.entitlementService.assertCanAddActiveClient(tenantId); membership = await this.membershipService.activateInvited( resolved.membership, ); diff --git a/coachhub/services/core-api/src/tenant/entities/tenant.entity.ts b/coachhub/services/core-api/src/tenant/entities/tenant.entity.ts index 81d1d0a..129bde7 100644 --- a/coachhub/services/core-api/src/tenant/entities/tenant.entity.ts +++ b/coachhub/services/core-api/src/tenant/entities/tenant.entity.ts @@ -8,6 +8,7 @@ import { UpdateDateColumn, } from 'typeorm'; import { Coach } from '../../coaches/entities/coach.entity'; +import { SubscriptionPlan } from '../../billing/enums/subscription-plan.enum'; @Entity('tenants') export class Tenant { @@ -42,6 +43,22 @@ export class Tenant { @Column({ type: 'char', length: 3, default: 'EGP' }) currency: string; + @Column({ + name: 'subscription_plan', + type: 'enum', + enum: SubscriptionPlan, + enumName: 'subscription_plan', + default: SubscriptionPlan.FREE, + }) + subscriptionPlan: SubscriptionPlan; + + @Column({ + name: 'subscription_expires_at', + type: 'timestamptz', + nullable: true, + }) + subscriptionExpiresAt: Date | null; + @Column({ type: 'jsonb', default: () => `'{}'` }) settings: Record;