X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=79e674d56a7d187d62e185da293c8b9849df85b9;hb=8d0e15c1c73639374443f5fd2c06334eb12bea44;hp=f21932cf607ffb01f96b1ae7583d8f7c63dc3695;hpb=786d21e09d1f11c8976a8d45ef734d2d0a100ee7;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index f21932cf6..79e674d56 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -6,13 +6,15 @@ use base qw( FS::cust_main::Packages FS::cust_main::Status FS::cust_main::NationalID FS::cust_main::Billing FS::cust_main::Billing_Realtime FS::cust_main::Billing_Discount + FS::cust_main::Billing_ThirdParty FS::cust_main::Location + FS::cust_main::Credit_Limit FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin - FS::geocode_Mixin FS::Quotable_Mixin + FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin FS::o2m_Common FS::Record ); -use vars qw( $DEBUG $me $conf +use vars qw( $DEBUG $me $conf $default_agent_custid $custnum_display_length @encrypted_fields $import $ignore_expired_card $ignore_banned_card $ignore_illegal_zip @@ -31,10 +33,9 @@ use Date::Format; #use Date::Manip; use File::Temp; #qw( tempfile ); use Business::CreditCard 0.28; -use Locale::Country; use FS::UID qw( getotaker dbh driver_name ); use FS::Record qw( qsearchs qsearch dbdef regexp_sql ); -use FS::Misc qw( generate_email send_email generate_ps do_print ); +use FS::Misc qw( generate_email send_email generate_ps do_print money_pretty card_types ); use FS::Msgcat qw(gettext); use FS::CurrentUser; use FS::TicketSystem; @@ -96,7 +97,8 @@ sub nohistory_fields { ('payinfo', 'paycvv'); } #$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) + $default_agent_custid = $conf->exists('cust_main-default_agent_custid'); + $custnum_display_length = $conf->config('cust_main-custnum-display_length'); }; sub _cache { @@ -272,6 +274,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 @@ -390,7 +396,7 @@ 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'); @@ -400,11 +406,8 @@ sub insert { # 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"; - } + + my $loc = delete $self->hashref->{$l} or return "$l not set"; if ( !$loc->locationnum ) { # warn the location that we're going to insert it with no custnum @@ -417,8 +420,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; @@ -442,7 +456,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"; @@ -527,6 +542,43 @@ sub insert { } + # validate card (needs custnum already set) + if ( $self->payby =~ /^(CARD|DCRD)$/ + && $conf->exists('business-onlinepayment-verification') ) { + $error = $self->realtime_verify_bop({ 'method'=>'CC' }); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + warn " setting contacts\n" + if $DEBUG > 1; + + if ( my $contact = delete $options{'contact'} ) { + + foreach my $c ( @$contact ) { + $c->custnum($self->custnum); + my $error = $c->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + } + + } 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_main_exemption\n" if $DEBUG > 1; @@ -1276,13 +1328,14 @@ sub merge { } tie my %financial_tables, 'Tie::IxHash', - 'cust_bill' => 'invoices', - 'cust_bill_void' => 'voided invoices', - 'cust_statement' => 'statements', - 'cust_credit' => 'credits', - 'cust_pay' => 'payments', - 'cust_pay_void' => 'voided payments', - 'cust_refund' => 'refunds', + '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 ) { @@ -1489,6 +1542,33 @@ 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; + + if ( $conf->exists('business-onlinepayment-verification') ) { + #need to standardize paydate for this, false laziness with check + 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"); + + $error = $self->realtime_verify_bop({ 'method'=>'CC' }); + return $error if $error; + } + } + return "Invoicing locale is required" if $old->locale && ! $self->locale @@ -1694,13 +1774,25 @@ sub queue_fuzzyfiles_update { local $FS::UID::AutoCommit = 0; my $dbh = dbh; + foreach my $field ( 'first', 'last', 'company', 'ship_company' ) { + my $queue = new FS::queue { + 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield' + }; + my @args = "cust_main.$field", $self->get($field); + my $error = $queue->insert( @args ); + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "queueing job (transaction rolled back): $error"; + } + } + my @locations = $self->bill_location; push @locations, $self->ship_location if $self->has_ship_address; foreach my $location (@locations) { my $queue = new FS::queue { - 'job' => 'FS::cust_main::Search::append_fuzzyfiles' + 'job' => 'FS::cust_main::Search::append_fuzzyfiles_fuzzyfield' }; - my @args = map $location->get($_), @FS::cust_main::Search::fuzzyfields; + my @args = 'cust_location.address1', $location->address1; my $error = $queue->insert( @args ); if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -1735,14 +1827,18 @@ sub check { || $self->ut_foreign_key('bill_locationnum', 'cust_location','locationnum') || $self->ut_foreign_key('ship_locationnum', 'cust_location','locationnum') || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum') + || $self->ut_foreign_keyn('salesnum', 'sales', 'salesnum') || $self->ut_textn('custbatch') || $self->ut_name('last') || $self->ut_name('first') || $self->ut_snumbern('signupdate') || $self->ut_snumbern('birthdate') + || $self->ut_namen('spouse_last') + || $self->ut_namen('spouse_first') || $self->ut_snumbern('spouse_birthdate') || $self->ut_snumbern('anniversary_date') || $self->ut_textn('company') + || $self->ut_textn('ship_company') || $self->ut_anything('comments') || $self->ut_numbern('referral_custnum') || $self->ut_textn('stateid') @@ -1757,13 +1853,16 @@ sub check { || $self->ut_flag('invoice_noemail') || $self->ut_flag('message_noemail') || $self->ut_enum('locale', [ '', FS::Locales->locales ]) + || $self->ut_flag('invoice_ship_address') ; - my $company = $self->company; - $company =~ s/^\s+//; - $company =~ s/\s+$//; - $company =~ s/\s+/ /g; - $self->company($company); + foreach (qw(company ship_company)) { + my $company = $self->get($_); + $company =~ s/^\s+//; + $company =~ s/\s+$//; + $company =~ s/\s+/ /g; + $self->set($_, $company); + } #barf. need message catalogs. i18n. etc. $error .= "Please select an advertising source." @@ -1790,6 +1889,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 = @@ -2024,9 +2128,17 @@ sub check { ) { $self->payname( $self->first. " ". $self->getfield('last') ); } else { - $self->payname =~ /^([\w \,\.\-\'\&]+)$/ - or return gettext('illegal_name'). " payname: ". $self->payname; - $self->payname($1); + + 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); + } + } return "Please select an invoicing locale" @@ -2047,6 +2159,40 @@ sub check { $self->SUPER::check; } +sub check_payinfo_cardtype { + my $self = shift; + + return '' unless $self->payby =~ /^(CARD|DCRD)$/; + + my $payinfo = $self->payinfo; + $payinfo =~ s/\D//g; + + return '' if $payinfo =~ /^99\d{14}$/; #token + + my %bop_card_types = map { $_=>1 } values %{ card_types() }; + my $cardtype = cardtype($payinfo); + + return "$cardtype not accepted" unless $bop_card_types{$cardtype}; + + ''; + +} + +=item replace_check + +Additional checks for replace only. + +=cut + +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); + } + return ''; +} + =item addr_fields Returns a list of fields which have ship_ duplicates. @@ -2055,6 +2201,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 @@ -2112,14 +2259,27 @@ sub cust_contact { =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 @@ -2203,33 +2363,60 @@ 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 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 + # but on 3.x, don't strictly enforce this + warn "cancel_pkgs should not 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'); @@ -2243,24 +2430,74 @@ sub cancel { my $ban = new FS::banned_pay $self->_new_banned_pay_hashref; my $error = $ban->insert; - return ( $error ) if $error; + 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; + + $FS::UID::AutoCommit = 1; + my @errors; + # now cancel all services, the same way we would for individual packages. + # if any of them fail, cancel the rest anyway. + 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 ); + my $error = $cust_svc->cancel; # immediate cancel, no date option + push @errors, $error if $error; + } + 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'} }; + } + foreach (@pkgs) { + my %lopt = %opt; + if (@cprs) { + my $cpr = shift @cprs; + $lopt{'reason'} = $cpr->reasonnum; + $lopt{'reason_otaker'} = $cpr->otaker; + } + my $error = $_->cancel(%lopt); + push @errors, 'pkgnum '.$_->pkgnum.': '.$error if $error; + } + + return @errors; } sub _banned_pay_hashref { @@ -2295,8 +2532,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 }, '', @@ -2461,7 +2698,13 @@ sub batch_card { }else{ $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments); } - return '' unless $amount > 0; + if ($amount <= 0) { + warn(sprintf("Customer balance %.2f - in transit amount %.2f is <= 0.\n", + $self->balance, + $self->in_transit_payments + )); + return; + } my $invnum = delete $options{invnum}; my $payby = $options{payby} || $self->payby; #still dubious @@ -2810,7 +3053,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 @@ -2818,6 +3061,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 @@ -2865,6 +3114,7 @@ sub in_transit_payments { foreach my $cust_pay_batch ( qsearch('cust_pay_batch', { 'batchnum' => $pay_batch->batchnum, 'custnum' => $self->custnum, + 'status' => '', } ) ) { $in_transit_payments += $cust_pay_batch->amount; } @@ -3219,6 +3469,91 @@ 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 '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.*', + extra_sql => ' WHERE contact.custnum = '.$self->custnum, + }; + + my @orwhere; + my @classnums; + foreach (@_) { + if ( $_ eq '0' ) { + push @orwhere, 'contact.classnum is null'; + } elsif ( /^\d+$/ ) { + push @classnums, $_; + } else { + die "bad classnum argument '$_'"; + } + } + + if (@classnums) { + push @orwhere, '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. Also accepts 'invoice' as an argument, in which case this will also +return the invoice email address if any. + +=cut + +sub contact_list_email { + my $self = shift; + my @classnums; + my $and_invoice; + foreach (@_) { + if (/^invoice$/) { + $and_invoice = 1; + } else { + push @classnums, $_; + } + } + my %emails; + # if the only argument passed was 'invoice' then no classnums are + # intended, so skip this. + if ( @classnums ) { + my @contacts = $self->contact_list(@classnums); + foreach my $contact (@contacts) { + foreach my $contact_email ($contact->contact_email) { + # unlike on 4.x, we have a separate list of invoice email + # destinations. + # make sure they're not redundant with contact emails + my $dest = $contact->firstlast . ' <' . $contact_email->emailaddress . '>'; + $emails{ $contact_email->emailaddress } = $dest; + } + } + } + if ( $and_invoice ) { + foreach my $email ($self->invoicing_list_emailonly) { + my $dest = $self->name_short . ' <' . $email . '>'; + $emails{ $email } ||= $dest; + } + } + values %emails; +} + =item referral_custnum_cust_main Returns the customer who referred this customer (or the empty string, if @@ -3323,6 +3658,8 @@ reason, and a 'reason_type' option must be passed to indicate the FS::reason_type for the new reason. An I option may be passed to set the credit's I field. +Likewise for I, I, I and +I. Any other options are passed to FS::cust_credit::insert. @@ -3348,10 +3685,10 @@ sub credit { $cust_credit->set('reason', $reason) } - for (qw( addlinfo eventnum )) { - $cust_credit->$_( delete $options{$_} ) - if exists($options{$_}); - } + $cust_credit->$_( delete $options{$_} ) + foreach grep exists($options{$_}), + qw( addlinfo eventnum ), + map "commission_$_", qw( agentnum salesnum pkgnum ); $cust_credit->insert(%options); @@ -3400,18 +3737,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} : ''; @@ -3428,8 +3768,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'; @@ -3460,6 +3802,7 @@ sub charge { 'setuptax' => $setuptax, 'taxclass' => $taxclass, 'taxproductnum' => $taxproduct, + 'setup_cost' => $setup_cost, } ); my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) } @@ -3494,6 +3837,7 @@ sub charge { 'quantity' => $quantity, 'start_date' => $start_date, 'no_auto' => $no_auto, + 'separate_bill' => $separate_bill, 'locationnum'=> $locationnum, } ); @@ -3727,6 +4071,19 @@ sub cust_credit_pkgnum { ); } +=item cust_credit_void + +Returns all voided credits (see L) for this customer. + +=cut + +sub cust_credit_void { + my $self = shift; + map { $_ } + sort { $a->_date <=> $b->_date } + qsearch( 'cust_credit_void', { 'custnum' => $self->custnum } ) +} + =item cust_pay Returns all the payments (see L) for this customer. @@ -3735,9 +4092,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 @@ -3755,6 +4120,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 @@ -3905,34 +4286,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; } @@ -4059,17 +4422,15 @@ 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; + $self->bill_location->country_full; +} + +sub ship_country_full { + my $self = shift; + $self->ship_location->country_full; +} =item county_state_county [ PREFIX ] @@ -4127,16 +4488,41 @@ 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; - return $status if $sth->fetchrow_arrayref->[0]; + 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 @@ -4165,14 +4551,17 @@ sub cust_statuscolor { __PACKAGE__->statuscolors->{$self->cust_status}; } -=item tickets +=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 = (); @@ -4180,7 +4569,12 @@ sub tickets { if ( $conf->config('ticket_system') ) { unless ( $conf->config('ticket_system-custom_priority_field') ) { - @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) }; + @tickets = @{ FS::TicketSystem->customer_tickets( $self->custnum, + $num, + undef, + $status, + ) + }; } else { @@ -4192,6 +4586,7 @@ sub tickets { @{ FS::TicketSystem->customer_tickets( $self->custnum, $num - scalar(@tickets), $priority, + $status, ) }; } @@ -4200,6 +4595,30 @@ sub tickets { (@tickets); } +=item appointments [ STATUS ] + +Returns an array of hashes representing the customer's RT tickets which +are appointments. + +=cut + +sub appointments { + my $self = shift; + my $status = ( @_ && $_[0] ) ? shift : ''; + + return () unless $conf->config('ticket_system'); + + my $queueid = $conf->config('ticket_system-appointment-queueid'); + + @{ FS::TicketSystem->customer_tickets( $self->custnum, + 99, + undef, + $status, + $queueid, + ) + }; +} + # Return services representing svc_accts in customer support packages sub support_services { my $self = shift; @@ -4242,6 +4661,180 @@ my ($self,$field) = @_; } +=item payment_history + +Returns an array of hashrefs standardizing information from cust_bill, cust_pay, +cust_credit and cust_refund objects. Each hashref has the following fields: + +I - 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; +} + =back =head1 CLASS METHODS @@ -4699,7 +5292,7 @@ sub notify { return unless $conf->exists($template); - my $from = $conf->config('invoice_from', $self->agentnum) + my $from = $conf->invoice_from_full($self->agentnum) if $conf->exists('invoice_from', $self->agentnum); $from = $options{from} if exists($options{from}); @@ -4769,6 +5362,8 @@ 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. @@ -4784,11 +5379,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"; @@ -4901,9 +5501,9 @@ sub queueable_print { my %opt = @_; my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } ) - or die "invalid customer number: " . $opt{custvnum}; + or die "invalid customer number: " . $opt{custnum}; - my $error = $self->print( $opt{template} ); + my $error = $self->print( { 'template' => $opt{template} } ); die $error if $error; } @@ -5002,6 +5602,9 @@ sub queued_bill { my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } ); warn 'bill_and_collect custnum#'. $cust_main->custnum. "\n";#log custnum w/pid + #without this errors don't get rolled back + $args{'fatal'} = 1; # runs from job queue, will be caught + $cust_main->bill_and_collect( %args ); } @@ -5017,42 +5620,6 @@ sub process_bill_and_collect { $cust_main->bill_and_collect( %$param ); } -=item process_censustract_update CUSTNUM - -Queueable function to update the census tract to the current year (as set in -the 'census_year' configuration variable) and retrieve the new tract code. - -=cut - -sub process_censustract_update { - eval "use FS::Misc::Geo qw(get_censustract)"; - die $@ if $@; - my $custnum = shift; - my $cust_main = qsearchs( 'cust_main', { custnum => $custnum }) - or die "custnum '$custnum' not found!\n"; - - my $new_year = $conf->config('census_year') or return; - my $new_tract = get_censustract({ $cust_main->location_hash }, $new_year); - if ( $new_tract =~ /^\d/ ) { - # then it's a tract code - $cust_main->set('censustract', $new_tract); - $cust_main->set('censusyear', $new_year); - - local($ignore_expired_card) = 1; - local($ignore_illegal_zip) = 1; - local($ignore_banned_card) = 1; - local($skip_fuzzyfiles) = 1; - local($import) = 1; #prevent automatic geocoding (need its own variable?) - my $error = $cust_main->replace; - die $error if $error; - } - else { - # it's an error message - die $new_tract; - } - return; -} - #starting to take quite a while for big dbs # (JRNL: journaled so it only happens once per database) # - seq scan of h_cust_main (yuck), but not going to index paycvv, so