X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=e698bfa6860b714babd10b9e7e7647fda6a44954;hp=d4909167174f7f3fdd19da00a8fda3f3ad55fb3f;hb=ec5603ae351d4ed8e4873dcd20bf71f8a4d549bb;hpb=3b35ccbf226efe00c94f3a72dd1c7ed64d926a7c diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index d49091671..e698bfa68 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -16,13 +16,14 @@ use Digest::MD5 qw(md5_base64); use Date::Format; use Date::Parse; #use Date::Manip; +use File::Slurp qw( slurp ); +use File::Temp qw( tempfile ); use String::Approx qw(amatch); use Business::CreditCard 0.28; use Locale::Country; -use Data::Dumper; use FS::UID qw( getotaker dbh driver_name ); use FS::Record qw( qsearchs qsearch dbdef ); -use FS::Misc qw( send_email generate_ps do_print ); +use FS::Misc qw( generate_email send_email generate_ps do_print ); use FS::Msgcat qw(gettext); use FS::cust_pkg; use FS::cust_svc; @@ -53,7 +54,7 @@ use FS::banned_pay; use FS::payinfo_Mixin; use FS::TicketSystem; -@ISA = qw( FS::Record FS::payinfo_Mixin ); +@ISA = qw( FS::payinfo_Mixin FS::Record ); @EXPORT_OK = qw( smart_search ); @@ -224,6 +225,8 @@ Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit nu =item spool_cdr - Enable individual CDR spooling, empty or `Y' +=item squelch_cdr - Discourage individual CDR printing, empty or `Y' + =back =head1 METHODS @@ -1531,7 +1534,7 @@ sub check { $self->payname($1); } - foreach my $flag (qw( tax spool_cdr )) { + foreach my $flag (qw( tax spool_cdr squelch_cdr )) { $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag(); $self->$flag($1); } @@ -2062,21 +2065,21 @@ sub bill { $self->select_for_update; #mutex my @cust_bill_pkg = (); + my @appended_cust_bill_pkg = (); ### # find the packages which are due for billing, find out how much they are # & generate invoice database. ### - my( $total_setup, $total_recur ) = ( 0, 0 ); + my( $total_setup, $total_recur, $postal_charge ) = ( 0, 0, 0 ); my %tax; my %taxlisthash; my %taxname; my @precommit_hooks = (); - foreach my $cust_pkg ( - qsearch('cust_pkg', { 'custnum' => $self->custnum } ) - ) { + my @cust_pkgs = qsearch('cust_pkg', { 'custnum' => $self->custnum } ); + foreach my $cust_pkg (@cust_pkgs) { #NO!! next if $cust_pkg->cancel; next if $cust_pkg->getfield('cancel'); @@ -2091,280 +2094,67 @@ sub bill { my $real_pkgpart = $cust_pkg->pkgpart; my %hash = $cust_pkg->hash; - my $old_cust_pkg = new FS::cust_pkg \%hash; foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) { - $cust_pkg->pkgpart($part_pkg->pkgpart); - $cust_pkg->set($_, $hash{$_}) foreach qw( setup last_bill bill ); - - my @details = (); - - my $lineitems = 0; - - ### - # bill setup - ### - - 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++; - - $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; - #do need it, but it won't get written to the db - #|| $cust_pkg->pkgpart != $real_pkgpart; + $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill ); + my $error = + $self->_make_lines( 'part_pkg' => $part_pkg, + 'cust_pkg' => $cust_pkg, + 'precommit_hooks' => \@precommit_hooks, + 'line_items' => \@cust_bill_pkg, + 'appended_line_items' => \@appended_cust_bill_pkg, + 'setup' => \$total_setup, + 'recur' => \$total_recur, + 'tax_matrix' => \%taxlisthash, + 'time' => $time, + 'options' => \%options, + ); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $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 "$@ 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 ( $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)); - - } - - 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 ) { - - 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 "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"; - } - } - - $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); - - - }else{ - - my %taxhash = map { $_ => $self->get("$prefix$_") } - qw( state county country ); - - $taxhash{'taxclass'} = $part_pkg->taxclass; - - @taxes = qsearch( 'cust_main_county', \%taxhash ); - - unless ( @taxes ) { - $taxhash{'taxclass'} = ''; - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } - - #one more try at a whole-country tax rate - unless ( @taxes ) { - $taxhash{$_} = '' foreach qw( state county ); - @taxes = qsearch( 'cust_main_county', \%taxhash ); - } - - } #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 - } #foreach my $part_pkg } #foreach my $cust_pkg + push @cust_bill_pkg, @appended_cust_bill_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 $postal_pkg = $self->charge_postal_fee(); + if ( $postal_pkg && !ref( $postal_pkg ) ) { + $dbh->rollback if $oldAutoCommit; + return "can't charge postal invoice fee for customer ". + $self->custnum. ": $postal_pkg"; + } + if ( $postal_pkg ) { + foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) { + my $error = + $self->_make_lines( 'part_pkg' => $part_pkg, + 'cust_pkg' => $postal_pkg, + 'precommit_hooks' => \@precommit_hooks, + 'line_items' => \@cust_bill_pkg, + 'appended_line_items' => \@appended_cust_bill_pkg, + 'setup' => \$total_setup, + 'recur' => \$total_recur, + 'tax_matrix' => \%taxlisthash, + 'time' => $time, + 'options' => \%options, + ); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + } + warn "having a look at the taxes we found...\n" if $DEBUG > 2; foreach my $tax ( keys %taxlisthash ) { my $tax_object = shift @{ $taxlisthash{$tax} }; @@ -2495,6 +2285,328 @@ sub bill { ''; #no error } + +sub _make_lines { + my ($self, %params) = @_; + + my $part_pkg = $params{part_pkg} or die "no part_pkg specified"; + my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified"; + my $precommit_hooks = $params{precommit_hooks} or die "no package specified"; + my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified"; + my $appended_cust_bill_pkg = $params{appended_line_items} + or die "no appended line buffer specified"; + my $total_setup = $params{setup} or die "no setup accumulator specified"; + my $total_recur = $params{recur} or die "no recur accumulator specified"; + my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified"; + my $time = $params{'time'} or die "no time specified"; + my (%options) = %{$params{options}}; #hmmm only for 'resetup' + + my $dbh = dbh; + my $real_pkgpart = $cust_pkg->pkgpart; + my %hash = $cust_pkg->hash; + my $old_cust_pkg = new FS::cust_pkg \%hash; + + my @details = (); + + my $lineitems = 0; + + $cust_pkg->pkgpart($part_pkg->pkgpart); + + ### + # bill setup + ### + + my $setup = 0; + my $unitsetup = 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++; + + $setup = eval { $cust_pkg->calc_setup( $time, \@details ) }; + return "$@ running calc_setup for $cust_pkg\n" + if $@; + + $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh + + $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; + + } + + ### + # bill recurring fee + ### + + #XXX unit stuff here too + my $recur = 0; + my $unitrecur = 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 ) }; + return "$@ running calc_recur for $cust_pkg\n" + if ( $@ ); + + + #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 { + return "unparsable frequency: ". $part_pkg->freq; + } + $cust_pkg->setfield('bill', + timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year)); + + } + + 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 ) { + + 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 }, + ); + return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error" + if $error; #just in case + } + + $setup = sprintf( "%.2f", $setup ); + $recur = sprintf( "%.2f", $recur ); + if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) { + return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum; + } + if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) { + 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, + 'unitsetup' => $unitsetup, + 'recur' => $recur, + 'unitrecur' => $unitrecur, + 'quantity' => $cust_pkg->quantity, + '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_pkgs, $cust_bill_pkg; + + $$total_setup += $setup; + $$total_recur += $recur; + + ### + # handle taxes + ### + + unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { + + $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg); + + } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' + + } #if $setup != 0 || $recur != 0 + + } #if $line_items + + if ( $part_pkg->can('append_cust_bill_pkgs') ) { + my %param = ( 'precommit_hooks' => $precommit_hooks, ); + my ($more_cust_bill_pkgs) = + eval { $part_pkg->append_cust_bill_pkgs( $cust_pkg, \$sdate, \%param ) }; + + return "$@ running append_cust_bill_pkgs for $cust_pkg\n" + if ( $@ ); + return "$more_cust_bill_pkgs" + unless ( ref($more_cust_bill_pkgs) ); + + foreach my $cust_bill_pkg ( @{$more_cust_bill_pkgs} ) { + + $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart) + unless $part_pkg->pkgpart == $real_pkgpart; + push @$appended_cust_bill_pkg, $cust_bill_pkg; + + unless ($cust_bill_pkg->duplicate) { + $$total_setup += $cust_bill_pkg->setup; + $$total_recur += $cust_bill_pkg->recur; + + ### + # handle taxes + ### + + unless ( $self->tax =~ /Y/i || $self->payby eq 'COMP' ) { + + $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg); + + } #unless $self->tax =~ /Y/i || $self->payby eq 'COMP' + } + } + } + +} + +sub _handle_taxes { + my $self = shift; + my $part_pkg = shift; + my $taxlisthash = shift; + my $cust_bill_pkg = shift; + + 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); + + + }else{ + + my %taxhash = map { $_ => $self->get("$prefix$_") } + qw( state county country ); + + $taxhash{'taxclass'} = $part_pkg->taxclass; + + @taxes = qsearch( 'cust_main_county', \%taxhash ); + + unless ( @taxes ) { + $taxhash{'taxclass'} = ''; + @taxes = qsearch( 'cust_main_county', \%taxhash ); + } + + #one more try at a whole-country tax rate + unless ( @taxes ) { + $taxhash{$_} = '' foreach qw( state county ); + @taxes = qsearch( 'cust_main_county', \%taxhash ); + } + + } #if $conf->exists('enable_taxproducts') + + # maybe eliminate this entirely, along with all the 0% records + unless ( @taxes ) { + 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 ]; + } + } + +} + =item collect OPTIONS (Attempt to) collect money for this customer's outstanding invoices (see @@ -4675,9 +4787,10 @@ the error, otherwise returns false. sub charge { my $self = shift; - my ( $amount, $pkg, $comment, $taxclass, $additional, $classnum ); + 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); @@ -4686,6 +4799,7 @@ sub charge { $additional = $_[0]->{additional}; }else{ $amount = shift; + $quantity = 1; $pkg = @_ ? shift : 'One-time charge'; $comment = @_ ? shift : '$'. sprintf("%.2f",$amount); $taxclass = @_ ? shift : ''; @@ -4738,8 +4852,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; @@ -4753,6 +4868,33 @@ sub charge { } +#=item charge_postal_fee +# +#Applies a one time charge this customer. If there is an error, +#returns the error, returns the cust_pkg charge object or false +#if there was no charge. +# +#=cut +# +# This should be a customer event. For that to work requires that bill +# also be a customer event. + +sub charge_postal_fee { + my $self = shift; + + my $pkgpart = $conf->config('postal_invoice-fee_pkgpart'); + return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list); + + my $cust_pkg = new FS::cust_pkg ( { + 'custnum' => $self->custnum, + 'pkgpart' => $pkgpart, + 'quantity' => 1, + } ); + + my $error = $cust_pkg->insert; + $error ? $error : $cust_pkg; +} + =item cust_bill Returns all the invoices (see L) for this customer. @@ -4996,7 +5138,7 @@ Returns a hex triplet color string for this customer's status. =cut use vars qw(%statuscolor); -tie my %statuscolor, 'Tie::IxHash', +tie %statuscolor, 'Tie::IxHash', 'prospect' => '7e0079', #'000000', #black? naw, purple 'active' => '00CC00', #green 'inactive' => '0000CC', #blue @@ -5100,7 +5242,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 @@ -5112,7 +5254,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 @@ -5148,17 +5290,17 @@ 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 ) ) + AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) "; + } =item uncancel_sql @@ -5212,15 +5354,24 @@ 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 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 -=item total - set to true to remove all customer comparison clauses, for totals +(unused. obsolete?) +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 where -=item join - JOIN clause (typically used with the total option) +(unused. obsolete?) +WHERE clause hashref (elements "AND"ed together) (typically used with the total option) -=item +=item join + +(unused. obsolete?) +JOIN clause (typically used with the total option) =back @@ -5279,6 +5430,303 @@ sub _money_table_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 email_search_sql HASHREF + +(Class method) + +Emails a notice to the specified customers. + +Valid parameters are those of the L method, plus the following: + +=over 4 + +=item from + +From: address + +=item subject + +Email Subject: + +=item html_body + +HTML body + +=item text_body + +Text body + +=item job + +Optional job queue job for status updates. + +=back + +Returns an error message, or false for success. + +If an error occurs during any email, stops the enture send and returns that +error. Presumably if you're getting SMTP errors aborting is better than +retrying everything. + +=cut + +sub email_search_sql { + my($class, $params) = @_; + + my $from = delete $params->{from}; + my $subject = delete $params->{subject}; + my $html_body = delete $params->{html_body}; + my $text_body = delete $params->{text_body}; + + my $job = delete $params->{'job'}; + + my $sql_query = $class->search_sql($params); + + my $count_query = delete($sql_query->{'count_query'}); + my $count_sth = dbh->prepare($count_query) + or die "Error preparing $count_query: ". dbh->errstr; + $count_sth->execute + or die "Error executing $count_query: ". $count_sth->errstr; + my $count_arrayref = $count_sth->fetchrow_arrayref; + my $num_cust = $count_arrayref->[0]; + + #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) }; + #my @extra_fields = @{ delete($sql_query->{'extra_fields'}) }; + + + my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo + + #eventually order+limit magic to reduce memory use? + foreach my $cust_main ( qsearch($sql_query) ) { + + my $to = $cust_main->invoicing_list_emailonly_scalar; + next unless $to; + + my $error = send_email( + generate_email( + 'from' => $from, + 'to' => $to, + 'subject' => $subject, + 'html_body' => $html_body, + 'text_body' => $text_body, + ) + ); + return $error if $error; + + if ( $job ) { #progressbar foo + $num++; + if ( time - $min_sec > $last ) { + my $error = $job->update_statustext( + int( 100 * $num / $num_cust ) + ); + die $error if $error; + $last = time; + } + } + + } + + return ''; +} + +use Storable qw(thaw); +use Data::Dumper; +use MIME::Base64; +sub process_email_search_sql { + my $job = shift; + #warn "$me process_re_X $method for job $job\n" if $DEBUG; + + my $param = thaw(decode_base64(shift)); + warn Dumper($param) if $DEBUG; + + $param->{'job'} = $job; + + my $error = FS::cust_main->email_search_sql( $param ); + die $error if $error; + +} + =item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ] Performs a fuzzy (approximate) search and returns the matching FS::cust_main @@ -5520,7 +5968,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 @@ -5762,17 +6210,19 @@ sub append_fuzzyfiles { =cut +#some false laziness w/cdr.pm now sub batch_import { my $param = shift; - #warn join('-',keys %$param); - my $fh = $param->{filehandle}; + + my $fh = $param->{filehandle}; + my $type = $param->{type} || 'csv'; + my $agentnum = $param->{agentnum}; + my $refnum = $param->{refnum}; + my $pkgpart = $param->{pkgpart}; - my $refnum = $param->{refnum}; - my $pkgpart = $param->{pkgpart}; + my $format = $param->{'format'}; - #my @fields = @{$param->{fields}}; - my $format = $param->{'format'}; my @fields; my $payby; if ( $format eq 'simple' ) { @@ -5807,14 +6257,32 @@ sub batch_import { die "unknown format $format"; } - eval "use Text::CSV_XS;"; - die $@ if $@; + my $parser; + my $spoolfile = ''; + if ( $type eq 'csv' ) { + eval "use Text::CSV_XS;"; + die $@ if $@; + $parser = new Text::CSV_XS; + } elsif ( $type eq 'xls' ) { - my $csv = new Text::CSV_XS; - #warn $csv; - #warn $fh; + eval "use Spreadsheet::ParseExcel;"; + die $@ if $@; + + ( my $spool_fh, $spoolfile ) = + tempfile('cust_main-batch_import-XXXXXXXXXXXX', + DIR => '%%%FREESIDE_CACHE%%%', + SUFFIX => '.xls', + ); + print $spool_fh slurp($fh); + close $spool_fh or die $!; + + my $excel = new Spreadsheet::ParseExcel::Workbook->Parse($spoolfile); + $parser = $excel->{Worksheet}[0]; #first sheet + + } else { + die "Unknown file type $type\n"; + } - my $imported = 0; #my $columns; local $SIG{HUP} = 'IGNORE'; @@ -5828,16 +6296,35 @@ sub batch_import { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - #while ( $columns = $csv->getline($fh) ) { my $line; - while ( defined($line=<$fh>) ) { + my $row = 0; + while (1) { - $csv->parse($line) or do { - $dbh->rollback if $oldAutoCommit; - return "can't parse: ". $csv->error_input(); - }; + my @columns = (); + if ( $type eq 'csv' ) { + + last unless defined($line=<$fh>); + + $parser->parse($line) or do { + $dbh->rollback if $oldAutoCommit; + return "can't parse: ". $parser->error_input(); + }; + @columns = $parser->fields(); + + } elsif ( $type eq 'xls' ) { + + last if $row > ($parser->{MaxRow} || $parser->{MinRow}); + + my @row = @{ $parser->{Cells}[$row] }; + @columns = map $_->{Val}, @row; + + #my $z = 'A'; + #warn $z++. ": $_\n" for @columns; + + } else { + die "Unknown file type $type\n"; + } - my @columns = $csv->fields(); #warn join('-',@columns); my %cust_main = ( @@ -5929,7 +6416,7 @@ sub batch_import { if ( $error ) { $dbh->rollback if $oldAutoCommit; - return "can't insert customer for $line: $error"; + return "can't insert customer ". ( $line ? "for $line" : '' ). ": $error"; } if ( $format eq 'simple' ) { @@ -5955,12 +6442,14 @@ sub batch_import { } - $imported++; + $row++; } $dbh->commit or die $dbh->errstr if $oldAutoCommit; - return "Empty file!" unless $imported; + unlink($spoolfile) if $spoolfile; + + return "Empty file!" unless $row; ''; #no error