Merge branch 'master' of git.freeside.biz:/home/git/freeside
[freeside.git] / FS / FS / pay_batch / RBC.pm
1 package FS::pay_batch::RBC;
2
3 use strict;
4 use vars qw(@ISA %import_info %export_info $name);
5 use Date::Format 'time2str';
6 use FS::Conf;
7 use Encode 'encode';
8
9 my $conf;
10 my ($client_num, $shortname, $longname, $trans_code, $testmode, $i, $declined, $totaloffset);
11
12 $name = 'RBC';
13 # Royal Bank of Canada ACH Direct Payments Service
14
15 # Meaning of initial characters in records:
16 # 0 - header row, skipped by begin_condition
17 # 1 - Debit Detail Record (only when subtype is 0)
18 # 2 - Credit Detail Record, we die with a parse error (shouldn't appear in freeside-generated batches)
19 # 3 - Account Trailer Record (appears after Returned items, we skip)
20 # 4 - Client Trailer Record, indicates end of batch in end_condition
21 #
22 # Subtypes (27th char) indicate different kinds of Debit/Credit records
23 # 0 - Credit/Debit Detail Record
24 # 3 - Error Message Record
25 # 4 - Foreign Currency Information Records
26 # We skip all subtypes except 0
27 #
28 # additional info available at https://www.rbcroyalbank.com/ach/cid-213166.html
29 %import_info = (
30   'filetype'    => 'fixed',
31   #this only really applies to Debit Detail, but we otherwise only need first char
32   'formatre'    => 
33   '^(.).{18}(.{4}).{3}(.).{11}(.{19}).{6}(.{30}).{17}(.{9})(.{18}).{6}(.{14}).{23}(.).{9}\r?$',
34   'fields' => [ qw(
35     recordtype
36     batchnum
37     subtype
38     paybatchnum
39     custname
40     bank
41     payinfo
42     paid
43     status
44     ) ],
45   'hook' => sub {
46       my $hash = shift;
47       $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
48       $hash->{'_date'} = time;
49       $hash->{'payinfo'} =~ s/^(\S+).*/$1/; # these often have trailing spaces
50       $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};
51   },
52   'approved'    => sub { 
53       my $hash = shift;
54       $hash->{'status'} eq ' '
55   },
56   'declined'    => sub {
57       my $hash = shift;
58       my $status = $hash->{'status'};
59       my $message = '';
60       if ($status eq 'E') {
61         $message = 'Reversed payment';
62       } elsif ($status eq 'R') {
63         $message = 'Rejected payment';
64       } elsif ($status eq 'U') {
65         $message = 'Returned payment';
66       } elsif ($status eq 'T') {
67         $message = 'Error';
68       } else {
69         return 0;
70       }
71       $hash->{'error_message'} = $message;
72       $declined->{$hash->{'paybatchnum'}} = 1;
73       return 1;
74   },
75   'begin_condition' => sub {
76       my $hash = shift;
77       # Debit Detail Record
78       if ($hash->{recordtype} eq '1') {
79         $declined = {};
80         $totaloffset = 0;
81         return 1;
82       # Credit Detail Record, will immediately trigger end condition & error
83       } elsif ($hash->{recordtype} eq '2') { 
84         return 1;
85       } else {
86         return 0;
87       }
88   },
89   'end_hook'    => sub {
90       my( $hash, $total, $line ) = @_;
91       return "Can't process Credit Detail Record, aborting import"
92         if ($hash->{'recordtype'} eq '2');
93       $totaloffset = sprintf("%.2f", $totaloffset / 100 );
94       $total += $totaloffset;
95       $total = sprintf("%.2f", $total);
96       # We assume here that this is an 'All Records' or 'Input Records' report.
97       my $batch_total = sprintf("%.2f", substr($line, 59, 18) / 100);
98       return "Our total $total does not match bank total $batch_total!"
99         if $total != $batch_total;
100       return '';
101   },
102   'end_condition' => sub {
103       my $hash = shift;
104       return ($hash->{recordtype} eq '4')  # Client Trailer Record
105           || ($hash->{recordtype} eq '2'); # Credit Detail Record, will throw error in end_hook
106   },
107   'skip_condition' => sub {
108       my $hash = shift;
109       #we already declined it this run, no takebacks
110       if ($declined->{$hash->{'paybatchnum'}}) {
111         #file counts this as part of total, but we skip
112         $totaloffset += $hash->{'paid'}
113           if $hash->{'status'} eq ' '; #false laziness with 'approved' above
114         return 1;
115       }
116       return 
117         ($hash->{'recordtype'} eq '3') || #Account Trailer Record, concludes returned items
118         ($hash->{'subtype'} ne '0'); #error messages, etc, too late to apply to previous entry
119   },
120 );
121
122 %export_info = (
123   init => sub {
124     $conf = shift;
125     ($client_num,
126      $shortname,
127      $longname,
128      $trans_code, 
129      $testmode
130      ) = $conf->config("batchconfig-RBC");
131     $testmode = '' unless $testmode eq 'TEST';
132     $i = 1;
133   },
134   header => sub { 
135     my $pay_batch = shift;
136     my $mode = $testmode ? 'TEST' : 'PROD';
137     my $filenum = $testmode ? 'TEST' : sprintf("%04u", $pay_batch->batchnum);
138     '$$AAPASTD0152['.$mode.'[NL$$'."\n".
139     '000001'.
140     'A'.
141     'HDR'.
142     sprintf("%10s", $client_num).
143     sprintf("%-30s", $longname).
144     $filenum.
145     time2str("%Y%j", $pay_batch->download).
146     'CAD'.
147     '1'.
148     ' ' x 87  # filler/reserved fields
149     ;
150   },
151   row => sub {
152     my ($cust_pay_batch, $pay_batch) = @_;
153     my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
154     my($bankno, $branch);
155     if ( $aba =~ /^0(\d{3})(\d{5})$/ ) { # standard format for Canadian bank ID
156       ($bankno, $branch) = ( $1, $2 );
157     } elsif ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #how we store branches
158       ($branch, $bankno) = ( $1, $2 );
159     } else {
160       die "invalid branch/routing number '$aba'\n";
161     }
162
163     $i++;
164     sprintf("%06u", $i).
165     'D'.
166     sprintf("%3s",$trans_code).
167     sprintf("%10s",$client_num).
168     ' '.
169     sprintf("%-19s", $cust_pay_batch->paybatchnum).
170     '00'.
171     sprintf("%04s", $bankno).
172     sprintf("%05s", $branch).
173     sprintf("%-18s", $account).
174     ' '.
175     sprintf("%010.0f",$cust_pay_batch->amount*100).
176     '      '.
177     time2str("%Y%j", $pay_batch->download).
178     sprintf("%-30.30s", encode('utf8', $cust_pay_batch->cust_main->first . ' ' .
179                      $cust_pay_batch->cust_main->last)).
180     'E'. # English
181     ' '.
182     sprintf("%-15s", $shortname).
183     'CAD'.
184     ' '.
185     'CAN'.
186     '    '.
187     'N' # no customer optional information follows
188     ;
189 # Note: IAT Address Information and Remittance records are not 
190 # supported. This means you probably can't process payments 
191 # destined to U.S. bank accounts.  If you need this feature, contact 
192 # Freeside Internet Services.
193   },
194   footer => sub {
195     my ($pay_batch, $batchcount, $batchtotal) = @_;
196     sprintf("%06u", $i + 1).
197     'Z'.
198     'TRL'.
199     sprintf("%10s", $client_num).
200     '0' x 20 .
201     sprintf("%06u", $batchcount).
202     sprintf("%014.0f", $batchtotal*100).
203     '00' .
204     '000000' . # total number of customer information records
205     ' ' x 84
206     ;
207   },
208 );
209
210 1;
211