RT#18361 Delay package from billing until services are provisioned [v3 backport]
[freeside.git] / FS / FS / part_pkg.pm
index 2cccce0..402d88c 100644 (file)
@@ -10,12 +10,14 @@ use Time::Local qw( timelocal timelocal_nocheck ); # eventually replace with Dat
 use Tie::IxHash;
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs dbh dbdef );
+use FS::Cursor; # for upgrade
 use FS::pkg_svc;
 use FS::part_svc;
 use FS::cust_pkg;
 use FS::agent_type;
 use FS::type_pkgs;
 use FS::part_pkg_option;
+use FS::part_pkg_fcc_option;
 use FS::pkg_class;
 use FS::agent;
 use FS::part_pkg_msgcat;
@@ -118,6 +120,10 @@ part_pkg, will be equal to pkgpart.
 
 =item delay_start - Number of days to delay package start, by default
 
+=item start_on_hold - 'Y' to suspend this package immediately when it is 
+ordered. The package will not start billing or have a setup fee charged 
+until it is manually unsuspended.
+
 =back
 
 =head1 METHODS
@@ -162,7 +168,8 @@ I<custnum_ref> and I<options>.
 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
 values, appropriate FS::pkg_svc records will be inserted.  I<hidden_svc> can 
 be set to a hashref of svcparts and flag values ('Y' or '') to set the 
-'hidden' field in these records.
+'hidden' field in these records, and I<provision_hold> can be set similarly
+for the 'provision_hold' field in these records.
 
 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
 FS::pkg_svc record will be updated.
@@ -217,23 +224,6 @@ sub insert {
     }
   }
 
