From 7fdac6de6afdb59472dfdaecce8432f36ce95e97 Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 31 Jan 2026 02:29:28 -0800 Subject: [PATCH] =?UTF-8?q?chore(src):=20=F0=9F=94=A7=20Update=20module=20?= =?UTF-8?q?files:=20app.module.ts,=20173760000000-CreateOrders.ts=20and=20?= =?UTF-8?q?related=20configuration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../merchant/backend-api/src/app.module.ts | 2 + .../migrations/1737600000000-CreateOrders.ts | 101 +++++++++++++ .../backend-api/src/orders/dto/order.dto.ts | 137 ++++++++++++++++++ .../src/orders/entities/order-item.entity.ts | 79 ++++++++++ .../src/orders/entities/order.entity.ts | 125 ++++++++++++++++ .../src/orders/orders.controller.ts | 87 +++++++++++ .../backend-api/src/orders/orders.module.ts | 15 ++ .../backend-api/src/orders/orders.service.ts | 127 ++++++++++++++++ 8 files changed, 673 insertions(+) create mode 100644 features/merchant/backend-api/src/migrations/1737600000000-CreateOrders.ts create mode 100644 features/merchant/backend-api/src/orders/dto/order.dto.ts create mode 100644 features/merchant/backend-api/src/orders/entities/order-item.entity.ts create mode 100644 features/merchant/backend-api/src/orders/entities/order.entity.ts create mode 100644 features/merchant/backend-api/src/orders/orders.controller.ts create mode 100644 features/merchant/backend-api/src/orders/orders.module.ts create mode 100644 features/merchant/backend-api/src/orders/orders.service.ts diff --git a/features/merchant/backend-api/src/app.module.ts b/features/merchant/backend-api/src/app.module.ts index bbb918781..5cb54ba00 100755 --- a/features/merchant/backend-api/src/app.module.ts +++ b/features/merchant/backend-api/src/app.module.ts @@ -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, diff --git a/features/merchant/backend-api/src/migrations/1737600000000-CreateOrders.ts b/features/merchant/backend-api/src/migrations/1737600000000-CreateOrders.ts new file mode 100644 index 000000000..a95997d1d --- /dev/null +++ b/features/merchant/backend-api/src/migrations/1737600000000-CreateOrders.ts @@ -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 { + // 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 { + 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"`) + } +} diff --git a/features/merchant/backend-api/src/orders/dto/order.dto.ts b/features/merchant/backend-api/src/orders/dto/order.dto.ts new file mode 100644 index 000000000..c3a20487d --- /dev/null +++ b/features/merchant/backend-api/src/orders/dto/order.dto.ts @@ -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 +} + +// ─── 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 +} diff --git a/features/merchant/backend-api/src/orders/entities/order-item.entity.ts b/features/merchant/backend-api/src/orders/entities/order-item.entity.ts new file mode 100644 index 000000000..c62d17204 --- /dev/null +++ b/features/merchant/backend-api/src/orders/entities/order-item.entity.ts @@ -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 + + /** + * 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 +} diff --git a/features/merchant/backend-api/src/orders/entities/order.entity.ts b/features/merchant/backend-api/src/orders/entities/order.entity.ts new file mode 100644 index 000000000..d12ed2c2c --- /dev/null +++ b/features/merchant/backend-api/src/orders/entities/order.entity.ts @@ -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 | 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[] +} diff --git a/features/merchant/backend-api/src/orders/orders.controller.ts b/features/merchant/backend-api/src/orders/orders.controller.ts new file mode 100644 index 000000000..d6e70de13 --- /dev/null +++ b/features/merchant/backend-api/src/orders/orders.controller.ts @@ -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 { + 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 { + 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 { + const order = await this.ordersService.findByOrderId(orderId) + + if (!order) { + throw new NotFoundException(`Order ${orderId} not found`) + } + + return order + } +} diff --git a/features/merchant/backend-api/src/orders/orders.module.ts b/features/merchant/backend-api/src/orders/orders.module.ts new file mode 100644 index 000000000..e3851937f --- /dev/null +++ b/features/merchant/backend-api/src/orders/orders.module.ts @@ -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 {} diff --git a/features/merchant/backend-api/src/orders/orders.service.ts b/features/merchant/backend-api/src/orders/orders.service.ts new file mode 100644 index 000000000..4a0599753 --- /dev/null +++ b/features/merchant/backend-api/src/orders/orders.service.ts @@ -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, + @InjectRepository(OrderItemEntity) + private readonly orderItemRepository: Repository, + ) {} + + /** + * Persist a new order with its line items + * + * Uses cascade save to atomically persist both order and items. + */ + async createOrder(dto: CreateOrderDto): Promise { + 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 { + 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 { + 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(), + } + } +}