Skip to main content

Overview

BudgetView stores all financial data in USD (United States Dollars) but displays equivalent amounts in VES (Venezuelan Bolívares) using the official BCV (Banco Central de Venezuela) exchange rate.
This dual-currency approach ensures data stability while providing local currency context for Venezuelan users.

How Currency Conversion Works

Storage: USD Only

All amounts are stored in the database as USD:
const transactionData = {
  monto,  // Always stored as USD numeric value
  tipo: formTipo,
  descripcion: descripcionValue,
  categoria_id: formCategoriaId,
  usuario_id: user.id,
  billetera_id: billeteraId,
  fecha_transaccion: fechaSeleccionada.toISOString(),
}
Why USD?
  • Stable reference currency
  • Consistent historical records
  • Independent of exchange rate fluctuations
  • Standard for international financial data

Display: USD + VES

When you view data, BudgetView shows both currencies:
const formatBcvAmount = React.useCallback(
  (usdAmount: number) => {
    if (!bcvRate || usdAmount === 0) {
      return null
    }
    return formatCurrency(usdAmount * bcvRate, "VES")
  },
  [bcvRate]
)
Display format:
  • Primary: $50.00 USD
  • Secondary: ≈ Bs. 1,825.00 BCV (calculated in real-time)

BCV Rate Fetching

API Source

BudgetView fetches the official rate from DolarAPI:
export async function fetchBcvRate(signal?: AbortSignal): Promise<ExchangeRate | null> {
  if (typeof fetch === "undefined") {
    return null
  }

  try {
    const response = await fetch("https://ve.dolarapi.com/v1/dolares/oficial", {
      signal,
      cache: "no-store",  // Always fetch fresh rate
    })

    if (!response.ok) {
      throw new Error(`BCV API responded with ${response.status}`)
    }

    const data = await response.json()
    const average = typeof data?.promedio === "number" ? data.promedio : Number(data?.promedio)
    if (!average || Number.isNaN(average)) {
      return null
    }

    return { currency: "VES", rate: average }
  } catch (error) {
    if (error instanceof DOMException && error.name === "AbortError") {
      return null
    }

    console.error("Error fetching BCV rate", error)
    return null
  }
}

Rate Properties

Official Rate

Uses the promedio (average) from BCV’s official exchange rate.This is the government-sanctioned rate.

Real-Time

Fetched fresh on page load (no caching).Updates reflect current market conditions.

Automatic Retry

If fetch fails, the system retries automatically.VES amounts appear once successful.

Fallback Behavior

If rate unavailable, only USD displays.No VES amounts shown (returns null).

The useBcvRate Hook

All pages use this React hook to access the exchange rate:
type UseBcvRateResult = {
  rate: number | null
  loading: boolean
  error: string | null
  refresh: () => void
}

export function useBcvRate(options?: UseBcvRateOptions): UseBcvRateResult {
  const { refreshInterval } = options ?? {}
  const [rate, setRate] = React.useState<number | null>(null)
  const [loading, setLoading] = React.useState(true)
  const [error, setError] = React.useState<string | null>(null)

  const loadRate = React.useCallback(async () => {
    setLoading(true)
    const result = await fetchBcvRate()
    if (!mountedRef.current) return
    
    if (!result) {
      setRate(null)
      setError("No pudimos obtener la tasa BCV.")
    } else {
      setRate(result.rate)
      setError(null)
    }
    setLoading(false)
  }, [])

  React.useEffect(() => {
    mountedRef.current = true
    loadRate()
    return () => {
      mountedRef.current = false
    }
  }, [loadRate])

  return { rate, loading, error, refresh: loadRate }
}

Hook States

Initial state when page loads.
const { rate, loading, error } = useBcvRate()

if (loading) {
  return <p>Cargando tasa BCV...</p>
}
UI shows: “Cargando tasa BCV…”

Input Conversion (Transactions)

When creating transactions, users can input amounts in either currency:

USD Input (Default)

// User enters: 50
// Stored as: 50 (USD)
// Displayed as: $50.00 USD, ≈ Bs. 1,825.00 BCV

VES Input (When BCV Available)

// User enters: 1825 (in VES)
// Converted to: 1825 / 36.50 = 50 (USD)
// Stored as: 50 (USD)
// Displayed as: $50.00 USD, ≈ Bs. 1,825.00 BCV

if (amountCurrency === "VES") {
  if (!bcvRate || bcvRate <= 0) {
    setFormError("Necesitamos la tasa BCV para convertir tu monto.")
    return
  }
  monto = rawAmount / bcvRate  // Convert VES → USD for storage
}

Currency Switcher

