163 lines
6.6 KiB
TypeScript
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
|
|
}))
|
|
}))
|
|
}
|
|
: {})
|
|
};
|
|
}
|
|
}
|