Merge branch 'master' of https://github.com/rvandam/Freeside
[freeside.git] / FS / FS / cust_main / Billing.pm
index 939a625..b8a71d4 100644 (file)
@@ -192,14 +192,30 @@ sub cancel_expired_pkgs {
 
   my @errors = ();
 
-  foreach my $cust_pkg ( @cancel_pkgs ) {
+  CUST_PKG: foreach my $cust_pkg ( @cancel_pkgs ) {
     my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
-    my $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
+    my $error;
+
+    if ( $cust_pkg->change_to_pkgnum ) {
+
+      my $new_pkg = FS::cust_pkg->by_key($cust_pkg->change_to_pkgnum);
+      if ( !$new_pkg ) {
+        push @errors, 'can\'t change pkgnum '.$cust_pkg->pkgnum.' to pkgnum '.
+                      $cust_pkg->change_to_pkgnum.'; not expiring';
+        next CUST_PKG;
+      }
+      $error = $cust_pkg->change( 'cust_pkg'        => $new_pkg,
+                                  'unprotect_svcs'  => 1 );
+      $error = '' if ref $error eq 'FS::cust_pkg';
+
+    } else { # just cancel it
+       $error = $cust_pkg->cancel($cpr ? ( 'reason'        => $cpr->reasonnum,
                                            'reason_otaker' => $cpr->otaker,
                                            'time'          => $time,
                                          )
                                        : ()
                                  );
+    }
     push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
   }
 
@@ -357,6 +373,11 @@ sub bill {
   my $time = $options{'time'} || time;
   my $invoice_time = $options{'invoice_time'} || $time;
 
+  my $cmp_time = ( $conf->exists('next-bill-ignore-time')
+                     ? day_end( $time )
+                     : $time
+                 );
+
   $options{'not_pkgpart'} ||= {};
   $options{'not_pkgpart'} = { map { $_ => 1 }
                                   split(/\s*,\s*/, $options{'not_pkgpart'})
@@ -437,6 +458,24 @@ sub bill {
     my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked;
     $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
  
+    # if this package was changed from another package,
+    # and it hasn't been billed since then,
+    # and package balances are enabled,
+    if ( $cust_pkg->change_pkgnum
+        and $cust_pkg->change_date >= ($cust_pkg->last_bill || 0)
+        and $cust_pkg->change_date <  $invoice_time
+      and $conf->exists('pkg-balances') )
+    {
+      # _transfer_balance will also create the appropriate credit
+      my @transfer_items = $self->_transfer_balance($cust_pkg);
+      # $part_pkg[0] is the "real" part_pkg
+      my $pass = ($cust_pkg->no_auto || $part_pkg[0]->no_auto) ? 
+                  'no_auto' : '';
+      push @{ $cust_bill_pkg{$pass} }, @transfer_items;
+      # treating this as recur, just because most charges are recur...
+      ${$total_recur{$pass}} += $_->recur foreach @transfer_items;
+    }
+
     foreach my $part_pkg ( @part_pkg ) {
 
       $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
@@ -446,7 +485,7 @@ sub bill {
       my $next_bill = $cust_pkg->getfield('bill') || 0;
       my $error;
       # let this run once if this is the last bill upon cancellation
-      while ( $next_bill <= $time or $options{cancel} ) {
+      while ( $next_bill <= $cmp_time or $options{cancel} ) {
         $error =
           $self->_make_lines( 'part_pkg'            => $part_pkg,
                               'cust_pkg'            => $cust_pkg,
@@ -933,6 +972,8 @@ sub _make_lines {
   my $unitsetup = 0;
   my @setup_discounts = ();
   my %setup_param = ( 'discounts' => \@setup_discounts );
+  my $setup_billed_currency = '';
+  my $setup_billed_amount = 0;
   if (     ! $options{recurring_only}
        and ! $options{cancel}
        and ( $options{'resetup'}
@@ -959,7 +1000,13 @@ sub _make_lines {
         return "$@ running calc_setup for $cust_pkg\n"
           if $@;
 
-        $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+        $unitsetup = $cust_pkg->base_setup()
+                       || $setup; #XXX uuh
+
+        if ( $setup_param{'billed_currency'} ) {
+          $setup_billed_currency = delete $setup_param{'billed_currency'};
+          $setup_billed_amount   = delete $setup_param{'billed_amount'};
+        }
     }
 
     $cust_pkg->setfield('setup', $time)
@@ -979,6 +1026,8 @@ sub _make_lines {
   my $recur = 0;
   my $unitrecur = 0;
   my @recur_discounts = ();
+  my $recur_billed_currency = '';
+  my $recur_billed_amount = 0;
   my $sdate;
   if (     ! $cust_pkg->start_date
        and ( ! $cust_pkg->susp || $cust_pkg->option('suspend_bill',1)
@@ -1040,6 +1089,11 @@ sub _make_lines {
     #base_cancel???
     $unitrecur = $cust_pkg->base_recur( \$sdate ) || $recur; #XXX uuh, better
 
+    if ( $param{'billed_currency'} ) {
+      $recur_billed_currency = delete $param{'billed_currency'};
+      $recur_billed_amount   = delete $param{'billed_amount'};
+    }
+
     if ( $increment_next_bill ) {
 
       my $next_bill;
@@ -1155,16 +1209,20 @@ sub _make_lines {
       push @details, @cust_pkg_detail;
 
       my $cust_bill_pkg = new FS::cust_bill_pkg {
-        'pkgnum'    => $cust_pkg->pkgnum,
-        'setup'     => $setup,
-        'unitsetup' => $unitsetup,
-        'recur'     => $recur,
-        'unitrecur' => $unitrecur,
-        'quantity'  => $cust_pkg->quantity,
-        'details'   => \@details,
-        'discounts' => [ @setup_discounts, @recur_discounts ],
-        'hidden'    => $part_pkg->hidden,
-        'freq'      => $part_pkg->freq,
+        'pkgnum'                => $cust_pkg->pkgnum,
+        'setup'                 => $setup,
+        'unitsetup'             => $unitsetup,
+        'setup_billed_currency' => $setup_billed_currency,
+        'setup_billed_amount'   => $setup_billed_amount,
+        'recur'                 => $recur,
+        'unitrecur'             => $unitrecur,
+        'recur_billed_currency' => $recur_billed_currency,
+        'recur_billed_amount'   => $recur_billed_amount,
+        'quantity'              => $cust_pkg->quantity,
+        'details'               => \@details,
+        'discounts'             => [ @setup_discounts, @recur_discounts ],
+        'hidden'                => $part_pkg->hidden,
+        'freq'                  => $part_pkg->freq,
       };
 
       if ( $part_pkg->option('prorate_defer_bill',1) 
@@ -1176,7 +1234,7 @@ sub _make_lines {
         $cust_bill_pkg->sdate( $hash{last_bill} );
         $cust_bill_pkg->edate( $sdate - 86399   ); #60s*60m*24h-1
         $cust_bill_pkg->edate( $time ) if $options{cancel};
-      } else { #if ( $part_pkg->recur_temporality eq 'upcoming' ) {
+      } else { #if ( $part_pkg->recur_temporality eq 'upcoming' )
         $cust_bill_pkg->sdate( $sdate );
         $cust_bill_pkg->edate( $cust_pkg->bill );
         #$cust_bill_pkg->edate( $time ) if $options{cancel};
@@ -1220,24 +1278,107 @@ sub _make_lines {
 
 }
 
-# This is _handle_taxes.  It's called once for each cust_bill_pkg generated
-# from _make_lines, along with the part_pkg, cust_pkg, invoice time, the 
-# non-overridden pkgpart, a flag indicating whether the package is being
-# canceled, and a partridge in a pear tree.
-#
-# The most important argument is 'taxlisthash'.  This is shared across the 
-# entire invoice.  It looks like this:
-# {
-#   'cust_main_county 1001' => [ [FS::cust_main_county], ... ],
-#   'cust_main_county 1002' => [ [FS::cust_main_county], ... ],
-# }
-#
-# 'cust_main_county' can also be 'tax_rate'.  The first object in the array
-# is always the cust_main_county or tax_rate identified by the key.
-#
-# That "..." is a list of FS::cust_bill_pkg objects that will be fed to 
-# the 'taxline' method to calculate the amount of the tax.  This doesn't
-# happen until calculate_taxes, though.
+=item _transfer_balance TO_PKG [ FROM_PKGNUM ]
+
+Takes one argument, a cust_pkg object that is being billed.  This will 
+be called only if the package was created by a package change, and has
+not been billed since the package change, and package balance tracking
+is enabled.  The second argument can be an alternate package number to 
+transfer the balance from; this should not be used externally.
+
+Transfers the balance from the previous package (now canceled) to
+this package, by crediting one package and creating an invoice item for 
+the other.  Inserts the credit and returns the invoice item (so that it 
+can be added to an invoice that's being built).
+
+If the previous package was never billed, and was also created by a package
+change, then this will also transfer the balance from I<its> previous 
+package, and so on, until reaching a package that either has been billed
+or was not created by a package change.
+
+=cut
+
+my $balance_transfer_reason;
+
+sub _transfer_balance {
+  my $self = shift;
+  my $cust_pkg = shift;
+  my $from_pkgnum = shift || $cust_pkg->change_pkgnum;
+  my $from_pkg = FS::cust_pkg->by_key($from_pkgnum);
+
+  my @transfers;
+
+  # if $from_pkg is not the first package in the chain, and it was never 
+  # billed, walk back
+  if ( $from_pkg->change_pkgnum and scalar($from_pkg->cust_bill_pkg) == 0 ) {
+    @transfers = $self->_transfer_balance($cust_pkg, $from_pkg->change_pkgnum);
+  }
+
+  my $prev_balance = $self->balance_pkgnum($from_pkgnum);
+  if ( $prev_balance != 0 ) {
+    $balance_transfer_reason ||= FS::reason->new_or_existing(
+      'reason' => 'Package balance transfer',
+      'type'   => 'Internal adjustment',
+      'class'  => 'R'
+    );
+
+    my $credit = FS::cust_credit->new({
+        'custnum'   => $self->custnum,
+        'amount'    => abs($prev_balance),
+        'reasonnum' => $balance_transfer_reason->reasonnum,
+        '_date'     => $cust_pkg->change_date,
+    });
+
+    my $cust_bill_pkg = FS::cust_bill_pkg->new({
+        'setup'     => 0,
+        'recur'     => abs($prev_balance),
+        #'sdate'     => $from_pkg->last_bill, # not sure about this
+        #'edate'     => $cust_pkg->change_date,
+        'itemdesc'  => $self->mt('Previous Balance, [_1]',
+                                 $from_pkg->part_pkg->pkg),
+    });
+
+    if ( $prev_balance > 0 ) {
+      # credit the old package, charge the new one
+      $credit->set('pkgnum', $from_pkgnum);
+      $cust_bill_pkg->set('pkgnum', $cust_pkg->pkgnum);
+    } else {
+      # the reverse
+      $credit->set('pkgnum', $cust_pkg->pkgnum);
+      $cust_bill_pkg->set('pkgnum', $from_pkgnum);
+    }
+    my $error = $credit->insert;
+    die "error transferring package balance from #".$from_pkgnum.
+        " to #".$cust_pkg->pkgnum.": $error\n" if $error;
+
+    push @transfers, $cust_bill_pkg;
+  } # $prev_balance != 0
+
+  return @transfers;
+}
+
+=item _handle_taxes PART_PKG TAXLISTHASH CUST_BILL_PKG CUST_PKG TIME PKGPART [ OPTIONS ]
+
+This is _handle_taxes.  It's called once for each cust_bill_pkg generated
+from _make_lines, along with the part_pkg, cust_pkg, invoice time, the 
+non-overridden pkgpart, a flag indicating whether the package is being
+canceled, and a partridge in a pear tree.
+
+The most important argument is 'taxlisthash'.  This is shared across the 
+entire invoice.  It looks like this:
+{
+  'cust_main_county 1001' => [ [FS::cust_main_county], ... ],
+  'cust_main_county 1002' => [ [FS::cust_main_county], ... ],
+}
+
+'cust_main_county' can also be 'tax_rate'.  The first object in the array
+is always the cust_main_county or tax_rate identified by the key.
+
+That "..." is a list of FS::cust_bill_pkg objects that will be fed to 
+the 'taxline' method to calculate the amount of the tax.  This doesn't
+happen until calculate_taxes, though.
+
+=cut
 
 sub _handle_taxes {
   my $self = shift;
@@ -1251,6 +1392,8 @@ sub _handle_taxes {
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
+  my $location = $cust_pkg->tax_location;
+
   return if ( $self->payby eq 'COMP' ); #dubious
 
   if ( $conf->exists('enable_taxproducts')
@@ -1293,7 +1436,7 @@ sub _handle_taxes {
 
     }
 
-    my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
+    my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate; # grrr
     foreach my $key (keys %tax_cust_bill_pkg) {
       # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
       # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of 
@@ -1308,11 +1451,6 @@ sub _handle_taxes {
 
         # this is the tax identifier, not the taxname
         my $taxname = ref( $tax ). ' '. $tax->taxnum;
-        $taxname .= ' billpkgnum'. $cust_bill_pkg->billpkgnum;
-        # We need to create a separate $taxlisthash entry for each billpkgnum
-        # on the invoice, so that cust_bill_pkg_tax_location records will
-        # be linked correctly.
-
         # $taxlisthash: keys are "setup", "recur", and usage classes.
         # Values are arrayrefs, first the tax object (cust_main_county
         # or tax_rate) and then any cust_bill_pkg objects that the 
@@ -1332,7 +1470,7 @@ sub _handle_taxes {
           if $DEBUG > 2;
         next unless $tax_object->can('tax_on_tax');
 
-        foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+        foreach my $tot ( $tax_object->tax_on_tax( $location ) ) {
           my $totname = ref( $tot ). ' '. $tot->taxnum;
 
           warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
@@ -1340,7 +1478,7 @@ sub _handle_taxes {
           next unless exists( $localtaxlisthash{ $totname } ); # only increase
                                                                # existing taxes
           warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
-          # we're calling taxline() right here?  wtf?
+          # calculate the tax amount that the tax_on_tax will apply to
           my $hashref_or_error = 
             $tax_object->taxline( $localtaxlisthash{$tax},
                                   'custnum'      => $self->custnum,
@@ -1349,6 +1487,7 @@ sub _handle_taxes {
           return $hashref_or_error
             unless ref($hashref_or_error);
           
+          # and append it to the list of taxable items
           $taxlisthash->{ $totname } ||= [ $tot ];
           push @{ $taxlisthash->{ $totname  } }, $hashref_or_error->{amount};
 
@@ -1364,7 +1503,6 @@ sub _handle_taxes {
     # because we need to record that fact.
 
     my @loc_keys = qw( district city county state country );
-    my $location = $cust_pkg->tax_location;
     my %taxhash = map { $_ => $location->$_ } @loc_keys;
 
     $taxhash{'taxclass'} = $part_pkg->taxclass;
@@ -1408,12 +1546,7 @@ sub _gather_taxes {
 
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
-  my $geocode;
-  if ( $cust_pkg->locationnum && $conf->exists('tax-pkg_address') ) {
-    $geocode = $cust_pkg->cust_location->geocode('cch');
-  } else {
-    $geocode = $self->geocode('cch');
-  }
+  my $geocode = $cust_pkg->tax_location->geocode('cch');
 
   my @taxes = ();