diff --git a/backend/src/payment/dto/create-payment.dto.ts b/backend/src/payment/dto/create-payment.dto.ts new file mode 100644 index 0000000..930381d --- /dev/null +++ b/backend/src/payment/dto/create-payment.dto.ts @@ -0,0 +1,26 @@ +import { PaymentProvider } from '../enums/payment-provider.enum'; +import { IsString, IsNumber, IsEnum, IsOptional } from 'class-validator'; + +export class CreatePaymentDto { + @IsString() + orderId: string; + + @IsString() + userId: string; + + @IsNumber() + amount: number; + + @IsString() + currency: string; + + @IsEnum(PaymentProvider) + provider: PaymentProvider; + + @IsOptional() + @IsString() + providerReference?: string; + + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/backend/src/payment/dto/refund-payment.dto.ts b/backend/src/payment/dto/refund-payment.dto.ts new file mode 100644 index 0000000..ef7100c --- /dev/null +++ b/backend/src/payment/dto/refund-payment.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class RefundPaymentDto { + @IsString() + reason: string; +} \ No newline at end of file diff --git a/backend/src/payment/entities/payment.entity.ts b/backend/src/payment/entities/payment.entity.ts new file mode 100644 index 0000000..8a54b6c --- /dev/null +++ b/backend/src/payment/entities/payment.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { PaymentProvider } from '../enums/payment-provider.enum'; + +@Entity() +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + orderId: string; + + @Column() + userId: string; + + @Column('decimal') + amount: number; + + @Column() + currency: string; + + @Column({ + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.PENDING, + }) + status: PaymentStatus; + + @Column({ + type: 'enum', + enum: PaymentProvider, + }) + provider: PaymentProvider; + + @Column({ nullable: true }) + providerReference: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/payment/enums/payment-provider.enum.ts b/backend/src/payment/enums/payment-provider.enum.ts new file mode 100644 index 0000000..f566f1e --- /dev/null +++ b/backend/src/payment/enums/payment-provider.enum.ts @@ -0,0 +1,5 @@ +export enum PaymentProvider { + STRIPE = 'stripe', + PAYSTACK = 'paystack', + MANUAL = 'manual', +} \ No newline at end of file diff --git a/backend/src/payment/enums/payment-status.enum.ts b/backend/src/payment/enums/payment-status.enum.ts new file mode 100644 index 0000000..8ef2793 --- /dev/null +++ b/backend/src/payment/enums/payment-status.enum.ts @@ -0,0 +1,6 @@ +export enum PaymentStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + REFUNDED = 'refunded', +} \ No newline at end of file diff --git a/backend/src/payment/payments.controller.ts b/backend/src/payment/payments.controller.ts new file mode 100644 index 0000000..441c7de --- /dev/null +++ b/backend/src/payment/payments.controller.ts @@ -0,0 +1,54 @@ +import { + Controller, + Post, + Get, + Param, + Body, + Query, + Req, +} from '@nestjs/common'; + +import { PaymentsService } from './payments.service'; +import { CreatePaymentDto } from './dto/create-payment.dto'; +import { RefundPaymentDto } from './dto/refund-payment.dto'; + +@Controller('payments') +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Post() + create(@Body() dto: CreatePaymentDto) { + return this.paymentsService.createPayment(dto); + } + + @Get() + getPayments( + @Query('page') page = 1, + @Query('limit') limit = 10, + ) { + return this.paymentsService.getPayments(Number(page), Number(limit)); + } + + @Get(':id') + getPayment(@Param('id') id: string) { + return this.paymentsService.getPaymentById(id); + } + + @Get('order/:orderId') + getPaymentsByOrder(@Param('orderId') orderId: string) { + return this.paymentsService.getPaymentsByOrder(orderId); + } + + @Post(':id/refund') + refundPayment( + @Param('id') id: string, + @Body() dto: RefundPaymentDto, + ) { + return this.paymentsService.refundPayment(id, dto); + } + + @Post('webhook') + handleWebhook(@Req() req: any) { + return this.paymentsService.handleWebhook(req.body); + } +} \ No newline at end of file diff --git a/backend/src/payment/payments.module.ts b/backend/src/payment/payments.module.ts new file mode 100644 index 0000000..7f564d7 --- /dev/null +++ b/backend/src/payment/payments.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { Payment } from './entities/payment.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Payment])], + controllers: [PaymentsController], + providers: [PaymentsService], + exports: [PaymentsService], +}) +export class PaymentsModule {} \ No newline at end of file diff --git a/backend/src/payment/payments.service.ts b/backend/src/payment/payments.service.ts new file mode 100644 index 0000000..08778eb --- /dev/null +++ b/backend/src/payment/payments.service.ts @@ -0,0 +1,77 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Payment } from './entities/payment.entity'; +import { CreatePaymentDto } from './dto/create-payment.dto'; +import { RefundPaymentDto } from './dto/refund-payment.dto'; +import { PaymentStatus } from './enums/payment-status.enum'; + +@Injectable() +export class PaymentsService { + constructor( + @InjectRepository(Payment) + private paymentRepo: Repository, + ) {} + + async createPayment(dto: CreatePaymentDto): Promise { + const payment = this.paymentRepo.create(dto); + return this.paymentRepo.save(payment); + } + + async getPayments(page = 1, limit = 10) { + const [data, total] = await this.paymentRepo.findAndCount({ + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + data, + total, + page, + limit, + }; + } + + async getPaymentById(id: string): Promise { + const payment = await this.paymentRepo.findOne({ where: { id } }); + + if (!payment) { + throw new NotFoundException('Payment not found'); + } + + return payment; + } + + async getPaymentsByOrder(orderId: string): Promise { + return this.paymentRepo.find({ + where: { orderId }, + order: { createdAt: 'DESC' }, + }); + } + + async refundPayment(id: string, dto: RefundPaymentDto): Promise { + const payment = await this.getPaymentById(id); + + payment.status = PaymentStatus.REFUNDED; + + payment.metadata = { + ...(payment.metadata || {}), + refundReason: dto.reason, + refundedAt: new Date(), + }; + + return this.paymentRepo.save(payment); + } + + async handleWebhook(payload: any) { + // provider-agnostic webhook handling + // store event metadata for tracking + + return { + received: true, + payload, + }; + } +} \ No newline at end of file