Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions __tests__/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Payment receipt view-model tests (issue #216).
*
* Covers the pure receipt formatting the success screen relies on but that the
* existing suite only exercises indirectly through the rendered screen:
* - "shows transaction details": amount (+unit), date, destination.
* - "hash can be copied": the full hash is preserved untouched.
* - "explorer link where configured": hasExplorerLink reflects the input.
* - "missing data handled gracefully": every field degrades to a placeholder.
* - "UI remains non-technical / avoid raw payloads": the displayed hash is
* truncated, never the raw full-length value.
*/

import {
buildPaymentReceipt,
formatReceiptAmount,
formatReceiptDate,
truncateHash,
RECEIPT_PLACEHOLDER,
HASH_LEAD,
HASH_TAIL,
} from '../src/features/transactions/receipt';

const TX_HASH = 'a1b2c3d4e5f6abcdef1234567890abcdef1234567890abcdef1234567890ab';
const DESTINATION = 'GBXXXXVALIDSTELLARADDRESS1234567890ABCDEFGHIJKLMNOPQRSTUVWX';
const EXPLORER_URL = 'https://stellar.expert/explorer/testnet/tx/' + TX_HASH;

describe('formatReceiptAmount', () => {
it('appends the XLM unit to a valid amount', () => {
expect(formatReceiptAmount('25')).toBe('25 XLM');
});

it('preserves decimal formatting from formatAmount', () => {
expect(formatReceiptAmount('1234.5')).toBe('1,234.5 XLM');
});

it('returns the placeholder (with no unit) for missing amounts', () => {
expect(formatReceiptAmount(undefined)).toBe(RECEIPT_PLACEHOLDER);
expect(formatReceiptAmount(null)).toBe(RECEIPT_PLACEHOLDER);
expect(formatReceiptAmount('')).toBe(RECEIPT_PLACEHOLDER);
});

it('returns the placeholder for non-numeric amounts', () => {
expect(formatReceiptAmount('xyz')).toBe(RECEIPT_PLACEHOLDER);
});
});

describe('formatReceiptDate', () => {
it('formats a valid date and includes the year', () => {
const out = formatReceiptDate('2026-07-26T15:40:00.000Z');
expect(out).not.toBe(RECEIPT_PLACEHOLDER);
expect(out).toContain('2026');
});

it('returns the placeholder for a missing date', () => {
expect(formatReceiptDate(undefined)).toBe(RECEIPT_PLACEHOLDER);
expect(formatReceiptDate(null)).toBe(RECEIPT_PLACEHOLDER);
expect(formatReceiptDate('')).toBe(RECEIPT_PLACEHOLDER);
});

it('returns the placeholder for an invalid date', () => {
expect(formatReceiptDate('invalid-date-string')).toBe(RECEIPT_PLACEHOLDER);
});
});

describe('truncateHash', () => {
it('keeps the leading and trailing characters and shortens the value', () => {
const out = truncateHash(TX_HASH);
expect(out.startsWith(TX_HASH.slice(0, HASH_LEAD))).toBe(true);
expect(out.endsWith(TX_HASH.slice(TX_HASH.length - HASH_TAIL))).toBe(true);
expect(out).not.toBe(TX_HASH);
expect(out.length).toBeLessThan(TX_HASH.length);
});

it('returns short values unchanged', () => {
expect(truncateHash('abcdef')).toBe('abcdef');
});

it('returns the placeholder for a missing hash', () => {
expect(truncateHash(undefined)).toBe(RECEIPT_PLACEHOLDER);
expect(truncateHash(null)).toBe(RECEIPT_PLACEHOLDER);
expect(truncateHash('')).toBe(RECEIPT_PLACEHOLDER);
});
});

describe('buildPaymentReceipt', () => {
it('produces display-ready details for a complete receipt', () => {
const vm = buildPaymentReceipt({
hash: TX_HASH,
amount: '25',
destination: DESTINATION,
date: '2026-07-26T15:40:00.000Z',
destinationLabel: 'Alice',
explorerUrl: EXPLORER_URL,
});
expect(vm.displayAmount).toBe('25 XLM');
expect(vm.displayDestination).toBe(DESTINATION);
expect(vm.destinationLabel).toBe('Alice');
expect(vm.displayDate).toContain('2026');
expect(vm.canCopyHash).toBe(true);
expect(vm.hasExplorerLink).toBe(true);
expect(vm.explorerUrl).toBe(EXPLORER_URL);
});

it('preserves the full hash for copying while displaying a truncated form', () => {
const vm = buildPaymentReceipt({ hash: TX_HASH });
expect(vm.fullHash).toBe(TX_HASH);
expect(vm.displayHash).not.toBe(TX_HASH);
expect(vm.displayHash.length).toBeLessThan(TX_HASH.length);
});

it('handles a completely empty receipt gracefully', () => {
const vm = buildPaymentReceipt({});
expect(vm.displayAmount).toBe(RECEIPT_PLACEHOLDER);
expect(vm.displayDate).toBe(RECEIPT_PLACEHOLDER);
expect(vm.displayDestination).toBe(RECEIPT_PLACEHOLDER);
expect(vm.displayHash).toBe(RECEIPT_PLACEHOLDER);
expect(vm.fullHash).toBeNull();
expect(vm.destinationLabel).toBeNull();
expect(vm.canCopyHash).toBe(false);
expect(vm.hasExplorerLink).toBe(false);
expect(vm.explorerUrl).toBeNull();
});

it('offers no explorer link when none is configured', () => {
const vm = buildPaymentReceipt({ hash: TX_HASH, explorerUrl: null });
expect(vm.canCopyHash).toBe(true);
expect(vm.hasExplorerLink).toBe(false);
expect(vm.explorerUrl).toBeNull();
});
});
147 changes: 147 additions & 0 deletions src/features/transactions/receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Payment success receipt view-model (issue #216).
*
* The payment-success screen (app/payment-success.tsx) currently derives all of
* its receipt strings inline -- the amount + "XLM" suffix, the date formatting
* and fallback, the destination fallback -- and it renders the full raw
* transaction hash. Issue #216 asks the receipt to show recipient, amount,
* date, a copyable hash and an explorer link "where configured", while keeping
* the UI non-technical and avoiding raw technical payloads.
*
* This module is the pure, framework-free view-model behind that receipt: one
* function turns the raw route params into ready-to-render, non-technical
* strings with graceful fallbacks for every missing/invalid field, truncates
* the hash for display, and preserves the full hash for copy-to-clipboard. It
* imports no React Native / Expo code and changes no existing behaviour, so it
* can be unit-tested exhaustively and adopted by the screen incrementally.
*/

import { formatAmount } from '../../utils/amount';

/** Shown for any receipt field that is missing or cannot be parsed. */
export const RECEIPT_PLACEHOLDER = '\u2014'; // em dash

/** Character inserted between the kept head and tail of a truncated hash. */
export const HASH_ELLIPSIS = '\u2026'; // horizontal ellipsis

/** Number of leading hash characters kept in the non-technical display form. */
export const HASH_LEAD = 6;

/** Number of trailing hash characters kept in the non-technical display form. */
export const HASH_TAIL = 6;

/** Raw receipt inputs, typically the route params of the success screen. */
export interface PaymentReceiptInput {
hash?: string | null;
amount?: string | null;
destination?: string | null;
date?: string | null;
/** Optional human label resolved from contacts for the destination. */
destinationLabel?: string | null;
/**
* Explorer transaction URL, or null/undefined when no explorer is configured
* for the active network. The caller computes this (e.g. via the stellar
* service) so this module stays pure and network-agnostic.
*/
explorerUrl?: string | null;
}

/** Ready-to-render receipt fields. Every string is safe to display as-is. */
export interface PaymentReceiptViewModel {
displayAmount: string;
displayDate: string;
displayDestination: string;
/** Non-technical, truncated hash for display (never the raw full value). */
displayHash: string;
/** Full hash, preserved for copy-to-clipboard, or null when unavailable. */
fullHash: string | null;
/** Human contact label for the destination, when known. */
destinationLabel: string | null;
canCopyHash: boolean;
explorerUrl: string | null;
hasExplorerLink: boolean;
}

function isNonEmpty(value: string | null | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

/**
* Format the amount for the receipt, appending the "XLM" unit. Falls back to the
* placeholder (with no unit) when the amount is missing or non-numeric.
*/
export function formatReceiptAmount(amount: string | number | null | undefined): string {
const formatted = formatAmount(amount ?? undefined);
if (!formatted || formatted === RECEIPT_PLACEHOLDER) {
return RECEIPT_PLACEHOLDER;
}
return formatted + ' XLM';
}

/**
* Format an ISO/parseable date string for the receipt. Missing or invalid dates
* degrade to the placeholder rather than throwing or rendering "Invalid Date".
*/
export function formatReceiptDate(date: string | null | undefined): string {
if (!isNonEmpty(date)) {
return RECEIPT_PLACEHOLDER;
}
const parsed = new Date(date);
if (isNaN(parsed.getTime())) {
return RECEIPT_PLACEHOLDER;
}
return parsed.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

/**
* Truncate a hash to a short, non-technical form keeping the leading and
* trailing characters so it is still recognisable. The full value is preserved
* separately for copying. Returns the placeholder for a missing hash and
* returns short hashes unchanged.
*/
export function truncateHash(
hash: string | null | undefined,
lead: number = HASH_LEAD,
tail: number = HASH_TAIL,
): string {
if (!isNonEmpty(hash)) {
return RECEIPT_PLACEHOLDER;
}
const trimmed = hash.trim();
if (trimmed.length <= lead + tail + 1) {
return trimmed;
}
return trimmed.slice(0, lead) + HASH_ELLIPSIS + trimmed.slice(trimmed.length - tail);
}

/**
* Build the full receipt view-model from raw inputs. Pure and total: it never
* throws and always returns display-safe strings, so the screen can render the
* receipt without any additional guarding.
*/
export function buildPaymentReceipt(input: PaymentReceiptInput): PaymentReceiptViewModel {
const fullHash = isNonEmpty(input.hash) ? input.hash.trim() : null;
const explorerUrl = isNonEmpty(input.explorerUrl) ? input.explorerUrl.trim() : null;
const destination = isNonEmpty(input.destination) ? input.destination.trim() : null;
const destinationLabel = isNonEmpty(input.destinationLabel)
? input.destinationLabel.trim()
: null;

return {
displayAmount: formatReceiptAmount(input.amount),
displayDate: formatReceiptDate(input.date),
displayDestination: destination ?? RECEIPT_PLACEHOLDER,
displayHash: truncateHash(fullHash),
fullHash,
destinationLabel,
canCopyHash: fullHash !== null,
explorerUrl,
hasExplorerLink: explorerUrl !== null,
};
}