X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_main.pm;h=f4b9c5993395940411764d21873f6f8442d32c5e;hb=f0bb712b44ff110ce3441a32a5226837d34738fe;hp=6d32e00fc73e0e302d6408bea28cba06df536d24;hpb=b0b00f5e3adc74e2b9f39cc7827da9403ca42be6;p=freeside.git diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 6d32e00fc..f4b9c5993 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -31,6 +31,7 @@ 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::Msgcat qw(gettext); +use FS::CurrentUser; use FS::payby; use FS::cust_pkg; use FS::cust_svc; @@ -1301,7 +1302,7 @@ sub reexport { } -=item delete NEW_CUSTNUM +=item delete [ OPTION => VALUE ... ] This deletes the customer. If there is an error, returns the error, otherwise returns false. @@ -1311,18 +1312,20 @@ what you want when a customer cancels service; for that, cancel all of the customer's packages (see L). If the customer has any uncancelled packages, you need to pass a new (valid) -customer number for those packages to be transferred to. Cancelled packages -will be deleted. Did I mention that this is NOT what you want when a customer -cancels service and that you really should be looking see L? +customer number for those packages to be transferred to, as the "new_customer" +option. Cancelled packages will be deleted. Did I mention that this is NOT +what you want when a customer cancels service and that you really should be +looking at L? You can't delete a customer with invoices (see L), -or credits (see L), payments (see L) or -refunds (see L). +statements (see L), credits (see L), +payments (see L) or refunds (see L), unless you +set the "delete_financials" option to a true value. =cut sub delete { - my $self = shift; + my( $self, %opt ) = @_; local $SIG{HUP} = 'IGNORE'; local $SIG{INT} = 'IGNORE'; @@ -1335,26 +1338,47 @@ sub delete { local $FS::UID::AutoCommit = 0; my $dbh = dbh; - if ( $self->cust_bill ) { - $dbh->rollback if $oldAutoCommit; - return "Can't delete a customer with invoices"; - } - if ( $self->cust_credit ) { - $dbh->rollback if $oldAutoCommit; - return "Can't delete a customer with credits"; + if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) { + $dbh->rollback if $oldAutoCommit; + return "Can't delete a master agent customer"; } - if ( $self->cust_pay ) { - $dbh->rollback if $oldAutoCommit; - return "Can't delete a customer with payments"; + + #use FS::access_user + if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) { + $dbh->rollback if $oldAutoCommit; + return "Can't delete a master employee customer"; } - if ( $self->cust_refund ) { - $dbh->rollback if $oldAutoCommit; - return "Can't delete a customer with refunds"; + + tie my %financial_tables, 'Tie::IxHash', + 'cust_bill' => 'invoices', + 'cust_statement' => 'statements', + 'cust_credit' => 'credits', + 'cust_pay' => 'payments', + 'cust_refund' => 'refunds', + ; + + foreach my $table ( keys %financial_tables ) { + + my @records = $self->$table(); + + if ( @records && ! $opt{'delete_financials'} ) { + $dbh->rollback if $oldAutoCommit; + return "Can't delete a customer with ". $financial_tables{$table}; + } + + foreach my $record ( @records ) { + my $error = $record->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "Error deleting ". $financial_tables{$table}. ": $error\n"; + } + } + } my @cust_pkg = $self->ncancelled_pkgs; if ( @cust_pkg ) { - my $new_custnum = shift; + my $new_custnum = $opt{'new_custnum'}; unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) { $dbh->rollback if $oldAutoCommit; return "Invalid new customer number: $new_custnum"; @@ -1381,8 +1405,15 @@ sub delete { } } - foreach my $table (qw( cust_main_invoice cust_main_exemption cust_tag )) { - foreach my $record ( qsearch( 'table', { 'custnum' => $self->custnum } ) ) { + #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_pay_void cust_pay_batch queue cust_tax_exempt + )) { + foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) { my $error = $record->delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -1391,6 +1422,54 @@ sub delete { } } + my $sth = $dbh->prepare( + 'UPDATE cust_main SET referral_custnum = NULL WHERE referral_custnum = ?' + ) or do { + my $errstr = $dbh->errstr; + $dbh->rollback if $oldAutoCommit; + return $errstr; + }; + $sth->execute($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( + 'DELETE FROM Links WHERE Target = ?' + ) or do { + my $errstr = $ticket_dbh->errstr; + $dbh->rollback if $oldAutoCommit; + return $errstr; + }; + $ticket_sth->execute('freeside://freeside/cust_main/'.$self->custnum) + or do { + my $errstr = $ticket_sth->errstr; + $dbh->rollback if $oldAutoCommit; + return $errstr; + }; + + #check and see if the customer is the only link on the ticket, and + #if so, set the ticket to deleted status in RT? + #maybe someday, for now this will at least fix tickets not displaying + + } + + #delete the customer record + my $error = $self->SUPER::delete; if ( $error ) { $dbh->rollback if $oldAutoCommit; @@ -1712,7 +1791,8 @@ sub check { # bad idea to disable, causes billing to fail because of no tax rates later -# unless ( $import ) { +# except we don't fail any more + unless ( $import ) { unless ( qsearch('cust_main_county', { 'country' => $self->country, 'state' => '', @@ -1725,7 +1805,7 @@ sub check { 'country' => $self->country, } ); } -# } + } $error = $self->ut_phonen('daytime', $self->country) @@ -1994,7 +2074,7 @@ sub check { $self->$flag($1); } - $self->otaker(getotaker) unless $self->otaker; + $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum; warn "$me check AFTER: \n". $self->_dump if $DEBUG > 2; @@ -2901,7 +2981,13 @@ sub bill { my $real_pkgpart = $cust_pkg->pkgpart; my %hash = $cust_pkg->hash; - foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) { + # we could implement this bit as FS::part_pkg::has_hidden, but we already + # suffer from performance issues + $options{has_hidden} = 0; + my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked; + $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden); + + foreach my $part_pkg ( @part_pkg ) { $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill ); @@ -2936,7 +3022,7 @@ sub bill { foreach my $pass (@passes) { # keys %cust_bill_pkg ) { - my @cust_bill_pkg = @{ $cust_bill_pkg{$pass} }; + my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} }); next unless @cust_bill_pkg; #don't create an invoice w/o line items @@ -2955,7 +3041,13 @@ sub bill { } elsif ( $postal_pkg ) { my $real_pkgpart = $postal_pkg->pkgpart; - foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) { + # we could implement this bit as FS::part_pkg::has_hidden, but we already + # suffer from performance issues + $options{has_hidden} = 0; + my @part_pkg = $postal_pkg->part_pkg->self_and_bill_linked; + $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden); + + foreach my $part_pkg ( @part_pkg ) { my %postal_options = %options; delete $postal_options{cancel}; my $error = @@ -2976,6 +3068,9 @@ sub bill { } } + # it's silly to have a zero value postal_pkg, but.... + @cust_bill_pkg = _omit_zero_value_bundles(@cust_bill_pkg); + } } @@ -3075,6 +3170,28 @@ sub bill { ''; #no error } +#discard bundled packages of 0 value +sub _omit_zero_value_bundles { + + my @cust_bill_pkg = (); + my @cust_bill_pkg_bundle = (); + my $sum = 0; + + foreach my $cust_bill_pkg ( @_ ) { + if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) { + push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0; + @cust_bill_pkg_bundle = (); + $sum = 0; + } + $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur; + push @cust_bill_pkg_bundle, $cust_bill_pkg; + } + push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0; + + (@cust_bill_pkg); + +} + =item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME This is a weird one. Perhaps it should not even be exposed. @@ -3358,6 +3475,7 @@ sub _make_lines { my %param = ( 'precommit_hooks' => $precommit_hooks, 'increment_next_bill' => $increment_next_bill, 'discounts' => \@discounts, + 'real_pkgpart' => $real_pkgpart, ); my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur'; @@ -3393,7 +3511,7 @@ sub _make_lines { # If $cust_pkg has been modified, update it (if we're a real pkgpart) ### - if ( $lineitems ) { + if ( $lineitems || $options{has_hidden} ) { if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) { # hmm.. and if just the options are modified in some weird price plan? @@ -3417,7 +3535,10 @@ sub _make_lines { return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum; } - if ( $setup != 0 || $recur != 0 ) { + if ( $setup != 0 || + $recur != 0 || + !$part_pkg->hidden && $options{has_hidden} ) #include some $0 lines + { warn " charges (setup=$setup, recur=$recur); adding line items\n" if $DEBUG > 1; @@ -3584,16 +3705,15 @@ sub _handle_taxes { my @display = (); my $separate = $conf->exists('separate_usage'); - my $usage_mandate = $cust_pkg->part_pkg->option('usage_mandate', 'Hush!'); - if ( $separate || $cust_bill_pkg->hidden || $usage_mandate ) { + my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart }; + my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!'); + my $section = $temp_pkg->part_pkg->categoryname; + if ( $separate || $section || $usage_mandate ) { - my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart }; - my %hash = $cust_bill_pkg->hidden # maybe for all bill linked? - ? ( 'section' => $temp_pkg->part_pkg->categoryname ) - : (); + my %hash = ( 'section' => $section ); - my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!'); - my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!'); + $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!'); + my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!'); if ( $separate ) { push @display, new FS::cust_bill_pkg_display { type => 'S', %hash }; push @display, new FS::cust_bill_pkg_display { type => 'R', %hash }; @@ -3615,8 +3735,10 @@ sub _handle_taxes { $hash{post_total} = 'Y'; } - $hash{section} = $section if ($separate || $usage_mandate); - push @display, new FS::cust_bill_pkg_display { type => 'U', %hash }; + if ($separate || $usage_mandate) { + $hash{section} = $section if ($separate || $usage_mandate); + push @display, new FS::cust_bill_pkg_display { type => 'U', %hash }; + } } $cust_bill_pkg->set('display', \@display); @@ -4805,8 +4927,7 @@ sub realtime_bop { #false laziness w/misc/process/payment.cgi - check both to make sure working # correctly - if ( defined $self->dbdef_table->column('paycvv') - && length($self->paycvv) + if ( length($self->paycvv) && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save') ) { my $error = $self->remove_cvv; @@ -5111,28 +5232,42 @@ sub _realtime_bop_result { && ! grep { $transaction->error_message =~ /$_/ } $conf->config('emaildecline-exclude') ) { - my @templ = $conf->config('declinetemplate'); - my $template = new Text::Template ( - TYPE => 'ARRAY', - SOURCE => [ map "$_\n", @templ ], - ) or return "($perror) can't create template: $Text::Template::ERROR"; - $template->compile() - or return "($perror) can't compile template: $Text::Template::ERROR"; - - my $templ_hash = { - 'company_name' => - scalar( $conf->config('company_name', $self->agentnum ) ), - 'company_address' => - join("\n", $conf->config('company_address', $self->agentnum ) ), - 'error' => $transaction->error_message, - }; - my $error = send_email( - 'from' => $conf->config('invoice_from', $self->agentnum ), - 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ], - 'subject' => 'Your payment could not be processed', - 'body' => [ $template->fill_in(HASH => $templ_hash) ], - ); + # Send a decline alert to the customer. + my $msgnum = $conf->config('decline_msgnum', $self->agentnum); + my $error = ''; + if ( $msgnum ) { + # include the raw error message in the transaction state + $cust_pay_pending->setfield('error', $transaction->error_message); + my $msg_template = qsearchs('msg_template', { msgnum => $msgnum }); + $error = $msg_template->send( 'cust_main' => $self, + 'object' => $cust_pay_pending ); + } + else { #!$msgnum + + my @templ = $conf->config('declinetemplate'); + my $template = new Text::Template ( + TYPE => 'ARRAY', + SOURCE => [ map "$_\n", @templ ], + ) or return "($perror) can't create template: $Text::Template::ERROR"; + $template->compile() + or return "($perror) can't compile template: $Text::Template::ERROR"; + + my $templ_hash = { + 'company_name' => + scalar( $conf->config('company_name', $self->agentnum ) ), + 'company_address' => + join("\n", $conf->config('company_address', $self->agentnum ) ), + 'error' => $transaction->error_message, + }; + + my $error = send_email( + 'from' => $conf->config('invoice_from', $self->agentnum ), + 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ], + 'subject' => 'Your payment could not be processed', + 'body' => [ $template->fill_in(HASH => $templ_hash) ], + ); + } $perror .= " (also received error sending decline notification: $error)" if $error; @@ -6261,7 +6396,7 @@ sub balance_date_range { my $self = shift; my $sql = 'SELECT SUM('. $self->balance_date_sql(@_). ') FROM cust_main WHERE custnum='. $self->custnum; - sprintf( "%.2f", $self->scalar_sql($sql) ); + sprintf( '%.2f', $self->scalar_sql($sql) ); } =item balance_pkgnum PKGNUM @@ -7086,6 +7221,26 @@ sub cust_pay_pending { ); } +=item cust_pay_pending_attempt + +Returns all payment attempts / declined payments for this customer, as pending +payments objects (see L), with status "done" but without +a corresponding payment (see L). + +=cut + +sub cust_pay_pending_attempt { + my $self = shift; + return $self->num_cust_pay_pending_attempt unless wantarray; + sort { $a->_date <=> $b->_date } + qsearch( 'cust_pay_pending', { + 'custnum' => $self->custnum, + 'status' => 'done', + 'paynum' => '', + }, + ); +} + =item num_cust_pay_pending Returns the number of pending payments (see L) for this @@ -7096,11 +7251,28 @@ cust_pay_pending method is used in a scalar context. sub num_cust_pay_pending { my $self = shift; - my $sql = " SELECT COUNT(*) FROM cust_pay_pending ". - " WHERE custnum = ? AND status != 'done' "; - my $sth = dbh->prepare($sql) or die dbh->errstr; - $sth->execute($self->custnum) or die $sth->errstr; - $sth->fetchrow_arrayref->[0]; + $self->scalar_sql( + " SELECT COUNT(*) FROM cust_pay_pending ". + " WHERE custnum = ? AND status != 'done' ", + $self->custnum + ); +} + +=item num_cust_pay_pending_attempt + +Returns the number of pending payments (see L) for this +customer, with status "done" but without a corresp. Also called automatically when the +cust_pay_pending method is used in a scalar context. + +=cut + +sub num_cust_pay_pending_attempt { + my $self = shift; + $self->scalar_sql( + " SELECT COUNT(*) FROM cust_pay_pending ". + " WHERE custnum = ? AND status = 'done' AND paynum IS NULL", + $self->custnum + ); } =item cust_refund @@ -7477,7 +7649,8 @@ recurring packages not yet setup). =cut sub ordered_sql { - " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) "; + FS::cust_main->none_active_sql. + " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) "; } =item active_sql @@ -7491,6 +7664,18 @@ sub active_sql { " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) "; } +=item none_active_sql + +Returns an SQL expression identifying cust_main records with no active +recurring packages. This includes customers of status prospect, ordered, +inactive, and suspended. + +=cut + +sub none_active_sql { + " 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) "; +} + =item inactive_sql Returns an SQL expression identifying inactive cust_main records (customers with @@ -7498,11 +7683,10 @@ no active recurring packages, but otherwise unsuspended/uncancelled). =cut -sub inactive_sql { " - 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) - AND - 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) -"; } +sub inactive_sql { + FS::cust_main->none_active_sql. + " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) "; +} =item susp_sql =item suspended_sql @@ -7513,11 +7697,10 @@ Returns an SQL expression identifying suspended cust_main records. sub suspended_sql { susp_sql(@_); } -sub susp_sql { " - 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " ) - AND - 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) -"; } +sub susp_sql { + FS::cust_main->none_active_sql. + " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " ) "; +} =item cancel_sql =item cancelled_sql @@ -8682,7 +8865,17 @@ sub batch_charge { my $param = shift; #warn join('-',keys %$param); my $fh = $param->{filehandle}; - my @fields = @{$param->{fields}}; + my $agentnum = $param->{agentnum}; + my $format = $param->{format}; + + my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql; + + my @fields; + if ( $format eq 'simple' ) { + @fields = qw( custnum agent_custid amount pkg ); + } else { + die "unknown format $format"; + } eval "use Text::CSV_XS;"; die $@ if $@; @@ -8722,10 +8915,32 @@ sub batch_charge { $row{$field} = shift @columns; } - my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } ); + if ( $row{custnum} && $row{agent_custid} ) { + dbh->rollback if $oldAutoCommit; + return "can't specify custnum with agent_custid $row{agent_custid}"; + } + + my %hash = (); + if ( $row{agent_custid} && $agentnum ) { + %hash = ( 'agent_custid' => $row{agent_custid}, + 'agentnum' => $agentnum, + ); + } + + if ( $row{custnum} ) { + %hash = ( 'custnum' => $row{custnum} ); + } + + unless ( scalar(keys %hash) ) { + $dbh->rollback if $oldAutoCommit; + return "can't find customer without custnum or agent_custid and agentnum"; + } + + my $cust_main = qsearchs('cust_main', { %hash } ); unless ( $cust_main ) { $dbh->rollback if $oldAutoCommit; - return "unknown custnum $row{'custnum'}"; + my $custnum = $row{custnum} || $row{agent_custid}; + return "unknown custnum $custnum"; } if ( $row{'amount'} > 0 ) { @@ -8759,6 +8974,9 @@ sub batch_charge { =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 @@ -8872,6 +9090,7 @@ I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or =cut +# a lot like cust_bill::print_latex sub generate_letter { my ($self, $template, %options) = @_; @@ -8922,8 +9141,13 @@ sub generate_letter { $letter_data{returnaddress} = $retadd; } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) { $letter_data{returnaddress} = - join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg, - $conf->config('company_address', $self->agentnum) + join( "\n", map { s/( {2,})/'~' x length($1)/eg; + s/$/\\\\\*/; + $_; + } + ( $conf->config('company_name', $self->agentnum), + $conf->config('company_address', $self->agentnum), + ) ); } else { $letter_data{returnaddress} = '~'; @@ -8935,6 +9159,17 @@ sub generate_letter { $letter_data{company_name} = $conf->config('company_name', $self->agentnum); my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc; + + my $lh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX', + DIR => $dir, + SUFFIX => '.eps', + UNLINK => 0, + ) or die "can't open temp file: $!\n"; + print $lh $conf->config_binary('logo.eps', $self->agentnum) + or die "can't write temp file: $!\n"; + close $lh; + $letter_data{'logo_file'} = $lh->filename; + my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX', DIR => $dir, SUFFIX => '.tex', @@ -8944,7 +9179,8 @@ sub generate_letter { $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data ); close $fh; $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename; - return $1; + return ($1, $letter_data{'logo_file'}); + } =item print_ps TEMPLATE @@ -8955,8 +9191,12 @@ Returns an postscript letter filled in from TEMPLATE, as a scalar. sub print_ps { my $self = shift; - my $file = $self->generate_letter(@_); - FS::Misc::generate_ps($file); + my($file, $lfile) = $self->generate_letter(@_); + my $ps = FS::Misc::generate_ps($file); + unlink($file.'.tex'); + unlink($lfile); + + $ps; } =item print TEMPLATE