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
12 changes: 6 additions & 6 deletions contracts/loan_manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,18 +569,18 @@ impl LoanManager {
}

let remaining_principal = Self::remaining_principal(loan);
let debt_before_late_fees = remaining_principal
.checked_add(loan.accrued_interest)
.expect("debt overflow");

// Stop accruing fees if principal + interest is fully paid
if debt_before_late_fees <= 0 {
// Stop accruing fees if principal is fully paid
if remaining_principal <= 0 {
loan.last_late_fee_ledger = current_ledger;
return 0;
}

let overdue_ledgers = current_ledger - late_fee_start;
let incremental_fee = debt_before_late_fees
// Late fee is calculated on original principal amount only, not remaining debt.
// This ensures the 25% late fee cap is meaningful regardless of payment state.
let incremental_fee = loan
.amount
.checked_mul(Self::late_fee_rate_bps(env) as i128)
.and_then(|value| value.checked_mul(overdue_ledgers as i128))
.and_then(|value| value.checked_div(10_000))
Expand Down
30 changes: 24 additions & 6 deletions frontend/src/app/[locale]/lend/LendPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
getPrecisionError,
parseAmount,
sanitizeAmountInput,
formatAmountOnBlur,
getAssetDecimals,
} from "../../utils/amount";

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
Expand Down Expand Up @@ -72,8 +74,10 @@ export function LendPageClient() {

const depositPrecisionError = getPrecisionError(depositAmount, "USDC");
const withdrawPrecisionError = getPrecisionError(withdrawAmount, "USDC");
const depositHelper = buildAmountHelperText(depositAmount, "USDC");
const withdrawHelper = buildAmountHelperText(withdrawAmount, "USDC");
const depositDecimals = getAssetDecimals("USDC");
const withdrawDecimals = getAssetDecimals("USDC");
const depositHelper = buildAmountHelperText(depositAmount, "USDC", depositDecimals);
const withdrawHelper = buildAmountHelperText(withdrawAmount, "USDC", withdrawDecimals);

const handleDeposit = async () => {
const amount = parseAmount(depositAmount);
Expand Down Expand Up @@ -312,9 +316,15 @@ export function LendPageClient() {
type="text"
inputMode="decimal"
min="0"
step="0.0000001"
step={Math.pow(10, -depositDecimals)}
value={depositAmount}
onChange={(event) => setDepositAmount(sanitizeAmountInput(event.target.value))}
onBlur={(event) => {
const formatted = formatAmountOnBlur(event.target.value, "USDC");
if (formatted && formatted !== event.target.value) {
setDepositAmount(formatted);
}
}}
aria-invalid={depositPrecisionError ? true : undefined}
className={`w-full rounded-xl border bg-zinc-50 px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-zinc-800 dark:bg-zinc-900 ${
depositPrecisionError ? "border-red-500" : "border-zinc-200"
Expand All @@ -327,7 +337,9 @@ export function LendPageClient() {
: "text-zinc-500 dark:text-zinc-400"
}`}
>
{depositPrecisionError ?? depositHelper ?? "Up to 7 decimal places supported."}
{depositPrecisionError ??
depositHelper ??
`Up to ${depositDecimals} decimal places supported.`}
</p>
<button
type="submit"
Expand Down Expand Up @@ -358,9 +370,15 @@ export function LendPageClient() {
type="text"
inputMode="decimal"
min="0"
step="0.0000001"
step={Math.pow(10, -withdrawDecimals)}
value={withdrawAmount}
onChange={(event) => setWithdrawAmount(sanitizeAmountInput(event.target.value))}
onBlur={(event) => {
const formatted = formatAmountOnBlur(event.target.value, "USDC");
if (formatted && formatted !== event.target.value) {
setWithdrawAmount(formatted);
}
}}
aria-invalid={withdrawPrecisionError ? true : undefined}
className={`w-full rounded-xl border bg-zinc-50 px-3 py-2 text-sm outline-none focus:border-indigo-500 dark:border-zinc-800 dark:bg-zinc-900 ${
withdrawPrecisionError ? "border-red-500" : "border-zinc-200"
Expand All @@ -375,7 +393,7 @@ export function LendPageClient() {
>
{withdrawPrecisionError ??
withdrawHelper ??
"Up to 7 decimal places supported."}
`Up to ${withdrawDecimals} decimal places supported.`}
</p>
<button
type="submit"
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/app/[locale]/repay/[loanId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
buildAmountHelperText,
getPrecisionError,
sanitizeAmountInput,
formatAmountOnBlur,
getAssetDecimals,
} from "../../../utils/amount";

export default function RepayLoanPage() {
Expand All @@ -49,8 +51,9 @@ export default function RepayLoanPage() {
const [lastError, setLastError] = useState<TransactionErrorDetails | null>(null);

const amountNumber = useMemo(() => Number(amount || "0"), [amount]);
const decimals = getAssetDecimals("USDC");
const precisionError = getPrecisionError(amount, "USDC");
const helperText = buildAmountHelperText(amount, "USDC");
const helperText = buildAmountHelperText(amount, "USDC", decimals);

const cancelFlow = () => {
setTrackerState("cancelled");
Expand Down Expand Up @@ -211,8 +214,15 @@ export default function RepayLoanPage() {
id="repayment-amount"
type="text"
inputMode="decimal"
step={Math.pow(10, -decimals)}
value={amount}
onChange={(event) => setAmount(sanitizeAmountInput(event.target.value))}
onBlur={(event) => {
const formatted = formatAmountOnBlur(event.target.value, "USDC");
if (formatted && formatted !== event.target.value) {
setAmount(formatted);
}
}}
className={`mt-2 w-full rounded-2xl border bg-zinc-50 px-4 py-3 text-zinc-900 outline-none transition focus:border-indigo-500 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50 ${
precisionError ? "border-red-500" : "border-zinc-200"
}`}
Expand All @@ -222,7 +232,7 @@ export default function RepayLoanPage() {
precisionError ? "text-red-600 dark:text-red-400" : "text-zinc-500 dark:text-zinc-400"
}`}
>
{precisionError ?? helperText ?? "Up to 7 decimal places supported."}
{precisionError ?? helperText ?? `Up to ${decimals} decimal places supported.`}
</p>
</div>

Expand Down
17 changes: 15 additions & 2 deletions frontend/src/app/components/borrower/LoanRepaymentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
getPrecisionError,
parseAmount,
sanitizeAmountInput,
formatAmountOnBlur,
getAssetDecimals,
} from "../../utils/amount";

interface LoanRepaymentFormProps {
Expand Down Expand Up @@ -47,13 +49,20 @@ export function LoanRepaymentForm({ loanId, totalOwed, minPayment = 0 }: LoanRep
},
});
const precisionError = getPrecisionError(amount, "USDC");
const helperText = buildAmountHelperText(amount, "USDC");
const helperText = buildAmountHelperText(amount, "USDC", getAssetDecimals("USDC"));

const handleAmountChange = (value: string) => {
setAmount(sanitizeAmountInput(value));
setError(null);
};

const handleAmountBlur = (value: string) => {
const formatted = formatAmountOnBlur(value, "USDC");
if (formatted && formatted !== value) {
setAmount(formatted);
}
};

const validateAmount = (): boolean => {
const numAmount = parseAmount(amount);

Expand Down Expand Up @@ -141,12 +150,16 @@ export function LoanRepaymentForm({ loanId, totalOwed, minPayment = 0 }: LoanRep
inputMode="decimal"
label="Repayment Amount"
placeholder="0.00"
step="0.01"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
onBlur={(e) => handleAmountBlur(e.target.value)}
error={error || precisionError || undefined}
leftIcon={<DollarSign className="h-4 w-4" />}
required
helperText={helperText ?? "Enter the amount you want to repay in USDC"}
helperText={
helperText ?? "Enter the amount you want to repay in USDC (max 2 decimals)"
}
/>

<Button variant="ghost" size="sm" onClick={handlePayFullAmount} className="w-full">
Expand Down
25 changes: 20 additions & 5 deletions frontend/src/app/components/loan-wizard/StepAmountAsset.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/Card";
import type { LoanWizardData } from "./LoanApplicationWizard";
import { buildAmountHelperText, getPrecisionError, sanitizeAmountInput } from "../../utils/amount";
import {
buildAmountHelperText,
getPrecisionError,
sanitizeAmountInput,
formatAmountOnBlur,
getAssetDecimals,
} from "../../utils/amount";

const TERM_OPTIONS = [
{ label: "30 days", days: 30 as const },
Expand Down Expand Up @@ -44,8 +50,10 @@ interface StepAmountAssetProps {
export function StepAmountAsset({ data, onChange, onNext, error, onError }: StepAmountAssetProps) {
const amountNumber = Number(data.amount || "0");
const minAmount = 100;
const precisionError = getPrecisionError(data.amount, data.asset || "USDC");
const helperText = buildAmountHelperText(data.amount, data.asset || "USDC");
const asset = data.asset || "USDC";
const decimals = getAssetDecimals(asset);
const precisionError = getPrecisionError(data.amount, asset);
const helperText = buildAmountHelperText(data.amount, asset, decimals);

const validate = (): boolean => {
if (!data.amount || Number.isNaN(amountNumber) || amountNumber <= 0) {
Expand Down Expand Up @@ -120,24 +128,31 @@ export function StepAmountAsset({ data, onChange, onNext, error, onError }: Step

{/* Amount */}
<Input
label={`Amount (${data.asset})`}
label={`Amount (${asset})`}
type="text"
inputMode="decimal"
min={minAmount}
max={data.maxAmount || undefined}
step={Math.pow(10, -decimals)}
value={data.amount}
onChange={(e) => {
onChange({ amount: sanitizeAmountInput(e.target.value) });
onError(null);
}}
onBlur={(e) => {
const formatted = formatAmountOnBlur(e.target.value, asset);
if (formatted && formatted !== e.target.value) {
onChange({ amount: formatted });
}
}}
placeholder="1000"
required
helperText={
precisionError ||
helperText ||
(data.maxAmount === 0
? "Not eligible"
: `Eligible range: ${formatMoney(minAmount)} – ${formatMoney(data.maxAmount)}`)
: `Eligible range: ${formatMoney(minAmount)} – ${formatMoney(data.maxAmount)} • Max ${decimals} decimal places`)
}
error={precisionError || undefined}
/>
Expand Down
17 changes: 14 additions & 3 deletions frontend/src/app/components/remittance/RemittanceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
getPrecisionError,
parseAmount,
sanitizeAmountInput,
formatAmountOnBlur,
getAssetDecimals,
} from "../../utils/amount";

interface RemittanceFormProps {
Expand All @@ -31,8 +33,9 @@ export function RemittanceForm({ onSuccess }: RemittanceFormProps) {

const txPreview = useTransactionPreview();
const mutation = useCreateRemittance();
const decimals = getAssetDecimals(token);
const precisionError = getPrecisionError(amount, token);
const helperText = buildAmountHelperText(amount, token);
const helperText = buildAmountHelperText(amount, token, decimals);

const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
Expand Down Expand Up @@ -77,6 +80,13 @@ export function RemittanceForm({ onSuccess }: RemittanceFormProps) {
}
};

const handleAmountBlur = (value: string) => {
const formatted = formatAmountOnBlur(value, token);
if (formatted && formatted !== value) {
setAmount(formatted);
}
};

const handleMemoChange = (value: string) => {
setMemo(value);
if (errors.memo) {
Expand Down Expand Up @@ -190,14 +200,15 @@ export function RemittanceForm({ onSuccess }: RemittanceFormProps) {
type="text"
inputMode="decimal"
placeholder="0.00"
step={Math.pow(10, -decimals)}
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
onBlur={(e) => handleAmountBlur(e.target.value)}
disabled={mutation.isPending}
required
min="0"
step="0.0000001"
error={errors.amount || undefined}
helperText={helperText ?? "Up to 7 decimal places supported."}
helperText={helperText ?? `Up to ${decimals} decimal places supported.`}
className={errors.amount ? "border-red-600" : ""}
/>

Expand Down
32 changes: 25 additions & 7 deletions frontend/src/app/utils/amount.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
export const STROOP_DECIMALS = 7;
export const STROOP_SCALE = 10 ** STROOP_DECIMALS;

// Asset-specific decimal precision
export const ASSET_DECIMALS: Record<string, number> = {
XLM: 7,
USDC: 2,
EURC: 2,
PHP: 2,
};

export function getAssetDecimals(asset: string): number {
return ASSET_DECIMALS[asset] ?? STROOP_DECIMALS;
}

export function sanitizeAmountInput(value: string): string {
let sanitized = value.replace(/[^\d.]/g, "");
const firstDotIndex = sanitized.indexOf(".");
Expand Down Expand Up @@ -74,14 +86,20 @@ export function buildAmountHelperText(
return `Formatted: ${formatted} ${asset} • Stroops: ${stroops.toString()}`;
}

export function getPrecisionError(
value: string,
asset = "XLM",
decimals = STROOP_DECIMALS,
): string | null {
if (!hasInvalidPrecision(value, decimals)) {
export function getPrecisionError(value: string, asset = "XLM", decimals?: number): string | null {
const assetDecimals = decimals ?? getAssetDecimals(asset);
if (!hasInvalidPrecision(value, assetDecimals)) {
return null;
}

return `${asset} supports at most ${decimals} decimal places.`;
return `${asset} supports at most ${assetDecimals} decimal places.`;
}

export function formatAmountOnBlur(value: string, asset = "XLM"): string {
const decimals = getAssetDecimals(asset);
const parsed = parseAmount(value);
if (!value || Number.isNaN(parsed)) {
return "";
}
return parsed.toFixed(decimals);
}
Loading