one-time charge "hold for later" / any package future start date, RT#5347
authorivan <ivan>
Tue, 30 Jun 2009 12:28:38 +0000 (12:28 +0000)
committerivan <ivan>
Tue, 30 Jun 2009 12:28:38 +0000 (12:28 +0000)
FS/FS/Cron/bill.pm
FS/FS/Schema.pm
FS/FS/cust_main.pm
FS/FS/cust_pkg.pm
httemplate/edit/REAL_cust_pkg.cgi
httemplate/edit/process/REAL_cust_pkg.cgi
httemplate/edit/process/quick-charge.cgi
httemplate/edit/quick-charge.html
httemplate/view/cust_main/packages/status.html

index 29f5f36..61b4735 100644 (file)
@@ -13,10 +13,16 @@ use FS::part_event;
 use FS::part_event_condition;
 
 @ISA = qw( Exporter );
-@EXPORT_OK = qw ( bill );
+@EXPORT_OK = qw ( bill bill_where );
 
-sub bill {
+#freeside-daily %opt:
+#  -s: re-charge setup fees
+#  -v: enable debugging
+#  -l: debugging level
+#  -m: Experimental multi-process mode uses the job queue for multi-process and/or multi-machine billing.
+#  -r: Multi-process mode dry run option
 
+sub bill {
   my %opt = @_;
 
   my $check_freq = $opt{'check_freq'} || '1d';
@@ -24,91 +30,24 @@ sub bill {
   my $debug = 0;
   $debug = 1 if $opt{'v'};
   $debug = $opt{'l'} if $opt{'l'};
   $FS::cust_main::DEBUG = $debug;
   #$FS::cust_event::DEBUG = $opt{'l'} if $opt{'l'};
 
-  my @search = ();
-
-  push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable?
-
-  push @search, "cust_main.payby    = '". $opt{'p'}. "'"
-    if $opt{'p'};
-  push @search, "cust_main.agentnum =  ". $opt{'a'}
-    if $opt{'a'};
-
-  if ( @ARGV ) {
-    push @search, "( ".
-      join(' OR ', map "cust_main.custnum = $_", @ARGV ).
-    " )";
-  }
-
-  ###
-  # generate where_pkg/where_event search clause
-  ###
-
   #we're at now now (and later).
-  my($time)= $opt{'d'} ? str2time($opt{'d'}) : $^T;
-  $time += $opt{'y'} * 86400 if $opt{'y'};
-
-  my $invoice_time = $opt{'n'} ? $^T : $time;
+  $opt{'time'} = $opt{'d'} ? str2time($opt{'d'}) : $^T;
+  $opt{'time'} += $opt{'y'} * 86400 if $opt{'y'};
 
-  # select * from cust_main where
-  my $where_pkg = <<"END";
-    0 < ( select count(*) from cust_pkg
-            where cust_main.custnum = cust_pkg.custnum
-              and ( cancel is null or cancel = 0 )
-              and (    setup is null or setup =  0
-                    or bill  is null or bill  <= $time 
-                    or ( expire is not null and expire <= $^T )
-                    or ( adjourn is not null and adjourn <= $^T )
-                  )
-        )
-END
-
-  my $where_event = join(' OR ', map {
-    my $eventtable = $_;
-
-    my $join  = FS::part_event_condition->join_conditions_sql(  $eventtable );
-    my $where = FS::part_event_condition->where_conditions_sql( $eventtable,
-                                                                'time'=>$time,
-                                                              );
-
-    my $are_part_event = 
-      "0 < ( SELECT COUNT(*) FROM part_event $join
-               WHERE check_freq = '$check_freq'
-                 AND eventtable = '$eventtable'
-                 AND ( disabled = '' OR disabled IS NULL )
-                 AND $where
-           )
-      ";
-
-    if ( $eventtable eq 'cust_main' ) { 
-      $are_part_event;
-    } else {
-      "0 < ( SELECT COUNT(*) FROM $eventtable
-               WHERE cust_main.custnum = $eventtable.custnum
-                 AND $are_part_event
-           )
-      ";
-    }
-
-  } FS::part_event->eventtables);
-
-  push @search, "( $where_pkg OR $where_event )";
+  $opt{'invoice_time'} = $opt{'n'} ? $^T : $opt{'time'};
 
   ###
   # get a list of custnums
   ###
 
-  warn "searching for customers:\n". join("\n", @search). "\n"
-    if $opt{'v'} || $opt{'l'};
-
   my $cursor_dbh = dbh->clone;
 
   $cursor_dbh->do(
     "DECLARE cron_bill_cursor CURSOR FOR ".
-    "  SELECT custnum FROM cust_main WHERE ". join(' AND ', @search)
+    "  SELECT custnum FROM cust_main WHERE ". bill_where( %opt )
   ) or die $cursor_dbh->errstr;
 
   while ( 1 ) {
@@ -129,8 +68,8 @@ END
     foreach my $custnum ( @custnums ) {
     
       my %args = (
-          'time'         => $time,
-          'invoice_time' => $invoice_time,
+          'time'         => $opt{'time'},
+          'invoice_time' => $opt{'invoice_time'},
           'actual_time'  => $^T, #when freeside-bill was started
                                  #(not, when using -m, freeside-queued)
           'check_freq'   => $check_freq,
@@ -175,4 +114,108 @@ END
 
 }
 
+# freeside-daily %opt:
+#  -d: Pretend it's 'date'.  Date is in any format Date::Parse is happy with,
+#      but be careful.
+#
+#  -y: In addition to -d, which specifies an absolute date, the -y switch
+#      specifies an offset, in days.  For example, "-y 15" would increment the
+#      "pretend date" 15 days from whatever was specified by the -d switch
+#      (or now, if no -d switch was given).
+#
+#  -n: When used with "-d" and/or "-y", specifies that invoices should be dated
+#      with today's date, irregardless of the pretend date used to pre-generate
+#      the invoices.
+#
+#  -p: Only process customers with the specified payby (I<CARD>, I<DCRD>, I<CHEK>, I<DCHK>, I<BILL>, I<COMP>, I<LECB>)
+#
+#  -a: Only process customers with the specified agentnum
+#
+#  -v: enable debugging
+#
+#  -l: debugging level
+
+sub bill_where {
+  my( %opt ) = @_;
+
+  my $time = $opt{'time'};
+  my $invoice_time = $opt{'invoice_time'};
+
+  my $check_freq = $opt{'check_freq'} || '1d';
+
+  my @search = ();
+
+  push @search, "( cust_main.archived != 'Y' OR archived IS NULL )"; #disable?
+
+  push @search, "cust_main.payby    = '". $opt{'p'}. "'"
+    if $opt{'p'};
+  push @search, "cust_main.agentnum =  ". $opt{'a'}
+    if $opt{'a'};
+
+  if ( @ARGV ) {
+    push @search, "( ".
+      join(' OR ', map "cust_main.custnum = $_", @ARGV ).
+    " )";
+  }
+
+  ###
+  # generate where_pkg/where_event search clause
+  ###
+
+  # select * from cust_main where
+  my $where_pkg = <<"END";
+    EXISTS(
+      SELECT 1 FROM cust_pkg
+        WHERE cust_main.custnum = cust_pkg.custnum
+          AND ( cancel IS NULL OR cancel = 0 )
+          AND (    ( ( setup IS NULL OR setup =  0 )
+                     AND ( start_date IS NULL OR start_date = 0
+                           OR ( start_date IS NOT NULL AND start_date <= $^T )
+                         )
+                   )
+                OR bill  IS NULL OR bill  <= $time 
+                OR ( expire  IS NOT NULL AND expire  <= $^T )
+                OR ( adjourn IS NOT NULL AND adjourn <= $^T )
+              )
+    )
+END
+
+  my $where_event = join(' OR ', map {
+    my $eventtable = $_;
+
+    my $join  = FS::part_event_condition->join_conditions_sql(  $eventtable );
+    my $where = FS::part_event_condition->where_conditions_sql( $eventtable,
+                                                                'time'=>$time,
+                                                              );
+
+    my $are_part_event = 
+      "EXISTS ( SELECT 1 FROM part_event $join
+                  WHERE check_freq = '$check_freq'
+                    AND eventtable = '$eventtable'
+                    AND ( disabled = '' OR disabled IS NULL )
+                    AND $where
+              )
+      ";
+
+    if ( $eventtable eq 'cust_main' ) { 
+      $are_part_event;
+    } else {
+      "EXISTS ( SELECT 1 FROM $eventtable
+                  WHERE cust_main.custnum = $eventtable.custnum
+                    AND $are_part_event
+              )
+      ";
+    }
+
+  } FS::part_event->eventtables);
+
+  push @search, "( $where_pkg OR $where_event )";
+
+  warn "searching for customers:\n". join("\n", @search). "\n"
+    if $opt{'v'} || $opt{'l'};
+
+  join(' AND ', @search);
+
+}
+
 1;
index 4351f28..3c203b9 100644 (file)
@@ -1085,6 +1085,7 @@ sub tables_hashref {
         'pkgpart',             'int',     '', '', '', '', 
         'locationnum',         'int', 'NULL', '', '', '',
         'otaker',          'varchar',     '', 32, '', '', 
+        'start_date',     @date_type,             '', '', 
         'setup',          @date_type,             '', '', 
         'bill',           @date_type,             '', '', 
         'last_bill',      @date_type,             '', '', 
@@ -1102,8 +1103,8 @@ sub tables_hashref {
       'primary_key' => 'pkgnum',
       'unique' => [],
       'index' => [ ['custnum'], ['pkgpart'], [ 'locationnum' ],
-                   ['setup'], ['last_bill'], ['bill'], ['susp'], ['adjourn'],
-                   ['expire'], ['cancel'],
+                   [ 'start_date' ], ['setup'], ['last_bill'], ['bill'],
+                   ['susp'], ['adjourn'], ['expire'], ['cancel'],
                    ['change_date'],
                  ],
     },
index e5f289c..5768beb 100644 (file)
@@ -9,6 +9,7 @@ use Safe;
 use Carp;
 use Exporter;
 use Scalar::Util qw( blessed );
+use List::Util qw( min );
 use Time::Local qw(timelocal);
 use Data::Dumper;
 use Tie::IxHash;
@@ -2056,6 +2057,18 @@ sub unsuspended_pkgs {
   grep { ! $_->susp } $self->ncancelled_pkgs;
 }
 
+=item next_bill_date
+
+Returns the next date this customer will be billed, as a UNIX timestamp, or
+undef if no active package has a next bill date.
+
+=cut
+
+sub next_bill_date {
+  my $self = shift;
+  min( map $_->get('bill'), grep $_->get('bill'), $self->unsuspended_pkgs );
+}
+
 =item num_cancelled_pkgs
 
 Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
@@ -2760,14 +2773,19 @@ sub _make_lines {
 
   my $setup = 0;
   my $unitsetup = 0;
-  if ( ! $cust_pkg->setup &&
-       (
-         ( $conf->exists('disable_setup_suspended_pkgs') &&
-          ! $cust_pkg->getfield('susp')
-        ) || ! $conf->exists('disable_setup_suspended_pkgs')
-       )
-    || $options{'resetup'}
-  ) {
+  if ( $options{'resetup'}
+       || ( ! $cust_pkg->setup
+            && ( ! $cust_pkg->start_date
+                 || $cust_pkg->start_date <= $time
+               )
+            && ( ! $conf->exists('disable_setup_suspended_pkgs')
+                 || ( $conf->exists('disable_setup_suspended_pkgs') &&
+                      ! $cust_pkg->getfield('susp')
+                    )
+               )
+          )
+    )
+  {
     
     warn "    bill setup\n" if $DEBUG > 1;
     $lineitems++;
@@ -2783,6 +2801,9 @@ sub _make_lines {
           #do need it, but it won't get written to the db
           #|| $cust_pkg->pkgpart != $real_pkgpart;
 
+    $cust_pkg->setfield('start_date', '')
+      if $cust_pkg->start_date;
+
   }
 
   ###
@@ -6727,12 +6748,14 @@ the error, otherwise returns false.
 
 sub charge {
   my $self = shift;
-  my ( $amount, $quantity, $pkg, $comment, $classnum, $additional );
+  my ( $amount, $quantity, $start_date, $classnum );
+  my ( $pkg, $comment, $additional );
   my ( $setuptax, $taxclass );   #internal taxes
   my ( $taxproduct, $override ); #vendor (CCH) taxes
   if ( ref( $_[0] ) ) {
     $amount     = $_[0]->{amount};
     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
+    $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
                                            : '$'. sprintf("%.2f",$amount);
@@ -6742,9 +6765,10 @@ sub charge {
     $additional = $_[0]->{additional};
     $taxproduct = $_[0]->{taxproductnum};
     $override   = { '' => $_[0]->{tax_override} };
-  }else{
+  } else {
     $amount     = shift;
     $quantity   = 1;
+    $start_date = '';
     $pkg        = @_ ? shift : 'One-time charge';
     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
     $setuptax   = '';
@@ -6802,9 +6826,10 @@ sub charge {
   }
 
   my $cust_pkg = new FS::cust_pkg ( {
-    'custnum'  => $self->custnum,
-    'pkgpart'  => $pkgpart,
-    'quantity' => $quantity,
+    'custnum'    => $self->custnum,
+    'pkgpart'    => $pkgpart,
+    'quantity'   => $quantity,
+    'start_date' => $start_date,
   } );
 
   $error = $cust_pkg->insert;
index 881e005..d2f0690 100644 (file)
@@ -122,6 +122,10 @@ Billing item definition (see L<FS::part_pkg>)
 
 Optional link to package location (see L<FS::location>)
 
+=item start_date
+
+date
+
 =item setup
 
 date
@@ -479,6 +483,7 @@ sub check {
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_numbern('pkgpart')
     || $self->ut_foreign_keyn('locationnum', 'cust_location', 'locationnum')
+    || $self->ut_numbern('start_date')
     || $self->ut_numbern('setup')
     || $self->ut_numbern('bill')
     || $self->ut_numbern('susp')
index a707aca..5752c8d 100755 (executable)
@@ -46,6 +46,7 @@
     <TD BGCOLOR="#ffffff"><% $cust_pkg->otaker %></TD>
   </TR>
 
+  <& .row_edit, cust_pkg=>$cust_pkg, column=>'start_date', label=>'Start' &>
   <& .row_edit, cust_pkg=>$cust_pkg, column=>'setup',     label=>'Setup' &>
   <& .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 &>
index c99ddc2..d4ba976 100755 (executable)
@@ -19,6 +19,7 @@ die "access denied"
 my $pkgnum = $cgi->param('pkgnum') or die;
 my $old = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
 my %hash = $old->hash;
+$hash{'start_date'} = $cgi->param('start_date') ? str2time($cgi->param('start_date')) : '';
 $hash{'setup'} = $cgi->param('setup') ? str2time($cgi->param('setup')) : '';
 $hash{'bill'} = $cgi->param('bill') ? str2time($cgi->param('bill')) : '';
 $hash{'last_bill'} =
index 470cd4b..8f0e424 100644 (file)
@@ -55,6 +55,10 @@ unless ( $error ) {
   $error ||= $cust_main->charge( {
     'amount'        => $amount,
     'quantity'      => $quantity,
+    'start_date'    => ( scalar($cgi->param('start_date'))
+                           ? str2time($cgi->param('start_date'))
+                           : ''
+                       ),
     'pkg'           => scalar($cgi->param('pkg')),
     'setuptax'      => scalar($cgi->param('setuptax')),
     'taxclass'      => scalar($cgi->param('taxclass')),
index 8aca34a..ad26bff 100644 (file)
@@ -3,6 +3,11 @@
           )
 %>
 
+<LINK REL="stylesheet" TYPE="text/css" HREF="../elements/calendar-win2k-2.css" TITLE="win2k-2">
+<SCRIPT TYPE="text/javascript" SRC="../elements/calendar_stripped.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="../elements/calendar-en.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="../elements/calendar-setup.js"></SCRIPT>
+
 <% include('/elements/error.html') %>
 
 <SCRIPT TYPE="text/javascript">
@@ -79,6 +84,33 @@ function validate_quick_charge () {
 
 <% include('/elements/tr-select-pkg_class.html', 'curr_value' => $cgi->param('classnum') ) %>
 
+<TR>
+  <TD ALIGN="right">Charge date </TD>
+  <TD>
+    <INPUT TYPE  = "text"
+           NAME  = "start_date"
+           SIZE  = 32
+           ID    = "start_date_text"
+           VALUE = "<% $start_date %>"
+    >
+    <IMG SRC   = "../images/calendar.png"
+         ID    = "start_date_button"
+         STYLE = "cursor: pointer"
+         TITLE = "Select date"
+    >
+    <FONT SIZE=-1>(leave blank to charge immediately)</FONT>
+  </TD>
+</TR>
+
+<SCRIPT TYPE="text/javascript">
+  Calendar.setup({
+    inputField: "start_date_text",
+    ifFormat:   "%m/%d/%Y",
+    button:     "start_date_button",
+    align:      "BR"
+  });
+</SCRIPT>
+
 
 <TR>
   <TD ALIGN="right">Tax exempt </TD>
@@ -179,6 +211,12 @@ my $conf = new FS::Conf;
 
 $cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
 my $custnum = $1;
+my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); #XXX agent-virt
+
+my $format = "%m/%d/%Y %T %z (%Z)"; #false laziness w/REAL_cust_pkg.cgi?
+my $start_date = $cust_main->next_bill_date;
+warn "*** $start_date";
+$start_date = $start_date ? time2str($format, $start_date) : '';
 
 my $amount = '';
 if ( $cgi->param('amount') =~ /^\s*\$?\s*(\d+(\.\d{1,2})?)\s*$/ ) {
index 141ed15..6daff50 100644 (file)
 
     <% pkg_status_row_colspan(
          ( $cpr ? $cpr->reasontext. ' by '. $cpr->otaker : '' ), '',
-         'align' => 'right', 'color' => 'ff0000', 'size' => '-2',
+         'align'=>'right', 'color'=>'ff0000', 'size'=>'-2', 'colspan'=>$colspan,
        )
     %>
 
 %   unless ( $cust_pkg->get('setup') ) { 
 
-        <% pkg_status_row_colspan('Never billed') %>
+        <% pkg_status_row_colspan('Never billed', '', 'colspan'=>$colspan, ) %>
 
 %   } else { 
 
        <% pkg_status_row( $cust_pkg, 'Setup', 'setup', %opt ) %>
-       <% pkg_status_row_changed( $cust_pkg, %opt ) %>
+       <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
        <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
        <% pkg_status_row_if( $cust_pkg, 'Suspended', 'susp', %opt, curuser=>$curuser ) %>
 
 
     <% pkg_status_row_colspan(
          ( $cpr ? $cpr->reasontext. ' by '. $cpr->otaker : '' ), '',
-         'align' => 'right', 'color' => 'FF9900', 'size' => '-2',
+         'align'=>'right', 'color'=>'FF9900', 'size'=>'-2', 'colspan'=>$colspan,
        )
     %>
 
 %   unless ( $cust_pkg->get('setup') ) { 
-      <% pkg_status_row_colspan('Never billed') %>
+      <% pkg_status_row_colspan('Never billed', '', 'colspan'=>$colspan ) %>
 %   } else { 
       <% pkg_status_row($cust_pkg, 'Setup', 'setup', %opt ) %>
 %   } 
 
-    <% pkg_status_row_changed( $cust_pkg, %opt ) %>
+    <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
     <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
 %   # pkg_status_row($cust_pkg, 'Next bill', 'bill', %opt)
     <% pkg_status_row_if( $cust_pkg, 'Expires', 'expire', %opt, curuser=>$curuser ) %>
@@ -70,7 +70,9 @@
 %
 %       unless ( $part_pkg->freq ) { 
 
-          <% pkg_status_row_colspan('Not&nbsp;yet&nbsp;billed&nbsp;(one-time&nbsp;charge)') %>
+          <% pkg_status_row_colspan('Not&nbsp;yet&nbsp;billed&nbsp;(one-time&nbsp;charge)', '', 'colspan'=>$colspan, ) %>
+
+          <% pkg_status_row_if($cust_pkg, 'Start billing', 'start_date', %opt) %>
 
           <TR>
             <TD COLSPAN=<%$colspan%>>
@@ -84,7 +86,9 @@
 
 %       } else { 
 
-         <% pkg_status_row_colspan("Not&nbsp;yet&nbsp;billed&nbsp;($billed_or_prepaid&nbsp;". myfreq($part_pkg). ')' ) %>
+         <% pkg_status_row_colspan("Not&nbsp;yet&nbsp;billed&nbsp;($billed_or_prepaid&nbsp;". myfreq($part_pkg). ')', '', 'colspan'=>$colspan ) %>
+
+          <% pkg_status_row_if($cust_pkg, 'Start billing', 'start_date', %opt) %>
 
 %       } 
 %
@@ -92,7 +96,7 @@
 %
 %       unless ( $part_pkg->freq ) { 
 
-          <% pkg_status_row_colspan('One-time&nbsp;charge') %>
+          <% pkg_status_row_colspan('One-time&nbsp;charge', '', 'colspan'=>$colspan, ) %>
 
           <% pkg_status_row($cust_pkg, 'Billed', 'setup', %opt) %>
 
             <% pkg_status_row_colspan(
                  'Overlimit',
                  $billed_or_prepaid. '&nbsp;'. myfreq($part_pkg),
-                 'color' => 'FFD000',
+                 'color'=>'FFD000', 'colspan'=>$colspan,
                )
             %>
 
             <% pkg_status_row_colspan(
                  'Active',
                  $billed_or_prepaid. '&nbsp;'. myfreq($part_pkg),
-                 'color' => '00CC00',
+                 'color'=>'00CC00', 'colspan'=>$colspan,
                )
             %>
 %         } 
 %       $cust_pkg->set('autosuspend', $autosuspend) if $autosuspend;
 %     }
 
-      <% pkg_status_row_changed( $cust_pkg, %opt ) %>
+      <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
       <% 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, 'Will automatically suspend by', 'autosuspend', %opt) %>
@@ -238,21 +242,32 @@ sub pkg_status_row_if {
 
 sub pkg_status_row_changed {
   my( $cust_pkg, %opt ) = @_;
+
   return '' unless $cust_pkg->change_date;
-  my $html = pkg_status_row( $cust_pkg, 'Package&nbsp;changed', 'change_date', %opt );
+
+  my $html =
+    pkg_status_row( $cust_pkg, 'Package&nbsp;changed', 'change_date', %opt );
+
   my $old = $cust_pkg->old_cust_pkg;
   if ( $old ) {
     my $part_pkg = $old->part_pkg;
     my $label = 'Changed from '. $cust_pkg->change_pkgnum. ': '.
                 $part_pkg->pkg_comment(nopartpkg => 1);
-    $html .= pkg_status_row_colspan( $label, '', size=>'-1', align=>'right' );
+    $html .= pkg_status_row_colspan( $label, '',
+                                     'size'    => '-1',
+                                     'align'   => 'right',
+                                     'colspan' => $opt{'colspan'},
+                                   );
   }
+
   $html;
 }
 
 sub pkg_status_row_colspan {
   my($title, $addl, %opt) = @_;
 
+  my $colspan  = $opt{'colspan'};
+
   my $align = $opt{'align'} ? 'ALIGN="'. $opt{'align'}.'"' : '';
   my $color = $opt{'color'} ? 'COLOR="#'.$opt{'color'}.'"' : '';
   my $size  = $opt{'size'}  ? 'SIZE="'.  $opt{'size'}. '"' : '';