[freeside-commits] branch master updated. 4d6c465f4b32a49f8bce091f6cb5abb209123ec2

Ivan ivan at 420.am
Wed Aug 1 14:07:24 PDT 2012


The branch, master has been updated
       via  4d6c465f4b32a49f8bce091f6cb5abb209123ec2 (commit)
       via  bec3b6c2bf97d66b992866d7ee7295f1f05452e6 (commit)
       via  55675d6cdd93f00b7c0ac93403e8c4d66567a729 (commit)
       via  338eca0837fdaddbda2a34ddf8af3f815de13d26 (commit)
       via  5c48396fab6b19e33dbeac6f807860441465fa3b (commit)
       via  f24c4bebce257bfcc61ba07fd3d16c5c0d730071 (commit)
      from  6cce5ada4fbf1e9ad7debd0451336e8005c12195 (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 4d6c465f4b32a49f8bce091f6cb5abb209123ec2
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Wed Aug 1 14:04:50 2012 -0700

    invoice voiding, RT#18677

diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index c45e783..663a48e 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -322,6 +322,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::cust_bill_pkg_tax_location_void;
   use FS::cust_bill_pkg_tax_rate_location_void;
   use FS::cust_tax_exempt_pkg_void;
+  use FS::cust_bill_pkg_discount_void;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index e59268b..cff0afd 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -1784,6 +1784,19 @@ sub tables_hashref {
       'index' => [ [ 'billpkgnum' ], [ 'pkgdiscountnum' ] ],
     },
 
+    'cust_bill_pkg_discount_void' => {
+      'columns' => [
+        'billpkgdiscountnum',    'int',        '', '', '', '',
+        'billpkgnum',            'int',        '', '', '', '', 
+        'pkgdiscountnum',        'int',        '', '', '', '', 
+        'amount',          @money_type,                '', '', 
+        'months',            'decimal', 'NULL', '7,4', '', '',
+      ],
+      'primary_key' => 'billpkgdiscountnum',
+      'unique' => [],
+      'index' => [ [ 'billpkgnum' ], [ 'pkgdiscountnum' ] ],
+    },
+
     'discount' => {
       'columns' => [
         'discountnum', 'serial',     '',      '', '', '',
diff --git a/FS/FS/cust_bill_pkg_discount.pm b/FS/FS/cust_bill_pkg_discount.pm
index e7dd5f2..dfa83d3 100644
--- a/FS/FS/cust_bill_pkg_discount.pm
+++ b/FS/FS/cust_bill_pkg_discount.pm
@@ -28,8 +28,8 @@ FS::cust_bill_pkg_discount - Object methods for cust_bill_pkg_discount records
 =head1 DESCRIPTION
 
 An FS::cust_bill_pkg_discount object represents the slice of a customer
-applied to a line item.  FS::cust_bill_pkg_discount inherits from
-FS::Record.  The following fields are currently supported:
+discount applied to a specific line item.  FS::cust_bill_pkg_discount inherits
+from FS::Record.  The following fields are currently supported:
 
 =over 4
 
diff --git a/FS/FS/quotation.pm b/FS/FS/quotation.pm
index ccaa1c3..9e7723c 100644
--- a/FS/FS/quotation.pm
+++ b/FS/FS/quotation.pm
@@ -142,9 +142,8 @@ sub cust_main {
 
 =cut
 
-sub cust_bill_pkg {
+sub cust_bill_pkg { #actually quotation_pkg objects
   my $self = shift;
-  #actually quotation_pkg objects
   qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
 }
 
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 01dab20..2163a23 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -663,3 +663,5 @@ FS/cust_bill_pkg_tax_rate_location_void.pm
 t/cust_bill_pkg_tax_rate_location_void.t
 FS/cust_tax_exempt_pkg_void.pm
 t/cust_tax_exempt_pkg_void.t
+FS/cust_bill_pkg_discount_void.pm
+t/cust_bill_pkg_discount_void.t
diff --git a/httemplate/view/cust_bill.cgi b/httemplate/view/cust_bill.cgi
index a8b4ac1..95ce60b 100755
--- a/httemplate/view/cust_bill.cgi
+++ b/httemplate/view/cust_bill.cgi
@@ -166,8 +166,6 @@ die "Invoice #$invnum not found!" unless $cust_bill;
 my $custnum = $cust_bill->custnum;
 my $display_custnum = $cust_bill->cust_main->display_custnum;
 
-#my $printed = $cust_bill->printed;
-
 my $link = "invnum=$invnum";
 $link .= ';template='. uri_escape($template) if $template;
 $link .= ';notice_name='. $notice_name if $notice_name;
diff --git a/httemplate/view/cust_bill_void.html b/httemplate/view/cust_bill_void.html
index 148c0ed..2c52674 100755
--- a/httemplate/view/cust_bill_void.html
+++ b/httemplate/view/cust_bill_void.html
@@ -2,7 +2,19 @@
   emt("View this customer (#[_1])",$display_custnum) => "${p}view/cust_main.cgi?$custnum",
 ) &>
 
-%#XXX something very big and obvious showing its voided...
+<SCRIPT TYPE="text/javascript">
+function areyousure(href, message) {
+  if (confirm(message) == true)
+    window.location.href = href;
+}
+</SCRIPT>
+<% areyousure_link("${p}misc/unvoid-cust_bill_void.html?invnum=". $cust_bill_void->invnum,
+                     emt('Are you sure you want to unvoid this invoice?'),
+                     emt('Unvoid this invoice'), #tooltip
+                     emt('Unvoid this invoice') #link
+                  )
+%>
+<BR><BR>
 
 % #voided PDFs?
 % #if ( $conf->exists('invoice_latex') ) {
@@ -11,6 +23,7 @@
 %#  <BR><BR>
 % #} 
 
+%#something very big and obvious showing its voided...
 <DIV STYLE="color:#FF0000; font-size:1000%; font-weight:bold; z-index:100;
             position: absolute; top: 300px; left: 130px;
             zoom: 1; filter: alpha(opacity=25); opacity: 0.25;
@@ -58,4 +71,9 @@ my $display_custnum = $cust_bill_void->cust_main->display_custnum;
 
 #my $link = "invnum=$invnum";
 
+sub areyousure_link {
+    my ($url,$msg,$title,$label) = (shift,shift,shift,shift);
+    '<A HREF="javascript:areyousure(\''.$url.'\',\''.$msg.'\')" TITLE="'.$title.'">'.$label.'</A>';
+}
+
 </%init>
diff --git a/httemplate/view/quotation.html b/httemplate/view/quotation.html
index 461b5df..a88acf8 100755
--- a/httemplate/view/quotation.html
+++ b/httemplate/view/quotation.html
@@ -44,8 +44,6 @@ XXX resending quotations
 % }
 % #plaintext quotations? <PRE><% join('', $quotation->print_text() ) %></PRE>
 
-</%doc>
-
 <& /elements/footer.html &>
 <%init>
 

commit bec3b6c2bf97d66b992866d7ee7295f1f05452e6
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Wed Aug 1 14:01:14 2012 -0700

    invoice voiding, RT#18677

diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index ebf66e6..b41ec2f 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -178,6 +178,7 @@ tie my %rights, 'Tie::IxHash',
     'View invoices',
     'Resend invoices', #NEWNEW
     'Void invoices',
+    'Unvoid invoices',
     'Delete invoices',
     'View customer tax exemptions', #yow
     'Add customer tax adjustment', #new, but no need to phase in
@@ -228,11 +229,11 @@ tie my %rights, 'Tie::IxHash',
   ###
   # customer voiding rights..
   ###
-  'Customer void rights' => [
+  'Customer payment void rights' => [
     { rightname=>'Credit card void', desc=>'Enable local-only voiding of echeck payments in addition to refunds against the payment gateway.' }, #aka. cc-void 
     { rightname=>'Echeck void', desc=>'Enable local-only voiding of echeck payments in addition to refunds against the payment gateway.' }, #aka. echeck-void
-    'Regular void',
-    { rightname=>'Unvoid', desc=>'Enable unvoiding of voided payments' }, #aka. unvoid 
+    'Void payments',
+    { rightname=>'Unvoid payments', desc=>'Enable unvoiding of voided payments' }, #aka. unvoid 
     
   
   ],
diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm
index bc57364..dc9f997 100644
--- a/FS/FS/access_right.pm
+++ b/FS/FS/access_right.pm
@@ -152,6 +152,8 @@ sub _upgrade_data { # class method
     'Process payment' => [ 'Process credit card payment', 'Process Echeck payment' ],
     'Post refund'     => [ 'Post check refund', 'Post cash refund' ],
     'Refund payment'  => [ 'Refund credit card payment', 'Refund Echeck payment' ],
+    'Regular void'    => [ 'Void payments' ],
+    'Unvoid'          => [ 'Unvoid payments', 'Unvoid invoices' ],
   );
 
   foreach my $oldright (keys %migrate) {
@@ -174,9 +176,10 @@ sub _upgrade_data { # class method
         die $error if $error;
       }
 
-      #after the WEST stuff is sorted, etc.
-      #my $error = $old->delete;
-      #die $error if $error;
+      unless ( $oldright =~ / (payment|refund)$/ ) { #after the WEST stuff is sorted
+        my $error = $old->delete;
+        die $error if $error;
+      }
 
     }
 
diff --git a/FS/FS/cust_bill_pkg_void.pm b/FS/FS/cust_bill_pkg_void.pm
index 7855d58..b7c6fee 100644
--- a/FS/FS/cust_bill_pkg_void.pm
+++ b/FS/FS/cust_bill_pkg_void.pm
@@ -2,11 +2,12 @@ package FS::cust_bill_pkg_void;
 use base qw( FS::TemplateItem_Mixin FS::Record );
 
 use strict;
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh fields );
 use FS::cust_bill_void;
 use FS::cust_bill_pkg_detail_void;
 use FS::cust_bill_pkg_display_void;
 use FS::cust_bill_pkg_discount_void;
+use FS::cust_bill_pkg;
 
 =head1 NAME
 
@@ -129,21 +130,84 @@ sub discount_table          { 'cust_bill_pkg_discount_void'; }
 Adds this record to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+=item unvoid 
+
+"Un-void"s this line item: Deletes the voided line item from the database and
+adds back a normal line item (and related tables).
+
 =cut
 
+sub unvoid {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill_pkg = new FS::cust_bill_pkg ( {
+    map { $_ => $self->get($_) } fields('cust_bill_pkg')
+  } );
+  my $error = $cust_bill_pkg->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $table (qw(
+    cust_bill_pkg_detail
+    cust_bill_pkg_display
+    cust_bill_pkg_discount
+    cust_bill_pkg_tax_location
+    cust_bill_pkg_tax_rate_location
+    cust_tax_exempt_pkg
+  )) {
+
+    foreach my $voided (
+      qsearch($table.'_void', { billpkgnum=>$self->billpkgnum })
+    ) {
+
+      my $class = 'FS::'.$table;
+      my $unvoid = $class->new( {
+        map { $_ => $voided->get($_) } fields($table)
+      });
+      my $error = $unvoid->insert || $voided->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+
+    }
+
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item delete
 
 Delete this record from the database.
 
-=cut
-
 =item replace OLD_RECORD
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
-=cut
-
 =item check
 
 Checks all fields to make sure this is a valid record.  If there is
diff --git a/FS/FS/cust_bill_void.pm b/FS/FS/cust_bill_void.pm
index cd6a9e1..cce77b3 100644
--- a/FS/FS/cust_bill_void.pm
+++ b/FS/FS/cust_bill_void.pm
@@ -2,11 +2,12 @@ package FS::cust_bill_void;
 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
 
 use strict;
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh fields );
 use FS::cust_main;
 use FS::cust_statement;
 use FS::access_user;
 use FS::cust_bill_pkg_void;
+use FS::cust_bill;
 
 =head1 NAME
 
@@ -117,7 +118,55 @@ otherwise returns false.
 
 =cut
 
-# the insert method can be inherited from FS::Record
+=item unvoid 
+
+"Un-void"s this invoice: Deletes the voided invoice from the database and adds
+back a normal invoice (and related tables).
+
+=cut
+
+sub unvoid {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill = new FS::cust_bill ( {
+    map { $_ => $self->get($_) } fields('cust_bill')
+  } );
+  my $error = $cust_bill->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $cust_bill_pkg_void ( $self->cust_bill_pkg ) {
+    my $error = $cust_bill_pkg_void->unvoid;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
 
 =item delete
 
@@ -125,8 +174,6 @@ Delete this record from the database.
 
 =cut
 
-# the delete method can be inherited from FS::Record
-
 =item replace OLD_RECORD
 
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
@@ -134,8 +181,6 @@ returns the error, otherwise returns false.
 
 =cut
 
-# the replace method can be inherited from FS::Record
-
 =item check
 
 Checks all fields to make sure this is a valid voided invoice.  If there is
@@ -144,9 +189,6 @@ and replace methods.
 
 =cut
 
-# the check method should currently be supplied - FS::Record contains some
-# data checking routines
-
 sub check {
   my $self = shift;
 
@@ -230,7 +272,6 @@ sub cust_bill_pkg { #actually cust_bill_pkg_void objects
 
 sub enable_previous { 0 }
 
-
 =back
 
 =head1 BUGS
diff --git a/httemplate/misc/process/void-cust_bill.html b/httemplate/misc/process/void-cust_bill.html
index f2930ec..899901a 100755
--- a/httemplate/misc/process/void-cust_bill.html
+++ b/httemplate/misc/process/void-cust_bill.html
@@ -2,7 +2,11 @@
 %  $cgi->param('error', $error);
 <% $cgi->redirect(popurl(1). "void-cust_bill.html?". $cgi->query_string ) %>
 %} else {
-<% $cgi->redirect(popurl(3). "view/cust_main.cgi?". $custnum) %>
+<& /elements/header-popup.html, 'Invoice voided' &>
+<SCRIPT TYPE="text/javascript">
+  window.top.location.reload();
+</SCRIPT>
+</BODY></HTML>
 %}
 <%init>
 
diff --git a/httemplate/misc/unvoid-cust_bill_void.html b/httemplate/misc/unvoid-cust_bill_void.html
new file mode 100755
index 0000000..f614165
--- /dev/null
+++ b/httemplate/misc/unvoid-cust_bill_void.html
@@ -0,0 +1,25 @@
+%if ( $error ) {
+%  errorpage($error);
+%} else {
+%   my $show = $curuser->default_customer_view =~ /^(jumbo|payment_history)$/
+%                ? ''
+%                : ';show=payment_history';
+<% $cgi->redirect($p. "view/cust_main.cgi?custnum=$custnum$show" ) %>
+%}
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Unvoid invoices');
+
+#untaint invnum
+$cgi->param('invnum') =~ /^(\d+)$/ || die "Illegal invnum";
+my $invnum = $1;
+
+my $cust_bill_void = qsearchs('cust_bill_void', { 'invnum' => $invnum } );
+my $custnum = $cust_bill_void->custnum;
+
+my $error = $cust_bill_void->unvoid;
+
+</%init>
diff --git a/httemplate/misc/unvoid-cust_pay_void.cgi b/httemplate/misc/unvoid-cust_pay_void.cgi
index 91fe1c2..4726ee5 100755
--- a/httemplate/misc/unvoid-cust_pay_void.cgi
+++ b/httemplate/misc/unvoid-cust_pay_void.cgi
@@ -6,7 +6,7 @@
 <%init>
 
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Unvoid');
+  unless $FS::CurrentUser::CurrentUser->access_right('Unvoid payments');
 
 #untaint paynum
 my($query) = $cgi->keywords;
diff --git a/httemplate/misc/void-cust_pay.cgi b/httemplate/misc/void-cust_pay.cgi
index 7b484e9..31b7a62 100755
--- a/httemplate/misc/void-cust_pay.cgi
+++ b/httemplate/misc/void-cust_pay.cgi
@@ -12,7 +12,7 @@ my $paynum = $1;
 
 my $cust_pay = qsearchs('cust_pay',{'paynum'=>$paynum});
 
-my $right = 'Regular void';
+my $right = 'Void payments';
 $right = 'Credit card void' if $cust_pay->payby eq 'CARD';
 $right = 'Echeck void'      if $cust_pay->payby eq 'CHEK';
 
diff --git a/httemplate/view/cust_main/payment_history/payment.html b/httemplate/view/cust_main/payment_history/payment.html
index d7322a2..ff269bf 100644
--- a/httemplate/view/cust_main/payment_history/payment.html
+++ b/httemplate/view/cust_main/payment_history/payment.html
@@ -181,7 +181,7 @@ $void = areyousure_link("${p}misc/void-cust_pay.cgi?".$cust_pay->paynum,
                && $curuser->access_right('Echeck void')
              )
           || ( $cust_pay->payby !~ /^(CARD|CHEK)$/
-               && $curuser->access_right('Regular void')
+               && $curuser->access_right('Void payments')
              )
         )
    );
diff --git a/httemplate/view/cust_main/payment_history/voided_invoice.html b/httemplate/view/cust_main/payment_history/voided_invoice.html
index 7bf2063..15393cb 100644
--- a/httemplate/view/cust_main/payment_history/voided_invoice.html
+++ b/httemplate/view/cust_main/payment_history/voided_invoice.html
@@ -25,7 +25,13 @@ my $link = $curuser->access_right('View invoices')
              ? qq!<A HREF="${p}view/cust_bill_void.html?$invnum">!
              : '';
 
-my $unvoid = ''; #XXX unvoid
+my $unvoid = '';
+$unvoid = areyousure_link("${p}misc/unvoid-cust_bill_void.html?invnum=". $cust_bill_void->invnum,
+                            emt('Are you sure you want to unvoid this invoice?'),
+                            emt('Unvoid this invoice'),
+                            emt('unvoid')
+                         )
+  if $cust_bill_void->closed !~ /^Y/ && $curuser->access_right('Unvoid invoices');
 
 my $delete = '';
 $delete = areyousure_link("${p}misc/delete-cust_bill.html?$invnum",
diff --git a/httemplate/view/cust_main/payment_history/voided_payment.html b/httemplate/view/cust_main/payment_history/voided_payment.html
index 2f038be..88b5e0a 100644
--- a/httemplate/view/cust_main/payment_history/voided_payment.html
+++ b/httemplate/view/cust_main/payment_history/voided_payment.html
@@ -31,6 +31,6 @@ $unvoid = areyousure_link("${p}misc/unvoid-cust_pay_void.cgi?".$cust_pay_void->p
                             emt('Unvoid this payment from the database') . $unvoidmsg,
                             emt('unvoid')
                          )
-    if ( $cust_pay_void->closed !~ /^Y/i && $curuser->access_right('Unvoid') );
+    if ( $cust_pay_void->closed !~ /^Y/i && $curuser->access_right('Unvoid payments') );
 
 </%init>

commit 55675d6cdd93f00b7c0ac93403e8c4d66567a729
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Wed Aug 1 13:16:42 2012 -0700

    invoice voiding, RT#18677

diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm
new file mode 100644
index 0000000..6d7ea26
--- /dev/null
+++ b/FS/FS/TemplateItem_Mixin.pm
@@ -0,0 +1,317 @@
+package FS::TemplateItem_Mixin;
+
+use strict;
+use vars qw( $DEBUG $me ); # but NOT $conf
+use Carp;
+use FS::UID;
+use FS::Record qw( qsearch qsearchs dbh );
+use FS::part_pkg;
+use FS::cust_pkg;
+
+$DEBUG = 0;
+$me = '[FS::TemplateItem_Mixin]';
+
+=item cust_pkg
+
+Returns the package (see L<FS::cust_pkg>) for this invoice line item.
+
+=cut
+
+sub cust_pkg {
+  my $self = shift;
+  carp "$me $self -> cust_pkg" if $DEBUG;
+  qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
+}
+
+=item part_pkg
+
+Returns the package definition for this invoice line item.
+
+=cut
+
+sub part_pkg {
+  my $self = shift;
+  if ( $self->pkgpart_override ) {
+    qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
+  } else {
+    my $part_pkg;
+    my $cust_pkg = $self->cust_pkg;
+    $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
+    $part_pkg;
+  }
+
+}
+
+=item desc
+
+Returns a description for this line item.  For typical line items, this is the
+I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
+For one-shot line items and named taxes, it is the I<itemdesc> field of this
+line item, and for generic taxes, simply returns "Tax".
+
+=cut
+
+sub desc {
+  my $self = shift;
+
+  if ( $self->pkgnum > 0 ) {
+    $self->itemdesc || $self->part_pkg->pkg;
+  } else {
+    my $desc = $self->itemdesc || 'Tax';
+    $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
+    $desc;
+  }
+}
+
+=item details [ OPTION => VALUE ... ]
+
+Returns an array of detail information for the invoice line item.
+
+Currently available options are: I<format>, I<escape_function> and
+I<format_function>.
+
+If I<format> is set to html or latex then the array members are improved
+for tabular appearance in those environments if possible.
+
+If I<escape_function> is set then the array members are processed by this
+function before being returned.
+
+I<format_function> overrides the normal HTML or LaTeX function for returning
+formatted CDRs.  It can be set to a subroutine which returns an empty list
+to skip usage detail:
+
+  'format_function' => sub { () },
+
+=cut
+
+sub details {
+  my ( $self, %opt ) = @_;
+  my $escape_function = $opt{escape_function} || sub { shift };
+
+  my $csv = new Text::CSV_XS;
+
+  if ( $opt{format_function} ) {
+
+    #this still expects to be passed a cust_bill_pkg_detail object as the
+    #second argument, which is expensive
+    carp "deprecated format_function passed to cust_bill_pkg->details";
+    my $format_sub = $opt{format_function} if $opt{format_function};
+
+    map { ( $_->format eq 'C'
+              ? &{$format_sub}( $_->detail, $_ )
+              : &{$escape_function}( $_->detail )
+          )
+        }
+      qsearch ({ 'table'    => $self->detail_table,
+                 'hashref'  => { 'billpkgnum' => $self->billpkgnum },
+                 'order_by' => 'ORDER BY detailnum',
+              });
+
+  } elsif ( $opt{'no_usage'} ) {
+
+    my $sql = "SELECT detail FROM ". $self->detail_table.
+              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "    AND ( format IS NULL OR format != 'C' ) ".
+              "  ORDER BY detailnum";
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+
+    map &{$escape_function}( $_->[0] ), @{ $sth->fetchall_arrayref };
+
+  } else {
+
+    my $format_sub;
+    my $format = $opt{format} || '';
+    if ( $format eq 'html' ) {
+
+      $format_sub = sub { my $detail = shift;
+                          $csv->parse($detail) or return "can't parse $detail";
+                          join('</TD><TD>', map { &$escape_function($_) }
+                                            $csv->fields
+                              );
+                        };
+
+    } elsif ( $format eq 'latex' ) {
+
+      $format_sub = sub {
+        my $detail = shift;
+        $csv->parse($detail) or return "can't parse $detail";
+        #join(' & ', map { '\small{'. &$escape_function($_). '}' }
+        #            $csv->fields );
+        my $result = '';
+        my $column = 1;
+        foreach ($csv->fields) {
+          $result .= ' & ' if $column > 1;
+          if ($column > 6) {                     # KLUDGE ALERT!
+            $result .= '\multicolumn{1}{l}{\scriptsize{'.
+                       &$escape_function($_). '}}';
+          }else{
+            $result .= '\scriptsize{'.  &$escape_function($_). '}';
+          }
+          $column++;
+        }
+        $result;
+      };
+
+    } else {
+
+      $format_sub = sub { my $detail = shift;
+                          $csv->parse($detail) or return "can't parse $detail";
+                          join(' - ', map { &$escape_function($_) }
+                                      $csv->fields
+                              );
+                        };
+
+    }
+
+    my $sql = "SELECT format, detail FROM ". $self->detail_table.
+              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "  ORDER BY detailnum";
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+
+    #avoid the fetchall_arrayref and loop for less memory usage?
+
+    map { (defined($_->[0]) && $_->[0] eq 'C')
+            ? &{$format_sub}(      $_->[1] )
+            : &{$escape_function}( $_->[1] );
+        }
+      @{ $sth->fetchall_arrayref };
+
+  }
+
+}
+
+=item details_header [ OPTION => VALUE ... ]
+
+Returns a list representing an invoice line item detail header, if any.
+This relies on the behavior of voip_cdr in that it expects the header
+to be the first CSV formatted detail (as is expected by invoice generation
+routines).  Returns the empty list otherwise.
+
+=cut
+
+sub details_header {
+  my $self = shift;
+
+  my $csv = new Text::CSV_XS;
+
+  my @detail = 
+    qsearch ({ 'table'    => $self->detail_table,
+               'hashref'  => { 'billpkgnum' => $self->billpkgnum,
+                               'format'     => 'C',
+                             },
+               'order_by' => 'ORDER BY detailnum LIMIT 1',
+            });
+  return() unless scalar(@detail);
+  $csv->parse($detail[0]->detail) or return ();
+  $csv->fields;
+}
+
+=item quantity
+
+=cut
+
+sub quantity {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('quantity', $value);
+  }
+  $self->getfield('quantity') || 1;
+}
+
+=item unitsetup
+
+=cut
+
+sub unitsetup {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('unitsetup', $value);
+  }
+  $self->getfield('unitsetup') eq ''
+    ? $self->getfield('setup')
+    : $self->getfield('unitsetup');
+}
+
+=item unitrecur
+
+=cut
+
+sub unitrecur {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('unitrecur', $value);
+  }
+  $self->getfield('unitrecur') eq ''
+    ? $self->getfield('recur')
+    : $self->getfield('unitrecur');
+}
+
+=item cust_bill_pkg_display [ type => TYPE ]
+
+Returns an array of display information for the invoice line item optionally
+limited to 'TYPE'.
+
+=cut
+
+sub cust_bill_pkg_display {
+  my ( $self, %opt ) = @_;
+
+  my $class = 'FS::'. $self->display_table;
+
+  my $default = $class->new( { billpkgnum =>$self->billpkgnum } );
+
+  my $type = $opt{type} if exists $opt{type};
+  my @result;
+
+  if ( $self->get('display') ) {
+    @result = grep { defined($type) ? ($type eq $_->type) : 1 }
+              @{ $self->get('display') };
+  } else {
+    my $hashref = { 'billpkgnum' => $self->billpkgnum };
+    $hashref->{type} = $type if defined($type);
+    
+    @result = qsearch ({ 'table'    => $self->display_table,
+                         'hashref'  => { 'billpkgnum' => $self->billpkgnum },
+                         'order_by' => 'ORDER BY billpkgdisplaynum',
+                      });
+  }
+
+  push @result, $default unless ( scalar(@result) || $type );
+
+  @result;
+
+}
+
+=item cust_bill_pkg_detail [ CLASSNUM ]
+
+Returns the list of associated cust_bill_pkg_detail objects
+The optional CLASSNUM argument will limit the details to the specified usage
+class.
+
+=cut
+
+sub cust_bill_pkg_detail {
+  my $self = shift;
+  my $classnum = shift || '';
+
+  my %hash = ( 'billpkgnum' => $self->billpkgnum );
+  $hash{classnum} = $classnum if $classnum;
+
+  qsearch( $self->detail_table, \%hash ),
+
+}
+
+=item cust_bill_pkg_discount 
+
+Returns the list of associated cust_bill_pkg_discount objects.
+
+=cut
+
+sub cust_bill_pkg_discount {
+  my $self = shift;
+  qsearch( $self->discount_table, { 'billpkgnum' => $self->billpkgnum } );
+}
+
+1;
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index 2ceef04..304d51d 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -1,14 +1,13 @@
 package FS::cust_bill_pkg;
+use base qw( FS::TemplateItem_Mixin FS::cust_main_Mixin FS::Record );
 
 use strict;
 use vars qw( @ISA $DEBUG $me );
 use Carp;
 use List::Util qw( sum );
 use Text::CSV_XS;
-use FS::Record qw( qsearch qsearchs dbdef dbh );
-use FS::cust_main_Mixin;
+use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_pkg;
-use FS::part_pkg;
 use FS::cust_bill;
 use FS::cust_bill_pkg_detail;
 use FS::cust_bill_pkg_display;
@@ -26,7 +25,6 @@ use FS::cust_bill_pkg_tax_location_void;
 use FS::cust_bill_pkg_tax_rate_location_void;
 use FS::cust_tax_exempt_pkg_void;
 
- at ISA = qw( FS::cust_main_Mixin FS::Record );
 
 $DEBUG = 0;
 $me = '[FS::cust_bill_pkg]';
@@ -125,6 +123,13 @@ customer object (see L<FS::cust_main>).
 
 sub table { 'cust_bill_pkg'; }
 
+sub detail_table            { 'cust_bill_pkg_detail'; }
+sub display_table           { 'cust_bill_pkg_display'; }
+sub discount_table          { 'cust_bill_pkg_discount'; }
+#sub tax_location_table      { 'cust_bill_pkg_tax_location'; }
+#sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
+#sub tax_exempt_pkg_table    { 'cust_tax_exempt_pkg'; }
+
 =item insert
 
 Adds this line item to the database.  If there is an error, returns the error,
@@ -270,6 +275,7 @@ sub void {
   foreach my $table (qw(
     cust_bill_pkg_detail
     cust_bill_pkg_display
+    cust_bill_pkg_discount
     cust_bill_pkg_tax_location
     cust_bill_pkg_tax_rate_location
     cust_tax_exempt_pkg
@@ -326,6 +332,7 @@ sub delete {
   foreach my $table (qw(
     cust_bill_pkg_detail
     cust_bill_pkg_display
+    cust_bill_pkg_discount
     cust_bill_pkg_tax_location
     cust_bill_pkg_tax_rate_location
     cust_tax_exempt_pkg
@@ -462,36 +469,6 @@ sub regularize_details {
   return;
 }
 
-=item cust_pkg
-
-Returns the package (see L<FS::cust_pkg>) for this invoice line item.
-
-=cut
-
-sub cust_pkg {
-  my $self = shift;
-  carp "$me $self -> cust_pkg" if $DEBUG;
-  qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
-}
-
-=item part_pkg
-
-Returns the package definition for this invoice line item.
-
-=cut
-
-sub part_pkg {
-  my $self = shift;
-  if ( $self->pkgpart_override ) {
-    qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
-  } else {
-    my $part_pkg;
-    my $cust_pkg = $self->cust_pkg;
-    $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
-    $part_pkg;
-  }
-}
-
 =item cust_bill
 
 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
@@ -521,173 +498,6 @@ sub previous_cust_bill_pkg {
   });
 }
 
-=item details [ OPTION => VALUE ... ]
-
-Returns an array of detail information for the invoice line item.
-
-Currently available options are: I<format>, I<escape_function> and
-I<format_function>.
-
-If I<format> is set to html or latex then the array members are improved
-for tabular appearance in those environments if possible.
-
-If I<escape_function> is set then the array members are processed by this
-function before being returned.
-
-I<format_function> overrides the normal HTML or LaTeX function for returning
-formatted CDRs.  It can be set to a subroutine which returns an empty list
-to skip usage detail:
-
-  'format_function' => sub { () },
-
-=cut
-
-sub details {
-  my ( $self, %opt ) = @_;
-  my $escape_function = $opt{escape_function} || sub { shift };
-
-  my $csv = new Text::CSV_XS;
-
-  if ( $opt{format_function} ) {
-
-    #this still expects to be passed a cust_bill_pkg_detail object as the
-    #second argument, which is expensive
-    carp "deprecated format_function passed to cust_bill_pkg->details";
-    my $format_sub = $opt{format_function} if $opt{format_function};
-
-    map { ( $_->format eq 'C'
-              ? &{$format_sub}( $_->detail, $_ )
-              : &{$escape_function}( $_->detail )
-          )
-        }
-      qsearch ({ 'table'    => 'cust_bill_pkg_detail',
-                 'hashref'  => { 'billpkgnum' => $self->billpkgnum },
-                 'order_by' => 'ORDER BY detailnum',
-              });
-
-  } elsif ( $opt{'no_usage'} ) {
-
-    my $sql = "SELECT detail FROM cust_bill_pkg_detail ".
-              "  WHERE billpkgnum = ". $self->billpkgnum.
-              "    AND ( format IS NULL OR format != 'C' ) ".
-              "  ORDER BY detailnum";
-    my $sth = dbh->prepare($sql) or die dbh->errstr;
-    $sth->execute or die $sth->errstr;
-
-    map &{$escape_function}( $_->[0] ), @{ $sth->fetchall_arrayref };
-
-  } else {
-
-    my $format_sub;
-    my $format = $opt{format} || '';
-    if ( $format eq 'html' ) {
-
-      $format_sub = sub { my $detail = shift;
-                          $csv->parse($detail) or return "can't parse $detail";
-                          join('</TD><TD>', map { &$escape_function($_) }
-                                            $csv->fields
-                              );
-                        };
-
-    } elsif ( $format eq 'latex' ) {
-
-      $format_sub = sub {
-        my $detail = shift;
-        $csv->parse($detail) or return "can't parse $detail";
-        #join(' & ', map { '\small{'. &$escape_function($_). '}' }
-        #            $csv->fields );
-        my $result = '';
-        my $column = 1;
-        foreach ($csv->fields) {
-          $result .= ' & ' if $column > 1;
-          if ($column > 6) {                     # KLUDGE ALERT!
-            $result .= '\multicolumn{1}{l}{\scriptsize{'.
-                       &$escape_function($_). '}}';
-          }else{
-            $result .= '\scriptsize{'.  &$escape_function($_). '}';
-          }
-          $column++;
-        }
-        $result;
-      };
-
-    } else {
-
-      $format_sub = sub { my $detail = shift;
-                          $csv->parse($detail) or return "can't parse $detail";
-                          join(' - ', map { &$escape_function($_) }
-                                      $csv->fields
-                              );
-                        };
-
-    }
-
-    my $sql = "SELECT format, detail FROM cust_bill_pkg_detail ".
-              "  WHERE billpkgnum = ". $self->billpkgnum.
-              "  ORDER BY detailnum";
-    my $sth = dbh->prepare($sql) or die dbh->errstr;
-    $sth->execute or die $sth->errstr;
-
-    #avoid the fetchall_arrayref and loop for less memory usage?
-
-    map { (defined($_->[0]) && $_->[0] eq 'C')
-            ? &{$format_sub}(      $_->[1] )
-            : &{$escape_function}( $_->[1] );
-        }
-      @{ $sth->fetchall_arrayref };
-
-  }
-
-}
-
-=item details_header [ OPTION => VALUE ... ]
-
-Returns a list representing an invoice line item detail header, if any.
-This relies on the behavior of voip_cdr in that it expects the header
-to be the first CSV formatted detail (as is expected by invoice generation
-routines).  Returns the empty list otherwise.
-
-=cut
-
-sub details_header {
-  my $self = shift;
-  return '' unless defined dbdef->table('cust_bill_pkg_detail');
-
-  my $csv = new Text::CSV_XS;
-
-  my @detail = 
-    qsearch ({ 'table'    => 'cust_bill_pkg_detail',
-               'hashref'  => { 'billpkgnum' => $self->billpkgnum,
-                               'format'     => 'C',
-                             },
-               'order_by' => 'ORDER BY detailnum LIMIT 1',
-            });
-  return() unless scalar(@detail);
-  $csv->parse($detail[0]->detail) or return ();
-  $csv->fields;
-}
-
-=item desc
-
-Returns a description for this line item.  For typical line items, this is the
-I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
-For one-shot line items and named taxes, it is the I<itemdesc> field of this
-line item, and for generic taxes, simply returns "Tax".
-
-=cut
-
-sub desc {
-  my $self = shift;
-
-  if ( $self->pkgnum > 0 ) {
-    $self->itemdesc || $self->part_pkg->pkg;
-  } else {
-    my $desc = $self->itemdesc || 'Tax';
-    $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
-    $desc;
-  }
-}
-
 =item owed_setup
 
 Returns the amount owed (still outstanding) on this line item's setup fee,
@@ -765,45 +575,6 @@ sub units {
   $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
 }
 
-=item quantity
-
-=cut
-
-sub quantity {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('quantity', $value);
-  }
-  $self->getfield('quantity') || 1;
-}
-
-=item unitsetup
-
-=cut
-
-sub unitsetup {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('unitsetup', $value);
-  }
-  $self->getfield('unitsetup') eq ''
-    ? $self->getfield('setup')
-    : $self->getfield('unitsetup');
-}
-
-=item unitrecur
-
-=cut
-
-sub unitrecur {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('unitrecur', $value);
-  }
-  $self->getfield('unitrecur') eq ''
-    ? $self->getfield('recur')
-    : $self->getfield('unitrecur');
-}
 
 =item set_display OPTION => VALUE ...
 
@@ -1015,44 +786,8 @@ sub usage_classes {
 
 }
 
-=item cust_bill_pkg_display [ type => TYPE ]
-
-Returns an array of display information for the invoice line item optionally
-limited to 'TYPE'.
-
-=cut
-
-sub cust_bill_pkg_display {
-  my ( $self, %opt ) = @_;
-
-  my $default =
-    new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
-
-  my $type = $opt{type} if exists $opt{type};
-  my @result;
-
-  if ( $self->get('display') ) {
-    @result = grep { defined($type) ? ($type eq $_->type) : 1 }
-              @{ $self->get('display') };
-  } else {
-    my $hashref = { 'billpkgnum' => $self->billpkgnum };
-    $hashref->{type} = $type if defined($type);
-    
-    @result = qsearch ({ 'table'    => 'cust_bill_pkg_display',
-                         'hashref'  => { 'billpkgnum' => $self->billpkgnum },
-                         'order_by' => 'ORDER BY billpkgdisplaynum',
-                      });
-  }
-
-  push @result, $default unless ( scalar(@result) || $type );
-
-  @result;
-
-}
-
 # reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
 # and FS::cust_main::bill
-
 sub _cust_tax_exempt_pkg {
   my ( $self ) = @_;
 
@@ -1080,36 +815,6 @@ sub cust_bill_pkg_tax_Xlocation {
 
 }
 
-=item cust_bill_pkg_detail [ CLASSNUM ]
-
-Returns the list of associated cust_bill_pkg_detail objects
-The optional CLASSNUM argument will limit the details to the specified usage
-class.
-
-=cut
-
-sub cust_bill_pkg_detail {
-  my $self = shift;
-  my $classnum = shift || '';
-
-  my %hash = ( 'billpkgnum' => $self->billpkgnum );
-  $hash{classnum} = $classnum if $classnum;
-
-  qsearch( 'cust_bill_pkg_detail', \%hash ),
-
-}
-
-=item cust_bill_pkg_discount 
-
-Returns the list of associated cust_bill_pkg_discount objects.
-
-=cut
-
-sub cust_bill_pkg_discount {
-  my $self = shift;
-  qsearch( 'cust_bill_pkg_discount', { 'billpkgnum' => $self->billpkgnum } );
-}
-
 =item recur_show_zero
 
 =cut
diff --git a/FS/FS/cust_bill_pkg_discount_void.pm b/FS/FS/cust_bill_pkg_discount_void.pm
new file mode 100644
index 0000000..859ef3c
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_discount_void.pm
@@ -0,0 +1,129 @@
+package FS::cust_bill_pkg_discount_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::cust_pkg_discount;
+
+=head1 NAME
+
+FS::cust_bill_pkg_discount_void - Object methods for cust_bill_pkg_discount_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_discount_void;
+
+  $record = new FS::cust_bill_pkg_discount_void \%hash;
+  $record = new FS::cust_bill_pkg_discount_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_discount_void object represents the slice of a customer
+discount applied to a specific voided line item.
+FS::cust_bill_pkg_discount_void inherits from FS::Record.  The following fields
+are currently supported:
+
+=over 4
+
+=item billpkgdiscountnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item pkgdiscountnum
+
+pkgdiscountnum
+
+=item amount
+
+amount
+
+=item months
+
+months
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new example.  To add the example to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_discount_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgdiscountnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_foreign_key('pkgdiscountnum', 'cust_pkg_discount', 'pkgdiscountnum' )
+    || $self->ut_money('amount')
+    || $self->ut_float('months')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_void.pm b/FS/FS/cust_bill_pkg_void.pm
index 1982839..7855d58 100644
--- a/FS/FS/cust_bill_pkg_void.pm
+++ b/FS/FS/cust_bill_pkg_void.pm
@@ -1,8 +1,12 @@
 package FS::cust_bill_pkg_void;
+use base qw( FS::TemplateItem_Mixin FS::Record );
 
 use strict;
-use base qw( FS::Record );
-use FS::Record; # qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs );
+use FS::cust_bill_void;
+use FS::cust_bill_pkg_detail_void;
+use FS::cust_bill_pkg_display_void;
+use FS::cust_bill_pkg_discount_void;
 
 =head1 NAME
 
@@ -113,6 +117,13 @@ points to.  You can ask the object for a copy with the I<hash> method.
 
 sub table { 'cust_bill_pkg_void'; }
 
+sub detail_table            { 'cust_bill_pkg_detail_void'; }
+sub display_table           { 'cust_bill_pkg_display_void'; }
+sub discount_table          { 'cust_bill_pkg_discount_void'; }
+#sub tax_location_table      { 'cust_bill_pkg_tax_location'; }
+#sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
+#sub tax_exempt_pkg_table    { 'cust_tax_exempt_pkg'; }
+
 =item insert
 
 Adds this record to the database.  If there is an error, returns the error,
@@ -147,7 +158,7 @@ sub check {
   my $error = 
     $self->ut_number('billpkgnum')
     || $self->ut_snumber('pkgnum')
-    || $self->ut_number('invnum') #cust_bill or cust_bill_void ?
+    || $self->ut_number('invnum') #cust_bill or cust_bill_void, if we ever support line item voiding
     || $self->ut_numbern('pkgpart_override')
     || $self->ut_money('setup')
     || $self->ut_money('recur')
@@ -167,6 +178,19 @@ sub check {
   $self->SUPER::check;
 }
 
+=item cust_bill
+
+Returns the voided invoice (see L<FS::cust_bill_void>) for this voided line
+item.
+
+=cut
+
+sub cust_bill {
+  my $self = shift;
+  #cust_bill or cust_bill_void, if we ever support line item voiding
+  qsearchs( 'cust_bill_void', { 'invnum' => $self->invnum } );
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/cust_bill_void.pm b/FS/FS/cust_bill_void.pm
index c782172..cd6a9e1 100644
--- a/FS/FS/cust_bill_void.pm
+++ b/FS/FS/cust_bill_void.pm
@@ -2,10 +2,11 @@ package FS::cust_bill_void;
 use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
 
 use strict;
-use FS::Record qw( qsearchs ); #qsearch );
+use FS::Record qw( qsearch qsearchs );
 use FS::cust_main;
 use FS::cust_statement;
 use FS::access_user;
+use FS::cust_bill_pkg_void;
 
 =head1 NAME
 
@@ -203,6 +204,33 @@ sub void_access_user {
   qsearchs('access_user', { 'usernum' => $self->void_usernum } );
 }
 
+=item cust_main
+
+=cut
+
+sub cust_main {
+  my $self = shift;
+  qsearchs('cust_main', { 'custnum' => $self->custnum } );
+}
+
+=item cust_bill_pkg
+
+=cut
+
+sub cust_bill_pkg { #actually cust_bill_pkg_void objects
+  my $self = shift;
+  qsearch('cust_bill_pkg_void', { invnum=>$self->invnum });
+}
+
+=back
+
+=item enable_previous
+
+=cut
+
+sub enable_previous { 0 }
+
+
 =back
 
 =head1 BUGS
diff --git a/FS/t/cust_bill_pkg_discount_void.t b/FS/t/cust_bill_pkg_discount_void.t
new file mode 100644
index 0000000..e591eb0
--- /dev/null
+++ b/FS/t/cust_bill_pkg_discount_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_discount_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/httemplate/view/cust_bill_void.html b/httemplate/view/cust_bill_void.html
index c7c5da1..148c0ed 100755
--- a/httemplate/view/cust_bill_void.html
+++ b/httemplate/view/cust_bill_void.html
@@ -11,6 +11,11 @@
 %#  <BR><BR>
 % #} 
 
+<DIV STYLE="color:#FF0000; font-size:1000%; font-weight:bold; z-index:100;
+            position: absolute; top: 300px; left: 130px;
+            zoom: 1; filter: alpha(opacity=25); opacity: 0.25;
+">VOID</DIV>
+
 % if ( $conf->exists('invoice_html') ) { 
   <% join('', $cust_bill_void->print_html(\%opt) ) %>
 % } else { 
@@ -43,13 +48,13 @@ my $cust_bill_void = qsearchs({
   'select'    => 'cust_bill_void.*',
   'table'     => 'cust_bill_void',
   #'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
-  'hashref'   => { 'quotationnum' => $quotationnum },
+  'hashref'   => { 'invnum' => $invnum },
   #'extra_sql' => ' AND '. $curuser->agentnums_sql,
 });
-die "Quotation #$quotationnum not found!" unless $quotation;
+die "Voided invoice #$invnum not found!" unless $cust_bill_void;
 
-my $custnum = $cust_bill->custnum;
-my $display_custnum = $cust_bill->cust_main->display_custnum;
+my $custnum = $cust_bill_void->custnum;
+my $display_custnum = $cust_bill_void->cust_main->display_custnum;
 
 #my $link = "invnum=$invnum";
 
diff --git a/httemplate/view/cust_main/payment_history/voided_invoice.html b/httemplate/view/cust_main/payment_history/voided_invoice.html
index 422edb2..7bf2063 100644
--- a/httemplate/view/cust_main/payment_history/voided_invoice.html
+++ b/httemplate/view/cust_main/payment_history/voided_invoice.html
@@ -21,9 +21,8 @@ my $under = '';
 
 my $invnum = $cust_bill_void->invnum;
 
-#XXX use cust_bill.cgi or?
 my $link = $curuser->access_right('View invoices')
-             ? qq!<A HREF="${p}view/cust_bill.cgi?$invnum">!
+             ? qq!<A HREF="${p}view/cust_bill_void.html?$invnum">!
              : '';
 
 my $unvoid = ''; #XXX unvoid

commit 338eca0837fdaddbda2a34ddf8af3f815de13d26
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Tue Jul 31 23:05:22 2012 -0700

    invoice voiding, RT#18677

diff --git a/FS/t/cust_bill_pkg_detail_void.t b/FS/t/cust_bill_pkg_detail_void.t
new file mode 100644
index 0000000..bd58c4e
--- /dev/null
+++ b/FS/t/cust_bill_pkg_detail_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_detail_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_display_void.t b/FS/t/cust_bill_pkg_display_void.t
new file mode 100644
index 0000000..87403e1
--- /dev/null
+++ b/FS/t/cust_bill_pkg_display_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_display_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_tax_location_void.t b/FS/t/cust_bill_pkg_tax_location_void.t
new file mode 100644
index 0000000..dbfea51
--- /dev/null
+++ b/FS/t/cust_bill_pkg_tax_location_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_tax_location_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_tax_rate_location_void.t b/FS/t/cust_bill_pkg_tax_rate_location_void.t
new file mode 100644
index 0000000..8ebda65
--- /dev/null
+++ b/FS/t/cust_bill_pkg_tax_rate_location_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_tax_rate_location_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_void.t b/FS/t/cust_bill_pkg_void.t
new file mode 100644
index 0000000..9256b46
--- /dev/null
+++ b/FS/t/cust_bill_pkg_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_void.t b/FS/t/cust_bill_void.t
new file mode 100644
index 0000000..95ff4a4
--- /dev/null
+++ b/FS/t/cust_bill_void.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_void;
+$loaded=1;
+print "ok 1\n";

commit 5c48396fab6b19e33dbeac6f807860441465fa3b
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Tue Jul 31 23:05:06 2012 -0700

    invoice voiding, RT#18677

diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 51edd97..c45e783 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -315,6 +315,13 @@ if ( -e $addl_handler_use_file ) {
   use FS::quotation;
   use FS::quotation_pkg;
   use FS::quotation_pkg_discount;
+  use FS::cust_bill_void;
+  use FS::cust_bill_pkg_void;
+  use FS::cust_bill_pkg_detail_void;
+  use FS::cust_bill_pkg_display_void;
+  use FS::cust_bill_pkg_tax_location_void;
+  use FS::cust_bill_pkg_tax_rate_location_void;
+  use FS::cust_tax_exempt_pkg_void;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 590874d..01dab20 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -649,3 +649,17 @@ FS/quotation_pkg_discount.pm
 t/quotation_pkg_discount.t
 FS/Quotable_Mixin.pm
 t/Quotable_Mixin.t
+FS/cust_bill_void.pm
+t/cust_bill_void.t
+FS/cust_bill_pkg_void.pm
+t/cust_bill_pkg_void.t
+FS/cust_bill_pkg_detail_void.pm
+t/cust_bill_pkg_detail_void.t
+FS/cust_bill_pkg_display_void.pm
+t/cust_bill_pkg_display_void.t
+FS/cust_bill_pkg_tax_location_void.pm
+t/cust_bill_pkg_tax_location_void.t
+FS/cust_bill_pkg_tax_rate_location_void.pm
+t/cust_bill_pkg_tax_rate_location_void.t
+FS/cust_tax_exempt_pkg_void.pm
+t/cust_tax_exempt_pkg_void.t

commit f24c4bebce257bfcc61ba07fd3d16c5c0d730071
Author: Ivan Kohler <ivan at freeside.biz>
Date:   Tue Jul 31 23:02:14 2012 -0700

    invoice voiding, RT#18677

diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index 4de2948..ebf66e6 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -177,7 +177,8 @@ tie my %rights, 'Tie::IxHash',
   'Customer invoice / financial info rights' => [
     'View invoices',
     'Resend invoices', #NEWNEW
-    'Delete invoices', #new, but no need to phase in
+    'Void invoices',
+    'Delete invoices',
     'View customer tax exemptions', #yow
     'Add customer tax adjustment', #new, but no need to phase in
     'View customer batched payments', #NEW
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index cfb8060..e59268b 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -551,6 +551,35 @@ sub tables_hashref {
       'index' => [ ['custnum'], ['_date'], ['statementnum'], ['agent_invid'] ],
     },
 
+    'cust_bill_void' => {
+      'columns' => [
+        #regular fields
+        'invnum',       'int',     '', '', '', '', 
+        'custnum',      'int',     '', '', '', '', 
+        '_date',        @date_type,        '', '', 
+        'charged',      @money_type,       '', '', 
+        'invoice_terms', 'varchar', 'NULL', $char_d, '', '',
+
+        #customer balance info at invoice generation time
+        'previous_balance',   @money_typen, '', '',  #eventually not nullable
+        'billing_balance',    @money_typen, '', '',  #eventually not nullable
+
+        #specific use cases
+        'closed',      'char', 'NULL',  1, '', '', #not yet used much
+        'statementnum', 'int', 'NULL', '', '', '', #invoice aggregate statements
+        'agent_invid',  'int', 'NULL', '', '', '', #(varchar?) importing legacy
+        'promised_date', @date_type,       '', '',
+
+        #void fields
+        'void_date', @date_type, '', '', 
+        'reason',    'varchar',   'NULL', $char_d, '', '', 
+        'void_usernum',   'int', 'NULL', '', '', '',
+      ],
+      'primary_key' => 'invnum',
+      'unique' => [ [ 'custnum', 'agent_invid' ] ], #agentnum?  huh
+      'index' => [ ['custnum'], ['_date'], ['statementnum'], ['agent_invid'], [ 'void_usernum' ] ],
+    },
+
     #for importing invoices from a legacy system for display purposes only
     # no effect upon balance
     'legacy_cust_bill' => {
@@ -787,6 +816,101 @@ sub tables_hashref {
       'index'  => [ [ 'billpkgnum' ], [ 'taxnum' ], [ 'taxratelocationnum' ] ],
     },
 
+    'cust_bill_pkg_void' => {
+      'columns' => [
+        'billpkgnum',           'int',     '',      '', '', '', 
+        'invnum',               'int',     '',      '', '', '', 
+        'pkgnum',               'int',     '',      '', '', '', 
+        'pkgpart_override',     'int', 'NULL',      '', '', '', 
+        'setup',               @money_type,             '', '', 
+        'recur',               @money_type,             '', '', 
+        'sdate',               @date_type,              '', '', 
+        'edate',               @date_type,              '', '', 
+        'itemdesc',         'varchar', 'NULL', $char_d, '', '', 
+        'itemcomment',      'varchar', 'NULL', $char_d, '', '', 
+        'section',          'varchar', 'NULL', $char_d, '', '', 
+        'freq',             'varchar', 'NULL', $char_d, '', '',
+        'quantity',             'int', 'NULL',      '', '', '',
+        'unitsetup',           @money_typen,            '', '', 
+        'unitrecur',           @money_typen,            '', '', 
+        'hidden',              'char', 'NULL',       1, '', '',
+        #void fields
+        'void_date', @date_type, '', '', 
+        'reason',    'varchar',   'NULL', $char_d, '', '', 
+        'void_usernum',   'int', 'NULL', '', '', '',
+      ],
+      'primary_key' => 'billpkgnum',
+      'unique' => [],
+      'index' => [ ['invnum'], [ 'pkgnum' ], [ 'itemdesc' ], [ 'void_usernum' ], ],
+    },
+
+    'cust_bill_pkg_detail_void' => {
+      'columns' => [
+        'detailnum',  'int', '', '', '', '', 
+        'billpkgnum', 'int', 'NULL', '', '', '',        # should not be nullable
+        'pkgnum',  'int', 'NULL', '', '', '',           # deprecated
+        'invnum',  'int', 'NULL', '', '', '',           # deprecated
+        'amount',  'decimal', 'NULL', '10,4', '', '',
+        'format',  'char', 'NULL', 1, '', '',
+        'classnum', 'int', 'NULL', '', '', '',
+        'duration', 'int', 'NULL', '',  0, '',
+        'phonenum', 'varchar', 'NULL', 15, '', '',
+        'accountcode', 'varchar',  'NULL',      20, '', '',
+        'startdate',  @date_type, '', '', 
+        'regionname', 'varchar', 'NULL', $char_d, '', '',
+        'detail',  'varchar', '', 255, '', '', 
+      ],
+      'primary_key' => 'detailnum',
+      'unique' => [],
+      'index' => [ [ 'billpkgnum' ], [ 'classnum' ], [ 'pkgnum', 'invnum' ] ],
+    },
+
+    'cust_bill_pkg_display_void' => {
+      'columns' => [
+        'billpkgdisplaynum',    'int', '', '', '', '', 
+        'billpkgnum', 'int', '', '', '', '', 
+        'section',  'varchar', 'NULL', $char_d, '', '', 
+        #'unitsetup', @money_typen, '', '',     #override the linked real one?
+        #'unitrecur', @money_typen, '', '',     #this too?
+        'post_total', 'char', 'NULL', 1, '', '',
+        'type',       'char', 'NULL', 1, '', '',
+        'summary',    'char', 'NULL', 1, '', '',
+      ],
+      'primary_key' => 'billpkgdisplaynum',
+      'unique' => [],
+      'index' => [ ['billpkgnum'], ],
+    },
+
+    'cust_bill_pkg_tax_location_void' => {
+      'columns' => [
+        'billpkgtaxlocationnum',    'int',      '', '', '', '',
+        'billpkgnum',               'int',      '', '', '', '',
+        'taxnum',                   'int',      '', '', '', '',
+        'taxtype',              'varchar',      '', $char_d, '', '',
+        'pkgnum',                   'int',      '', '', '', '',
+        'locationnum',              'int',      '', '', '', '', #redundant?
+        'amount',                   @money_type,        '', '',
+      ],
+      'primary_key' => 'billpkgtaxlocationnum',
+      'unique' => [],
+      'index'  => [ [ 'billpkgnum' ], [ 'taxnum' ], [ 'pkgnum' ], [ 'locationnum' ] ],
+    },
+
+    'cust_bill_pkg_tax_rate_location_void' => {
+      'columns' => [
+        'billpkgtaxratelocationnum',    'int',      '', '', '', '',
+        'billpkgnum',                   'int',      '', '', '', '',
+        'taxnum',                       'int',      '', '', '', '',
+        'taxtype',                  'varchar',      '', $char_d, '', '',
+        'locationtaxid',            'varchar',  'NULL', $char_d, '', '',
+        'taxratelocationnum',           'int',      '', '', '', '',
+        'amount',                       @money_type,        '', '',
+      ],
+      'primary_key' => 'billpkgtaxratelocationnum',
+      'unique' => [],
+      'index'  => [ [ 'billpkgnum' ], [ 'taxnum' ], [ 'taxratelocationnum' ] ],
+    },
+
     'cust_credit' => {
       'columns' => [
         'crednum',  'serial', '', '', '', '', 
@@ -1419,20 +1543,29 @@ sub tables_hashref {
       'columns' => [
         'paynum',    'int',    '',   '', '', '', 
         'custnum',   'int',    '',   '', '', '', 
-        'paid',      @money_type, '', '', 
         '_date',     @date_type, '', '', 
+        'paid',      @money_type, '', '', 
+        'otaker',   'varchar', 'NULL', 32, '', '', 
+        'usernum',   'int', 'NULL', '', '', '',
         'payby',     'char',   '',     4, '', '', # CARD/BILL/COMP, should be
                                                   # index into payby table
                                                   # eventually
         'payinfo',   'varchar',   'NULL', 512, '', '', #see cust_main above
 	'paymask', 'varchar', 'NULL', $char_d, '', '', 
+        #'paydate' ?
         'paybatch',  'varchar',   'NULL', $char_d, '', '', #for auditing purposes.
         'closed',    'char', 'NULL', 1, '', '', 
         'pkgnum', 'int', 'NULL', '', '', '', #desired pkgnum for pkg-balances
+        # cash/check deposit info fields
+        'bank',       'varchar', 'NULL', $char_d, '', '',
+        'depositor',  'varchar', 'NULL', $char_d, '', '',
+        'account',    'varchar', 'NULL', 20,      '', '',
+        'teller',     'varchar', 'NULL', 20,      '', '',
+        'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key
+
+        #void fields
         'void_date', @date_type, '', '', 
         'reason',    'varchar',   'NULL', $char_d, '', '', 
-        'otaker',   'varchar', 'NULL', 32, '', '', 
-        'usernum',   'int', 'NULL', '', '', '',
         'void_usernum',   'int', 'NULL', '', '', '',
       ],
       'primary_key' => 'paynum',
@@ -2568,6 +2701,26 @@ sub tables_hashref {
                   ],
     },
 
+    'cust_tax_exempt_pkg_void' => {
+      'columns' => [
+        'exemptpkgnum',  'int', '', '', '', '', 
+        #'custnum',      'int', '', '', '', ''
+        'billpkgnum',   'int', '', '', '', '', 
+        'taxnum',       'int', '', '', '', '', 
+        'year',         'int', '', '', '', '', 
+        'month',        'int', '', '', '', '', 
+        'creditbillpkgnum', 'int', 'NULL', '', '', '',
+        'amount',       @money_type, '', '', 
+      ],
+      'primary_key' => 'exemptpkgnum',
+      'unique' => [],
+      'index'  => [ [ 'taxnum', 'year', 'month' ],
+                    [ 'billpkgnum' ],
+                    [ 'taxnum' ],
+                    [ 'creditbillpkgnum' ],
+                  ],
+    },
+
     'router' => {
       'columns' => [
         'routernum', 'serial', '', '', '', '', 
diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm
index e6266b4..bc57364 100644
--- a/FS/FS/access_right.pm
+++ b/FS/FS/access_right.pm
@@ -193,6 +193,7 @@ sub _upgrade_data { # class method
     'Suspend customer package'            => 'Suspend customer',
     'Unsuspend customer package'          => 'Unsuspend customer',
     'New prospect'                        => 'Generate quotation',
+    'Delete invoices'                     => 'Void invoices',
 
     'List services'    => [ 'Services: Accounts',
                             'Services: Domains',
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index c3d48a6..c5b707b 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -38,6 +38,7 @@ use FS::cust_bill_batch;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
 use FS::discount_plan;
+use FS::cust_bill_void;
 use FS::L10N;
 
 $DEBUG = 0;
@@ -203,10 +204,63 @@ sub insert {
 
 }
 
+=item void
+
+Voids this invoice: deletes the invoice and adds a record of the voided invoice
+to the FS::cust_bill_void table (and related tables starting from
+FS::cust_bill_pkg_void).
+
+=cut
+
+sub void {
+  my $self = shift;
+  my $reason = scalar(@_) ? shift : '';
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill_void = new FS::cust_bill_void ( {
+    map { $_ => $self->get($_) } $self->fields
+  } );
+  $cust_bill_void->reason($reason);
+  my $error = $cust_bill_void->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+    my $error = $cust_bill_pkg->void($reason);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item delete
 
 This method now works but you probably shouldn't use it.  Instead, apply a
-credit against the invoice.
+credit against the invoice, or use the new void method.
 
 Using this method to delete invoices outright is really, really bad.  There
 would be no record you ever posted this invoice, and there are no check to
@@ -236,11 +290,10 @@ sub delete {
     cust_event
     cust_credit_bill
     cust_bill_pay
-    cust_credit_bill
     cust_pay_batch
     cust_bill_pay_batch
-    cust_bill_pkg
     cust_bill_batch
+    cust_bill_pkg
   )) {
 
     foreach my $linked ( $self->$table() ) {
diff --git a/FS/FS/cust_bill_pkg.pm b/FS/FS/cust_bill_pkg.pm
index 4220d3c..2ceef04 100644
--- a/FS/FS/cust_bill_pkg.pm
+++ b/FS/FS/cust_bill_pkg.pm
@@ -3,6 +3,7 @@ package FS::cust_bill_pkg;
 use strict;
 use vars qw( @ISA $DEBUG $me );
 use Carp;
+use List::Util qw( sum );
 use Text::CSV_XS;
 use FS::Record qw( qsearch qsearchs dbdef dbh );
 use FS::cust_main_Mixin;
@@ -18,8 +19,12 @@ use FS::cust_tax_exempt_pkg;
 use FS::cust_bill_pkg_tax_location;
 use FS::cust_bill_pkg_tax_rate_location;
 use FS::cust_tax_adjustment;
-
-use List::Util qw(sum);
+use FS::cust_bill_pkg_void;
+use FS::cust_bill_pkg_detail_void;
+use FS::cust_bill_pkg_display_void;
+use FS::cust_bill_pkg_tax_location_void;
+use FS::cust_bill_pkg_tax_rate_location_void;
+use FS::cust_tax_exempt_pkg_void;
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
@@ -230,6 +235,74 @@ sub insert {
 
 }
 
+=item void
+
+Voids this line item: deletes the line item and adds a record of the voided
+line item to the FS::cust_bill_pkg_void table (and related tables).
+
+=cut
+
+sub void {
+  my $self = shift;
+  my $reason = scalar(@_) ? shift : '';
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
+    map { $_ => $self->get($_) } $self->fields
+  } );
+  $cust_bill_pkg_void->reason($reason);
+  my $error = $cust_bill_pkg_void->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $table (qw(
+    cust_bill_pkg_detail
+    cust_bill_pkg_display
+    cust_bill_pkg_tax_location
+    cust_bill_pkg_tax_rate_location
+    cust_tax_exempt_pkg
+  )) {
+
+    foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
+
+      my $vclass = 'FS::'.$table.'_void';
+      my $void = $vclass->new( {
+        map { $_ => $linked->get($_) } $linked->fields
+      });
+      my $error = $void->insert || $linked->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+
+    }
+
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item delete
 
 Not recommended.
diff --git a/FS/FS/cust_bill_pkg_detail_void.pm b/FS/FS/cust_bill_pkg_detail_void.pm
new file mode 100644
index 0000000..cebe7c1
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_detail_void.pm
@@ -0,0 +1,168 @@
+package FS::cust_bill_pkg_detail_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::usage_class;
+
+=head1 NAME
+
+FS::cust_bill_pkg_detail_void - Object methods for cust_bill_pkg_detail_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_detail_void;
+
+  $record = new FS::cust_bill_pkg_detail_void \%hash;
+  $record = new FS::cust_bill_pkg_detail_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_detail_void object represents additional detail
+information for a voided invoice line item.  FS::cust_bill_pkg_detail_void
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item detailnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item pkgnum
+
+pkgnum
+
+=item invnum
+
+invnum
+
+=item amount
+
+amount
+
+=item format
+
+format
+
+=item classnum
+
+classnum
+
+=item duration
+
+duration
+
+=item phonenum
+
+phonenum
+
+=item accountcode
+
+accountcode
+
+=item startdate
+
+startdate
+
+=item regionname
+
+regionname
+
+=item detail
+
+detail
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_detail_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('detailnum')
+    || $self->ut_foreign_keyn('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum')
+    || $self->ut_numbern('pkgnum')
+    || $self->ut_numbern('invnum')
+    || $self->ut_floatn('amount')
+    || $self->ut_enum('format', [ '', 'C' ] )
+    || $self->ut_foreign_keyn('classnum', 'usage_class', 'classnum')
+    || $self->ut_numbern('duration')
+    || $self->ut_textn('phonenum')
+    || $self->ut_textn('accountcode')
+    || $self->ut_numbern('startdate')
+    || $self->ut_textn('regionname')
+    || $self->ut_text('detail')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_display_void.pm b/FS/FS/cust_bill_pkg_display_void.pm
new file mode 100644
index 0000000..e78801a
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_display_void.pm
@@ -0,0 +1,132 @@
+package FS::cust_bill_pkg_display_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+
+=head1 NAME
+
+FS::cust_bill_pkg_display_void - Object methods for cust_bill_pkg_display_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_display_void;
+
+  $record = new FS::cust_bill_pkg_display_void \%hash;
+  $record = new FS::cust_bill_pkg_display_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_display_void object represents voided line item display
+information.  FS::cust_bill_pkg_display_void inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item billpkgdisplaynum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item section
+
+section
+
+=item post_total
+
+post_total
+
+=item type
+
+type
+
+=item summary
+
+summary
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_display_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgdisplaynum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum')
+    || $self->ut_textn('section')
+    || $self->ut_enum('post_total', [ '', 'Y' ])
+    || $self->ut_enum('type', [ '', 'S', 'R', 'U' ])
+    || $self->ut_enum('summary', [ '', 'Y' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_tax_location_void.pm b/FS/FS/cust_bill_pkg_tax_location_void.pm
new file mode 100644
index 0000000..9e0794b
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_tax_location_void.pm
@@ -0,0 +1,139 @@
+package FS::cust_bill_pkg_tax_location_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::cust_pkg;
+use FS::cust_location;
+
+=head1 NAME
+
+FS::cust_bill_pkg_tax_location_void - Object methods for cust_bill_pkg_tax_location_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_tax_location_void;
+
+  $record = new FS::cust_bill_pkg_tax_location_void \%hash;
+  $record = new FS::cust_bill_pkg_tax_location_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_tax_location_void object represents a voided record
+of taxation based on package location.  FS::cust_bill_pkg_tax_location_void
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item billpkgtaxlocationnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item taxnum
+
+taxnum
+
+=item taxtype
+
+taxtype
+
+=item pkgnum
+
+pkgnum
+
+=item locationnum
+
+locationnum
+
+=item amount
+
+amount
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_tax_location_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgtaxlocationnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_number('taxnum') #cust_bill_pkg/tax_rate key, based on taxtype
+    || $self->ut_enum('taxtype', [ qw( FS::cust_main_county FS::tax_rate ) ] )
+    || $self->ut_foreign_key('pkgnum', 'cust_pkg', 'pkgnum' )
+    || $self->ut_foreign_key('locationnum', 'cust_location', 'locationnum' )
+    || $self->ut_money('amount')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_tax_rate_location_void.pm b/FS/FS/cust_bill_pkg_tax_rate_location_void.pm
new file mode 100644
index 0000000..f2e85c0
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_tax_rate_location_void.pm
@@ -0,0 +1,139 @@
+package FS::cust_bill_pkg_tax_rate_location_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::tax_rate_location;
+
+=head1 NAME
+
+FS::cust_bill_pkg_tax_rate_location_void - Object methods for cust_bill_pkg_tax_rate_location_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_tax_rate_location_void;
+
+  $record = new FS::cust_bill_pkg_tax_rate_location_void \%hash;
+  $record = new FS::cust_bill_pkg_tax_rate_location_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_tax_rate_location_void object represents a voided record
+of taxation based on package location.
+FS::cust_bill_pkg_tax_rate_location_void inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item billpkgtaxratelocationnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item taxnum
+
+taxnum
+
+=item taxtype
+
+taxtype
+
+=item locationtaxid
+
+locationtaxid
+
+=item taxratelocationnum
+
+taxratelocationnum
+
+=item amount
+
+amount
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_tax_rate_location_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgtaxratelocationnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_number('taxnum') #cust_bill_pkg/tax_rate key, based on taxtype
+    || $self->ut_text('taxtype', [ qw( FS::cust_main_county FS::tax_rate ) ] )
+    || $self->ut_textn('locationtaxid')
+    || $self->ut_foreign_key('taxratelocationnum', 'tax_rate_location', 'taxratelocationnum' )
+    || $self->ut_money('amount')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_bill_pkg_tax_rate_location>, L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_void.pm b/FS/FS/cust_bill_pkg_void.pm
new file mode 100644
index 0000000..1982839
--- /dev/null
+++ b/FS/FS/cust_bill_pkg_void.pm
@@ -0,0 +1,181 @@
+package FS::cust_bill_pkg_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cust_bill_pkg_void - Object methods for cust_bill_pkg_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_void;
+
+  $record = new FS::cust_bill_pkg_void \%hash;
+  $record = new FS::cust_bill_pkg_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_void object represents a voided invoice line item.
+FS::cust_bill_pkg_void inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item billpkgnum
+
+primary key
+
+=item invnum
+
+invnum
+
+=item pkgnum
+
+pkgnum
+
+=item pkgpart_override
+
+pkgpart_override
+
+=item setup
+
+setup
+
+=item recur
+
+recur
+
+=item sdate
+
+sdate
+
+=item edate
+
+edate
+
+=item itemdesc
+
+itemdesc
+
+=item itemcomment
+
+itemcomment
+
+=item section
+
+section
+
+=item freq
+
+freq
+
+=item quantity
+
+quantity
+
+=item unitsetup
+
+unitsetup
+
+=item unitrecur
+
+unitrecur
+
+=item hidden
+
+hidden
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgnum')
+    || $self->ut_snumber('pkgnum')
+    || $self->ut_number('invnum') #cust_bill or cust_bill_void ?
+    || $self->ut_numbern('pkgpart_override')
+    || $self->ut_money('setup')
+    || $self->ut_money('recur')
+    || $self->ut_numbern('sdate')
+    || $self->ut_numbern('edate')
+    || $self->ut_textn('itemdesc')
+    || $self->ut_textn('itemcomment')
+    || $self->ut_textn('section')
+    || $self->ut_textn('freq')
+    || $self->ut_numbern('quantity')
+    || $self->ut_moneyn('unitsetup')
+    || $self->ut_moneyn('unitrecur')
+    || $self->ut_enum('hidden', [ '', 'Y' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_void.pm b/FS/FS/cust_bill_void.pm
new file mode 100644
index 0000000..c782172
--- /dev/null
+++ b/FS/FS/cust_bill_void.pm
@@ -0,0 +1,217 @@
+package FS::cust_bill_void;
+use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
+
+use strict;
+use FS::Record qw( qsearchs ); #qsearch );
+use FS::cust_main;
+use FS::cust_statement;
+use FS::access_user;
+
+=head1 NAME
+
+FS::cust_bill_void - Object methods for cust_bill_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_void;
+
+  $record = new FS::cust_bill_void \%hash;
+  $record = new FS::cust_bill_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_void object represents a voided invoice.  FS::cust_bill_void
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item invnum
+
+primary key
+
+=item custnum
+
+custnum
+
+=item _date
+
+_date
+
+=item charged
+
+charged
+
+=item invoice_terms
+
+invoice_terms
+
+=item previous_balance
+
+previous_balance
+
+=item billing_balance
+
+billing_balance
+
+=item closed
+
+closed
+
+=item statementnum
+
+statementnum
+
+=item agent_invid
+
+agent_invid
+
+=item promised_date
+
+promised_date
+
+=item void_date
+
+void_date
+
+=item reason
+
+reason
+
+=item void_usernum
+
+void_usernum
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new voided invoice.  To add the voided invoice to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_void'; }
+sub notice_name { 'VOIDED Invoice'; }
+#XXXsub template_conf { 'quotation_'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid voided invoice.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('invnum')
+    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
+    || $self->ut_numbern('_date')
+    || $self->ut_money('charged')
+    || $self->ut_textn('invoice_terms')
+    || $self->ut_moneyn('previous_balance')
+    || $self->ut_moneyn('billing_balance')
+    || $self->ut_enum('closed', [ '', 'Y' ])
+    || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum')
+    || $self->ut_numbern('agent_invid')
+    || $self->ut_numbern('promised_date')
+    || $self->ut_numbern('void_date')
+    || $self->ut_textn('reason')
+    || $self->ut_numbern('void_usernum')
+  ;
+  return $error if $error;
+
+  $self->void_date(time) unless $self->void_date;
+
+  $self->void_usernum($FS::CurrentUser::CurrentUser->usernum)
+    unless $self->void_usernum;
+
+  $self->SUPER::check;
+}
+
+=item display_invnum
+
+Returns the displayed invoice number for this invoice: agent_invid if
+cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
+
+=cut
+
+sub display_invnum {
+  my $self = shift;
+  my $conf = $self->conf;
+  if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
+    return $self->agent_invid;
+  } else {
+    return $self->invnum;
+  }
+}
+
+=item void_access_user
+
+Returns the voiding employee object (see L<FS::access_user>).
+
+=cut
+
+sub void_access_user {
+  my $self = shift;
+  qsearchs('access_user', { 'usernum' => $self->void_usernum } );
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 9602941..36c6280 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -42,6 +42,7 @@ use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
 use FS::cust_bill;
+use FS::cust_bill_void;
 use FS::legacy_cust_bill;
 use FS::cust_pay;
 use FS::cust_pay_pending;
@@ -1279,6 +1280,7 @@ sub merge {
 
   tie my %financial_tables, 'Tie::IxHash',
     'cust_bill'      => 'invoices',
+    'cust_bill_void' => 'voided invoices',
     'cust_statement' => 'statements',
     'cust_credit'    => 'credits',
     'cust_pay'       => 'payments',
@@ -3646,6 +3648,20 @@ be passed.
 
 =cut
 
+=item cust_bill_void
+
+Returns all the voided invoices (see L<FS::cust_bill_void>) for this customer.
+
+=cut
+
+sub cust_bill_void {
+  my $self = shift;
+
+  map { $_ } #return $self->num_cust_bill_void unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_bill_void', { 'custnum' => $self->custnum } )
+}
+
 sub cust_statement {
   my $self = shift;
   my $opt = ref($_[0]) ? shift : { @_ };
@@ -3802,7 +3818,7 @@ sub cust_pay_void {
 
 =item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
-Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
+Returns all batched payments (see L<FS::cust_pay_batch>) for this customer.
 
 Optionally, a list or hashref of additional arguments to the qsearch call can
 be passed.
diff --git a/FS/FS/cust_tax_exempt_pkg_void.pm b/FS/FS/cust_tax_exempt_pkg_void.pm
new file mode 100644
index 0000000..51c85b4
--- /dev/null
+++ b/FS/FS/cust_tax_exempt_pkg_void.pm
@@ -0,0 +1,138 @@
+package FS::cust_tax_exempt_pkg_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::cust_main_county;
+
+=head1 NAME
+
+FS::cust_tax_exempt_pkg_void - Object methods for cust_tax_exempt_pkg_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_tax_exempt_pkg_void;
+
+  $record = new FS::cust_tax_exempt_pkg_void \%hash;
+  $record = new FS::cust_tax_exempt_pkg_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_tax_exempt_pkg_void object represents a voided record of a customer
+tax exemption.  FS::cust_tax_exempt_pkg_void inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item exemptpkgnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item taxnum
+
+taxnum
+
+=item year
+
+year
+
+=item month
+
+month
+
+=item creditbillpkgnum
+
+creditbillpkgnum
+
+=item amount
+
+amount
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_tax_exempt_pkg_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('exemptpkgnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum')
+    || $self->ut_number('year')
+    || $self->ut_number('month')
+    || $self->ut_numbern('creditbillpkgnum') #no FK check, will have been del'ed
+    || $self->ut_money('amount')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/httemplate/misc/process/void-cust_bill.html b/httemplate/misc/process/void-cust_bill.html
new file mode 100755
index 0000000..f2930ec
--- /dev/null
+++ b/httemplate/misc/process/void-cust_bill.html
@@ -0,0 +1,22 @@
+%if ( $error ) {
+%  $cgi->param('error', $error);
+<% $cgi->redirect(popurl(1). "void-cust_bill.html?". $cgi->query_string ) %>
+%} else {
+<% $cgi->redirect(popurl(3). "view/cust_main.cgi?". $custnum) %>
+%}
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Void invoices');
+
+#untaint invnum
+$cgi->param('invnum') =~ /^(\d+)$/ || die "Illegal invnum";
+my $invnum = $1;
+
+my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
+
+my $custnum = $cust_bill->custnum;
+
+my $error = $cust_bill->void( $cgi->param('reason') );
+
+</%init>
diff --git a/httemplate/misc/void-cust_bill.html b/httemplate/misc/void-cust_bill.html
new file mode 100644
index 0000000..1608fd0
--- /dev/null
+++ b/httemplate/misc/void-cust_bill.html
@@ -0,0 +1,45 @@
+<& /elements/header-popup.html, mt('Void invoice') &>
+
+<% include('/elements/error.html') %>
+
+<% emt('Are you sure you want to void this invoice?') %>
+<BR><BR>
+
+<% emt("Invoice #[_1] ([_2])",$cust_bill->display_invnum, $money_char. $cust_bill->owed) %>
+<BR><BR>
+
+<FORM METHOD="POST" ACTION="process/void-cust_bill.html">
+<INPUT TYPE="hidden" NAME="invnum" VALUE="<% $invnum %>">
+
+<% ntable("#cccccc", 2) %>
+<TR>
+  <TD ALIGN="right">Reason</TD>
+  <TD><INPUT TYPE="text" NAME="reason" VALUE="<% $cgi->param('reason') |h %>"></TD>
+</TR>
+
+</TABLE>
+
+<BR>
+<CENTER>
+<BUTTON TYPE="submit">Yes, void invoice</BUTTON>   \
+<BUTTON TYPE="button" onClick="parent.cClick();">No, do not void invoice</BUTTON>
+</CENTER>
+
+</FORM>
+</BODY>
+</HTML>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Void invoices');
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+#untaint invnum
+$cgi->param('invnum') =~ /^(\d+)$/ || die "Illegal invnum";
+my $invnum = $1;
+
+my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
+
+</%init>
diff --git a/httemplate/view/cust_bill_void.html b/httemplate/view/cust_bill_void.html
new file mode 100755
index 0000000..c7c5da1
--- /dev/null
+++ b/httemplate/view/cust_bill_void.html
@@ -0,0 +1,56 @@
+<& /elements/header.html, mt('Voided Invoice'),  menubar(
+  emt("View this customer (#[_1])",$display_custnum) => "${p}view/cust_main.cgi?$custnum",
+) &>
+
+%#XXX something very big and obvious showing its voided...
+
+% #voided PDFs?
+% #if ( $conf->exists('invoice_latex') ) {
+%#
+%#  <A HREF="<% $p %>view/cust_bill-pdf.cgi?<% $link %>"><% mt('View typeset invoice PDF') |h %></A>
+%#  <BR><BR>
+% #} 
+
+% if ( $conf->exists('invoice_html') ) { 
+  <% join('', $cust_bill_void->print_html(\%opt) ) %>
+% } else { 
+  <PRE><% join('', $cust_bill_void->print_text(\%opt) ) %></PRE>
+% } 
+
+<& /elements/footer.html &>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('View invoices');
+
+my $invnum;
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ ) {
+  $invnum = $1;
+} else {
+  $invnum = $cgi->param('invnum');
+}
+
+my $conf = new FS::Conf;
+
+my %opt = (
+  'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
+);
+
+my $cust_bill_void = qsearchs({
+  'select'    => 'cust_bill_void.*',
+  'table'     => 'cust_bill_void',
+  #'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => { 'quotationnum' => $quotationnum },
+  #'extra_sql' => ' AND '. $curuser->agentnums_sql,
+});
+die "Quotation #$quotationnum not found!" unless $quotation;
+
+my $custnum = $cust_bill->custnum;
+my $display_custnum = $cust_bill->cust_main->display_custnum;
+
+#my $link = "invnum=$invnum";
+
+</%init>
diff --git a/httemplate/view/cust_main/payment_history.html b/httemplate/view/cust_main/payment_history.html
index 9e08c0c..166addb 100644
--- a/httemplate/view/cust_main/payment_history.html
+++ b/httemplate/view/cust_main/payment_history.html
@@ -277,7 +277,9 @@
 %                  ? sprintf("$money_char\%.2f", $item->{'charge'})
 %                  : exists($item->{'charge_nobal'})
 %                    ? sprintf("$money_char\%.2f", $item->{'charge_nobal'})
-%                    : '';
+%                    : exists($item->{'void_charge'})
+%                      ? sprintf("<DEL>$money_char\%.2f</DEL>", $item->{'void_charge'})
+%                      : '';
 %
 %  my $payment = exists($item->{'payment'})
 %                  ? sprintf("- $money_char\%.2f", $item->{'payment'})
@@ -428,6 +430,15 @@ foreach my $cust_bill ($cust_main->cust_bill) {
   $num_cust_bill++;
 }
 
+#voided invoices
+foreach my $cust_bill_void ($cust_main->cust_bill_void) {
+  push @history, {
+    'date'        => $cust_bill_void->_date,
+    'desc'        => include('payment_history/voided_invoice.html', $cust_bill_void, %opt ),
+    'void_charge' => $cust_bill_void->charged,
+  };
+}
+
 #statements
 foreach my $cust_statement ($cust_main->cust_statement) {
   push @history, {
diff --git a/httemplate/view/cust_main/payment_history/invoice.html b/httemplate/view/cust_main/payment_history/invoice.html
index 3028f0f..96a9f54 100644
--- a/httemplate/view/cust_main/payment_history/invoice.html
+++ b/httemplate/view/cust_main/payment_history/invoice.html
@@ -1,4 +1,4 @@
-<% $link %><% $invoice %><% $link ? '</A>' : '' %><% $delete %><% $under %>
+<% $link %><% $invoice %><% $link ? '</A>' : '' %><% "$void$delete$under" %>
 <%init>
 
 my( $cust_bill, %opt ) = @_;
@@ -26,6 +26,18 @@ my $link = $curuser->access_right('View invoices')
              ? qq!<A HREF="${p}view/cust_bill.cgi?$invnum">!
              : '';
 
+my $void = '';
+if ( $cust_bill->closed !~ /^Y/i && $curuser->access_right('Void invoices') ) {
+  $void =
+    ' ('. include('/elements/popup_link.html',
+                    'label'     => emt('void'),
+                    'action'    => "${p}misc/void-cust_bill.html?;invnum=".
+                                    $cust_bill->invnum,
+                    'actionlabel' => emt('Void Invoice'),
+                 ).
+     ')';
+}
+
 my $delete = '';
 $delete = areyousure_link("${p}misc/delete-cust_bill.html?$invnum",
                             emt('Are you sure you want to delete this invoice?'),
diff --git a/httemplate/view/cust_main/payment_history/invoice.html b/httemplate/view/cust_main/payment_history/voided_invoice.html
similarity index 60%
copy from httemplate/view/cust_main/payment_history/invoice.html
copy to httemplate/view/cust_main/payment_history/voided_invoice.html
index 3028f0f..422edb2 100644
--- a/httemplate/view/cust_main/payment_history/invoice.html
+++ b/httemplate/view/cust_main/payment_history/voided_invoice.html
@@ -1,31 +1,33 @@
-<% $link %><% $invoice %><% $link ? '</A>' : '' %><% $delete %><% $under %>
+<DEL><% $link %><% $invoice %><% $link ? '</A>' : '' %></DEL>
+<I><% mt("voided [_1]", time2str($date_format, $cust_bill_void->void_date) ) |h %> 
+% my $void_user = $cust_bill_void->void_access_user;
+% if ($void_user) {
+    by <% $void_user->username %></I>
+% }
+<% "$unvoid$delete$under" %>
 <%init>
 
-my( $cust_bill, %opt ) = @_;
+my( $cust_bill_void, %opt ) = @_;
+
+my $date_format = $opt{'date_format'} || '%m/%d/%Y';
 
 my $conf = new FS::Conf;
 
 my $curuser = $FS::CurrentUser::CurrentUser;
 
-my $invoice = emt("Invoice #[_1] (Balance [_2])",$cust_bill->display_invnum,$cust_bill->owed);
+my $invoice = emt("Invoice #[_1] (Balance [_2])",$cust_bill_void->display_invnum, $cust_bill_void->charged);
 
 my $under = '';
-if ( $cust_bill->owed > 0 ) {
-  $invoice = '<B><FONT SIZE="+1" COLOR="#FF0000">' .
-    emt("Open Invoice #[_1] (Balance [_2])",$cust_bill->display_invnum,$cust_bill->owed) .
-    '</FONT></B>';
-  if ( $cust_bill->promised_date ) {
-    $under .= '<BR>'. emt('Payment promised on [_1]',
-        time2str($opt{'date_format'}, $cust_bill->promised_date));
-  }
-} #if $cust_bill->owed
-
-my $invnum = $cust_bill->invnum;
 
+my $invnum = $cust_bill_void->invnum;
+
+#XXX use cust_bill.cgi or?
 my $link = $curuser->access_right('View invoices')
              ? qq!<A HREF="${p}view/cust_bill.cgi?$invnum">!
              : '';
 
+my $unvoid = ''; #XXX unvoid
+
 my $delete = '';
 $delete = areyousure_link("${p}misc/delete-cust_bill.html?$invnum",
                             emt('Are you sure you want to delete this invoice?'),
@@ -36,7 +38,7 @@ $delete = areyousure_link("${p}misc/delete-cust_bill.html?$invnum",
 
 my $events = '';
 #1.9
-if ( $cust_bill->num_cust_event
+if ( $cust_bill_void->num_cust_event
      && (    $curuser->access_right('Billing event reports')
           || $curuser->access_right('View customer billing events')
         )

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

Summary of changes:
 FS/FS/AccessRight.pm                               |   10 +-
 FS/FS/Mason.pm                                     |    8 +
 FS/FS/Schema.pm                                    |  172 +++++++++-
 FS/FS/TemplateItem_Mixin.pm                        |  317 ++++++++++++++++
 FS/FS/access_right.pm                              |   10 +-
 FS/FS/cust_bill.pm                                 |   59 +++-
 FS/FS/cust_bill_pkg.pm                             |  394 +++++---------------
 FS/FS/cust_bill_pkg_detail_void.pm                 |  168 +++++++++
 FS/FS/cust_bill_pkg_discount.pm                    |    4 +-
 FS/FS/cust_bill_pkg_discount_void.pm               |  129 +++++++
 FS/FS/cust_bill_pkg_display_void.pm                |  132 +++++++
 FS/FS/cust_bill_pkg_tax_location_void.pm           |  139 +++++++
 FS/FS/cust_bill_pkg_tax_rate_location_void.pm      |  139 +++++++
 FS/FS/cust_bill_pkg_void.pm                        |  269 +++++++++++++
 FS/FS/cust_bill_void.pm                            |  286 ++++++++++++++
 FS/FS/cust_main.pm                                 |   18 +-
 FS/FS/cust_tax_exempt_pkg_void.pm                  |  138 +++++++
 FS/FS/quotation.pm                                 |    3 +-
 FS/MANIFEST                                        |   16 +
 FS/t/cust_bill_pkg_detail_void.t                   |    5 +
 FS/t/cust_bill_pkg_discount_void.t                 |    5 +
 FS/t/cust_bill_pkg_display_void.t                  |    5 +
 FS/t/cust_bill_pkg_tax_location_void.t             |    5 +
 FS/t/cust_bill_pkg_tax_rate_location_void.t        |    5 +
 FS/t/cust_bill_pkg_void.t                          |    5 +
 FS/t/cust_bill_void.t                              |    5 +
 httemplate/misc/process/void-cust_bill.html        |   26 ++
 httemplate/misc/unvoid-cust_bill_void.html         |   25 ++
 httemplate/misc/unvoid-cust_pay_void.cgi           |    2 +-
 httemplate/misc/void-cust_bill.html                |   45 +++
 httemplate/misc/void-cust_pay.cgi                  |    2 +-
 httemplate/view/cust_bill.cgi                      |    2 -
 httemplate/view/cust_bill_void.html                |   79 ++++
 httemplate/view/cust_main/payment_history.html     |   13 +-
 .../view/cust_main/payment_history/invoice.html    |   14 +-
 .../view/cust_main/payment_history/payment.html    |    2 +-
 .../cust_main/payment_history/voided_invoice.html  |   57 +++
 .../cust_main/payment_history/voided_payment.html  |    2 +-
 httemplate/view/quotation.html                     |    2 -
 39 files changed, 2381 insertions(+), 336 deletions(-)
 create mode 100644 FS/FS/TemplateItem_Mixin.pm
 create mode 100644 FS/FS/cust_bill_pkg_detail_void.pm
 create mode 100644 FS/FS/cust_bill_pkg_discount_void.pm
 create mode 100644 FS/FS/cust_bill_pkg_display_void.pm
 create mode 100644 FS/FS/cust_bill_pkg_tax_location_void.pm
 create mode 100644 FS/FS/cust_bill_pkg_tax_rate_location_void.pm
 create mode 100644 FS/FS/cust_bill_pkg_void.pm
 create mode 100644 FS/FS/cust_bill_void.pm
 create mode 100644 FS/FS/cust_tax_exempt_pkg_void.pm
 create mode 100644 FS/t/cust_bill_pkg_detail_void.t
 create mode 100644 FS/t/cust_bill_pkg_discount_void.t
 create mode 100644 FS/t/cust_bill_pkg_display_void.t
 create mode 100644 FS/t/cust_bill_pkg_tax_location_void.t
 create mode 100644 FS/t/cust_bill_pkg_tax_rate_location_void.t
 create mode 100644 FS/t/cust_bill_pkg_void.t
 create mode 100644 FS/t/cust_bill_void.t
 create mode 100755 httemplate/misc/process/void-cust_bill.html
 create mode 100755 httemplate/misc/unvoid-cust_bill_void.html
 create mode 100644 httemplate/misc/void-cust_bill.html
 create mode 100755 httemplate/view/cust_bill_void.html
 create mode 100644 httemplate/view/cust_main/payment_history/voided_invoice.html




More information about the freeside-commits mailing list