File Coverage

blib/lib/Business/NAB/AccountInformation/File.pm
Criterion Covered Total %
statement 115 119 96.6
branch 37 42 88.1
condition 7 7 100.0
subroutine 16 16 100.0
pod 2 4 50.0
total 177 188 94.1


line stmt bran cond sub pod time code
1             package Business::NAB::AccountInformation::File;
2             $Business::NAB::AccountInformation::File::VERSION = '0.03';
3             =head1 NAME
4              
5             Business::NAB::AccountInformation::File
6              
7             =head1 SYNOPSIS
8              
9             use Business::NAB::AccountInformation::File;
10              
11             # parse
12             my $AccountInfo = Business::NAB::AccountInformation::File
13             ->new_from_file( $file_path );
14              
15             foreach my $Group ( $AccountInfo->groups->@* ) {
16              
17             foreach my $Account ( $Group->accounts->@* ) {
18              
19             foreach my $Transaction ( $Account->transactions->@* ) {
20              
21             ...
22             }
23             }
24             }
25              
26             =head1 DESCRIPTION
27              
28             Class for parsing a NAB "Account Information File (NAI/BAI2)" file
29              
30             =cut
31              
32 1     1   1127867 use strict;
  1         4  
  1         43  
33 1     1   5 use warnings;
  1         2  
  1         79  
34 1     1   6 use feature qw/ signatures /;
  1         2  
  1         194  
35 1     1   560 use autodie qw/ :all /;
  1         17789  
  1         6  
36 1     1   21221 use Carp qw/ croak /;
  1         2  
  1         58  
37              
38 1     1   650 use Moose;
  1         568560  
  1         9  
39 1     1   9830 use Moose::Util::TypeConstraints;
  1         2  
  1         11  
40 1     1   2873 no warnings qw/ experimental::signatures /;
  1         2  
  1         67  
41              
42 1     1   817 use Module::Load;
  1         1511  
  1         9  
43 1     1   1090 use Text::CSV_XS qw/ csv /;
  1         18580  
  1         109  
44 1         2142 use Business::NAB::Types qw/
45             add_max_string_attribute
46 1     1   759 /;
  1         5  
