Skip to content
Merged
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
12 changes: 12 additions & 0 deletions src/modules/dto/create-order.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions src/modules/dto/refund.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsUUID, IsString } from "class-validator";

export class RefundDto {
@IsUUID()
orderId: string;

@IsString()
organizerId: string;

// optional reason
reason?: string;
}
9 changes: 9 additions & 0 deletions src/modules/dto/release-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { IsUUID } from "class-validator";

export class ReleaseEscrowDto {
@IsUUID()
orderId: string;

// who triggers release (scanner, system, organizer) - optional
triggeredById?: string;
}
33 changes: 33 additions & 0 deletions src/modules/entities/escrow.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
48 changes: 48 additions & 0 deletions src/modules/entities/order.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions src/modules/entities/payment.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions src/modules/entities/refund.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 27 additions & 0 deletions src/modules/entities/ticket.entitiy.ts
Original file line number Diff line number Diff line change
@@ -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;
}
36 changes: 36 additions & 0 deletions src/modules/escrow.controller.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
71 changes: 71 additions & 0 deletions src/modules/escrow.service.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
80 changes: 80 additions & 0 deletions src/modules/orders.service.ts
Original file line number Diff line number Diff line change
@@ -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<Order> {
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);

Check warning on line 26 in src/modules/orders.service.ts

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

Unsafe argument of type error typed assigned to a parameter of type `EntityTarget<ObjectLiteral>`

Check warning on line 27 in src/modules/orders.service.ts

View workflow job for this annotation

GitHub Actions / Lint, Test, Build

Unsafe argument of type error typed assigned to a parameter of type `EntityTarget<ObjectLiteral>`
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();
}
}
}
Loading
Loading