feat(backend): implement pricing engine for loyalty points and 3-level promotions

This commit is contained in:
Your Name committed 2026-06-06 20:00:18 +07:00
1 parent 36ea126993
commit 77c52e0534
4 files changed
+108 -6

No files matched your search

+30 -2
View File
@@ -82,6 +82,7 @@ model Customer {
pictureUrl String? pictureUrl String?
email String? email String?
phone String? phone String?
points Int @default(0)
orders Order[] orders Order[]
reviews Review[] reviews Review[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -116,12 +117,27 @@ model Coupon {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Promotion {
id Int @id @default(autoincrement())
name String
level Int
type String
value Float
minPurchase Float @default(0)
isActive Boolean @default(true)
startDate DateTime?
endDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StoreSettings { model StoreSettings {
id Int @id @default(1) id Int @id @default(1)
shippingFee Float @default(35) shippingFee Float @default(35)
bankName String @default("KASIKORNBANK (KBANK)") bankName String @default("KASIKORNBANK (KBANK)")
bankAccountName String @default("HomAroma Co., Ltd.") bankAccountName String @default("HomAroma Co., Ltd.")
bankAccountNumber String @default("012-3-45678-9") bankAccountNumber String @default("012-3-45678-9")
pointsEarnRate Float @default(1.0)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -131,10 +147,22 @@ model Order {
customerEmail String customerEmail String
customerPhone String customerPhone String
shippingAddress String shippingAddress String
totalAmount Float
shippingFee Float @default(35) subtotal Float @default(0)
promoLevel1Name String?
promoLevel1Disc Float @default(0)
promoLevel2Name String?
promoLevel2Disc Float @default(0)
couponCode String? couponCode String?
discountAmount Float @default(0) discountAmount Float @default(0)
pointsUsed Int @default(0)
pointsDiscount Float @default(0)
pointsEarned Int @default(0)
shippingFee Float @default(35)
totalAmount Float
status String @default("PENDING_PAYMENT") status String @default("PENDING_PAYMENT")
paymentSlipUrl String? paymentSlipUrl String?
+2 -1
View File
@@ -19,6 +19,7 @@ import { lazadaOrderRoute } from "./routes/lazadaOrder";
import { customersRoute } from "./routes/customers"; import { customersRoute } from "./routes/customers";
import { couponsRoute } from "./routes/coupons"; import { couponsRoute } from "./routes/coupons";
import { reviewsRoute } from "./routes/reviews"; import { reviewsRoute } from "./routes/reviews";
import { promotionsRoute } from "./routes/promotions";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
@@ -82,7 +83,7 @@ const app = new Elysia()
return file; return file;
}) })
.group("/api", (app) => .group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute) app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute)
) )
.listen(PORT); .listen(PORT);
+56 -3
View File
@@ -22,6 +22,24 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
.post("/", async ({ body, set }) => { .post("/", async ({ body, set }) => {
try { try {
const data = body as any; const data = body as any;
const settings = await prisma.storeSettings.findFirst()
const earnRate = settings?.pointsEarnRate || 1.0
const netAmount = data.totalAmount - data.shippingFee
const pointsEarned = Math.floor(Math.max(0, netAmount) * earnRate)
if (data.customerId && data.pointsUsed && data.pointsUsed > 0) {
// Verify customer has enough points
const customer = await prisma.customer.findUnique({ where: { id: data.customerId } })
if (!customer || customer.points < data.pointsUsed) {
set.status = 400
return { error: "Insufficient points" }
}
// Deduct points immediately
await prisma.customer.update({
where: { id: data.customerId },
data: { points: { decrement: data.pointsUsed } }
})
}
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
@@ -30,10 +48,18 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
customerEmail: data.customerEmail, customerEmail: data.customerEmail,
customerPhone: data.customerPhone, customerPhone: data.customerPhone,
shippingAddress: data.shippingAddress, shippingAddress: data.shippingAddress,
totalAmount: data.totalAmount, subtotal: data.subtotal || 0,
shippingFee: data.shippingFee, promoLevel1Name: data.promoLevel1Name,
promoLevel1Disc: data.promoLevel1Disc || 0,
promoLevel2Name: data.promoLevel2Name,
promoLevel2Disc: data.promoLevel2Disc || 0,
couponCode: data.couponCode || null, couponCode: data.couponCode || null,
discountAmount: data.discountAmount || 0, discountAmount: data.discountAmount || 0,
pointsUsed: data.pointsUsed || 0,
pointsDiscount: data.pointsDiscount || 0,
pointsEarned: pointsEarned,
shippingFee: data.shippingFee,
totalAmount: data.totalAmount,
items: { items: {
create: data.items.map((item: any) => ({ create: data.items.map((item: any) => ({
productId: item.productId, productId: item.productId,
@@ -76,8 +102,15 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
shippingAddress: t.String(), shippingAddress: t.String(),
totalAmount: t.Number(), totalAmount: t.Number(),
shippingFee: t.Number(), shippingFee: t.Number(),
subtotal: t.Optional(t.Number()),
promoLevel1Name: t.Optional(t.Nullable(t.String())),
promoLevel1Disc: t.Optional(t.Number()),
promoLevel2Name: t.Optional(t.Nullable(t.String())),
promoLevel2Disc: t.Optional(t.Number()),
couponCode: t.Optional(t.Nullable(t.String())), couponCode: t.Optional(t.Nullable(t.String())),
discountAmount: t.Optional(t.Number()), discountAmount: t.Optional(t.Number()),
pointsUsed: t.Optional(t.Number()),
pointsDiscount: t.Optional(t.Number()),
items: t.Array(t.Object({ items: t.Array(t.Object({
productId: t.Number(), productId: t.Number(),
variantId: t.Optional(t.Number()), variantId: t.Optional(t.Number()),
@@ -92,11 +125,31 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
.patch("/:id/status", async ({ params: { id }, body, set }) => { .patch("/:id/status", async ({ params: { id }, body, set }) => {
try { try {
const { status } = body as any; const { status } = body as any;
return await prisma.order.update({ const order = await prisma.order.findUnique({ where: { id: Number(id) } });
const updatedOrder = await prisma.order.update({
where: { id: Number(id) }, where: { id: Number(id) },
data: { status }, data: { status },
include: { items: true } include: { items: true }
}); });
// Grant points if status becomes PAID and it wasn't PAID before
if (status === 'PAID' && order?.status !== 'PAID' && order?.customerId && order?.pointsEarned > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsEarned } }
});
}
// If status becomes CANCELLED, refund the points used
if (status === 'CANCELLED' && order?.status !== 'CANCELLED' && order?.customerId && order?.pointsUsed > 0) {
await prisma.customer.update({
where: { id: order.customerId },
data: { points: { increment: order.pointsUsed } }
});
}
return updatedOrder;
} catch (err) { } catch (err) {
set.status = 500; set.status = 500;
return { error: "Failed to update order status" }; return { error: "Failed to update order status" };
+20
View File
@@ -0,0 +1,20 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const promotionsRoute = new Elysia({ prefix: "/promotions" })
.get("/active", async () => {
const now = new Date()
return await prisma.promotion.findMany({
where: {
isActive: true,
OR: [
{ startDate: null },
{ startDate: { lte: now } }
],
AND: [
{ OR: [{ endDate: null }, { endDate: { gte: now } }] }
]
},
orderBy: { level: 'asc' }
});
});