Files
nullcart/backend/src/modules/order/services/OrderCreationService.ts
T

163 lines
6.6 KiB
TypeScript

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<void> {
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<string, PreparedStockClaim>) {
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
}))
}))
}
: {})
};
}
}