package FS::Conf;
use vars qw($base_dir @config_items @base_items @card_types $DEBUG);
+use strict;
use Carp;
use IO::File;
use File::Basename;
},
);
+my %invoice_mode_options = (
+ 'type' => 'select-sub',
+ 'options_sub' => sub {
+ my @modes = qsearch({
+ 'table' => 'invoice_mode',
+ 'extra_sql' => ' WHERE '.
+ $FS::CurrentUser::CurrentUser->agentnums_sql(null => 1),
+ });
+ map { $_->modenum, $_->modename } @modes;
+ },
+ 'option_sub' => sub {
+ my $mode = FS::invoice_mode->by_key(shift);
+ $mode ? $mode->modename : '',
+ },
+ 'per_agent' => 1,
+);
+
my @cdr_formats = (
'' => '',
'default' => 'Default',
},
{
+ 'key' => 'invoice_email_pdf_msgnum',
+ 'section' => 'invoicing',
+ 'description' => 'Message template to send as the text and HTML part of PDF invoices. If not selected, a text and HTML version of the invoice will be sent.',
+ %msg_template_options,
+ },
+
+ {
'key' => 'invoice_email_pdf_note',
'section' => 'invoicing',
'description' => 'If defined, this text will replace the default HTML invoice as the body of emailed PDF invoices.',
},
{
+ 'key' => 'payment_receipt_statement_mode',
+ 'section' => 'notification',
+ 'description' => 'Automatic payments will cause a post-payment statement to be sent to the customer. Select the invoice mode to use for this statement. If unspecified, it will use the "_statement" versions of invoice configuration settings, and have the notice name "Statement".',
+ %invoice_mode_options,
+ },
+
+ {
'key' => 'payment_receipt_msgnum',
'section' => 'notification',
- 'description' => 'Template to use for payment receipts.',
+ 'description' => 'Template to use for manual payment receipts.',
%msg_template_options,
},
},
'option_sub' => sub { require FS::Record;
require FS::agent_type;
- my $agent = FS::Record::qsearchs(
+ my $agent_type = FS::Record::qsearchs(
'agent_type', { 'typenum'=>shift }
);
$agent_type ? $agent_type->atype : '';
'recur_show_zero', 'char', 'NULL', 1, '', '',
'setup_show_zero', 'char', 'NULL', 1, '', '',
'change_to_pkgnum', 'int', 'NULL', '', '', '',
+ 'separate_bill', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'pkgnum',
'unique' => [],
alternate template name, optional
-=item print_text
-
-text attachment arrayref, optional
-
=item subject
email subject, optional
my $tc = $self->template_conf;
- if ( $conf->exists($tc.'html') ) {
+ my @text; # array of lines
+ my $html; # a big string
+ my @related_parts; # will contain the text/HTML alternative, and images
+ my $related; # will contain the multipart/related object
- warn "$me creating HTML/text multipart message"
- if $DEBUG;
+ if ( $conf->exists($tc. 'email_pdf') ) {
+ if ( my $msgnum = $conf->config($tc.'email_pdf_msgnum') ) {
- $return{'nobody'} = 1;
+ warn "$me using '${tc}email_pdf_msgnum' in multipart message"
+ if $DEBUG;
- my $alternative = build MIME::Entity
- 'Type' => 'multipart/alternative',
- #'Encoding' => '7bit',
- 'Disposition' => 'inline'
- ;
+ my $msg_template = FS::msg_template->by_key($msgnum)
+ or die "${tc}email_pdf_msgnum $msgnum not found\n";
+ my %prepared = $msg_template->prepare(
+ cust_main => $self->cust_main,
+ object => $self
+ );
+
+ @text = split(/(?=\n)/, $prepared{'text_body'});
+ $html = $prepared{'html_body'};
- my $data = '';
- if ( $conf->exists($tc. 'email_pdf')
- and scalar($conf->config($tc. 'email_pdf_note')) ) {
+ } elsif ( my @note = $conf->config($tc.'email_pdf_note') ) {
warn "$me using '${tc}email_pdf_note' in multipart message"
if $DEBUG;
- $data = [ map { $_ . "\n" }
- $conf->config($tc.'email_pdf_note')
- ];
+ @text = $conf->config($tc.'email_pdf_note');
+ $html = join('<BR>', @text);
+
+ } # else use the plain text invoice
+ }
- } else {
+ if (!@text) {
- warn "$me not using '${tc}email_pdf_note' in multipart message"
- if $DEBUG;
- if ( ref($args{'print_text'}) eq 'ARRAY' ) {
- $data = $args{'print_text'};
- } elsif ( $conf->exists($tc.'template') ) { #plaintext invoice_template
- $data = [ $self->print_text(\%args) ];
- }
+ warn "$me generating plain text invoice"
+ if $DEBUG;
- }
+ # 'print_text' argument is no longer used
+ @text = $self->print_text(\%args);
- if ( $data ) {
- $alternative->attach(
- 'Type' => 'text/plain',
- 'Encoding' => 'quoted-printable',
- 'Charset' => 'UTF-8',
- #'Encoding' => '7bit',
- 'Data' => $data,
- 'Disposition' => 'inline',
- );
- }
+ }
- my $htmldata;
- my $image = '';
- my $barcode = '';
- if ( $conf->exists($tc.'email_pdf')
- and scalar($conf->config($tc.'email_pdf_note')) ) {
+ my $text_part = build MIME::Entity (
+ 'Type' => 'text/plain',
+ 'Encoding' => 'quoted-printable',
+ 'Charset' => 'UTF-8',
+ #'Encoding' => '7bit',
+ 'Data' => \@text,
+ 'Disposition' => 'inline',
+ );
- $htmldata = join('<BR>', $conf->config($tc.'email_pdf_note') );
+ if (!$html) {
- } else {
+ if ( $conf->exists($tc.'html') ) {
+ warn "$me generating HTML invoice"
+ if $DEBUG;
$args{'from'} =~ /\@([\w\.\-]+)/;
my $from = $1 || 'example.com';
}
my $image_data = $conf->config_binary( $logo, $agentnum);
- $image = build MIME::Entity
+ push @related_parts, build MIME::Entity
'Type' => 'image/png',
'Encoding' => 'base64',
'Data' => $image_data,
if ( ref($self) eq 'FS::cust_bill' && $conf->exists('invoice-barcode') ) {
my $barcode_content_id = join('.', rand()*(2**32), $$, time). "\@$from";
- $barcode = build MIME::Entity
+ push @related_parts, build MIME::Entity
'Type' => 'image/png',
'Encoding' => 'base64',
'Data' => $self->invoice_barcode(0),
$args{'barcode_cid'} = $barcode_content_id;
}
- $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
+ $html = $self->print_html({ 'cid'=>$content_id, %args });
+ }
+
+ }
+
+ if ( $html ) {
+
+ warn "$me creating HTML/text multipart message"
+ if $DEBUG;
+
+ $return{'nobody'} = 1;
+
+ my $alternative = build MIME::Entity
+ 'Type' => 'multipart/alternative',
+ #'Encoding' => '7bit',
+ 'Disposition' => 'inline'
+ ;
+
+ if ( @text ) {
+ $alternative->add_part($text_part);
}
$alternative->attach(
' </title>',
' </head>',
' <body bgcolor="#e8e8e8">',
- $htmldata,
+ $html,
' </body>',
'</html>',
],
#'Filename' => 'invoice.pdf',
);
+ unshift @related_parts, $alternative;
- my @otherparts = ();
- if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
-
- push @otherparts, build MIME::Entity
- 'Type' => 'text/csv',
- 'Encoding' => '7bit',
- 'Data' => [ map { "$_\n" }
- $self->call_details('prepend_billed_number' => 1)
- ],
- 'Disposition' => 'attachment',
- 'Filename' => 'usage-'. $self->invnum. '.csv',
- ;
-
- }
-
- if ( $conf->exists($tc.'email_pdf') ) {
-
- #attaching pdf too:
- # multipart/mixed
- # multipart/related
- # multipart/alternative
- # text/plain
- # text/html
- # image/png
- # application/pdf
-
- my $related = build MIME::Entity 'Type' => 'multipart/related',
- 'Encoding' => '7bit';
-
- #false laziness w/Misc::send_email
- $related->head->replace('Content-type',
- $related->mime_type.
- '; boundary="'. $related->head->multipart_boundary. '"'.
- '; type=multipart/alternative'
- );
-
- $related->add_part($alternative);
-
- $related->add_part($image) if $image;
+ $related = build MIME::Entity 'Type' => 'multipart/related',
+ 'Encoding' => '7bit';
- my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+ #false laziness w/Misc::send_email
+ $related->head->replace('Content-type',
+ $related->mime_type.
+ '; boundary="'. $related->head->multipart_boundary. '"'.
+ '; type=multipart/alternative'
+ );
- $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
+ $related->add_part($_) foreach @related_parts;
- } else {
+ }
- #no other attachment:
- # multipart/related
- # multipart/alternative
- # text/plain
- # text/html
- # image/png
+ my @otherparts = ();
+ if ( ref($self) eq 'FS::cust_bill' && $cust_main->email_csv_cdr ) {
- $return{'content-type'} = 'multipart/related';
- if ($conf->exists('invoice-barcode') && $barcode) {
- $return{'mimeparts'} = [ $alternative, $image, $barcode, @otherparts ];
- } else {
- $return{'mimeparts'} = [ $alternative, $image, @otherparts ];
- }
- $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
- #$return{'disposition'} = 'inline';
+ push @otherparts, build MIME::Entity
+ 'Type' => 'text/csv',
+ 'Encoding' => '7bit',
+ 'Data' => [ map { "$_\n" }
+ $self->call_details('prepend_billed_number' => 1)
+ ],
+ 'Disposition' => 'attachment',
+ 'Filename' => 'usage-'. $self->invnum. '.csv',
+ ;
- }
-
- } else {
+ }
- if ( $conf->exists($tc.'email_pdf') ) {
- warn "$me creating PDF attachment"
- if $DEBUG;
+ if ( $conf->exists($tc.'email_pdf') ) {
- #mime parts arguments a la MIME::Entity->build().
- $return{'mimeparts'} = [
- { $self->mimebuild_pdf(\%args) }
- ];
- }
-
- if ( $conf->exists($tc.'email_pdf')
- and scalar($conf->config($tc.'email_pdf_note')) ) {
+ #attaching pdf too:
+ # multipart/mixed
+ # multipart/related
+ # multipart/alternative
+ # text/plain
+ # text/html
+ # image/png
+ # application/pdf
- warn "$me using '${tc}email_pdf_note'"
- if $DEBUG;
- $return{'body'} = [ map { $_ . "\n" }
- $conf->config($tc.'email_pdf_note')
- ];
+ my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
+ push @otherparts, $pdf;
+ }
+ if (@otherparts) {
+ $return{'content-type'} = 'multipart/mixed'; # of the outer container
+ if ( $html ) {
+ $return{'mimeparts'} = [ $related, @otherparts ];
+ $return{'type'} = 'multipart/related'; # of the first part
} else {
-
- warn "$me not using '${tc}email_pdf_note'"
- if $DEBUG;
- if ( ref($args{'print_text'}) eq 'ARRAY' ) {
- $return{'body'} = $args{'print_text'};
- } else {
- $return{'body'} = [ $self->print_text(\%args) ];
- }
-
+ $return{'mimeparts'} = [ $text_part, @otherparts ];
+ $return{'type'} = 'text/plain';
}
-
+ } elsif ( $html ) { # no PDF or CSV, strip the outer container
+ $return{'mimeparts'} = \@related_parts;
+ $return{'content-type'} = 'multipart/related';
+ $return{'type'} = 'multipart/alternative';
+ } else { # no HTML either
+ $return{'body'} = \@text;
+ $return{'content-type'} = 'text/plain';
}
%return;
my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
or die "invalid invoice number: " . $opt{invnum};
+ if ( $opt{mode} ) {
+ $self->set('mode', $opt{mode});
+ }
+
my %args = map {$_ => $opt{$_}}
grep { $opt{$_} }
qw( from notice_name no_coupon template );
my $sql = 'SELECT SUM(COALESCE(amount,0)) FROM cust_bill_pkg_detail '.
' WHERE billpkgnum = '. $self->billpkgnum;
- $sql .= " AND classnum = $classnum" if defined($classnum);
+ if (defined $classnum) {
+ if ($classnum =~ /^(\d+)$/) {
+ $sql .= " AND classnum = $1";
+ } elsif ($classnum eq '') {
+ $sql .= " AND classnum IS NULL";
+ }
+ }
my $sth = dbh->prepare($sql) or die dbh->errstr;
$sth->execute or die $sth->errstr;
my ( $setuptax, $taxclass ); #internal taxes
my ( $taxproduct, $override ); #vendor (CCH) taxes
my $no_auto = '';
+ my $separate_bill = '';
my $cust_pkg_ref = '';
my ( $bill_now, $invoice_terms ) = ( 0, '' );
my $locationnum;
$bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
$invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
$locationnum = $_[0]->{locationnum} || $self->ship_locationnum;
- } else {
+ $separate_bill = $_[0]->{separate_bill} || '';
+ } else { # yuck
$amount = shift;
$setup_cost = '';
$quantity = 1;
'quantity' => $quantity,
'start_date' => $start_date,
'no_auto' => $no_auto,
+ 'separate_bill' => $separate_bill,
'locationnum'=> $locationnum,
} );
push @{ $cust_bill_pkg{$pass} }, @transfer_items;
# treating this as recur, just because most charges are recur...
${$total_recur{$pass}} += $_->recur foreach @transfer_items;
+
+ # currently not considering separate_bill here, as it's for
+ # one-time charges only
}
foreach my $part_pkg ( @part_pkg ) {
$cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
- my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
+ my $pass = '';
+ if ( $cust_pkg->separate_bill ) {
+ # if no_auto is also set, that's fine. we just need to not have
+ # invoices that are both auto and no_auto, and since the package
+ # gets an invoice all to itself, it will only be one or the other.
+ $pass = $cust_pkg->pkgnum;
+ if (!exists $cust_bill_pkg{$pass}) { # it may not exist yet
+ push @passes, $pass;
+ $total_setup{$pass} = do { my $z = 0; \$z };
+ $total_recur{$pass} = do { my $z = 0; \$z };
+ # it also needs its own tax context
+ $tax_engines{$pass} = FS::TaxEngine->new(
+ cust_main => $self,
+ invoice_time => $invoice_time,
+ cancel => $options{cancel}
+ );
+ $cust_bill_pkg{$pass} = [];
+ }
+ } elsif ( ($cust_pkg->no_auto || $part_pkg->no_auto) ) {
+ $pass = 'no_auto';
+ }
my $next_bill = $cust_pkg->getfield('bill') || 0;
my $error;
} #foreach my $cust_pkg
- #if the customer isn't on an automatic payby, everything can go on a single
- #invoice anyway?
- #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
- #merge everything into one list
- #}
-
- foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
+ foreach my $pass (@passes) { # keys %cust_bill_pkg )
my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} });
'custnum' => $cust_main->custnum,
};
- $error = $queue->insert(
+ my %opt = (
'invnum' => $cust_bill->invnum,
- 'template' => 'statement',
- 'notice_name' => 'Statement',
'no_coupon' => 1,
);
+ if ( my $mode = $conf->config('payment_receipt_statement_mode') ) {
+ $opt{'mode'} = $mode;
+ } else {
+ # backward compatibility, no good fix for this yet as some people may
+ # still have "invoice_latex_statement" and such options
+ $opt{'template'} = 'statement';
+ $opt{'notice_name'} = 'Statement';
+ }
+
+ $error = $queue->insert(%opt);
+
}
warn "send_receipt: $error\n" if $error;
|| $self->ut_numbern('resume')
|| $self->ut_numbern('expire')
|| $self->ut_numbern('dundate')
- || $self->ut_enum('no_auto', [ '', 'Y' ])
- || $self->ut_enum('waive_setup', [ '', 'Y' ])
+ || $self->ut_flag('no_auto', [ '', 'Y' ])
+ || $self->ut_flag('waive_setup', [ '', 'Y' ])
+ || $self->ut_flag('separate_bill')
|| $self->ut_textn('agent_pkgid')
|| $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
|| $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
setup
susp adjourn resume expire start_date contract_end dundate
change_date change_pkgpart change_locationnum
- manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
+ manual_flag no_auto separate_bill quantity agent_pkgid
+ recur_show_zero setup_show_zero
),
};
- start_date: the date when it will be billed
- amount: the setup fee to be charged
- quantity: the multiplier for the setup fee
+- separate_bill: whether to put the charge on a separate invoice
If you pass 'adjust_commission' => 1, and the classnum changes, and there are
commission credits linked to this charge, they will be recalculated.
}
if ( !$self->get('setup') ) {
- # not yet billed, so allow amount, setup_cost, quantity and start_date
+ # not yet billed, so allow amount, setup_cost, quantity, start_date,
+ # and separate_bill
if ( exists($opt{'amount'})
and $part_pkg->option('setup_fee') != $opt{'amount'}
$self->set('start_date', $opt{'start_date'});
}
+ if ( exists($opt{'separate_bill'})
+ and $opt{'separate_bill'} ne $self->separate_bill ) {
+
+ $self->set('separate_bill', $opt{'separate_bill'});
+ }
+
} # else simply ignore them; the UI shouldn't allow editing the fields
my %opt = @_;
my $locale = $opt{'locale'} || '';
- my $conf = FS::Conf->new(locale => $locale);
+ my $conf = FS::Conf->new({ locale => $locale });
$locale ||= $conf->config('locale') || 'en_US';
my %locale_info = FS::Locales->locale_info($locale);
my $language_name = $locale_info{'name'};
- my $self = { conf => FS::Conf->new(locale => $locale),
+ my $self = { conf => FS::Conf->new({ locale => $locale }),
csv => Text::CSV_XS->new({ binary => 1 }),
inbound => ($opt{'inbound'} ? 1 : 0),
buffer => ($opt{'buffer'} || []),
_date
_date_pretty
due_date
- due_date2str
- )],
+ ),
+ [ due_date2str => sub { shift->due_date2str('short') } ],
+ ],
#XXX not really thinking about cust_bill substitutions quite yet
# for welcome and limit warning messages
map { $_->taxproductnum }
$self->expand_cch_taxproduct
);
- $extra_sql .= "AND taxproductnum IN($tpnums)";
+
+ # if there are no taxproductnums, there are no matching tax classes
+ return if length($tpnums) == 0;
+
+ $extra_sql .= " AND taxproductnum IN($tpnums)";
my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
'tax_override' => $override,
'quantity' => $quantity,
'start_date' => $start_date,
+ 'separate_bill' => scalar($cgi->param('separate_bill')),
);
} else { # the usual case: new one-time charge
: ''
),
'no_auto' => scalar($cgi->param('no_auto')),
+ 'separate_bill' => scalar($cgi->param('separate_bill')),
'pkg' => scalar($cgi->param('pkg')),
'setuptax' => scalar($cgi->param('setuptax')),
'taxclass' => scalar($cgi->param('taxclass')),
noinit => 1,
}
&>
-% }
-% unless ($billed) {
-<TR>
- <TD ALIGN="right"><% mt('Tax exempt') |h %> </TD>
- <TD><INPUT TYPE="checkbox" NAME="setuptax" VALUE="Y" <% $cgi->param('setuptax') ? 'CHECKED' : '' %>></TD>
-</TR>
+ <& /elements/tr-checkbox.html,
+ label => emt('Invoice this charge separately'),
+ field => 'separate_bill',
+ value => 'Y',
+ curr_value => $cust_pkg->get('separate_bill'),
+ &>
+ <TR>
+ <TD ALIGN="right"><% mt('Tax exempt') |h %> </TD>
+ <TD><INPUT TYPE="checkbox" NAME="setuptax" VALUE="Y" <% $cgi->param('setuptax') ? 'CHECKED' : '' %>></TD>
+ </TR>
-<& /elements/tr-select-taxclass.html, 'curr_value' => $part_pkg->get('taxclass') &>
+ <& /elements/tr-select-taxclass.html, 'curr_value' => $part_pkg->get('taxclass') &>
-<& /elements/tr-select-taxproduct.html, 'label' => emt('Tax product'), 'onclick' => 'parent.taxproductmagic(this);', 'curr_value' => $part_pkg->get('taxproductnum') &>
-% }
+ <& /elements/tr-select-taxproduct.html, 'label' => emt('Tax product'), 'onclick' => 'parent.taxproductmagic(this);', 'curr_value' => $part_pkg->get('taxproductnum') &>
+% }
% } else { # new one-time charge
});
</SCRIPT>
+<& /elements/tr-checkbox.html,
+ label => emt('Invoice this charge separately'),
+ field => 'separate_bill',
+ value => 'Y'
+&>
+
% }
% if ( ! $quotationnum && $cust_main->payby =~ /^(CARD|CHEK)$/ ) {
'actionlabel' => emt('Modify'),
'cust_pkg' => $cust_pkg,
'width' => 690,
- 'height' => 380,
+ 'height' => 440,
);
}
<% pkg_status_row_noauto( $cust_pkg, %opt ) %>
+ <% pkg_status_row_separate_bill( $cust_pkg, %opt ) %>
+
<% pkg_status_row_discount( $cust_pkg, %opt ) %>
% unless ( $cust_pkg->order_date eq $cust_pkg->get('susp') ) { #on hold
<% pkg_status_row_noauto( $cust_pkg, %opt ) %>
+ <% pkg_status_row_separate_bill( $cust_pkg, %opt ) %>
+
<% pkg_status_row_discount( $cust_pkg, %opt ) %>
<% pkg_status_row_if(
<% pkg_status_row_noauto( $cust_pkg, %opt ) %>
+ <% pkg_status_row_separate_bill( $cust_pkg, %opt ) %>
+
<% pkg_status_row_discount( $cust_pkg, %opt ) %>
<% pkg_status_row_if($cust_pkg, emt('Start billing'), 'start_date', %opt) %>
<% pkg_status_row_noauto( $cust_pkg, %opt ) %>
+ <% pkg_status_row_separate_bill( $cust_pkg, %opt ) %>
+
<% pkg_status_row_discount( $cust_pkg, %opt ) %>
<% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
<% pkg_status_row_noauto( $cust_pkg, %opt ) %>
+ <% pkg_status_row_separate_bill( $cust_pkg, %opt ) %>
+
<% pkg_status_row_discount( $cust_pkg, %opt ) %>
<% pkg_status_row($cust_pkg, emt('Setup'), 'setup', %opt) %>
pkg_status_row_colspan( $cust_pkg, emt("No automatic $what charge"), '');
}
+sub pkg_status_row_separate_bill {
+ my $cust_pkg = shift;
+ return '' unless $cust_pkg->separate_bill;
+ pkg_status_row_colspan( $cust_pkg, emt("Invoiced separately") );
+}
+
sub pkg_status_row_discount {
my( $cust_pkg, %opt ) = @_;