X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=11d77639327cedc03ec5bdf73d225b33a6eeb2c0;hp=f0a479997292d5554e3790f5b180e0ce91b3ddbc;hb=c5a2d3613acdc3b9ab6f32eaf5316c2834071417;hpb=5f8111de04a4a914c72a1642722476db4728339c diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index f0a479997..11d776393 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -4,12 +4,14 @@ use base qw( FS::cust_main::Packages FS::cust_main::NationalID FS::cust_main::Billing FS::cust_main::Billing_Realtime + FS::cust_main::Billing_Batch FS::cust_main::Billing_Discount FS::cust_main::Billing_ThirdParty FS::cust_main::Location FS::cust_main::Credit_Limit + FS::cust_main::Merge FS::cust_main::API - FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin + FS::otaker_Mixin FS::cust_main_Mixin FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin FS::o2m_Common FS::Record @@ -17,31 +19,21 @@ use base qw( FS::cust_main::Packages require 5.006; use strict; -use vars qw( $DEBUG $me $conf - @encrypted_fields - $import - $ignore_expired_card $ignore_banned_card $ignore_illegal_zip - $ignore_invalid_card - $skip_fuzzyfiles - @paytypes - ); use Carp; use Scalar::Util qw( blessed ); use Time::Local qw(timelocal); -use Storable qw(thaw); -use MIME::Base64; use Data::Dumper; use Tie::IxHash; -use Digest::MD5 qw(md5_base64); use Date::Format; #use Date::Manip; use File::Temp; #qw( tempfile ); use Business::CreditCard 0.28; -use Locale::Country; +use List::Util qw(min); +use Try::Tiny; 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 card_types ); use FS::Msgcat qw(gettext); use FS::CurrentUser; use FS::TicketSystem; @@ -61,6 +53,7 @@ use FS::part_referral; use FS::cust_main_county; use FS::cust_location; use FS::cust_class; +use FS::tax_status; use FS::cust_main_exemption; use FS::cust_tax_adjustment; use FS::cust_tax_location; @@ -78,36 +71,44 @@ use FS::agent_payment_gateway; use FS::banned_pay; use FS::cust_main_note; use FS::cust_attachment; -use FS::contact; +use FS::cust_contact; use FS::Locales; use FS::upgrade_journal; use FS::sales; use FS::cust_payby; +use FS::contact; +use FS::reason; # 1 is mostly method/subroutine entry and options # 2 traces progress of some operations # 3 is even more information including possibly sensitive data -$DEBUG = 0; -$me = '[FS::cust_main]'; +our $DEBUG = 0; +our $me = '[FS::cust_main]'; -$import = 0; -$ignore_expired_card = 0; -$ignore_banned_card = 0; -$ignore_invalid_card = 0; +our $import = 0; +our $ignore_expired_card = 0; +our $ignore_banned_card = 0; +our $ignore_invalid_card = 0; -$skip_fuzzyfiles = 0; +our $skip_fuzzyfiles = 0; -@encrypted_fields = ('payinfo', 'paycvv'); -sub nohistory_fields { ('payinfo', 'paycvv'); } +our $ucfirst_nowarn = 0; -@paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings'); +#this info is in cust_payby as of 4.x +#this and the fields themselves can be removed in 5.x +our @encrypted_fields = ('payinfo', 'paycvv'); +sub nohistory_fields { ('payinfo', 'paycvv'); } +our $conf; +our $default_agent_custid; +our $custnum_display_length; #ask FS::UID to run this stuff for us later #$FS::UID::callback{'FS::cust_main'} = sub { install_callback FS::UID sub { $conf = new FS::Conf; - #yes, need it for stuff below (prolly should be cached) - $ignore_invalid_card = $conf->exists('allow_invalid_cards'); + $ignore_invalid_card = $conf->exists('allow_invalid_cards'); + $default_agent_custid = $conf->exists('cust_main-default_agent_custid'); + $custnum_display_length = $conf->config('cust_main-custnum-display_length'); }; sub _cache { @@ -283,6 +284,10 @@ Allow self-service editing of ticket subjects, empty or 'Y' Do not call, empty or 'Y' +=item invoice_ship_address + +Display ship_address ("Service address") on invoices for this customer, empty or 'Y' + =back =head1 METHODS @@ -325,17 +330,10 @@ 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, I, -I and I. +I, I, I and I. If I is set, all provisioning jobs will have a dependancy on the supplied jobnum (they will not run until the specific job completes). @@ -352,15 +350,27 @@ created and inserted. If I is set, moves contacts and locations from that prospect. -If I is set to an arrayref of FS::contact objects, inserts those -new contacts with this new customer. +If I is set to an arrayref of FS::contact objects, those will be +inserted. + +If I is set to a hashref of CGI parameters (and I is +unset), inserts those new contacts with this new customer. Handles CGI +paramaters for an "m2" multiple entry field as passed by edit/cust_main.cgi + +If I is set to a hashref o fCGI parameters, inserts those +new stored payment records with this new customer. Handles CGI parameters +for an "m2" multiple entry field as passed by edit/cust_main.cgi =cut 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" @@ -382,7 +392,7 @@ sub insert { my $payby = ''; if ( $self->payby eq 'PREPAY' ) { - $self->payby('BILL'); + $self->payby(''); #'BILL'); $prepay_identifier = $self->payinfo; $self->payinfo(''); @@ -404,24 +414,19 @@ sub insert { $payby = 'PREP' if $amount; - } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|PPAL)$/ ) { + } elsif ( $self->payby =~ /^(CASH|WEST|MCRD|MCHK|PPAL)$/ ) { $payby = $1; - $self->payby('BILL'); + $self->payby(''); #'BILL'); $amount = $self->paid; } # insert locations foreach my $l (qw(bill_location ship_location)) { - my $loc = delete $self->hashref->{$l}; - # XXX if we're moving a prospect's locations, do that here - if ( !$loc ) { - #return "$l not set"; - #location-less customer records are now permitted - next; - } - + + my $loc = delete $self->hashref->{$l} or next; + if ( !$loc->locationnum ) { # warn the location that we're going to insert it with no custnum $loc->set(custnum_pending => 1); @@ -433,8 +438,19 @@ sub insert { my $label = $l eq 'ship_location' ? 'service' : 'billing'; return "$error (in $label location)"; } - } - elsif ( ($loc->custnum || 0) > 0 or $loc->prospectnum ) { + + } elsif ( $loc->prospectnum ) { + + $loc->prospectnum(''); + $loc->set(custnum_pending => 1); + my $error = $loc->replace; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + my $label = $l eq 'ship_location' ? 'service' : 'billing'; + return "$error (moving $label location)"; + } + + } elsif ( ($loc->custnum || 0) > 0 ) { # then it somehow belongs to another customer--shouldn't happen $dbh->rollback if $oldAutoCommit; return "$l belongs to customer ".$loc->custnum; @@ -458,7 +474,8 @@ sub insert { $self->auto_agent_custid() if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid; - my $error = $self->SUPER::insert; + my $error = $self->check_payinfo_cardtype + || $self->SUPER::insert; if ( $error ) { $dbh->rollback if $oldAutoCommit; #return "inserting cust_main record (transaction rolled back): $error"; @@ -481,19 +498,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; @@ -527,11 +531,24 @@ sub insert { return $error; } - my @contact = $prospect_main->contact; + foreach my $prospect_contact ( $prospect_main->prospect_contact ) { + my $cust_contact = new FS::cust_contact { + 'custnum' => $self->custnum, + 'invoice_dest' => 'Y', # invoice_dest currently not set for prospect contacts + map { $_ => $prospect_contact->$_() } qw( contactnum classnum comment ) + }; + my $error = $cust_contact->insert + || $prospect_contact->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + my @cust_location = $prospect_main->cust_location; my @qual = $prospect_main->qual; - foreach my $r ( @contact, @cust_location, @qual ) { + foreach my $r ( @cust_location, @qual ) { $r->prospectnum(''); $r->custnum($self->custnum); my $error = $r->replace; @@ -540,11 +557,67 @@ sub insert { return $error; } } + # since we set invoice_dest on all migrated prospect contacts (for now), + # don't process invoicing_list. + delete $options{'invoicing_list'}; + $invoicing_list = undef; + } + + 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->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; + } + } + } + + if ( $email ) { + + 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; + } + + } } - my $contact = delete $options{'contact'}; - if ( $contact ) { + if ( my $contact = delete $options{'contact'} ) { foreach my $c ( @$contact ) { $c->custnum($self->custnum); @@ -556,6 +629,45 @@ sub insert { } + } elsif ( my $contact_params = delete $options{'contact_params'} ) { + + my $error = $self->process_o2m( 'table' => 'contact', + 'fields' => FS::contact->cgi_contact_fields, + 'params' => $contact_params, + ); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + warn " setting cust_payby\n" + if $DEBUG > 1; + + 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', + 'fields' => FS::cust_payby->cgi_cust_payby_fields, + 'params' => $cust_payby_params, + 'hash_callback' => \&FS::cust_payby::cgi_hash_callback, + ); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } warn " setting cust_main_exemption\n" @@ -1108,10 +1220,9 @@ sub delete { #cust_tax_adjustment in financials? #cust_pay_pending? ouch - #cust_recon? foreach my $table (qw( cust_main_invoice cust_main_exemption cust_tag cust_attachment contact - cust_location cust_main_note cust_tax_adjustment + cust_payby cust_location cust_main_note cust_tax_adjustment cust_pay_void cust_pay_batch queue cust_tax_exempt )) { foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) { @@ -1199,232 +1310,6 @@ sub delete { } -=item merge NEW_CUSTNUM [ , OPTION => VALUE ... ] - -This merges this customer into the provided new custnum, and then deletes the -customer. If there is an error, returns the error, otherwise returns false. - -The source customer's name, company name, phone numbers, agent, -referring customer, customer class, advertising source, order taker, and -billing information (except balance) are discarded. - -All packages are moved to the target customer. Packages with package locations -are preserved. Packages without package locations are moved to a new package -location with the source customer's service/shipping address. - -All invoices, statements, payments, credits and refunds are moved to the target -customer. The source customer's balance is added to the target customer. - -All notes, attachments, tickets and customer tags are moved to the target -customer. - -Change history is not currently moved. - -=cut - -sub merge { - my( $self, $new_custnum, %opt ) = @_; - - return "Can't merge a customer into self" if $self->custnum == $new_custnum; - - my $new_cust_main = qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) - or return "Invalid new customer number: $new_custnum"; - - return 'Access denied: "Merge customer across agents" access right required to merge into a customer of a different agent' - if $self->agentnum != $new_cust_main->agentnum - && ! $FS::CurrentUser::CurrentUser->access_right('Merge customer across agents'); - - 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; - - if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) { - $dbh->rollback if $oldAutoCommit; - return "Can't merge a master agent customer"; - } - - #use FS::access_user - if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) { - $dbh->rollback if $oldAutoCommit; - return "Can't merge a master employee customer"; - } - - if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum, - 'status' => { op=>'!=', value=>'done' }, - } - ) - ) { - $dbh->rollback if $oldAutoCommit; - return "Can't merge a customer with pending payments"; - } - - tie my %financial_tables, 'Tie::IxHash', - 'cust_bill' => 'invoices', - 'cust_bill_void' => 'voided invoices', - 'cust_statement' => 'statements', - 'cust_credit' => 'credits', - 'cust_credit_void' => 'voided credits', - 'cust_pay' => 'payments', - 'cust_pay_void' => 'voided payments', - 'cust_refund' => 'refunds', - ; - - foreach my $table ( keys %financial_tables ) { - - my @records = $self->$table(); - - foreach my $record ( @records ) { - $record->custnum($new_custnum); - my $error = $record->replace; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "Error merging ". $financial_tables{$table}. ": $error\n"; - } - } - - } - - my $name = $self->ship_name; #? - - my $locationnum = ''; - foreach my $cust_pkg ( $self->all_pkgs ) { - $cust_pkg->custnum($new_custnum); - - unless ( $cust_pkg->locationnum ) { - unless ( $locationnum ) { - my $cust_location = new FS::cust_location { - $self->location_hash, - 'custnum' => $new_custnum, - }; - my $error = $cust_location->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - $locationnum = $cust_location->locationnum; - } - $cust_pkg->locationnum($locationnum); - } - - my $error = $cust_pkg->replace; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - - # add customer (ship) name to svc_phone.phone_name if blank - my @cust_svc = $cust_pkg->cust_svc; - foreach my $cust_svc (@cust_svc) { - my($label, $value, $svcdb) = $cust_svc->label; - next unless $svcdb eq 'svc_phone'; - my $svc_phone = $cust_svc->svc_x; - next if $svc_phone->phone_name; - $svc_phone->phone_name($name); - my $error = $svc_phone->replace; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - } - - } - - #not considered: - # cust_tax_exempt (texas tax exemptions) - # cust_recon (some sort of not-well understood thing for OnPac) - - #these are moved over - foreach my $table (qw( - cust_tag cust_location contact cust_attachment cust_main_note - cust_tax_adjustment cust_pay_batch queue - )) { - foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) { - $record->custnum($new_custnum); - my $error = $record->replace; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - } - } - - #these aren't preserved - foreach my $table (qw( - cust_main_exemption cust_main_invoice - )) { - foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) { - my $error = $record->delete; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - } - } - - - my $sth = $dbh->prepare( - 'UPDATE cust_main SET referral_custnum = ? WHERE referral_custnum = ?' - ) or do { - my $errstr = $dbh->errstr; - $dbh->rollback if $oldAutoCommit; - return $errstr; - }; - $sth->execute($new_custnum, $self->custnum) or do { - my $errstr = $sth->errstr; - $dbh->rollback if $oldAutoCommit; - return $errstr; - }; - - #tickets - - my $ticket_dbh = ''; - if ($conf->config('ticket_system') eq 'RT_Internal') { - $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 }); - #or die "RT_External DBI->connect error: $DBI::errstr\n"; - } - - if ( $ticket_dbh ) { - - my $ticket_sth = $ticket_dbh->prepare( - 'UPDATE Links SET Target = ? WHERE Target = ?' - ) or do { - my $errstr = $ticket_dbh->errstr; - $dbh->rollback if $oldAutoCommit; - return $errstr; - }; - $ticket_sth->execute('freeside://freeside/cust_main/'.$new_custnum, - 'freeside://freeside/cust_main/'.$self->custnum) - or do { - my $errstr = $ticket_sth->errstr; - $dbh->rollback if $oldAutoCommit; - return $errstr; - }; - - } - - #delete the customer record - - my $error = $self->delete; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit; - ''; - -} - =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ] Replaces the OLD_RECORD with this one in the database. If there is an error, @@ -1434,19 +1319,25 @@ To change the customer's address, set the pseudo-fields C and C. 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. +Currently available options are: I, I, +I, I. The I 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 and I can be hashrefs of named parameter +groups (describing the customer's payment methods and contacts, respectively) +in the style supported by L. See L +and L for the fields these can contain. + +I is a synonym for the INVOICING_LIST_ARYREF parameter, and +should be used instead if possible. + =cut sub replace { @@ -1462,13 +1353,10 @@ sub replace { if $DEBUG; my $curuser = $FS::CurrentUser::CurrentUser; - if ( $self->payby eq 'COMP' - && $self->payby ne $old->payby - && ! $curuser->access_right('Complimentary customer') - ) - { - return "You are not permitted to create complimentary accounts."; - } + return "You are not permitted to create complimentary accounts." + if $self->complimentary eq 'Y' + && $self->complimentary ne $old->complimentary + && ! $curuser->access_right('Complimentary customer'); local($ignore_expired_card) = 1 if $old->payby =~ /^(CARD|DCRD)$/ @@ -1480,6 +1368,14 @@ sub replace { || $old->payby =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ ) && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask ); + if ( $self->payby =~ /^(CARD|DCRD)$/ + && $old->payinfo ne $self->payinfo + && $old->paymask ne $self->paymask ) + { + my $error = $self->check_payinfo_cardtype; + return $error if $error; + } + return "Invoicing locale is required" if $old->locale && ! $self->locale @@ -1497,8 +1393,8 @@ sub replace { my $dbh = dbh; for my $l (qw(bill_location ship_location)) { - my $old_loc = $old->$l; - my $new_loc = $self->$l; + #my $old_loc = $old->$l; + my $new_loc = $self->$l or next; # find the existing location if there is one $new_loc->set('custnum' => $self->custnum); @@ -1510,21 +1406,135 @@ sub replace { $self->set($l.'num', $new_loc->locationnum); } #for $l - # replace the customer record - my $error = $self->SUPER::replace($old); - - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return $error; + 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; } - # now move packages to the new service location - $self->set('ship_location', ''); #flush cache - if ( $old->ship_locationnum and # should only be null during upgrade... - $old->ship_locationnum != $self->ship_locationnum ) { - $error = $old->ship_location->move_to($self->ship_location); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; + 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); + + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + # now move packages to the new service location + $self->set('ship_location', ''); #flush cache + if ( $old->ship_locationnum and # should only be null during upgrade... + $old->ship_locationnum != $self->ship_locationnum ) { + $error = $old->ship_location->move_to($self->ship_location); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; return $error; } } @@ -1539,16 +1549,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 @@ -1571,8 +1571,6 @@ sub replace { } - my %options = @param; - my $tax_exemption = delete $options{'tax_exemption'}; if ( $tax_exemption ) { @@ -1615,21 +1613,37 @@ sub replace { } - if ( $self->payby =~ /^(CARD|CHEK|LECB)$/ - && ( ( $self->get('payinfo') ne $old->get('payinfo') - && $self->get('payinfo') !~ /^99\d{14}$/ - ) - || grep { $self->get($_) ne $old->get($_) } qw(paydate payname) - ) - ) - { + if ( my $cust_payby_params = delete $options{'cust_payby_params'} ) { + + my $error = $self->process_o2m( + 'table' => 'cust_payby', + 'fields' => FS::cust_payby->cgi_cust_payby_fields, + 'params' => $cust_payby_params, + 'hash_callback' => \&FS::cust_payby::cgi_hash_callback, + ); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + } - # card/check/lec info has changed, want to retry realtime_ invoice events - my $error = $self->retry_realtime; + 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 ) { @@ -1740,6 +1754,7 @@ sub check { || $self->ut_foreign_keyn('ship_locationnum', 'cust_location','locationnum') || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum') || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum') + || $self->ut_foreign_keyn('taxstatusnum', 'tax_status', 'taxstatusnum') || $self->ut_textn('custbatch') || $self->ut_name('last') || $self->ut_name('first') @@ -1766,6 +1781,10 @@ sub check { || $self->ut_flag('message_noemail') || $self->ut_enum('locale', [ '', FS::Locales->locales ]) || $self->ut_currencyn('currency') + || $self->ut_textn('po_number') + || $self->ut_enum('complimentary', [ '', 'Y' ]) + || $self->ut_flag('invoice_ship_address') + || $self->ut_flag('invoice_dest') ; foreach (qw(company ship_company)) { @@ -1810,6 +1829,11 @@ sub check { $self->ss("$1-$2-$3"); } + #turn off invoice_ship_address if ship & bill are the same + if ($self->bill_locationnum eq $self->ship_locationnum) { + $self->invoice_ship_address(''); + } + # cust_main_county verification now handled by cust_location check $error = @@ -1840,249 +1864,60 @@ sub check { } - ### start of stuff moved to cust_payby - # then mostly kept here to support upgrades (can remove in 5.x) - # but modified to allow everything to be empty - - if ( $self->payby ) { - FS::payby->can_payby($self->table, $self->payby) - or return "Illegal payby: ". $self->payby; - } else { - $self->payby(''); - } + return "Please select an invoicing locale" + if ! $self->locale + && ! $self->custnum + && $conf->exists('cust_main-require_locale'); - $error = $self->ut_numbern('paystart_month') - || $self->ut_numbern('paystart_year') - || $self->ut_numbern('payissue') - || $self->ut_textn('paytype') - ; - return $error if $error; + return "Please select a customer class" + if ! $self->classnum + && $conf->exists('cust_main-require_classnum'); - if ( $self->payip eq '' ) { - $self->payip(''); - } else { - $error = $self->ut_ip('payip'); - return $error if $error; + foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) { + $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag(); + $self->$flag($1); } - # If it is encrypted and the private key is not availaible then we can't - # check the credit card. - my $check_payinfo = ! $self->is_encrypted($self->payinfo); - - # Need some kind of global flag to accept invalid cards, for testing - # on scrubbed data. - if ( !$import && !$ignore_invalid_card && $check_payinfo && - $self->payby =~ /^(CARD|DCRD)$/ ) { - - my $payinfo = $self->payinfo; - $payinfo =~ s/\D//g; - $payinfo =~ /^(\d{13,16}|\d{8,9})$/ - or return gettext('invalid_card'); # . ": ". $self->payinfo; - $payinfo = $1; - $self->payinfo($payinfo); - validate($payinfo) - or return gettext('invalid_card'); # . ": ". $self->payinfo; - - return gettext('unknown_card_type') - if $self->payinfo !~ /^99\d{14}$/ #token - && cardtype($self->payinfo) eq "Unknown"; - - unless ( $ignore_banned_card ) { - my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } ); - if ( $ban ) { - if ( $ban->bantype eq 'warn' ) { - #or others depending on value of $ban->reason ? - return '_duplicate_card'. - ': disabled from'. time2str('%a %h %o at %r', $ban->_date). - ' until '. time2str('%a %h %o at %r', $ban->_end_date). - ' (ban# '. $ban->bannum. ')' - unless $self->override_ban_warn; - } else { - return 'Banned credit card: banned on '. - time2str('%a %h %o at %r', $ban->_date). - ' by '. $ban->otaker. - ' (ban# '. $ban->bannum. ')'; - } - } - } - - if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) { - if ( cardtype($self->payinfo) eq 'American Express card' ) { - $self->paycvv =~ /^(\d{4})$/ - or return "CVV2 (CID) for American Express cards is four digits."; - $self->paycvv($1); - } else { - $self->paycvv =~ /^(\d{3})$/ - or return "CVV2 (CVC2/CID) is three digits."; - $self->paycvv($1); - } - } else { - $self->paycvv(''); - } - - my $cardtype = cardtype($payinfo); - if ( $cardtype =~ /^(Switch|Solo)$/i ) { - - return "Start date or issue number is required for $cardtype cards" - unless $self->paystart_month && $self->paystart_year or $self->payissue; - - return "Start month must be between 1 and 12" - if $self->paystart_month - and $self->paystart_month < 1 || $self->paystart_month > 12; - - return "Start year must be 1990 or later" - if $self->paystart_year - and $self->paystart_year < 1990; - - return "Issue number must be beween 1 and 99" - if $self->payissue - and $self->payissue < 1 || $self->payissue > 99; - - } else { - $self->paystart_month(''); - $self->paystart_year(''); - $self->payissue(''); - } - - } elsif ( !$ignore_invalid_card && $check_payinfo && - $self->payby =~ /^(CHEK|DCHK)$/ ) { - - my $payinfo = $self->payinfo; - $payinfo =~ s/[^\d\@\.]//g; - if ( $conf->config('echeck-country') eq 'CA' ) { - $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/ - or return 'invalid echeck account@branch.bank'; - $payinfo = "$1\@$2.$3"; - } elsif ( $conf->config('echeck-country') eq 'US' ) { - $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba'; - $payinfo = "$1\@$2"; - } else { - $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing'; - $payinfo = "$1\@$2"; - } - $self->payinfo($payinfo); - $self->paycvv(''); - - unless ( $ignore_banned_card ) { - my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } ); - if ( $ban ) { - if ( $ban->bantype eq 'warn' ) { - #or others depending on value of $ban->reason ? - return '_duplicate_ach' unless $self->override_ban_warn; - } else { - return 'Banned ACH account: banned on '. - time2str('%a %h %o at %r', $ban->_date). - ' by '. $ban->otaker. - ' (ban# '. $ban->bannum. ')'; - } - } - } - - } elsif ( $self->payby eq 'LECB' ) { - - my $payinfo = $self->payinfo; - $payinfo =~ s/\D//g; - $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number'; - $payinfo = $1; - $self->payinfo($payinfo); - $self->paycvv(''); - - } elsif ( $self->payby eq 'BILL' ) { + $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum; - $error = $self->ut_textn('payinfo'); - return "Illegal P.O. number: ". $self->payinfo if $error; - $self->paycvv(''); + warn "$me check AFTER: \n". $self->_dump + if $DEBUG > 2; - } elsif ( $self->payby eq 'COMP' ) { + $self->SUPER::check; +} - my $curuser = $FS::CurrentUser::CurrentUser; - if ( ! $self->custnum - && ! $curuser->access_right('Complimentary customer') - ) - { - return "You are not permitted to create complimentary accounts." - } +sub check_payinfo_cardtype { + my $self = shift; - $error = $self->ut_textn('payinfo'); - return "Illegal comp account issuer: ". $self->payinfo if $error; - $self->paycvv(''); + return '' unless $self->payby =~ /^(CARD|DCRD)$/; - } elsif ( $self->payby eq 'PREPAY' ) { + my $payinfo = $self->payinfo; + $payinfo =~ s/\D//g; - my $payinfo = $self->payinfo; - $payinfo =~ s/\W//g; #anything else would just confuse things - $self->payinfo($payinfo); - $error = $self->ut_alpha('payinfo'); - return "Illegal prepayment identifier: ". $self->payinfo if $error; - return "Unknown prepayment identifier" - unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } ); - $self->paycvv(''); + return '' if $payinfo =~ /^99\d{14}$/; #token - } + my %bop_card_types = map { $_=>1 } values %{ card_types() }; + my $cardtype = cardtype($payinfo); - if ( $self->paydate eq '' || $self->paydate eq '-' ) { - return "Expiration date required" - # shouldn't payinfo_check do this? - unless ! $self->payby - || $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD|PPAL)$/; - $self->paydate(''); - } else { - my( $m, $y ); - if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) { - ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" ); - } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { - ( $m, $y ) = ( $2, "19$1" ); - } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) { - ( $m, $y ) = ( $3, "20$2" ); - } else { - return "Illegal expiration date: ". $self->paydate; - } - $m = sprintf('%02d',$m); - $self->paydate("$y-$m-01"); - my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900; - return gettext('expired_card') - if !$import - && !$ignore_expired_card - && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) ); - } + return "$cardtype not accepted" unless $bop_card_types{$cardtype}; - if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ && - ( ! $conf->exists('require_cardname') - || $self->payby !~ /^(CARD|DCRD)$/ ) - ) { - $self->payname( $self->first. " ". $self->getfield('last') ); - } else { + ''; - if ( $self->payby =~ /^(CHEK|DCHK)$/ ) { - $self->payname =~ /^([\w \,\.\-\']*)$/ - or return gettext('illegal_name'). " payname: ". $self->payname; - $self->payname($1); - } else { - $self->payname =~ /^([\w \,\.\-\'\&]*)$/ - or return gettext('illegal_name'). " payname: ". $self->payname; - $self->payname($1); - } +} - } +=item replace_check - ### end of stuff moved to cust_payby +Additional checks for replace only. - return "Please select an invoicing locale" - if ! $self->locale - && ! $self->custnum - && $conf->exists('cust_main-require_locale'); +=cut - foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) { - $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag(); - $self->$flag($1); +sub replace_check { + my ($new,$old) = @_; + #preserve old value if global config is set + if ($old && $conf->exists('invoice-ship_address')) { + $new->invoice_ship_address($old->invoice_ship_address); } - - $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum; - - warn "$me check AFTER: \n". $self->_dump - if $DEBUG > 2; - - $self->SUPER::check; + return ''; } =item addr_fields @@ -2093,6 +1928,7 @@ Returns a list of fields which have ship_ duplicates. sub addr_fields { qw( last first company + locationname address1 address2 city county state zip country latitude longitude daytime night fax mobile @@ -2131,48 +1967,92 @@ Returns all locations (see L) for this customer. sub cust_location { my $self = shift; - qsearch('cust_location', { 'custnum' => $self->custnum, - 'prospectnum' => '' } ); + qsearch({ + 'table' => 'cust_location', + 'hashref' => { 'custnum' => $self->custnum, + 'prospectnum' => '', + }, + 'order_by' => 'ORDER BY country, LOWER(state), LOWER(city), LOWER(county), LOWER(address1), LOWER(address2)', + }); } =item cust_contact -Returns all contacts (see L) for this customer. +Returns all contact associations (see L) for this customer. =cut -#already used :/ sub contact { sub cust_contact { my $self = shift; - qsearch('contact', { 'custnum' => $self->custnum } ); + qsearch('cust_contact', { 'custnum' => $self->custnum } ); } -=item cust_payby +=item cust_payby PAYBY Returns all payment methods (see L) for this customer. +If one or more PAYBY are specified, returns only payment methods for specified PAYBY. +Does not validate PAYBY. + =cut sub cust_payby { my $self = shift; - qsearch({ + my @payby = @_; + my $search = { 'table' => 'cust_payby', 'hashref' => { 'custnum' => $self->custnum }, - 'order_by' => 'ORDER BY weight ASC', - }); + 'order_by' => "ORDER BY payby IN ('CARD','CHEK') DESC, weight ASC", + }; + $search->{'extra_sql'} = ' AND payby IN ( '. + join(',', map dbh->quote($_), @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({ + 'table' => 'cust_payby', + 'hashref' => { 'custnum' => $self->custnum, }, + 'extra_sql' => " AND payby IN ( 'CARD', 'CHEK' ) ", + 'order_by' => 'LIMIT 1', + }) ); + } =item unsuspend Unsuspends all unflagged suspended packages (see L -and L) for this customer. Always returns a list: an empty list -on success or a list of errors. +and L) for this customer, except those on hold. + +Returns a list: an empty list on success or a list of errors. =cut sub unsuspend { my $self = shift; - grep { $_->unsuspend } $self->suspended_pkgs; + grep { ($_->get('setup')) && $_->unsuspend } $self->suspended_pkgs; +} + +=item release_hold + +Unsuspends all suspended packages in the on-hold state (those without setup +dates) for this customer. + +=cut + +sub release_hold { + my $self = shift; + grep { (!$_->setup) && $_->unsuspend } $self->suspended_pkgs; } =item suspend @@ -2256,67 +2136,179 @@ sub suspend_unless_pkgpart { =item cancel [ OPTION => VALUE ... ] Cancels all uncancelled packages (see L) for this customer. +The cancellation time will be now. -Available options are: +=back + +Always returns a list: an empty list on success or a list of errors. + +=cut + +sub cancel { + my $self = shift; + my %opt = @_; + warn "$me cancel called on customer ". $self->custnum. " with options ". + join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" + if $DEBUG; + my @pkgs = $self->ncancelled_pkgs; + + $self->cancel_pkgs( %opt, 'cust_pkg' => \@pkgs ); +} + +=item cancel_pkgs OPTIONS + +Cancels a specified list of packages. OPTIONS can include: =over 4 +=item cust_pkg - an arrayref of the packages. Required. + +=item time - the cancellation time, used to calculate final bills and +unused-time credits if any. Will be passed through to the bill() and +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 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, reason - Text of the new reason. +=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) +reason - Text of the new reason. + +=item cust_pkg_reason - can be an arrayref of L objects +for the individual packages, parallel to the C argument. The +reason and reason_otaker arguments will be taken from those objects. =item ban - can be set true to ban this customer's credit card or ACH information, if present. =item nobill - can be set true to skip billing if it might otherwise be done. -=back - -Always returns a list: an empty list on success or a list of errors. - =cut -# nb that dates are not specified as valid options to this method - -sub cancel { +sub cancel_pkgs { my( $self, %opt ) = @_; - warn "$me cancel called on customer ". $self->custnum. " with options ". - join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n" - if $DEBUG; + # we're going to cancel services, which is not reversible + die "cancel_pkgs cannot be run inside a transaction" + if $FS::UID::AutoCommit == 0; + + local $FS::UID::AutoCommit = 0; return ( 'access denied' ) unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer'); - if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) { + if ( $opt{'ban'} ) { + + foreach my $cust_payby ( $self->cust_payby ) { - #should try decryption (we might have the private key) - # and if not maybe queue a job for the server that does? - return ( "Can't (yet) ban encrypted credit cards" ) - if $self->is_encrypted($self->payinfo); + #well, if they didn't get decrypted on search, then we don't have to + # try again... queue a job for the server that does have decryption + # capability if we're in a paranoid multi-server implementation? + return ( "Can't (yet) ban encrypted credit cards" ) + if $cust_payby->is_encrypted($cust_payby->payinfo); - my $ban = new FS::banned_pay $self->_new_banned_pay_hashref; - my $error = $ban->insert; - return ( $error ) if $error; + my $ban = new FS::banned_pay $cust_payby->_new_banned_pay_hashref; + my $error = $ban->insert; + if ($error) { + dbh->rollback; + return ( $error ); + } + + } } - my @pkgs = $self->ncancelled_pkgs; + my @pkgs = @{ delete $opt{'cust_pkg'} }; + my $cancel_time = $opt{'time'} || time; + # bill all packages first, so we don't lose usage, service counts for + # bulk billing, etc. if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) { $opt{nobill} = 1; - my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 ); - warn "Error billing during cancel, custnum ". $self->custnum. ": $error" - if $error; + my $error = $self->bill( 'pkg_list' => [ @pkgs ], + 'cancel' => 1, + 'time' => $cancel_time ); + if ($error) { + warn "Error billing during cancel, custnum ". $self->custnum. ": $error"; + dbh->rollback; + return ( "Error billing during cancellation: $error" ); + } + } + dbh->commit; + + my @errors; + # try to cancel each service, the same way we would for individual packages, + # but in cancel weight order. + my @cust_svc = map { $_->cust_svc } @pkgs; + my @sorted_cust_svc = + map { $_->[0] } + sort { $a->[1] <=> $b->[1] } + map { [ $_, $_->svc_x ? $_->svc_x->table_info->{'cancel_weight'} : -1 ]; } @cust_svc + ; + warn "$me removing ".scalar(@sorted_cust_svc)." service(s) for customer ". + $self->custnum."\n" + if $DEBUG; + foreach my $cust_svc (@sorted_cust_svc) { + 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; + push @errors, $error; + } else { + dbh->commit; + } + } + if (@errors) { + return @errors; } - warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/". - scalar(@pkgs). " packages for customer ". $self->custnum. "\n" + warn "$me cancelling ". scalar(@pkgs) ." package(s) for customer ". + $self->custnum. "\n" if $DEBUG; - grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs; + my @cprs; + if ($opt{'cust_pkg_reason'}) { + @cprs = @{ delete $opt{'cust_pkg_reason'} }; + } + my $null_reason; + foreach (@pkgs) { + my %lopt = %opt; + if (@cprs) { + my $cpr = shift @cprs; + if ( $cpr ) { + $lopt{'reason'} = $cpr->reasonnum; + $lopt{'reason_otaker'} = $cpr->otaker; + } else { + warn "no reason found when canceling package ".$_->pkgnum."\n"; + # we're not actually required to pass a reason to cust_pkg::cancel, + # but if we're getting to this point, something has gone awry. + $null_reason ||= FS::reason->new_or_existing( + reason => 'unknown reason', + type => 'Cancel Reason', + class => 'C', + ); + $lopt{'reason'} = $null_reason->reasonnum; + $lopt{'reason_otaker'} = $FS::CurrentUser::CurrentUser->username; + } + } + my $error = $_->cancel(%lopt); + if ( $error ) { + dbh->rollback; + push @errors, 'pkgnum '.$_->pkgnum.': '.$error; + } else { + dbh->commit; + } + } + + return @errors; } sub _banned_pay_hashref { + die 'cust_main->_banned_pay_hashref deprecated'; + my $self = shift; my %payby2ban = ( @@ -2333,13 +2325,6 @@ sub _banned_pay_hashref { }; } -sub _new_banned_pay_hashref { - my $self = shift; - my $hr = $self->_banned_pay_hashref; - $hr->{payinfo} = md5_base64($hr->{payinfo}); - $hr; -} - =item notes Returns all notes (see L) for this customer. @@ -2348,8 +2333,8 @@ Returns all notes (see L) for this customer. sub notes { my($self,$orderby_classnum) = (shift,shift); - my $orderby = "_DATE DESC"; - $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum; + my $orderby = "sticky DESC, _date DESC"; + $orderby = "classnum ASC, $orderby" if $orderby_classnum; qsearch( 'cust_main_note', { 'custnum' => $self->custnum }, '', @@ -2425,6 +2410,36 @@ sub classname { : ''; } +=item tax_status + +Returns the external tax status, as an FS::tax_status object, or the empty +string if there is no tax status. + +=cut + +sub tax_status { + my $self = shift; + if ( $self->taxstatusnum ) { + qsearchs('tax_status', { 'taxstatusnum' => $self->taxstatusnum } ); + } else { + return ''; + } +} + +=item taxstatus + +Returns the tax status code if there is one. + +=cut + +sub taxstatus { + my $self = shift; + my $tax_status = $self->tax_status; + $tax_status + ? $tax_status->taxstatus + : ''; +} + =item BILLING METHODS Documentation on billing methods has been moved to @@ -2441,9 +2456,12 @@ Removes the I field from the database directly. If there is an error, returns the error, otherwise returns false. +DEPRECATED. Use L instead. + =cut sub remove_cvv { + die 'cust_main->remove_cvv deprecated'; my $self = shift; my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?") or return dbh->errstr; @@ -2453,165 +2471,12 @@ sub remove_cvv { ''; } -=item batch_card OPTION => VALUE... +=item total_owed -Adds a payment for this invoice to the pending credit card batch (see -L), or, if the B option is set to a true value, -runs the payment using a realtime gateway. +Returns the total owed for this customer on all invoices +(see L). -Options may include: - -B: the amount to be paid; defaults to the customer's balance minus -any payments in transit. - -B: the payment method; defaults to cust_main.payby - -B: runs this as a realtime payment instead of adding it to a -batch. Deprecated. - -B: sets cust_pay_batch.invnum. - -B, B, B, B, B, B: sets -the billing address for the payment; defaults to the customer's billing -location. - -B, B, B: sets the payment account, expiration -date, and name; defaults to those fields in cust_main. - -=cut - -sub batch_card { - my ($self, %options) = @_; - - my $amount; - if (exists($options{amount})) { - $amount = $options{amount}; - }else{ - $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments); - } - return '' unless $amount > 0; - - my $invnum = delete $options{invnum}; - my $payby = $options{payby} || $self->payby; #still dubious - - if ($options{'realtime'}) { - return $self->realtime_bop( FS::payby->payby2bop($self->payby), - $amount, - %options, - ); - } - - my $oldAutoCommit = $FS::UID::AutoCommit; - local $FS::UID::AutoCommit = 0; - my $dbh = dbh; - - #this needs to handle mysql as well as Pg, like svc_acct.pm - #(make it into a common function if folks need to do batching with mysql) - $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE") - or return "Cannot lock pay_batch: " . $dbh->errstr; - - my %pay_batch = ( - 'status' => 'O', - 'payby' => FS::payby->payby2payment($payby), - ); - $pay_batch{agentnum} = $self->agentnum if $conf->exists('batch-spoolagent'); - - my $pay_batch = qsearchs( 'pay_batch', \%pay_batch ); - - unless ( $pay_batch ) { - $pay_batch = new FS::pay_batch \%pay_batch; - my $error = $pay_batch->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - die "error creating new batch: $error\n"; - } - } - - my $old_cust_pay_batch = qsearchs('cust_pay_batch', { - 'batchnum' => $pay_batch->batchnum, - 'custnum' => $self->custnum, - } ); - - foreach (qw( address1 address2 city state zip country latitude longitude - payby payinfo paydate payname )) - { - $options{$_} = '' unless exists($options{$_}); - } - - my $loc = $self->bill_location; - - my $cust_pay_batch = new FS::cust_pay_batch ( { - 'batchnum' => $pay_batch->batchnum, - 'invnum' => $invnum || 0, # is there a better value? - # this field should be - # removed... - # cust_bill_pay_batch now - 'custnum' => $self->custnum, - 'last' => $self->getfield('last'), - 'first' => $self->getfield('first'), - 'address1' => $options{address1} || $loc->address1, - 'address2' => $options{address2} || $loc->address2, - 'city' => $options{city} || $loc->city, - 'state' => $options{state} || $loc->state, - 'zip' => $options{zip} || $loc->zip, - 'country' => $options{country} || $loc->country, - 'payby' => $options{payby} || $self->payby, - 'payinfo' => $options{payinfo} || $self->payinfo, - 'exp' => $options{paydate} || $self->paydate, - 'payname' => $options{payname} || $self->payname, - 'amount' => $amount, # consolidating - } ); - - $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum) - if $old_cust_pay_batch; - - my $error; - if ($old_cust_pay_batch) { - $error = $cust_pay_batch->replace($old_cust_pay_batch) - } else { - $error = $cust_pay_batch->insert; - } - - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - die $error; - } - - my $unapplied = $self->total_unapplied_credits - + $self->total_unapplied_payments - + $self->in_transit_payments; - foreach my $cust_bill ($self->open_cust_bill) { - #$dbh->commit or die $dbh->errstr if $oldAutoCommit; - my $cust_bill_pay_batch = new FS::cust_bill_pay_batch { - 'invnum' => $cust_bill->invnum, - 'paybatchnum' => $cust_pay_batch->paybatchnum, - 'amount' => $cust_bill->owed, - '_date' => time, - }; - if ($unapplied >= $cust_bill_pay_batch->amount){ - $unapplied -= $cust_bill_pay_batch->amount; - next; - }else{ - $cust_bill_pay_batch->amount(sprintf ( "%.2f", - $cust_bill_pay_batch->amount - $unapplied )); $unapplied = 0; - } - $error = $cust_bill_pay_batch->insert; - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - die $error; - } - } - - $dbh->commit or die $dbh->errstr if $oldAutoCommit; - ''; -} - -=item total_owed - -Returns the total owed for this customer on all invoices -(see L). - -=cut +=cut sub total_owed { my $self = shift; @@ -2838,7 +2703,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. -Available options are: +Accepts the same options as L: =over 4 @@ -2846,6 +2711,12 @@ Available options are: 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 cutoff + +An absolute cutoff time. Payments, credits, and refunds I after this +time will be ignored. Note that START_TIME and END_TIME only limit the date +range for invoices and I payments, credits, and refunds. + =back =cut @@ -2877,29 +2748,6 @@ sub balance_pkgnum { ); } -=item in_transit_payments - -Returns the total of requests for payments for this customer pending in -batches in transit to the bank. See L and L - -=cut - -sub in_transit_payments { - my $self = shift; - my $in_transit_payments = 0; - foreach my $pay_batch ( qsearch('pay_batch', { - 'status' => 'I', - } ) ) { - foreach my $cust_pay_batch ( qsearch('cust_pay_batch', { - 'batchnum' => $pay_batch->batchnum, - 'custnum' => $self->custnum, - } ) ) { - $in_transit_payments += $cust_pay_batch->amount; - } - } - sprintf( "%.2f", $in_transit_payments ); -} - =item payment_info Returns a hash of useful information for making a payment. @@ -2940,6 +2788,7 @@ For electronic check transactions: =cut +#XXX i need to be updated for 4.x+ sub payment_info { my $self = shift; @@ -2981,82 +2830,36 @@ 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 -expiration date. For CARD/DCRD customers this is the end of the month; -for others (COMP is the only other payby that uses paydate) it's the start. -Returns 0 if the paydate is empty or set to the far future. +Returns the next payment expiration date for this customer. If they have no +payment methods that will expire, returns 0. =cut sub paydate_epoch { my $self = shift; - my ($month, $year) = $self->paydate_monthyear; - return 0 if !$year or $year >= 2037; - if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) { - $month++; - if ( $month == 13 ) { - $month = 1; - $year++; - } - return timelocal(0,0,0,1,$month-1,$year) - 1; - } - else { - return timelocal(0,0,0,1,$month-1,$year); - } + # filter out the ones that individually return 0, but then return 0 if + # there are no results + my @epochs = grep { $_ > 0 } map { $_->paydate_epoch } $self->cust_payby; + min( @epochs ) || 0; } =item paydate_epoch_sql -Class method. Returns an SQL expression to obtain the payment expiration date -as a number of seconds. +Returns an SQL expression to get the next payment expiration date for a +customer. Returns 2143260000 (2037-12-01) if there are no payment expiration +dates, so that it's safe to test for "will it expire before date X" for any +date up to then. =cut -# Special expiration date behavior for non-CARD/DCRD customers has been -# carefully preserved. Do we really use that? sub paydate_epoch_sql { my $class = shift; - my $table = shift || 'cust_main'; - my ($case1, $case2); - if ( driver_name eq 'Pg' ) { - $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1"; - $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )"; - } - elsif ( lc(driver_name) eq 'mysql' ) { - $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1"; - $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )"; - } - else { return '' } - return "CASE WHEN $table.payby IN('CARD','DCRD') - THEN ($case1) - ELSE ($case2) - END" + my $paydate = FS::cust_payby->paydate_epoch_sql; + "(SELECT COALESCE(MIN($paydate), 2143260000) FROM cust_payby WHERE cust_payby.custnum = cust_main.custnum)"; } -=item tax_exemption TAXNAME - -=cut - sub tax_exemption { my( $self, $taxname ) = @_; @@ -3068,18 +2871,10 @@ sub tax_exemption { =item cust_main_exemption -=item invoicing_list [ ARRAYREF ] - -If an arguement is given, sets these email addresses as invoice recipients -(see L). 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). +=item invoicing_list -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 @@ -3087,47 +2882,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 @@ -3165,18 +2926,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. @@ -3206,10 +2955,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 @@ -3223,7 +2973,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 @@ -3240,6 +2999,73 @@ sub invoicing_list_emailonly_scalar { join(', ', $self->invoicing_list_emailonly); } +=item contact_list [ CLASSNUM, ... ] + +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. + +If no arguments are given, returns all contacts for the customer. + +=cut + +sub contact_list { + my $self = shift; + my $search = { + table => 'contact', + select => 'contact.*, cust_contact.invoice_dest', + addl_from => ' JOIN cust_contact USING (contactnum)', + extra_sql => ' WHERE cust_contact.custnum = '.$self->custnum, + }; + + my @orwhere; + my @classnums; + foreach (@_) { + if ( $_ eq 'invoice' ) { + push @orwhere, 'cust_contact.invoice_dest = \'Y\''; + } elsif ( $_ eq '0' ) { + push @orwhere, 'cust_contact.classnum is null'; + } elsif ( /^\d+$/ ) { + push @classnums, $_; + } else { + die "bad classnum argument '$_'"; + } + } + + if (@classnums) { + push @orwhere, 'cust_contact.classnum IN ('.join(',', @classnums).')'; + } + if (@orwhere) { + $search->{extra_sql} .= ' AND (' . + join(' OR ', map "( $_ )", @orwhere) . + ')'; + } + + qsearch($search); +} + +=item contact_list_email [ CLASSNUM, ... ] + +Same as L, but returns email destinations instead of contact +objects. + +=cut + +sub contact_list_email { + my $self = shift; + my @contacts = $self->contact_list(@_); + my @emails; + foreach my $contact (@contacts) { + foreach my $contact_email ($contact->contact_email) { + push @emails, + $contact->firstlast . ' <' . $contact_email->emailaddress . '>'; + } + } + @emails; +} + =item referral_custnum_cust_main Returns the customer who referred this customer (or the empty string, if @@ -3423,18 +3249,21 @@ Old-style: =cut +#super false laziness w/quotation::charge sub charge { my $self = shift; - my ( $amount, $quantity, $start_date, $classnum ); + my ( $amount, $setup_cost, $quantity, $start_date, $classnum ); my ( $pkg, $comment, $additional ); my ( $setuptax, $taxclass ); #internal taxes my ( $taxproduct, $override ); #vendor (CCH) taxes my $no_auto = ''; + my $separate_bill = ''; my $cust_pkg_ref = ''; my ( $bill_now, $invoice_terms ) = ( 0, '' ); my $locationnum; if ( ref( $_[0] ) ) { $amount = $_[0]->{amount}; + $setup_cost = $_[0]->{setup_cost}; $quantity = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1; $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : ''; $no_auto = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : ''; @@ -3451,8 +3280,10 @@ sub charge { $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : ''; $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : ''; $locationnum = $_[0]->{locationnum} || $self->ship_locationnum; - } else { + $separate_bill = $_[0]->{separate_bill} || ''; + } else { # yuck $amount = shift; + $setup_cost = ''; $quantity = 1; $start_date = ''; $pkg = @_ ? shift : 'One-time charge'; @@ -3483,6 +3314,7 @@ sub charge { 'setuptax' => $setuptax, 'taxclass' => $taxclass, 'taxproductnum' => $taxproduct, + 'setup_cost' => $setup_cost, } ); my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) } @@ -3517,6 +3349,7 @@ sub charge { 'quantity' => $quantity, 'start_date' => $start_date, 'no_auto' => $no_auto, + 'separate_bill' => $separate_bill, 'locationnum'=> $locationnum, } ); @@ -3728,9 +3561,12 @@ Returns all the credits (see L) for this customer. sub cust_credit { my $self = shift; - map { $_ } #return $self->num_cust_credit unless wantarray; - sort { $a->_date <=> $b->_date } - qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) + + #return $self->num_cust_credit unless wantarray; + + map { $_ } #behavior of sort undefined in scalar context + sort { $a->_date <=> $b->_date } + qsearch( 'cust_credit', { 'custnum' => $self->custnum } ) } =item cust_credit_pkgnum @@ -3771,9 +3607,17 @@ Returns all the payments (see L) for this customer. sub cust_pay { my $self = shift; - return $self->num_cust_pay unless wantarray; - sort { $a->_date <=> $b->_date } - qsearch( 'cust_pay', { 'custnum' => $self->custnum } ) + my $opt = ref($_[0]) ? shift : { @_ }; + + return $self->num_cust_pay unless wantarray || keys %$opt; + + $opt->{'table'} = 'cust_pay'; + $opt->{'hashref'}{'custnum'} = $self->custnum; + + map { $_ } #behavior of sort undefined in scalar context + sort { $a->_date <=> $b->_date } + qsearch($opt); + } =item num_cust_pay @@ -3791,6 +3635,22 @@ sub num_cust_pay { $sth->fetchrow_arrayref->[0]; } +=item unapplied_cust_pay + +Returns all the unapplied payments (see L) for this customer. + +=cut + +sub unapplied_cust_pay { + my $self = shift; + + $self->cust_pay( + 'extra_sql' => ' AND '. FS::cust_pay->unapplied_sql. ' > 0', + #@_ + ); + +} + =item cust_pay_pkgnum Returns all the payments (see L) for this customer's specific @@ -3821,31 +3681,6 @@ sub cust_pay_void { qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } ) } -=item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ] - -Returns all batched payments (see L) for this customer. - -Optionally, a list or hashref of additional arguments to the qsearch call can -be passed. - -=cut - -sub cust_pay_batch { - my $self = shift; - my $opt = ref($_[0]) ? shift : { @_ }; - - #return $self->num_cust_statement unless wantarray || keys %$opt; - - $opt->{'table'} = 'cust_pay_batch'; - $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway... - $opt->{'hashref'}{'custnum'} = $self->custnum; - $opt->{'order_by'} ||= 'ORDER BY paybatchnum ASC'; - - map { $_ } #behavior of sort undefined in scalar context - sort { $a->paybatchnum <=> $b->paybatchnum } - qsearch($opt); -} - =item cust_pay_pending Returns all pending payments (see L) for this customer @@ -3941,34 +3776,16 @@ cust_main-default_agent_custid is set and it has a value, custnum otherwise. sub display_custnum { my $self = shift; + return $self->agent_custid + if $default_agent_custid && $self->agent_custid; + my $prefix = $conf->config('cust_main-custnum-display_prefix', $self->agentnum) || ''; - if ( my $special = $conf->config('cust_main-custnum-display_special') ) { - if ( $special eq 'CoStAg' ) { - $prefix = uc( join('', - $self->country, - ($self->state =~ /^(..)/), - $prefix || ($self->agent->agent =~ /^(..)/) - ) ); - } - elsif ( $special eq 'CoStCl' ) { - $prefix = uc( join('', - $self->country, - ($self->state =~ /^(..)/), - ($self->classnum ? $self->cust_class->classname =~ /^(..)/ : '__') - ) ); - } - # add any others here if needed - } - my $length = $conf->config('cust_main-custnum-display_length'); - if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){ - return $self->agent_custid; - } elsif ( $prefix ) { - $length = 8 if !defined($length); + if ( $prefix ) { return $prefix . - sprintf('%0'.$length.'d', $self->custnum) - } elsif ( $length ) { - return sprintf('%0'.$length.'d', $self->custnum); + sprintf('%0'.($custnum_display_length||8).'d', $self->custnum) + } elsif ( $custnum_display_length ) { + return sprintf('%0'.$custnum_display_length.'d', $self->custnum); } else { return $self->custnum; } @@ -4002,9 +3819,11 @@ sub service_contact { my $classnum = $self->scalar_sql( 'SELECT classnum FROM contact_class WHERE classname = \'Service\'' ) || 0; #if it's zero, qsearchs will return nothing - $self->{service_contact} = qsearchs('contact', { - 'classnum' => $classnum, 'custnum' => $self->custnum - }) || undef; + my $cust_contact = qsearchs('cust_contact', { + 'classnum' => $classnum, + 'custnum' => $self->custnum, + }); + $self->{service_contact} = $cust_contact->contact if $cust_contact; } $self->{service_contact}; } @@ -4095,26 +3914,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 ] @@ -4135,166 +3942,740 @@ sub county_state_country { $cust_location->county_state_country; } -=item geocode DATA_VENDOR +=item geocode DATA_VENDOR + +Returns a value for the customer location as encoded by DATA_VENDOR. +Currently this only makes sense for "CCH" as DATA_VENDOR. + +=cut + +=item cust_status + +=item status + +Returns a status string for this customer, currently: + +=over 4 + +=item prospect + +No packages have ever been ordered. Displayed as "No packages". + +=item ordered + +Recurring packages all are new (not yet billed). + +=item active + +One or more recurring packages is active. + +=item inactive + +No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled). + +=item suspended + +All non-cancelled recurring packages are suspended. + +=item cancelled + +All recurring packages are cancelled. + +=back + +Behavior of inactive vs. cancelled edge cases can be adjusted with the +cust_main-status_module configuration option. + +=cut + +sub status { shift->cust_status(@_); } + +sub cust_status { + my $self = shift; + return $self->hashref->{cust_status} if $self->hashref->{cust_status}; + for my $status ( FS::cust_main->statuses() ) { + my $method = $status.'_sql'; + my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g; + my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr; + $sth->execute( ($self->custnum) x $numnum ) + or die "Error executing 'SELECT $sql': ". $sth->errstr; + if ( $sth->fetchrow_arrayref->[0] ) { + $self->hashref->{cust_status} = $status; + return $status; + } + } +} + +=item is_status_delay_cancel + +Returns true if customer status is 'suspended' +and all suspended cust_pkg return true for +cust_pkg->is_status_delay_cancel. + +This is not a real status, this only meant for hacking display +values, because otherwise treating the customer as suspended is +really the whole point of the delay_cancel option. + +=cut + +sub is_status_delay_cancel { + my ($self) = @_; + return 0 unless $self->status eq 'suspended'; + foreach my $cust_pkg ($self->ncancelled_pkgs) { + return 0 unless $cust_pkg->is_status_delay_cancel; + } + return 1; +} + +=item ucfirst_cust_status + +=item ucfirst_status + +Deprecated, use the cust_status_label method instead. + +Returns the status with the first character capitalized. + +=cut + +sub ucfirst_status { + carp "ucfirst_status deprecated, use cust_status_label" unless $ucfirst_nowarn; + local($ucfirst_nowarn) = 1; + shift->ucfirst_cust_status(@_); +} + +sub ucfirst_cust_status { + carp "ucfirst_cust_status deprecated, use cust_status_label" unless $ucfirst_nowarn; + my $self = shift; + ucfirst($self->cust_status); +} + +=item cust_status_label + +=item status_label + +Returns the display label for this status. + +=cut + +sub status_label { shift->cust_status_label(@_); } + +sub cust_status_label { + my $self = shift; + __PACKAGE__->statuslabels->{$self->cust_status}; +} + +=item statuscolor + +Returns a hex triplet color string for this customer's status. + +=cut + +sub statuscolor { shift->cust_statuscolor(@_); } + +sub cust_statuscolor { + my $self = shift; + __PACKAGE__->statuscolors->{$self->cust_status}; +} + +=item tickets [ STATUS ] + +Returns an array of hashes representing the customer's RT tickets. + +An optional status (or arrayref or hashref of statuses) may be specified. + +=cut + +sub tickets { + my $self = shift; + my $status = ( @_ && $_[0] ) ? shift : ''; + + my $num = $conf->config('cust_main-max_tickets') || 10; + my @tickets = (); + + if ( $conf->config('ticket_system') ) { + unless ( $conf->config('ticket_system-custom_priority_field') ) { + + @tickets = @{ FS::TicketSystem->customer_tickets( $self->custnum, + $num, + undef, + $status, + ) + }; + + } else { + + foreach my $priority ( + $conf->config('ticket_system-custom_priority_field-values'), '' + ) { + last if scalar(@tickets) >= $num; + push @tickets, + @{ FS::TicketSystem->customer_tickets( $self->custnum, + $num - scalar(@tickets), + $priority, + $status, + ) + }; + } + } + } + (@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; + my %packages = map { $_ => 1 } $conf->config('support_packages'); + + grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' } + grep { $_->part_svc->svcdb eq 'svc_acct' } + map { $_->cust_svc } + grep { exists $packages{ $_->pkgpart } } + $self->ncancelled_pkgs; + +} + +# Return a list of latitude/longitude for one of the services (if any) +sub service_coordinates { + my $self = shift; + + my @svc_X = + grep { $_->latitude && $_->longitude } + map { $_->svc_x } + map { $_->cust_svc } + $self->ncancelled_pkgs; + + scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : () +} + +=item masked FIELD + +Returns a masked version of the named field + +=cut + +sub masked { +my ($self,$field) = @_; + +# Show last four + +'x'x(length($self->getfield($field))-4). + substr($self->getfield($field), (length($self->getfield($field))-4)); + +} + +=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 - one of 'Line item', 'Invoice', 'Payment', 'Credit', 'Refund' or 'Previous' + +I - value of _date field, unix timestamp + +I - user-friendly date + +I - user-friendly description of item + +I - 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 - includes money char + +I - customer balance, chronologically as of this item + +I - includes money char + +I - amount charged for cust_bill (Invoice or Line item) records, undef for other types + +I - amount paid for cust_pay records, undef for other types + +I - amount credited for cust_credit records, undef for other types. +Literally the 'amount' field from cust_credit, renamed here to avoid confusion. + +I - 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 - if true, returns charges ('Line item') rather than invoices + +I - 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 - unix timestamp, only include records before + +I - order from newest to oldest (default is oldest to newest) + +I - 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, or the +specified weight. Existing payment methods have their weight incremented as +appropriate. + +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: + +=over 4 + +=item payment_payby + +either CARD or CHEK + +=item auto + +save as an automatic payment type (CARD/CHEK if true, DCRD/DCHK if false) + +=item weight + +optional, set higher than 1 for secondary, etc. + +=item payinfo -Returns a value for the customer location as encoded by DATA_VENDOR. -Currently this only makes sense for "CCH" as DATA_VENDOR. +required -=cut +=item paymask -=item cust_status +optional, but should be specified for anything that might be tokenized, will be preserved when replacing -=item status +=item payname -Returns a status string for this customer, currently: +required -=over 4 +=item payip -=item prospect - No packages have ever been ordered +optional, will be preserved when replacing -=item ordered - Recurring packages all are new (not yet billed). +=item paydate -=item active - One or more recurring packages is active +CARD only, required -=item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled) +=item bill_location -=item suspended - All non-cancelled recurring packages are suspended +CARD only, required, FS::cust_location object -=item cancelled - All recurring packages are cancelled +=item paystart_month -=back +CARD only, optional, will be preserved when replacing -Behavior of inactive vs. cancelled edge cases can be adjusted with the -cust_main-status_module configuration option. +=item paystart_year -=cut +CARD only, optional, will be preserved when replacing -sub status { shift->cust_status(@_); } +=item payissue -sub cust_status { - my $self = shift; - for my $status ( FS::cust_main->statuses() ) { - my $method = $status.'_sql'; - my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g; - my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr; - $sth->execute( ($self->custnum) x $numnum ) - or die "Error executing 'SELECT $sql': ". $sth->errstr; - return $status if $sth->fetchrow_arrayref->[0]; - } -} +CARD only, optional, will be preserved when replacing -=item ucfirst_cust_status +=item paycvv -=item ucfirst_status +CARD only, only used if conf cvv-save is set appropriately -Returns the status with the first character capitalized. +=item paytype -=cut +CHEK only -sub ucfirst_status { shift->ucfirst_cust_status(@_); } +=item paystate -sub ucfirst_cust_status { - my $self = shift; - ucfirst($self->cust_status); -} +CHEK only -=item statuscolor +=item saved_cust_payby -Returns a hex triplet color string for this customer's status. +scalar reference, for returning saved object + +=back =cut -sub statuscolor { shift->cust_statuscolor(@_); } +#The code for this option is in place, but it's not currently used +# +# =item replace +# +# existing cust_payby object to be replaced (must match custnum) -sub cust_statuscolor { +# 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; - __PACKAGE__->statuscolors->{$self->cust_status}; -} + my %opt = @_; -=item tickets [ STATUS ] + 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 ); -Returns an array of hashes representing the customer's RT tickets. + my $payby = $opt{'payment_payby'}; + return "Bad payby" unless grep(/^$payby$/,('CARD','CHEK')); -An optional status (or arrayref or hashref of statuses) may be specified. + # don't allow turning off auto when replacing + $opt{'auto'} ||= 1 if $old and $old->payby !~ /^D/; -=cut + my @check_existing; # payby relevant to this payment_payby -sub tickets { - my $self = shift; - my $status = ( @_ && $_[0] ) ? shift : ''; + # 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 ); + } - my $num = $conf->config('cust_main-max_tickets') || 10; - my @tickets = (); + $new->set( 'weight' => $opt{'auto'} ? $opt{'weight'} : '' ); - if ( $conf->config('ticket_system') ) { - unless ( $conf->config('ticket_system-custom_priority_field') ) { + # 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 - @tickets = @{ FS::TicketSystem->customer_tickets( $self->custnum, - $num, - undef, - $status, - ) - }; + my $conf = new FS::Conf; - } else { + # 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')) + || $conf->exists('business-onlinepayment-verification') + ) + ) { + $new->set( 'paycvv' => $opt{'paycvv'} ); + } else { + $new->set( 'paycvv' => ''); + } - foreach my $priority ( - $conf->config('ticket_system-custom_priority_field-values'), '' - ) { - last if scalar(@tickets) >= $num; - push @tickets, - @{ FS::TicketSystem->customer_tickets( $self->custnum, - $num - scalar(@tickets), - $priority, - $status, - ) - }; + 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} ); } } - (@tickets); -} -# Return services representing svc_accts in customer support packages -sub support_services { - my $self = shift; - my %packages = map { $_ => 1 } $conf->config('support_packages'); + # 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; + } #PAYBYLOOP + } - grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' } - grep { $_->part_svc->svcdb eq 'svc_acct' } - map { $_->cust_svc } - grep { exists $packages{ $_->pkgpart } } - $self->ncancelled_pkgs; + 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; + } + } -# Return a list of latitude/longitude for one of the services (if any) -sub service_coordinates { - my $self = shift; + # 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 ''; + } - my @svc_X = - grep { $_->latitude && $_->longitude } - map { $_->svc_x } - map { $_->cust_svc } - $self->ncancelled_pkgs; + # 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; + next if $cust_payby->weight < ($opt{'weight'} || 1); + $cust_payby->weight( $cust_payby->weight + 1 ); + 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; + } + + ${$opt{'saved_cust_payby'}} = $new + if $opt{'saved_cust_payby'}; + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; - scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : () } -=item masked FIELD +=item remove_cvv_from_cust_payby PAYINFO -Returns a masked version of the named field +Removes paycvv from associated cust_payby with matching PAYINFO. =cut -sub masked { -my ($self,$field) = @_; +sub remove_cvv_from_cust_payby { + my ($self,$payinfo) = @_; -# Show last four + my $oldAutoCommit = $FS::UID::AutoCommit; + local $FS::UID::AutoCommit = 0; + my $dbh = dbh; -'x'x(length($self->getfield($field))-4). - substr($self->getfield($field), (length($self->getfield($field))-4)); + foreach my $cust_payby ( qsearch('cust_payby',{ custnum => $self->custnum }) ) { + next unless $cust_payby->payinfo eq $payinfo; # can't qsearch on payinfo + $cust_payby->paycvv(''); + my $error = $cust_payby->replace; + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; } =back @@ -4434,15 +4815,10 @@ Returns an SQL expression identifying un-cancelled cust_main records. =cut sub uncancelled_sql { uncancel_sql(@_); } -sub uncancel_sql { " - ( 0 < ( $select_count_pkgs - AND ( cust_pkg.cancel IS NULL - OR cust_pkg.cancel = 0 - ) - ) - OR 0 = ( $select_count_pkgs ) - ) -"; } +sub uncancel_sql { + my $self = shift; + "( NOT (".$self->cancelled_sql.") )"; #sensitive to cust_main-status_module +} =item balance_sql @@ -4602,103 +4978,6 @@ sub search { =over 4 -=item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS - -Deprecated. Use event notification and message templates -(L) instead. - -Sends a templated email notification to the customer (see L). - -OPTIONS is a hash and may include - -I - the email sender (default is invoice_from) - -I - comma-separated scalar or arrayref of recipients - (default is invoicing_list) - -I - The subject line of the sent email notification - (default is "Notice from company_name") - -I - a hashref of name/value pairs which will be substituted - into the template - -The following variables are vavailable in 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->config('invoice_from', $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 Generates a templated notification to the customer (see L). @@ -4709,13 +4988,11 @@ I - 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 - 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. -I<$payby> - a description of the method of payment for the customer - # would be nice to use FS::payby::shortname -I<$payinfo> - the masked account information used to collect for this customer -I<$expdate> - the expiration of the customer payment method in seconds from epoch I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address =cut @@ -4724,11 +5001,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"; @@ -4737,27 +5019,6 @@ sub generate_letter { or die "can't compile template: Text::Template::ERROR"; my %letter_data = map { $_ => $self->$_ } $self->fields; - $letter_data{payinfo} = $self->mask_payinfo; - - #my $paydate = $self->paydate || '2037-12-31'; - my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31'; - - 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') { - $letter_data{payby} = 'credit card'; - ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++); - $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear); - $expire_time--; - }elsif ($payby eq 'COMP') { - $letter_data{payby} = 'complimentary account'; - }else{ - $letter_data{payby} = 'current method'; - } - $letter_data{expdate} = $expire_time; for (keys %{$options{extra_fields}}){ $letter_data{$_} = $options{extra_fields}->{$_}; @@ -4843,7 +5104,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; } @@ -4926,6 +5189,42 @@ sub _agent_plandata { } +sub process_o2m_qsearch { + my $self = shift; + my $table = shift; + return qsearch($table, @_) unless $table eq 'contact'; + + my $hashref = shift; + my %hash = %$hashref; + ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/ + or die 'guru meditation #4343'; + + qsearch({ 'table' => 'contact', + 'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )', + 'hashref' => \%hash, + 'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ). + " cust_contact.custnum = $custnum " + }); +} + +sub process_o2m_qsearchs { + my $self = shift; + my $table = shift; + return qsearchs($table, @_) unless $table eq 'contact'; + + my $hashref = shift; + my %hash = %$hashref; + ( my $custnum = delete $hash{'custnum'} ) =~ /^(\d+)$/ + or die 'guru meditation #2121'; + + qsearchs({ 'table' => 'contact', + 'addl_from' => 'LEFT JOIN cust_contact USING ( contactnum )', + 'hashref' => \%hash, + 'extra_sql' => ( keys %hash ? ' AND ' : ' WHERE ' ). + " cust_contact.custnum = $custnum " + }); +} + =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ] Subroutine (not a method), designed to be called from the queue. @@ -4948,9 +5247,24 @@ sub queued_bill { $cust_main->bill_and_collect( %args ); } +=item queued_collect 'custnum' => CUSTNUM [ , OPTION => VALUE ... ] + +Like queued_bill, but instead of C, just runs the +C part. This is used in batch tax calculation, where invoice +generation and collection events have to be completely separated. + +=cut + +sub queued_collect { + my (%args) = @_; + my $cust_main = FS::cust_main->by_key($args{'custnum'}); + + $cust_main->collect(%args); +} + sub process_bill_and_collect { my $job = shift; - my $param = thaw(decode_base64(shift)); + my $param = shift; my $cust_main = qsearchs( 'cust_main', { custnum => $param->{'custnum'} } ) or die "custnum '$param->{custnum}' not found!\n"; $param->{'job'} = $job; @@ -4976,9 +5290,7 @@ sub process_bill_and_collect { sub _upgrade_data { #class method my ($class, %opts) = @_; - my @statements = ( - 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL', - ); + my @statements = (); #this seems to be the only expensive one.. why does it take so long? unless ( FS::upgrade_journal->is_done('cust_main__signupdate') ) { @@ -4987,29 +5299,6 @@ sub _upgrade_data { #class method FS::upgrade_journal->set_done('cust_main__signupdate'); } - unless ( FS::upgrade_journal->is_done('cust_main__paydate') ) { - - # fix yyyy-m-dd formatted paydates - if ( driver_name =~ /^mysql/i ) { - push @statements, - "UPDATE cust_main SET paydate = CONCAT( SUBSTRING(paydate FROM 1 FOR 5), '0', SUBSTRING(paydate FROM 6) ) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'"; - } else { # the SQL standard - push @statements, - "UPDATE cust_main SET paydate = SUBSTRING(paydate FROM 1 FOR 5) || '0' || SUBSTRING(paydate FROM 6) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'"; - } - FS::upgrade_journal->set_done('cust_main__paydate'); - } - - unless ( FS::upgrade_journal->is_done('cust_main__payinfo') ) { - - push @statements, #fix the weird BILL with a cc# in payinfo problem - #DCRD to be safe - "UPDATE cust_main SET payby = 'DCRD' WHERE payby = 'BILL' and length(payinfo) = 16 and payinfo ". regexp_sql. q( '^[0-9]*$' ); - - FS::upgrade_journal->set_done('cust_main__payinfo'); - - } - my $t = time; foreach my $sql ( @statements ) { my $sth = dbh->prepare($sql) or die dbh->errstr; @@ -5044,44 +5333,6 @@ sub _upgrade_data { #class method } - unless ( FS::upgrade_journal->is_done('cust_main__cust_payby') ) { - - #we don't want to decrypt them, just stuff them as-is into cust_payby - local(@encrypted_fields) = (); - - local($FS::cust_payby::ignore_expired_card) = 1; - local($FS::cust_payby::ignore_banned_card) = 1; - - my @payfields = qw( payby payinfo paycvv paymask - paydate paystart_month paystart_year payissue - payname paystate paytype payip - ); - - my $search = new FS::Cursor { - 'table' => 'cust_main', - 'extra_sql' => " WHERE ( payby IS NOT NULL AND payby != '' ) ", - }; - - while (my $cust_main = $search->fetch) { - - my $cust_payby = new FS::cust_payby { - 'custnum' => $cust_main->custnum, - 'weight' => 1, - map { $_ => $cust_main->$_(); } @payfields - }; - - my $error = $cust_payby->insert; - die $error if $error; - - $cust_main->setfield($_, '') foreach @payfields; - $error = $cust_main->replace; - die $error if $error; - - }; - - FS::upgrade_journal->set_done('cust_main__cust_payby'); - } - $class->_upgrade_otaker(%opts); } @@ -5103,13 +5354,8 @@ card types. No multiple currency support (probably a larger project than just this module). -payinfo_masked false laziness with cust_pay.pm and cust_refund.pm - Birthdates rely on negative epoch values. -The payby for card/check batches is broken. With mixed batching, bad -things will happen. - B I should be renamed I