credit card expiration event, #13202
[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 FS::cust_main;
11 use FS::cust_msg;
12
13 use Date::Format qw( time2str );
14 use HTML::Entities qw( decode_entities encode_entities ) ;
15 use HTML::FormatText;
16 use HTML::TreeBuilder;
17 use vars qw( $DEBUG $conf );
18
19 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
20
21 $DEBUG=0;
22
23 =head1 NAME
24
25 FS::msg_template - Object methods for msg_template records
26
27 =head1 SYNOPSIS
28
29   use FS::msg_template;
30
31   $record = new FS::msg_template \%hash;
32   $record = new FS::msg_template { 'column' => 'value' };
33
34   $error = $record->insert;
35
36   $error = $new_record->replace($old_record);
37
38   $error = $record->delete;
39
40   $error = $record->check;
41
42 =head1 DESCRIPTION
43
44 An FS::msg_template object represents a customer message template.
45 FS::msg_template inherits from FS::Record.  The following fields are currently
46 supported:
47
48 =over 4
49
50 =item msgnum
51
52 primary key
53
54 =item msgname
55
56 Template name.
57
58 =item agentnum
59
60 Agent associated with this template.  Can be NULL for a global template.
61
62 =item mime_type
63
64 MIME type.  Defaults to text/html.
65
66 =item from_addr
67
68 Source email address.
69
70 =item subject
71
72 The message subject line, in L<Text::Template> format.
73
74 =item body
75
76 The message body, as plain text or HTML, in L<Text::Template> format.
77
78 =item disabled
79
80 disabled
81
82 =back
83
84 =head1 METHODS
85
86 =over 4
87
88 =item new HASHREF
89
90 Creates a new template.  To add the template to the database, see L<"insert">.
91
92 Note that this stores the hash reference, not a distinct copy of the hash it
93 points to.  You can ask the object for a copy with the I<hash> method.
94
95 =cut
96
97 # the new method can be inherited from FS::Record, if a table method is defined
98
99 sub table { 'msg_template'; }
100
101 =item insert
102
103 Adds this record to the database.  If there is an error, returns the error,
104 otherwise returns false.
105
106 =cut
107
108 # the insert method can be inherited from FS::Record
109
110 =item delete
111
112 Delete this record from the database.
113
114 =cut
115
116 # the delete method can be inherited from FS::Record
117
118 =item replace OLD_RECORD
119
120 Replaces the OLD_RECORD with this one in the database.  If there is an error,
121 returns the error, otherwise returns false.
122
123 =cut
124
125 # the replace method can be inherited from FS::Record
126
127 =item check
128
129 Checks all fields to make sure this is a valid template.  If there is
130 an error, returns the error, otherwise returns false.  Called by the insert
131 and replace methods.
132
133 =cut
134
135 # the check method should currently be supplied - FS::Record contains some
136 # data checking routines
137
138 sub check {
139   my $self = shift;
140
141   my $error = 
142     $self->ut_numbern('msgnum')
143     || $self->ut_text('msgname')
144     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
145     || $self->ut_textn('mime_type')
146     || $self->ut_anything('subject')
147     || $self->ut_anything('body')
148     || $self->ut_enum('disabled', [ '', 'Y' ] )
149     || $self->ut_textn('from_addr')
150   ;
151   return $error if $error;
152
153   $self->mime_type('text/html') unless $self->mime_type;
154
155   $self->SUPER::check;
156 }
157
158 =item prepare OPTION => VALUE
159
160 Fills in the template and returns a hash of the 'from' address, 'to' 
161 addresses, subject line, and body.
162
163 Options are passed as a list of name/value pairs:
164
165 =over 4
166
167 =item cust_main
168
169 Customer object (required).
170
171 =item object
172
173 Additional context object (currently, can be a cust_main, cust_pkg, 
174 cust_bill, cust_pay, cust_pay_pending, or svc_(acct, phone, broadband, 
175 domain) ).  If the object is a svc_*, its cust_pkg will be fetched and 
176 used for substitution.
177
178 As a special case, this may be an arrayref of two objects.  Both 
179 objects will be available for substitution, with their field names 
180 prefixed with 'new_' and 'old_' respectively.  This is used in the 
181 rt_ticket export when exporting "replace" events.
182
183 =item from_config
184
185 Configuration option to use as the source address, based on the customer's 
186 agentnum.  If unspecified (or the named option is empty), 'invoice_from' 
187 will be used.
188
189 The I<from_addr> field in the template takes precedence over this.
190
191 =item to
192
193 Destination address.  The default is to use the customer's 
194 invoicing_list addresses.  Multiple addresses may be comma-separated.
195
196 =back
197
198 =cut
199
200 sub prepare {
201   my( $self, %opt ) = @_;
202
203   my $cust_main = $opt{'cust_main'};
204   my $object = $opt{'object'};
205   warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n"
206     if($DEBUG);
207
208   my $subs = $self->substitutions;
209
210   ###
211   # create substitution table
212   ###  
213   my %hash;
214   my @objects = ($cust_main);
215   my @prefixes = ('');
216   my $svc;
217   if( ref $object ) {
218     if( ref($object) eq 'ARRAY' ) {
219       # [new, old], for provisioning tickets
220       push @objects, $object->[0], $object->[1];
221       push @prefixes, 'new_', 'old_';
222       $svc = $object->[0] if $object->[0]->isa('FS::svc_Common');
223     }
224     else {
225       push @objects, $object;
226       push @prefixes, '';
227       $svc = $object if $object->isa('FS::svc_Common');
228     }
229   }
230   if( $svc ) {
231     push @objects, $svc->cust_svc->cust_pkg;
232     push @prefixes, '';
233   }
234
235   foreach my $obj (@objects) {
236     my $prefix = shift @prefixes;
237     foreach my $name (@{ $subs->{$obj->table} }) {
238       if(!ref($name)) {
239         # simple case
240         $hash{$prefix.$name} = $obj->$name();
241       }
242       elsif( ref($name) eq 'ARRAY' ) {
243         # [ foo => sub { ... } ]
244         $hash{$prefix.($name->[0])} = $name->[1]->($obj);
245       }
246       else {
247         warn "bad msg_template substitution: '$name'\n";
248         #skip it?
249       } 
250     } 
251   } 
252   $_ = encode_entities($_ || '') foreach values(%hash);
253
254
255   ###
256   # clean up template
257   ###
258   my $subject_tmpl = new Text::Template (
259     TYPE   => 'STRING',
260     SOURCE => $self->subject,
261   );
262   my $subject = $subject_tmpl->fill_in( HASH => \%hash );
263
264   my $body = $self->body;
265   my ($skin, $guts) = eviscerate($body);
266   @$guts = map { 
267     $_ = decode_entities($_); # turn all punctuation back into itself
268     s/\r//gs;           # remove \r's
269     s/<br[^>]*>/\n/gsi; # and <br /> tags
270     s/<p>/\n/gsi;       # and <p>
271     s/<\/p>//gsi;       # and </p>
272     s/\240/ /gs;        # and &nbsp;
273     $_
274   } @$guts;
275   
276   $body = '{ use Date::Format qw(time2str); "" }';
277   while(@$skin || @$guts) {
278     $body .= shift(@$skin) || '';
279     $body .= shift(@$guts) || '';
280   }
281
282   ###
283   # fill-in
284   ###
285
286   my $body_tmpl = new Text::Template (
287     TYPE          => 'STRING',
288     SOURCE        => $body,
289   );
290
291   $body = $body_tmpl->fill_in( HASH => \%hash );
292
293   ###
294   # and email
295   ###
296
297   my @to;
298   if ( exists($opt{'to'}) ) {
299     @to = split(/\s*,\s*/, $opt{'to'});
300   }
301   else {
302     @to = $cust_main->invoicing_list_emailonly;
303   }
304   # no warning when preparing with no destination
305
306   my $from_addr = $self->from_addr;
307
308   if ( !$from_addr ) {
309     if ( $opt{'from_config'} ) {
310       $from_addr = scalar( $conf->config($opt{'from_config'}, 
311                                          $cust_main->agentnum) );
312     }
313     $from_addr ||= scalar( $conf->config('invoice_from',
314                                          $cust_main->agentnum) );
315   }
316 #  my @cust_msg = ();
317 #  if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) {
318 #    my $cust_msg = FS::cust_msg->new({
319 #        'custnum' => $cust_main->custnum,
320 #        'msgnum'  => $self->msgnum,
321 #        'status'  => 'prepared',
322 #      });
323 #    $cust_msg->insert;
324 #    @cust_msg = ('cust_msg' => $cust_msg);
325 #  }
326
327   (
328     'custnum' => $cust_main->custnum,
329     'msgnum'  => $self->msgnum,
330     'from' => $from_addr,
331     'to'   => \@to,
332     'bcc'  => $self->bcc_addr || undef,
333     'subject'   => $subject,
334     'html_body' => $body,
335     'text_body' => HTML::FormatText->new(leftmargin => 0, rightmargin => 70
336                     )->format( HTML::TreeBuilder->new_from_content($body) ),
337   );
338
339 }
340
341 =item send OPTION => VALUE
342
343 Fills in the template and sends it to the customer.  Options are as for 
344 'prepare'.
345
346 =cut
347
348 # broken out from prepare() in case we want to queue the sending,
349 # preview it, etc.
350 sub send {
351   my $self = shift;
352   send_email(generate_email($self->prepare(@_)));
353 }
354
355 # helper sub for package dates
356 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
357
358 #my $conf = new FS::Conf;
359
360 #return contexts and fill-in values
361 # If you add anything, be sure to add a description in 
362 # httemplate/edit/msg_template.html.
363 sub substitutions {
364   { 'cust_main' => [qw(
365       display_custnum agentnum agent_name
366
367       last first company
368       name name_short contact contact_firstlast
369       address1 address2 city county state zip
370       country
371       daytime night fax
372
373       has_ship_address
374       ship_last ship_first ship_company
375       ship_name ship_name_short ship_contact ship_contact_firstlast
376       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
377       ship_country
378       ship_daytime ship_night ship_fax
379
380       paymask payname paytype payip
381       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
382       classname categoryname
383       balance
384       credit_limit
385       invoicing_list_emailonly
386       cust_status ucfirst_cust_status cust_statuscolor
387
388       signupdate dundate
389       packages recurdates
390       ),
391       [ expdate           => sub { shift->paydate_epoch } ], #compatibility
392       [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
393       [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
394       [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
395       [ otaker_first      => sub { shift->access_user->first } ],
396       [ otaker_last       => sub { shift->access_user->last } ],
397       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
398       [ company_name      => sub { 
399           $conf->config('company_name', shift->agentnum) 
400         } ],
401       [ company_address   => sub {
402           $conf->config('company_address', shift->agentnum)
403         } ],
404       [ company_phonenum  => sub {
405           $conf->config('company_phonenum', shift->agentnum)
406         } ],
407     ],
408     # next_bill_date
409     'cust_pkg'  => [qw( 
410       pkgnum pkg_label pkg_label_long
411       location_label
412       status statuscolor
413     
414       start_date setup bill last_bill 
415       adjourn susp expire 
416       labels_short
417       ),
418       [ pkg               => sub { shift->part_pkg->pkg } ],
419       [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
420       [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
421       [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
422       [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
423       [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
424       [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
425       [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
426       [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
427       [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
428     ],
429     'cust_bill' => [qw(
430       invnum
431       _date
432     )],
433     #XXX not really thinking about cust_bill substitutions quite yet
434     
435     # for welcome and limit warning messages
436     'svc_acct' => [qw(
437       svcnum
438       username
439       domain
440       ),
441       [ password          => sub { shift->getfield('_password') } ],
442     ],
443     'svc_domain' => [qw(
444       svcnum
445       domain
446       ),
447       [ registrar         => sub {
448           my $registrar = qsearchs('registrar', 
449             { registrarnum => shift->registrarnum} );
450           $registrar ? $registrar->registrarname : ''
451         }
452       ],
453       [ catchall          => sub { 
454           my $svc_acct = qsearchs('svc_acct', { svcnum => shift->catchall });
455           $svc_acct ? $svc_acct->email : ''
456         }
457       ],
458     ],
459     'svc_phone' => [qw(
460       svcnum
461       phonenum
462       countrycode
463       domain
464       )
465     ],
466     'svc_broadband' => [qw(
467       svcnum
468       speed_up
469       speed_down
470       ip_addr
471       mac_addr
472       )
473     ],
474     # for payment receipts
475     'cust_pay' => [qw(
476       paynum
477       _date
478       ),
479       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
480       # overrides the one in cust_main in cases where a cust_pay is passed
481       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
482       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
483       [ payinfo           => sub { 
484           my $cust_pay = shift;
485           ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
486             $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
487         } ],
488     ],
489     # for payment decline messages
490     # try to support all cust_pay fields
491     # 'error' is a special case, it contains the raw error from the gateway
492     'cust_pay_pending' => [qw(
493       _date
494       error
495       ),
496       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
497       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
498       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
499       [ payinfo           => sub {
500           my $pending = shift;
501           ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
502             $pending->paymask : $pending->decrypt($pending->payinfo)
503         } ],
504     ],
505   };
506 }
507
508 sub _upgrade_data {
509   my ($self, %opts) = @_;
510
511   my @fixes = (
512     [ 'alerter_msgnum',  'alerter_template',   '',               '', '' ],
513     [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '', '' ],
514     [ 'decline_msgnum',  'declinetemplate',    '',               '', '' ],
515     [ 'impending_recur_msgnum', 'impending_recur_template', '',  '', 'impending_recur_bcc' ],
516     [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '', '' ],
517     [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from', '' ],
518     [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from', '' ],
519   );
520  
521   my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
522   foreach my $agentnum (@agentnums) {
523     foreach (@fixes) {
524       my ($newname, $oldname, $subject, $from, $bcc) = @$_;
525       if ($conf->exists($oldname, $agentnum)) {
526         my $new = new FS::msg_template({
527            'msgname'   => $oldname,
528            'agentnum'  => $agentnum,
529            'from_addr' => ($from && $conf->config($from, $agentnum)) || 
530                           $conf->config('invoice_from', $agentnum),
531            'bcc_addr'  => ($bcc && $conf->config($from, $agentnum)) || '',
532            'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
533            'mime_type' => 'text/html',
534            'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
535         });
536         my $error = $new->insert;
537         die $error if $error;
538         $conf->set($newname, $new->msgnum, $agentnum);
539         $conf->delete($oldname, $agentnum);
540         $conf->delete($from, $agentnum) if $from;
541         $conf->delete($subject, $agentnum) if $subject;
542       }
543     }
544   }
545 }
546
547 sub eviscerate {
548   # Every bit as pleasant as it sounds.
549   #
550   # We do this because Text::Template::Preprocess doesn't
551   # actually work.  It runs the entire template through 
552   # the preprocessor, instead of the code segments.  Which 
553   # is a shame, because Text::Template already contains
554   # the code to do this operation.
555   my $body = shift;
556   my (@outside, @inside);
557   my $depth = 0;
558   my $chunk = '';
559   while($body || $chunk) {
560     my ($first, $delim, $rest);
561     # put all leading non-delimiters into $first
562     ($first, $rest) =
563         ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
564     $chunk .= $first;
565     # put a leading delimiter into $delim if there is one
566     ($delim, $rest) =
567       ($rest =~ /^([{}]?)(.*)$/s);
568
569     if( $delim eq '{' ) {
570       $chunk .= '{';
571       if( $depth == 0 ) {
572         push @outside, $chunk;
573         $chunk = '';
574       }
575       $depth++;
576     }
577     elsif( $delim eq '}' ) {
578       $depth--;
579       if( $depth == 0 ) {
580         push @inside, $chunk;
581         $chunk = '';
582       }
583       $chunk .= '}';
584     }
585     else {
586       # no more delimiters
587       if( $depth == 0 ) {
588         push @outside, $chunk . $rest;
589       } # else ? something wrong
590       last;
591     }
592     $body = $rest;
593   }
594   (\@outside, \@inside);
595 }
596
597 =back
598
599 =head1 BUGS
600
601 =head1 SEE ALSO
602
603 L<FS::Record>, schema.html from the base documentation.
604
605 =cut
606
607 1;
608