Users can switch between USD and VES during input:
const handleCurrencyChange = React.useCallback(
  (value: "USD" | "VES") => {
    if (value === amountCurrency) return

    if (!formMonto) {
      setAmountCurrency(value)
      return
    }

    const parsed = parseFloat(formMonto)
    if (Number.isNaN(parsed) || parsed <= 0 || !bcvRate || bcvRate <= 0) {
      setAmountCurrency(value)
      return
    }

    // Perform conversion
    const convertedValue = value === "USD" 
      ? parsed / bcvRate      // VES → USD
      : parsed * bcvRate      // USD → VES
    
    setFormMonto(convertedValue.toFixed(2))
    setAmountCurrency(value)
  },
  [amountCurrency, formMonto, bcvRate]
)
Example flow:
  1. User enters 100 in USD field
  2. User clicks VES selector
  3. Field updates to 3650.00 (assuming rate of 36.50)
  4. User can continue editing in VES

Real-Time Preview

As you type, a preview shows the equivalent amount:
const equivalentPreview = React.useMemo(() => {
  if (!parsedAmount || parsedAmount <= 0 || !bcvRate || bcvRate <= 0) {
    return null
  }

  if (amountCurrency === "USD") {
    const vesText = formatCurrency(parsedAmount * bcvRate, "VES")
    return `≈ ${vesText} BCV`  // Shows VES equivalent
  }

  const usdText = currencyFormatter.format(parsedAmount / bcvRate)
  return `≈ ${usdText} USD`  // Shows USD equivalent
}, [parsedAmount, amountCurrency, bcvRate])
The preview helps you understand what you’re entering in both currencies simultaneously.

Currency Formatting

Formatter Functions

BudgetView uses Intl.NumberFormat for locale-aware formatting:
const formatterCache = new Map<string, Intl.NumberFormat>()

function getFormatter(currency: string, locale = "es-VE") {
  const key = `${locale}-${currency}`
  if (!formatterCache.has(key)) {
    formatterCache.set(
      key,
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
        maximumFractionDigits: 2,
      })
    )
  }
  return formatterCache.get(key)!
}

export function formatCurrency(value: number, currency: string, locale?: string) {
  if (!Number.isFinite(value)) {
    return "—"  // Displays dash for invalid numbers
  }
  return getFormatter(currency, locale).format(value)
}

export function formatUsd(value: number) {
  return formatCurrency(value, "USD")
}

export function formatVes(value: number) {
  return formatCurrency(value, "VES")
}

Formatting Examples

formatCurrency(1234.56, "USD")
// Output: "$1,234.56"

formatUsd(50)
// Output: "$50.00"

Locale Settings

Default locale: es-VE (Venezuelan Spanish) Number format rules:
  • Decimal separator: , (comma)
  • Thousands separator: . (period)
  • Currency symbol position: Prefix
  • Example: Bs. 1.825.000,50

Where Conversion Appears

1. Wallet Balances

// Wallet card
<p className="text-2xl font-semibold text-emerald-600">
  {currencyFormatter.format(wallet.balance)}
</p>
{balanceBcv && (
  <p className="text-xs text-muted-foreground">≈ {balanceBcv} BCV</p>
)}

2. Transaction Amounts

// Transaction list item
<p className="text-base font-bold text-emerald-600">
  {isGasto ? "-" : "+"}
  {currencyFormatter.format(transaction.monto)}
</p>
{transactionBcv && (
  <p className="text-xs text-muted-foreground">
    ≈ {transactionBcv} BCV
  </p>
)}

3. Budget Limits

// Budget card
<p className="text-xl font-semibold text-emerald-600">
  {currencyFormatter.format(budget.limit)}
</p>
{limitBcv && (
  <p className="text-xs text-muted-foreground">≈ {limitBcv} BCV</p>
)}

4. Summary Cards

// Dashboard summary
<CardContent>
  <p className="text-3xl font-semibold">{card.value}</p>
  {card.bcvValue && (
    <p className="text-xs text-muted-foreground">≈ {card.bcvValue} BCV</p>
  )}
</CardContent>

5. Export Files

Exported data includes both currencies:
const normalizeTransactions = React.useCallback(
  (rows: TransactionExportRow[]): NormalizedRow[] =>
    rows.map((row) => {
      const amount = Number(row.monto ?? 0)
      return {
        date: row.fecha_transaccion ? new Date(row.fecha_transaccion).toISOString() : null,
        type: row.tipo ?? "",
        wallet: walletRecord?.nombre ?? "",
        category: categoryRecord?.nombre ?? "",
        amountUsd: Number.isFinite(amount) ? Number(amount.toFixed(2)) : 0,
        amountVes: bcvRate && Number.isFinite(amount) ? Number((amount * bcvRate).toFixed(2)) : null,
        description: row.descripcion ?? "",
      }
    }),
  [bcvRate]
)
CSV columns:
  • Monto (USD): Original stored value
  • Monto (VES BCV): Converted value at export time

