[freeside-commits] branch master updated. ceaa9e6ca4222595c1795c4bbde8d1c9609045e7

Mark Wells mark at 420.am
Sat Jan 12 12:09:22 PST 2013


The branch, master has been updated
       via  ceaa9e6ca4222595c1795c4bbde8d1c9609045e7 (commit)
       via  b70b0d8c6f571a68ffb60c5ca728a230926abee4 (commit)
      from  e722e522c695781f626adfdf36bf0d130698f665 (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit ceaa9e6ca4222595c1795c4bbde8d1c9609045e7
Merge: b70b0d8 e722e52
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Jan 12 12:08:42 2013 -0800

    Merge branch 'master' of git.freeside.biz:/home/git/freeside


commit b70b0d8c6f571a68ffb60c5ca728a230926abee4
Author: Mark Wells <mark at freeside.biz>
Date:   Sat Jan 12 12:03:16 2013 -0800

    supplemental packages, #20689

diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 69e21da..5ac2b5f 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1738,6 +1738,8 @@ sub tables_hashref {
         'change_pkgnum',       'int', 'NULL', '', '', '',
         'change_pkgpart',      'int', 'NULL', '', '', '',
         'change_locationnum',  'int', 'NULL', '', '', '',
+        'main_pkgnum',         'int', 'NULL', '', '', '',
+        'pkglinknum',          'int', 'NULL', '', '', '',
         'manual_flag',        'char', 'NULL',  1, '', '', 
         'no_auto',            'char', 'NULL',  1, '', '', 
         'quantity',            'int', 'NULL', '', '', '',
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
index cd46c73..deb5e84 100644
--- a/FS/FS/cust_main/Billing.pm
+++ b/FS/FS/cust_main/Billing.pm
@@ -410,6 +410,7 @@ sub bill {
   my @precommit_hooks = ();
 
   $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ];  #param checks?
+
   foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
 
     next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
@@ -1031,9 +1032,31 @@ sub _make_lines {
 
     if ( $increment_next_bill ) {
 
-      my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
+      my $next_bill;
+
+      if ( my $main_pkg = $cust_pkg->main_pkg ) {
+        # supplemental package
+        # to keep in sync with the main package, simulate billing at 
+        # its frequency
+        my $main_pkg_freq = $main_pkg->part_pkg->freq;
+        my $supp_pkg_freq = $part_pkg->freq;
+        my $ratio = $supp_pkg_freq / $main_pkg_freq;
+        if ( $ratio != int($ratio) ) {
+          # the UI should prevent setting up packages like this, but just
+          # in case
+          return "supplemental package period is not an integer multiple of main  package period";
+        }
+        $next_bill = $sdate;
+        for (1..$ratio) {
+          $next_bill = $part_pkg->add_freq( $next_bill, $main_pkg_freq );
+        }
+
+      } else {
+        # the normal case
+      $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
       return "unparsable frequency: ". $part_pkg->freq
         if $next_bill == -1;
+      }  
   
       #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
       # only for figuring next bill date, nothing else, so, reset $sdate again
diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm
index 395cce7..ee38f04 100644
--- a/FS/FS/cust_main/Packages.pm
+++ b/FS/FS/cust_main/Packages.pm
@@ -29,6 +29,9 @@ These methods are available on FS::cust_main objects;
 
 Orders a single package.
 
+Note that if the package definition has supplemental packages, those will
+be ordered as well.
+
 Options may be passed as a list of key/value pairs or as a hash reference.
 Options are:
 
@@ -141,6 +144,34 @@ sub order_pkg {
     }
   }
 
+  # add supplemental packages, if any are needed
+  my $part_pkg = FS::part_pkg->by_key($cust_pkg->pkgpart);
+  foreach my $link ($part_pkg->supp_part_pkg_link) {
+    warn "inserting supplemental package ".$link->dst_pkgpart;
+    my $pkg = FS::cust_pkg->new({
+        'pkgpart'       => $link->dst_pkgpart,
+        'pkglinknum'    => $link->pkglinknum,
+        'custnum'       => $self->custnum,
+        'main_pkgnum'   => $cust_pkg->pkgnum,
+        'locationnum'   => $cust_pkg->locationnum,
+        # try to prevent as many surprises as possible
+        'pkgbatch'      => $cust_pkg->pkgbatch,
+        'start_date'    => $cust_pkg->start_date,
+        'order_date'    => $cust_pkg->order_date,
+        'expire'        => $cust_pkg->expire,
+        'adjourn'       => $cust_pkg->adjourn,
+        'contract_end'  => $cust_pkg->contract_end,
+        'refnum'        => $cust_pkg->refnum,
+        'discountnum'   => $cust_pkg->discountnum,
+        'waive_setup'   => $cust_pkg->waive_setup,
+    });
+    $error = $self->order_pkg('cust_pkg' => $pkg);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "inserting supplemental package: $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   ''; #no error
 
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 22a7b2c..6d85a11 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -197,6 +197,15 @@ Previous locationnum
 
 =item waive_setup
 
+=item main_pkgnum
+
+The pkgnum of the package that this package is supplemental to, if any.
+
+=item pkglinknum
+
+The package link (L<FS::part_pkg_link>) that defines this supplemental
+package, if it is one.
+
 =back
 
 Note: setup, last_bill, bill, adjourn, susp, expire, cancel and change_date
@@ -616,6 +625,8 @@ sub check {
     || $self->ut_numbern('agent_pkgid')
     || $self->ut_enum('recur_show_zero', [ '', 'Y', 'N', ])
     || $self->ut_enum('setup_show_zero', [ '', 'Y', 'N', ])
+    || $self->ut_foreign_keyn('main_pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_foreign_keyn('pkglinknum', 'part_pkg_link', 'pkglinknum')
   ;
   return $error if $error;
 
@@ -730,6 +741,11 @@ sub cancel {
   my( $self, %options ) = @_;
   my $error;
 
+  # pass all suspend/cancel actions to the main package
+  if ( $self->main_pkgnum and !$options{'from_main'} ) {
+    return $self->main_pkg->cancel(%options);
+  }
+
   my $conf = new FS::Conf;
 
   warn "cust_pkg::cancel called with options".
@@ -835,6 +851,14 @@ sub cancel {
     return $error;
   }
 
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    $error = $supp_pkg->cancel(%options, 'from_main' => 1);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   return '' if $date; #no errors
 
@@ -894,6 +918,9 @@ svc_fatal: service provisioning errors are fatal
 
 svc_errors: pass an array reference, will be filled in with any provisioning errors
 
+main_pkgnum: link the package as a supplemental package of this one.  For 
+internal use only.
+
 =cut
 
 sub uncancel {
@@ -902,6 +929,10 @@ sub uncancel {
   #in case you try do do $uncancel-date = $cust_pkg->uncacel 
   return '' unless $self->get('cancel');
 
+  if ( $self->main_pkgnum and !$options{'main_pkgnum'} ) {
+    return $self->main_pkg->uncancel(%options);
+  }
+
   ##
   # Transaction-alize
   ##
@@ -926,6 +957,7 @@ sub uncancel {
     bill            => ( $options{'bill'}      || $self->get('bill')      ),
     uncancel        => time,
     uncancel_pkgnum => $self->pkgnum,
+    main_pkgnum     => ($options{'main_pkgnum'} || ''),
     map { $_ => $self->get($_) } qw(
       custnum pkgpart locationnum
       setup
@@ -1023,6 +1055,20 @@ sub uncancel {
   }
 
   ##
+  # Uncancel any supplemental packages, and make them supplemental to the 
+  # new one.
+  ##
+
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    my $new_pkg;
+    $error = $supp_pkg->uncancel(%options, 'main_pkgnum' => $cust_pkg->pkgnum);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "canceling supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
+  }
+
+  ##
   # Finish
   ##
 
@@ -1111,6 +1157,9 @@ of final invoices or unused-time credits
 unsuspended.  This may be more convenient than calling C<unsuspend()>
 separately.
 
+=item from_main - allows a supplemental package to be suspended, rather
+than redirecting the method call to its main package.  For internal use.
+
 =back
 
 If there is an error, returns the error, otherwise returns false.
@@ -1121,6 +1170,11 @@ sub suspend {
   my( $self, %options ) = @_;
   my $error;
 
+  # pass all suspend/cancel actions to the main package
+  if ( $self->main_pkgnum and !$options{'from_main'} ) {
+    return $self->main_pkg->suspend(%options);
+  }
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE'; 
@@ -1271,6 +1325,14 @@ sub suspend {
 
   }
 
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    $error = $supp_pkg->suspend(%options, 'from_main' => 1);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "suspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ''; #no errors
@@ -1353,6 +1415,11 @@ sub unsuspend {
   my( $self, %opt ) = @_;
   my $error;
 
+  # pass all suspend/cancel actions to the main package
+  if ( $self->main_pkgnum and !$opt{'from_main'} ) {
+    return $self->main_pkg->unsuspend(%opt);
+  }
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE'; 
@@ -1511,6 +1578,14 @@ sub unsuspend {
 
   }
 
+  foreach my $supp_pkg ( $self->supplemental_pkgs ) {
+    $error = $supp_pkg->unsuspend(%opt, 'from_main' => 1);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "unsuspending supplemental pkg#".$supp_pkg->pkgnum.": $error";
+    }
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ''; #no errors
@@ -1700,7 +1775,6 @@ sub change {
     locationnum  => ( $opt->{'locationnum'}                        ),
     %hash,
   };
-
   $error = $cust_pkg->insert( 'change' => 1 );
   if ($error) {
     $dbh->rollback if $oldAutoCommit;
@@ -1749,11 +1823,63 @@ sub change {
     }
   }
 
+  # Order any supplemental packages.
+  my $part_pkg = $cust_pkg->part_pkg;
+  my @old_supp_pkgs = $self->supplemental_pkgs;
+  my @new_supp_pkgs;
+  foreach my $link ($part_pkg->supp_part_pkg_link) {
+    my $old;
+    foreach (@old_supp_pkgs) {
+      if ($_->pkgpart == $link->dst_pkgpart) {
+        $old = $_;
+        $_->pkgpart(0); # so that it can't match more than once
+      }
+      last if $old;
+    }
+    # false laziness with FS::cust_main::Packages::order_pkg
+    my $new = FS::cust_pkg->new({
+        pkgpart       => $link->dst_pkgpart,
+        pkglinknum    => $link->pkglinknum,
+        custnum       => $self->custnum,
+        main_pkgnum   => $cust_pkg->pkgnum,
+        locationnum   => $cust_pkg->locationnum,
+        start_date    => $cust_pkg->start_date,
+        order_date    => $cust_pkg->order_date,
+        expire        => $cust_pkg->expire,
+        adjourn       => $cust_pkg->adjourn,
+        contract_end  => $cust_pkg->contract_end,
+        refnum        => $cust_pkg->refnum,
+        discountnum   => $cust_pkg->discountnum,
+        waive_setup   => $cust_pkg->waive_setup
+    });
+    if ( $old and $opt->{'keep_dates'} ) {
+      foreach (qw(setup bill last_bill)) {
+        $new->set($_, $old->get($_));
+      }
+    }
+    $error = $new->insert;
+    # transfer services
+    if ( $old ) {
+      $error ||= $old->transfer($new);
+    }
+    if ( $error and $error > 0 ) {
+      # no reason why this should ever fail, but still...
+      $error = "Unable to transfer all services from supplemental package ".
+        $old->pkgnum;
+    }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+    push @new_supp_pkgs, $new;
+  }
+
   #Good to go, cancel old package.  Notify 'cancel' of whether to credit 
   #remaining time.
   #Don't allow billing the package (preceding period packages and/or 
   #outstanding usage) if we are keeping dates (i.e. location changing), 
   #because the new package will be billed for the same date range.
+  #Supplemental packages are also canceled here.
   $error = $self->cancel(
     quiet         => 1, 
     unused_credit => $unused_credit,
@@ -1766,7 +1892,9 @@ sub change {
 
   if ( $conf->exists('cust_pkg-change_pkgpart-bill_now') ) {
     #$self->cust_main
-    my $error = $cust_pkg->cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
+    my $error = $cust_pkg->cust_main->bill( 
+      'pkg_list' => [ $cust_pkg, @new_supp_pkgs ]
+    );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
@@ -3139,6 +3267,31 @@ sub cust_pkg_discount_active {
 
 =back
 
+=item supplemental_pkgs
+
+Returns a list of all packages supplemental to this one.
+
+=cut
+
+sub supplemental_pkgs {
+  my $self = shift;
+  qsearch('cust_pkg', { 'main_pkgnum' => $self->pkgnum });
+}
+
+=item main_pkg
+
+Returns the package that this one is supplemental to, if any.
+
+=cut
+
+sub main_pkg {
+  my $self = shift;
+  if ( $self->main_pkgnum ) {
+    return FS::cust_pkg->by_key($self->main_pkgnum);
+  }
+  return;
+}
+
 =head1 CLASS METHODS
 
 =over 4
@@ -3951,11 +4104,25 @@ sub order {
                                       %hash,
                                     };
     $error = $cust_pkg->insert( 'change' => $change );
+    push @$return_cust_pkg, $cust_pkg;
+
+    foreach my $link ($cust_pkg->part_pkg->supp_part_pkg_link) {
+      my $supp_pkg = FS::cust_pkg->new({
+          custnum => $custnum,
+          pkgpart => $link->dst_pkgpart,
+          refnum  => $refnum,
+          main_pkgnum => $cust_pkg->pkgnum,
+          %hash,
+      });
+      $error ||= $supp_pkg->insert( 'change' => $change );
+      push @$return_cust_pkg, $supp_pkg;
+    }
+
     if ($error) {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
-    push @$return_cust_pkg, $cust_pkg;
+
   }
   # $return_cust_pkg now contains refs to all of the newly 
   # created packages.
diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm
index 6e7f8f8..d4c420f 100644
--- a/FS/FS/part_pkg.pm
+++ b/FS/FS/part_pkg.pm
@@ -1175,6 +1175,17 @@ sub svc_part_pkg_link {
   shift->_part_pkg_link('svc', @_);
 }
 
+=item supp_part_pkg_link
+
+Returns the associated part_pkg_link records of type 'supp' (supplemental
+packages).
+
+=cut
+
+sub supp_part_pkg_link {
+  shift->_part_pkg_link('supp', @_);
+}
+
 sub _part_pkg_link {
   my( $self, $type ) = @_;
   qsearch({ table    => 'part_pkg_link',
diff --git a/FS/FS/part_pkg_link.pm b/FS/FS/part_pkg_link.pm
index fb7a8d3..9ce8e6a 100644
--- a/FS/FS/part_pkg_link.pm
+++ b/FS/FS/part_pkg_link.pm
@@ -49,12 +49,13 @@ Destination package (see L<FS::part_pkg>)
 =item link_type
 
 Link type - currently, "bill" (source package bills a line item from target
-package), or "svc" (source package includes services from target package).
+package), or "svc" (source package includes services from target package), 
+or "supp" (ordering source package creates a target package).
 
 =item hidden
 
 Flag indicating that this subpackage should be felt, but not seen as an invoice
-line item when set to 'Y'
+line item when set to 'Y'.  Not allowed for "supp" links.
 
 =back
 
@@ -119,11 +120,26 @@ sub check {
     $self->ut_numbern('pkglinknum')
     || $self->ut_foreign_key('src_pkgpart', 'part_pkg', 'pkgpart')
     || $self->ut_foreign_key('dst_pkgpart', 'part_pkg', 'pkgpart')
-    || $self->ut_enum('link_type', [ 'bill', 'svc' ] )
+    || $self->ut_enum('link_type', [ 'bill', 'svc', 'supp' ] )
     || $self->ut_enum('hidden', [ '', 'Y' ] )
   ;
   return $error if $error;
 
+  if ( $self->link_type eq 'supp' ) {
+    # some sanity checking
+    my $src_pkg = $self->src_pkg;
+    my $dst_pkg = $self->dst_pkg;
+    if ( $src_pkg->freq eq '0' and $dst_pkg->freq ne '0' ) {
+      return "One-time charges can't have supplemental packages."
+    } elsif ( $dst_pkg->freq ne '0' ) {
+      my $ratio = $dst_pkg->freq / $src_pkg->freq;
+      if ($ratio != int($ratio)) {
+        return "Supplemental package period (pkgpart ".$dst_pkg->pkgpart.
+               ") must be an integer multiple of main package period.";
+      }
+    }
+  }
+
   $self->SUPER::check;
 }
 
diff --git a/httemplate/browse/part_pkg.cgi b/httemplate/browse/part_pkg.cgi
index 57a4297..5b19a30 100755
--- a/httemplate/browse/part_pkg.cgi
+++ b/httemplate/browse/part_pkg.cgi
@@ -20,6 +20,7 @@
                  'fields'                => \@fields,
                  'links'                 => \@links,
                  'align'                 => $align,
+                 'link_field'            => 'pkgpart',
              )
 %>
 <%init>
@@ -274,6 +275,18 @@ push @fields, sub {
         : ()
       ),
     ],
+    ( map { my $dst_pkg = $_->dst_pkg;
+            [
+              { data => 'Supplemental:  '.
+                        '<A HREF="#'. $dst_pkg->pkgpart . '">' .
+                        $dst_pkg->pkg . '</A>',
+                align=> 'center',
+                colspan => 2,
+              }
+            ]
+          }
+      $part_pkg->supp_part_pkg_link
+    ),
     ( map { 
             my $dst_pkg = $_->dst_pkg;
             [ 
diff --git a/httemplate/edit/REAL_cust_pkg.cgi b/httemplate/edit/REAL_cust_pkg.cgi
index 166a3b7..4bcf55c 100755
--- a/httemplate/edit/REAL_cust_pkg.cgi
+++ b/httemplate/edit/REAL_cust_pkg.cgi
@@ -9,6 +9,29 @@
 <SCRIPT TYPE="text/javascript" SRC="../elements/calendar-en.js"></SCRIPT>
 <SCRIPT TYPE="text/javascript" SRC="../elements/calendar-setup.js"></SCRIPT>
 
+<SCRIPT TYPE="text/javascript">
+var submit_fields = [];
+function confirm_changes() {
+  var i;
+  var querystring = 'pkgnum=<%$pkgnum%>';
+  var f = document.forms.formname;
+  for(i = 0; i < submit_fields.length; i++) {
+    querystring += ';'
+                + submit_fields[i]
+                + '='
+                + encodeURIComponent(f.elements[submit_fields[i] + '_text'].value);
+  }
+  overlib(
+    OLiframeContent(
+      '<%$p%>/misc/confirm-cust_pkg-edit_dates.html?' + querystring,
+      576, 576, 'confirm_popup'
+    ),
+    CAPTION, 'Package date changes', STICKY, AUTOSTATUSCAP, CLOSETEXT, '', 
+    MIDX, 0, MIDY, 0, DRAGGABLE, BGCOLOR, '#333399', CGCOLOR, '#333399', 
+    TEXTSIZE, 3
+  );
+}
+</SCRIPT>
 <FORM NAME="formname" ACTION="process/REAL_cust_pkg.cgi" METHOD="POST">
 <INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $pkgnum %>">
 
@@ -31,6 +54,15 @@
     <TD BGCOLOR="#ffffff"><% $part_pkg->pkg %></TD>
   </TR>
 
+% if ( $cust_pkg->main_pkgnum ) {
+%   my $main_pkg = $cust_pkg->main_pkg;
+  <TR>
+    <TD ALIGN="right">Supplemental to</TD>
+    <TD BGCOLOR="#ffffff">Package #<% $cust_pkg->main_pkgnum%>: \
+    <% $main_pkg->part_pkg->pkg %></TD>
+  </TR>
+
+% }
   <TR>
     <TD ALIGN="right">Custom</TD>
     <TD BGCOLOR="#ffffff"><% $part_pkg->custom %></TD>
@@ -50,14 +82,14 @@
 % if ( $cust_pkg->setup && ! $cust_pkg->start_date ) {
   <& .row_display, cust_pkg=>$cust_pkg, column=>'start_date',   label=>'Start' &>
 % } else {
-  <& .row_edit, cust_pkg=>$cust_pkg, column=>'start_date', label=>'Start' &>
+  <& .row_edit, cust_pkg=>$cust_pkg, column=>'start_date', label=>'Start', if_primary=>1 &>
 % }
 
-  <& .row_edit, cust_pkg=>$cust_pkg, column=>'setup',     label=>'Setup' &>
+  <& .row_edit, cust_pkg=>$cust_pkg, column=>'setup',     label=>'Setup', if_primary=>1 &>
   <& .row_edit, cust_pkg=>$cust_pkg, column=>'last_bill', label=>$last_bill_or_renewed &>
   <& .row_edit, cust_pkg=>$cust_pkg, column=>'bill',      label=>$next_bill_or_prepaid_until &>
 %#if ( $cust_pkg->contract_end or $part_pkg->option('contract_end_months',1) ) {
-    <& .row_edit, cust_pkg=>$cust_pkg, column=>'contract_end',label=>'Contract end' &>
+    <& .row_edit, cust_pkg=>$cust_pkg, column=>'contract_end',label=>'Contract end', if_primary=>1 &>
 %#}
   <& .row_display, cust_pkg=>$cust_pkg, column=>'adjourn',  label=>'Adjournment', note=>'(will <b>suspend</b> this package when the date is reached)' &>
   <& .row_display, cust_pkg=>$cust_pkg, column=>'susp',     label=>'Suspension' &>
@@ -73,10 +105,17 @@
   $column
   $label
   $note => ''
+  $if_primary => 0
 </%args>
 % my $value = $cust_pkg->get($column);
 % $value = $value ? time2str($format, $value) : "";
-
+%
+% # if_primary for the dates that can't be edited on supplemental packages
+% if ($if_primary and $cust_pkg->main_pkgnum) {
+  <INPUT TYPE="hidden" ID="<%$column%>_text" VALUE="<% $cust_pkg->get($column) %>">
+  <SCRIPT>submit_fields.push('<%$column%>');</SCRIPT>
+  <& .row_display, %ARGS &>
+% } else {
   <TR>
     <TD ALIGN="right"><% $label %> date</TD>
     <TD>
@@ -104,8 +143,11 @@
       button:     "<% $column %>_button",
       align:      "BR"
     });
-  </SCRIPT>
 
+    submit_fields.push('<%$column%>');
+
+  </SCRIPT>
+% }
 </%def>
 
 <%def .row_display>
@@ -114,6 +156,7 @@
   $column
   $label
   $note => ''
+  $is_primary => 0 #ignored
 </%args>
 % if ( $cust_pkg->get($column) ) { 
     <TR>
@@ -130,7 +173,7 @@
 </TABLE>
 
 <BR>
-<INPUT TYPE="submit" VALUE="<% mt('Apply changes') |h %>">
+<INPUT TYPE="button" VALUE="<% mt('Apply changes') |h %>" onclick="confirm_changes()">
 </FORM>
 
 <% include('/elements/footer.html') %>
@@ -160,38 +203,6 @@ if ( $cgi->param('error') ) {
     my @errors = ();
     my %errors = map { $_=>1 } split(',', $cgi->param('error'));
     $cgi->param('error', '');
-
-    if ( $errors{'_bill_areyousure'} ) {
-      if ( $cgi->param('bill') =~ /^([\s\d\/\:\-\(\w\)]*)$/ ) {
-        my $bill = $1;
-        push @errors,
-          "You are attempting to set the next bill date to $bill, which is
-           in the past.  This will charge the customer for the interval
-           from $bill until now.  Are you sure you want to do this? ".
-          '<INPUT TYPE="checkbox" NAME="bill_areyousure" VALUE="1">';
-      }
-    }
-
-    if ( $errors{'_setup_areyousure'} ) {
-      push @errors,
-        "You are attempting to remove the setup date.  This will re-charge the
-         customer for the setup fee.  Are you sure you want to do this? ".
-        '<INPUT TYPE="checkbox" NAME="setup_areyousure" VALUE="1">';
-    }
-
-    if ( $errors{'_setupadd_areyousure'} ) {
-      push @errors,
-        "You are attempting to add a setup date.  This will prevent charging the
-         customer for the setup fee.  Are you sure you want to do this? ".
-        '<INPUT TYPE="checkbox" NAME="setupadd_areyousure" VALUE="1">';
-    }
-
-    if ( $errors{'_start'} ) {
-      push @errors,
-        "You are attempting to add a start date to a package that has already
-         started billing.";
-    }
-
     $error = join('<BR><BR>', @errors );
 
   }
diff --git a/httemplate/edit/cust_pkg.cgi b/httemplate/edit/cust_pkg.cgi
index dd1ed33..88e9254 100755
--- a/httemplate/edit/cust_pkg.cgi
+++ b/httemplate/edit/cust_pkg.cgi
@@ -7,7 +7,6 @@
 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
 
 %#current packages
-%my @cust_pkg = qsearch('cust_pkg', { 'custnum' => $custnum, 'cancel' => '' } );
 %if (@cust_pkg) {
 
   Current packages - select to remove (services are moved to a new package below)
@@ -18,13 +17,7 @@
     </TR>
   <BR><BR>
 %
-%
-%  foreach ( sort {     $all_pkg{ $a->getfield('pkgpart') }
-%                   cmp $all_pkg{ $b->getfield('pkgpart') }
-%                 }
-%                 @cust_pkg
-%          )
-%  {
+%  foreach ( @main_pkgs ) {
 %    my($pkgnum,$pkgpart)=( $_->getfield('pkgnum'), $_->getfield('pkgpart') );
 %    my $checked = $remove_pkg{$pkgnum} ? ' CHECKED' : '';
 %
@@ -36,6 +29,13 @@
       <TD ALIGN="right"><% $pkgnum %>:</TD>
       <TD><% $all_pkg{$pkgpart} %> - <% $all_comment{$pkgpart} %></TD>
     </TR>
+%   foreach my $supp_pkg ( @{ $supp_pkgs_of{$pkgnum} } ) {
+    <TR>
+      <TD></TD>
+      <TD></TD>
+      <TD>+ <% $all_pkg{$supp_pkg->pkgpart} %> - <% $all_comment{$supp_pkg->pkgpart} %></TD>
+    </TR>
+%   }
 % } 
 
 
@@ -147,4 +147,24 @@ if ( $cgi->param('error') ) {
 
 my $p1 = popurl(1);
 
+my @cust_pkg = qsearch('cust_pkg', { 'custnum' => $custnum, 'cancel' => '' } );
+my @main_pkgs;
+my %supp_pkgs_of; # main pkgnum => arrayref of cust_pkgs
+
+
+foreach my $cust_pkg
+  ( sort { $all_pkg{ $a->pkgpart } cmp $all_pkg{ $b->getfield('pkgpart') } }
+    @cust_pkg
+  )
+  # XXX does not properly handle recursive supplemental links
+{
+  if ( my $main_pkgnum = $cust_pkg->main_pkgnum ) {
+    $supp_pkgs_of{$main_pkgnum} ||= [];
+    push @{ $supp_pkgs_of{$main_pkgnum} }, $cust_pkg;
+  } else {
+    push @main_pkgs, $cust_pkg;
+    $supp_pkgs_of{$cust_pkg->pkgnum} ||= [];
+  }
+}
+
 </%init>
diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi
index c3f4f88..7baf84d 100755
--- a/httemplate/edit/part_pkg.cgi
+++ b/httemplate/edit/part_pkg.cgi
@@ -53,6 +53,7 @@
                             'discountnum'      => 'Offer discounts for longer terms',
                             'bill_dst_pkgpart' => 'Include line item(s) from package',
                             'svc_dst_pkgpart'  => 'Include services of package',
+                            'supp_dst_pkgpart' => 'Include complete package',
                             'report_option'    => 'Report classes',
                             'fcc_ds0s'         => 'Voice-grade equivalents',
                             'fcc_voip_class'   => 'Category',
@@ -239,6 +240,19 @@
                             },
 
                             { 'type'    => 'tablebreak-tr-title',
+                              'value'   => 'Supplemental packages',
+                              'colspan' => '4',
+                            },
+                            { 'field'       => 'supp_dst_pkgpart',
+                              'type'        => 'select-part_pkg',
+                              'm2_label'    => 'Include complete package',
+                              'm2m_method'  => 'supp_part_pkg_link',
+                              'm2m_dstcol'  => 'dst_pkgpart',
+                              'm2_error_callback' =>
+                                &{$m2_error_callback_maker}('supp'),
+                            },
+
+                            { 'type'    => 'tablebreak-tr-title',
                               'value'   => 'Pricing add-ons',
                               'colspan' => 4,
                             },
diff --git a/httemplate/edit/process/REAL_cust_pkg.cgi b/httemplate/edit/process/REAL_cust_pkg.cgi
index 3e0ef59..fd28934 100755
--- a/httemplate/edit/process/REAL_cust_pkg.cgi
+++ b/httemplate/edit/process/REAL_cust_pkg.cgi
@@ -19,36 +19,41 @@ die "access denied"
 my $pkgnum = $cgi->param('pkgnum') or die;
 my $old = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
 my %hash = $old->hash;
-$hash{$_}= $cgi->param($_) ? parse_datetime($cgi->param($_)) : ''
-  foreach qw( start_date setup bill last_bill contract_end );
+foreach ( qw( start_date setup bill last_bill contract_end ) ) {
+  if ( $cgi->param($_) =~ /^(\d+)$/ ) {
+    $hash{$_} = $1;
+  } else {
+    $hash{$_} = '';
+  }
   # adjourn, expire, resume not editable this way
-
-my @errors = ();
-
-push @errors, '_bill_areyousure'
-  if $hash{'bill'} != $old->bill             # if the next bill date was changed
-  && $hash{'bill'} < time                    # to a date in the past
-  && ! $cgi->param('bill_areyousure');       # and it wasn't confirmed
-
-push @errors, '_setup_areyousure'
-  if ! $hash{'setup'} && $old->setup         # if the setup date was removed
-  && ! $cgi->param('setup_areyousure');      # and it wasn't confirmed 
-
-push @errors, '_setupadd_areyousure'
-  if $hash{'setup'} && ! $old->setup         # if the setup date was added
-  && ! $cgi->param('setupadd_areyousure');   # and it wasn't confirmed 
-
-push @errors, '_start'
-  if $hash{'start_date'} && !$old->start_date # if a start date was added
-  && $hash{'setup'};                          # but there's a setup date
+}
 
 my $new;
 my $error;
-if ( @errors ) {
-  $error = join(',', @errors);
-} else {
-  $new = new FS::cust_pkg \%hash;
-  $error = $new->replace($old);
+$new = new FS::cust_pkg \%hash;
+$error = $new->replace($old);
+
+if (!$error) {
+  my @supp_pkgs = $old->supplemental_pkgs;
+  foreach $new (@supp_pkgs) {
+    foreach ( qw( start_date setup contract_end ) ) {
+      # propagate these to supplementals
+      $new->set($_, $hash{$_});
+    }
+    if ( $hash{'bill'} ne $old->get('bill') ) {
+      if ( $hash{'bill'} and $old->get('bill') ) {
+        # adjust by the same interval
+        my $diff = $hash{'bill'} - $old->get('bill');
+        $new->set('bill', $new->get('bill') + $diff);
+      } else {
+        # absolute date
+        $new->set('bill', $hash{'bill'});
+      }
+    }
+    $error = $new->replace;
+    $error .= ' (supplemental package '.$new->pkgnum.')' if $error; 
+    last if $error;
+  }
 }
 
 </%init>
diff --git a/httemplate/edit/process/part_pkg.cgi b/httemplate/edit/process/part_pkg.cgi
index c388676..2ac57f9 100755
--- a/httemplate/edit/process/part_pkg.cgi
+++ b/httemplate/edit/process/part_pkg.cgi
@@ -185,6 +185,15 @@ my @process_m2m = (
                         grep /^svc_dst_pkgpart/, $cgi->param
                       ],
   },
+  { 'link_table'   => 'part_pkg_link',
+    'target_table' => 'part_pkg',
+    'base_field'   => 'src_pkgpart',
+    'target_field' => 'dst_pkgpart',
+    'hashref'      => { 'link_type' => 'supp', 'hidden' => '' },
+    'params'       => [ map $cgi->param($_),
+                        grep /^supp_dst_pkgpart/, $cgi->param
+                      ],
+  },
   map { 
     my $hidden = $_;
     { 'link_table'   => 'part_pkg_link',
diff --git a/httemplate/misc/confirm-cust_pkg-edit_dates.html b/httemplate/misc/confirm-cust_pkg-edit_dates.html
new file mode 100755
index 0000000..27b9a82
--- /dev/null
+++ b/httemplate/misc/confirm-cust_pkg-edit_dates.html
@@ -0,0 +1,283 @@
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Edit customer package dates');
+
+my %arg = $cgi->Vars;
+
+my $pkgnum = $arg{'pkgnum'};
+$pkgnum =~ /^\d+$/ or die "bad pkgnum '$pkgnum'";
+my $cust_pkg = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
+my %hash = $cust_pkg->hash;
+foreach (qw( start_date setup bill last_bill contract_end )) {
+  # adjourn, expire, resume not editable this way
+  if( $arg{$_} =~ /^\d+$/ ) {
+    $hash{$_} = $arg{$_};
+  } elsif ( $arg{$_} ) {
+    $hash{$_} = parse_datetime($arg{$_});
+  } else {
+    $hash{$_} = '';
+  }
+}
+
+my (@changes, @confirm, @errors);
+
+my $part_pkg = $cust_pkg->part_pkg;
+my @supp_pkgs = $cust_pkg->supplemental_pkgs;
+my $main_pkg = $cust_pkg->main_pkg;
+
+my $conf = FS::Conf->new;
+my $date_format = $conf->config('date_format') || '%b %o, %Y';
+# Start date
+if ( $hash{'start_date'} != $cust_pkg->get('start_date') and !$hash{'setup'} ) {
+  my $start = '';
+  $start = time2str($date_format, $hash{'start_date'}) if $hash{'start_date'};
+  my $text = 'Set this package';
+  if ( @supp_pkgs ) {
+    $text .= ' and all its supplemental packages';
+  }
+  $text .= ' to start billing';
+  if ( $start ) {
+    $text .= ' on [_1].';
+    push @changes, mt($text, $start);
+  } else {
+    $text .= ' immediately.';
+    push @changes, mt($text);
+  }
+  push @confirm, '';
+}
+
+# Setup date changes
+if ( $hash{'setup'} != $cust_pkg->get('setup') ) {
+  my $setup = time2str($date_format, $hash{'setup'});
+  my $has_setup_fee = grep { $_->part_pkg->option('setup_fee',1) > 0 }
+                      $cust_pkg, @supp_pkgs;
+  if ( !$hash{'setup'} ) {
+    my $text = 'Remove the setup date';
+    $text .= ' from this and all its supplemental packages' if @supp_pkgs;
+    $text .= '.';
+    push @changes, mt($text);
+    if ( $has_setup_fee ) {
+      push @confirm, mt('This will re-charge the customer for the setup fee.');
+    } else {
+      push @confirm, '';
+    }
+  } elsif ( $cust_pkg->get('setup') ) {
+    my $text = 'Add a setup date of [_1]';
+    $text .= ' to this and all its supplemental packages' if @supp_pkgs;
+    $text .= '.';
+    push @changes, mt($text, $setup);
+    if ( $has_setup_fee ) {
+      push @confirm, mt('This will prevent charging the setup fee.');
+    } else {
+      push @confirm, '';
+    }
+  } else {
+    my $text = 'Set the setup date to [_1]';
+    $text .= ' on this and all its supplemental packages' if @supp_pkgs;
+    $text .= '.';
+    push @changes, mt($text, $setup);
+    push @confirm, '';
+  }
+}
+
+# Check for start date + setup date
+if ( $hash{'start_date'} and $hash{'setup'} ) {
+  if ( $cust_pkg->get('setup') ) {
+    push @errors, mt('Since the package has already started billing, it '.
+                     'cannot have a start date.');
+  } else {
+    push @errors, mt('You cannot set both a start date and a setup date on '.
+                     'the same package.');
+  }
+}
+
+# Last bill date change
+if ( $hash{'last_bill'} != $cust_pkg->get('last_bill') ) {
+  my $last_bill = time2str($date_format, $hash{'last_bill'});
+  my $name = 'last bill date';
+  $name = 'last renewal date' if $part_pkg->is_prepaid;
+  if ( $hash{'last_bill'} ) {
+    push @changes, mt('Set the [_1] to [_2].', $name, $last_bill);
+  } else {
+    push @changes, mt('Remove the [_1].', $name);
+  }
+  push @confirm, '';
+  # I don't think we want to adjust this on supplemental packages.
+}
+
+# Bill date change
+if ( $hash{'bill'} != $cust_pkg->get('bill') ) {
+  my $bill = time2str($date_format, $hash{'bill'});
+  $bill = 'the current day' if !$hash{'bill'}; # or 'the end of today'?...
+  my $name = 'next bill date';
+  $name = 'end of the prepaid period' if $part_pkg->is_prepaid;
+  push @changes, mt('Set the [_1] to [_2].', $name, $bill);
+
+  if ( $hash{'bill'} < time and $hash{'bill'} ) {
+    push @confirm, 
+      mt('The customer will be charged for the interval from [_1] until now.',
+         $bill);
+  } else {
+    push @confirm, '';
+  }
+
+  if ( @supp_pkgs ) {
+    push @changes, '';
+    if ( $cust_pkg->get('bill') and $hash{'bill'} ) {
+      # the package already has a bill date, so adjust the dates 
+      # of supplementals by the same interval
+      my $diff = $hash{'bill'} - $cust_pkg->get('bill');
+      my $sign = $diff < 0 ? -1 : 1;
+      $diff = $diff * $sign / 86400;
+      if ( $diff < 1 ) {
+        $diff = mt('[quant,_1,hour]', int($diff * 24));
+      } else {
+        $diff = mt('[quant,_1,day]', int($diff));
+      }
+      push @confirm,
+        mt('[_1] supplemental package will also be billed [_2] [_3].',
+            (@supp_pkgs > 1 ? 'Each' : 'The'),
+            $diff,
+            ($sign > 0 ? 'later' : 'earlier')
+        );
+    } else {
+      # the package hasn't been billed yet, or you've set bill = null
+      push @confirm,
+        mt('[_1] supplemental package will also be billed on [_2].',
+            (@supp_pkgs > 1 ? 'Each' : 'The'),
+            $bill
+        );
+    }
+  } #if @supp_pkgs
+
+  if ( $main_pkg ) {
+    push @changes, '';
+    push @confirm,
+      mt('This package is a supplemental package.  The bill date of its '.
+         'main package will not be adjusted.');
+  }
+}
+
+# Contract end change
+if ( $hash{'contract_end'} != $cust_pkg->get('contract_end') ) {
+  if ( $hash{'contract_end'} ) {
+    my $contract_end = time2str($date_format, $hash{'contract_end'});
+    push @changes,
+      mt('Set this package\'s contract end date to [_1]', $contract_end);
+  } else {
+    push @changes, mt('Remove this package\'s contract end date.');
+  }
+  if ( @supp_pkgs ) {
+    my $text = 'This change will also apply to ' .
+      (@supp_pkgs > 1 ?
+        'all supplemental packages.':
+        'the supplemental package.');
+    push @confirm, mt($text);
+  } else {
+    push @confirm, '';
+  }
+}
+
+my $title = '';
+if ( @errors ) {
+  $title = 'Error changing package dates';
+} else {
+  $title = 'Confirm date changes';
+}
+</%init>
+<& /elements/header-popup.html, { title => $title, etc => 'BGCOLOR=""' } &>
+<STYLE TYPE="text/css">
+.error { 
+  color: #ff0000;
+  font-weight: bold;
+  text-align: center;
+}
+.confirm { color: #ff0000 }
+.button-container {
+  position: fixed;
+  bottom: 5px;
+  text-align: center;
+  width: 100%
+}
+</STYLE>
+<DIV STYLE="text-align: center; padding:1em">
+<% emt('Package #') %><B><% $pkgnum %></B>: <B><% $cust_pkg->part_pkg->pkg %></B><BR>
+% if ( @changes ) {
+  <% emt('The following changes will be made:') %>
+% } else {
+  <% emt('No changes will be made.') %>
+% }
+</DIV>
+<TABLE WIDTH="100%">
+% if ( @errors ) {
+%   foreach my $error ( @errors ) {
+<TR>
+  <TD><IMG SRC="<%$p%>images/cross.png"></TD>
+  <TD CLASS="error"><% $error %></TD>
+</TR>
+%   }
+% } else {
+%   while (@changes, @confirm) {
+%     my $text = shift @changes;
+%     if (length $text) {
+<TR>
+  <TD><IMG SRC="<%$p%>images/tick.png"></TD>
+  <TD><% $text %></TD>
+</TR>
+%     }
+%     $text = shift @confirm;
+%     if (length $text) {
+<TR>
+  <TD>
+    <INPUT TYPE="checkbox" NAME="areyousure" VALUE=1 onclick="submit_ready()">
+  </TD>
+  <TD CLASS="confirm"><% $text %></TD>
+</TR>
+%     }
+%   }
+% }
+</TABLE>
+%# action buttons
+<DIV CLASS="button-container">
+  <BUTTON TYPE="button" STYLE="width:145px" ID="submit_cancel"\
+    onclick="submit_cancel()">
+    <IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel
+  </BUTTON>
+% if (!@errors ) {
+  <BUTTON TYPE="button" STYLE="width:145px" ID="submit_continue"\
+    onclick="submit_continue()">
+    <IMG SRC="<%$p%>images/tick.png" ALT=""> Continue
+  </BUTTON>
+</DIV>
+% }
+<FORM NAME="DateEditForm" STYLE="display:none" TARGET="_parent" ACTION="<%$p%>edit/process/REAL_cust_pkg.cgi" METHOD="POST">
+% foreach (keys %hash) {
+<INPUT TYPE="hidden" NAME="<%$_%>" VALUE="<% $hash{$_} |h%>">
+% }
+</FORM>
+<SCRIPT>
+function submit_ready() {
+  var ready = true;
+  var checkboxes = document.getElementsByName('areyousure');
+  var i;
+  for (i=0; i < checkboxes.length; i++) {
+    if (! checkboxes[i].checked ) {
+      ready = false;
+    }
+  }
+  document.getElementById('submit_continue').disabled = !ready;
+  return ready;
+}
+function submit_cancel() {
+  parent.nd(1);
+}
+function submit_continue() {
+  if ( submit_ready() ) {
+    document.forms.DateEditForm.submit();
+  }
+}
+submit_ready();
+</SCRIPT>
+<& /elements/footer.html &>
diff --git a/httemplate/search/elements/search-html.html b/httemplate/search/elements/search-html.html
index 7ccf356..e760bc5 100644
--- a/httemplate/search/elements/search-html.html
+++ b/httemplate/search/elements/search-html.html
@@ -253,7 +253,17 @@
 %                   $bgcolor = $bgcolor1;
 %                 }
 
-                  <TR>
+%                 my $trid = '';
+%                   if ( $opt{'link_field' } ) {
+%                     my $link_field = $opt{'link_field'};
+%                     if ( ref($link_field) eq 'CODE' ) {
+%                       $trid = &{$link_field}($row);
+%                     } else {
+%                       $trid = $row->$link_field();
+%                     }
+%                   }
+                  <TR ID="<%$trid |h%>">
+                      
 
 %                   if ( $opt{'fields'} ) {
 %
diff --git a/httemplate/search/elements/search.html b/httemplate/search/elements/search.html
index 5a16a22..68c4888 100644
--- a/httemplate/search/elements/search.html
+++ b/httemplate/search/elements/search.html
@@ -167,6 +167,11 @@ Example:
     # miscellany
    'download_label' => 'Download this report',
                         # defaults to 'Download full results' 
+   'link_field'     => 'pkgpart'
+                        # will create internal links for each row,
+                        # with the value of this field as the NAME attribute
+                        # If this is a coderef, will evaluate it, passing the
+                        # row as an argument, and use the result as the NAME.
   &>
 
 </%doc>
diff --git a/httemplate/view/cust_main/packages.html b/httemplate/view/cust_main/packages.html
index 7d79306..7b5b156 100755
--- a/httemplate/view/cust_main/packages.html
+++ b/httemplate/view/cust_main/packages.html
@@ -1,3 +1,22 @@
+<STYLE TYPE="text/css">
+td.package {
+  vertical-align: top;
+  border-width: 0;
+  border-style: solid;
+  border-color: #bbbbff;
+}
+table.package {
+  border: none;
+  padding: 0;
+  border-spacing: 0;
+  width: 100%;
+}
+<!-- even/odd rows -->
+  
+.row0 { background-color: #eeeeee; }
+.row1 { background-color: #ffffff; }
+
+</STYLE>
 % my $s = 0;
 
 % if ( $curuser->access_right('Qualify service') ) { 
@@ -116,7 +135,7 @@ my( $packages, $num_old_packages ) = get_packages($cust_main, $conf);
 
 
 my $show_location = $conf->exists('cust_pkg-always_show_location') 
-                        || (grep $_->locationnum, @$packages); # ? '1' : '0';
+  || (grep $_->locationnum ne $cust_main->ship_locationnum, @$packages);
 
 my $countrydefault = scalar($conf->config('countrydefault')) || 'US';
 #subroutines
@@ -178,6 +197,10 @@ sub get_packages {
   }
 
   $num_old_packages -= scalar(@packages);
+  
+  # don't include supplemental packages in this list; they'll be found from
+  # their main packages
+  @packages = grep !$_->main_pkgnum, @packages;
 
   ( \@packages, $num_old_packages );
 }
diff --git a/httemplate/view/cust_main/packages/package.html b/httemplate/view/cust_main/packages/package.html
index 5d93ad4..3a362b6 100644
--- a/httemplate/view/cust_main/packages/package.html
+++ b/httemplate/view/cust_main/packages/package.html
@@ -1,5 +1,6 @@
-<TD CLASS="inv" BGCOLOR="<% $bgcolor %>" VALIGN="top">
-  <TABLE CLASS="inv" BORDER=0 CELLSPACING=0 CELLPADDING=0 WIDTH="100%">
+<TD CLASS="inv package" BGCOLOR="<% $bgcolor %>" VALIGN="top"
+  STYLE="border-left-width: <% $supplemental * 30 %>px">
+  <TABLE CLASS="inv package"> 
     <TR>
       <TD COLSPAN=2>
         <A NAME="cust_pkg<% $cust_pkg->pkgnum %>"
@@ -17,7 +18,7 @@
         <B><% $cust_pkg->quantity %></B>
       </TD>
     </TR>
-%  }
+% }
 
     <TR>
       <TD COLSPAN=2>
@@ -25,42 +26,51 @@
 
 %         unless ( $cust_pkg->get('cancel') ) { 
 %
-%           my $br = 0;
-%           if ( $curuser->access_right('Change customer package') ) {
-%             $br=1;
-              ( <%pkg_change_link($cust_pkg)%> )
-%           } 
+%           if ( $supplemental ) {
+%             # then only show "Edit dates", "Add invoice details", and "Add
+%             # comments".
+%             if ( $curuser->access_right('Edit customer package dates') ) {
+                ( <%pkg_dates_link($cust_pkg)%> )
+%             }
+%           } else {
+%             # the usual case
+%             my $br = 0;
+%             if ( $curuser->access_right('Change customer package') ) {
+%               $br=1;
+                ( <%pkg_change_link($cust_pkg)%> )
+%             } 
 %
-%           if ( $curuser->access_right('Edit customer package dates') ) {
-%             $br=1;
-              ( <%pkg_dates_link($cust_pkg)%> )
-%           } 
+%             if ( $curuser->access_right('Edit customer package dates') ) {
+%               $br=1;
+                ( <%pkg_dates_link($cust_pkg)%> )
+%             } 
 %
-%           if ( $curuser->access_right('Discount customer package')
-%                && $part_pkg->can_discount
-%                && ! scalar($cust_pkg->cust_pkg_discount_active)
-%                && ! scalar($cust_pkg->part_pkg->part_pkg_discount)
-%              )
-%           {
-%             $br=1;
-              ( <%pkg_discount_link($cust_pkg)%> )
-%           }
+%             if ( $curuser->access_right('Discount customer package')
+%                  && $part_pkg->can_discount
+%                  && ! scalar($cust_pkg->cust_pkg_discount_active)
+%                  && ! scalar($cust_pkg->part_pkg->part_pkg_discount)
+%                )
+%             {
+%               $br=1;
+                ( <%pkg_discount_link($cust_pkg)%> )
+%             }
 %
-%           if ( $curuser->access_right('Customize customer package') ) {
-%             $br=1;
-              ( <%pkg_customize_link($cust_pkg,$part_pkg)%> )
-%           } 
+%             if ( $curuser->access_right('Customize customer package') ) {
+%               $br=1;
+                ( <%pkg_customize_link($cust_pkg,$part_pkg)%> )
+%             } 
 %
-            <% $br ? '<BR>' : '' %>
-%         } 
+              <% $br ? '<BR>' : '' %>
+%           } 
 
-%         if ( $cust_pkg->num_cust_event
-%              && (    $curuser->access_right('Billing event reports')
-%                   || $curuser->access_right('View customer billing events')
-%                 )
-%            ) {
-            ( <%pkg_event_link($cust_pkg)%> )
-%         }
+%           if ( $cust_pkg->num_cust_event
+%                && (    $curuser->access_right('Billing event reports')
+%                     || $curuser->access_right('View customer billing events')
+%                   )
+%              ) {
+              ( <%pkg_event_link($cust_pkg)%> )
+%           }
+%         } #!$supplemental
 
         </FONT>
       </TD>
@@ -170,6 +180,7 @@
       </TR>
 %     if ( $curuser->access_right('Change customer package') and 
 %           !$cust_pkg->get('cancel') and
+%           !$supplemental and
 %           !$opt{'show_location'}) {
       <TR>
         <TD><FONT SIZE="-1">
@@ -196,6 +207,7 @@ my $countrydefault = $opt{'countrydefault'} || 'US';
 my $statedefault   = $opt{'statedefault'}
                      || ($countrydefault eq 'US' ? 'CA' : '');
 
+my $supplemental = $opt{'supplemental'} || 0;
 #subroutines
 
 #false laziness w/status.html
diff --git a/httemplate/view/cust_main/packages/section.html b/httemplate/view/cust_main/packages/section.html
index 85f0c79..53bdfa1 100755
--- a/httemplate/view/cust_main/packages/section.html
+++ b/httemplate/view/cust_main/packages/section.html
@@ -1,8 +1,4 @@
 % if ( @$packages ) { 
-%   my $bgcolor1 = '#eeeeee';
-%   my $bgcolor2 = '#ffffff';
-%   my $bgcolor = '';
-
 <TR>
 % #my $width = $show_location ? 'WIDTH="25%"' : 'WIDTH="33%"';
   <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('Package') |h %></TH>
@@ -15,39 +11,39 @@
 
 % #$FS::cust_pkg::DEBUG = 2;
 %   foreach my $cust_pkg (@$packages) {
+    <& .packagerow, $cust_pkg,
+        'cust_main' => $opt{'cust_main'},
+        %conf_opt
+    &>
+%   }
+% } else { # there are no packages
+<BR>
+% }
+<%def .packagerow>
 %
-%     if ( $bgcolor eq $bgcolor1 ) {
-%       $bgcolor = $bgcolor2;
-%     } else {
-%       $bgcolor = $bgcolor1;
-%     }
-%
-%     my %iopt = (
-%       'bgcolor'   => $bgcolor,
-%       'cust_pkg'  => $cust_pkg,
-%       'part_pkg'  => $cust_pkg->part_pkg,
-%       'cust_main' => $opt{'cust_main'},
-%       %conf_opt,
-%     );
-%
-
+% my ($cust_pkg, %iopt) = @_;
+% $iopt{'cust_pkg'} = $cust_pkg;
+% $iopt{'part_pkg'} = $cust_pkg->part_pkg;
   <!--pkgnum: <% $cust_pkg->pkgnum %>-->
-  <TR>
+  <TR CLASS="row<%$row % 2%>">
     <& package.html, %iopt &>
     <& status.html, %iopt &>
-%     if ( $show_location ) {
+%     if ( $iopt{'show_location'} ) {
     <& location.html, %iopt &>
 %     }
     <& services.html, %iopt &>
   </TR>
-
-%   } #foreach $cust_pkg
-%# </TABLE>
-% } #if @$packages
-% else {
-<BR>
+% $row++;
+% # include supplemental packages if any
+% $iopt{'supplemental'} = ($iopt{'supplemental'} || 0) + 1;
+% foreach my $supp_pkg ($cust_pkg->supplemental_pkgs) {
+% warn $supp_pkg->pkgnum;
+    <& .packagerow, $supp_pkg, %iopt &>
 % }
-
+</%def>
+<%shared>
+my $row = 0;
+</%shared>
 <%init>
 
 my %opt = @_;
diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html
index e901774..6be0296 100644
--- a/httemplate/view/cust_main/packages/status.html
+++ b/httemplate/view/cust_main/packages/status.html
@@ -3,7 +3,9 @@
 
 %#this should use cust_pkg->status and cust_pkg->statuscolor eventually
 
-% if ( $cust_pkg->order_date ) {
+% if ( $supplemental ) {
+    <% pkg_status_row_colspan($cust_pkg, emt('Supplemental'), '', 'color' => '7777FF', %opt) %>
+% } elsif ( $cust_pkg->order_date ) {
     <% pkg_status_row($cust_pkg, emt('Ordered'), 'order_date', %opt ) %>
 % }
 
@@ -12,30 +14,25 @@
 
     <% pkg_status_row($cust_pkg, emt('Cancelled'), 'cancel', 'color'=>'FF0000', %opt ) %>
 
-    <% pkg_status_row_colspan( $cust_pkg,
-         ( $cpr ? $cpr->reasontext. ' by '. $cpr->otaker : '' ), '',
-         'align'=>'right', 'color'=>'ff0000', 'size'=>'-2', 'colspan'=>$colspan,
-         %opt
-       )
-    %>
+    <% pkg_reason_row($cust_pkg, $cpr, color => 'ff0000', %opt) %>
 
 %   unless ( $cust_pkg->get('setup') ) { 
 
-        <% pkg_status_row_colspan( $cust_pkg, emt('Never billed'), '', 'colspan'=>$colspan, %opt, ) %>
+        <% pkg_status_row_colspan( $cust_pkg, emt('Never billed'), '', %opt, ) %>
 
 %   } else { 
 
        <% pkg_status_row( $cust_pkg, emt('Setup'), 'setup', %opt ) %>
-       <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+       <% pkg_status_row_changed( $cust_pkg, %opt ) %>
        <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
        <% pkg_status_row_if( $cust_pkg, emt('Suspended'), 'susp', %opt, curuser=>$curuser ) %>
 
 %   } 
 %
-%   if ( $part_pkg->freq ) { #?
+%   if ( $part_pkg->freq and !$supplemental ) { #?
 
       <TR>
-        <TD COLSPAN=<%$colspan%>>
+        <TD COLSPAN=<%$opt{colspan}%>>
           <FONT SIZE=-1>
 %           if ( $curuser->access_right('Un-cancel customer package') ) { 
               ( <% pkg_uncancel_link($cust_pkg) %> )
@@ -52,26 +49,21 @@
 
     <% pkg_status_row( $cust_pkg, emt('Suspended'), 'susp', 'color'=>'FF9900', %opt ) %>
 
-    <% pkg_status_row_colspan( $cust_pkg,
-         ( $cpr ? $cpr->reasontext. ' by '. $cpr->otaker : '' ), '',
-         'align'=>'right', 'color'=>'FF9900', 'size'=>'-2', 'colspan'=>$colspan,
-         %opt,
-       )
-    %>
+    <% pkg_reason_row( $cust_pkg, $cpr, 'color' => 'FF9900', %opt ) %>
 
-    <% pkg_status_row_noauto( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+    <% pkg_status_row_noauto( $cust_pkg, %opt ) %>
 
-    <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+    <% pkg_status_row_discount( $cust_pkg, %opt ) %>
 
 %   unless ( $cust_pkg->get('setup') ) { 
-      <% pkg_status_row_colspan( $cust_pkg, emt('Never billed'), '', 'colspan'=>$colspan, %opt ) %>
+      <% pkg_status_row_colspan( $cust_pkg, emt('Never billed'), '', %opt ) %>
 %   } else { 
       <% pkg_status_row($cust_pkg, emt('Setup'), 'setup', %opt ) %>
 %   } 
 
     <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
 
-    <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+    <% pkg_status_row_changed( $cust_pkg, %opt ) %>
     <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
 %   if ( $cust_pkg->option('suspend_bill', 1)
 %        || ( $part_pkg->option('suspend_bill', 1)
@@ -85,31 +77,33 @@
     <% pkg_status_row_if( $cust_pkg, emt('Expires'), 'expire', %opt, curuser=>$curuser ) %>
     <% pkg_status_row_if( $cust_pkg, emt('Contract ends'), 'contract_end', %opt ) %>
 
-    <TR>
-      <TD COLSPAN=<%$colspan%>>
-        <FONT SIZE=-1>
-%         if ( $curuser->access_right('Unsuspend customer package') ) { 
-            ( <% pkg_unsuspend_link($cust_pkg) %> )
-            ( <% pkg_resume_link($cust_pkg) %> )
-%         }
-%         if ( $curuser->access_right('Cancel customer package immediately') ) {
-            ( <% pkg_cancel_link($cust_pkg) %> )
-%         } 
-        </FONT>
-      </TD>
-    </TR>
-
+% if ( !$supplemental ) {
+      <TR>
+        <TD COLSPAN=<%$opt{colspan}%>>
+          <FONT SIZE=-1>
+%           if ( $curuser->access_right('Unsuspend customer package') ) { 
+              ( <% pkg_unsuspend_link($cust_pkg) %> )
+              ( <% pkg_resume_link($cust_pkg) %> )
+%           }
+%           if ( $curuser->access_right('Cancel customer package immediately') ) {
+              ( <% pkg_cancel_link($cust_pkg) %> )
+%           } 
+          </FONT>
+        </TD>
+      </TR>
+%     }
+%
 %   } else { #status: active
 %
 %     unless ( $cust_pkg->get('setup') ) { #not setup
 %
 %       unless ( $part_pkg->freq ) {
 
-          <% pkg_status_row_colspan( $cust_pkg, emt('Not yet billed (one-time charge)'), '', 'colspan'=>$colspan, %opt ) %>
+          <% pkg_status_row_colspan( $cust_pkg, emt('Not yet billed (one-time charge)'), '', %opt ) %>
 
-          <% pkg_status_row_noauto( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_noauto( $cust_pkg, %opt ) %>
 
-          <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_discount( $cust_pkg, %opt ) %>
 
           <% pkg_status_row_if(
                $cust_pkg,
@@ -121,8 +115,9 @@
 
           <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
 
+%         if (!$supplemental) {
           <TR>
-            <TD COLSPAN=<%$colspan%>>
+            <TD COLSPAN=<%$opt{colspan}%>>
               <FONT SIZE=-1>
 %               if ( $curuser->access_right('Cancel customer package immediately') ) { 
                   ( <% pkg_cancel_link($cust_pkg) %> )
@@ -130,14 +125,15 @@
               </FONT>
             </TD>
           </TR>
+%         }
 
 %       } else { 
 
-          <% pkg_status_row_colspan($cust_pkg, emt("Not yet billed ($billed_or_prepaid [_1])", myfreq($part_pkg) ), '', 'colspan'=>$colspan, %opt ) %>
+          <% pkg_status_row_colspan($cust_pkg, emt("Not yet billed ($billed_or_prepaid [_1])", myfreq($part_pkg) ), '', %opt ) %>
 
-          <% pkg_status_row_noauto( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_noauto( $cust_pkg, %opt ) %>
 
-          <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_discount( $cust_pkg, %opt ) %>
 
           <% pkg_status_row_if($cust_pkg, emt('Start billing'), 'start_date', %opt) %>
           <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
@@ -148,13 +144,13 @@
 %
 %       unless ( $part_pkg->freq ) { 
 
-          <% pkg_status_row_colspan($cust_pkg, emt('One-time charge'), '', 'colspan'=>$colspan, %opt ) %>
+          <% pkg_status_row_colspan($cust_pkg, emt('One-time charge'), '', %opt ) %>
 
           <% pkg_status_row($cust_pkg, emt('Billed'), 'setup', %opt) %>
 
-          <% pkg_status_row_noauto( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_noauto( $cust_pkg, %opt ) %>
 
-          <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_discount( $cust_pkg, %opt ) %>
 
           <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
 
@@ -170,7 +166,7 @@
             <% pkg_status_row_colspan( $cust_pkg,
                  emt('Overlimit'),
                  $billed_or_prepaid. ' '. myfreq($part_pkg),
-                 'color'=>'FFD000', 'colspan'=>$colspan,
+                 'color'=>'FFD000',
                  %opt
                )
             %>
@@ -179,15 +175,15 @@
             <% pkg_status_row_colspan( $cust_pkg,
                  emt('Active'),
                  $billed_or_prepaid. ' '. myfreq($part_pkg),
-                 'color'=>'00CC00', 'colspan'=>$colspan,
+                 'color'=>'00CC00',
                  %opt
                )
             %>
 %         } 
 
-          <% pkg_status_row_noauto( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_noauto( $cust_pkg, %opt ) %>
 
-          <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+          <% pkg_status_row_discount( $cust_pkg, %opt ) %>
 
           <% pkg_status_row($cust_pkg, emt('Setup'), 'setup', %opt) %>
 
@@ -202,7 +198,7 @@
 %       $cust_pkg->set('autosuspend', $autosuspend) if $autosuspend;
 %     }
 
-      <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
+      <% pkg_status_row_changed( $cust_pkg, %opt ) %>
       <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
       <% pkg_status_row_if( $cust_pkg, $next_bill_or_prepaid_until, 'bill', %opt, curuser=>$curuser ) %>
       <% pkg_status_row_if($cust_pkg, emt('Will automatically suspend by'), 'autosuspend', %opt) %>
@@ -212,10 +208,10 @@
       <% pkg_status_row_if( $cust_pkg, emt('Expires'), 'expire', %opt, curuser=>$curuser ) %>
       <% pkg_status_row_if( $cust_pkg, emt('Contract ends'), 'contract_end', %opt ) %>
 
-%     if ( $part_pkg->freq ) { 
+%     if ( $part_pkg->freq and !$supplemental ) { 
 
         <TR>
-          <TD COLSPAN=<%$colspan%>>
+          <TD COLSPAN=<%$opt{colspan}%>>
             <FONT SIZE=-1>
 %             if ( $curuser->access_right('Suspend customer package') ) { 
                 ( <% pkg_suspend_link($cust_pkg) %> )
@@ -251,8 +247,10 @@ my $bgcolor  = $opt{'bgcolor'};
 my $cust_pkg = $opt{'cust_pkg'};
 my $part_pkg = $opt{'part_pkg'};
 my $curuser  = $FS::CurrentUser::CurrentUser;
-my $colspan  = $opt{'cust_pkg-display_times'} ? 8 : 4;
 my $width    = $opt{'cust_pkg-display_times'} ? '38%' : '56%';
+my $supplemental = $opt{'supplemental'};
+
+$opt{colspan}  = $opt{'cust_pkg-display_times'} ? 8 : 4;
 
 #false laziness w/edit/REAL_cust_pkg.cgi
 my( $billed_or_prepaid, $last_bill_or_renewed, $next_bill_or_prepaid_until );
@@ -285,9 +283,27 @@ sub pkg_link {
 sub pkg_status_row {
   my( $cust_pkg, $title, $field, %opt ) = @_;
 
+  if ( $field and $cust_pkg->main_pkgnum ) {
+    # for supplemental packages, we mostly only show these if they're 
+    # different from the main package
+    my $main_pkg = $cust_pkg-> main_pkg;
+    if (    $main_pkg->get($field) ne $cust_pkg->get($field)
+        # with some exceptions
+        or  $field eq 'bill'
+        or  $field eq 'last_bill'
+        or  $field eq 'setup'
+        or  $field eq 'susp'
+        or  $field eq 'cancel'
+      ) {
+      # handle it normally
+    } else {
+      return '';
+    }
+  }
+
   my $color = $opt{'color'};
 
-  my $html = qq(<TR><TD WIDTH="<%$width%>" ALIGN="right">);
+  my $html = qq(<TR><TD WIDTH="$width" ALIGN="right">);
   $html   .= qq(<FONT COLOR="#$color"><B>) if length($color);
   $html   .= qq($title );
   $html   .= qq(</B></FONT>) if length($color);
@@ -338,7 +354,6 @@ sub pkg_status_row_changed {
                                      '',
                                      'size'    => '-1',
                                      'align'   => 'right',
-                                     'colspan' => $opt{'colspan'},
                                    );
   }
 
@@ -356,9 +371,7 @@ sub pkg_status_row_noauto {
   return '' unless $cust_main->payby =~ /^(CARD|CHEK)$/;
   my $what = lc(FS::payby->shortname($cust_main->payby));
 
-  pkg_status_row_colspan( $cust_pkg, emt("No automatic $what charge"), '',
-                          'colspan' => $opt{'colspan'},
-                        );
+  pkg_status_row_colspan( $cust_pkg, emt("No automatic $what charge"), '');
 }
 
 sub pkg_status_row_discount {
@@ -382,15 +395,24 @@ sub pkg_status_row_discount {
                   $cust_pkg_discount->pkgdiscountnum.
                 '">'.emt('remove discount').'</A>)</FONT>';
 
-    $html .= pkg_status_row_colspan( $cust_pkg, $label, '',
-                                     'colspan' => $opt{'colspan'},
-                                   );
+    $html .= pkg_status_row_colspan( $cust_pkg, $label, '', %opt );
 
   }
 
   $html;
 }
 
+sub pkg_reason_row {
+  my ($cust_pkg, $cpr, %opt) = @_;
+  return '' if $cust_pkg->main_pkgnum;
+
+  my $reasontext = '';
+  $reasontext = $cpr->reasontext . ' by ' . $cpr->otaker if $cpr;
+  pkg_status_row_colspan( $cust_pkg, $reasontext, '',
+    'align'=>'right', 'size'=>'-2', %opt
+  );
+}
+
 sub pkg_status_row_colspan {
   my($cust_pkg, $title, $addl, %opt) = @_;
 

-----------------------------------------------------------------------

Summary of changes:
 FS/FS/Schema.pm                                  |    2 +
 FS/FS/cust_main/Billing.pm                       |   25 ++-
 FS/FS/cust_main/Packages.pm                      |   31 +++
 FS/FS/cust_pkg.pm                                |  173 +++++++++++++-
 FS/FS/part_pkg.pm                                |   11 +
 FS/FS/part_pkg_link.pm                           |   22 ++-
 httemplate/browse/part_pkg.cgi                   |   13 +
 httemplate/edit/REAL_cust_pkg.cgi                |   87 ++++---
 httemplate/edit/cust_pkg.cgi                     |   36 +++-
 httemplate/edit/part_pkg.cgi                     |   14 +
 httemplate/edit/process/REAL_cust_pkg.cgi        |   57 +++--
 httemplate/edit/process/part_pkg.cgi             |    9 +
 httemplate/misc/confirm-cust_pkg-edit_dates.html |  283 ++++++++++++++++++++++
 httemplate/search/elements/search-html.html      |   12 +-
 httemplate/search/elements/search.html           |    5 +
 httemplate/view/cust_main/packages.html          |   25 ++-
 httemplate/view/cust_main/packages/package.html  |   80 ++++---
 httemplate/view/cust_main/packages/section.html  |   52 ++---
 httemplate/view/cust_main/packages/status.html   |  144 +++++++-----
 19 files changed, 877 insertions(+), 204 deletions(-)
 create mode 100755 httemplate/misc/confirm-cust_pkg-edit_dates.html




More information about the freeside-commits mailing list