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 & { 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();