| 1 |
|
defmodule DaProductApp.Merchants.Merchant do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Unified merchant entity supporting both NPCI specification and partner management. |
| 4 |
|
|
| 5 |
|
Merchants are enrolled by partners and contain both NPCI-required fields |
| 6 |
|
and partner-specific business information for international payments. |
| 7 |
|
""" |
| 8 |
|
use Ecto.Schema |
| 9 |
|
import Ecto.Changeset |
| 10 |
|
|
| 11 |
|
alias DaProductApp.Merchants.MerchantInvoice |
| 12 |
|
alias DaProductApp.Partners.Partner |
| 13 |
|
alias DaProductApp.Transactions.Transaction |
| 14 |
|
|
| 15 |
:-( |
schema "merchants" do |
| 16 |
|
# NPCI Required Fields (from UPI specification) |
| 17 |
|
field :mid, :string # Merchant ID (NPCI standard) |
| 18 |
|
field :tid, :string # Terminal ID |
| 19 |
|
field :sid, :string # Store ID |
| 20 |
|
field :sub_code, :string # Sub merchant code |
| 21 |
|
field :merchant_type, :string # SMALL, MEDIUM, LARGE |
| 22 |
|
field :merchant_genre, :string # RETAIL, ONLINE |
| 23 |
|
field :onboarding_type, :string # AGGREGATOR, DIRECT, BANK |
| 24 |
|
field :inst_code, :string # Institution code |
| 25 |
|
field :country_code, :string # Country code (SG, AE, US, IN) |
| 26 |
|
field :network_inst_id, :string # Network institution ID |
| 27 |
|
|
| 28 |
|
# Business Identity Fields (from international migration) |
| 29 |
|
field :merchant_code, :string # Internal unique identifier |
| 30 |
|
field :brand_name, :string # Public brand name |
| 31 |
|
field :legal_name, :string # Legal registered name |
| 32 |
|
field :franchise_name, :string # Franchise name if applicable |
| 33 |
|
field :ownership_type, :string # PRIVATE, PUBLIC, PARTNERSHIP |
| 34 |
|
|
| 35 |
|
# UPI & Payment Fields (from international migration) |
| 36 |
|
field :merchant_vpa, :string # UPI VPA (e.g., merchant@partner) |
| 37 |
|
field :business_type, :string # RETAIL, ECOMMERCE, SERVICES |
| 38 |
|
field :business_category, :string # MCC code category |
| 39 |
|
field :static_qr_code, :string # Static QR for merchant |
| 40 |
|
|
| 41 |
|
# Transaction Controls (from international migration) |
| 42 |
|
field :qr_enabled, :boolean, default: true |
| 43 |
|
field :dynamic_qr_enabled, :boolean, default: false |
| 44 |
|
field :max_transaction_limit, :decimal |
| 45 |
|
field :daily_transaction_limit, :decimal |
| 46 |
|
|
| 47 |
|
# International Support (new fields) |
| 48 |
|
field :corridor, :string # SINGAPORE, UAE, USA |
| 49 |
|
field :local_currency, :string # SGD, AED, USD, INR |
| 50 |
|
field :fx_markup_rate, :decimal # Partner's FX markup percentage |
| 51 |
|
|
| 52 |
|
# Settlement Details (from international migration + new) |
| 53 |
|
field :settlement_frequency, :string, default: "T+1" # T+0, T+1, WEEKLY |
| 54 |
|
field :settlement_account_intl, :string # From international migration |
| 55 |
|
field :settlement_account_number, :string # New field |
| 56 |
|
field :settlement_account_ifsc, :string # New field |
| 57 |
|
field :settlement_account_type, :string, default: "CURRENT" # New field |
| 58 |
|
field :settlement_bank_country, :string # New field |
| 59 |
|
|
| 60 |
|
# Contact Information (from international migration) |
| 61 |
|
field :contact_person, :string |
| 62 |
|
field :contact_email, :string |
| 63 |
|
field :contact_phone, :string |
| 64 |
|
field :address, :string |
| 65 |
|
field :city, :string |
| 66 |
|
field :state, :string |
| 67 |
|
field :pincode, :string |
| 68 |
|
|
| 69 |
|
# Compliance & KYC (from international migration + new) |
| 70 |
|
field :gstin, :string # GST identification number |
| 71 |
|
field :pan, :string # PAN card number |
| 72 |
|
field :regulatory_license, :string # New field |
| 73 |
|
field :compliance_status, :string, default: "PENDING" # New field |
| 74 |
|
field :risk_category, :string, default: "MEDIUM" # New field |
| 75 |
|
|
| 76 |
|
# Status & Tracking (from international migration + new) |
| 77 |
|
field :intl_status, :string, default: "ACTIVE" # From international migration |
| 78 |
|
field :status, :string, default: "ACTIVE" # New field (will replace intl_status) |
| 79 |
|
field :onboarded_at, :utc_datetime |
| 80 |
|
field :last_transaction_at, :utc_datetime |
| 81 |
|
field :partner_managed, :boolean, default: true # New field |
| 82 |
|
|
| 83 |
|
# Relationships |
| 84 |
|
belongs_to :partner, Partner, type: :binary_id |
| 85 |
|
has_many :invoices, MerchantInvoice |
| 86 |
|
has_many :transactions, Transaction |
| 87 |
|
|
| 88 |
|
timestamps(type: :utc_datetime) |
| 89 |
|
end |
| 90 |
|
|
| 91 |
|
@required ~w(mid tid)a |
| 92 |
|
@optional ~w( |
| 93 |
|
sid sub_code merchant_type merchant_genre onboarding_type inst_code |
| 94 |
|
brand_name legal_name franchise_name ownership_type country_code network_inst_id |
| 95 |
|
merchant_code merchant_vpa business_type business_category static_qr_code |
| 96 |
|
qr_enabled dynamic_qr_enabled max_transaction_limit daily_transaction_limit |
| 97 |
|
corridor local_currency fx_markup_rate settlement_frequency settlement_account_intl |
| 98 |
|
settlement_account_number settlement_account_ifsc settlement_account_type settlement_bank_country |
| 99 |
|
contact_person contact_email contact_phone address city state pincode |
| 100 |
|
gstin pan regulatory_license compliance_status risk_category intl_status status |
| 101 |
|
onboarded_at last_transaction_at partner_managed partner_id |
| 102 |
|
)a |
| 103 |
|
|
| 104 |
|
def changeset(struct, attrs) do |
| 105 |
|
struct |
| 106 |
|
|> cast(attrs, @required ++ @optional) |
| 107 |
|
|> validate_required(@required) |
| 108 |
|
|> validate_merchant_fields() |
| 109 |
|
|> validate_business_fields() |
| 110 |
|
|> validate_international_fields() |
| 111 |
|
|> validate_contact_fields() |
| 112 |
|
|> unique_constraint([:mid, :tid]) |
| 113 |
|
|> unique_constraint(:merchant_code) |
| 114 |
:-( |
|> unique_constraint(:merchant_vpa) |
| 115 |
|
end |
| 116 |
|
|
| 117 |
|
# Validation helper functions |
| 118 |
|
defp validate_merchant_fields(changeset) do |
| 119 |
|
changeset |
| 120 |
|
|> validate_format(:mid, ~r/^[A-Za-z0-9]{6,15}$/, message: "MID must be 6-15 alphanumeric characters") |
| 121 |
|
|> validate_format(:tid, ~r/^[A-Za-z0-9]{1,8}$/, message: "TID must be 1-8 alphanumeric characters") |
| 122 |
:-( |
|> validate_format(:merchant_code, ~r/^[A-Za-z0-9_]{4,20}$/, message: "Merchant code must be 4-20 characters") |
| 123 |
|
end |
| 124 |
|
|
| 125 |
|
defp validate_business_fields(changeset) do |
| 126 |
|
changeset |
| 127 |
|
|> validate_inclusion(:merchant_type, ["SMALL", "MEDIUM", "LARGE"]) |
| 128 |
|
|> validate_inclusion(:merchant_genre, ["RETAIL", "ONLINE"]) |
| 129 |
|
|> validate_inclusion(:onboarding_type, ["AGGREGATOR", "DIRECT", "BANK"]) |
| 130 |
|
|> validate_inclusion(:business_type, ["RETAIL", "ECOMMERCE", "SERVICES"]) |
| 131 |
|
|> validate_inclusion(:ownership_type, ["PRIVATE", "PUBLIC", "PARTNERSHIP"]) |
| 132 |
|
|> validate_format(:merchant_vpa, ~r/^[\w\.-]+@[\w\.-]+$/, message: "Invalid VPA format") |
| 133 |
|
|> validate_length(:brand_name, min: 2, max: 99) |
| 134 |
:-( |
|> validate_length(:legal_name, max: 99) |
| 135 |
|
end |
| 136 |
|
|
| 137 |
|
defp validate_international_fields(changeset) do |
| 138 |
|
changeset |
| 139 |
|
|> validate_inclusion(:corridor, ["SINGAPORE", "UAE", "USA", "DOMESTIC"]) |
| 140 |
|
|> validate_inclusion(:local_currency, ["SGD", "AED", "USD", "INR"]) |
| 141 |
|
|> validate_inclusion(:country_code, ["SG", "AE", "US", "IN"]) |
| 142 |
|
|> validate_number(:fx_markup_rate, greater_than_or_equal_to: 0, less_than_or_equal_to: 10) |
| 143 |
|
|> validate_inclusion(:settlement_frequency, ["T+0", "T+1", "WEEKLY"]) |
| 144 |
:-( |
|> validate_inclusion(:settlement_account_type, ["CURRENT", "SAVINGS"]) |
| 145 |
|
end |
| 146 |
|
|
| 147 |
|
defp validate_contact_fields(changeset) do |
| 148 |
|
changeset |
| 149 |
|
|> validate_format(:contact_email, ~r/@/, message: "Invalid email format") |
| 150 |
|
|> validate_format(:contact_phone, ~r/^\+?[\d\-\s\(\)]{7,15}$/, message: "Invalid phone format") |
| 151 |
|
|> validate_length(:pincode, is: 6) |
| 152 |
|
|> validate_inclusion(:compliance_status, ["PENDING", "VERIFIED", "REJECTED"]) |
| 153 |
|
|> validate_inclusion(:risk_category, ["LOW", "MEDIUM", "HIGH"]) |
| 154 |
|
|> validate_inclusion(:status, ["ACTIVE", "SUSPENDED", "INACTIVE"]) |
| 155 |
:-( |
|> validate_inclusion(:intl_status, ["ACTIVE", "SUSPENDED", "INACTIVE"]) |
| 156 |
|
end |
| 157 |
|
|
| 158 |
|
# Helper functions for business logic |
| 159 |
:-( |
def active?(merchant), do: (merchant.status || merchant.intl_status) == "ACTIVE" |
| 160 |
:-( |
def international?(merchant), do: merchant.corridor && merchant.corridor != "DOMESTIC" |
| 161 |
:-( |
def qr_enabled?(merchant), do: merchant.qr_enabled and active?(merchant) |
| 162 |
|
def can_process_amount?(merchant, amount) do |
| 163 |
:-( |
active?(merchant) and |
| 164 |
:-( |
(is_nil(merchant.max_transaction_limit) or |
| 165 |
:-( |
Decimal.compare(amount, merchant.max_transaction_limit) != :gt) |
| 166 |
|
end |
| 167 |
|
end |