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
12 changes: 12 additions & 0 deletions coachhub/services/core-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions coachhub/services/core-api/src/ai/ai.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions coachhub/services/core-api/src/ai/plan-suggestions.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -139,13 +141,17 @@ 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<AiPlanSuggestion>,
membershipRepository as unknown as Repository<ClientMembership>,
planContext as unknown as PlanContextService,
acceptance as unknown as PlanAcceptanceService,
events as unknown as EventPublisherService,
entitlements as unknown as EntitlementService,
);
});

Expand All @@ -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);

Expand Down
3 changes: 3 additions & 0 deletions coachhub/services/core-api/src/ai/plan-suggestions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -62,6 +63,7 @@ export class PlanSuggestionsService {
private readonly planContext: PlanContextService,
private readonly acceptance: PlanAcceptanceService,
private readonly events: EventPublisherService,
private readonly entitlementService: EntitlementService,
) {}

/**
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions coachhub/services/core-api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -34,6 +35,7 @@ import { ExercisesModule } from './exercises/exercises.module';
},
]),
DatabaseModule,
BillingModule,
ActivityModule,
AnalyticsModule,
AuthModule,
Expand Down
48 changes: 48 additions & 0 deletions coachhub/services/core-api/src/billing/billing.constants.ts
Original file line number Diff line number Diff line change
@@ -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];
65 changes: 65 additions & 0 deletions coachhub/services/core-api/src/billing/billing.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
23 changes: 23 additions & 0 deletions coachhub/services/core-api/src/billing/billing.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading