This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -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'
}