import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Repository } from 'typeorm'; import { createOrderDetailQuery } from '../../../utils/order/createOrderDetailQuery'; import { deriveOrderState } from '../../../utils/order/deriveOrderState'; import { deriveOrderTotals } from '../../../utils/order/deriveOrderTotals'; import { formatInvoicePaymentConfirmationStatus } from '../../../utils/invoice/formatInvoicePaymentConfirmationStatus'; import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage'; import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations'; import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState'; import type { CryptoAtomicConverter } from '../../../types/CryptoAtomicConverter'; import { paymentMethodLabel } from '../../../consts/paymentMethodLabel'; import { resolveAtomicToCryptoConverter } from '../../../utils/payment/resolveAtomicToCryptoConverter'; import type { Invoice } from '../../payment/entities/Invoice'; import type { InvoicePayment } from '../../payment/entities/InvoicePayment'; import type { InvoiceExtended } from '../../payment/types/InvoiceExtended'; import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended'; import { InvoiceReason } from '../../payment/types/InvoiceReason'; import { InvoiceService } from '../../payment/services/InvoiceService'; import { DeliveryMode } from '../../product/types/DeliveryMode'; import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto'; import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto'; import { Order } from '../entities/Order'; import { OrderLineManualFulfillment } from '../entities/OrderLineManualFulfillment'; import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; import { EncryptionService } from '../../encryption/services/EncryptionService'; import { OrderAccessTokenService } from './OrderAccessTokenService'; import { OrderChatService } from './OrderChatService'; import type { OrderExtended } from '../types/OrderExtended'; import type { OrderListItem } from '../types/OrderListItem'; import type { PaginatedResponse } from '../../../types/PaginatedResponse'; @Injectable() export class OrderService { constructor( @InjectRepository(Order) private readonly orderRepo: Repository, @InjectRepository(OrderLineManualFulfillment) private readonly manualFulfillmentRepo: Repository, private readonly orderChatService: OrderChatService, private readonly accessTokenService: OrderAccessTokenService, private readonly encryptionService: EncryptionService, private readonly invoiceService: InvoiceService ) {} async findIdByCheckoutSessionId(checkoutSessionId: string): Promise { const order = await this.orderRepo.findOne({ where: { checkoutSession: { id: checkoutSessionId } }, select: { id: true } }); return order?.id ?? null; } async findById(id: string): Promise { const orderDetailQuery = createOrderDetailQuery(this.orderRepo, id); const order = await orderDetailQuery.getOne(); if (!order) { throw new NotFoundException('Order not found'); } order.accessToken = this.accessTokenService.decryptStored(order.accessToken); this.encryptionService.decryptPlaintextFieldInPlace(order.messages, 'body'); for (const line of order.lines ?? []) { this.encryptionService.decryptPlaintextFieldInPlace(line.autoFulfillmentItems, 'contentSnapshot'); } return this.toOrderExtended(order); } /** * Paginate in two steps: entities first, then hydrate relations. * * Do not join one-to-many relations in the paginated query — LIMIT/skip apply to joined * rows, so a page of 20 items can return far fewer parents when each parent has * multiple children (one-to-many row multiplication). * * @see https://github.com/typeorm/typeorm/issues/11316#issuecomment-2074916139 */ async findAll({ page = 1, limit = 20 }: ListOrdersQueryDto): Promise> { const [orders, total] = await this.orderRepo.findAndCount({ order: { createdAt: 'DESC' }, skip: (page - 1) * limit, take: limit }); if (orders.length === 0) { return { items: [], total, page, limit }; } const orderIds = orders.map(order => order.id); const ordersWithRelations = await this.orderRepo.find({ where: { id: In(orderIds) }, relations: [ 'checkoutInvoice', 'checkoutInvoice.payments', 'checkoutInvoice.moneroDetails', 'checkoutInvoice.btcDetails', 'shippingInvoice', 'shippingInvoice.payments', 'shippingInvoice.moneroDetails', 'shippingInvoice.btcDetails', 'lines', 'lines.manualFulfillment', 'discounts', 'messages' ], order: { createdAt: 'DESC' } }); return { items: ordersWithRelations.map(order => this.toOrderListItem(order)), total, page, limit }; } private toOrderListItem(order: Order): OrderListItem { const fiatCurrency = order.checkoutInvoice?.fiatCurrency; if (!fiatCurrency) { throw new InternalServerErrorException('Order is missing checkout invoice fiat currency'); } const { checkoutInvoiceState, shippingInvoiceState, status } = deriveOrderState(order); const { totalFiat, grandTotalFiat } = deriveOrderTotals(order); const checkoutPaymentLabel = checkoutInvoiceState ? resolveInvoiceStatusMessage(checkoutInvoiceState) : null; const shippingPaymentLabel = shippingInvoiceState ? resolveInvoiceStatusMessage(shippingInvoiceState) : null; return { id: order.id, status, checkoutPaymentLabel, shippingPaymentLabel, totalFiat, grandTotalFiat, fiatCurrency, lineCount: order.lines?.length ?? 0, unreadMessageCount: this.orderChatService.countUnreadBuyerMessages(order), failureReason: order.failureReason, createdAt: order.createdAt, updatedAt: order.updatedAt }; } private toOrderExtended(order: Order): OrderExtended { const fiatCurrency = order.checkoutInvoice?.fiatCurrency; if (!fiatCurrency) { throw new InternalServerErrorException('Order is missing checkout invoice fiat currency'); } const state = deriveOrderState(order); const totals = deriveOrderTotals(order); const checkoutInvoice = order.checkoutInvoice ? this.toInvoiceExtended(order.checkoutInvoice, state.checkoutInvoiceState) : null; const shippingInvoice = order.shippingInvoice ? this.toInvoiceExtended(order.shippingInvoice, state.shippingInvoiceState) : null; return { ...order, state, totals, fiatCurrency, checkoutInvoice, shippingInvoice }; } private toInvoiceExtended(invoice: Invoice, invoiceState: InvoiceState | null): InvoiceExtended { const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null; const convertAtomicToCrypto = resolveAtomicToCryptoConverter(invoice.paymentMethod); const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic); const payments = (invoice.payments ?? []).map(payment => this.toInvoicePaymentExtended(payment, requiredConfirmations, convertAtomicToCrypto) ); return { ...invoice, statusLabel, paymentLabel: paymentMethodLabel[invoice.paymentMethod], expectedTotalCrypto, payments }; } private toInvoicePaymentExtended( payment: InvoicePayment, requiredConfirmations: number, convertAtomicToCrypto: CryptoAtomicConverter ): InvoicePaymentExtended { const isConfirmed = payment.confirmations >= requiredConfirmations; const amountCrypto = convertAtomicToCrypto(payment.amountAtomic); const confirmationsLabel = formatInvoicePaymentConfirmationStatus({ confirmations: payment.confirmations, requiredConfirmations, format: 'compact' }); return { ...payment, amountCrypto, isConfirmed, confirmationsLabel }; } async setDeliveryCost(orderId: string, { deliveryCost }: SetDeliveryCostDto): Promise { const order = await this.orderRepo.findOne({ where: { id: orderId, failureReason: IsNull(), quotedAt: IsNull(), shippingInvoice: IsNull() }, relations: ['lines', 'checkoutInvoice'] }); if (!order || !order.checkoutInvoice) { throw new NotFoundException('Order not found'); } const hasManualLines = order.lines.some(line => line.deliveryMode === DeliveryMode.Manual); if (!hasManualLines) { throw new BadRequestException('Order does not require shipping'); } const quotedAt = new Date(); if (deliveryCost <= 0) { await this.orderRepo.update(orderId, { quotedAt }); return this.findById(orderId); } const shippingInvoice = await this.invoiceService.issueInvoice({ paymentMethod: order.checkoutInvoice.paymentMethod, reason: InvoiceReason.Shipping, contextId: orderId, amountFiat: deliveryCost }); await this.orderRepo.update(orderId, { shippingInvoice: { id: shippingInvoice.id }, quotedAt }); return this.findById(orderId); } async fulfillManualLine(orderId: string, lineId: string): Promise { const order = await this.orderRepo.findOne({ where: { id: orderId, failureReason: IsNull() }, relations: ['lines', 'lines.manualFulfillment'] }); if (!order) { throw new NotFoundException('Order not found'); } const line = order.lines?.find(item => item.id === lineId); if (!line) { throw new NotFoundException('Order line not found'); } if (line.deliveryMode !== DeliveryMode.Manual) { throw new BadRequestException('Order line is not manually delivered'); } if (!line.manualFulfillment) { throw new BadRequestException('Order line has no manual fulfillment record'); } if (line.manualFulfillment.status === ManualLineFulfillmentStatus.Fulfilled) { throw new BadRequestException('Order line is already fulfilled'); } await this.manualFulfillmentRepo.update(line.manualFulfillment.id, { status: ManualLineFulfillmentStatus.Fulfilled, fulfilledAt: new Date() }); return this.findById(orderId); } }