This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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";
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
// --- 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<void> {
|
||||
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));`);
|
||||
}
|
||||
}
|
||||
@@ -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 = 'خطا در تایید پرداخت زرینپال',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<Order> {
|
||||
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<Order> {
|
||||
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<Order> {
|
||||
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<Order> {
|
||||
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<Order> {
|
||||
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;
|
||||
|
||||
@@ -124,7 +124,7 @@ export class OrderRepository extends EntityRepository<Order> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user