diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..fa27750c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.md text eol=lf +*.css text eol=lf diff --git a/backend/src/__tests__/loanConfig.test.ts b/backend/src/__tests__/loanConfig.test.ts index b32c78ce..58b307bc 100644 --- a/backend/src/__tests__/loanConfig.test.ts +++ b/backend/src/__tests__/loanConfig.test.ts @@ -1,3 +1,4 @@ +import { jest } from '@jest/globals'; import { validateLoanConfig, validateLoanConfigOnStartup } from '../config/loanConfig.js'; describe('Loan config startup validation', () => { diff --git a/backend/src/__tests__/roundToCents.test.ts b/backend/src/__tests__/roundToCents.test.ts new file mode 100644 index 00000000..1b085c1c --- /dev/null +++ b/backend/src/__tests__/roundToCents.test.ts @@ -0,0 +1,23 @@ +import { roundToCents } from '../controllers/loanController.js'; + +describe('roundToCents half-even rounding', () => { + it('applies banker rounding (half-even) for .5 cent cases', () => { + expect(roundToCents(0.125)).toBe(0.12); + expect(roundToCents(0.135)).toBe(0.14); + expect(roundToCents(10.005)).toBe(10.0); + expect(roundToCents(10.015)).toBe(10.02); + }); + + it('handles negative half-even rounding symmetrically', () => { + expect(roundToCents(-0.125)).toBe(-0.12); + expect(roundToCents(-0.135)).toBe(-0.14); + }); + + it('rounds non-tie values to nearest cent', () => { + expect(roundToCents(10.004)).toBe(10.0); + expect(roundToCents(10.006)).toBe(10.01); + expect(roundToCents(12.3456)).toBe(12.35); + expect(roundToCents(12.3412)).toBe(12.34); + expect(roundToCents(0.0049)).toBe(0); + }); +}); diff --git a/backend/src/controllers/loanController.ts b/backend/src/controllers/loanController.ts index b79ba540..a11b2a8d 100644 --- a/backend/src/controllers/loanController.ts +++ b/backend/src/controllers/loanController.ts @@ -12,6 +12,7 @@ import logger from '../utils/logger.js'; import { cacheService } from '../services/cacheService.js'; import { notificationService } from '../services/notificationService.js'; import { invalidateOnRepay, invalidateOnLoanRequest } from '../utils/cacheKeys.js'; +import { roundToCents } from '../money/decimal.js'; // ─── Test/Dev Only ──────────────────────────────────────────────────────────── @@ -225,7 +226,7 @@ const getLatestLedger = async (): Promise => { return result.rows[0]?.last_indexed_ledger ?? 0; }; -const roundToCents = (value: number): number => Math.floor((value + Number.EPSILON) * 100) / 100; +export { roundToCents }; const addDays = (date: Date, days: number): Date => { const result = new Date(date); diff --git a/backend/src/money/__tests__/decimal.test.ts b/backend/src/money/__tests__/decimal.test.ts index 99448a00..7583255f 100644 --- a/backend/src/money/__tests__/decimal.test.ts +++ b/backend/src/money/__tests__/decimal.test.ts @@ -3,6 +3,7 @@ import { toStroops, fromStroops, splitProRata, + roundToCents, RoundingMode, MoneyError, STROOP_SCALE, @@ -168,3 +169,17 @@ describe('splitProRata', () => { expect(() => splitProRata(1n, [-1n])).toThrow(MoneyError); }); }); + +describe('roundToCents', () => { + it('rounds via half-even rounding mode (bankers rounding)', () => { + expect(roundToCents(0.125)).toBe(0.12); + expect(roundToCents(0.135)).toBe(0.14); + expect(roundToCents(10.005)).toBe(10.0); + expect(roundToCents(10.015)).toBe(10.02); + }); + + it('handles negative numbers symmetrically', () => { + expect(roundToCents(-0.125)).toBe(-0.12); + expect(roundToCents(-0.135)).toBe(-0.14); + }); +}); diff --git a/backend/src/money/decimal.ts b/backend/src/money/decimal.ts index 1ee9f5d8..6e45098e 100644 --- a/backend/src/money/decimal.ts +++ b/backend/src/money/decimal.ts @@ -154,6 +154,17 @@ export function fromStroops(value: bigint): string { return `${negative ? '-' : ''}${whole.toString()}.${fraction}`; } +/** + * Round a numeric amount to 2 decimal places (cents) using the policy's + * default half-even rounding mode. + */ +export function roundToCents(value: number, mode: RoundingMode = DEFAULT_MODE): number { + if (!Number.isFinite(value)) return value; + const stroops = toStroops(value.toFixed(7), mode); + const cents = roundDiv(stroops, 100_000n, mode); + return Number(cents) / 100; +} + /** * Split `total` stroops across `weights` proportionally using the * largest-remainder method, guaranteeing the returned parts sum *exactly* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f12306c6..26e69e1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11965,7 +11965,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/frontend/src/app/[locale]/activity/page.tsx b/frontend/src/app/[locale]/activity/page.tsx index 626f16e9..cbf2deaf 100644 --- a/frontend/src/app/[locale]/activity/page.tsx +++ b/frontend/src/app/[locale]/activity/page.tsx @@ -84,7 +84,10 @@ export default function ActivityPage() { const remittanceEvents: ActivityItem[] = remittances.map((remittance) => ({ id: `remittance-${remittance.id}`, type: "Remittance", - description: `To ${remittance.recipientAddress.slice(0, 6)}...${remittance.recipientAddress.slice(-4)}`, + description: `To ${remittance.recipientAddress.slice( + 0, + 6, + )}...${remittance.recipientAddress.slice(-4)}`, amount: `-${formatCurrency(remittance.amount)}`, timestamp: new Date(remittance.createdAt).toISOString(), status: remittance.status, diff --git a/frontend/src/app/components/borrower/LoanCard.tsx b/frontend/src/app/components/borrower/LoanCard.tsx index f92ec65d..4e172b46 100644 --- a/frontend/src/app/components/borrower/LoanCard.tsx +++ b/frontend/src/app/components/borrower/LoanCard.tsx @@ -150,7 +150,9 @@ export function LoanCard({ loan, variant = "compact" }: LoanCardProps) {

Next Payment

{formatDate(loan.nextPaymentDeadline)}

diff --git a/frontend/src/app/components/dashboards/FinancialPerformanceDashboard.tsx b/frontend/src/app/components/dashboards/FinancialPerformanceDashboard.tsx index c6862d79..c25a1368 100644 --- a/frontend/src/app/components/dashboards/FinancialPerformanceDashboard.tsx +++ b/frontend/src/app/components/dashboards/FinancialPerformanceDashboard.tsx @@ -354,7 +354,9 @@ export function FinancialPerformanceDashboard({
@@ -417,7 +419,11 @@ export function FinancialPerformanceDashboard({ value={fmt(displayDepositorStats.currentValue)} sub={ displayDepositorStats.depositAmount > 0 - ? `+${(((displayDepositorStats.currentValue - displayDepositorStats.depositAmount) / displayDepositorStats.depositAmount) * 100).toFixed(2)}% growth` + ? `+${( + ((displayDepositorStats.currentValue - displayDepositorStats.depositAmount) / + displayDepositorStats.depositAmount) * + 100 + ).toFixed(2)}% growth` : undefined } colorClass="bg-blue-50 text-blue-900 dark:bg-blue-950/30 dark:text-blue-200" diff --git a/frontend/src/app/components/loan-wizard/StepAmountAsset.tsx b/frontend/src/app/components/loan-wizard/StepAmountAsset.tsx index 861d55f6..6c55eee1 100644 --- a/frontend/src/app/components/loan-wizard/StepAmountAsset.tsx +++ b/frontend/src/app/components/loan-wizard/StepAmountAsset.tsx @@ -152,7 +152,9 @@ export function StepAmountAsset({ data, onChange, onNext, error, onError }: Step helperText || (data.maxAmount === 0 ? "Not eligible" - : `Eligible range: ${formatMoney(minAmount)} – ${formatMoney(data.maxAmount)} • Max ${decimals} decimal places`) + : `Eligible range: ${formatMoney(minAmount)} – ${formatMoney( + data.maxAmount, + )} • Max ${decimals} decimal places`) } error={precisionError || undefined} /> diff --git a/frontend/src/app/components/ui.tsx b/frontend/src/app/components/ui.tsx index 489fdddd..540c805b 100644 --- a/frontend/src/app/components/ui.tsx +++ b/frontend/src/app/components/ui.tsx @@ -1 +1 @@ -export { COPY_FEEDBACK_RESET_MS } from './ui/CopyButton'; +export { COPY_FEEDBACK_RESET_MS } from "./ui/CopyButton"; diff --git a/frontend/src/app/components/ui/OperationProgress.tsx b/frontend/src/app/components/ui/OperationProgress.tsx index 2df7d230..09eb4dca 100644 --- a/frontend/src/app/components/ui/OperationProgress.tsx +++ b/frontend/src/app/components/ui/OperationProgress.tsx @@ -88,7 +88,10 @@ export function OperationProgress({ transaction, type = "generic" }: OperationPr href={`https://stellar.expert/explorer/testnet/tx/${txHash}`} target="_blank" rel="noopener noreferrer" - aria-label={`View transaction ${txHash.slice(0, 8)}… on Stellar Explorer (opens in new tab)`} + aria-label={`View transaction ${txHash.slice( + 0, + 8, + )}… on Stellar Explorer (opens in new tab)`} className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 hover:underline flex items-center gap-1" > diff --git a/frontend/src/app/components/ui/Toaster.tsx b/frontend/src/app/components/ui/Toaster.tsx index adeab47d..52e9f45b 100644 --- a/frontend/src/app/components/ui/Toaster.tsx +++ b/frontend/src/app/components/ui/Toaster.tsx @@ -57,7 +57,9 @@ function ToastCard({ toast }: { toast: ToastItem }) { animate={{ opacity: 1, x: 0, scale: 1 }} exit={{ opacity: 0, x: 20, scale: 0.96 }} transition={{ duration: 0.2, ease: "easeOut" }} - className={`w-full rounded-xl border p-4 shadow-lg shadow-zinc-900/5 ${getToastStyles(toast.type)}`} + className={`w-full rounded-xl border p-4 shadow-lg shadow-zinc-900/5 ${getToastStyles( + toast.type, + )}`} role="alert" >
diff --git a/frontend/src/app/hooks/useReveal.test.tsx b/frontend/src/app/hooks/useReveal.test.tsx index 3feac3ab..454acc39 100644 --- a/frontend/src/app/hooks/useReveal.test.tsx +++ b/frontend/src/app/hooks/useReveal.test.tsx @@ -125,12 +125,10 @@ describe("useReveal", () => { it("does not expose the value before the request resolves", async () => { let resolveFetch!: (val: unknown) => void; - global.fetch = jest.fn( - () => - new Promise((resolve) => { - resolveFetch = resolve; - }), - ) as unknown as typeof fetch; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + global.fetch = jest.fn(() => fetchPromise) as unknown as typeof fetch; const { result } = renderHook(() => useReveal(), { wrapper: createWrapper() }); diff --git a/frontend/src/app/utils/transactionFormatter.ts b/frontend/src/app/utils/transactionFormatter.ts index 84a9898d..9aa8fe34 100644 --- a/frontend/src/app/utils/transactionFormatter.ts +++ b/frontend/src/app/utils/transactionFormatter.ts @@ -186,7 +186,10 @@ export function formatRemittanceSend(params: { const operations: TransactionOperation[] = [ { type: "Send Remittance", - description: `You are sending ${params.amount} ${params.token} to ${params.recipient.slice(0, 8)}...${params.recipient.slice(-6)}`, + description: `You are sending ${params.amount} ${params.token} to ${params.recipient.slice( + 0, + 8, + )}...${params.recipient.slice(-6)}`, amount: params.amount.toString(), token: params.token, details: {