init
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum InvoiceReason {
|
||||
Checkout = 'checkout',
|
||||
Shipping = 'shipping'
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type InvoiceReasonData = {
|
||||
addressLabel: string;
|
||||
validityMs: number;
|
||||
unavailableMessage: string;
|
||||
rateUnavailableMessage: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { InvoiceReason } from './InvoiceReason';
|
||||
import type { PaymentMethod } from './PaymentMethod';
|
||||
|
||||
export type IssueInvoiceData = {
|
||||
paymentMethod: PaymentMethod;
|
||||
reason: InvoiceReason;
|
||||
contextId: string;
|
||||
amountFiat: number;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum PaymentMethod {
|
||||
Xmr = 'xmr'
|
||||
}
|
||||
Reference in New Issue
Block a user