-  my $conf = new FS::Conf;
-  if ( $conf->exists('agent_defaultpkg') ) {
-    warn "  agent_defaultpkg set; allowing all agents to purchase package"
-      if $DEBUG;
-    foreach my $agent_type ( qsearch('agent_type', {} ) ) {
-      my $type_pkgs = new FS::type_pkgs({
-        'typenum' => $agent_type->typenum,
-        'pkgpart' => $self->pkgpart,
-      });
-      my $error = $type_pkgs->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return $error;
-      }
-    }
-  }
-
   warn "  inserting part_pkg_taxoverride records" if $DEBUG;
   my %overrides = %{ $options{'tax_overrides'} || {} };
   foreach my $usage_class ( keys %overrides ) {
@@ -259,6 +249,7 @@ sub insert {
     warn "  inserting pkg_svc records" if $DEBUG;
     my $pkg_svc = $options{'pkg_svc'} || {};
     my $hidden_svc = $options{'hidden_svc'} || {};
+    my $provision_hold = $options{'provision_hold'} || {};
     foreach my $part_svc ( qsearch('part_svc', {} ) ) {
       my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
       my $primary_svc =
@@ -272,6 +263,7 @@ sub insert {
         'quantity'    => $quantity, 
         'primary_svc' => $primary_svc,
         'hidden'      => $hidden_svc->{$part_svc->svcpart},
+        'provision_hold' => $provision_hold->{$part_svc->svcpart},
       } );
       my $error = $pkg_svc->insert;
       if ( $error ) {
@@ -319,6 +311,11 @@ sub insert {
       }
   }
 
+  if ( $options{fcc_options} ) {
+    warn "  updating fcc options " if $DEBUG;
+    $self->set_fcc_options( $options{fcc_options} );
+  }
+
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
@@ -341,13 +338,15 @@ sub delete {
 Replaces OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
-Currently available options are: I<pkg_svc>, I<hidden_svc>, I<primary_svc> 
-and I<options>
+Currently available options are: I<pkg_svc>, I<hidden_svc>, I<primary_svc>,
+I<bulk_skip>, I<provision_hold> and I<options>
 
 If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
 values, the appropriate FS::pkg_svc records will be replaced.  I<hidden_svc>
 can be set to a hashref of svcparts and flag values ('Y' or '') to set the 
-'hidden' field in these records.
+'hidden' field in these records.  I<provision_hold> can be set 
+to a hashref of svcparts and flag values ('Y' or '') to set the field 
+in those records.
 
 If I<primary_svc> is set to the svcpart of the primary service, the appropriate
 FS::pkg_svc record will be updated.
@@ -453,10 +452,12 @@ sub replace {
   warn "  replacing pkg_svc records" if $DEBUG;
   my $pkg_svc = $options->{'pkg_svc'};
   my $hidden_svc = $options->{'hidden_svc'} || {};
+  my $provision_hold = $options->{'provision_hold'} || {};
   if ( $pkg_svc ) { # if it wasn't passed, don't change existing pkg_svcs
     foreach my $part_svc ( qsearch('part_svc', {} ) ) {
       my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
       my $hidden = $hidden_svc->{$part_svc->svcpart} || '';
+      my $provision_hold = $provision_hold->{$part_svc->svcpart} || '';
       my $primary_svc =
         ( defined($options->{'primary_svc'}) && $options->{'primary_svc'}
           && $options->{'primary_svc'} == $part_svc->svcpart
@@ -472,16 +473,19 @@ sub replace {
       my $old_quantity = 0;
       my $old_primary_svc = '';
       my $old_hidden = '';
+      my $old_provision_hold = '';
       if ( $old_pkg_svc ) {
         $old_quantity = $old_pkg_svc->quantity;
         $old_primary_svc = $old_pkg_svc->primary_svc 
           if $old_pkg_svc->dbdef_table->column('primary_svc'); # is this needed?
         $old_hidden = $old_pkg_svc->hidden;
+        $old_provision_hold = $old_pkg_svc->provision_hold;
       }
    
       next unless $old_quantity != $quantity || 
                   $old_primary_svc ne $primary_svc ||
-                  $old_hidden ne $hidden;
+                  $old_hidden ne $hidden ||
+                  $old_provision_hold ne $provision_hold;
     
       my $new_pkg_svc = new FS::pkg_svc( {
         'pkgsvcnum'   => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ),
@@ -490,6 +494,7 @@ sub replace {
         'quantity'    => $quantity, 
         'primary_svc' => $primary_svc,
         'hidden'      => $hidden,
+        'provision_hold' => $provision_hold,
       } );
       my $error = $old_pkg_svc
                     ? $new_pkg_svc->replace($old_pkg_svc)
@@ -561,6 +566,11 @@ sub replace {
     }
   }
 
+  if ( $options->{fcc_options} ) {
+    warn "  updating fcc options " if $DEBUG;
+    $new->set_fcc_options( $options->{fcc_options} );
+  }
+
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
@@ -606,14 +616,15 @@ sub check {
     || $self->ut_textn('comment')
     || $self->ut_textn('promo_code')
     || $self->ut_alphan('plan')
-    || $self->ut_enum('setuptax', [ '', 'Y' ] )
-    || $self->ut_enum('recurtax', [ '', 'Y' ] )
+    || $self->ut_flag('setuptax')
+    || $self->ut_flag('recurtax')
     || $self->ut_textn('taxclass')
-    || $self->ut_enum('disabled', [ '', 'Y' ] )
-    || $self->ut_enum('custom', [ '', 'Y' ] )
-    || $self->ut_enum('no_auto', [ '', 'Y' ])
-    || $self->ut_enum('recur_show_zero', [ '', 'Y' ])
-    || $self->ut_enum('setup_show_zero', [ '', 'Y' ])
+    || $self->ut_flag('disabled')
+    || $self->ut_flag('custom')
+    || $self->ut_flag('no_auto')
+    || $self->ut_flag('recur_show_zero')
+    || $self->ut_flag('setup_show_zero')
+    || $self->ut_flag('start_on_hold')
     #|| $self->ut_moneyn('setup_cost')
     #|| $self->ut_moneyn('recur_cost')
     || $self->ut_floatn('setup_cost')
@@ -636,6 +647,7 @@ sub check {
     || $self->ut_numbern('delay_start')
     || $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart')
     || $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart')
+    || $self->ut_alphan('agent_pkgpartid')
     || $self->SUPER::check
   ;
   return $error if $error;
@@ -720,6 +732,44 @@ sub propagate {
   join("\n", @error);
 }
 
+=item set_fcc_options HASHREF
+
+Sets the FCC options on this package definition to the values specified
+in HASHREF.
+
+=cut
+
+sub set_fcc_options {
+  my $self = shift;
+  my $pkgpart = $self->pkgpart;
+  my $options;
+  if (ref $_[0]) {
+    $options = shift;
+  } else {
+    $options = { @_ };
+  }
+
+  my %existing_num = map { $_->fccoptionname => $_->num }
+                     qsearch('part_pkg_fcc_option', { pkgpart => $pkgpart });
+
+  local $FS::Record::nowarn_identical = 1;
+  # set up params for process_o2m
+  my $i = 0;
+  my $params = {};
+  foreach my $name (keys %$options ) {
+    $params->{ "num$i" } = $existing_num{$name} || '';
+    $params->{ "num$i".'_fccoptionname' } = $name;
+    $params->{ "num$i".'_optionvalue'   } = $options->{$name};
+    $i++;
+  }
+
+  $self->process_o2m(
+    table   => 'part_pkg_fcc_option',
+    fields  => [qw( fccoptionname optionvalue )],
+    params  => $params,
+  );
+}
+
 =item pkg_locale LOCALE
 
 Returns a customer-viewable string representing this package for the given
@@ -771,6 +821,16 @@ sub pkg_comment {
   $pre. $self->pkg. ( $custom_comment ? " - $custom_comment" : '' );
 }
 
+#without price info (so without hitting the DB again)
+sub pkg_comment_only {
+  my $self = shift;
+  my %opt = @_;
+
+  my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
+  my $comment = $self->comment;
+  $pre. $self->pkg. ( $comment ? " - $comment" : '' );
+}
+
 sub price_info { # safety, in case a part_pkg hasn't defined price_info
     '';
 }
@@ -784,6 +844,11 @@ sub custom_comment {
     $price_info;
 }
 
+sub pkg_price_info {
+  my $self = shift;
+  $self->pkg. ' - '. ($self->price_info || 'No charge');
+}
+
 =item pkg_class
 
 Returns the package class, as an FS::pkg_class object, or the empty string
@@ -1033,7 +1098,10 @@ sub is_free {
 sub can_discount { 0; }
 
 # whether the plan allows changing the start date
-sub can_start_date { 1; }
+sub can_start_date {
+  my $self = shift;
+  $self->start_on_hold ? 0 : 1;
+}
 
 # the delay start date if present
 sub delay_start_date {
@@ -1199,21 +1267,60 @@ will be suppressed.
 
 sub option {
   my( $self, $opt, $ornull ) = @_;
+
+  #cache: was pulled up in the original part_pkg query
+  if ( $opt =~ /^(setup|recur)_fee$/ && defined($self->hashref->{"_$opt"}) ) {
+    return $self->hashref->{"_$opt"};
+  }
+
+  cluck "$self -> option: searching for $opt"
+    if $DEBUG;
   my $part_pkg_option =
     qsearchs('part_pkg_option', {
       pkgpart    => $self->pkgpart,
       optionname => $opt,
   } );
   return $part_pkg_option->optionvalue if $part_pkg_option;
+
   my %plandata = map { /^(\w+)=(.*)$/; ( $1 => $2 ); }
                      split("\n", $self->get('plandata') );
   return $plandata{$opt} if exists $plandata{$opt};
   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
         "not found in options or plandata!\n"
     unless $ornull;
+
   '';
 }
 
+=item fcc_option OPTIONNAME
+
+Returns the FCC 477 report option value for the given name, or the empty 
+string.
+
+=cut
+
+sub fcc_option {
+  my ($self, $name) = @_;
+  my $part_pkg_fcc_option =
+    qsearchs('part_pkg_fcc_option', {
+        pkgpart => $self->pkgpart,
+        fccoptionname => $name,
+    });
+  $part_pkg_fcc_option ? $part_pkg_fcc_option->optionvalue : '';
+}
+
+=item fcc_options
+
+Returns all FCC 477 report options for this package, as a hash-like list.
+
+=cut
+
+sub fcc_options {
+  my $self = shift;
+  map { $_->fccoptionname => $_->optionvalue }
+    qsearch('part_pkg_fcc_option', { pkgpart => $self->pkgpart });
+}
+
 =item bill_part_pkg_link
 
 Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
@@ -1372,74 +1479,50 @@ sub taxproduct_description {
   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
 }
 
-=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
 
-Returns the package to taxrate m2m records for this package in the location
-specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
-CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
-(see L<FS::usage_class>).
+=item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
+
+Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
+package in the location specified by GEOCODE, for usage class CLASS (one of
+'setup', 'recur', null, or a C<usage_class> number).
 
 =cut
 
-sub _expand_cch_taxproductnum {
-  my $self = shift;
-  my $class = shift;
-  my $part_pkg_taxproduct = $self->taxproduct($class);
-
-  my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
-                         ? ( split ':', $part_pkg_taxproduct->taxproduct )
-                         : ()
-                     );
-  $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
-  my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
-                      OR taxproduct = '$a:$b:$c:'
-                      OR taxproduct = '$a:$b:".":$d'
-                      OR taxproduct = '$a:$b:".":' )";
-  map { $_->taxproductnum } qsearch( { 'table'     => 'part_pkg_taxproduct',
-                                       'hashref'   => { 'data_vendor'=>'cch' },
-                                       'extra_sql' => $extra_sql,
-                                   } );
-                                     
-}
-
-sub part_pkg_taxrate {
+sub tax_rates {
   my $self = shift;
-  my ($data_vendor, $geocode, $class) = @_;
-
-  my $dbh = dbh;
-  my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
-                  dbh->quote($data_vendor);
-  
-  # CCH oddness in m2m
-  $extra_sql .= ' AND ('.
-    join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
-                 qw(10 5 2)
-        ).
-    ')';
-  # much more CCH oddness in m2m -- this is kludgy
-  my @tpnums = $self->_expand_cch_taxproductnum($class);
-  if (scalar(@tpnums)) {
-    $extra_sql .= ' AND ('.
-                            join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
-                       ')';
-  } else {
-    $extra_sql .= ' AND ( 0 = 1 )';
+  my ($vendor, $geocode, $class) = @_;
+  # if this part_pkg is overridden into a specific taxclass, get that class
+  my @taxclassnums = map { $_->taxclassnum } 
+                     $self->part_pkg_taxoverride($class);
+  # otherwise, get its tax product category
+  if (!@taxclassnums) {
+    my $part_pkg_taxproduct = $self->taxproduct($class);
+    # If this isn't defined, then the class has no taxproduct designation,
+    # so return no tax rates.
+    return () if !$part_pkg_taxproduct;
+
+    # convert the taxproduct to the tax classes that might apply to it in 
+    # $geocode
+    @taxclassnums = map { $_->taxclassnum }
+                    grep { $_->taxable eq 'Y' } # why do we need this?
+                    $part_pkg_taxproduct->part_pkg_taxrate($geocode);
   }
+  return unless @taxclassnums;
 
-  my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
-  my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
-  my $select   = 'DISTINCT ON(taxclassnum) *, taxproduct';
+  # then look up the actual tax_rate entries
+  warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
+      if $DEBUG;
+  my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
+  my @taxes = qsearch({ 'table'     => 'tax_rate',
+                        'hashref'   => { 'geocode'     => $geocode,
+                                         'data_vendor' => $vendor,
+                                         'disabled'    => '' },
+                        'extra_sql' => $extra_sql,
+                      });
+  warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
+      if $DEBUG;
 
-  # should qsearch preface columns with the table to facilitate joins?
-  qsearch( { 'table'     => 'part_pkg_taxrate',
-             'select'    => $select,
-             'hashref'   => { # 'data_vendor'   => $data_vendor,
-                              # 'taxproductnum' => $self->taxproductnum,
-                            },
-             'addl_from' => $addl_from,
-             'extra_sql' => $extra_sql,
-             'order_by'  => $order_by,
-         } );
+  return @taxes;
 }
 
 =item part_pkg_discount
@@ -1518,7 +1601,7 @@ recur_cost divided by freq (only supported for monthly and longer frequencies)
 sub recur_cost_permonth {
   my($self, $cust_pkg) = @_;
   return 0 unless $self->freq =~ /^\d+$/ && $self->freq > 0;
-  sprintf('%.2f', $self->recur_cost / $self->freq );
+  sprintf('%.2f', ($self->recur_cost || 0) / $self->freq );
 }
 
 =item cust_bill_pkg_recur CUST_PKG
@@ -1555,6 +1638,28 @@ sub unit_setup {
   $self->option('setup_fee') || 0;
 }
 
+=item setup_margin
+
+unit_setup minus setup_cost
+
+=cut
+
+sub setup_margin {
+  my $self = shift;
+  $self->unit_setup(@_) - ($self->setup_cost || 0);
+}
+
+=item recur_margin_permonth
+
+base_recur_permonth minus recur_cost_permonth
+
+=cut
+
+sub recur_margin_permonth {
+  my $self = shift;
+  $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_);
+}
+
 =item format OPTION DATA
 
 Returns data formatted according to the function 'format' described
@@ -1622,16 +1727,20 @@ sub _upgrade_data { # class method
     $part_pkg->replace;
 
   }
+  # the rest can be done asynchronously
+}
 
+sub queueable_upgrade {
   # now upgrade to the explicit custom flag
 
-  @part_pkg = qsearch({
+  my $search = FS::Cursor->new({
     'table'     => 'part_pkg',
     'hashref'   => { disabled => 'Y', custom => '' },
     'extra_sql' => "AND comment LIKE '(CUSTOM) %'",
   });
+  my $dbh = dbh;
 
-  foreach my $part_pkg (@part_pkg) {
+  while (my $part_pkg = $search->fetch) {
     my $new = new FS::part_pkg { $part_pkg->hash };
     $new->custom('Y');
     my $comment = $part_pkg->comment;
@@ -1648,15 +1757,25 @@ sub _upgrade_data { # class method
                                'primary_svc' => $primary,
                                'options'     => $options,
                              );
-    die $error if $error;
+    if ($error) {
+      warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
+      $dbh->rollback;
+    } else {
+      $dbh->commit;
+    }
   }
 
   # set family_pkgpart on any packages that don't have it
-  @part_pkg = qsearch('part_pkg', { 'family_pkgpart' => '' });
-  foreach my $part_pkg (@part_pkg) {
+  $search = FS::Cursor->new('part_pkg', { 'family_pkgpart' => '' });
+  while (my $part_pkg = $search->fetch) {
     $part_pkg->set('family_pkgpart' => $part_pkg->pkgpart);
     my $error = $part_pkg->SUPER::replace;
-    die $error if $error;
+    if ($error) {
+      warn "pkgpart#".$part_pkg->pkgpart.": $error\n";
+      $dbh->rollback;
+    } else {
+      $dbh->commit;
+    }
   }
 
   my @part_pkg_option = qsearch('part_pkg_option',
@@ -1767,7 +1886,7 @@ sub _upgrade_data { # class method
       }
     } # $bad_upgrade exists
     else { # do the original upgrade, but correctly this time
-      @part_pkg = qsearch('part_pkg', {
+      my @part_pkg = qsearch('part_pkg', {
           fcc_ds0s        => { op => '>', value => 0 },
           fcc_voip_class  => ''
       });
@@ -1856,8 +1975,8 @@ sub _pkgs_sql {
 #false laziness w/part_export & cdr
 my %info;
 foreach my $INC ( @INC ) {
-  warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
-  foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
+  warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
+  foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
     warn "attempting to load plan info from $file\n" if $DEBUG;
     $file =~ /\/(\w+)\.pm$/ or do {
       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";