feat(backend): implement pricing engine for loyalty points and 3-level promotions
This commit is contained in:
1 parent
36ea126993
commit
77c52e0534
4 files changed
+109
-7
No files matched your search
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,25 @@ 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: {
|
||||||
customerId: data.customerId,
|
customerId: data.customerId,
|
||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user