import { Injectable } from '@nestjs/common'; import { DataSource, type EntityManager } from 'typeorm'; import { deriveInvoiceState } from '../../../utils/invoice/deriveInvoiceState'; import { NotificationService } from '../../notifications/services/NotificationService'; import { DeliveryMode } from '../../product/types/DeliveryMode'; import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession'; import { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine'; import { Order } from '../entities/Order'; import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus'; import { OrderFailureReason } from '../types/OrderFailureReason'; import type { PreparedStockClaim } from '../types/PreparedStockClaim'; import { isPreparedDigitalStockClaim, isPreparedManualStockClaim } from '../utils/isPreparedStockClaim'; import { OrderAccessTokenService } from './OrderAccessTokenService'; import { OrderClaimService } from './OrderClaimService'; @Injectable() export class OrderCreationService { constructor( private readonly dataSource: DataSource, private readonly orderClaimService: OrderClaimService, private readonly accessTokenService: OrderAccessTokenService, private readonly notificationService: NotificationService ) {} async createFromPaidSession(sessionId: string): Promise { const now = new Date(); let createdOrderId: string | null = null; await this.dataSource.transaction(async manager => { const sessionRepo = manager.getRepository(CheckoutSession); const orderRepo = manager.getRepository(Order); const session = await sessionRepo .createQueryBuilder('session') .leftJoinAndSelect('session.lines', 'line') .leftJoinAndSelect('session.discounts', 'discount') .leftJoinAndSelect('session.invoice', 'invoice') .leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails') .leftJoinAndSelect('invoice.btcDetails', 'btcDetails') .leftJoinAndSelect('invoice.payments', 'payment') .leftJoin('session.order', 'order') .where('session.id = :sessionId', { sessionId }) .andWhere('session.cancelledAt IS NULL') .andWhere('order.id IS NULL') .andWhere('invoice.expiresAt > :now', { now }) .setLock('pessimistic_write', undefined, ['session']) .getOne(); if (!session || !session.invoice) { return; } const { isPaidSufficient } = deriveInvoiceState(session.invoice); if (!isPaidSufficient) { return; } const claimResult = await this.orderClaimService.claimFromSession(manager, session); const failureReason = claimResult.success ? null : claimResult.failureReason; const stockClaims = claimResult.success ? claimResult.stockClaims : []; const { lookup, encrypted } = this.accessTokenService.generate(); const order = this.buildOrderFromSession( manager, session, { accessTokenLookup: lookup, accessToken: encrypted, failureReason }, stockClaims ); await orderRepo.save(order); createdOrderId = order.id; }); if (createdOrderId) { this.notificationService.sendNotification(createdOrderId, 'newOrder'); } } private buildOrderFromSession( manager: EntityManager, session: CheckoutSession, { accessTokenLookup, accessToken, failureReason }: { accessTokenLookup: string; accessToken: string; failureReason: OrderFailureReason | null; }, stockClaims: PreparedStockClaim[] = [] ): Order { const orderRepo = manager.getRepository(Order); const stockClaimsByLineId = new Map(stockClaims.map(claim => [claim.checkoutSessionLineId, claim])); return orderRepo.create({ accessTokenLookup, accessToken, failureReason, checkoutSession: { id: session.id }, checkoutInvoice: { id: session.invoice.id }, discounts: (session.discounts ?? []).map(discount => ({ code: discount.code, amountFiat: discount.amountFiat })), lines: (session.lines ?? []).map(line => this.mapCheckoutLine(line, stockClaimsByLineId)) }); } private mapCheckoutLine(source: CheckoutSessionLine, stockClaimsByLineId: Map) { const claim = stockClaimsByLineId.get(source.id); const isManualStockClaim = source.deliveryMode === DeliveryMode.Manual && claim && isPreparedManualStockClaim(claim); const isDigitalStockClaim = source.deliveryMode === DeliveryMode.Auto && claim && isPreparedDigitalStockClaim(claim); return { variantId: source.variantId, productId: source.productId, productTitle: source.productTitle, variantTitle: source.variantTitle, thumbnailUrl: source.thumbnailUrl, qty: source.qty, unitPriceFiat: source.unitPriceFiat, lineSubtotalFiat: source.lineSubtotalFiat, deliveryMode: source.deliveryMode, ...(isManualStockClaim ? { manualFulfillment: { status: ManualLineFulfillmentStatus.Pending } } : {}), ...(isDigitalStockClaim ? { autoFulfillmentItems: claim.items.map((item, index) => ({ sortOrder: index, contentSnapshot: item.content, sourceDigitalStockItemId: item.id, attachments: (item.attachments ?? []).map(attachment => ({ storageKey: attachment.storageKey, sourceDigitalStockAttachmentId: attachment.id, originalFilename: attachment.originalFilename, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes })) })) } : {}) }; } }