Skip to content

Instantly share code, notes, and snippets.

@pbrdmn
Last active March 14, 2023 02:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pbrdmn/71ad39e69f0085418e176324750c430f to your computer and use it in GitHub Desktop.
Save pbrdmn/71ad39e69f0085418e176324750c430f to your computer and use it in GitHub Desktop.
Create ACH (Automated Clearing House) files for dispatch into the NACHA network
class ACH
RECORD_SIZE = 94 # Always "094" because every record contains 94 characters.
PRIORITY_CODE = "01"
HEADER_RECORD_TYPE_CODE = "1"
BATCH_HEADER_RECORD_TYPE_CODE = "5"
ENTRY_RECORD_TYPE_CODE = "6"
BATCH_CONTROL_RECORD_TYPE_CODE = "8"
FOOTER_RECORD_TYPE_CODE = "9"
BLOCKING_FACTOR = 10
FORMAT_CODE = "1"
ADDENDA_RECORD_INDICATOR = "0"
attr_reader :file_entry_count, :batch_entry_count, :file_debits, :file_credits, :file_creation_date, :file_creation_time
def initialize (
immediate_destination,
immediate_origin,
immediate_destination_name,
immediate_origin_name,
reference_code = 'REF',
date = Time.now.strftime("%y%m%d"),
time = Time.now.strftime("%H%M"),
file_id_modifier = 'A'
)
# The nine-digit routing number of the institution receiving the ACH file for processing, preceded by a blank. Typically, this is your bank’s routing and transit number.
@immediate_destination = _num(immediate_destination, 9)
# The nine-digit routing transit number of the institution sending (originating) the ACH file, preceded by a blank. (Often your ODFI will have you insert your company ID in this field.)
@immediate_origin = _num(immediate_origin, 10)
# Name of the financial institution receiving the payment file.
@immediate_destination_name = _str(immediate_destination_name, 23)
# Name of the financial institution sending the payment file.
@immediate_origin_name = _str(immediate_origin_name, 23)
# This field is reserved for information pertinent to the business.
@reference_code = reference_code[0,8].rjust(8, " ")
# For a single processing day, each file submitted by the financial institution should have a unique ID to allow for thorough duplicate file identification.
@file_id_modifier = (file_id_modifier + " ")[0,1]
# The date that the ACH file was created. (YYMMDD)
@file_creation_date = date
# The time that the ACH file was created using a 24-hour, military time format. (HHMM)
@file_creation_time = time
# number of batches within the file
@batch_count = 0
# trace number, increment after each entry for unique id
@file_entry_count = 0
@batch_entry_count = 0
# Sum of the receiving DFI ID (first 8 digits of the other party's ABA routing number) for each entry/transaction within the file. If the sum exceeds 10 places, use the rightmost 10 digits.
@file_entry_hash = 0
@batch_entry_hash = 0
# totals of debits for all entries
@file_debits = 0
@batch_debits = 0
# totals of credits for all entries
@file_credits = 0
@batch_credits = 0
end
def header
HEADER_RECORD_TYPE_CODE +
PRIORITY_CODE +
" " + @immediate_destination +
@immediate_origin +
@file_creation_date +
@file_creation_time +
@file_id_modifier +
_num(RECORD_SIZE, 3) +
_num(BLOCKING_FACTOR, 2) +
FORMAT_CODE +
@immediate_destination_name +
@immediate_origin_name +
@reference_code +
"\n"
end
def footer
batch_footer +
FOOTER_RECORD_TYPE_CODE +
_num(@batch_count, 6) +
_num(block_count, 6) +
_num(@file_entry_count, 8) +
format_entry_hash(@file_entry_hash) +
_num(@file_debits, 12) +
_num(@file_credits, 12) +
(" " * 39)
end
def batch (
# Identifies the general classification of dollar entries to be exchanged
# 200 – Mixed Debits and Credits
# 220 – Credits only
# 225 – Debits only
service_class_code,
# Name of the Originator known and recognized by the Receiver.
company_name,
# Originator/ODFI may include codes of significance only to them to enable specialized handling of all entries within the batch.
company_discretionary_data,
# Used to identify the Originator. Assigned by the ODFI.
company_identification,
# Three-character code used to identify distinct types of entries. [PPD = Pre-arranged Payment or Deposit]
standard_entry_class_code,
# Originator inserts this field's value to provide the Receiver with a description of the entry's purpose; however some SEC Codes require specific values for this field.
company_entry_description,
# The code refers to the ODFI initiating the entry.
originator_status_code = "1"
)
# Store trailer record for previous batch (or "" for first batch)
previous_batch_trailer_record = batch_footer
# Store batch details to be duplicated in header and trailer records
@batch_service_class_code = _str(service_class_code, 3)
@batch_company_identification = _str(company_identification, 10)
# Inserted by ACH Operator.
settlement_date = " "
# The routing number of the DFI originating the entries within the batch.
@batch_originating_dfi_identification = _num(@immediate_destination, 8)
# increment the batch_count, used for generating batch numbers
@batch_count = @batch_count + 1
# reset batch entry count, hash, debits, credits
@batch_entry_count = 0
@batch_entry_hash = 0
@batch_debits = 0
@batch_credits = 0
previous_batch_trailer_record +
BATCH_HEADER_RECORD_TYPE_CODE +
@batch_service_class_code +
_str(company_name, 16) +
_str(company_discretionary_data, 20) +
@batch_company_identification +
standard_entry_class_code +
_str(company_entry_description, 10) +
@file_creation_date +
@file_creation_date +
settlement_date +
originator_status_code +
@batch_originating_dfi_identification +
batch_number +
"\n"
end
def batch_number
_num(@batch_count, 7)
end
def batch_footer
return "" unless @batch_count >= 1
# Message Authentication Code
# The 8-character code from a special key used in conjunction with the DES algorithm.
# It is used to validate the authenticity of the ACH Entries.
message_authentication_code = " " * 19
reserved = " " * 6
BATCH_CONTROL_RECORD_TYPE_CODE +
@batch_service_class_code +
_num(@batch_entry_count, 6) +
format_entry_hash(@batch_entry_hash) +
_num(@batch_debits, 12) +
_num(@batch_credits, 12) +
@batch_company_identification +
message_authentication_code +
reserved +
@batch_originating_dfi_identification +
batch_number +
"\n"
end
def entry (
# Trancode for the transaction - see Common Data Elements for trancode definitions.
transaction_code, # 22 = sending money
# This is the first 8 digits of the routing and transit number of the receiving bank (where the recipient account is located).
# 9th digit is check_digit
routing_number,
# This is the account number of the recipient.
account_number,
# This is the amount of the transaction (in dollars)
amount, # 123.4
# This is an optional field to identify the transaction to the Receiver.
identification_number, # artist name
# Entered by the Originator to provide additional identification of the Receiver and may be helpful in identifying return entries.
# PPD Return Fee entries authorized by notice must contain the same information identified within the Individual Name field of the ARC, BOC, or POP entry to which the Return Fee Entry relates.
receiving_name
)
amount_in_cents = (amount.to_f * 100).to_i
@file_credits += amount_in_cents
@batch_credits += amount_in_cents
# Assigned by the ODFI in ascending sequence that uniquely identifies each entry within a batch and the file. (15 chars)
@file_entry_count += 1
@batch_entry_count += 1
trace_number = @immediate_destination[0,8] + _num(@file_entry_count, 7)
# Sum of the receiving DFI ID (first 8 digits of the other party's ABA routing number) for each entry/transaction within the file. If the sum exceeds 10 places, use the rightmost 10 digits.
entry_routing_hash = routing_number[0,8].to_i
@file_entry_hash += entry_routing_hash
@batch_entry_hash += entry_routing_hash
# ODFI may include codes, of significance to them, to enable specialized handling of the entry.
discretionary_data = " "
# Format entry record
ENTRY_RECORD_TYPE_CODE +
_num(transaction_code, 2) +
_num(routing_number, 9) +
_str(account_number, 17) +
_num(amount_in_cents, 10) +
_str(identification_number, 15) +
_str(receiving_name, 22) +
discretionary_data +
ADDENDA_RECORD_INDICATOR +
trace_number +
"\n"
end
def total_lines
return 2 + # file header and footer
2 * @batch_count + # batch header and footers
@file_entry_count # entries
end
def block_count
(total_lines / BLOCKING_FACTOR.to_f).ceil
end
def format_entry_hash (entries_hash)
# Sum of the receiving DFI ID (first 8 digits of the other party's ABA routing number) for each entry/transaction within the file or batch. If the sum exceeds 10 places, use the rightmost 10 digits.
entries_hash.to_s.rjust(10, "0")[-10..-1]
end
def _str (str, length)
str[0,length].ljust(length, " ")
end
def _num (num, length)
num.to_s[0,length].rjust(length, "0")
end
def padding
# File should always be written in multiples of BLOCKING_FACTOR
padding_lines = (BLOCKING_FACTOR - total_lines % BLOCKING_FACTOR) % BLOCKING_FACTOR
("\n" + ("9" * RECORD_SIZE)) * padding_lines
end
end
require_relative './ach'
describe ACH do
let(:ach) { ACH.new('123456789', '9876543210', 'Bank of ACH Receiver', 'Bank of ACH Originator', 'REF', '230315', '1159', 'B') }
describe "#header" do
it "returns the header record string" do
expect(ach.header).to eq("101 12345678998765432102303151159B094101Bank of ACH Receiver Bank of ACH Originator REF\n")
end
end
describe "#batch" do
it "returns the batch header record string" do
expect(ach.batch("200", "Company Name", "Discretionary Data", "1234567890", "PPD", "Entry Description")).to eq("5200Company Name Discretionary Data 1234567890PPDEntry Desc230315230315 1123456780000001\n")
end
end
describe "#entry" do
it "increments the batch and file entry count" do
expect { ach.entry("22", "123456789", "987654321", "100.1", "Identification", "Individual Name") }.to change { ach.batch_entry_count }.by(1).and change { ach.file_entry_count }.by(1)
end
it "returns formatted entry record string" do
expect(ach.entry("22", "123456789", "987654321", "99.9", "Identification", "Individual Name")).to eq("622123456789987654321 0000009990Identification Individual Name 0123456780000001\n")
end
end
describe "#batch_footer" do
it "returns the batch header record string" do
ach.batch("200", "Company Name", "Discretionary Data", "1234567890", "PPD", "Entry Description")
ach.entry("22", "123456789", "987654321", "99.9", "Identification", "Individual Name")
ach.entry("22", "123456789", "987654321", "123.45", "Identification", "Individual Name")
expect(ach.batch_footer).to eq("820000000200246913560000000000000000000223351234567890 123456780000001\n")
end
end
describe "#footer" do
it "returns the footer record string" do
ach.batch("200", "Company Name", "Discretionary Data", "1234567890", "PPD", "Entry Description")
ach.entry("22", "123456789", "987654321", "99.9", "Identification", "Individual Name")
ach.entry("22", "123456789", "987654321", "123.45", "Identification", "Individual Name")
expect(ach.footer).to eq("#{ach.batch_footer}9000001000001000000020024691356000000000000000000022335"+(" " * 39))
end
end
describe "#padding" do
it "returns the padding record string" do
ach.batch("200", "Company Name", "Discretionary Data", "1234567890", "PPD", "Entry Description")
ach.entry("22", "123456789", "987654321", "99.9", "Identification", "Individual Name")
ach.entry("22", "123456789", "987654321", "123.45", "Identification", "Individual Name")
expect(ach.padding).to eq("\n#{'9' * 94}" * 4)
end
end
end
require './ach'
require 'csv'
company_id = '1234567890'
company_name = 'COMPANY INC'
bank_id = '123456784'
bank_name = 'YOUR BANK NAME'
date = Time.now.strftime("%y%m%d")
time = Time.now.strftime("%H%M")
# Define input, output files
raise ArgumentError.new("You must provide an input filename") if ARGV.length < 1
input_filename, output_filename, *args = ARGV
output_filename = "ach-#{date}#{time}.txt" if output_filename == nil
# Error if no input file found
fail ArgumentError, "cannot find file #{input_filename}" if !File.exists?(input_filename)
## New ACH Instance, write header record
ach = ACH.new(bank_id, company_id, bank_name, company_name, 'PAYRUN', date, time)
File.write(output_filename, ach.header);
File.write(output_filename, ach.batch(
"220", # credits only
company_name,
"",
company_id,
"CCD",
"Payment",
), mode: 'a')
# Loop through row in the SVB file
CSV.foreach(input_filename) do |record|
if record[0] == "R"
# Write ACH entry from CSV data
File.write(output_filename, ach.entry(
record[6],
record[3],
record[4],
record[7],
record[1],
record[2]
), mode: 'a');
end
end
# Append footer record and padding to file
File.write(output_filename, ach.footer, mode: 'a');
File.write(output_filename, ach.padding, mode: 'a');
puts "Wrote to #{output_filename}
#{ach.file_entry_count} entries, #{ach.file_credits} credits, #{ach.file_debits} debits"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment