From 53980636ecfa3a7714b1d5615b53e9cfbdc84fbc Mon Sep 17 00:00:00 2001 From: eleven-smg Date: Sun, 26 Jul 2026 17:16:24 +0100 Subject: [PATCH] feat(transactions): add tested payment receipt view-model (#216) --- __tests__/receipt.test.ts | 131 ++++++++++++++++++++++++ src/features/transactions/receipt.ts | 147 +++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 __tests__/receipt.test.ts create mode 100644 src/features/transactions/receipt.ts diff --git a/__tests__/receipt.test.ts b/__tests__/receipt.test.ts new file mode 100644 index 0000000..247044f --- /dev/null +++ b/__tests__/receipt.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/features/transactions/receipt.ts b/src/features/transactions/receipt.ts new file mode 100644 index 0000000..f254826 --- /dev/null +++ b/src/features/transactions/receipt.ts @@ -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, + }; +} \ No newline at end of file