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,33 @@
import { FileValidator } from '@nestjs/common/pipes/file/file-validator.interface';
import { fileTypeFromBuffer } from 'file-type';
import type { DiskFileTypeValidatorOptions } from '../types/DiskFileTypeValidatorOptions';
import type { ValidatedUploadFile } from '../types/ValidatedUploadFile';
export class BufferFileTypeValidator extends FileValidator<DiskFileTypeValidatorOptions> {
async isValid(file?: Express.Multer.File): Promise<boolean> {
if (!file?.buffer) {
return false;
}
try {
const detected = await fileTypeFromBuffer(file.buffer);
if (!detected?.mime.match(this.validationOptions.fileType)) {
return false;
}
(file as ValidatedUploadFile).detectedMimeType = detected.mime;
return true;
} catch {
return false;
}
}
buildErrorMessage(file?: Express.Multer.File): string {
const declared = file?.mimetype ? ` (declared type is ${file.mimetype})` : '';
return `Validation failed (file type is not allowed${declared})`;
}
}
@@ -0,0 +1,13 @@
export class ColumnBigIntTransformer {
to(data: string | null): string | null {
return data;
}
from(data: string | null): string | null {
if (data == null) {
return null;
}
return String(data);
}
}
@@ -0,0 +1,15 @@
import Decimal from 'decimal.js';
export class ColumnNumericTransformer {
to(data: number | null) {
return data;
}
from(data: string | null) {
if (data == null) {
return null;
}
return new Decimal(data).toNumber();
}
}
@@ -0,0 +1,38 @@
import { FileValidator } from '@nestjs/common/pipes/file/file-validator.interface';
import { fileTypeFromFile } from 'file-type';
import type { DiskFileTypeValidatorOptions } from '../types/DiskFileTypeValidatorOptions';
import { removeFileFromDisk } from './removeFileFromDisk';
import type { ValidatedUploadFile } from '../types/ValidatedUploadFile';
export class DiskFileTypeValidator extends FileValidator<DiskFileTypeValidatorOptions> {
async isValid(file?: Express.Multer.File): Promise<boolean> {
if (!file?.path) {
return false;
}
try {
const detected = await fileTypeFromFile(file.path);
if (!detected?.mime.match(this.validationOptions.fileType)) {
await removeFileFromDisk(file.path, DiskFileTypeValidator.name);
return false;
}
(file as ValidatedUploadFile).detectedMimeType = detected.mime;
return true;
} catch {
await removeFileFromDisk(file.path, DiskFileTypeValidator.name);
return false;
}
}
buildErrorMessage(file?: Express.Multer.File): string {
const declared = file?.mimetype ? ` (declared type is ${file.mimetype})` : '';
return `Validation failed (file type is not allowed${declared})`;
}
}
@@ -0,0 +1,12 @@
import { addAtomic } from './addAtomic';
describe('addAtomic', () => {
it('sums atomic amounts as integer strings', () => {
expect(addAtomic('250000000', '9060000')).toBe('259060000');
});
it('returns an integer string without scientific notation', () => {
expect(addAtomic('1000000000000', '1000000000000')).toBe('2000000000000');
expect(addAtomic('1000000000000', '1000000000000')).not.toMatch(/e/i);
});
});
+5
View File
@@ -0,0 +1,5 @@
import Decimal from 'decimal.js';
export const addAtomic = (leftAtomic: string, rightAtomic: string): string => {
return new Decimal(leftAtomic).plus(rightAtomic).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString();
};
@@ -0,0 +1,19 @@
import { isAtomicGte } from './isAtomicGte';
describe('isAtomicGte', () => {
it('returns true when left atomic amount is greater than right', () => {
expect(isAtomicGte('200000000000', '100000000000')).toBe(true);
});
it('returns true when atomic amounts are equal', () => {
expect(isAtomicGte('100000000000', '100000000000')).toBe(true);
});
it('returns false when left atomic amount is less than right', () => {
expect(isAtomicGte('99999999999', '100000000000')).toBe(false);
});
it('compares large atomic amounts without precision loss', () => {
expect(isAtomicGte('1000000000000000000', '999999999999999999')).toBe(true);
});
});
+5
View File
@@ -0,0 +1,5 @@
import Decimal from 'decimal.js';
export const isAtomicGte = (leftAtomic: string, rightAtomic: string): boolean => {
return new Decimal(leftAtomic).gte(rightAtomic);
};
@@ -0,0 +1,12 @@
import { subtractAtomic } from './subtractAtomic';
describe('subtractAtomic', () => {
it('subtracts atomic amounts as integer strings', () => {
expect(subtractAtomic('259070000', '259060000')).toBe('10000');
});
it('returns an integer string without scientific notation', () => {
expect(subtractAtomic('1000000000000', '1')).toBe('999999999999');
expect(subtractAtomic('1000000000000', '1')).not.toMatch(/e/i);
});
});
@@ -0,0 +1,5 @@
import Decimal from 'decimal.js';
export const subtractAtomic = (minuendAtomic: string, subtrahendAtomic: string): string => {
return new Decimal(minuendAtomic).minus(subtrahendAtomic).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString();
};
@@ -0,0 +1,11 @@
import { buildAllowedMimeRegex } from './buildAllowedMimeRegex';
describe('buildAllowedMimeRegex', () => {
it('matches only the allowed mime types', () => {
const regex = buildAllowedMimeRegex(['image/png', 'image/jpeg']);
expect('image/png').toMatch(regex);
expect('image/jpeg').toMatch(regex);
expect('application/pdf').not.toMatch(regex);
});
});
@@ -0,0 +1,2 @@
export const buildAllowedMimeRegex = (allowedMimes: readonly string[]): RegExp =>
new RegExp(`^(${allowedMimes.join('|')})$`);
@@ -0,0 +1,22 @@
import { getQtyByVariantIdFromCart } from './getQtyByVariantIdFromCart';
describe('getQtyByVariantIdFromCart', () => {
it('returns an empty map for an empty cart', () => {
expect(getQtyByVariantIdFromCart([])).toEqual(new Map());
});
it('sums quantities for duplicate variant rows', () => {
expect(
getQtyByVariantIdFromCart([
{ variantId: 'variant-1', qty: 2 },
{ variantId: 'variant-1', qty: 3 },
{ variantId: 'variant-2', qty: 1 }
])
).toEqual(
new Map([
['variant-1', 5],
['variant-2', 1]
])
);
});
});
@@ -0,0 +1,11 @@
import type { CookieCart } from '../../modules/storefrontCore/types/cart/CookieCart';
export const getQtyByVariantIdFromCart = (cart: CookieCart): Map<string, number> => {
const map = new Map<string, number>();
for (const line of cart) {
map.set(line.variantId, (map.get(line.variantId) ?? 0) + line.qty);
}
return map;
};
@@ -0,0 +1,16 @@
import { getTotalCartQtyFromCart } from './getTotalCartQtyFromCart';
describe('getTotalCartQtyFromCart', () => {
it('returns zero for an empty cart', () => {
expect(getTotalCartQtyFromCart([])).toBe(0);
});
it('sums line quantities', () => {
expect(
getTotalCartQtyFromCart([
{ variantId: 'variant-1', qty: 2 },
{ variantId: 'variant-2', qty: 3 }
])
).toBe(5);
});
});
@@ -0,0 +1,3 @@
import type { CookieCart } from '../../modules/storefrontCore/types/cart/CookieCart';
export const getTotalCartQtyFromCart = (cart: CookieCart): number => cart.reduce((sum, line) => sum + line.qty, 0);
@@ -0,0 +1,109 @@
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { deriveCheckoutSessionState } from './deriveCheckoutSessionState';
describe('deriveCheckoutSessionState', () => {
const openInvoice = {
paymentMethod: PaymentMethod.Xmr,
expectedTotalAtomic: '1000',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
moneroDetails: { requiredConfirmations: 1 },
payments: [] as { amountAtomic: string; confirmations: number }[]
};
it('detects cancelled sessions', () => {
const cancelledState = deriveCheckoutSessionState({ cancelledAt: new Date() });
const openState = deriveCheckoutSessionState({ cancelledAt: null });
expect(cancelledState.isCancelled).toBe(true);
expect(openState.isCancelled).toBe(false);
});
it('keeps an unpaid non-expired session open for payment', () => {
const state = deriveCheckoutSessionState({
cancelledAt: null,
invoice: openInvoice
});
expect(state.isOpenForPayment).toBe(true);
expect(state.isPastDue).toBe(false);
});
it('treats underpaid non-expired sessions as still open for payment', () => {
const state = deriveCheckoutSessionState({
cancelledAt: null,
invoice: {
...openInvoice,
payments: [{ amountAtomic: '100', confirmations: 0 }]
}
});
expect(state.invoice?.isUnderpaid).toBe(true);
expect(state.isOpenForPayment).toBe(true);
expect(state.isPastDue).toBe(false);
});
it('closes payment once the invoice is paid in full', () => {
const state = deriveCheckoutSessionState({
cancelledAt: null,
invoice: {
...openInvoice,
payments: [{ amountAtomic: '1000', confirmations: 0 }]
}
});
expect(state.invoice?.isPaidSufficient).toBe(true);
expect(state.isOpenForPayment).toBe(false);
expect(state.isPastDue).toBe(false);
});
it('treats expired unpaid sessions as past due', () => {
const state = deriveCheckoutSessionState({
cancelledAt: null,
invoice: {
...openInvoice,
expiresAt: new Date('2020-01-01T00:00:00.000Z')
}
});
expect(state.invoice?.isExpired).toBe(true);
expect(state.isOpenForPayment).toBe(true);
expect(state.isPastDue).toBe(true);
});
it('does not treat a fully paid expired invoice as past due', () => {
const state = deriveCheckoutSessionState({
cancelledAt: null,
invoice: {
...openInvoice,
payments: [{ amountAtomic: '1000', confirmations: 0 }],
expiresAt: new Date('2020-01-01T00:00:00.000Z')
}
});
expect(state.invoice?.isExpired).toBe(true);
expect(state.invoice?.isPaidSufficient).toBe(true);
expect(state.isOpenForPayment).toBe(false);
expect(state.isPastDue).toBe(false);
});
it('blocks payment on cancelled sessions even when the invoice is still valid', () => {
const state = deriveCheckoutSessionState({
cancelledAt: new Date(),
invoice: openInvoice
});
expect(state.isCancelled).toBe(true);
expect(state.isOpenForPayment).toBe(false);
expect(state.isPastDue).toBe(false);
});
it('does not open payment when the session has no invoice', () => {
const state = deriveCheckoutSessionState({
cancelledAt: null
});
expect(state.invoice).toBeNull();
expect(state.isOpenForPayment).toBe(false);
expect(state.isPastDue).toBe(false);
});
});
@@ -0,0 +1,18 @@
import { deriveInvoiceState } from '../invoice/deriveInvoiceState';
import { isSet } from '../isSet';
import type { CheckoutSessionStateInput } from './types/CheckoutSessionStateInput';
import type { CheckoutSessionState } from './types/CheckoutSessionState';
export const deriveCheckoutSessionState = (session: CheckoutSessionStateInput): CheckoutSessionState => {
const isCancelled = isSet(session.cancelledAt);
const invoice = session.invoice ? deriveInvoiceState(session.invoice) : null;
const isOpenForPayment = !isCancelled && invoice !== null && !invoice.isPaidSufficient;
const isPastDue = isOpenForPayment && invoice.isExpired;
return {
isCancelled,
invoice,
isOpenForPayment,
isPastDue
};
};
@@ -0,0 +1,67 @@
import { deriveCheckoutTotals } from './deriveCheckoutTotals';
import type { CheckoutTotalsInput } from './types/CheckoutTotalsInput';
const baseCheckout = (overrides: Partial<CheckoutTotalsInput> = {}): CheckoutTotalsInput => ({
lines: [{ lineSubtotalFiat: 10 }],
discounts: [],
invoice: { amountFiat: 10 },
...overrides
});
describe('deriveCheckoutTotals', () => {
it('sums subtotal from multiple lines', () => {
const totals = deriveCheckoutTotals(
baseCheckout({
lines: [{ lineSubtotalFiat: 10 }, { lineSubtotalFiat: 25.5 }]
})
);
expect(totals.subtotalFiat).toBe(35.5);
});
it('sums discount total', () => {
const totals = deriveCheckoutTotals(
baseCheckout({
discounts: [{ amountFiat: 2 }, { amountFiat: 3.5 }]
})
);
expect(totals.discountTotalFiat).toBe(5.5);
});
it('uses invoice amount for totalFiat', () => {
const totals = deriveCheckoutTotals(
baseCheckout({
lines: [{ lineSubtotalFiat: 100 }],
discounts: [{ amountFiat: 10 }],
invoice: { amountFiat: 90 }
})
);
expect(totals.subtotalFiat).toBe(100);
expect(totals.discountTotalFiat).toBe(10);
expect(totals.totalFiat).toBe(90);
});
it('defaults missing invoice to totalFiat 0', () => {
const totals = deriveCheckoutTotals(
baseCheckout({
invoice: null
})
);
expect(totals.totalFiat).toBe(0);
});
it('defaults missing lines and discounts to zero', () => {
const totals = deriveCheckoutTotals(
baseCheckout({
lines: undefined,
discounts: undefined
})
);
expect(totals.subtotalFiat).toBe(0);
expect(totals.discountTotalFiat).toBe(0);
});
});
@@ -0,0 +1,15 @@
import { sumByKey } from '../sumByKey';
import type { CheckoutTotals } from './types/CheckoutTotals';
import type { CheckoutTotalsInput } from './types/CheckoutTotalsInput';
export const deriveCheckoutTotals = (checkout: CheckoutTotalsInput): CheckoutTotals => {
const subtotalFiat = sumByKey(checkout.lines ?? [], 'lineSubtotalFiat');
const discountTotalFiat = sumByKey(checkout.discounts ?? [], 'amountFiat');
const totalFiat = checkout.invoice?.amountFiat ?? 0;
return {
subtotalFiat,
discountTotalFiat,
totalFiat
};
};
@@ -0,0 +1,8 @@
import type { InvoiceState } from '../../invoice/types/InvoiceState';
export type CheckoutSessionState = {
isCancelled: boolean;
invoice: InvoiceState | null;
isOpenForPayment: boolean;
isPastDue: boolean;
};
@@ -0,0 +1,6 @@
import type { InvoiceStateInput } from '../../invoice/types/InvoiceStateInput';
export type CheckoutSessionStateInput = {
cancelledAt: Date | null;
invoice?: InvoiceStateInput | null;
};
@@ -0,0 +1,5 @@
export type CheckoutTotals = {
subtotalFiat: number;
discountTotalFiat: number;
totalFiat: number;
};
@@ -0,0 +1,5 @@
export type CheckoutTotalsInput = {
lines?: Array<{ lineSubtotalFiat: number }>;
discounts?: Array<{ amountFiat: number }>;
invoice?: { amountFiat: number } | null;
};
@@ -0,0 +1,20 @@
import { randomUUID } from 'node:crypto';
import { mkdirSync } from 'node:fs';
import { diskStorage } from 'multer';
import { getFileExtensionFromMimeType } from './getFileExtensionFromMimeType';
export const createDiskStorageUploadOptions = (uploadDir: string) => ({
storage: diskStorage({
destination: (_req, _file, cb) => {
mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (_req, file, cb) => {
const ext = getFileExtensionFromMimeType(file.mimetype);
cb(null, `${randomUUID()}.${ext}`);
}
})
});
+26
View File
@@ -0,0 +1,26 @@
import { MaxFileSizeValidator, ParseFilePipe } from '@nestjs/common';
import type { UploadFileSource } from '../types/UploadFileSource';
import { buildAllowedMimeRegex } from './buildAllowedMimeRegex';
import { BufferFileTypeValidator } from './BufferFileTypeValidator';
import { DiskFileTypeValidator } from './DiskFileTypeValidator';
export const createUploadFilePipe = (
allowedMimes: readonly string[],
maxFileBytes: number,
source: UploadFileSource = 'disk'
): ParseFilePipe => {
const fileType = buildAllowedMimeRegex(allowedMimes);
const fileTypeValidator =
source === 'buffer' ? new BufferFileTypeValidator({ fileType }) : new DiskFileTypeValidator({ fileType });
return new ParseFilePipe({
validators: [
new MaxFileSizeValidator({
maxSize: maxFileBytes
}),
fileTypeValidator
]
});
};
@@ -0,0 +1,11 @@
import dayjs from '../plugins/dayjs';
import { formatRelativeTimeAgo } from './formatRelativeTimeAgo';
describe('formatRelativeTimeAgo', () => {
it('formats a relative time from the provided now date', () => {
const now = new Date('2026-01-02T12:00:00.000Z');
const earlier = new Date('2026-01-02T11:00:00.000Z');
expect(formatRelativeTimeAgo(earlier, now)).toBe(dayjs(earlier).from(dayjs(now)));
});
});
@@ -0,0 +1,5 @@
import dayjs from '../plugins/dayjs';
export const formatRelativeTimeAgo = (date: Date, now = new Date()): string => {
return dayjs(date).from(dayjs(now));
};
@@ -0,0 +1,5 @@
import QRCode from 'qrcode';
export const generateQrCodeDataUrl = (data: string, size = 220): Promise<string> => {
return QRCode.toDataURL(data, { width: size, margin: 1 });
};
+12
View File
@@ -0,0 +1,12 @@
import { getErrorMessage } from './getErrorMessage';
describe('getErrorMessage', () => {
it('returns the message from Error instances', () => {
expect(getErrorMessage(new Error('boom'))).toBe('boom');
});
it('stringifies non-error values', () => {
expect(getErrorMessage('plain')).toBe('plain');
expect(getErrorMessage(404)).toBe('404');
});
});
+1
View File
@@ -0,0 +1 @@
export const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
@@ -0,0 +1,26 @@
import { getFileExtensionFromMimeType } from './getFileExtensionFromMimeType';
describe('getFileExtensionFromMimeType', () => {
it('maps JPEG MIME subtypes to jpg', () => {
expect(getFileExtensionFromMimeType('image/jpeg')).toBe('jpg');
expect(getFileExtensionFromMimeType('image/jpg')).toBe('jpg');
});
it('maps ICO MIME subtypes to ico', () => {
expect(getFileExtensionFromMimeType('image/x-icon')).toBe('ico');
expect(getFileExtensionFromMimeType('image/vnd.microsoft.icon')).toBe('ico');
});
it('returns the MIME subtype for common extensions', () => {
expect(getFileExtensionFromMimeType('image/png')).toBe('png');
expect(getFileExtensionFromMimeType('application/pdf')).toBe('pdf');
});
it('strips structured suffix before mapping', () => {
expect(getFileExtensionFromMimeType('image/svg+xml')).toBe('svg');
});
it('falls back to bin for invalid MIME types', () => {
expect(getFileExtensionFromMimeType('invalid')).toBe('bin');
});
});
@@ -0,0 +1,13 @@
export const getFileExtensionFromMimeType = (mimetype: string): string => {
const sub = (mimetype.split('/')[1]?.split('+')[0] ?? 'bin').toLowerCase();
if (sub === 'jpeg' || sub === 'jpg') {
return 'jpg';
}
if (sub === 'x-icon' || sub === 'vnd.microsoft.icon') {
return 'ico';
}
return sub;
};
@@ -0,0 +1,37 @@
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { deriveInvoiceConfirmationsMet } from './deriveInvoiceConfirmationsMet';
describe('deriveInvoiceConfirmationsMet', () => {
const invoice = {
paymentMethod: PaymentMethod.Xmr,
moneroDetails: { requiredConfirmations: 3 }
};
it('returns false when there are no payments', () => {
expect(deriveInvoiceConfirmationsMet(invoice)).toBe(false);
});
it('returns false when any payment is below the required confirmations', () => {
expect(
deriveInvoiceConfirmationsMet({
...invoice,
payments: [
{ confirmations: 3 },
{ confirmations: 2 }
]
})
).toBe(false);
});
it('returns true when every payment meets the required confirmations', () => {
expect(
deriveInvoiceConfirmationsMet({
...invoice,
payments: [
{ confirmations: 3 },
{ confirmations: 4 }
]
})
).toBe(true);
});
});
@@ -0,0 +1,13 @@
import type { InvoiceConfirmationsInput } from './types/InvoiceConfirmationsInput';
import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations';
export const deriveInvoiceConfirmationsMet = (invoice: InvoiceConfirmationsInput): boolean => {
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
const payments = invoice.payments;
if (!payments || payments.length === 0) {
return false;
}
return payments.every(payment => payment.confirmations >= requiredConfirmations);
};
@@ -0,0 +1,115 @@
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { deriveInvoiceState } from './deriveInvoiceState';
describe('deriveInvoiceState', () => {
const invoice = {
paymentMethod: PaymentMethod.Xmr,
expectedTotalAtomic: '1000',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
moneroDetails: { requiredConfirmations: 1 },
payments: [] as { amountAtomic: string; confirmations: number }[]
};
it('derives awaiting payment when nothing was received', () => {
const state = deriveInvoiceState(invoice);
expect(state.isAwaitingPayment).toBe(true);
expect(state.isUnderpaid).toBe(false);
expect(state.isPaidSufficient).toBe(false);
expect(state.isPaidAwaitingConfirmations).toBe(false);
expect(state.isPaidAndConfirmed).toBe(false);
expect(state.hasPendingConfirmations).toBe(false);
});
it('derives underpaid from invoice payments', () => {
const underpaidState = deriveInvoiceState({
...invoice,
payments: [{ amountAtomic: '100', confirmations: 0 }]
});
const unpaidState = deriveInvoiceState(invoice);
expect(underpaidState.isUnderpaid).toBe(true);
expect(underpaidState.isAwaitingPayment).toBe(false);
expect(unpaidState.isUnderpaid).toBe(false);
});
it('derives paid sufficient from invoice payments', () => {
const state = deriveInvoiceState({
...invoice,
payments: [{ amountAtomic: '1000', confirmations: 0 }]
});
expect(state.isPaidSufficient).toBe(true);
expect(state.isAwaitingPayment).toBe(false);
});
it('derives awaiting confirmations when paid sufficient but confirmations are pending', () => {
const state = deriveInvoiceState({
...invoice,
moneroDetails: { requiredConfirmations: 3 },
payments: [{ amountAtomic: '1000', confirmations: 1 }]
});
expect(state.isPaidSufficient).toBe(true);
expect(state.isPaidAwaitingConfirmations).toBe(true);
expect(state.isPaidAndConfirmed).toBe(false);
expect(state.hasPendingConfirmations).toBe(true);
});
it('derives paid and confirmed when amount and confirmations are sufficient', () => {
const state = deriveInvoiceState({
...invoice,
moneroDetails: { requiredConfirmations: 1 },
payments: [{ amountAtomic: '1000', confirmations: 1 }]
});
expect(state.isPaidAndConfirmed).toBe(true);
expect(state.isPaidAwaitingConfirmations).toBe(false);
expect(state.hasPendingConfirmations).toBe(false);
});
it('does not treat underpaid invoices as awaiting confirmations even when partial txs are confirmed', () => {
const state = deriveInvoiceState({
...invoice,
moneroDetails: { requiredConfirmations: 1 },
payments: [{ amountAtomic: '100', confirmations: 10 }]
});
expect(state.isUnderpaid).toBe(true);
expect(state.isPaidAwaitingConfirmations).toBe(false);
expect(state.isPaidAndConfirmed).toBe(false);
expect(state.hasPendingConfirmations).toBe(false);
});
it('derives hasPendingConfirmations for underpaid invoices with unconfirmed partial txs', () => {
const state = deriveInvoiceState({
...invoice,
moneroDetails: { requiredConfirmations: 3 },
payments: [{ amountAtomic: '100', confirmations: 1 }]
});
expect(state.isUnderpaid).toBe(true);
expect(state.hasPendingConfirmations).toBe(true);
});
it('derives expired from invoice expiry', () => {
const expiredState = deriveInvoiceState({
...invoice,
expiresAt: new Date('2020-01-01T00:00:00.000Z')
});
const openState = deriveInvoiceState(invoice);
expect(expiredState.isExpired).toBe(true);
expect(openState.isExpired).toBe(false);
});
it('throws when monero details are missing', () => {
expect(() =>
deriveInvoiceState({
...invoice,
moneroDetails: undefined,
payments: [{ amountAtomic: '1000', confirmations: 10 }]
})
).toThrow('Invoice is missing Monero required confirmations');
});
});
@@ -0,0 +1,40 @@
import dayjs from '../../plugins/dayjs';
import { isAtomicGte } from '../atomic/isAtomicGte';
import { deriveInvoiceConfirmationsMet } from './deriveInvoiceConfirmationsMet';
import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations';
import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic';
import type { InvoiceState } from './types/InvoiceState';
import type { InvoiceStateInput } from './types/InvoiceStateInput';
export const deriveInvoiceState = (invoice: InvoiceStateInput): InvoiceState => {
const receivedAmountAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
const isPaidSufficient = isAtomicGte(receivedAmountAtomic, invoice.expectedTotalAtomic);
const isUnderpaid = receivedAmountAtomic !== '0' && !isPaidSufficient;
const isAwaitingPayment = receivedAmountAtomic === '0';
const isExpired = dayjs(invoice.expiresAt).isBefore(dayjs());
const confirmationsMet = deriveInvoiceConfirmationsMet(invoice);
const isPaidAwaitingConfirmations = isPaidSufficient && !confirmationsMet;
const isPaidAndConfirmed = isPaidSufficient && confirmationsMet;
const hasPendingConfirmations = deriveHasPendingConfirmations(invoice);
return {
isAwaitingPayment,
isUnderpaid,
isPaidSufficient,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
isExpired,
hasPendingConfirmations
};
};
const deriveHasPendingConfirmations = (invoice: InvoiceStateInput): boolean => {
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
const payments = invoice.payments;
if (!payments || payments.length === 0) {
return false;
}
return payments.some(payment => payment.confirmations < requiredConfirmations);
};
@@ -0,0 +1,88 @@
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
describe('formatInvoicePaymentConfirmationStatus', () => {
let formatRelativeTimeAgoSpy: jest.SpiedFunction<typeof formatRelativeTimeAgoModule.formatRelativeTimeAgo>;
beforeEach(() => {
formatRelativeTimeAgoSpy = jest
.spyOn(formatRelativeTimeAgoModule, 'formatRelativeTimeAgo')
.mockReturnValue('2 hours ago');
});
afterEach(() => {
formatRelativeTimeAgoSpy.mockRestore();
});
describe('compact', () => {
it('returns Confirmed when confirmations meet the requirement', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 3,
requiredConfirmations: 3,
format: 'compact'
})
).toBe('Confirmed');
});
it('returns compact progress with a slash separator', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 2,
requiredConfirmations: 10,
format: 'compact'
})
).toBe('2/10');
});
it('treats zero-confirmation tiers as confirmed', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 0,
requiredConfirmations: 0,
format: 'compact'
})
).toBe('Confirmed');
});
});
describe('extended', () => {
const createdAt = new Date('2026-01-01T12:00:00.000Z');
it('returns Confirmed when confirmations meet the requirement', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 1,
requiredConfirmations: 1,
createdAt,
format: 'extended'
})
).toBe('Confirmed');
expect(formatRelativeTimeAgoSpy).not.toHaveBeenCalled();
});
it('returns extended progress with relative detection time', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 2,
requiredConfirmations: 10,
createdAt,
format: 'extended'
})
).toBe('2 / 10 confirmations · detected 2 hours ago');
expect(formatRelativeTimeAgoSpy).toHaveBeenCalledWith(createdAt);
});
it('requires createdAt for extended format', () => {
expect(() =>
formatInvoicePaymentConfirmationStatus({
confirmations: 1,
requiredConfirmations: 3,
format: 'extended'
})
).toThrow('createdAt is required for extended confirmation status format');
});
});
});
@@ -0,0 +1,33 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({
confirmations,
requiredConfirmations,
createdAt,
format
}: {
confirmations: number;
requiredConfirmations: number;
createdAt?: Date;
format: InvoicePaymentConfirmationStatusFormat;
}): string => {
if (confirmations >= requiredConfirmations) {
return 'Confirmed';
}
if (format === 'extended') {
if (!createdAt) {
throw new Error('createdAt is required for extended confirmation status format');
}
const detectedAgo = formatRelativeTimeAgo(createdAt);
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
}
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`;
};
@@ -0,0 +1,30 @@
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations';
describe('resolveInvoiceRequiredConfirmations', () => {
it('returns required confirmations for XMR invoices', () => {
expect(
resolveInvoiceRequiredConfirmations({
paymentMethod: PaymentMethod.Xmr,
moneroDetails: { requiredConfirmations: 3 }
})
).toBe(3);
});
it('throws when monero details are missing', () => {
expect(() =>
resolveInvoiceRequiredConfirmations({
paymentMethod: PaymentMethod.Xmr,
moneroDetails: null
})
).toThrow('Invoice is missing Monero required confirmations');
});
it('throws for unsupported payment methods', () => {
expect(() =>
resolveInvoiceRequiredConfirmations({
paymentMethod: 'btc' as PaymentMethod
})
).toThrow('Unsupported payment method: btc');
});
});
@@ -0,0 +1,18 @@
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import type { InvoiceConfirmationsInput } from './types/InvoiceConfirmationsInput';
export const resolveInvoiceRequiredConfirmations = (invoice: InvoiceConfirmationsInput): number => {
switch (invoice.paymentMethod) {
case PaymentMethod.Xmr: {
const requiredConfirmations = invoice.moneroDetails?.requiredConfirmations;
if (requiredConfirmations === undefined) {
throw new Error('Invoice is missing Monero required confirmations');
}
return requiredConfirmations;
}
default:
throw new Error(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
}
};
@@ -0,0 +1,38 @@
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
import type { InvoiceState } from './types/InvoiceState';
const baseState: InvoiceState = {
isAwaitingPayment: false,
isUnderpaid: false,
isPaidSufficient: false,
isPaidAwaitingConfirmations: false,
isPaidAndConfirmed: false,
isExpired: false,
hasPendingConfirmations: false
};
describe('resolveInvoiceStatusMessage', () => {
it('returns Payment confirmed when paid and confirmed', () => {
expect(resolveInvoiceStatusMessage({ ...baseState, isPaidAndConfirmed: true })).toBe('Payment confirmed');
});
it('returns Awaiting confirmations when confirmations are pending', () => {
expect(resolveInvoiceStatusMessage({ ...baseState, isPaidAwaitingConfirmations: true })).toBe(
'Awaiting confirmations'
);
});
it('returns Payment expired for expired unpaid invoices', () => {
expect(resolveInvoiceStatusMessage({ ...baseState, isExpired: true, isUnderpaid: true })).toBe(
'Payment expired'
);
});
it('returns Partial payment received for underpaid invoices', () => {
expect(resolveInvoiceStatusMessage({ ...baseState, isUnderpaid: true })).toBe('Partial payment received');
});
it('returns Awaiting payment when nothing was received', () => {
expect(resolveInvoiceStatusMessage({ ...baseState, isAwaitingPayment: true })).toBe('Awaiting payment');
});
});
@@ -0,0 +1,28 @@
import type { InvoiceState } from './types/InvoiceState';
import type { InvoiceStatusLabel } from './types/InvoiceStatusLabel';
export const resolveInvoiceStatusMessage = (invoiceState: InvoiceState): InvoiceStatusLabel | null => {
const { isExpired, isAwaitingPayment, isUnderpaid, isPaidAwaitingConfirmations, isPaidAndConfirmed } = invoiceState;
if (isPaidAndConfirmed) {
return 'Payment confirmed';
}
if (isPaidAwaitingConfirmations) {
return 'Awaiting confirmations';
}
if (isExpired && (isAwaitingPayment || isUnderpaid)) {
return 'Payment expired';
}
if (isUnderpaid) {
return 'Partial payment received';
}
if (isAwaitingPayment) {
return 'Awaiting payment';
}
return null;
};
@@ -0,0 +1,42 @@
import { resolveInvoiceStatusVariant } from './resolveInvoiceStatusVariant';
import type { InvoiceState } from './types/InvoiceState';
const baseState: InvoiceState = {
isAwaitingPayment: false,
isUnderpaid: false,
isPaidSufficient: false,
isPaidAwaitingConfirmations: false,
isPaidAndConfirmed: false,
isExpired: false,
hasPendingConfirmations: false
};
describe('resolveInvoiceStatusVariant', () => {
it('returns confirmed when payment is paid and confirmed', () => {
expect(resolveInvoiceStatusVariant({ ...baseState, isPaidAndConfirmed: true })).toBe('confirmed');
});
it('returns awaiting-confirmations when paid but confirmations are pending', () => {
expect(resolveInvoiceStatusVariant({ ...baseState, isPaidAwaitingConfirmations: true })).toBe(
'awaiting-confirmations'
);
});
it('returns expired for unpaid expired invoices', () => {
expect(
resolveInvoiceStatusVariant({ ...baseState, isExpired: true, isAwaitingPayment: true })
).toBe('expired');
});
it('returns underpaid for partial payments', () => {
expect(resolveInvoiceStatusVariant({ ...baseState, isUnderpaid: true })).toBe('underpaid');
});
it('returns awaiting-payment when nothing was received', () => {
expect(resolveInvoiceStatusVariant({ ...baseState, isAwaitingPayment: true })).toBe('awaiting-payment');
});
it('returns null for unrecognized combinations', () => {
expect(resolveInvoiceStatusVariant(baseState)).toBeNull();
});
});
@@ -0,0 +1,28 @@
import type { InvoiceState } from './types/InvoiceState';
import type { InvoiceStatusVariant } from './types/InvoiceStatusVariant';
export const resolveInvoiceStatusVariant = (invoiceState: InvoiceState): InvoiceStatusVariant | null => {
const { isExpired, isAwaitingPayment, isUnderpaid, isPaidAwaitingConfirmations, isPaidAndConfirmed } = invoiceState;
if (isPaidAndConfirmed) {
return 'confirmed';
}
if (isPaidAwaitingConfirmations) {
return 'awaiting-confirmations';
}
if (isExpired && (isAwaitingPayment || isUnderpaid)) {
return 'expired';
}
if (isUnderpaid) {
return 'underpaid';
}
if (isAwaitingPayment) {
return 'awaiting-payment';
}
return null;
};
@@ -0,0 +1,17 @@
import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic';
describe('sumInvoicePaymentAmountsAtomic', () => {
it('returns zero when payments are missing or empty', () => {
expect(sumInvoicePaymentAmountsAtomic(undefined)).toBe('0');
expect(sumInvoicePaymentAmountsAtomic([])).toBe('0');
});
it('sums multiple payment amounts atomically', () => {
expect(
sumInvoicePaymentAmountsAtomic([
{ amountAtomic: '100000000000' },
{ amountAtomic: '250000000000' }
])
).toBe('350000000000');
});
});
@@ -0,0 +1,9 @@
import { addAtomic } from '../atomic/addAtomic';
export const sumInvoicePaymentAmountsAtomic = (payments: { amountAtomic: string }[] | undefined): string => {
if (!payments?.length) {
return '0';
}
return payments.reduce((total, payment) => addAtomic(total, payment.amountAtomic), '0');
};
@@ -0,0 +1,664 @@
import { InternalServerErrorException } from '@nestjs/common';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
import type { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { InvoiceReason } from '../../modules/payment/types/InvoiceReason';
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const paymentAddress = '4StorefrontInvoiceViewTestAddress';
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
({
txHash: 'default-tx-hash',
amountAtomic: oneXmrAtomic,
confirmations: 0,
createdAt: new Date('2026-01-01T12:00:00.000Z'),
...overrides
}) as InvoicePayment;
type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
({
paymentAddressIndex: 1,
fiatPerXmrAtCreation: 150,
requiredConfirmations: 1,
...overrides
}) as InvoiceMoneroDetails;
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
reason: InvoiceReason.Checkout,
paymentMethod: PaymentMethod.Xmr,
amountFiat: 42,
fiatCurrency: 'USD',
expiresAt: new Date('2099-06-15T12:05:30.000Z'),
paymentAddress,
expectedTotalAtomic: oneXmrAtomic,
payments: [],
moneroDetails: buildMoneroDetails(),
...overrides
}) as Invoice;
describe('toStorefrontInvoiceView', () => {
let generateQrCodeDataUrlSpy: jest.SpiedFunction<typeof generateQrCodeDataUrlModule.generateQrCodeDataUrl>;
let formatRelativeTimeAgoSpy: jest.SpiedFunction<typeof formatRelativeTimeAgoModule.formatRelativeTimeAgo>;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-01T12:00:00.000Z'));
generateQrCodeDataUrlSpy = jest
.spyOn(generateQrCodeDataUrlModule, 'generateQrCodeDataUrl')
.mockResolvedValue('data:image/png;base64,qr');
formatRelativeTimeAgoSpy = jest
.spyOn(formatRelativeTimeAgoModule, 'formatRelativeTimeAgo')
.mockReturnValue('2 hours ago');
});
afterEach(() => {
generateQrCodeDataUrlSpy.mockRestore();
formatRelativeTimeAgoSpy.mockRestore();
jest.useRealTimers();
});
describe('errors', () => {
it('throws when monero details are missing', async () => {
const invoice = buildInvoice({ moneroDetails: null });
await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException);
});
it('throws for unsupported payment methods', async () => {
const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod });
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: btc');
});
});
describe('awaiting payment', () => {
it('maps core invoice fields and awaiting-payment visibility', async () => {
const invoice = buildInvoice();
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
cryptoCurrency: 'XMR',
expectedTotalCrypto: '1.00000000',
receivedTotalCrypto: null,
paymentAddress,
qrCodeUrl: 'data:image/png;base64,qr',
instructionPrefix: 'Send exactly',
instructionAmountCrypto: '1.00000000',
instructionSuffix: 'to the address below.',
payments: [],
isPaidSufficient: false,
showStatusMessage: true,
statusMessage: 'Awaiting payment',
statusVariant: 'awaiting-payment',
showProminentAmount: true,
showExpectedTotal: false,
showReceivedTotal: false,
showInstruction: true,
showExpiry: true,
showQr: true,
showAddress: true,
showPayments: false,
showRefresh: true
});
});
it('builds a qr code for the full expected amount', async () => {
const invoice = buildInvoice();
await toStorefrontInvoiceView(invoice);
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`);
});
it('formats expiry as seconds only when under one minute remains', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:00:45.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBe('45 seconds');
});
it('formats expiry as minutes only when seconds are zero', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:05:00.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBe('5 minutes');
});
it('formats expiry with minutes and seconds', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:05:30.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBe('5 minutes and 30 seconds');
});
it('uses singular minute and second labels', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:01:01.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBe('1 minute and 1 second');
});
it('clamps expired invoices to zero remaining seconds in the expiry label', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2020-01-01T00:00:00.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBeNull();
expect(view.showRefresh).toBe(false);
});
it('formats long expiry durations using days and hours instead of thousands of minutes', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-03T11:52:12.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBe('1 day and 23 hours');
});
it('formats sub-day expiry as hours and minutes', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2026-06-02T11:52:00.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.expiresInDuration).toBe('23 hours and 52 minutes');
});
});
describe('expired', () => {
it('maps expired awaiting-payment invoices without misleading payment capture UI', async () => {
const invoice = buildInvoice({
expiresAt: new Date('2020-01-01T00:00:00.000Z')
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
qrCodeUrl: null,
instructionPrefix: null,
instructionAmountCrypto: null,
instructionSuffix: null,
expiresInDuration: null,
statusMessage: 'Payment expired',
statusVariant: 'expired',
showProminentAmount: false,
showExpectedTotal: true,
showReceivedTotal: false,
showInstruction: false,
showExpiry: false,
showQr: false,
showAddress: false,
showPayments: false,
showRefresh: false
});
expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled();
});
it('still refreshes expired underpaid invoices while confirmations are pending', async () => {
const payment = buildPayment({
txHash: 'partial-expired',
amountAtomic: '100000000000',
confirmations: 0
});
const invoice = buildInvoice({
expiresAt: new Date('2020-01-01T00:00:00.000Z'),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
statusMessage: 'Payment expired',
statusVariant: 'expired',
receivedTotalCrypto: '0.10000000',
qrCodeUrl: null,
instructionPrefix: null,
showExpectedTotal: true,
showReceivedTotal: true,
showInstruction: false,
showQr: false,
showAddress: false,
showPayments: true,
showRefresh: true
});
expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled();
});
it('stops refresh on expired underpaid invoices once partial txs are confirmed', async () => {
const payment = buildPayment({
txHash: 'partial-expired-confirmed',
amountAtomic: '100000000000',
confirmations: 10
});
const invoice = buildInvoice({
expiresAt: new Date('2020-01-01T00:00:00.000Z'),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
statusMessage: 'Payment expired',
showPayments: true,
showRefresh: false
});
});
it('keeps awaiting-confirmations status when an expired invoice is already paid sufficient', async () => {
const payment = buildPayment({
txHash: 'paid-expired-unconfirmed',
confirmations: 0
});
const invoice = buildInvoice({
expiresAt: new Date('2020-01-01T00:00:00.000Z'),
moneroDetails: buildMoneroDetails({ requiredConfirmations: 3 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
statusMessage: 'Awaiting confirmations',
showRefresh: true,
showInstruction: false,
showQr: false
});
});
});
describe('underpaid', () => {
it('maps partial payment totals, instruction, and visibility', async () => {
const payment = buildPayment({
txHash: 'partial-tx',
amountAtomic: '100000000000',
confirmations: 10
});
const invoice = buildInvoice({ payments: [payment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
expectedTotalCrypto: '1.00000000',
receivedTotalCrypto: '0.10000000',
instructionPrefix: 'Send',
instructionAmountCrypto: '0.90000000',
instructionSuffix: 'more to the same address below.',
isPaidSufficient: false,
showProminentAmount: false,
showReceivedTotal: true,
showInstruction: true,
showQr: true,
showAddress: true,
showPayments: true,
showRefresh: true,
statusMessage: 'Partial payment received',
statusVariant: 'underpaid'
});
});
it('rounds the remaining amount up when converting atomic to display units', async () => {
const payment = buildPayment({ amountAtomic: '999999999999' });
const invoice = buildInvoice({ payments: [payment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view.instructionAmountCrypto).toBe('0.00000001');
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=0.00000001`);
});
it('builds a qr code for the remaining amount', async () => {
const payment = buildPayment({ amountAtomic: '250000000000' });
const invoice = buildInvoice({ payments: [payment] });
await toStorefrontInvoiceView(invoice);
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=0.75000000`);
});
it('hides the payments list when there are no payments yet', async () => {
const invoice = buildInvoice();
const view = await toStorefrontInvoiceView(invoice);
expect(view.showPayments).toBe(false);
expect(view.payments).toEqual([]);
});
});
describe('paid awaiting confirmations', () => {
it('maps paid-awaiting state and hides payment capture UI', async () => {
const payment = buildPayment({
txHash: 'paid-unconfirmed',
confirmations: 0
});
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 3 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
receivedTotalCrypto: '1.00000000',
qrCodeUrl: null,
expiresInDuration: null,
instructionPrefix: null,
instructionAmountCrypto: null,
instructionSuffix: null,
isPaidSufficient: true,
showProminentAmount: false,
showExpectedTotal: true,
showReceivedTotal: true,
showInstruction: false,
showExpiry: false,
showQr: false,
showAddress: false,
showPayments: true,
showRefresh: true,
statusMessage: 'Awaiting confirmations',
statusVariant: 'awaiting-confirmations'
});
expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled();
});
});
describe('paid and confirmed', () => {
it('maps confirmed state with collapsed payment capture UI', async () => {
const payment = buildPayment({
txHash: 'confirmed-tx',
confirmations: 5
});
const invoice = buildInvoice({ payments: [payment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
receivedTotalCrypto: '1.00000000',
qrCodeUrl: null,
expiresInDuration: null,
instructionPrefix: null,
instructionAmountCrypto: null,
instructionSuffix: null,
isPaidSufficient: true,
showProminentAmount: false,
showExpectedTotal: true,
showReceivedTotal: true,
showInstruction: false,
showExpiry: false,
showQr: false,
showAddress: false,
showPayments: false,
showRefresh: false,
statusMessage: 'Payment confirmed',
statusVariant: 'confirmed'
});
});
it('treats tx-detected invoices as confirmed for payment status display', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.showRefresh).toBe(false);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
});
});
describe('overpayment', () => {
it('treats overpayment as paid sufficient without a qr code', async () => {
const payment = buildPayment({
amountAtomic: '2000000000000',
confirmations: 10
});
const invoice = buildInvoice({ payments: [payment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view.isPaidSufficient).toBe(true);
expect(view.receivedTotalCrypto).toBe('2.00000000');
expect(view.qrCodeUrl).toBeNull();
expect(generateQrCodeDataUrlSpy).not.toHaveBeenCalled();
});
});
describe('payment mapping', () => {
it('maps tx hash and converts atomic amount to display crypto', async () => {
const payment = buildPayment({
txHash: 'abc123hash',
amountAtomic: '1500000000000',
confirmations: 0
});
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 2 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments).toEqual([
{
txHash: 'abc123hash',
amountCrypto: '1.50000000',
confirmationStatus: '0 / 2 confirmations · detected 2 hours ago',
confirmationStatusVariant: 'confirming'
}
]);
expect(formatRelativeTimeAgoSpy).toHaveBeenCalledWith(payment.createdAt);
});
it('sorts payments by detection time ascending', async () => {
const laterPayment = buildPayment({
txHash: 'later',
createdAt: new Date('2026-01-02T12:00:00.000Z')
});
const earlierPayment = buildPayment({
txHash: 'earlier',
createdAt: new Date('2026-01-01T12:00:00.000Z')
});
const invoice = buildInvoice({
payments: [laterPayment, earlierPayment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments.map(payment => payment.txHash)).toEqual(['earlier', 'later']);
});
it('marks a payment confirmed when confirmations meet the requirement', async () => {
const payment = buildPayment({ confirmations: 1 });
const invoice = buildInvoice({ payments: [payment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
expect(view.payments[0].confirmationStatusVariant).toBe('confirmed');
expect(formatRelativeTimeAgoSpy).not.toHaveBeenCalled();
});
it('marks a payment confirmed when confirmations exceed the requirement', async () => {
const payment = buildPayment({ confirmations: 99 });
const invoice = buildInvoice({
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
expect(view.payments[0].confirmationStatusVariant).toBe('confirmed');
});
it('shows partial confirmation progress when below the requirement', async () => {
const payment = buildPayment({ confirmations: 2 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 10 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments[0].confirmationStatus).toBe('2 / 10 confirmations · detected 2 hours ago');
expect(view.payments[0].confirmationStatusVariant).toBe('confirming');
});
it('shows zero confirmations against a non-zero requirement', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 6 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments[0].confirmationStatus).toBe('0 / 6 confirmations · detected 2 hours ago');
expect(view.payments[0].confirmationStatusVariant).toBe('confirming');
});
it('maps each payment independently in a multi-payment invoice', async () => {
const firstPayment = buildPayment({
txHash: 'first',
amountAtomic: '400000000000',
confirmations: 0,
createdAt: new Date('2026-01-01T10:00:00.000Z')
});
const secondPayment = buildPayment({
txHash: 'second',
amountAtomic: '600000000000',
confirmations: 1,
createdAt: new Date('2026-01-01T11:00:00.000Z')
});
const invoice = buildInvoice({
payments: [secondPayment, firstPayment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments).toEqual([
{
txHash: 'first',
amountCrypto: '0.40000000',
confirmationStatus: '0 / 1 confirmations · detected 2 hours ago',
confirmationStatusVariant: 'confirming'
},
{
txHash: 'second',
amountCrypto: '0.60000000',
confirmationStatus: 'Confirmed',
confirmationStatusVariant: 'confirmed'
}
]);
expect(view.isPaidSufficient).toBe(true);
expect(view.showRefresh).toBe(true);
expect(view.showPayments).toBe(true);
});
it('marks the invoice paid and confirmed only when every payment meets confirmations', async () => {
const firstPayment = buildPayment({
txHash: 'first',
amountAtomic: '400000000000',
confirmations: 1,
createdAt: new Date('2026-01-01T10:00:00.000Z')
});
const secondPayment = buildPayment({
txHash: 'second',
amountAtomic: '600000000000',
confirmations: 3,
createdAt: new Date('2026-01-01T11:00:00.000Z')
});
const invoice = buildInvoice({
payments: [firstPayment, secondPayment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments.every(payment => payment.confirmationStatus === 'Confirmed')).toBe(true);
expect(view.showRefresh).toBe(false);
expect(view.showPayments).toBe(false);
});
it('still shows confirmed status on underpaid partial txs', async () => {
const payment = buildPayment({
amountAtomic: '100000000000',
confirmations: 10
});
const invoice = buildInvoice({ payments: [payment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view.statusMessage).toBe('Partial payment received');
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
expect(view.showPayments).toBe(true);
});
it('sums multiple underpaid payments before computing remaining amount', async () => {
const firstPayment = buildPayment({
txHash: 'first',
amountAtomic: '100000000000'
});
const secondPayment = buildPayment({
txHash: 'second',
amountAtomic: '200000000000',
createdAt: new Date('2026-01-02T12:00:00.000Z')
});
const invoice = buildInvoice({ payments: [firstPayment, secondPayment] });
const view = await toStorefrontInvoiceView(invoice);
expect(view.receivedTotalCrypto).toBe('0.30000000');
expect(view.instructionAmountCrypto).toBe('0.70000000');
expect(view.payments).toHaveLength(2);
});
it('handles an empty payments array', async () => {
const invoice = buildInvoice({ payments: [] });
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments).toEqual([]);
expect(view.receivedTotalCrypto).toBeNull();
});
it('handles a missing payments relation as empty', async () => {
const invoice = buildInvoice({ payments: undefined });
const view = await toStorefrontInvoiceView(invoice);
expect(view.payments).toEqual([]);
expect(view.receivedTotalCrypto).toBeNull();
});
});
});
@@ -0,0 +1,206 @@
import Decimal from 'decimal.js';
import { InternalServerErrorException } from '@nestjs/common';
import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView';
import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView';
import type { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import dayjs from '../../plugins/dayjs';
import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl';
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
import { subtractAtomic } from '../atomic/subtractAtomic';
import { deriveInvoiceState } from './deriveInvoiceState';
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
import { resolveInvoiceStatusVariant } from './resolveInvoiceStatusVariant';
import { resolveInvoiceRequiredConfirmations } from './resolveInvoiceRequiredConfirmations';
import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic';
export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
switch (invoice.paymentMethod) {
case PaymentMethod.Xmr:
return toXmrInvoiceView(invoice);
default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
}
};
const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
if (!invoice.moneroDetails) {
throw new InternalServerErrorException('Invoice is missing Monero payment details');
}
const invoiceState = deriveInvoiceState(invoice);
const {
isAwaitingPayment,
isUnderpaid,
isPaidSufficient,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
isExpired,
hasPendingConfirmations
} = invoiceState;
const cryptoCurrency = 'XMR';
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic);
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
const payments = [...(invoice.payments ?? [])]
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
.map(payment => toPaymentView(payment, requiredConfirmations));
const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired;
const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null;
let remainingTotalCrypto: string | null = null;
let instructionPrefix: string | null = null;
let instructionAmountCrypto: string | null = null;
let instructionSuffix: string | null = null;
let qrCodeUrl: string | null = null;
if (showPaymentCapture) {
if (isAwaitingPayment) {
remainingTotalCrypto = expectedTotalCrypto;
instructionAmountCrypto = expectedTotalCrypto;
instructionPrefix = 'Send exactly';
instructionSuffix = 'to the address below.';
} else {
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
remainingTotalCrypto = convertXmrAtomicToXmr(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
instructionAmountCrypto = remainingTotalCrypto;
instructionPrefix = 'Send';
instructionSuffix = 'more to the same address below.';
}
qrCodeUrl = await generateQrCodeDataUrl(
`monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}`
);
}
const statusMessage = resolveInvoiceStatusMessage(invoiceState);
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
return {
cryptoCurrency,
expectedTotalCrypto,
receivedTotalCrypto,
paymentAddress: invoice.paymentAddress,
qrCodeUrl,
instructionPrefix,
instructionAmountCrypto,
instructionSuffix,
expiresInDuration,
payments,
isPaidSufficient,
showStatusMessage: statusMessage !== null,
statusMessage,
statusVariant,
showProminentAmount: showPaymentCapture && isAwaitingPayment,
showExpectedTotal:
isPaidAwaitingConfirmations || isPaidAndConfirmed || (isExpired && (isAwaitingPayment || isUnderpaid)),
showReceivedTotal: isUnderpaid || isPaidAwaitingConfirmations || isPaidAndConfirmed,
showInstruction: showPaymentCapture,
showExpiry: showPaymentCapture,
showQr: showPaymentCapture,
showAddress: showPaymentCapture,
showPayments: (isUnderpaid || isPaidAwaitingConfirmations) && payments.length > 0,
showRefresh: resolveShowRefresh({
isExpired,
isAwaitingPayment,
isUnderpaid,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
hasPendingConfirmations
})
};
};
const toPaymentView = (payment: InvoicePayment, requiredConfirmations: number): StorefrontInvoicePaymentView => {
const isConfirmed = payment.confirmations >= requiredConfirmations;
return {
txHash: payment.txHash,
amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic),
confirmationStatus: formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations,
requiredConfirmations,
createdAt: payment.createdAt,
format: 'extended'
}),
confirmationStatusVariant: isConfirmed ? 'confirmed' : 'confirming'
};
};
const resolveShowRefresh = ({
isExpired,
isAwaitingPayment,
isUnderpaid,
isPaidAwaitingConfirmations,
isPaidAndConfirmed,
hasPendingConfirmations
}: {
isExpired: boolean;
isAwaitingPayment: boolean;
isUnderpaid: boolean;
isPaidAwaitingConfirmations: boolean;
isPaidAndConfirmed: boolean;
hasPendingConfirmations: boolean;
}): boolean => {
if (isPaidAndConfirmed) {
return false;
}
if (isPaidAwaitingConfirmations) {
return true;
}
if (isExpired && isAwaitingPayment) {
return false;
}
if (isExpired && isUnderpaid) {
return hasPendingConfirmations;
}
return isAwaitingPayment || isUnderpaid;
};
const formatInvoiceExpiresInDuration = (expiresAt: Date): string => {
const totalSeconds = Math.max(0, dayjs(expiresAt).diff(dayjs(), 'second'));
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (days > 0) {
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
}
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
}
if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
}
if (seconds > 0) {
parts.push(`${seconds} ${seconds === 1 ? 'second' : 'seconds'}`);
}
if (parts.length === 0) {
return '0 seconds';
}
return parts.slice(0, 2).join(' and ');
};
@@ -0,0 +1,7 @@
import type { PaymentMethod } from '../../../modules/payment/types/PaymentMethod';
export type InvoiceConfirmationsInput = {
paymentMethod: PaymentMethod;
payments?: { confirmations: number }[];
moneroDetails?: { requiredConfirmations: number } | null;
};
@@ -0,0 +1 @@
export type InvoicePaymentConfirmationStatusFormat = 'compact' | 'extended';
@@ -0,0 +1 @@
export type InvoicePaymentConfirmationVariant = 'confirmed' | 'confirming';
@@ -0,0 +1,9 @@
export type InvoiceState = {
isAwaitingPayment: boolean;
isUnderpaid: boolean;
isPaidSufficient: boolean;
isPaidAwaitingConfirmations: boolean;
isPaidAndConfirmed: boolean;
isExpired: boolean;
hasPendingConfirmations: boolean;
};
@@ -0,0 +1,9 @@
import type { PaymentMethod } from '../../../modules/payment/types/PaymentMethod';
export type InvoiceStateInput = {
paymentMethod: PaymentMethod;
expectedTotalAtomic: string;
expiresAt: Date;
payments?: { confirmations: number; amountAtomic: string }[];
moneroDetails?: { requiredConfirmations: number } | null;
};
@@ -0,0 +1,6 @@
export type InvoiceStatusLabel =
| 'Payment confirmed'
| 'Awaiting confirmations'
| 'Partial payment received'
| 'Payment expired'
| 'Awaiting payment';
@@ -0,0 +1,6 @@
export type InvoiceStatusVariant =
| 'confirmed'
| 'awaiting-confirmations'
| 'expired'
| 'underpaid'
| 'awaiting-payment';
+15
View File
@@ -0,0 +1,15 @@
import { isSet } from './isSet';
describe('isSet', () => {
it('returns false for null and undefined', () => {
expect(isSet(null)).toBe(false);
expect(isSet(undefined)).toBe(false);
});
it('returns true for present values including zero and empty string', () => {
expect(isSet(0)).toBe(true);
expect(isSet('')).toBe(true);
expect(isSet(false)).toBe(true);
expect(isSet(new Date('2026-01-01T00:00:00.000Z'))).toBe(true);
});
});
+1
View File
@@ -0,0 +1 @@
export const isSet = <T>(value: T | null | undefined): value is T => value !== null && value !== undefined;
@@ -0,0 +1,11 @@
import { convertFiatToXmr } from './convertFiatToXmr';
describe('convertFiatToXmr', () => {
it('formats small amounts without scientific notation', () => {
expect(convertFiatToXmr(0.003, 300_000)).toBe('0.00000001');
});
it('converts fiat to XMR at the shop rate', () => {
expect(convertFiatToXmr(150, 300)).toBe('0.50000000');
});
});
@@ -0,0 +1,5 @@
import Decimal from 'decimal.js';
export const convertFiatToXmr = (fiatAmount: number, fiatPerXmr: number): string => {
return new Decimal(fiatAmount).div(fiatPerXmr).toDecimalPlaces(8, Decimal.ROUND_HALF_UP).toFixed(8);
};
@@ -0,0 +1,16 @@
import Decimal from 'decimal.js';
import { convertXmrAtomicToXmr } from './convertXmrAtomicToXmr';
describe('convertXmrAtomicToXmr', () => {
it('formats small amounts without scientific notation', () => {
expect(convertXmrAtomicToXmr('10000', Decimal.ROUND_CEIL)).toBe('0.00000001');
});
it('formats one XMR', () => {
expect(convertXmrAtomicToXmr('1000000000000')).toBe('1.00000000');
});
it('rounds up remaining amounts when requested', () => {
expect(convertXmrAtomicToXmr('9070001', Decimal.ROUND_CEIL)).toBe('0.00000908');
});
});
@@ -0,0 +1,9 @@
import Decimal from 'decimal.js';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
export const convertXmrAtomicToXmr = (
amountAtomic: string,
rounding: Decimal.Rounding = Decimal.ROUND_HALF_UP
): string => {
return new Decimal(amountAtomic).div(XMR_ATOMIC_PER_XMR).toDecimalPlaces(8, rounding).toFixed(8);
};
@@ -0,0 +1,20 @@
import { convertXmrToXmrAtomic } from './convertXmrToXmrAtomic';
describe('convertXmrToXmrAtomic', () => {
it('converts one XMR to atomic units', () => {
expect(convertXmrToXmrAtomic('1')).toBe('1000000000000');
});
it('converts the smallest display unit to atomic units', () => {
expect(convertXmrToXmrAtomic('0.00000001')).toBe('10000');
});
it('accepts scientific notation input', () => {
expect(convertXmrToXmrAtomic('1e-8')).toBe('10000');
});
it('returns an integer string without scientific notation', () => {
expect(convertXmrToXmrAtomic('0.00025907')).toBe('259070000');
expect(convertXmrToXmrAtomic('0.00025907')).not.toMatch(/e/i);
});
});
@@ -0,0 +1,6 @@
import Decimal from 'decimal.js';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
export const convertXmrToXmrAtomic = (amountXmr: string): string => {
return new Decimal(amountXmr).mul(XMR_ATOMIC_PER_XMR).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString();
};
@@ -0,0 +1,16 @@
import { deduplicateIncomingMoneroTransfers } from './deduplicateIncomingMoneroTransfers';
describe('deduplicateIncomingMoneroTransfers', () => {
it('keeps the transfer with the highest confirmations for each tx hash', () => {
const result = deduplicateIncomingMoneroTransfers([
{ txHash: 'tx-a', amountAtomic: '100', confirmations: 1, subaddrIndex: 3 },
{ txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 },
{ txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 }
]);
expect(result).toEqual([
{ txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 },
{ txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 }
]);
});
});
@@ -0,0 +1,17 @@
import type { MoneroWalletRpcIncomingTransfer } from '../../modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer';
export const deduplicateIncomingMoneroTransfers = (
transfers: MoneroWalletRpcIncomingTransfer[]
): MoneroWalletRpcIncomingTransfer[] => {
const byTxHash = new Map<string, MoneroWalletRpcIncomingTransfer>();
for (const transfer of transfers) {
const existing = byTxHash.get(transfer.txHash);
if (!existing || transfer.confirmations > existing.confirmations) {
byTxHash.set(transfer.txHash, transfer);
}
}
return [...byTxHash.values()];
};
@@ -0,0 +1,18 @@
import { groupIncomingMoneroTransfersBySubaddrIndex } from './groupIncomingMoneroTransfersBySubaddrIndex';
describe('groupIncomingMoneroTransfersBySubaddrIndex', () => {
it('deduplicates and groups transfers by subaddress index', () => {
const grouped = groupIncomingMoneroTransfersBySubaddrIndex([
{ txHash: 'tx-a', amountAtomic: '100', confirmations: 1, subaddrIndex: 3 },
{ txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 },
{ txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 }
]);
expect(grouped.get(3)).toEqual([
{ txHash: 'tx-a', amountAtomic: '100', confirmations: 4, subaddrIndex: 3 }
]);
expect(grouped.get(7)).toEqual([
{ txHash: 'tx-b', amountAtomic: '200', confirmations: 2, subaddrIndex: 7 }
]);
});
});
@@ -0,0 +1,19 @@
import type { MoneroWalletRpcIncomingTransfer } from '../../modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { deduplicateIncomingMoneroTransfers } from './deduplicateIncomingMoneroTransfers';
export const groupIncomingMoneroTransfersBySubaddrIndex = (
transfers: MoneroWalletRpcIncomingTransfer[]
): Map<number, MoneroWalletRpcIncomingTransfer[]> => {
const deduped = deduplicateIncomingMoneroTransfers(transfers);
const grouped = new Map<number, MoneroWalletRpcIncomingTransfer[]>();
for (const transfer of deduped) {
const existing = grouped.get(transfer.subaddrIndex) ?? [];
existing.push(transfer);
grouped.set(transfer.subaddrIndex, existing);
}
return grouped;
};
@@ -0,0 +1,76 @@
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
import type { MoneroWalletRpcIncomingTransfer } from '../../modules/moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { deduplicateIncomingMoneroTransfers } from './deduplicateIncomingMoneroTransfers';
import { groupIncomingMoneroTransfersBySubaddrIndex } from './groupIncomingMoneroTransfersBySubaddrIndex';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const transfer = (
overrides: Partial<MoneroWalletRpcIncomingTransfer> & Pick<MoneroWalletRpcIncomingTransfer, 'txHash'>
): MoneroWalletRpcIncomingTransfer => ({
amountAtomic: oneXmrAtomic,
confirmations: 1,
subaddrIndex: 1,
...overrides
});
describe('deduplicateIncomingMoneroTransfers', () => {
it('keeps the entry with more confirmations for the same tx hash', () => {
const pending = transfer({ txHash: 'abc', confirmations: 0, amountAtomic: '100' });
const confirmed = transfer({ txHash: 'abc', confirmations: 3, amountAtomic: '100' });
const deduped = deduplicateIncomingMoneroTransfers([pending, confirmed]);
expect(deduped).toEqual([confirmed]);
});
it('keeps unrelated transfers', () => {
const first = transfer({ txHash: 'abc', subaddrIndex: 1 });
const second = transfer({ txHash: 'def', subaddrIndex: 2 });
const deduped = deduplicateIncomingMoneroTransfers([first, second]);
expect(deduped).toEqual([first, second]);
});
it('keeps one transfer when the same tx hash appears under different subaddrs', () => {
const first = transfer({ txHash: 'abc', subaddrIndex: 1, confirmations: 1 });
const second = transfer({ txHash: 'abc', subaddrIndex: 2, confirmations: 2 });
const deduped = deduplicateIncomingMoneroTransfers([first, second]);
expect(deduped).toEqual([second]);
});
});
describe('groupIncomingMoneroTransfersBySubaddrIndex', () => {
it('groups transfers by subaddr index', () => {
const first = transfer({ txHash: 'abc', subaddrIndex: 1 });
const second = transfer({ txHash: 'def', subaddrIndex: 2 });
const third = transfer({ txHash: 'ghi', subaddrIndex: 1 });
const grouped = groupIncomingMoneroTransfersBySubaddrIndex([first, second, third]);
expect(grouped).toEqual(
new Map([
[1, [first, third]],
[2, [second]]
])
);
});
it('deduplicates before grouping', () => {
const pending = transfer({ txHash: 'abc', subaddrIndex: 5, confirmations: 0 });
const confirmed = transfer({ txHash: 'abc', subaddrIndex: 5, confirmations: 2 });
const grouped = groupIncomingMoneroTransfersBySubaddrIndex([pending, confirmed]);
expect(grouped).toEqual(new Map([[5, [confirmed]]]));
});
it('returns an empty map for no transfers', () => {
const grouped = groupIncomingMoneroTransfersBySubaddrIndex([]);
expect(grouped).toEqual(new Map());
});
});
@@ -0,0 +1,21 @@
import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 },
{ upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 }
] as const;
describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0);
});
it('returns the middle tier for medium orders', () => {
expect(resolveMinConfirmations(100, [...tiers])).toBe(5);
});
it('returns the catch-all tier for large orders', () => {
expect(resolveMinConfirmations(500, [...tiers])).toBe(10);
});
});
@@ -0,0 +1,16 @@
import Decimal from 'decimal.js';
import type { MoneroConfirmationTier } from '../../types/MoneroConfirmationTier';
export const resolveMinConfirmations = (totalFiat: number, tiers: MoneroConfirmationTier[]): number => {
for (const tier of tiers) {
if (tier.upToTotalFiat === undefined) {
return tier.minConfirmations;
}
if (new Decimal(totalFiat).lte(tier.upToTotalFiat)) {
return tier.minConfirmations;
}
}
return tiers[tiers.length - 1].minConfirmations;
};
@@ -0,0 +1,25 @@
import { createOrderDetailQuery } from './createOrderDetailQuery';
describe('createOrderDetailQuery', () => {
it('builds an order detail query with the expected joins and filter', () => {
const queryBuilder = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn()
};
const orderRepo = {
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder)
};
createOrderDetailQuery(orderRepo as never, 'order-1');
expect(orderRepo.createQueryBuilder).toHaveBeenCalledWith('order');
expect(queryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('order.lines', 'orderLine');
expect(queryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('order.checkoutInvoice', 'checkoutInvoice');
expect(queryBuilder.where).toHaveBeenCalledWith('order.id = :orderId', { orderId: 'order-1' });
});
});
@@ -0,0 +1,24 @@
import type { Repository, SelectQueryBuilder } from 'typeorm';
import { Order } from '../../modules/order/entities/Order';
export const createOrderDetailQuery = (orderRepo: Repository<Order>, orderId: string): SelectQueryBuilder<Order> =>
orderRepo
.createQueryBuilder('order')
.leftJoinAndSelect('order.lines', 'orderLine')
.leftJoinAndSelect('orderLine.autoFulfillmentItems', 'autoFulfillmentItem')
.leftJoinAndSelect('autoFulfillmentItem.attachments', 'autoFulfillmentItemAttachment')
.leftJoinAndSelect('orderLine.manualFulfillment', 'manualFulfillment')
.leftJoinAndSelect('order.discounts', 'orderDiscount')
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'checkoutMoneroDetails')
.leftJoinAndSelect('checkoutInvoice.payments', 'checkoutPayment')
.leftJoinAndSelect('order.shippingInvoice', 'shippingInvoice')
.leftJoinAndSelect('shippingInvoice.moneroDetails', 'shippingMoneroDetails')
.leftJoinAndSelect('shippingInvoice.payments', 'shippingPayment')
.leftJoinAndSelect('order.messages', 'message')
.addSelect('order.accessToken')
.addSelect('autoFulfillmentItem.contentSnapshot')
.addSelect('autoFulfillmentItemAttachment.storageKey')
.orderBy('message.createdAt', 'ASC')
.addOrderBy('autoFulfillmentItem.sortOrder', 'ASC')
.where('order.id = :orderId', { orderId });
@@ -0,0 +1,117 @@
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { DeliveryMode } from '../../modules/product/types/DeliveryMode';
import { ManualLineFulfillmentStatus } from '../../modules/order/types/ManualLineFulfillmentStatus';
import { OrderFailureReason } from '../../modules/order/types/OrderFailureReason';
import { OrderStatus } from '../../modules/order/types/OrderStatus';
import { deriveOrderState } from './deriveOrderState';
const paidCheckoutInvoice = {
paymentMethod: PaymentMethod.Xmr,
amountFiat: 10,
expectedTotalAtomic: '1000',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
payments: [{ amountAtomic: '1000', confirmations: 10 }],
moneroDetails: { requiredConfirmations: 1 }
};
const unpaidShippingInvoice = {
paymentMethod: PaymentMethod.Xmr,
amountFiat: 5,
expectedTotalAtomic: '500',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
payments: [],
moneroDetails: { requiredConfirmations: 1 }
};
const paidShippingInvoice = {
paymentMethod: PaymentMethod.Xmr,
amountFiat: 5,
expectedTotalAtomic: '500',
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
payments: [{ amountAtomic: '500', confirmations: 10 }],
moneroDetails: { requiredConfirmations: 1 }
};
describe('deriveOrderState', () => {
it('treats failureReason as unfulfillable', () => {
const state = deriveOrderState({
failureReason: OrderFailureReason.StockUnavailable,
checkoutInvoice: paidCheckoutInvoice,
lines: []
});
expect(state.status).toBe(OrderStatus.Unfulfillable);
});
it('fulfills auto-only orders when checkout is paid and confirmed', () => {
const state = deriveOrderState({
failureReason: null,
checkoutInvoice: paidCheckoutInvoice,
lines: [{ deliveryMode: DeliveryMode.Auto }]
});
expect(state.status).toBe(OrderStatus.Fulfilled);
});
it('stays unfulfilled when manual lines are still pending', () => {
const state = deriveOrderState({
failureReason: null,
checkoutInvoice: paidCheckoutInvoice,
lines: [
{
deliveryMode: DeliveryMode.Manual,
manualFulfillment: { status: ManualLineFulfillmentStatus.Pending }
}
]
});
expect(state.status).toBe(OrderStatus.Unfulfilled);
});
it('fulfills manual orders when checkout is satisfied and lines are fulfilled', () => {
const state = deriveOrderState({
failureReason: null,
checkoutInvoice: paidCheckoutInvoice,
lines: [
{
deliveryMode: DeliveryMode.Manual,
manualFulfillment: { status: ManualLineFulfillmentStatus.Fulfilled }
}
]
});
expect(state.status).toBe(OrderStatus.Fulfilled);
});
it('fulfills manual orders when paid shipping is satisfied and lines are fulfilled', () => {
const state = deriveOrderState({
failureReason: null,
checkoutInvoice: paidCheckoutInvoice,
shippingInvoice: paidShippingInvoice,
lines: [
{
deliveryMode: DeliveryMode.Manual,
manualFulfillment: { status: ManualLineFulfillmentStatus.Fulfilled }
}
]
});
expect(state.status).toBe(OrderStatus.Fulfilled);
});
it('stays unfulfilled when a shipping invoice exists but is not paid and confirmed', () => {
const state = deriveOrderState({
failureReason: null,
checkoutInvoice: paidCheckoutInvoice,
shippingInvoice: unpaidShippingInvoice,
lines: [
{
deliveryMode: DeliveryMode.Manual,
manualFulfillment: { status: ManualLineFulfillmentStatus.Fulfilled }
}
]
});
expect(state.status).toBe(OrderStatus.Unfulfilled);
});
});
@@ -0,0 +1,45 @@
import { deriveInvoiceState } from '../invoice/deriveInvoiceState';
import type { InvoiceState } from '../invoice/types/InvoiceState';
import { isSet } from '../isSet';
import { DeliveryMode } from '../../modules/product/types/DeliveryMode';
import { ManualLineFulfillmentStatus } from '../../modules/order/types/ManualLineFulfillmentStatus';
import { OrderStatus } from '../../modules/order/types/OrderStatus';
import type { OrderState } from './types/OrderState';
import type { OrderStateInput } from './types/OrderStateInput';
export const deriveOrderState = (order: OrderStateInput): OrderState => {
const checkoutInvoiceState = order.checkoutInvoice ? deriveInvoiceState(order.checkoutInvoice) : null;
const shippingInvoiceState = order.shippingInvoice ? deriveInvoiceState(order.shippingInvoice) : null;
return {
status: deriveOrderStatus(order, checkoutInvoiceState, shippingInvoiceState),
checkoutInvoiceState,
shippingInvoiceState
};
};
const deriveOrderStatus = (
order: OrderStateInput,
checkoutInvoiceState: InvoiceState | null,
shippingInvoiceState: InvoiceState | null
): OrderStatus => {
if (isSet(order.failureReason)) {
return OrderStatus.Unfulfillable;
}
if (!checkoutInvoiceState?.isPaidAndConfirmed) {
return OrderStatus.Unfulfilled;
}
if (order.shippingInvoice && !shippingInvoiceState?.isPaidAndConfirmed) {
return OrderStatus.Unfulfilled;
}
const manualLines = (order.lines ?? []).filter(line => line.deliveryMode === DeliveryMode.Manual);
const manualLinesFulfilled = manualLines.every(
line => line.manualFulfillment?.status === ManualLineFulfillmentStatus.Fulfilled
);
return manualLinesFulfilled ? OrderStatus.Fulfilled : OrderStatus.Unfulfilled;
};
@@ -0,0 +1,113 @@
import { deriveOrderTotals } from './deriveOrderTotals';
import type { OrderTotalsInput } from './types/OrderTotalsInput';
const baseOrder = (overrides: Partial<OrderTotalsInput> = {}): OrderTotalsInput => ({
lines: [{ lineSubtotalFiat: 10 }],
discounts: [],
checkoutInvoice: { amountFiat: 10 },
quotedAt: null,
shippingInvoice: null,
...overrides
});
describe('deriveOrderTotals', () => {
it('returns null grand total until shipping is quoted', () => {
const totals = deriveOrderTotals(baseOrder());
expect(totals.totalFiat).toBe(10);
expect(totals.shippingCostFiat).toBeNull();
expect(totals.grandTotalFiat).toBeNull();
});
it('includes quoted shipping in grand total', () => {
const totals = deriveOrderTotals(
baseOrder({
quotedAt: new Date('2026-01-01T12:00:00Z'),
shippingInvoice: { amountFiat: 5 }
})
);
expect(totals.shippingCostFiat).toBe(5);
expect(totals.grandTotalFiat).toBe(15);
});
it('treats quoted free shipping as zero in grand total', () => {
const totals = deriveOrderTotals(
baseOrder({
quotedAt: new Date('2026-01-01T12:00:00Z'),
shippingInvoice: null
})
);
expect(totals.shippingCostFiat).toBe(0);
expect(totals.grandTotalFiat).toBe(10);
});
it('sums subtotal from multiple lines', () => {
const totals = deriveOrderTotals(
baseOrder({
lines: [{ lineSubtotalFiat: 10 }, { lineSubtotalFiat: 25.5 }]
})
);
expect(totals.subtotalFiat).toBe(35.5);
});
it('sums discount total', () => {
const totals = deriveOrderTotals(
baseOrder({
discounts: [{ amountFiat: 2 }, { amountFiat: 3.5 }]
})
);
expect(totals.discountTotalFiat).toBe(5.5);
});
it('uses checkout invoice amount for totalFiat', () => {
const totals = deriveOrderTotals(
baseOrder({
lines: [{ lineSubtotalFiat: 100 }],
discounts: [{ amountFiat: 10 }],
checkoutInvoice: { amountFiat: 90 }
})
);
expect(totals.subtotalFiat).toBe(100);
expect(totals.discountTotalFiat).toBe(10);
expect(totals.totalFiat).toBe(90);
});
it('defaults missing checkout invoice to totalFiat 0', () => {
const totals = deriveOrderTotals(
baseOrder({
checkoutInvoice: null
})
);
expect(totals.totalFiat).toBe(0);
});
it('defaults missing lines and discounts to zero', () => {
const totals = deriveOrderTotals(
baseOrder({
lines: undefined,
discounts: undefined
})
);
expect(totals.subtotalFiat).toBe(0);
expect(totals.discountTotalFiat).toBe(0);
});
it('rounds grand total to two decimal places', () => {
const totals = deriveOrderTotals(
baseOrder({
checkoutInvoice: { amountFiat: 10.1 },
quotedAt: new Date('2026-01-01T12:00:00Z'),
shippingInvoice: { amountFiat: 5.335 }
})
);
expect(totals.grandTotalFiat).toBe(15.44);
});
});
@@ -0,0 +1,23 @@
import Decimal from 'decimal.js';
import { sumByKey } from '../sumByKey';
import { deriveShippingDeliveryCostFiat } from './deriveShippingDeliveryCostFiat';
import type { OrderTotals } from './types/OrderTotals';
import type { OrderTotalsInput } from './types/OrderTotalsInput';
export const deriveOrderTotals = (order: OrderTotalsInput): OrderTotals => {
const subtotalFiat = sumByKey(order.lines ?? [], 'lineSubtotalFiat');
const discountTotalFiat = sumByKey(order.discounts ?? [], 'amountFiat');
const totalFiat = order.checkoutInvoice?.amountFiat ?? 0;
const shippingCostFiat = deriveShippingDeliveryCostFiat(order);
return {
subtotalFiat,
discountTotalFiat,
totalFiat,
shippingCostFiat,
grandTotalFiat:
shippingCostFiat === null
? null
: new Decimal(totalFiat).plus(shippingCostFiat).toDecimalPlaces(2).toNumber()
};
};
@@ -0,0 +1,30 @@
import { deriveShippingDeliveryCostFiat } from './deriveShippingDeliveryCostFiat';
describe('deriveShippingDeliveryCostFiat', () => {
it('returns null when shipping is not quoted yet', () => {
expect(
deriveShippingDeliveryCostFiat({
quotedAt: null,
shippingInvoice: null
})
).toBeNull();
});
it('returns 0 for free shipping quotes without an invoice', () => {
expect(
deriveShippingDeliveryCostFiat({
quotedAt: new Date('2026-01-01T12:00:00Z'),
shippingInvoice: null
})
).toBe(0);
});
it('returns the shipping invoice amount when payment is required', () => {
expect(
deriveShippingDeliveryCostFiat({
quotedAt: new Date('2026-01-01T12:00:00Z'),
shippingInvoice: { amountFiat: 5 }
})
).toBe(5);
});
});
@@ -0,0 +1,17 @@
import { isSet } from '../isSet';
import type { ShippingDeliveryCostInput } from './types/ShippingDeliveryCostInput';
export const deriveShippingDeliveryCostFiat = ({
quotedAt,
shippingInvoice
}: ShippingDeliveryCostInput): number | null => {
if (!isSet(quotedAt)) {
return null;
}
if (shippingInvoice) {
return shippingInvoice.amountFiat;
}
return 0;
};
@@ -0,0 +1,7 @@
import { formatShortOrderId } from './formatShortOrderId';
describe('formatShortOrderId', () => {
it('prefixes the first four characters of the order id', () => {
expect(formatShortOrderId('abcd-efgh-ijkl')).toBe('#abcd');
});
});
@@ -0,0 +1,3 @@
export function formatShortOrderId(orderId: string): string {
return `#${orderId.slice(0, 4)}`;
}
@@ -0,0 +1,7 @@
import type { DeliveryMode } from '../../../modules/product/types/DeliveryMode';
import type { ManualLineFulfillmentStatus } from '../../../modules/order/types/ManualLineFulfillmentStatus';
export type OrderLineStateInput = {
deliveryMode: DeliveryMode;
manualFulfillment?: { status: ManualLineFulfillmentStatus } | null;
};
@@ -0,0 +1,8 @@
import type { InvoiceState } from '../../invoice/types/InvoiceState';
import type { OrderStatus } from '../../../modules/order/types/OrderStatus';
export type OrderState = {
status: OrderStatus;
checkoutInvoiceState: InvoiceState | null;
shippingInvoiceState: InvoiceState | null;
};
@@ -0,0 +1,10 @@
import type { InvoiceStateInput } from '../../invoice/types/InvoiceStateInput';
import type { OrderFailureReason } from '../../../modules/order/types/OrderFailureReason';
import type { OrderLineStateInput } from './OrderLineStateInput';
export type OrderStateInput = {
failureReason: OrderFailureReason | null;
checkoutInvoice?: InvoiceStateInput | null;
shippingInvoice?: InvoiceStateInput | null;
lines?: OrderLineStateInput[];
};
@@ -0,0 +1,7 @@
export type OrderTotals = {
subtotalFiat: number;
discountTotalFiat: number;
totalFiat: number;
shippingCostFiat: number | null;
grandTotalFiat: number | null;
};
@@ -0,0 +1,7 @@
import type { ShippingDeliveryCostInput } from './ShippingDeliveryCostInput';
export type OrderTotalsInput = ShippingDeliveryCostInput & {
lines?: Array<{ lineSubtotalFiat: number }>;
discounts?: Array<{ amountFiat: number }>;
checkoutInvoice?: { amountFiat: number } | null;
};
@@ -0,0 +1,4 @@
export type ShippingDeliveryCostInput = {
quotedAt: Date | null;
shippingInvoice?: { amountFiat: number } | null;
};
+10
View File
@@ -0,0 +1,10 @@
import { Logger } from '@nestjs/common';
import { unlink } from 'node:fs/promises';
export const removeFileFromDisk = async (path: string, logContext = 'removeFileFromDisk'): Promise<void> => {
try {
await unlink(path);
} catch {
Logger.error(`Failed to remove file ${path}`, logContext);
}
};
@@ -0,0 +1,118 @@
import type { Request } from 'express';
import { safeInternalShopRedirectPath } from './safeInternalShopRedirectPath';
import type { RequestOverrides } from './types/SafeInternalShopRedirectPathTestTypes';
const mockRequestGet = (impl: (name: string) => string | undefined): Request['get'] =>
jest.fn(impl) as unknown as Request['get'];
const buildRedirectTestRequest = (overrides: RequestOverrides = {}): Request =>
({
protocol: 'https',
get: mockRequestGet(header => {
if (header === 'host') {
return 'shop.example.test';
}
if (header === 'referer') {
return 'https://shop.example.test/cart?tab=items';
}
return undefined;
}),
...overrides
}) as Request;
describe('safeInternalShopRedirectPath', () => {
it('returns the fallback when referer is missing', () => {
const req = buildRedirectTestRequest({
get: mockRequestGet(() => undefined)
});
expect(safeInternalShopRedirectPath(req, '/cart')).toBe('/cart');
});
it('returns same-origin pathname and search from referer', () => {
expect(safeInternalShopRedirectPath(buildRedirectTestRequest())).toBe('/cart?tab=items');
});
it('returns pathname without search when referer has no query string', () => {
const req = buildRedirectTestRequest({
get: mockRequestGet(header => {
if (header === 'host') {
return 'shop.example.test';
}
if (header === 'referer') {
return 'https://shop.example.test/checkout';
}
return undefined;
})
});
expect(safeInternalShopRedirectPath(req)).toBe('/checkout');
});
it('returns the fallback for cross-origin referers', () => {
const req = buildRedirectTestRequest({
get: mockRequestGet(header => {
if (header === 'host') {
return 'shop.example.test';
}
if (header === 'referer') {
return 'https://evil.example/phish';
}
return undefined;
})
});
expect(safeInternalShopRedirectPath(req)).toBe('/');
});
it('returns the fallback when referer host matches but protocol differs', () => {
const req = buildRedirectTestRequest({
protocol: 'http',
get: mockRequestGet(header => {
if (header === 'host') {
return 'shop.example.test';
}
if (header === 'referer') {
return 'https://shop.example.test/cart';
}
return undefined;
})
});
expect(safeInternalShopRedirectPath(req, '/safe')).toBe('/safe');
});
it('returns the fallback when referer uses a different port on the same host', () => {
const req = buildRedirectTestRequest({
get: mockRequestGet(header => {
if (header === 'host') {
return 'shop.example.test';
}
if (header === 'referer') {
return 'https://shop.example.test:8443/admin';
}
return undefined;
})
});
expect(safeInternalShopRedirectPath(req)).toBe('/');
});
it('returns the fallback for malformed referer URLs', () => {
const req = buildRedirectTestRequest({
get: mockRequestGet(header => (header === 'referer' ? 'not-a-url' : 'shop.example.test'))
});
expect(safeInternalShopRedirectPath(req, '/checkout')).toBe('/checkout');
});
});
@@ -0,0 +1,28 @@
import type { Request } from 'express';
/**
* Same-origin `pathname + search` from `Referer`, otherwise `fallbackPath` (typically `/`).
* Convenience for Post/Redirect/Get: redirect back to where the POST came from without open redirects.
*/
export const safeInternalShopRedirectPath = (req: Request, fallbackPath: string = '/'): string => {
const referer = req.get('referer');
if (!referer) {
return fallbackPath;
}
try {
const url = new URL(referer);
const expectedOrigin = `${req.protocol}://${req.get('host')}`;
if (url.origin !== expectedOrigin) {
return fallbackPath;
}
const pathWithQuery = `${url.pathname}${url.search}`;
return pathWithQuery.length > 0 ? pathWithQuery : fallbackPath;
} catch {
return fallbackPath;
}
};
@@ -0,0 +1,17 @@
import { sanitizeUploadFilename } from './sanitizeUploadFilename';
describe('sanitizeUploadFilename', () => {
it('strips path segments and unsafe characters', () => {
expect(sanitizeUploadFilename('../../evil name "file".pdf')).toBe('evil name file.pdf');
});
it('returns file when the sanitized name is empty', () => {
expect(sanitizeUploadFilename(' ')).toBe('file');
});
it('truncates very long filenames', () => {
const longName = `${'a'.repeat(300)}.pdf`;
expect(sanitizeUploadFilename(longName).length).toBe(255);
});
});
@@ -0,0 +1,22 @@
import { basename } from 'node:path';
const MAX_UPLOAD_FILENAME_LENGTH = 255;
export const sanitizeUploadFilename = (name: string): string => {
const base = basename(name.normalize('NFC'));
const cleaned = base
// Unicode control characters (NUL, CR, LF, DEL, etc.)
.replace(/\p{Cc}/gu, '')
// Characters that break Content-Disposition quoted strings
.replace(/["\\]/g, '')
// Collapse runs of whitespace
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) {
return 'file';
}
return cleaned.slice(0, MAX_UPLOAD_FILENAME_LENGTH);
};
@@ -0,0 +1,24 @@
import { SHOP_SURFACE_HEADER_NAME } from '../consts/shopSurfaceHeader';
import { NodeEnv } from '../types/NodeEnv';
import { ShopSurface } from '../types/ShopSurface';
import { shouldUseSecureCookie } from './shouldUseSecureCookie';
describe('shouldUseSecureCookie', () => {
it('returns true only for production clearnet requests', () => {
const req = { headers: { [SHOP_SURFACE_HEADER_NAME]: ShopSurface.Clearnet } };
expect(shouldUseSecureCookie(NodeEnv.Production, req)).toBe(true);
});
it('returns false for production onion requests', () => {
const req = { headers: { [SHOP_SURFACE_HEADER_NAME]: ShopSurface.Onion } };
expect(shouldUseSecureCookie(NodeEnv.Production, req)).toBe(false);
});
it('returns false outside production', () => {
const req = { headers: { [SHOP_SURFACE_HEADER_NAME]: ShopSurface.Clearnet } };
expect(shouldUseSecureCookie(NodeEnv.Development, req)).toBe(false);
});
});
@@ -0,0 +1,10 @@
import type { Request } from 'express';
import { SHOP_SURFACE_HEADER_NAME } from '../consts/shopSurfaceHeader';
import { NodeEnv } from '../types/NodeEnv';
import { ShopSurface } from '../types/ShopSurface';
export const shouldUseSecureCookie = (nodeEnv: string | undefined, req: Pick<Request, 'headers'>): boolean => {
const raw = req.headers[SHOP_SURFACE_HEADER_NAME];
return nodeEnv === NodeEnv.Production && raw === ShopSurface.Clearnet;
};
+4
View File
@@ -0,0 +1,4 @@
export const sleep = (ms: number): Promise<void> =>
new Promise(resolve => {
setTimeout(resolve, ms);
});
@@ -0,0 +1,10 @@
import { toStorefrontDiscountView } from './toStorefrontDiscountView';
describe('toStorefrontDiscountView', () => {
it('maps discount code and amount to the storefront view', () => {
expect(toStorefrontDiscountView({ code: 'SAVE10', amountFiat: 5 })).toEqual({
code: 'SAVE10',
amountFiat: 5
});
});
});
@@ -0,0 +1,10 @@
import type { StorefrontDiscountView } from '../../modules/storefrontCore/types/StorefrontDiscountView';
import type { StorefrontDiscountViewInput } from './types/StorefrontDiscountViewInput';
export const toStorefrontDiscountView = ({
code,
amountFiat
}: StorefrontDiscountViewInput): StorefrontDiscountView => ({
code,
amountFiat
});
@@ -0,0 +1,4 @@
export type StorefrontDiscountViewInput = {
code: string;
amountFiat: number;
};
+19
View File
@@ -0,0 +1,19 @@
import { sumByKey } from './sumByKey';
describe('sumByKey', () => {
it('sums numeric values by key with two decimal places by default', () => {
const items = [{ amountFiat: 10.1 }, { amountFiat: 20.2 }];
expect(sumByKey(items, 'amountFiat')).toBe(30.3);
});
it('returns zero for empty collections', () => {
expect(sumByKey([] as { amountFiat: number }[], 'amountFiat')).toBe(0);
});
it('supports custom decimal places', () => {
const items = [{ qty: 1 }, { qty: 2 }];
expect(sumByKey(items, 'qty', 0)).toBe(3);
});
});
+8
View File
@@ -0,0 +1,8 @@
import Decimal from 'decimal.js';
import type { NumericKeyOf } from './types/NumericKeyOf';
export const sumByKey = <T>(items: readonly T[], key: NumericKeyOf<T>, decimalPlaces = 2): number =>
items
.reduce((sum, item) => sum.plus(item[key] as number), new Decimal(0))
.toDecimalPlaces(decimalPlaces)
.toNumber();

Some files were not shown because too many files have changed in this diff Show More