Compare commits

..

10 Commits

Author SHA1 Message Date
ItsMalma
4ad66f46cb hmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmmm part 6 2026-01-08 21:49:53 +07:00
ItsMalma
ce354a948d hmmmmmmmmmmmmmmmm part 5 2026-01-08 21:37:51 +07:00
ItsMalma
8bcf0457de hmmmmmmm part 4 2026-01-08 21:30:30 +07:00
ItsMalma
3349f52da1 hmmmmm part 3 2026-01-08 21:27:36 +07:00
ItsMalma
2ad76e94d4 hmmmm part 2 2026-01-08 21:25:57 +07:00
ItsMalma
6e9624751e hmmm 2026-01-08 21:23:01 +07:00
ItsMalma
1a3a0be78d try to fix order migration again 2026-01-08 21:21:55 +07:00
ItsMalma
d5a153702d update order price calculation 2026-01-08 21:15:47 +07:00
ItsMalma
d95811ad83 add database migration for order 2026-01-08 20:54:31 +07:00
ItsMalma
4124d9bf19 change order flow 2026-01-08 20:28:01 +07:00
18 changed files with 473 additions and 486 deletions

View File

@@ -5,12 +5,12 @@ export const paginationQuerySchema = z.object({
page: z.coerce page: z.coerce
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(1, "Minimum 1.") .min(1, "Min 1.")
.default(1), .default(1),
per_page: z.coerce per_page: z.coerce
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(1, "Minimum 1.") .min(1, "Min 1.")
.default(100), .default(100),
}); });

View File

