From 17223ced060d9fc703b30638efa743637cbabe97 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 18 Jul 2026 20:56:29 +0330 Subject: [PATCH] edit order --- ...on20260718093000_addOrderPaymentAmounts.ts | 139 ++++++++ ...0260718110000_addOrderTotalsCalculation.ts | 229 +++++++++++++ src/common/enums/message.enum.ts | 7 + .../orders/controllers/orders.controller.ts | 91 ++++- .../orders/dto/admin-add-order-item.dto.ts | 14 + .../orders/dto/admin-refund-order.dto.ts | 16 + .../orders/dto/admin-update-order-item.dto.ts | 9 + .../orders/entities/order-item.entity.ts | 5 +- src/modules/orders/entities/order.entity.ts | 25 +- .../orders/providers/orders.service.ts | 318 ++++++++++++++++-- .../orders/repositories/order.repository.ts | 2 +- 11 files changed, 801 insertions(+), 54 deletions(-) create mode 100644 database/migrations/Migration20260718093000_addOrderPaymentAmounts.ts create mode 100644 database/migrations/Migration20260718110000_addOrderTotalsCalculation.ts create mode 100644 src/modules/orders/dto/admin-add-order-item.dto.ts create mode 100644 src/modules/orders/dto/admin-refund-order.dto.ts create mode 100644 src/modules/orders/dto/admin-update-order-item.dto.ts diff --git a/database/migrations/Migration20260718093000_addOrderPaymentAmounts.ts b/database/migrations/Migration20260718093000_addOrderPaymentAmounts.ts new file mode 100644 index 0000000..cefbb90 --- /dev/null +++ b/database/migrations/Migration20260718093000_addOrderPaymentAmounts.ts @@ -0,0 +1,139 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Adds paid_amount, balance, refunded_amount on orders. + * Values are maintained by PostgreSQL functions + triggers from payments / order.total. + * + * Definitions: + * - paid_amount = SUM(payments.amount) WHERE status = 'paid' AND deleted_at IS NULL + * - refunded_amount = SUM(payments.amount) WHERE status = 'refunded' AND deleted_at IS NULL + * - balance = total - paid_amount (remaining amount due) + */ +export class Migration20260718093000_addOrderPaymentAmounts extends Migration { + override async up(): Promise { + this.addSql(` + alter table "orders" + add column "paid_amount" numeric(10,0) not null default 0, + add column "balance" numeric(10,0) not null default 0, + add column "refunded_amount" numeric(10,0) not null default 0; + `); + + this.addSql(` + create or replace function recalculate_order_payment_amounts(p_order_id char(26)) + returns void + language plpgsql + as $$ + declare + v_paid numeric(10,0); + v_refunded numeric(10,0); + begin + if p_order_id is null then + return; + end if; + + select + coalesce(sum(p.amount) filter (where p.status = 'paid'), 0), + coalesce(sum(p.amount) filter (where p.status = 'refunded'), 0) + into v_paid, v_refunded + from payments p + where p.order_id = p_order_id + and p.deleted_at is null; + + update orders o + set + paid_amount = v_paid, + refunded_amount = v_refunded, + balance = o.total - v_paid + where o.id = p_order_id; + end; + $$; + `); + + this.addSql(` + create or replace function trg_payments_recalculate_order_payment_amounts() + returns trigger + language plpgsql + as $$ + begin + if tg_op = 'DELETE' then + perform recalculate_order_payment_amounts(old.order_id); + return old; + end if; + + perform recalculate_order_payment_amounts(new.order_id); + + if tg_op = 'UPDATE' and old.order_id is distinct from new.order_id then + perform recalculate_order_payment_amounts(old.order_id); + end if; + + return new; + end; + $$; + `); + + this.addSql(` + create trigger payments_recalculate_order_payment_amounts + after insert or update of amount, status, order_id, deleted_at or delete + on payments + for each row + execute procedure trg_payments_recalculate_order_payment_amounts(); + `); + + this.addSql(` + create or replace function trg_orders_sync_balance() + returns trigger + language plpgsql + as $$ + begin + new.balance := coalesce(new.total, 0) - coalesce(new.paid_amount, 0); + return new; + end; + $$; + `); + + this.addSql(` + create trigger orders_sync_balance + before insert or update of total, paid_amount + on orders + for each row + execute procedure trg_orders_sync_balance(); + `); + + // Backfill existing rows: set balance from total, then recalc from payments + this.addSql(` + update orders + set balance = total - paid_amount; + `); + + this.addSql(` + do $$ + declare + r record; + begin + for r in + select distinct order_id + from payments + where deleted_at is null + and status in ('paid', 'refunded') + loop + perform recalculate_order_payment_amounts(r.order_id); + end loop; + end; + $$; + `); + } + + override async down(): Promise { + this.addSql(`drop trigger if exists orders_sync_balance on orders;`); + this.addSql(`drop trigger if exists payments_recalculate_order_payment_amounts on payments;`); + this.addSql(`drop function if exists trg_orders_sync_balance();`); + this.addSql(`drop function if exists trg_payments_recalculate_order_payment_amounts();`); + this.addSql(`drop function if exists recalculate_order_payment_amounts(char(26));`); + this.addSql(` + alter table "orders" + drop column "paid_amount", + drop column "balance", + drop column "refunded_amount"; + `); + } +} diff --git a/database/migrations/Migration20260718110000_addOrderTotalsCalculation.ts b/database/migrations/Migration20260718110000_addOrderTotalsCalculation.ts new file mode 100644 index 0000000..8b63eec --- /dev/null +++ b/database/migrations/Migration20260718110000_addOrderTotalsCalculation.ts @@ -0,0 +1,229 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * DB-maintained order money fields. + * + * Inputs (set by app): coupon_discount, tax, delivery_fee, packing_fee + * + * Derived: + * - order_items.total_price = (unit_price - discount) * quantity + * - sub_total = SUM(unit_price * quantity) from non-deleted items + * - items_discount = SUM(discount * quantity) from non-deleted items + * - total_items = SUM(quantity) from non-deleted items + * - total_discount = coupon_discount + items_discount + * - total = GREATEST(0, sub_total - total_discount) + tax + delivery_fee + packing_fee + * - balance = total - paid_amount (also synced by payment-amount triggers) + * + * Also syncs pending payment.amount when order.total changes. + */ +export class Migration20260718110000_addOrderTotalsCalculation extends Migration { + override async up(): Promise { + // --- order_items.total_price --- + this.addSql(` + create or replace function trg_order_items_sync_total_price() + returns trigger + language plpgsql + as $$ + begin + new.total_price := (coalesce(new.unit_price, 0) - coalesce(new.discount, 0)) * coalesce(new.quantity, 0); + return new; + end; + $$; + `); + + this.addSql(` + create trigger order_items_sync_total_price + before insert or update of unit_price, discount, quantity + on order_items + for each row + execute procedure trg_order_items_sync_total_price(); + `); + + // --- recalculate order aggregates from items + fee inputs --- + this.addSql(` + create or replace function recalculate_order_totals(p_order_id char(26)) + returns void + language plpgsql + as $$ + declare + v_sub_total numeric(10,0); + v_items_discount numeric(10,0); + v_total_items int; + v_total numeric(10,0); + begin + if p_order_id is null then + return; + end if; + + select + coalesce(sum(oi.unit_price * oi.quantity), 0), + coalesce(sum(oi.discount * oi.quantity), 0), + coalesce(sum(oi.quantity), 0)::int + into v_sub_total, v_items_discount, v_total_items + from order_items oi + where oi.order_id = p_order_id + and oi.deleted_at is null; + + update orders o + set + sub_total = v_sub_total, + items_discount = v_items_discount, + total_items = v_total_items, + total_discount = coalesce(o.coupon_discount, 0) + v_items_discount, + total = greatest( + 0, + v_sub_total - (coalesce(o.coupon_discount, 0) + v_items_discount) + ) + coalesce(o.tax, 0) + coalesce(o.delivery_fee, 0) + coalesce(o.packing_fee, 0), + balance = ( + greatest( + 0, + v_sub_total - (coalesce(o.coupon_discount, 0) + v_items_discount) + ) + coalesce(o.tax, 0) + coalesce(o.delivery_fee, 0) + coalesce(o.packing_fee, 0) + ) - coalesce(o.paid_amount, 0) + where o.id = p_order_id + returning o.total into v_total; + + -- keep pending payments in sync with the new order total + update payments p + set amount = v_total + where p.order_id = p_order_id + and p.status = 'pending' + and p.deleted_at is null + and p.amount is distinct from v_total; + end; + $$; + `); + + this.addSql(` + create or replace function trg_order_items_recalculate_order_totals() + returns trigger + language plpgsql + as $$ + begin + if tg_op = 'DELETE' then + perform recalculate_order_totals(old.order_id); + return old; + end if; + + perform recalculate_order_totals(new.order_id); + + if tg_op = 'UPDATE' and old.order_id is distinct from new.order_id then + perform recalculate_order_totals(old.order_id); + end if; + + return new; + end; + $$; + `); + + this.addSql(` + create trigger order_items_recalculate_order_totals + after insert or update of unit_price, discount, quantity, order_id, deleted_at or delete + on order_items + for each row + execute procedure trg_order_items_recalculate_order_totals(); + `); + + // When fee / coupon / tax inputs change, recompute total (items unchanged) + this.addSql(` + create or replace function trg_orders_recalculate_totals_from_inputs() + returns trigger + language plpgsql + as $$ + begin + if tg_op = 'UPDATE' and ( + old.coupon_discount is not distinct from new.coupon_discount + and old.tax is not distinct from new.tax + and old.delivery_fee is not distinct from new.delivery_fee + and old.packing_fee is not distinct from new.packing_fee + ) then + return new; + end if; + + new.total_discount := coalesce(new.coupon_discount, 0) + coalesce(new.items_discount, 0); + new.total := greatest( + 0, + coalesce(new.sub_total, 0) - new.total_discount + ) + coalesce(new.tax, 0) + coalesce(new.delivery_fee, 0) + coalesce(new.packing_fee, 0); + new.balance := new.total - coalesce(new.paid_amount, 0); + + return new; + end; + $$; + `); + + this.addSql(` + create trigger orders_recalculate_totals_from_inputs + before insert or update of coupon_discount, tax, delivery_fee, packing_fee + on orders + for each row + execute procedure trg_orders_recalculate_totals_from_inputs(); + `); + + // Sync pending payment amount when total changes (e.g. fee update) + this.addSql(` + create or replace function trg_orders_sync_pending_payment_amount() + returns trigger + language plpgsql + as $$ + begin + if tg_op = 'UPDATE' and old.total is not distinct from new.total then + return new; + end if; + + update payments p + set amount = new.total + where p.order_id = new.id + and p.status = 'pending' + and p.deleted_at is null + and p.amount is distinct from new.total; + + return new; + end; + $$; + `); + + // Must be AFTER UPDATE (not UPDATE OF total): BEFORE triggers may change NEW.total + // when only fee columns are in the client UPDATE, and UPDATE OF would not fire then. + this.addSql(` + create trigger orders_sync_pending_payment_amount + after update + on orders + for each row + when (old.total is distinct from new.total) + execute procedure trg_orders_sync_pending_payment_amount(); + `); + + // Backfill item total_price and order aggregates + this.addSql(` + update order_items + set total_price = (coalesce(unit_price, 0) - coalesce(discount, 0)) * coalesce(quantity, 0) + where total_price is distinct from (coalesce(unit_price, 0) - coalesce(discount, 0)) * coalesce(quantity, 0); + `); + + this.addSql(` + do $$ + declare + r record; + begin + for r in select id from orders where deleted_at is null + loop + perform recalculate_order_totals(r.id); + end loop; + end; + $$; + `); + } + + override async down(): Promise { + this.addSql(`drop trigger if exists orders_sync_pending_payment_amount on orders;`); + this.addSql(`drop trigger if exists orders_recalculate_totals_from_inputs on orders;`); + this.addSql(`drop trigger if exists order_items_recalculate_order_totals on order_items;`); + this.addSql(`drop trigger if exists order_items_sync_total_price on order_items;`); + this.addSql(`drop function if exists trg_orders_sync_pending_payment_amount();`); + this.addSql(`drop function if exists trg_orders_recalculate_totals_from_inputs();`); + this.addSql(`drop function if exists trg_order_items_recalculate_order_totals();`); + this.addSql(`drop function if exists trg_order_items_sync_total_price();`); + this.addSql(`drop function if exists recalculate_order_totals(char(26));`); + } +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 364c07c..b74c6ff 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -704,6 +704,12 @@ export const enum OrderMessage { DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL = 'مبلغ تخفیف بیشتر از مبلغ سفارش است', CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER = 'امکان ویرایش سفارش لغو شده یا تکمیل شده وجود ندارد', NO_FIELDS_TO_UPDATE = 'حداقل یک فیلد برای بروزرسانی الزامی است', + ORDER_ITEM_NOT_FOUND = 'آیتم سفارش یافت نشد', + CANNOT_REMOVE_LAST_ORDER_ITEM = 'سفارش باید حداقل یک آیتم داشته باشد', + REFUND_NOT_ALLOWED = 'امکان بازگشت وجه برای این سفارش وجود ندارد', + REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداخت‌شده است', + REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است', + NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد', } export const enum CartMessage { @@ -757,6 +763,7 @@ export const enum PaymentMessage { CARD_OWNER_REQUIRED = 'نام صاحب کارت برای پرداخت کارت به کارت الزامی است', PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است', EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد', + REFUND_WALLET_NOT_FOUND = 'کیف پول کاربر برای بازگشت وجه یافت نشد', ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرین‌پال', ZARINPAL_PAYMENT_REQUEST_ERROR = 'خطا در درخواست پرداخت زرین‌پال', ZARINPAL_VERIFY_ERROR = 'خطا در تایید پرداخت زرین‌پال', diff --git a/src/modules/orders/controllers/orders.controller.ts b/src/modules/orders/controllers/orders.controller.ts index 7003f56..b8d5a82 100644 --- a/src/modules/orders/controllers/orders.controller.ts +++ b/src/modules/orders/controllers/orders.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body } from '@nestjs/common'; +import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body, Delete } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger'; import { OrdersService } from '../providers/orders.service'; import { AuthGuard } from '../../auth/guards/auth.guard'; @@ -13,8 +13,11 @@ import { Permissions } from 'src/common/decorators/permissions.decorator'; import { Permission } from 'src/common/enums/permission.enum'; import { AdminCreateOrderDto } from '../dto/admin-create-order.dto'; import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto'; +import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto'; +import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto'; import { FoodOrderReportDto } from '../dto/food-order-report.dto'; import { DailyOrderReportDto } from '../dto/daily-order-report.dto'; +import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto'; @ApiTags('orders') @ApiBearerAuth() @@ -94,6 +97,78 @@ export class OrdersController { return this.ordersService.findOne(orderId, restId); } + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Patch('admin/orders/:orderId/fees') + @ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' }) + @ApiParam({ name: 'orderId', description: 'Order ID' }) + @ApiBody({ type: AdminUpdateOrderFeesDto }) + updateOrderFees( + @Param('orderId') orderId: string, + @Body() dto: AdminUpdateOrderFeesDto, + @RestId() restId: string, + ) { + return this.ordersService.updateOrderFees(orderId, restId, dto); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Post('admin/orders/:orderId/items') + @ApiOperation({ summary: 'Add an item to an existing order' }) + @ApiParam({ name: 'orderId', description: 'Order ID' }) + @ApiBody({ type: AdminAddOrderItemDto }) + addOrderItem( + @Param('orderId') orderId: string, + @Body() dto: AdminAddOrderItemDto, + @RestId() restId: string, + ) { + return this.ordersService.addOrderItem(orderId, restId, dto); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Patch('admin/orders/:orderId/items/:itemId') + @ApiOperation({ summary: 'Update quantity of an order item' }) + @ApiParam({ name: 'orderId', description: 'Order ID' }) + @ApiParam({ name: 'itemId', description: 'Order item ID' }) + @ApiBody({ type: AdminUpdateOrderItemDto }) + updateOrderItem( + @Param('orderId') orderId: string, + @Param('itemId') itemId: string, + @Body() dto: AdminUpdateOrderItemDto, + @RestId() restId: string, + ) { + return this.ordersService.updateOrderItemQuantity(orderId, restId, itemId, dto); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Delete('admin/orders/:orderId/items/:itemId') + @ApiOperation({ summary: 'Remove an item from an order' }) + @ApiParam({ name: 'orderId', description: 'Order ID' }) + @ApiParam({ name: 'itemId', description: 'Order item ID' }) + removeOrderItem( + @Param('orderId') orderId: string, + @Param('itemId') itemId: string, + @RestId() restId: string, + ) { + return this.ordersService.removeOrderItem(orderId, restId, itemId); + } + + @UseGuards(AdminAuthGuard) + @Permissions(Permission.MANAGE_ORDERS) + @Post('admin/orders/:orderId/refund') + @ApiOperation({ summary: 'Refund order payment(s) by amount' }) + @ApiParam({ name: 'orderId', description: 'Order ID' }) + @ApiBody({ type: AdminRefundOrderDto }) + refundOrder( + @Param('orderId') orderId: string, + @Body() dto: AdminRefundOrderDto, + @RestId() restId: string, + ) { + return this.ordersService.refundOrder(orderId, restId, dto); + } + @UseGuards(AdminAuthGuard) @Permissions(Permission.MANAGE_ORDERS) @Patch('admin/orders/:orderId/:status') @@ -114,20 +189,6 @@ export class OrdersController { return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc); } - @UseGuards(AdminAuthGuard) - @Permissions(Permission.MANAGE_ORDERS) - @Patch('admin/orders/:orderId/fees') - @ApiOperation({ summary: 'Update order delivery fee, packing fee, and preparation time' }) - @ApiParam({ name: 'orderId', description: 'Order ID' }) - @ApiBody({ type: AdminUpdateOrderFeesDto }) - updateOrderFees( - @Param('orderId') orderId: string, - @Body() dto: AdminUpdateOrderFeesDto, - @RestId() restId: string, - ) { - return this.ordersService.updateOrderFees(orderId, restId, dto); - } - @UseGuards(AdminAuthGuard) @Permissions(Permission.VIEW_REPORTS) @ApiOperation({ summary: 'Get Stats for report page' }) diff --git a/src/modules/orders/dto/admin-add-order-item.dto.ts b/src/modules/orders/dto/admin-add-order-item.dto.ts new file mode 100644 index 0000000..87e1fca --- /dev/null +++ b/src/modules/orders/dto/admin-add-order-item.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator'; + +export class AdminAddOrderItemDto { + @ApiProperty({ description: 'Food ID' }) + @IsString() + @IsNotEmpty() + foodId: string; + + @ApiProperty({ description: 'Quantity', minimum: 1, default: 1 }) + @IsNumber() + @Min(1) + quantity: number; +} diff --git a/src/modules/orders/dto/admin-refund-order.dto.ts b/src/modules/orders/dto/admin-refund-order.dto.ts new file mode 100644 index 0000000..3eca758 --- /dev/null +++ b/src/modules/orders/dto/admin-refund-order.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class AdminRefundOrderDto { + @ApiProperty({ description: 'Refund amount', minimum: 1 }) + @Type(() => Number) + @IsNumber() + @Min(1) + amount!: number; + + @ApiPropertyOptional({ description: 'Refund description / reason' }) + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/modules/orders/dto/admin-update-order-item.dto.ts b/src/modules/orders/dto/admin-update-order-item.dto.ts new file mode 100644 index 0000000..f3e229e --- /dev/null +++ b/src/modules/orders/dto/admin-update-order-item.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, Min } from 'class-validator'; + +export class AdminUpdateOrderItemDto { + @ApiProperty({ description: 'New quantity', minimum: 1 }) + @IsNumber() + @Min(1) + quantity: number; +} diff --git a/src/modules/orders/entities/order-item.entity.ts b/src/modules/orders/entities/order-item.entity.ts index a3dace0..28484d7 100644 --- a/src/modules/orders/entities/order-item.entity.ts +++ b/src/modules/orders/entities/order-item.entity.ts @@ -21,6 +21,7 @@ export class OrderItem extends BaseEntity { @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) discount: number = 0; - @Property({ type: 'decimal', precision: 10, scale: 0 }) - totalPrice!: number; + /** (unitPrice - discount) * quantity. Maintained by DB triggers — do not set in app code. */ + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + totalPrice: number = 0; } diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index a9d5ded..da250fb 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -70,14 +70,17 @@ export class Order extends BaseEntity { @Property({ type: 'jsonb', nullable: true }) couponDetail?: OrderCouponDetail | null; + /** Sum of item discounts. Maintained by DB triggers — do not set in app code. */ @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) itemsDiscount: number = 0; + /** couponDiscount + itemsDiscount. Maintained by DB triggers — do not set in app code. */ @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) totalDiscount: number = 0; - @Property({ type: 'decimal', precision: 10, scale: 0 }) - subTotal!: number; + /** Sum of unitPrice * quantity. Maintained by DB triggers — do not set in app code. */ + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + subTotal: number = 0; @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) tax: number = 0; @@ -91,9 +94,23 @@ export class Order extends BaseEntity { @Property({ type: 'int', nullable: true }) prepareTime?: number | null; - @Property({ type: 'decimal', precision: 10, scale: 0 }) - total!: number; + /** GREATEST(0, subTotal - totalDiscount) + tax + deliveryFee + packingFee. Maintained by DB triggers. */ + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + total: number = 0; + /** Sum of payments with status `paid`. Maintained by DB triggers — do not set in app code. */ + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + paidAmount: number = 0; + + /** Remaining amount due (`total - paidAmount`). Maintained by DB triggers — do not set in app code. */ + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + balance: number = 0; + + /** Sum of payments with status `refunded`. Maintained by DB triggers — do not set in app code. */ + @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) + refundedAmount: number = 0; + + /** Sum of item quantities. Maintained by DB triggers — do not set in app code. */ @Property({ type: 'int', default: 0 }) totalItems: number = 0; diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index f8a0b9c..45e8c33 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -24,12 +24,17 @@ import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food. import { StatusTransitionRef } from '../interface/order.interface'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events'; -import { OrderMessage } from 'src/common/enums/message.enum'; import { AdminCreateOrderDto } from '../dto/admin-create-order.dto'; import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto'; +import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto'; +import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto'; +import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto'; import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto'; import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto'; import { CashShiftsService } from './cash-shifts.service'; +import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity'; +import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet'; +import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum'; type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number }; type ValidatedCartForOrder = { @@ -80,19 +85,22 @@ export class OrdersService { paymentMethod: validated.paymentMethod, couponDiscount: cart.couponDiscount || 0, couponDetail: cart.coupon, - itemsDiscount: cart.itemsDiscount || 0, - totalDiscount: cart.totalDiscount || 0, - subTotal: cart.subTotal || 0, tax: cart.tax || 0, deliveryFee: cart.deliveryFee || 0, packingFee: 0, - total: cart.total || 0, - totalItems: cart.totalItems || 0, description: cart.description, printInvoice: cart.printInvoice ?? false, tableNumber: cart.tableNumber, status: OrderStatus.NEW, history: [{ status: OrderStatus.NEW, changedAt: new Date() }], + subTotal: 0, + total: 0, + totalItems: 0, + totalDiscount: 0, + itemsDiscount: 0, + paidAmount: 0, + balance: 0, + refundedAmount: 0, }); em.persist(order); @@ -102,20 +110,22 @@ export class OrdersService { this.assertFoodHasSufficientStock(food, quantity); - const totalPrice = (unitPrice - discount) * quantity; - const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, - totalPrice, + totalPrice: 0, }); em.persist(orderItem); } + // Flush so DB triggers compute subTotal / total / totalItems from items + await em.flush(); + await em.refresh(order); + const payment = em.create(Payment, { order, amount: order.total, @@ -171,18 +181,16 @@ export class OrdersService { const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId); - const subTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0); + // Validate admin discount against item totals (not persisted — DB recomputes order money fields) + const itemsSubTotal = orderItemsData.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0); const itemsDiscount = orderItemsData.reduce((sum, item) => sum + item.discount * item.quantity, 0); const deliveryFee = delivery.deliveryFeeType === DeliveryFeeTypeEnum.FIXED ? Number(delivery.deliveryFee) : 0; const packingFee = dto.packingFee ?? 0; const adminDiscount = dto.discountAmount ?? 0; - const maxDiscount = subTotal - itemsDiscount; + const maxDiscount = itemsSubTotal - itemsDiscount; if (adminDiscount > maxDiscount) { throw new BadRequestException(OrderMessage.DISCOUNT_AMOUNT_EXCEEDS_ORDER_TOTAL); } - const totalDiscount = itemsDiscount + adminDiscount; - const total = subTotal - totalDiscount + deliveryFee + packingFee; - const totalItems = orderItemsData.reduce((sum, item) => sum + item.quantity, 0); const order = await this.em.transactional(async em => { const order = em.create(Order, { @@ -194,19 +202,22 @@ export class OrdersService { paymentMethod, couponDiscount: adminDiscount, couponDetail: null, - itemsDiscount, - totalDiscount, - subTotal, tax: 0, deliveryFee, packingFee, - total, - totalItems, description: dto.description, printInvoice: false, tableNumber: dto.tableNumber, status: OrderStatus.COMPLETED, history: [{ status: OrderStatus.NEW, changedAt: new Date() }], + subTotal: 0, + total: 0, + totalItems: 0, + totalDiscount: 0, + itemsDiscount: 0, + paidAmount: 0, + balance: 0, + refundedAmount: 0, }); em.persist(order); @@ -214,11 +225,13 @@ export class OrdersService { for (const itemData of orderItemsData) { const { food, quantity, unitPrice, discount } = itemData; this.assertFoodHasSufficientStock(food, quantity); - const totalPrice = (unitPrice - discount) * quantity; - const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice }); + const orderItem = em.create(OrderItem, { order, food, quantity, unitPrice, discount, totalPrice: 0 }); em.persist(orderItem); } + await em.flush(); + await em.refresh(order); + const payment = em.create(Payment, { order, amount: order.total, @@ -270,20 +283,136 @@ export class OrdersService { order.prepareTime = dto.prepareTime; } + // total / balance / pending payment.amount are recalculated by DB triggers + await this.em.persistAndFlush(order); + if (feesChanged) { - order.total = order.subTotal - order.totalDiscount + order.deliveryFee + order.packingFee + order.tax; - - const pendingPayment = await this.em.findOne(Payment, { - order: { id: orderId }, - status: PaymentStatusEnum.Pending, - }); - - if (pendingPayment) { - pendingPayment.amount = order.total; - } + await this.em.refresh(order); } - await this.em.persistAndFlush(order); + return order; + } + + async addOrderItem(orderId: string, restId: string, dto: AdminAddOrderItemDto): Promise { + const order = await this.getOrderWithItemsOrFail(orderId, restId); + this.assertOrderEditable(order); + + const existingItem = order.items.getItems().find(item => item.food.id === dto.foodId); + + if (existingItem) { + const newQuantity = existingItem.quantity + dto.quantity; + return this.updateOrderItemQuantity(orderId, restId, existingItem.id, { quantity: newQuantity }); + } + + const [itemData] = await this.buildOrderItemsDataFromDto([{ foodId: dto.foodId, quantity: dto.quantity }], restId); + + await this.em.transactional(async em => { + const orderItem = em.create(OrderItem, { + order, + food: itemData.food, + quantity: itemData.quantity, + unitPrice: itemData.unitPrice, + discount: itemData.discount, + totalPrice: 0, + }); + em.persist(orderItem); + + await this.inventoryService.deductFromInventory(em, { + items: [{ foodId: itemData.food.id, quantity: itemData.quantity }], + }); + await em.flush(); + }); + + return this.findOne(orderId, restId); + } + + async updateOrderItemQuantity( + orderId: string, + restId: string, + itemId: string, + dto: AdminUpdateOrderItemDto, + ): Promise { + const order = await this.getOrderWithItemsOrFail(orderId, restId); + this.assertOrderEditable(order); + + const orderItem = order.items.getItems().find(item => item.id === itemId); + if (!orderItem) { + throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND); + } + + if (orderItem.quantity === dto.quantity) { + return this.findOne(orderId, restId); + } + + const delta = dto.quantity - orderItem.quantity; + const foodId = orderItem.food.id; + + if (delta > 0) { + const food = await this.em.findOne(Food, { id: foodId }, { populate: ['inventory'] }); + if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND); + this.assertFoodHasSufficientStock(food, delta); + } + + await this.em.transactional(async em => { + orderItem.quantity = dto.quantity; + em.persist(orderItem); + + if (delta > 0) { + await this.inventoryService.deductFromInventory(em, { + items: [{ foodId, quantity: delta }], + }); + } else { + await this.inventoryService.restoreToInventory(em, { + items: [{ foodId, quantity: Math.abs(delta) }], + }); + } + await em.flush(); + }); + + return this.findOne(orderId, restId); + } + + async removeOrderItem(orderId: string, restId: string, itemId: string): Promise { + const order = await this.getOrderWithItemsOrFail(orderId, restId); + this.assertOrderEditable(order); + + const items = order.items.getItems(); + if (items.length <= 1) { + throw new BadRequestException(OrderMessage.CANNOT_REMOVE_LAST_ORDER_ITEM); + } + + const orderItem = items.find(item => item.id === itemId); + if (!orderItem) { + throw new NotFoundException(OrderMessage.ORDER_ITEM_NOT_FOUND); + } + + const foodId = orderItem.food.id; + const quantity = orderItem.quantity; + + await this.em.transactional(async em => { + await this.inventoryService.restoreToInventory(em, { + items: [{ foodId, quantity }], + }); + em.remove(orderItem); + await em.flush(); + }); + + return this.findOne(orderId, restId); + } + + private assertOrderEditable(order: Order) { + if ([OrderStatus.CANCELED, OrderStatus.COMPLETED].includes(order.status)) { + throw new BadRequestException(OrderMessage.CANNOT_UPDATE_CANCELED_OR_COMPLETED_ORDER); + } + } + + private async getOrderWithItemsOrFail(orderId: string, restId: string): Promise { + const order = await this.em.findOne( + Order, + { id: orderId, restaurant: { id: restId } }, + { populate: ['items', 'items.food', 'items.food.inventory'] }, + ); + if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND); return order; } @@ -368,6 +497,7 @@ export class OrdersService { 'carAddress', 'paymentMethod', 'cashShift', + 'cashShift.admin', 'payments', 'items', 'items.food', @@ -414,6 +544,130 @@ export class OrdersService { ); return order; } + + async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise { + return this.em.transactional(async em => { + const order = await em.findOne( + Order, + { id: orderId, restaurant: { id: restId } }, + { populate: ['payments', 'user', 'restaurant', 'paymentMethod'] }, + ); + if (!order) { + throw new NotFoundException(OrderMessage.NOT_FOUND); + } + + const paidAmount = Number(order.paidAmount ?? 0); + const balance = Number(order.balance ?? 0); + const isCanceled = order.status === OrderStatus.CANCELED; + const canRefund = balance < 0 || (isCanceled && paidAmount > 0); + + if (!canRefund) { + throw new BadRequestException(OrderMessage.REFUND_NOT_ALLOWED); + } + + const amount = Number(dto.amount); + if (!amount || amount <= 0) { + throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO); + } + + if (amount > paidAmount) { + throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_PAID); + } + + if (!isCanceled && balance < 0 && amount > Math.abs(balance)) { + throw new BadRequestException(OrderMessage.REFUND_AMOUNT_EXCEEDS_OVERPAYMENT); + } + + const paidPayments = order.payments + .getItems() + .filter(p => p.status === PaymentStatusEnum.Paid && Number(p.amount) > 0) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + if (paidPayments.length === 0) { + throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND); + } + + let remaining = amount; + let walletRefundAmount = 0; + + for (const payment of paidPayments) { + if (remaining <= 0) break; + + const paymentAmount = Number(payment.amount); + const refundFromPayment = Math.min(paymentAmount, remaining); + + if (refundFromPayment === paymentAmount) { + payment.status = PaymentStatusEnum.Refunded; + if (dto.description) { + payment.description = dto.description; + } + } else { + payment.amount = paymentAmount - refundFromPayment; + const refundPayment = em.create(Payment, { + order, + amount: refundFromPayment, + method: payment.method, + gateway: payment.gateway ?? null, + status: PaymentStatusEnum.Refunded, + description: dto.description ?? null, + referenceId: payment.referenceId ?? null, + transactionId: payment.transactionId ?? null, + }); + em.persist(refundPayment); + } + + if (payment.method === PaymentMethodEnum.Wallet) { + walletRefundAmount += refundFromPayment; + } + + remaining -= refundFromPayment; + em.persist(payment); + } + + if (remaining > 0) { + throw new BadRequestException(OrderMessage.NO_PAID_PAYMENTS_TO_REFUND); + } + + if (walletRefundAmount > 0) { + if (!order.user) { + throw new NotFoundException(OrderMessage.USER_NOT_FOUND); + } + + const latestWalletTx = await em.findOne( + WalletTransaction, + { + user: { id: order.user.id }, + restaurant: { id: order.restaurant.id }, + }, + { orderBy: { createdAt: 'DESC' } }, + ); + + if (!latestWalletTx) { + throw new NotFoundException(PaymentMessage.REFUND_WALLET_NOT_FOUND); + } + + const newBalance = Number(latestWalletTx.balance ?? 0) + walletRefundAmount; + const creditTx = em.create(WalletTransaction, { + user: order.user, + restaurant: order.restaurant, + amount: walletRefundAmount, + type: WalletTransactionType.CREDIT, + reason: WalletTransactionReason.ORDER_REFUND, + balance: newBalance, + }); + em.persist(creditTx); + } + + await em.flush(); + + return em.findOneOrFail( + Order, + { id: orderId }, + { populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] }, + ); + }); + } + /* Helpers */ private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) { const paymentMethod = order.paymentMethod?.method; diff --git a/src/modules/orders/repositories/order.repository.ts b/src/modules/orders/repositories/order.repository.ts index 79e1c0e..c2ec306 100644 --- a/src/modules/orders/repositories/order.repository.ts +++ b/src/modules/orders/repositories/order.repository.ts @@ -124,7 +124,7 @@ export class OrderRepository extends EntityRepository { limit, offset, orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, - populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'payments'] as never, + populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'cashShift', 'cashShift.admin', 'payments'] as never, }); const totalPages = Math.ceil(total / limit);