d7d9f50a858cbd69a4fd0a568ddb457a4efba517
[freeside.git] / FS / FS / msg_template.pm
1 package FS::msg_template;
2 use base qw( FS::Record );
3
4 use strict;
5 use vars qw( $DEBUG $conf );
6
7 use FS::Conf;
8 use FS::Record qw( qsearch qsearchs );
9
10 use FS::cust_msg;
11 use FS::template_content;
12
13 use Date::Format qw(time2str);
14
15 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
16
17 $DEBUG=0;
18
19 =head1 NAME
20
21 FS::msg_template - Object methods for msg_template records
22
23 =head1 SYNOPSIS
24
25   use FS::msg_template;
26
27   $record = new FS::msg_template \%hash;
28   $record = new FS::msg_template { 'column' => 'value' };
29
30   $error = $record->insert;
31
32   $error = $new_record->replace($old_record);
33
34   $error = $record->delete;
35
36   $error = $record->check;
37
38 =head1 DESCRIPTION
39
40 An FS::msg_template object represents a customer message template.
41 FS::msg_template inherits from FS::Record.  The following fields are currently
42 supported:
43
44 =over 4
45
46 =item msgnum - primary key
47
48 =item msgname - Name of the template.  This will appear in the user interface;
49 if it needs to be localized for some users, add it to the message catalog.
50
51 =item msgclass - The L<FS::msg_template> subclass that this should belong to.
52 Defaults to 'email'.
53
54 =item agentnum - Agent associated with this template.  Can be NULL for a 
55 global template.
56
57 =item mime_type - MIME type.  Defaults to text/html.
58
59 =item from_addr - Source email address.
60
61 =item bcc_addr - Bcc all mail to this address.
62
63 =item disabled - disabled ('Y' or NULL).
64
65 =back
66
67 =head1 METHODS
68
69 =over 4
70
71 =item new HASHREF
72
73 Creates a new template.  To add the template to the database, see L<"insert">.
74
75 Note that this stores the hash reference, not a distinct copy of the hash it
76 points to.  You can ask the object for a copy with the I<hash> method.
77
78 =cut
79
80 # the new method can be inherited from FS::Record, if a table method is defined
81
82 sub table { 'msg_template'; }
83
84 sub _rebless {
85   my $self = shift;
86   my $class = 'FS::msg_template::' . $self->msgclass;
87   eval "use $class;";
88   bless($self, $class) unless $@;
89   $self;
90 }
91
92 =item insert [ CONTENT ]
93
94 Adds this record to the database.  If there is an error, returns the error,
95 otherwise returns false.
96
97 # inherited
98
99 =item delete
100
101 Delete this record from the database.
102
103 =cut
104
105 # inherited
106
107 =item replace [ OLD_RECORD ] [ CONTENT ]
108
109 Replaces the OLD_RECORD with this one in the database.  If there is an error,
110 returns the error, otherwise returns false.
111
112 =cut
113
114 # inherited
115
116 sub replace_check {
117   my $self = shift;
118   my $old = $self->replace_old;
119   # don't allow changing msgclass, except null to not-null (for upgrade)
120   if ( $old->msgclass ) {
121     if ( !$self->msgclass ) {
122       $self->set('msgclass', $old->msgclass);
123     } else {
124       return "Can't change message template class from ".$old->msgclass.
125              " to ".$self->msgclass.".";
126     }
127   }
128   '';
129 }
130
131 =item check
132
133 Checks all fields to make sure this is a valid template.  If there is
134 an error, returns the error, otherwise returns false.  Called by the insert
135 and replace methods.
136
137 =cut
138
139 # the check method should currently be supplied - FS::Record contains some
140 # data checking routines
141
142 sub check {
143   my $self = shift;
144
145   my $error = 
146     $self->ut_numbern('msgnum')
147     || $self->ut_text('msgname')
148     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
149     || $self->ut_textn('mime_type')
150     || $self->ut_enum('disabled', [ '', 'Y' ] )
151     || $self->ut_textn('from_addr')
152     || $self->ut_textn('bcc_addr')
153     # fine for now, but change this to some kind of dynamic check if we
154     # ever have more than two msgclasses
155     || $self->ut_enum('msgclass', [ qw(email http) ]),
156   ;
157   return $error if $error;
158
159   $self->mime_type('text/html') unless $self->mime_type;
160
161   $self->SUPER::check;
162 }
163
164 =item prepare OPTION => VALUE
165
166 Fills in the template and returns an L<FS::cust_msg> object, containing the
167 message to be sent.  This method must be provided by the subclass.
168
169 Options are passed as a list of name/value pairs:
170
171 =over 4
172
173 =item cust_main
174
175 Customer object (required).
176
177 =item object
178
179 Additional context object (currently, can be a cust_main, cust_pkg, 
180 cust_bill, cust_pay, cust_pay_pending, or svc_(acct, phone, broadband, 
181 domain) ).  If the object is a svc_*, its cust_pkg will be fetched and 
182 used for substitution.
183
184 As a special case, this may be an arrayref of two objects.  Both 
185 objects will be available for substitution, with their field names 
186 prefixed with 'new_' and 'old_' respectively.  This is used in the 
187 rt_ticket export when exporting "replace" events.
188
189 =item from_config
190
191 Configuration option to use as the source address, based on the customer's 
192 agentnum.  If unspecified (or the named option is empty), 'invoice_from' 
193 will be used.
194
195 The I<from_addr> field in the template takes precedence over this.
196
197 =item to
198
199 Destination address.  The default is to use the customer's 
200 invoicing_list addresses.  Multiple addresses may be comma-separated.
201
202 =item substitutions
203
204 A hash reference of additional substitutions
205
206 =back
207
208 =cut
209
210 sub prepare {
211   die "unimplemented";
212 }
213
214 =item prepare_substitutions OPTION => VALUE ...
215
216 Takes the same arguments as L</prepare>, and returns a hashref of the 
217 substitution variables.
218
219 =cut
220
221 sub prepare_substitutions {
222   my( $self, %opt ) = @_;
223
224   my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
225   my $object = $opt{'object'} or die 'object required';
226
227   warn "preparing substitutions for '".$self->msgname."'\n"
228     if $DEBUG;
229
230   my $subs = $self->substitutions;
231
232   ###
233   # create substitution table
234   ###  
235   my %hash;
236   my @objects = ();
237   push @objects, $cust_main if $cust_main;
238   my @prefixes = ('');
239   my $svc;
240   if( ref $object ) {
241     if( ref($object) eq 'ARRAY' ) {
242       # [new, old], for provisioning tickets
243       push @objects, $object->[0], $object->[1];
244       push @prefixes, 'new_', 'old_';
245       $svc = $object->[0] if $object->[0]->isa('FS::svc_Common');
246     }
247     else {
248       push @objects, $object;
249       push @prefixes, '';
250       $svc = $object if $object->isa('FS::svc_Common');
251     }
252   }
253   if( $svc ) {
254     push @objects, $svc->cust_svc->cust_pkg;
255     push @prefixes, '';
256   }
257
258   foreach my $obj (@objects) {
259     my $prefix = shift @prefixes;
260     foreach my $name (@{ $subs->{$obj->table} }) {
261       if(!ref($name)) {
262         # simple case
263         $hash{$prefix.$name} = $obj->$name();
264       }
265       elsif( ref($name) eq 'ARRAY' ) {
266         # [ foo => sub { ... } ]
267         $hash{$prefix.($name->[0])} = $name->[1]->($obj);
268       }
269       else {
270         warn "bad msg_template substitution: '$name'\n";
271         #skip it?
272       } 
273     } 
274   } 
275
276   if ( $opt{substitutions} ) {
277     $hash{$_} = $opt{substitutions}->{$_} foreach keys %{$opt{substitutions}};
278   }
279
280   return \%hash;
281 }
282
283 =item send OPTION => VALUE ...
284
285 Creates a message with L</prepare> (taking all the same options) and sends it.
286
287 =cut
288
289 sub send {
290   my $self = shift;
291   my $cust_msg = $self->prepare(@_);
292   $self->send_prepared($cust_msg);
293 }
294
295 =item render OPTION => VALUE ...
296
297 Fills in the template and renders it to a PDF document.  Returns the 
298 name of the PDF file.
299
300 Options are as for 'prepare', but 'from' and 'to' are meaningless.
301
302 =cut
303
304 # XXX not sure where this ends up post-refactoring--a separate template
305 # class? it doesn't use the same rendering OR output machinery as ::email
306
307 # will also have options to set paper size, margins, etc.
308
309 sub render {
310   my $self = shift;
311   eval "use PDF::WebKit";
312   die $@ if $@;
313   my %opt = @_;
314   my %hash = $self->prepare(%opt);
315   my $html = $hash{'html_body'};
316
317   # Graphics/stylesheets should probably go in /var/www on the Freeside 
318   # machine.
319   my $script_path = `/usr/bin/which freeside-wkhtmltopdf`;
320   chomp $script_path;
321   my $kit = PDF::WebKit->new(\$html); #%options
322   # hack to use our wrapper script
323   $kit->configure(sub { shift->wkhtmltopdf($script_path) });
324
325   $kit->to_pdf;
326 }
327
328 =item print OPTIONS
329
330 Render a PDF and send it to the printer.  OPTIONS are as for 'render'.
331
332 =cut
333
334 sub print {
335   my( $self, %opt ) = @_;
336   do_print( [ $self->render(%opt) ], agentnum=>$opt{cust_main}->agentnum );
337 }
338
339 # helper sub for package dates
340 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
341
342 # helper sub for money amounts
343 my $money = sub { ($conf->money_char || '$') . sprintf('%.2f', $_[0] || 0) };
344
345 # helper sub for usage-related messages
346 my $usage_warning = sub {
347   my $svc = shift;
348   foreach my $col (qw(seconds upbytes downbytes totalbytes)) {
349     my $amount = $svc->$col; next if $amount eq '';
350     my $method = $col.'_threshold';
351     my $threshold = $svc->$method; next if $threshold eq '';
352     return [$col, $amount, $threshold] if $amount <= $threshold;
353     # this only returns the first one that's below threshold, if there are 
354     # several.
355   }
356   return ['', '', ''];
357 };
358
359 #return contexts and fill-in values
360 # If you add anything, be sure to add a description in 
361 # httemplate/edit/msg_template.html.
362 sub substitutions {
363   { 'cust_main' => [qw(
364       display_custnum agentnum agent_name
365
366       last first company
367       name name_short contact contact_firstlast
368       address1 address2 city county state zip
369       country
370       daytime night mobile fax
371
372       has_ship_address
373       ship_name ship_name_short ship_contact ship_contact_firstlast
374       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
375       ship_country
376
377       paymask payname paytype payip
378       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
379       classname categoryname
380       balance
381       credit_limit
382       invoicing_list_emailonly
383       cust_status ucfirst_cust_status cust_statuscolor cust_status_label
384
385       signupdate dundate
386       packages recurdates
387       ),
388       [ invoicing_email => sub { shift->invoicing_list_emailonly_scalar } ],
389       #compatibility: obsolete ship_ fields - use the non-ship versions
390       map (
391         { my $field = $_;
392           [ "ship_$field"   => sub { shift->$field } ]
393         }
394         qw( last first company daytime night fax )
395       ),
396       # ship_name, ship_name_short, ship_contact, ship_contact_firstlast
397       # still work, though
398       [ expdate           => sub { shift->paydate_epoch } ], #compatibility
399       [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
400       [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
401       [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
402       [ otaker_first      => sub { shift->access_user->first } ],
403       [ otaker_last       => sub { shift->access_user->last } ],
404       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
405       [ company_name      => sub { 
406           $conf->config('company_name', shift->agentnum) 
407         } ],
408       [ company_address   => sub {
409           $conf->config('company_address', shift->agentnum)
410         } ],
411       [ company_phonenum  => sub {
412           $conf->config('company_phonenum', shift->agentnum)
413         } ],
414       [ selfservice_server_base_url => sub { 
415           $conf->config('selfservice_server-base_url') #, shift->agentnum) 
416         } ],
417     ],
418     # next_bill_date
419     'cust_pkg'  => [qw( 
420       pkgnum pkg_label pkg_label_long
421       location_label
422       status statuscolor
423     
424       start_date setup bill last_bill 
425       adjourn susp expire 
426       labels_short
427       ),
428       [ pkg               => sub { shift->part_pkg->pkg } ],
429       [ pkg_category      => sub { shift->part_pkg->categoryname } ],
430       [ pkg_class         => sub { shift->part_pkg->classname } ],
431       [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
432       [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
433       [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
434       [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
435       [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
436       [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
437       [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
438       [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
439       [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
440
441       # not necessarily correct for non-flat packages
442       [ setup_fee         => sub { shift->part_pkg->option('setup_fee') } ],
443       [ recur_fee         => sub { shift->part_pkg->option('recur_fee') } ],
444
445       [ freq_pretty       => sub { shift->part_pkg->freq_pretty } ],
446
447     ],
448     'cust_bill' => [qw(
449       invnum
450       _date
451       _date_pretty
452       due_date
453     ),
454       [ due_date2str      => sub { shift->due_date2str('short') } ],
455     ],
456     #XXX not really thinking about cust_bill substitutions quite yet
457     
458     # for welcome and limit warning messages
459     'svc_acct' => [qw(
460       svcnum
461       username
462       domain
463       ),
464       [ password          => sub { shift->getfield('_password') } ],
465       [ column            => sub { &$usage_warning(shift)->[0] } ],
466       [ amount            => sub { &$usage_warning(shift)->[1] } ],
467       [ threshold         => sub { &$usage_warning(shift)->[2] } ],
468     ],
469     'svc_domain' => [qw(
470       svcnum
471       domain
472       ),
473       [ registrar         => sub {
474           my $registrar = qsearchs('registrar', 
475             { registrarnum => shift->registrarnum} );
476           $registrar ? $registrar->registrarname : ''
477         }
478       ],
479       [ catchall          => sub { 
480           my $svc_acct = qsearchs('svc_acct', { svcnum => shift->catchall });
481           $svc_acct ? $svc_acct->email : ''
482         }
483       ],
484     ],
485     'svc_phone' => [qw(
486       svcnum
487       phonenum
488       countrycode
489       domain
490       )
491     ],
492     'svc_broadband' => [qw(
493       svcnum
494       speed_up
495       speed_down
496       ip_addr
497       mac_addr
498       )
499     ],
500     # for payment receipts
501     'cust_pay' => [qw(
502       paynum
503       _date
504       ),
505       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
506       # overrides the one in cust_main in cases where a cust_pay is passed
507       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
508       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
509       [ payinfo           => sub { 
510           my $cust_pay = shift;
511           ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
512             $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
513         } ],
514     ],
515     # for payment decline messages
516     # try to support all cust_pay fields
517     # 'error' is a special case, it contains the raw error from the gateway
518     'cust_pay_pending' => [qw(
519       _date
520       error
521       ),
522       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
523       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
524       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
525       [ payinfo           => sub {
526           my $pending = shift;
527           ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
528             $pending->paymask : $pending->decrypt($pending->payinfo)
529         } ],
530     ],
531   };
532 }
533
534 =item content LOCALE
535
536 Stub, returns nothing.
537
538 =cut
539
540 sub content {}
541
542 =item agent
543
544 Returns the L<FS::agent> object for this template.
545
546 =cut
547
548 sub _upgrade_data {
549   my ($self, %opts) = @_;
550
551   ###
552   # First move any historical templates in config to real message templates
553   ###
554
555   my @fixes = (
556     [ 'alerter_msgnum',  'alerter_template',   '',               '', '' ],
557     [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '', '' ],
558     [ 'decline_msgnum',  'declinetemplate',    '',               '', '' ],
559     [ 'impending_recur_msgnum', 'impending_recur_template', '',  '', 'impending_recur_bcc' ],
560     [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '', '' ],
561     [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from', '' ],
562     [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from', '' ],
563   );
564  
565   my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
566   foreach my $agentnum (@agentnums) {
567     foreach (@fixes) {
568       my ($newname, $oldname, $subject, $from, $bcc) = @$_;
569       if ($conf->exists($oldname, $agentnum)) {
570         my $new = new FS::msg_template({
571           'msgname'   => $oldname,
572           'agentnum'  => $agentnum,
573           'from_addr' => ($from && $conf->config($from, $agentnum)) || '',
574           'bcc_addr'  => ($bcc && $conf->config($from, $agentnum)) || '',
575           'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
576           'mime_type' => 'text/html',
577           'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
578         });
579         my $error = $new->insert;
580         die $error if $error;
581         $conf->set($newname, $new->msgnum, $agentnum);
582         $conf->delete($oldname, $agentnum);
583         $conf->delete($from, $agentnum) if $from;
584         $conf->delete($subject, $agentnum) if $subject;
585       }
586     }
587
588     if ( $conf->exists('alert_expiration', $agentnum) ) {
589       my $msgnum = $conf->exists('alerter_msgnum', $agentnum);
590       my $template = FS::msg_template->by_key($msgnum) if $msgnum;
591       if (!$template) {
592         warn "template for alerter_msgnum $msgnum not found\n";
593         next;
594       }
595       # this is now a set of billing events
596       foreach my $days (30, 15, 5) {
597         my $event = FS::part_event->new({
598             'agentnum'    => $agentnum,
599             'event'       => "Card expiration warning - $days days",
600             'eventtable'  => 'cust_main',
601             'check_freq'  => '1d',
602             'action'      => 'notice',
603             'disabled'    => 'Y', #initialize first
604         });
605         my $error = $event->insert( 'msgnum' => $msgnum );
606         if ($error) {
607           warn "error creating expiration alert event:\n$error\n\n";
608           next;
609         }
610         # make it work like before:
611         # only send each warning once before the card expires,
612         # only warn active customers,
613         # only warn customers with CARD/DCRD,
614         # only warn customers who get email invoices
615         my %conds = (
616           'once_every'          => { 'run_delay' => '30d' },
617           'cust_paydate_within' => { 'within' => $days.'d' },
618           'cust_status'         => { 'status' => { 'active' => 1 } },
619           'payby'               => { 'payby'  => { 'CARD' => 1,
620                                                    'DCRD' => 1, }
621                                    },
622           'message_email'       => {},
623         );
624         foreach (keys %conds) {
625           my $condition = FS::part_event_condition->new({
626               'conditionname' => $_,
627               'eventpart'     => $event->eventpart,
628           });
629           $error = $condition->insert( %{ $conds{$_} });
630           if ( $error ) {
631             warn "error creating expiration alert event:\n$error\n\n";
632             next;
633           }
634         }
635         $error = $event->initialize;
636         if ( $error ) {
637           warn "expiration alert event was created, but not initialized:\n$error\n\n";
638         }
639       } # foreach $days
640       $conf->delete('alerter_msgnum', $agentnum);
641       $conf->delete('alert_expiration', $agentnum);
642
643     } # if alerter_msgnum
644
645   }
646
647   ###
648   # Move subject and body from msg_template to template_content
649   ###
650
651   foreach my $msg_template ( qsearch('msg_template', {}) ) {
652     if ( $msg_template->subject || $msg_template->body ) {
653       # create new default content
654       my %content;
655       $content{subject} = $msg_template->subject;
656       $msg_template->set('subject', '');
657
658       # work around obscure Pg/DBD bug
659       # https://rt.cpan.org/Public/Bug/Display.html?id=60200
660       # (though the right fix is to upgrade DBD)
661       my $body = $msg_template->body;
662       if ( $body =~ /^x([0-9a-f]+)$/ ) {
663         # there should be no real message templates that look like that
664         warn "converting template body to TEXT\n";
665         $body = pack('H*', $1);
666       }
667       $content{body} = $body;
668       $msg_template->set('body', '');
669       my $error = $msg_template->replace(%content);
670       die $error if $error;
671     }
672
673     if ( !$msg_template->msgclass ) {
674       # set default message class
675       $msg_template->set('msgclass', 'email');
676       my $error = $msg_template->replace;
677       die $error if $error;
678     }
679   }
680
681   ###
682   # Add new-style default templates if missing
683   ###
684   $self->_populate_initial_data;
685
686 }
687
688 sub _populate_initial_data { #class method
689   #my($class, %opts) = @_;
690   #my $class = shift;
691
692   eval "use FS::msg_template::InitialData;";
693   die $@ if $@;
694
695   my $initial_data = FS::msg_template::InitialData->_initial_data;
696
697   foreach my $hash ( @$initial_data ) {
698
699     next if $hash->{_conf} && $conf->config( $hash->{_conf} );
700
701     my $msg_template = new FS::msg_template($hash);
702     my $error = $msg_template->insert( @{ $hash->{_insert_args} || [] } );
703     die $error if $error;
704
705     $conf->set( $hash->{_conf}, $msg_template->msgnum ) if $hash->{_conf};
706   
707   }
708
709 }
710
711 =back
712
713 =head1 BUGS
714
715 =head1 SEE ALSO
716
717 L<FS::Record>, schema.html from the base documentation.
718
719 =cut
720
721 1;
722