X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=1f2fe886bc7dd65a469b9b4c774fbfe4f8dd9490;hb=1203e278e2ec38fcf1468da2e4f10862004bebeb;hp=b5e6892336cbf5d527c7cf8662d33d3e20a5f119;hpb=f144c3a14169d99fd93f265da690d9ce38dbae68;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index b5e689233..1f2fe886b 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -8,6 +8,7 @@ use vars qw( $realtime_bop_decline_quiet ); #ugh use Safe; use Carp; use Exporter; +use Scalar::Util qw( blessed ); use Time::Local qw(timelocal_nocheck); use Data::Dumper; use Tie::IxHash; @@ -45,8 +46,6 @@ use FS::part_pkg; use FS::part_event; use FS::part_event_condition; #use FS::cust_event; -use FS::cust_tax_exempt; -use FS::cust_tax_exempt_pkg; use FS::type_pkgs; use FS::payment_gateway; use FS::agent_payment_gateway; @@ -1054,7 +1053,7 @@ sub delete { } -=item replace OLD_RECORD [ INVOICING_LIST_ARYREF ] +=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. @@ -1070,23 +1069,16 @@ check_invoicing_list first. Here's an example: sub replace { my $self = shift; - my $old = shift; + + my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') ) + ? shift + : $self->replace_old; + my @param = @_; + warn "$me replace called\n" if $DEBUG; - local $SIG{HUP} = 'IGNORE'; - local $SIG{INT} = 'IGNORE'; - local $SIG{QUIT} = 'IGNORE'; - local $SIG{TERM} = 'IGNORE'; - local $SIG{TSTP} = 'IGNORE'; - local $SIG{PIPE} = 'IGNORE'; - - # We absolutely have to have an old vs. new record to make this work. - if (!defined($old)) { - $old = qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); - } - my $curuser = $FS::CurrentUser::CurrentUser; if ( $self->payby eq 'COMP' && $self->payby ne $old->payby @@ -1101,6 +1093,13 @@ sub replace { && $self->payby =~ /^(CARD|DCRD)$/ && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask ); + local $SIG{HUP} = 'IGNORE'; + local $SIG{INT} = 'IGNORE'; + local $SIG{QUIT} = 'IGNORE'; + local $SIG{TERM} = 'IGNORE'; + local $SIG{TSTP} = 'IGNORE'; + local $SIG{PIPE} = 'IGNORE'; + my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; my $dbh = dbh; @@ -1951,10 +1950,12 @@ sub bill_and_collect { # cancel packages ### - #$^T not $options{time} because freeside-daily -d is for pre-printing invoices - foreach my $cust_pkg ( - grep { $_->expire && $_->expire <= $^T } $self->ncancelled_pkgs - ) { + #$options{actual_time} not $options{time} because freeside-daily -d is for + #pre-printing invoices + my @cancel_pkgs = grep { $_->expire && $_->expire <= $options{actual_time} } + $self->ncancelled_pkgs; + + foreach my $cust_pkg ( @cancel_pkgs ) { my $error = $cust_pkg->cancel; warn "Error cancelling expired pkg ". $cust_pkg->pkgnum. " for custnum ". $self->custnum. ": $error" @@ -1965,15 +1966,22 @@ sub bill_and_collect { # suspend packages ### - #$^T not $options{time} because freeside-daily -d is for pre-printing invoices - foreach my $cust_pkg ( - grep { ( $_->part_pkg->is_prepaid && $_->bill && $_->bill < $^T - || $_->adjourn && $_->adjourn <= $^T - ) - && ! $_->susp + #$options{actual_time} not $options{time} because freeside-daily -d is for + #pre-printing invoices + my @susp_pkgs = + grep { ! $_->susp + && ( ( $_->part_pkg->is_prepaid + && $_->bill + && $_->bill < $options{actual_time} + ) + || ( $_->adjourn + && $_->adjourn <= $options{actual_time} + ) + ) } - $self->ncancelled_pkgs - ) { + $self->ncancelled_pkgs; + + foreach my $cust_pkg ( @susp_pkgs ) { my $error = $cust_pkg->suspend; warn "Error suspending package ". $cust_pkg->pkgnum. " for custnum ". $self->custnum. ": $error" @@ -2039,8 +2047,6 @@ sub bill { my $time = $options{'time'} || time; - my $error; - #put below somehow? local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; @@ -2055,21 +2061,7 @@ sub bill { $self->select_for_update; #mutex - #create a new invoice - #(we'll remove it later if it doesn't actually need to be generated [contains - # no line items] and we're inside a transaciton so nothing else will see it) - my $cust_bill = new FS::cust_bill ( { - 'custnum' => $self->custnum, - '_date' => ( $options{'invoice_time'} || $time ), - #'charged' => $charged, - 'charged' => 0, - } ); - $error = $cust_bill->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't create invoice for customer #". $self->custnum. ": $error"; - } - my $invnum = $cust_bill->invnum; + my @cust_bill_pkg = (); ### # find the packages which are due for billing, find out how much they are @@ -2078,6 +2070,8 @@ sub bill { my( $total_setup, $total_recur ) = ( 0, 0 ); my %tax; + my %taxlisthash; + my %taxname; my @precommit_hooks = (); foreach my $cust_pkg ( @@ -2093,352 +2087,399 @@ sub bill { $cust_pkg->setfield('bill', '') unless defined($cust_pkg->bill); - my $part_pkg = $cust_pkg->part_pkg; + #my $part_pkg = $cust_pkg->part_pkg; + my $real_pkgpart = $cust_pkg->pkgpart; my %hash = $cust_pkg->hash; my $old_cust_pkg = new FS::cust_pkg \%hash; - my @details = (); - - ### - # bill setup - ### + foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) { - my $setup = 0; - if ( ! $cust_pkg->setup && - ( - ( $conf->exists('disable_setup_suspended_pkgs') && - ! $cust_pkg->getfield('susp') - ) || ! $conf->exists('disable_setup_suspended_pkgs') - ) - || $options{'resetup'} - ) { - - warn " bill setup\n" if $DEBUG > 1; - - $setup = eval { $cust_pkg->calc_setup( $time, \@details ) }; - if ( $@ ) { - $dbh->rollback if $oldAutoCommit; - return "$@ running calc_setup for $cust_pkg\n"; - } - - $cust_pkg->setfield('setup', $time) unless $cust_pkg->setup; - } - - ### - # bill recurring fee - ### - - my $recur = 0; - my $sdate; - if ( $part_pkg->getfield('freq') ne '0' && - ! $cust_pkg->getfield('susp') && - ( $cust_pkg->getfield('bill') || 0 ) <= $time - ) { - - # XXX should this be a package event? probably. events are called - # at collection time at the moment, though... - if ( $part_pkg->can('reset_usage') ) { - warn " resetting usage counters" if $DEBUG > 1; - $part_pkg->reset_usage($cust_pkg); - } - - warn " bill recur\n" if $DEBUG > 1; - - # XXX shared with $recur_prog - $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; - - #over two params! lets at least switch to a hashref for the rest... - my %param = ( 'precommit_hooks' => \@precommit_hooks, ); - - $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) }; - if ( $@ ) { - $dbh->rollback if $oldAutoCommit; - return "$@ running calc_recur for $cust_pkg\n"; - } - - #change this bit to use Date::Manip? CAREFUL with timezones (see - # mailing list archive) - my ($sec,$min,$hour,$mday,$mon,$year) = - (localtime($sdate) )[0,1,2,3,4,5]; - - #pro-rating magic - if $recur_prog fiddles $sdate, want to use that - # only for figuring next bill date, nothing else, so, reset $sdate again - # here - $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; - $cust_pkg->last_bill($sdate) - if $cust_pkg->dbdef_table->column('last_bill'); - - if ( $part_pkg->freq =~ /^\d+$/ ) { - $mon += $part_pkg->freq; - until ( $mon < 12 ) { $mon -= 12; $year++; } - } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) { - my $weeks = $1; - $mday += $weeks * 7; - } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) { - my $days = $1; - $mday += $days; - } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) { - my $hours = $1; - $hour += $hours; - } else { - $dbh->rollback if $oldAutoCommit; - return "unparsable frequency: ". $part_pkg->freq; - } - $cust_pkg->setfield('bill', - timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year)); - } + $cust_pkg->pkgpart($part_pkg->pkgpart); + $cust_pkg->set($_, $hash{$_}) foreach qw( setup last_bill bill ); + + my @details = (); - warn "\$setup is undefined" unless defined($setup); - warn "\$recur is undefined" unless defined($recur); - warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill); + my $lineitems = 0; - ### - # If $cust_pkg has been modified, update it and create cust_bill_pkg records - ### + ### + # bill setup + ### - if ( $cust_pkg->modified ) { # hmmm.. and if the options are modified? + my $setup = 0; + if ( ! $cust_pkg->setup && + ( + ( $conf->exists('disable_setup_suspended_pkgs') && + ! $cust_pkg->getfield('susp') + ) || ! $conf->exists('disable_setup_suspended_pkgs') + ) + || $options{'resetup'} + ) { + + warn " bill setup\n" if $DEBUG > 1; + $lineitems++; - warn " package ". $cust_pkg->pkgnum. " modified; updating\n" - if $DEBUG >1; + $setup = eval { $cust_pkg->calc_setup( $time, \@details ) }; + if ( $@ ) { + $dbh->rollback if $oldAutoCommit; + return "$@ running calc_setup for $cust_pkg\n"; + } - $error=$cust_pkg->replace($old_cust_pkg, - options => { $cust_pkg->options }, - ); - if ( $error ) { #just in case - $dbh->rollback if $oldAutoCommit; - return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"; - } + $cust_pkg->setfield('setup', $time) + unless $cust_pkg->setup; + #do need it, but it won't get written to the db + #|| $cust_pkg->pkgpart != $real_pkgpart; - $setup = sprintf( "%.2f", $setup ); - $recur = sprintf( "%.2f", $recur ); - if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) { - $dbh->rollback if $oldAutoCommit; - return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum; - } - if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) { - $dbh->rollback if $oldAutoCommit; - return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; } - if ( $setup != 0 || $recur != 0 ) { - - warn " charges (setup=$setup, recur=$recur); adding line items\n" - if $DEBUG > 1; - my $cust_bill_pkg = new FS::cust_bill_pkg ({ - 'invnum' => $invnum, - 'pkgnum' => $cust_pkg->pkgnum, - 'setup' => $setup, - 'recur' => $recur, - 'sdate' => $sdate, - 'edate' => $cust_pkg->bill, - 'details' => \@details, - }); - $error = $cust_bill_pkg->insert; - if ( $error ) { + ### + # bill recurring fee + ### + + my $recur = 0; + my $sdate; + if ( $part_pkg->getfield('freq') ne '0' && + ! $cust_pkg->getfield('susp') && + ( $cust_pkg->getfield('bill') || 0 ) <= $time + ) { + + # XXX should this be a package event? probably. events are called + # at collection time at the moment, though... + $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG) + if $part_pkg->can('reset_usage'); + #don't want to reset usage just cause we want a line item?? + #&& $part_pkg->pkgpart == $real_pkgpart; + + warn " bill recur\n" if $DEBUG > 1; + $lineitems++; + + # XXX shared with $recur_prog + $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; + + #over two params! lets at least switch to a hashref for the rest... + my %param = ( 'precommit_hooks' => \@precommit_hooks, ); + + $recur = eval { $cust_pkg->calc_recur( \$sdate, \@details, \%param ) }; + if ( $@ ) { $dbh->rollback if $oldAutoCommit; - return "can't create invoice line item for invoice #$invnum: $error"; + return "$@ running calc_recur for $cust_pkg\n"; } - $total_setup += $setup; - $total_recur += $recur; - ### - # handle taxes - ### - - unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { - - my $prefix = - ( $conf->exists('tax-ship_address') && length($self->ship_last) ) - ? 'ship_' - : ''; - my %taxhash = map { $_ => $self->get("$prefix$_") } - qw( state county country ); - - $taxhash{'taxclass'} = $part_pkg->taxclass; - - my @taxes = qsearch( 'cust_main_county', \%taxhash ); - - unless ( @taxes ) { - $taxhash{'taxclass'} = ''; - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } + + #change this bit to use Date::Manip? CAREFUL with timezones (see + # mailing list archive) + my ($sec,$min,$hour,$mday,$mon,$year) = + (localtime($sdate) )[0,1,2,3,4,5]; + + #pro-rating magic - if $recur_prog fiddles $sdate, want to use that + # only for figuring next bill date, nothing else, so, reset $sdate again + # here + $sdate = $cust_pkg->bill || $cust_pkg->setup || $time; + $cust_pkg->last_bill($sdate); + + if ( $part_pkg->freq =~ /^\d+$/ ) { + $mon += $part_pkg->freq; + until ( $mon < 12 ) { $mon -= 12; $year++; } + } elsif ( $part_pkg->freq =~ /^(\d+)w$/ ) { + my $weeks = $1; + $mday += $weeks * 7; + } elsif ( $part_pkg->freq =~ /^(\d+)d$/ ) { + my $days = $1; + $mday += $days; + } elsif ( $part_pkg->freq =~ /^(\d+)h$/ ) { + my $hours = $1; + $hour += $hours; + } else { + $dbh->rollback if $oldAutoCommit; + return "unparsable frequency: ". $part_pkg->freq; + } + $cust_pkg->setfield('bill', + timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year)); + + } - #one more try at a whole-country tax rate - unless ( @taxes ) { - $taxhash{$_} = '' foreach qw( state county ); - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } + warn "\$setup is undefined" unless defined($setup); + warn "\$recur is undefined" unless defined($recur); + warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill); + + ### + # If there's line items, create em cust_bill_pkg records + # If $cust_pkg has been modified, update it (if we're a real pkgpart) + ### + + if ( $lineitems ) { - # maybe eliminate this entirely, along with all the 0% records - unless ( @taxes ) { + if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) { + # hmm.. and if just the options are modified in some weird price plan? + + warn " package ". $cust_pkg->pkgnum. " modified; updating\n" + if $DEBUG >1; + + my $error = $cust_pkg->replace( $old_cust_pkg, + 'options' => { $cust_pkg->options }, + ); + if ( $error ) { #just in case $dbh->rollback if $oldAutoCommit; - return - "fatal: can't find tax rate for state/county/country/taxclass ". - join('/', ( map $self->get("$prefix$_"), - qw(state county country) - ), - $part_pkg->taxclass ). "\n"; + return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"; } + } - foreach my $tax ( @taxes ) { - - my $taxable_charged = 0; - $taxable_charged += $setup - unless $part_pkg->setuptax =~ /^Y$/i - || $tax->setuptax =~ /^Y$/i; - $taxable_charged += $recur - unless $part_pkg->recurtax =~ /^Y$/i - || $tax->recurtax =~ /^Y$/i; - next unless $taxable_charged; - - if ( $tax->exempt_amount && $tax->exempt_amount > 0 ) { - #my ($mon,$year) = (localtime($sdate) )[4,5]; - my ($mon,$year) = (localtime( $sdate || $cust_bill->_date ) )[4,5]; - $mon++; - my $freq = $part_pkg->freq || 1; - if ( $freq !~ /(\d+)$/ ) { - $dbh->rollback if $oldAutoCommit; - return "daily/weekly package definitions not (yet?)". - " compatible with monthly tax exemptions"; - } - my $taxable_per_month = - sprintf("%.2f", $taxable_charged / $freq ); - - #call the whole thing off if this customer has any old - #exemption records... - my @cust_tax_exempt = - qsearch( 'cust_tax_exempt' => { custnum=> $self->custnum } ); - if ( @cust_tax_exempt ) { - $dbh->rollback if $oldAutoCommit; - return - 'this customer still has old-style tax exemption records; '. - 'run bin/fs-migrate-cust_tax_exempt?'; + $setup = sprintf( "%.2f", $setup ); + $recur = sprintf( "%.2f", $recur ); + if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) { + $dbh->rollback if $oldAutoCommit; + return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum; + } + if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) { + $dbh->rollback if $oldAutoCommit; + return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; + } + + if ( $setup != 0 || $recur != 0 ) { + + warn " charges (setup=$setup, recur=$recur); adding line items\n" + if $DEBUG > 1; + my $cust_bill_pkg = new FS::cust_bill_pkg { + 'pkgnum' => $cust_pkg->pkgnum, + 'setup' => $setup, + 'recur' => $recur, + 'sdate' => $sdate, + 'edate' => $cust_pkg->bill, + 'details' => \@details, + }; + $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart) + unless $part_pkg->pkgpart == $real_pkgpart; + push @cust_bill_pkg, $cust_bill_pkg; + + $total_setup += $setup; + $total_recur += $recur; + + ### + # handle taxes + ### + + unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { + + my @taxes = (); + my @taxoverrides = $part_pkg->part_pkg_taxoverride; + + my $prefix = + ( $conf->exists('tax-ship_address') && length($self->ship_last) ) + ? 'ship_' + : ''; + + if ( $conf->exists('enable_taxproducts') + && (scalar(@taxoverrides) || $part_pkg->taxproductnum ) + ) + { + + my @taxclassnums = (); + my $geocode = $self->geocode('cch'); + + if ( scalar( @taxoverrides ) ) { + @taxclassnums = map { $_->taxclassnum } @taxoverrides; + }elsif ( $part_pkg->taxproductnum ) { + @taxclassnums = map { $_->taxclassnum } + $part_pkg->part_pkg_taxrate('cch', $geocode); } + + my $extra_sql = + "AND (". + join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")"; + + @taxes = qsearch({ 'table' => 'tax_rate', + 'hashref' => { 'geocode' => $geocode, }, + 'extra_sql' => $extra_sql, + }) + if scalar(@taxclassnums); + - foreach my $which_month ( 1 .. $freq ) { - - #maintain the new exemption table now - my $sql = " - SELECT SUM(amount) - FROM cust_tax_exempt_pkg - LEFT JOIN cust_bill_pkg USING ( billpkgnum ) - LEFT JOIN cust_bill USING ( invnum ) - WHERE custnum = ? - AND taxnum = ? - AND year = ? - AND month = ? - "; - my $sth = dbh->prepare($sql) or do { - $dbh->rollback if $oldAutoCommit; - return "fatal: can't lookup exising exemption: ". dbh->errstr; - }; - $sth->execute( - $self->custnum, - $tax->taxnum, - 1900+$year, - $mon, - ) or do { - $dbh->rollback if $oldAutoCommit; - return "fatal: can't lookup exising exemption: ". dbh->errstr; - }; - my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0; - - my $remaining_exemption = - $tax->exempt_amount - $existing_exemption; - if ( $remaining_exemption > 0 ) { - my $addl = $remaining_exemption > $taxable_per_month - ? $taxable_per_month - : $remaining_exemption; - $taxable_charged -= $addl; - - my $cust_tax_exempt_pkg = new FS::cust_tax_exempt_pkg ( { - 'billpkgnum' => $cust_bill_pkg->billpkgnum, - 'taxnum' => $tax->taxnum, - 'year' => 1900+$year, - 'month' => $mon, - 'amount' => sprintf("%.2f", $addl ), - } ); - $error = $cust_tax_exempt_pkg->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "fatal: can't insert cust_tax_exempt_pkg: $error"; - } - } # if $remaining_exemption > 0 - - #++ - $mon++; - #until ( $mon < 12 ) { $mon -= 12; $year++; } - until ( $mon < 13 ) { $mon -= 12; $year++; } + }else{ + + my %taxhash = map { $_ => $self->get("$prefix$_") } + qw( state county country ); - } #foreach $which_month + $taxhash{'taxclass'} = $part_pkg->taxclass; - } #if $tax->exempt_amount + @taxes = qsearch( 'cust_main_county', \%taxhash ); - $taxable_charged = sprintf( "%.2f", $taxable_charged); + unless ( @taxes ) { + $taxhash{'taxclass'} = ''; + @taxes = qsearch( 'cust_main_county', \%taxhash ); + } - #$tax += $taxable_charged * $cust_main_county->tax / 100 - $tax{ $tax->taxname || 'Tax' } += - $taxable_charged * $tax->tax / 100 + #one more try at a whole-country tax rate + unless ( @taxes ) { + $taxhash{$_} = '' foreach qw( state county ); + @taxes = qsearch( 'cust_main_county', \%taxhash ); + } - } #foreach my $tax ( @taxes ) + } #if $conf->exists('enable_taxproducts') + + # maybe eliminate this entirely, along with all the 0% records + unless ( @taxes ) { + $dbh->rollback if $oldAutoCommit; + my $error; + if ( $conf->exists('enable_taxproducts') ) { + $error = + "fatal: can't find tax rate for zip/taxproduct/pkgpart ". + join('/', ( map $self->get("$prefix$_"), + qw(zip) + ), + $part_pkg->taxproduct_description, + $part_pkg->pkgpart ). "\n"; + } else { + $error = + "fatal: can't find tax rate for state/county/country/taxclass ". + join('/', ( map $self->get("$prefix$_"), + qw(state county country) + ), + $part_pkg->taxclass ). "\n"; + } + return $error; + } + + foreach my $tax ( @taxes ) { + my $taxname = ref( $tax ). ' '. $tax->taxnum; + if ( exists( $taxlisthash{ $taxname } ) ) { + push @{ $taxlisthash{ $taxname } }, $cust_bill_pkg; + }else{ + $taxlisthash{ $taxname } = [ $tax, $cust_bill_pkg ]; + } + } - } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' - } #if $setup != 0 || $recur != 0 - - } #if $cust_pkg->modified + } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' - } #foreach my $cust_pkg + } #if $setup != 0 || $recur != 0 + + } #if $cust_pkg->modified - unless ( $cust_bill->cust_bill_pkg ) { - $cust_bill->delete; #don't create an invoice w/o line items + } #foreach my $part_pkg - # XXX this seems to be broken - #( DBD::Pg::st execute failed: ERROR: syntax error at or near "hcb" ) -# # get rid of our fake history too, waste of unecessary space -# my $h_cleanup_query = q{ -# DELETE FROM h_cust_bill hcb -# WHERE hcb.invnum = ? -# AND NOT EXISTS ( SELECT 1 FROM cust_bill cb where cb.invnum = hcb.invnum ) -# }; -# my $h_sth = $dbh->prepare($h_cleanup_query); -# $h_sth->execute($invnum); + } #foreach my $cust_pkg + unless ( @cust_bill_pkg ) { #don't create an invoice w/o line items + #but do commit any package date cycling that happened $dbh->commit or die $dbh->errstr if $oldAutoCommit; return ''; } - my $charged = sprintf( "%.2f", $total_setup + $total_recur ); + warn "having a look at the taxes we found...\n" if $DEBUG > 2; + foreach my $tax ( keys %taxlisthash ) { + my $tax_object = shift @{ $taxlisthash{$tax} }; + warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2; + my $listref_or_error = $tax_object->taxline( @{ $taxlisthash{$tax} } ); + unless (ref($listref_or_error)) { + $dbh->rollback if $oldAutoCommit; + return $listref_or_error; + } + unshift @{ $taxlisthash{$tax} }, $tax_object; - foreach my $taxname ( grep { $tax{$_} > 0 } keys %tax ) { - my $tax = sprintf("%.2f", $tax{$taxname} ); - $charged = sprintf( "%.2f", $charged+$tax ); + warn "adding ". $listref_or_error->[1]. + " as ". $listref_or_error->[0]. "\n" + if $DEBUG > 2; + $tax{ $tax_object->taxname } += $listref_or_error->[1]; + if ( $taxname{ $listref_or_error->[0] } ) { + push @{ $taxname{ $listref_or_error->[0] } }, $tax_object->taxname; + }else{ + $taxname{ $listref_or_error->[0] } = [ $tax_object->taxname ]; + } + + } + + #some taxes are taxed + my %totlisthash; + + warn "finding taxed taxes...\n" if $DEBUG > 2; + foreach my $tax ( keys %taxlisthash ) { + my $tax_object = shift @{ $taxlisthash{$tax} }; + warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n" + if $DEBUG > 2; + next unless $tax_object->can('tax_on_tax'); + + foreach my $tot ( $tax_object->tax_on_tax( $self ) ) { + my $totname = ref( $tot ). ' '. $tot->taxnum; + + warn "checking $totname which we call ". $tot->taxname. " as applicable\n" + if $DEBUG > 2; + next unless exists( $taxlisthash{ $totname } ); # only increase + # existing taxes + warn "adding $totname to taxed taxes\n" if $DEBUG > 2; + if ( exists( $totlisthash{ $totname } ) ) { + push @{ $totlisthash{ $totname } }, $tax{ $tax_object->taxname }; + }else{ + $totlisthash{ $totname } = [ $tot, $tax{ $tax_object->taxname } ]; + } + } + } + + warn "having a look at taxed taxes...\n" if $DEBUG > 2; + foreach my $tax ( keys %totlisthash ) { + my $tax_object = shift @{ $totlisthash{$tax} }; + warn "found previously found taxed tax ". $tax_object->taxname. "\n" + if $DEBUG > 2; + my $listref_or_error = $tax_object->taxline( @{ $totlisthash{$tax} } ); + unless (ref($listref_or_error)) { + $dbh->rollback if $oldAutoCommit; + return $listref_or_error; + } + + warn "adding taxed tax amount ". $listref_or_error->[1]. + " as ". $tax_object->taxname. "\n" + if $DEBUG; + $tax{ $tax_object->taxname } += $listref_or_error->[1]; + } + + #consolidate and create tax line items + warn "consolidating and generating...\n" if $DEBUG > 2; + foreach my $taxname ( keys %taxname ) { + my $tax = 0; + my %seen = (); + warn "adding $taxname\n" if $DEBUG > 1; + foreach my $taxitem ( @{ $taxname{$taxname} } ) { + $tax += $tax{$taxitem} unless $seen{$taxitem}; + warn "adding $tax{$taxitem}\n" if $DEBUG > 1; + } + next unless $tax; + + $tax = sprintf('%.2f', $tax ); + $total_setup = sprintf('%.2f', $total_setup+$tax ); - my $cust_bill_pkg = new FS::cust_bill_pkg ({ - 'invnum' => $invnum, + push @cust_bill_pkg, new FS::cust_bill_pkg { 'pkgnum' => 0, 'setup' => $tax, 'recur' => 0, 'sdate' => '', 'edate' => '', 'itemdesc' => $taxname, - }); - $error = $cust_bill_pkg->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "can't create invoice line item for invoice #$invnum: $error"; - } - $total_setup += $tax; + }; } - $cust_bill->charged( sprintf( "%.2f", $total_setup + $total_recur ) ); - $error = $cust_bill->replace; + my $charged = sprintf('%.2f', $total_setup + $total_recur ); + + #create the new invoice + my $cust_bill = new FS::cust_bill ( { + 'custnum' => $self->custnum, + '_date' => ( $options{'invoice_time'} || $time ), + 'charged' => $charged, + } ); + my $error = $cust_bill->insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "can't update charged for invoice #$invnum: $error"; + return "can't create invoice for customer #". $self->custnum. ": $error"; + } + + foreach my $cust_bill_pkg ( @cust_bill_pkg ) { + $cust_bill_pkg->invnum($cust_bill->invnum); + my $error = $cust_bill_pkg->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "can't create invoice line item: $error"; + } } + foreach my $hook ( @precommit_hooks ) { eval { @@ -3170,7 +3211,7 @@ sub realtime_bop { 'gatewaynum' => ( $payment_gateway ? $payment_gateway->gatewaynum : '' ), }; $cust_pay_pending->payunique( $options{payunique} ) - if length($options{payunique}); + if defined($options{payunique}) && length($options{payunique}); my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted return $cpp_new_err if $cpp_new_err; @@ -3323,7 +3364,8 @@ sub realtime_bop { 'paydate' => $paydate, } ); #doesn't hurt to know, even though the dup check is in cust_pay_pending now - $cust_pay->payunique( $options{payunique} ) if length($options{payunique}); + $cust_pay->payunique( $options{payunique} ) + if defined($options{payunique}) && length($options{payunique}); my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; @@ -4633,16 +4675,19 @@ the error, otherwise returns false. sub charge { my $self = shift; - my ( $amount, $pkg, $comment, $taxclass, $additional ); + my ( $amount, $quantity, $pkg, $comment, $taxclass, $additional, $classnum ); if ( ref( $_[0] ) ) { $amount = $_[0]->{amount}; + $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1; $pkg = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge'; $comment = exists($_[0]->{comment}) ? $_[0]->{comment} : '$'. sprintf("%.2f",$amount); $taxclass = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : ''; + $classnum = exists($_[0]->{classnum}) ? $_[0]->{classnum} : ''; $additional = $_[0]->{additional}; }else{ $amount = shift; + $quantity = 1; $pkg = @_ ? shift : 'One-time charge'; $comment = @_ ? shift : '$'. sprintf("%.2f",$amount); $taxclass = @_ ? shift : ''; @@ -4666,6 +4711,7 @@ sub charge { 'plan' => 'flat', 'freq' => 0, 'disabled' => 'Y', + 'classnum' => $classnum ? $classnum : '', 'taxclass' => $taxclass, } ); @@ -4694,8 +4740,9 @@ sub charge { } my $cust_pkg = new FS::cust_pkg ( { - 'custnum' => $self->custnum, - 'pkgpart' => $pkgpart, + 'custnum' => $self->custnum, + 'pkgpart' => $pkgpart, + 'quantity' => $quantity, } ); $error = $cust_pkg->insert; @@ -4860,6 +4907,40 @@ sub country_full { code2country($self->country); } +=item geocode DATA_VENDOR + +Returns a value for the customer location as encoded by DATA_VENDOR. +Currently this only makes sense for "CCH" as DATA_VENDOR. + +=cut + +sub geocode { + my ($self, $data_vendor) = (shift, shift); #always cch for now + + my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) ) + ? 'ship_' + : ''; + + my ($zip,$plus4) = split /-/, $self->get("${prefix}zip") + if $self->country eq 'US'; + + #CCH specific location stuff + my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'"; + + my $geocode = ''; + my $cust_tax_location = + qsearchs( { + 'table' => 'cust_tax_location', + 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor }, + 'extra_sql' => $extra_sql, + } + ); + $geocode = $cust_tax_location->geocode + if $cust_tax_location; + + $geocode; +} + =item cust_status =item status @@ -5022,7 +5103,7 @@ sub prospect_sql { " =item active_sql Returns an SQL expression identifying active cust_main records (customers with -no active recurring packages, but otherwise unsuspended/uncancelled). +active recurring packages). =cut @@ -5034,7 +5115,7 @@ sub active_sql { " =item inactive_sql Returns an SQL expression identifying inactive cust_main records (customers with -active recurring packages). +no active recurring packages, but otherwise unsuspended/uncancelled). =cut @@ -5070,17 +5151,16 @@ sub cancelled_sql { cancel_sql(@_); } sub cancel_sql { my $recurring_sql = FS::cust_pkg->recurring_sql; - #my $recurring_sql = " - # '0' != ( select freq from part_pkg - # where cust_pkg.pkgpart = part_pkg.pkgpart ) - #"; + my $cancelled_sql = FS::cust_pkg->cancelled_sql; " - 0 < ( $select_count_pkgs ) + 0 < ( $select_count_pkgs ) + AND 0 < ( $select_count_pkgs AND $recurring_sql AND $cancelled_sql ) AND 0 = ( $select_count_pkgs AND $recurring_sql AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 ) ) "; + } =item uncancel_sql @@ -5108,58 +5188,280 @@ Returns an SQL fragment to retreive the balance. =cut sub balance_sql { " - COALESCE( ( SELECT SUM(charged) FROM cust_bill - WHERE cust_bill.custnum = cust_main.custnum ), 0) - - COALESCE( ( SELECT SUM(paid) FROM cust_pay - WHERE cust_pay.custnum = cust_main.custnum ), 0) - - COALESCE( ( SELECT SUM(amount) FROM cust_credit - WHERE cust_credit.custnum = cust_main.custnum ), 0) - + COALESCE( ( SELECT SUM(refund) FROM cust_refund - WHERE cust_refund.custnum = cust_main.custnum ), 0) + ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill + WHERE cust_bill.custnum = cust_main.custnum ) + - ( SELECT COALESCE( SUM(paid), 0 ) FROM cust_pay + WHERE cust_pay.custnum = cust_main.custnum ) + - ( SELECT COALESCE( SUM(amount), 0 ) FROM cust_credit + WHERE cust_credit.custnum = cust_main.custnum ) + + ( SELECT COALESCE( SUM(refund), 0 ) FROM cust_refund + WHERE cust_refund.custnum = cust_main.custnum ) "; } -=item balance_date_sql TIME +=item balance_date_sql START_TIME [ END_TIME [ OPTION => VALUE ... ] ] Returns an SQL fragment to retreive the balance for this customer, only -considering invoices with date earlier than TIME. (total_owed_date minus total_credited minus -total_unapplied_payments). TIME is specified as an SQL fragment or a numeric -UNIX timestamp; see L). Also see L and -L for conversion functions. +considering invoices with date earlier than START_TIME, and optionally not +later than END_TIME (total_owed_date minus total_credited minus +total_unapplied_payments). + +Times are specified as SQL fragments or numeric +UNIX timestamps; see L). Also see L and +L for conversion functions. The empty string can be passed +to disable that time constraint completely. + +Available options are: + +=over 4 + +=item unapplied_date - set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering) + +=item total - set to true to remove all customer comparison clauses, for totals + +=item where - WHERE clause hashref (elements "AND"ed together) (typically used with the total option) + +=item join - JOIN clause (typically used with the total option) + +=item + +=back =cut sub balance_date_sql { - my( $class, $time ) = @_; + my( $class, $start, $end, %opt ) = @_; - my $owed_sql = FS::cust_bill->owed_sql; - my $unapp_refund_sql = FS::cust_refund->unapplied_sql; - #my $unapp_credit_sql = FS::cust_credit->unapplied_sql; - my $unapp_credit_sql = FS::cust_credit->credited_sql; - my $unapp_pay_sql = FS::cust_pay->unapplied_sql; + my $owed = FS::cust_bill->owed_sql; + my $unapp_refund = FS::cust_refund->unapplied_sql; + my $unapp_credit = FS::cust_credit->unapplied_sql; + my $unapp_pay = FS::cust_pay->unapplied_sql; - " - COALESCE( ( SELECT SUM($owed_sql) FROM cust_bill - WHERE cust_bill.custnum = cust_main.custnum - AND cust_bill._date <= $time ) - ,0 - ) - + COALESCE( ( SELECT SUM($unapp_refund_sql) FROM cust_refund - WHERE cust_refund.custnum = cust_main.custnum ) - ,0 - ) - - COALESCE( ( SELECT SUM($unapp_credit_sql) FROM cust_credit - WHERE cust_credit.custnum = cust_main.custnum ) - ,0 - ) - - COALESCE( ( SELECT SUM($unapp_pay_sql) FROM cust_pay - WHERE cust_pay.custnum = cust_main.custnum ) - ,0 - ) + my $j = $opt{'join'} || ''; + + my $owed_wh = $class->_money_table_where( 'cust_bill', $start,$end,%opt ); + my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt ); + my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt ); + my $pay_wh = $class->_money_table_where( 'cust_pay', $start,$end,%opt ); + " ( SELECT COALESCE(SUM($owed), 0) FROM cust_bill $j $owed_wh ) + + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh ) + - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh ) + - ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $j $pay_wh ) "; } +=item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ] + +Helper method for balance_date_sql; name (and usage) subject to change +(suggestions welcome). + +Returns a WHERE clause for the specified monetary TABLE (cust_bill, +cust_refund, cust_credit or cust_pay). + +If TABLE is "cust_bill" or the unapplied_date option is true, only +considers records with date earlier than START_TIME, and optionally not +later than END_TIME . + +=cut + +sub _money_table_where { + my( $class, $table, $start, $end, %opt ) = @_; + + my @where = (); + push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'}; + if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) { + push @where, "$table._date <= $start" if defined($start) && length($start); + push @where, "$table._date > $end" if defined($end) && length($end); + } + push @where, @{$opt{'where'}} if $opt{'where'}; + my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : ''; + + $where; + +} + +=item search_sql HASHREF + +(Class method) + +Returns a qsearch hash expression to search for parameters specified in HREF. +Valid parameters are + +=over 4 + +=item agentnum + +=item status + +=item cancelled_pkgs + +bool + +=item signupdate + +listref of start date, end date + +=item payby + +listref + +=item current_balance + +listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance')) + +=item cust_fields + +=item flattened_pkgs + +bool + +=back + +=cut + +sub search_sql { + my ($class, $params) = @_; + + my $dbh = dbh; + + my @where = (); + my $orderby; + + ## + # parse agent + ## + + if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) { + push @where, + "cust_main.agentnum = $1"; + } + + ## + # parse status + ## + + #prospect active inactive suspended cancelled + if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) { + my $method = $params->{'status'}. '_sql'; + #push @where, $class->$method(); + push @where, FS::cust_main->$method(); + } + + ## + # parse cancelled package checkbox + ## + + my $pkgwhere = ""; + + $pkgwhere .= "AND (cancel = 0 or cancel is null)" + unless $params->{'cancelled_pkgs'}; + + ## + # dates + ## + + foreach my $field (qw( signupdate )) { + + next unless exists($params->{$field}); + + my($beginning, $ending) = @{$params->{$field}}; + + push @where, + "cust_main.$field IS NOT NULL", + "cust_main.$field >= $beginning", + "cust_main.$field <= $ending"; + + $orderby ||= "ORDER BY cust_main.$field"; + + } + + ### + # payby + ### + + my @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} }; + if ( @payby ) { + push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'; + } + + ## + # amounts + ## + + #my $balance_sql = $class->balance_sql(); + my $balance_sql = FS::cust_main->balance_sql(); + + push @where, map { s/current_balance/$balance_sql/; $_ } + @{ $params->{'current_balance'} }; + + ## + # setup queries, subs, etc. for the search + ## + + $orderby ||= 'ORDER BY custnum'; + + # here is the agent virtualization + push @where, $FS::CurrentUser::CurrentUser->agentnums_sql; + + my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : ''; + + my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) '; + + my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql"; + + my $select = join(', ', + 'cust_main.custnum', + FS::UI::Web::cust_sql_fields($params->{'cust_fields'}), + ); + + my(@extra_headers) = (); + my(@extra_fields) = (); + + if ($params->{'flattened_pkgs'}) { + + if ($dbh->{Driver}->{Name} eq 'Pg') { + + $select .= ", array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic"; + + }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) { + $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic"; + $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )"; + }else{ + warn "warning: unknown database type ". $dbh->{Driver}->{Name}. + "omitting packing information from report."; + } + + my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1"; + + my $sth = dbh->prepare($header_query) or die dbh->errstr; + $sth->execute() or die $sth->errstr; + my $headerrow = $sth->fetchrow_arrayref; + my $headercount = $headerrow ? $headerrow->[0] : 0; + while($headercount) { + unshift @extra_headers, "Package ". $headercount; + unshift @extra_fields, eval q!sub {my $c = shift; + my @a = split '\|', $c->magic; + my $p = $a[!.--$headercount. q!]; + $p; + };!; + } + + } + + my $sql_query = { + 'table' => 'cust_main', + 'select' => $select, + 'hashref' => {}, + 'extra_sql' => $extra_sql, + 'order_by' => $orderby, + 'count_query' => $count_query, + 'extra_headers' => \@extra_headers, + 'extra_fields' => \@extra_fields, + }; + +} + =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ] Performs a fuzzy (approximate) search and returns the matching FS::cust_main @@ -5288,7 +5590,14 @@ sub smart_search { } - } elsif ( $search =~ /^\s*(\d+)\s*$/ ) { # customer # search + # custnum search (also try agent_custid), with some tweaking options if your + # legacy cust "numbers" have letters + } elsif ( $search =~ /^\s*(\d+)\s*$/ + || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+' + && $search =~ /^\s*(\w\w?\d+)\s*$/ + ) + ) + { push @cust_main, qsearch( { 'table' => 'cust_main', @@ -5296,6 +5605,12 @@ sub smart_search { 'extra_sql' => " AND $agentnums_sql", #agent virtualization } ); + push @cust_main, qsearch( { + 'table' => 'cust_main', + 'hashref' => { 'agent_custid' => $1, %options }, + 'extra_sql' => " AND $agentnums_sql", #agent virtualization + } ); + } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) { my($company, $last, $first) = ( $1, $2, $3 ); @@ -5388,7 +5703,7 @@ sub smart_search { #getting complaints searches are not returning enough unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) { - #still some false laziness w/ search/cust_main.cgi + #still some false laziness w/search_sql (was search/cust_main.cgi) #substring @@ -5459,6 +5774,72 @@ sub smart_search { } +=item email_search + +Accepts the following options: I, the email address to search for. The +email address will be searched for as an email invoice destination and as an +svc_acct account. + +#Any additional options are treated as an additional qualifier on the search +#(i.e. I). + +Returns a (possibly empty) array of FS::cust_main objects (but usually just +none or one). + +=cut + +sub email_search { + my %options = @_; + + local($DEBUG) = 1; + + my $email = delete $options{'email'}; + + #we're only being used by RT at the moment... no agent virtualization yet + #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql; + + my @cust_main = (); + + if ( $email =~ /([^@]+)\@([^@]+)/ ) { + + my ( $user, $domain ) = ( $1, $2 ); + + warn "$me smart_search: searching for $user in domain $domain" + if $DEBUG; + + push @cust_main, + map $_->cust_main, + qsearch( { + 'table' => 'cust_main_invoice', + 'hashref' => { 'dest' => $email }, + } + ); + + push @cust_main, + map $_->cust_main, + grep $_, + map $_->cust_svc->cust_pkg, + qsearch( { + 'table' => 'svc_acct', + 'hashref' => { 'username' => $user, }, + 'extra_sql' => + 'AND ( SELECT domain FROM svc_domain + WHERE svc_acct.domsvc = svc_domain.svcnum + ) = '. dbh->quote($domain), + } + ); + } + + my %saw = (); + @cust_main = grep { !$saw{$_->custnum}++ } @cust_main; + + warn "$me smart_search: found ". scalar(@cust_main). " unique customers" + if $DEBUG; + + @cust_main; + +} + =item check_and_rebuild_fuzzyfiles =cut