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 email_sender_transport_or_error
20 states_hash counties cities state_label
23 generate_ps generate_pdf do_print
33 FS::Misc - Miscellaneous subroutines
37 use FS::Misc qw(send_email);
43 Miscellaneous subroutines. This module contains miscellaneous subroutines
44 called from multiple other modules. These are not OO or necessarily related,
45 but are collected here to eliminate code duplication.
47 =head1 DISABLE ALL NOTICES
49 Set $FS::Misc::DISABLE_ALL_NOTICES to suppress:
53 =item FS::cust_bill::send_csv
55 =item FS::cust_bill::spool_csv
57 =item FS::msg_template::email::send_prepared
59 =item FS::Misc::send_email
61 =item FS::Misc::do_print
63 =item FS::Misc::send_fax
65 =item FS::Template_Mixin::postal_mail_fsinc
71 $DISABLE_ALL_NOTICES = 0;
77 =item send_email OPTION => VALUE ...
89 (required) comma-separated scalar or arrayref of recipients
97 (optional) MIME type for the body
101 (required unless I<nobody> is true) arrayref of body text lines
105 (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().
109 (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,
110 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
112 =item content-encoding
114 (optional) when using nobody, optional top-level MIME
115 encoding which, if specified, overrides the default "7bit".
119 (optional) type parameter for multipart/related messages
123 (optional) L<FS::cust_main> key; if passed, the message will be logged
124 (if logging is enabled) with this custnum.
128 (optional) L<FS::msg_template> key, for logging.
134 use vars qw( $conf );
137 use Email::Sender::Simple qw(sendmail);
138 use Email::Sender::Transport::SMTP;
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 push @to, $options{bcc} if defined($options{bcc});
277 # fully unpack all addresses found in @to (including Bcc) to make the
280 foreach my $dest (@to) {
281 push @env_to, map { $_->address } Email::Address->parse($dest);
284 my $transport = email_sender_transport_or_error($domain);
287 if ( ref($transport) ) {
289 local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
290 local $@; # just in case
291 eval { sendmail($message, { transport => $transport,
295 if (ref($@) and $@->isa('Email::Sender::Failure')) {
296 $error = $@->code.' ' if $@->code;
297 $error .= $@->message;
307 if ( $conf->exists('log_sent_mail') ) {
308 my $cust_msg = FS::cust_msg->new({
309 'env_from' => $options{'from'},
310 'env_to' => join(', ', @env_to),
311 'header' => $message->header_as_string,
312 'body' => $message->body_as_string,
315 'custnum' => $options{'custnum'},
316 'msgnum' => $options{'msgnum'},
317 'status' => ($error ? 'failed' : 'sent'),
318 'msgtype' => $options{'msgtype'},
320 my $log_error = $cust_msg->insert;
321 warn "Error logging message: $log_error\n" if $log_error; # at least warn
328 sub email_sender_transport_or_error {
331 my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
335 my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
336 $smtp_opt{'port'} = $port;
338 if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
339 $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
340 } elsif ( defined($enc) && $enc eq 'starttls') {
341 return "SMTP settings misconfiguration: STARTTLS enabled in ".
342 "smtp-encryption but smtp-username or smtp-password missing";
345 if ( defined($enc) ) {
346 $smtp_opt{'ssl'} = 'starttls' if $enc eq 'starttls';
347 $smtp_opt{'ssl'} = 1 if $enc eq 'tls';
350 Email::Sender::Transport::SMTP->new( %smtp_opt );
354 =item generate_email OPTION => VALUE ...
362 Sender address, required
366 Recipient address, required
370 Blind copy address, optional
374 email subject, required
378 Email body (HTML alternative). Arrayref of lines, or scalar.
380 Will be placed inside an HTML <BODY> tag.
384 Email body (Text alternative). Arrayref of lines, or scalar.
386 =item custnum, msgnum (optional)
388 Customer and template numbers, passed through to send_email for logging.
392 Constructs a multipart message from text_body and html_body.
396 #false laziness w/FS::cust_bill::generate_email
404 my $me = '[FS::Misc::generate_email]';
406 my @fields = qw(from to bcc subject custnum msgnum msgtype);
408 @return{@fields} = @args{@fields};
410 warn "$me creating HTML/text multipart message"
413 $return{'nobody'} = 1;
415 my $alternative = build MIME::Entity
416 'Type' => 'multipart/alternative',
417 'Encoding' => '7bit',
418 'Disposition' => 'inline'
422 if ( ref($args{'text_body'}) eq 'ARRAY' ) {
423 $data = join("\n", @{ $args{'text_body'} });
425 $data = $args{'text_body'};
428 $alternative->attach(
429 'Type' => 'text/plain',
430 'Encoding' => 'quoted-printable',
431 'Charset' => 'UTF-8',
432 #'Encoding' => '7bit',
433 'Data' => Encode::encode_utf8($data),
434 'Disposition' => 'inline',
438 if ( ref($args{'html_body'}) eq 'ARRAY' ) {
439 @html_data = @{ $args{'html_body'} };
441 @html_data = split(/\n/, $args{'html_body'});
444 $alternative->attach(
445 'Type' => 'text/html',
446 'Encoding' => 'quoted-printable',
447 'Data' => [ '<html>',
450 ' '. encode_entities($return{'subject'}),
453 ' <body bgcolor="#ffffff">',
454 ( map Encode::encode_utf8($_), @html_data ),
458 'Disposition' => 'inline',
459 #'Filename' => 'invoice.pdf',
462 #no other attachment:
464 # multipart/alternative
468 $return{'content-type'} = 'multipart/related';
469 $return{'mimeparts'} = [ $alternative ];
470 $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
471 #$return{'disposition'} = 'inline';
477 =item send_fax OPTION => VALUE ...
481 I<dialstring> - (required) 10-digit phone number w/ area code
483 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
487 I<docfile> - (required) Filename of PostScript TIFF Class F document
489 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
498 die 'HylaFAX support has not been configured.'
499 unless $conf->exists('hylafax');
501 if ( $DISABLE_ALL_NOTICES ) {
502 warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
507 require Fax::Hylafax::Client;
511 if ($@ =~ /^Can't locate Fax.*/) {
512 die "You must have Fax::Hylafax::Client installed to use invoice faxing."
518 my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
520 die 'Called send_fax without a \'dialstring\'.'
521 unless exists($options{'dialstring'});
523 if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
524 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
525 my $fh = new File::Temp(
526 TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
529 ) or die "can't open temp file: $!\n";
531 $options{docfile} = $fh->filename;
533 print $fh @{$options{'docdata'}};
536 delete $options{'docdata'};
539 die 'Called send_fax without a \'docfile\' or \'docdata\'.'
540 unless exists($options{'docfile'});
542 #FIXME: Need to send canonical dialstring to HylaFAX, but this only
545 $options{'dialstring'} =~ s/[^\d\+]//g;
546 if ($options{'dialstring'} =~ /^\d{10}$/) {
547 $options{dialstring} = '+1' . $options{'dialstring'};
549 return 'Invalid dialstring ' . $options{'dialstring'} . '.';
552 my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
554 if ($faxjob->success) {
555 warn "Successfully queued fax to '$options{dialstring}' with jobid " .
560 return 'Error while sending FAX: ' . $faxjob->trace;
565 =item states_hash COUNTRY
567 Returns a list of key/value pairs containing state (or other sub-country
568 division) abbriviations and names.
572 use FS::Record qw(qsearch);
573 use Locale::SubCountry;
578 #a hash? not expecting an explosion of business from unrecognized countries..
579 return states_hash_nosubcountry($country) if $country eq 'XC';
583 map { s/[\n\r]//g; $_; }
587 'table' => 'cust_main_county',
588 'hashref' => { 'country' => $country },
589 'extra_sql' => 'GROUP BY state',
592 #it could throw a fatal "Invalid country code" error (for example "AX")
593 my $subcountry = eval { new Locale::SubCountry($country) }
594 or return (); # ( '', '(n/a)' );
596 #"i see your schwartz is as big as mine!"
597 map { ( $_->[0] => $_->[1] ) }
598 sort { $a->[1] cmp $b->[1] }
599 map { [ $_ => state_label($_, $subcountry) ] }
603 sub states_hash_nosubcountry {
608 map { s/[\n\r]//g; $_; }
612 'table' => 'cust_main_county',
613 'hashref' => { 'country' => $country },
614 'extra_sql' => 'GROUP BY state',
617 #"i see your schwartz is as big as mine!"
618 map { ( $_->[0] => $_->[1] ) }
619 sort { $a->[1] cmp $b->[1] }
624 =item counties STATE COUNTRY
626 Returns a list of counties for this state and country.
631 my( $state, $country ) = @_;
633 map { $_ } #return num_counties($state, $country) unless wantarray;
634 sort map { s/[\n\r]//g; $_; }
637 'select' => 'DISTINCT county',
638 'table' => 'cust_main_county',
639 'hashref' => { 'state' => $state,
640 'country' => $country,
645 =item cities COUNTY STATE COUNTRY
647 Returns a list of cities for this county, state and country.
652 my( $county, $state, $country ) = @_;
654 map { $_ } #return num_cities($county, $state, $country) unless wantarray;
655 sort map { s/[\n\r]//g; $_; }
658 'select' => 'DISTINCT city',
659 'table' => 'cust_main_county',
660 'hashref' => { 'county' => $county,
662 'country' => $country,
667 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
672 my( $state, $country ) = @_;
674 unless ( ref($country) ) {
675 $country = eval { new Locale::SubCountry($country) }
680 # US kludge to avoid changing existing behaviour
681 # also we actually *use* the abbriviations...
682 my $full_name = $country->country_code eq 'US'
684 : $country->full_name($state);
686 $full_name = '' if $full_name eq 'unknown';
687 $full_name =~ s/\(see also.*\)\s*$//;
688 $full_name .= " ($state)" if $full_name;
690 $full_name || $state || '(n/a)';
696 Returns a hash reference of the accepted credit card types. Keys are shorter
697 identifiers and values are the longer strings used by the system (see
698 L<Business::CreditCard>).
705 my $conf = new FS::Conf;
708 #displayname #value (Business::CreditCard)
709 "VISA" => "VISA card",
710 "MasterCard" => "MasterCard",
711 "Discover" => "Discover card",
712 "American Express" => "American Express card",
713 "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
714 "enRoute" => "enRoute",
716 "BankCard" => "BankCard",
717 "Switch" => "Switch",
720 my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
721 if ( @conf_card_types ) {
722 #perhaps the hash is backwards for this, but this way works better for
723 #usage in selfservice
724 %card_types = map { $_ => $card_types{$_} }
727 grep { $card_types{$d} eq $_ } @conf_card_types
737 Returns a hash reference of allowed package billing frequencies.
742 tie my %freq, 'Tie::IxHash', (
743 '0' => '(no recurring fee)',
746 '2d' => 'every two days',
747 '3d' => 'every three days',
749 '2w' => 'biweekly (every 2 weeks)',
751 '45d' => 'every 45 days',
752 '2' => 'bimonthly (every 2 months)',
753 '3' => 'quarterly (every 3 months)',
754 '4' => 'every 4 months',
755 '137d' => 'every 4 1/2 months (137 days)',
756 '6' => 'semiannually (every 6 months)',
758 '13' => 'every 13 months (annually +1 month)',
759 '24' => 'biannually (every 2 years)',
760 '36' => 'triannually (every 3 years)',
761 '48' => '(every 4 years)',
762 '60' => '(every 5 years)',
763 '120' => '(every 10 years)',
768 =item generate_ps FILENAME
770 Returns an postscript rendition of the LaTex file, as a scalar.
771 FILENAME does not contain the .tex suffix and is unlinked by this function.
775 use String::ShellQuote;
780 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
785 my $papersize = $conf->config('papersize') || 'letter';
787 local($SIG{CHLD}) = sub {};
789 system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
790 or die "dvips failed";
792 open(POSTSCRIPT, "<$file.ps")
793 or die "can't open $file.ps: $! (error in LaTeX template?)\n";
795 unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
796 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
800 if ( $conf->exists('lpr-postscript_prefix') ) {
801 my $prefix = $conf->config('lpr-postscript_prefix');
802 $ps .= eval qq("$prefix");
805 while (<POSTSCRIPT>) {
811 if ( $conf->exists('lpr-postscript_suffix') ) {
812 my $suffix = $conf->config('lpr-postscript_suffix');
813 $ps .= eval qq("$suffix");
820 =item generate_pdf FILENAME
822 Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not
823 contain the .tex suffix and is unlinked by this function.
827 use String::ShellQuote;
832 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
835 #system('pdflatex', "$file.tex");
836 #system('pdflatex', "$file.tex");
837 #! LaTeX Error: Unknown graphics extension: .eps.
841 my $sfile = shell_quote $file;
843 #system('dvipdf', "$file.dvi", "$file.pdf" );
844 my $papersize = $conf->config('papersize') || 'letter';
846 local($SIG{CHLD}) = sub {};
849 "dvips -q -f $sfile.dvi -t $papersize ".
850 "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
853 or die "dvips | gs failed: $!";
855 open(PDF, "<$file.pdf")
856 or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
858 unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
859 unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
875 #my $sfile = shell_quote $file;
879 '-interaction=batchmode',
880 '\AtBeginDocument{\RequirePackage{pslatex}}',
881 '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
882 '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
887 my $timeout = 30; #? should be more than enough
891 local($SIG{CHLD}) = sub {};
892 run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
893 or warn "bad exit status from pslatex pass $_\n";
897 return if -e "$file.dvi" && -s "$file.dvi";
898 die "pslatex $file.tex failed, see $file.log for details?\n";
902 =item do_print ARRAYREF [, OPTION => VALUE ... ]
904 Sends the lines in ARRAYREF to the printer.
906 Options available are:
912 Uses this agent's 'lpr' configuration setting override instead of the global
917 Uses this command instead of the configured lpr command (overrides both the
918 global value and agentnum).
923 my( $data, %opt ) = @_;
925 if ( $DISABLE_ALL_NOTICES ) {
926 warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
930 my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
932 : $conf->config('lpr', $opt{'agentnum'} );
935 local($SIG{CHLD}) = sub {};
936 run3 $lpr, $data, \$outerr, \$outerr;
938 $outerr = ": $outerr" if length($outerr);
939 die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
944 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
946 Converts the filehandle referenced by FILEREF from fixed length record
947 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
948 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each
949 should return the value to be substituted in place of its single argument.
951 Returns false on success or an error if one occurs.
956 my( $fhref, $countref, $lengths, $callbacks) = @_;
958 eval { require Text::CSV_XS; };
962 my $unpacker = new Text::CSV_XS;
964 my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
966 my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
967 my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
970 ) or return "can't open temp file: $!\n"
973 while ( defined(my $line=<$ofh>) ) {
979 return "unexpected input at line $$countref: $line".
980 " -- expected $total but received ". length($line)
981 unless length($line) == $total;
983 $unpacker->combine( map { my $i = $column++;
984 defined( $callbacks->[$i] )
985 ? &{ $callbacks->[$i] }( $_ )
987 } unpack( $template, $line )
989 or return "invalid data for CSV: ". $unpacker->error_input;
991 print $fh $unpacker->string(), "\n"
992 or return "can't write temp file: $!\n";
996 if ( $template ) { close $$fhref; $$fhref = $fh }
1002 =item ocr_image IMAGE_SCALAR
1004 Runs OCR on the provided image data and returns a list of text lines.
1009 my $logo_data = shift;
1011 #XXX use conf dir location from Makefile
1012 my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
1013 my $fh = new File::Temp(
1014 TEMPLATE => 'bizcard.XXXXXXXX',
1015 SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
1018 ) or die "can't open temp file: $!\n";
1020 my $filename = $fh->filename;
1022 print $fh $logo_data;
1025 local($SIG{CHLD}) = sub {};
1027 run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
1028 or die "ocroscript recognize failed\n";
1030 run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
1031 or die "ocroscript hocr-to-text failed\n";
1033 my @lines = split(/\n/, <OUT> );
1035 foreach (@lines) { s/\.c0m\s*$/.com/; }
1040 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
1043 Use Unicode::Truncate truncate_egc instead
1045 A replacement for "substr" that counts raw bytes rather than logical
1046 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
1047 rather than output them. Unlike real "substr", is not an lvalue.
1051 # sub bytes_substr {
1052 # my ($string, $offset, $length, $repl) = @_;
1053 # my $bytes = substr(
1054 # Encode::encode('utf8', $string),
1057 # Encode::encode('utf8', $repl)
1059 # my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
1060 # return Encode::decode('utf8', $bytes, $chk);
1065 Accepts a postive or negative numerical value.
1066 Returns amount formatted for display,
1067 including money character.
1073 my $money_char = $conf->{'money_char'} || '$';
1074 $amount = sprintf("%0.2f",$amount);
1075 $amount =~ s/^(-?)/$1$money_char/;
1083 This package exists.
1087 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
1089 L<Fax::Hylafax::Client>