RT#38363: use cust_payby when saving cards during payments
[freeside.git] / FS / FS / cust_main.pm
index f102d97..ee70dea 100644 (file)
@@ -28,11 +28,10 @@ use Date::Format;
 #use Date::Manip;
 use File::Temp; #qw( tempfile );
 use Business::CreditCard 0.28;
-use Locale::Country;
 use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
 use FS::Cursor;
-use FS::Misc qw( generate_email send_email generate_ps do_print );
+use FS::Misc qw( generate_ps do_print money_pretty );
 use FS::Msgcat qw(gettext);
 use FS::CurrentUser;
 use FS::TicketSystem;
@@ -325,14 +324,7 @@ a better explanation of this, but until then, here's an example:
   );
   $cust_main->insert( \%hash );
 
-INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
-be set as the invoicing list (see L<"invoicing_list">).  Errors return as
-expected and rollback the entire transaction; it is not necessary to call 
-check_invoicing_list first.  The invoicing_list is set after the records in the
-CUST_PKG_HASHREF above are inserted, so it is now possible to set an
-invoicing_list destination to the newly-created svc_acct.  Here's an example:
-
-  $cust_main->insert( {}, [ $email, 'POST' ] );
+INVOICING_LIST_ARYREF: No longer supported.
 
 Currently available options are: I<depend_jobnum>, I<noexport>,
 I<tax_exemption>, I<prospectnum>, I<contact> and I<contact_params>.
@@ -352,8 +344,8 @@ created and inserted.
 
 If I<prospectnum> is set, moves contacts and locations from that prospect.
 
-If I<contact> is set to an arrayref of FS::contact objects, inserts those
-new contacts with this new customer.
+If I<contact> is set to an arrayref of FS::contact objects, those will be
+inserted.
 
 If I<contact_params> is set to a hashref of CGI parameters (and I<contact> is
 unset), inserts those new contacts with this new customer.  Handles CGI
@@ -368,7 +360,11 @@ for an "m2" multiple entry field as passed by edit/cust_main.cgi
 sub insert {
   my $self = shift;
   my $cust_pkgs = @_ ? shift : {};
-  my $invoicing_list = @_ ? shift : '';
+  my $invoicing_list;
+  if ( $_[0] and ref($_[0]) eq 'ARRAY' ) {
+    warn "cust_main::insert using deprecated invoicing list argument";
+    $invoicing_list = shift;
+  }
   my %options = @_;
   warn "$me insert called with options ".
        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
@@ -495,19 +491,6 @@ sub insert {
     }
   }
 
-  warn "  setting invoicing list\n"
-    if $DEBUG > 1;
-
-  if ( $invoicing_list ) {
-    $error = $self->check_invoicing_list( $invoicing_list );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      #return "checking invoicing_list (transaction rolled back): $error";
-      return $error;
-    }
-    $self->invoicing_list( $invoicing_list );
-  }
-
   warn "  setting customer tags\n"
     if $DEBUG > 1;
 
