payment receipts use msg_template, RT#9060
[freeside.git] / FS / FS / msg_template.pm
1 package FS::msg_template;
2
3 use strict;
4 use base qw( FS::Record );
5 use Text::Template;
6 use FS::Misc qw( generate_email send_email );
7 use FS::Conf;
8 use FS::Record qw( qsearch qsearchs );
9
10 use Date::Format qw( time2str );
11 use HTML::Entities qw( decode_entities encode_entities ) ;
12 use HTML::FormatText;
13 use HTML::TreeBuilder;
14 use vars '$DEBUG';
15
16 $DEBUG=0;
17
18 =head1 NAME
19
20 FS::msg_template - Object methods for msg_template records
21
22 =head1 SYNOPSIS
23
24   use FS::msg_template;
25
26   $record = new FS::msg_template \%hash;
27   $record = new FS::msg_template { 'column' => 'value' };
28
29   $error = $record->insert;
30
31   $error = $new_record->replace($old_record);
32
33   $error = $record->delete;
34
35   $error = $record->check;
36
37 =head1 DESCRIPTION
38
39 An FS::msg_template object represents a customer message template.
40 FS::msg_template inherits from FS::Record.  The following fields are currently
41 supported:
42
43 =over 4
44
45 =item msgnum
46
47 primary key
48
49 =item msgname
50
51 Template name.
52
53 =item agentnum
54
55 Agent associated with this template.  Can be NULL for a global template.
56
57 =item mime_type
58
59 MIME type.  Defaults to text/html.
60
61 =item from_addr
62
63 Source email address.
64
65 =item subject
66
67 The message subject line, in L<Text::Template> format.
68
69 =item body
70
71 The message body, as plain text or HTML, in L<Text::Template> format.
72
73 =item disabled
74
75 disabled
76
77 =back
78
79 =head1 METHODS
80
81 =over 4
82
83 =item new HASHREF
84
85 Creates a new template.  To add the template to the database, see L<"insert">.
86
87 Note that this stores the hash reference, not a distinct copy of the hash it
88 points to.  You can ask the object for a copy with the I<hash> method.
89
90 =cut
91
92 # the new method can be inherited from FS::Record, if a table method is defined
93
94 sub table { 'msg_template'; }
95
96 =item insert
97
98 Adds this record to the database.  If there is an error, returns the error,
99 otherwise returns false.
100
101 =cut
102
103 # the insert method can be inherited from FS::Record
104
105 =item delete
106
107 Delete this record from the database.
108
109 =cut
110
111 # the delete method can be inherited from FS::Record
112
113 =item replace OLD_RECORD
114
115 Replaces the OLD_RECORD with this one in the database.  If there is an error,
116 returns the error, otherwise returns false.
117
118 =cut
119
120 # the replace method can be inherited from FS::Record
121
122 =item check
123
124 Checks all fields to make sure this is a valid template.  If there is
125 an error, returns the error, otherwise returns false.  Called by the insert
126 and replace methods.
127
128 =cut
129
130 # the check method should currently be supplied - FS::Record contains some
131 # data checking routines
132
133 sub check {
134   my $self = shift;
135
136   my $error = 
137     $self->ut_numbern('msgnum')
138     || $self->ut_text('msgname')
139     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
140     || $self->ut_textn('mime_type')
141     || $self->ut_anything('subject')
142     || $self->ut_anything('body')
143     || $self->ut_enum('disabled', [ '', 'Y' ] )
144     || $self->ut_textn('from_addr')
145   ;
146   return $error if $error;
147
148   $self->mime_type('text/html') unless $self->mime_type;
149
150   $self->SUPER::check;
151 }
152
153 =item prepare OPTION => VALUE
154
155 Fills in the template and returns a hash of the 'from' address, 'to' 
156 addresses, subject line, and body.
157
158 Options are passed as a list of name/value pairs:
159
160 =over 4
161
162 =item cust_main
163
164 Customer object (required).
165
166 =item object
167
168 Additional context object (currently, can be a cust_main, cust_pkg, 
169 cust_bill, svc_acct, or cust_pay object).
170
171 =back
172
173 =cut
174
175 sub prepare {
176   my( $self, %opt ) = @_;
177
178   my $cust_main = $opt{'cust_main'};
179   my $object = $opt{'object'};
180   warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n"
181     if($DEBUG);
182
183   my $subs = $self->substitutions;
184
185   ###
186   # create substitution table
187   ###  
188   my %hash;
189   foreach my $obj ($cust_main, $object || ()) {
190     foreach my $name (@{ $subs->{$obj->table} }) {
191       if(!ref($name)) {
192         # simple case
193         $hash{$name} = $obj->$name();
194       }
195       elsif( ref($name) eq 'ARRAY' ) {
196         # [ foo => sub { ... } ]
197         $hash{$name->[0]} = $name->[1]->($obj);
198       }
199       else {
200         warn "bad msg_template substitution: '$name'\n";
201         #skip it?
202       } 
203     } 
204   } 
205   $_ = encode_entities($_) foreach values(%hash);
206
207
208   ###
209   # clean up template
210   ###
211   my $subject_tmpl = new Text::Template (
212     TYPE   => 'STRING',
213     SOURCE => $self->subject,
214   );
215   my $subject = $subject_tmpl->fill_in( HASH => \%hash );
216
217   my $body = $self->body;
218   my ($skin, $guts) = eviscerate($body);
219   @$guts = map { 
220     $_ = decode_entities($_); # turn all punctuation back into itself
221     s/\r//gs;           # remove \r's
222     s/<br[^>]*>/\n/gsi; # and <br /> tags
223     s/<p>/\n/gsi;       # and <p>
224     s/<\/p>//gsi;       # and </p>
225     s/\240/ /gs;        # and &nbsp;
226     $_
227   } @$guts;
228   
229   $body = '';
230   while(@$skin || @$guts) {
231     $body .= shift(@$skin) || '';
232     $body .= shift(@$guts) || '';
233   }
234
235   ###
236   # fill-in
237   ###
238
239   my $body_tmpl = new Text::Template (
240     TYPE          => 'STRING',
241     SOURCE        => $body,
242   );
243
244   $body = $body_tmpl->fill_in( HASH => \%hash );
245
246   ###
247   # and email
248   ###
249
250   my @to = $cust_main->invoicing_list_emailonly;
251   warn "prepared msg_template with no email destination (custnum ".
252     $cust_main->custnum.")\n"
253     if !@to;
254
255   my $conf = new FS::Conf;
256
257   (
258     'from' => $self->from || 
259               scalar( $conf->config('invoice_from', $cust_main->agentnum) ),
260     'to'   => \@to,
261     'subject'   => $subject,
262     'html_body' => $body,
263     'text_body' => HTML::FormatText->new(leftmargin => 0, rightmargin => 70
264                     )->format( HTML::TreeBuilder->new_from_content($body) ),
265   );
266
267 }
268
269 =item send OPTION => VALUE
270
271 Fills in the template and sends it to the customer.  Options are as for 
272 'prepare'.
273
274 =cut
275
276 # broken out from prepare() in case we want to queue the sending,
277 # preview it, etc.
278 sub send {
279   my $self = shift;
280   send_email(generate_email($self->prepare(@_)));
281 }
282
283 # helper sub for package dates
284 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
285
286 # needed for some things
287 my $conf = new FS::Conf;
288
289 #return contexts and fill-in values
290 # If you add anything, be sure to add a description in 
291 # httemplate/edit/msg_template.html.
292 sub substitutions {
293   { 'cust_main' => [qw(
294       display_custnum agentnum agent_name
295
296       last first company
297       name name_short contact contact_firstlast
298       address1 address2 city county state zip
299       country
300       daytime night fax
301
302       has_ship_address
303       ship_last ship_first ship_company
304       ship_name ship_name_short ship_contact ship_contact_firstlast
305       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
306       ship_country
307       ship_daytime ship_night ship_fax
308
309       paymask payname paytype payip
310       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
311       classname categoryname
312       balance
313       invoicing_list_emailonly
314       cust_status ucfirst_cust_status cust_statuscolor
315
316       signupdate dundate
317       ),
318       [ signupdate_ymd    => sub { time2str('%Y-%m-%d', shift->signupdate) } ],
319       [ dundate_ymd       => sub { time2str('%Y-%m-%d', shift->dundate) } ],
320       [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
321       [ otaker_first      => sub { shift->access_user->first } ],
322       [ otaker_last       => sub { shift->access_user->last } ],
323       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
324       [ company_name      => sub { 
325           $conf->config('company_name', shift->agentnum) 
326         } ],
327     ],
328     # next_bill_date
329     'cust_pkg'  => [qw( 
330       pkgnum pkg_label pkg_label_long
331       location_label
332       status statuscolor
333     
334       start_date setup bill last_bill 
335       adjourn susp expire 
336       labels_short
337       ),
338       [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
339       [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
340       [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
341       [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
342       [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
343       [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
344       [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
345       [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
346       [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
347     ],
348     'cust_bill' => [qw(
349       invnum
350       _date
351     )],
352     #XXX not really thinking about cust_bill substitutions quite yet
353     
354     'svc_acct' => [qw(
355       username
356       ),
357       [ password          => sub { shift->getfield('_password') } ],
358     ], # for welcome messages
359     'cust_pay' => [qw(
360       paynum
361       _date
362       ),
363       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
364       # overrides the one in cust_main in cases where a cust_pay is passed
365       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
366       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
367       [ payinfo           => sub { 
368           my $cust_pay = shift;
369           ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
370             $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
371         } ],
372     ],
373   };
374 }
375
376 sub _upgrade_data {
377   my ($self, %opts) = @_;
378
379   my @fixes = (
380     [ 'alerter_msgnum',  'alerter_template',   '',               '' ],
381     [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '' ],
382     [ 'decline_msgnum',  'declinetemplate',    '',               '' ],
383     [ 'impending_recur_msgnum', 'impending_recur_template', '',  '' ],
384     [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '' ],
385     [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from' ],
386     [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from' ],
387   );
388  
389   my $conf = new FS::Conf;
390   my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
391   foreach my $agentnum (@agentnums) {
392     foreach (@fixes) {
393       my ($newname, $oldname, $subject, $from) = @$_;
394       if ($conf->exists($oldname, $agentnum)) {
395         my $new = new FS::msg_template({
396            'msgname'   => $oldname,
397            'agentnum'  => $agentnum,
398            'from_addr' => ($from && $conf->config($from, $agentnum)) || 
399                           $conf->config('invoice_from', $agentnum),
400            'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
401            'mime_type' => 'text/html',
402            'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
403         });
404         my $error = $new->insert;
405         die $error if $error;
406         $conf->set($newname, $new->msgnum, $agentnum);
407         $conf->delete($oldname, $agentnum);
408         $conf->delete($from, $agentnum) if $from;
409         $conf->delete($subject, $agentnum) if $subject;
410       }
411     }
412   }
413 }
414
415 sub eviscerate {
416   # Every bit as pleasant as it sounds.
417   #
418   # We do this because Text::Template::Preprocess doesn't
419   # actually work.  It runs the entire template through 
420   # the preprocessor, instead of the code segments.  Which 
421   # is a shame, because Text::Template already contains
422   # the code to do this operation.
423   my $body = shift;
424   my (@outside, @inside);
425   my $depth = 0;
426   my $chunk = '';
427   while($body || $chunk) {
428     # put all leading non-delimiters into $first
429     my ($first, $rest) =
430         ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
431     $chunk .= $first;
432     # put a leading delimiter into $delim if there is one
433     my ($delim, $rest) =
434       ($rest =~ /^([{}]?)(.*)$/s);
435
436     if( $delim eq '{' ) {
437       $chunk .= '{';
438       if( $depth == 0 ) {
439         push @outside, $chunk;
440         $chunk = '';
441       }
442       $depth++;
443     }
444     elsif( $delim eq '}' ) {
445       $depth--;
446       if( $depth == 0 ) {
447         push @inside, $chunk;
448         $chunk = '';
449       }
450       $chunk .= '}';
451     }
452     else {
453       # no more delimiters
454       if( $depth == 0 ) {
455         push @outside, $chunk . $rest;
456       } # else ? something wrong
457       last;
458     }
459     $body = $rest;
460   }
461   (\@outside, \@inside);
462 }
463
464 =back
465
466 =head1 BUGS
467
468 =head1 SEE ALSO
469
470 L<FS::Record>, schema.html from the base documentation.
471
472 =cut
473
474 1;
475