@@ -1,9 +1,9 @@
import { PaymentError } from "@/common/errors/payment.error"; import { PaymentError } from "@/common/errors/payment.error";
import { AbstractPaymentService } from "@/common/services/payment-service/abstract.payment-service"; import { AbstractPaymentService } from "@/common/services/payment-service/abstract.payment-service";
import { midtransConfig } from "@/configs/midtrans.config"; import { midtransConfig } from "@/configs/midtrans.config";
import type { OrderDetail } from "@/database/entities/order-detail.entity";
import type { Order } from "@/database/entities/order.entity"; import type { Order } from "@/database/entities/order.entity";
import { RoomType } from "@/database/enums/room-type.enum"; import { Kit } from "@/database/enums/kit.enum";
import { OrderType } from "@/database/enums/order-type.enum";
type CreateTransactionResponseSuccess = { type CreateTransactionResponseSuccess = {
token: string; token: string;
@@ -13,6 +13,12 @@ type CreateTransactionResponseFailed = {
error_messages: string[]; error_messages: string[];
}; };
const BOOKING_SEAT_PRICE = 1_000_000;
const MINIMAL_KIT_PRICE = 350_000;
const WITHOUT_SUITCASE_KIT_PRICE = 750_000;
const FULL_KIT_PRICE = 1_500_000;
const VACCINE_PRICE = 500_000;
export class MidtransPaymentService extends AbstractPaymentService { export class MidtransPaymentService extends AbstractPaymentService {
private readonly _basicAuth: string; private readonly _basicAuth: string;
@@ -22,41 +28,66 @@ export class MidtransPaymentService extends AbstractPaymentService {
this._basicAuth = `Basic ${Buffer.from(`${midtransConfig.serverKey}:`).toBase64()}`; this._basicAuth = `Basic ${Buffer.from(`${midtransConfig.serverKey}:`).toBase64()}`;
} }
private calculateOrderDetailsPrice(orderDetails: OrderDetail[]): number { public async createPaymentUrl(order: Order): Promise<string> {
const quantity = order.quad + order.triple + order.double + order.infant;
let price = 0; let price = 0;
for (const orderDetail of orderDetails) { if (order.type === OrderType.bookingSeat) {
switch (orderDetail.roomType) { price += BOOKING_SEAT_PRICE * quantity;
case RoomType.double: } else if (order.type === OrderType.downPayment) {
price += orderDetail.order.package.doublePrice; if (order.quadDownPaymentPercentage > 0) {
break; price +=
case RoomType.triple: Math.ceil(
price += orderDetail.order.package.triplePrice; ((order.quadDownPaymentPercentage / 100) *
break; (order.package.quadPrice - order.package.quadDiscount)) /
case RoomType.quad: 100_000,
price += orderDetail.order.package.quadPrice; ) *
break; 100_000 *
case RoomType.infant: order.quad;
price += orderDetail.order.package.infantPrice ?? 0; }
break; if (order.tripleDownPaymentPercentage > 0) {
price +=
Math.ceil(
((order.tripleDownPaymentPercentage / 100) *
(order.package.triplePrice - order.package.tripleDiscount)) /
100_000,
) *
100_000 *
order.triple;
}
if (order.doubleDownPaymentPercentage > 0) {
price +=
Math.ceil(
((order.doubleDownPaymentPercentage / 100) *
(order.package.doublePrice - order.package.doubleDiscount)) /
100_000,
) *
100_000 *
order.double;
}
if (
order.infantDownPaymentPercentage > 0 &&
order.package.infantPrice !== null &&
order.package.infantDiscount !== null
) {
price +=
Math.ceil(
((order.infantDownPaymentPercentage / 100) *
(order.package.infantPrice - order.package.infantDiscount)) /
100_000,
) *
100_000 *
order.infant;
} }
} }
if (order.kit === Kit.minimal) {
return price; price += MINIMAL_KIT_PRICE * quantity;
} } else if (order.kit === Kit.withoutSuitcase) {
price += WITHOUT_SUITCASE_KIT_PRICE * quantity;
public async createPaymentUrl(order: Order): Promise<string> { } else if (order.kit === Kit.full) {
const doubleOrderDetails = order.details.filter( price += FULL_KIT_PRICE * quantity;
(orderDetail) => orderDetail.roomType === RoomType.double, }
); price += VACCINE_PRICE * order.vaccine;
const tripleOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const quadOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const infantOrderDetails = order.details.filter(
(orderDetail) => orderDetail.roomType === RoomType.double,
);
const response = await fetch(`${midtransConfig.baseUrl}/transactions`, { const response = await fetch(`${midtransConfig.baseUrl}/transactions`, {
method: "POST", method: "POST",
@@ -68,55 +99,18 @@ export class MidtransPaymentService extends AbstractPaymentService {
body: JSON.stringify({ body: JSON.stringify({
transaction_details: { transaction_details: {
order_id: order.id, order_id: order.id,
gross_amount: this.calculateOrderDetailsPrice( gross_amount: price,
order.details.getItems(),
),
}, },
item_details: [ item_details: [
doubleOrderDetails.length > 0 {
? { id: order.id,
id: doubleOrderDetails[0].id, price,
price: order.package.doublePrice, quantity: 1,
quantity: doubleOrderDetails.length, name: `${order.package.package.name}`,
name: `${order.package.package.name} / Double`, brand: "GoUmrah",
brand: "GoUmrah", category: "Paket",
category: "Paket", merchant_name: "GoUmrah",
merchant_name: "GoUmrah", },
}
: undefined,
tripleOrderDetails.length > 0
? {
id: tripleOrderDetails[0].id,
price: order.package.triplePrice,
quantity: tripleOrderDetails.length,
name: `${order.package.package.name} / Triple`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
quadOrderDetails.length > 0
? {
id: quadOrderDetails[0].id,
price: order.package.quadPrice,
quantity: quadOrderDetails.length,
name: `${order.package.package.name} / Quad`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
infantOrderDetails.length > 0
? {
id: infantOrderDetails[0].id,
price: order.package.infantPrice,
quantity: infantOrderDetails.length,
name: `${order.package.package.name} / Infant`,
brand: "GoUmrah",
category: "Paket",
merchant_name: "GoUmrah",
}
: undefined,
], ],
customer_details: { customer_details: {
first_name: order.name, first_name: order.name,

View File

@@ -6,13 +6,13 @@ export const _env = z
SERVER_PORT: z.coerce SERVER_PORT: z.coerce
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(0, "Min 0."), .min(0, "Minimum0."),
DATABASE_HOST: z.string("Must be string.").nonempty("Must not empty."), DATABASE_HOST: z.string("Must be string.").nonempty("Must not empty."),
DATABASE_PORT: z.coerce DATABASE_PORT: z.coerce
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(0, "Min 0."), .min(0, "Minimum0."),
DATABASE_USERNAME: z.string("Must be string.").nonempty("Must not empty."), DATABASE_USERNAME: z.string("Must be string.").nonempty("Must not empty."),
DATABASE_PASSWORD: z.string("Must be string.").nonempty("Must not empty."), DATABASE_PASSWORD: z.string("Must be string.").nonempty("Must not empty."),
DATABASE_NAME: z.string("Must be string.").nonempty("Must not empty."), DATABASE_NAME: z.string("Must be string.").nonempty("Must not empty."),
@@ -49,7 +49,7 @@ export const _env = z
MAIL_PORT: z.coerce MAIL_PORT: z.coerce
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(0, "Min 0."), .min(0, "Minimum0."),
MAIL_USERNAME: z.string("Must be string.").nonempty("Must not empty."), MAIL_USERNAME: z.string("Must be string.").nonempty("Must not empty."),
MAIL_PASSWORD: z.string("Must be string.").nonempty("Must not empty."), MAIL_PASSWORD: z.string("Must be string.").nonempty("Must not empty."),

View File

@@ -1,35 +0,0 @@
import { Order } from "@/database/entities/order.entity";
import { RoomType } from "@/database/enums/room-type.enum";
import {
Entity,
Enum,
ManyToOne,
PrimaryKey,
Property,
type Rel,
} from "@mikro-orm/core";
@Entity()
export class OrderDetail {
@PrimaryKey({ type: "varchar", length: 30 })
id!: string;
@ManyToOne(() => Order)
order!: Rel<Order>;
@Enum({ items: () => RoomType })
roomType!: RoomType;
@Property({
type: "timestamp",
onCreate: () => new Date(),
})
createdAt!: Date;
@Property({
type: "timestamp",
onCreate: () => new Date(),
onUpdate: () => new Date(),
})
updatedAt!: Date;
}

View File

@@ -1,12 +1,10 @@
import { OrderDetail } from "@/database/entities/order-detail.entity";
import { PackageDetail } from "@/database/entities/package-detail.entity"; import { PackageDetail } from "@/database/entities/package-detail.entity";
import { Partner } from "@/database/entities/partner.entity"; import { Kit } from "@/database/enums/kit.enum";
import { Verification } from "@/database/entities/verification.entity"; import { OrderType } from "@/database/enums/order-type.enum";
import { import {
Collection,
Entity, Entity,
Enum,
ManyToOne, ManyToOne,
OneToMany,
PrimaryKey, PrimaryKey,
Property, Property,
type Rel, type Rel,
@@ -20,18 +18,45 @@ export class Order {
@ManyToOne(() => PackageDetail) @ManyToOne(() => PackageDetail)
package!: Rel<PackageDetail>; package!: Rel<PackageDetail>;
@Property({ type: "int", unsigned: true })
quad!: number;
@Property({ type: "int", unsigned: true })
triple!: number;
@Property({ type: "int", unsigned: true })
double!: number;
@Property({ type: "int", unsigned: true })
infant!: number;
@Enum({ items: () => OrderType })
type!: OrderType;
@Property({ type: "int", unsigned: true })
quadDownPaymentPercentage!: number;
@Property({ type: "int", unsigned: true })
tripleDownPaymentPercentage!: number;
@Property({ type: "int", unsigned: true })
doubleDownPaymentPercentage!: number;
@Property({ type: "int", unsigned: true })
infantDownPaymentPercentage!: number;
@Enum({ items: () => Kit })
kit!: Kit;
@Property({ type: "int", unsigned: true })
vaccine!: number;
@Property({ type: "varchar", length: 100 }) @Property({ type: "varchar", length: 100 })
name!: string; name!: string;
@Property({ type: "varchar", length: 20 }) @Property({ type: "varchar", length: 20 })
whatsapp!: string; whatsapp!: string;
@ManyToOne(() => Verification, { nullable: true })
verification!: Rel<Verification> | null;
@ManyToOne(() => Partner, { nullable: true })
partner!: Rel<Partner | null>;
@Property({ type: "timestamp", nullable: true }) @Property({ type: "timestamp", nullable: true })
expiredAt!: Date | null; expiredAt!: Date | null;
@@ -53,9 +78,4 @@ export class Order {
onUpdate: () => new Date(), onUpdate: () => new Date(),
}) })
updatedAt!: Date; updatedAt!: Date;
// Collections
@OneToMany(() => OrderDetail, (orderDetail) => orderDetail.order)
details = new Collection<OrderDetail>(this);
} }

View File

@@ -1,10 +1,7 @@
import { Order } from "@/database/entities/order.entity";
import { Verification } from "@/database/entities/verification.entity"; import { Verification } from "@/database/entities/verification.entity";
import { import {
Collection,
Entity, Entity,
ManyToOne, ManyToOne,
OneToMany,
PrimaryKey, PrimaryKey,
Property, Property,
type Rel, type Rel,
@@ -45,9 +42,4 @@ export class Partner {
onUpdate: () => new Date(), onUpdate: () => new Date(),
}) })
updatedAt!: Date; updatedAt!: Date;
// Collections
@OneToMany(() => Order, (order) => order.partner)
orders = new Collection<Order>(this);
} }

View File

@@ -0,0 +1,5 @@
export enum Kit {
minimal = "minimal",
withoutSuitcase = "without_suitcase",
full = "full",
}

View File

@@ -0,0 +1,4 @@
export enum OrderType {
bookingSeat = "booking_seat",
downPayment = "down_payment",
}

View File

@@ -3149,16 +3149,6 @@
"length": 30, "length": 30,
"mappedType": "string" "mappedType": "string"
}, },
"code": {
"name": "code",
"type": "varchar(6)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"mappedType": "string"
},
"package_id": { "package_id": {
"name": "package_id", "name": "package_id",
"type": "varchar(30)", "type": "varchar(30)",
@@ -3169,89 +3159,113 @@
"length": 30, "length": 30,
"mappedType": "string" "mappedType": "string"
}, },
"created_at": { "quad": {
"name": "created_at", "name": "quad",
"type": "timestamptz", "type": "int",
"unsigned": false, "unsigned": true,
"autoincrement": false, "autoincrement": false,
"primary": false, "primary": false,
"nullable": false, "nullable": false,
"length": 6, "mappedType": "integer"
"mappedType": "datetime"
}, },
"updated_at": { "triple": {
"name": "updated_at", "name": "triple",
"type": "timestamptz", "type": "int",
"unsigned": false, "unsigned": true,
"autoincrement": false, "autoincrement": false,
"primary": false, "primary": false,
"nullable": false, "nullable": false,
"length": 6, "mappedType": "integer"
"mappedType": "datetime"
}
},
"name": "package_consult_session",
"schema": "public",
"indexes": [
{
"columnNames": [
"code"
],
"composite": false,
"keyName": "package_consult_session_code_unique",
"constraint": true,
"primary": false,
"unique": true
}, },
{ "double": {
"keyName": "package_consult_session_pkey", "name": "double",
"columnNames": [ "type": "int",
"id" "unsigned": true,
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"package_consult_session_package_id_foreign": {
"constraintName": "package_consult_session_package_id_foreign",
"columnNames": [
"package_id"
],
"localTableName": "public.package_consult_session",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.package",
"updateRule": "cascade"
}
},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "varchar(30)",
"unsigned": false,
"autoincrement": false, "autoincrement": false,
"primary": false, "primary": false,
"nullable": false, "nullable": false,
"length": 30, "mappedType": "integer"
"mappedType": "string"
}, },
"package_id": { "infant": {
"name": "package_id", "name": "infant",
"type": "varchar(30)", "type": "int",
"unsigned": true,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
},
"type": {
"name": "type",
"type": "text",
"unsigned": false, "unsigned": false,
"autoincrement": false, "autoincrement": false,
"primary": false, "primary": false,
"nullable": false, "nullable": false,
"length": 30, "enumItems": [
"mappedType": "string" "booking_seat",
"down_payment"
],
"mappedType": "enum"
},
"quad_down_payment_percentage": {
"name": "quad_down_payment_percentage",
"type": "int",
"unsigned": true,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
},
"triple_down_payment_percentage": {
"name": "triple_down_payment_percentage",
"type": "int",
"unsigned": true,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
},
"double_down_payment_percentage": {
"name": "double_down_payment_percentage",
"type": "int",
"unsigned": true,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
},
"infant_down_payment_percentage": {
"name": "infant_down_payment_percentage",
"type": "int",
"unsigned": true,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
},
"kit": {
"name": "kit",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": [
"minimal",
"without_suitcase",
"full"
],
"mappedType": "enum"
},
"vaccine": {
"name": "vaccine",
"type": "int",
"unsigned": true,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "integer"
}, },
"name": { "name": {
"name": "name", "name": "name",
@@ -3273,26 +3287,6 @@
"length": 20, "length": 20,
"mappedType": "string" "mappedType": "string"
}, },
"verification_id": {
"name": "verification_id",
"type": "varchar(30)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 30,
"mappedType": "string"
},
"partner_id": {
"name": "partner_id",
"type": "varchar(30)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 30,
"mappedType": "string"
},
"expired_at": { "expired_at": {
"name": "expired_at", "name": "expired_at",
"type": "timestamptz", "type": "timestamptz",
@@ -3371,32 +3365,6 @@
], ],
"referencedTableName": "public.package_detail", "referencedTableName": "public.package_detail",
"updateRule": "cascade" "updateRule": "cascade"
},
"order_verification_id_foreign": {
"constraintName": "order_verification_id_foreign",
"columnNames": [
"verification_id"
],
"localTableName": "public.order",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.verification",
"deleteRule": "set null",
"updateRule": "cascade"
},
"order_partner_id_foreign": {
"constraintName": "order_partner_id_foreign",
"columnNames": [
"partner_id"
],
"localTableName": "public.order",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.partner",
"deleteRule": "set null",
"updateRule": "cascade"
} }
}, },
"nativeEnums": {} "nativeEnums": {}
@@ -3413,8 +3381,18 @@
"length": 30, "length": 30,
"mappedType": "string" "mappedType": "string"
}, },
"order_id": { "code": {
"name": "order_id", "name": "code",
"type": "varchar(6)",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"mappedType": "string"
},
"package_id": {
"name": "package_id",
"type": "varchar(30)", "type": "varchar(30)",
"unsigned": false, "unsigned": false,
"autoincrement": false, "autoincrement": false,
@@ -3423,21 +3401,6 @@
"length": 30, "length": 30,
"mappedType": "string" "mappedType": "string"
}, },
"room_type": {
"name": "room_type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"enumItems": [
"quad",
"triple",
"double",
"infant"
],
"mappedType": "enum"
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "timestamptz", "type": "timestamptz",
@@ -3459,11 +3422,21 @@
"mappedType": "datetime" "mappedType": "datetime"
} }
}, },
"name": "order_detail", "name": "package_consult_session",
"schema": "public", "schema": "public",
"indexes": [ "indexes": [
{ {
"keyName": "order_detail_pkey", "columnNames": [
"code"
],
"composite": false,
"keyName": "package_consult_session_code_unique",
"constraint": true,
"primary": false,
"unique": true
},
{
"keyName": "package_consult_session_pkey",
"columnNames": [ "columnNames": [
"id" "id"
], ],
@@ -3475,16 +3448,16 @@
], ],
"checks": [], "checks": [],
"foreignKeys": { "foreignKeys": {
"order_detail_order_id_foreign": { "package_consult_session_package_id_foreign": {
"constraintName": "order_detail_order_id_foreign", "constraintName": "package_consult_session_package_id_foreign",
"columnNames": [ "columnNames": [
"order_id" "package_id"
], ],
"localTableName": "public.order_detail", "localTableName": "public.package_consult_session",
"referencedColumnNames": [ "referencedColumnNames": [
"id" "id"
], ],
"referencedTableName": "public.order", "referencedTableName": "public.package",
"updateRule": "cascade" "updateRule": "cascade"
} }
}, },

View File

@@ -0,0 +1,113 @@
import { Migration } from "@mikro-orm/migrations";
export class Migration20260108134605 extends Migration {
override async up(): Promise<void> {
const knex = this.getKnex();
knex.schema.dropTable("order_detail");
await knex.schema.alterTable("order", (table) => {
table.dropColumn("verification_id");
table.dropColumn("partner_id");
});
await knex.raw(`
create type "order_type" as enum (
'booking_seat',
'down_payment'
);
`);
await knex.raw(`
create type "kit" as enum (
'minimal',
'without_suitcase',
'full'
);
`);
await knex.schema.alterTable("order", (table) => {
table.integer("quad").notNullable();
table.integer("triple").notNullable();
table.integer("double").notNullable();
table.integer("infant").notNullable();
table
.enum("type", null, {
useNative: true,
existingType: true,
enumName: "order_type",
})
.notNullable();
table.integer("quad_down_payment_percentage").notNullable();
table.integer("triple_down_payment_percentage").notNullable();
table.integer("double_down_payment_percentage").notNullable();
table.integer("infant_down_payment_percentage").notNullable();
table
.enum("kit", null, {
useNative: true,
existingType: true,
enumName: "kit",
})
.notNullable();
table.integer("vaccine").notNullable();
});
}
override async down(): Promise<void> {
const knex = this.getKnex();
await knex.schema.alterTable("order", (table) => {
table.dropColumn("quad");
table.dropColumn("triple");
table.dropColumn("double");
table.dropColumn("infant");
table.dropColumn("type");
table.dropColumn("quad_down_payment_percentage");
table.dropColumn("triple_down_payment_percentage");
table.dropColumn("double_down_payment_percentage");
table.dropColumn("infant_down_payment_percentage");
table.dropColumn("kit");
table.dropColumn("vaccine");
});
await knex.raw(`drop type "kit";`);
await knex.raw(`drop type "order_type";`);
await knex.schema.alterTable("order", (table) => {
table.string("verification_id", 30).nullable();
table.string("partner_id", 30).nullable();
table
.foreign("verification_id", "order_verification_id_foreign")
.references("verification.id")
.onUpdate("NO ACTION")
.onDelete("SET NULL");
table
.foreign("partner_id", "order_partner_id_foreign")
.references("partner.id")
.onUpdate("NO ACTION")
.onDelete("SET NULL");
});
await knex.schema.createTable("order_detail", (table) => {
// Columns
table.string("id", 30).notNullable();
table.string("order_id", 30).notNullable();
table
.enum("room_type", null, {
useNative: true,
existingType: true,
enumName: "room_type",
})
.notNullable();
table.timestamp("created_at", { useTz: true }).notNullable();
table.timestamp("updated_at", { useTz: true }).notNullable();
// Constraints
table.primary(["id"], { constraintName: "order_detail_pkey" });
table
.foreign("order_id", "order_detail_order_id_foreign")
.references("order.id")
.onUpdate("NO ACTION")
.onDelete("CASCADE");
});
}
}

View File

@@ -0,0 +1,29 @@
import { Migration } from "@mikro-orm/migrations";
export class Migration20260108142003 extends Migration {
override async up(): Promise<void> {
const knex = this.getKnex();
await knex.schema.alterTable("order", (table) => {
table.dropForeign("package_id", "order_package_id_foreign");
table
.foreign("package_id", "order_package_id_foreign")
.references("package_detail.id")
.onUpdate("NO ACTION")
.onDelete("CASCADE");
});
}
override async down(): Promise<void> {
const knex = this.getKnex();
await knex.schema.alterTable("order", (table) => {
table.dropForeign("package_id", "order_package_id_foreign");
table
.foreign("package_id", "order_package_id_foreign")
.references("package.id")
.onUpdate("NO ACTION")
.onDelete("CASCADE");
});
}
}

View File

@@ -14,8 +14,8 @@ export const airlineRequestSchema = z.object({
skytrax_rating: z skytrax_rating: z
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(1, "Minimum 1.") .min(1, "Min 1.")
.max(5, "Maximum 5."), .max(5, "Max 5."),
skytrax_type: z.enum( skytrax_type: z.enum(
SkytraxType, SkytraxType,
"Must be either 'full_service' or 'low_cost'.", "Must be either 'full_service' or 'low_cost'.",

View File

@@ -12,8 +12,8 @@ export const hotelRequestSchema = z.object({
star: z star: z
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(1, "Minimum 1.") .min(1, "Min 1.")
.max(7, "Maximum 7."), .max(7, "Max 7."),
images: z images: z
.array( .array(
z.base64("Must be base64 string.").nonempty("Must not empty."), z.base64("Must be base64 string.").nonempty("Must not empty."),

View File

@@ -12,22 +12,15 @@ import type {
ListResponse, ListResponse,
SingleResponse, SingleResponse,
} from "@/common/types"; } from "@/common/types";
import { generateRandomCode } from "@/common/utils";
import { OrderDetail } from "@/database/entities/order-detail.entity";
import { Order } from "@/database/entities/order.entity"; import { Order } from "@/database/entities/order.entity";
import { PackageDetail } from "@/database/entities/package-detail.entity"; import { PackageDetail } from "@/database/entities/package-detail.entity";
import { Partner } from "@/database/entities/partner.entity";
import { Verification } from "@/database/entities/verification.entity";
import { VerificationType } from "@/database/enums/verification-type.enum";
import { orm } from "@/database/orm"; import { orm } from "@/database/orm";
import type { OrderMapper } from "@/modules/order/order.mapper"; import type { OrderMapper } from "@/modules/order/order.mapper";
import { import {
orderParamsSchema, orderParamsSchema,
orderRequestSchema, orderRequestSchema,
orderVerifyRequestSchema,
} from "@/modules/order/order.schemas"; } from "@/modules/order/order.schemas";
import type { OrderResponse } from "@/modules/order/order.types"; import type { OrderResponse } from "@/modules/order/order.types";
import * as dateFns from "date-fns";
import { Router, type Request, type Response } from "express"; import { Router, type Request, type Response } from "express";
import { ulid } from "ulid"; import { ulid } from "ulid";
@@ -47,9 +40,15 @@ export class OrderController extends Controller {
} }
const body = parseBodyResult.data; const body = parseBodyResult.data;
const packageDetail = await orm.em.findOne(PackageDetail, { const packageDetail = await orm.em.findOne(
id: body.package_id, PackageDetail,
}); {
id: body.package_id,
},
{
populate: ["*"],
},
);
if (!packageDetail) { if (!packageDetail) {
return res.status(404).json({ return res.status(404).json({
data: null, data: null,
@@ -63,22 +62,22 @@ export class OrderController extends Controller {
} satisfies ErrorResponse); } satisfies ErrorResponse);
} }
const verification = orm.em.create(Verification, {
id: ulid(),
code: generateRandomCode(6, "0123456789"),
type: VerificationType.createOrder,
expiredAt: dateFns.addHours(new Date(), 1),
createdAt: new Date(),
updatedAt: new Date(),
});
const order = orm.em.create(Order, { const order = orm.em.create(Order, {
id: ulid(), id: ulid(),
package: packageDetail, package: packageDetail,
quad: body.quad,
triple: body.triple,
double: body.double,
infant: body.infant,
type: body.type,
quadDownPaymentPercentage: body.quad_down_payment_percentage,
tripleDownPaymentPercentage: body.triple_down_payment_percentage,
doubleDownPaymentPercentage: body.double_down_payment_percentage,
infantDownPaymentPercentage: body.infant_down_payment_percentage,
kit: body.kit,
vaccine: body.vaccine,
name: body.name, name: body.name,
whatsapp: body.whatsapp, whatsapp: body.whatsapp,
verification,
partner: null,
expiredAt: null, expiredAt: null,
purchasedAt: null, purchasedAt: null,
finishedAt: null, finishedAt: null,
@@ -86,27 +85,21 @@ export class OrderController extends Controller {
updatedAt: new Date(), updatedAt: new Date(),
}); });
for (const roomType of body.room_types) {
order.details.add(
orm.em.create(OrderDetail, {
id: ulid(),
order,
roomType,
createdAt: new Date(),
updatedAt: new Date(),
}),
);
}
await orm.em.flush(); await orm.em.flush();
const paymentUrl = await this.paymentService.createPaymentUrl(order);
return res.status(201).json({ return res.status(201).json({
data: { data: {
message: ...this.mapper.mapEntityToResponse(order),
"Order created successfully. Please check your email for verification.", payment_url: paymentUrl,
}, },
errors: null, errors: null,
} satisfies SingleResponse); } satisfies SingleResponse<
OrderResponse & {
payment_url: string;
}
>);
} }
async list(_req: Request, res: Response) { async list(_req: Request, res: Response) {
@@ -123,8 +116,11 @@ export class OrderController extends Controller {
const orders = await orm.em.find( const orders = await orm.em.find(
Order, Order,
{ {
verification: null, package: {
partner: req.partner, package: {
partner: req.partner,
},
},
}, },
{ {
limit: query.per_page, limit: query.per_page,
@@ -159,8 +155,11 @@ export class OrderController extends Controller {
Order, Order,
{ {
id: params.id, id: params.id,
verification: null, package: {
partner: req.partner, package: {
partner: req.partner,
},
},
}, },
{ {
populate: ["*"], populate: ["*"],
@@ -198,8 +197,11 @@ export class OrderController extends Controller {
Order, Order,
{ {
id: params.id, id: params.id,
verification: null, package: {
partner: req.partner, package: {
partner: req.partner,
},
},
}, },
{ {
populate: ["*"], populate: ["*"],
@@ -229,94 +231,6 @@ export class OrderController extends Controller {
} satisfies SingleResponse<OrderResponse>); } satisfies SingleResponse<OrderResponse>);
} }
async verify(req: Request, res: Response) {
const parseParamsResult = orderParamsSchema.safeParse(req.params);
if (!parseParamsResult.success) {
return this.handleZodError(parseParamsResult.error, res, "params");
}
const params = parseParamsResult.data;
const parseBodyResult = orderVerifyRequestSchema.safeParse(req.body);
if (!parseBodyResult.success) {
return this.handleZodError(parseBodyResult.error, res, "body");
}
const body = parseBodyResult.data;
const order = await orm.em.findOne(
Order,
{ id: params.id },
{
populate: ["*"],
},
);
if (!order) {
return res.status(404).json({
data: null,
errors: [
{
path: "id",
location: "params",
message: "Order not found.",
},
],
} satisfies ErrorResponse);
}
if (order.verification === null) {
return res.status(400).json({
data: null,
errors: [
{
message: "Order is already verified.",
},
],
} satisfies ErrorResponse);
}
if (order.verification.code !== body.code) {
return res.status(400).json({
data: null,
errors: [
{
path: "code",
location: "body",
message: "Incorrect.",
},
],
} satisfies ErrorResponse);
}
orm.em.remove(order.verification);
const partners = await orm.em.findAll(Partner, { populate: ["*"] });
const partner = partners.toSorted(
(a, b) =>
a.orders.filter((order) => order.finishedAt === null).length -
b.orders.filter((order) => order.finishedAt === null).length,
)[0];
order.verification = null;
order.partner = partner;
order.expiredAt = dateFns.addHours(new Date(), 24);
order.updatedAt = new Date();
await orm.em.flush();
const paymentUrl = await this.paymentService.createPaymentUrl(order);
return res.status(200).json({
data: {
...this.mapper.mapEntityToResponse(order),
payment_url: paymentUrl,
},
errors: null,
} satisfies SingleResponse<
OrderResponse & {
payment_url: string;
}
>);
}
async delete(_req: Request, res: Response) { async delete(_req: Request, res: Response) {
const req = _req as Request & PartnerRequestPlugin; const req = _req as Request & PartnerRequestPlugin;
@@ -328,7 +242,14 @@ export class OrderController extends Controller {
const order = await orm.em.findOne( const order = await orm.em.findOne(
Order, Order,
{ id: params.id, verification: null, partner: req.partner }, {
id: params.id,
package: {
package: {
partner: req.partner,
},
},
},
{ {
populate: ["*"], populate: ["*"],
}, },
@@ -372,11 +293,6 @@ export class OrderController extends Controller {
isPartnerMiddleware(this.jwtService), isPartnerMiddleware(this.jwtService),
this.finish.bind(this), this.finish.bind(this),
); );
router.put(
"/:id/verify",
createOrmContextMiddleware,
this.verify.bind(this),
);
router.delete( router.delete(
"/:id", "/:id",
createOrmContextMiddleware, createOrmContextMiddleware,

View File

@@ -1,5 +1,4 @@
import type { Order } from "@/database/entities/order.entity"; import type { Order } from "@/database/entities/order.entity";
import { RoomType } from "@/database/enums/room-type.enum";
import type { OrderResponse } from "@/modules/order/order.types"; import type { OrderResponse } from "@/modules/order/order.types";
import type { PackageMapper } from "@/modules/package/package.mapper"; import type { PackageMapper } from "@/modules/package/package.mapper";
import type { PartnerMapper } from "@/modules/partner/partner.mapper"; import type { PartnerMapper } from "@/modules/partner/partner.mapper";
@@ -11,44 +10,11 @@ export class OrderMapper {
) {} ) {}
public mapEntityToResponse(order: Order): OrderResponse { public mapEntityToResponse(order: Order): OrderResponse {
const details: OrderResponse["details"] = [];
let totalPrice = 0;
for (const detail of order.details) {
let price = 0;
switch (detail.roomType) {
case RoomType.double:
price = order.package.doublePrice;
break;
case RoomType.triple:
price = order.package.triplePrice;
break;
case RoomType.quad:
price = order.package.quadPrice;
break;
case RoomType.infant:
price = order.package.infantPrice ?? 0;
break;
}
details.push({
price,
room_type: detail.roomType,
});
totalPrice += price;
}
return { return {
id: order.id, id: order.id,
package: this.packageMapper.mapDetailEntityToResponse(order.package), package: this.packageMapper.mapDetailEntityToResponse(order.package),
name: order.name, name: order.name,
whatsapp: order.whatsapp, whatsapp: order.whatsapp,
details,
total_price: totalPrice,
is_verified: order.verification === null,
partner: order.partner
? this.partnerMapper.mapEntityToResponse(order.partner)
: null,
expired_at: order.expiredAt, expired_at: order.expiredAt,
purchased_at: order.purchasedAt, purchased_at: order.purchasedAt,
finished_at: order.finishedAt, finished_at: order.finishedAt,

View File

@@ -1,5 +1,6 @@
import { phoneNumberSchema } from "@/common/schemas"; import { phoneNumberSchema } from "@/common/schemas";
import { RoomType } from "@/database/enums/room-type.enum"; import { Kit } from "@/database/enums/kit.enum";
import { OrderType } from "@/database/enums/order-type.enum";
import z from "zod"; import z from "zod";
export const orderRequestSchema = z.object({ export const orderRequestSchema = z.object({
@@ -7,20 +8,38 @@ export const orderRequestSchema = z.object({
.ulid("Must be ulid string.") .ulid("Must be ulid string.")
.nonempty("Must not empty.") .nonempty("Must not empty.")
.max(30, "Max 30 characters."), .max(30, "Max 30 characters."),
quad: z.number("Must be number.").int("Must be integer.").min(0, "Min 0."),
triple: z.number("Must be number.").int("Must be integer.").min(0, "Min 0."),
double: z.number("Must be number.").int("Must be integer.").min(0, "Min 0."),
infant: z.number("Must be number.").int("Must be integer.").min(0, "Min 0."),
type: z.enum(OrderType, "Must be either 'booking_seat' or 'down_payment'."),
quad_down_payment_percentage: z
.number("Must be number.")
.int("Must be integer")
.min(0, "Min 0.")
.max(100, "Max 100."),
triple_down_payment_percentage: z
.number("Must be number.")
.int("Must be integer")
.min(0, "Min 0.")
.max(100, "Max 100."),
double_down_payment_percentage: z
.number("Must be number.")
.int("Must be integer")
.min(0, "Min 0.")
.max(100, "Max 100."),
infant_down_payment_percentage: z
.number("Must be number.")
.int("Must be integer")
.min(0, "Min 0.")
.max(100, "Max 100."),
kit: z.enum(Kit, "Must be either 'minimal', 'without_suitcase', or 'full'."),
vaccine: z.number("Must be number.").int("Must be integer.").min(0, "Min 0."),
name: z name: z
.string("Must be string.") .string("Must be string.")
.nonempty("Must not empty.") .nonempty("Must not empty.")
.max(100, "Max 100 characters."), .max(100, "Max 100 characters."),
whatsapp: phoneNumberSchema, whatsapp: phoneNumberSchema,
room_types: z
.array(
z.enum(
RoomType,
"Must be either 'double', 'triple', 'quad', or 'infant'.",
),
"Must be array.",
)
.nonempty("Must not empty."),
}); });
export const orderVerifyRequestSchema = z.object({ export const orderVerifyRequestSchema = z.object({

View File

@@ -1,10 +1,8 @@
import type { RoomType } from "@/database/enums/room-type.enum";
import type { import type {
orderParamsSchema, orderParamsSchema,
orderRequestSchema, orderRequestSchema,
} from "@/modules/order/order.schemas"; } from "@/modules/order/order.schemas";
import type { PackageDetailResponse } from "@/modules/package/package.types"; import type { PackageDetailResponse } from "@/modules/package/package.types";
import type { PartnerResponse } from "@/modules/partner/partner.types";
import z from "zod"; import z from "zod";
export type OrderRequest = z.infer<typeof orderRequestSchema>; export type OrderRequest = z.infer<typeof orderRequestSchema>;
@@ -16,13 +14,6 @@ export type OrderResponse = {
package: PackageDetailResponse; package: PackageDetailResponse;
name: string; name: string;
whatsapp: string; whatsapp: string;
details: {
room_type: RoomType;
price: number;
}[];
total_price: number;
is_verified: boolean;
partner: PartnerResponse | null;
expired_at: Date | null; expired_at: Date | null;
purchased_at: Date | null; purchased_at: Date | null;
finished_at: Date | null; finished_at: Date | null;

View File

@@ -12,7 +12,7 @@ export const testimonyRequestSchema = z.object({
rating: z rating: z
.number("Must be number.") .number("Must be number.")
.int("Must be integer.") .int("Must be integer.")
.min(1, "Min 1.") .min(1, "Minimum1.")
.max(5, "Max 5."), .max(5, "Max 5."),
}); });