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
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions backend/src/__tests__/loanConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { jest } from '@jest/globals';
import { validateLoanConfig, validateLoanConfigOnStartup } from '../config/loanConfig.js';

describe('Loan config startup validation', () => {
Expand Down
23 changes: 23 additions & 0 deletions backend/src/__tests__/roundToCents.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion backend/src/controllers/loanController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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 ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -52,7 +53,7 @@
try {
const { loanId } = req.params;

const borrower = (req as any).user?.publicKey as string;

Check warning on line 56 in backend/src/controllers/loanController.ts

View workflow job for this annotation

GitHub Actions / backend

Unexpected any. Specify a different type

const result = await query('SELECT * FROM loans WHERE id = $1', [loanId]);
const loan = result.rows[0] as Record<string, unknown> | undefined;
Expand Down Expand Up @@ -225,7 +226,7 @@
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);
Expand Down
15 changes: 15 additions & 0 deletions backend/src/money/__tests__/decimal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
toStroops,
fromStroops,
splitProRata,
roundToCents,
RoundingMode,
MoneyError,
STROOP_SCALE,
Expand Down Expand Up @@ -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);
});
});
11 changes: 11 additions & 0 deletions backend/src/money/decimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
1 change: 0 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion frontend/src/app/[locale]/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/app/components/borrower/LoanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ export function LoanCard({ loan, variant = "compact" }: LoanCardProps) {
<div className={`p-4 rounded-lg ${deadlineBg}`}>
<p className="text-sm text-gray-600 mb-1">Next Payment</p>
<p
className={`${variant === "compact" ? "text-lg" : "text-sm"} font-semibold ${deadlineTextColor}`}
className={`${
variant === "compact" ? "text-lg" : "text-sm"
} font-semibold ${deadlineTextColor}`}
>
{formatDate(loan.nextPaymentDeadline)}
</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,9 @@ export function FinancialPerformanceDashboard({
<StatCard
title="On-time Rate"
value={`${displayLoanStats.onTimeRate}%`}
sub={`${displayLoanStats.repaid} of ${displayLoanStats.repaid + displayLoanStats.defaulted} resolved`}
sub={`${displayLoanStats.repaid} of ${
displayLoanStats.repaid + displayLoanStats.defaulted
} resolved`}
colorClass="bg-purple-50 text-purple-900 dark:bg-purple-950/30 dark:text-purple-200"
/>
</div>
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/app/components/loan-wizard/StepAmountAsset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/components/ui.tsx
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { COPY_FEEDBACK_RESET_MS } from './ui/CopyButton';
export { COPY_FEEDBACK_RESET_MS } from "./ui/CopyButton";
5 changes: 4 additions & 1 deletion frontend/src/app/components/ui/OperationProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<Clock className="h-3 w-3" />
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/app/components/ui/Toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="flex items-start gap-3">
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/app/hooks/useReveal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() });

Expand Down
5 changes: 4 additions & 1 deletion frontend/src/app/utils/transactionFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading