init
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user