Skip to content
Merged

Dev #65

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
1 change: 1 addition & 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 Down
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 {}
142 changes: 142 additions & 0 deletions coachhub/services/core-api/src/billing/billing.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<PaymentAttempt>,
{} as Repository<Tenant>,
{} 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();
});
});
Loading
Loading