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