From 0610c50fd0786e2e6ccc850d68b6e865eba86541 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 6 Jul 2012 12:30:38 -0700 Subject: sales by ad source report, #17971 --- httemplate/elements/menu.html | 1 + httemplate/search/cust_bill_pkg_referral.html | 287 +++++++++++++++++++++ httemplate/search/elements/search-xls.html | 20 ++ .../search/report_cust_bill_pkg_referral.html | 55 ++++ 4 files changed, 363 insertions(+) create mode 100644 httemplate/search/cust_bill_pkg_referral.html create mode 100644 httemplate/search/report_cust_bill_pkg_referral.html diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html index 938303521..019afe94e 100644 --- a/httemplate/elements/menu.html +++ b/httemplate/elements/menu.html @@ -314,6 +314,7 @@ if($curuser->access_right('Financial reports')) { 'Daily Sales, Credits and Receipts' => [ $fsurl.'graph/report_money_time_daily.html', 'Sales, credits and receipts (broken down by day) summary graph' ], 'Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg.html', 'Sales report and graph (by agent, package class and/or date range)' ], 'Rated Call Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg_detail.html', 'Sales report and graph (by agent, package class, usage class and/or date range)' ], + 'Sales With Advertising Source' => [ $fsurl.'search/report_cust_bill_pkg_referral.html' ], 'Employee Commission Report' => [ $fsurl.'search/report_employee_commission.html', '' ], 'Credit Report' => [ $fsurl.'search/report_cust_credit.html', 'Credit report (by employee and/or date range)' ], 'Unapplied Credits' => [ $fsurl.'search/report_cust_credit.html?unapplied=1', 'Unapplied credit report (by type and/or date range)' ], diff --git a/httemplate/search/cust_bill_pkg_referral.html b/httemplate/search/cust_bill_pkg_referral.html new file mode 100644 index 000000000..f17cea3d9 --- /dev/null +++ b/httemplate/search/cust_bill_pkg_referral.html @@ -0,0 +1,287 @@ +<& 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", + ); + +if ( $cgi->param('status') =~ /^([a-z]+)$/ ) { + push @where, FS::cust_pkg->cust_status_sql . " = '$1'"; +} + +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); + +$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/elements/search-xls.html b/httemplate/search/elements/search-xls.html index 0b5636c0e..6a19cf2f5 100644 --- a/httemplate/search/elements/search-xls.html +++ b/httemplate/search/elements/search-xls.html @@ -42,6 +42,9 @@ 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. @@ -50,6 +53,7 @@ my $writer = sub { # 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 +61,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/report_cust_bill_pkg_referral.html b/httemplate/search/report_cust_bill_pkg_referral.html new file mode 100644 index 000000000..1fbb13d4f --- /dev/null +++ b/httemplate/search/report_cust_bill_pkg_referral.html @@ -0,0 +1,55 @@ +<% 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-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'); + + -- cgit v1.2.1 From 005424d0c899aa899f43f583a6c74deb13ea4be1 Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 6 Jul 2012 13:00:17 -0700 Subject: excel number format fix, #17971 --- httemplate/search/cust_bill_pkg_referral.html | 2 +- httemplate/search/elements/search-xls.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/httemplate/search/cust_bill_pkg_referral.html b/httemplate/search/cust_bill_pkg_referral.html index f17cea3d9..1bb6b9179 100644 --- a/httemplate/search/cust_bill_pkg_referral.html +++ b/httemplate/search/cust_bill_pkg_referral.html @@ -190,7 +190,7 @@ 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); +push @where, '('.join(' OR ', @orwhere).')' if @orwhere; $join_cust = ' JOIN cust_bill USING ( invnum ) LEFT JOIN cust_main USING ( custnum ) diff --git a/httemplate/search/elements/search-xls.html b/httemplate/search/elements/search-xls.html index 6a19cf2f5..a3a8226c5 100644 --- a/httemplate/search/elements/search-xls.html +++ b/httemplate/search/elements/search-xls.html @@ -49,7 +49,7 @@ 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; -- cgit v1.2.1 From 4db3780dd49caf7b84e48fe1cfc72f1d13f1282c Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Sun, 8 Jul 2012 22:43:27 -0700 Subject: per-package flags to override package def level suspend_bill flag, RT#18376 --- FS/FS/cust_main/Billing.pm | 5 ++++- FS/FS/cust_main/Packages.pm | 6 +++++- FS/FS/cust_pkg.pm | 7 ++++++- httemplate/misc/cancel_pkg.html | 14 +++++++++++++- httemplate/misc/process/cancel_pkg.html | 11 +++++++++-- httemplate/view/cust_main/packages/status.html | 7 ++++++- 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm index 0a557fca2..665662aa8 100644 --- a/FS/FS/cust_main/Billing.pm +++ b/FS/FS/cust_main/Billing.pm @@ -958,7 +958,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 06331d3df..316dedea1 100644 --- a/FS/FS/cust_main/Packages.pm +++ b/FS/FS/cust_main/Packages.pm @@ -406,7 +406,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_pkg.pm b/FS/FS/cust_pkg.pm index 855accc0c..4b458deaf 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -1041,8 +1041,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; diff --git a/httemplate/misc/cancel_pkg.html b/httemplate/misc/cancel_pkg.html index 4b5df8654..42cc56dfe 100755 --- a/httemplate/misc/cancel_pkg.html +++ b/httemplate/misc/cancel_pkg.html @@ -31,7 +31,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/process/cancel_pkg.html b/httemplate/misc/process/cancel_pkg.html index 662a77670..4f8e11b7f 100755 --- a/httemplate/misc/process/cancel_pkg.html +++ b/httemplate/misc/process/cancel_pkg.html @@ -49,10 +49,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($_)), + suspend_bill no_suspend_bill + }; } my $cust_pkg = qsearchs( 'cust_pkg', {'pkgnum'=>$pkgnum} ); @@ -73,7 +78,9 @@ if ( $method ne 'unsuspend' ) { #i.e. 'resume' my $error = $cust_pkg->$method( 'reason' => $reasonnum, 'date' => $date, - 'resume_date' => $resume_date ); + 'resume_date' => $resume_date, + 'options' => $options, + ); if ($error) { $cgi->param('error', $error); diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html index 28df9da95..47ef1bcb7 100644 --- a/httemplate/view/cust_main/packages/status.html +++ b/httemplate/view/cust_main/packages/status.html @@ -58,7 +58,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 ) %> -- cgit v1.2.1 From e27244386c346f459d1569db26344407a0372a05 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Sun, 8 Jul 2012 22:44:43 -0700 Subject: fix selfservice balance display --- fs_selfservice/FS-SelfService/cgi/myaccount.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fs_selfservice/FS-SelfService/cgi/myaccount.html b/fs_selfservice/FS-SelfService/cgi/myaccount.html index d27f688e3..f82b706b1 100644 --- a/fs_selfservice/FS-SelfService/cgi/myaccount.html +++ b/fs_selfservice/FS-SelfService/cgi/myaccount.html @@ -4,7 +4,7 @@ Hello <%= $name %>!

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

