This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -0,0 +1,299 @@
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 { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
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 { PaymentMethod } from '../../payment/types/PaymentMethod';
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<Order>,
@InjectRepository(OrderLineManualFulfillment)
private readonly manualFulfillmentRepo: Repository<OrderLineManualFulfillment>,
private readonly orderChatService: OrderChatService,
private readonly accessTokenService: OrderAccessTokenService,
private readonly encryptionService: EncryptionService,
private readonly invoiceService: InvoiceService
) {}
async findIdByCheckoutSessionId(checkoutSessionId: string): Promise<string | null> {
const order = await this.orderRepo.findOne({
where: { checkoutSession: { id: checkoutSessionId } },
select: { id: true }
});
return order?.id ?? null;
}
async findById(id: string): Promise<OrderExtended> {
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<PaginatedResponse<OrderListItem>> {
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',
'shippingInvoice',
'shippingInvoice.payments',
'shippingInvoice.moneroDetails',
'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 expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
const payments = (invoice.payments ?? []).map(payment =>
this.toInvoicePaymentExtended(payment, requiredConfirmations)
);
return {
...invoice,
statusLabel,
expectedTotalCrypto,
payments
};
}
private toInvoicePaymentExtended(payment: InvoicePayment, requiredConfirmations: number): InvoicePaymentExtended {
const isConfirmed = payment.confirmations >= requiredConfirmations;
const amountCrypto = convertXmrAtomicToXmr(payment.amountAtomic);
const confirmationsLabel = formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations,
requiredConfirmations,
format: 'compact'
});
return {
...payment,
amountCrypto,
isConfirmed,
confirmationsLabel
};
}
async setDeliveryCost(orderId: string, { deliveryCost }: SetDeliveryCostDto): Promise<OrderExtended> {
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: PaymentMethod.Xmr,
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<OrderExtended> {
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);
}
}