event action to send a notice to a fixed address, RT#8209
[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, cust_pay, or cust_pay_pending object).
170
171 =item to
172
173 Destination address.  The default is to use the customer's 
174 invoicing_list addresses.
175
176 =back
177
178 =cut
179
180 sub prepare {
181   my( $self, %opt ) = @_;
182
183   my $cust_main = $opt{'cust_main'};
184   my $object = $opt{'object'};
185   warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n"
186     if($DEBUG);
187
188   my $subs = $self->substitutions;
189
190   ###
191   # create substitution table
192   ###  
193   my %hash;
194   foreach my $obj ($cust_main, $object || ()) {
195     foreach my $name (@{ $subs->{$obj->table} }) {
196       if(!ref($name)) {
197         # simple case
198         $hash{$name} = $obj->$name();
199       }
200       elsif( ref($name) eq 'ARRAY' ) {
201         # [ foo => sub { ... } ]
202         $hash{$name->[0]} = $name->[1]->($obj);
203       }
204       else {
205         warn "bad msg_template substitution: '$name'\n";
206         #skip it?
207       } 
208     } 
209   } 
210   $_ = encode_entities($_) foreach values(%hash);
211
212
213   ###
214   # clean up template
215   ###
216   my $subject_tmpl = new Text::Template (
217     TYPE   => 'STRING',
218     SOURCE => $self->subject,
219   );
220   my $subject = $subject_tmpl->fill_in( HASH => \%hash );
221
222   my $body = $self->body;
223   my ($skin, $guts) = eviscerate($body);
224   @$guts = map { 
225     $_ = decode_entities($_); # turn all punctuation back into itself
226     s/\r//gs;           # remove \r's
227     s/<br[^>]*>/\n/gsi; # and <br /> tags
228     s/<p>/\n/gsi;       # and <p>
229     s/<\/p>//gsi;       # and </p>
230     s/\240/ /gs;        # and &nbsp;
231     $_
232   } @$guts;
233   
234   $body = '{ use Date::Format qw(time2str); "" }';
235   while(@$skin || @$guts) {
236     $body .= shift(@$skin) || '';
237     $body .= shift(@$guts) || '';
238   }
239
240   ###
241   # fill-in
242   ###
243
244   my $body_tmpl = new Text::Template (
245     TYPE          => 'STRING',
246     SOURCE        => $body,
247   );
248
249   $body = $body_tmpl->fill_in( HASH => \%hash );
250
251   ###
252   # and email
253   ###
254
255   my @to = ($opt{'to'}) || $cust_main->invoicing_list_emailonly;
256   warn "prepared msg_template with no email destination (custnum ".
257     $cust_main->custnum.")\n"
258     if !@to;
259
260   my $conf = new FS::Conf;
261
262   (
263     'from' => $self->from_addr || 
264               scalar( $conf->config('invoice_from', $cust_main->agentnum) ),
265     'to'   => \@to,
266     'bcc'  => $self->bcc_addr || undef,
267     'subject'   => $subject,
268     'html_body' => $body,
269     'text_body' => HTML::FormatText->new(leftmargin => 0, rightmargin => 70
270                     )->format( HTML::TreeBuilder->new_from_content($body) ),
271   );
272
273 }
274
275 =item send OPTION => VALUE
276
277 Fills in the template and sends it to the customer.  Options are as for 
278 'prepare'.
279
280 =cut
281
282 # broken out from prepare() in case we want to queue the sending,
283 # preview it, etc.
284 sub send {
285   my $self = shift;
286   send_email(generate_email($self->prepare(@_)));
287 }
288
289 # helper sub for package dates
290 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
291
292 # needed for some things
293 my $conf = new FS::Conf;
294
295 #return contexts and fill-in values
296 # If you add anything, be sure to add a description in 
297 # httemplate/edit/msg_template.html.
298 sub substitutions {
299   { 'cust_main' => [qw(
300       display_custnum agentnum agent_name
301
302       last first company
303       name name_short contact contact_firstlast
304       address1 address2 city county state zip
305       country
306       daytime night fax
307
308       has_ship_address
309       ship_last ship_first ship_company
310       ship_name ship_name_short ship_contact ship_contact_firstlast
311       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
312       ship_country
313       ship_daytime ship_night ship_fax
314
315       paymask payname paytype payip
316       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
317       classname categoryname
318       balance
319       credit_limit
320       invoicing_list_emailonly
321       cust_status ucfirst_cust_status cust_statuscolor
322
323       signupdate dundate
324       expdate
325       packages recurdates
326       ),
327       # expdate is a special case
328       [ signupdate_ymd    => sub { time2str('%Y-%m-%d', shift->signupdate) } ],
329       [ dundate_ymd       => sub { time2str('%Y-%m-%d', shift->dundate) } ],
330       [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
331       [ otaker_first      => sub { shift->access_user->first } ],
332       [ otaker_last       => sub { shift->access_user->last } ],
333       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
334       [ company_name      => sub { 
335           $conf->config('company_name', shift->agentnum) 
336         } ],
337       [ company_address   => sub {
338           $conf->config('company_address', shift->agentnum)
339         } ],
340     ],
341     # next_bill_date
342     'cust_pkg'  => [qw( 
343       pkgnum pkg_label pkg_label_long
344       location_label
345       status statuscolor
346     
347       start_date setup bill last_bill 
348       adjourn susp expire 
349       labels_short
350       ),
351       [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
352       [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
353       [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
354       [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
355       [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
356       [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
357       [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
358       [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
359       [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
360     ],
361     'cust_bill' => [qw(
362       invnum
363       _date
364     )],
365     #XXX not really thinking about cust_bill substitutions quite yet
366     
367     # for welcome and limit warning messages
368     'svc_acct' => [qw(
369       username
370       ),
371       [ password          => sub { shift->getfield('_password') } ],
372     ],
373     # for payment receipts
374     'cust_pay' => [qw(
375       paynum
376       _date
377       ),
378       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
379       # overrides the one in cust_main in cases where a cust_pay is passed
380       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
381       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
382       [ payinfo           => sub { 
383           my $cust_pay = shift;
384           ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
385             $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
386         } ],
387     ],
388     # for payment decline messages
389     # try to support all cust_pay fields
390     # 'error' is a special case, it contains the raw error from the gateway
391     'cust_pay_pending' => [qw(
392       _date
393       error
394       ),
395       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
396       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
397       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
398       [ payinfo           => sub {
399           my $pending = shift;
400           ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
401             $pending->paymask : $pending->decrypt($pending->payinfo)
402         } ],
403     ],
404   };
405 }
406
407 sub _upgrade_data {
408   my ($self, %opts) = @_;
409
410   my @fixes = (
411     [ 'alerter_msgnum',  'alerter_template',   '',               '', '' ],
412     [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '', '' ],
413     [ 'decline_msgnum',  'declinetemplate',    '',               '', '' ],
414     [ 'impending_recur_msgnum', 'impending_recur_template', '',  '', 'impending_recur_bcc' ],
415     [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '', '' ],
416     [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from', '' ],
417     [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from', '' ],
418   );
419  
420   my $conf = new FS::Conf;
421   my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
422   foreach my $agentnum (@agentnums) {
423     foreach (@fixes) {
424       my ($newname, $oldname, $subject, $from, $bcc) = @$_;
425       if ($conf->exists($oldname, $agentnum)) {
426         my $new = new FS::msg_template({
427            'msgname'   => $oldname,
428            'agentnum'  => $agentnum,
429            'from_addr' => ($from && $conf->config($from, $agentnum)) || 
430                           $conf->config('invoice_from', $agentnum),
431            'bcc_addr'  => ($bcc && $conf->config($from, $agentnum)) || '',
432            'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
433            'mime_type' => 'text/html',
434            'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
435         });
436         my $error = $new->insert;
437         die $error if $error;
438         $conf->set($newname, $new->msgnum, $agentnum);
439         $conf->delete($oldname, $agentnum);
440         $conf->delete($from, $agentnum) if $from;
441         $conf->delete($subject, $agentnum) if $subject;
442       }
443     }
444   }
445 }
446
447 sub eviscerate {
448   # Every bit as pleasant as it sounds.
449   #
450   # We do this because Text::Template::Preprocess doesn't
451   # actually work.  It runs the entire template through 
452   # the preprocessor, instead of the code segments.  Which 
453   # is a shame, because Text::Template already contains
454   # the code to do this operation.
455   my $body = shift;
456   my (@outside, @inside);
457   my $depth = 0;
458   my $chunk = '';
459   while($body || $chunk) {
460     my ($first, $delim, $rest);
461     # put all leading non-delimiters into $first
462     ($first, $rest) =
463         ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
464     $chunk .= $first;
465     # put a leading delimiter into $delim if there is one
466     ($delim, $rest) =
467       ($rest =~ /^([{}]?)(.*)$/s);
468
469     if( $delim eq '{' ) {
470       $chunk .= '{';
471       if( $depth == 0 ) {
472         push @outside, $chunk;
473         $chunk = '';
474       }
475       $depth++;
476     }
477     elsif( $delim eq '}' ) {
478       $depth--;
479       if( $depth == 0 ) {
480         push @inside, $chunk;
481         $chunk = '';
482       }
483       $chunk .= '}';
484     }
485     else {
486       # no more delimiters
487       if( $depth == 0 ) {
488         push @outside, $chunk . $rest;
489       } # else ? something wrong
490       last;
491     }
492     $body = $rest;
493   }
494   (\@outside, \@inside);
495 }
496
497 =back
498
499 =head1 BUGS
500
501 =head1 SEE ALSO
502
503 L<FS::Record>, schema.html from the base documentation.
504
505 =cut
506
507 1;
508