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