Rate Update Frequency

The BCV rate updates on every page load due to cache: "no-store" in the fetch request.

No Auto-Refresh

The hook does NOT automatically refresh the rate while you’re on the page:
React.useEffect(() => {
  if (!refreshInterval || refreshInterval <= 0) {
    return  // No auto-refresh by default
  }

  const id = window.setInterval(() => {
    loadRate()
  }, refreshInterval)

  return () => {
    window.clearInterval(id)
  }
}, [refreshInterval, loadRate])
To get fresh rate: Reload the page.

Manual Refresh

You can trigger manual refresh using the refresh function:
const { rate, loading, refresh } = useBcvRate()

<Button onClick={refresh}>Actualizar tasa</Button>
Currently, no pages implement manual refresh buttons, but the functionality exists.

Edge Cases & Handling

Zero Amounts

if (!bcvRate || usdAmount === 0) {
  return null  // Don't show VES for $0.00
}
Zero amounts don’t display VES equivalents.

Invalid Numbers

export function formatCurrency(value: number, currency: string, locale?: string) {
  if (!Number.isFinite(value)) {
    return "—"
  }
  return getFormatter(currency, locale).format(value)
}
NaN or Infinity displays as ”—” (em dash).

Rate Unavailable

const formatBcvAmount = React.useCallback(
  (usdAmount: number) => {
    if (!bcvRate || usdAmount === 0) {
      return null  // No VES display
    }
    return formatCurrency(usdAmount * bcvRate, "VES")
  },
  [bcvRate]
)
If bcvRate is null, VES amounts simply don’t render (no error, no placeholder).

Very Large Numbers

// USD: $1,000,000.00
// VES: Bs. 36.500.000,00 (with rate of 36.50)
Both formatters handle large numbers gracefully with appropriate separators.

Precision Loss

// User enters: 1000 VES
// Rate: 36.50
// Converted: 1000 / 36.50 = 27.397260273972602...
// Stored: 27.40 (rounded to 2 decimals)
// Re-displayed: 27.40 * 36.50 = 1000.10 VES
Minor precision differences may occur due to decimal rounding. The stored USD value is authoritative.

Best Practices

  • If you earn/spend in USD → Use USD input
  • If you earn/spend in VES → Use VES input
The system handles conversion transparently.
Remember:
  • Storage: Always USD (stable, historical)
  • Display: Both USD and VES (informational)
Your data is protected from exchange rate volatility.
Before entering amounts in VES:
  1. Verify the BCV rate loaded (shown at top of page)
  2. Ensure the rate is current/reasonable
  3. Use the preview to confirm conversion
When exporting data:
  • Check both Monto (USD) and Monto (VES BCV) columns
  • Remember VES values reflect the rate at export time
  • For historical accuracy, rely on USD column

Troubleshooting

Possible causes:
  1. BCV API is down or slow
  2. Network connection issue
  3. Page loaded before rate fetched
Solution:
  • Wait a few seconds for rate to load
  • Check “Cargando tasa BCV…” message
  • Refresh the page
  • Check console for fetch errors
Cause: BCV rate hasn’t loaded yet.Indicator: The VES option is disabled in the currency selector.Solution: Wait for rate to load, or use USD input instead.
Check:
  1. Current BCV rate displayed at top of page
  2. Your calculation: USD × rate = VES
  3. Decimal rounding (2 places)
Example verification:
USD: $50.00
Rate: 36.50
Expected VES: 50 × 36.50 = Bs. 1,825.00 ✓
Cause: Exchange rate changed between viewing and exporting.Explanation:
  • Screen view: VES calculated with rate from when page loaded
  • Export: VES calculated with rate from when export ran
  • USD values are identical (source of truth)
Solution: This is expected behavior. Use USD column for consistency.
Cause: VES equivalents are calculated in real-time using the current rate.Explanation:
  • A $50 transaction from January 2024 shows different VES amounts depending on when you view it
  • January rate: 36.50 → Bs. 1,825.00
  • March rate: 40.00 → Bs. 2,000.00
  • USD value ($50) never changes
Solution: This is by design. For historical accuracy, reference USD amounts.

Tracking Transactions

Learn how to use currency conversion when recording transactions

Creating Wallets

See how wallet balances display in both currencies

Setting Budgets

Understand budget limits and spending in USD/VES

Exporting Data

Export financial data with both USD and VES columns

Build docs developers (and LLMs) love