X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=ac300b5d1d9b8e308f9ff9b28c39dfd660a52f2e;hb=d417ecbed3d356919ae80317c33a9143e20646ef;hp=f6c3ff78d3038c75a60234cf48481c232f7a0a61;hpb=893c5330eed4d2d8691cbdf89886a374efff2f05;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index f6c3ff78d..ac300b5d1 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -79,6 +79,8 @@ use FS::sales; use FS::cust_payby; use FS::contact; use FS::reason; +use FS::Misc::Savepoint; +use FS::DBI; # 1 is mostly method/subroutine entry and options # 2 traces progress of some operations @@ -271,7 +273,7 @@ Enable individual CDR spooling, empty or `Y' =item dundate -A suggestion to events (see L) to delay until this unix timestamp +A suggestion to events (see L) to delay until this unix timestamp =item squelch_cdr @@ -742,20 +744,6 @@ sub insert { } } - # FS::geocode_Mixin::after_insert or something? - if ( $conf->config('tax_district_method') and !$import ) { - # if anything non-empty, try to look it up - my $queue = new FS::queue { - 'job' => 'FS::geocode_Mixin::process_district_update', - 'custnum' => $self->custnum, - }; - my $error = $queue->insert( ref($self), $self->custnum ); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "queueing tax district update: $error"; - } - } - # cust_main exports! warn " exporting\n" if $DEBUG > 1; @@ -1259,7 +1247,7 @@ sub delete { $ticket_dbh = $dbh; } elsif ($conf->config('ticket_system') eq 'RT_External') { my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc'); - $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 }); + $ticket_dbh = FS::DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 }); #or die "RT_External DBI->connect error: $DBI::errstr\n"; } @@ -2292,7 +2280,7 @@ Returns a list: an empty list on success or a list of errors. sub unsuspend { my $self = shift; - grep { ($_->get('setup')) && $_->unsuspend } $self->suspended_pkgs; + grep { ($_->get('setup')) && $_->unsuspend } $self->suspended_pkgs(@_); } =item release_hold @@ -2421,7 +2409,7 @@ FS::cust_pkg::cancel() methods. =item quiet - can be set true to supress email cancellation notices. -=item reason - can be set to a cancellation reason (see L), either a +=item reason - can be set to a cancellation reason (see L), either a reasonnum of an existing reason, or passing a hashref will create a new reason. The hashref should have the following keys: typenum - Reason type (see L) @@ -2441,11 +2429,15 @@ sub cancel_pkgs { my( $self, %opt ) = @_; # we're going to cancel services, which is not reversible + # unless exports are suppressed die "cancel_pkgs cannot be run inside a transaction" - if $FS::UID::AutoCommit == 0; + if !$FS::UID::AutoCommit && !$FS::svc_Common::noexport_hack; + my $oldAutoCommit = $FS::UID::AutoCommit; local $FS::UID::AutoCommit = 0; + savepoint_create('cancel_pkgs'); + return ( 'access denied' ) unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); @@ -2462,7 +2454,8 @@ sub cancel_pkgs { my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref; my $error = $ban->insert; if ($error) { - dbh->rollback; + savepoint_rollback_and_release('cancel_pkgs'); + dbh->rollback if $oldAutoCommit; return ( $error ); } @@ -2482,13 +2475,14 @@ sub cancel_pkgs { 'time' => $cancel_time ); if ($error) { warn "Error billing during cancel, custnum ". $self->custnum. ": $error"; - dbh->rollback; + savepoint_rollback_and_release('cancel_pkgs'); + dbh->rollback if $oldAutoCommit; return ( "Error billing during cancellation: $error" ); } } - dbh->commit; + savepoint_release('cancel_pkgs'); + dbh->commit if $oldAutoCommit; - $FS::UID::AutoCommit = 1; my @errors; # now cancel all services, the same way we would for individual packages. # if any of them fail, cancel the rest anyway. @@ -2501,11 +2495,23 @@ sub cancel_pkgs { warn "$me removing ".scalar(@sorted_cust_svc)." service(s) for customer ". $self->custnum."\n" if $DEBUG; + my $i = 0; foreach my $cust_svc (@sorted_cust_svc) { + my $savepoint = 'cancel_pkgs_'.$i++; + savepoint_create( $savepoint ); my $part_svc = $cust_svc->part_svc; next if ( defined($part_svc) and $part_svc->preserve ); - my $error = $cust_svc->cancel; # immediate cancel, no date option - push @errors, $error if $error; + # immediate cancel, no date option + # transactionize individually + my $error = try { $cust_svc->cancel } catch { $_ }; + if ( $error ) { + savepoint_rollback_and_release( $savepoint ); + dbh->rollback if $oldAutoCommit; + push @errors, $error; + } else { + savepoint_release( $savepoint ); + dbh->commit if $oldAutoCommit; + } } if (@errors) { return @errors; @@ -2520,8 +2526,11 @@ sub cancel_pkgs { @cprs = @{ delete $opt{'cust_pkg_reason'} }; } my $null_reason; + $i = 0; foreach (@pkgs) { my %lopt = %opt; + my $savepoint = 'cancel_pkgs_'.$i++; + savepoint_create( $savepoint ); if (@cprs) { my $cpr = shift @cprs; if ( $cpr ) { @@ -2541,7 +2550,14 @@ sub cancel_pkgs { } } my $error = $_->cancel(%lopt); - push @errors, 'pkgnum '.$_->pkgnum.': '.$error if $error; + if ( $error ) { + savepoint_rollback_and_release( $savepoint ); + dbh->rollback if $oldAutoCommit; + push @errors, 'pkgnum '.$_->pkgnum.': '.$error; + } else { + savepoint_release( $savepoint ); + dbh->commit if $oldAutoCommit; + } } return @errors; @@ -2941,7 +2957,7 @@ UNIX timestamps; see L). Also see L and L for conversion functions. The empty string can be passed to disable that time constraint completely. -Accepts the same options as L: +Accepts the same options as L: =over 4 @@ -3236,48 +3252,104 @@ sub invoicing_list_emailonly_scalar { join(', ', $self->invoicing_list_emailonly); } -=item contact_list [ CLASSNUM, ... ] +=item contact_list [ CLASSNUM, DEST_FLAG... ] -Returns a list of contacts (L objects) for the customer. If -a list of contact classnums is given, returns only contacts in those -classes. If the pseudo-classnum 'invoice' is given, returns contacts that -are marked as invoice destinations. If '0' is given, also returns contacts -with no class. +Returns a list of contacts (L objects) for the customer. If no arguments are given, returns all contacts for the customer. +Arguments may contain classnums. When classnums are specified, only +contacts with a matching cust_contact.classnum are returned. When a +classnum of 0 is given, contacts with a null classnum are also included. + +Arguments may also contain the dest flag names 'invoice' or 'message'. +If given, contacts who's invoice_dest and/or message_dest flags are +not set to 'Y' will be excluded. + =cut sub contact_list { my $self = shift; my $search = { table => 'contact', - select => 'contact.*, cust_contact.invoice_dest', + select => join(', ',( + 'contact.*', + 'cust_contact.invoice_dest', + 'cust_contact.message_dest', + )), addl_from => ' JOIN cust_contact USING (contactnum)', extra_sql => ' WHERE cust_contact.custnum = '.$self->custnum, }; - my @orwhere; + # Bugfix notes: + # Calling methods were relying on this method to use invoice_dest to + # block e-mail messages. Depending on parameters, this may or may not + # have actually happened. + # + # The bug could cause this SQL to be used to filter e-mail addresses: + # + # AND ( + # cust_contact.classnums IN (1,2,3) + # OR cust_contact.invoice_dest = 'Y' + # ) + # + # improperly including everybody with the opt-in flag AND everybody + # in the contact classes + # + # Possibility to introduce new bugs: + # If callers of this method called it incorrectly, and didn't notice + # because it seemed to send the e-mails they wanted. + + # WHERE ... + # AND ( + # ( + # cust_contact.classnum IN (1,2,3) + # OR + # cust_contact.classnum IS NULL + # ) + # AND ( + # cust_contact.invoice_dest = 'Y' + # OR + # cust_contact.message_dest = 'Y' + # ) + # ) + + my @and_dest; + my @or_classnum; my @classnums; - foreach (@_) { - if ( $_ eq 'invoice' ) { - push @orwhere, 'cust_contact.invoice_dest = \'Y\''; - } elsif ( $_ eq '0' ) { - push @orwhere, 'cust_contact.classnum is null'; + for (@_) { + if ($_ eq 'invoice' || $_ eq 'message') { + push @and_dest, " cust_contact.${_}_dest = 'Y' "; + } elsif ($_ eq '0') { + push @or_classnum, ' cust_contact.classnum IS NULL '; } elsif ( /^\d+$/ ) { push @classnums, $_; } else { - die "bad classnum argument '$_'"; + croak "bad classnum argument '$_'"; } } - if (@classnums) { - push @orwhere, 'cust_contact.classnum IN ('.join(',', @classnums).')'; - } - if (@orwhere) { - $search->{extra_sql} .= ' AND (' . - join(' OR ', map "( $_ )", @orwhere) . - ')'; + push @or_classnum, 'cust_contact.classnum IN ('.join(',',@classnums).')' + if @classnums; + + if (@or_classnum || @and_dest) { # catch, no arguments given + $search->{extra_sql} .= ' AND ( '; + + if (@or_classnum) { + $search->{extra_sql} .= ' ( '; + $search->{extra_sql} .= join ' OR ', map {" $_ "} @or_classnum; + $search->{extra_sql} .= ' ) '; + $search->{extra_sql} .= ' AND ( ' if @and_dest; + } + + if (@and_dest) { + $search->{extra_sql} .= join ' OR ', map {" $_ "} @and_dest; + $search->{extra_sql} .= ' ) ' if @or_classnum; + } + + $search->{extra_sql} .= ' ) '; + + warn "\$extra_sql: $search->{extra_sql} \n" if $DEBUG; } qsearch($search); @@ -4080,6 +4152,27 @@ sub name { $name; } +=item batch_payment_payname + +Returns a name string for this customer, either "cust_batch_payment->payname" or "First Last" or "Company, +based on if a company name exists and is the account being used a business account. + +=cut + +sub batch_payment_payname { + my $self = shift; + my $cust_pay_batch = shift; + my $name; + + if ($cust_pay_batch->{Hash}->{payby} eq "CARD") { $name = $cust_pay_batch->payname; } + else { $name = $self->first .' '. $self->last; } + + $name = $self->company + if (($cust_pay_batch->{Hash}->{paytype} eq "Business checking" || $cust_pay_batch->{Hash}->{paytype} eq "Business savings") && $self->company); + + $name; +} + =item service_contact Returns the L object for this customer that has the 'Service' @@ -4831,6 +4924,8 @@ PAYBYLOOP: next if grep(/^$field$/, qw( custpaybynum payby weight ) ); next if grep(/^$field$/, @preserve ); next PAYBYLOOP unless $new->get($field) eq $cust_payby->get($field); + # check if paymask exists, if so stop and don't save, no need for a duplicate. + return '' if $new->get('paymask') eq $cust_payby->get('paymask'); } # now check fields that can replace if one value is blank my $replace = 0; @@ -5668,7 +5763,68 @@ sub process_bill_and_collect { $param->{'fatal'} = 1; # runs from job queue, will be caught $param->{'retry'} = 1; - $cust_main->bill_and_collect( %$param ); + local $@; + eval { $cust_main->bill_and_collect( %$param) }; + if ( $@ ) { + die $@ =~ /cancel_pkgs cannot be run inside a transaction/ + ? "Bill Now unavailable for customer with pending package expiration\n" + : $@; + } +} + +=item pending_invoice_count + +Return number of cust_bill with pending=Y for this customer + +=cut + +sub pending_invoice_count { + FS::cust_bill->count( 'custnum = '.shift->custnum."AND pending = 'Y'" ); +} + +=item cust_locations_missing_district + +Always returns empty list, unless tax_district_method eq 'wa_sales' + +Return cust_location rows for this customer, associated with active +customer packages, where tax district column is empty. Presense of +these rows should block billing, because invoice would be generated +with incorrect taxes + +=cut + +sub cust_locations_missing_district { + my ( $self ) = @_; + + my $tax_district_method = FS::Conf->new->config('tax_district_method'); + + return () + unless $tax_district_method + && $tax_district_method eq 'wa_sales'; + + qsearch({ + table => 'cust_location', + select => 'cust_location.*', + addl_from => ' + LEFT JOIN cust_main USING (custnum) + LEFT JOIN cust_pkg ON cust_location.locationnum = cust_pkg.locationnum + ', + extra_sql => sprintf(q{ + WHERE cust_location.state = 'WA' + AND cust_location.custnum = %s + AND ( + cust_location.district IS NULL + or cust_location.district = '' + ) + AND cust_pkg.pkgnum IS NOT NULL + AND ( + cust_pkg.cancel > %s + OR cust_pkg.cancel IS NULL + ) + }, + $self->custnum, time() + ), + }); } #starting to take quite a while for big dbs @@ -5771,15 +5927,17 @@ sub _upgrade_data { #class method # at the time we do this, also migrate paytype into cust_pay_batch # so that batches that are open before the migration can still be # processed - my @cust_pay_batch = qsearch('cust_pay_batch', { - 'custnum' => $cust_main->custnum, - 'payby' => 'CHEK', - 'paytype' => '', - }); - foreach my $cust_pay_batch (@cust_pay_batch) { - $cust_pay_batch->set('paytype', $cust_main->get('paytype')); - my $error = $cust_pay_batch->replace; - die "$error (setting cust_pay_batch.paytype)" if $error; + if ( $cust_main->get('paytype') ) { + my @cust_pay_batch = qsearch('cust_pay_batch', { + 'custnum' => $cust_main->custnum, + 'payby' => 'CHEK', + 'paytype' => '', + }); + foreach my $cust_pay_batch (@cust_pay_batch) { + $cust_pay_batch->set('paytype', $cust_main->get('paytype')); + my $error = $cust_pay_batch->replace; + die "$error (setting cust_pay_batch.paytype)" if $error; + } } $cust_main->complimentary('Y') if $cust_main->payby eq 'COMP'; @@ -5963,4 +6121,3 @@ L, L, schema.html from the base documentation. =cut 1; -