specify Avalara tax product for per-line taxes, #73063
[freeside.git] / FS / FS / part_pkg.pm
index 741eb87..ae63487 100644 (file)
@@ -4,7 +4,9 @@ use base qw( FS::part_pkg::API
            );
 
 use strict;
-use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
+use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack
+             $cache_enabled %cache_link %cache_pkg_svc
+           );
 use Carp qw(carp cluck confess);
 use Scalar::Util qw( blessed );
 use DateTime;
@@ -30,11 +32,17 @@ use FS::part_pkg_link;
 use FS::part_pkg_discount;
 use FS::part_pkg_vendor;
 use FS::part_pkg_currency;
+use FS::part_svc_link;
 
 $DEBUG = 0;
+
 $setup_hack = 0;
 $skip_pkg_svc_hack = 0;
 
+$cache_enabled = 0;
+%cache_link = ();
+%cache_pkg_svc = ();
+
 =head1 NAME
 
 FS::part_pkg - Object methods for part_pkg objects
@@ -122,6 +130,22 @@ 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.
+
+=item change_to_pkgpart - When this package is ordered, schedule a future 
+package change. The 'expire_months' field will determine when the package
+change occurs.
+
+=item expire_months - Number of months until this package expires (or changes
+to another package).
+
+=item adjourn_months - Number of months until this package becomes suspended.
+
+=item contract_end_months - Number of months until the package's contract 
+ends.
+
 =back
 
 =head1 METHODS
@@ -166,7 +190,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.
@@ -276,6 +301,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 =
@@ -289,6 +315,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 ) {
@@ -297,6 +324,12 @@ sub insert {
       }
     }
 
