diff --git a/src/modules/dto/create-order.dto.ts b/src/modules/dto/create-order.dto.ts new file mode 100644 index 00000000..51345e15 --- /dev/null +++ b/src/modules/dto/create-order.dto.ts @@ -0,0 +1,12 @@ +import { IsUUID, IsNumber } from "class-validator"; + +export class CreateOrderDto { + // buyer id can be taken from auth in real app + buyerId: string; + + @IsUUID() + ticketId: string; + + @IsNumber() + amount: number; +} diff --git a/src/modules/dto/refund.dto.ts b/src/modules/dto/refund.dto.ts new file mode 100644 index 00000000..02bdf8a6 --- /dev/null +++ b/src/modules/dto/refund.dto.ts @@ -0,0 +1,12 @@ +import { IsUUID, IsString } from "class-validator"; + +export class RefundDto { + @IsUUID() + orderId: string; + + @IsString() + organizerId: string; + + // optional reason + reason?: string; +} diff --git a/src/modules/dto/release-escrow.dto.ts b/src/modules/dto/release-escrow.dto.ts new file mode 100644 index 00000000..a3e82e90 --- /dev/null +++ b/src/modules/dto/release-escrow.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from "class-validator"; + +export class ReleaseEscrowDto { + @IsUUID() + orderId: string; + + // who triggers release (scanner, system, organizer) - optional + triggeredById?: string; +} diff --git a/src/modules/entities/escrow.entity.ts b/src/modules/entities/escrow.entity.ts new file mode 100644 index 00000000..39e32301 --- /dev/null +++ b/src/modules/entities/escrow.entity.ts @@ -0,0 +1,33 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + ManyToOne, + CreateDateColumn, +} from "typeorm"; +import { Order } from "./order.entity"; +import { User } from "./user.entity"; + +export type EscrowStatus = "holding" | "released" | "refunded"; + +@Entity() +export class Escrow { + @PrimaryGeneratedColumn("uuid") + id: string; + + @OneToOne(() => Order, (o) => o.escrow) + order: Order; + + @ManyToOne(() => User, { eager: true }) + beneficiary: User; // organizer who will receive funds on release + + @Column({ type: "decimal", precision: 12, scale: 2 }) + amount: number; + + @Column({ default: "holding" }) + status: EscrowStatus; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/entities/order.entity.ts b/src/modules/entities/order.entity.ts new file mode 100644 index 00000000..8571cfcd --- /dev/null +++ b/src/modules/entities/order.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "./user.entity"; +import { Ticket } from "./ticket.entity"; +import { Payment } from "./payment.entity"; +import { Escrow } from "./escrow.entity"; + +export type OrderStatus = "pending" | "paid" | "released" | "refunded" | "cancelled"; + +@Entity() +export class Order { + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => User, (u) => u.orders, { eager: true }) + buyer: User; + + @ManyToOne(() => Ticket, { eager: true }) + ticket: Ticket; + + @Column({ type: "decimal", precision: 12, scale: 2 }) + amount: number; + + @Column({ default: "pending" }) + status: OrderStatus; + + @OneToOne(() => Payment, (p) => p.order, { cascade: true, eager: true }) + @JoinColumn() + payment: Payment; + + @OneToOne(() => Escrow, (e) => e.order, { cascade: true, eager: true }) + @JoinColumn() + escrow: Escrow; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/entities/payment.entity.ts b/src/modules/entities/payment.entity.ts new file mode 100644 index 00000000..091bb8b0 --- /dev/null +++ b/src/modules/entities/payment.entity.ts @@ -0,0 +1,31 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToOne, + CreateDateColumn, +} from "typeorm"; +import { Order } from "./order.entity"; + +export type PaymentStatus = "initiated" | "held" | "captured" | "refunded" | "failed"; + +@Entity() +export class Payment { + @PrimaryGeneratedColumn("uuid") + id: string; + + @OneToOne(() => Order, (o) => o.payment) + order: Order; + + @Column() + providerPaymentId: string; // id from Stripe/Paystack or internal + + @Column({ type: "decimal", precision: 12, scale: 2 }) + amount: number; + + @Column({ default: "held" }) + status: PaymentStatus; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/entities/refund.entity.ts b/src/modules/entities/refund.entity.ts new file mode 100644 index 00000000..c75f3733 --- /dev/null +++ b/src/modules/entities/refund.entity.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn } from "typeorm"; +import { Order } from "./order.entity"; +import { User } from "./user.entity"; + +export type RefundStatus = "issued" | "failed"; + +@Entity() +export class Refund { + @PrimaryGeneratedColumn("uuid") + id: string; + + @ManyToOne(() => Order, { eager: true }) + order: Order; + + @ManyToOne(() => User, { eager: true }) + issuedBy: User; // organizer who issued the refund + + @Column({ type: "decimal", precision: 12, scale: 2 }) + amount: number; + + @Column({ default: "issued" }) + status: RefundStatus; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/entities/ticket.entitiy.ts b/src/modules/entities/ticket.entitiy.ts new file mode 100644 index 00000000..14d10a96 --- /dev/null +++ b/src/modules/entities/ticket.entitiy.ts @@ -0,0 +1,27 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from "typeorm"; +import { User } from "./user.entity"; +import { Order } from "./order.entity"; + +export type TicketStatus = "available" | "sold" | "validated" | "refunded"; + +@Entity() +export class Ticket { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + eventName: string; + + @Column({ type: "decimal", precision: 12, scale: 2 }) + price: number; + + @Column({ default: "available" }) + status: TicketStatus; + + // owner/organizer of this ticket + @ManyToOne(() => User, (user) => user.id, { nullable: false }) + organizer: User; + + @ManyToOne(() => Order, (order) => order.ticket, { nullable: true }) + order: Order; +} diff --git a/src/modules/escrow.controller.ts b/src/modules/escrow.controller.ts new file mode 100644 index 00000000..864c456e --- /dev/null +++ b/src/modules/escrow.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Post, Body, Param } from "@nestjs/common"; +import { OrdersService } from "./orders.service"; +import { EscrowService } from "./escrow.service"; +import { RefundsService } from "./refunds.service"; +import { CreateOrderDto } from "./dto/create-order.dto"; +import { ReleaseEscrowDto } from "./dto/release-escrow.dto"; +import { RefundDto } from "./dto/refund.dto"; + +@Controller() +export class EscrowController { + constructor( + private ordersService: OrdersService, + private escrowService: EscrowService, + private refundsService: RefundsService, + ) {} + + @Post("orders") + async createOrder(@Body() body: CreateOrderDto) { + // in real app extract buyerId from auth token + const buyerId = body.buyerId; + const order = await this.ordersService.createOrder(buyerId, body.ticketId, body.amount); + return { success: true, order }; + } + + @Post("escrow/release") + async release(@Body() body: ReleaseEscrowDto) { + const res = await this.escrowService.releaseEscrow(body.orderId, body.triggeredById); + return { success: true, ...res }; + } + + @Post("refunds") + async refund(@Body() body: RefundDto) { + const refund = await this.refundsService.issueRefund(body.orderId, body.organizerId, body.reason); + return { success: true, refund }; + } +} diff --git a/src/modules/escrow.service.ts b/src/modules/escrow.service.ts new file mode 100644 index 00000000..b80626f7 --- /dev/null +++ b/src/modules/escrow.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { Order } from "./entities/order.entity"; +import { Escrow } from "./entities/escrow.entity"; +import { Payment } from "./entities/payment.entity"; +import { Refund } from "./entities/refund.entity"; + +@Injectable() +export class EscrowService { + constructor(private ds: DataSource) {} + + /** + * Release escrow - called when ticket validated. + * This transaction: + * - verifies order & escrow states + * - simulates transferring funds to beneficiary + * - marks payment as captured and escrow as released + * - marks order status as released + */ + async releaseEscrow(orderId: string, triggeredById?: string): Promise<{ order: Order; escrow: Escrow }> { + const qr = this.ds.createQueryRunner(); + await qr.connect(); + await qr.startTransaction(); + + try { + const orderRepo = qr.manager.getRepository(Order); + const escrowRepo = qr.manager.getRepository(Escrow); + const paymentRepo = qr.manager.getRepository(Payment); + const ticketRepo = qr.manager.getRepository("Ticket"); + + const order = await orderRepo.findOne({ where: { id: orderId }, relations: ["escrow", "payment", "ticket"] }); + if (!order) throw new NotFoundException("Order not found"); + + const escrow = order.escrow; + if (!escrow) throw new BadRequestException("Escrow not found for order"); + if (escrow.status !== "holding") throw new BadRequestException("Escrow not in holding state"); + + // Ensure ticket is validated before releasing - in your system this would be more elaborate. + const ticket = order.ticket; + if (!ticket) throw new BadRequestException("Ticket missing"); + if (ticket.status !== "validated") { + throw new BadRequestException("Ticket has not been validated; cannot release escrow"); + } + + // Simulate external provider capture/transfer + // TODO: integrate Stripe/Paystack capture or send to organizer payout + const payment = order.payment; + if (!payment) throw new BadRequestException("Payment record missing"); + + // Update payment status -> captured + payment.status = "captured"; + await paymentRepo.save(payment); + + // Update escrow status -> released + escrow.status = "released"; + await escrowRepo.save(escrow); + + // Update order status + order.status = "released"; + await orderRepo.save(order); + + await qr.commitTransaction(); + return { order, escrow }; + } catch (err) { + await qr.rollbackTransaction(); + throw err; + } finally { + await qr.release(); + } + } +} diff --git a/src/modules/orders.service.ts b/src/modules/orders.service.ts new file mode 100644 index 00000000..9b8bd053 --- /dev/null +++ b/src/modules/orders.service.ts @@ -0,0 +1,80 @@ +import { Injectable, BadRequestException } from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { Order } from "./entities/order.entity"; +import { User } from "./entities/user.entity"; +import { Ticket } from "./entities/ticket.entity"; +import { Payment } from "./entities/payment.entity"; +import { Escrow } from "./entities/escrow.entity"; + +@Injectable() +export class OrdersService { + constructor(private dataSource: DataSource) {} + + /** + * Create an order: reserve ticket, create payment record (status HELD), create escrow record + */ + async createOrder(buyerId: string, ticketId: string, amount: number): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const userRepo = queryRunner.manager.getRepository(User); + const ticketRepo = queryRunner.manager.getRepository(Ticket); + const orderRepo = queryRunner.manager.getRepository(Order); + const paymentRepo = queryRunner.manager.getRepository(Payment); + const escrowRepo = queryRunner.manager.getRepository(Escrow); + + const buyer = await userRepo.findOne({ where: { id: buyerId } }); + if (!buyer) throw new BadRequestException("Buyer not found"); + + const ticket = await ticketRepo.findOne({ where: { id: ticketId }, relations: ["organizer"] }); + if (!ticket) throw new BadRequestException("Ticket not found or unavailable"); + if (ticket.status !== "available") throw new BadRequestException("Ticket not available"); + + // mark ticket as sold (logical reservation) + ticket.status = "sold"; + await ticketRepo.save(ticket); + + // create order + const order = orderRepo.create({ + buyer, + ticket, + amount, + status: "paid", // payment is captured in the sense customer paid, but funds are HELD + }); + await orderRepo.save(order); + + // create payment record (held) + const payment = paymentRepo.create({ + order, + providerPaymentId: `PROV_${Date.now()}`, // replace with real provider id + amount, + status: "held", + }); + await paymentRepo.save(payment); + + // create escrow record + const escrow = escrowRepo.create({ + order, + beneficiary: ticket.organizer, + amount, + status: "holding", + }); + await escrowRepo.save(escrow); + + // attach payment + escrow to order and save + order.payment = payment; + order.escrow = escrow; + await orderRepo.save(order); + + await queryRunner.commitTransaction(); + return order; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } +} diff --git a/src/modules/payments.module.ts b/src/modules/payments.module.ts new file mode 100644 index 00000000..3fc31f40 --- /dev/null +++ b/src/modules/payments.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { OrdersService } from "./orders.service"; +import { EscrowService } from "./escrow.service"; +import { RefundsService } from "./refunds.service"; +import { EscrowController } from "./escrow.controller"; +import { User } from "./entities/user.entity"; +import { Ticket } from "./entities/ticket.entity"; +import { Order } from "./entities/order.entity"; +import { Payment } from "./entities/payment.entity"; +import { Escrow } from "./entities/escrow.entity"; +import { Refund } from "./entities/refund.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([User, Ticket, Order, Payment, Escrow, Refund])], + providers: [OrdersService, EscrowService, RefundsService], + controllers: [EscrowController], +}) +export class PaymentsModule {} diff --git a/src/modules/refunds.service.ts b/src/modules/refunds.service.ts new file mode 100644 index 00000000..8937cd9f --- /dev/null +++ b/src/modules/refunds.service.ts @@ -0,0 +1,91 @@ +import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from "@nestjs/common"; +import { DataSource } from "typeorm"; +import { Refund } from "./entities/refund.entity"; +import { Order } from "./entities/order.entity"; +import { Ticket } from "./entities/ticket.entity"; +import { Payment } from "./entities/payment.entity"; +import { User } from "./entities/user.entity"; + +@Injectable() +export class RefundsService { + constructor(private ds: DataSource) {} + + /** + * Issue a refund for an order. + * Rules (example): + * - Only organizer of the ticket can issue refund (you can add admin, support roles) + * - Refund allowed only if payment was held or captured but not already refunded + */ + async issueRefund(orderId: string, organizerId: string, reason?: string): Promise { + const qr = this.ds.createQueryRunner(); + await qr.connect(); + await qr.startTransaction(); + + try { + const orderRepo = qr.manager.getRepository(Order); + const paymentRepo = qr.manager.getRepository(Payment); + const refundRepo = qr.manager.getRepository(Refund); + const userRepo = qr.manager.getRepository(User); + const ticketRepo = qr.manager.getRepository(Ticket); + + const order = await orderRepo.findOne({ where: { id: orderId }, relations: ["ticket", "payment"] }); + if (!order) throw new NotFoundException("Order not found"); + + const organizer = await userRepo.findOne({ where: { id: organizerId } }); + if (!organizer) throw new NotFoundException("Organizer not found"); + + // permission: only organizer of ticket can issue refund + if (order.ticket.organizer.id !== organizer.id) { + throw new ForbiddenException("Only the organizer can issue refunds for this ticket"); + } + + if (order.status === "refunded") { + throw new BadRequestException("Order already refunded"); + } + + // Simulate external refund via payment provider + const payment = order.payment; + if (!payment) throw new BadRequestException("Payment missing"); + if (payment.status === "refunded") throw new BadRequestException("Payment already refunded"); + + // call external payment provider to refund -> on success: + // For demo: we assume refund succeeded + + // create refund record + const refund = refundRepo.create({ + order, + issuedBy: organizer, + amount: payment.amount, + status: "issued", + }); + await refundRepo.save(refund); + + // update payment status + payment.status = "refunded"; + await paymentRepo.save(payment); + + // update escrow if exists + if (order.escrow) { + const escrowRepo = qr.manager.getRepository("Escrow"); + await escrowRepo.update({ id: order.escrow.id }, { status: "refunded" }); + } + + // update ticket status to refunded + const ticket = order.ticket; + ticket.status = "refunded"; + await ticketRepo.save(ticket); + + // update order status + order.status = "refunded"; + await orderRepo.save(order); + + await qr.commitTransaction(); + return refund; + } catch (err) { + await qr.rollbackTransaction(); + throw err; + } finally { + await qr.release(); + } + } +}