| 1 |
|
defmodule DaProductApp.ForeignExchange.FxRateService do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Service for managing FX rates and currency conversions in UPI International. |
| 4 |
|
|
| 5 |
|
Handles: |
| 6 |
|
- Real-time rate fetching and caching |
| 7 |
|
- Currency conversion with markup |
| 8 |
|
- Rate validation and expiry |
| 9 |
|
- Integration with external FX providers |
| 10 |
|
""" |
| 11 |
|
|
| 12 |
|
import Ecto.Query |
| 13 |
|
alias DaProductApp.Repo |
| 14 |
|
alias DaProductApp.ForeignExchange.FxRate |
| 15 |
|
|
| 16 |
|
@doc """ |
| 17 |
|
Get current FX rate for currency pair in specific corridor |
| 18 |
|
""" |
| 19 |
|
def get_current_rate(from_currency, to_currency, corridor) do |
| 20 |
:-( |
case Repo.one(FxRate.get_active_rate_query(from_currency, to_currency, corridor)) do |
| 21 |
:-( |
nil -> fetch_and_cache_live_rate(from_currency, to_currency, corridor) |
| 22 |
:-( |
rate -> {:ok, rate} |
| 23 |
|
end |
| 24 |
|
end |
| 25 |
|
|
| 26 |
|
@doc """ |
| 27 |
|
Convert INR amount to foreign currency with current rates |
| 28 |
|
""" |
| 29 |
|
def convert_inr_to_foreign(inr_amount, target_currency, corridor) do |
| 30 |
:-( |
case get_current_rate("INR", target_currency, corridor) do |
| 31 |
|
{:ok, fx_rate} -> |
| 32 |
:-( |
foreign_amount = FxRate.calculate_foreign_amount(inr_amount, fx_rate) |
| 33 |
|
{:ok, %{ |
| 34 |
|
foreign_amount: foreign_amount, |
| 35 |
|
foreign_currency: target_currency, |
| 36 |
:-( |
fx_rate: fx_rate.fx_rate, |
| 37 |
:-( |
markup_rate: fx_rate.markup_rate, |
| 38 |
|
inr_amount: inr_amount, |
| 39 |
|
corridor: corridor, |
| 40 |
:-( |
rate_timestamp: fx_rate.last_modified_ts |
| 41 |
|
}} |
| 42 |
|
|
| 43 |
:-( |
{:error, reason} -> {:error, reason} |
| 44 |
|
end |
| 45 |
|
end |
| 46 |
|
|
| 47 |
|
@doc """ |
| 48 |
|
Convert foreign currency amount to INR with current rates |
| 49 |
|
""" |
| 50 |
|
def convert_foreign_to_inr(foreign_amount, from_currency, corridor) do |
| 51 |
:-( |
case get_current_rate("INR", from_currency, corridor) do |
| 52 |
|
{:ok, fx_rate} -> |
| 53 |
:-( |
inr_amount = FxRate.calculate_inr_amount(foreign_amount, fx_rate) |
| 54 |
|
{:ok, %{ |
| 55 |
|
inr_amount: inr_amount, |
| 56 |
|
foreign_amount: foreign_amount, |
| 57 |
|
foreign_currency: from_currency, |
| 58 |
:-( |
fx_rate: fx_rate.fx_rate, |
| 59 |
:-( |
markup_rate: fx_rate.markup_rate, |
| 60 |
|
corridor: corridor, |
| 61 |
:-( |
rate_timestamp: fx_rate.last_modified_ts |
| 62 |
|
}} |
| 63 |
|
|
| 64 |
:-( |
{:error, reason} -> {:error, reason} |
| 65 |
|
end |
| 66 |
|
end |
| 67 |
|
|
| 68 |
|
@doc """ |
| 69 |
|
Validate if FX rates are still current and not expired |
| 70 |
|
""" |
| 71 |
:-( |
def validate_rate_freshness(fx_timestamp, max_age_minutes \\ 5) do |
| 72 |
:-( |
case DateTime.diff(DateTime.utc_now(), fx_timestamp, :minute) do |
| 73 |
:-( |
diff when diff <= max_age_minutes -> {:ok, :fresh} |
| 74 |
:-( |
diff -> {:error, {:rate_expired, diff}} |
| 75 |
|
end |
| 76 |
|
end |
| 77 |
|
|
| 78 |
|
@doc """ |
| 79 |
|
Get supported corridors and their currencies |
| 80 |
|
""" |
| 81 |
|
def get_supported_corridors() do |
| 82 |
:-( |
%{ |
| 83 |
|
"SINGAPORE" => %{currency: "SGD", country: "Singapore"}, |
| 84 |
|
"UAE" => %{currency: "AED", country: "United Arab Emirates"}, |
| 85 |
|
"USA" => %{currency: "USD", country: "United States"}, |
| 86 |
|
"UK" => %{currency: "GBP", country: "United Kingdom"}, |
| 87 |
|
"JAPAN" => %{currency: "JPY", country: "Japan"}, |
| 88 |
|
"AUSTRALIA" => %{currency: "AUD", country: "Australia"} |
| 89 |
|
} |
| 90 |
|
end |
| 91 |
|
|
| 92 |
|
@doc """ |
| 93 |
|
Create or update FX rate |
| 94 |
|
""" |
| 95 |
|
def upsert_fx_rate(attrs) do |
| 96 |
|
# Deactivate existing rates for the same currency pair and corridor |
| 97 |
:-( |
deactivate_existing_rates(attrs.base_currency, attrs.target_currency, attrs.corridor) |
| 98 |
|
|
| 99 |
|
# Create new rate |
| 100 |
|
%FxRate{} |
| 101 |
|
|> FxRate.changeset(Map.put(attrs, :active, true)) |
| 102 |
:-( |
|> Repo.insert() |
| 103 |
|
end |
| 104 |
|
|
| 105 |
|
# Private functions |
| 106 |
|
|
| 107 |
|
defp fetch_and_cache_live_rate(from_currency, to_currency, corridor) do |
| 108 |
:-( |
case fetch_live_rate_from_provider(from_currency, to_currency, corridor) do |
| 109 |
|
{:ok, rate_data} -> |
| 110 |
:-( |
case upsert_fx_rate(%{ |
| 111 |
|
base_currency: from_currency, |
| 112 |
|
target_currency: to_currency, |
| 113 |
|
corridor: corridor, |
| 114 |
:-( |
fx_rate: rate_data.rate, |
| 115 |
:-( |
markup_rate: rate_data.markup || get_default_markup(corridor), |
| 116 |
:-( |
source: rate_data.source, |
| 117 |
|
rate_type: "SPOT", |
| 118 |
|
effective_from: DateTime.utc_now(), |
| 119 |
|
last_modified_ts: DateTime.utc_now(), |
| 120 |
|
created_by: "SYSTEM" |
| 121 |
|
}) do |
| 122 |
:-( |
{:ok, fx_rate} -> {:ok, fx_rate} |
| 123 |
:-( |
{:error, reason} -> {:error, {:cache_failed, reason}} |
| 124 |
|
end |
| 125 |
|
|
| 126 |
|
{:error, reason} -> |
| 127 |
:-( |
get_fallback_rate(from_currency, to_currency, corridor, reason) |
| 128 |
|
end |
| 129 |
|
end |
| 130 |
|
|
| 131 |
|
defp fetch_live_rate_from_provider(from_currency, to_currency, corridor) do |
| 132 |
|
# Simulate fetching from external FX provider |
| 133 |
|
# In production: integrate with RBI, partner APIs, or commercial FX providers |
| 134 |
:-( |
case corridor do |
| 135 |
:-( |
"SINGAPORE" when from_currency == "INR" and to_currency == "SGD" -> |
| 136 |
|
{:ok, %{rate: Decimal.new("0.0165"), markup: Decimal.new("2.50"), source: "RBI_REFERENCE"}} |
| 137 |
|
|
| 138 |
:-( |
"UAE" when from_currency == "INR" and to_currency == "AED" -> |
| 139 |
|
{:ok, %{rate: Decimal.new("0.0446"), markup: Decimal.new("2.25"), source: "RBI_REFERENCE"}} |
| 140 |
|
|
| 141 |
:-( |
"USA" when from_currency == "INR" and to_currency == "USD" -> |
| 142 |
|
{:ok, %{rate: Decimal.new("0.0121"), markup: Decimal.new("2.75"), source: "RBI_REFERENCE"}} |
| 143 |
|
|
| 144 |
:-( |
_ -> |
| 145 |
|
{:error, {:unsupported_corridor, corridor}} |
| 146 |
|
end |
| 147 |
|
end |
| 148 |
|
|
| 149 |
|
defp get_fallback_rate(from_currency, to_currency, corridor, _original_error) do |
| 150 |
|
# Use cached rate even if slightly stale, or return error |
| 151 |
:-( |
query = from r in FxRate, |
| 152 |
|
where: r.base_currency == ^from_currency and |
| 153 |
|
r.target_currency == ^to_currency and |
| 154 |
|
r.corridor == ^corridor, |
| 155 |
|
order_by: [desc: r.last_modified_ts], |
| 156 |
|
limit: 1 |
| 157 |
|
|
| 158 |
:-( |
case Repo.one(query) do |
| 159 |
:-( |
nil -> {:error, {:no_rate_available, corridor}} |
| 160 |
:-( |
stale_rate -> {:ok, stale_rate} # Use stale rate as fallback |
| 161 |
|
end |
| 162 |
|
end |
| 163 |
|
|
| 164 |
|
defp deactivate_existing_rates(base_currency, target_currency, corridor) do |
| 165 |
|
from(r in FxRate, |
| 166 |
|
where: r.base_currency == ^base_currency and |
| 167 |
|
r.target_currency == ^target_currency and |
| 168 |
|
r.corridor == ^corridor and |
| 169 |
|
r.active == true |
| 170 |
|
) |
| 171 |
:-( |
|> Repo.update_all(set: [active: false, effective_until: DateTime.utc_now()]) |
| 172 |
|
end |
| 173 |
|
|
| 174 |
|
defp get_default_markup(corridor) do |
| 175 |
:-( |
case corridor do |
| 176 |
:-( |
"SINGAPORE" -> Decimal.new("2.50") |
| 177 |
:-( |
"UAE" -> Decimal.new("2.25") |
| 178 |
:-( |
"USA" -> Decimal.new("2.75") |
| 179 |
:-( |
_ -> Decimal.new("2.00") |
| 180 |
|
end |
| 181 |
|
end |
| 182 |
|
end |