Prevent checkout sessions from being created without an invoice when the payment method switch has no matching case.
310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
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<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',
|
|
'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<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: 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<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);
|
|
}
|
|
}
|