RT# 82094 - updated UI for bulk emailing from advanced customer reports
[freeside.git] / FS / FS / cust_main.pm
index 925eb4e..8647c82 100644 (file)
@@ -79,6 +79,7 @@ use FS::sales;
 use FS::cust_payby;
 use FS::contact;
 use FS::reason;
+use FS::Misc::Savepoint;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -742,20 +743,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;
 
@@ -2063,7 +2050,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
@@ -2212,11 +2199,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');
 
@@ -2233,7 +2224,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 );
       }
 
@@ -2253,11 +2245,13 @@ 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;
 
   my @errors;
   # try to cancel each service, the same way we would for individual packages,
@@ -2271,17 +2265,22 @@ 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 );
     # immediate cancel, no date option
     # transactionize individually
     my $error = try { $cust_svc->cancel } catch { $_ };
     if ( $error ) {
-      dbh->rollback;
+      savepoint_rollback_and_release( $savepoint );
+      dbh->rollback if $oldAutoCommit;
       push @errors, $error;
     } else {
-      dbh->commit;
+      savepoint_release( $savepoint );
+      dbh->commit if $oldAutoCommit;
     }
   }
   if (@errors) {
@@ -2297,8 +2296,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 ) {
@@ -2319,10 +2321,12 @@ sub cancel_pkgs {
     }
     my $error = $_->cancel(%lopt);
     if ( $error ) {
-      dbh->rollback;
+      savepoint_rollback_and_release( $savepoint );
+      dbh->rollback if $oldAutoCommit;
       push @errors, 'pkgnum '.$_->pkgnum.': '.$error;
     } else {
-      dbh->commit;
+      savepoint_release( $savepoint );
+      dbh->commit if $oldAutoCommit;
     }
   }
 
@@ -3022,48 +3026,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<FS::contact> 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<FS::contact> 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);
@@ -3090,6 +3150,101 @@ sub contact_list_email {
   @emails;
 }
 
+=item contact_list_email_destinations
+
+Returns a list of emails and whether they receive invoices or messages destinations.
+{ emailaddress => 'email.com', invoice => 'Y', message => '', }
+
+=cut
+
+sub contact_list_email_destinations {
+  my $self = shift;
+  warn "$me contact_list_email_destinations"
+    if $DEBUG;
+  return () if !$self->custnum; # not yet inserted
+  return map { $_ }
+    qsearch({
+        table     => 'cust_contact',
+        select    => 'emailaddress, cust_contact.invoice_dest as invoice, cust_contact.message_dest as message',
+        addl_from => ' JOIN contact USING (contactnum) '.
+                     ' JOIN contact_email USING (contactnum)',
+        hashref   => { 'custnum' => $self->custnum, },
+        order_by  => 'ORDER BY custcontactnum DESC',
+        extra_sql => '',
+    });
+}
+
+=item contact_list_emailonly
+
+Returns an array of hashes containing the emails. Used for displaying contact email field in advanced customer reports.
+[ { data => 'email.com', }, ]
+
+=cut
+
+sub contact_list_emailonly {
+  my $self = shift;
+  warn "$me contact_list_emailonly called"
+    if $DEBUG;
+  my @emails;
+  foreach ($self->contact_list_email_destinations) {
+    my $data = [
+      {
+        'data'  => $_->emailaddress,
+      },
+    ];
+    push @emails, $data;
+  }
+  return \@emails;
+}
+
+=item contact_list_cust_invoice_only
+
+Returns an array of hashes containing cust_contact.invoice_dest.  Does this email receive invoices. Used for displaying email Invoice field in advanced customer reports.
+[ { data => 'Yes', }, ]
+
+=cut
+
+sub contact_list_cust_invoice_only {
+  my $self = shift;
+  warn "$me contact_list_cust_invoice_only called"
+    if $DEBUG;
+  my @emails;
+  foreach ($self->contact_list_email_destinations) {
+    my $invoice = $_->invoice ? 'Yes' : 'No';
+    my $data = [
+      {
+        'data'  => $invoice,
+      },
+    ];
+    push @emails, $data;
+  }
+  return \@emails;
+}
+
+=item contact_list_cust_message_only
+
+Returns an array of hashes containing cust_contact.message_dest.  Does this email receive message notifications. Used for displaying email Message field in advanced customer reports.
+[ { data => 'Yes', }, ]
+
+=cut
+
+sub contact_list_cust_message_only {
+  my $self = shift;
+  warn "$me contact_list_cust_message_only called"
+    if $DEBUG;
+  my @emails;
+  foreach ($self->contact_list_email_destinations) {
+    my $message = $_->message ? 'Yes' : 'No';
+    my $data = [
+      {
+        'data'  => $message,
+      },
+    ];
+    push @emails, $data;
+  }
+  return \@emails;
+}
+
 =item referral_custnum_cust_main
 
 Returns the customer who referred this customer (or the empty string, if
@@ -3866,6 +4021,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<FS::contact> object for this customer that has the 'Service'
@@ -5334,7 +5510,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
@@ -5540,4 +5777,3 @@ L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.
 =cut
 
 1;
-