+    my $error = $self->check_pkg_svc(%options);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
   }
 
   if ( $options{'cust_pkg'} ) {
@@ -338,7 +371,7 @@ sub insert {
 
   if ( $options{fcc_options} ) {
     warn "  updating fcc options " if $DEBUG;
-    $self->process_fcc_options( $options{fcc_options} );
+    $self->set_fcc_options( $options{fcc_options} );
   }
 
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
@@ -363,15 +396,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.  I<bulk_skip> can be set to a hashref of
-svcparts and flag values ('Y' or '') to set the 'bulk_skip' field in those
-records.
+'hidden' field in these records.  I<bulk_skip> and I<provision_hold> can be set 
+to a hashref of svcparts and flag values ('Y' or '') to set the respective 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.
@@ -509,11 +542,14 @@ sub replace {
   my $pkg_svc = $options->{'pkg_svc'};
   my $hidden_svc = $options->{'hidden_svc'} || {};
   my $bulk_skip  = $options->{'bulk_skip'} || {};
+  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 $bulk_skip = $bulk_skip->{$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
@@ -530,18 +566,21 @@ sub replace {
       my $old_primary_svc = '';
       my $old_hidden = '';
       my $old_bulk_skip = '';
+      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_bulk_skip = $old_pkg_svc->old_bulk_skip;
+        $old_bulk_skip = $old_pkg_svc->old_bulk_skip; # should this just be bulk_skip?
+        $old_provision_hold = $old_pkg_svc->provision_hold;
       }
    
       next unless $old_quantity    != $quantity
                || $old_primary_svc ne $primary_svc
                || $old_hidden      ne $hidden
-               || $old_bulk_skip   ne $bulk_skip;
+               || $old_bulk_skip   ne $bulk_skip
+               || $old_provision_hold ne $provision_hold;
     
       my $new_pkg_svc = new FS::pkg_svc( {
         'pkgsvcnum'   => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ),
@@ -551,6 +590,7 @@ sub replace {
         'primary_svc' => $primary_svc,
         'hidden'      => $hidden,
         'bulk_skip'   => $bulk_skip,
+        'provision_hold' => $provision_hold,
       } );
       my $error = $old_pkg_svc
                     ? $new_pkg_svc->replace($old_pkg_svc)
@@ -560,6 +600,13 @@ sub replace {
         return $error;
       }
     } #foreach $part_svc
+
+    my $error = $new->check_pkg_svc(%$options);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+
   } #if $options->{pkg_svc}
   
   my @part_pkg_vendor = $old->part_pkg_vendor;
@@ -624,7 +671,7 @@ sub replace {
 
   if ( $options->{fcc_options} ) {
     warn "  updating fcc options " if $DEBUG;
-    $new->process_fcc_options( $options->{fcc_options} );
+    $new->set_fcc_options( $options->{fcc_options} );
   }
 
   warn "  committing transaction" if $DEBUG and $oldAutoCommit;
@@ -672,14 +719,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')
@@ -687,6 +735,7 @@ sub check {
     || $self->ut_floatn('pay_weight')
     || $self->ut_floatn('credit_weight')
     || $self->ut_numbern('taxproductnum')
+    || $self->ut_numbern('units_taxproductnum')
     || $self->ut_foreign_keyn('classnum',       'pkg_class', 'classnum')
     || $self->ut_foreign_keyn('addon_classnum', 'pkg_class', 'classnum')
     || $self->ut_foreign_keyn('taxproductnum',
@@ -702,6 +751,11 @@ 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_numbern('expire_months')
+    || $self->ut_numbern('adjourn_months')
+    || $self->ut_numbern('contract_end_months')
+    || $self->ut_numbern('change_to_pkgpart')
+    || $self->ut_foreign_keyn('change_to_pkgpart', 'part_pkg', 'pkgpart')
     || $self->ut_alphan('agent_pkgpartid')
     || $self->SUPER::check
   ;
@@ -717,6 +771,103 @@ sub check {
   '';
 }
 
+=item check_options
+
+For a passed I<$options> hashref, validates any options that
+have 'validate' subroutines defined in the info hash, 
+then validates the entire hashref if the price plan has 
+its own 'validate' subroutine defined in the info hash 
+(I<$options> values might be altered.)  
+
+Returns error message, or empty string if valid.
+
+Invoked by L</insert> and L</replace> via the equivalent
+methods in L<FS::option_Common>.
+
+=cut
+
+sub check_options {
+  my ($self,$options) = @_;
+  foreach my $option (keys %$options) {
+    if (exists $plans{ $self->plan }->{fields}->{$option}) {
+      if (exists($plans{$self->plan}->{fields}->{$option}->{'validate'})) {
+        # pass option name for use in error message
+        # pass a reference to the $options value, so it can be cleaned up
+        my $error = &{$plans{$self->plan}->{fields}->{$option}->{'validate'}}($option,\($options->{$option}));
+        return $error if $error;
+      }
+    } # else "option does not exist" error?
+  }
+  if (exists($plans{$self->plan}->{'validate'})) {
+    my $error = &{$plans{$self->plan}->{'validate'}}($options);
+    return $error if $error;
+  }
+  return '';
+}
+
+=item check_pkg_svc
+
+Checks pkg_svc records as a whole (for part_svc_link dependencies).
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub check_pkg_svc {
+  my( $self, %opt ) = @_;
+
+  my $agentnum = $self->agentnum;
+
+  my %pkg_svc = map { $_->svcpart => $_ } $self->pkg_svc;
+
+  foreach my $svcpart ( keys %pkg_svc ) {
+
+    foreach my $part_svc_link ( $self->part_svc_link(
+                                  'src_svcpart' => $svcpart,
+                                  'link_type'   => 'part_pkg_restrict',
+                                )
+    ) {
+
+      return $part_svc_link->dst_svc. ' must be included with '.
+             $part_svc_link->src_svc
+        unless $pkg_svc{ $part_svc_link->dst_svcpart };
+    }
+
+  }
+
+  return '' if $opt{part_pkg_restrict_soft_override};
+
+  foreach my $svcpart ( keys %pkg_svc ) {
+
+    foreach my $part_svc_link ( $self->part_svc_link(
+                                  'src_svcpart' => $svcpart,
+                                  'link_type'   => 'part_pkg_restrict_soft',
+                                )
+    ) {
+      return $part_svc_link->dst_svc. ' is suggested with '.
+             $part_svc_link->src_svc
+        unless $pkg_svc{ $part_svc_link->dst_svcpart };
+    }
+
+  }
+
+  '';
+}
+
+=item part_svc_link OPTION => VALUE ...
+
+Returns the service dependencies (see L<FS::part_svc_link>) for the given
+search options, taking into account this package definition's agent.
+
+Available options are any field in part_svc_link.  Typically used options are
+src_svcpart and link_type.
+
+=cut
+
+sub part_svc_link {
+  FS::part_svc_link->by_agentnum( shift->agentnum, @_ );
+}
+
 =item supersede OLD [, OPTION => VALUE ... ]
 
 Inserts this package as a successor to the package OLD.  All options are as
@@ -787,14 +938,14 @@ sub propagate {
   join("\n", @error);
 }
 
-=item process_fcc_options HASHREF
+=item set_fcc_options HASHREF
 
 Sets the FCC options on this package definition to the values specified
 in HASHREF.
 
 =cut
 
-sub process_fcc_options {
+sub set_fcc_options {
   my $self = shift;
   my $pkgpart = $self->pkgpart;
   my $options;
@@ -995,19 +1146,19 @@ definition.
 sub pkg_svc {
   my $self = shift;
 
+  return @{ $cache_pkg_svc{$self->pkgpart} }
+    if $cache_enabled && $cache_pkg_svc{$self->pkgpart};
+
 #  #sort { $b->primary cmp $a->primary } 
 #    grep { $_->quantity }
 #      qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
 
   my $opt = ref($_[0]) ? $_[0] : { @_ };
-  my %pkg_svc = map  { $_->svcpart => $_ }
-                grep { $_->quantity }
-                qsearch( 'pkg_svc', { 'pkgpart' => $self->pkgpart } );
+  my %pkg_svc = map  { $_->svcpart => $_ } $self->_pkg_svc;
 
   unless ( $opt->{disable_linked} ) {
     foreach my $dst_pkg ( map $_->dst_pkg, $self->svc_part_pkg_link ) {
-      my @pkg_svc = grep { $_->quantity }
-                    qsearch( 'pkg_svc', { pkgpart=>$dst_pkg->pkgpart } );
+      my @pkg_svc = $dst_pkg->_pkg_svc;
       foreach my $pkg_svc ( @pkg_svc ) {
         if ( $pkg_svc{$pkg_svc->svcpart} ) {
           my $quantity = $pkg_svc{$pkg_svc->svcpart}->quantity;
@@ -1019,10 +1170,25 @@ sub pkg_svc {
     }
   }
 
-  values(%pkg_svc);
+  my @pkg_svc = values(%pkg_svc);
+
+  $cache_pkg_svc{$self->pkgpart} = \@pkg_svc if $cache_enabled;
+
+  @pkg_svc;
 
 }
 
+sub _pkg_svc {
+  my $self = shift;
+  grep { $_->quantity }
+    qsearch({
+      'select'    => 'pkg_svc.*, part_svc.*',
+      'table'     => 'pkg_svc',
+      'addl_from' => 'LEFT JOIN part_svc USING ( svcpart )',
+      'hashref'   => { 'pkgpart' => $self->pkgpart },
+    });
+}
+
 =item svcpart [ SVCDB ]
 
 Returns the svcpart of the primary service definition (see L<FS::part_svc>)
@@ -1130,7 +1296,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;
+}
 
 # whether the plan supports part_pkg_usageprice add-ons (a specific kind of
 #  pre-selectable usage pricing, there's others this doesn't refer to)
@@ -1288,20 +1457,31 @@ will be suppressed.
 
 sub option {
   my( $self, $opt, $ornull ) = @_;
-  cluck "$self -> option: searching for $opt"
-    if $DEBUG;
+
+  #cache: was pulled up in the original part_pkg query
+  return $self->hashref->{"_opt_$opt"}
+    if exists $self->hashref->{"_opt_$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};
+
+  # check whether the option is defined in plan info (if so, don't warn)
+  if (exists $plans{ $self->plan }->{fields}->{$opt}) {
+    return '';
+  }
   cluck "WARNING: (pkgpart ". $self->pkgpart. ") Package def option $opt ".
         "not found in options or plandata!\n"
     unless $ornull;
+
   '';
 }
 
@@ -1416,14 +1596,25 @@ sub supp_part_pkg_link {
 
 sub _part_pkg_link {
   my( $self, $type ) = @_;
-  qsearch({ table    => 'part_pkg_link',
-            hashref  => { 'src_pkgpart' => $self->pkgpart,
-                          'link_type'   => $type,
-                          #protection against infinite recursive links
-                          'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
-                        },
-            order_by => "ORDER BY hidden",
-         });
+
+  return @{ $cache_link{$type}->{$self->pkgpart} }
+    if $cache_enabled && $cache_link{$type}->{$self->pkgpart};
+
+  cluck $type.'_part_pkg_link called' if $DEBUG;
+
+  my @ppl = 
+    qsearch({ table    => 'part_pkg_link',
+              hashref  => { src_pkgpart => $self->pkgpart,
+                            link_type   => $type,
+                            #protection against infinite recursive links
+                            dst_pkgpart => { op=>'!=', value=> $self->pkgpart },
+                          },
+              order_by => "ORDER BY hidden",
+           });
+
+  $cache_link{$type}->{$self->pkgpart} = \@ppl if $cache_enabled;
+
+  return @ppl;
 }
 
 sub self_and_bill_linked {
@@ -1541,6 +1732,19 @@ sub taxproduct_description {
   $part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
 }
 
+=item units_taxproduct
+
+Returns the L<FS::part_pkg_taxproduct> record used to report the taxable
+service units (usually phone lines) on this package.
+
+=cut
+
+sub units_taxproduct {
+  my $self = shift;
+  $self->units_taxproductnum
+    ? FS::part_pkg_taxproduct->by_key($self->units_taxproductnum)
+    : '';
+}
 
 =item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
 
@@ -1553,8 +1757,10 @@ package in the location specified by GEOCODE, for usage class CLASS (one of
 sub tax_rates {
   my $self = shift;
   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,
@@ -1575,7 +1781,8 @@ sub tax_rates {
   my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
   my @taxes = qsearch({ 'table'     => 'tax_rate',
                         'hashref'   => { 'geocode'     => $geocode,
-                                         'data_vendor' => $vendor },
+                                         'data_vendor' => $vendor,
+                                         'disabled'    => '' },
                         'extra_sql' => $extra_sql,
                       });
   warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
@@ -1594,6 +1801,19 @@ for this package.
 Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for 
 this package.
 
+=item change_to_pkg
+
+Returns the automatic transfer target for this package, or an empty string
+if there isn't one.
+
+=cut
+
+sub change_to_pkg {
+  my $self = shift;
+  my $pkgpart = $self->change_to_pkgpart or return '';
+  FS::part_pkg->by_key($pkgpart);
+}
+
 =item _rebless
 
 Reblesses the object into the FS::part_pkg::PLAN class (if available), where
@@ -1619,16 +1839,134 @@ sub _rebless {
   $self;
 }
 
+=item calc_setup CUST_PKG START_DATE DETAILS_ARRAYREF OPTIONS_HASHREF
+
+=item calc_recur CUST_PKG START_DATE DETAILS_ARRAYREF OPTIONS_HASHREF
+
+Calculates and returns the setup or recurring fees, respectively, for this
+package.  Implementation is in the FS::part_pkg:* module specific to this price
+plan.
+
+Adds invoicing details to the passed-in DETAILS_ARRAYREF
+
+Options are passed as a hashref.  Available options:
+
+=over 4
+
+=item freq_override
+
+Frequency override (for calc_recur)
+
+=item discounts
+
+This option is filled in by the method rather than controlling its operation.
+It is an arrayref.  Applicable discounts will be added to the arrayref, as
+L<FS::cust_bill_pkg_discount|FS::cust_bill_pkg_discount records>.
+
+=item real_pkgpart
+
+For package add-ons, is the base L<FS::part_pkg|package definition>, otherwise
+no different than pkgpart.
+
+=item precommit_hooks
+
+This option is filled in by the method rather than controlling its operation.
+It is an arrayref.  Anonymous coderefs will be added to the arrayref.  They
+need to be called before completing the billing operation.  For calc_recur
+only.
+
+=item increment_next_bill
+
+Increment the next bill date (boolean, for calc_recur).  Typically true except
+for particular situations.
+
+=item setup_fee
+
+This option is filled in by the method rather than controlling its operation.
+It indicates a deferred setup fee that is billed at calc_recur time (see price
+plan option prorate_defer_bill).
+
+=back
+
+Note: Don't calculate prices when not actually billing the package.  For that,
+see the L</base_setup|base_setup> and L</base_recur|base_recur> methods.
+
+=cut
+
 #fatal fallbacks
 sub calc_setup { die 'no calc_setup for '. shift->plan. "\n"; }
 sub calc_recur { die 'no calc_recur for '. shift->plan. "\n"; }
 
-#fallback that return 0 for old legacy packages with no plan
+=item calc_remain CUST_PKG [ OPTION => VALUE ... ]
+
+Calculates and returns the remaining value to be credited upon package
+suspension, change, or cancellation, if enabled.
+
+Options are passed as a list of keys and values.  Available options:
+
+=over 4
+
+=item time
+
+Override for the current time
+
+=item cust_credit_source_bill_pkg
+
+This option is filled in by the method rather than controlling its operation.
+It is an arrayref.
+L<FS::cust_credit_source_bill_pkg|FS::cust_credit_source_bill_pkg> records will
+be added to the arrayref indicating the specific line items and amounts which
+are the source of this remaining credit.
+
+=back
+
+Note: Don't calculate prices when not actually suspending or cancelling the
+package.
+
+=cut
+
+#fallback that returns 0 for old legacy packages with no plan
 sub calc_remain { 0; }
-sub calc_units  { 0; }
+
+=item calc_units CUST_PKG
+
+This returns the number of provisioned svc_phone records, or, of the package
+count_available_phones option is set, the number available to be provisioned
+in the package.
+
+=cut
+
+sub calc_units {
+  my($self, $cust_pkg ) = @_;
+  my $count = 0;
+  if ( $self->option('count_available_phones', 1)) {
+    foreach my $pkg_svc ($cust_pkg->part_pkg->pkg_svc) {
+      if ($pkg_svc->part_svc->svcdb eq 'svc_phone') { # svc_pbx?
+        $count += $pkg_svc->quantity || 0;
+      }
+    }
+    $count *= $cust_pkg->quantity;
+  } else {
+    $count =
+      scalar(grep { $_->part_svc->svcdb eq 'svc_phone' } $cust_pkg->cust_svc);
+  }
+  $count;
+}
 
 #fallback for everything not based on flat.pm
 sub recur_temporality { 'upcoming'; }
+
+=item calc_cancel START_DATE DETAILS_ARRAYREF OPTIONS_HASHREF
+
+Runs any necessary billing on cancellation: another recurring cycle for
+recur_temporailty 'preceding' pacakges with the bill_recur_on_cancel option
+set (calc_recur), or, any outstanding usage for pacakges with the
+bill_usage_on_cancel option set (calc_usage).
+
+=cut
+
+#fallback for everything not based on flat.pm, doesn't do this yet (which is
+#okay, nothing of ours not based on flat.pm does usage-on-cancel billing
 sub calc_cancel { 0; }
 
 #fallback for everything except bulk.pm
@@ -1646,7 +1984,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
@@ -1691,7 +2029,7 @@ unit_setup minus setup_cost
 
 sub setup_margin {
   my $self = shift;
-  $self->unit_setup(@_) - $self->setup_cost;
+  $self->unit_setup(@_) - ($self->setup_cost || 0);
 }
 
 =item recur_margin_permonth
@@ -1705,6 +2043,18 @@ sub recur_margin_permonth {
   $self->base_recur_permonth(@_) - $self->recur_cost_permonth(@_);
 }
 
+=item intro_end PACKAGE
+
+Takes an L<FS::cust_pkg> object.  If this plan has an introductory rate,
+returns the expected date the intro period will end. If there is no intro
+rate, returns zero.
+
+=cut
+
+sub intro_end {
+  0;
+}
+
 =item format OPTION DATA
 
 Returns data formatted according to the function 'format' described
@@ -1752,7 +2102,7 @@ sub parse {
 # Used by FS::Upgrade to migrate to a new database.
 
 sub _upgrade_data { # class method
-  my($class, %opts) = @_;
+   my($class, %opts) = @_;
 
   warn "[FS::part_pkg] upgrading $class\n" if $DEBUG;
 
@@ -1772,6 +2122,49 @@ sub _upgrade_data { # class method
     $part_pkg->replace;
 
   }
+
+  # Convert RADIUS accounting usage metrics from megabytes to gigabytes
+  # (FS RT#28105)
+  my $upgrade = 'part_pkg_gigabyte_usage';
+  if (!FS::upgrade_journal->is_done($upgrade)) {
+    foreach my $part_pkg (qsearch('part_pkg',
+                                 { plan => 'sqlradacct_hour' })
+                        ){
+
+      my $pkgpart = $part_pkg->pkgpart;
+
+      foreach my $opt (qsearch('part_pkg_option',
+                              { 'optionname'  => { op => 'LIKE',
+                                                   value => 'recur_included_%',
+                                                 },
+                                pkgpart => $pkgpart,
+                              })){
+
+        next if $opt->optionname eq 'recur_included_hours'; # unfortunately named field
+        next if $opt->optionvalue == 0;
+
+       $opt->optionvalue($opt->optionvalue / 1024);
+
+       my $error = $opt->replace;
+       die $error if $error;
+      }
+
+      foreach my $opt (qsearch('part_pkg_option',
+                              { 'optionname'  => { op => 'LIKE',
+                                                   value => 'recur_%_charge',
+                                                 },
+                                pkgpart => $pkgpart,
+                              })){
+       $opt->optionvalue($opt->optionvalue * 1024);
+
+       my $error = $opt->replace;
+       die $error if $error;
+      }
+
+    }
+    FS::upgrade_journal->set_done($upgrade);
+  }
+
   # the rest can be done asynchronously
 }
 
@@ -1953,6 +2346,19 @@ sub queueable_upgrade {
     FS::upgrade_journal->set_done($upgrade);
   }
 
+  # migrate adjourn_months, expire_months, and contract_end_months to 
+  # real fields
+  foreach my $field (qw(adjourn_months expire_months contract_end_months)) {
+    foreach my $option (qsearch('part_pkg_option', { optionname => $field })) {
+      my $part_pkg = $option->part_pkg;
+      my $error = $option->delete;
+      if ( $option->optionvalue and $part_pkg->get($field) eq '' ) {
+        $part_pkg->set($field, $option->optionvalue);
+        $error ||= $part_pkg->replace;
+      }
+      die $error if $error;
+    }
+  }
 }
 
 =item curuser_pkgs_sql
@@ -2007,6 +2413,26 @@ sub _pkgs_sql {
 
 }
 
+=item join_options_sql
+
+Returns an SQL fragment for JOINing the part_pkg_option records for this
+package's setup_fee and recur_fee (as setup_option and recur_option,
+respectively).  Useful for optimization.
+
+=cut
+
+sub join_options_sql {
+  #my $class = shift;
+  "
+    LEFT JOIN part_pkg_option AS setup_option
+      ON (     part_pkg.pkgpart = setup_option.pkgpart
+           AND setup_option.optionname = 'setup_fee' )
+    LEFT JOIN part_pkg_option AS recur_option
+      ON (     part_pkg.pkgpart = recur_option.pkgpart
+           AND recur_option.optionname = 'recur_fee' )
+  ";
+}
+
 =back
 
 =head1 SUBROUTINES