666 lines
15 KiB
TypeScript
666 lines
15 KiB
TypeScript
import { db } from '../db/db_index'
|
|
import {
|
|
orders,
|
|
orderItems,
|
|
orderStatus,
|
|
addresses,
|
|
productInfo,
|
|
productSkus,
|
|
paymentInfoTable,
|
|
coupons,
|
|
couponUsage,
|
|
cartItems,
|
|
refunds,
|
|
units,
|
|
userDetails,
|
|
deliverySlotInfo,
|
|
} from '../db/schema'
|
|
import { and, eq, inArray, desc, sql } from 'drizzle-orm'
|
|
import type {
|
|
UserOrderSummary,
|
|
UserOrderDetail,
|
|
PlacedOrder,
|
|
CouponUsageWithCoupon,
|
|
} from '@packages/shared'
|
|
import { coerceDate } from '../lib/date'
|
|
import { runBatched } from '../lib/run-batched'
|
|
import { composeSkuName, composeUnitNotation } from '../lib/sku-features'
|
|
|
|
export interface OrderWithRelations {
|
|
id: number
|
|
userId: number
|
|
addressId: number
|
|
slotId: number | null
|
|
totalAmount: string
|
|
deliveryCharge: string
|
|
isCod: boolean
|
|
isOnlinePayment: boolean
|
|
isFlashDelivery: boolean
|
|
userNotes: string | null
|
|
createdAt: Date
|
|
orderItems: Array<{
|
|
id: number
|
|
skuId: number
|
|
quantity: string
|
|
price: string
|
|
discountedPrice: string | null
|
|
is_packaged: boolean
|
|
sku: {
|
|
id: number
|
|
name: string | null
|
|
images: unknown
|
|
product: {
|
|
name: string
|
|
} | null
|
|
} | null
|
|
}>
|
|
slot: {
|
|
deliveryTime: Date
|
|
} | null
|
|
paymentInfo: {
|
|
id: number
|
|
status: string
|
|
} | null
|
|
orderStatus: Array<{
|
|
id: number
|
|
isCancelled: boolean
|
|
isDelivered: boolean
|
|
paymentStatus: string
|
|
cancelReason: string | null
|
|
}>
|
|
refunds: Array<{
|
|
refundStatus: string
|
|
refundAmount: string | null
|
|
}>
|
|
}
|
|
|
|
export type OrderDetailWithRelations = OrderWithRelations;
|
|
|
|
export interface CouponValidationResult {
|
|
id: number
|
|
couponCode: string
|
|
isInvalidated: boolean
|
|
validTill: Date | null
|
|
maxLimitForUser: number | null
|
|
minOrder: string | null
|
|
discountPercent: string | null
|
|
flatDiscount: string | null
|
|
maxValue: string | null
|
|
usages: Array<{
|
|
id: number
|
|
userId: number
|
|
}>
|
|
}
|
|
|
|
export async function validateAndGetCoupon(
|
|
couponId: number | undefined,
|
|
userId: number,
|
|
totalAmount: number
|
|
): Promise<CouponValidationResult | null> {
|
|
if (!couponId) return null
|
|
|
|
const coupon = await db.query.coupons.findFirst({
|
|
where: eq(coupons.id, couponId),
|
|
with: {
|
|
usages: { where: eq(couponUsage.userId, userId) },
|
|
},
|
|
})
|
|
|
|
if (!coupon) throw new Error('Invalid coupon')
|
|
if (coupon.isInvalidated) throw new Error('Coupon is no longer valid')
|
|
if (coupon.validTill && new Date(coupon.validTill) < new Date())
|
|
throw new Error('Coupon has expired')
|
|
if (
|
|
coupon.maxLimitForUser &&
|
|
coupon.usages.length >= coupon.maxLimitForUser
|
|
)
|
|
throw new Error('Coupon usage limit exceeded')
|
|
if (
|
|
coupon.minOrder &&
|
|
parseFloat(coupon.minOrder.toString()) > totalAmount
|
|
)
|
|
throw new Error('Order amount does not meet coupon minimum requirement')
|
|
|
|
if (coupon.isFirstOrderOnly) {
|
|
const orderCount = await getOrderCount(userId)
|
|
if (orderCount > 0) {
|
|
throw new Error(
|
|
"This is first order only coupon. You already placed your first order. This coupon doesn't apply to you"
|
|
)
|
|
}
|
|
}
|
|
|
|
return coupon as CouponValidationResult
|
|
}
|
|
|
|
export function applyDiscountToOrder(
|
|
orderTotal: number,
|
|
appliedCoupon: CouponValidationResult | null,
|
|
proportion: number
|
|
): { finalOrderTotal: number; orderGroupProportion: number } {
|
|
let finalOrderTotal = orderTotal
|
|
|
|
if (appliedCoupon) {
|
|
if (appliedCoupon.discountPercent) {
|
|
const discount = Math.min(
|
|
(orderTotal *
|
|
parseFloat(appliedCoupon.discountPercent.toString())) /
|
|
100,
|
|
appliedCoupon.maxValue
|
|
? parseFloat(appliedCoupon.maxValue.toString()) * proportion
|
|
: Infinity
|
|
)
|
|
finalOrderTotal -= discount
|
|
} else if (appliedCoupon.flatDiscount) {
|
|
const discount = Math.min(
|
|
parseFloat(appliedCoupon.flatDiscount.toString()) * proportion,
|
|
appliedCoupon.maxValue
|
|
? parseFloat(appliedCoupon.maxValue.toString()) * proportion
|
|
: finalOrderTotal
|
|
)
|
|
finalOrderTotal -= discount
|
|
}
|
|
}
|
|
|
|
return { finalOrderTotal, orderGroupProportion: proportion }
|
|
}
|
|
|
|
export async function getAddressByIdAndUser(
|
|
addressId: number,
|
|
userId: number
|
|
) {
|
|
return db.query.addresses.findFirst({
|
|
where: and(eq(addresses.userId, userId), eq(addresses.id, addressId)),
|
|
})
|
|
}
|
|
|
|
export async function getProductById(skuId: number) {
|
|
const sku = await db.query.productSkus.findFirst({
|
|
where: eq(productSkus.id, skuId),
|
|
with: {
|
|
marketStats: true,
|
|
product: true,
|
|
},
|
|
})
|
|
|
|
if (!sku) {
|
|
return null
|
|
}
|
|
|
|
const marketStats = sku.marketStats
|
|
|
|
return {
|
|
...sku,
|
|
price: marketStats?.ourPrice ? String(marketStats.ourPrice) : '0',
|
|
marketPrice: marketStats?.marketPrice ? String(marketStats.marketPrice) : null,
|
|
flashPrice: marketStats?.flashPrice ? String(marketStats.flashPrice) : null,
|
|
isFlashAvailable: marketStats?.isFlashAvailable ?? false,
|
|
isOutOfStock: marketStats?.isOutOfStock ?? false,
|
|
}
|
|
}
|
|
|
|
export async function checkUserSuspended(userId: number): Promise<boolean> {
|
|
const userDetail = await db.query.userDetails.findFirst({
|
|
where: eq(userDetails.userId, userId),
|
|
})
|
|
return userDetail?.isSuspended ?? false
|
|
}
|
|
|
|
export async function getSlotCapacityStatus(slotId: number): Promise<boolean> {
|
|
const slot = await db.query.deliverySlotInfo.findFirst({
|
|
where: eq(deliverySlotInfo.id, slotId),
|
|
columns: {
|
|
isCapacityFull: true,
|
|
},
|
|
})
|
|
return slot?.isCapacityFull ?? false
|
|
}
|
|
|
|
export async function placeOrderTransaction(params: {
|
|
userId: number
|
|
ordersData: Array<{
|
|
order: Omit<typeof orders.$inferInsert, 'id'>
|
|
orderItems: Omit<typeof orderItems.$inferInsert, 'id'>[]
|
|
orderStatus: Omit<typeof orderStatus.$inferInsert, 'id'>
|
|
}>
|
|
paymentMethod: 'online' | 'cod'
|
|
totalWithDelivery: number
|
|
}): Promise<PlacedOrder[]> {
|
|
const { userId, ordersData, paymentMethod } = params
|
|
|
|
return db.transaction(async (tx) => {
|
|
let sharedPaymentInfoId: number | null = null
|
|
if (paymentMethod === 'online') {
|
|
const [paymentInfo] = await tx
|
|
.insert(paymentInfoTable)
|
|
.values({
|
|
status: 'pending',
|
|
gateway: 'razorpay',
|
|
merchantOrderId: `multi_order_${Date.now()}`,
|
|
})
|
|
.returning()
|
|
sharedPaymentInfoId = paymentInfo.id
|
|
}
|
|
|
|
const ordersToInsert: Omit<typeof orders.$inferInsert, 'id'>[] =
|
|
ordersData.map((od) => ({
|
|
...od.order,
|
|
paymentInfoId: sharedPaymentInfoId,
|
|
}))
|
|
|
|
const insertedOrders = await tx.insert(orders).values(ordersToInsert).returning()
|
|
|
|
const allOrderItems: Omit<typeof orderItems.$inferInsert, 'id'>[] = []
|
|
const allOrderStatuses: Omit<typeof orderStatus.$inferInsert, 'id'>[] = []
|
|
|
|
insertedOrders.forEach((order, index) => {
|
|
const od = ordersData[index]
|
|
od.orderItems.forEach((item) => {
|
|
allOrderItems.push({ ...item, orderId: order.id })
|
|
})
|
|
allOrderStatuses.push({
|
|
...od.orderStatus,
|
|
orderId: order.id,
|
|
})
|
|
})
|
|
|
|
const chunkSize = 5
|
|
for (let i = 0; i < allOrderItems.length; i += chunkSize) {
|
|
await tx.insert(orderItems).values(allOrderItems.slice(i, i + chunkSize))
|
|
}
|
|
await tx.insert(orderStatus).values(allOrderStatuses)
|
|
|
|
return insertedOrders as PlacedOrder[]
|
|
})
|
|
}
|
|
|
|
export async function deleteCartItemsForOrder(
|
|
userId: number,
|
|
skuIds: number[]
|
|
): Promise<void> {
|
|
await db.delete(cartItems).where(
|
|
and(
|
|
eq(cartItems.userId, userId),
|
|
inArray(cartItems.skuId, skuIds)
|
|
)
|
|
)
|
|
}
|
|
|
|
export async function recordCouponUsage(
|
|
userId: number,
|
|
couponId: number,
|
|
orderId: number
|
|
): Promise<void> {
|
|
await db.insert(couponUsage).values({
|
|
userId,
|
|
couponId,
|
|
orderId,
|
|
orderItemId: null,
|
|
usedAt: new Date(),
|
|
})
|
|
}
|
|
|
|
export async function getOrdersWithRelations(
|
|
userId: number,
|
|
offset: number,
|
|
pageSize: number
|
|
): Promise<OrderWithRelations[]> {
|
|
const ordersWithRelations = await db.query.orders.findMany({
|
|
where: eq(orders.userId, userId),
|
|
with: {
|
|
orderItems: {
|
|
with: {
|
|
sku: {
|
|
with: {
|
|
product: {
|
|
columns: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
images: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
slot: {
|
|
columns: {
|
|
deliveryTime: true,
|
|
},
|
|
},
|
|
paymentInfo: {
|
|
columns: {
|
|
id: true,
|
|
status: true,
|
|
},
|
|
},
|
|
orderStatus: {
|
|
columns: {
|
|
id: true,
|
|
isCancelled: true,
|
|
isDelivered: true,
|
|
paymentStatus: true,
|
|
cancelReason: true,
|
|
},
|
|
},
|
|
refunds: {
|
|
columns: {
|
|
refundStatus: true,
|
|
refundAmount: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [desc(orders.createdAt)],
|
|
limit: pageSize,
|
|
offset: offset,
|
|
})
|
|
|
|
return ordersWithRelations.map((order) => {
|
|
const createdAt = coerceDate(order.createdAt) ?? new Date(0)
|
|
const slot = order.slot
|
|
? {
|
|
...order.slot,
|
|
deliveryTime: coerceDate(order.slot.deliveryTime) ?? new Date(0),
|
|
}
|
|
: null
|
|
|
|
return {
|
|
...order,
|
|
createdAt,
|
|
slot,
|
|
}
|
|
}) as OrderWithRelations[]
|
|
}
|
|
|
|
export async function getOrderCount(userId: number): Promise<number> {
|
|
const result = await db
|
|
.select({ count: sql`count(*)` })
|
|
.from(orders)
|
|
.where(eq(orders.userId, userId))
|
|
|
|
return Number(result[0]?.count ?? 0)
|
|
}
|
|
|
|
export async function getOrderByIdWithRelations(
|
|
orderId: number,
|
|
userId: number
|
|
): Promise<OrderDetailWithRelations | null> {
|
|
const order = await db.query.orders.findFirst({
|
|
where: and(eq(orders.id, orderId), eq(orders.userId, userId)),
|
|
with: {
|
|
orderItems: {
|
|
with: {
|
|
sku: {
|
|
with: {
|
|
product: {
|
|
columns: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
images: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
slot: {
|
|
columns: {
|
|
deliveryTime: true,
|
|
},
|
|
},
|
|
paymentInfo: {
|
|
columns: {
|
|
id: true,
|
|
status: true,
|
|
},
|
|
},
|
|
orderStatus: {
|
|
columns: {
|
|
id: true,
|
|
isCancelled: true,
|
|
isDelivered: true,
|
|
paymentStatus: true,
|
|
cancelReason: true,
|
|
},
|
|
with: {
|
|
refundCoupon: {
|
|
columns: {
|
|
id: true,
|
|
couponCode: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
refunds: {
|
|
columns: {
|
|
refundStatus: true,
|
|
refundAmount: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!order) {
|
|
return null
|
|
}
|
|
|
|
const createdAt = coerceDate(order.createdAt) ?? new Date(0)
|
|
const slot = order.slot
|
|
? {
|
|
...order.slot,
|
|
deliveryTime: coerceDate(order.slot.deliveryTime) ?? new Date(0),
|
|
}
|
|
: null
|
|
|
|
return {
|
|
...order,
|
|
createdAt,
|
|
slot,
|
|
} as OrderDetailWithRelations
|
|
}
|
|
|
|
export async function getCouponUsageForOrder(
|
|
orderId: number
|
|
): Promise<CouponUsageWithCoupon[]> {
|
|
return db.query.couponUsage.findMany({
|
|
where: eq(couponUsage.orderId, orderId),
|
|
with: {
|
|
coupon: {
|
|
columns: {
|
|
id: true,
|
|
couponCode: true,
|
|
discountPercent: true,
|
|
flatDiscount: true,
|
|
maxValue: true,
|
|
},
|
|
},
|
|
},
|
|
}) as Promise<CouponUsageWithCoupon[]>
|
|
}
|
|
|
|
export async function getOrderBasic(orderId: number) {
|
|
return db.query.orders.findFirst({
|
|
where: eq(orders.id, orderId),
|
|
with: {
|
|
orderStatus: {
|
|
columns: {
|
|
id: true,
|
|
isCancelled: true,
|
|
isDelivered: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
export async function cancelOrderTransaction(
|
|
orderId: number,
|
|
statusId: number,
|
|
reason: string,
|
|
isCod: boolean
|
|
): Promise<void> {
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(orderStatus)
|
|
.set({
|
|
isCancelled: true,
|
|
cancelReason: reason,
|
|
cancellationUserNotes: reason,
|
|
cancellationReviewed: false,
|
|
})
|
|
.where(eq(orderStatus.id, statusId))
|
|
|
|
const refundStatus = isCod ? 'na' : 'pending'
|
|
|
|
await tx.insert(refunds).values({
|
|
orderId,
|
|
refundStatus,
|
|
})
|
|
})
|
|
}
|
|
|
|
export async function updateOrderNotes(
|
|
orderId: number,
|
|
userNotes: string
|
|
): Promise<void> {
|
|
await db
|
|
.update(orders)
|
|
.set({
|
|
userNotes: userNotes || null,
|
|
})
|
|
.where(eq(orders.id, orderId))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Post-Order Handler Helpers (for Telegram notifications)
|
|
// ============================================================================
|
|
|
|
export interface OrderWithFullData {
|
|
id: number
|
|
userId: number
|
|
totalAmount: string
|
|
isFlashDelivery: boolean
|
|
address: {
|
|
name: string | null
|
|
addressLine1: string | null
|
|
addressLine2: string | null
|
|
city: string | null
|
|
state: string | null
|
|
pincode: string | null
|
|
phone: string | null
|
|
} | null
|
|
orderItems: Array<{
|
|
quantity: string
|
|
sku: {
|
|
product: {
|
|
name: string
|
|
} | null
|
|
} | null
|
|
}>
|
|
slot: {
|
|
deliveryTime: Date
|
|
} | null
|
|
}
|
|
|
|
export async function getOrdersByIdsWithFullData(
|
|
orderIds: number[]
|
|
): Promise<OrderWithFullData[]> {
|
|
if (orderIds.length === 0) return []
|
|
|
|
const orderChunks = await runBatched(db, orderIds, 10, async (tx, chunk) => {
|
|
return tx.query.orders.findMany({
|
|
where: inArray(orders.id, chunk),
|
|
with: {
|
|
address: {
|
|
columns: {
|
|
name: true,
|
|
addressLine1: true,
|
|
addressLine2: true,
|
|
city: true,
|
|
state: true,
|
|
pincode: true,
|
|
phone: true,
|
|
},
|
|
},
|
|
orderItems: {
|
|
with: {
|
|
sku: {
|
|
columns: {
|
|
name: true,
|
|
},
|
|
with: {
|
|
product: {
|
|
columns: { name: true },
|
|
},
|
|
features: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
slot: {
|
|
columns: {
|
|
deliveryTime: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
return orderChunks.flat() as unknown as OrderWithFullData[]
|
|
}
|
|
|
|
export interface OrderWithCancellationData extends OrderWithFullData {
|
|
refunds: Array<{
|
|
refundStatus: string
|
|
}>
|
|
}
|
|
|
|
export async function getOrderByIdWithFullData(
|
|
orderId: number
|
|
): Promise<OrderWithCancellationData | null> {
|
|
return db.query.orders.findFirst({
|
|
where: eq(orders.id, orderId),
|
|
with: {
|
|
address: {
|
|
columns: {
|
|
name: true,
|
|
addressLine1: true,
|
|
addressLine2: true,
|
|
city: true,
|
|
state: true,
|
|
pincode: true,
|
|
phone: true,
|
|
},
|
|
},
|
|
orderItems: {
|
|
with: {
|
|
sku: {
|
|
with: {
|
|
product: {
|
|
columns: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
slot: {
|
|
columns: {
|
|
deliveryTime: true,
|
|
},
|
|
},
|
|
refunds: {
|
|
columns: {
|
|
refundStatus: true,
|
|
},
|
|
},
|
|
},
|
|
}) as Promise<OrderWithCancellationData | null>
|
|
}
|