defmodule DaProductApp.TransactionRulesTest do use ExUnit.Case, async: true alias DaProductApp.Schemas.ShukriaMms.TransactionRule alias DaProductApp.TransactionRules # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp make_rule(rule_type, params, opts \\ []) do %TransactionRule{ id: Keyword.get(opts, :id, 1), rule_id: Keyword.get(opts, :rule_id, "test-#{rule_type}"), rule_name: "Test #{rule_type}", rule_type: rule_type, response_code: Keyword.get(opts, :response_code, "05"), scope: :merchant, merchant_id: "M001", terminal_id: "T001", params: params, enabled: true, priority: 100 } end defp base_txn(overrides \\ %{}) do Map.merge( %{ "merchant_id" => "M001", "terminal_id" => "T001", "amount" => %{"value" => "100.00", "currency" => "AED"}, "transaction" => %{ "type" => "PURCHASE", "stan" => "123456", "rrn" => "410512345678", "timestamp" => "2026-04-11T10:00:00Z" } }, overrides ) end # --------------------------------------------------------------------------- # MIN_AMOUNT # --------------------------------------------------------------------------- describe "MIN_AMOUNT" do test "allows transaction above minimum" do rule = make_rule("MIN_AMOUNT", %{"min_amount" => 50.0}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines transaction below minimum" do rule = make_rule("MIN_AMOUNT", %{"min_amount" => 200.0}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "below minimum" end end # --------------------------------------------------------------------------- # MAX_AMOUNT # --------------------------------------------------------------------------- describe "MAX_AMOUNT" do test "allows transaction below maximum" do rule = make_rule("MAX_AMOUNT", %{"max_amount" => 500.0}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines transaction above maximum" do rule = make_rule("MAX_AMOUNT", %{"max_amount" => 50.0}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "exceeds maximum" end end # --------------------------------------------------------------------------- # TIME_WINDOW # --------------------------------------------------------------------------- describe "TIME_WINDOW" do test "allows transaction within time window" do rule = make_rule("TIME_WINDOW", %{"start_time" => "00:00", "end_time" => "23:59"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines transaction outside time window" do # The timestamp in base_txn is 10:00 UTC, so a 20:00-21:00 window should decline it rule = make_rule("TIME_WINDOW", %{"start_time" => "20:00", "end_time" => "21:00"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "outside allowed time window" end end # --------------------------------------------------------------------------- # BLACKLIST # --------------------------------------------------------------------------- describe "BLACKLIST" do test "allows non-blacklisted merchant" do rule = make_rule("BLACKLIST", %{"merchants" => ["EVIL_MERCHANT"], "terminals" => []}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines blacklisted merchant" do rule = make_rule("BLACKLIST", %{"merchants" => ["M001"], "terminals" => []}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "blacklisted" end test "declines blacklisted terminal" do rule = make_rule("BLACKLIST", %{"merchants" => [], "terminals" => ["T001"]}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "blacklisted" end end # --------------------------------------------------------------------------- # REFUND_POLICY # --------------------------------------------------------------------------- describe "REFUND_POLICY" do test "allows purchase transactions regardless of refund policy" do rule = make_rule("REFUND_POLICY", %{"refunds_allowed" => false}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines refund when refunds_allowed is false" do rule = make_rule("REFUND_POLICY", %{"refunds_allowed" => false}) txn = base_txn(%{"transaction" => %{"type" => "REFUND"}}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "not allowed" end test "allows refund within max refund amount" do rule = make_rule("REFUND_POLICY", %{"refunds_allowed" => true, "max_refund_amount" => 500.0}) txn = base_txn(%{"transaction" => %{"type" => "REFUND"}}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "declines refund exceeding max refund amount" do rule = make_rule("REFUND_POLICY", %{"refunds_allowed" => true, "max_refund_amount" => 50.0}) txn = base_txn(%{"transaction" => %{"type" => "REFUND"}}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "exceeds maximum refund limit" end test "CREDIT type is treated as a refund" do rule = make_rule("REFUND_POLICY", %{"refunds_allowed" => false}) txn = base_txn(%{"transaction" => %{"type" => "CREDIT"}}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "not allowed" end end # --------------------------------------------------------------------------- # MCC_RESTRICTION # --------------------------------------------------------------------------- describe "MCC_RESTRICTION" do test "allows when no MCC in context" do rule = make_rule("MCC_RESTRICTION", %{"blocked_mccs" => ["5411"]}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "allows transaction with non-blocked MCC" do rule = make_rule("MCC_RESTRICTION", %{"blocked_mccs" => ["5411"]}) txn = base_txn(%{"mcc" => "5812"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "declines transaction with blocked MCC" do rule = make_rule("MCC_RESTRICTION", %{"blocked_mccs" => ["5411"]}) txn = base_txn(%{"mcc" => "5411"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "restricted" end test "declines transaction with MCC not in allowed list" do rule = make_rule("MCC_RESTRICTION", %{"allowed_mccs" => ["5812", "7011"]}) txn = base_txn(%{"mcc" => "5411"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "not in the allowed list" end test "allows transaction with MCC in allowed list" do rule = make_rule("MCC_RESTRICTION", %{"allowed_mccs" => ["5812", "5411"]}) txn = base_txn(%{"mcc" => "5411"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "reads MCC from context field" do rule = make_rule("MCC_RESTRICTION", %{"blocked_mccs" => ["9999"]}) txn = base_txn(%{"context" => %{"mcc" => "9999"}}) assert {:decline, _, _, _} = TransactionRules.evaluate_transaction([rule], txn) end end # --------------------------------------------------------------------------- # BIN_LIMITS # --------------------------------------------------------------------------- describe "BIN_LIMITS" do test "allows when no BIN in context" do rule = make_rule("BIN_LIMITS", %{"blocked_bins" => ["411111"]}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "allows transaction with non-blocked BIN" do rule = make_rule("BIN_LIMITS", %{"blocked_bins" => ["411111"]}) txn = base_txn(%{"bin" => "512345"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "declines transaction with blocked BIN" do rule = make_rule("BIN_LIMITS", %{"blocked_bins" => ["411111"]}) txn = base_txn(%{"bin" => "411111"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "restricted" end test "declines transaction with BIN not in allowed list" do rule = make_rule("BIN_LIMITS", %{"allowed_bins" => ["512345", "601100"]}) txn = base_txn(%{"bin" => "411111"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "not in the allowed list" end test "declines transaction exceeding per-BIN amount limit" do rule = make_rule("BIN_LIMITS", %{"max_amount_by_bin" => %{"411111" => 50.0}}) txn = base_txn(%{"bin" => "411111"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "exceeds BIN limit" end test "allows transaction within per-BIN amount limit" do rule = make_rule("BIN_LIMITS", %{"max_amount_by_bin" => %{"411111" => 500.0}}) txn = base_txn(%{"bin" => "411111"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "reads BIN from card field" do rule = make_rule("BIN_LIMITS", %{"blocked_bins" => ["999999"]}) txn = base_txn(%{"card" => %{"bin" => "999999"}}) assert {:decline, _, _, _} = TransactionRules.evaluate_transaction([rule], txn) end end # --------------------------------------------------------------------------- # TXN_TYPE_CONTROL # --------------------------------------------------------------------------- describe "TXN_TYPE_CONTROL" do test "allows transaction type not in blocked list" do rule = make_rule("TXN_TYPE_CONTROL", %{"blocked_types" => ["REFUND"]}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines blocked transaction type" do rule = make_rule("TXN_TYPE_CONTROL", %{"blocked_types" => ["PURCHASE"]}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "not allowed" end test "allows transaction type in allowed list" do rule = make_rule("TXN_TYPE_CONTROL", %{"allowed_types" => ["PURCHASE", "REFUND"]}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines transaction type not in allowed list" do rule = make_rule("TXN_TYPE_CONTROL", %{"allowed_types" => ["REFUND"]}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "not permitted" end test "allows all types when both lists are empty" do rule = make_rule("TXN_TYPE_CONTROL", %{"allowed_types" => [], "blocked_types" => []}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end end # --------------------------------------------------------------------------- # GEO_RESTRICTION # --------------------------------------------------------------------------- describe "GEO_RESTRICTION" do test "allows when no country in context" do rule = make_rule("GEO_RESTRICTION", %{"blocked_countries" => ["IR"]}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "allows transaction from non-blocked country" do rule = make_rule("GEO_RESTRICTION", %{"blocked_countries" => ["IR"]}) txn = base_txn(%{"country" => "AE"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "declines transaction from blocked country" do rule = make_rule("GEO_RESTRICTION", %{"blocked_countries" => ["IR"]}) txn = base_txn(%{"country" => "IR"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "restricted" end test "declines transaction from country not in allowed list" do rule = make_rule("GEO_RESTRICTION", %{"allowed_countries" => ["AE", "SA"]}) txn = base_txn(%{"country" => "US"}) assert {:decline, _, msg, _} = TransactionRules.evaluate_transaction([rule], txn) assert msg =~ "not permitted" end test "allows transaction from country in allowed list" do rule = make_rule("GEO_RESTRICTION", %{"allowed_countries" => ["AE", "SA"]}) txn = base_txn(%{"country" => "AE"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], txn) end test "reads country from context field" do rule = make_rule("GEO_RESTRICTION", %{"blocked_countries" => ["XX"]}) txn = base_txn(%{"context" => %{"country" => "XX"}}) assert {:decline, _, _, _} = TransactionRules.evaluate_transaction([rule], txn) end end # --------------------------------------------------------------------------- # SOFT_DECLINE # --------------------------------------------------------------------------- describe "SOFT_DECLINE" do test "allows transaction below threshold" do rule = make_rule("SOFT_DECLINE", %{"threshold_amount" => 500.0}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "flags transaction above threshold" do rule = make_rule("SOFT_DECLINE", %{"threshold_amount" => 50.0}) assert {:flag, _, msg, _} = TransactionRules.evaluate_transaction([rule], base_txn()) assert msg =~ "soft decline threshold" end test "allows transaction exactly at threshold" do rule = make_rule("SOFT_DECLINE", %{"threshold_amount" => 100.0}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end end # --------------------------------------------------------------------------- # CUSTOM_SCRIPT # --------------------------------------------------------------------------- describe "CUSTOM_SCRIPT" do test "allows when conditions list is empty" do rule = make_rule("CUSTOM_SCRIPT", %{"conditions" => [], "action" => "decline"}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "declines when all conditions match (default action)" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [%{"field" => "amount", "operator" => "gt", "value" => "50.0"}], "message" => "Custom rule triggered" }) assert {:decline, _, "Custom rule triggered", _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "flags when all conditions match and action is flag" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [%{"field" => "amount", "operator" => "gt", "value" => "50.0"}], "action" => "flag", "message" => "Flagged by custom rule" }) assert {:flag, _, "Flagged by custom rule", _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "allows when not all conditions match" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [ %{"field" => "amount", "operator" => "gt", "value" => "50.0"}, %{"field" => "amount", "operator" => "gt", "value" => "500.0"} ], "action" => "decline" }) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "eq operator matches string fields" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [%{"field" => "currency", "operator" => "eq", "value" => "AED"}], "action" => "decline", "message" => "AED blocked" }) assert {:decline, _, "AED blocked", _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "ne operator allows non-matching field" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [%{"field" => "currency", "operator" => "ne", "value" => "AED"}], "action" => "decline" }) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "in operator with list value" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [ %{"field" => "type", "operator" => "in", "value" => ["PURCHASE", "REFUND"]} ], "action" => "decline", "message" => "type in list" }) assert {:decline, _, "type in list", _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "not_in operator allows when field not in list" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [ %{"field" => "type", "operator" => "not_in", "value" => ["REFUND", "CREDIT"]} ], "action" => "decline" }) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "lte operator matches numeric fields" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [%{"field" => "amount", "operator" => "lte", "value" => "100.0"}], "action" => "decline", "message" => "at or below limit" }) assert {:decline, _, "at or below limit", _} = TransactionRules.evaluate_transaction([rule], base_txn()) end test "unknown operator returns false (allows transaction)" do rule = make_rule("CUSTOM_SCRIPT", %{ "conditions" => [ %{"field" => "amount", "operator" => "unknown_op", "value" => "100.0"} ], "action" => "decline" }) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end end # --------------------------------------------------------------------------- # Unknown rule type # --------------------------------------------------------------------------- describe "unknown rule type" do test "allows transaction for unknown rule type" do rule = make_rule("FUTURE_RULE_TYPE", %{}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([rule], base_txn()) end end # --------------------------------------------------------------------------- # Multiple rules evaluated in sequence # --------------------------------------------------------------------------- describe "multiple rules" do test "first rule declines, remaining rules skipped" do deny = make_rule("MAX_AMOUNT", %{"max_amount" => 50.0}, rule_id: "deny") allow_rule = make_rule("MIN_AMOUNT", %{"min_amount" => 0.0}, rule_id: "allow") assert {:decline, _, _, rule} = TransactionRules.evaluate_transaction([deny, allow_rule], base_txn()) assert rule.rule_id == "deny" end test "first rule allows, second rule declines" do allow_rule = make_rule("MIN_AMOUNT", %{"min_amount" => 0.0}, rule_id: "allow") deny = make_rule("MAX_AMOUNT", %{"max_amount" => 50.0}, rule_id: "deny") assert {:decline, _, _, rule} = TransactionRules.evaluate_transaction([allow_rule, deny], base_txn()) assert rule.rule_id == "deny" end test "all rules allow" do r1 = make_rule("MIN_AMOUNT", %{"min_amount" => 0.0}) r2 = make_rule("MAX_AMOUNT", %{"max_amount" => 999.0}) assert {:allow, _, _} = TransactionRules.evaluate_transaction([r1, r2], base_txn()) end test "empty rules list allows transaction" do assert {:allow, _, _} = TransactionRules.evaluate_transaction([], base_txn()) end end end