|
@@ -0,0 +1,402 @@
|
|
|
|
+import { selPool, updPool } from "#db/db.js";
|
|
|
|
+import { DatabaseTransactionConnection, SerializableValue, sql } from "slonik";
|
|
|
|
+import { v7 as uuidv7 } from "uuid";
|
|
|
|
+import { cartService } from "./cart/cart-service.js";
|
|
|
|
+import { ApiError } from "#exceptions/api-error.js";
|
|
|
|
+import { z } from "zod";
|
|
|
|
+import { DbSchema } from "#db/db-schema.js";
|
|
|
|
+import { CustomFieldWithValue } from "#api/v_0.1.0/types/custom-fields-types.js";
|
|
|
|
+import { generateRandomNumber } from "#utils/other-utils.js";
|
|
|
|
+import { Cart } from "./cart/cart-types.js";
|
|
|
|
+import { dayjs } from "#plugins/dayjs.js";
|
|
|
|
+import { PaymentService } from "./payment/payment-service.js";
|
|
|
|
+import { logger } from "#plugins/logger.js";
|
|
|
|
+import { YooKassaProvider } from "./payment/yookassa-provider.js";
|
|
|
|
+import { cActService } from "../activities/c-act-service.js";
|
|
|
|
+
|
|
|
|
+type OrderUserData = Record<string, SerializableValue> & {
|
|
|
|
+ firstName: string;
|
|
|
|
+ lastName: string;
|
|
|
|
+ patronymic: string | null;
|
|
|
|
+ email: string;
|
|
|
|
+ phone: string;
|
|
|
|
+};
|
|
|
|
+
|
|
|
|
+class OrdersService {
|
|
|
|
+ async createOrder(
|
|
|
|
+ tr: DatabaseTransactionConnection,
|
|
|
|
+ {
|
|
|
|
+ cart,
|
|
|
|
+ userId,
|
|
|
|
+ userData,
|
|
|
|
+ }: {
|
|
|
|
+ cart: Cart;
|
|
|
|
+ userId?: string;
|
|
|
|
+ userData: OrderUserData;
|
|
|
|
+ },
|
|
|
|
+ ) {
|
|
|
|
+ const products = await Promise.all(
|
|
|
|
+ cart.items.map(async (item) => {
|
|
|
|
+ const p = await cartService.getProductById(item.productId);
|
|
|
|
+ if (!p) {
|
|
|
|
+ throw ApiError.BadRequest(
|
|
|
|
+ "productNotFound",
|
|
|
|
+ "Не найден товар из корзины",
|
|
|
|
+ );
|
|
|
|
+ }
|
|
|
|
+ if (item.priceAtAddition !== p.price) {
|
|
|
|
+ throw ApiError.BadRequest(
|
|
|
|
+ "productPriceChanged",
|
|
|
|
+ "Цена товара изменилась",
|
|
|
|
+ );
|
|
|
|
+ }
|
|
|
|
+ return {
|
|
|
|
+ ...p,
|
|
|
|
+ quantity: item.quantity,
|
|
|
|
+ activityRegId: item.activityRegId,
|
|
|
|
+ peMemberId: item.peMemberId,
|
|
|
|
+ cartItemId: item.cartItemId,
|
|
|
|
+ };
|
|
|
|
+ }),
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ // order
|
|
|
|
+ const orderId = uuidv7();
|
|
|
|
+
|
|
|
|
+ let orderNumber = generateRandomNumber();
|
|
|
|
+
|
|
|
|
+ while (await this.checkOrderNumber(orderNumber)) {
|
|
|
|
+ orderNumber = generateRandomNumber();
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const totalAmount = products.reduce((acc, product) => {
|
|
|
|
+ return acc + product.price * product.quantity;
|
|
|
|
+ }, 0);
|
|
|
|
+
|
|
|
|
+ const paymentDueDate = dayjs().add(1, "hour").toDate();
|
|
|
|
+
|
|
|
|
+ await tr.query(sql.unsafe`
|
|
|
|
+ insert into shop.orders (
|
|
|
|
+ order_id,
|
|
|
|
+ order_number,
|
|
|
|
+ user_id,
|
|
|
|
+ status,
|
|
|
|
+ total_amount,
|
|
|
|
+ currency_code,
|
|
|
|
+ billing_data_snapshot,
|
|
|
|
+ payment_due_date
|
|
|
|
+ )
|
|
|
|
+ values (
|
|
|
|
+ ${orderId},
|
|
|
|
+ ${orderNumber},
|
|
|
|
+ ${userId || null},
|
|
|
|
+ 'PENDING_PAYMENT',
|
|
|
|
+ ${totalAmount},
|
|
|
|
+ 'RUB',
|
|
|
|
+ ${sql.jsonb(userData)},
|
|
|
|
+ ${paymentDueDate.toISOString()}
|
|
|
|
+ )
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ for (const product of products) {
|
|
|
|
+ await tr.query(sql.unsafe`
|
|
|
|
+ insert into shop.order_items (
|
|
|
|
+ order_item_id,
|
|
|
|
+ order_id,
|
|
|
|
+ product_id,
|
|
|
|
+ quantity,
|
|
|
|
+ unit_price,
|
|
|
|
+ total_price,
|
|
|
|
+ activity_reg_id,
|
|
|
|
+ pe_member_id,
|
|
|
|
+ attributes_snapshot
|
|
|
|
+ )
|
|
|
|
+ values (
|
|
|
|
+ ${uuidv7()},
|
|
|
|
+ ${orderId},
|
|
|
|
+ ${product.productId},
|
|
|
|
+ ${product.quantity},
|
|
|
|
+ ${product.price},
|
|
|
|
+ ${product.price * product.quantity},
|
|
|
|
+ ${"activityRegId" in product ? product.activityRegId : null},
|
|
|
|
+ ${"peMemberId" in product ? product.peMemberId : null},
|
|
|
|
+ ${sql.jsonb(product.attributes)}
|
|
|
|
+ )
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ // чтобы скрыть из корзины
|
|
|
|
+ await tr.query(sql.unsafe`
|
|
|
|
+ update shop.cart_items set order_id = ${orderId} where cart_item_id = ${product.cartItemId}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return {
|
|
|
|
+ orderId,
|
|
|
|
+ orderNumber,
|
|
|
|
+ totalAmount,
|
|
|
|
+ currencyCode: "RUB",
|
|
|
|
+ billingDataSnapshot: userData,
|
|
|
|
+ paymentDueDate,
|
|
|
|
+ items: products,
|
|
|
|
+ };
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async checkOrderNumber(orderNumber: string) {
|
|
|
|
+ const order = await selPool.exists(sql.unsafe`
|
|
|
|
+ select 1 from shop.orders where order_number = ${orderNumber}
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ return !!order;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async getOrder(order: { orderId: string } | { orderNumber: string }) {
|
|
|
|
+ return await selPool.maybeOne(sql.type(
|
|
|
|
+ z.object({
|
|
|
|
+ orderId: DbSchema.shop.orders.orderId,
|
|
|
|
+ orderNumber: DbSchema.shop.orders.orderNumber,
|
|
|
|
+ userId: DbSchema.shop.orders.userId,
|
|
|
|
+ status: DbSchema.shop.orders.status,
|
|
|
|
+ totalAmount: DbSchema.shop.orders.totalAmount,
|
|
|
|
+ currencyCode: DbSchema.shop.orders.currencyCode,
|
|
|
|
+ billingDataSnapshot: DbSchema.shop.orders.billingDataSnapshot,
|
|
|
|
+ createdAt: DbSchema.shop.orders.createdAt,
|
|
|
|
+ paymentDueDate: DbSchema.shop.orders.paymentDueDate,
|
|
|
|
+ items: z.array(
|
|
|
|
+ z.object({
|
|
|
|
+ orderItemId: DbSchema.shop.orderItems.orderItemId,
|
|
|
|
+ orderId: DbSchema.shop.orderItems.orderId,
|
|
|
|
+ productId: DbSchema.shop.orderItems.productId,
|
|
|
|
+ quantity: DbSchema.shop.orderItems.quantity,
|
|
|
|
+ unitPrice: DbSchema.shop.orderItems.unitPrice,
|
|
|
|
+ totalPrice: DbSchema.shop.orderItems.totalPrice,
|
|
|
|
+ activityRegId: DbSchema.shop.orderItems.activityRegId.nullable(),
|
|
|
|
+ peMemberId: DbSchema.shop.orderItems.peMemberId.nullable(),
|
|
|
|
+ attributesSnapshot: DbSchema.shop.orderItems.attributesSnapshot,
|
|
|
|
+ productName: DbSchema.shop.products.name,
|
|
|
|
+ productType: DbSchema.shop.products.productType,
|
|
|
|
+ stockQuantity: DbSchema.shop.products.stockQuantity,
|
|
|
|
+ actPublicName: DbSchema.act.activities.publicName.nullable(),
|
|
|
|
+ peMemberFields: CustomFieldWithValue.extend({
|
|
|
|
+ userEfId: z.string().uuid(),
|
|
|
|
+ }).nullable(),
|
|
|
|
+ }),
|
|
|
|
+ ),
|
|
|
|
+ }),
|
|
|
|
+ )`
|
|
|
|
+ select
|
|
|
|
+ order_id as "orderId",
|
|
|
|
+ order_number as "orderNumber",
|
|
|
|
+ user_id as "userId",
|
|
|
|
+ status,
|
|
|
|
+ total_amount::float as "totalAmount",
|
|
|
|
+ currency_code as "currencyCode",
|
|
|
|
+ billing_data_snapshot as "billingDataSnapshot",
|
|
|
|
+ created_at as "createdAt",
|
|
|
|
+ payment_due_date as "paymentDueDate",
|
|
|
|
+ items
|
|
|
|
+ from
|
|
|
|
+ shop.orders_with_items
|
|
|
|
+ where
|
|
|
|
+ ${"orderId" in order ? sql.fragment`order_id = ${order.orderId}` : sql.fragment`order_number = ${order.orderNumber}`}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async cancelOrder(orderId: string) {
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.orders set status = 'CANCELLED' where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async getLastPaymentByOrderId(orderId: string) {
|
|
|
|
+ return await selPool.maybeOne(sql.type(
|
|
|
|
+ z.object({
|
|
|
|
+ paymentId: DbSchema.shop.payments.paymentId,
|
|
|
|
+ orderId: DbSchema.shop.payments.orderId,
|
|
|
|
+ userId: DbSchema.shop.payments.userId,
|
|
|
|
+ amount: DbSchema.shop.payments.amount,
|
|
|
|
+ currencyCode: DbSchema.shop.payments.currencyCode,
|
|
|
|
+ paymentMethod: DbSchema.shop.payments.paymentMethod,
|
|
|
|
+ bank: DbSchema.shop.payments.bank,
|
|
|
|
+ status: DbSchema.shop.payments.status,
|
|
|
|
+ externalTransactionId: DbSchema.shop.payments.externalTransactionId,
|
|
|
|
+ paymentGatewayDetails: DbSchema.shop.payments.paymentGatewayDetails,
|
|
|
|
+ createdAt: DbSchema.shop.payments.createdAt,
|
|
|
|
+ updatedAt: DbSchema.shop.payments.updatedAt,
|
|
|
|
+ confirmation: DbSchema.shop.payments.confirmation,
|
|
|
|
+ }),
|
|
|
|
+ )`
|
|
|
|
+ select
|
|
|
|
+ payment_id as "paymentId",
|
|
|
|
+ order_id as "orderId",
|
|
|
|
+ user_id as "userId",
|
|
|
|
+ amount::float as "amount",
|
|
|
|
+ currency_code as "currencyCode",
|
|
|
|
+ payment_method as "paymentMethod",
|
|
|
|
+ bank,
|
|
|
|
+ status,
|
|
|
|
+ external_transaction_id "externalTransactionId",
|
|
|
|
+ payment_gateway_details "paymentGatewayDetails",
|
|
|
|
+ created_at "createdAt",
|
|
|
|
+ updated_at "updatedAt",
|
|
|
|
+ confirmation
|
|
|
|
+ from
|
|
|
|
+ shop.payments
|
|
|
|
+ where
|
|
|
|
+ order_id = ${orderId}
|
|
|
|
+ order by
|
|
|
|
+ created_at desc
|
|
|
|
+ limit 1;
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async waitPandingOrder(orderId: string) {
|
|
|
|
+ const order = await this.getOrder({ orderId });
|
|
|
|
+ const payment = await this.getLastPaymentByOrderId(orderId);
|
|
|
|
+
|
|
|
|
+ if (!order || !payment) {
|
|
|
|
+ logger.error("Order or payment not found");
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // Инициализация провайдера
|
|
|
|
+ const yookassaProvider = new YooKassaProvider();
|
|
|
|
+ // Инициализация сервиса с конкретным провайдером
|
|
|
|
+ const paymentService = new PaymentService(yookassaProvider);
|
|
|
|
+
|
|
|
|
+ const status = await paymentService.waitPayment(
|
|
|
|
+ payment.paymentId,
|
|
|
|
+ order.paymentDueDate,
|
|
|
|
+ );
|
|
|
|
+ if (status === "succeeded") {
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.orders
|
|
|
|
+ set status = 'PAID'
|
|
|
|
+ where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.order_items
|
|
|
|
+ set status = 'PAID'
|
|
|
|
+ where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ // статус для регистраций
|
|
|
|
+ for (const item of order.items) {
|
|
|
|
+ if (
|
|
|
|
+ item.productType === "ACTIVITY_REGISTRATION" ||
|
|
|
|
+ item.productType === "ACTIVITY_PARTICIPANT"
|
|
|
|
+ ) {
|
|
|
|
+ if (!item.activityRegId) throw new Error("activityRegId not found");
|
|
|
|
+ await cActService.updateActRegPaymentStatus(item.activityRegId);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // удаляем из корзины
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ delete from shop.cart_items where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (status === "canceled") {
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.orders
|
|
|
|
+ set status = 'CANCELLED'
|
|
|
|
+ where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.order_items
|
|
|
|
+ set status = 'CANCELLED'
|
|
|
|
+ where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ // возвращаем в корзину
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.cart_items set order_id = null where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (status === "failed") {
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.orders
|
|
|
|
+ set status = 'FAILED'
|
|
|
|
+ where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+
|
|
|
|
+ // возвращаем в корзину
|
|
|
|
+ await updPool.query(sql.unsafe`
|
|
|
|
+ update shop.cart_items set order_id = null where order_id = ${orderId}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async waitAllPendingOrders() {
|
|
|
|
+ logger.info("Запуск проверки всех ожидающих заказов...");
|
|
|
|
+ const orders = await this.getAllPendingOrders();
|
|
|
|
+ for (const orderId of orders) {
|
|
|
|
+ await this.waitPandingOrder(orderId);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async getAllPendingOrders() {
|
|
|
|
+ return await selPool.anyFirst(sql.type(
|
|
|
|
+ z.object({
|
|
|
|
+ orderId: z.string().uuid(),
|
|
|
|
+ }),
|
|
|
|
+ )`
|
|
|
|
+ select order_id "orderId" from shop.orders where status = 'PENDING_PAYMENT'
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ async getOrders(userId: string) {
|
|
|
|
+ return selPool.any(sql.type(
|
|
|
|
+ z.object({
|
|
|
|
+ orderId: DbSchema.shop.orders.orderId,
|
|
|
|
+ orderNumber: DbSchema.shop.orders.orderNumber,
|
|
|
|
+ userId: DbSchema.shop.orders.userId,
|
|
|
|
+ status: DbSchema.shop.orders.status,
|
|
|
|
+ totalAmount: DbSchema.shop.orders.totalAmount,
|
|
|
|
+ currencyCode: DbSchema.shop.orders.currencyCode,
|
|
|
|
+ billingDataSnapshot: DbSchema.shop.orders.billingDataSnapshot,
|
|
|
|
+ createdAt: DbSchema.shop.orders.createdAt,
|
|
|
|
+ paymentDueDate: DbSchema.shop.orders.paymentDueDate,
|
|
|
|
+ items: z.array(
|
|
|
|
+ z.object({
|
|
|
|
+ orderItemId: DbSchema.shop.orderItems.orderItemId,
|
|
|
|
+ orderId: DbSchema.shop.orderItems.orderId,
|
|
|
|
+ productId: DbSchema.shop.orderItems.productId,
|
|
|
|
+ quantity: DbSchema.shop.orderItems.quantity,
|
|
|
|
+ unitPrice: DbSchema.shop.orderItems.unitPrice,
|
|
|
|
+ totalPrice: DbSchema.shop.orderItems.totalPrice,
|
|
|
|
+ activityRegId: DbSchema.shop.orderItems.activityRegId.nullable(),
|
|
|
|
+ peMemberId: DbSchema.shop.orderItems.peMemberId.nullable(),
|
|
|
|
+ attributesSnapshot: DbSchema.shop.orderItems.attributesSnapshot,
|
|
|
|
+ productName: DbSchema.shop.products.name,
|
|
|
|
+ productType: DbSchema.shop.products.productType,
|
|
|
|
+ stockQuantity: DbSchema.shop.products.stockQuantity,
|
|
|
|
+ actPublicName: DbSchema.act.activities.publicName.nullable(),
|
|
|
|
+ peMemberFields: CustomFieldWithValue.extend({
|
|
|
|
+ userEfId: z.string().uuid(),
|
|
|
|
+ }).nullable(),
|
|
|
|
+ }),
|
|
|
|
+ ),
|
|
|
|
+ }),
|
|
|
|
+ )`
|
|
|
|
+ select
|
|
|
|
+ order_id "orderId",
|
|
|
|
+ order_number "orderNumber",
|
|
|
|
+ user_id "userId",
|
|
|
|
+ status,
|
|
|
|
+ total_amount::float "totalAmount",
|
|
|
|
+ currency_code "currencyCode",
|
|
|
|
+ billing_data_snapshot "billingDataSnapshot",
|
|
|
|
+ created_at "createdAt",
|
|
|
|
+ payment_due_date "paymentDueDate",
|
|
|
|
+ items
|
|
|
|
+ from shop.orders_with_items
|
|
|
|
+ where user_id = ${userId}
|
|
|
|
+ `);
|
|
|
|
+ }
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+export const ordersService = new OrdersService();
|