init
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { EncryptionService } from '../../encryption/services/EncryptionService';
|
||||
import { OrderAccessTokenService } from './OrderAccessTokenService';
|
||||
|
||||
describe('OrderAccessTokenService', () => {
|
||||
let service: OrderAccessTokenService;
|
||||
|
||||
beforeEach(() => {
|
||||
const encryptionService = new EncryptionService({
|
||||
get: jest.fn().mockReturnValue({ keyBase64: randomBytes(32).toString('base64') })
|
||||
} as unknown as ConfigService);
|
||||
|
||||
service = new OrderAccessTokenService(encryptionService);
|
||||
});
|
||||
|
||||
it('generates a formatted token with lookup and encrypted storage', () => {
|
||||
const generated = service.generate();
|
||||
|
||||
expect(generated.token).toMatch(/^[0-9A-F]{8}(?:-[0-9A-F]{8}){3}$/);
|
||||
expect(generated.lookup).toHaveLength(64);
|
||||
expect(service.decryptStored(generated.encrypted)).toBe(generated.token);
|
||||
});
|
||||
|
||||
it('sets lookup to the hash of the generated token', () => {
|
||||
const generated = service.generate();
|
||||
|
||||
expect(service.hashLookup(generated.token)).toBe(generated.lookup);
|
||||
});
|
||||
|
||||
it('generates distinct tokens on each call', () => {
|
||||
const first = service.generate();
|
||||
const second = service.generate();
|
||||
|
||||
expect(first.token).not.toBe(second.token);
|
||||
expect(first.lookup).not.toBe(second.lookup);
|
||||
});
|
||||
|
||||
it('does not store the plaintext token in the encrypted blob', () => {
|
||||
const generated = service.generate();
|
||||
const normalized = generated.token.replace(/-/g, '');
|
||||
|
||||
expect(generated.encrypted).not.toContain(generated.token);
|
||||
expect(generated.encrypted).not.toContain(normalized);
|
||||
});
|
||||
|
||||
it('supports auth lookup from a decrypted stored token', () => {
|
||||
const generated = service.generate();
|
||||
const fromStorage = service.decryptStored(generated.encrypted);
|
||||
|
||||
expect(service.hashLookup(fromStorage)).toBe(generated.lookup);
|
||||
});
|
||||
|
||||
it('hashes lookup deterministically regardless of dashes and case', () => {
|
||||
const lookupA = service.hashLookup('12345678-90ABCDEF-12345678-90ABCDEF');
|
||||
const lookupB = service.hashLookup('1234567890abcdef1234567890abcdef');
|
||||
|
||||
expect(lookupA).toBe(lookupB);
|
||||
});
|
||||
|
||||
it('hashes different tokens to different lookups', () => {
|
||||
const lookupA = service.hashLookup('12345678901234567890123456789012');
|
||||
const lookupB = service.hashLookup('FEDCBA0987654321FEDCBA0987654321');
|
||||
|
||||
expect(lookupA).not.toBe(lookupB);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { EncryptionService } from '../../encryption/services/EncryptionService';
|
||||
import type { GeneratedAccessToken } from '../types/GeneratedAccessToken';
|
||||
|
||||
@Injectable()
|
||||
export class OrderAccessTokenService {
|
||||
private readonly tokenByteLength = 16;
|
||||
private readonly tokenGroupLength = 8;
|
||||
|
||||
constructor(private readonly encryptionService: EncryptionService) {}
|
||||
|
||||
generate(): GeneratedAccessToken {
|
||||
const normalized = randomBytes(this.tokenByteLength).toString('hex').toUpperCase();
|
||||
const token = this.formatToken(normalized);
|
||||
|
||||
return {
|
||||
token,
|
||||
lookup: this.hashLookup(token),
|
||||
encrypted: this.encryptToken(token)
|
||||
};
|
||||
}
|
||||
|
||||
private formatToken(normalized: string): string {
|
||||
const groups = normalized.match(new RegExp(`.{1,${this.tokenGroupLength}}`, 'g'));
|
||||
|
||||
if (!groups || groups.length !== 4) {
|
||||
throw new Error('Invalid normalized access token length');
|
||||
}
|
||||
|
||||
return groups.join('-');
|
||||
}
|
||||
|
||||
hashLookup(token: string): string {
|
||||
const normalized = this.normalizeToken(token);
|
||||
|
||||
return createHash('sha256').update(normalized, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
private encryptToken(token: string): string {
|
||||
const normalized = this.normalizeToken(token);
|
||||
|
||||
return this.encryptionService.encryptPlaintext(normalized);
|
||||
}
|
||||
|
||||
private normalizeToken(token: string): string {
|
||||
return token.replace(/-/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
decryptStored(storedAccessToken: string): string {
|
||||
const normalized = this.encryptionService.decryptPlaintext(storedAccessToken);
|
||||
|
||||
return this.formatToken(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
import type { EncryptionService } from '../../encryption/services/EncryptionService';
|
||||
import type { NotificationService } from '../../notifications/services/NotificationService';
|
||||
import type { Order } from '../entities/Order';
|
||||
import type { OrderMessage } from '../entities/OrderMessage';
|
||||
import { OrderChatService } from './OrderChatService';
|
||||
import { OrderMessageSender } from '../types/OrderMessageSender';
|
||||
|
||||
describe('OrderChatService', () => {
|
||||
let orderRepo: {
|
||||
exists: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let messageRepo: {
|
||||
find: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
insert: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
let encryptionService: jest.Mocked<
|
||||
Pick<EncryptionService, 'encryptPlaintext' | 'decryptPlaintext' | 'decryptPlaintextFieldInPlace'>
|
||||
>;
|
||||
let notificationService: jest.Mocked<Pick<NotificationService, 'sendNotification'>>;
|
||||
let service: OrderChatService;
|
||||
|
||||
const decryptedMessages: OrderMessage[] = [
|
||||
{
|
||||
id: 'message-1',
|
||||
sender: OrderMessageSender.Buyer,
|
||||
body: 'Hello there',
|
||||
createdAt: new Date('2026-01-01T12:00:00.000Z')
|
||||
} as OrderMessage
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
orderRepo = {
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 })
|
||||
};
|
||||
|
||||
messageRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'message-1',
|
||||
sender: OrderMessageSender.Buyer,
|
||||
body: 'serialized-body',
|
||||
createdAt: new Date('2026-01-01T12:00:00.000Z')
|
||||
}
|
||||
]),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(
|
||||
(entity): OrderMessage =>
|
||||
({
|
||||
id: 'message-2',
|
||||
createdAt: new Date('2026-01-02T12:00:00.000Z'),
|
||||
...entity
|
||||
}) as OrderMessage
|
||||
),
|
||||
insert: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
|
||||
encryptionService = {
|
||||
encryptPlaintext: jest.fn().mockReturnValue('serialized-body'),
|
||||
decryptPlaintext: jest.fn().mockReturnValue('Hello there'),
|
||||
decryptPlaintextFieldInPlace: jest.fn((messages, field) => {
|
||||
for (const message of messages ?? []) {
|
||||
(message as Record<string, string>)[field as string] = 'Hello there';
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
notificationService = {
|
||||
sendNotification: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
service = new OrderChatService(
|
||||
orderRepo as unknown as Repository<Order>,
|
||||
messageRepo as unknown as Repository<OrderMessage>,
|
||||
encryptionService as unknown as EncryptionService,
|
||||
notificationService as unknown as NotificationService
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a message and returns the full decrypted thread', async () => {
|
||||
await expect(service.createMessage('order-1', OrderMessageSender.Buyer, 'Hello there')).resolves.toEqual(
|
||||
decryptedMessages
|
||||
);
|
||||
|
||||
expect(orderRepo.exists).toHaveBeenCalledWith({ where: { id: 'order-1' } });
|
||||
expect(messageRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
order: { id: 'order-1' },
|
||||
sender: OrderMessageSender.Buyer,
|
||||
body: 'serialized-body'
|
||||
})
|
||||
);
|
||||
expect(messageRepo.insert).toHaveBeenCalled();
|
||||
expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newBuyerMessage');
|
||||
expect(messageRepo.find).toHaveBeenCalledWith({
|
||||
where: { order: { id: 'order-1' } },
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when creating a message for a missing order', async () => {
|
||||
orderRepo.exists.mockResolvedValue(false);
|
||||
|
||||
await expect(service.createMessage('order-1', OrderMessageSender.Buyer, 'Hello there')).rejects.toBeInstanceOf(
|
||||
NotFoundException
|
||||
);
|
||||
|
||||
expect(messageRepo.insert).not.toHaveBeenCalled();
|
||||
expect(notificationService.sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes a message and returns the full decrypted thread', async () => {
|
||||
messageRepo.findOne.mockResolvedValue({
|
||||
id: 'message-1',
|
||||
sender: OrderMessageSender.Buyer
|
||||
});
|
||||
|
||||
await expect(service.deleteMessage('order-1', 'message-1', OrderMessageSender.Buyer)).resolves.toEqual(
|
||||
decryptedMessages
|
||||
);
|
||||
|
||||
expect(messageRepo.delete).toHaveBeenCalledWith('message-1');
|
||||
expect(messageRepo.find).toHaveBeenCalledWith({
|
||||
where: { order: { id: 'order-1' } },
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when deleting a missing message', async () => {
|
||||
messageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deleteMessage('order-1', 'message-1', OrderMessageSender.Buyer)).rejects.toBeInstanceOf(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it('counts unread buyer messages after staffChatLastReadAt', () => {
|
||||
const readAt = new Date('2026-01-02T12:00:00.000Z');
|
||||
|
||||
expect(
|
||||
service.countUnreadBuyerMessages({
|
||||
staffChatLastReadAt: readAt,
|
||||
messages: [
|
||||
{
|
||||
sender: OrderMessageSender.Buyer,
|
||||
createdAt: new Date('2026-01-01T12:00:00.000Z')
|
||||
} as OrderMessage,
|
||||
{
|
||||
sender: OrderMessageSender.Buyer,
|
||||
createdAt: new Date('2026-01-03T12:00:00.000Z')
|
||||
} as OrderMessage,
|
||||
{
|
||||
sender: OrderMessageSender.Staff,
|
||||
createdAt: new Date('2026-01-04T12:00:00.000Z')
|
||||
} as OrderMessage
|
||||
]
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('counts all buyer messages as unread when staffChatLastReadAt is null', () => {
|
||||
expect(
|
||||
service.countUnreadBuyerMessages({
|
||||
staffChatLastReadAt: null,
|
||||
messages: [
|
||||
{
|
||||
sender: OrderMessageSender.Buyer,
|
||||
createdAt: new Date('2026-01-01T12:00:00.000Z')
|
||||
} as OrderMessage
|
||||
]
|
||||
})
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('marks chat as read for an existing order', async () => {
|
||||
await service.markChatRead('order-1');
|
||||
|
||||
expect(orderRepo.exists).toHaveBeenCalledWith({ where: { id: 'order-1' } });
|
||||
expect(orderRepo.update).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [orderId, payload] = orderRepo.update.mock.calls[0] as [string, { staffChatLastReadAt: Date }];
|
||||
|
||||
expect(orderId).toBe('order-1');
|
||||
expect(payload.staffChatLastReadAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('throws when marking chat read for a missing order', async () => {
|
||||
orderRepo.exists.mockResolvedValue(false);
|
||||
|
||||
await expect(service.markChatRead('order-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(orderRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import { EncryptionService } from '../../encryption/services/EncryptionService';
|
||||
import { NotificationService } from '../../notifications/services/NotificationService';
|
||||
import { Order } from '../entities/Order';
|
||||
import { OrderMessage } from '../entities/OrderMessage';
|
||||
import { OrderMessageSender } from '../types/OrderMessageSender';
|
||||
|
||||
@Injectable()
|
||||
export class OrderChatService {
|
||||
constructor(
|
||||
@InjectRepository(Order)
|
||||
private readonly orderRepo: Repository<Order>,
|
||||
@InjectRepository(OrderMessage)
|
||||
private readonly messageRepo: Repository<OrderMessage>,
|
||||
private readonly encryptionService: EncryptionService,
|
||||
private readonly notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
async listMessagesForOrder(orderId: string): Promise<OrderMessage[]> {
|
||||
const messages = await this.messageRepo.find({
|
||||
where: { order: { id: orderId } },
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
this.encryptionService.decryptPlaintextFieldInPlace(messages, 'body');
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
countUnreadBuyerMessages(order: Pick<Order, 'staffChatLastReadAt' | 'messages'>): number {
|
||||
const readAfter = order.staffChatLastReadAt ? dayjs(order.staffChatLastReadAt) : dayjs(0);
|
||||
|
||||
return (order.messages ?? []).filter(
|
||||
message => message.sender === OrderMessageSender.Buyer && dayjs(message.createdAt).isAfter(readAfter)
|
||||
).length;
|
||||
}
|
||||
|
||||
async markChatRead(orderId: string): Promise<void> {
|
||||
const orderExists = await this.orderRepo.exists({ where: { id: orderId } });
|
||||
|
||||
if (!orderExists) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
await this.orderRepo.update(orderId, { staffChatLastReadAt: new Date() });
|
||||
}
|
||||
|
||||
async createMessage(orderId: string, sender: OrderMessageSender, body: string): Promise<OrderMessage[]> {
|
||||
const orderExists = await this.orderRepo.exists({ where: { id: orderId } });
|
||||
|
||||
if (!orderExists) {
|
||||
throw new NotFoundException('Order not found');
|
||||
}
|
||||
|
||||
const entity = this.messageRepo.create({
|
||||
order: { id: orderId },
|
||||
sender,
|
||||
body: this.encryptionService.encryptPlaintext(body)
|
||||
});
|
||||
|
||||
await this.messageRepo.insert(entity);
|
||||
|
||||
if (sender === OrderMessageSender.Buyer) {
|
||||
this.notificationService.sendNotification(orderId, 'newBuyerMessage');
|
||||
}
|
||||
|
||||
return this.listMessagesForOrder(orderId);
|
||||
}
|
||||
|
||||
async deleteMessage(orderId: string, messageId: string, sender: OrderMessageSender): Promise<OrderMessage[]> {
|
||||
const message = await this.messageRepo.findOne({
|
||||
where: {
|
||||
id: messageId,
|
||||
order: { id: orderId },
|
||||
sender
|
||||
}
|
||||
});
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('We could not find that message.');
|
||||
}
|
||||
|
||||
await this.messageRepo.delete(message.id);
|
||||
|
||||
return this.listMessagesForOrder(orderId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, MoreThanOrEqual } from 'typeorm';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import { DigitalStockItem } from '../../product/entities/DigitalStockItem';
|
||||
import type { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine';
|
||||
import { DiscountCode } from '../../discountCode/entities/DiscountCode';
|
||||
import { ProductVariant } from '../../product/entities/ProductVariant';
|
||||
import { OrderFailureReason } from '../types/OrderFailureReason';
|
||||
import type { DigitalStockItemRepoMock, DiscountCodeRepoMock, VariantRepoMock } from '../types/OrderClaimServiceMocks';
|
||||
import type { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import { OrderClaimService } from './OrderClaimService';
|
||||
|
||||
const buildAutoLine = (overrides: Partial<CheckoutSessionLine> = {}): CheckoutSessionLine =>
|
||||
({
|
||||
id: 'line-auto-1',
|
||||
variantId: 'variant-auto-1',
|
||||
qty: 1,
|
||||
deliveryMode: DeliveryMode.Auto,
|
||||
...overrides
|
||||
}) as unknown as CheckoutSessionLine;
|
||||
|
||||
const buildManualLine = (overrides: Partial<CheckoutSessionLine> = {}): CheckoutSessionLine =>
|
||||
({
|
||||
id: 'line-manual-1',
|
||||
variantId: 'variant-manual-1',
|
||||
qty: 1,
|
||||
deliveryMode: DeliveryMode.Manual,
|
||||
...overrides
|
||||
}) as unknown as CheckoutSessionLine;
|
||||
|
||||
const mockDigitalStockItemsLoad = (
|
||||
digitalStockItemRepo: DigitalStockItemRepoMock,
|
||||
items: Array<{ id: string; content: string; attachments: unknown[] }>,
|
||||
options: { hydratedItems?: Array<{ id: string; content: string; attachments: unknown[] }> } = {}
|
||||
) => {
|
||||
const hydratedItems = options.hydratedItems ?? items;
|
||||
|
||||
const idQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue(items.map(item => ({ id: item.id })))
|
||||
};
|
||||
|
||||
const loadQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(hydratedItems)
|
||||
};
|
||||
|
||||
let callCount = 0;
|
||||
|
||||
digitalStockItemRepo.createQueryBuilder.mockImplementation(() => {
|
||||
callCount += 1;
|
||||
|
||||
return callCount === 1 ? idQueryBuilder : loadQueryBuilder;
|
||||
});
|
||||
|
||||
return { idQueryBuilder, loadQueryBuilder };
|
||||
};
|
||||
|
||||
describe('OrderClaimService', () => {
|
||||
let service: OrderClaimService;
|
||||
let digitalStockItemRepo: DigitalStockItemRepoMock;
|
||||
let variantRepo: VariantRepoMock;
|
||||
let discountCodeRepo: DiscountCodeRepoMock;
|
||||
let manager: EntityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
digitalStockItemRepo = {
|
||||
find: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createQueryBuilder: jest.fn()
|
||||
};
|
||||
variantRepo = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn()
|
||||
};
|
||||
discountCodeRepo = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn()
|
||||
};
|
||||
|
||||
manager = {
|
||||
getRepository: jest.fn((entity: { name: string }) => {
|
||||
if (entity.name === DigitalStockItem.name) {
|
||||
return digitalStockItemRepo;
|
||||
}
|
||||
|
||||
if (entity.name === ProductVariant.name) {
|
||||
return variantRepo;
|
||||
}
|
||||
|
||||
if (entity.name === DiscountCode.name) {
|
||||
return discountCodeRepo;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected repository: ${entity.name}`);
|
||||
})
|
||||
} as unknown as EntityManager;
|
||||
|
||||
service = new OrderClaimService();
|
||||
});
|
||||
|
||||
describe('auto-delivery lines', () => {
|
||||
it('claims stock and returns digital stock claims', async () => {
|
||||
mockDigitalStockItemsLoad(digitalStockItemRepo, [
|
||||
{
|
||||
id: 'item-1',
|
||||
content: 'username: buyer\npassword: secret',
|
||||
attachments: []
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
content: 'license: ABC-123',
|
||||
attachments: [
|
||||
{
|
||||
id: 'stock-attachment-1',
|
||||
storageKey: 'attachments/item-2/file.pdf',
|
||||
originalFilename: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
const line = buildAutoLine({ id: 'line-1', variantId: 'variant-1', qty: 2 });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-1',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
content: 'username: buyer\npassword: secret',
|
||||
attachments: []
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
content: 'license: ABC-123',
|
||||
attachments: [
|
||||
{
|
||||
id: 'stock-attachment-1',
|
||||
storageKey: 'attachments/item-2/file.pdf',
|
||||
originalFilename: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalled();
|
||||
expect(digitalStockItemRepo.update).toHaveBeenCalledWith(
|
||||
{ id: In(['item-1', 'item-2']) },
|
||||
{ isSold: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns stock unavailable when locked stock is insufficient', async () => {
|
||||
mockDigitalStockItemsLoad(digitalStockItemRepo, [{ id: 'item-1', content: '', attachments: [] }]);
|
||||
|
||||
const line = buildAutoLine({ qty: 2 });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
expect(digitalStockItemRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks item ids with delivery mode check, then hydrates by id without attachments', async () => {
|
||||
const { idQueryBuilder, loadQueryBuilder } = mockDigitalStockItemsLoad(digitalStockItemRepo, [
|
||||
{ id: 'item-1', content: 'key', attachments: [] }
|
||||
]);
|
||||
|
||||
const line = buildAutoLine({ variantId: 'variant-1', qty: 1 });
|
||||
|
||||
await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(idQueryBuilder.select).toHaveBeenCalledWith('item.id', 'id');
|
||||
expect(idQueryBuilder.innerJoin).toHaveBeenCalledWith('item.variant', 'variant');
|
||||
expect(idQueryBuilder.innerJoin).toHaveBeenCalledWith('variant.product', 'product');
|
||||
expect(idQueryBuilder.where).toHaveBeenCalledWith('variant.id = :variantId', {
|
||||
variantId: 'variant-1'
|
||||
});
|
||||
expect(idQueryBuilder.andWhere).toHaveBeenCalledWith('product.deliveryMode = :deliveryMode', {
|
||||
deliveryMode: DeliveryMode.Auto
|
||||
});
|
||||
expect(idQueryBuilder.andWhere).toHaveBeenCalledWith('item.isSold = false');
|
||||
expect(idQueryBuilder.limit).toHaveBeenCalledWith(1);
|
||||
expect(idQueryBuilder.setLock).toHaveBeenCalledWith('pessimistic_write', undefined, ['item']);
|
||||
expect(idQueryBuilder.getRawMany).toHaveBeenCalled();
|
||||
expect(loadQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('item.attachments', 'attachment');
|
||||
expect(loadQueryBuilder.where).toHaveBeenCalledWith('item.id IN (:...ids)', { ids: ['item-1'] });
|
||||
expect(loadQueryBuilder.getMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claims full qty when each item has multiple attachments', async () => {
|
||||
const attachment = {
|
||||
id: 'attachment-1',
|
||||
storageKey: 'attachments/item/file.pdf',
|
||||
originalFilename: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 512
|
||||
};
|
||||
|
||||
mockDigitalStockItemsLoad(digitalStockItemRepo, [
|
||||
{ id: 'item-1', content: 'line-1', attachments: [attachment, { ...attachment, id: 'attachment-2' }] },
|
||||
{ id: 'item-2', content: 'line-2', attachments: [attachment, { ...attachment, id: 'attachment-3' }] }
|
||||
]);
|
||||
|
||||
const line = buildAutoLine({ qty: 2 });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-auto-1',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
content: 'line-1',
|
||||
attachments: [attachment, { ...attachment, id: 'attachment-2' }]
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
content: 'line-2',
|
||||
attachments: [attachment, { ...attachment, id: 'attachment-3' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(digitalStockItemRepo.update).toHaveBeenCalledWith(
|
||||
{ id: In(['item-1', 'item-2']) },
|
||||
{ isSold: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('returns stock unavailable when lock returns more ids than qty', async () => {
|
||||
const { idQueryBuilder } = mockDigitalStockItemsLoad(digitalStockItemRepo, [
|
||||
{ id: 'item-1', content: '', attachments: [] },
|
||||
{ id: 'item-2', content: '', attachments: [] },
|
||||
{ id: 'item-3', content: '', attachments: [] }
|
||||
]);
|
||||
idQueryBuilder.getRawMany.mockResolvedValue([
|
||||
{ id: 'item-1' },
|
||||
{ id: 'item-2' },
|
||||
{ id: 'item-3' }
|
||||
]);
|
||||
|
||||
const line = buildAutoLine({ qty: 2 });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledTimes(1);
|
||||
expect(digitalStockItemRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns stock unavailable when hydrate returns fewer items than qty', async () => {
|
||||
mockDigitalStockItemsLoad(
|
||||
digitalStockItemRepo,
|
||||
[
|
||||
{ id: 'item-1', content: '', attachments: [] },
|
||||
{ id: 'item-2', content: '', attachments: [] }
|
||||
],
|
||||
{
|
||||
hydratedItems: [{ id: 'item-1', content: '', attachments: [] }]
|
||||
}
|
||||
);
|
||||
|
||||
const line = buildAutoLine({ qty: 2 });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
expect(digitalStockItemRepo.createQueryBuilder).toHaveBeenCalledTimes(2);
|
||||
expect(digitalStockItemRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual-delivery lines', () => {
|
||||
it('returns manual stock claims and decrements variant stock', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 });
|
||||
|
||||
const line = buildManualLine({ qty: 2 });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-manual-1',
|
||||
variantId: 'variant-manual-1',
|
||||
newStockQuantity: 3
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(variantRepo.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'variant-manual-1',
|
||||
product: { deliveryMode: DeliveryMode.Manual },
|
||||
stockQuantity: MoreThanOrEqual(2)
|
||||
},
|
||||
lock: { mode: 'pessimistic_write' }
|
||||
});
|
||||
expect(variantRepo.update).toHaveBeenCalledWith('variant-manual-1', { stockQuantity: 3 });
|
||||
});
|
||||
|
||||
it('returns stock unavailable when manual variant stock is insufficient', async () => {
|
||||
variantRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const line = buildManualLine();
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
expect(variantRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('discount redeems', () => {
|
||||
it('increments discount redemption count after stock is prepared', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 });
|
||||
discountCodeRepo.findOne.mockResolvedValue({
|
||||
id: 'discount-1',
|
||||
code: 'SAVE10',
|
||||
redemptionCount: 2,
|
||||
maxRedemptions: 10
|
||||
});
|
||||
|
||||
const line = buildManualLine();
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: [{ code: 'SAVE10' }]
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(discountCodeRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { code: 'SAVE10' },
|
||||
lock: { mode: 'pessimistic_write' }
|
||||
});
|
||||
expect(discountCodeRepo.update).toHaveBeenCalledWith('discount-1', { redemptionCount: 3 });
|
||||
});
|
||||
|
||||
it('returns discount exhausted when the code is missing', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 });
|
||||
discountCodeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const line = buildManualLine();
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: [{ code: 'MISSING' }]
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.DiscountExhausted
|
||||
});
|
||||
expect(variantRepo.update).not.toHaveBeenCalled();
|
||||
expect(discountCodeRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns discount exhausted when the code reached its redemption limit', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 });
|
||||
discountCodeRepo.findOne.mockResolvedValue({
|
||||
id: 'discount-1',
|
||||
code: 'MAXED',
|
||||
redemptionCount: 5,
|
||||
maxRedemptions: 5
|
||||
});
|
||||
|
||||
const line = buildManualLine();
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: [{ code: 'MAXED' }]
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.DiscountExhausted
|
||||
});
|
||||
expect(discountCodeRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session validation and mixed carts', () => {
|
||||
it('returns stock unavailable when the session has no lines', async () => {
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
expect(digitalStockItemRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
expect(variantRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claims stock for mixed manual and auto lines in one session', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 4 });
|
||||
mockDigitalStockItemsLoad(digitalStockItemRepo, [
|
||||
{
|
||||
id: 'item-1',
|
||||
content: 'license-key',
|
||||
attachments: []
|
||||
}
|
||||
]);
|
||||
|
||||
const manualLine = buildManualLine();
|
||||
const autoLine = buildAutoLine({ id: 'line-auto-2', variantId: 'variant-auto-2' });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [autoLine, manualLine],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-auto-2',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
content: 'license-key',
|
||||
attachments: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
checkoutSessionLineId: 'line-manual-1',
|
||||
variantId: 'variant-manual-1',
|
||||
newStockQuantity: 3
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(variantRepo.update).toHaveBeenCalledWith('variant-manual-1', { stockQuantity: 3 });
|
||||
expect(digitalStockItemRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply stock when a later line fails preparation', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 });
|
||||
mockDigitalStockItemsLoad(digitalStockItemRepo, []);
|
||||
|
||||
const manualLine = buildManualLine();
|
||||
const autoLine = buildAutoLine({ id: 'line-auto-2', variantId: 'variant-auto-2' });
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [manualLine, autoLine],
|
||||
discounts: []
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
expect(variantRepo.update).not.toHaveBeenCalled();
|
||||
expect(digitalStockItemRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not apply stock when discount preparation fails after stock prepared', async () => {
|
||||
variantRepo.findOne.mockResolvedValue({ id: 'variant-manual-1', stockQuantity: 5 });
|
||||
discountCodeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const line = buildManualLine();
|
||||
|
||||
const result = await service.claimFromSession(manager, {
|
||||
lines: [line],
|
||||
discounts: [{ code: 'MISSING' }]
|
||||
} as unknown as CheckoutSession);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.DiscountExhausted
|
||||
});
|
||||
expect(variantRepo.update).not.toHaveBeenCalled();
|
||||
expect(digitalStockItemRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, MoreThanOrEqual } from 'typeorm';
|
||||
import { DiscountCode } from '../../discountCode/entities/DiscountCode';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import { DigitalStockItem } from '../../product/entities/DigitalStockItem';
|
||||
import { ProductVariant } from '../../product/entities/ProductVariant';
|
||||
import { getRedemptionLimitIssue } from '../../storefrontCart/utils/getRedemptionLimitIssue';
|
||||
import type { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import type { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine';
|
||||
import type { ClaimFromSessionResult } from '../types/ClaimFromSessionResult';
|
||||
import { OrderFailureReason } from '../types/OrderFailureReason';
|
||||
import type { PreparedDiscountRedeem } from '../types/PreparedDiscountRedeem';
|
||||
import type { PreparedStockClaim } from '../types/PreparedStockClaim';
|
||||
|
||||
@Injectable()
|
||||
export class OrderClaimService {
|
||||
async claimFromSession(
|
||||
manager: EntityManager,
|
||||
{ lines, discounts }: CheckoutSession
|
||||
): Promise<ClaimFromSessionResult> {
|
||||
if (lines.length === 0) {
|
||||
return { success: false, failureReason: OrderFailureReason.StockUnavailable };
|
||||
}
|
||||
|
||||
const stockClaims: PreparedStockClaim[] = [];
|
||||
const antiDeadlockSortedLines = [...lines].sort((a, b) => a.variantId.localeCompare(b.variantId));
|
||||
|
||||
for (const line of antiDeadlockSortedLines) {
|
||||
const prepared = await this.prepareStockClaim(manager, line);
|
||||
|
||||
if (!prepared) {
|
||||
return { success: false, failureReason: OrderFailureReason.StockUnavailable };
|
||||
}
|
||||
|
||||
stockClaims.push(prepared);
|
||||
}
|
||||
|
||||
const discountRedeems: PreparedDiscountRedeem[] = [];
|
||||
const antiDeadlockSortedDiscounts = [...discounts].sort((a, b) => a.code.localeCompare(b.code));
|
||||
|
||||
for (const discount of antiDeadlockSortedDiscounts) {
|
||||
const prepared = await this.prepareDiscountRedeem(manager, discount.code);
|
||||
|
||||
if (!prepared) {
|
||||
return { success: false, failureReason: OrderFailureReason.DiscountExhausted };
|
||||
}
|
||||
|
||||
discountRedeems.push(prepared);
|
||||
}
|
||||
|
||||
await this.applyStockClaims(manager, stockClaims);
|
||||
await this.applyDiscountRedeems(manager, discountRedeems);
|
||||
|
||||
return { success: true, stockClaims };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-delivery: lock the oldest unsold digital stock rows first (IDs only), then load content
|
||||
* and attachments in a second query.
|
||||
*
|
||||
* The lock query inner-joins variant/product (many-to-one) to verify deliveryMode at claim
|
||||
* time. With getRawMany(), use limit() when joins are present — take()/skip() target entity
|
||||
* pagination and are omitted from SQL on raw queries with joins. Do not join one-to-many
|
||||
* relations (attachments) in the limited query; row multiplication returns fewer parents than
|
||||
* qty.
|
||||
*
|
||||
* @see https://github.com/typeorm/typeorm/issues/11590#issuecomment-3166485348
|
||||
* @see https://github.com/typeorm/typeorm/issues/11316#issuecomment-2074916139
|
||||
*/
|
||||
private async prepareStockClaim(
|
||||
manager: EntityManager,
|
||||
line: CheckoutSessionLine
|
||||
): Promise<PreparedStockClaim | null> {
|
||||
const variantRepo = manager.getRepository(ProductVariant);
|
||||
const digitalStockItemRepo = manager.getRepository(DigitalStockItem);
|
||||
|
||||
if (line.deliveryMode === DeliveryMode.Manual) {
|
||||
const variant = await variantRepo.findOne({
|
||||
where: {
|
||||
id: line.variantId,
|
||||
product: { deliveryMode: DeliveryMode.Manual },
|
||||
stockQuantity: MoreThanOrEqual(line.qty)
|
||||
},
|
||||
lock: { mode: 'pessimistic_write' }
|
||||
});
|
||||
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
checkoutSessionLineId: line.id,
|
||||
variantId: variant.id,
|
||||
newStockQuantity: variant.stockQuantity! - line.qty
|
||||
};
|
||||
}
|
||||
|
||||
const digitalStockItemIds = await digitalStockItemRepo
|
||||
.createQueryBuilder('item')
|
||||
.select('item.id', 'id')
|
||||
.innerJoin('item.variant', 'variant')
|
||||
.innerJoin('variant.product', 'product')
|
||||
.where('variant.id = :variantId', { variantId: line.variantId })
|
||||
.andWhere('product.deliveryMode = :deliveryMode', { deliveryMode: DeliveryMode.Auto })
|
||||
.andWhere('item.isSold = false')
|
||||
.orderBy('item.createdAt', 'ASC')
|
||||
.limit(line.qty)
|
||||
.setLock('pessimistic_write', undefined, ['item'])
|
||||
.getRawMany<{ id: string }>();
|
||||
|
||||
if (digitalStockItemIds.length !== line.qty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ids = digitalStockItemIds.map(row => row.id);
|
||||
|
||||
const digitalStockItemsWithRelations = await digitalStockItemRepo
|
||||
.createQueryBuilder('item')
|
||||
.leftJoinAndSelect('item.attachments', 'attachment')
|
||||
.addSelect('item.content')
|
||||
.addSelect('attachment.storageKey')
|
||||
.where('item.id IN (:...ids)', { ids })
|
||||
.orderBy('item.createdAt', 'ASC')
|
||||
.addOrderBy('attachment.createdAt', 'ASC')
|
||||
.getMany();
|
||||
|
||||
if (digitalStockItemsWithRelations.length !== line.qty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
checkoutSessionLineId: line.id,
|
||||
items: digitalStockItemsWithRelations
|
||||
};
|
||||
}
|
||||
|
||||
private async prepareDiscountRedeem(manager: EntityManager, code: string): Promise<PreparedDiscountRedeem | null> {
|
||||
const discountCodeRepo = manager.getRepository(DiscountCode);
|
||||
|
||||
const discountCode = await discountCodeRepo.findOne({
|
||||
where: { code },
|
||||
lock: { mode: 'pessimistic_write' }
|
||||
});
|
||||
|
||||
if (!discountCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issue = getRedemptionLimitIssue(discountCode.redemptionCount, discountCode.maxRedemptions);
|
||||
|
||||
if (issue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
discountCodeId: discountCode.id,
|
||||
newRedemptionCount: discountCode.redemptionCount + 1
|
||||
};
|
||||
}
|
||||
|
||||
private async applyStockClaims(manager: EntityManager, stockClaims: PreparedStockClaim[]): Promise<void> {
|
||||
const variantRepo = manager.getRepository(ProductVariant);
|
||||
const digitalStockItemRepo = manager.getRepository(DigitalStockItem);
|
||||
|
||||
for (const claim of stockClaims) {
|
||||
if ('newStockQuantity' in claim) {
|
||||
await variantRepo.update(claim.variantId, { stockQuantity: claim.newStockQuantity });
|
||||
} else {
|
||||
await digitalStockItemRepo.update({ id: In(claim.items.map(item => item.id)) }, { isSold: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyDiscountRedeems(
|
||||
manager: EntityManager,
|
||||
discountRedeems: PreparedDiscountRedeem[]
|
||||
): Promise<void> {
|
||||
const discountCodeRepo = manager.getRepository(DiscountCode);
|
||||
|
||||
for (const redeem of discountRedeems) {
|
||||
await discountCodeRepo.update(redeem.discountCodeId, {
|
||||
redemptionCount: redeem.newRedemptionCount
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import type { DataSource, EntityManager } from 'typeorm';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import type { CheckoutSessionLine } from '../../storefrontCheckout/entities/CheckoutSessionLine';
|
||||
import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import type { Invoice } from '../../payment/entities/Invoice';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import type { NotificationService } from '../../notifications/services/NotificationService';
|
||||
import { Order } from '../entities/Order';
|
||||
import { OrderFailureReason } from '../types/OrderFailureReason';
|
||||
import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus';
|
||||
import type { OrderAccessTokenService } from './OrderAccessTokenService';
|
||||
import type { OrderClaimService } from './OrderClaimService';
|
||||
import { OrderCreationService } from './OrderCreationService';
|
||||
|
||||
const buildPaidInvoice = () => ({
|
||||
id: 'invoice-1',
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
expectedTotalAtomic: '1000',
|
||||
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
|
||||
moneroDetails: { requiredConfirmations: 1 },
|
||||
payments: [{ amountAtomic: '1000', confirmations: 1 }]
|
||||
});
|
||||
|
||||
const buildManualLine = (overrides: Partial<CheckoutSessionLine> = {}): CheckoutSessionLine =>
|
||||
({
|
||||
id: 'line-manual-1',
|
||||
variantId: 'variant-manual-1',
|
||||
productId: 'product-1',
|
||||
productTitle: 'Manual product',
|
||||
variantTitle: 'Manual variant',
|
||||
thumbnailUrl: null,
|
||||
qty: 1,
|
||||
unitPriceFiat: 10,
|
||||
lineSubtotalFiat: 10,
|
||||
deliveryMode: DeliveryMode.Manual,
|
||||
...overrides
|
||||
}) as CheckoutSessionLine;
|
||||
|
||||
const buildAutoLine = (overrides: Partial<CheckoutSessionLine> = {}): CheckoutSessionLine =>
|
||||
({
|
||||
id: 'line-auto-1',
|
||||
variantId: 'variant-auto-1',
|
||||
productId: 'product-2',
|
||||
productTitle: 'Digital product',
|
||||
variantTitle: 'Digital variant',
|
||||
thumbnailUrl: '/thumb.png',
|
||||
qty: 1,
|
||||
unitPriceFiat: 20,
|
||||
lineSubtotalFiat: 20,
|
||||
deliveryMode: DeliveryMode.Auto,
|
||||
...overrides
|
||||
}) as CheckoutSessionLine;
|
||||
|
||||
const buildPaidSession = (overrides: Partial<CheckoutSession> = {}): CheckoutSession =>
|
||||
({
|
||||
id: 'session-1',
|
||||
invoice: buildPaidInvoice(),
|
||||
lines: [buildManualLine()],
|
||||
discounts: [{ code: 'SAVE10', amountFiat: 5 }],
|
||||
...overrides
|
||||
}) as CheckoutSession;
|
||||
|
||||
describe('OrderCreationService', () => {
|
||||
let service: OrderCreationService;
|
||||
let dataSource: {
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let sessionQueryBuilder: {
|
||||
leftJoinAndSelect: jest.Mock;
|
||||
leftJoin: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
setLock: jest.Mock;
|
||||
getOne: jest.Mock;
|
||||
};
|
||||
let sessionRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let orderRepo: {
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let manager: EntityManager;
|
||||
let orderClaimService: {
|
||||
claimFromSession: jest.Mock;
|
||||
};
|
||||
let accessTokenService: {
|
||||
generate: jest.Mock;
|
||||
};
|
||||
let notificationService: {
|
||||
sendNotification: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sessionQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue(null)
|
||||
};
|
||||
|
||||
sessionRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(sessionQueryBuilder)
|
||||
};
|
||||
|
||||
orderRepo = {
|
||||
create: jest.fn(data => ({ id: 'order-1', ...data })),
|
||||
save: jest.fn(async order => order)
|
||||
};
|
||||
|
||||
manager = {
|
||||
getRepository: jest.fn((entity: { name: string }) => {
|
||||
if (entity.name === CheckoutSession.name) {
|
||||
return sessionRepo;
|
||||
}
|
||||
|
||||
if (entity.name === Order.name) {
|
||||
return orderRepo;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected repository: ${entity.name}`);
|
||||
})
|
||||
} as unknown as EntityManager;
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback: (entityManager: EntityManager) => Promise<void>) => callback(manager))
|
||||
};
|
||||
|
||||
orderClaimService = {
|
||||
claimFromSession: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-manual-1',
|
||||
variantId: 'variant-manual-1',
|
||||
newStockQuantity: 4
|
||||
}
|
||||
]
|
||||
})
|
||||
};
|
||||
|
||||
accessTokenService = {
|
||||
generate: jest.fn().mockReturnValue({
|
||||
lookup: 'lookup-token',
|
||||
encrypted: 'encrypted-token'
|
||||
})
|
||||
};
|
||||
|
||||
notificationService = {
|
||||
sendNotification: jest.fn()
|
||||
};
|
||||
|
||||
service = new OrderCreationService(
|
||||
dataSource as unknown as DataSource,
|
||||
orderClaimService as unknown as OrderClaimService,
|
||||
accessTokenService as unknown as OrderAccessTokenService,
|
||||
notificationService as unknown as NotificationService
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create an order when the checkout session is missing', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(null);
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.save).not.toHaveBeenCalled();
|
||||
expect(notificationService.sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('only considers open sessions without an existing order and with a non-expired invoice', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(null);
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(sessionQueryBuilder.andWhere).toHaveBeenCalledWith('session.cancelledAt IS NULL');
|
||||
expect(sessionQueryBuilder.andWhere).toHaveBeenCalledWith('order.id IS NULL');
|
||||
expect(sessionQueryBuilder.andWhere).toHaveBeenCalledWith('invoice.expiresAt > :now', {
|
||||
now: expect.any(Date)
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create an order when the session has no invoice', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession({ invoice: undefined }));
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.save).not.toHaveBeenCalled();
|
||||
expect(notificationService.sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not create an order when the invoice is not paid sufficiently', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(
|
||||
buildPaidSession({
|
||||
invoice: {
|
||||
...buildPaidInvoice(),
|
||||
payments: [{ amountAtomic: '100', confirmations: 1 }]
|
||||
} as Invoice
|
||||
})
|
||||
);
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderClaimService.claimFromSession).not.toHaveBeenCalled();
|
||||
expect(orderRepo.save).not.toHaveBeenCalled();
|
||||
expect(notificationService.sendNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates an order and sends a notification when the session is paid and stock is claimed', async () => {
|
||||
const session = buildPaidSession();
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(session);
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderClaimService.claimFromSession).toHaveBeenCalledWith(manager, session);
|
||||
expect(accessTokenService.generate).toHaveBeenCalled();
|
||||
expect(orderRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessTokenLookup: 'lookup-token',
|
||||
accessToken: 'encrypted-token',
|
||||
failureReason: null,
|
||||
checkoutSession: { id: 'session-1' },
|
||||
checkoutInvoice: { id: 'invoice-1' },
|
||||
discounts: [{ code: 'SAVE10', amountFiat: 5 }],
|
||||
lines: [
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-manual-1',
|
||||
manualFulfillment: { status: ManualLineFulfillmentStatus.Pending }
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(orderRepo.save).toHaveBeenCalled();
|
||||
expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newOrder');
|
||||
});
|
||||
|
||||
it('creates a failed order when stock claim fails', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession());
|
||||
orderClaimService.claimFromSession.mockResolvedValue({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.StockUnavailable
|
||||
});
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
failureReason: OrderFailureReason.StockUnavailable,
|
||||
lines: [
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-manual-1'
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newOrder');
|
||||
});
|
||||
|
||||
|
||||
it('creates a failed order when discount redemption fails', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession());
|
||||
orderClaimService.claimFromSession.mockResolvedValue({
|
||||
success: false,
|
||||
failureReason: OrderFailureReason.DiscountExhausted
|
||||
});
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
failureReason: OrderFailureReason.DiscountExhausted
|
||||
})
|
||||
);
|
||||
expect(notificationService.sendNotification).toHaveBeenCalledWith('order-1', 'newOrder');
|
||||
});
|
||||
|
||||
it('maps mixed manual and auto lines in one order', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(
|
||||
buildPaidSession({
|
||||
lines: [buildManualLine(), buildAutoLine()]
|
||||
})
|
||||
);
|
||||
orderClaimService.claimFromSession.mockResolvedValue({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-manual-1',
|
||||
variantId: 'variant-manual-1',
|
||||
newStockQuantity: 4
|
||||
},
|
||||
{
|
||||
checkoutSessionLineId: 'line-auto-1',
|
||||
items: [{ id: 'stock-item-1', content: 'license-key', attachments: [] }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lines: [
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-manual-1',
|
||||
manualFulfillment: { status: ManualLineFulfillmentStatus.Pending }
|
||||
}),
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-auto-1',
|
||||
autoFulfillmentItems: [
|
||||
expect.objectContaining({
|
||||
contentSnapshot: 'license-key',
|
||||
attachments: []
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach fulfillment when a successful claim does not match the line', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(
|
||||
buildPaidSession({
|
||||
lines: [buildAutoLine()]
|
||||
})
|
||||
);
|
||||
orderClaimService.claimFromSession.mockResolvedValue({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'other-line-id',
|
||||
items: [{ id: 'stock-item-1', content: 'license-key', attachments: [] }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
const createdOrder = orderRepo.create.mock.calls[0][0];
|
||||
const autoLine = createdOrder.lines.find((line: { variantId: string }) => line.variantId === 'variant-auto-1');
|
||||
|
||||
expect(autoLine).toEqual(
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-auto-1'
|
||||
})
|
||||
);
|
||||
expect(autoLine).not.toHaveProperty('autoFulfillmentItems');
|
||||
});
|
||||
|
||||
it('maps an empty discount list when the session has no discounts', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(buildPaidSession({ discounts: undefined }));
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.create).toHaveBeenCalledWith(expect.objectContaining({ discounts: [] }));
|
||||
});
|
||||
|
||||
it('maps auto-delivery claims onto order lines with attachments', async () => {
|
||||
sessionQueryBuilder.getOne.mockResolvedValue(
|
||||
buildPaidSession({
|
||||
lines: [buildAutoLine()]
|
||||
})
|
||||
);
|
||||
orderClaimService.claimFromSession.mockResolvedValue({
|
||||
success: true,
|
||||
stockClaims: [
|
||||
{
|
||||
checkoutSessionLineId: 'line-auto-1',
|
||||
items: [
|
||||
{
|
||||
id: 'stock-item-1',
|
||||
content: 'license-key-123',
|
||||
attachments: [
|
||||
{
|
||||
id: 'attachment-1',
|
||||
storageKey: 'stock/file.pdf',
|
||||
originalFilename: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await service.createFromPaidSession('session-1');
|
||||
|
||||
expect(orderRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lines: [
|
||||
expect.objectContaining({
|
||||
variantId: 'variant-auto-1',
|
||||
autoFulfillmentItems: [
|
||||
{
|
||||
sortOrder: 0,
|
||||
contentSnapshot: 'license-key-123',
|
||||
sourceDigitalStockItemId: 'stock-item-1',
|
||||
attachments: [
|
||||
{
|
||||
storageKey: 'stock/file.pdf',
|
||||
sourceDigitalStockAttachmentId: 'attachment-1',
|
||||
originalFilename: 'file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sizeBytes: 1024
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
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.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
|
||||
}))
|
||||
}))
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import type { EncryptionService } from '../../encryption/services/EncryptionService';
|
||||
import type { Invoice } from '../../payment/entities/Invoice';
|
||||
import { InvoiceReason } from '../../payment/types/InvoiceReason';
|
||||
import { PaymentMethod } from '../../payment/types/PaymentMethod';
|
||||
import type { InvoiceService } from '../../payment/services/InvoiceService';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import type { Order } from '../entities/Order';
|
||||
import type { OrderExtended } from '../types/OrderExtended';
|
||||
import type { OrderLineManualFulfillment } from '../entities/OrderLineManualFulfillment';
|
||||
import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus';
|
||||
import { OrderService } from './OrderService';
|
||||
import type { OrderChatService } from './OrderChatService';
|
||||
import type { OrderAccessTokenService } from './OrderAccessTokenService';
|
||||
|
||||
describe('OrderService', () => {
|
||||
let orderRepo: {
|
||||
findAndCount: jest.Mock;
|
||||
find: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
update: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let orderChatService: {
|
||||
countUnreadBuyerMessages: jest.Mock;
|
||||
};
|
||||
let invoiceService: {
|
||||
issueInvoice: jest.Mock;
|
||||
};
|
||||
let manualFulfillmentRepo: {
|
||||
update: jest.Mock;
|
||||
};
|
||||
let accessTokenService: {
|
||||
decryptStored: jest.Mock;
|
||||
};
|
||||
let encryptionService: {
|
||||
decryptPlaintextFieldInPlace: jest.Mock;
|
||||
};
|
||||
let service: OrderService;
|
||||
let findByIdSpy: jest.SpyInstance;
|
||||
|
||||
const orderExtended = { id: 'order-1' } as OrderExtended;
|
||||
|
||||
beforeEach(() => {
|
||||
orderRepo = {
|
||||
findAndCount: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createQueryBuilder: jest.fn()
|
||||
};
|
||||
|
||||
orderChatService = {
|
||||
countUnreadBuyerMessages: jest.fn().mockReturnValue(0)
|
||||
};
|
||||
|
||||
invoiceService = {
|
||||
issueInvoice: jest.fn().mockResolvedValue({ id: 'shipping-invoice-1' } as Invoice)
|
||||
};
|
||||
|
||||
manualFulfillmentRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
accessTokenService = {
|
||||
decryptStored: jest.fn().mockReturnValue('plain-token')
|
||||
};
|
||||
|
||||
encryptionService = {
|
||||
decryptPlaintextFieldInPlace: jest.fn()
|
||||
};
|
||||
|
||||
service = new OrderService(
|
||||
orderRepo as unknown as Repository<Order>,
|
||||
manualFulfillmentRepo as unknown as Repository<OrderLineManualFulfillment>,
|
||||
orderChatService as unknown as OrderChatService,
|
||||
accessTokenService as unknown as OrderAccessTokenService,
|
||||
encryptionService as unknown as EncryptionService,
|
||||
invoiceService as unknown as InvoiceService
|
||||
);
|
||||
|
||||
findByIdSpy = jest.spyOn(service, 'findById').mockResolvedValue(orderExtended);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
findByIdSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns paginated order list items', async () => {
|
||||
const listItem = {
|
||||
id: 'order-1',
|
||||
status: 'open',
|
||||
checkoutPaymentLabel: null,
|
||||
shippingPaymentLabel: null,
|
||||
totalFiat: 10,
|
||||
grandTotalFiat: null,
|
||||
fiatCurrency: 'USD',
|
||||
lineCount: 1,
|
||||
unreadMessageCount: 0,
|
||||
failureReason: null,
|
||||
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z')
|
||||
};
|
||||
|
||||
orderRepo.findAndCount.mockResolvedValue([[{ id: 'order-1' }], 2]);
|
||||
orderRepo.find.mockResolvedValue([{ id: 'order-1' }]);
|
||||
jest.spyOn(service as unknown as { toOrderListItem: () => typeof listItem }, 'toOrderListItem').mockReturnValue(
|
||||
listItem
|
||||
);
|
||||
|
||||
const result = await service.findAll({ page: 2, limit: 1 });
|
||||
|
||||
expect(orderRepo.findAndCount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 1,
|
||||
take: 1,
|
||||
order: { createdAt: 'DESC' }
|
||||
})
|
||||
);
|
||||
expect(orderRepo.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: expect.anything() }
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
items: [listItem],
|
||||
total: 2,
|
||||
page: 2,
|
||||
limit: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an order id for a checkout session when one exists', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({ id: 'order-1' });
|
||||
|
||||
await expect(service.findIdByCheckoutSessionId('session-1')).resolves.toBe('order-1');
|
||||
});
|
||||
|
||||
it('returns null when no order exists for the checkout session', async () => {
|
||||
orderRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findIdByCheckoutSessionId('session-1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
let orderDetailQueryBuilder: {
|
||||
leftJoinAndSelect: jest.Mock;
|
||||
addSelect: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
addOrderBy: jest.Mock;
|
||||
where: jest.Mock;
|
||||
getOne: jest.Mock;
|
||||
};
|
||||
|
||||
const buildStoredOrder = (): Order =>
|
||||
({
|
||||
id: 'order-1',
|
||||
accessToken: 'encrypted-token',
|
||||
failureReason: null,
|
||||
checkoutInvoice: {
|
||||
id: 'invoice-1',
|
||||
fiatCurrency: 'USD',
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
expectedTotalAtomic: '100000000000',
|
||||
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
|
||||
moneroDetails: { requiredConfirmations: 1 },
|
||||
payments: [{ id: 'pay-1', amountAtomic: '100000000000', confirmations: 1, txHash: 'tx-1' }],
|
||||
reason: InvoiceReason.Checkout
|
||||
},
|
||||
shippingInvoice: null,
|
||||
lines: [],
|
||||
messages: [],
|
||||
discounts: [],
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z')
|
||||
}) as unknown as Order;
|
||||
|
||||
beforeEach(() => {
|
||||
findByIdSpy.mockRestore();
|
||||
|
||||
orderDetailQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue(null)
|
||||
};
|
||||
|
||||
orderRepo.createQueryBuilder = jest.fn().mockReturnValue(orderDetailQueryBuilder);
|
||||
});
|
||||
|
||||
it('throws when the order cannot be found', async () => {
|
||||
await expect(service.findById('order-1')).rejects.toThrow(new NotFoundException('Order not found'));
|
||||
});
|
||||
|
||||
it('decrypts sensitive fields and returns an extended order view', async () => {
|
||||
const storedOrder = buildStoredOrder();
|
||||
orderDetailQueryBuilder.getOne.mockResolvedValue(storedOrder);
|
||||
|
||||
const result = await service.findById('order-1');
|
||||
|
||||
expect(accessTokenService.decryptStored).toHaveBeenCalledWith('encrypted-token');
|
||||
expect(encryptionService.decryptPlaintextFieldInPlace).toHaveBeenCalledWith([], 'body');
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'order-1',
|
||||
fiatCurrency: 'USD',
|
||||
accessToken: 'plain-token',
|
||||
checkoutInvoice: expect.objectContaining({
|
||||
statusLabel: 'Payment confirmed',
|
||||
expectedTotalCrypto: '0.10000000'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDeliveryCost', () => {
|
||||
const buildQuotableOrder = () =>
|
||||
({
|
||||
id: 'order-1',
|
||||
lines: [{ deliveryMode: DeliveryMode.Manual }],
|
||||
checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD' }
|
||||
}) as Order;
|
||||
|
||||
it('throws when the order cannot be quoted', async () => {
|
||||
orderRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow(
|
||||
new NotFoundException('Order not found')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the quotable order is missing a checkout invoice', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
...buildQuotableOrder(),
|
||||
checkoutInvoice: undefined
|
||||
});
|
||||
|
||||
await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow(
|
||||
new NotFoundException('Order not found')
|
||||
);
|
||||
});
|
||||
|
||||
it('treats already-quoted or invoiced orders as not quotable', async () => {
|
||||
orderRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow(
|
||||
new NotFoundException('Order not found')
|
||||
);
|
||||
|
||||
expect(orderRepo.findOne).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
quotedAt: expect.anything(),
|
||||
shippingInvoice: expect.anything()
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the order has no manual-delivery lines', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
...buildQuotableOrder(),
|
||||
lines: [{ deliveryMode: DeliveryMode.Auto }]
|
||||
});
|
||||
|
||||
await expect(service.setDeliveryCost('order-1', { deliveryCost: 5 })).rejects.toThrow(
|
||||
new BadRequestException('Order does not require shipping')
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the order quoted without creating a shipping invoice for free delivery', async () => {
|
||||
orderRepo.findOne.mockResolvedValue(buildQuotableOrder());
|
||||
|
||||
const result = await service.setDeliveryCost('order-1', { deliveryCost: 0 });
|
||||
|
||||
expect(invoiceService.issueInvoice).not.toHaveBeenCalled();
|
||||
expect(orderRepo.update).toHaveBeenCalledWith('order-1', { quotedAt: expect.any(Date) });
|
||||
expect(findByIdSpy).toHaveBeenCalledWith('order-1');
|
||||
expect(result).toBe(orderExtended);
|
||||
});
|
||||
|
||||
it('issues a shipping invoice and links it when delivery has a cost', async () => {
|
||||
orderRepo.findOne.mockResolvedValue(buildQuotableOrder());
|
||||
|
||||
const result = await service.setDeliveryCost('order-1', { deliveryCost: 12.5 });
|
||||
|
||||
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
reason: InvoiceReason.Shipping,
|
||||
contextId: 'order-1',
|
||||
amountFiat: 12.5
|
||||
});
|
||||
expect(orderRepo.update).toHaveBeenCalledWith('order-1', {
|
||||
shippingInvoice: { id: 'shipping-invoice-1' },
|
||||
quotedAt: expect.any(Date)
|
||||
});
|
||||
expect(result).toBe(orderExtended);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fulfillManualLine', () => {
|
||||
const buildManualLine = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'line-1',
|
||||
deliveryMode: DeliveryMode.Manual,
|
||||
manualFulfillment: {
|
||||
id: 'fulfillment-1',
|
||||
status: ManualLineFulfillmentStatus.Pending
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
it('throws when the order cannot be found', async () => {
|
||||
orderRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow(
|
||||
new NotFoundException('Order not found')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the order line cannot be found', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
lines: []
|
||||
});
|
||||
|
||||
await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow(
|
||||
new NotFoundException('Order line not found')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the line is not manually delivered', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
lines: [buildManualLine({ deliveryMode: DeliveryMode.Auto })]
|
||||
});
|
||||
|
||||
await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow(
|
||||
new BadRequestException('Order line is not manually delivered')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the line has no manual fulfillment record', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
lines: [buildManualLine({ manualFulfillment: undefined })]
|
||||
});
|
||||
|
||||
await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow(
|
||||
new BadRequestException('Order line has no manual fulfillment record')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the line is already fulfilled', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
lines: [
|
||||
buildManualLine({
|
||||
manualFulfillment: {
|
||||
id: 'fulfillment-1',
|
||||
status: ManualLineFulfillmentStatus.Fulfilled
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
await expect(service.fulfillManualLine('order-1', 'line-1')).rejects.toThrow(
|
||||
new BadRequestException('Order line is already fulfilled')
|
||||
);
|
||||
});
|
||||
|
||||
it('marks a pending manual line as fulfilled', async () => {
|
||||
orderRepo.findOne.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
lines: [buildManualLine()]
|
||||
});
|
||||
|
||||
const result = await service.fulfillManualLine('order-1', 'line-1');
|
||||
|
||||
expect(manualFulfillmentRepo.update).toHaveBeenCalledWith('fulfillment-1', {
|
||||
status: ManualLineFulfillmentStatus.Fulfilled,
|
||||
fulfilledAt: expect.any(Date)
|
||||
});
|
||||
expect(findByIdSpy).toHaveBeenCalledWith('order-1');
|
||||
expect(result).toBe(orderExtended);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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