cover/Elixir.DaProductApp.Merchants.html

1 defmodule DaProductApp.Merchants do
2 @moduledoc """
3 Context for managing merchants enrolled by partners.
4
5 Provides business logic for merchant registration, validation,
6 and transaction processing in the UPI International PSP system.
7 """
8
9 import Ecto.Query, warn: false
10 alias DaProductApp.Repo
11 alias DaProductApp.Merchants.Merchant
12 alias DaProductApp.Partners.Partner
13
14 # ================================
15 # MERCHANT CRUD OPERATIONS
16 # ================================
17
18 @doc """
19 Create a new merchant under a partner.
20 """
21 def create_merchant(partner_id, attrs) do
22
:-(
with %Partner{} = partner <- get_partner!(partner_id),
23
:-(
attrs <- Map.put(attrs, "partner_id", partner.id) do
24
25 %Merchant{}
26 |> Merchant.changeset(attrs)
27 |> put_defaults_for_corridor(attrs["corridor"])
28
:-(
|> Repo.insert()
29 else
30
:-(
nil -> {:error, "Partner not found"}
31
:-(
error -> error
32 end
33 end
34
35 @doc """
36 Get merchant by ID with partner information.
37 """
38 def get_merchant(id) do
39 Merchant
40
:-(
|> preload(:partner)
41
:-(
|> Repo.get(id)
42 end
43
44 @doc """
45 Get merchant by ID, raising if not found.
46 """
47 def get_merchant!(id) do
48 Merchant
49
:-(
|> preload(:partner)
50
:-(
|> Repo.get!(id)
51 end
52
53 @doc """
54 Get merchant by merchant code.
55 """
56 def get_merchant_by_code(merchant_code) do
57 Merchant
58 |> where([m], m.merchant_code == ^merchant_code)
59
:-(
|> preload(:partner)
60
:-(
|> Repo.one()
61 end
62
63 @doc """
64 Get merchant by VPA.
65 """
66 def get_merchant_by_vpa(vpa) do
67 Merchant
68 |> where([m], m.merchant_vpa == ^vpa)
69
:-(
|> preload(:partner)
70
:-(
|> Repo.one()
71 end
72
73 @doc """
74 Get merchant by MID (NPCI Merchant ID).
75 """
76 def get_merchant_by_mid(mid) do
77 Merchant
78 |> where([m], m.mid == ^mid)
79
:-(
|> preload(:partner)
80
:-(
|> Repo.one()
81 end
82
83 @doc """
84 Get merchant by MSID (Merchant Store ID / Store ID).
85 Used for dynamic QR validation when NPCI sends msid parameter.
86 """
87 def get_merchant_by_msid(msid) do
88 Merchant
89 |> where([m], m.sid == ^msid)
90
:-(
|> preload(:partner)
91
:-(
|> Repo.one()
92 end
93
94 @doc """
95 Get merchant by TID (Terminal ID).
96 Some QRs include `mtid`/`tid` and we allow direct lookup by terminal id.
97 """
98 def get_merchant_by_tid(tid) do
99 # Some databases may contain duplicate/legacy rows for the same TID.
100 # To avoid raising Ecto.MultipleResultsError we explicitly limit the
101 # query to one row and pick the most recently inserted merchant.
102 Merchant
103 |> where([m], m.tid == ^tid)
104 |> order_by([m], desc: m.inserted_at)
105 |> limit(1)
106
:-(
|> preload(:partner)
107
:-(
|> Repo.one()
108 end
109
110 @doc """
111 Get merchant by settlement IFSC code.
112 This is used when ReqPay contains an IFSC value and we need to find
113 the merchant that owns that settlement account.
114 """
115 def get_merchant_by_settlement_ifsc(ifsc) when is_binary(ifsc) and ifsc != "" do
116 Merchant
117 |> where([m], m.settlement_account_ifsc == ^ifsc)
118 |> order_by([m], desc: m.inserted_at)
119 |> limit(1)
120
:-(
|> preload(:partner)
121
:-(
|> Repo.one()
122 end
123
124 @doc """
125 Update merchant information.
126 """
127 def update_merchant(%Merchant{} = merchant, attrs) do
128 merchant
129 |> Merchant.changeset(attrs)
130
:-(
|> Repo.update()
131 end
132
133 @doc """
134 List all merchants for a partner.
135 """
136
:-(
def list_partner_merchants(partner_id, opts \\ []) do
137
:-(
query = from m in Merchant,
138 where: m.partner_id == ^partner_id,
139 preload: [:partner]
140
141 query
142 |> maybe_filter_by_status(opts[:status])
143 |> maybe_filter_by_corridor(opts[:corridor])
144 |> maybe_paginate(opts[:page], opts[:per_page])
145
:-(
|> Repo.all()
146 end
147
148 @doc """
149 Get merchant count for a partner.
150 """
151 def count_partner_merchants(partner_id) do
152 from(m in Merchant, where: m.partner_id == ^partner_id, select: count(m.id))
153
:-(
|> Repo.one()
154 end
155
156 @doc """
157 Delete a merchant (soft delete by marking inactive).
158 """
159 def delete_merchant(%Merchant{} = merchant) do
160
:-(
update_merchant(merchant, %{status: "INACTIVE"})
161 end
162
163 # ================================
164 # BUSINESS OPERATIONS
165 # ================================
166
167 @doc """
168 Validate merchant for transaction processing.
169 """
170 def validate_merchant_for_transaction(merchant_id) do
171
:-(
case get_merchant(merchant_id) do
172 %Merchant{} = merchant ->
173
:-(
cond do
174
:-(
merchant.status != "ACTIVE" ->
175 {:error, "Merchant is not active"}
176
177
:-(
merchant.compliance_status != "VERIFIED" ->
178 {:error, "Merchant compliance not verified"}
179
180
:-(
not merchant.qr_enabled ->
181 {:error, "QR payments disabled for merchant"}
182
183
:-(
true ->
184 {:ok, merchant}
185 end
186
187
:-(
nil ->
188 {:error, "Merchant not found"}
189 end
190 end
191
192 @doc """
193 Check if merchant can process the given transaction amount.
194 """
195 def check_transaction_limits(%Merchant{} = merchant, amount) do
196
:-(
decimal_amount = if is_binary(amount), do: Decimal.new(amount), else: amount
197
198
:-(
cond do
199
:-(
merchant.max_transaction_limit &&
200
:-(
Decimal.compare(decimal_amount, merchant.max_transaction_limit) == :gt ->
201 {:error, "Amount exceeds merchant transaction limit"}
202
203
:-(
not check_daily_limit(merchant, decimal_amount) ->
204 {:error, "Amount exceeds daily transaction limit"}
205
206
:-(
true ->
207 :ok
208 end
209 end
210
211 @doc """
212 Update merchant's last transaction timestamp.
213 """
214 def update_last_transaction(merchant_id) do
215 from(m in Merchant, where: m.id == ^merchant_id)
216
:-(
|> Repo.update_all(set: [last_transaction_at: DateTime.utc_now()])
217 end
218
219 @doc """
220 Update merchant status (ACTIVE, SUSPENDED, INACTIVE).
221 """
222 def update_merchant_status(merchant_id, status) when status in ["ACTIVE", "SUSPENDED", "INACTIVE"] do
223
:-(
case get_merchant(merchant_id) do
224 %Merchant{} = merchant ->
225
:-(
update_merchant(merchant, %{status: status})
226
227
:-(
nil ->
228 {:error, "Merchant not found"}
229 end
230 end
231
232 # ================================
233 # MERCHANT SEARCH & FILTERING
234 # ================================
235
236 @doc """
237 Search merchants by various criteria.
238 """
239 def search_merchants(params) do
240 Merchant
241
:-(
|> preload(:partner)
242 |> maybe_filter_by_partner(params[:partner_id])
243 |> maybe_filter_by_status(params[:status])
244 |> maybe_filter_by_corridor(params[:corridor])
245 |> maybe_filter_by_business_type(params[:business_type])
246 |> maybe_search_by_name(params[:search])
247 |> maybe_order_by(params[:sort], params[:order])
248 |> maybe_paginate(params[:page], params[:per_page])
249
:-(
|> Repo.all()
250 end
251
252 # ================================
253 # INTERNATIONAL MERCHANT OPERATIONS
254 # ================================
255
256 @doc """
257 Get merchants for a specific corridor.
258 """
259 def list_corridor_merchants(corridor) do
260 from(m in Merchant,
261 where: m.corridor == ^corridor and m.status == "ACTIVE",
262 preload: [:partner]
263 )
264
:-(
|> Repo.all()
265 end
266
267 @doc """
268 Get merchant with FX configuration.
269 """
270 def get_merchant_with_fx_config(merchant_id) do
271
:-(
case get_merchant(merchant_id) do
272 %Merchant{corridor: corridor, local_currency: currency} = merchant when corridor != "DOMESTIC" ->
273
:-(
fx_config = %{
274 corridor: corridor,
275 local_currency: currency,
276
:-(
fx_markup_rate: merchant.fx_markup_rate || Decimal.new("0"),
277
:-(
partner_code: merchant.partner.partner_code
278 }
279
:-(
{:ok, merchant, fx_config}
280
281 %Merchant{} = merchant ->
282
:-(
{:ok, merchant, nil}
283
284
:-(
nil ->
285 {:error, "Merchant not found"}
286 end
287 end
288
289 # ================================
290 # PRIVATE HELPER FUNCTIONS
291 # ================================
292
293 defp get_partner!(partner_id) do
294
:-(
Repo.get(Partner, partner_id)
295 end
296
297 defp put_defaults_for_corridor(changeset, corridor) do
298
:-(
case corridor do
299 "SINGAPORE" ->
300 changeset
301 |> Ecto.Changeset.put_change(:country_code, "SG")
302
:-(
|> Ecto.Changeset.put_change(:local_currency, "SGD")
303
304 "UAE" ->
305 changeset
306 |> Ecto.Changeset.put_change(:country_code, "AE")
307
:-(
|> Ecto.Changeset.put_change(:local_currency, "AED")
308
309 "USA" ->
310 changeset
311 |> Ecto.Changeset.put_change(:country_code, "US")
312
:-(
|> Ecto.Changeset.put_change(:local_currency, "USD")
313
314 _ ->
315 changeset
316 |> Ecto.Changeset.put_change(:country_code, "IN")
317 |> Ecto.Changeset.put_change(:local_currency, "INR")
318
:-(
|> Ecto.Changeset.put_change(:corridor, "DOMESTIC")
319 end
320 end
321
322 defp check_daily_limit(merchant, amount) do
323
:-(
if merchant.daily_transaction_limit do
324
:-(
today_start = DateTime.utc_now() |> DateTime.beginning_of_day()
325
326
:-(
daily_total = from(t in "transactions",
327
:-(
where: t.merchant_id == ^merchant.id and t.inserted_at >= ^today_start,
328 select: sum(t.inr_amount)
329 )
330 |> Repo.one()
331 |> case do
332
:-(
nil -> Decimal.new("0")
333
:-(
total -> total
334 end
335
336
:-(
new_total = Decimal.add(daily_total, amount)
337
:-(
Decimal.compare(new_total, merchant.daily_transaction_limit) != :gt
338 else
339 true
340 end
341 end
342
343 # Query filter helpers
344
:-(
defp maybe_filter_by_partner(query, nil), do: query
345 defp maybe_filter_by_partner(query, partner_id) do
346
:-(
where(query, [m], m.partner_id == ^partner_id)
347 end
348
349
:-(
defp maybe_filter_by_status(query, nil), do: query
350 defp maybe_filter_by_status(query, status) do
351
:-(
where(query, [m], m.status == ^status)
352 end
353
354
:-(
defp maybe_filter_by_corridor(query, nil), do: query
355 defp maybe_filter_by_corridor(query, corridor) do
356
:-(
where(query, [m], m.corridor == ^corridor)
357 end
358
359
:-(
defp maybe_filter_by_business_type(query, nil), do: query
360 defp maybe_filter_by_business_type(query, business_type) do
361
:-(
where(query, [m], m.business_type == ^business_type)
362 end
363
364
:-(
defp maybe_search_by_name(query, nil), do: query
365 defp maybe_search_by_name(query, search_term) do
366
:-(
search_pattern = "%#{search_term}%"
367
:-(
where(query, [m],
368 ilike(m.brand_name, ^search_pattern) or
369 ilike(m.legal_name, ^search_pattern) or
370 ilike(m.merchant_code, ^search_pattern)
371 )
372 end
373
374
:-(
defp maybe_order_by(query, nil, _), do: order_by(query, [m], desc: m.inserted_at)
375 defp maybe_order_by(query, sort_field, order) when sort_field in ["name", "code", "created"] do
376
:-(
field = case sort_field do
377
:-(
"name" -> :brand_name
378
:-(
"code" -> :merchant_code
379
:-(
"created" -> :inserted_at
380 end
381
382
:-(
direction = if order == "desc", do: :desc, else: :asc
383
:-(
order_by(query, [m], [{^direction, field(m, ^field)}])
384 end
385
:-(
defp maybe_order_by(query, _, _), do: order_by(query, [m], desc: m.inserted_at)
386
387
:-(
defp maybe_paginate(query, nil, _), do: query
388 defp maybe_paginate(query, page, per_page) when is_integer(page) and is_integer(per_page) do
389
:-(
offset = (page - 1) * per_page
390 query
391 |> limit(^per_page)
392
:-(
|> offset(^offset)
393 end
394
:-(
defp maybe_paginate(query, _, _), do: query
395 end
Line Hits Source