init
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
})
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user