allow utf8 characters in CDR details, #28102
[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 
38   a large copy operation at the end of processing.  However, since 
39   summary formats will produce nothing until the end of processing, 
40   C<finish> must be called after all CDRs have been appended.
41
42 - inbound: a flag telling the formatter to format CDRs for display to 
43   the 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 
46   changed with the C<inbound> method.
47
48 - locale: a locale string to use for static text and date formats.  This
49   is 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 =item inbound VALUE
88
89 Set/get the 'inbound' flag.
90
91 =cut
92
93 sub inbound {
94   my $self = shift;
95   $self->{inbound} = ($_[0] > 0) if (@_);
96   $self->{inbound};
97 }
98
99 =item append CDRS
100
101 Takes any number of call detail records (as L<FS::cdr> objects),
102 formats them, and appends them to the internal buffer.
103
104 By default, this simply calls C<single_detail> on each CDR in the 
105 set.  Subclasses should override C<append> and maybe C<finish> if 
106 they do not produce detail lines from CDRs in a 1:1 fashion.
107
108 The 'billpkgnum', 'invnum', 'pkgnum', and 'phonenum' fields will 
109 be set later.
110
111 =cut
112
113 sub append {
114   my $self = shift;
115   foreach (@_) {
116     push @{ $self->{buffer} }, $self->single_detail($_);
117   }
118 }
119
120 =item details
121
122 Returns all invoice detail records in the buffer.  This will perform 
123 a C<finish> first.  Subclasses generally shouldn't override this.
124
125 =cut
126
127 sub details {
128   my $self = shift;
129   $self->finish;
130   @{ $self->{buffer} }
131 }
132
133 =item finish
134
135 Ensures that all invoice details are generated given the CDRs that 
136 have been appended.  By default, this does nothing.
137
138 =cut
139
140 sub finish {}
141
142 =item header
143
144 Returns a header row for the format, as an L<FS::cust_bill_pkg_detail>
145 object.  By default this has 'format' = 'C', 'detail' = the value 
146 returned by C<header_detail>, and all other fields empty.
147
148 This is called after C<finish>, so it can use information from the CDRs.
149
150 =cut
151
152 sub header {
153   my $self = shift;
154
155   FS::cust_bill_pkg_detail->new(
156     { 'format' => 'C', 'detail' => $self->mt($self->header_detail) }
157   )
158 }
159
160 =item single_detail CDR
161
162 Takes a single CDR and returns an invoice detail to describe it.
163
164 By default, this maps the following fields from the CDR:
165
166 rated_price       => amount
167 rated_classnum    => classnum
168 rated_seconds     => duration
169 rated_regionname  => regionname
170 accountcode       => accountcode
171 startdate         => startdate
172
173 It then calls C<columns> on the CDR to obtain a list of detail
174 columns, formats them as a CSV string, and stores that in the 
175 'detail' field.
176
177 =cut
178
179 sub single_detail {
180   my $self = shift;
181   my $cdr = shift;
182
183   my @columns = $self->columns($cdr);
184   my $status = $self->csv->combine(@columns);
185   die "$me error combining ".$self->csv->error_input."\n"
186     if !$status;
187
188   my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
189   my $price = $object->rated_price if $object;
190   $price = 0 if $cdr->freesidestatus eq 'no-charge';
191
192   FS::cust_bill_pkg_detail->new( {
193       'amount'      => $price,
194       'classnum'    => $cdr->rated_classnum,
195       'duration'    => $cdr->rated_seconds,
196       'regionname'  => $cdr->rated_regionname,
197       'accountcode' => $cdr->accountcode,
198       'startdate'   => $cdr->startdate,
199       'format'      => 'C',
200       'detail'      => $self->csv->string,
201   });
202 }
203
204 =item columns CDR
205
206 Returns a list of CSV columns (to be shown on the invoice) for
207 the CDR.  This is the method most subclasses should override.
208
209 =cut
210
211 sub columns {
212   my $self = shift;
213   die "$me no columns method in ".ref($self);
214 }
215
216 =item header_detail
217
218 Returns the 'detail' field for the header row.  This should 
219 probably be a CSV string of column headers for the values returned
220 by C<columns>.
221
222 =cut
223
224 sub header_detail {
225   my $self = shift;
226   die "$me no header_detail method in ".ref($self);
227 }
228
229 # convenience methods for subclasses
230
231 sub conf { $_[0]->{conf} }
232
233 sub csv { $_[0]->{csv} }
234
235 sub date_format {
236   my $self = shift;
237   $self->{date_format} ||= ($self->conf->config('date_format') || '%m/%d/%Y');
238 }
239
240 sub money_char {
241   my $self = shift;
242   $self->{money_char} ||= ($self->conf->config('money_char') || '$');
243 }
244
245 # localization methods
246
247 sub time2str_local {
248   my $self = shift;
249   $self->{_dh}->time2str(@_);
250 }
251
252 sub mt {
253   my $self = shift;
254   $self->{_lh}->maketext(@_);
255 }
256
257 #imitate previous behavior for now
258
259 sub duration {
260   my $self = shift;
261   my $cdr = shift;
262   my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
263   my $sec = $object->rated_seconds if $object;
264   $sec ||= 0;
265   # XXX termination objects don't have rated_granularity so this may 
266   # result in inbound CDRs being displayed as min/sec when they shouldn't.
267   # Should probably fix this.
268   if ( $cdr->rated_granularity eq '0' ) {
269     '1 call';
270   }
271   elsif ( $cdr->rated_granularity eq '60' ) {
272     sprintf('%dm', ($sec + 59)/60);
273   }
274   else {
275     sprintf('%dm %ds', $sec / 60, $sec % 60);
276   }
277 }
278
279 sub price {
280   my $self = shift;
281   my $cdr = shift;
282   my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
283   my $price = $object->rated_price if $object;
284   $price = '0.00' if $object->freesidestatus eq 'no-charge';
285   length($price) ? $self->money_char . $price : '';
286 }
287
288 1;