summaryrefslogtreecommitdiff
path: root/FS/FS/pay_batch/RBC.pm
blob: 691e2a2af2ff822eed23942a3c5f0356dd08aa90 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
package FS::pay_batch::RBC;

use strict;
use vars qw(@ISA %import_info %export_info $name);
use Date::Format 'time2str';
use Date::Parse;
use FS::Conf;
use Encode 'encode';
use feature 'state';

my $conf;
my ($client_num, $shortname, $longname, $trans_code, $testmode, $i, $declined, $totaloffset);

$name = 'RBC';
# Royal Bank of Canada ACH Direct Payments Service

# Meaning of initial characters in records:
# 0 - header row, skipped by begin_condition
# 1 - Debit Detail Record (only when subtype is 0)
# 2 - Credit Detail Record, we die with a parse error (shouldn't appear in freeside-generated batches)
# 3 - Account Trailer Record (appears after Returned items, we skip)
# 4 - Client Trailer Record, indicates end of batch in end_condition
#
# Subtypes (27th char) indicate different kinds of Debit/Credit records
# 0 - Credit/Debit Detail Record
# 3 - Error Message Record
# 4 - Foreign Currency Information Records
# We skip all subtypes except 0
#
# additional info available at https://www.rbcroyalbank.com/ach/cid-213166.html
%import_info = (
  'filetype'    => 'fixed',
  #this only really applies to Debit Detail, but we otherwise only need first char
  'formatre'    => 
  '^(.).{3}(.{10}).{5}(.{4}).{3}(.).{11}(.{19}).{6}(.{30})(.{2})(.{2})(.{4}).{9}(.{9})(.{18}).{6}(.{14}).{23}(.).{9}\r?$',
  'fields' => [ qw(
    recordtype
    clientnum
    batchnum
    subtype
    paybatchnum
    custname
    paydate_month
    paydate_day
    paydate_year
    bank
    payinfo
    paid
    status
    ) ],
  'hook' => sub {
    # pull client_num from config and check it against what's in the batch
    state $clientnum ||= do {
      my $conf = FS::Conf->new;
      my @config = $conf->config("batchconfig-RBC");
      $config[0];
    };

    my $hash = shift;
    $hash->{'paid'} = sprintf("%.2f", $hash->{'paid'} / 100 );
    my $paydate = $hash->{'paydate_year'} . $hash->{'paydate_month'} . $hash->{'paydate_day'};
    $hash->{'_date'} = str2time($paydate, 'local');
    $hash->{'payinfo'} =~ s/^(\S+).*/$1/; # these often have trailing spaces
    $hash->{'payinfo'} = $hash->{'payinfo'} . '@' . $hash->{'bank'};

    if ( $clientnum and $hash->{clientnum} ne $clientnum ) {
      die "RBC client number in batch (".$hash->{clientnum}.") does not ".
        "match configuration.\n";
    }
    '';
  },
  'approved'    => sub { 
      my $hash = shift;
      ($hash->{'status'} eq ' ') || ($hash->{'status'} eq 'W');
  },
  'declined'    => sub {
      my $hash = shift;
      my $status = $hash->{'status'};
      my $message = '';
      if ($status eq 'E') {
        $message = 'Reversed payment';
      } elsif ($status eq 'R') {
        $message = 'Rejected payment';
      } elsif ($status eq 'U') {
        $message = 'Returned payment';
      } elsif ($status eq 'T') {
        $message = 'Error';
      } else {
        return 0;
      }
      $hash->{'error_message'} = $message;
      $declined->{$hash->{'paybatchnum'}} = 1;
      return 1;
  },
  'begin_condition' => sub {
      my $hash = shift;
      # Detail Record
      if ($hash->{recordtype} eq '1' || $hash->{recordtype} eq '2') {
        $declined = {};
        $totaloffset = 0;
        return 1;
      } else {
        return 0;
      }
  },
  'end_hook'    => sub {
      my( $hash, $total, $line ) = @_;
      $total += $totaloffset;
      $total = sprintf("%.2f", $total);
      # We assume here that this is an 'All Records' or 'Input Records' report.
      my $batch_total = sprintf("%.2f", substr($line, 59, 18) / 100);
      return "Our total $total does not match bank total $batch_total!"
        if $total != $batch_total;
      return '';
  },
  'end_condition' => sub {
      my $hash = shift;
      return ($hash->{recordtype} eq '4');  # Client Trailer Record
  },
  'skip_condition' => sub {
      my $hash = shift;
      #we already declined it this run, no takebacks
      if ($declined->{$hash->{'paybatchnum'}}) {
        #file counts this as part of total, but we skip
        $totaloffset += sprintf("%.2f", $hash->{'paid'} / 100 )
          if $hash->{'status'} eq ' '; #false laziness with 'approved' above
        return 1;
      }
      return 
        ($hash->{'recordtype'} eq '3') || #Account Trailer Record, concludes returned items
        ($hash->{'subtype'} ne '0'); #error messages, etc, too late to apply to previous entry
  },
);

