tax engine refactoring for Avalara and Billsoft tax vendors, #25718
[freeside.git] / FS / FS / detail_format.pm
1 package FS::detail_format;
2
3 use strict;
4 use vars qw( $DEBUG );
5 use FS::Conf;
6 use FS::cdr;
7 use FS::cust_bill_pkg_detail;
8 use FS::L10N;
9 use Date::Language;
10 use Text::CSV_XS;
11
12 my $me = '[FS::detail_format]';
13
14 =head1 NAME
15
16 FS::detail_format - invoice detail formatter
17
18 =head1 DESCRIPTION
19
20 An FS::detail_format object is a converter to create invoice details 
21 (L<FS::cust_bill_pkg_detail>) from call detail records (L<FS::cdr>)
22 or other usage information.  FS::detail_format inherits from nothing.
23
24 Subclasses of FS::detail_format represent specific detail formats.
25
26 =head1 CLASS METHODS
27
28 =over 4
29
30 =item new FORMAT, OPTIONS
31
32 Returns a new detail formatter.  The FORMAT argument is the name of 
33 a subclass.
34
35 OPTIONS may contain:
36
37 - buffer: an arrayref to store details into.  This may avoid the need for a
38 large copy operation at the end of processing.  However, since summary formats
39 will produce nothing until the end of processing, C<finish> must be called
40 after all CDRs have been appended.
41
42 - inbound: a flag telling the formatter to format CDRs for display to the
43 receiving party, rather than the originator.  In this case, the
44 L<FS::cdr_termination> object will be fetched and its values used for
45 rated_price, rated_seconds, rated_minutes, and svcnum.  This can be changed
46 with the C<inbound> method.
47
48 - locale: a locale string to use for static text and date formats.  This is
49 optional.
50
51 =cut
52
53 sub new {
54   my $class = shift;
55   if ( $class eq 'FS::detail_format' ) {
56     my $format = shift
57       or die "$me format name required";
58     $class = "FS::detail_format::$format"
59       unless $format =~ /^FS::detail_format::/;
60   }
61   eval "use $class";
62   die "$me error loading $class: $@" if $@;
63   my %opt = @_;
64
65   my $locale = $opt{'locale'} || '';
66   my $conf = FS::Conf->new(locale => $locale);
67   $locale ||= $conf->config('locale') || 'en_US';
68
69   my %locale_info = FS::Locales->locale_info($locale);
70   my $language_name = $locale_info{'name'};
71
72   my $self = { conf => FS::Conf->new(locale => $locale),
73                csv  => Text::CSV_XS->new({ binary => 1 }),
74                inbound  => ($opt{'inbound'} ? 1 : 0),
75                buffer   => ($opt{'buffer'} || []),
76                _lh      => FS::L10N->get_handle($locale),
77                _dh      => eval { Date::Language->new($language_name) } ||
78                            Date::Language->new()
79              };
80   bless $self, $class;
81 }
82
83 =back
84
85 =head1 METHODS
86
87 =over 4
88
89 =item inbound VALUE
90
91 Set/get the 'inbound' flag.
92
93 =cut
94
95 sub inbound {
96   my $self = shift;
97   $self->{inbound} = ($_[0] > 0) if (@_);
98   $self->{inbound};
99 }
100
101 =item phonenum VALUE
102
103 Set/get the locally meaningful phone number.  This is used to tag call details
104 for presentation on certain kinds of invoices.
105
106 =cut
107
108 sub phonenum {
109   my $self = shift;
110   $self->{phonenum} = shift if @_;
111   $self->{phonenum};
112 }
113
114 =item append CDRS
115
116 Takes any number of call detail records (as L<FS::cdr> objects),
117 formats them, and appends them to the internal buffer.
118
119 By default, this simply calls C<single_detail> on each CDR in the 
120 set.  Subclasses should override C<append> and maybe C<finish> if 
121 they do not produce detail lines from CDRs in a 1:1 fashion.
122
123 The 'billpkgnum', 'invnum', 'pkgnum', and 'phonenum' fields will 
124 be set later.
125
126 =cut
127
128 sub append {
129   my $self = shift;
130   foreach (@_) {
131     push @{ $self->{buffer} }, $self->single_detail($_);
132   }
133 }
134
135 =item details
136
137 Returns all invoice detail records in the buffer.  This will perform 
138 a C<finish> first.  Subclasses generally shouldn't override this.
139
140 =cut
141
142 sub details {
143   my $self = shift;
144   $self->finish;
145   @{ $self->{buffer} }
146 }
147
148 =item finish
149
150 Ensures that all invoice details are generated given the CDRs that 
151 have been appended.  By default, this does nothing.
152
153 =cut
154
155 sub finish {}
156
157 =item header
158
159 Returns a header row for the format, as an L<FS::cust_bill_pkg_detail>
160 object.  By default this has 'format' = 'C', 'detail' = the value 
161 returned by C<header_detail>, and all other fields empty.
162
163 This is called after C<finish>, so it can use information from the CDRs.
164
165 =cut
166
167 sub header {
168   my $self = shift;
169
170   FS::cust_bill_pkg_detail->new(
171     { 'format' => 'C', 'detail' => $self->mt($self->header_detail) }
172   )
173 }
174
175 =item single_detail CDR
176
177 Takes a single CDR and returns an invoice detail to describe it.
178
179 By default, this maps the following fields from the CDR:
180
181 acctid            => acctid
182 rated_price       => amount
183 rated_classnum    => classnum
184 rated_seconds     => duration
185 rated_regionname  => regionname
186 accountcode       => accountcode
187 startdate         => startdate
188
189 'phonenum' is set to the internal C<phonenum> value set on the formatter
190 object.
191
192 It then calls C<columns> on the CDR to obtain a list of detail
193 columns, formats them as a CSV string, and stores that in the 
194 'detail' field.
195
196 =cut
197
198 sub single_detail {
199   my $self = shift;
200   my $cdr = shift;
201
202   my @columns = $self->columns($cdr);
203   my $status = $self->csv->combine(@columns);
204   die "$me error combining ".$self->csv->error_input."\n"
205     if !$status;
206
207   my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
208   my $price = $object->rated_price if $object;
209   $price = 0 if $cdr->freesidestatus eq 'no-charge';
210
211   FS::cust_bill_pkg_detail->new( {
212       'acctid'      => $cdr->acctid,
213       'amount'      => $price,
214       'classnum'    => $cdr->rated_classnum,
215       'duration'    => $cdr->rated_seconds,
216       'regionname'  => $cdr->rated_regionname,
217       'accountcode' => $cdr->accountcode,
218       'startdate'   => $cdr->startdate,
219       'format'      => 'C',
220       'detail'      => $self->csv->string,
221       'phonenum'    => $self->phonenum,
222   });
223 }
224
225 =item columns CDR
226
227 Returns a list of CSV columns (to be shown on the invoice) for
228 the CDR.  This is the method most subclasses should override.
229
230 =cut
231
232 sub columns {
233   my $self = shift;
234   die "$me no columns method in ".ref($self);
235 }
236
237 =item header_detail
238
239 Returns the 'detail' field for the header row.  This should 
240 probably be a CSV string of column headers for the values returned
241 by C<columns>.
242
243 =cut
244
245 sub header_detail {
246   my $self = shift;
247   die "$me no header_detail method in ".ref($self);
248 }
249
250 # convenience methods for subclasses
251
252 sub conf { $_[0]->{conf} }
253
254 sub csv { $_[0]->{csv} }
255
256 sub date_format {
257   my $self = shift;
258   $self->{date_format} ||= ($self->conf->config('date_format') || '%m/%d/%Y');
259 }
260
261 sub money_char {
262   my $self = shift;
263   $self->{money_char} ||= ($self->conf->config('money_char') || '$');
264 }
265
266 # localization methods
267
268 sub time2str_local {
269   my $self = shift;
270   $self->{_dh}->time2str(@_);
271 }
272
273 sub mt {
274   my $self = shift;
275   $self->{_lh}->maketext(@_);
276 }
277
278 #imitate previous behavior for now
279
280 sub duration {
281   my $self = shift;
282   my $cdr = shift;
283   my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
284   my $sec = $object->rated_seconds if $object;
285   $sec ||= 0;
286   # XXX termination objects don't have rated_granularity so this may 
287   # result in inbound CDRs being displayed as min/sec when they shouldn't.
288   # Should probably fix this.
289   if ( $cdr->rated_granularity eq '0' ) {
290     '1 call';
291   }
292   elsif ( $cdr->rated_granularity eq '60' ) {
293     sprintf('%dm', ($sec + 59)/60);
294   }
295   else {
296     sprintf('%dm %ds', $sec / 60, $sec % 60);
297   }
298 }
299
300 sub price {
301   my $self = shift;
302   my $cdr = shift;
303   my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
304   my $price = $object->rated_price if $object;
305   $price = '0.00' if $object->freesidestatus eq 'no-charge';
306   length($price) ? $self->money_char . $price : '';
307 }
308
309 1;