init
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './controllers/AuthController';
|
||||
import { AuthService } from './services/AuthService';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService]
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import type { Request, Response } from 'express';
|
||||
import { LoginDto } from '../dto/LoginDto';
|
||||
import { AuthService } from '../services/AuthService';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Config } from '../../../types/Config';
|
||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||
import { shouldUseSecureCookie } from '../../../utils/shouldUseSecureCookie';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
@Throttle(throttleProfiles.cmsLogin)
|
||||
login(@Body() { password }: LoginDto, @Res() res: Response, @Req() req: Request) {
|
||||
const { bearerCookie, cookieExpires } = this.authService.login(password);
|
||||
|
||||
const { nodeEnv } = this.configService.get('app') as Config['app'];
|
||||
|
||||
res.cookie('bearer_token', bearerCookie, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: shouldUseSecureCookie(nodeEnv, req),
|
||||
expires: cookieExpires
|
||||
});
|
||||
|
||||
return res.sendStatus(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './AuthService';
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
sign: jest.fn(() => 'signed-token')
|
||||
}));
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'app') {
|
||||
return { cmsPassword: 'secret-password' };
|
||||
}
|
||||
|
||||
if (key === 'jwt') {
|
||||
return { secret: 'jwt-secret', expiresInMs: 3_600_000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
};
|
||||
|
||||
service = new AuthService(configService as unknown as ConfigService);
|
||||
});
|
||||
|
||||
it('rejects invalid passwords', () => {
|
||||
expect(() => service.verifyPassword('wrong')).toThrow(new UnauthorizedException('Invalid credentials'));
|
||||
});
|
||||
|
||||
it('accepts the configured cms password', () => {
|
||||
expect(() => service.verifyPassword('secret-password')).not.toThrow();
|
||||
});
|
||||
|
||||
it('returns a bearer cookie and expiry when login succeeds', () => {
|
||||
const result = service.login('secret-password');
|
||||
|
||||
expect(result.bearerCookie).toBe('Bearer signed-token');
|
||||
expect(result.cookieExpires).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { Config } from '../../../types/Config';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
verifyPassword(password: string): void {
|
||||
const { cmsPassword } = this.configService.get('app') as Config['app'];
|
||||
|
||||
if (password !== cmsPassword) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
}
|
||||
|
||||
login(password: string): { bearerCookie: string; cookieExpires: Date } {
|
||||
this.verifyPassword(password);
|
||||
|
||||
const { secret, expiresInMs } = this.configService.get('jwt') as Config['jwt'];
|
||||
|
||||
const token = jwt.sign({}, secret, { expiresIn: Math.floor(expiresInMs / 1000) });
|
||||
|
||||
return {
|
||||
bearerCookie: `Bearer ${token}`,
|
||||
cookieExpires: dayjs().add(expiresInMs, 'millisecond').toDate()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrderModule } from '../order/OrderModule';
|
||||
import { PaymentModule } from '../payment/PaymentModule';
|
||||
import { StorefrontCheckoutModule } from '../storefrontCheckout/StorefrontCheckoutModule';
|
||||
import { OrderDataWipeService } from './services/OrderDataWipeService';
|
||||
|
||||
@Module({
|
||||
imports: [OrderModule, PaymentModule, StorefrontCheckoutModule],
|
||||
providers: [OrderDataWipeService]
|
||||
})
|
||||
export class DataWipeModule {}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { Order } from '../../order/entities/Order';
|
||||
import { Invoice } from '../../payment/entities/Invoice';
|
||||
import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import { OrderDataWipeService } from './OrderDataWipeService';
|
||||
|
||||
describe('OrderDataWipeService', () => {
|
||||
let orderRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let service: OrderDataWipeService;
|
||||
let queryBuilder: {
|
||||
select: jest.Mock;
|
||||
addSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
limit: jest.Mock;
|
||||
getRawMany: jest.Mock;
|
||||
};
|
||||
let transactionManager: {
|
||||
getRepository: jest.Mock;
|
||||
};
|
||||
let transactionalOrderRepo: { delete: jest.Mock };
|
||||
let transactionalSessionRepo: { delete: jest.Mock };
|
||||
let transactionalInvoiceRepo: { delete: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
queryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn()
|
||||
};
|
||||
|
||||
orderRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder)
|
||||
};
|
||||
|
||||
transactionalOrderRepo = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
transactionalSessionRepo = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
transactionalInvoiceRepo = { delete: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
transactionManager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === Order) {
|
||||
return transactionalOrderRepo;
|
||||
}
|
||||
|
||||
if (entity === CheckoutSession) {
|
||||
return transactionalSessionRepo;
|
||||
}
|
||||
|
||||
if (entity === Invoice) {
|
||||
return transactionalInvoiceRepo;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected entity: ${String(entity)}`);
|
||||
})
|
||||
};
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback: (manager: EntityManager) => Promise<void>) => {
|
||||
await callback(transactionManager as unknown as EntityManager);
|
||||
})
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'order') {
|
||||
return { dataRetentionDays: 30 };
|
||||
}
|
||||
|
||||
return {};
|
||||
})
|
||||
};
|
||||
|
||||
service = new OrderDataWipeService(
|
||||
orderRepo as unknown as Repository<Order>,
|
||||
dataSource as unknown as DataSource,
|
||||
configService as unknown as ConfigService
|
||||
);
|
||||
});
|
||||
|
||||
it('computes retention cutoff from configured days', () => {
|
||||
const before = Date.now();
|
||||
|
||||
const cutoff = service.getRetentionCutoff();
|
||||
|
||||
const after = Date.now();
|
||||
const expectedMs = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before - expectedMs - 1000);
|
||||
expect(cutoff.getTime()).toBeLessThanOrEqual(after - expectedMs + 1000);
|
||||
});
|
||||
|
||||
it('finds expired orders with linked session and invoice ids', async () => {
|
||||
queryBuilder.getRawMany.mockResolvedValue([
|
||||
{
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: 'invoice-2'
|
||||
}
|
||||
]);
|
||||
|
||||
const cutoff = new Date('2026-01-01T00:00:00.000Z');
|
||||
jest.spyOn(service, 'getRetentionCutoff').mockReturnValue(cutoff);
|
||||
|
||||
const targets = await service.findExpiredOrderWipeTargets();
|
||||
|
||||
expect(queryBuilder.where).toHaveBeenCalledWith('order.createdAt < :cutoff', { cutoff });
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('order.checkoutSessionId IS NOT NULL');
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('order.checkoutInvoiceId IS NOT NULL');
|
||||
expect(queryBuilder.limit).toHaveBeenCalledWith(50);
|
||||
expect(targets).toEqual([
|
||||
{
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: 'invoice-2'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('deletes order, checkout session, and invoices in one transaction', async () => {
|
||||
await service.wipeOrder({
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: 'invoice-2'
|
||||
});
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(transactionalOrderRepo.delete).toHaveBeenCalledWith('order-1');
|
||||
expect(transactionalSessionRepo.delete).toHaveBeenCalledWith('session-1');
|
||||
expect(transactionalInvoiceRepo.delete).toHaveBeenCalledWith(['invoice-1', 'invoice-2']);
|
||||
});
|
||||
|
||||
it('skips shipping invoice delete when absent', async () => {
|
||||
await service.wipeOrder({
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: null
|
||||
});
|
||||
|
||||
expect(transactionalInvoiceRepo.delete).toHaveBeenCalledWith(['invoice-1']);
|
||||
});
|
||||
|
||||
it('stops after max iterations when orders keep failing to delete', async () => {
|
||||
const target = {
|
||||
id: 'order-1',
|
||||
checkoutSessionId: 'session-1',
|
||||
checkoutInvoiceId: 'invoice-1',
|
||||
shippingInvoiceId: null
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'findExpiredOrderWipeTargets').mockResolvedValue([target]);
|
||||
jest.spyOn(service, 'wipeOrder').mockRejectedValue(new Error('delete failed'));
|
||||
const warnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation();
|
||||
jest.spyOn(service['logger'], 'error').mockImplementation();
|
||||
|
||||
await service.wipeExpiredOrders();
|
||||
|
||||
expect(service.wipeOrder).toHaveBeenCalledTimes(20);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Order wipe max iterations limit reached. Most likely some order keeps failing to be deleted or there are huge amount of orders to wipe.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { Order } from '../../order/entities/Order';
|
||||
import { Invoice } from '../../payment/entities/Invoice';
|
||||
import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import type { OrderWipeTarget } from '../types/OrderWipeTarget';
|
||||
|
||||
@Injectable()
|
||||
export class OrderDataWipeService {
|
||||
private readonly logger = new Logger(OrderDataWipeService.name);
|
||||
|
||||
private readonly wipeBatchSize = 50;
|
||||
private readonly maxWipeIterations = 20;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Order)
|
||||
private readonly orderRepo: Repository<Order>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async wipeExpiredOrders(): Promise<void> {
|
||||
let wipedCount = 0;
|
||||
let iterations = 0;
|
||||
|
||||
let targets = await this.findExpiredOrderWipeTargets();
|
||||
|
||||
while (targets.length > 0 && iterations < this.maxWipeIterations) {
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await this.wipeOrder(target);
|
||||
|
||||
wipedCount += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to wipe order ${target.id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
targets = await this.findExpiredOrderWipeTargets();
|
||||
iterations += 1;
|
||||
}
|
||||
|
||||
if (iterations >= this.maxWipeIterations && targets.length > 0) {
|
||||
this.logger.warn(
|
||||
'Order wipe max iterations limit reached. Most likely some order keeps failing to be deleted or there are huge amount of orders to wipe.'
|
||||
);
|
||||
}
|
||||
|
||||
if (wipedCount > 0) {
|
||||
this.logger.log(`Wiped ${wipedCount} expired order(s).`);
|
||||
}
|
||||
}
|
||||
|
||||
async findExpiredOrderWipeTargets(): Promise<OrderWipeTarget[]> {
|
||||
const cutoff = this.getRetentionCutoff();
|
||||
|
||||
return this.orderRepo
|
||||
.createQueryBuilder('order')
|
||||
.select('order.id', 'id')
|
||||
.addSelect('order.checkoutSessionId', 'checkoutSessionId')
|
||||
.addSelect('order.checkoutInvoiceId', 'checkoutInvoiceId')
|
||||
.addSelect('order.shippingInvoiceId', 'shippingInvoiceId')
|
||||
.where('order.createdAt < :cutoff', { cutoff })
|
||||
.andWhere('order.checkoutSessionId IS NOT NULL')
|
||||
.andWhere('order.checkoutInvoiceId IS NOT NULL')
|
||||
.orderBy('order.createdAt', 'ASC')
|
||||
.limit(this.wipeBatchSize)
|
||||
.getRawMany<OrderWipeTarget>();
|
||||
}
|
||||
|
||||
async wipeOrder({ id, checkoutSessionId, checkoutInvoiceId, shippingInvoiceId }: OrderWipeTarget): Promise<void> {
|
||||
const invoiceIds = [checkoutInvoiceId, shippingInvoiceId].filter((invoiceId): invoiceId is string =>
|
||||
Boolean(invoiceId)
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async manager => {
|
||||
const orderRepo = manager.getRepository(Order);
|
||||
const sessionRepo = manager.getRepository(CheckoutSession);
|
||||
const invoiceRepo = manager.getRepository(Invoice);
|
||||
|
||||
await orderRepo.delete(id);
|
||||
await sessionRepo.delete(checkoutSessionId);
|
||||
await invoiceRepo.delete(invoiceIds);
|
||||
});
|
||||
}
|
||||
|
||||
getRetentionCutoff(): Date {
|
||||
const { dataRetentionDays } = this.configService.get('order') as Config['order'];
|
||||
|
||||
return dayjs().subtract(dataRetentionDays, 'day').toDate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type OrderWipeTarget = {
|
||||
id: string;
|
||||
checkoutSessionId: string;
|
||||
checkoutInvoiceId: string;
|
||||
shippingInvoiceId: string | null;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ProductsModule } from '../product/ProductsModule';
|
||||
import { DiscountCodesController } from './controllers/DiscountCodesController';
|
||||
import { DiscountCode } from './entities/DiscountCode';
|
||||
import { DiscountCodesService } from './services/DiscountCodesService';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DiscountCode]), ProductsModule],
|
||||
controllers: [DiscountCodesController],
|
||||
providers: [DiscountCodesService],
|
||||
exports: [DiscountCodesService]
|
||||
})
|
||||
export class DiscountCodesModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { CreateOrUpdateDiscountCodeDto } from '../dto/CreateOrUpdateDiscountCodeDto';
|
||||
import { DiscountCodesService } from '../services/DiscountCodesService';
|
||||
|
||||
@Controller('discount-codes')
|
||||
@UseGuards(JwtGuard)
|
||||
export class DiscountCodesController {
|
||||
constructor(private readonly discountCodesService: DiscountCodesService) {}
|
||||
|
||||
@Get('/')
|
||||
findAll() {
|
||||
return this.discountCodesService.findAll();
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.discountCodesService.findOne(id);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
create(@Body() dto: CreateOrUpdateDiscountCodeDto) {
|
||||
return this.discountCodesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: CreateOrUpdateDiscountCodeDto) {
|
||||
return this.discountCodesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.discountCodesService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { getAppConfig } from '../../../config';
|
||||
import { NullOrDate, NullOrInt, NullOrNumber } from '../../../validation/decorators/nullOr';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
|
||||
const {
|
||||
validation: { discountCodeMaxLength }
|
||||
} = getAppConfig();
|
||||
|
||||
export class CreateOrUpdateDiscountCodeDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MaxLength(discountCodeMaxLength)
|
||||
code: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(DiscountType)
|
||||
type: DiscountType;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
value: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isActive: boolean;
|
||||
|
||||
@NullOrDate()
|
||||
validFrom: Date | null;
|
||||
|
||||
@NullOrDate()
|
||||
validUntil: Date | null;
|
||||
|
||||
@NullOrInt({ min: 1 })
|
||||
maxRedemptions: number | null;
|
||||
|
||||
@NullOrNumber({ min: 0 })
|
||||
minOrderAmount: number | null;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
isExclusive: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
productIds: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
categoryIds: string[];
|
||||
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
variantIds: string[];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm';
|
||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||
import { Category } from '../../product/entities/Category';
|
||||
import { Product } from '../../product/entities/Product';
|
||||
import { ProductVariant } from '../../product/entities/ProductVariant';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
|
||||
@Entity('discount_codes')
|
||||
export class DiscountCode {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'enum', enum: DiscountType })
|
||||
type: DiscountType;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
value: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
||||
validFrom: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true, default: null })
|
||||
validUntil: Date | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true, default: null })
|
||||
maxRedemptions: number | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
redemptionCount: number;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
default: null,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
minOrderAmount: number | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isExclusive: boolean;
|
||||
|
||||
@ManyToMany(() => Product)
|
||||
@JoinTable({
|
||||
name: 'discount_codes_products',
|
||||
joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'productId', referencedColumnName: 'id' }
|
||||
})
|
||||
products: Product[];
|
||||
|
||||
@ManyToMany(() => Category)
|
||||
@JoinTable({
|
||||
name: 'discount_codes_categories',
|
||||
joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'categoryId', referencedColumnName: 'id' }
|
||||
})
|
||||
categories: Category[];
|
||||
|
||||
@ManyToMany(() => ProductVariant)
|
||||
@JoinTable({
|
||||
name: 'discount_codes_variants',
|
||||
joinColumn: { name: 'discountCodeId', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'variantId', referencedColumnName: 'id' }
|
||||
})
|
||||
variants: ProductVariant[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import type { Repository } from 'typeorm';
|
||||
import type { CategoriesService } from '../../product/services/CategoriesService';
|
||||
import type { ProductVariantsService } from '../../product/services/ProductVariantsService';
|
||||
import type { ProductsService } from '../../product/services/ProductsService';
|
||||
import { DiscountCode } from '../entities/DiscountCode';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
import { DiscountCodesService } from './DiscountCodesService';
|
||||
|
||||
const buildDto = () => ({
|
||||
code: ' save10 ',
|
||||
type: DiscountType.Percent,
|
||||
value: 10,
|
||||
isActive: true,
|
||||
validFrom: null,
|
||||
validUntil: null,
|
||||
maxRedemptions: null,
|
||||
minOrderAmount: null,
|
||||
isExclusive: false,
|
||||
productIds: [] as string[],
|
||||
categoryIds: [] as string[],
|
||||
variantIds: [] as string[]
|
||||
});
|
||||
|
||||
describe('DiscountCodesService', () => {
|
||||
let service: DiscountCodesService;
|
||||
let discountCodeRepo: {
|
||||
find: jest.Mock;
|
||||
findOne: jest.Mock;
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
exists: jest.Mock;
|
||||
delete: jest.Mock;
|
||||
};
|
||||
let productsService: {
|
||||
findByIds: jest.Mock;
|
||||
};
|
||||
let categoriesService: {
|
||||
findByIds: jest.Mock;
|
||||
};
|
||||
let productVariantsService: {
|
||||
findByIds: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
discountCodeRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(data => ({ id: 'discount-1', ...data })),
|
||||
save: jest.fn(async (entity: DiscountCode) => entity),
|
||||
exists: jest.fn().mockResolvedValue(false),
|
||||
delete: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
productsService = {
|
||||
findByIds: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
categoriesService = {
|
||||
findByIds: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
productVariantsService = {
|
||||
findByIds: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
service = new DiscountCodesService(
|
||||
discountCodeRepo as unknown as Repository<DiscountCode>,
|
||||
productsService as unknown as ProductsService,
|
||||
productVariantsService as unknown as ProductVariantsService,
|
||||
categoriesService as unknown as CategoriesService
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes discount codes by trimming and uppercasing', () => {
|
||||
expect(service.normalizeCode(' save10 ')).toBe('SAVE10');
|
||||
});
|
||||
|
||||
it('returns an empty list when no normalized codes are provided', async () => {
|
||||
await expect(service.findByNormalizedCodes([])).resolves.toEqual([]);
|
||||
expect(discountCodeRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates normalized codes before loading entities', async () => {
|
||||
await service.findByNormalizedCodes(['save10', 'SAVE10', ' save10 ']);
|
||||
|
||||
expect(discountCodeRepo.find).toHaveBeenCalledWith({
|
||||
where: { code: expect.anything() },
|
||||
relations: ['products', 'categories', 'variants']
|
||||
});
|
||||
const whereArg = discountCodeRepo.find.mock.calls[0][0].where.code;
|
||||
expect(whereArg._value).toEqual(['SAVE10']);
|
||||
});
|
||||
|
||||
it('rejects duplicate codes on create', async () => {
|
||||
discountCodeRepo.findOne.mockResolvedValue({ id: 'existing', code: 'SAVE10' });
|
||||
|
||||
await expect(service.create(buildDto())).rejects.toThrow(
|
||||
new BadRequestException('Discount code already exists')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects percent values outside 0-100', async () => {
|
||||
await expect(service.create({ ...buildDto(), value: 101 })).rejects.toThrow(
|
||||
new BadRequestException('Percent value must be between 0 and 100')
|
||||
);
|
||||
|
||||
await expect(service.create({ ...buildDto(), value: -1 })).rejects.toThrow(
|
||||
new BadRequestException('Percent value must be between 0 and 100')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects negative fixed discount values', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...buildDto(),
|
||||
type: DiscountType.Fixed,
|
||||
value: -5
|
||||
})
|
||||
).rejects.toThrow(new BadRequestException('Fixed value must be at least 0'));
|
||||
});
|
||||
|
||||
it('allows updating a code without treating itself as a duplicate', async () => {
|
||||
const existing = { id: 'discount-1', code: 'SAVE10' } as DiscountCode;
|
||||
|
||||
discountCodeRepo.findOne
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing)
|
||||
.mockResolvedValueOnce(existing);
|
||||
|
||||
await service.update('discount-1', buildDto());
|
||||
|
||||
expect(discountCodeRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects updating to a code owned by another discount', async () => {
|
||||
discountCodeRepo.findOne
|
||||
.mockResolvedValueOnce({ id: 'discount-1', code: 'OLD' } as DiscountCode)
|
||||
.mockResolvedValueOnce({ id: 'discount-2', code: 'SAVE10' });
|
||||
|
||||
await expect(service.update('discount-1', buildDto())).rejects.toThrow(
|
||||
new BadRequestException('Discount code already exists')
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects discount date ranges where validFrom is after validUntil', async () => {
|
||||
await expect(
|
||||
service.create({
|
||||
...buildDto(),
|
||||
validFrom: new Date('2026-02-01T00:00:00.000Z'),
|
||||
validUntil: new Date('2026-01-01T00:00:00.000Z')
|
||||
})
|
||||
).rejects.toThrow(new BadRequestException('Date from should be before date until'));
|
||||
});
|
||||
|
||||
it('creates a normalized discount code', async () => {
|
||||
discountCodeRepo.findOne
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'discount-1', code: 'SAVE10' } as DiscountCode);
|
||||
|
||||
const created = await service.create(buildDto());
|
||||
|
||||
expect(discountCodeRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: 'SAVE10',
|
||||
type: DiscountType.Percent,
|
||||
value: 10
|
||||
})
|
||||
);
|
||||
expect(discountCodeRepo.save).toHaveBeenCalled();
|
||||
expect(created).toEqual(expect.objectContaining({ id: 'discount-1', code: 'SAVE10' }));
|
||||
});
|
||||
|
||||
it('throws when loading a missing discount code by id', async () => {
|
||||
await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('deletes an existing discount code', async () => {
|
||||
discountCodeRepo.exists.mockResolvedValue(true);
|
||||
|
||||
await service.remove('discount-1');
|
||||
|
||||
expect(discountCodeRepo.delete).toHaveBeenCalledWith('discount-1');
|
||||
});
|
||||
|
||||
it('throws when deleting a missing discount code', async () => {
|
||||
discountCodeRepo.exists.mockResolvedValue(false);
|
||||
|
||||
await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException);
|
||||
expect(discountCodeRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { CategoriesService } from '../../product/services/CategoriesService';
|
||||
import { ProductVariantsService } from '../../product/services/ProductVariantsService';
|
||||
import { ProductsService } from '../../product/services/ProductsService';
|
||||
import type { CreateOrUpdateDiscountCodeDto } from '../dto/CreateOrUpdateDiscountCodeDto';
|
||||
import { DiscountCode } from '../entities/DiscountCode';
|
||||
import { DiscountType } from '../types/DiscountType';
|
||||
|
||||
@Injectable()
|
||||
export class DiscountCodesService {
|
||||
constructor(
|
||||
@InjectRepository(DiscountCode)
|
||||
private readonly discountCodeRepo: Repository<DiscountCode>,
|
||||
private readonly productsService: ProductsService,
|
||||
private readonly productVariantsService: ProductVariantsService,
|
||||
private readonly categoriesService: CategoriesService
|
||||
) {}
|
||||
|
||||
normalizeCode(raw: string): string {
|
||||
return raw.trim().toUpperCase();
|
||||
}
|
||||
|
||||
async findAll(): Promise<DiscountCode[]> {
|
||||
return this.discountCodeRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: ['products', 'categories', 'variants', 'variants.product']
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<DiscountCode> {
|
||||
const entity = await this.discountCodeRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['products', 'categories', 'variants', 'variants.product']
|
||||
});
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
async findByNormalizedCodes(codes: string[]): Promise<DiscountCode[]> {
|
||||
if (codes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = [...new Set(codes.map(c => this.normalizeCode(c)))];
|
||||
|
||||
return this.discountCodeRepo.find({
|
||||
where: { code: In(normalized) },
|
||||
relations: ['products', 'categories', 'variants']
|
||||
});
|
||||
}
|
||||
|
||||
async create({
|
||||
code,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
isExclusive,
|
||||
productIds,
|
||||
categoryIds,
|
||||
variantIds
|
||||
}: CreateOrUpdateDiscountCodeDto): Promise<DiscountCode> {
|
||||
const normalizedCode = this.normalizeCode(code);
|
||||
|
||||
await this.validateCodeAvailable(normalizedCode);
|
||||
|
||||
this.validateDiscountDateRange(validFrom, validUntil);
|
||||
this.validateDiscountValueForType(type, value);
|
||||
|
||||
const [products, categories, variants] = await Promise.all([
|
||||
this.productsService.findByIds(productIds),
|
||||
this.categoriesService.findByIds(categoryIds),
|
||||
this.productVariantsService.findByIds(variantIds)
|
||||
]);
|
||||
|
||||
const entity = this.discountCodeRepo.create({
|
||||
code: normalizedCode,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
isExclusive,
|
||||
products,
|
||||
categories,
|
||||
variants
|
||||
});
|
||||
|
||||
await this.discountCodeRepo.save(entity);
|
||||
|
||||
return this.findOne(entity.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
{
|
||||
code,
|
||||
type,
|
||||
value,
|
||||
isActive,
|
||||
validFrom,
|
||||
validUntil,
|
||||
maxRedemptions,
|
||||
minOrderAmount,
|
||||
isExclusive,
|
||||
productIds,
|
||||
categoryIds,
|
||||
variantIds
|
||||
}: CreateOrUpdateDiscountCodeDto
|
||||
): Promise<DiscountCode> {
|
||||
const entity = await this.discountCodeRepo.findOne({ where: { id } });
|
||||
|
||||
if (!entity) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const normalizedCode = this.normalizeCode(code);
|
||||
|
||||
await this.validateCodeAvailable(normalizedCode, id);
|
||||
|
||||
this.validateDiscountDateRange(validFrom, validUntil);
|
||||
this.validateDiscountValueForType(type, value);
|
||||
|
||||
const [products, categories, variants] = await Promise.all([
|
||||
this.productsService.findByIds(productIds),
|
||||
this.categoriesService.findByIds(categoryIds),
|
||||
this.productVariantsService.findByIds(variantIds)
|
||||
]);
|
||||
|
||||
entity.code = normalizedCode;
|
||||
entity.type = type;
|
||||
entity.value = value;
|
||||
entity.isActive = isActive;
|
||||
entity.validFrom = validFrom;
|
||||
entity.validUntil = validUntil;
|
||||
entity.maxRedemptions = maxRedemptions;
|
||||
entity.minOrderAmount = minOrderAmount;
|
||||
entity.isExclusive = isExclusive;
|
||||
entity.products = products;
|
||||
entity.categories = categories;
|
||||
entity.variants = variants;
|
||||
|
||||
await this.discountCodeRepo.save(entity);
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const exists = await this.discountCodeRepo.exists({ where: { id } });
|
||||
|
||||
if (!exists) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.discountCodeRepo.delete(id);
|
||||
}
|
||||
|
||||
private async validateCodeAvailable(code: string, excludeId?: string): Promise<void> {
|
||||
const existing = await this.discountCodeRepo.findOne({ where: { code } });
|
||||
|
||||
if (existing && existing.id !== excludeId) {
|
||||
throw new BadRequestException('Discount code already exists');
|
||||
}
|
||||
}
|
||||
|
||||
private validateDiscountDateRange(validFrom: Date | null, validUntil: Date | null): void {
|
||||
if (validFrom && validUntil && dayjs(validFrom).isAfter(dayjs(validUntil))) {
|
||||
throw new BadRequestException('Date from should be before date until');
|
||||
}
|
||||
}
|
||||
|
||||
private validateDiscountValueForType(type: DiscountType, value: number): void {
|
||||
if (type === DiscountType.Percent && (value < 0 || value > 100)) {
|
||||
throw new BadRequestException('Percent value must be between 0 and 100');
|
||||
}
|
||||
|
||||
if (type === DiscountType.Fixed && value < 0) {
|
||||
throw new BadRequestException('Fixed value must be at least 0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum DiscountType {
|
||||
Percent = 'percent',
|
||||
Fixed = 'fixed'
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EncryptionService } from './services/EncryptionService';
|
||||
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService]
|
||||
})
|
||||
export class EncryptionModule {}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { EncryptedField } from '../types/EncryptedField';
|
||||
import { EncryptionService } from './EncryptionService';
|
||||
|
||||
describe('EncryptionService', () => {
|
||||
const validKeyBase64 = randomBytes(32).toString('base64');
|
||||
let service: EncryptionService;
|
||||
let configGet: jest.MockedFunction<ConfigService['get']>;
|
||||
|
||||
beforeEach(() => {
|
||||
configGet = jest.fn().mockReturnValue({ keyBase64: validKeyBase64 }) as jest.MockedFunction<
|
||||
ConfigService['get']
|
||||
>;
|
||||
service = new EncryptionService({ get: configGet } as unknown as ConfigService);
|
||||
});
|
||||
|
||||
const parseSerialized = (serialized: string): EncryptedField => JSON.parse(serialized) as EncryptedField;
|
||||
|
||||
it('reads encryption config when encrypting plaintext', () => {
|
||||
service.encryptPlaintext('hello');
|
||||
|
||||
expect(configGet).toHaveBeenCalledWith('encryption');
|
||||
});
|
||||
|
||||
it('round-trips plaintext', () => {
|
||||
const plaintext = 'Deliver via Simplex: example-handle';
|
||||
const serialized = service.encryptPlaintext(plaintext);
|
||||
|
||||
expect(service.decryptPlaintext(serialized)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('throws when encryption key is not configured', () => {
|
||||
configGet.mockReturnValue({ keyBase64: '' });
|
||||
|
||||
expect(() => service.encryptPlaintext('x')).toThrow('Encryption key is not configured');
|
||||
});
|
||||
|
||||
it('throws when encryption key is not 32 bytes', () => {
|
||||
configGet.mockReturnValue({ keyBase64: Buffer.from('short').toString('base64') });
|
||||
|
||||
expect(() => service.encryptPlaintext('x')).toThrow('Encryption key must be a base64-encoded 32-byte value');
|
||||
});
|
||||
|
||||
it('throws on invalid serialized payload', () => {
|
||||
expect(() => service.decryptPlaintext('{"ciphertext":"x"}')).toThrow('Invalid encrypted field payload');
|
||||
});
|
||||
|
||||
it('fails decrypt when ciphertext is tampered', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
const encrypted = parseSerialized(serialized);
|
||||
|
||||
encrypted.ciphertext = Buffer.from('tampered').toString('base64');
|
||||
|
||||
expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow();
|
||||
});
|
||||
|
||||
it('throws when encryption key is not configured on decrypt', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
configGet.mockReturnValue({ keyBase64: '' });
|
||||
|
||||
expect(() => service.decryptPlaintext(serialized)).toThrow('Encryption key is not configured');
|
||||
});
|
||||
|
||||
it('fails decrypt when auth tag is tampered', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
const encrypted = parseSerialized(serialized);
|
||||
|
||||
encrypted.tag = Buffer.from('tampered-tag').toString('base64');
|
||||
|
||||
expect(() => service.decryptPlaintext(JSON.stringify(encrypted))).toThrow();
|
||||
});
|
||||
|
||||
it('fails decrypt when encryption key changes', () => {
|
||||
const serialized = service.encryptPlaintext('secret');
|
||||
configGet.mockReturnValue({ keyBase64: randomBytes(32).toString('base64') });
|
||||
|
||||
expect(() => service.decryptPlaintext(serialized)).toThrow();
|
||||
});
|
||||
|
||||
it('decryptPlaintextFieldInPlace decrypts each item field', () => {
|
||||
const messages = [{ body: service.encryptPlaintext('Hello') }, { body: service.encryptPlaintext('World') }];
|
||||
|
||||
service.decryptPlaintextFieldInPlace(messages, 'body');
|
||||
|
||||
expect(messages).toEqual([{ body: 'Hello' }, { body: 'World' }]);
|
||||
});
|
||||
|
||||
it('decryptPlaintextFieldInPlace handles undefined items', () => {
|
||||
expect(() => service.decryptPlaintextFieldInPlace(undefined, 'body')).not.toThrow();
|
||||
});
|
||||
|
||||
it('round-trips files written as encrypted buffer', async () => {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'encryption-service-'));
|
||||
const filePath = join(tempDir, 'attachment.bin');
|
||||
const plaintext = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d, 0x00, 0xff]);
|
||||
|
||||
try {
|
||||
await service.writeEncryptedBufferToPath(plaintext, filePath);
|
||||
|
||||
const onDisk = await readFile(filePath, 'utf8');
|
||||
|
||||
expect(onDisk).not.toEqual(plaintext.toString());
|
||||
|
||||
expect(await service.decryptFileAtPath(filePath)).toEqual(plaintext);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { Config } from '../../../types/Config';
|
||||
import { EncryptedField } from '../types/EncryptedField';
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
private readonly ivLengthBytes = 12;
|
||||
private readonly encryptionKeyByteLength = 32;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
encryptPlaintext(plaintext: string): string {
|
||||
const encrypted = this.encryptBuffer(Buffer.from(plaintext, 'utf8'));
|
||||
|
||||
return this.serialize(encrypted);
|
||||
}
|
||||
|
||||
decryptPlaintext(serialized: string): string {
|
||||
const encrypted = this.deserialize(serialized);
|
||||
|
||||
return this.decryptBuffer(encrypted).toString('utf8');
|
||||
}
|
||||
|
||||
decryptPlaintextFieldInPlace<T>(items: T[] | undefined, field: keyof T): void {
|
||||
this.decryptPlaintextInPlace(
|
||||
items,
|
||||
item => item[field] as string,
|
||||
(item, plaintext) => {
|
||||
(item as Record<keyof T, unknown>)[field] = plaintext;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async writeEncryptedBufferToPath(plaintext: Buffer, absolutePath: string): Promise<void> {
|
||||
const serialized = this.encryptBufferToSerialized(plaintext);
|
||||
|
||||
await writeFile(absolutePath, serialized, 'utf8');
|
||||
}
|
||||
|
||||
async decryptFileAtPath(absolutePath: string): Promise<Buffer> {
|
||||
const serialized = await readFile(absolutePath, 'utf8');
|
||||
|
||||
return this.decryptBufferToSerialized(serialized);
|
||||
}
|
||||
|
||||
private decryptPlaintextInPlace<T>(
|
||||
items: T[] | undefined,
|
||||
read: (item: T) => string,
|
||||
write: (item: T, plaintext: string) => void
|
||||
): void {
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
write(item, this.decryptPlaintext(read(item)));
|
||||
}
|
||||
}
|
||||
|
||||
private encryptBufferToSerialized(plaintext: Buffer): string {
|
||||
const encrypted = this.encryptBuffer(plaintext);
|
||||
|
||||
return this.serialize(encrypted);
|
||||
}
|
||||
|
||||
private decryptBufferToSerialized(serialized: string): Buffer {
|
||||
const encrypted = this.deserialize(serialized);
|
||||
|
||||
return this.decryptBuffer(encrypted);
|
||||
}
|
||||
|
||||
private encryptBuffer(plaintext: Buffer): EncryptedField {
|
||||
const key = this.parseKey();
|
||||
const iv = randomBytes(this.ivLengthBytes);
|
||||
const cipher = createCipheriv(this.algorithm, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return {
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: tag.toString('base64')
|
||||
};
|
||||
}
|
||||
|
||||
private decryptBuffer(payload: EncryptedField): Buffer {
|
||||
const key = this.parseKey();
|
||||
const iv = Buffer.from(payload.iv, 'base64');
|
||||
const tag = Buffer.from(payload.tag, 'base64');
|
||||
const ciphertext = Buffer.from(payload.ciphertext, 'base64');
|
||||
const decipher = createDecipheriv(this.algorithm, key, iv);
|
||||
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
private serialize(payload: EncryptedField): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
private deserialize(serialized: string): EncryptedField {
|
||||
const parsed: unknown = JSON.parse(serialized);
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof (parsed as EncryptedField).ciphertext !== 'string' ||
|
||||
typeof (parsed as EncryptedField).iv !== 'string' ||
|
||||
typeof (parsed as EncryptedField).tag !== 'string'
|
||||
) {
|
||||
throw new Error('Invalid encrypted field payload');
|
||||
}
|
||||
|
||||
return parsed as EncryptedField;
|
||||
}
|
||||
|
||||
private parseKey(): Buffer {
|
||||
const { keyBase64 } = this.configService.get('encryption') as Config['encryption'];
|
||||
|
||||
if (!keyBase64) {
|
||||
throw new Error('Encryption key is not configured');
|
||||
}
|
||||
|
||||
const key = Buffer.from(keyBase64, 'base64');
|
||||
|
||||
if (key.length !== this.encryptionKeyByteLength) {
|
||||
throw new Error(`Encryption key must be a base64-encoded ${this.encryptionKeyByteLength}-byte value`);
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface EncryptedField {
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthCheckController } from './controllers/HealthCheckController';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthCheckController]
|
||||
})
|
||||
export class HealthCheckModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@Controller('/health-check')
|
||||
@SkipThrottle({ default: true })
|
||||
export class HealthCheckController {
|
||||
@Get('/')
|
||||
getStatus(@Res() res: Response) {
|
||||
return res.sendStatus(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/AuthModule';
|
||||
import { MoneroWalletController } from './controllers/MoneroWalletController';
|
||||
import { MoneroWalletAdminService } from './services/MoneroWalletAdminService';
|
||||
import { MoneroWalletRpcClient } from './services/MoneroWalletRpcClient';
|
||||
import { MoneroWalletRpcConnectionService } from './services/MoneroWalletRpcConnectionService';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [MoneroWalletController],
|
||||
providers: [MoneroWalletRpcClient, MoneroWalletRpcConnectionService, MoneroWalletAdminService],
|
||||
exports: [MoneroWalletRpcClient]
|
||||
})
|
||||
export class MoneroWalletModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||
import { MoneroWalletRevealSeedDto } from '../dto/MoneroWalletRevealSeedDto';
|
||||
import { MoneroWalletWithdrawDto } from '../dto/MoneroWalletWithdrawDto';
|
||||
import { MoneroWalletAdminService } from '../services/MoneroWalletAdminService';
|
||||
|
||||
@Controller('monero-wallet')
|
||||
@UseGuards(JwtGuard)
|
||||
export class MoneroWalletController {
|
||||
constructor(private readonly walletAdminService: MoneroWalletAdminService) {}
|
||||
|
||||
@Get('/')
|
||||
getStatus() {
|
||||
return this.walletAdminService.getStatus();
|
||||
}
|
||||
|
||||
@Post('/withdraw')
|
||||
@Throttle(throttleProfiles.walletWithdraw)
|
||||
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
|
||||
return this.walletAdminService.withdrawAll(destinationAddress, password);
|
||||
}
|
||||
|
||||
@Post('/reveal-seed')
|
||||
@Throttle(throttleProfiles.walletRevealSeed)
|
||||
revealSeed(@Body() { password }: MoneroWalletRevealSeedDto) {
|
||||
return this.walletAdminService.revealSeed(password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class MoneroWalletRevealSeedDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
|
||||
|
||||
export class MoneroWalletWithdrawDto {
|
||||
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsMoneroStandardAddress()
|
||||
destinationAddress: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type { AuthService } from '../../auth/services/AuthService';
|
||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('MoneroWalletAdminService', () => {
|
||||
let service: MoneroWalletAdminService;
|
||||
let walletRpcClient: {
|
||||
tryRefresh: jest.Mock;
|
||||
getVersion: jest.Mock;
|
||||
getHeight: jest.Mock;
|
||||
getBalance: jest.Mock;
|
||||
sweepAll: jest.Mock;
|
||||
queryMnemonic: jest.Mock;
|
||||
};
|
||||
let authService: {
|
||||
verifyPassword: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
walletRpcClient = {
|
||||
tryRefresh: jest.fn().mockResolvedValue(undefined),
|
||||
getVersion: jest.fn().mockResolvedValue('0.18.3.1'),
|
||||
getHeight: jest.fn().mockResolvedValue(3_000_000),
|
||||
getBalance: jest.fn().mockResolvedValue({
|
||||
balanceAtomic: '2000000000000',
|
||||
unlockedBalanceAtomic: '1000000000000'
|
||||
}),
|
||||
sweepAll: jest.fn().mockResolvedValue({
|
||||
txHashes: ['tx-hash-1'],
|
||||
amountAtomic: '1000000000000'
|
||||
}),
|
||||
queryMnemonic: jest.fn().mockResolvedValue('seed words')
|
||||
};
|
||||
|
||||
authService = {
|
||||
verifyPassword: jest.fn()
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
network: 'mainnet',
|
||||
daemonRpcUrl: 'http://daemon.test/json_rpc',
|
||||
rpcTimeoutMs: 5000
|
||||
})
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { result: { height: 3_000_000 } }
|
||||
});
|
||||
|
||||
service = new MoneroWalletAdminService(
|
||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||
authService as unknown as AuthService,
|
||||
configService as unknown as ConfigService
|
||||
);
|
||||
});
|
||||
|
||||
it('returns wallet status when RPC and daemon calls succeed', async () => {
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(walletRpcClient.tryRefresh).toHaveBeenCalled();
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
network: 'mainnet',
|
||||
rpcVersion: '0.18.3.1',
|
||||
walletHeight: 3_000_000,
|
||||
daemonHeight: 3_000_000,
|
||||
syncStatus: MoneroWalletSyncStatus.Synced,
|
||||
balanceXmr: '2.00000000',
|
||||
unlockedBalanceXmr: '1.00000000'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when wallet status cannot be loaded', async () => {
|
||||
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(service.getStatus()).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects withdrawals when there is no unlocked balance', async () => {
|
||||
walletRpcClient.getBalance.mockResolvedValue({
|
||||
balanceAtomic: '0',
|
||||
unlockedBalanceAtomic: '0'
|
||||
});
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new BadRequestException('No unlocked balance to withdraw.')
|
||||
);
|
||||
|
||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects withdrawals while the wallet is still syncing', async () => {
|
||||
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
|
||||
);
|
||||
});
|
||||
|
||||
it('sweeps unlocked funds when the wallet is synced', async () => {
|
||||
const result = await service.withdrawAll('4DestinationAddressExample', 'password');
|
||||
|
||||
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
|
||||
expect(result).toEqual({
|
||||
txHashes: ['tx-hash-1'],
|
||||
amountXmr: '1.00000000'
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals the wallet seed after password verification', async () => {
|
||||
await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
|
||||
|
||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||
expect(walletRpcClient.queryMnemonic).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unknown sync status when the daemon height cannot be fetched', async () => {
|
||||
mockedAxios.post.mockRejectedValue(new Error('daemon down'));
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
|
||||
expect(status.daemonHeight).toBeNull();
|
||||
});
|
||||
|
||||
it('treats the wallet as synced when it is one block behind the daemon', async () => {
|
||||
walletRpcClient.getHeight.mockResolvedValue(2_999_999);
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { result: { height: 3_000_000 } }
|
||||
});
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
|
||||
});
|
||||
|
||||
it('throws when reveal seed RPC fails', async () => {
|
||||
walletRpcClient.queryMnemonic.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(service.revealSeed('password')).rejects.toThrow(
|
||||
new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.')
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when sweep all fails after prechecks pass', async () => {
|
||||
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { AuthService } from '../../auth/services/AuthService';
|
||||
import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
|
||||
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
|
||||
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
|
||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
||||
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletAdminService {
|
||||
constructor(
|
||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
async getStatus(): Promise<MoneroWalletStatusView> {
|
||||
const { network } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
await this.walletRpcClient.tryRefresh();
|
||||
|
||||
try {
|
||||
const [rpcVersion, daemonHeight, walletHeight, { balanceAtomic, unlockedBalanceAtomic }] =
|
||||
await Promise.all([
|
||||
this.walletRpcClient.getVersion(),
|
||||
this.fetchDaemonHeight(),
|
||||
this.walletRpcClient.getHeight(),
|
||||
this.walletRpcClient.getBalance()
|
||||
]);
|
||||
|
||||
return {
|
||||
network,
|
||||
rpcVersion,
|
||||
walletHeight,
|
||||
daemonHeight,
|
||||
syncStatus: this.resolveSyncStatus(walletHeight, daemonHeight),
|
||||
balanceXmr: convertXmrAtomicToXmr(balanceAtomic),
|
||||
unlockedBalanceXmr: convertXmrAtomicToXmr(unlockedBalanceAtomic)
|
||||
};
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> {
|
||||
this.authService.verifyPassword(password);
|
||||
|
||||
await this.walletRpcClient.tryRefresh();
|
||||
|
||||
let unlockedBalanceAtomic: string;
|
||||
let walletHeight: number;
|
||||
let daemonHeight: number | null;
|
||||
|
||||
try {
|
||||
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
|
||||
this.walletRpcClient.getBalance(),
|
||||
this.walletRpcClient.getHeight(),
|
||||
this.fetchDaemonHeight()
|
||||
]);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||
}
|
||||
|
||||
if (unlockedBalanceAtomic === '0') {
|
||||
throw new BadRequestException('No unlocked balance to withdraw.');
|
||||
}
|
||||
|
||||
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
|
||||
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
||||
}
|
||||
|
||||
let txHashes: string[];
|
||||
let amountAtomic: string;
|
||||
|
||||
try {
|
||||
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
txHashes,
|
||||
amountXmr: convertXmrAtomicToXmr(amountAtomic)
|
||||
};
|
||||
}
|
||||
|
||||
async revealSeed(password: string): Promise<MoneroWalletRevealSeedResult> {
|
||||
this.authService.verifyPassword(password);
|
||||
|
||||
try {
|
||||
const mnemonic = await this.walletRpcClient.queryMnemonic();
|
||||
|
||||
return { mnemonic };
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDaemonHeight(): Promise<number | null> {
|
||||
const { daemonRpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ result?: MoneroDaemonGetInfoResult }>(
|
||||
daemonRpcUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: '0',
|
||||
method: 'get_info'
|
||||
},
|
||||
{ timeout: rpcTimeoutMs }
|
||||
);
|
||||
|
||||
return data.result?.height ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
|
||||
if (daemonHeight === null) {
|
||||
return MoneroWalletSyncStatus.Unknown;
|
||||
}
|
||||
|
||||
return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { MoneroWalletRpcClientTest } from '../types/MoneroWalletRpcClientTest';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
jest.mock('node:crypto', () => {
|
||||
const actual = jest.requireActual<typeof import('node:crypto')>('node:crypto');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
randomBytes: jest.fn(() => Buffer.from('0123456789abcdef', 'hex'))
|
||||
};
|
||||
});
|
||||
|
||||
describe('MoneroWalletRpcClient', () => {
|
||||
let client: MoneroWalletRpcClientTest;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MoneroWalletRpcClient({
|
||||
get: jest.fn()
|
||||
} as unknown as ConfigService) as unknown as MoneroWalletRpcClientTest;
|
||||
});
|
||||
|
||||
describe('formatRpcVersion', () => {
|
||||
it('formats the packed RPC version integer from get_version', () => {
|
||||
expect(client.formatRpcVersion(65539)).toBe('1.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDigestChallenge', () => {
|
||||
it('parses a full digest challenge header', () => {
|
||||
const header = 'Digest realm="monero-rpc", nonce="abc123", opaque="opaque-value", qop="auth"';
|
||||
|
||||
expect(client.parseDigestChallenge(header)).toEqual({
|
||||
realm: 'monero-rpc',
|
||||
nonce: 'abc123',
|
||||
opaque: 'opaque-value',
|
||||
qop: 'auth'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses headers with a lowercase digest prefix', () => {
|
||||
const header = 'digest realm="monero-rpc", nonce="abc123"';
|
||||
|
||||
expect(client.parseDigestChallenge(header)).toEqual({
|
||||
realm: 'monero-rpc',
|
||||
nonce: 'abc123',
|
||||
opaque: undefined,
|
||||
qop: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when realm is missing', () => {
|
||||
expect(() => client.parseDigestChallenge('Digest nonce="abc123"')).toThrow(
|
||||
'Invalid Monero wallet RPC digest challenge'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when nonce is missing', () => {
|
||||
expect(() => client.parseDigestChallenge('Digest realm="monero-rpc"')).toThrow(
|
||||
'Invalid Monero wallet RPC digest challenge'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncomingTransfers', () => {
|
||||
let rpcClient: MoneroWalletRpcClient;
|
||||
let callSpy: jest.SpiedFunction<(method: string, params?: Record<string, unknown>) => Promise<unknown>>;
|
||||
|
||||
beforeEach(() => {
|
||||
rpcClient = new MoneroWalletRpcClient({
|
||||
get: jest.fn()
|
||||
} as unknown as ConfigService);
|
||||
|
||||
callSpy = jest.spyOn(
|
||||
MoneroWalletRpcClient.prototype as unknown as {
|
||||
call: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
},
|
||||
'call'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
callSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns an empty array when the RPC omits in and pool', async () => {
|
||||
callSpy.mockResolvedValue({});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('maps confirmed and pending transfers when present', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{
|
||||
txid: 'confirmed-tx',
|
||||
amount: 1000000000000,
|
||||
confirmations: 2,
|
||||
subaddr_index: { major: 0, minor: 3 }
|
||||
}
|
||||
],
|
||||
pool: [
|
||||
{
|
||||
txid: 'pending-tx',
|
||||
amount: 500000000000,
|
||||
confirmations: 0,
|
||||
subaddr_index: { major: 0, minor: 4 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3, 4])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'confirmed-tx',
|
||||
amountAtomic: '1000000000000',
|
||||
confirmations: 2,
|
||||
subaddrIndex: 3
|
||||
},
|
||||
{
|
||||
txHash: 'pending-tx',
|
||||
amountAtomic: '500000000000',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 4
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults missing confirmations on confirmed transfers to zero', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{
|
||||
txid: 'confirmed-tx',
|
||||
amount: 1000000000000,
|
||||
subaddr_index: { major: 0, minor: 3 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'confirmed-tx',
|
||||
amountAtomic: '1000000000000',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 3
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips malformed transfer entries', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{ amount: 1, subaddr_index: { major: 0, minor: 1 } },
|
||||
{ txid: 'valid-tx', amount: 2, subaddr_index: { major: 0, minor: 2 } }
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([1, 2])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'valid-tx',
|
||||
amountAtomic: '2',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 2
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDigestAuthorization', () => {
|
||||
it('builds a digest authorization header from the challenge', () => {
|
||||
const username = 'rpcuser';
|
||||
const password = 'rpcpass';
|
||||
const uri = '/json_rpc';
|
||||
const realm = 'monero-rpc';
|
||||
const nonce = 'server-nonce';
|
||||
const qop = 'auth';
|
||||
const nc = '00000001';
|
||||
const cnonce = '0123456789abcdef';
|
||||
const digestHeader = `Digest realm="${realm}", nonce="${nonce}", qop="${qop}"`;
|
||||
const ha1 = createHash('md5').update(`${username}:${realm}:${password}`).digest('hex');
|
||||
const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex');
|
||||
const response = createHash('md5').update(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`).digest('hex');
|
||||
|
||||
expect(client.buildDigestAuthorization(uri, username, password, digestHeader)).toBe(
|
||||
`Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${response}"`
|
||||
);
|
||||
});
|
||||
|
||||
it('includes opaque when the challenge provides it', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", opaque="opaque-token", qop="auth"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('opaque="opaque-token"');
|
||||
});
|
||||
|
||||
it('defaults qop to auth when the challenge omits it', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('qop=auth');
|
||||
});
|
||||
|
||||
it('uses the first qop option when several are offered', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", qop="auth, auth-int"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('qop=auth');
|
||||
expect(authorization).not.toContain('auth-int');
|
||||
});
|
||||
|
||||
it('throws when the challenge header is invalid', () => {
|
||||
expect(() =>
|
||||
client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', 'Digest qop="auth"')
|
||||
).toThrow('Invalid Monero wallet RPC digest challenge');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import type { MoneroCreateAddressResult } from '../types/MoneroCreateAddressResult';
|
||||
import type { MoneroWalletRpcIncomingTransfer } from '../types/MoneroWalletRpcIncomingTransfer';
|
||||
import type { MoneroWalletRpcGetTransfersResult } from '../types/MoneroWalletRpcGetTransfersResult';
|
||||
import type { MoneroWalletRpcTransferEntry } from '../types/MoneroWalletRpcTransferEntry';
|
||||
import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGetBalanceResult';
|
||||
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
|
||||
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
|
||||
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
|
||||
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
|
||||
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
|
||||
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletRpcClient {
|
||||
private readonly logger = new Logger(MoneroWalletRpcClient.name);
|
||||
private readonly accountIndex = 0;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
private async call<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options: { timeoutMs?: number } = {}
|
||||
): Promise<T> {
|
||||
const { rpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? rpcTimeoutMs;
|
||||
|
||||
const body = { method, params };
|
||||
|
||||
const authorization = await this.doDigestAuthorization(body);
|
||||
|
||||
const { data } = await axios.post<MoneroWalletRpcResponse<T>>(rpcUrl, body, {
|
||||
timeout: timeoutMs,
|
||||
headers: { Authorization: authorization }
|
||||
});
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
if (data.result === undefined) {
|
||||
throw new Error(`Monero wallet RPC ${method} returned no result`);
|
||||
}
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
private async doDigestAuthorization(body: { method: string; params: Record<string, unknown> }): Promise<string> {
|
||||
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
|
||||
'moneroWallet'
|
||||
) as Config['moneroWallet'];
|
||||
|
||||
const rpcUri = new URL(rpcUrl);
|
||||
const requestPath = `${rpcUri.pathname}${rpcUri.search}`;
|
||||
|
||||
const challengeResponse = await axios.post(rpcUrl, body, {
|
||||
timeout: rpcTimeoutMs,
|
||||
validateStatus: (status: HttpStatus) => status === HttpStatus.UNAUTHORIZED
|
||||
});
|
||||
|
||||
const digestHeader = challengeResponse.headers['www-authenticate'] as unknown;
|
||||
|
||||
if (!digestHeader || typeof digestHeader !== 'string') {
|
||||
throw new Error(`Monero wallet RPC ${body.method} did not return a digest auth challenge`);
|
||||
}
|
||||
|
||||
return this.buildDigestAuthorization(requestPath, username, password, digestHeader);
|
||||
}
|
||||
|
||||
private buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string {
|
||||
const challenge = this.parseDigestChallenge(digestHeader);
|
||||
const ha1 = createHash('md5').update(`${username}:${challenge.realm}:${password}`).digest('hex');
|
||||
const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex');
|
||||
const nc = '00000001';
|
||||
const cnonce = randomBytes(8).toString('hex');
|
||||
const qop = challenge.qop?.split(',')[0]?.trim() || 'auth';
|
||||
const response = createHash('md5')
|
||||
.update(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`)
|
||||
.digest('hex');
|
||||
|
||||
const parts = [
|
||||
`username="${username}"`,
|
||||
`realm="${challenge.realm}"`,
|
||||
`nonce="${challenge.nonce}"`,
|
||||
`uri="${uri}"`,
|
||||
`qop=${qop}`,
|
||||
`nc=${nc}`,
|
||||
`cnonce="${cnonce}"`,
|
||||
`response="${response}"`
|
||||
];
|
||||
|
||||
if (challenge.opaque) {
|
||||
parts.push(`opaque="${challenge.opaque}"`);
|
||||
}
|
||||
|
||||
return `Digest ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
private parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge {
|
||||
const params = Object.fromEntries(
|
||||
header
|
||||
.replace(/^Digest\s+/i, '')
|
||||
.split(',')
|
||||
.map(part => {
|
||||
const [key, ...valueParts] = part.trim().split('=');
|
||||
|
||||
return [key, valueParts.join('=').replace(/^"|"$/g, '')];
|
||||
})
|
||||
);
|
||||
|
||||
if (!params.realm || !params.nonce) {
|
||||
throw new Error('Invalid Monero wallet RPC digest challenge');
|
||||
}
|
||||
|
||||
return {
|
||||
realm: params.realm,
|
||||
nonce: params.nonce,
|
||||
opaque: params.opaque,
|
||||
qop: params.qop
|
||||
};
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const { version, release } = await this.call<MoneroWalletRpcGetVersionResult>('get_version');
|
||||
|
||||
if (version === undefined) {
|
||||
throw new Error('Monero wallet RPC get_version returned no version');
|
||||
}
|
||||
|
||||
const formatted = this.formatRpcVersion(version);
|
||||
|
||||
if (release === false) {
|
||||
return `${formatted} (non-release)`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
private formatRpcVersion(version: number): string {
|
||||
const major = version >>> 16;
|
||||
const minor = version & 0xffff;
|
||||
|
||||
return `${major}.${minor}`;
|
||||
}
|
||||
|
||||
async createAddress(label?: string): Promise<{ address: string; address_index: number }> {
|
||||
const params: { account_index: number; label?: string } = { account_index: this.accountIndex };
|
||||
|
||||
if (label) {
|
||||
params.label = label;
|
||||
}
|
||||
|
||||
const result = await this.call<MoneroCreateAddressResult>('create_address', params);
|
||||
|
||||
if (!result.address || result.address_index === undefined) {
|
||||
throw new Error('Monero wallet RPC create_address returned incomplete result');
|
||||
}
|
||||
|
||||
return { address: result.address, address_index: result.address_index };
|
||||
}
|
||||
|
||||
async getIncomingTransfers(subaddrIndices: number[]): Promise<MoneroWalletRpcIncomingTransfer[]> {
|
||||
if (subaddrIndices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await this.call<MoneroWalletRpcGetTransfersResult>('get_transfers', {
|
||||
in: true,
|
||||
pool: true,
|
||||
account_index: this.accountIndex,
|
||||
subaddr_indices: subaddrIndices
|
||||
});
|
||||
|
||||
const confirmed = (result.in ?? [])
|
||||
.map(transfer => this.mapIncomingTransfer(transfer, transfer.confirmations ?? 0))
|
||||
.filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null);
|
||||
|
||||
const pending = (result.pool ?? [])
|
||||
.map(transfer => this.mapIncomingTransfer(transfer, 0))
|
||||
.filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null);
|
||||
|
||||
return [...confirmed, ...pending];
|
||||
}
|
||||
|
||||
async tryRefresh(): Promise<void> {
|
||||
try {
|
||||
await this.call('refresh');
|
||||
} catch (error) {
|
||||
this.logger.warn(`Monero wallet refresh failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getHeight(): Promise<number> {
|
||||
const { height } = await this.call<MoneroWalletRpcGetHeightResult>('get_height');
|
||||
|
||||
if (height === undefined) {
|
||||
throw new Error('Monero wallet RPC get_height returned no height');
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
async getBalance(): Promise<{ balanceAtomic: string; unlockedBalanceAtomic: string }> {
|
||||
const { balance, unlocked_balance } = await this.call<MoneroWalletRpcGetBalanceResult>('get_balance', {
|
||||
account_index: this.accountIndex
|
||||
});
|
||||
|
||||
if (balance === undefined || unlocked_balance === undefined) {
|
||||
throw new Error('Monero wallet RPC get_balance returned incomplete result');
|
||||
}
|
||||
|
||||
return {
|
||||
balanceAtomic: String(balance),
|
||||
unlockedBalanceAtomic: String(unlocked_balance)
|
||||
};
|
||||
}
|
||||
|
||||
async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> {
|
||||
const result = await this.call<MoneroWalletRpcSweepAllResult>(
|
||||
'sweep_all',
|
||||
{
|
||||
address: destinationAddress,
|
||||
account_index: this.accountIndex,
|
||||
subaddr_indices_all: true,
|
||||
priority: 1
|
||||
},
|
||||
{ timeoutMs: 120_000 }
|
||||
);
|
||||
|
||||
const txHashes = result.tx_hash_list;
|
||||
|
||||
if (!txHashes?.length) {
|
||||
throw new Error('Monero wallet RPC sweep_all returned no transaction hashes');
|
||||
}
|
||||
|
||||
const sweptAmount = (result.amount_list ?? []).reduce((sum, amount) => sum + amount, 0);
|
||||
|
||||
return {
|
||||
txHashes,
|
||||
amountAtomic: String(sweptAmount)
|
||||
};
|
||||
}
|
||||
|
||||
async queryMnemonic(): Promise<string> {
|
||||
const { key } = await this.call<MoneroWalletRpcQueryKeyResult>('query_key', {
|
||||
key_type: 'mnemonic'
|
||||
});
|
||||
|
||||
const trimmedKey = key?.trim();
|
||||
|
||||
if (!trimmedKey) {
|
||||
throw new Error('Monero wallet RPC query_key returned no mnemonic');
|
||||
}
|
||||
|
||||
return trimmedKey;
|
||||
}
|
||||
|
||||
private mapIncomingTransfer(
|
||||
transfer: MoneroWalletRpcTransferEntry,
|
||||
confirmations: number
|
||||
): MoneroWalletRpcIncomingTransfer | null {
|
||||
const txHash = transfer.txid?.trim();
|
||||
|
||||
if (!txHash || transfer.amount === undefined || transfer.subaddr_index?.minor === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
txHash,
|
||||
amountAtomic: String(transfer.amount),
|
||||
confirmations,
|
||||
subaddrIndex: transfer.subaddr_index.minor
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletRpcConnectionService } from './MoneroWalletRpcConnectionService';
|
||||
|
||||
describe('MoneroWalletRpcConnectionService', () => {
|
||||
let service: MoneroWalletRpcConnectionService;
|
||||
let walletRpcClient: {
|
||||
getVersion: jest.Mock;
|
||||
};
|
||||
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
|
||||
let errorSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
walletRpcClient = {
|
||||
getVersion: jest.fn().mockResolvedValue('0.18.3.1')
|
||||
};
|
||||
|
||||
service = new MoneroWalletRpcConnectionService(walletRpcClient as unknown as MoneroWalletRpcClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs a successful wallet rpc connection on module init', async () => {
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(walletRpcClient.getVersion).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith('Connected to monero-wallet-rpc (version 0.18.3.1)');
|
||||
});
|
||||
|
||||
it('logs an error when wallet rpc is unreachable at startup', async () => {
|
||||
walletRpcClient.getVersion.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to reach monero-wallet-rpc at startup: connection refused');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletRpcConnectionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MoneroWalletRpcConnectionService.name);
|
||||
|
||||
constructor(private readonly walletRpcClient: MoneroWalletRpcClient) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
const version = await this.walletRpcClient.getVersion();
|
||||
|
||||
this.logger.log(`Connected to monero-wallet-rpc (version ${version})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to reach monero-wallet-rpc at startup: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroCreateAddressResult = {
|
||||
address?: string;
|
||||
address_index?: number;
|
||||
address_indices?: number[];
|
||||
addresses?: string[];
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroDaemonGetInfoResult {
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRevealSeedResult {
|
||||
mnemonic: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MoneroWalletRpcDigestChallenge } from './MoneroWalletRpcDigestChallenge';
|
||||
|
||||
export type MoneroWalletRpcClientTest = {
|
||||
formatRpcVersion(version: number): string;
|
||||
parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge;
|
||||
buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWalletRpcDigestChallenge = {
|
||||
realm: string;
|
||||
nonce: string;
|
||||
opaque?: string;
|
||||
qop?: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcError = {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletRpcGetBalanceResult {
|
||||
balance?: number;
|
||||
unlocked_balance?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRpcGetHeightResult {
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MoneroWalletRpcTransferEntry } from './MoneroWalletRpcTransferEntry';
|
||||
|
||||
export type MoneroWalletRpcGetTransfersResult = {
|
||||
in?: MoneroWalletRpcTransferEntry[];
|
||||
out?: MoneroWalletRpcTransferEntry[];
|
||||
pending?: MoneroWalletRpcTransferEntry[];
|
||||
failed?: MoneroWalletRpcTransferEntry[];
|
||||
pool?: MoneroWalletRpcTransferEntry[];
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcGetVersionResult = {
|
||||
version?: number;
|
||||
release?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWalletRpcIncomingTransfer = {
|
||||
txHash: string;
|
||||
amountAtomic: string;
|
||||
confirmations: number;
|
||||
subaddrIndex: number;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRpcQueryKeyResult {
|
||||
key?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MoneroWalletRpcError } from './MoneroWalletRpcError';
|
||||
|
||||
export type MoneroWalletRpcResponse<T> = {
|
||||
id: string;
|
||||
jsonrpc: string;
|
||||
result?: T;
|
||||
error?: MoneroWalletRpcError;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcSubaddrIndex = {
|
||||
major?: number;
|
||||
minor?: number;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletRpcSweepAllResult {
|
||||
tx_hash_list?: string[];
|
||||
amount_list?: number[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MoneroWalletRpcSubaddrIndex } from './MoneroWalletRpcSubaddrIndex';
|
||||
|
||||
export type MoneroWalletRpcTransferEntry = {
|
||||
txid?: string;
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
subaddr_index?: MoneroWalletRpcSubaddrIndex;
|
||||
address?: string;
|
||||
height?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
locked?: boolean;
|
||||
amounts?: number[];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MoneroNetwork } from '../../../types/MoneroNetwork';
|
||||
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
|
||||
|
||||
export interface MoneroWalletStatusView {
|
||||
network: MoneroNetwork;
|
||||
rpcVersion: string;
|
||||
walletHeight: number;
|
||||
daemonHeight: number | null;
|
||||
syncStatus: MoneroWalletSyncStatus;
|
||||
balanceXmr: string;
|
||||
unlockedBalanceXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MoneroWalletSyncStatus {
|
||||
Synced = 'synced',
|
||||
Syncing = 'syncing',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletWithdrawResult {
|
||||
txHashes: string[];
|
||||
amountXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
|
||||
import { SimplexModule } from '../simplex/SimplexModule';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
|
||||
@Module({
|
||||
imports: [SimplexModule, ShopSettingsModule],
|
||||
providers: [NotificationService],
|
||||
exports: [NotificationService]
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||
import type { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||
import { NotificationService } from './NotificationService';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
let service: NotificationService;
|
||||
let shopSettingsService: {
|
||||
findSettings: jest.Mock;
|
||||
};
|
||||
let simplexChatClient: {
|
||||
sendText: jest.Mock;
|
||||
};
|
||||
let warnSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
shopSettingsService = {
|
||||
findSettings: jest.fn().mockResolvedValue({
|
||||
notificationsEnabled: true,
|
||||
simplexNotificationContactId: 42,
|
||||
notifyOnNewOrder: true,
|
||||
notifyOnOrderMessage: false
|
||||
})
|
||||
};
|
||||
|
||||
simplexChatClient = {
|
||||
sendText: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
service = new NotificationService(
|
||||
shopSettingsService as unknown as ShopSettingsService,
|
||||
simplexChatClient as unknown as SimplexChatClient
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when notifications are disabled', async () => {
|
||||
shopSettingsService.findSettings.mockResolvedValue({
|
||||
notificationsEnabled: false,
|
||||
simplexNotificationContactId: 42,
|
||||
notifyOnNewOrder: true,
|
||||
notifyOnOrderMessage: true
|
||||
});
|
||||
|
||||
await service.sendNotification('order-1234-abcd', 'newOrder');
|
||||
|
||||
expect(simplexChatClient.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the notification type is disabled', async () => {
|
||||
await service.sendNotification('order-1234-abcd', 'newBuyerMessage');
|
||||
|
||||
expect(simplexChatClient.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a new order notification when enabled', async () => {
|
||||
await service.sendNotification('order-1234-abcd', 'newOrder');
|
||||
|
||||
expect(simplexChatClient.sendText).toHaveBeenCalledWith(42, 'New order #orde — open CMS Orders.');
|
||||
});
|
||||
|
||||
it('logs a warning when sending fails without throwing', async () => {
|
||||
simplexChatClient.sendText.mockRejectedValue(new Error('simplex down'));
|
||||
|
||||
await expect(service.sendNotification('order-1234-abcd', 'newOrder')).resolves.toBeUndefined();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('Failed to send newOrder notification: simplex down');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
|
||||
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
|
||||
import { SimplexChatClient } from '../../simplex/services/SimplexChatClient';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
private readonly logger = new Logger(NotificationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly shopSettingsService: ShopSettingsService,
|
||||
private readonly simplexChatClient: SimplexChatClient
|
||||
) {}
|
||||
|
||||
async sendNotification(orderId: string, type: 'newOrder' | 'newBuyerMessage'): Promise<void> {
|
||||
try {
|
||||
const settings = await this.shopSettingsService.findSettings();
|
||||
|
||||
if (!settings.notificationsEnabled || settings.simplexNotificationContactId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = type === 'newOrder' ? settings.notifyOnNewOrder : settings.notifyOnOrderMessage;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortId = formatShortOrderId(orderId);
|
||||
|
||||
const message =
|
||||
type === 'newOrder'
|
||||
? `New order ${shortId} — open CMS Orders.`
|
||||
: `New message on order ${shortId}.`;
|
||||
|
||||
await this.simplexChatClient.sendText(settings.simplexNotificationContactId, message);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to send ${type} notification: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EncryptionModule } from '../encryption/EncryptionModule';
|
||||
import { NotificationsModule } from '../notifications/NotificationsModule';
|
||||
import { PaymentModule } from '../payment/PaymentModule';
|
||||
import { OrderChatController } from './controllers/OrderChatController';
|
||||
import { OrdersController } from './controllers/OrdersController';
|
||||
import { Order } from './entities/Order';
|
||||
import { OrderLineAutoFulfillmentItemAttachment } from './entities/OrderLineAutoFulfillmentItemAttachment';
|
||||
import { OrderLineManualFulfillment } from './entities/OrderLineManualFulfillment';
|
||||
import { OrderMessage } from './entities/OrderMessage';
|
||||
import { OrderClaimService } from './services/OrderClaimService';
|
||||
import { OrderAccessTokenService } from './services/OrderAccessTokenService';
|
||||
import { OrderCreationService } from './services/OrderCreationService';
|
||||
import { OrderChatService } from './services/OrderChatService';
|
||||
import { OrderService } from './services/OrderService';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Order,
|
||||
OrderMessage,
|
||||
OrderLineAutoFulfillmentItemAttachment,
|
||||
OrderLineManualFulfillment
|
||||
]),
|
||||
EncryptionModule,
|
||||
PaymentModule,
|
||||
NotificationsModule
|
||||
],
|
||||
controllers: [OrdersController, OrderChatController],
|
||||
providers: [OrderService, OrderClaimService, OrderCreationService, OrderAccessTokenService, OrderChatService],
|
||||
exports: [
|
||||
OrderService,
|
||||
OrderCreationService,
|
||||
OrderAccessTokenService,
|
||||
OrderChatService,
|
||||
TypeOrmModule.forFeature([Order, OrderLineAutoFulfillmentItemAttachment])
|
||||
]
|
||||
})
|
||||
export class OrderModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Delete, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { SubmitOrderMessageDto } from '../dto/SubmitOrderMessageDto';
|
||||
import { OrderMessage } from '../entities/OrderMessage';
|
||||
import { OrderChatService } from '../services/OrderChatService';
|
||||
import { OrderMessageSender } from '../types/OrderMessageSender';
|
||||
|
||||
@Controller('orders/:orderId/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
export class OrderChatController {
|
||||
constructor(private readonly orderChatService: OrderChatService) {}
|
||||
|
||||
@Post()
|
||||
createMessage(
|
||||
@Param('orderId', ParseUUIDPipe) orderId: string,
|
||||
@Body() { body }: SubmitOrderMessageDto
|
||||
): Promise<OrderMessage[]> {
|
||||
return this.orderChatService.createMessage(orderId, OrderMessageSender.Staff, body);
|
||||
}
|
||||
|
||||
@Post('mark-read')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
markChatRead(@Param('orderId', ParseUUIDPipe) orderId: string): Promise<void> {
|
||||
return this.orderChatService.markChatRead(orderId);
|
||||
}
|
||||
|
||||
@Delete(':messageId')
|
||||
deleteMessage(
|
||||
@Param('orderId', ParseUUIDPipe) orderId: string,
|
||||
@Param('messageId', ParseUUIDPipe) messageId: string
|
||||
): Promise<OrderMessage[]> {
|
||||
return this.orderChatService.deleteMessage(orderId, messageId, OrderMessageSender.Staff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto';
|
||||
import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto';
|
||||
import { OrderService } from '../services/OrderService';
|
||||
|
||||
@Controller('orders')
|
||||
@UseGuards(JwtGuard)
|
||||
export class OrdersController {
|
||||
constructor(private readonly orderService: OrderService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: ListOrdersQueryDto) {
|
||||
return this.orderService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.orderService.findById(id);
|
||||
}
|
||||
|
||||
@Post(':id/delivery-cost')
|
||||
setDeliveryCost(@Param('id', ParseUUIDPipe) id: string, @Body() payload: SetDeliveryCostDto) {
|
||||
return this.orderService.setDeliveryCost(id, payload);
|
||||
}
|
||||
|
||||
@Post(':id/lines/:lineId/fulfill')
|
||||
fulfillManualLine(@Param('id', ParseUUIDPipe) id: string, @Param('lineId', ParseUUIDPipe) lineId: string) {
|
||||
return this.orderService.fulfillManualLine(id, lineId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListOrdersQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty, IsNumber, Min } from 'class-validator';
|
||||
|
||||
export class SetDeliveryCostDto {
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
deliveryCost: number;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { getAppConfig } from '../../../config';
|
||||
|
||||
const {
|
||||
validation: { orderMessageMaxLength }
|
||||
} = getAppConfig();
|
||||
|
||||
export class SubmitOrderMessageDto {
|
||||
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(orderMessageMaxLength)
|
||||
body: string;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn
|
||||
} from 'typeorm';
|
||||
import { Invoice } from '../../payment/entities/Invoice';
|
||||
import { CheckoutSession } from '../../storefrontCheckout/entities/CheckoutSession';
|
||||
import { OrderFailureReason } from '../types/OrderFailureReason';
|
||||
import { OrderDiscount } from './OrderDiscount';
|
||||
import { OrderLine } from './OrderLine';
|
||||
import { OrderMessage } from './OrderMessage';
|
||||
|
||||
@Entity('orders')
|
||||
export class Order {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ length: 64, unique: true })
|
||||
accessTokenLookup: string;
|
||||
|
||||
@Column({ type: 'text', select: false })
|
||||
accessToken: string;
|
||||
|
||||
@Column({ type: 'enum', enum: OrderFailureReason, nullable: true })
|
||||
failureReason: OrderFailureReason | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
accessTokenSavedConfirmedAt: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
quotedAt: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
staffChatLastReadAt: Date | null;
|
||||
|
||||
@OneToOne(() => CheckoutSession, session => session.order)
|
||||
@JoinColumn()
|
||||
checkoutSession: CheckoutSession;
|
||||
|
||||
@OneToOne(() => Invoice)
|
||||
@JoinColumn()
|
||||
checkoutInvoice: Invoice;
|
||||
|
||||
@OneToOne(() => Invoice)
|
||||
@JoinColumn()
|
||||
shippingInvoice: Invoice | null;
|
||||
|
||||
@OneToMany(() => OrderLine, line => line.order, { cascade: true })
|
||||
lines: OrderLine[];
|
||||
|
||||
@OneToMany(() => OrderDiscount, discount => discount.order, { cascade: true })
|
||||
discounts: OrderDiscount[];
|
||||
|
||||
@OneToMany(() => OrderMessage, message => message.order)
|
||||
messages: OrderMessage[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||
import { Order } from './Order';
|
||||
|
||||
@Entity('order_discounts')
|
||||
export class OrderDiscount {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => Order, order => order.discounts, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
order: Order;
|
||||
|
||||
@Column({ length: 32 })
|
||||
code: string;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
amountFiat: number;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||
import { DeliveryMode } from '../../product/types/DeliveryMode';
|
||||
import { Order } from './Order';
|
||||
import { OrderLineAutoFulfillmentItem } from './OrderLineAutoFulfillmentItem';
|
||||
import { OrderLineManualFulfillment } from './OrderLineManualFulfillment';
|
||||
|
||||
@Entity('order_lines')
|
||||
export class OrderLine {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => Order, order => order.lines, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
order: Order;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
variantId: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
productId: string;
|
||||
|
||||
@Column()
|
||||
productTitle: string;
|
||||
|
||||
@Column()
|
||||
variantTitle: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
thumbnailUrl: string | null;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
qty: number;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
unitPriceFiat: number;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
lineSubtotalFiat: number;
|
||||
|
||||
@Column({ type: 'enum', enum: DeliveryMode })
|
||||
deliveryMode: DeliveryMode;
|
||||
|
||||
@OneToMany(() => OrderLineAutoFulfillmentItem, item => item.orderLine, { cascade: true })
|
||||
autoFulfillmentItems: OrderLineAutoFulfillmentItem[];
|
||||
|
||||
@OneToOne(() => OrderLineManualFulfillment, manualFulfillment => manualFulfillment.orderLine, { cascade: true })
|
||||
manualFulfillment: OrderLineManualFulfillment | null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { OrderLine } from './OrderLine';
|
||||
import { OrderLineAutoFulfillmentItemAttachment } from './OrderLineAutoFulfillmentItemAttachment';
|
||||
|
||||
@Entity('order_line_auto_fulfillment_items')
|
||||
export class OrderLineAutoFulfillmentItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
sortOrder: number;
|
||||
|
||||
@Column({ type: 'text', select: false })
|
||||
contentSnapshot: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
sourceDigitalStockItemId: string;
|
||||
|
||||
@ManyToOne(() => OrderLine, line => line.autoFulfillmentItems, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
orderLine: OrderLine;
|
||||
|
||||
@OneToMany(() => OrderLineAutoFulfillmentItemAttachment, attachment => attachment.item, { cascade: true })
|
||||
attachments: OrderLineAutoFulfillmentItemAttachment[];
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { OrderLineAutoFulfillmentItem } from './OrderLineAutoFulfillmentItem';
|
||||
|
||||
@Entity('order_line_auto_fulfillment_item_attachments')
|
||||
export class OrderLineAutoFulfillmentItemAttachment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ select: false })
|
||||
storageKey: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
sourceDigitalStockAttachmentId: string;
|
||||
|
||||
@Column()
|
||||
originalFilename: string;
|
||||
|
||||
@Column()
|
||||
mimeType: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
sizeBytes: number;
|
||||
|
||||
@ManyToOne(() => OrderLineAutoFulfillmentItem, item => item.attachments, { onDelete: 'CASCADE' })
|
||||
item: OrderLineAutoFulfillmentItem;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ManualLineFulfillmentStatus } from '../types/ManualLineFulfillmentStatus';
|
||||
import { OrderLine } from './OrderLine';
|
||||
|
||||
@Entity('order_line_manual_fulfillments')
|
||||
export class OrderLineManualFulfillment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: ManualLineFulfillmentStatus, default: ManualLineFulfillmentStatus.Pending })
|
||||
status: ManualLineFulfillmentStatus;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
fulfilledAt: Date | null;
|
||||
|
||||
@OneToOne(() => OrderLine, line => line.manualFulfillment, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
orderLine: OrderLine;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { OrderMessageSender } from '../types/OrderMessageSender';
|
||||
import { Order } from './Order';
|
||||
|
||||
@Entity('order_messages')
|
||||
export class OrderMessage {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => Order, order => order.messages, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
order: Order;
|
||||
|
||||
@Column({ type: 'enum', enum: OrderMessageSender })
|
||||
sender: OrderMessageSender;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
body: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OrderFailureReason } from './OrderFailureReason';
|
||||
import type { PreparedStockClaim } from './PreparedStockClaim';
|
||||
|
||||
type ClaimFromSessionSuccess = {
|
||||
success: true;
|
||||
stockClaims: PreparedStockClaim[];
|
||||
};
|
||||
|
||||
type ClaimFromSessionFailure = {
|
||||
success: false;
|
||||
failureReason: OrderFailureReason;
|
||||
};
|
||||
|
||||
export type ClaimFromSessionResult = ClaimFromSessionSuccess | ClaimFromSessionFailure;
|
||||
@@ -0,0 +1,5 @@
|
||||
export type GeneratedAccessToken = {
|
||||
token: string;
|
||||
lookup: string;
|
||||
encrypted: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ManualLineFulfillmentStatus {
|
||||
Pending = 'pending',
|
||||
Fulfilled = 'fulfilled'
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type DigitalStockItemRepoMock = {
|
||||
find: jest.Mock;
|
||||
update: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
|
||||
export type VariantRepoMock = {
|
||||
findOne: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
|
||||
export type DiscountCodeRepoMock = {
|
||||
findOne: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { InvoiceExtended } from '../../payment/types/InvoiceExtended';
|
||||
import type { Order } from '../entities/Order';
|
||||
import type { OrderState } from '../../../utils/order/types/OrderState';
|
||||
import type { OrderTotals } from '../../../utils/order/types/OrderTotals';
|
||||
|
||||
export type OrderExtended = Omit<Order, 'checkoutInvoice' | 'shippingInvoice'> & {
|
||||
state: OrderState;
|
||||
totals: OrderTotals;
|
||||
fiatCurrency: string;
|
||||
checkoutInvoice: InvoiceExtended | null;
|
||||
shippingInvoice: InvoiceExtended | null;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum OrderFailureReason {
|
||||
StockUnavailable = 'stock_unavailable',
|
||||
DiscountExhausted = 'discount_exhausted'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceStatusLabel';
|
||||
import type { OrderFailureReason } from './OrderFailureReason';
|
||||
import type { OrderStatus } from './OrderStatus';
|
||||
|
||||
export type OrderListItem = {
|
||||
id: string;
|
||||
status: OrderStatus;
|
||||
checkoutPaymentLabel: InvoiceStatusLabel | null;
|
||||
shippingPaymentLabel: InvoiceStatusLabel | null;
|
||||
totalFiat: number;
|
||||
grandTotalFiat: number | null;
|
||||
fiatCurrency: string;
|
||||
lineCount: number;
|
||||
unreadMessageCount: number;
|
||||
failureReason: OrderFailureReason | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum OrderMessageSender {
|
||||
Buyer = 'buyer',
|
||||
Staff = 'staff'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum OrderStatus {
|
||||
Unfulfilled = 'unfulfilled',
|
||||
Fulfilled = 'fulfilled',
|
||||
Unfulfillable = 'unfulfillable'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type PreparedDiscountRedeem = {
|
||||
discountCodeId: string;
|
||||
newRedemptionCount: number;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { DigitalStockItem } from '../../product/entities/DigitalStockItem';
|
||||
|
||||
export type PreparedManualStockClaim = {
|
||||
checkoutSessionLineId: string;
|
||||
variantId: string;
|
||||
newStockQuantity: number;
|
||||
};
|
||||
|
||||
export type PreparedDigitalStockClaim = {
|
||||
checkoutSessionLineId: string;
|
||||
items: DigitalStockItem[];
|
||||
};
|
||||
|
||||
export type PreparedStockClaim = PreparedManualStockClaim | PreparedDigitalStockClaim;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type {
|
||||
PreparedDigitalStockClaim,
|
||||
PreparedManualStockClaim,
|
||||
PreparedStockClaim
|
||||
} from '../types/PreparedStockClaim';
|
||||
|
||||
export const isPreparedManualStockClaim = (claim: PreparedStockClaim): claim is PreparedManualStockClaim =>
|
||||
'variantId' in claim;
|
||||
|
||||
export const isPreparedDigitalStockClaim = (claim: PreparedStockClaim): claim is PreparedDigitalStockClaim =>
|
||||
'items' in claim;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
|
||||
import { XmrRateModule } from '../xmrRate/XmrRateModule';
|
||||
import { Invoice } from './entities/Invoice';
|
||||
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
|
||||
import { InvoicePayment } from './entities/InvoicePayment';
|
||||
import { InvoicePaymentService } from './services/InvoicePaymentService';
|
||||
import { InvoiceService } from './services/InvoiceService';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
|
||||
MoneroWalletModule,
|
||||
XmrRateModule
|
||||
],
|
||||
providers: [InvoicePaymentService, InvoiceService],
|
||||
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
|
||||
})
|
||||
export class PaymentModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer';
|
||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||
import { PaymentMethod } from '../types/PaymentMethod';
|
||||
import { InvoiceReason } from '../types/InvoiceReason';
|
||||
import { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
|
||||
import { InvoicePayment } from './InvoicePayment';
|
||||
|
||||
@Entity('invoices')
|
||||
export class Invoice {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'enum', enum: InvoiceReason })
|
||||
reason: InvoiceReason;
|
||||
|
||||
@Column({ type: 'enum', enum: PaymentMethod })
|
||||
paymentMethod: PaymentMethod;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
amountFiat: number;
|
||||
|
||||
@Column({ length: 3 })
|
||||
fiatCurrency: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
paymentAddress: string;
|
||||
|
||||
@Column({ type: 'bigint', transformer: new ColumnBigIntTransformer() })
|
||||
expectedTotalAtomic: string;
|
||||
|
||||
@OneToMany(() => InvoicePayment, payment => payment.invoice, { cascade: true })
|
||||
payments: InvoicePayment[];
|
||||
|
||||
@OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true })
|
||||
moneroDetails: InvoiceMoneroDetails | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
|
||||
import { Invoice } from './Invoice';
|
||||
|
||||
@Entity('invoice_monero_details')
|
||||
export class InvoiceMoneroDetails {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@OneToOne(() => Invoice, invoice => invoice.moneroDetails, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
invoice: Invoice;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
paymentAddressIndex: number;
|
||||
|
||||
@Column({
|
||||
type: 'numeric',
|
||||
precision: 12,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer()
|
||||
})
|
||||
fiatPerXmrAtCreation: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
requiredConfirmations: number;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer';
|
||||
import { Invoice } from './Invoice';
|
||||
|
||||
@Entity('invoice_payments')
|
||||
export class InvoicePayment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => Invoice, invoice => invoice.payments, { onDelete: 'CASCADE' })
|
||||
@JoinColumn()
|
||||
invoice: Invoice;
|
||||
|
||||
@Column({ length: 64, unique: true })
|
||||
txHash: string;
|
||||
|
||||
@Column({ type: 'bigint', transformer: new ColumnBigIntTransformer() })
|
||||
amountAtomic: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
confirmations: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
||||
import { Invoice } from '../entities/Invoice';
|
||||
import { InvoicePayment } from '../entities/InvoicePayment';
|
||||
import { PaymentMethod } from '../types/PaymentMethod';
|
||||
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
|
||||
import { InvoicePaymentService } from './InvoicePaymentService';
|
||||
|
||||
const minIncomingAtomic = '100000000';
|
||||
|
||||
const buildTransfer = (
|
||||
overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
|
||||
): MoneroWalletRpcIncomingTransfer => ({
|
||||
txHash: 'tx-hash-1',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 1,
|
||||
subaddrIndex: 3,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
|
||||
({
|
||||
id: 'invoice-1',
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 },
|
||||
payments: [],
|
||||
...overrides
|
||||
}) as Invoice;
|
||||
|
||||
describe('InvoicePaymentService', () => {
|
||||
let service: InvoicePaymentServiceTest;
|
||||
let invoiceRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let pollQueryBuilder: {
|
||||
innerJoinAndSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
getMany: jest.Mock;
|
||||
};
|
||||
let walletRpcClient: {
|
||||
getIncomingTransfers: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let transactionalInvoiceRepo: {
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let transactionalInvoiceQueryBuilder: {
|
||||
leftJoinAndSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
setLock: jest.Mock;
|
||||
getOne: jest.Mock;
|
||||
};
|
||||
let paymentRepo: {
|
||||
update: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let insertQueryBuilder: {
|
||||
insert: jest.Mock;
|
||||
values: jest.Mock;
|
||||
orIgnore: jest.Mock;
|
||||
execute: jest.Mock;
|
||||
};
|
||||
let manager: EntityManager;
|
||||
let processInvoiceSpy: jest.SpyInstance;
|
||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
pollQueryBuilder = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
invoiceRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(pollQueryBuilder)
|
||||
};
|
||||
|
||||
transactionalInvoiceQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn()
|
||||
};
|
||||
|
||||
transactionalInvoiceRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(transactionalInvoiceQueryBuilder)
|
||||
};
|
||||
|
||||
insertQueryBuilder = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
orIgnore: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
|
||||
paymentRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
|
||||
};
|
||||
|
||||
manager = {
|
||||
getRepository: jest.fn((entity: { name: string }) => {
|
||||
if (entity.name === Invoice.name) {
|
||||
return transactionalInvoiceRepo;
|
||||
}
|
||||
|
||||
if (entity.name === InvoicePayment.name) {
|
||||
return paymentRepo;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected repository: ${entity.name}`);
|
||||
})
|
||||
} as unknown as EntityManager;
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback: (entityManager: EntityManager) => Promise<void>) =>
|
||||
callback(manager)
|
||||
)
|
||||
};
|
||||
|
||||
walletRpcClient = {
|
||||
getIncomingTransfers: jest.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
minByMethod: {
|
||||
[PaymentMethod.Xmr]: minIncomingAtomic
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
service = new InvoicePaymentService(
|
||||
invoiceRepo as unknown as Repository<Invoice>,
|
||||
dataSource as unknown as DataSource,
|
||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||
configService as unknown as ConfigService
|
||||
) as unknown as InvoicePaymentServiceTest;
|
||||
|
||||
processInvoiceSpy = jest.spyOn(service, 'processInvoice').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processInvoiceSpy.mockRestore();
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('pollInvoices', () => {
|
||||
it('returns early when there are no open invoices', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([]);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
|
||||
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns early when incoming transfers cannot be fetched', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]);
|
||||
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
||||
expect(processInvoiceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes transfers to each invoice by subaddress index', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([
|
||||
buildInvoice({
|
||||
id: 'invoice-1',
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
}),
|
||||
buildInvoice({
|
||||
id: 'invoice-2',
|
||||
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
})
|
||||
]);
|
||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([
|
||||
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
|
||||
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
|
||||
]);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
|
||||
expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 })
|
||||
]);
|
||||
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
|
||||
expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('continues processing other invoices when one invoice fails', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([
|
||||
buildInvoice({ id: 'invoice-1' }),
|
||||
buildInvoice({ id: 'invoice-2' })
|
||||
]);
|
||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
|
||||
processInvoiceSpy.mockRestore();
|
||||
processInvoiceSpy = jest
|
||||
.spyOn(service, 'processInvoice')
|
||||
.mockRejectedValueOnce(new Error('invoice-1 failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
|
||||
pollQueryBuilder.getMany.mockResolvedValue([
|
||||
buildInvoice({
|
||||
id: 'invoice-1',
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
}),
|
||||
buildInvoice({
|
||||
id: 'invoice-2',
|
||||
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
|
||||
})
|
||||
]);
|
||||
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
|
||||
|
||||
await service.pollInvoices();
|
||||
|
||||
expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
|
||||
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('processInvoice', () => {
|
||||
beforeEach(() => {
|
||||
processInvoiceSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing when the invoice is missing inside the transaction', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
|
||||
|
||||
await service.processInvoice('invoice-1', [buildTransfer()]);
|
||||
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips transfers below the configured minimum', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
||||
|
||||
await service.processInvoice('invoice-1', [
|
||||
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' })
|
||||
]);
|
||||
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
||||
|
||||
await service.processInvoice('invoice-1', [
|
||||
buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 })
|
||||
]);
|
||||
|
||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||
invoice: { id: 'invoice-1' },
|
||||
txHash: 'min-tx',
|
||||
amountAtomic: minIncomingAtomic,
|
||||
confirmations: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('processes a mixed batch of dust, new, and existing transfers', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||
buildInvoice({
|
||||
payments: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
txHash: 'known-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 1
|
||||
}
|
||||
] as InvoicePayment[]
|
||||
})
|
||||
);
|
||||
|
||||
await service.processInvoice('invoice-1', [
|
||||
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }),
|
||||
buildTransfer({ txHash: 'known-tx', confirmations: 4 }),
|
||||
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
|
||||
]);
|
||||
|
||||
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
|
||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||
invoice: { id: 'invoice-1' },
|
||||
txHash: 'new-tx',
|
||||
amountAtomic: '300000000',
|
||||
confirmations: 2
|
||||
});
|
||||
expect(insertQueryBuilder.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
it('inserts a new payment for transfers at or above the minimum', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
|
||||
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
|
||||
|
||||
await service.processInvoice('invoice-1', [transfer]);
|
||||
|
||||
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
|
||||
invoice: { id: 'invoice-1' },
|
||||
txHash: 'new-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 2
|
||||
});
|
||||
expect(insertQueryBuilder.orIgnore).toHaveBeenCalled();
|
||||
expect(insertQueryBuilder.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates confirmations for an existing payment when they change', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||
buildInvoice({
|
||||
payments: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
txHash: 'known-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 1
|
||||
}
|
||||
] as InvoicePayment[]
|
||||
})
|
||||
);
|
||||
|
||||
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]);
|
||||
|
||||
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not update an existing payment when confirmations are unchanged', async () => {
|
||||
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
|
||||
buildInvoice({
|
||||
payments: [
|
||||
{
|
||||
id: 'payment-1',
|
||||
txHash: 'known-tx',
|
||||
amountAtomic: '200000000',
|
||||
confirmations: 3
|
||||
}
|
||||
] as InvoicePayment[]
|
||||
})
|
||||
);
|
||||
|
||||
await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]);
|
||||
|
||||
expect(paymentRepo.update).not.toHaveBeenCalled();
|
||||
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Brackets, DataSource, Repository } from 'typeorm';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
|
||||
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
||||
import { Invoice } from '../entities/Invoice';
|
||||
import { InvoicePayment } from '../entities/InvoicePayment';
|
||||
import { PaymentMethod } from '../types/PaymentMethod';
|
||||
|
||||
@Injectable()
|
||||
export class InvoicePaymentService {
|
||||
private readonly logger = new Logger(InvoicePaymentService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Invoice)
|
||||
private readonly invoiceRepo: Repository<Invoice>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_10_SECONDS)
|
||||
private async pollInvoices(): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
const invoices = await this.invoiceRepo
|
||||
.createQueryBuilder('invoice')
|
||||
.innerJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
|
||||
.where('invoice.paymentMethod = :paymentMethod', { paymentMethod: PaymentMethod.Xmr })
|
||||
.andWhere(
|
||||
new Brackets(qb => {
|
||||
qb.where('invoice.expiresAt > :now', { now }).orWhere(
|
||||
`"moneroDetails"."requiredConfirmations" > 0 AND EXISTS (
|
||||
SELECT 1 FROM invoice_payments pollPayment
|
||||
WHERE pollPayment."invoiceId" = invoice.id
|
||||
AND pollPayment.confirmations < "moneroDetails"."requiredConfirmations"
|
||||
)`
|
||||
);
|
||||
})
|
||||
)
|
||||
.getMany();
|
||||
|
||||
if (invoices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subaddrIndices = [...new Set(invoices.map(invoice => invoice.moneroDetails!.paymentAddressIndex))];
|
||||
|
||||
let transfers: MoneroWalletRpcIncomingTransfer[];
|
||||
|
||||
try {
|
||||
transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const transfersBySubaddrIndex = groupIncomingMoneroTransfersBySubaddrIndex(transfers);
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const moneroDetails = invoice.moneroDetails!;
|
||||
|
||||
const invoiceTransfers = transfersBySubaddrIndex.get(moneroDetails.paymentAddressIndex) ?? [];
|
||||
|
||||
try {
|
||||
await this.processInvoice(invoice.id, invoiceTransfers);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to process invoice ${invoice.id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise<void> {
|
||||
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
|
||||
|
||||
await this.dataSource.transaction(async manager => {
|
||||
const invoiceRepo = manager.getRepository(Invoice);
|
||||
const paymentRepo = manager.getRepository(InvoicePayment);
|
||||
|
||||
const invoice = await invoiceRepo
|
||||
.createQueryBuilder('invoice')
|
||||
.leftJoinAndSelect('invoice.payments', 'payment')
|
||||
.where('invoice.id = :invoiceId', { invoiceId })
|
||||
.setLock('pessimistic_write', undefined, ['invoice'])
|
||||
.getOne();
|
||||
|
||||
if (!invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
|
||||
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
|
||||
|
||||
for (const transfer of transfers) {
|
||||
const existing = knownByTxHash.get(transfer.txHash);
|
||||
|
||||
if (existing) {
|
||||
if (existing.confirmations !== transfer.confirmations) {
|
||||
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await paymentRepo
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({
|
||||
invoice: { id: invoiceId },
|
||||
txHash: transfer.txHash,
|
||||
amountAtomic: transfer.amountAtomic,
|
||||
confirmations: transfer.confirmations
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { Repository } from 'typeorm';
|
||||
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||
import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
||||
import { Invoice } from '../entities/Invoice';
|
||||
import { InvoiceReason } from '../types/InvoiceReason';
|
||||
import { PaymentMethod } from '../types/PaymentMethod';
|
||||
import { InvoiceService } from './InvoiceService';
|
||||
|
||||
const confirmationTiers = [{ upToTotalFiat: 1000, minConfirmations: 1 }];
|
||||
|
||||
describe('InvoiceService', () => {
|
||||
let service: InvoiceService;
|
||||
let invoiceRepo: {
|
||||
create: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
let walletRpcClient: {
|
||||
createAddress: jest.Mock;
|
||||
};
|
||||
let xmrRateService: {
|
||||
getLiveFiatPerXmr: jest.Mock;
|
||||
};
|
||||
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
invoiceRepo = {
|
||||
create: jest.fn(data => ({ id: 'invoice-1', ...data })),
|
||||
save: jest.fn(async (invoice: Invoice) => invoice)
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'shopSettings') {
|
||||
return { shopFiatCurrency: 'USD' };
|
||||
}
|
||||
|
||||
if (key === 'shopSettings.monero') {
|
||||
return { confirmationTiers };
|
||||
}
|
||||
|
||||
if (key === 'order') {
|
||||
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})
|
||||
};
|
||||
|
||||
walletRpcClient = {
|
||||
createAddress: jest.fn().mockResolvedValue({
|
||||
address: '4MoneroPaymentAddressExample',
|
||||
address_index: 12
|
||||
})
|
||||
};
|
||||
|
||||
xmrRateService = {
|
||||
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
|
||||
};
|
||||
|
||||
service = new InvoiceService(
|
||||
invoiceRepo as unknown as Repository<Invoice>,
|
||||
configService as unknown as ConfigService,
|
||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||
xmrRateService as unknown as XmrRateService
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
errorLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
const issueCheckoutInvoice = () =>
|
||||
service.issueInvoice({
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
reason: InvoiceReason.Checkout,
|
||||
contextId: 'session-uuid',
|
||||
amountFiat: 15
|
||||
});
|
||||
|
||||
it('throws when the live XMR rate is unavailable for checkout invoices', async () => {
|
||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
|
||||
|
||||
await expect(issueCheckoutInvoice()).rejects.toThrow(
|
||||
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
|
||||
);
|
||||
|
||||
expect(walletRpcClient.createAddress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws and logs when wallet address allocation fails for checkout invoices', async () => {
|
||||
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(issueCheckoutInvoice()).rejects.toThrow(
|
||||
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
|
||||
);
|
||||
|
||||
expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Monero payment address'));
|
||||
expect(invoiceRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a checkout invoice with converted totals and monero details', async () => {
|
||||
const invoice = await issueCheckoutInvoice();
|
||||
|
||||
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
|
||||
expect(invoiceRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reason: InvoiceReason.Checkout,
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
amountFiat: 15,
|
||||
fiatCurrency: 'USD',
|
||||
paymentAddress: '4MoneroPaymentAddressExample',
|
||||
expectedTotalAtomic: '100000000000',
|
||||
expiresAt: expect.any(Date),
|
||||
moneroDetails: {
|
||||
paymentAddressIndex: 12,
|
||||
fiatPerXmrAtCreation: 150,
|
||||
requiredConfirmations: 1
|
||||
}
|
||||
})
|
||||
);
|
||||
expect(invoiceRepo.save).toHaveBeenCalled();
|
||||
expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 }));
|
||||
});
|
||||
|
||||
it('uses a higher confirmation tier for larger checkout amounts', async () => {
|
||||
configService.get.mockImplementation((key: string) => {
|
||||
if (key === 'shopSettings') {
|
||||
return { shopFiatCurrency: 'USD' };
|
||||
}
|
||||
|
||||
if (key === 'shopSettings.monero') {
|
||||
return {
|
||||
confirmationTiers: [
|
||||
{ upToTotalFiat: 10, minConfirmations: 1 },
|
||||
{ upToTotalFiat: 100, minConfirmations: 5 },
|
||||
{ minConfirmations: 10 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (key === 'order') {
|
||||
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await service.issueInvoice({
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
reason: InvoiceReason.Checkout,
|
||||
contextId: 'session-large',
|
||||
amountFiat: 75
|
||||
});
|
||||
|
||||
expect(invoiceRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
moneroDetails: expect.objectContaining({
|
||||
requiredConfirmations: 5
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses shipping-specific messages and address labels for shipping invoices', async () => {
|
||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
|
||||
|
||||
await expect(
|
||||
service.issueInvoice({
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
reason: InvoiceReason.Shipping,
|
||||
contextId: 'order-1',
|
||||
amountFiat: 5
|
||||
})
|
||||
).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
"We can't quote shipping in XMR right now. Please try again in a few minutes."
|
||||
)
|
||||
);
|
||||
|
||||
xmrRateService.getLiveFiatPerXmr.mockReturnValue(150);
|
||||
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(
|
||||
service.issueInvoice({
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
reason: InvoiceReason.Shipping,
|
||||
contextId: 'order-1',
|
||||
amountFiat: 5
|
||||
})
|
||||
).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
"We can't take shipping payments right now. Please try again in a few minutes."
|
||||
)
|
||||
);
|
||||
|
||||
walletRpcClient.createAddress.mockResolvedValue({
|
||||
address: '4ShippingPaymentAddressExample',
|
||||
address_index: 3
|
||||
});
|
||||
|
||||
await service.issueInvoice({
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
reason: InvoiceReason.Shipping,
|
||||
contextId: 'order-1',
|
||||
amountFiat: 5
|
||||
});
|
||||
|
||||
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository } from 'typeorm';
|
||||
import dayjs from '../../../plugins/dayjs';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
|
||||
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
|
||||
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
|
||||
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
|
||||
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
|
||||
import { Invoice } from '../entities/Invoice';
|
||||
import { InvoiceReason } from '../types/InvoiceReason';
|
||||
import type { IssueInvoiceData } from '../types/IssueInvoiceData';
|
||||
import type { InvoiceReasonData } from '../types/InvoiceReasonData';
|
||||
import { PaymentMethod } from '../types/PaymentMethod';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceService {
|
||||
private readonly logger = new Logger(InvoiceService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Invoice)
|
||||
private readonly invoiceRepo: Repository<Invoice>,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
||||
private readonly xmrRateService: XmrRateService
|
||||
) {}
|
||||
|
||||
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
|
||||
switch (data.paymentMethod) {
|
||||
case PaymentMethod.Xmr:
|
||||
return this.issueXmrInvoice(data);
|
||||
}
|
||||
}
|
||||
|
||||
private async issueXmrInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise<Invoice> {
|
||||
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
|
||||
const { confirmationTiers } = this.configService.get('shopSettings.monero') as Config['shopSettings']['monero'];
|
||||
|
||||
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
|
||||
reason,
|
||||
contextId
|
||||
);
|
||||
|
||||
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
|
||||
|
||||
if (fiatPerXmr === null) {
|
||||
throw new ServiceUnavailableException(rateUnavailableMessage);
|
||||
}
|
||||
|
||||
let paymentAddress: string;
|
||||
let paymentAddressIndex: number;
|
||||
|
||||
try {
|
||||
const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel);
|
||||
|
||||
paymentAddress = address;
|
||||
paymentAddressIndex = address_index;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to allocate Monero payment address: ${getErrorMessage(error)}`);
|
||||
|
||||
throw new ServiceUnavailableException(unavailableMessage);
|
||||
}
|
||||
|
||||
const requiredConfirmations = resolveMinConfirmations(amountFiat, confirmationTiers);
|
||||
|
||||
const expiresAt = dayjs().add(validityMs, 'millisecond').toDate();
|
||||
|
||||
const expectedTotalXmr = convertFiatToXmr(amountFiat, fiatPerXmr);
|
||||
const expectedTotalAtomic = convertXmrToXmrAtomic(expectedTotalXmr);
|
||||
|
||||
const invoice = this.invoiceRepo.create({
|
||||
reason,
|
||||
paymentMethod: PaymentMethod.Xmr,
|
||||
amountFiat,
|
||||
fiatCurrency: shopFiatCurrency,
|
||||
expiresAt,
|
||||
paymentAddress,
|
||||
expectedTotalAtomic,
|
||||
moneroDetails: {
|
||||
paymentAddressIndex,
|
||||
fiatPerXmrAtCreation: fiatPerXmr,
|
||||
requiredConfirmations
|
||||
}
|
||||
});
|
||||
|
||||
return this.invoiceRepo.save(invoice);
|
||||
}
|
||||
|
||||
private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData {
|
||||
const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
|
||||
|
||||
switch (reason) {
|
||||
case InvoiceReason.Checkout:
|
||||
return {
|
||||
addressLabel: `checkout - ${contextId}`,
|
||||
validityMs: checkoutValidityMs,
|
||||
unavailableMessage: "We can't take payments right now. Please try again in a few minutes.",
|
||||
rateUnavailableMessage: "We can't show a price right now. Please try again in a few minutes."
|
||||
};
|
||||
case InvoiceReason.Shipping:
|
||||
return {
|
||||
addressLabel: `order-shipping - ${contextId}`,
|
||||
validityMs: shippingPaymentValidityMs,
|
||||
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
|
||||
rateUnavailableMessage:
|
||||
"We can't quote shipping in XMR right now. Please try again in a few minutes."
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Invoice } from '../entities/Invoice';
|
||||
import type { InvoicePaymentExtended } from './InvoicePaymentExtended';
|
||||
import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceStatusLabel';
|
||||
|
||||
export type InvoiceExtended = Omit<Invoice, 'payments'> & {
|
||||
statusLabel: InvoiceStatusLabel | null;
|
||||
expectedTotalCrypto: string;
|
||||
payments: InvoicePaymentExtended[];
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { InvoicePayment } from '../entities/InvoicePayment';
|
||||
|
||||
export type InvoicePaymentExtended = InvoicePayment & {
|
||||
amountCrypto: string;
|
||||
isConfirmed: boolean;
|
||||
confirmationsLabel: string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
|
||||
|
||||
export type InvoicePaymentServiceTest = {
|
||||
pollInvoices: () => Promise<void>;
|
||||
processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise<void>;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user