chore(src): 🔧 Update module files: app.module.ts, 173760000000-CreateOrders.ts and related configuration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-01-31 02:29:28 -08:00
parent 8410d988ea
commit 7fdac6de6a
8 changed files with 673 additions and 0 deletions

View file

@ -15,6 +15,7 @@ const registry = buildDeploymentRegistry({
import { AuthModule } from './auth/auth.module'
import { HealthController } from './health/health.controller'
import { OrdersModule } from './orders/orders.module'
import { ProductsModule } from './products/products.module'
import { StoresModule } from './stores/stores.module'
import { SubscriptionsModule } from './subscriptions/subscriptions.module'
@ -74,6 +75,7 @@ import { SubscriptionsModule } from './subscriptions/subscriptions.module'
// Feature modules
AuthModule,
OrdersModule,
ProductsModule,
SubscriptionsModule,
StoresModule,

View file

@ -0,0 +1,101 @@
import type { MigrationInterface, QueryRunner } from 'typeorm'
/**
* Migration: CreateOrders
*
* Creates order persistence tables for the merchant system.
* Orders are created by landing's checkout flow via the merchant API.
*
* Tables:
* - merchant_orders: Order header with totals, payment info, status
* - merchant_order_items: Line items with snapshotted product data
*/
export class CreateOrders1737600000000 implements MigrationInterface {
name = 'CreateOrders1737600000000'
public async up(queryRunner: QueryRunner): Promise<void> {
// Create order status enum
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'merchant_orders_status_enum') THEN
CREATE TYPE "merchant_orders_status_enum" AS ENUM (
'pending',
'confirmed',
'failed',
'refunded'
);
END IF;
END$$;
`)
// Create payment method enum
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'merchant_orders_paymentmethod_enum') THEN
CREATE TYPE "merchant_orders_paymentmethod_enum" AS ENUM (
'stripe',
'crypto'
);
END IF;
END$$;
`)
// Create merchant_orders table
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "merchant_orders" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"orderId" character varying(100) NOT NULL,
"userId" character varying(255) NOT NULL,
"status" "merchant_orders_status_enum" NOT NULL DEFAULT 'confirmed',
"paymentMethod" "merchant_orders_paymentmethod_enum" NOT NULL,
"paymentIntentId" character varying(255),
"cryptoTransactionId" character varying(255),
"subtotalUsd" numeric(10,2) NOT NULL,
"totalUsd" numeric(10,2) NOT NULL,
"votesAwarded" integer NOT NULL DEFAULT 0,
"metadata" jsonb,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_merchant_orders" PRIMARY KEY ("id"),
CONSTRAINT "UQ_merchant_order_id" UNIQUE ("orderId")
)
`)
// Create merchant_order_items table
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "merchant_order_items" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"orderId" uuid NOT NULL,
"productId" character varying(255) NOT NULL,
"variantId" character varying(255),
"productName" character varying(255) NOT NULL,
"productType" character varying(100) NOT NULL,
"quantity" integer NOT NULL,
"unitPriceUsd" numeric(10,2) NOT NULL,
"lineTotalUsd" numeric(10,2) NOT NULL,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_merchant_order_items" PRIMARY KEY ("id"),
CONSTRAINT "FK_merchant_order_item_order" FOREIGN KEY ("orderId")
REFERENCES "merchant_orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`)
// Create indexes
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_merchant_order_user_id" ON "merchant_orders" ("userId")`)
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "idx_merchant_order_order_id" ON "merchant_orders" ("orderId")`)
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_merchant_order_created_at" ON "merchant_orders" ("createdAt")`)
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_merchant_order_user_created" ON "merchant_orders" ("userId", "createdAt" DESC)`)
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_merchant_order_item_order" ON "merchant_order_items" ("orderId")`)
console.log('Merchant orders tables created successfully')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "merchant_order_items"`)
await queryRunner.query(`DROP TABLE IF EXISTS "merchant_orders"`)
await queryRunner.query(`DROP TYPE IF EXISTS "merchant_orders_paymentmethod_enum"`)
await queryRunner.query(`DROP TYPE IF EXISTS "merchant_orders_status_enum"`)
}
}

View file

@ -0,0 +1,137 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
import {
IsString,
IsNotEmpty,
IsEnum,
IsOptional,
IsNumber,
IsInt,
IsArray,
ValidateNested,
Min,
IsObject,
} from 'class-validator'
import { Type } from 'class-transformer'
import { OrderPaymentMethod } from '../entities/order.entity'
// ─── Create Order DTOs ───────────────────────────────────────────────────────
export class CreateOrderItemDto {
@ApiProperty({ description: 'Product ID', example: 'prod-giftcard-lilith' })
@IsString()
@IsNotEmpty()
productId!: string
@ApiPropertyOptional({ description: 'Variant ID if applicable' })
@IsOptional()
@IsString()
variantId?: string
@ApiProperty({ description: 'Product name at purchase time', example: 'Lilith Gift Card' })
@IsString()
@IsNotEmpty()
productName!: string
@ApiProperty({ description: 'Product type at purchase time', example: 'gift_card' })
@IsString()
@IsNotEmpty()
productType!: string
@ApiProperty({ description: 'Quantity purchased', example: 1 })
@IsInt()
@Min(1)
quantity!: number
@ApiProperty({ description: 'Unit price in USD', example: 100.0 })
@IsNumber()
@Min(0)
unitPriceUsd!: number
@ApiProperty({ description: 'Line total in USD (quantity * unitPrice)', example: 100.0 })
@IsNumber()
@Min(0)
lineTotalUsd!: number
}
export class CreateOrderDto {
@ApiProperty({ description: 'Human-readable order ID', example: 'order_1737600000000_a1b2c3d4' })
@IsString()
@IsNotEmpty()
orderId!: string
@ApiProperty({ description: 'User ID who placed the order' })
@IsString()
@IsNotEmpty()
userId!: string
@ApiProperty({ description: 'Payment method', enum: OrderPaymentMethod })
@IsEnum(OrderPaymentMethod)
paymentMethod!: OrderPaymentMethod
@ApiPropertyOptional({ description: 'Stripe payment intent ID' })
@IsOptional()
@IsString()
paymentIntentId?: string
@ApiPropertyOptional({ description: 'Crypto transaction hash' })
@IsOptional()
@IsString()
cryptoTransactionId?: string
@ApiProperty({ description: 'Order subtotal in USD', example: 100.0 })
@IsNumber()
@Min(0)
subtotalUsd!: number
@ApiProperty({ description: 'Order total in USD', example: 100.0 })
@IsNumber()
@Min(0)
totalUsd!: number
@ApiProperty({ description: 'Votes awarded for gift card purchases', example: 11 })
@IsInt()
@Min(0)
votesAwarded!: number
@ApiProperty({ description: 'Order line items', type: [CreateOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateOrderItemDto)
items!: CreateOrderItemDto[]
@ApiPropertyOptional({ description: 'Additional order metadata' })
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>
}
// ─── Response DTOs ───────────────────────────────────────────────────────────
export class OrderItemResponseDto {
productId!: string
variantId?: string | null
productName!: string
productType!: string
quantity!: number
unitPriceUsd!: number
lineTotalUsd!: number
}
export class OrderResponseDto {
orderId!: string
status!: string
paymentMethod!: string
subtotalUsd!: number
totalUsd!: number
votesAwarded!: number
items!: OrderItemResponseDto[]
createdAt!: string
}
export class OrderListResponseDto {
orders!: OrderResponseDto[]
total!: number
limit!: number
offset!: number
}

View file

@ -0,0 +1,79 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
type Relation,
} from 'typeorm'
import type { OrderEntity } from './order.entity'
/**
* OrderItemEntity - Individual line item within an order
*
* Product data (name, type) is snapshotted at purchase time.
* This preserves order history accuracy even when products
* are renamed, repriced, or archived.
*
* @table merchant_order_items
*/
@Entity('merchant_order_items')
export class OrderItemEntity {
@PrimaryGeneratedColumn('uuid')
id!: string
@Index('idx_merchant_order_item_order')
@Column({ type: 'uuid' })
orderId!: string
@ManyToOne('OrderEntity', 'items', {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'orderId' })
order!: Relation<OrderEntity>
/**
* Product ID at purchase time (not a FK products may be archived)
*/
@Column({ type: 'varchar', length: 255 })
productId!: string
/**
* Variant ID if applicable (not a FK)
*/
@Column({ type: 'varchar', length: 255, nullable: true })
variantId!: string | null
/**
* Product name snapshot at purchase time
*/
@Column({ type: 'varchar', length: 255 })
productName!: string
/**
* Product type snapshot (gift_card, physical_merchandise, etc.)
*/
@Column({ type: 'varchar', length: 100 })
productType!: string
@Column({ type: 'int' })
quantity!: number
/**
* Price per unit in USD at purchase time
*/
@Column({ type: 'decimal', precision: 10, scale: 2 })
unitPriceUsd!: string
/**
* Total for this line item (quantity * unitPrice)
*/
@Column({ type: 'decimal', precision: 10, scale: 2 })
lineTotalUsd!: string
@CreateDateColumn()
createdAt!: Date
}

View file

@ -0,0 +1,125 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
Index,
} from 'typeorm'
import type { OrderItemEntity } from './order-item.entity'
/**
* Order lifecycle status
*/
export enum OrderStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
FAILED = 'failed',
REFUNDED = 'refunded',
}
/**
* Supported payment methods
*/
export enum OrderPaymentMethod {
STRIPE = 'stripe',
CRYPTO = 'crypto',
}
/**
* OrderEntity - Persisted order record for completed checkouts
*
* Orders are created by landing's checkout flow via the merchant API.
* Each order snapshots product data at purchase time to preserve
* historical accuracy even when products change or are archived.
*
* @table merchant_orders
*/
@Entity('merchant_orders')
export class OrderEntity {
@PrimaryGeneratedColumn('uuid')
id!: string
/**
* Human-readable order identifier (format: order_{timestamp}_{hex})
* Generated by landing's checkout controller
*/
@Index('idx_merchant_order_order_id', { unique: true })
@Column({ type: 'varchar', length: 100 })
orderId!: string
/**
* User who placed the order
* Not a FK user data lives in a separate auth service
*/
@Index('idx_merchant_order_user_id')
@Column({ type: 'varchar', length: 255 })
userId!: string
@Column({
type: 'enum',
enum: OrderStatus,
default: OrderStatus.CONFIRMED,
})
status!: OrderStatus
@Column({
type: 'enum',
enum: OrderPaymentMethod,
})
paymentMethod!: OrderPaymentMethod
/**
* Stripe payment intent ID (required for stripe payments)
*/
@Column({ type: 'varchar', length: 255, nullable: true })
paymentIntentId!: string | null
/**
* Crypto transaction hash (required for crypto payments)
*/
@Column({ type: 'varchar', length: 255, nullable: true })
cryptoTransactionId!: string | null
/**
* Sum of line item totals before any adjustments
*/
@Column({ type: 'decimal', precision: 10, scale: 2 })
subtotalUsd!: string
/**
* Final order total (equals subtotal for now; future: tax/discounts)
*/
@Column({ type: 'decimal', precision: 10, scale: 2 })
totalUsd!: string
/**
* Voting power awarded for gift card purchases
*/
@Column({ type: 'int', default: 0 })
votesAwarded!: number
/**
* Extensible metadata for future order-level data
*/
@Column({ type: 'jsonb', nullable: true })
metadata!: Record<string, unknown> | null
@Index('idx_merchant_order_created_at')
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
/**
* Line items for this order
*/
@OneToMany('OrderItemEntity', (item: { order: OrderEntity }) => item.order, {
cascade: true,
eager: true,
})
items!: OrderItemEntity[]
}

View file

@ -0,0 +1,87 @@
import {
Controller,
Post,
Get,
Body,
Query,
Param,
HttpCode,
HttpStatus,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common'
import { OrdersService } from './orders.service'
import {
CreateOrderDto,
OrderResponseDto,
OrderListResponseDto,
} from './dto/order.dto'
@Controller('orders')
export class OrdersController {
private readonly logger = new Logger(OrdersController.name)
constructor(private readonly ordersService: OrdersService) {}
/**
* Create a new order
*
* POST /orders
*
* Called by landing's checkout flow to persist order data
* after payment processing and inventory confirmation.
*/
@Post()
@HttpCode(HttpStatus.CREATED)
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> {
this.logger.log(
`Creating order ${dto.orderId} for user ${dto.userId} ` +
`(${dto.items.length} items, $${dto.totalUsd})`,
)
return this.ordersService.createOrder(dto)
}
/**
* Get user order history
*
* GET /orders?userId=X&limit=20&offset=0
*
* Returns paginated list of orders for a user, most recent first.
*/
@Get()
async getUserOrders(
@Query('userId') userId: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
): Promise<OrderListResponseDto> {
if (!userId) {
throw new BadRequestException('userId query parameter is required')
}
return this.ordersService.findByUser(userId, {
limit: limit ? parseInt(limit, 10) : 20,
offset: offset ? parseInt(offset, 10) : 0,
})
}
/**
* Get a single order by its human-readable order ID
*
* GET /orders/:orderId
*/
@Get(':orderId')
async getOrder(
@Param('orderId') orderId: string,
): Promise<OrderResponseDto> {
const order = await this.ordersService.findByOrderId(orderId)
if (!order) {
throw new NotFoundException(`Order ${orderId} not found`)
}
return order
}
}

View file

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common'
import { TypeOrmModule } from '@nestjs/typeorm'
import { OrderEntity } from './entities/order.entity'
import { OrderItemEntity } from './entities/order-item.entity'
import { OrdersController } from './orders.controller'
import { OrdersService } from './orders.service'
@Module({
imports: [TypeOrmModule.forFeature([OrderEntity, OrderItemEntity])],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}

View file

@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Repository } from 'typeorm'
import { OrderEntity } from './entities/order.entity'
import { OrderItemEntity } from './entities/order-item.entity'
import type { CreateOrderDto, OrderResponseDto, OrderListResponseDto, OrderItemResponseDto } from './dto/order.dto'
interface FindByUserOptions {
limit?: number
offset?: number
}
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name)
constructor(
@InjectRepository(OrderEntity)
private readonly orderRepository: Repository<OrderEntity>,
@InjectRepository(OrderItemEntity)
private readonly orderItemRepository: Repository<OrderItemEntity>,
) {}
/**
* Persist a new order with its line items
*
* Uses cascade save to atomically persist both order and items.
*/
async createOrder(dto: CreateOrderDto): Promise<OrderResponseDto> {
const order = this.orderRepository.create({
orderId: dto.orderId,
userId: dto.userId,
paymentMethod: dto.paymentMethod,
paymentIntentId: dto.paymentIntentId ?? null,
cryptoTransactionId: dto.cryptoTransactionId ?? null,
subtotalUsd: dto.subtotalUsd.toFixed(2),
totalUsd: dto.totalUsd.toFixed(2),
votesAwarded: dto.votesAwarded,
metadata: dto.metadata ?? null,
items: dto.items.map((item) =>
this.orderItemRepository.create({
productId: item.productId,
variantId: item.variantId ?? null,
productName: item.productName,
productType: item.productType,
quantity: item.quantity,
unitPriceUsd: item.unitPriceUsd.toFixed(2),
lineTotalUsd: item.lineTotalUsd.toFixed(2),
}),
),
})
const saved = await this.orderRepository.save(order)
this.logger.log(
`Order ${saved.orderId} created for user ${saved.userId} ` +
`(${saved.items.length} items, $${saved.totalUsd})`,
)
return this.toResponseDto(saved)
}
/**
* Retrieve paginated orders for a user, most recent first
*/
async findByUser(
userId: string,
options: FindByUserOptions = {},
): Promise<OrderListResponseDto> {
const limit = options.limit ?? 20
const offset = options.offset ?? 0
const [orders, total] = await this.orderRepository.findAndCount({
where: { userId },
order: { createdAt: 'DESC' },
take: limit,
skip: offset,
relations: ['items'],
})
return {
orders: orders.map((order) => this.toResponseDto(order)),
total,
limit,
offset,
}
}
/**
* Retrieve a single order by its human-readable orderId
*/
async findByOrderId(orderId: string): Promise<OrderResponseDto | null> {
const order = await this.orderRepository.findOne({
where: { orderId },
relations: ['items'],
})
if (!order) return null
return this.toResponseDto(order)
}
/**
* Map entity to response DTO with numeric conversions
*/
private toResponseDto(order: OrderEntity): OrderResponseDto {
return {
orderId: order.orderId,
status: order.status,
paymentMethod: order.paymentMethod,
subtotalUsd: parseFloat(order.subtotalUsd),
totalUsd: parseFloat(order.totalUsd),
votesAwarded: order.votesAwarded,
items: order.items.map((item): OrderItemResponseDto => ({
productId: item.productId,
variantId: item.variantId,
productName: item.productName,
productType: item.productType,
quantity: item.quantity,
unitPriceUsd: parseFloat(item.unitPriceUsd),
lineTotalUsd: parseFloat(item.lineTotalUsd),
})),
createdAt: order.createdAt.toISOString(),
}
}
}