diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6bd441cc..8e1e5753 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -29,6 +29,7 @@ import { AccessControlModule } from './access-control/access-control.module'; import { WaitlistModule } from './waitlist/waitlist.module'; import { EventsModule } from './events/events.module'; import { MembershipPlansModule } from './membership-plans/membership-plans.module'; +import { CreditsModule } from './credits/credits.module'; import { TeamsModule } from './teams/teams.module'; @Module({ @@ -115,6 +116,7 @@ import { TeamsModule } from './teams/teams.module'; WaitlistModule, EventsModule, MembershipPlansModule, + CreditsModule, TeamsModule, ], controllers: [AppController], diff --git a/backend/src/credits/credits.controller.ts b/backend/src/credits/credits.controller.ts new file mode 100644 index 00000000..2d9de750 --- /dev/null +++ b/backend/src/credits/credits.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Get, + Post, + Body, + Query, + ParseUUIDPipe, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { CreditsService } from './credits.service'; +import { PurchaseCreditsDto } from './dto/purchase-credits.dto'; +import { GetCurrentUser } from '../auth/decorators/getCurrentUser.decorator'; + +@ApiTags('Credits') +@ApiBearerAuth() +@Controller('credits') +export class CreditsController { + constructor(private readonly creditsService: CreditsService) {} + + @Get('packs') + @ApiOperation({ summary: 'List active credit packs' }) + async getPacks() { + const data = await this.creditsService.getCreditPacks(); + return { message: 'Credit packs retrieved successfully', data }; + } + + @Post('purchase') + @ApiOperation({ summary: 'Purchase a credit pack' }) + async purchase( + @Body() dto: PurchaseCreditsDto, + @GetCurrentUser('id') userId: string, + ) { + const data = await this.creditsService.purchase(dto.creditPackId, userId); + return { message: 'Credit purchase initiated', data }; + } + + @Get('balance') + @ApiOperation({ summary: 'Get remaining credit hours' }) + async getBalance(@GetCurrentUser('id') userId: string) { + const data = await this.creditsService.getBalance(userId); + return { message: 'Credit balance retrieved successfully', data }; + } + + @Get('history') + @ApiOperation({ summary: 'Get paginated credit transaction history' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getHistory( + @Query('page') page: string, + @Query('limit') limit: string, + @GetCurrentUser('id') userId: string, + ) { + const data = await this.creditsService.getHistory(userId, { + page: page ? Number(page) : undefined, + limit: limit ? Number(limit) : undefined, + }); + return { message: 'Credit history retrieved successfully', ...data }; + } +} diff --git a/backend/src/credits/credits.module.ts b/backend/src/credits/credits.module.ts new file mode 100644 index 00000000..b8386e5f --- /dev/null +++ b/backend/src/credits/credits.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CreditPack } from './entities/credit-pack.entity'; +import { UserCredit } from './entities/user-credit.entity'; +import { UserCreditTransaction } from './entities/credit-transaction.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { User } from '../users/entities/user.entity'; +import { CreditsService } from './credits.service'; +import { CreditsController } from './credits.controller'; +import { GetCreditPacksProvider } from './providers/get-credit-packs.provider'; +import { PurchaseCreditsProvider } from './providers/purchase-credits.provider'; +import { GetCreditBalanceProvider } from './providers/get-credit-balance.provider'; +import { GetCreditHistoryProvider } from './providers/get-credit-history.provider'; +import { PaystackProvider } from '../payments/providers/paystack.provider'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CreditPack, + UserCredit, + UserCreditTransaction, + Payment, + User, + ]), + ], + controllers: [CreditsController], + providers: [ + CreditsService, + GetCreditPacksProvider, + PurchaseCreditsProvider, + GetCreditBalanceProvider, + GetCreditHistoryProvider, + PaystackProvider, + ], + exports: [CreditsService], +}) +export class CreditsModule {} diff --git a/backend/src/credits/credits.service.ts b/backend/src/credits/credits.service.ts new file mode 100644 index 00000000..3328e78f --- /dev/null +++ b/backend/src/credits/credits.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { GetCreditPacksProvider } from './providers/get-credit-packs.provider'; +import { PurchaseCreditsProvider } from './providers/purchase-credits.provider'; +import { GetCreditBalanceProvider } from './providers/get-credit-balance.provider'; +import { + GetCreditHistoryProvider, + CreditHistoryQuery, +} from './providers/get-credit-history.provider'; +import { CreditPack } from './entities/credit-pack.entity'; +import { UserCreditTransaction } from './entities/credit-transaction.entity'; + +@Injectable() +export class CreditsService { + constructor( + private readonly getCreditPacksProvider: GetCreditPacksProvider, + private readonly purchaseCreditsProvider: PurchaseCreditsProvider, + private readonly getCreditBalanceProvider: GetCreditBalanceProvider, + private readonly getCreditHistoryProvider: GetCreditHistoryProvider, + ) {} + + getCreditPacks(): Promise { + return this.getCreditPacksProvider.findAllActive(); + } + + purchase(creditPackId: string, userId: string) { + return this.purchaseCreditsProvider.purchase(creditPackId, userId); + } + + getBalance(userId: string): Promise<{ remainingHours: number }> { + return this.getCreditBalanceProvider.getBalance(userId); + } + + getHistory( + userId: string, + query: CreditHistoryQuery, + ): Promise<{ + data: UserCreditTransaction[]; + total: number; + page: number; + limit: number; + }> { + return this.getCreditHistoryProvider.find(userId, query); + } +} diff --git a/backend/src/credits/dto/purchase-credits.dto.ts b/backend/src/credits/dto/purchase-credits.dto.ts new file mode 100644 index 00000000..5e810ee1 --- /dev/null +++ b/backend/src/credits/dto/purchase-credits.dto.ts @@ -0,0 +1,8 @@ +import { IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class PurchaseCreditsDto { + @ApiProperty({ description: 'The credit pack ID to purchase' }) + @IsUUID() + creditPackId: string; +} diff --git a/backend/src/credits/entities/credit-pack.entity.ts b/backend/src/credits/entities/credit-pack.entity.ts new file mode 100644 index 00000000..96dfcd61 --- /dev/null +++ b/backend/src/credits/entities/credit-pack.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('credit_packs') +export class CreditPack { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ type: 'decimal', precision: 8, scale: 2 }) + hours: number; + + @Column({ type: 'bigint' }) + priceKobo: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/credits/entities/credit-transaction.entity.ts b/backend/src/credits/entities/credit-transaction.entity.ts new file mode 100644 index 00000000..501d5d2a --- /dev/null +++ b/backend/src/credits/entities/credit-transaction.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + JoinColumn, +} from 'typeorm'; +import { UserCredit } from './user-credit.entity'; +import { CreditTransactionType } from '../enums/credit-transaction-type.enum'; + +@Entity('credit_transactions') +export class UserCreditTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + userCreditId: string; + + @ManyToOne(() => UserCredit) + @JoinColumn({ name: 'userCreditId' }) + userCredit: UserCredit; + + @Column({ type: 'enum', enum: CreditTransactionType }) + type: CreditTransactionType; + + @Column({ type: 'decimal', precision: 8, scale: 2 }) + hours: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/credits/entities/user-credit.entity.ts b/backend/src/credits/entities/user-credit.entity.ts new file mode 100644 index 00000000..bcc02251 --- /dev/null +++ b/backend/src/credits/entities/user-credit.entity.ts @@ -0,0 +1,21 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('user_credits') +export class UserCredit { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + userId: string; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + remainingHours: number; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/credits/enums/credit-transaction-type.enum.ts b/backend/src/credits/enums/credit-transaction-type.enum.ts new file mode 100644 index 00000000..c707e600 --- /dev/null +++ b/backend/src/credits/enums/credit-transaction-type.enum.ts @@ -0,0 +1,5 @@ +export enum CreditTransactionType { + PURCHASE = 'PURCHASE', + SPEND = 'SPEND', + REFUND = 'REFUND', +} diff --git a/backend/src/credits/providers/get-credit-balance.provider.ts b/backend/src/credits/providers/get-credit-balance.provider.ts new file mode 100644 index 00000000..0c276a96 --- /dev/null +++ b/backend/src/credits/providers/get-credit-balance.provider.ts @@ -0,0 +1,22 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserCredit } from '../entities/user-credit.entity'; + +@Injectable() +export class GetCreditBalanceProvider { + constructor( + @InjectRepository(UserCredit) + private readonly userCreditsRepository: Repository, + ) {} + + async getBalance(userId: string): Promise<{ remainingHours: number }> { + const userCredit = await this.userCreditsRepository.findOne({ + where: { userId }, + }); + if (!userCredit) { + return { remainingHours: 0 }; + } + return { remainingHours: Number(userCredit.remainingHours) }; + } +} diff --git a/backend/src/credits/providers/get-credit-history.provider.ts b/backend/src/credits/providers/get-credit-history.provider.ts new file mode 100644 index 00000000..f2e0d70e --- /dev/null +++ b/backend/src/credits/providers/get-credit-history.provider.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserCreditTransaction } from '../entities/credit-transaction.entity'; +import { UserCredit } from '../entities/user-credit.entity'; + +export interface CreditHistoryQuery { + page?: number; + limit?: number; +} + +@Injectable() +export class GetCreditHistoryProvider { + constructor( + @InjectRepository(UserCreditTransaction) + private readonly transactionsRepository: Repository, + @InjectRepository(UserCredit) + private readonly userCreditsRepository: Repository, + ) {} + + async find( + userId: string, + query: CreditHistoryQuery, + ): Promise<{ + data: UserCreditTransaction[]; + total: number; + page: number; + limit: number; + }> { + const page = query.page ?? 1; + const limit = query.limit ?? 20; + + const userCredit = await this.userCreditsRepository.findOne({ + where: { userId }, + }); + if (!userCredit) { + return { data: [], total: 0, page, limit }; + } + + const [data, total] = await this.transactionsRepository.findAndCount({ + where: { userCreditId: userCredit.id }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total, page, limit }; + } +} diff --git a/backend/src/credits/providers/get-credit-packs.provider.ts b/backend/src/credits/providers/get-credit-packs.provider.ts new file mode 100644 index 00000000..25344ee2 --- /dev/null +++ b/backend/src/credits/providers/get-credit-packs.provider.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreditPack } from '../entities/credit-pack.entity'; + +@Injectable() +export class GetCreditPacksProvider { + constructor( + @InjectRepository(CreditPack) + private readonly creditPacksRepository: Repository, + ) {} + + async findAllActive(): Promise { + return this.creditPacksRepository.find({ + where: { isActive: true }, + order: { createdAt: 'ASC' }, + }); + } +} diff --git a/backend/src/credits/providers/purchase-credits.provider.ts b/backend/src/credits/providers/purchase-credits.provider.ts new file mode 100644 index 00000000..87d62a81 --- /dev/null +++ b/backend/src/credits/providers/purchase-credits.provider.ts @@ -0,0 +1,81 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { CreditPack } from '../entities/credit-pack.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { PaymentProvider } from '../../payments/enums/payment-provider.enum'; +import { PaymentStatus } from '../../payments/enums/payment-status.enum'; +import { PaystackProvider } from '../../payments/providers/paystack.provider'; +import { User } from '../../users/entities/user.entity'; + +@Injectable() +export class PurchaseCreditsProvider { + constructor( + @InjectRepository(CreditPack) + private readonly creditPacksRepository: Repository, + @InjectRepository(Payment) + private readonly paymentsRepository: Repository, + @InjectRepository(User) + private readonly usersRepository: Repository, + private readonly paystackProvider: PaystackProvider, + private readonly configService: ConfigService, + ) {} + + async purchase( + creditPackId: string, + userId: string, + ): Promise<{ + paymentId: string; + authorizationUrl: string; + reference: string; + }> { + const creditPack = await this.creditPacksRepository.findOne({ + where: { id: creditPackId, isActive: true }, + }); + if (!creditPack) { + throw new NotFoundException('Credit pack not found'); + } + + const user = await this.usersRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const payment = this.paymentsRepository.create({ + bookingId: null, + userId, + amount: Number(creditPack.priceKobo), + provider: PaymentProvider.PAYSTACK, + status: PaymentStatus.PENDING, + metadata: { + type: 'credit_purchase', + creditPackId, + creditPackName: creditPack.name, + creditHours: Number(creditPack.hours), + }, + }); + const savedPayment = await this.paymentsRepository.save(payment); + + const paystackData = await this.paystackProvider.initializeTransaction( + user.email, + Number(creditPack.priceKobo), + savedPayment.id, + this.configService.get('FRONTEND_PAYMENT_CALLBACK_URL'), + { type: 'credit_purchase', creditPackId, userId }, + ); + + savedPayment.providerReference = paystackData.reference; + await this.paymentsRepository.save(savedPayment); + + return { + paymentId: savedPayment.id, + authorizationUrl: paystackData.authorization_url, + reference: paystackData.reference, + }; + } +} diff --git a/backend/src/payments/entities/payment.entity.ts b/backend/src/payments/entities/payment.entity.ts index dc810374..fbdc1d37 100644 --- a/backend/src/payments/entities/payment.entity.ts +++ b/backend/src/payments/entities/payment.entity.ts @@ -21,12 +21,12 @@ export class Payment { @PrimaryGeneratedColumn('uuid') id: string; - @Column('uuid') - bookingId: string; + @Column({ type: 'uuid', nullable: true }) + bookingId: string | null; - @ManyToOne(() => Booking, { onDelete: 'RESTRICT' }) + @ManyToOne(() => Booking, { nullable: true, onDelete: 'RESTRICT' }) @JoinColumn({ name: 'bookingId' }) - booking: Booking; + booking: Booking | null; @Column({ type: 'uuid', nullable: true }) userId: string | null; diff --git a/backend/src/payments/payments.module.ts b/backend/src/payments/payments.module.ts index 13b428ce..7048d6fc 100644 --- a/backend/src/payments/payments.module.ts +++ b/backend/src/payments/payments.module.ts @@ -3,6 +3,9 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Payment } from './entities/payment.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { User } from '../users/entities/user.entity'; +import { UserCredit } from '../credits/entities/user-credit.entity'; +import { UserCreditTransaction } from '../credits/entities/credit-transaction.entity'; +import { CreditPack } from '../credits/entities/credit-pack.entity'; import { PaymentsService } from './payments.service'; import { PaymentsController } from './payments.controller'; import { PaystackProvider } from './providers/paystack.provider'; @@ -18,7 +21,7 @@ import { PromoCodesModule } from '../promo-codes/promo-codes.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Payment, Booking, User]), + TypeOrmModule.forFeature([Payment, Booking, User, UserCredit, UserCreditTransaction, CreditPack]), BookingsModule, InvoicesModule, NotificationsModule, diff --git a/backend/src/payments/providers/handle-webhook.provider.ts b/backend/src/payments/providers/handle-webhook.provider.ts index c8cb592e..adb0f53d 100644 --- a/backend/src/payments/providers/handle-webhook.provider.ts +++ b/backend/src/payments/providers/handle-webhook.provider.ts @@ -20,6 +20,9 @@ import { NotificationType } from '../../notifications/enums/notification-type.en import { User } from '../../users/entities/user.entity'; import { EmailService } from '../../email/email.service'; import { PromoCodesService } from '../../promo-codes/promo-codes.service'; +import { UserCredit } from '../../credits/entities/user-credit.entity'; +import { UserCreditTransaction } from '../../credits/entities/credit-transaction.entity'; +import { CreditTransactionType } from '../../credits/enums/credit-transaction-type.enum'; const LONG_TERM_PLANS = new Set([ PlanType.MONTHLY, @@ -38,6 +41,10 @@ export class HandleWebhookProvider { private readonly bookingsRepository: Repository, @InjectRepository(User) private readonly usersRepository: Repository, + @InjectRepository(UserCredit) + private readonly userCreditsRepository: Repository, + @InjectRepository(UserCreditTransaction) + private readonly creditTransactionsRepository: Repository, private readonly paystackProvider: PaystackProvider, private readonly sorobanEscrowProvider: SorobanEscrowProvider, private readonly bookingsService: BookingsService, @@ -111,8 +118,14 @@ export class HandleWebhookProvider { payment.metadata = data; await this.paymentsRepository.save(payment); + // Handle credit purchases + if (payment.metadata && (payment.metadata as Record).type === 'credit_purchase') { + await this.handleCreditPurchase(payment); + return; + } + // Confirm the booking - const booking = await this.bookingsService.confirm(payment.bookingId); + const booking = await this.bookingsService.confirm(payment.bookingId!); // For long-term bookings, record on-chain escrow if (LONG_TERM_PLANS.has(booking.planType)) { @@ -208,6 +221,12 @@ export class HandleWebhookProvider { payment.status = PaymentStatus.FAILED; await this.paymentsRepository.save(payment); + // Credit purchase payments don't have a booking — skip email/notification + if (payment.metadata && (payment.metadata as Record).type === 'credit_purchase') { + this.logger.log(`charge.failed: credit purchase payment ${payment.id} marked FAILED`); + return; + } + // Send payment failed email if (payment.userId) { this.usersRepository @@ -253,6 +272,55 @@ export class HandleWebhookProvider { this.logger.log(`charge.failed: payment ${payment.id} marked FAILED`); } + private async handleCreditPurchase(payment: Payment): Promise { + const meta = payment.metadata as Record | null; + const creditPackId = meta?.creditPackId as string | undefined; + const creditHours = Number(meta?.creditHours ?? 0); + + if (!payment.userId || !creditPackId || creditHours <= 0) { + this.logger.warn( + `credit_purchase: invalid metadata for payment ${payment.id}`, + ); + return; + } + + let userCredit = await this.userCreditsRepository.findOne({ + where: { userId: payment.userId }, + }); + + if (!userCredit) { + userCredit = this.userCreditsRepository.create({ + userId: payment.userId, + remainingHours: 0, + }); + } + + userCredit.remainingHours = Number(userCredit.remainingHours) + creditHours; + await this.userCreditsRepository.save(userCredit); + + const transaction = this.creditTransactionsRepository.create({ + userCreditId: userCredit.id, + type: CreditTransactionType.PURCHASE, + hours: creditHours, + description: `Credit pack purchase (${meta?.creditPackName ?? creditPackId})`, + }); + await this.creditTransactionsRepository.save(transaction); + + this.notificationsService + .create({ + userId: payment.userId, + type: NotificationType.PAYMENT_SUCCESS, + title: 'Credit Purchase Successful', + message: `${creditHours} credit hours have been added to your account.`, + metadata: { paymentId: payment.id, creditPackId }, + }) + .catch(() => void 0); + + this.logger.log( + `credit_purchase: payment ${payment.id}, ${creditHours} hours added to user ${payment.userId}`, + ); + } + private async recordSorobanEscrow( payment: Payment, booking: Booking,