From 56b25aaaed97c3664b5838dfe941fb5436af4492 Mon Sep 17 00:00:00 2001 From: Elisha Suleiman <112385548+lishmanTech@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:02:40 +0000 Subject: [PATCH 1/2] feat(payments): implement PaymentsModule with payment ledger and webhook support --- backend/src/payment/dto/create-payment.dto.ts | 26 +++++++ backend/src/payment/dto/refund-payment.dto.ts | 6 ++ .../src/payment/entities/payment.entity.ts | 48 ++++++++++++ .../payment/enums/payment-provider.enum.ts | 5 ++ .../src/payment/enums/payment-status.enum.ts | 6 ++ backend/src/payment/payments.controller.ts | 54 +++++++++++++ backend/src/payment/payments.module.ts | 14 ++++ backend/src/payment/payments.service.ts | 77 +++++++++++++++++++ 8 files changed, 236 insertions(+) create mode 100644 backend/src/payment/dto/create-payment.dto.ts create mode 100644 backend/src/payment/dto/refund-payment.dto.ts create mode 100644 backend/src/payment/entities/payment.entity.ts create mode 100644 backend/src/payment/enums/payment-provider.enum.ts create mode 100644 backend/src/payment/enums/payment-status.enum.ts create mode 100644 backend/src/payment/payments.controller.ts create mode 100644 backend/src/payment/payments.module.ts create mode 100644 backend/src/payment/payments.service.ts 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 From 264c68fe04998b9a275443be4518e1225aec12d6 Mon Sep 17 00:00:00 2001 From: Elisha Suleiman <112385548+lishmanTech@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:11:18 +0000 Subject: [PATCH 2/2] feat(invoices): implement self-contained InvoicesModule for invoice generation and tracking --- .../src/invoices/dto/create-invoice.dto.ts | 44 ++++++ .../src/invoices/dto/filter-invoice.dto.ts | 16 +++ .../src/invoices/entities/invoice.entity.ts | 59 ++++++++ .../src/invoices/enums/invoice-status.enum.ts | 6 + backend/src/invoices/invoices.controller.ts | 57 ++++++++ backend/src/invoices/invoices.module.ts | 14 ++ backend/src/invoices/invoices.service.ts | 133 ++++++++++++++++++ 7 files changed, 329 insertions(+) create mode 100644 backend/src/invoices/dto/create-invoice.dto.ts create mode 100644 backend/src/invoices/dto/filter-invoice.dto.ts create mode 100644 backend/src/invoices/entities/invoice.entity.ts create mode 100644 backend/src/invoices/enums/invoice-status.enum.ts create mode 100644 backend/src/invoices/invoices.controller.ts create mode 100644 backend/src/invoices/invoices.module.ts create mode 100644 backend/src/invoices/invoices.service.ts diff --git a/backend/src/invoices/dto/create-invoice.dto.ts b/backend/src/invoices/dto/create-invoice.dto.ts new file mode 100644 index 0000000..f232307 --- /dev/null +++ b/backend/src/invoices/dto/create-invoice.dto.ts @@ -0,0 +1,44 @@ +import { + IsString, + IsNumber, + IsArray, + IsOptional, + IsDateString, +} from 'class-validator'; + +export class CreateInvoiceDto { + @IsString() + userId: string; + + @IsString() + orderId: string; + + @IsString() + paymentId: string; + + @IsDateString() + issuedAt: Date; + + @IsOptional() + @IsDateString() + dueAt?: Date; + + @IsArray() + items: Record[]; + + @IsNumber() + subtotal: number; + + @IsNumber() + tax: number; + + @IsNumber() + total: number; + + @IsString() + currency: string; + + @IsOptional() + @IsString() + notes?: string; +} \ No newline at end of file diff --git a/backend/src/invoices/dto/filter-invoice.dto.ts b/backend/src/invoices/dto/filter-invoice.dto.ts new file mode 100644 index 0000000..2571007 --- /dev/null +++ b/backend/src/invoices/dto/filter-invoice.dto.ts @@ -0,0 +1,16 @@ +import { IsOptional, IsEnum, IsDateString } from 'class-validator'; +import { InvoiceStatus } from '../enums/invoice-status.enum'; + +export class FilterInvoiceDto { + @IsOptional() + @IsEnum(InvoiceStatus) + status?: InvoiceStatus; + + @IsOptional() + @IsDateString() + startDate?: Date; + + @IsOptional() + @IsDateString() + endDate?: Date; +} \ No newline at end of file diff --git a/backend/src/invoices/entities/invoice.entity.ts b/backend/src/invoices/entities/invoice.entity.ts new file mode 100644 index 0000000..13f059e --- /dev/null +++ b/backend/src/invoices/entities/invoice.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { InvoiceStatus } from '../enums/invoice-status.enum'; + +@Entity() +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + invoiceNumber: string; + + @Column() + userId: string; + + @Column() + orderId: string; + + @Column() + paymentId: string; + + @Column({ + type: 'enum', + enum: InvoiceStatus, + default: InvoiceStatus.DRAFT, + }) + status: InvoiceStatus; + + @Column({ type: 'timestamp' }) + issuedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + dueAt: Date; + + @Column({ type: 'jsonb' }) + items: Record[]; + + @Column('decimal') + subtotal: number; + + @Column('decimal') + tax: number; + + @Column('decimal') + total: number; + + @Column() + currency: string; + + @Column({ nullable: true }) + notes: string; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/invoices/enums/invoice-status.enum.ts b/backend/src/invoices/enums/invoice-status.enum.ts new file mode 100644 index 0000000..f9095ad --- /dev/null +++ b/backend/src/invoices/enums/invoice-status.enum.ts @@ -0,0 +1,6 @@ +export enum InvoiceStatus { + DRAFT = 'draft', + SENT = 'sent', + PAID = 'paid', + VOID = 'void', +} \ No newline at end of file diff --git a/backend/src/invoices/invoices.controller.ts b/backend/src/invoices/invoices.controller.ts new file mode 100644 index 0000000..8b55013 --- /dev/null +++ b/backend/src/invoices/invoices.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Post, + Get, + Param, + Body, + Query, +} from '@nestjs/common'; + +import { InvoicesService } from './invoices.service'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { FilterInvoiceDto } from './dto/filter-invoice.dto'; + +@Controller('invoices') +export class InvoicesController { + constructor( + private readonly invoicesService: InvoicesService, + ) {} + + @Post() + create(@Body() dto: CreateInvoiceDto) { + return this.invoicesService.createInvoice(dto); + } + + @Get() + getInvoices( + @Query('page') page = 1, + @Query('limit') limit = 10, + @Query() filter: FilterInvoiceDto, + ) { + return this.invoicesService.getInvoices( + Number(page), + Number(limit), + filter, + ); + } + + @Get(':id') + getInvoice(@Param('id') id: string) { + return this.invoicesService.getInvoice(id); + } + + @Get(':id/download') + downloadInvoice(@Param('id') id: string) { + return this.invoicesService.downloadInvoice(id); + } + + @Post(':id/send') + sendInvoice(@Param('id') id: string) { + return this.invoicesService.sendInvoice(id); + } + + @Post(':id/void') + voidInvoice(@Param('id') id: string) { + return this.invoicesService.voidInvoice(id); + } +} \ No newline at end of file diff --git a/backend/src/invoices/invoices.module.ts b/backend/src/invoices/invoices.module.ts new file mode 100644 index 0000000..33e25b1 --- /dev/null +++ b/backend/src/invoices/invoices.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { InvoicesController } from './invoices.controller'; +import { InvoicesService } from './invoices.service'; +import { Invoice } from './entities/invoice.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Invoice])], + controllers: [InvoicesController], + providers: [InvoicesService], + exports: [InvoicesService], +}) +export class InvoicesModule {} \ No newline at end of file diff --git a/backend/src/invoices/invoices.service.ts b/backend/src/invoices/invoices.service.ts new file mode 100644 index 0000000..3884bd3 --- /dev/null +++ b/backend/src/invoices/invoices.service.ts @@ -0,0 +1,133 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between } from 'typeorm'; + +import { Invoice } from './entities/invoice.entity'; +import { CreateInvoiceDto } from './dto/create-invoice.dto'; +import { FilterInvoiceDto } from './dto/filter-invoice.dto'; +import { InvoiceStatus } from './enums/invoice-status.enum'; + +@Injectable() +export class InvoicesService { + constructor( + @InjectRepository(Invoice) + private invoiceRepo: Repository, + ) {} + + private async generateInvoiceNumber(): Promise { + const now = new Date(); + const yearMonth = `${now.getFullYear()}${( + now.getMonth() + 1 + ) + .toString() + .padStart(2, '0')}`; + + const count = await this.invoiceRepo.count(); + + const sequence = (count + 1) + .toString() + .padStart(4, '0'); + + return `INV-${yearMonth}-${sequence}`; + } + + async createInvoice(dto: CreateInvoiceDto): Promise { + const invoiceNumber = await this.generateInvoiceNumber(); + + const invoice = this.invoiceRepo.create({ + ...dto, + invoiceNumber, + status: InvoiceStatus.DRAFT, + }); + + return this.invoiceRepo.save(invoice); + } + + async getInvoices( + page = 1, + limit = 10, + filter?: FilterInvoiceDto, + ) { + const where: any = {}; + + if (filter?.status) { + where.status = filter.status; + } + + if (filter?.startDate && filter?.endDate) { + where.issuedAt = Between( + filter.startDate, + filter.endDate, + ); + } + + const [data, total] = await this.invoiceRepo.findAndCount({ + where, + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + data, + total, + page, + limit, + }; + } + + async getInvoice(id: string): Promise { + const invoice = await this.invoiceRepo.findOne({ + where: { id }, + }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + return invoice; + } + + async downloadInvoice(id: string) { + const invoice = await this.getInvoice(id); + + return { + invoiceNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt, + dueAt: invoice.dueAt, + items: invoice.items, + subtotal: invoice.subtotal, + tax: invoice.tax, + total: invoice.total, + currency: invoice.currency, + notes: invoice.notes, + userId: invoice.userId, + orderId: invoice.orderId, + paymentId: invoice.paymentId, + }; + } + + async sendInvoice(id: string) { + const invoice = await this.getInvoice(id); + + invoice.status = InvoiceStatus.SENT; + + await this.invoiceRepo.save(invoice); + + return { + message: 'Invoice marked as sent', + invoice, + }; + } + + async voidInvoice(id: string) { + const invoice = await this.getInvoice(id); + + invoice.status = InvoiceStatus.VOID; + + return this.invoiceRepo.save(invoice); + } +} \ No newline at end of file