HTML templates for printable form letters, #17349
[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 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   my $tmp = 'msg'.$self->msgnum.'-'.time2str('%Y%m%d', time).'-XXXXXXXX';
461   my $dir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc";
462
463   # Graphics/stylesheets should probably go in /var/www on the Freeside 
464   # machine.
465   my $kit = PDF::WebKit->new(\$html); #%options
466   # hack to use our wrapper script
467   $kit->configure(sub { shift->wkhtmltopdf('freeside-wkhtmltopdf') });
468   my $fh = File::Temp->new(
469     TEMPLATE  => $tmp,
470     DIR       => $dir,
471     UNLINK    => 0,
472     SUFFIX    => '.pdf'
473   );
474
475   print $fh $kit->to_pdf;
476   close $fh;
477   return $fh->filename;
478 }
479
480 =item print OPTIONS
481
482 Render a PDF and send it to the printer.  OPTIONS are as for 'render'.
483
484 =cut
485
486 sub print {
487   my $file = render(@_);
488   my @lpr = $conf->config('lpr');
489   run ([@lpr, '-r'], '<', $file)
490     or die "lpr error:\n$?\n";
491 }
492
493
494 # helper sub for package dates
495 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
496
497 # helper sub for money amounts
498 my $money = sub { ($conf->money_char || '$') . sprintf('%.2f', $_[0] || 0) };
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_last ship_first ship_company
517       ship_name ship_name_short ship_contact ship_contact_firstlast
518       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
519       ship_country
520       ship_daytime ship_night ship_mobile ship_fax
521
522       paymask payname paytype payip
523       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
524       classname categoryname
525       balance
526       credit_limit
527       invoicing_list_emailonly
528       cust_status ucfirst_cust_status cust_statuscolor
529
530       signupdate dundate
531       packages recurdates
532       ),
533       [ expdate           => sub { shift->paydate_epoch } ], #compatibility
534       [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
535       [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
536       [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
537       [ otaker_first      => sub { shift->access_user->first } ],
538       [ otaker_last       => sub { shift->access_user->last } ],
539       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
540       [ company_name      => sub { 
541           $conf->config('company_name', shift->agentnum) 
542         } ],
543       [ company_address   => sub {
544           $conf->config('company_address', shift->agentnum)
545         } ],
546       [ invoicing_email => sub { shift->invoicing_list_emailonly_scalar } ],
547       [ company_phonenum  => sub {
548           $conf->config('company_phonenum', shift->agentnum)
549         } ],
550     ],
551     # next_bill_date
552     'cust_pkg'  => [qw( 
553       pkgnum pkg_label pkg_label_long
554       location_label
555       status statuscolor
556     
557       start_date setup bill last_bill 
558       adjourn susp expire 
559       labels_short
560       ),
561       [ pkg               => sub { shift->part_pkg->pkg } ],
562       [ pkg_category      => sub { shift->part_pkg->categoryname } ],
563       [ pkg_class         => sub { shift->part_pkg->classname } ],
564       [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
565       [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
566       [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
567       [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
568       [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
569       [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
570       [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
571       [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
572       [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
573
574       # not necessarily correct for non-flat packages
575       [ setup_fee         => sub { shift->part_pkg->option('setup_fee') } ],
576       [ recur_fee         => sub { shift->part_pkg->option('recur_fee') } ],
577
578       [ freq_pretty       => sub { shift->part_pkg->freq_pretty } ],
579
580     ],
581     'cust_bill' => [qw(
582       invnum
583       _date
584     )],
585     #XXX not really thinking about cust_bill substitutions quite yet
586     
587     # for welcome and limit warning messages
588     'svc_acct' => [qw(
589       svcnum
590       username
591       domain
592       ),
593       [ password          => sub { shift->getfield('_password') } ],
594     ],
595     'svc_domain' => [qw(
596       svcnum
597       domain
598       ),
599       [ registrar         => sub {
600           my $registrar = qsearchs('registrar', 
601             { registrarnum => shift->registrarnum} );
602           $registrar ? $registrar->registrarname : ''
603         }
604       ],
605       [ catchall          => sub { 
606           my $svc_acct = qsearchs('svc_acct', { svcnum => shift->catchall });
607           $svc_acct ? $svc_acct->email : ''
608         }
609       ],
610     ],
611     'svc_phone' => [qw(
612       svcnum
613       phonenum
614       countrycode
615       domain
616       )
617     ],
618     'svc_broadband' => [qw(
619       svcnum
620       speed_up
621       speed_down
622       ip_addr
623       mac_addr
624       )
625     ],
626     # for payment receipts
627     'cust_pay' => [qw(
628       paynum
629       _date
630       ),
631       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
632       # overrides the one in cust_main in cases where a cust_pay is passed
633       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
634       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
635       [ payinfo           => sub { 
636           my $cust_pay = shift;
637           ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
638             $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
639         } ],
640     ],
641     # for payment decline messages
642     # try to support all cust_pay fields
643     # 'error' is a special case, it contains the raw error from the gateway
644     'cust_pay_pending' => [qw(
645       _date
646       error
647       ),
648       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
649       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
650       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
651       [ payinfo           => sub {
652           my $pending = shift;
653           ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
654             $pending->paymask : $pending->decrypt($pending->payinfo)
655         } ],
656     ],
657   };
658 }
659
660 =item content LOCALE
661
662 Returns the L<FS::template_content> object appropriate to LOCALE, if there 
663 is one.  If not, returns the one with a NULL locale.
664
665 =cut
666
667 sub content {
668   my $self = shift;
669   my $locale = shift;
670   qsearchs('template_content', 
671             { 'msgnum' => $self->msgnum, 'locale' => $locale }) || 
672   qsearchs('template_content',
673             { 'msgnum' => $self->msgnum, 'locale' => '' });
674 }
675
676 =item agent
677
678 Returns the L<FS::agent> object for this template.
679
680 =cut
681
682 sub agent {
683   qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
684 }
685
686 sub _upgrade_data {
687   my ($self, %opts) = @_;
688
689   my @fixes = (
690     [ 'alerter_msgnum',  'alerter_template',   '',               '', '' ],
691     [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '', '' ],
692     [ 'decline_msgnum',  'declinetemplate',    '',               '', '' ],
693     [ 'impending_recur_msgnum', 'impending_recur_template', '',  '', 'impending_recur_bcc' ],
694     [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '', '' ],
695     [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from', '' ],
696     [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from', '' ],
697   );
698  
699   my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
700   foreach my $agentnum (@agentnums) {
701     foreach (@fixes) {
702       my ($newname, $oldname, $subject, $from, $bcc) = @$_;
703       if ($conf->exists($oldname, $agentnum)) {
704         my $new = new FS::msg_template({
705           'msgname'   => $oldname,
706           'agentnum'  => $agentnum,
707           'from_addr' => ($from && $conf->config($from, $agentnum)) || '',
708           'bcc_addr'  => ($bcc && $conf->config($from, $agentnum)) || '',
709           'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
710           'mime_type' => 'text/html',
711           'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
712         });
713         my $error = $new->insert;
714         die $error if $error;
715         $conf->set($newname, $new->msgnum, $agentnum);
716         $conf->delete($oldname, $agentnum);
717         $conf->delete($from, $agentnum) if $from;
718         $conf->delete($subject, $agentnum) if $subject;
719       }
720     }
721   }
722   foreach my $msg_template ( qsearch('msg_template', {}) ) {
723     if ( $msg_template->subject || $msg_template->body ) {
724       # create new default content
725       my %content;
726       $content{subject} = $msg_template->subject;
727       $msg_template->set('subject', '');
728
729       # work around obscure Pg/DBD bug
730       # https://rt.cpan.org/Public/Bug/Display.html?id=60200
731       # (though the right fix is to upgrade DBD)
732       my $body = $msg_template->body;
733       if ( $body =~ /^x([0-9a-f]+)$/ ) {
734         # there should be no real message templates that look like that
735         warn "converting template body to TEXT\n";
736         $body = pack('H*', $1);
737       }
738       $content{body} = $body;
739       $msg_template->set('body', '');
740
741       my $error = $msg_template->replace(%content);
742       die $error if $error;
743     }
744   }
745 }
746
747 sub eviscerate {
748   # Every bit as pleasant as it sounds.
749   #
750   # We do this because Text::Template::Preprocess doesn't
751   # actually work.  It runs the entire template through 
752   # the preprocessor, instead of the code segments.  Which 
753   # is a shame, because Text::Template already contains
754   # the code to do this operation.
755   my $body = shift;
756   my (@outside, @inside);
757   my $depth = 0;
758   my $chunk = '';
759   while($body || $chunk) {
760     my ($first, $delim, $rest);
761     # put all leading non-delimiters into $first
762     ($first, $rest) =
763         ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
764     $chunk .= $first;
765     # put a leading delimiter into $delim if there is one
766     ($delim, $rest) =
767       ($rest =~ /^([{}]?)(.*)$/s);
768
769     if( $delim eq '{' ) {
770       $chunk .= '{';
771       if( $depth == 0 ) {
772         push @outside, $chunk;
773         $chunk = '';
774       }
775       $depth++;
776     }
777     elsif( $delim eq '}' ) {
778       $depth--;
779       if( $depth == 0 ) {
780         push @inside, $chunk;
781         $chunk = '';
782       }
783       $chunk .= '}';
784     }
785     else {
786       # no more delimiters
787       if( $depth == 0 ) {
788         push @outside, $chunk . $rest;
789       } # else ? something wrong
790       last;
791     }
792     $body = $rest;
793   }
794   (\@outside, \@inside);
795 }
796
797 =back
798
799 =head1 BUGS
800
801 =head1 SEE ALSO
802
803 L<FS::Record>, schema.html from the base documentation.
804
805 =cut
806
807 1;
808