X-Git-Url: http://git.freeside.biz/gitweb/?a=blobdiff_plain;f=FS%2FFS%2Fcust_pkg.pm;h=203a26901b23168ac0f2bd3220324391ae3287d7;hb=a04f7d97cd3e57ee9061fba5f737d6d77c782c13;hp=457d257863615a8d1ec0f2d30dc38233d760d18f;hpb=7905f5dfd903529a6de89875e6fae74638a89aa3;p=freeside.git diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 457d25786..203a26901 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -13,9 +13,10 @@ use FS::cust_main_Mixin; use FS::cust_svc; use FS::part_pkg; use FS::cust_main; -use FS::type_pkgs; +use FS::cust_location; use FS::pkg_svc; use FS::cust_bill_pkg; +use FS::cust_pkg_detail; use FS::cust_event; use FS::h_cust_svc; use FS::reg_code; @@ -103,38 +104,84 @@ inherits from FS::Record. The following fields are currently supported: =over 4 -=item pkgnum - primary key (assigned automatically for new billing items) +=item pkgnum -=item custnum - Customer (see L) +Primary key (assigned automatically for new billing items) -=item pkgpart - Billing item definition (see L) +=item custnum -=item setup - date +Customer (see L) -=item bill - date (next bill date) +=item pkgpart + +Billing item definition (see L) + +=item locationnum + +Optional link to package location (see L) + +=item setup + +date + +=item bill + +date (next bill date) -=item last_bill - last bill date +=item last_bill + +last bill date + +=item adjourn + +date -=item adjourn - date +=item susp + +date -=item susp - date +=item expire -=item expire - date +date + +=item cancel -=item cancel - date +date -=item otaker - order taker (assigned automatically if null, see L) +=item otaker -=item manual_flag - If this field is set to 1, disables the automatic -unsuspension of this package when using the B config file. +order taker (assigned automatically if null, see L) -=item quantity - If not set, defaults to 1 +=item manual_flag + +If this field is set to 1, disables the automatic +unsuspension of this package when using the B config option. + +=item quantity + +If not set, defaults to 1 + +=item change_date + +Date of change from previous package + +=item change_pkgnum + +Previous pkgnum + +=item change_pkgpart + +Previous pkgpart + +=item change_locationnum + +Previous locationnum =back -Note: setup, bill, adjourn, susp, expire and cancel are specified as UNIX timestamps; -see L. Also see L and L for -conversion functions. +Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date +are specified as UNIX timestamps; see L. Also see +L and L for conversion functions. =head1 METHODS @@ -169,9 +216,19 @@ setting I to an array reference of refnums or a hash reference with refnums as keys. If no I is defined, a default FS::pkg_referral record will be created corresponding to cust_main.refnum. -The following options are available: I +The following options are available: + +=over 4 + +=item change + +If set true, supresses any referral credit to a referring customer. + +=item options + +cust_pkg_option records will be created -I, if set true, supresses any referral credit to a referring customer. +=back =cut @@ -212,42 +269,6 @@ sub insert { #} my $conf = new FS::Conf; - my $cust_main = $self->cust_main; - my $part_pkg = $self->part_pkg; - if ( $conf->exists('referral_credit') - && $cust_main->referral_custnum - && ! $options{'change'} - && $part_pkg->freq !~ /^0\D?$/ - ) - { - my $referring_cust_main = $cust_main->referring_cust_main; - if ( $referring_cust_main->status ne 'cancelled' ) { - my $error; - if ( $part_pkg->freq !~ /^\d+$/ ) { - warn 'WARNING: Not crediting customer '. $cust_main->referral_custnum. - ' for package '. $self->pkgnum. - ' ( customer '. $self->custnum. ')'. - ' - One-time referral credits not (yet) available for '. - ' packages with '. $part_pkg->freq_pretty. ' frequency'; - } else { - - my $amount = sprintf( "%.2f", $part_pkg->base_recur / $part_pkg->freq ); - my $error = - $referring_cust_main-> - credit( $amount, - 'Referral credit for '.$cust_main->name, - 'reason_type' => $conf->config('referral_credit_type') - ); - if ( $error ) { - $dbh->rollback if $oldAutoCommit; - return "Error crediting customer ". $cust_main->referral_custnum. - " for referral: $error"; - } - - } - - } - } if ($conf->config('welcome_letter') && $self->cust_main->num_pkgs == 1) { my $queue = new FS::queue { @@ -282,7 +303,7 @@ the customer ever purchased the item. Instead, see the cancel method. # return "Can't delete cust_pkg records!"; #} -=item replace OLD_RECORD +=item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ] Replaces the OLD_RECORD with this one in the database. If there is an error, returns the error, otherwise returns false. @@ -299,7 +320,23 @@ suspend is normally updated by the suspend and unsuspend methods. cancel is normally updated by the cancel method (and also the order subroutine in some cases). -Calls +Available options are: + +=over 4 + +=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_otaker + +the access_user (see L) providing the reason + +=item options + +hashref of keys and values - cust_pkg_option records will be created, updated or removed as appopriate + +=back =cut @@ -340,11 +377,12 @@ sub replace { foreach my $method ( qw(adjourn expire) ) { # How many reasons? if ($options->{'reason'} && $new->$method && $old->$method ne $new->$method) { - my $error = $new->insert_reason( 'reason' => $options->{'reason'}, - 'date' => $new->$method, - 'action' => $method, - 'reason_otaker' => $options{'reason_otaker'}, - ); + my $error = $new->insert_reason( + 'reason' => $options->{'reason'}, + 'date' => $new->$method, + 'action' => $method, + 'reason_otaker' => $options->{'reason_otaker'}, + ); if ( $error ) { dbh->rollback if $oldAutoCommit; return "Error inserting cust_pkg_reason: $error"; @@ -401,10 +439,15 @@ replace methods. sub check { my $self = shift; + $self->locationnum('') + if defined($self->locationnum) && length($self->locationnum) + && ( $self->locationnum == 0 || $self->locationnum == -1 ); + my $error = $self->ut_numbern('pkgnum') || $self->ut_foreign_key('custnum', 'cust_main', 'custnum') || $self->ut_numbern('pkgpart') + || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum') || $self->ut_numbern('setup') || $self->ut_numbern('bill') || $self->ut_numbern('susp') @@ -521,6 +564,7 @@ sub cancel { if ( $options{'reason'} ) { $error = $self->insert_reason( 'reason' => $options{'reason'}, 'action' => $date ? 'expire' : 'cancel', + 'date' => $date ? $date : $cancel_time, 'reason_otaker' => $options{'reason_otaker'}, ); if ( $error ) { @@ -581,7 +625,7 @@ sub cancel { if ( !$options{'quiet'} && $conf->exists('emailcancel') && @invoicing_list ) { my $conf = new FS::Conf; my $error = send_email( - 'from' => $conf->config('invoice_from'), + 'from' => $conf->config('invoice_from', $self->cust_main->agentnum), 'to' => \@invoicing_list, 'subject' => ( $conf->config('cancelsubject') || 'Cancellation Notice' ), 'body' => [ map "$_\n", $conf->config('cancelmessage') ], @@ -718,9 +762,12 @@ sub suspend { return "Package $pkgnum expires before it would be suspended."; } + my $suspend_time = $options{'time'} || time; + if ( $options{'reason'} ) { $error = $self->insert_reason( 'reason' => $options{'reason'}, 'action' => $date ? 'adjourn' : 'suspend', + 'date' => $date ? $date : $suspend_time, 'reason_otaker' => $options{'reason_otaker'}, ); if ( $error ) { @@ -730,6 +777,9 @@ sub suspend { } unless ( $date ) { + + my @labels = (); + foreach my $cust_svc ( qsearch( 'cust_svc', { 'pkgnum' => $self->pkgnum } ) ) { @@ -749,12 +799,44 @@ sub suspend { $dbh->rollback if $oldAutoCommit; return $error; } + my( $label, $value ) = $cust_svc->label; + push @labels, "$label: $value"; } } + + my $conf = new FS::Conf; + if ( $conf->config('suspend_email_admin') ) { + + my $error = send_email( + 'from' => $conf->config('invoice_from', $self->cust_main->agentnum), + #invoice_from ??? well as good as any + 'to' => $conf->config('suspend_email_admin'), + 'subject' => 'FREESIDE NOTIFICATION: Customer package suspended', + 'body' => [ + "This is an automatic message from your Freeside installation\n", + "informing you that the following customer package has been suspended:\n", + "\n", + 'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n", + 'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n", + ( map { "Service : $_\n" } @labels ), + ], + ); + + if ( $error ) { + warn "WARNING: can't send suspension admin email (suspending anyway): ". + "$error\n"; + } + + } + } my %hash = $self->hash; - $date ? ($hash{'adjourn'} = $date) : ($hash{'susp'} = time); + if ( $date ) { + $hash{'adjourn'} = $date; + } else { + $hash{'susp'} = $suspend_time; + } my $new = new FS::cust_pkg ( \%hash ); $error = $new->replace( $self, options => { $self->options } ); if ( $error ) { @@ -773,15 +855,21 @@ Unsuspends all services (see L and L) in this package, then unsuspends the package itself (clears the susp field and the adjourn field if it is in the past). -Available options are: I. +Available options are: + +=over 4 + +=item adjust_next_bill -I can be set true to adjust the next bill date forward by +Can be set true to adjust the next bill date forward by the amount of time the account was inactive. This was set true by default since 1.4.2 and 1.5.0pre6; however, starting with 1.7.0 this needs to be explicitly requested. Price plans for which this makes sense (anniversary-date based than prorate or subscription) could have an option to enable this behaviour? +=back + If there is an error, returns the error, otherwise returns false. =cut @@ -844,7 +932,7 @@ sub unsuspend { $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive if ( $opt{'adjust_next_bill'} - || $conf->config('unsuspend-always_adjust_next_bill_date') ) + || $conf->exists('unsuspend-always_adjust_next_bill_date') ) && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} ); $hash{'susp'} = ''; @@ -919,6 +1007,148 @@ sub unadjourn { } + +=item change HASHREF | OPTION => VALUE ... + +Changes this package: cancels it and creates a new one, with a different +pkgpart or locationnum or both. All services are transferred to the new +package (no change will be made if this is not possible). + +Options may be passed as a list of key/value pairs or as a hash reference. +Options are: + +=over 4 + +=item locaitonnum + +New locationnum, to change the location for this package. + +=item cust_location + +New FS::cust_location object, to create a new location and assign it +to this package. + +=item pkgpart + +New pkgpart (see L). + +=item refnum + +New refnum (see L). + +=back + +At least one option must be specified (otherwise, what's the point?) + +Returns either the new FS::cust_pkg object or a scalar error. + +For example: + + my $err_or_new_cust_pkg = $old_cust_pkg->change + +=cut + +#some false laziness w/order +sub change { + my $self = shift; + my $opt = ref($_[0]) ? shift : { @_ }; + +# my ($custnum, $pkgparts, $remove_pkgnum, $return_cust_pkg, $refnum) = @_; +# + + my $conf = new FS::Conf; + + # Transactionize this whole mess + 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; + + my $error; + + my %hash = (); + + my $time = time; + + #$hash{$_} = $self->$_() foreach qw( last_bill bill ); + + #$hash{$_} = $self->$_() foreach qw( setup ); + + $hash{'setup'} = $time if $self->setup; + + $hash{'change_date'} = $time; + $hash{"change_$_"} = $self->$_() + foreach qw( pkgnum pkgpart locationnum ); + + if ( $opt->{'cust_location'} && + ( ! $opt->{'locationnum'} || $opt->{'locationnum'} == -1 ) ) { + $error = $opt->{'cust_location'}->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "inserting cust_location (transaction rolled back): $error"; + } + $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum; + } + + # Create the new package. + my $cust_pkg = new FS::cust_pkg { + custnum => $self->custnum, + pkgpart => ( $opt->{'pkgpart'} || $self->pkgpart ), + refnum => ( $opt->{'refnum'} || $self->refnum ), + locationnum => ( $opt->{'locationnum'} || $self->locationnum ), + %hash, + }; + + $error = $cust_pkg->insert( 'change' => 1 ); + if ($error) { + $dbh->rollback if $oldAutoCommit; + return $error; + } + + # Transfer services and cancel old package. + + $error = $self->transfer($cust_pkg); + if ($error and $error == 0) { + # $old_pkg->transfer failed. + $dbh->rollback if $oldAutoCommit; + return $error; + } + + if ( $error > 0 && $conf->exists('cust_pkg-change_svcpart') ) { + warn "trying transfer again with change_svcpart option\n" if $DEBUG; + $error = $self->transfer($cust_pkg, 'change_svcpart'=>1 ); + if ($error and $error == 0) { + # $old_pkg->transfer failed. + $dbh->rollback if $oldAutoCommit; + return $error; + } + } + + if ($error > 0) { + # Transfers were successful, but we still had services left on the old + # package. We can't change the package under this circumstances, so abort. + $dbh->rollback if $oldAutoCommit; + return "Unable to transfer all services from package ". $self->pkgnum; + } + + #Good to go, cancel old package. + $error = $self->cancel( quiet=>1 ); + if ($error) { + $dbh->rollback; + return $error; + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + $cust_pkg; + +} + =item last_bill Returns the last bill date, or if there is no last bill date, the setup date. @@ -1056,6 +1286,77 @@ sub cust_bill_pkg { qsearch( 'cust_bill_pkg', { 'pkgnum' => $self->pkgnum } ); } +=item cust_pkg_detail [ DETAILTYPE ] + +Returns any customer package details for this package (see +L). + +DETAILTYPE can be set to "I" for invoice details or "C" for comments. + +=cut + +sub cust_pkg_detail { + my $self = shift; + my %hash = ( 'pkgnum' => $self->pkgnum ); + $hash{detailtype} = shift if @_; + qsearch({ + 'table' => 'cust_pkg_detail', + 'hashref' => \%hash, + 'order_by' => 'ORDER BY weight, pkgdetailnum', + }); +} + +=item set_cust_pkg_detail DETAILTYPE [ DETAIL, DETAIL, ... ] + +Sets customer package details for this package (see L). + +DETAILTYPE can be set to "I" for invoice details or "C" for comments. + +If there is an error, returns the error, otherwise returns false. + +=cut + +sub set_cust_pkg_detail { + my( $self, $detailtype, @details ) = @_; + + 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; + + foreach my $current ( $self->cust_pkg_detail($detailtype) ) { + my $error = $current->delete; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error removing old detail: $error"; + } + } + + foreach my $detail ( @details ) { + my $cust_pkg_detail = new FS::cust_pkg_detail { + 'pkgnum' => $self->pkgnum, + 'detailtype' => $detailtype, + 'detail' => $detail, + }; + my $error = $cust_pkg_detail->insert; + if ( $error ) { + $dbh->rollback if $oldAutoCommit; + return "error adding new detail: $error"; + } + + } + + $dbh->commit or die $dbh->errstr if $oldAutoCommit; + ''; + +} + =item cust_event Returns the new-style customer billing events (see L) for this invoice. @@ -1380,24 +1681,29 @@ sub h_labels { =item h_labels_short END_TIMESTAMP [ START_TIMESTAMP ] -Like h_labels, except returns a simple flat list, and shortens long -(currently >5) lists of identical services to one line that lists the service -label and the number of individual services rather than individual items. +Like h_labels, except returns a simple flat list, and shortens long +(currently >5 or the cust_bill-max_same_services configuration value) lists of +identical services to one line that lists the service label and the number of +individual services rather than individual items. =cut sub h_labels_short { my $self = shift; + my $conf = new FS::Conf; + my $max_same_services = $conf->config('cust_bill-max_same_services') || 5; + my %labels; #tie %labels, 'Tie::IxHash'; push @{ $labels{$_->[0]} }, $_->[1] foreach $self->h_labels(@_); my @labels; foreach my $label ( keys %labels ) { - my @values = @{ $labels{$label} }; + my %seen = (); + my @values = grep { ! $seen{$_}++ } @{ $labels{$label} }; my $num = scalar(@values); - if ( $num > 5 ) { + if ( $num > $max_same_services ) { push @labels, "$label ($num)"; } else { push @labels, map { "$label: $_" } @values; @@ -1419,6 +1725,30 @@ sub cust_main { qsearchs( 'cust_main', { 'custnum' => $self->custnum } ); } +=item cust_location + +Returns the location object, if any (see L). + +=cut + +sub cust_location { + my $self = shift; + return '' unless $self->locationnum; + qsearchs( 'cust_location', { 'locationnum' => $self->locationnum } ); +} + +=item cust_location_or_main + +If this package is associated with a location, returns the locaiton (see +L), otherwise returns the customer (see L). + +=cut + +sub cust_location_or_main { + my $self = shift; + $self->cust_location || $self->cust_main; +} + =item seconds_since TIMESTAMP Returns the number of seconds all accounts (see L) in this @@ -2017,6 +2347,99 @@ sub search_sql { } +=item location_sql + +Returns a list: the first item is an SQL fragment identifying matching +packages/customers via location (taking into account shipping and package +address taxation, if enabled), and subsequent items are the parameters to +substitute for the placeholders in that fragment. + +=cut + +sub location_sql { + my($class, %opt) = @_; + my $ornull = $opt{'ornull'}; + my $nec = $opt{'noempty_county'}; + + my $conf = new FS::Conf; + + # '?' placeholders in _location_sql_where + my @bill_param; + if ( $ornull ) { + @bill_param = qw( county county state state state country ); + } else { + @bill_param = qw( county state state country ); + } + unshift @bill_param, 'county' unless $nec; + + my $main_where; + my @main_param; + if ( $conf->exists('tax-ship_address') ) { + + $main_where = "( + ( ( ship_last IS NULL OR ship_last = '' ) + AND ". _location_sql_where('cust_main', '', $ornull, $nec ). " + ) + OR ( ship_last IS NOT NULL AND ship_last != '' + AND ". _location_sql_where('cust_main', 'ship_', $ornull, $nec ). " + ) + )"; + # AND payby != 'COMP' + + @main_param = ( @bill_param, @bill_param ); + + } else { + + $main_where = _location_sql_where('cust_main'); # AND payby != 'COMP' + @main_param = @bill_param; + + } + + my $where; + my @param; + if ( $conf->exists('tax-pkg_address') ) { + + my $loc_where = _location_sql_where( 'cust_location', + '', #prefix + $ornull, + $nec, + ); + + $where = " ( + ( cust_pkg.locationnum IS NULL AND $main_where ) + OR ( cust_pkg.locationnum IS NOT NULL AND $loc_where ) + ) + "; + @param = ( @main_param, @bill_param ); + + } else { + + $where = $main_where; + @param = @main_param; + + } + + ( $where, @param ); + +} + +#subroutine, helper for location_sql +sub _location_sql_where { + my $table = shift; + my $prefix = @_ ? shift : ''; + my $ornull = @_ ? shift : ''; + my $no_empty_county = @_ ? shift : ''; + + $ornull = $ornull ? ' OR ? IS NULL ' : ''; + my $or_empty_county = $no_empty_county ? '' : " OR ? = '' "; + + " + ( $table.${prefix}county = ? $or_empty_county $ornull ) + AND ( $table.${prefix}state = ? OR ? = '' $ornull ) + AND $table.${prefix}country = ? + "; +} + =head1 SUBROUTINES =over 4 @@ -2064,8 +2487,8 @@ sub order { my $dbh = dbh; my $error; - my $cust_main = qsearchs('cust_main', { custnum => $custnum }); - return "Customer not found: $custnum" unless $cust_main; +# my $cust_main = qsearchs('cust_main', { custnum => $custnum }); +# return "Customer not found: $custnum" unless $cust_main; my @old_cust_pkg = map { qsearchs('cust_pkg', { pkgnum => $_ }) } @$remove_pkgnum; @@ -2075,15 +2498,19 @@ sub order { my %hash = (); if ( scalar(@old_cust_pkg) == 1 && scalar(@$pkgparts) == 1 ) { - my $time = time; + my $err_or_cust_pkg = + $old_cust_pkg[0]->change( 'pkgpart' => $pkgparts->[0], + 'refnum' => $refnum, + ); - #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( last_bill bill ); - - #$hash{$_} = $old_cust_pkg[0]->$_() foreach qw( setup ); - $hash{'setup'} = $time if $old_cust_pkg[0]->setup; + unless (ref($err_or_cust_pkg)) { + $dbh->rollback if $oldAutoCommit; + return $err_or_cust_pkg; + } + + push @$return_cust_pkg, $err_or_cust_pkg; + return ''; - $hash{'change_date'} = $time; - $hash{"change_$_"} = $old_cust_pkg[0]->$_() foreach qw( pkgnum pkgpart ); } # Create the new packages. @@ -2144,30 +2571,12 @@ sub order { ''; } -=item insert_reason - -Associates this package with a (suspension or cancellation) reason (see -L, possibly inserting a new reason on the fly (see -L). - -Available options are: - -=over 4 - -=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 date - -=back - -If there is an error, returns the error, otherwise returns false. - -=cut - =item bulk_change PKGPARTS_ARYREF, REMOVE_PKGNUMS_ARYREF [ RETURN_CUST_PKG_ARRAYREF ] +A bulk change method to change packages for multiple customers. + PKGPARTS is a list of pkgparts specifying the the billing item definitions (see -L) to order for this customer. Duplicates are of course +L) to order for each customer. Duplicates are of course permitted. REMOVE_PKGNUMS is an list of pkgnums specifying the billing items to @@ -2222,6 +2631,38 @@ sub bulk_change { ''; } +=item insert_reason + +Associates this package with a (suspension or cancellation) reason (see +L, possibly inserting a new reason on the fly (see +L). + +Available options are: + +=over 4 + +=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_otaker + +the access_user (see L) providing the reason + +=item date + +a unix timestamp + +=item action + +the action (cancel, susp, adjourn, expire) associated with the reason + +=back + +If there is an error, returns the error, otherwise returns false. + +=cut + sub insert_reason { my ($self, %options) = @_;