From: Ivan Kohler Date: Thu, 26 Jul 2012 21:05:08 +0000 (-0700) Subject: Merge branch 'master' of git.freeside.biz:/home/git/freeside X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=commitdiff_plain;h=8c450aab9bae89373c2c1b35c85597bb52299de3;hp=2528cc7b182781a82844d8bbb1b555560487abc7 Merge branch 'master' of git.freeside.biz:/home/git/freeside --- diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 043594536..37bbf6e17 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -2538,7 +2538,7 @@ and customer address. Include units.', { 'key' => 'manual_process-pkgpart', 'section' => 'billing', - 'description' => 'Package to add to each manual credit card and ACH payments entered from the backend. Enabling this option may be in violation of your merchant agreement(s), so please check them carefully before enabling this option.', + 'description' => 'Package to add to each manual credit card and ACH payment entered by employees from the backend. Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.', 'type' => 'select-part_pkg', }, @@ -2561,6 +2561,56 @@ and customer address. Include units.', }, { + 'key' => 'selfservice_process-pkgpart', + 'section' => 'billing', + 'description' => 'Package to add to each manual credit card and ACH payment entered by the customer themselves in the self-service interface. Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.', + 'type' => 'select-part_pkg', + }, + + { + 'key' => 'selfservice_process-display', + 'section' => 'billing', + 'description' => 'When using selfservice_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.', + 'type' => 'select', + 'select_hash' => [ + 'add' => 'Add fee to amount entered', + 'subtract' => 'Subtract fee from amount entered', + ], + }, + + { + 'key' => 'selfservice_process-skip_first', + 'section' => 'billing', + 'description' => "When using selfservice_process-pkgpart, omit the fee if it is the customer's first payment.", + 'type' => 'checkbox', + }, + + { + 'key' => 'suto_process-pkgpart', + 'section' => 'billing', + 'description' => 'Package to add to each automatic credit card and ACH payment processed by billing events. Enabling this option may be in violation of your merchant agreement(s), so please check them carefully before enabling this option.', + 'type' => 'select-part_pkg', + }, + +# { +# 'key' => 'auto_process-display', +# 'section' => 'billing', +# 'description' => 'When using auto_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.', +# 'type' => 'select', +# 'select_hash' => [ +# 'add' => 'Add fee to amount entered', +# 'subtract' => 'Subtract fee from amount entered', +# ], +# }, + + { + 'key' => 'auto_process-skip_first', + 'section' => 'billing', + 'description' => "When using auto_process-pkgpart, omit the fee if it is the customer's first payment.", + 'type' => 'checkbox', + }, + + { 'key' => 'allow_negative_charges', 'section' => 'billing', 'description' => 'Allow negative charges. Normally not used unless importing data from a legacy system that requires this.', @@ -3475,6 +3525,13 @@ and customer address. Include units.', 'select_enum' => [ 'approve', 'decline' ], }, + { + 'key' => 'batch-errors_to', + 'section' => 'billing', + 'description' => 'Email errors when processing batches to this address. If unspecified, batch processing will stop immediately on error.', + 'type' => 'text', + }, + #lists could be auto-generated from pay_batch info { 'key' => 'batch-fixed_format-CARD', @@ -3935,7 +3992,7 @@ and customer address. Include units.', { 'key' => 'disable_previous_balance', 'section' => 'invoicing', - 'description' => 'Disable inclusion of previous balance, payment, and credit lines on invoices', + 'description' => 'Disable inclusion of previous balance, payment, and credit lines on invoices.', 'type' => 'checkbox', 'per_agent' => 1, }, @@ -3962,6 +4019,13 @@ and customer address. Include units.', }, { + 'key' => 'previous_balance-show_on_statements', + 'section' => 'invoicing', + 'description' => 'Show previous invoices on statements, without itemized charges.', + 'type' => 'checkbox', + }, + + { 'key' => 'balance_due_below_line', 'section' => 'invoicing', 'description' => 'Place the balance due message below a line. Only meaningful when when invoice_sections is false.', @@ -5154,6 +5218,17 @@ and customer address. Include units.', 'description' => 'If set, automatically log users out of the backoffice after this many minutes.', 'type' => 'text', }, + + { + 'key' => 'spreadsheet_format', + 'section' => 'UI', + 'description' => 'Default format for spreadsheet download.', + 'type' => 'select', + 'select_hash' => [ + 'XLS' => 'XLS (Excel 97/2000/XP)', + 'XLSX' => 'XLSX (Excel 2007+)', + ], + }, { key => "apacheroot", section => "deprecated", description => "DEPRECATED", type => "text" }, { key => "apachemachine", section => "deprecated", description => "DEPRECATED", type => "text" }, diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index e26a4b747..51edd97cc 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -91,6 +91,9 @@ if ( -e $addl_handler_use_file ) { use Text::CSV_XS; use Spreadsheet::WriteExcel; use Spreadsheet::WriteExcel::Utility; + use Excel::Writer::XLSX; + use Excel::Writer::XLSX::Utility; + use Business::CreditCard 0.30; #for mask-aware cardtype() use NetAddr::IP; use Net::Ping; diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 3fc26b071..ff40cd673 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1408,6 +1408,7 @@ sub tables_hashref { 'depositor', 'varchar', 'NULL', $char_d, '', '', 'account', 'varchar', 'NULL', 20, '', '', 'teller', 'varchar', 'NULL', 20, '', '', + 'batchnum', 'int', 'NULL', '', '', '', #pay_batch foreign key ], 'primary_key' => 'paynum', #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it# 'unique' => [ [ 'payunique' ] ], @@ -1487,10 +1488,11 @@ sub tables_hashref { 'columns' => [ 'batchnum', 'serial', '', '', '', '', 'agentnum', 'int', 'NULL', '', '', '', - 'payby', 'char', '', 4, '', '', # CARD/CHEK + 'payby', 'char', '', 4, '', '', # CARD/CHEK 'status', 'char', 'NULL', 1, '', '', 'download', @date_type, '', '', 'upload', @date_type, '', '', + 'title', 'varchar', 'NULL',255, '', '', ], 'primary_key' => 'batchnum', 'unique' => [], diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index d1bcec5d5..61cfccba8 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -12,6 +12,7 @@ use Text::Template 1.20; use File::Temp 0.14; use HTML::Entities; use Locale::Country; +use Cwd; use FS::UID; use FS::Record qw( qsearch qsearchs ); use FS::Misc qw( generate_ps generate_pdf ); @@ -133,7 +134,9 @@ sub print_latex { close $lh; $params{'logo_file'} = $lh->filename; - if( $conf->exists('invoice-barcode') && $self->can('invoice_barcode') ) { + if( $conf->exists('invoice-barcode') + && $self->can('invoice_barcode') + && $self->invnum ) { # don't try to barcode statements my $png_file = $self->invoice_barcode($dir); my $eps_file = $png_file; $eps_file =~ s/\.png$/.eps/g; @@ -699,6 +702,8 @@ sub print_generic { warn "$me generating sections\n" if $DEBUG > 1; + # Previous Charges section + # subtotal is the first return value from $self->previous my $previous_section = { 'description' => $self->mt('Previous Charges'), 'subtotal' => $other_money_char. sprintf('%.2f', $pr_total), @@ -801,11 +806,11 @@ sub print_generic { } } - unless ( $conf->exists('disable_previous_balance', $agentnum) - || $conf->exists('previous_balance-summary_only') - || ! $self->can('_items_previous') - ) - { + # previous invoice balances in the Previous Charges section if there + # is one, otherwise in the main detail section + if ( $self->can('_items_previous') && + $self->enable_previous && + ! $conf->exists('previous_balance-summary_only') ) { warn "$me adding previous balances\n" if $DEBUG > 1; @@ -836,9 +841,8 @@ sub print_generic { } } - - if ( @pr_cust_bill && !$conf->exists('disable_previous_balance', $agentnum) ) - { + + if ( @pr_cust_bill && $self->enable_previous ) { push @buf, ['','-----------']; push @buf, [ $self->mt('Total Previous Balance'), $money_char. sprintf("%10.2f", $pr_total) ]; @@ -923,8 +927,10 @@ sub print_generic { } $detail->{'amount'} = ( $old_latex ? '' : $money_char ). $line_item->{'amount'}; - $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). - $line_item->{'unit_amount'}; + if ( exists $line_item->{'unit_amount'} ) { + $detail->{'unit_amount'} = ( $old_latex ? '' : $money_char ). + $line_item->{'unit_amount'}; + } $detail->{'product_code'} = $line_item->{'pkgpart'} || 'N/A'; $detail->{'sdate'} = $line_item->{'sdate'}; @@ -954,7 +960,9 @@ sub print_generic { $invoice_data{current_less_finance} = sprintf('%.2f', $self->charged - $invoice_data{finance_amount} ); - if ( $multisection && !$conf->exists('disable_previous_balance', $agentnum) + # create a major section for previous balance if we have major sections, + # or if previous_section is in summary form + if ( ( $multisection && $self->enable_previous ) || $conf->exists('previous_balance-summary_only') ) { unshift @sections, $previous_section if $pr_total; @@ -1018,25 +1026,26 @@ sub print_generic { push @buf,['','-----------']; push @buf,[$self->mt( - $conf->exists('disable_previous_balance', $agentnum) + (!$self->enable_previous) ? 'Total Charges' : 'Total New Charges' ), $money_char. sprintf("%10.2f",$self->charged) ]; push @buf,['','']; + # calculate total, possibly including total owed on previous + # invoices { my $total = {}; my $item = 'Total'; $item = $conf->config('previous_balance-exclude_from_total') || 'Total New Charges' if $conf->exists('previous_balance-exclude_from_total'); - my $amount = $self->charged + - ( $conf->exists('disable_previous_balance', $agentnum) || - $conf->exists('previous_balance-exclude_from_total') - ? 0 - : $pr_total - ); + my $amount = $self->charged; + if ( $self->enable_previous and !$conf->exists('previous_balance-exclude_from_total') ) { + $amount += $pr_total; + } + $total->{'total_item'} = &$embolden_function($self->mt($item)); $total->{'total_amount'} = &$embolden_function( $other_money_char. sprintf( '%.2f', $amount ) ); @@ -1058,12 +1067,13 @@ sub print_generic { ]; push @buf,['','']; } - - unless ( $conf->exists('disable_previous_balance', $agentnum) - || ! $self->can('_items_credits') - || ! $self->can('_items_payments') - ) - { + + # if we're showing previous invoices, also show previous + # credits and payments + if ( $self->enable_previous + and $self->can('_items_credits') + and $self->can('_items_payments') ) + { #foreach my $thing ( sort { $a->_date <=> $b->_date } $self->_items_credits, $self->_items_payments # credits diff --git a/FS/FS/access_user.pm b/FS/FS/access_user.pm index 5d5cc126c..509cc0950 100644 --- a/FS/FS/access_user.pm +++ b/FS/FS/access_user.pm @@ -511,6 +511,42 @@ sub default_customer_view { } +=item spreadsheet_format [ OVERRIDE ] + +Returns a hashref of this user's Excel spreadsheet download settings: +'extension' (xls or xlsx), 'class' (Spreadsheet::WriteExcel or +Excel::Writer::XLSX), and 'mime_type'. If OVERRIDE is 'XLS' or 'XLSX', +use that instead of the user's setting. + +=cut + +# is there a better place to put this? +my %formats = ( + XLS => { + extension => '.xls', + class => 'Spreadsheet::WriteExcel', + mime_type => 'application/vnd.ms-excel', + }, + XLSX => { + extension => '.xlsx', + class => 'Excel::Writer::XLSX', + mime_type => # it's on wikipedia, it must be true + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } +); + +sub spreadsheet_format { + my $self = shift; + my $override = shift; + + my $f = $override + || $self->option('spreadsheet_format') + || $conf->config('spreadsheet_format') + || 'XLS'; + + $formats{$f}; +} + =item is_system_user Returns true if this user has the name of a known system account. These diff --git a/FS/FS/addr_block.pm b/FS/FS/addr_block.pm index e00f587c6..686bdbd18 100755 --- a/FS/FS/addr_block.pm +++ b/FS/FS/addr_block.pm @@ -223,43 +223,45 @@ sub cidr { $self->NetAddr->cidr; } -=item free_addrs +=item next_free_addr Returns a NetAddr::IP object corresponding to the first unassigned address in the block (other than the network, broadcast, or gateway address). If there are no free addresses, returns nothing. There are never free addresses when manual_flag is true. -=item next_free_addr - -Returns a NetAddr::IP object for the first unassigned address in the block, -or '' if there are none. +There is no longer a method to return all free addresses in a block. =cut -sub free_addrs { +sub next_free_addr { my $self = shift; + my $selfaddr = $self->NetAddr; return if $self->manual_flag; my $conf = new FS::Conf; my @excludeaddr = $conf->config('exclude_ip_addr'); - + my %used = map { $_ => 1 } ( + @excludeaddr, + $selfaddr->addr, + $selfaddr->network->addr, + $selfaddr->broadcast->addr, (map { $_->NetAddr->addr } - ($self, - qsearch('svc_broadband', { blocknum => $self->blocknum })) + qsearch('svc_broadband', { blocknum => $self->blocknum }) ), @excludeaddr ); - grep { !$used{$_->addr} } $self->NetAddr->hostenum; - -} + # just do a linear search of the block + my $freeaddr = $selfaddr->network + 1; + while ( $freeaddr < $selfaddr->broadcast ) { + return $freeaddr unless $used{ $freeaddr->addr }; + $freeaddr++; + } + return; -sub next_free_addr { - my $self = shift; - ($self->free_addrs, '')[0] } =item allocate -- deprecated diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index 83748be1b..c3d48a61c 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -387,6 +387,19 @@ sub previous { $total, @cust_bill; } +=item enable_previous + +Whether to show the 'Previous Charges' section when printing this invoice. +The negation of the 'disable_previous_balance' config setting. + +=cut + +sub enable_previous { + my $self = shift; + my $agentnum = $self->cust_main->agentnum; + !$self->conf->exists('disable_previous_balance', $agentnum); +} + =item cust_bill_pkg Returns the line items (see L) for this invoice. diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 78791dd57..96029415a 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -2465,6 +2465,25 @@ Adds a payment for this invoice to the pending credit card batch (see L), or, if the B option is set to a true value, runs the payment using a realtime gateway. +Options may include: + +B: the amount to be paid; defaults to the customer's balance minus +any payments in transit. + +B: the payment method; defaults to cust_main.payby + +B: runs this as a realtime payment instead of adding it to a +batch. Deprecated. + +B: sets cust_pay_batch.invnum. + +B, B, B, B, B, B: sets +the billing address for the payment; defaults to the customer's billing +location. + +B, B, B: sets the payment account, expiration +date, and name; defaults to those fields in cust_main. + =cut sub batch_card { @@ -2542,10 +2561,10 @@ sub batch_card { 'state' => $options{state} || $loc->state, 'zip' => $options{zip} || $loc->zip, 'country' => $options{country} || $loc->country, - 'payby' => $options{payby} || $loc->payby, - 'payinfo' => $options{payinfo} || $loc->payinfo, - 'exp' => $options{paydate} || $loc->paydate, - 'payname' => $options{payname} || $loc->payname, + 'payby' => $options{payby} || $self->payby, + 'payinfo' => $options{payinfo} || $self->payinfo, + 'exp' => $options{paydate} || $self->paydate, + 'payname' => $options{payname} || $self->payname, 'amount' => $amount, # consolidating } ); diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index e7b953060..bab94c31d 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -968,7 +968,10 @@ sub _make_lines { my @recur_discounts = (); my $sdate; if ( ! $cust_pkg->start_date - and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill', 1) ) + and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1) + || ( $part_pkg->option('suspend_bill', 1) ) + && ! $cust_pkg->option('no_suspend_bill',1) + ) and ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= day_end($time) ) || ( $part_pkg->plan eq 'voip_cdr' diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm index 957043a84..11c13e5dd 100644 --- a/FS/FS/cust_main/Packages.pm +++ b/FS/FS/cust_main/Packages.pm @@ -412,7 +412,11 @@ sub billing_pkgs { my $self = shift; grep { my $part_pkg = $_->part_pkg; $part_pkg->freq ne '' && $part_pkg->freq ne '0' - && ( ! $_->susp || $part_pkg->option('suspend_bill', 1) ); + && ( ! $_->susp || $_->option('suspend_bill',1) + || ( $part_pkg->option('suspend_bill', 1) + && ! $_->option('no_suspend_bill',1) + ) + ); } $self->ncancelled_pkgs; } diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm index 2a2b9d025..c11738665 100644 --- a/FS/FS/cust_pay.pm +++ b/FS/FS/cust_pay.pm @@ -130,6 +130,11 @@ The deposit account number. The teller number. +=item pay_batch + +The number of the batch this payment came from (see L), +or null if it was processed through a realtime gateway or entered manually. + =back =head1 METHODS @@ -514,6 +519,7 @@ sub check { || $self->ut_alphan('depositor') || $self->ut_numbern('account') || $self->ut_numbern('teller') + || $self->ut_foreign_keyn('batchnum', 'pay_batch', 'batchnum') || $self->payinfo_check() ; return $error if $error; @@ -983,6 +989,21 @@ sub _upgrade_data { #class method $class->_upgrade_otaker(%opts); $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it + ### + # migrate batchnums from the misused 'paybatch' field to 'batchnum' + ### + my @cust_pay = qsearch( { + 'table' => 'cust_pay', + 'addl_from' => ' JOIN pay_batch ON cust_pay.paybatch = CAST(pay_batch.batchnum AS text) ', + } ); + foreach my $cust_pay (@cust_pay) { + $cust_pay->set('batchnum' => $cust_pay->paybatch); + $cust_pay->set('paybatch' => ''); + my $error = $cust_pay->replace; + warn "error setting batchnum on cust_pay #".$cust_pay->paynum.":\n $error" + if $error; + } + } =back diff --git a/FS/FS/cust_pay_batch.pm b/FS/FS/cust_pay_batch.pm index 5f21ff4b1..9f2e9ddfc 100644 --- a/FS/FS/cust_pay_batch.pm +++ b/FS/FS/cust_pay_batch.pm @@ -322,6 +322,7 @@ sub approve { 'paid' => $new->paid, '_date' => $new->_date, 'usernum' => $new->usernum, + 'batchnum' => $new->batchnum, } ); $error = $cust_pay->insert; if ( $error ) { diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 22559e95f..aed99e51d 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -970,21 +970,25 @@ sub uncancel { } my $svc_error = $svc_x->insert; - if ( $svc_error && $options{svc_fatal} ) { - $dbh->rollback if $oldAutoCommit; - return $svc_error; - } else { - my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum }); - if ( $cust_svc ) { - my $cs_error = $cust_svc->delete; - if ( $cs_error ) { - $dbh->rollback if $oldAutoCommit; - return $cs_error; + if ( $svc_error ) { + if ( $options{svc_fatal} ) { + $dbh->rollback if $oldAutoCommit; + return $svc_error; + } else { + push @svc_errors, $svc_error; + # is this necessary? svc_Common::insert already deletes the + # cust_svc if inserting svc_x fails. + my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum }); + if ( $cust_svc ) { + my $cs_error = $cust_svc->delete; + if ( $cs_error ) { + $dbh->rollback if $oldAutoCommit; + return $cs_error; + } } - } - } - push @svc_errors, $svc_error if $svc_error; - } + } # svc_fatal + } # svc_error + } #foreach $h_cust_svc #these are pretty rare, but should handle them # - dsl_device (mac addresses) @@ -1189,8 +1193,13 @@ sub suspend { $hash{'resume'} = $resume_date; } + $options{options} ||= {}; + my $new = new FS::cust_pkg ( \%hash ); - $error = $new->replace( $self, options => { $self->options } ); + $error = $new->replace( $self, options => { $self->options, + %{ $options{options} }, + } + ); if ( $error ) { $dbh->rollback if $oldAutoCommit; return $error; @@ -2700,7 +2709,7 @@ sub seconds_since_sqlradacct { grep { my $part_svc = $_->part_svc; $part_svc->svcdb eq 'svc_acct' - && scalar($part_svc->part_export('sqlradius')); + && scalar($part_svc->part_export_usage); } $self->cust_svc ) { $seconds += $cust_svc->seconds_since_sqlradacct($start, $end); @@ -2732,7 +2741,7 @@ sub attribute_since_sqlradacct { grep { my $part_svc = $_->part_svc; $part_svc->svcdb eq 'svc_acct' - && scalar($part_svc->part_export('sqlradius')); + && scalar($part_svc->part_export_usage); } $self->cust_svc ) { $sum += $cust_svc->attribute_since_sqlradacct($start, $end, $attrib); @@ -3590,20 +3599,40 @@ sub search { 'LEFT JOIN part_pkg USING ( pkgpart ) '. 'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '; - my $count_query = "SELECT COUNT(*) FROM cust_pkg $addl_from $extra_sql"; + my $select; + my $count_query; + if ( $params->{'select_zip5'} ) { + my $zip = 'cust_location.zip'; + + $select = "DISTINCT substr($zip,1,5) as zip"; + $orderby = "ORDER BY substr($zip,1,5)"; + $addl_from .= 'LEFT JOIN cust_location ON ( + cust_location.locationnum = COALESCE( + cust_pkg.locationnum, + cust_main.ship_locationnum, + cust_main.bill_locationnum + ) + )'; + $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )"; + } else { + $select = join(', ', + 'cust_pkg.*', + ( map "part_pkg.$_", qw( pkg freq ) ), + 'pkg_class.classname', + 'cust_main.custnum AS cust_main_custnum', + FS::UI::Web::cust_sql_fields( + $params->{'cust_fields'} + ), + ); + $count_query = 'SELECT COUNT(*)'; + } + + $count_query .= " FROM cust_pkg $addl_from $extra_sql"; my $sql_query = { 'table' => 'cust_pkg', 'hashref' => {}, - 'select' => join(', ', - 'cust_pkg.*', - ( map "part_pkg.$_", qw( pkg freq ) ), - 'pkg_class.classname', - 'cust_main.custnum AS cust_main_custnum', - FS::UI::Web::cust_sql_fields( - $params->{'cust_fields'} - ), - ), + 'select' => $select, 'extra_sql' => $extra_sql, 'order_by' => $orderby, 'addl_from' => $addl_from, diff --git a/FS/FS/cust_statement.pm b/FS/FS/cust_statement.pm index 45fae1ccf..9954b7b90 100644 --- a/FS/FS/cust_statement.pm +++ b/FS/FS/cust_statement.pm @@ -6,6 +6,8 @@ use FS::Record qw( dbh qsearch ); #qsearchs ); use FS::cust_main; use FS::cust_bill; +use List::Util qw( sum ); + =head1 NAME FS::cust_statement - Object methods for cust_statement records @@ -61,8 +63,13 @@ Note that this stores the hash reference, not a distinct copy of the hash it points to. You can ask the object for a copy with the I method. Pass "statementnum => 'ALL'" to create a temporary statement that includes -all of the customer's invoices. This statement can't be inserted and won't -set the statementnum field on any invoices. +all of the customer's open invoices. This statement can't be inserted and +won't set the statementnum field on any invoices. + +Pass "invnum => number" to create a temporary statement including only +the specified invoice. This is functionally the same as the invoice itself, +but will be rendered using the statement template and other +statement-specific options. =cut @@ -170,13 +177,23 @@ Returns the associated invoices (cust_bill records) for this statement. sub cust_bill { my $self = shift; # we use it about a thousand times, let's cache it - $self->{Hash}->{cust_bill} ||= [ - qsearch('cust_bill', { - $self->statementnum eq 'ALL' ? - ('custnum' => $self->custnum) : - ('statementnum' => $self->statementnum) - } ) - ]; + if ( !exists($self->{Hash}->{cust_bill}) ) { + my @cust_bill; + if ( $self->invnum && $self->invnum =~ /^\d+$/ ) { + # one specific invoice + @cust_bill = FS::cust_bill->by_key($self->invnum) + or die "unknown invnum '".$self->invnum."'"; + $self->set('custnum' => $cust_bill[0]->custnum); + } elsif ( $self->statementnum eq 'ALL' ) { + # all open invoices + @cust_bill = $self->cust_main->open_cust_bill; + } else { + @cust_bill = qsearch('cust_bill', + { statementnum => $self->statementnum } + ); + } + $self->{Hash}->{cust_bill} = \@cust_bill; + } @{ $self->{Hash}->{cust_bill} } } @@ -266,9 +283,20 @@ sub tax { shift->_total('tax', @_); } sub charged { shift->_total('charged', @_); } sub owed { shift->_total('owed', @_); } -#don't show previous info +sub enable_previous { + my $self = shift; + $self->conf->exists('previous_balance-show_on_statements'); +} + sub previous { - ( 0 ); # 0, empty list + my $self = shift; + if ( $self->enable_previous ) { + my @previous = grep { $_->_date < ($self->cust_bill)[0]->_date } + $self->cust_main->open_cust_bill; + return(sum(map {$_->owed} @previous), @previous); + } else { + return 0; + } } =back diff --git a/FS/FS/domain_record.pm b/FS/FS/domain_record.pm index 8d767d510..cd881ae08 100644 --- a/FS/FS/domain_record.pm +++ b/FS/FS/domain_record.pm @@ -3,8 +3,7 @@ package FS::domain_record; use strict; use vars qw( @ISA $noserial_hack $DEBUG $me ); use FS::Conf; -#use FS::Record qw( qsearch qsearchs ); -use FS::Record qw( qsearchs dbh ); +use FS::Record qw( qsearchs dbh ); #qsearch use FS::svc_domain; use FS::svc_www; diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm index ffb4f52fb..cac7fe572 100644 --- a/FS/FS/msg_template.pm +++ b/FS/FS/msg_template.pm @@ -678,10 +678,20 @@ sub _upgrade_data { if ( $msg_template->subject || $msg_template->body ) { # create new default content my %content; - foreach ('subject','body') { - $content{$_} = $msg_template->$_; - $msg_template->setfield($_, ''); + $content{subject} = $msg_template->subject; + $msg_template->set('subject', ''); + + # work around obscure Pg/DBD bug + # https://rt.cpan.org/Public/Bug/Display.html?id=60200 + # (though the right fix is to upgrade DBD) + my $body = $msg_template->body; + if ( $body =~ /^x([0-9a-f]+)$/ ) { + # there should be no real message templates that look like that + warn "converting template body to TEXT\n"; + $body = pack('H*', $1); } + $content{body} = $body; + $msg_template->set('body', ''); my $error = $msg_template->replace(%content); die $error if $error; diff --git a/FS/FS/part_export/ez_prepaid.pm b/FS/FS/part_export/ez_prepaid.pm new file mode 100644 index 000000000..d171eb135 --- /dev/null +++ b/FS/FS/part_export/ez_prepaid.pm @@ -0,0 +1,183 @@ +package FS::part_export::ez_prepaid; + +use base qw( FS::part_export ); + +use strict; +use vars qw(@ISA %info $version $replace_ok_kludge $product_info); +use Tie::IxHash; +use FS::Record qw( qsearchs ); +use FS::svc_external; +use SOAP::Lite; +use XML::Simple qw( xml_in ); +use Data::Dumper; + +$version = '01'; + +my $product_info; +my %language_id = ( English => 1, Spanish => 2 ); + +tie my %options, 'Tie::IxHash', + 'site_id' => { label => 'Site ID' }, + 'clerk_id' => { label => 'Clerk ID' }, +# 'product_id' => { label => 'Product ID' }, use the 'title' field +# 'amount' => { label => 'Purchase amount' }, + 'language' => { label => 'Language', + type => 'select', + options => [ 'English', 'Spanish' ], + }, + + 'debug' => { label => 'Debug level', + type => 'select', options => [0, 1, 2 ] }, +; + +%info = ( + 'svc' => 'svc_external', + 'desc' => 'Purchase EZ-Prepaid PIN', + 'options' => \%options, + 'notes' => <<'END' +

