defmodule ISO8583.PacketBuilder do @moduledoc """ Builds ISO87B BCD-packed ISO8583 packets for jPOS server communication. Format: 2-byte big-endian length + 5-byte TPDU + 2-byte BCD MTI + 8-byte binary bitmap + fields. """ import Bitwise @doc """ Builds an ISO8583 packet based on transaction data. ## Parameters - test_case: Map containing transaction details - _options: Additional options (optional) ## Returns Binary packet ready to send to jPOS server """ def build(test_case, _options \\ %{}) do # Extract fields from test case mti = Map.get(test_case, :mti, "0200") stan = String.pad_leading(test_case.stan, 6, "0") amount = String.pad_leading(test_case.amount, 12, "0") pan = String.trim(test_case.pan) tid = String.pad_trailing(String.trim(test_case.tid), 8) mid = String.pad_trailing(String.trim(test_case.mid), 15) nii = String.pad_leading(test_case.nii, 3, "0") proc_code = String.pad_leading(test_case.processing_code, 6, "0") # Get current timestamp now = DateTime.utc_now() time_str = Calendar.strftime(now, "%H%M%S") date_str = Calendar.strftime(now, "%m%d") expiry = Map.get(test_case, :expiry, "2512") # TPDU: 0x60 0x00 + 2-byte BCD NII + 0x00 tpdu = <<0x60, 0x00>> <> bcd_encode(nii, 2) <> <<0x00>> # MTI: 2-byte BCD mti_bcd = bcd_encode(mti, 2) # Build bitmap and fields based on transaction type {bitmap, fields} = build_fields(test_case, %{ pan: pan, proc_code: proc_code, amount: amount, stan: stan, time_str: time_str, date_str: date_str, expiry: expiry, tid: tid, mid: mid, nii: nii }) # Construct packet body body = tpdu <> mti_bcd <> bitmap <> fields # Add length prefix (2-byte big-endian) length = byte_size(body) <> <> body end # Builds bitmap and field data for the transaction. # Returns {bitmap_binary, fields_binary} defp build_fields(test_case, field_data) do # Standard fields for most transactions: # DE2 (PAN), DE3 (proc code), DE4 (amount), DE11 (STAN), # DE12 (time), DE13 (date), DE14 (expiry), DE22 (POS entry mode), # DE23 (card seq), DE24 (NII), DE25 (POS condition), DE41 (TID), DE42 (MID) fields_list = [ {2, build_de2(field_data.pan)}, {3, build_de3(field_data.proc_code)}, {4, build_de4(field_data.amount)}, {11, build_de11(field_data.stan)}, {12, build_de12(field_data.time_str)}, {13, build_de13(field_data.date_str)}, {14, build_de14(field_data.expiry)}, {22, build_de22(Map.get(test_case, :pos_entry_mode, "071"))}, {23, build_de23(Map.get(test_case, :card_sequence, "001"))}, {24, build_de24(field_data.nii)}, {25, build_de25(Map.get(test_case, :pos_condition, "00"))}, {41, build_de41(field_data.tid)}, {42, build_de42(field_data.mid)} ] # Add optional fields based on transaction scenario fields_list = add_optional_fields(fields_list, test_case, field_data) # Build bitmap (8 bytes for fields 1-64) bitmap = build_bitmap(fields_list) # Concatenate all field data in order field_data_binary = fields_list |> Enum.sort_by(fn {field_num, _} -> field_num end) |> Enum.map(fn {_, data} -> data end) |> Enum.reduce(<<>>, fn data, acc -> cond do is_binary(data) -> acc <> data is_list(data) -> acc <> :binary.list_to_bin(data) true -> acc end end) {bitmap, field_data_binary} end defp add_optional_fields(fields_list, test_case, field_data) do # Add DE35 (track2) if available fields_list = if Map.has_key?(test_case, :track2) do [{35, build_de35(test_case.track2)} | fields_list] else # Default track2 if not provided track2 = "#{field_data.pan}=#{field_data.expiry}12260015700" [{35, build_de35(track2)} | fields_list] end # Add DE38 (approval code) for reversal/void transactions fields_list = if Map.has_key?(test_case, :approval_code) do [{38, build_de38(test_case.approval_code)} | fields_list] else fields_list end # Add DE39 (response code) for reversal transactions fields_list = if Map.has_key?(test_case, :original_response_code) do [{39, build_de39(test_case.original_response_code)} | fields_list] else fields_list end # Add DE49 (currency code) - default to USD (840) currency = Map.get(test_case, :currency_code, "840") [{49, build_de49(currency)} | fields_list] end # Field builders - Each builds the specific format for that DE defp build_de2(pan) do # LLVAR BCD: 1 byte BCD length + BCD data len = String.length(pan) bcd_encode(String.pad_leading(Integer.to_string(len), 2, "0"), 1) <> bcd_encode(pan, div(len + 1, 2)) end defp build_de3(proc_code), do: bcd_encode(proc_code, 3) defp build_de4(amount), do: bcd_encode(amount, 6) defp build_de11(stan), do: bcd_encode(stan, 3) defp build_de12(time), do: bcd_encode(time, 3) defp build_de13(date), do: bcd_encode(date, 2) defp build_de14(expiry), do: bcd_encode(expiry, 2) defp build_de22(pos_entry) do # 2 bytes BCD, right-justified bcd_encode(String.pad_leading(pos_entry, 4, "0"), 2) end defp build_de23(card_seq), do: bcd_encode(String.pad_leading(card_seq, 3, "0"), 2) defp build_de24(nii), do: bcd_encode(nii, 2) defp build_de25(pos_condition), do: bcd_encode(pos_condition, 1) defp build_de35(track2) do # LLVAR BCD len = String.length(track2) bcd_encode(String.pad_leading(Integer.to_string(len), 2, "0"), 1) <> bcd_encode(track2, div(len + 1, 2)) end defp build_de38(approval_code) do # 6 bytes ASCII String.pad_trailing(approval_code, 6) |> :binary.bin_to_list() end defp build_de39(response_code) do # 2 bytes ASCII response_code |> :binary.bin_to_list() end defp build_de41(tid) do # 8 bytes ASCII String.pad_trailing(tid, 8) |> :binary.bin_to_list() end defp build_de42(mid) do # 15 bytes ASCII String.pad_trailing(mid, 15) |> :binary.bin_to_list() end defp build_de49(currency) do # 3 bytes ASCII currency |> :binary.bin_to_list() end # Builds an 8-byte bitmap from list of {field_number, data} tuples. defp build_bitmap(fields_list) do # Create 64-bit bitmap (8 bytes) bitmap_bits = for i <- 1..64 do if Enum.any?(fields_list, fn {field_num, _} -> field_num == i end), do: 1, else: 0 end # Convert to binary bitmap_bits |> Enum.chunk_every(8) |> Enum.map(fn bits -> bits |> Enum.with_index() |> Enum.reduce(0, fn {bit, idx}, acc -> acc + (bit <<< (7 - idx)) end) end) |> :binary.list_to_bin() end # Encodes a numeric string to BCD (Binary Coded Decimal). # Handles track2 special characters: '=' -> 0xD, 'D' -> 0xD defp bcd_encode(str, byte_count) do # Pad to even length str = if rem(String.length(str), 2) == 1, do: "0" <> str, else: str # Convert to BCD str |> String.graphemes() |> Enum.chunk_every(2) |> Enum.map(fn [high, low] -> high_nibble = char_to_bcd_nibble(high) low_nibble = char_to_bcd_nibble(low) (high_nibble <<< 4) + low_nibble end) |> Enum.take(byte_count) |> :binary.list_to_bin() end # Converts a character to its BCD nibble value # Digits 0-9 -> 0x0-0x9, '=' or 'D' -> 0xD, 'F' -> 0xF defp char_to_bcd_nibble(char) do case char do "=" -> 0xD "D" -> 0xD "F" -> 0xF digit -> String.to_integer(digit) end end end