!; } ''; -- cgit v1.2.1 From 8ca531ca72ddffcc3561813bdd2f12f07b5911ce Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Mon, 9 Jul 2012 17:02:49 -0700 Subject: stop upgrade from corrupting message templates, #18406 --- FS/FS/msg_template.pm | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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; -- cgit v1.2.1 From acf95956ea282bd4145536d1dc6b4f015af42bf5 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Mon, 9 Jul 2012 22:19:49 -0700 Subject: prevent RT::EmailParser::RescueOutlook from throwing a fatal error.. still better to accept the message anyway, even if it can't be rescued from Outlook --- rt/lib/RT/EmailParser.pm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rt/lib/RT/EmailParser.pm b/rt/lib/RT/EmailParser.pm index 4cf4184a4..059a0967a 100644 --- a/rt/lib/RT/EmailParser.pm +++ b/rt/lib/RT/EmailParser.pm @@ -576,7 +576,7 @@ 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} ) @@ -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; } } -- cgit v1.2.1 From 35441752ed24a30b2ccba25db4322d9ddbc88f2d Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Mon, 9 Jul 2012 22:28:38 -0700 Subject: prevent RT::EmailParser::RescueOutlook from throwing a fatal error.. still better to accept the message anyway, even if it can't be rescued from Outlook --- rt/lib/RT/EmailParser.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rt/lib/RT/EmailParser.pm b/rt/lib/RT/EmailParser.pm index 059a0967a..dd73d9049 100644 --- a/rt/lib/RT/EmailParser.pm +++ b/rt/lib/RT/EmailParser.pm @@ -579,7 +579,7 @@ sub RescueOutlook { 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; } -- cgit v1.2.1 From eea8f640d0008d8b7a2987cc530cbec157cc652f Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Tue, 10 Jul 2012 14:55:18 -0700 Subject: fix uncancellation of services, from #17518 --- FS/FS/cust_pkg.pm | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 627a7fc3e..6c7895809 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) -- cgit v1.2.1 From 73c83632921705248b1ebf68c561852b5e0e7144 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Wed, 11 Jul 2012 02:09:16 -0700 Subject: Vendor Qualification ID is now editable if empty, RT#18405, RT#18313 --- httemplate/edit/svc_dsl.cgi | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/httemplate/edit/svc_dsl.cgi b/httemplate/edit/svc_dsl.cgi index 1aeadb376..36345b9c5 100644 --- a/httemplate/edit/svc_dsl.cgi +++ b/httemplate/edit/svc_dsl.cgi @@ -52,12 +52,24 @@ my $edit_cb = sub { elsif($export->exporttype eq 'ikano') { @fields = ( 'password', 'monitored', ); - foreach my $hf ( keys %$ti_fields ) { - push @fields, { - field => $hf, - type => 'hidden', - value => $svc_x->$hf, - } unless ( $hf eq 'password' || $hf eq 'monitored' ); + if ( $svc_x->vendor_qual_id ) { + push @fields, { field => 'vendor_qual_id', + type => 'hidden', + value => $svc_x->vendor_qual_id, + }; + } else { + push @fields, 'vendor_qual_id'; + } + + foreach my $hf ( + grep { $_ !~ /^(password|monitored|vendor_qual_id)$/ } + keys %$ti_fields + ) { + push @fields, { + field => $hf, + type => 'hidden', + value => $svc_x->$hf, + }; } } # else add any other export-specific stuff here -- cgit v1.2.1 From f27da610fe9dabe8c7319f1935abd1a8cc7b2e90 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Wed, 11 Jul 2012 21:39:00 -0700 Subject: fix accidental fallout from premature commit of RT#18376, RT#18499 --- httemplate/misc/process/cancel_pkg.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/httemplate/misc/process/cancel_pkg.html b/httemplate/misc/process/cancel_pkg.html index 79e489c70..a106b845a 100755 --- a/httemplate/misc/process/cancel_pkg.html +++ b/httemplate/misc/process/cancel_pkg.html @@ -58,8 +58,8 @@ if ( $method eq 'suspend' ) { #or 'adjourn' $resume_date = parse_datetime($cgi->param('resume_date')) if $cgi->param('resume_date'); - $options = { map scalar($cgi->param($_)), - suspend_bill no_suspend_bill + $options = { map { $_ => scalar($cgi->param($_)) } + qw( suspend_bill no_suspend_bill ) }; } -- cgit v1.2.1 From 37eaedf1acb77298a1c5b6ca0eecf372633dc4cc Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Wed, 11 Jul 2012 23:44:47 -0700 Subject: fix RADIUS overage billing, RT#18490 --- FS/FS/cust_pkg.pm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 6c7895809..8297d2211 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -2709,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); @@ -2741,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); -- cgit v1.2.1 From 26004f55ce70242d07fc8de51e24439e783e9e49 Mon Sep 17 00:00:00 2001 From: Ivan Kohler Date: Fri, 13 Jul 2012 00:49:12 -0700 Subject: abstract out the amount + fee + input, javascript, display so it can be reused by self-service without code duplication --- FS/FS/Conf.pm | 52 ++++++++++++++- httemplate/elements/tr-amount_fee.html | 98 ++++++++++++++++++++++++++++ httemplate/misc/payment.cgi | 115 +++++++-------------------------- 3 files changed, 172 insertions(+), 93 deletions(-) create mode 100644 httemplate/elements/tr-amount_fee.html diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 7e641308b..e3f8a5a15 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', }, @@ -2560,6 +2560,56 @@ and customer address. Include units.', 'type' => 'checkbox', }, + { + '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', diff --git a/httemplate/elements/tr-amount_fee.html b/httemplate/elements/tr-amount_fee.html new file mode 100644 index 000000000..a1a9e3433 --- /dev/null +++ b/httemplate/elements/tr-amount_fee.html @@ -0,0 +1,98 @@ + + <% 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 ) { + + + +% } + +<%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/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; -- cgit v1.2.1 From 0ad946f751d3a953c8a41eea1d30ad362ba38ace Mon Sep 17 00:00:00 2001 From: Mark Wells Date: Fri, 13 Jul 2012 10:56:10 -0700 Subject: one-way check batches, #17623 --- FS/FS/Conf.pm | 7 + FS/FS/Schema.pm | 4 +- FS/FS/cust_main.pm | 27 +- FS/FS/cust_pay.pm | 21 ++ FS/FS/cust_pay_batch.pm | 1 + FS/FS/pay_batch.pm | 341 +++++++++++++++------ FS/FS/payment_gateway.pm | 2 +- httemplate/edit/payment_gateway.html | 7 +- httemplate/search/elements/cust_pay_batch_top.html | 2 +- httemplate/search/elements/cust_pay_or_refund.html | 9 + httemplate/search/pay_batch.cgi | 92 ++++-- 11 files changed, 385 insertions(+), 128 deletions(-) diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index e3f8a5a15..b4ce0baaa 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -3525,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', diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index 797b70549..61bd00cec 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -1407,6 +1407,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' ] ], @@ -1486,10 +1487,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/cust_main.pm b/FS/FS/cust_main.pm index 82b09b61f..d6a86c786 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -2463,6 +2463,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 { @@ -2540,10 +2559,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_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/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/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 @@