%export_info = (
  init => sub {
    $conf = shift;
    ($client_num,
     $shortname,
     $longname,
     $trans_code, 
     $testmode
     ) = $conf->config("batchconfig-RBC");
    $testmode = '' unless $testmode eq 'TEST';
    $i = 1;
  },
  header => sub { 
    my $pay_batch = shift;
    my $mode = $testmode ? 'TEST' : 'PROD';
    my $filenum = $testmode ? 'TEST' : sprintf("%04u", $pay_batch->batchnum);
    my $qualifier = $pay_batch->type eq 'CREDIT' ? 'D' : 'A';
    '$$AAP'.$qualifier.'STD0152['.$mode.'[NL$$'."\n".
    '000001'.
    'A'.
    'HDR'.
    sprintf("%10s", $client_num).
    sprintf("%-30s", $longname).
    $filenum.
    time2str("%Y%j", $pay_batch->download).
    'CAD'.
    '1'.
    ' ' x 87  # filler/reserved fields
    ;
  },
  row => sub {
    my ($cust_pay_batch, $pay_batch) = @_;
    my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
    my($bankno, $branch);
    if ( $aba =~ /^0(\d{3})(\d{5})$/ ) { # standard format for Canadian bank ID
      ($bankno, $branch) = ( $1, $2 );
    } elsif ( $aba =~ /^(\d{5})\.(\d{3})$/ ) { #how we store branches
      ($branch, $bankno) = ( $1, $2 );
    } else {
      die "invalid branch/routing number '$aba'\n";
    }

    ## set custname to business name if business checking or savings account is used otherwise leave as first and last name.
    my $custname = $cust_pay_batch->cust_main->batch_payment_payname($cust_pay_batch);

    $i++;

    ## set to D for debit by default, then override to what cust_pay_batch has as payments may not have paycode.
    my $debitorcredit = 'D';
    $debitorcredit = $cust_pay_batch->paycode unless !$cust_pay_batch->paycode;

    sprintf("%06u", $i).
    $debitorcredit.
    sprintf("%3s",$trans_code).
    sprintf("%10s",$client_num).
    ' '.
    sprintf("%-19s", $cust_pay_batch->paybatchnum).
    '00'.
    sprintf("%04s", $bankno).
    sprintf("%05s", $branch).
    sprintf("%-18s", $account).
    ' '.
    sprintf("%010.0f",$cust_pay_batch->amount*100).
    '      '.
    time2str("%Y%j", time + 86400).
    sprintf("%-30.30s", encode('utf8', $custname)).
    'E'. # English
    ' '.
    sprintf("%-15s", $shortname).
    'CAD'.
    ' '.
    'CAN'.
    '    '.
    'N' # no customer optional information follows
    ;
# Note: IAT Address Information and Remittance records are not 
# supported. This means you probably can't process payments 
# destined to U.S. bank accounts.  If you need this feature, contact 
# Freeside Internet Services.
  },
  footer => sub {
    my ($pay_batch, $batchcount, $batchtotal) = @_;

    my $batch_info = '0' x 20 . sprintf("%06u", $batchcount) . sprintf("%014.0f", $batchtotal*100);
    $batch_info = sprintf("%06u", $batchcount) . sprintf("%014.0f", $batchtotal*100) . '0' x 20 if ($pay_batch->type eq 'CREDIT');

    sprintf("%06u", $i + 1).
    'Z'.
    'TRL'.
    sprintf("%10s", $client_num).
    $batch_info.
    '00' .
    '000000' . # total number of customer information records
    ' ' x 84
    ;
  },
);

## this format can handle credit transactions
sub can_handle_credits {
  1;
}

1;