@@ -572,6 +555,54 @@ sub insert {
   warn "  setting contacts\n"
     if $DEBUG > 1;
 
+  $invoicing_list ||= $options{'invoicing_list'};
+  if ( $invoicing_list ) {
+
+    $invoicing_list = [ $invoicing_list ] if !ref($invoicing_list);
+
+    my $email = '';
+    foreach my $dest (@$invoicing_list ) {
+      if ($dest eq 'POST') {
+        $self->set('postal_invoice', 'Y');
+      } else {
+
+        my $contact_email = qsearchs('contact_email', { emailaddress => $dest });
+        if ( $contact_email ) {
+          my $cust_contact = FS::cust_contact->new({
+              contactnum    => $contact_email->contactnum,
+              custnum       => $self->custnum,
+          });
+          $cust_contact->set('invoice_dest', 'Y');
+          my $error = $cust_contact->contactnum ?
+                        $cust_contact->replace : $cust_contact->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "$error (linking to email address $dest)";
+          }
+
+        } else {
+          # this email address is not yet linked to any contact
+          $email .= ',' if length($email);
+          $email .= $dest;
+        }
+      }
+    }
+
+    my $contact = FS::contact->new({
+      'custnum'       => $self->get('custnum'),
+      'last'          => $self->get('last'),
+      'first'         => $self->get('first'),
+      'emailaddress'  => $email,
+      'invoice_dest'  => 'Y', # yes, you can set this via the contact
+    });
+    my $error = $contact->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
   if ( my $contact = delete $options{'contact'} ) {
 
     foreach my $c ( @$contact ) {
@@ -599,7 +630,18 @@ sub insert {
   warn "  setting cust_payby\n"
     if $DEBUG > 1;
 
-  if ( my $cust_payby_params = delete $options{'cust_payby_params'} ) {
+  if ( $options{cust_payby} ) {
+
+    foreach my $cust_payby ( @{ $options{cust_payby} } ) {
+      $cust_payby->custnum($self->custnum);
+      my $error = $cust_payby->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  } elsif ( my $cust_payby_params = delete $options{'cust_payby_params'} ) {
 
     my $error = $self->process_o2m(
       'table'         => 'cust_payby',
@@ -1263,19 +1305,25 @@ To change the customer's address, set the pseudo-fields C<bill_location> and
 C<ship_location>.  The address will still only change if at least one of the
 address fields differs from the existing values.
 
-INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
-be set as the invoicing list (see L<"invoicing_list">).  Errors return as
-expected and rollback the entire transaction; it is not necessary to call 
-check_invoicing_list first.  Here's an example:
-
-  $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
+INVOICING_LIST_ARYREF: If you pass an arrayref to this method, it will be
+set as the contact email address for a default contact with the same name as
+the customer.
 
-Currently available options are: I<tax_exemption>.
+Currently available options are: I<tax_exemption>, I<cust_payby_params>, 
+I<contact_params>, I<invoicing_list>.
 
 The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
 of tax names and exemption numbers.  FS::cust_main_exemption records will be
 deleted and inserted as appropriate.
 
+I<cust_payby_params> and I<contact_params> can be hashrefs of named parameter
+groups (describing the customer's payment methods and contacts, respectively)
+in the style supported by L<FS::o2m_Common/process_o2m>. See L<FS::cust_payby>
+and L<FS::contact> for the fields these can contain.
+
+I<invoicing_list> is a synonym for the INVOICING_LIST_ARYREF parameter, and
+should be used instead if possible.
+
 =cut
 
 sub replace {
@@ -1336,6 +1384,120 @@ sub replace {
     $self->set($l.'num', $new_loc->locationnum);
   } #for $l
 
+  my $invoicing_list;
+  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
+    warn "cust_main::replace: using deprecated invoicing list argument";
+    $invoicing_list = shift @param;
+  }
+
+  my %options = @param;
+
+  $invoicing_list ||= $options{invoicing_list};
+
+  my @contacts = map { $_->contact } $self->cust_contact;
+  # find a contact that matches the customer's name
+  my ($implicit_contact) = grep { $_->first eq $old->get('first')
+                              and $_->last  eq $old->get('last') }
+                            @contacts;
+  $implicit_contact ||= FS::contact->new({
+      'custnum'       => $self->custnum,
+      'locationnum'   => $self->get('bill_locationnum'),
+  });
+
+  # for any of these that are already contact emails, link to the existing
+  # contact
+  if ( $invoicing_list ) {
+    my $email = '';
+
+    # kind of like process_m2m on these, except:
+    # - the other side is two tables in a join
+    # - and we might have to create new contact_emails
+    # - and possibly a new contact
+    # 
+    # Find existing invoice emails that aren't on the implicit contact.
+    # Any of these that are not on the new invoicing list will be removed.
+    my %old_email_cust_contact;
+    foreach my $cust_contact ($self->cust_contact) {
+      next if !$cust_contact->invoice_dest;
+      next if $cust_contact->contactnum == ($implicit_contact->contactnum || 0);
+
+      foreach my $contact_email ($cust_contact->contact->contact_email) {
+        $old_email_cust_contact{ $contact_email->emailaddress } = $cust_contact;
+      }
+    }
+
+    foreach my $dest (@$invoicing_list) {
+
+      if ($dest eq 'POST') {
+
+        $self->set('postal_invoice', 'Y');
+
+      } elsif ( exists($old_email_cust_contact{$dest}) ) {
+
+        delete $old_email_cust_contact{$dest}; # don't need to remove it, then
+
+      } else {
+
+        # See if it belongs to some other contact; if so, link it.
+        my $contact_email = qsearchs('contact_email', { emailaddress => $dest });
+        if ( $contact_email
+             and $contact_email->contactnum != ($implicit_contact->contactnum || 0) ) {
+          my $cust_contact = qsearchs('cust_contact', {
+              contactnum  => $contact_email->contactnum,
+              custnum     => $self->custnum,
+          }) || FS::cust_contact->new({
+              contactnum    => $contact_email->contactnum,
+              custnum       => $self->custnum,
+          });
+          $cust_contact->set('invoice_dest', 'Y');
+          my $error = $cust_contact->custcontactnum ?
+                        $cust_contact->replace : $cust_contact->insert;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return "$error (linking to email address $dest)";
+          }
+
+        } else {
+          # This email address is not yet linked to any contact, so it will
+          # be added to the implicit contact.
+          $email .= ',' if length($email);
+          $email .= $dest;
+        }
+      }
+    }
+
+    foreach my $remove_dest (keys %old_email_cust_contact) {
+      my $cust_contact = $old_email_cust_contact{$remove_dest};
+      # These were not in the list of requested destinations, so take them off.
+      $cust_contact->set('invoice_dest', '');
+      my $error = $cust_contact->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "$error (unlinking email address $remove_dest)";
+      }
+    }
+
+    # make sure it keeps up with the changed customer name, if any
+    $implicit_contact->set('last', $self->get('last'));
+    $implicit_contact->set('first', $self->get('first'));
+    $implicit_contact->set('emailaddress', $email);
+    $implicit_contact->set('invoice_dest', 'Y');
+    $implicit_contact->set('custnum', $self->custnum);
+
+    my $error;
+    if ( $implicit_contact->contactnum ) {
+      $error = $implicit_contact->replace;
+    } elsif ( length($email) ) { # don't create a new contact if not needed
+      $error = $implicit_contact->insert;
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$error (adding email address $email)";
+    }
+
+  }
+
   # replace the customer record
   my $error = $self->SUPER::replace($old);
 
@@ -1365,16 +1527,6 @@ sub replace {
     }
   }
 
-  if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
-    my $invoicing_list = shift @param;
-    $error = $self->check_invoicing_list( $invoicing_list );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return $error;
-    }
-    $self->invoicing_list( $invoicing_list );
-  }
-
   if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
 
     #this could be more efficient than deleting and re-inserting, if it matters
@@ -1397,8 +1549,6 @@ sub replace {
 
   }
 
-  my %options = @param;
-
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
@@ -1456,6 +1606,24 @@ sub replace {
 
   }
 
+  if ( my $contact_params = delete $options{'contact_params'} ) {
+
+    # this can potentially replace contacts that were created by the
+    # invoicing list argument, but the UI shouldn't allow both of them
+    # to be specified
+
+    my $error = $self->process_o2m(
+      'table'         => 'contact',
+      'fields'        => FS::contact->cgi_contact_fields,
+      'params'        => $contact_params,
+    );
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
+  }
+
   unless ( $import || $skip_fuzzyfiles ) {
     $error = $self->queue_fuzzyfiles_update;
     if ( $error ) {
@@ -1594,6 +1762,7 @@ sub check {
     || $self->ut_alphan('po_number')
     || $self->ut_enum('complimentary', [ '', 'Y' ])
     || $self->ut_flag('invoice_ship_address')
+    || $self->ut_flag('invoice_dest')
   ;
 
   foreach (qw(company ship_company)) {
@@ -2000,21 +2169,35 @@ sub cust_contact {
   qsearch('cust_contact', { 'custnum' => $self->custnum } );
 }
 
-=item cust_payby
+=item cust_payby PAYBY
 
 Returns all payment methods (see L<FS::cust_payby>) for this customer.
 
+If one or more PAYBY are specified, returns only payment methods for specified PAYBY.
+Does not validate PAYBY--do not pass tainted values.
+
 =cut
 
 sub cust_payby {
   my $self = shift;
-  qsearch({
+  my @payby = @_;
+  my $search = {
     'table'    => 'cust_payby',
     'hashref'  => { 'custnum' => $self->custnum },
     'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC",
-  });
+  };
+  $search->{'extra_sql'} = ' AND payby IN ( ' . join(',', map { "'$_'" } @payby) . ' ) '
+    if @payby;
+
+  qsearch($search);
 }
 
+=item has_cust_payby_auto
+
+Returns true if customer has an automatic payment method ('CARD' or 'CHEK')
+
+=cut
+
 sub has_cust_payby_auto {
   my $self = shift;
   scalar( qsearch({ 
@@ -2716,24 +2899,6 @@ sub payment_info {
 
 }
 
-=item paydate_monthyear
-
-Returns a two-element list consisting of the month and year of this customer's
-paydate (credit card expiration date for CARD customers)
-
-=cut
-
-sub paydate_monthyear {
-  my $self = shift;
-  if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
-    ( $2, $1 );
-  } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
-    ( $1, $3 );
-  } else {
-    ('', '');
-  }
-}
-
 =item paydate_epoch
 
 Returns the exact time in seconds corresponding to the payment method 
@@ -2803,18 +2968,10 @@ sub tax_exemption {
 
 =item cust_main_exemption
 
-=item invoicing_list [ ARRAYREF ]
+=item invoicing_list
 
-If an arguement is given, sets these email addresses as invoice recipients
-(see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
-(except as warnings), so use check_invoicing_list first.
-
-Returns a list of email addresses (with svcnum entries expanded).
-
-Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
-check it without disturbing anything by passing nothing.
-
-This interface may change in the future.
+Returns a list of email addresses (with svcnum entries expanded), and the word
+'POST' if the customer receives postal invoices.
 
 =cut
 
@@ -2822,47 +2979,13 @@ sub invoicing_list {
   my( $self, $arrayref ) = @_;
 
   if ( $arrayref ) {
-    my @cust_main_invoice;
-    if ( $self->custnum ) {
-      @cust_main_invoice = 
-        qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-    } else {
-      @cust_main_invoice = ();
-    }
-    foreach my $cust_main_invoice ( @cust_main_invoice ) {
-      #warn $cust_main_invoice->destnum;
-      unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
-        #warn $cust_main_invoice->destnum;
-        my $error = $cust_main_invoice->delete;
-        warn $error if $error;
-      }
-    }
-    if ( $self->custnum ) {
-      @cust_main_invoice = 
-        qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-    } else {
-      @cust_main_invoice = ();
-    }
-    my %seen = map { $_->address => 1 } @cust_main_invoice;
-    foreach my $address ( @{$arrayref} ) {
-      next if exists $seen{$address} && $seen{$address};
-      $seen{$address} = 1;
-      my $cust_main_invoice = new FS::cust_main_invoice ( {
-        'custnum' => $self->custnum,
-        'dest'    => $address,
-      } );
-      my $error = $cust_main_invoice->insert;
-      warn $error if $error;
-    }
+    warn "FS::cust_main::invoicing_list(ARRAY) is no longer supported.";
   }
   
-  if ( $self->custnum ) {
-    map { $_->address }
-      qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
-  } else {
-    ();
-  }
+  my @emails = $self->invoicing_list_emailonly;
+  push @emails, 'POST' if $self->get('postal_invoice');
 
+  @emails;
 }
 
 =item check_invoicing_list ARRAYREF
@@ -2900,18 +3023,6 @@ sub check_invoicing_list {
   '';
 }
 
-=item set_default_invoicing_list
-
-Sets the invoicing list to all accounts associated with this customer,
-overwriting any previous invoicing list.
-
-=cut
-
-sub set_default_invoicing_list {
-  my $self = shift;
-  $self->invoicing_list($self->all_emails);
-}
-
 =item all_emails
 
 Returns the email addresses of all accounts provisioned for this customer.
@@ -2941,10 +3052,11 @@ to receive postal invoices, does nothing.
 
 sub invoicing_list_addpost {
   my $self = shift;
-  return if grep { $_ eq 'POST' } $self->invoicing_list;
-  my @invoicing_list = $self->invoicing_list;
-  push @invoicing_list, 'POST';
-  $self->invoicing_list(\@invoicing_list);
+  if ( $self->get('postal_invoice') eq '' ) {
+    $self->set('postal_invoice', 'Y');
+    my $error = $self->replace;
+    warn $error if $error; # should fail harder, but this is traditional
+  }
 }
 
 =item invoicing_list_emailonly
@@ -2958,7 +3070,16 @@ sub invoicing_list_emailonly {
   my $self = shift;
   warn "$me invoicing_list_emailonly called"
     if $DEBUG;
-  grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
+  return () if !$self->custnum; # not yet inserted
+  return map { $_->emailaddress }
+    qsearch({
+        table     => 'cust_contact',
+        select    => 'emailaddress',
+        addl_from => ' JOIN contact USING (contactnum) '.
+                     ' JOIN contact_email USING (contactnum)',
+        hashref   => { 'custnum' => $self->custnum, },
+        extra_sql => q( AND cust_contact.invoice_dest = 'Y'),
+    });
 }
 
 =item invoicing_list_emailonly_scalar
@@ -3838,26 +3959,14 @@ sub ship_contact_firstlast {
   $contact->get('first') . ' '. $contact->get('last');
 }
 
-#XXX this doesn't work in 3.x+
-#=item country_full
-#
-#Returns this customer's full country name
-#
-#=cut
-#
-#sub country_full {
-#  my $self = shift;
-#  code2country($self->country);
-#}
-
 sub bill_country_full {
   my $self = shift;
-  code2country($self->bill_location->country);
+  $self->bill_location->country_full;
 }
 
 sub ship_country_full {
   my $self = shift;
-  code2country($self->ship_location->country);
+  $self->ship_location->country_full;
 }
 
 =item county_state_county [ PREFIX ]
@@ -4053,6 +4162,30 @@ sub tickets {
   (@tickets);
 }
 
+=item appointments [ STATUS ]
+
+Returns an array of hashes representing the customer's RT tickets which
+are appointments.
+
+=cut
+
+sub appointments {
+  my $self = shift;
+  my $status = ( @_ && $_[0] ) ? shift : '';
+
+  return () unless $conf->config('ticket_system');
+
+  my $queueid = $conf->config('ticket_system-appointment-queueid');
+
+  @{ FS::TicketSystem->customer_tickets( $self->custnum,
+                                         99,
+                                         undef,
+                                         $status,
+                                         $queueid,
+                                       )
+  };
+}
+
 # Return services representing svc_accts in customer support packages
 sub support_services {
   my $self = shift;
@@ -4095,6 +4228,420 @@ my ($self,$field) = @_;
 
 }
 
+=item payment_history
+
+Returns an array of hashrefs standardizing information from cust_bill, cust_pay,
+cust_credit and cust_refund objects.  Each hashref has the following fields:
+
+I<type> - one of 'Line item', 'Invoice', 'Payment', 'Credit', 'Refund' or 'Previous'
+
+I<date> - value of _date field, unix timestamp
+
+I<date_pretty> - user-friendly date
+
+I<description> - user-friendly description of item
+
+I<amount> - impact of item on user's balance 
+(positive for Invoice/Refund/Line item, negative for Payment/Credit.)
+Not to be confused with the native 'amount' field in cust_credit, see below.
+
+I<amount_pretty> - includes money char
+
+I<balance> - customer balance, chronologically as of this item
+
+I<balance_pretty> - includes money char
+
+I<charged> - amount charged for cust_bill (Invoice or Line item) records, undef for other types
+
+I<paid> - amount paid for cust_pay records, undef for other types
+
+I<credit> - amount credited for cust_credit records, undef for other types.
+Literally the 'amount' field from cust_credit, renamed here to avoid confusion.
+
+I<refund> - amount refunded for cust_refund records, undef for other types
+
+The four table-specific keys always have positive values, whether they reflect charges or payments.
+
+The following options may be passed to this method:
+
+I<line_items> - if true, returns charges ('Line item') rather than invoices
+
+I<start_date> - unix timestamp, only include records on or after.
+If specified, an item of type 'Previous' will also be included.
+It does not have table-specific fields.
+
+I<end_date> - unix timestamp, only include records before
+
+I<reverse_sort> - order from newest to oldest (default is oldest to newest)
+
+I<conf> - optional already-loaded FS::Conf object.
+
+=cut
+
+# Caution: this gets used by FS::ClientAPI::MyAccount::billing_history,
+# and also for sending customer statements, which should both be kept customer-friendly.
+# If you add anything that shouldn't be passed on through the API or exposed 
+# to customers, add a new option to include it, don't include it by default
+sub payment_history {
+  my $self = shift;
+  my $opt = ref($_[0]) ? $_[0] : { @_ };
+
+  my $conf = $$opt{'conf'} || new FS::Conf;
+  my $money_char = $conf->config("money_char") || '$',
+
+  #first load entire history, 
+  #need previous to calculate previous balance
+  #loading after end_date shouldn't hurt too much?
+  my @history = ();
+  if ( $$opt{'line_items'} ) {
+
+    foreach my $cust_bill ( $self->cust_bill ) {
+
+      push @history, {
+        'type'        => 'Line item',
+        'description' => $_->desc( $self->locale ).
+                           ( $_->sdate && $_->edate
+                               ? ' '. time2str('%d-%b-%Y', $_->sdate).
+                                 ' To '. time2str('%d-%b-%Y', $_->edate)
+                               : ''
+                           ),
+        'amount'      => sprintf('%.2f', $_->setup + $_->recur ),
+        'charged'     => sprintf('%.2f', $_->setup + $_->recur ),
+        'date'        => $cust_bill->_date,
+        'date_pretty' => $self->time2str_local('short', $cust_bill->_date ),
+      }
+        foreach $cust_bill->cust_bill_pkg;
+
+    }
+
+  } else {
+
+    push @history, {
+                     'type'        => 'Invoice',
+                     'description' => 'Invoice #'. $_->display_invnum,
+                     'amount'      => sprintf('%.2f', $_->charged ),
+                     'charged'     => sprintf('%.2f', $_->charged ),
+                     'date'        => $_->_date,
+                     'date_pretty' => $self->time2str_local('short', $_->_date ),
+                   }
+      foreach $self->cust_bill;
+
+  }
+
+  push @history, {
+                   'type'        => 'Payment',
+                   'description' => 'Payment', #XXX type
+                   'amount'      => sprintf('%.2f', 0 - $_->paid ),
+                   'paid'        => sprintf('%.2f', $_->paid ),
+                   'date'        => $_->_date,
+                   'date_pretty' => $self->time2str_local('short', $_->_date ),
+                 }
+    foreach $self->cust_pay;
+
+  push @history, {
+                   'type'        => 'Credit',
+                   'description' => 'Credit', #more info?
+                   'amount'      => sprintf('%.2f', 0 -$_->amount ),
+                   'credit'      => sprintf('%.2f', $_->amount ),
+                   'date'        => $_->_date,
+                   'date_pretty' => $self->time2str_local('short', $_->_date ),
+                 }
+    foreach $self->cust_credit;
+
+  push @history, {
+                   'type'        => 'Refund',
+                   'description' => 'Refund', #more info?  type, like payment?
+                   'amount'      => $_->refund,
+                   'refund'      => $_->refund,
+                   'date'        => $_->_date,
+                   'date_pretty' => $self->time2str_local('short', $_->_date ),
+                 }
+    foreach $self->cust_refund;
+
+  #put it all in chronological order
+  @history = sort { $a->{'date'} <=> $b->{'date'} } @history;
+
+  #calculate balance, filter items outside date range
+  my $previous = 0;
+  my $balance = 0;
+  my @out = ();
+  foreach my $item (@history) {
+    last if $$opt{'end_date'} && ($$item{'date'} >= $$opt{'end_date'});
+    $balance += $$item{'amount'};
+    if ($$opt{'start_date'} && ($$item{'date'} < $$opt{'start_date'})) {
+      $previous += $$item{'amount'};
+      next;
+    }
+    $$item{'balance'} = sprintf("%.2f",$balance);
+    foreach my $key ( qw(amount balance) ) {
+      $$item{$key.'_pretty'} = money_pretty($$item{$key});
+    }
+    push(@out,$item);
+  }
+
+  # start with previous balance, if there was one
+  if ($previous) {
+    my $item = {
+      'type'        => 'Previous',
+      'description' => 'Previous balance',
+      'amount'      => sprintf("%.2f",$previous),
+      'balance'     => sprintf("%.2f",$previous),
+      'date'        => $$opt{'start_date'},
+      'date_pretty' => $self->time2str_local('short', $$opt{'start_date'} ),
+    };
+    #false laziness with above
+    foreach my $key ( qw(amount balance) ) {
+      $$item{$key.'_pretty'} = $$item{$key};
+      $$item{$key.'_pretty'} =~ s/^(-?)/$1$money_char/;
+    }
+    unshift(@out,$item);
+  }
+
+  @out = reverse @history if $$opt{'reverse_sort'};
+
+  return @out;
+}
+
+=item save_cust_payby
+
+Saves a new cust_payby for this customer, replacing an existing entry only
+in select circumstances.  Does not validate input.
+
+If auto is specified, marks this as the customer's primary method (weight 1) 
+and changes existing primary methods for that payby to secondary methods (weight 2.)
+If bill_location is specified with auto, also sets location in cust_main.
+
+Will not insert complete duplicates of existing records, or records in which the
+only difference from an existing record is to turn off automatic payment (will
+return without error.)  Will replace existing records in which the only difference 
+is to add a value to a previously empty preserved field and/or turn on automatic payment.
+Fields marked as preserved are optional, and existing values will not be overwritten with 
+blanks when replacing.
+
+Accepts the following named parameters:
+
+payment_payby - either CARD or CHEK
+
+auto - save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false)
+
+payinfo - required
+
+paymask - optional, but should be specified for anything that might be tokenized, will be preserved when replacing
+
+payname - required
+
+payip - optional, will be preserved when replacing
+
+paydate - CARD only, required
+
+bill_location - CARD only, required, FS::cust_location object
+
+paystart_month - CARD only, optional, will be preserved when replacing
+
+paystart_year - CARD only, optional, will be preserved when replacing
+
+payissue - CARD only, optional, will be preserved when replacing
+
+paycvv - CARD only, only used if conf cvv-save is set appropriately
+
+paytype - CHEK only
+
+paystate - CHEK only
+
+=cut
+
+#The code for this option is in place, but it's not currently used
+#
+# replace - existing cust_payby object to be replaced (must match custnum)
+
+# stateid/stateid_state/ss are not currently supported in cust_payby,
+# might not even work properly in 4.x, but will need to work here if ever added
+
+sub save_cust_payby {
+  my $self = shift;
+  my %opt = @_;
+
+  my $old = $opt{'replace'};
+  my $new = new FS::cust_payby { $old ? $old->hash : () };
+  return "Customer number does not match" if $new->custnum and $new->custnum != $self->custnum;
+  $new->set( 'custnum' => $self->custnum );
+
+  my $payby = $opt{'payment_payby'};
+  return "Bad payby" unless grep(/^$payby$/,('CARD','CHEK'));
+
+  # don't allow turning off auto when replacing
+  $opt{'auto'} ||= 1 if $old and $old->payby !~ /^D/;
+
+  my @check_existing; # payby relevant to this payment_payby
+
+  # set payby based on auto
+  if ( $payby eq 'CARD' ) { 
+    $new->set( 'payby' => ( $opt{'auto'} ? 'CARD' : 'DCRD' ) );
+    @check_existing = qw( CARD DCRD );
+  } elsif ( $payby eq 'CHEK' ) {
+    $new->set( 'payby' => ( $opt{'auto'} ? 'CHEK' : 'DCHK' ) );
+    @check_existing = qw( CHEK DCHK );
+  }
+
+  # every automatic payment type added here will be marked primary
+  $new->set( 'weight' => $opt{'auto'} ? 1 : '' );
+
+  # basic fields
+  $new->payinfo($opt{'payinfo'}); # sets default paymask, but not if it's already tokenized
+  $new->paymask($opt{'paymask'}) if $opt{'paymask'}; # in case it's been tokenized, override with loaded paymask
+  $new->set( 'payname' => $opt{'payname'} );
+  $new->set( 'payip' => $opt{'payip'} ); # will be preserved below
+
+  my $conf = new FS::Conf;
+
+  # compare to FS::cust_main::realtime_bop - check both to make sure working correctly
+  if ( $payby eq 'CARD' &&
+       grep { $_ eq cardtype($opt{'payinfo'}) } $conf->config('cvv-save') ) {
+    $new->set( 'paycvv' => $opt{'paycvv'} );
+  } else {
+    $new->set( 'paycvv' => '');
+  }
+
+  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;
+
+  # set fields specific to payment_payby
+  if ( $payby eq 'CARD' ) {
+    if ($opt{'bill_location'}) {
+      $opt{'bill_location'}->set('custnum' => $self->custnum);
+      my $error = $opt{'bill_location'}->find_or_insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+      $new->set( 'locationnum' => $opt{'bill_location'}->locationnum );
+    }
+    foreach my $field ( qw( paydate paystart_month paystart_year payissue ) ) {
+      $new->set( $field => $opt{$field} );
+    }
+  } else {
+    foreach my $field ( qw(paytype paystate) ) {
+      $new->set( $field => $opt{$field} );
+    }
+  }
+
+  # other cust_payby to compare this to
+  my @existing = $self->cust_payby(@check_existing);
+
+  # fields that can overwrite blanks with values, but not values with blanks
+  my @preserve = qw( paymask locationnum paystart_month paystart_year payissue payip );
+
+  my $skip_cust_payby = 0; # true if we don't need to save or reweight cust_payby
+  unless ($old) {
+    # generally, we don't want to overwrite existing cust_payby with this,
+    # but we can replace if we're only marking it auto or adding a preserved field
+    # and we can avoid saving a total duplicate or merely turning off auto
+PAYBYLOOP:
+    foreach my $cust_payby (@existing) {
+      # check fields that absolutely should not change
+      foreach my $field ($new->fields) {
+        next if grep(/^$field$/, qw( custpaybynum payby weight ) );
+        next if grep(/^$field$/, @preserve );
+        next PAYBYLOOP unless $new->get($field) eq $cust_payby->get($field);
+      }
+      # now check fields that can replace if one value is blank
+      my $replace = 0;
+      foreach my $field (@preserve) {
+        if (
+          ( $new->get($field) and !$cust_payby->get($field) ) or
+          ( $cust_payby->get($field) and !$new->get($field) )
+        ) {
+          # prevention of overwriting values with blanks happens farther below
+          $replace = 1;
+        } elsif ( $new->get($field) ne $cust_payby->get($field) ) {
+          next PAYBYLOOP;
+        }
+      }
+      unless ( $replace ) {
+        # nearly identical, now check weight
+        if ($new->get('weight') eq $cust_payby->get('weight') or !$new->get('weight')) {
+          # ignore identical cust_payby, and ignore attempts to turn off auto
+          # no need to save or re-weight cust_payby (but still need to update/commit $self)
+          $skip_cust_payby = 1;
+          last PAYBYLOOP;
+        }
+        # otherwise, only change is to mark this as primary
+      }
+      # if we got this far, we're definitely replacing
+      $old = $cust_payby;
+      last PAYBYLOOP;
+    }
+  }
+
+  if ($old) {
+    $new->set( 'custpaybynum' => $old->custpaybynum );
+    # don't turn off automatic payment (but allow it to be turned on)
+    if ($new->payby =~ /^D/ and $new->payby ne $old->payby) {
+      $opt{'auto'} = 1;
+      $new->set( 'payby' => $old->payby );
+      $new->set( 'weight' => 1 );
+    }
+    # make sure we're not overwriting values with blanks
+    foreach my $field (@preserve) {
+      if ( $old->get($field) and !$new->get($field) ) {
+        $new->set( $field => $old->get($field) );
+      }
+    }
+  }
+
+  # only overwrite cust_main bill_location if auto
+  if ($opt{'auto'} && $opt{'bill_location'}) {
+    $self->set('bill_location' => $opt{'bill_location'});
+    my $error = $self->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  # done with everything except reweighting and saving cust_payby
+  # still need to commit changes to cust_main and cust_location
+  if ($skip_cust_payby) {
+    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    return '';
+  }
+
+  # re-weight existing primary cust_pay for this payby
+  if ($opt{'auto'}) {
+    foreach my $cust_payby (@existing) {
+      # relies on cust_payby return order
+      last unless $cust_payby->payby !~ /^D/;
+      last if $cust_payby->weight > 1;
+      next if $new->custpaybynum eq $cust_payby->custpaybynum;
+      $cust_payby->set( 'weight' => 2 );
+      my $error = $cust_payby->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return "Error reweighting cust_payby: $error";
+      }
+    }
+  }
+
+  # finally, save cust_payby
+  my $error = $old ? $new->replace($old) : $new->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -4400,102 +4947,102 @@ sub search {
 
 =over 4
 
-=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
+#=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
-Deprecated.  Use event notification and message templates 
-(L<FS::msg_template>) instead.
+#Deprecated.  Use event notification and message templates 
+#(L<FS::msg_template>) instead.
 
-Sends a templated email notification to the customer (see L<Text::Template>).
+#Sends a templated email notification to the customer (see L<Text::Template>).
 
-OPTIONS is a hash and may include
-
-I<from> - the email sender (default is invoice_from)
+#OPTIONS is a hash and may include
 
-I<to> - comma-separated scalar or arrayref of recipients 
-   (default is invoicing_list)
+#I<from> - the email sender (default is invoice_from)
 
-I<subject> - The subject line of the sent email notification
-   (default is "Notice from company_name")
+#I<to> - comma-separated scalar or arrayref of recipients 
+#   (default is invoicing_list)
 
-I<extra_fields> - a hashref of name/value pairs which will be substituted
-   into the template
+#I<subject> - The subject line of the sent email notification
+#   (default is "Notice from company_name")
 
-The following variables are vavailable in the template.
+#I<extra_fields> - a hashref of name/value pairs which will be substituted
+#   into the template
 
-I<$first> - the customer first name
-I<$last> - the customer last name
-I<$company> - the customer company
-I<$payby> - a description of the method of payment for the customer
-            # would be nice to use FS::payby::shortname
-I<$payinfo> - the account information used to collect for this customer
-I<$expdate> - the expiration of the customer payment in seconds from epoch
-
-=cut
-
-sub notify {
-  my ($self, $template, %options) = @_;
-
-  return unless $conf->exists($template);
-
-  my $from = $conf->invoice_from_full($self->agentnum)
-    if $conf->exists('invoice_from', $self->agentnum);
-  $from = $options{from} if exists($options{from});
-
-  my $to = join(',', $self->invoicing_list_emailonly);
-  $to = $options{to} if exists($options{to});
-  
-  my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
-    if $conf->exists('company_name', $self->agentnum);
-  $subject = $options{subject} if exists($options{subject});
-
-  my $notify_template = new Text::Template (TYPE => 'ARRAY',
-                                            SOURCE => [ map "$_\n",
-                                              $conf->config($template)]
-                                           )
-    or die "can't create new Text::Template object: Text::Template::ERROR";
-  $notify_template->compile()
-    or die "can't compile template: Text::Template::ERROR";
+#The following variables are vavailable in the template.
 
-  $FS::notify_template::_template::company_name =
-    $conf->config('company_name', $self->agentnum);
-  $FS::notify_template::_template::company_address =
-    join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
+#I<$first> - the customer first name
+#I<$last> - the customer last name
+#I<$company> - the customer company
+#I<$payby> - a description of the method of payment for the customer
+#            # would be nice to use FS::payby::shortname
+#I<$payinfo> - the account information used to collect for this customer
+#I<$expdate> - the expiration of the customer payment in seconds from epoch
 
-  my $paydate = $self->paydate || '2037-12-31';
-  $FS::notify_template::_template::first = $self->first;
-  $FS::notify_template::_template::last = $self->last;
-  $FS::notify_template::_template::company = $self->company;
-  $FS::notify_template::_template::payinfo = $self->mask_payinfo;
-  my $payby = $self->payby;
-  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
-  my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
-
-  #credit cards expire at the end of the month/year of their exp date
-  if ($payby eq 'CARD' || $payby eq 'DCRD') {
-    $FS::notify_template::_template::payby = 'credit card';
-    ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
-    $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
-    $expire_time--;
-  }elsif ($payby eq 'COMP') {
-    $FS::notify_template::_template::payby = 'complimentary account';
-  }else{
-    $FS::notify_template::_template::payby = 'current method';
-  }
-  $FS::notify_template::_template::expdate = $expire_time;
-
-  for (keys %{$options{extra_fields}}){
-    no strict "refs";
-    ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
-  }
+#=cut
 
-  send_email(from => $from,
-             to => $to,
-             subject => $subject,
-             body => $notify_template->fill_in( PACKAGE =>
-                                                'FS::notify_template::_template'                                              ),
-            );
+#sub notify {
+#  my ($self, $template, %options) = @_;
+
+#  return unless $conf->exists($template);
+
+#  my $from = $conf->invoice_from_full($self->agentnum)
+#    if $conf->exists('invoice_from', $self->agentnum);
+#  $from = $options{from} if exists($options{from});
+
+#  my $to = join(',', $self->invoicing_list_emailonly);
+#  $to = $options{to} if exists($options{to});
+#  
+#  my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
+#    if $conf->exists('company_name', $self->agentnum);
+#  $subject = $options{subject} if exists($options{subject});
+
+#  my $notify_template = new Text::Template (TYPE => 'ARRAY',
+#                                            SOURCE => [ map "$_\n",
+#                                              $conf->config($template)]
+#                                           )
+#    or die "can't create new Text::Template object: Text::Template::ERROR";
+#  $notify_template->compile()
+#    or die "can't compile template: Text::Template::ERROR";
+
+#  $FS::notify_template::_template::company_name =
+#    $conf->config('company_name', $self->agentnum);
+#  $FS::notify_template::_template::company_address =
+#    join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
+
+#  my $paydate = $self->paydate || '2037-12-31';
+#  $FS::notify_template::_template::first = $self->first;
+#  $FS::notify_template::_template::last = $self->last;
+#  $FS::notify_template::_template::company = $self->company;
+#  $FS::notify_template::_template::payinfo = $self->mask_payinfo;
+#  my $payby = $self->payby;
+#  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
+#  my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
+
+#  #credit cards expire at the end of the month/year of their exp date
+#  if ($payby eq 'CARD' || $payby eq 'DCRD') {
+#    $FS::notify_template::_template::payby = 'credit card';
+#    ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
+#    $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
+#    $expire_time--;
+#  }elsif ($payby eq 'COMP') {
+#    $FS::notify_template::_template::payby = 'complimentary account';
+#  }else{
+#    $FS::notify_template::_template::payby = 'current method';
+#  }
+#  $FS::notify_template::_template::expdate = $expire_time;
+
+#  for (keys %{$options{extra_fields}}){
+#    no strict "refs";
+#    ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
+#  }
+
+#  send_email(from => $from,
+#             to => $to,
+#             subject => $subject,
+#             body => $notify_template->fill_in( PACKAGE =>
+#                                                'FS::notify_template::_template'                                              ),
+#            );
 
-}
+#}
 
 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
 
@@ -4507,6 +5054,8 @@ I<extra_fields> - a hashref of name/value pairs which will be substituted
    into the template.  These values may override values mentioned below
    and those from the customer record.
 
+I<template_text> - if present, ignores TEMPLATE_NAME and uses the provided text
+
 The following variables are available in the template instead of or in addition
 to the fields of the customer record.
 
@@ -4522,11 +5071,16 @@ I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or
 sub generate_letter {
   my ($self, $template, %options) = @_;
 
-  return unless $conf->exists($template);
+  warn "Template $template does not exist" && return
+    unless $conf->exists($template) || $options{'template_text'};
+
+  my $template_source = $options{'template_text'} 
+                        ? [ $options{'template_text'} ] 
+                        : [ map "$_\n", $conf->config($template) ];
 
   my $letter_template = new Text::Template
                         ( TYPE       => 'ARRAY',
-                          SOURCE     => [ map "$_\n", $conf->config($template)],
+                          SOURCE     => $template_source,
                           DELIMITERS => [ '[@--', '--@]' ],
                         )
     or die "can't create new Text::Template object: Text::Template::ERROR";
@@ -4641,7 +5195,9 @@ sub queueable_print {
   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
     or die "invalid customer number: " . $opt{custnum};
 
-  my $error = $self->print( { 'template' => $opt{template} } );
+#do not backport this change to 3.x
+#  my $error = $self->print( { 'template' => $opt{template} } );
+  my $error = $self->print( $opt{'template'} );
   die $error if $error;
 }