4 use vars qw ( @ISA @EXPORT_OK $DEBUG $DISABLE_ALL_NOTICES );
8 use IPC::Run qw( run timeout ); # for _pslatex
9 use IPC::Run3; # for do_print... should just use IPC::Run i guess
13 #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable
14 #until on client machine) dependancy loops. put them in FS::Misc::Something
17 @ISA = qw( Exporter );
18 @EXPORT_OK = qw( send_email generate_email send_fax
19 states_hash counties cities state_label
22 generate_ps generate_pdf do_print
32 FS::Misc - Miscellaneous subroutines
36 use FS::Misc qw(send_email);
42 Miscellaneous subroutines. This module contains miscellaneous subroutines
43 called from multiple other modules. These are not OO or necessarily related,
44 but are collected here to eliminate code duplication.
46 =head1 DISABLE ALL NOTICES
48 Set $FS::Misc::DISABLE_ALL_NOTICES to suppress:
52 =item FS::cust_bill::send_csv
54 =item FS::cust_bill::spool_csv
56 =item FS::msg_template::email::send_prepared
58 =item FS::Misc::send_email
60 =item FS::Misc::do_print
62 =item FS::Misc::send_fax
64 =item FS::Template_Mixin::postal_mail_fsinc
70 $DISABLE_ALL_NOTICES = 0;
76 =item send_email OPTION => VALUE ...
88 (required) comma-separated scalar or arrayref of recipients
96 (optional) MIME type for the body
100 (required unless I<nobody> is true) arrayref of body text lines
104 (optional, but required if I<nobody> is true) arrayref of MIME::Entity->build PARAMHASH refs or MIME::Entity objects. These will be passed as arguments to MIME::Entity->attach().
108 (optional) when set true, send_email will ignore the I<body> option and simply construct a message with the given I<mimeparts>. In this case,
109 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
111 =item content-encoding
113 (optional) when using nobody, optional top-level MIME
114 encoding which, if specified, overrides the default "7bit".
118 (optional) type parameter for multipart/related messages
122 (optional) L<FS::cust_main> key; if passed, the message will be logged
123 (if logging is enabled) with this custnum.
127 (optional) L<FS::msg_template> key, for logging.
133 use vars qw( $conf );
136 use Email::Sender::Simple qw(sendmail);
137 use Email::Sender::Transport::SMTP;
138 use Email::Sender::Transport::SMTP::TLS 0.11;
141 FS::UID->install_callback( sub {
142 $conf = new FS::Conf;
148 if ( $DISABLE_ALL_NOTICES ) {
149 warn 'send_email() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
154 my %doptions = %options;
155 $doptions{'body'} = '(full body not shown in debug)';
156 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
157 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
160 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
164 if ( $options{'nobody'} ) {
166 croak "'mimeparts' option required when 'nobody' option given\n"
167 unless $options{'mimeparts'};
169 @mimeparts = @{$options{'mimeparts'}};
172 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
173 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
178 @mimeparts = @{$options{'mimeparts'}}
179 if ref($options{'mimeparts'}) eq 'ARRAY';
181 if (scalar(@mimeparts)) {
184 'Type' => 'multipart/mixed',
185 'Encoding' => '7bit',
188 unshift @mimeparts, {
189 'Type' => ( $options{'content-type'} || 'text/plain' ),
190 'Charset' => 'UTF-8',
191 'Data' => ( $options{'content-type'} =~ /^text\//
192 ? Encode::encode_utf8( $options{'body'} )
195 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
196 'Disposition' => 'inline',
202 'Type' => ( $options{'content-type'} || 'text/plain' ),
203 'Data' => ( $options{'content-type'} =~ /^text\//
204 ? Encode::encode_utf8( $options{'body'} )
207 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
208 'Charset' => 'UTF-8',
215 my $from = $options{from};
216 $from =~ s/^\s*//; $from =~ s/\s*$//;
217 if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
223 if ( $from =~ /\@([\w\.\-]+)/ ) {
226 warn 'no domain found in invoice from address '. $options{'from'}.
227 '; constructing Message-ID (and saying HELO) @example.com';
228 $domain = 'example.com';
230 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
233 my $message = MIME::Entity->build(
234 'From' => $options{'from'},
235 'To' => join(', ', @to),
236 'Sender' => $options{'from'},
237 'Reply-To' => $options{'from'},
238 'Date' => time2str("%a, %d %b %Y %X %z", $time),
239 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
240 'Message-ID' => "<$message_id>",
244 if ( $options{'type'} ) {
245 #false laziness w/cust_bill::generate_email
246 $message->head->replace('Content-type',
248 '; boundary="'. $message->head->multipart_boundary. '"'.
249 '; type='. $options{'type'}
253 foreach my $part (@mimeparts) {
255 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
257 warn "attaching MIME part from MIME::Entity object\n"
259 $message->add_part($part);
261 } elsif ( ref($part) eq 'HASH' ) {
263 warn "attaching MIME part from hashref:\n".
264 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
266 $message->attach(%$part);
269 croak "mimepart $part isn't a hashref or MIME::Entity object!";
276 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
280 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
281 $smtp_opt{'port'} = $port;
285 if ( defined($enc) && $enc eq 'starttls' ) {
286 foreach (qw(username password)) {
287 $smtp_opt{$_} = $conf->config("smtp-$_");
288 $error = "SMTP settings misconfiguration: ".
289 "STARTTLS enabled in smtp-encryption but smtp-$_ missing"
290 if ! length($smtp_opt{$_});
292 $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
294 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
295 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
297 $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
298 $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
301 push @to, $options{bcc} if defined($options{bcc});
302 # fully unpack all addresses found in @to (including Bcc) to make the
305 foreach my $dest (@to) {
306 push @env_to, map { $_->address } Email::Address->parse($dest);
309 unless ( length($error) ) {
311 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
312 local $@; # just in case
313 eval { sendmail($message, { transport => $transport,
317 if (ref($@) and $@->isa('Email::Sender::Failure')) {
318 $error = $@->code.' ' if $@->code;
319 $error .= $@->message;
327 if ( $conf->exists('log_sent_mail') ) {
328 my $cust_msg = FS::cust_msg->new({
329 'env_from' => $options{'from'},
330 'env_to' => join(', ', @env_to),
331 'header' => $message->header_as_string,
332 'body' => $message->body_as_string,
335 'custnum' => $options{'custnum'},
336 'msgnum' => $options{'msgnum'},
337 'status' => ($error ? 'failed' : 'sent'),
338 'msgtype' => $options{'msgtype'},
340 my $log_error = $cust_msg->insert;
341 warn "Error logging message: $log_error\n" if $log_error; # at least warn
348 =item generate_email OPTION => VALUE ...
356 Sender address, required
360 Recipient address, required
364 Blind copy address, optional
368 email subject, required
372 Email body (HTML alternative). Arrayref of lines, or scalar.
374 Will be placed inside an HTML <BODY> tag.
378 Email body (Text alternative). Arrayref of lines, or scalar.
380 =item custnum, msgnum (optional)
382 Customer and template numbers, passed through to send_email for logging.
386 Constructs a multipart message from text_body and html_body.
390 #false laziness w/FS::cust_bill::generate_email
398 my $me = '[FS::Misc::generate_email]';
400 my @fields = qw(from to bcc subject custnum msgnum msgtype);
402 @return{@fields} = @args{@fields};
404 warn "$me creating HTML/text multipart message"
407 $return{'nobody'} = 1;
409 my $alternative = build MIME::Entity
410 'Type' => 'multipart/alternative',
411 'Encoding' => '7bit',
412 'Disposition' => 'inline'
416 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
417 $data = join("\n", @{ $args{'text_body'} });
419 $data = $args{'text_body'};
422 $alternative->attach(
423 'Type' => 'text/plain',
424 'Encoding' => 'quoted-printable',
425 'Charset' => 'UTF-8',
426 #'Encoding' => '7bit',
427 'Data' => Encode::encode_utf8($data),
428 'Disposition' => 'inline',
432 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
433 @html_data = @{ $args{'html_body'} };
435 @html_data = split(/\n/, $args{'html_body'});
438 $alternative->attach(
439 'Type' => 'text/html',
440 'Encoding' => 'quoted-printable',
441 'Data' => [ '<html>',
444 ' '. encode_entities($return{'subject'}),
447 ' <body bgcolor="#ffffff">',
448 ( map Encode::encode_utf8($_), @html_data ),
452 'Disposition' => 'inline',
453 #'Filename' => 'invoice.pdf',
456 #no other attachment:
458 # multipart/alternative
462 $return{'content-type'} = 'multipart/related';
463 $return{'mimeparts'} = [ $alternative ];
464 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
465 #$return{'disposition'} = 'inline';
471 =item send_fax OPTION => VALUE ...
475 I<dialstring> - (required) 10-digit phone number w/ area code
477 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
481 I<docfile> - (required) Filename of PostScript TIFF Class F document
483 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
492 die 'HylaFAX support has not been configured.'
493 unless $conf->exists('hylafax');
495 if ( $DISABLE_ALL_NOTICES ) {
496 warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
501 require Fax::Hylafax::Client;
505 if ($@ =~ /^Can't locate Fax.*/) {
506 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
512 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
514 die 'Called send_fax without a \'dialstring\'.'
515 unless exists($options{'dialstring'});
517 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
518 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
519 my $fh = new File::Temp(
520 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
523 ) or die "can't open temp file: $!\n";
525 $options{docfile} = $fh->filename;
527 print $fh @{$options{'docdata'}};
530 delete $options{'docdata'};
533 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
534 unless exists($options{'docfile'});
536 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
539 $options{'dialstring'} =~ s/[^\d\+]//g;
540 if ($options{'dialstring'} =~ /^\d{10}$/) {
541 $options{dialstring} = '+1' . $options{'dialstring'};
543 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
546 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
548 if ($faxjob->success) {
549 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
554 return 'Error while sending FAX: ' . $faxjob->trace;
559 =item states_hash COUNTRY
561 Returns a list of key/value pairs containing state (or other sub-country
562 division) abbriviations and names.
566 use FS::Record qw(qsearch);
567 use Locale::SubCountry;
572 #a hash? not expecting an explosion of business from unrecognized countries..
573 return states_hash_nosubcountry($country) if $country eq 'XC';
577 map { s/[\n\r]//g; $_; }
581 'table' => 'cust_main_county',
582 'hashref' => { 'country' => $country },
583 'extra_sql' => 'GROUP BY state',
586 #it could throw a fatal "Invalid country code" error (for example "AX")
587 my $subcountry = eval { new Locale::SubCountry($country) }
588 or return (); # ( '', '(n/a)' );
590 #"i see your schwartz is as big as mine!"
591 map { ( $_->[0] => $_->[1] ) }
592 sort { $a->[1] cmp $b->[1] }
593 map { [ $_ => state_label($_, $subcountry) ] }
597 sub states_hash_nosubcountry {
602 map { s/[\n\r]//g; $_; }
606 'table' => 'cust_main_county',
607 'hashref' => { 'country' => $country },
608 'extra_sql' => 'GROUP BY state',
611 #"i see your schwartz is as big as mine!"
612 map { ( $_->[0] => $_->[1] ) }
613 sort { $a->[1] cmp $b->[1] }
618 =item counties STATE COUNTRY
620 Returns a list of counties for this state and country.
625 my( $state, $country ) = @_;
627 map { $_ } #return num_counties($state, $country) unless wantarray;
628 sort map { s/[\n\r]//g; $_; }
631 'select' => 'DISTINCT county',
632 'table' => 'cust_main_county',
633 'hashref' => { 'state' => $state,
634 'country' => $country,
639 =item cities COUNTY STATE COUNTRY
641 Returns a list of cities for this county, state and country.
646 my( $county, $state, $country ) = @_;
648 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
649 sort map { s/[\n\r]//g; $_; }
652 'select' => 'DISTINCT city',
653 'table' => 'cust_main_county',
654 'hashref' => { 'county' => $county,
656 'country' => $country,
661 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
666 my( $state, $country ) = @_;
668 unless ( ref($country) ) {
669 $country = eval { new Locale::SubCountry($country) }
674 # US kludge to avoid changing existing behaviour
675 # also we actually *use* the abbriviations...
676 my $full_name = $country->country_code eq 'US'
678 : $country->full_name($state);
680 $full_name = '' if $full_name eq 'unknown';
681 $full_name =~ s/\(see also.*\)\s*$//;
682 $full_name .= " ($state)" if $full_name;
684 $full_name || $state || '(n/a)';
690 Returns a hash reference of the accepted credit card types. Keys are shorter
691 identifiers and values are the longer strings used by the system (see
692 L<Business::CreditCard>).
699 my $conf = new FS::Conf;
702 #displayname #value (Business::CreditCard)
703 "VISA" => "VISA card",
704 "MasterCard" => "MasterCard",
705 "Discover" => "Discover card",
706 "American Express" => "American Express card",
707 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
708 "enRoute" => "enRoute",
710 "BankCard" => "BankCard",
711 "Switch" => "Switch",
714 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
715 if ( @conf_card_types ) {
716 #perhaps the hash is backwards for this, but this way works better for
717 #usage in selfservice
718 %card_types = map { $_ => $card_types{$_} }
721 grep { $card_types{$d} eq $_ } @conf_card_types
731 Returns a hash reference of allowed package billing frequencies.
736 tie my %freq, 'Tie::IxHash', (
737 '0' => '(no recurring fee)',
740 '2d' => 'every two days',
741 '3d' => 'every three days',
743 '2w' => 'biweekly (every 2 weeks)',
745 '45d' => 'every 45 days',
746 '2' => 'bimonthly (every 2 months)',
747 '3' => 'quarterly (every 3 months)',
748 '4' => 'every 4 months',
749 '137d' => 'every 4 1/2 months (137 days)',
750 '6' => 'semiannually (every 6 months)',
752 '13' => 'every 13 months (annually +1 month)',
753 '24' => 'biannually (every 2 years)',
754 '36' => 'triannually (every 3 years)',
755 '48' => '(every 4 years)',
756 '60' => '(every 5 years)',
757 '120' => '(every 10 years)',
762 =item generate_ps FILENAME
764 Returns an postscript rendition of the LaTex file, as a scalar.
765 FILENAME does not contain the .tex suffix and is unlinked by this function.
769 use String::ShellQuote;
774 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
779 my $papersize = $conf->config('papersize') || 'letter';
781 local($SIG{CHLD}) = sub {};
783 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
784 or die "dvips failed";
786 open(POSTSCRIPT, "<$file.ps")
787 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
789 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
790 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
794 if ( $conf->exists('lpr-postscript_prefix') ) {
795 my $prefix = $conf->config('lpr-postscript_prefix');
796 $ps .= eval qq("$prefix");
799 while (<POSTSCRIPT>) {
805 if ( $conf->exists('lpr-postscript_suffix') ) {
806 my $suffix = $conf->config('lpr-postscript_suffix');
807 $ps .= eval qq("$suffix");
814 =item generate_pdf FILENAME
816 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
817 contain the .tex suffix and is unlinked by this function.
821 use String::ShellQuote;
826 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
829 #system('pdflatex', "$file.tex");
830 #system('pdflatex', "$file.tex");
831 #! LaTeX Error: Unknown graphics extension: .eps.
835 my $sfile = shell_quote $file;
837 #system('dvipdf', "$file.dvi", "$file.pdf" );
838 my $papersize = $conf->config('papersize') || 'letter';
840 local($SIG{CHLD}) = sub {};
843 "dvips -q -f $sfile.dvi -t $papersize ".
844 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
847 or die "dvips | gs failed: $!";
849 open(PDF, "<$file.pdf")
850 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
852 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
853 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
869 #my $sfile = shell_quote $file;
873 '-interaction=batchmode',
874 '\AtBeginDocument{\RequirePackage{pslatex}}',
875 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
876 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
881 my $timeout = 30; #? should be more than enough
885 local($SIG{CHLD}) = sub {};
886 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
887 or warn "bad exit status from pslatex pass $_\n";
891 return if -e "$file.dvi" && -s "$file.dvi";
892 die "pslatex $file.tex failed, see $file.log for details?\n";
896 =item do_print ARRAYREF [, OPTION => VALUE ... ]
898 Sends the lines in ARRAYREF to the printer.
900 Options available are:
906 Uses this agent's 'lpr' configuration setting override instead of the global
911 Uses this command instead of the configured lpr command (overrides both the
912 global value and agentnum).
917 my( $data, %opt ) = @_;
919 if ( $DISABLE_ALL_NOTICES ) {
920 warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
924 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
926 : $conf->config('lpr', $opt{'agentnum'} );
929 local($SIG{CHLD}) = sub {};
930 run3 $lpr, $data, \$outerr, \$outerr;
932 $outerr = ": $outerr" if length($outerr);
933 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
938 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
940 Converts the filehandle referenced by FILEREF from fixed length record
941 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
942 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
943 should return the value to be substituted in place of its single argument.
945 Returns false on success or an error if one occurs.
950 my( $fhref, $countref, $lengths, $callbacks) = @_;
952 eval { require Text::CSV_XS; };
956 my $unpacker = new Text::CSV_XS;
958 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
960 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
961 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
964 ) or return "can't open temp file: $!\n"
967 while ( defined(my $line=<$ofh>) ) {
973 return "unexpected input at line $$countref: $line".
974 " -- expected $total but received ". length($line)
975 unless length($line) == $total;
977 $unpacker->combine( map { my $i = $column++;
978 defined( $callbacks->[$i] )
979 ? &{ $callbacks->[$i] }( $_ )
981 } unpack( $template, $line )
983 or return "invalid data for CSV: ". $unpacker->error_input;
985 print $fh $unpacker->string(), "\n"
986 or return "can't write temp file: $!\n";
990 if ( $template ) { close $$fhref; $$fhref = $fh }
996 =item ocr_image IMAGE_SCALAR
998 Runs OCR on the provided image data and returns a list of text lines.
1003 my $logo_data = shift;
1005 #XXX use conf dir location from Makefile
1006 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
1007 my $fh = new File::Temp(
1008 TEMPLATE => 'bizcard.XXXXXXXX',
1009 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
1012 ) or die "can't open temp file: $!\n";
1014 my $filename = $fh->filename;
1016 print $fh $logo_data;
1019 local($SIG{CHLD}) = sub {};
1021 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
1022 or die "ocroscript recognize failed\n";
1024 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
1025 or die "ocroscript hocr-to-text failed\n";
1027 my @lines = split(/\n/, <OUT> );
1029 foreach (@lines) { s/\.c0m\s*$/.com/; }
1034 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
1037 Use Unicode::Truncate truncate_egc instead
1039 A replacement for "substr" that counts raw bytes rather than logical
1040 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
1041 rather than output them. Unlike real "substr", is not an lvalue.
1045 # sub bytes_substr {
1046 # my ($string, $offset, $length, $repl) = @_;
1047 # my $bytes = substr(
1048 # Encode::encode('utf8', $string),
1051 # Encode::encode('utf8', $repl)
1053 # my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
1054 # return Encode::decode('utf8', $bytes, $chk);
1059 Accepts a postive or negative numerical value.
1060 Returns amount formatted for display,
1061 including money character.
1067 my $money_char = $conf->{'money_char'} || '$';
1068 $amount = sprintf("%0.2f",$amount);
1069 $amount =~ s/^(-?)/$1$money_char/;
1077 This package exists.
1081 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
1083 L<Fax::Hylafax::Client>