47              
48             # we have long namespaces and use them multiple times so have
49             # normalised them out into the $parent and @subclasses below
50             my $parent = 'Business::NAB::AccountInformation';
51              
52             my @subclasses = (
53             qw/
54             Transaction
55             Group
56             Account
57             /
58             );
59              
60             load( $parent . "::$_" ) for @subclasses;
61              
62             =head1 ATTRIBUTES
63              
64             =over
65              
66             =item sender_identification (Str, max length 8)
67              
68             =item receiver_identification (Str, max length 4096)
69              
70             =item file_creation_date (DateTime)
71              
72             =item file_creation_time (Str, max length 4)
73              
74             =item file_sequence_number (NAB::Type::PositiveInt)
75              
76             =item physical_record_length (NAB::Type::PositiveIntOrZero)
77              
78             =item blocking_factor (NAB::Type::PositiveIntOrZero)
79              
80             =item version_number (NAB::Type::PositiveInt)
81              
82             =item control_total_a (Int)
83              
84             =item number_of_groups (Int)
85              
86             =item number_of_records (Int)
87              
88             =item control_total_b (Int)
89              
90             =item groups (ArrayRef[Business::NAB::AccountInformation::Group])
91              
92             =back
93              
94             =cut
95              
96             foreach my $str_attr (
97             'sender_identification[8]',
98             'receiver_identification[4096]',
99             'file_creation_time[4]',
100             ) {
101             __PACKAGE__->add_max_string_attribute(
102             $str_attr,
103             is => 'ro',
104             required => 1,
105             );
106             }
107              
108             has 'file_creation_date' => (
109             isa => 'NAB::Type::StatementDate',
110             is => 'ro',
111             required => 1,
112             coerce => 1,
113             );
114              
115             has [
116             qw/
117             file_sequence_number
118             blocking_factor
119             /
120             ] => (
121             isa => 'NAB::Type::PositiveIntOrZero',
122             is => 'ro',
123             required => 1,
124             );
125              
126             has [
127             qw/
128             version_number
129             /
130             ] => (
131             isa => 'NAB::Type::PositiveInt',
132             is => 'ro',
133             required => 0,
134             default => sub { 1 },
135             );
136              
137             has [
138             qw/
139             physical_record_length
140             /
141             ] => (
142             isa => 'Maybe[Str]',
143             is => 'ro',
144             required => 0,
145             predicate => '_has_physical_record_length',
146             );
147              
148             has [
149             qw/
150             control_total_a
151             number_of_groups
152             number_of_records
153             control_total_b
154             _raw_record_count
155             /
156             ] => (
157             isa => 'Int',
158             is => 'rw',
159             );
160              
161             subtype "Groups"
162             => as "ArrayRef[${parent}::Group]";
163              
164             has 'groups' => (
165             traits => [ 'Array' ],
166             is => 'rw',
167             isa => 'Groups',
168             default => sub { [] },
169             handles => {
170             "add_group" => 'push',
171             },
172             );
173              
174             =head1 METHODS
175              
176             =head2 new_from_file
177              
178             Returns a new instance of the class with attributes populated from
179             the result of parsing the passed file
180              
181             my $Payments = Business::NAB::AccountInformation::File
182             ->new_from_file( $file_path );
183              
184             =cut
185              
186 3     3 1 48091 sub new_from_file ( $class, $file ) {
  3         8  
  3         8  
  3         6  
187              
188 3         12 my $reconstructed_records = $class->reconstruct_file_records( $file );
189              
190 3         10 my ( $File, $Group, $Account );
191              
192 3         13 foreach my $record ( $reconstructed_records->{ records }->@* ) {
193              
194 40         195 my ( $record_type, @rest ) = $record->@*;
195              
196 40 100       77 if ( $record_type eq '01' ) {
197 3         10 $File = _file_header( $class, @rest );
198             }
199              
200 40 100       88 if ( $record_type eq '02' ) {
201 3         33 $Group = Business::NAB::AccountInformation::Group
202             ->new_from_record( $record_type, @rest );
203              
204 3         91 $File->add_group( $Group );
205             }
206              
207 40 100       81 if ( $record_type eq '03' ) {
208 8         42 $Account = Business::NAB::AccountInformation::Account
209             ->new_from_record( $record_type, @rest );
210              
211 8         302 $Group->add_account( $Account );
212             }
213              
214 40 100       71 if ( $record_type eq '16' ) {
215 12         42 my $Transaction = Business::NAB::AccountInformation::Transaction
216             ->new_from_record( $record_type, @rest );
217              
218 12         439 $Account->add_transaction( $Transaction );
219             }
220              
221 40 100       63 if ( $record_type eq '49' ) {
222 8         277 $Account->control_total_a( $rest[ 0 ] );
223              
224 8 100       23 $File->is_bai2
225             ? $Account->number_of_records( $rest[ 1 ] )
226             : $Account->control_total_b( $rest[ 1 ] );
227              
228 8         21 $Account->validate_totals( $File->is_bai2 );
229             }
230              
231 40 100       69 if ( $record_type eq '98' ) {
232 3         99 $Group->control_total_a( $rest[ 0 ] );
233 3         114 $Group->number_of_accounts( $rest[ 1 ] );
234              
235 3 100       6 $File->is_bai2
236             ? $Group->number_of_records( $rest[ 2 ] )
237             : $Group->control_total_b( $rest[ 2 ] );
238              
239 3         8 $Group->validate_totals( $File->is_bai2 );
240             }
241              
242 40 100       125 if ( $record_type eq '99' ) {
243 3         120 $File->control_total_a( $rest[ 0 ] );
244 3         145 $File->number_of_groups( $rest[ 1 ] );
245 3         114 $File->number_of_records( $rest[ 2 ] );
246 3 100       10 $File->control_total_b( $rest[ 3 ] )
247             if !$File->is_bai2;
248             $File->_raw_record_count(
249             $reconstructed_records->{ raw_record_count }
250 3         113 );
251              
252 3         8 $File->validate_totals;
253             }
254              
255             }
256              
257 3         145 return $File;
258             }
259              
260             =head2 validate_totals
261              
262             Checks if the control_total_a and control_total_b values match the
263             expected totals of the contained group items:
264              
265             $File->validate_totals;
266              
267             Will throw an exception if any total doesn't match the expected value.
268              
269             =cut
270              
271 5     5 1 9 sub validate_totals ( $self ) {
  5         5  
  5         6  
272              
273 5         139 my $num_groups = scalar( $self->groups->@* );
274 5 50       129 croak(
275             "number of nested groups ($num_groups) != number_of_groups "
276 0         0 . "(@{[ $self->number_of_groups ]})"
277             ) if $num_groups != $self->number_of_groups;
278              
279 5         114 my $num_raw_records = $self->_raw_record_count;
280 5 50       114 croak(
281             "number of records ($num_raw_records) != number_of_records "
282 0         0 . "(@{[ $self->number_of_records ]})"
283             ) if $num_raw_records != $self->number_of_records;
284              
285 5         10 my ( $group_total_a, $group_total_b ) = ( 0, 0 );
286              
287 5         108 foreach my $Group ( $self->groups->@* ) {
288 5         120 $group_total_a += $Group->control_total_a;
289              
290 5 100       11 if ( !$self->is_bai2 ) {
291 2         61 $group_total_b += $Group->control_total_b;
292             }
293             }
294              
295             croak(
296 5 50       138 "calculated sum ($group_total_a) != control_total_a "
297 0         0 . "(@{[$self->control_total_a]})"
298             ) if $group_total_a != $self->control_total_a;
299              
300 5 100       9 if ( !$self->is_bai2 ) {
301 2 50       58 croak(
302             "calculated sum ($group_total_b) != control_total_b "
303 0         0 . "(@{[$self->control_total_b]})"
304             ) if $group_total_b != $self->control_total_b;
305             }
306              
307 5         20 return 1;
308             }
309              
310             =head1 reconstruct_file_records
311              
312             Returns the file contents as a hashref, having reconstructed the various
313             records within it, for easier parsing:
314              
315             my $records = Business::NAB::AccountInformation::File
316             ->reconstruct_file_records( $file );
317              
318             This is due to the file format being somewhat baroque and, essentially, a
319             CSV of fixed width meaning some lines get truncated and continued on the
320             next line or multiple lines.
321              
322             The returned hashref is of the form:
323              
324             {
325             records => @records,
326             raw_record_count => $raw_record_count,
327             }
328              
329             =cut
330              
331 4     4 0 1836 sub reconstruct_file_records ( $self, $file ) {
  4         10  
  4         7  
  4         8  
332              
333 4         25 open( my $fh, '<', $file );
334              
335 4         2225 my ( $field_continues, @records, $raw_record_count );
336              
337 4         268 while ( my $line = <$fh> ) {
338              
339 99 50       221 my $aoa = csv( in => \$line )
340             or croak( Text::CSV->error_diag );
341              
342 99         28206 my ( $record_type, @rest ) = $aoa->[ 0 ]->@*;
343              
344             # a trailing / means the last field of the record is complete,
345             # otherwise it continues on the next line (which should be a
346             # Continuation record (88)
347 99 100       300 if ( $rest[ -1 ] =~ m!/$! ) {
348 84         171 chop( $rest[ -1 ] );
349 84         104 $field_continues = 0;
350             } else {
351 15         19 $field_continues = 1;
352             }
353              
354 99 100       159 if ( $record_type eq '88' ) {
355              
356             # continuation of the previous record, this is a little
357             # bit messy depending on the previous record type
358              
359 43 100 100     125 if (
360             $records[ -1 ][ 0 ] eq '16'
361              
362             # the previous record was complete, append to the
363             # last field in that record
364             && scalar( $records[ -1 ]->@* ) == 7
365             ) {
366 6         21 $records[ -1 ][ -1 ] .= ' ' . join( ' ', @rest );
367             } else {
368              
369             # the previous record was incomplete, complete it
370 37         43 $records[ -1 ] = [ @{ $records[ -1 ] }, @rest ];
  37         236  
371             }
372             } else {
373 56         216 push( @records, [ $record_type, @rest ] );
374             }
375              
376 99         542 $raw_record_count++;
377             }
378              
379             return {
380 4         109 records => \@records,
381             raw_record_count => $raw_record_count,
382             };
383             }
384              
385             =head1 is_bai2
386              
387             Boolean check on the file type
388              
389             if ( $File->is_bai2 ) {
390             ...
391             } else {
392             # it's an NAI file
393             ...
394             }
395              
396             =cut
397              
398 40     40 0 46 sub is_bai2 ( $self ) {
  40         68  
  40         39  
399 40         1107 return $self->version_number == 2;
400             }
401              
402 3     3   6 sub _file_header ( $class, @fields ) {
  3         6  
  3         12  
  3         5  
403              
404 3 100 100     190 return $class->new(
      100        
405             sender_identification => $fields[ 0 ],
406             receiver_identification => $fields[ 1 ],
407             file_creation_date => $fields[ 2 ],
408             file_creation_time => $fields[ 3 ],
409             file_sequence_number => $fields[ 4 ],
410             physical_record_length => $fields[ 5 ] || 0,
411             blocking_factor => $fields[ 6 ] || 0,
412              
413             ( scalar( @fields ) == 8 )
414             ? ( version_number => $fields[ 7 ] )
415             : (),
416             );
417             }
418              
419             =head1 SEE ALSO
420              
421             L<Business::NAB::Types>
422              
423             L<Business::NAB::AccountInformation::Group>
424              
425             L<Business::NAB::AccountInformation::Account>
426              
427             L<Business::NAB::AccountInformation::Transaction>
428              
429             =cut
430              
431             __PACKAGE__->meta->make_immutable;