Export to the EZ-Prepaid PIN purchase service. If the purchase is allowed, +the PIN will be stored as svc_external.id.

+

svc_external.title must contain the product ID, and should be set as a fixed +field in the service definition. For a list of product IDs, see the +"Merchant Info" tab in the EZ Prepaid reseller portal.

+END + ); + +$replace_ok_kludge = 0; + +sub _export_insert { + my ($self, $svc_external) = @_; + + # the name on the certificate is 'debisys.com', for some reason + local $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME}=0; + + my $pin = eval { $self->ez_prepaid_PinDistSale( $svc_external->title ) }; + return $@ if $@; + + local($replace_ok_kludge) = 1; + $svc_external->set('id', $pin); + $svc_external->replace; +} + +sub _export_replace { + $replace_ok_kludge ? '' : "can't change PIN after purchase"; +} + +sub _export_delete { + "can't delete PIN after purchase"; +} + +# possibly options at some point to relate these to agentnum/usernum +sub site_id { $_[0]->option('site_id') } + +sub clerk_id { $_[0]->option('clerk_id') } + +sub ez_prepaid_PinDistSale { + my $self = shift; + my $product_id = shift; + $self->ez_prepaid_init; # populate product ID cache + my $info = $product_info->{$product_id}; + if ( $info ) { + if ( $self->option('debug') ) { + warn "Purchasing PIN product #$product_id:\n" . + $info->{Description}."\n". + $info->{CurrencyCode} . ' ' .$info->{Amount}."\n"; + } + } else { #no $info + die "Unknown PIN product #$product_id.\n"; + } + + my $response = $self->ez_prepaid_request( + 'PinDistSale', + $version, + $self->site_id, + $self->clerk_id, + $product_id, + '', # AccountID, not used for PIN sale + $product_info->{$product_id}->{Amount}, + $self->svcnum, + ($language_id{ $self->option('language') } || 1), + ); + if ( $self->option('debug') ) { + warn Dumper($response); + # includes serial number and transaction ID, possibly useful + # (but we don't have a structured place to store it--maybe in + # a customer note?) + } + $response->{Pin}; +} + +sub ez_prepaid_init { + # returns the SOAP client object + my $self = shift; + my $wsdl = 'https://webservice.ez-prepaid.com/soap/webServices.wsdl'; + + if ( $self->option('debug') >= 2 ) { + SOAP::Lite->import(+trace => [transport => \&log_transport ]); + } + + if ( !$self->client ) { + $self->set(client => SOAP::Lite->new->service($wsdl)); + # I don't know if this can happen, but better to bail out here + # than go into recursion. + die "Error creating SOAP client\n" if !$self->client; + } + + if ( !defined($product_info) ) { + # for now we only support the 'PIN' type + my $response = $self->ez_prepaid_request( + 'GetTransTypeList', $version, $self->site_id, '', '', '', '' + ); + my %transtype = map { $_->{Description} => $_->{TransTypeId} } + @{ $response->{TransType} }; + + if ( !exists $transtype{PIN} ) { + warn "'PIN' transaction type not available.\n"; + # or else your site ID is wrong + return; + } + + $response = $self->ez_prepaid_request( + 'GetProductList', + $version, + $self->option('site_id'), + $transtype{PIN}, + '', #CarrierId + '', #CategoryId + '', #ProductId + ); + $product_info = +{ + map { $_->{ProductId} => $_ } + @{ $response->{Product} } + }; + } #!defined $product_info +} + +sub log_transport { + my $in = shift; + if ( UNIVERSAL::can($in, 'content') ) { + warn $in->content."\n"; + } +} + +my @ForceArray = qw(TransType Product); # add others as needed +sub ez_prepaid_request { + my $self = shift; + # takes a method name and param list, + # returns a hashref containing the unpacked response + # or dies on error + + $self->ez_prepaid_init if !$self->client; + + my $method = shift; + my $xml = $self->client->$method(@_); + # All of their response data types are one part, a string, containing + # an encoded XML structure, containing the fields described in the docs. + my $response = xml_in($xml, ForceArray => \@ForceArray); + if ( exists($response->{ResponseCode}) && $response->{ResponseCode} > 0 ) { + die "[$method] ".$response->{ResponseMessage}; + } + $response; +} + +1; diff --git a/FS/FS/part_pkg/prepaid.pm b/FS/FS/part_pkg/prepaid.pm index 407343bc8..50f908c6d 100644 --- a/FS/FS/part_pkg/prepaid.pm +++ b/FS/FS/part_pkg/prepaid.pm @@ -23,7 +23,7 @@ tie my %overlimit_action, 'Tie::IxHash', 'shortname' => 'Prepaid, no automatic cycle', 'inherit_fields' => [ 'usage_Mixin', 'global_Mixin' ], 'fields' => { - 'recur_action' => { 'name' => 'Action to take upon reaching end of prepaid preiod', + 'recur_action' => { 'name' => 'Action to take upon reaching end of prepaid period', 'type' => 'select', 'select_options' => \%recur_action, }, diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm index 4f223e113..813d096b4 100644 --- a/FS/FS/pay_batch.pm +++ b/FS/FS/pay_batch.pm @@ -12,6 +12,8 @@ use Date::Parse qw(str2time); use Business::CreditCard qw(cardtype); use Scalar::Util 'blessed'; use IO::Scalar; +use FS::Misc qw(send_email); # for error notification +use List::Util qw(sum); @ISA = qw(FS::Record); @@ -49,10 +51,14 @@ from FS::Record. The following fields are currently supported: =item status - O (Open), I (In-transit), or R (Resolved) -=item download - +=item download - time when the batch was first downloaded -=item upload - +=item upload - time when the batch was first uploaded +=item title - unique batch identifier + +For incoming batches, the combination of 'title', 'payby', and 'agentnum' +must be unique. =back @@ -118,9 +124,22 @@ sub check { || $self->ut_enum('payby', [ 'CARD', 'CHEK' ]) || $self->ut_enum('status', [ 'O', 'I', 'R' ]) || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum') + || $self->ut_alphan('title') ; return $error if $error; + if ( $self->title ) { + my @existing = + grep { !$self->batchnum or $_->batchnum != $self->batchnum } + qsearch('pay_batch', { + payby => $self->payby, + agentnum => $self->agentnum, + title => $self->title, + }); + return "Batch already exists as batchnum ".$existing[0]->batchnum + if @existing; + } + $self->SUPER::check; } @@ -225,11 +244,6 @@ sub import_results { my $job = $param->{'job'}; $job->update_statustext(0) if $job; - my $gateway = $param->{'gateway'}; - if ( $gateway ) { - return $self->import_from_gateway($gateway, 'file' => $fh, 'job' => $job); - } - my $format = $param->{'format'}; my $info = $import_info{$format} or die "unknown format $format"; @@ -444,9 +458,6 @@ sub process_import_results { my $param = thaw(decode_base64(shift)); $param->{'job'} = $job; warn Dumper($param) if $DEBUG; - my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n"; - my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n"; - my $gatewaynum = delete $param->{'gatewaynum'}; if ( $gatewaynum ) { $param->{'gateway'} = FS::payment_gateway->by_key($gatewaynum) @@ -461,12 +472,20 @@ sub process_import_results { '<', "$dir/$file" ) or die "unable to open '$file'.\n"; - my $error = $batch->import_results($param); + + my $error; + if ( $param->{gateway} ) { + $error = FS::pay_batch->import_from_gateway(%$param); + } else { + my $batchnum = delete $param->{'batchnum'} or die "no batchnum specified\n"; + my $batch = FS::pay_batch->by_key($batchnum) or die "batchnum '$batchnum' not found\n"; + $error = $batch->import_results($param); + } unlink $file; die $error if $error; } -=item import_from_gateway GATEWAY [ OPTIONS ] +=item import_from_gateway [ OPTIONS ] Import results from a L, using Business::BatchPayment, and apply them. GATEWAY must use the Business::BatchPayment namespace. @@ -477,15 +496,16 @@ or declined payment can have its status changed by a later import. OPTIONS may include: -- file: a file name or handle to use as a data source. +- gateway: the L, required +- filehandle: a file name or handle to use as a data source. - job: an L object to update with progress messages. =cut sub import_from_gateway { my $class = shift; - my $gateway = shift; my %opt = @_; + my $gateway = $opt{'gateway'}; my $conf = FS::Conf->new; # unavoidable duplication with import_batch, for now @@ -508,121 +528,250 @@ sub import_from_gateway { unless eval { $gateway->isa('FS::payment_gateway') }; my %proc_opt = ( - 'input' => $opt{'file'}, # will do nothing if it's empty + 'input' => $opt{'filehandle'}, # will do nothing if it's empty # any other constructor options go here ); + my @item_errors; + my $mail_on_error = $conf->config('batch-errors_to'); + if ( $mail_on_error ) { + # construct error trap + $proc_opt{'on_parse_error'} = sub { + my ($self, $line, $error) = @_; + push @item_errors, " '$line'\n$error"; + }; + } + my $processor = $gateway->batch_processor(%proc_opt); my @batches = $processor->receive; - my $error; + my $num = 0; + my $total_items = sum( map{$_->count} @batches); + # whether to allow items to change status my $reconsider = $conf->exists('batch-reconsider'); # mutex all affected batches my %pay_batch_for_update; + my %bop2payby = (CC => 'CARD', ECHECK => 'CHEK'); + BATCH: foreach my $batch (@batches) { + + my %incoming_batch = ( + 'CARD' => {}, + 'CHEK' => {}, + ); + ITEM: foreach my $item ($batch->elements) { - # cust_pay_batch.paybatchnum should be in the 'tid' attribute - my $paybatchnum = $item->tid; - my $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum); - if (!$cust_pay_batch) { - # XXX for one-way batch protocol this needs to create new payments - $error = "unknown paybatchnum $paybatchnum"; - last ITEM; - } - my $batchnum = $cust_pay_batch->batchnum; - if ( $batch->batch_id and $batch->batch_id != $batchnum ) { - warn "batch ID ".$batch->batch_id. - " does not match batchnum ".$cust_pay_batch->batchnum."\n"; - } + my $cust_pay_batch; # the new batch entry (with status) + my $pay_batch; # the freeside batch it belongs to + my $payby; # CARD or CHEK + my $error; - # lock the batch and check its status - my $pay_batch = FS::pay_batch->by_key($batchnum); - $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update; - if ( $pay_batch->status ne 'I' and !$reconsider ) { - $error = "batch $batchnum no longer in transit"; - last ITEM; - } + # follow realtime gateway practice here + # though eventually this stuff should go into separate fields... + my $paybatch = $gateway->gatewaynum . '-' . $gateway->gateway_module . + ':' . $item->authorization . ':' . $item->order_number; + + if ( $batch->incoming ) { + # This is a one-way batch. + # Locate the customer, find an open batch correct for them, + # create a payment. Don't bother creating a cust_pay_batch + # entry. + my $cust_main; + if ( defined($item->customer_id) + and $item->customer_id =~ /^\d+$/ + and $item->customer_id > 0 ) { + + $cust_main = FS::cust_main->by_key($item->customer_id) + || qsearchs('cust_main', + { 'agent_custid' => $item->customer_id } + ); + if ( !$cust_main ) { + push @item_errors, "Unknown customer_id ".$item->customer_id; + next ITEM; + } + } + else { + push @item_errors, "Illegal customer_id '".$item->customer_id."'"; + next ITEM; + } + # it may also make sense to allow selecting the customer by + # invoice_number, but no modules currently work that way + + $payby = $bop2payby{ $item->payment_type }; + my $agentnum = ''; + $agentnum = $cust_main->agentnum if $conf->exists('batch-spoolagent'); + + # create a batch if necessary + $pay_batch = $incoming_batch{$payby}->{$agentnum} ||= + FS::pay_batch->new({ + status => 'R', # pre-resolve it + payby => $payby, + agentnum => $agentnum, + upload => time, + title => $batch->batch_id, + }); + if ( !$pay_batch->batchnum ) { + $error = $pay_batch->insert; + die $error if $error; # can't do anything if this fails + } + + if ( !$item->approved ) { + $error ||= "payment rejected - ".$item->error_message; + } + if ( !defined($item->amount) or $item->amount <= 0 ) { + $error ||= "no amount in item $num"; + } + + my $payinfo; + if ( $item->check_number ) { + $payby = 'BILL'; # right? + $payinfo = $item->check_number; + } elsif ( $item->assigned_token ) { + $payinfo = $item->assigned_token; + } + # create the payment + my $cust_pay = FS::cust_pay->new( + { + custnum => $cust_main->custnum, + _date => $item->payment_date->epoch, + paid => sprintf('%.2f',$item->amount), + payby => $payby, + invnum => $item->invoice_number, + batchnum => $pay_batch->batchnum, + paybatch => $paybatch, + payinfo => $payinfo, + } + ); + $error ||= $cust_pay->insert; + eval { $cust_main->apply_payments }; + $error ||= $@; - if ( $cust_pay_batch->status ) { - my $new_status = $item->approved ? 'approved' : 'declined'; - if ( lc( $cust_pay_batch->status ) eq $new_status ) { - # already imported with this status, so don't touch + if ( $error ) { + push @item_errors, 'Payment for customer '.$item->customer_id."\n$error"; + } + + } else { + # This is a request/reply batch. + # Locate the request (the 'tid' attribute is the paybatchnum). + my $paybatchnum = $item->tid; + $cust_pay_batch = FS::cust_pay_batch->by_key($paybatchnum); + if (!$cust_pay_batch) { + push @item_errors, "paybatchnum $paybatchnum not found"; next ITEM; } - elsif ( !$reconsider ) { - # then we're not allowed to change its status, so bail out - $error = "paybatchnum ".$item->tid. + $payby = $cust_pay_batch->payby; + + my $batchnum = $cust_pay_batch->batchnum; + if ( $batch->batch_id and $batch->batch_id != $batchnum ) { + warn "batch ID ".$batch->batch_id. + " does not match batchnum ".$cust_pay_batch->batchnum."\n"; + } + + # lock the batch and check its status + $pay_batch = FS::pay_batch->by_key($batchnum); + $pay_batch_for_update{$batchnum} ||= $pay_batch->select_for_update; + if ( $pay_batch->status ne 'I' and !$reconsider ) { + $error = "batch $batchnum no longer in transit"; + } + + if ( $cust_pay_batch->status ) { + my $new_status = $item->approved ? 'approved' : 'declined'; + if ( lc( $cust_pay_batch->status ) eq $new_status ) { + # already imported with this status, so don't touch + next ITEM; + } + elsif ( !$reconsider ) { + # then we're not allowed to change its status, so bail out + $error = "paybatchnum ".$item->tid. " already resolved with status '". $cust_pay_batch->status . "'"; - last ITEM; + } } - } - # create a new cust_pay_batch with whatever information we got back - my $new_cust_pay_batch = new FS::cust_pay_batch { $cust_pay_batch->hash }; - my $new_payinfo; - # update payinfo, if needed - if ( $item->assigned_token ) { - $new_payinfo = $item->assigned_token; - } elsif ( $cust_pay_batch->payby eq 'CARD' ) { - $new_payinfo = $item->card_number if $item->card_number; - } else { #$cust_pay_batch->payby eq 'CHEK' - $new_payinfo = $item->account_number . '@' . $item->routing_code - if $item->account_number; - } - $new_cust_pay_batch->payinfo($new_payinfo) if $new_payinfo; + if ( $error ) { + push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error"; + next ITEM; + } - # set "paid" pseudo-field (transfers to cust_pay) to the actual amount - # paid, if the batch says it's different from the amount requested - if ( defined $item->amount ) { - $new_cust_pay_batch->paid($item->amount); - } else { - $new_cust_pay_batch->paid($cust_pay_batch->amount); - } + my $new_payinfo; + # update payinfo, if needed + if ( $item->assigned_token ) { + $new_payinfo = $item->assigned_token; + } elsif ( $payby eq 'CARD' ) { + $new_payinfo = $item->card_number if $item->card_number; + } else { #$payby eq 'CHEK' + $new_payinfo = $item->account_number . '@' . $item->routing_code + if $item->account_number; + } + $cust_pay_batch->set('payinfo', $new_payinfo) if $new_payinfo; + + # set "paid" pseudo-field (transfers to cust_pay) to the actual amount + # paid, if the batch says it's different from the amount requested + if ( defined $item->amount ) { + $cust_pay_batch->set('paid', $item->amount); + } else { + $cust_pay_batch->set('paid', $cust_pay_batch->amount); + } + + # set payment date to when it was processed + $cust_pay_batch->_date($item->payment_date->epoch) + if $item->payment_date; + + # approval status + if ( $item->approved ) { + # follow Billing_Realtime format for paybatch + $error = $cust_pay_batch->approve($paybatch); + $total += $cust_pay_batch->paid; + } + else { + $error = $cust_pay_batch->decline($item->error_message); + } + + if ( $error ) { + push @item_errors, "Payment for customer ".$cust_pay_batch->custnum."\n$error"; + next ITEM; + } + } # $batch->incoming - # set payment date to when it was processed - $new_cust_pay_batch->_date($item->payment_date->epoch) - if $item->payment_date; - - # approval status - if ( $item->approved ) { - # follow Billing_Realtime format for paybatch - my $paybatch = $gateway->gatewaynum . - '-' . - $gateway->gateway_module . - ':' . - $item->authorization . - ':' . - $item->order_number; - - $error = $new_cust_pay_batch->approve($paybatch); - $total += $new_cust_pay_batch->paid; - } - else { - $error = $new_cust_pay_batch->decline($item->error_message); - } - last ITEM if $error; $num++; - $job->update_statustext(int(100 * $num/( $batch->count + 1 ) ), + $job->update_statustext(int(100 * $num/( $total_items ) ), 'Importing batch items') - if $job; + if $job; + } #foreach $item - if ( $error ) { + } #foreach $batch (input batch, not pay_batch) + + # Format an error message + if ( @item_errors ) { + my $error_text = join("\n\n", + "Errors during batch import: ".scalar(@item_errors), + @item_errors + ); + if ( $mail_on_error ) { + my $subject = "Batch import errors"; #? + my $body = "Import from gateway ".$gateway->label."\n".$error_text; + send_email( + to => $mail_on_error, + from => $conf->config('invoice_from'), + subject => $subject, + body => $body, + ); + } else { + # Bail out. $dbh->rollback if $oldAutoCommit; - return $error; + die $error_text; } + } - } #foreach $batch (input batch, not pay_batch) - - # Auto-resolve + # Auto-resolve (with brute-force error handling) foreach my $pay_batch (values %pay_batch_for_update) { - $error = $pay_batch->try_to_resolve; + my $error = $pay_batch->try_to_resolve; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -637,7 +786,7 @@ sub import_from_gateway { =item try_to_resolve Resolve this batch if possible. A batch can be resolved if all of its -entries have a status. If the system options 'batch-auto_resolve_days' +entries have status. If the system options 'batch-auto_resolve_days' and 'batch-auto_resolve_status' are set, and the batch's download date is at least (batch-auto_resolve_days) before the current time, then it can be auto-resolved; entries with no status will be approved or declined diff --git a/FS/FS/payment_gateway.pm b/FS/FS/payment_gateway.pm index fac738499..4a7585e24 100644 --- a/FS/FS/payment_gateway.pm +++ b/FS/FS/payment_gateway.pm @@ -219,7 +219,7 @@ Returns a semi-friendly label for the gateway. sub label { my $self = shift; $self->gatewaynum . ': ' . - $self->gateway_username . '@' . + ($self->gateway_username ? $self->gateway_username . '@' : '') . $self->gateway_module } diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm index 0cfb11e2f..ccaa1c34b 100644 --- a/FS/FS/quotation.pm +++ b/FS/FS/quotation.pm @@ -150,6 +150,12 @@ sub cust_bill_pkg { =back +=item enable_previous + +=cut + +sub enable_previous { 0 } + =head1 BUGS =head1 SEE ALSO diff --git a/fs_selfservice/FS-SelfService/cgi/myaccount.html b/fs_selfservice/FS-SelfService/cgi/myaccount.html index 5370f7c22..9ab262261 100644 --- a/fs_selfservice/FS-SelfService/cgi/myaccount.html +++ b/fs_selfservice/FS-SelfService/cgi/myaccount.html @@ -6,7 +6,7 @@ Hello <%= $name %>!

<%= include('small_custview') %>
-<%= if ( $access_pkgnum ) { +<%= unless ( $access_pkgnum ) { $OUT .= qq!Balance: \$$balance

!; } ''; diff --git a/fs_selfservice/FS-SelfService/cgi/provision_list.html b/fs_selfservice/FS-SelfService/cgi/provision_list.html index 22054e644..2a3a8e909 100644 --- a/fs_selfservice/FS-SelfService/cgi/provision_list.html +++ b/fs_selfservice/FS-SelfService/cgi/provision_list.html @@ -10,7 +10,6 @@ foreach my $pkg ( } @cust_pkg ) { my $susp = $pkg->{'susp'} || ''; - warn $pkg->{'pkg'}. ' '.$susp."\n"; my @pkg_actions = ( [ 'customer_change_pkg' => 'change' ] ); push @pkg_actions, [ 'process_suspend_pkg' => 'suspend' ] if $self_suspend_reason and !$susp; diff --git a/httemplate/edit/payment_gateway.html b/httemplate/edit/payment_gateway.html index 2840df35b..e5897b035 100644 --- a/httemplate/edit/payment_gateway.html +++ b/httemplate/edit/payment_gateway.html @@ -19,8 +19,7 @@ + +% } + +<%init> + +my %opt = @_; + +my $conf = new FS::Conf; +my $money_char = $conf->config('money_char') || '$'; + +my $fee = ''; +my $fee_pkg = ''; +my $fee_display = ''; +my $fee_op = ''; + +if ( $opt{'process-pkgpart'} + and ! $opt{'process-skip_first'} || $opt{'num_payments'} + ) +{ + + $fee_display = $opt{'process-display'} || 'add'; + $fee_op = $fee_display eq 'add' ? '+' : '-'; + + $fee_pkg = + qsearchs('part_pkg', { pkgpart=>$opt{'process-pkgpart'} } ); + + #well ->unit_setup or ->calc_setup both call for a $cust_pkg + # (though ->unit_setup doesn't use it...) + $fee = $fee_pkg->option('setup_fee') + if $fee_pkg; #in case.. better than dying with a perl traceback + +} + +my $amount = $opt{'amount'}; +if ( $amount > 0 ) { + $amount += $fee + if $fee && $fee_display eq 'subtract'; + + &{ $opt{post_fee_callback} }( \$amount ) if $opt{post_fee_callback}; + + $amount = sprintf("%.2f", $amount); +} + + diff --git a/httemplate/graph/elements/report.html b/httemplate/graph/elements/report.html index 3600f2c66..f7746165a 100644 --- a/httemplate/graph/elements/report.html +++ b/httemplate/graph/elements/report.html @@ -77,15 +77,16 @@ any delimiter and linked from the elements in @data. % } % % } elsif ( $cgi->param('_type') =~ /(xls)$/ ) { -% -% #http_header('Content-Type' => 'application/excel' ); #eww -% http_header('Content-Type' => 'application/vnd.ms-excel' ); -% #http_header('Content-Type' => 'application/msexcel' ); #alas -% http_header('Content-Disposition' => "attachment;filename=$filename.xls"); +% #false laziness w/ search/elements/search-xls +% my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format; +% $filename .= $format->{extension}; +% +% http_header('Content-Type' => $format->{mime_type} ); +% http_header('Content-Disposition' => qq!attachment;filename="$filename"! ); % % my $output = ''; % my $XLS = new IO::Scalar \$output; -% my $workbook = Spreadsheet::WriteExcel->new($XLS) +% my $workbook = $format->{class}->new($XLS) % or die "Error opening .xls file: $!"; % % my $worksheet = $workbook->add_worksheet(substr($opt{'title'},0,31)); @@ -304,9 +305,6 @@ td.cell { <% include('/elements/footer.html') %> % } -<%once> - - <%init> my(%opt) = @_; diff --git a/httemplate/misc/cancel_pkg.html b/httemplate/misc/cancel_pkg.html index 348f0a6cb..f9a46a898 100755 --- a/httemplate/misc/cancel_pkg.html +++ b/httemplate/misc/cancel_pkg.html @@ -62,7 +62,19 @@ &> % } -% if ( ( $method eq 'adjourn' or $method eq 'suspend' ) and +% if ( $method eq 'adjourn' || $method eq 'suspend' ) { + +% if ( $part_pkg->option('suspend_bill', 1) ) { + <& /elements/checkbox.html, name=>'no_suspend_bill', value=>'Y' &> + Disable recurring billing while suspended +% } else { + <& /elements/checkbox.html, name=>'suspend_bill', value=>'Y' &> + Continue recurring billing while suspended +% } + +% } + +% if ( ( $method eq 'adjourn' || $method eq 'suspend' ) and % $curuser->access_right('Unsuspend customer package') ) { #later? % my $resume_date = $cgi->param('error') % ? str2time($cgi->param('resume_date')) diff --git a/httemplate/misc/order_pkg.html b/httemplate/misc/order_pkg.html index 57fdd64ee..c5f4509ab 100644 --- a/httemplate/misc/order_pkg.html +++ b/httemplate/misc/order_pkg.html @@ -44,6 +44,8 @@ +% } else { + % } diff --git a/httemplate/misc/payment.cgi b/httemplate/misc/payment.cgi index 093494a06..1ae15b930 100644 --- a/httemplate/misc/payment.cgi +++ b/httemplate/misc/payment.cgi @@ -9,67 +9,20 @@ <& /elements/init_overlib.html &> <% ntable('#cccccc') %> - - <% mt('Payment amount') |h %> - -
- <% $money_char %> - -% if ( $fee ) { - - - <% $fee_op %> - <% $money_char . $fee %> - - <% $fee_pkg->pkg |h %> - = - - <% length($amount) ? $money_char. sprintf('%.2f', ($fee_display eq 'add') ? $amount + $fee : $amount - $fee ) : '' %> <% $fee_display eq 'add' ? 'TOTAL' : 'AVAILABLE' %> - -% } -
- - - -% if ( $fee ) { - - -% } + <& /elements/tr-amount_fee.html, + 'amount' => $amount, + 'process-pkgpart' => scalar($conf->config('manual_process-pkgpart')), + 'process-display' => scalar($conf->config('manual_process-display')), + 'process-skip-first' => $conf->exists('manual_process-skip_first'), + 'num_payments' => scalar($cust_main->cust_pay), + 'post_fee_callback' => $post_fee_callback, + &> -<& /elements/tr-select-discount_term.html, - 'custnum' => $custnum, - 'amount_id' => 'amount', -&> + <& /elements/tr-select-discount_term.html, + 'custnum' => $custnum, + 'amount_id' => 'amount', + &> % if ( $payby eq 'CARD' ) { % @@ -304,8 +257,6 @@ my $payinfo = ''; my $conf = new FS::Conf; -my $money_char = $conf->config('money_char') || '$'; - #false laziness w/selfservice make_payment.html shortcut for one-country my %states = map { $_->state => 1 } qsearch('cust_main_county', { @@ -313,43 +264,23 @@ my %states = map { $_->state => 1 } } ); my @states = sort { $a cmp $b } keys %states; -my $fee = ''; -my $fee_pkg = ''; -my $fee_display = ''; -my $fee_op = ''; -my $num_payments = scalar($cust_main->cust_pay); -#handle old cust_main.pm (remove...) -$num_payments = scalar( @{ [ $cust_main->cust_pay ] } ) - unless defined $num_payments; -if ( $conf->config('manual_process-pkgpart') - and ! $conf->exists('manual_process-skip_first') || $num_payments - ) -{ - - $fee_display = $conf->config('manual_process-display') || 'add'; - $fee_op = $fee_display eq 'add' ? '+' : '-'; - - $fee_pkg = - qsearchs('part_pkg', { pkgpart=>$conf->config('manual_process-pkgpart') } ); - - #well ->unit_setup or ->calc_setup both call for a $cust_pkg - # (though ->unit_setup doesn't use it...) - $fee = $fee_pkg->option('setup_fee') - if $fee_pkg; #in case.. better than dying with a perl traceback - -} - my $amount = ''; if ( $balance > 0 ) { $amount = $balance; - $amount += $fee - if $fee && $fee_display eq 'subtract'; +} + +my $post_fee_callback = sub { + my( $amountref ) = @_; + + return unless $$amountref > 0; + + my $conf = new FS::Conf; my $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage'); - $amount += $amount * $cc_surcharge_pct/100 if $cc_surcharge_pct > 0; + $$amountref += $$amountref * $cc_surcharge_pct/100 if $cc_surcharge_pct > 0; - $amount = sprintf("%.2f", $amount); -} + $$amountref = sprintf("%.2f", $$amountref); +}; my $payunique = "webui-payment-". time. "-$$-". rand() * 2**32; diff --git a/httemplate/misc/process/cancel_pkg.html b/httemplate/misc/process/cancel_pkg.html index b2d7bfaa4..a106b845a 100755 --- a/httemplate/misc/process/cancel_pkg.html +++ b/httemplate/misc/process/cancel_pkg.html @@ -52,10 +52,15 @@ if ($method eq 'expire' || $method eq 'adjourn' || $method eq 'resume') { $method = 'unsuspend' if $method eq 'resume'; } -my $resume_date; +my $resume_date = ''; +my $options = ''; if ( $method eq 'suspend' ) { #or 'adjourn' $resume_date = parse_datetime($cgi->param('resume_date')) if $cgi->param('resume_date'); + + $options = { map { $_ => scalar($cgi->param($_)) } + qw( suspend_bill no_suspend_bill ) + }; } my $cust_pkg = qsearchs( 'cust_pkg', {'pkgnum'=>$pkgnum} ); @@ -88,6 +93,7 @@ my $error = $cust_pkg->$method( 'reason' => $reasonnum, 'last_bill' => $last_bill, 'bill' => $bill, 'svc_fatal' => $svc_fatal, + 'options' => $options, ); if ($error) { diff --git a/httemplate/pref/pref-process.html b/httemplate/pref/pref-process.html index bd6bb860a..932cf1a0a 100644 --- a/httemplate/pref/pref-process.html +++ b/httemplate/pref/pref-process.html @@ -48,7 +48,8 @@ unless ( $error ) { # if ($access_user) { my %param = $access_user->options; #XXX autogen - my @paramlist = qw( locale menu_position default_customer_view mobile_menu + my @paramlist = qw( locale menu_position default_customer_view + spreadsheet_format mobile_menu disable_html_editor disable_enter_submit_onetimecharge email_address snom-ip snom-username snom-password diff --git a/httemplate/pref/pref.html b/httemplate/pref/pref.html index 8e56355db..9ebf2f1ba 100644 --- a/httemplate/pref/pref.html +++ b/httemplate/pref/pref.html @@ -75,6 +75,21 @@ Interface + + + Spreadsheet download format: + + + + Disable HTML editor for customer notes: diff --git a/httemplate/search/477partV.html b/httemplate/search/477partV.html index 55ebc0be2..0987fea44 100755 --- a/httemplate/search/477partV.html +++ b/httemplate/search/477partV.html @@ -34,9 +34,11 @@ $search_hash{'classnum'} = [ $cgi->param('classnum') ]; $search_hash{report_option} = $cgi->param('partv_report_option') if $cgi->param('partv_report_option'); -my $sql_query = FS::cust_pkg->search( { %search_hash, 'fcc_line' => 1 }); -$sql_query->{select} = 'DISTINCT substr(zip,1,5) as zip'; -$sql_query->{order_by} = 'ORDER BY substr(zip,1,5)'; +my $sql_query = FS::cust_pkg->search( { %search_hash, + 'fcc_line' => 1, + 'select_zip5' => 1, + } + ); my $count_query = delete($sql_query->{'count_query'}); $count_query =~ s/COUNT\(\*\)/count(DISTINCT substr(zip,1,5))/; $count_query =~ s/ORDER BY [.\w]+//; diff --git a/httemplate/search/cust_bill_pkg_referral.html b/httemplate/search/cust_bill_pkg_referral.html new file mode 100644 index 000000000..3cb434caa --- /dev/null +++ b/httemplate/search/cust_bill_pkg_referral.html @@ -0,0 +1,294 @@ +<& elements/search.html, + 'title' => emt('Sales with advertising source'), + 'name' => emt('line items'), + 'query' => $query, + 'count_query' => $count_query, + 'count_addl' => [ + ($setup ? $money_char. '%.2f setup' : ()), + ($recur ? $money_char. '%.2f recurring' : ()), + ($usage ? $money_char. '%.2f usage' : ()), + ], + 'header' => [ + emt('Description'), + ($setup ? emt('Setup') : ()), + ($recur ? emt('Recurring') : ()), + ($usage ? emt('Usage') : ()), + emt('Invoice'), + emt('Invoice date'), + emt('Paid'), + emt('Payment date'), + emt('Pkg. status'), + emt('Pkg. class'), + '', #report class + emt('Cust#'), + emt('Customer'), + emt('Ad source'), + emt('Agent'), + ], + 'fields' => [ + 'pkg', + ($setup ? money_sub('setup') : ()), + ($recur ? money_sub('recur_no_usage') : ()), + ($usage ? money_sub('recur_usage') : ()), + 'invnum', + date_sub('_date'), + money_sub('paid'), + date_sub('last_pay'), + sub { + my $cust_pkg = shift->cust_pkg; + $cust_pkg ? ucfirst($cust_pkg->status) : ''; + }, + 'classname', + sub { # report_option + my $cust_bill_pkg = shift; + my $pkgpart = $cust_bill_pkg->pkgpart_override + || $cust_bill_pkg->cust_pkg->pkgpart; + if ( !exists($report_classes{$pkgpart}) ) { + my $part_pkg = FS::part_pkg->by_key($pkgpart); + my %opts = $part_pkg->options; + $report_classes{$pkgpart} = [ + map { /^report_option_(\d+)/ ? + $report_option_name{$1} : + () } + keys %opts + ]; + } + join( '
', @{ $report_classes{$pkgpart} }); + }, + 'custnum', + 'name', + 'referral', # from query + 'agent', + ], + 'sort_fields' => [ + '', + ($setup ? 'setup' : ()), + ($recur ? 'recur_no_usage' : ()), + ($usage ? 'recur_usage' : ()), + 'invnum', + '_date', + 'paid', + 'last_pay', + '', #package status + 'classname', + '', #report_option + 'custnum', + '', + 'referral', + 'agent', + ], + 'links' => [ + '', #package/item desc + ('') x $x, #setup/recur/usage + $ilink, #invnum + $ilink, #invoice date + '', #paid amt + '', #payment date + '', #pkg status + '', #classnum + '', #report class + $clink, #custnum + $clink, #customer name + '', #referral + '', #agent + ], + #'align' => 'rlrrrc'.FS::UI::Web::cust_aligns(), + 'align' => 'l' . ('r' x $x) . 'rcrccccrlll', + 'color' => [ ('') x (5 + $x), + sub { + my $cust_pkg = shift->cust_pkg; + $cust_pkg ? ucfirst($cust_pkg->statuscolor) : ''; + }, + ('') x 6, + ], + 'style' => [ + ('') x (5 + $x), + 'b', + ('') x 6 + ], +&> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Financial reports'); + +my $conf = new FS::Conf; + +my $setup = $cgi->param('setup') ? 1 : 0; +my $recur = $cgi->param('recur') ? 1 : 0; +my $usage = $cgi->param('usage') ? 1 : 0; + +my $x = $setup + $recur + $usage; + +my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' ); +my ($join_cust, $join_pkg ) = ('', ''); + +#here is the agent virtualization +my $agentnums_sql = + $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' ); + +my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi); + +my @where = ( $agentnums_sql, + 'cust_bill_pkg.pkgnum != 0', # exclude taxes + "cust_bill._date >= $beginning", + "cust_bill._date <= $ending", + ); + +my @status_where; +foreach my $status ($cgi->param('status')) { + if ( $status =~ /^([- a-z]+)$/ ) { #"one-time charge" + push @status_where, "'$status'"; + } +} +if ( @status_where ) { + push @where, '('. FS::cust_pkg->status_sql. + ') IN (' . join(',', @status_where) .')'; +} + +if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) { + push @where, "cust_main.agentnum = $1"; +} + +#classnum +# not specified: all classes +# 0: empty class +# N: classnum +my $use_override = 1; #$cgi->param('use_override'); +if ( $cgi->param('classnum') =~ /^(\d+)$/ ) { + my $comparison = ''; + if ( $1 == 0 ) { + $comparison = "IS NULL"; + } else { + $comparison = "= $1"; + } + + if ( $use_override ) { + push @where, "( + part_pkg.classnum $comparison AND pkgpart_override IS NULL OR + override.classnum $comparison AND pkgpart_override IS NOT NULL + )"; + } else { + push @where, "part_pkg.classnum $comparison"; + } +} + +# report option +my @report_option = grep /^\d+$/, ( $cgi->param('report_option') ); +if ( @report_option ) { + @report_option = map { "'report_option_$_'" } @report_option; + push @where, "EXISTS( + SELECT 1 FROM part_pkg_option WHERE optionname IN (". + join(',', @report_option).") AND ( + part_pkg_option.pkgpart = cust_pkg.pkgpart AND pkgpart_override IS NULL + OR part_pkg_option.pkgpart = pkgpart_override + ) + )"; +} + +my $setup_sql = + FS::cust_bill_pkg->charged_sql('', '', setuprecur => 'setup'); +my $recur_sql = + FS::cust_bill_pkg->charged_sql('', '', setuprecur => 'recur', no_usage => 1); +my $usage_sql = FS::cust_bill_pkg->usage_sql; + +# exclude zero-amount items +my @orwhere; +push @orwhere, "(cust_bill_pkg.setup > 0)" if $setup; +push @orwhere, "($recur_sql > 0)" if $recur; +push @orwhere, "($usage_sql > 0)" if $usage; +push @where, '('.join(' OR ', @orwhere).')' if @orwhere; + +$join_cust = ' JOIN cust_bill USING ( invnum ) + LEFT JOIN cust_main USING ( custnum ) + LEFT JOIN part_referral USING ( refnum ) + LEFT JOIN agent ON cust_main.agentnum = agent.agentnum + '; + +$join_pkg .= ' LEFT JOIN cust_pkg USING ( pkgnum ) + LEFT JOIN part_pkg USING ( pkgpart ) + LEFT JOIN part_pkg AS override + ON pkgpart_override = override.pkgpart + LEFT JOIN pkg_class ON '; #... + +if ( $use_override ) { + # join to whichever pkgpart is appropriate + $join_pkg .= ' + ( pkgpart_override IS NULL AND part_pkg.classnum = pkg_class.classnum ) + OR ( pkgpart_override IS NOT NULL AND override.classnum = pkg_class.classnum )'; +} else { + $join_pkg .= 'part_pkg.classnum = pkg_class.classnum'; +} + +my $where = ' WHERE '. join(' AND ', @where); + +# setup and recurring only +my $count_query = "SELECT + COUNT(billpkgnum)". + ($setup ? ", SUM($setup_sql)" : ''). + ($recur ? ", SUM($recur_sql)" : ''). + ($usage ? ", SUM($usage_sql)" : ''). + " FROM cust_bill_pkg + $join_cust + $join_pkg + $where + "; + +my $paid_sql = FS::cust_bill_pkg->paid_sql('', ''); +my $last_pay_sql = "SELECT MAX(_date) + FROM cust_bill_pay JOIN cust_bill_pay_pkg USING (billpaynum) + WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum"; + +push @select, 'part_pkg.pkg', + 'part_pkg.freq', + 'cust_main.custnum', + 'cust_main.first', + 'cust_main.last', + 'cust_main.company', + 'part_referral.referral', + "($paid_sql) AS paid", + "($last_pay_sql) AS last_pay", + "($recur_sql) AS recur_no_usage", + "($usage_sql) AS recur_usage", + 'pkg_class.classname', + 'agent.agent', + ; + +my $query = { + 'table' => 'cust_bill_pkg', + 'addl_from' => "$join_cust $join_pkg", + 'hashref' => {}, + 'select' => join(",\n", @select ), + 'extra_sql' => $where, + 'order_by' => 'ORDER BY cust_bill._date, billpkgnum', +}; + +my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ]; +my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ]; + +my $conf = new FS::Conf; +my $money_char = $conf->config('money_char') || '$'; + +my %report_classes; #cache +my %report_option_name = + map { $_->num => $_->name } qsearch('part_pkg_report_option', {}); + +# should this be in Mason.pm or something? +sub money_sub { + $conf ||= new FS::Conf; + $money_char ||= $conf->config('money_char') || '$'; + my $field = shift; + sub { + $money_char . sprintf('%.2f', $_[0]->get($field)); + }; +} + +sub date_sub { + my $field = shift; + sub { + my $value = $_[0]->get($field); + $value ? time2str('%b %d %Y', $value) : ''; + }; +} + + diff --git a/httemplate/search/cust_main-zip.html b/httemplate/search/cust_main-zip.html index e87b21474..c317dc36f 100644 --- a/httemplate/search/cust_main-zip.html +++ b/httemplate/search/cust_main-zip.html @@ -5,7 +5,7 @@ 'count_query' => $count_sql, 'header' => [ 'Zip code', 'Customers', ], #'fields' => [ 'zip', 'num_cust', ], - 'links' => [ '', sub { 'somewhere'; } ], + #'links' => [ '', sub { 'somewhere'; } ], ) %> <%init> diff --git a/httemplate/search/elements/cust_pay_batch_top.html b/httemplate/search/elements/cust_pay_batch_top.html index 005b76182..739e65b50 100644 --- a/httemplate/search/elements/cust_pay_batch_top.html +++ b/httemplate/search/elements/cust_pay_batch_top.html @@ -103,7 +103,7 @@ Batch is <% $statustext{$status} %>
% } <%shared> -my $show_gateways = FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment'"); +my $show_gateways = FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment' AND disabled IS NULL"); <%init> my %opt = @_; diff --git a/httemplate/search/elements/cust_pay_or_refund.html b/httemplate/search/elements/cust_pay_or_refund.html index dc3cb2a99..c60411107 100755 --- a/httemplate/search/elements/cust_pay_or_refund.html +++ b/httemplate/search/elements/cust_pay_or_refund.html @@ -357,6 +357,15 @@ if ( $cgi->param('magic') ) { $orderby = "LOWER(company || ' ' || last || ' ' || first )"; + } elsif ( $cgi->param('magic') eq 'batchnum' ) { + + $cgi->param('batchnum') =~ /^(\d+)$/ + or die "illegal batchnum: ".$cgi->param('batchnum'); + + push @search, "batchnum = $1"; + + $orderby = "LOWER(company || ' ' || last || ' ' || first )"; + } else { die "unknown search magic: ". $cgi->param('magic'); } diff --git a/httemplate/search/elements/search-xls.html b/httemplate/search/elements/search-xls.html index 0b5636c0e..09dbe46e0 100644 --- a/httemplate/search/elements/search-xls.html +++ b/httemplate/search/elements/search-xls.html @@ -7,14 +7,20 @@ my $header = $args{'header'}; my $rows = $args{'rows'}; my %opt = %{ $args{'opt'} }; +my $override = scalar(@$rows) >= 65536 ? 'XLSX' : ''; + +my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format($override); + +my $filename = $opt{'name'} || PL($opt{'name_singular'}); +$filename .= $format->{extension}; + #http_header('Content-Type' => 'application/excel' ); #eww #http_header('Content-Type' => 'application/msexcel' ); #alas #http_header('Content-Type' => 'application/x-msexcel' ); #? #http://support.microsoft.com/kb/199841 -http_header('Content-Type' => 'application/vnd.ms-excel' ); -http_header('Content-Disposition' => - 'attachment;filename="'.($opt{'name'} || PL($opt{'name_singular'}) ).'.xls"'); +http_header('Content-Type' => $format->{mime_type} ); +http_header('Content-Disposition' => qq!attachment;filename="$filename"! ); #http://support.microsoft.com/kb/812935 #http://support.microsoft.com/kb/323308 @@ -22,8 +28,8 @@ $HTML::Mason::Commands::r->headers_out->{'Cache-control'} = 'max-age=0'; my $data = ''; my $XLS = new IO::Scalar \$data; -my $workbook = Spreadsheet::WriteExcel->new($XLS) - or die "Error opening .xls file: $!"; +my $workbook = $format->{class}->new($XLS) + or die "Error opening Excel file: $!"; my $worksheet = $workbook->add_worksheet(substr($opt{'title'},0,31)); @@ -42,14 +48,18 @@ my $default_format = $workbook->add_format(locked => 0); my %money_format; my $money_char = FS::Conf->new->config('money_char') || '$'; +my %date_format; +xl_parse_date_init(); + my $writer = sub { # Wrapper for $worksheet->write. # Do any massaging of the value/format here. my ($r, $c, $value, $format) = @_; - if ( $value =~ /^\Q$money_char\E(\d+\.?\d*)$/ ) { + if ( $value =~ /^\Q$money_char\E(-?\d+\.?\d*)$/ ) { # Currency: strip the symbol, clone the requested format, # and format it for currency $value = $1; +# warn "formatting $value as money\n"; if ( !exists($money_format{$format}) ) { $money_format{$format} = $workbook->add_format(); $money_format{$format}->copy($format); @@ -57,6 +67,22 @@ my $writer = sub { } $format = $money_format{$format}; } + elsif ( $value =~ /^([A-Z][a-z]{2}) (\d{2}) (\d{4})$/ ) { + # Date: convert the value to an Excel date number and set + # the format + $value = xl_parse_date($value); +# warn "formatting $value as date\n"; + if ( !exists($date_format{$format}) ) { + $date_format{$format} = $workbook->add_format(); + $date_format{$format}->copy($format); + $date_format{$format}->set_num_format('mmm dd yyyy'); + } + $format = $date_format{$format}; + } + else { + # String: replace line breaks with newlines + $value =~ s/
/\n/gi; + } $worksheet->write($r, $c, $value, $format); }; diff --git a/httemplate/search/pay_batch.cgi b/httemplate/search/pay_batch.cgi index 05415f36e..aeaa012f4 100755 --- a/httemplate/search/pay_batch.cgi +++ b/httemplate/search/pay_batch.cgi @@ -14,12 +14,13 @@ 'Type', 'First Download', 'Last Upload', - 'Items', - 'Unresolved', - 'Amount', + '', # requests + '', # req amt + '', # payments + '', # pay amt 'Status', ], - 'align' => 'rcllrrc', + 'align' => 'rcllrrrrc', 'fields' => [ 'batchnum', sub { FS::payby->shortname(shift->payby); @@ -47,33 +48,44 @@ } }, sub { - FS::cust_pay_batch->count( - 'batchnum = '.$_[0]->batchnum - ) + my $c = FS::cust_pay_batch->count('batchnum = '.$_[0]->batchnum); + $c ? "$c requested" : '' }, sub { - FS::cust_pay_batch->count( - 'status is null and batchnum = '. - $_[0]->batchnum - ) - }, - sub { my $st = "SELECT SUM(amount) from cust_pay_batch WHERE batchnum=" . shift->batchnum; my $sth = dbh->prepare($st) - or die dbh->errstr. "doing $st"; + or die dbh->errstr. "doing $st"; $sth->execute - or die "Error executing \"$st\": ". $sth->errstr; - $sth->fetchrow_arrayref->[0]; - }, + or die "Error executing \"$st\": ". $sth->errstr; + my $total = $sth->fetchrow_arrayref->[0]; + $total ? $money_char.sprintf('%.2f',$total) : ''; + }, + sub { + my $c = FS::cust_pay->count('batchnum = '.$_[0]->batchnum); + $c ? "$c paid" : '' + }, + sub { + my $st = "SELECT SUM(paid) from cust_pay WHERE batchnum=" . shift->batchnum; + my $sth = dbh->prepare($st) + or die dbh->errstr. "doing $st"; + $sth->execute + or die "Error executing \"$st\": ". $sth->errstr; + my $total = $sth->fetchrow_arrayref->[0]; + $total ? $money_char.sprintf('%.2f',$total) : ''; + }, sub { $statusmap{shift->status}; }, ], 'links' => [ - $link, + '', '', - sub { shift->status eq 'O' ? $link : '' }, - sub { shift->status eq 'I' ? $link : '' }, + sub { shift->status eq 'O' ? $cpb_link : '' }, + sub { shift->status eq 'I' ? $cpb_link : '' }, + $cpb_link, + $cpb_link, + $pay_link, + $pay_link, ], 'size' => [ '', @@ -88,9 +100,42 @@ sub { shift->status eq 'I' ? "b" : '' }, ], 'html_init' => $html_init, + 'html_foot' => include('.upload_incoming'), ) - %> +<%def .upload_incoming> +% if ( FS::payment_gateway->count("gateway_namespace = 'Business::BatchPayment' AND disabled IS NULL") > 0 ) { +<& /elements/form-file_upload.html, + name => 'FileUpload', + action => $p.'misc/upload-batch.cgi', + num_files => 1, + fields => [ 'gatewaynum' ], + message => 'Incoming batch uploaded.', +&> +
+
+Upload incoming batch from gateway +<& /elements/select-table.html, + table => 'payment_gateway', + field => 'gatewaynum', + name_col => 'label', + value_col => 'gatewaynum', + order_by => 'ORDER BY gatewaynum', + empty_label => ' ', + hashref => + { 'gateway_namespace' => 'Business::BatchPayment', + 'disabled' => '' }, +&> +
+<& '/elements/file-upload.html', + field => 'file', + label => 'Filename', + no_table => 1, +&> + + +% } + <%init> die "access denied" @@ -134,11 +179,14 @@ push @where, my $extra_sql = scalar(@where) ? 'WHERE ' . join(' AND ', @where) : ''; -my $link = [ "${p}search/cust_pay_batch.cgi?dcln=1;batchnum=", 'batchnum' ]; +my $cpb_link = [ "${p}search/cust_pay_batch.cgi?dcln=1;batchnum=", 'batchnum' ]; +my $pay_link = [ "${p}search/cust_pay.html?magic=batchnum;batchnum=", 'batchnum' ]; my $resolved = $cgi->param('resolved') || 0; $cgi->param('resolved' => !$resolved); my $html_init = ''. ($resolved ? 'Hide' : 'Show') . ' resolved batches
'; +my $money_char = FS::Conf->new->config('money_char') || '$'; + diff --git a/httemplate/search/report_cust_bill_pkg_referral.html b/httemplate/search/report_cust_bill_pkg_referral.html new file mode 100644 index 000000000..ff2caa1fa --- /dev/null +++ b/httemplate/search/report_cust_bill_pkg_referral.html @@ -0,0 +1,61 @@ +<% include('/elements/header.html', 'Sales Report with Advertising Source' ) %> + +
+ + + +<& /elements/tr-input-beginning_ending.html &> + +<& /elements/tr-select-agent.html, + 'label' => 'For agent: ', + 'disable_empty' => 0, + 'empty_label' => 'all', +&> + +<& /elements/tr-select-cust_pkg-status.html, + 'label' => 'Package status', + 'multiple' => 1, + 'disable_empty' => 1, +&> + +<& /elements/tr-select-pkg_class.html, + 'pre_options' => [ '' => 'all', '0' => '(empty class)' ], + 'disable_empty' => 1, +&> + +<& /elements/tr-select-table.html, + 'label' => 'Report classes', + 'table' => 'part_pkg_report_option', + 'name_col' => 'name', + 'hashref' => { disabled => '' }, + 'element_name' => 'report_option', + 'multiple' => 1, +&> + + + + + + + + + + + + + + + + +
Show setup/one-time fees
Show recurring fees
Show usage charges
+ +
+
+ +<% include('/elements/footer.html') %> +<%init> + +die "access denied" + unless $FS::CurrentUser::CurrentUser->access_right('Financial reports'); + + diff --git a/httemplate/search/report_tax-xls.cgi b/httemplate/search/report_tax-xls.cgi index 1c278dfd1..f19f85aaa 100755 --- a/httemplate/search/report_tax-xls.cgi +++ b/httemplate/search/report_tax-xls.cgi @@ -1,9 +1,25 @@ <% $data %> <%init> +my $htmldoc = include('report_tax.cgi'); + +my ($title) = ($htmldoc =~ /\s*(.*)\s*<\/title>/i); + +# do this first so we can override the format if it's too many rows +# attribs option: how to locate the table? It's the only one with class="grid". +my $te = HTML::TableExtract->new(attribs => {class => 'grid'}); +$te->parse($htmldoc); +my $table = $te->first_table_found; + +my $override = ($table->row_count >= 65536 ? 'XLSX' : ''); +my $format = $FS::CurrentUser::CurrentUser->spreadsheet_format($override); +my $filename = 'report_tax'.$format->{extension}; + +http_header('Content-Type' => $format->{mime_type}); +http_header('Content-Disposition' => qq!attachment;filename="$filename"! ); my $data = ''; my $XLS = new IO::Scalar \$data; -my $workbook = Spreadsheet::WriteExcel->new($XLS) +my $workbook = $format->{class}->new($XLS) or die "Error opening .xls file: $!"; # hardcoded formats, this could be handled better @@ -66,15 +82,6 @@ foreach (keys(%format)) { } my $ws = $workbook->add_worksheet('taxreport'); -my $htmldoc = include('report_tax.cgi'); - -my ($title) = ($htmldoc =~ /<title>\s*(.*)\s*<\/title>/i); - -# attribs option: how to locate the table? It's the only one with class="grid". -my $te = HTML::TableExtract->new(attribs => {class => 'grid'}); -$te->parse($htmldoc); -my $table = $te->first_table_found; - my @sheet; $sheet[0][0] = { text => $title, @@ -148,6 +155,4 @@ for my $x (0..scalar(@widths)-1) { $workbook->close; -http_header('Content-Type' => 'application/vnd.ms-excel'); -http_header('Content-Disposition' => 'attachment;filename="report_tax.xls"'); </%init> diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html index 4aec90efb..e9017745b 100644 --- a/httemplate/view/cust_main/packages/status.html +++ b/httemplate/view/cust_main/packages/status.html @@ -73,7 +73,12 @@ <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %> <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %> -% if ( $part_pkg->option('suspend_bill', 1) ) { +% if ( $cust_pkg->option('suspend_bill', 1) +% || ( $part_pkg->option('suspend_bill', 1) +% && ! $cust_pkg->option('no_suspend_bill',1) +% ) +% ) +% { <% pkg_status_row_if( $cust_pkg, emt('Next bill'), 'bill', %opt, curuser=>$curuser ) %> % } <% pkg_status_row_if( $cust_pkg, emt('Will resume'), 'resume', %opt, curuser=>$curuser ) %> diff --git a/httemplate/view/cust_main_statement-pdf.cgi b/httemplate/view/cust_main_statement-pdf.cgi index 7a0e19838..7c2c20799 100755 --- a/httemplate/view/cust_main_statement-pdf.cgi +++ b/httemplate/view/cust_main_statement-pdf.cgi @@ -23,13 +23,17 @@ my $cust_main = qsearchs({ 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql, }); die "Customer #$custnum not found!" unless $cust_main; +my $cust_bill = ($cust_main->cust_bill)[-1] + or die "Customer #$custnum has no invoices!"; my $cust_statement = FS::cust_statement->new({ 'custnum' => $custnum, - 'statementnum' => 'ALL', #magic +# 'statementnum' => 'ALL', #magic + 'invnum' => $cust_bill->invnum, '_date' => time, }); + my $pdf = $cust_statement->print_pdf( '', $templatename ); http_header('Content-Type' => 'application/pdf' ); diff --git a/rt/lib/RT/EmailParser.pm b/rt/lib/RT/EmailParser.pm index 4cf4184a4..dd73d9049 100644 --- a/rt/lib/RT/EmailParser.pm +++ b/rt/lib/RT/EmailParser.pm @@ -576,10 +576,10 @@ sub RescueOutlook { my $text_part; if ( $mime->head->get('Content-Type') =~ m{multipart/mixed} ) { my $first = $mime->parts(0); - if ( $first->head->get('Content-Type') =~ m{multipart/alternative} ) + if ( $first && $first->head->get('Content-Type') =~ m{multipart/alternative} ) { my $inner_first = $first->parts(0); - if ( $inner_first->head->get('Content-Type') =~ m{text/plain} ) + if ( $inner_first && $inner_first->head->get('Content-Type') =~ m{text/plain} ) { $text_part = $inner_first; } @@ -587,7 +587,7 @@ sub RescueOutlook { } elsif ( $mime->head->get('Content-Type') =~ m{multipart/alternative} ) { my $first = $mime->parts(0); - if ( $first->head->get('Content-Type') =~ m{text/plain} ) { + if ( $first && $first->head->get('Content-Type') =~ m{text/plain} ) { $text_part = $first; } } diff --git a/rt/lib/RT/Ticket.pm b/rt/lib/RT/Ticket.pm index 6ba6c5bc5..00f88b657 100755 --- a/rt/lib/RT/Ticket.pm +++ b/rt/lib/RT/Ticket.pm @@ -2447,13 +2447,13 @@ sub _Links { # at least to myself $links->Limit( FIELD => $field, #$limit_on, - OPERATOR => 'LIKE', + OPERATOR => 'MATCHES', VALUE => 'fsck.com-rt://%/ticket/'. $self->id, ENTRYAGGREGATOR => 'OR', ); $links->Limit( FIELD => $field, #$limit_on, - OPERATOR => 'LIKE', + OPERATOR => 'MATCHES', VALUE => 'fsck.com-rt://%/ticket/'. $_, ENTRYAGGREGATOR => 'OR', ) foreach $self->Merged;