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 _sendmail
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;
140 FS::UID->install_callback( sub {
141 $conf = new FS::Conf;
147 if ( $DISABLE_ALL_NOTICES ) {
148 warn 'send_email() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
153 my %doptions = %options;
154 $doptions{'body'} = '(full body not shown in debug)';
155 warn "FS::Misc::send_email called with options:\n ". Dumper(\%doptions);
156 # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
159 my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
163 if ( $options{'nobody'} ) {
165 croak "'mimeparts' option required when 'nobody' option given\n"
166 unless $options{'mimeparts'};
168 @mimeparts = @{$options{'mimeparts'}};
171 'Type' => ( $options{'content-type'} || 'multipart/mixed' ),
172 'Encoding' => ( $options{'content-encoding'} || '7bit' ),
177 @mimeparts = @{$options{'mimeparts'}}
178 if ref($options{'mimeparts'}) eq 'ARRAY';
180 if (scalar(@mimeparts)) {
183 'Type' => 'multipart/mixed',
184 'Encoding' => '7bit',
187 unshift @mimeparts, {
188 'Type' => ( $options{'content-type'} || 'text/plain' ),
189 'Charset' => 'UTF-8',
190 'Data' => ( $options{'content-type'} =~ /^text\//
191 ? Encode::encode_utf8( $options{'body'} )
194 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
195 'Disposition' => 'inline',
201 'Type' => ( $options{'content-type'} || 'text/plain' ),
202 'Data' => ( $options{'content-type'} =~ /^text\//
203 ? Encode::encode_utf8( $options{'body'} )
206 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
207 'Charset' => 'UTF-8',
214 my $from = $options{from};
215 $from =~ s/^\s*//; $from =~ s/\s*$//;
216 if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
222 if ( $from =~ /\@([\w\.\-]+)/ ) {
225 warn 'no domain found in invoice from address '. $options{'from'}.
226 '; constructing Message-ID (and saying HELO) @example.com';
227 $domain = 'example.com';
229 my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
232 my $message = MIME::Entity->build(
233 'From' => $options{'from'},
234 'To' => join(', ', @to),
235 'Sender' => $options{'from'},
236 'Reply-To' => $options{'from'},
237 'Date' => time2str("%a, %d %b %Y %X %z", $time),
238 'Subject' => Encode::encode('MIME-Header', $options{'subject'}),
239 'Message-ID' => "<$message_id>",
243 if ( $options{'type'} ) {
244 #false laziness w/cust_bill::generate_email
245 $message->head->replace('Content-type',
247 '; boundary="'. $message->head->multipart_boundary. '"'.
248 '; type='. $options{'type'}
252 foreach my $part (@mimeparts) {
254 if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
256 warn "attaching MIME part from MIME::Entity object\n"
258 $message->add_part($part);
260 } elsif ( ref($part) eq 'HASH' ) {
262 warn "attaching MIME part from hashref:\n".
263 join("\n", map " $_: ".$part->{$_}, keys %$part ). "\n"
265 $message->attach(%$part);
268 croak "mimepart $part isn't a hashref or MIME::Entity object!";
275 push @to, $options{bcc} if defined($options{bcc});
276 # fully unpack all addresses found in @to (including Bcc) to make the
279 foreach my $dest (@to) {
280 push @env_to, map { $_->address } Email::Address->parse($dest);
283 my $error = _sendmail( $message, { 'from' => $from,
290 if ( $conf->exists('log_sent_mail') ) {
291 my $cust_msg = FS::cust_msg->new({
292 'env_from' => $options{'from'},
293 'env_to' => join(', ', @env_to),
294 'header' => $message->header_as_string,
295 'body' => $message->body_as_string,
298 'custnum' => $options{'custnum'},
299 'msgnum' => $options{'msgnum'},
300 'status' => ($error ? 'failed' : 'sent'),
301 'msgtype' => $options{'msgtype'},
303 my $log_error = $cust_msg->insert;
304 warn "Error logging message: $log_error\n" if $log_error; # at least warn
312 my($message, $options) = @_;
313 my $domain = delete $options->{'domain'};
315 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
319 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
320 $smtp_opt{'port'} = $port;
322 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
323 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
324 } elsif ( defined($enc) && $enc eq 'starttls') {
325 return "SMTP settings misconfiguration: STARTTLS enabled in ".
326 "smtp-encryption but smtp-username or smtp-password missing";
329 if ( defined($enc) ) {
330 $smtp_opt{'ssl'} = 'starttls' if $enc eq 'starttls';
331 $smtp_opt{'ssl'} = 1 if $enc eq 'tls';
334 $options->{'transport'} = Email::Sender::Transport::SMTP->new( %smtp_opt );
338 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
339 local $@; # just in case
340 eval { sendmail($message, $options) };
342 if (ref($@) and $@->isa('Email::Sender::Failure')) {
343 $error = $@->code.' ' if $@->code;
344 $error .= $@->message;
353 =item generate_email OPTION => VALUE ...
361 Sender address, required
365 Recipient address, required
369 Blind copy address, optional
373 email subject, required
377 Email body (HTML alternative). Arrayref of lines, or scalar.
379 Will be placed inside an HTML <BODY> tag.
383 Email body (Text alternative). Arrayref of lines, or scalar.
385 =item custnum, msgnum (optional)
387 Customer and template numbers, passed through to send_email for logging.
391 Constructs a multipart message from text_body and html_body.
395 #false laziness w/FS::cust_bill::generate_email
403 my $me = '[FS::Misc::generate_email]';
405 my @fields = qw(from to bcc subject custnum msgnum msgtype);
407 @return{@fields} = @args{@fields};
409 warn "$me creating HTML/text multipart message"
412 $return{'nobody'} = 1;
414 my $alternative = build MIME::Entity
415 'Type' => 'multipart/alternative',
416 'Encoding' => '7bit',
417 'Disposition' => 'inline'
421 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
422 $data = join("\n", @{ $args{'text_body'} });
424 $data = $args{'text_body'};
427 $alternative->attach(
428 'Type' => 'text/plain',
429 'Encoding' => 'quoted-printable',
430 'Charset' => 'UTF-8',
431 #'Encoding' => '7bit',
432 'Data' => Encode::encode_utf8($data),
433 'Disposition' => 'inline',
437 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
438 @html_data = @{ $args{'html_body'} };
440 @html_data = split(/\n/, $args{'html_body'});
443 $alternative->attach(
444 'Type' => 'text/html',
445 'Encoding' => 'quoted-printable',
446 'Data' => [ '<html>',
449 ' '. encode_entities($return{'subject'}),
452 ' <body bgcolor="#ffffff">',
453 ( map Encode::encode_utf8($_), @html_data ),
457 'Disposition' => 'inline',
458 #'Filename' => 'invoice.pdf',
461 #no other attachment:
463 # multipart/alternative
467 $return{'content-type'} = 'multipart/related';
468 $return{'mimeparts'} = [ $alternative ];
469 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
470 #$return{'disposition'} = 'inline';
476 =item send_fax OPTION => VALUE ...
480 I<dialstring> - (required) 10-digit phone number w/ area code
482 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
486 I<docfile> - (required) Filename of PostScript TIFF Class F document
488 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
497 die 'HylaFAX support has not been configured.'
498 unless $conf->exists('hylafax');
500 if ( $DISABLE_ALL_NOTICES ) {
501 warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
506 require Fax::Hylafax::Client;
510 if ($@ =~ /^Can't locate Fax.*/) {
511 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
517 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
519 die 'Called send_fax without a \'dialstring\'.'
520 unless exists($options{'dialstring'});
522 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
523 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
524 my $fh = new File::Temp(
525 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
528 ) or die "can't open temp file: $!\n";
530 $options{docfile} = $fh->filename;
532 print $fh @{$options{'docdata'}};
535 delete $options{'docdata'};
538 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
539 unless exists($options{'docfile'});
541 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
544 $options{'dialstring'} =~ s/[^\d\+]//g;
545 if ($options{'dialstring'} =~ /^\d{10}$/) {
546 $options{dialstring} = '+1' . $options{'dialstring'};
548 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
551 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
553 if ($faxjob->success) {
554 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
559 return 'Error while sending FAX: ' . $faxjob->trace;
564 =item states_hash COUNTRY
566 Returns a list of key/value pairs containing state (or other sub-country
567 division) abbriviations and names.
571 use FS::Record qw(qsearch);
572 use Locale::SubCountry;
577 #a hash? not expecting an explosion of business from unrecognized countries..
578 return states_hash_nosubcountry($country) if $country eq 'XC';
582 map { s/[\n\r]//g; $_; }
586 'table' => 'cust_main_county',
587 'hashref' => { 'country' => $country },
588 'extra_sql' => 'GROUP BY state',
591 #it could throw a fatal "Invalid country code" error (for example "AX")
592 my $subcountry = eval { new Locale::SubCountry($country) }
593 or return (); # ( '', '(n/a)' );
595 #"i see your schwartz is as big as mine!"
596 map { ( $_->[0] => $_->[1] ) }
597 sort { $a->[1] cmp $b->[1] }
598 map { [ $_ => state_label($_, $subcountry) ] }
602 sub states_hash_nosubcountry {
607 map { s/[\n\r]//g; $_; }
611 'table' => 'cust_main_county',
612 'hashref' => { 'country' => $country },
613 'extra_sql' => 'GROUP BY state',
616 #"i see your schwartz is as big as mine!"
617 map { ( $_->[0] => $_->[1] ) }
618 sort { $a->[1] cmp $b->[1] }
623 =item counties STATE COUNTRY
625 Returns a list of counties for this state and country.
630 my( $state, $country ) = @_;
632 map { $_ } #return num_counties($state, $country) unless wantarray;
633 sort map { s/[\n\r]//g; $_; }
636 'select' => 'DISTINCT county',
637 'table' => 'cust_main_county',
638 'hashref' => { 'state' => $state,
639 'country' => $country,
644 =item cities COUNTY STATE COUNTRY
646 Returns a list of cities for this county, state and country.
651 my( $county, $state, $country ) = @_;
653 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
654 sort map { s/[\n\r]//g; $_; }
657 'select' => 'DISTINCT city',
658 'table' => 'cust_main_county',
659 'hashref' => { 'county' => $county,
661 'country' => $country,
666 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
671 my( $state, $country ) = @_;
673 unless ( ref($country) ) {
674 $country = eval { new Locale::SubCountry($country) }
679 # US kludge to avoid changing existing behaviour
680 # also we actually *use* the abbriviations...
681 my $full_name = $country->country_code eq 'US'
683 : $country->full_name($state);
685 $full_name = '' if $full_name eq 'unknown';
686 $full_name =~ s/\(see also.*\)\s*$//;
687 $full_name .= " ($state)" if $full_name;
689 $full_name || $state || '(n/a)';
695 Returns a hash reference of the accepted credit card types. Keys are shorter
696 identifiers and values are the longer strings used by the system (see
697 L<Business::CreditCard>).
704 my $conf = new FS::Conf;
707 #displayname #value (Business::CreditCard)
708 "VISA" => "VISA card",
709 "MasterCard" => "MasterCard",
710 "Discover" => "Discover card",
711 "American Express" => "American Express card",
712 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
713 "enRoute" => "enRoute",
715 "BankCard" => "BankCard",
716 "Switch" => "Switch",
719 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
720 if ( @conf_card_types ) {
721 #perhaps the hash is backwards for this, but this way works better for
722 #usage in selfservice
723 %card_types = map { $_ => $card_types{$_} }
726 grep { $card_types{$d} eq $_ } @conf_card_types
736 Returns a hash reference of allowed package billing frequencies.
741 tie my %freq, 'Tie::IxHash', (
742 '0' => '(no recurring fee)',
745 '2d' => 'every two days',
746 '3d' => 'every three days',
748 '2w' => 'biweekly (every 2 weeks)',
750 '45d' => 'every 45 days',
751 '2' => 'bimonthly (every 2 months)',
752 '3' => 'quarterly (every 3 months)',
753 '4' => 'every 4 months',
754 '137d' => 'every 4 1/2 months (137 days)',
755 '6' => 'semiannually (every 6 months)',
757 '13' => 'every 13 months (annually +1 month)',
758 '24' => 'biannually (every 2 years)',
759 '36' => 'triannually (every 3 years)',
760 '48' => '(every 4 years)',
761 '60' => '(every 5 years)',
762 '120' => '(every 10 years)',
767 =item generate_ps FILENAME
769 Returns an postscript rendition of the LaTex file, as a scalar.
770 FILENAME does not contain the .tex suffix and is unlinked by this function.
774 use String::ShellQuote;
779 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
784 my $papersize = $conf->config('papersize') || 'letter';
786 local($SIG{CHLD}) = sub {};
788 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
789 or die "dvips failed";
791 open(POSTSCRIPT, "<$file.ps")
792 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
794 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
795 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
799 if ( $conf->exists('lpr-postscript_prefix') ) {
800 my $prefix = $conf->config('lpr-postscript_prefix');
801 $ps .= eval qq("$prefix");
804 while (<POSTSCRIPT>) {
810 if ( $conf->exists('lpr-postscript_suffix') ) {
811 my $suffix = $conf->config('lpr-postscript_suffix');
812 $ps .= eval qq("$suffix");
819 =item generate_pdf FILENAME
821 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
822 contain the .tex suffix and is unlinked by this function.
826 use String::ShellQuote;
831 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
834 #system('pdflatex', "$file.tex");
835 #system('pdflatex', "$file.tex");
836 #! LaTeX Error: Unknown graphics extension: .eps.
840 my $sfile = shell_quote $file;
842 #system('dvipdf', "$file.dvi", "$file.pdf" );
843 my $papersize = $conf->config('papersize') || 'letter';
845 local($SIG{CHLD}) = sub {};
848 "dvips -q -f $sfile.dvi -t $papersize ".
849 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
852 or die "dvips | gs failed: $!";
854 open(PDF, "<$file.pdf")
855 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
857 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
858 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
874 #my $sfile = shell_quote $file;
878 '-interaction=batchmode',
879 '\AtBeginDocument{\RequirePackage{pslatex}}',
880 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
881 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
886 my $timeout = 30; #? should be more than enough
890 local($SIG{CHLD}) = sub {};
891 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
892 or warn "bad exit status from pslatex pass $_\n";
896 return if -e "$file.dvi" && -s "$file.dvi";
897 die "pslatex $file.tex failed, see $file.log for details?\n";
901 =item do_print ARRAYREF [, OPTION => VALUE ... ]
903 Sends the lines in ARRAYREF to the printer.
905 Options available are:
911 Uses this agent's 'lpr' configuration setting override instead of the global
916 Uses this command instead of the configured lpr command (overrides both the
917 global value and agentnum).
922 my( $data, %opt ) = @_;
924 if ( $DISABLE_ALL_NOTICES ) {
925 warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
929 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
931 : $conf->config('lpr', $opt{'agentnum'} );
934 local($SIG{CHLD}) = sub {};
935 run3 $lpr, $data, \$outerr, \$outerr;
937 $outerr = ": $outerr" if length($outerr);
938 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
943 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
945 Converts the filehandle referenced by FILEREF from fixed length record
946 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
947 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
948 should return the value to be substituted in place of its single argument.
950 Returns false on success or an error if one occurs.
955 my( $fhref, $countref, $lengths, $callbacks) = @_;
957 eval { require Text::CSV_XS; };
961 my $unpacker = new Text::CSV_XS;
963 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
965 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
966 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
969 ) or return "can't open temp file: $!\n"
972 while ( defined(my $line=<$ofh>) ) {
978 return "unexpected input at line $$countref: $line".
979 " -- expected $total but received ". length($line)
980 unless length($line) == $total;
982 $unpacker->combine( map { my $i = $column++;
983 defined( $callbacks->[$i] )
984 ? &{ $callbacks->[$i] }( $_ )
986 } unpack( $template, $line )
988 or return "invalid data for CSV: ". $unpacker->error_input;
990 print $fh $unpacker->string(), "\n"
991 or return "can't write temp file: $!\n";
995 if ( $template ) { close $$fhref; $$fhref = $fh }
1001 =item ocr_image IMAGE_SCALAR
1003 Runs OCR on the provided image data and returns a list of text lines.
1008 my $logo_data = shift;
1010 #XXX use conf dir location from Makefile
1011 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
1012 my $fh = new File::Temp(
1013 TEMPLATE => 'bizcard.XXXXXXXX',
1014 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
1017 ) or die "can't open temp file: $!\n";
1019 my $filename = $fh->filename;
1021 print $fh $logo_data;
1024 local($SIG{CHLD}) = sub {};
1026 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
1027 or die "ocroscript recognize failed\n";
1029 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
1030 or die "ocroscript hocr-to-text failed\n";
1032 my @lines = split(/\n/, <OUT> );
1034 foreach (@lines) { s/\.c0m\s*$/.com/; }
1039 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
1042 Use Unicode::Truncate truncate_egc instead
1044 A replacement for "substr" that counts raw bytes rather than logical
1045 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
1046 rather than output them. Unlike real "substr", is not an lvalue.
1050 # sub bytes_substr {
1051 # my ($string, $offset, $length, $repl) = @_;
1052 # my $bytes = substr(
1053 # Encode::encode('utf8', $string),
1056 # Encode::encode('utf8', $repl)
1058 # my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
1059 # return Encode::decode('utf8', $bytes, $chk);
1064 Accepts a postive or negative numerical value.
1065 Returns amount formatted for display,
1066 including money character.
1072 my $money_char = $conf->{'money_char'} || '$';
1073 $amount = sprintf("%0.2f",$amount);
1074 $amount =~ s/^(-?)/$1$money_char/;
1082 This package exists.
1086 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
1088 L<Fax::Hylafax::Client>