Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Sun, 20 Oct 2013 05:31:58 +0000 (22:31 -0700)
committerIvan Kohler <ivan@freeside.biz>
Sun, 20 Oct 2013 05:31:58 +0000 (22:31 -0700)
28 files changed:
FS/FS/AccessRight.pm
FS/FS/Mason.pm
FS/FS/Misc/Geo.pm
FS/FS/Report/Table/Daily.pm
FS/FS/Schema.pm
FS/FS/cust_credit.pm
FS/FS/cust_credit_void.pm [new file with mode: 0644]
FS/FS/cust_main.pm
FS/FS/cust_pkg.pm
FS/FS/part_pkg.pm
FS/FS/part_pkg/prorate_calendar.pm [new file with mode: 0644]
FS/FS/sales.pm
FS/MANIFEST
FS/t/cust_credit_void.t [new file with mode: 0644]
httemplate/edit/process/quick-charge.cgi
httemplate/edit/quick-charge.html
httemplate/elements/tr-input-beginning_ending.html
httemplate/graph/report_money_time_daily.html
httemplate/loginout/login.html
httemplate/search/cust_bill_pkg.cgi
httemplate/search/cust_pkg.cgi
httemplate/search/report_cust_pkg.html
httemplate/search/report_sales_commission.html
httemplate/search/sales_commission.html
httemplate/search/sales_pkg_class.html
httemplate/view/cust_main/packages/package.html
httemplate/view/cust_main/payment_history.html
httemplate/view/cust_main/payment_history/voided_credit.html [new file with mode: 0644]

index 2783ada..ca96eb5 100644 (file)
@@ -130,6 +130,7 @@ tie my %rights, 'Tie::IxHash',
     'View customer packages', #NEW
     'Order customer package',
     'One-time charge',
+    'Modify one-time charge',
     'Change customer package',
     'Detach customer package',
     'Bulk change customer packages',
index 1215ca4..398d785 100644 (file)
@@ -356,6 +356,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::invoice_mode;
   use FS::invoice_conf;
   use FS::cable_provider;
+  use FS::cust_credit_void;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index d9dcf3f..6bd817c 100644 (file)
@@ -6,6 +6,7 @@ use vars qw( $DEBUG @EXPORT_OK $conf );
 use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Request::Common qw( GET POST );
+use HTTP::Cookies;
 use HTML::TokeParser;
 use URI::Escape 3.31;
 use Data::Dumper;
@@ -48,19 +49,20 @@ sub get_censustract_ffiec {
   my $return = {};
   my $error = '';
 
-  my $ua = new LWP::UserAgent;
+  my $ua = new LWP::UserAgent('cookie_jar' => HTTP::Cookies->new);
   my $res = $ua->request( GET( $url ) );
 
   warn $res->as_string
     if $DEBUG > 2;
 
-  unless ($res->code  eq '200') {
+  if (!$res->is_success) {
 
     $error = $res->message;
 
   } else {
 
     my $content = $res->content;
+
     my $p = new HTML::TokeParser \$content;
     my $viewstate;
     my $eventvalidation;
@@ -74,7 +76,7 @@ sub get_censustract_ffiec {
       last if $viewstate && $eventvalidation;
     }
 
-    unless ($viewstate && $eventvalidation ) {
+    if (!$viewstate or !$eventvalidation ) {
 
       $error = "either no __VIEWSTATE or __EVENTVALIDATION found";
 
@@ -86,6 +88,7 @@ sub get_censustract_ffiec {
       my @ffiec_args = (
         __VIEWSTATE => $viewstate,
         __EVENTVALIDATION => $eventvalidation,
+        __VIEWSTATEENCRYPTED => '',
         ddlbYear    => $year,
         txtAddress  => $location->{address1},
         txtCity     => $location->{city},  
index 6087b0d..570fefe 100644 (file)
@@ -27,6 +27,7 @@ FS::Report::Table::Daily - Tables of report data, indexed daily
     'end_day'     => 27,
     #opt
     'agentnum'    => 54
+    'cust_classnum' => [ 1,2,4 ],
     'params'      => [ [ 'paramsfor', 'item_one' ], [ 'item', 'two' ] ], # ...
     'remove_empty' => 1, #collapse empty rows, default 0
     'item_labels' => [ ], #useful with remove_empty
@@ -54,6 +55,8 @@ sub data {
   my $emonth = $self->{'end_month'};
   my $eyear = $self->{'end_year'};
   my $agentnum = $self->{'agentnum'};
+  my $cust_classnum = $self->{'cust_classnum'} || [];
+  $cust_classnum = [ $cust_classnum ] if !ref($cust_classnum);
 
   my %data;
 
@@ -83,6 +86,7 @@ sub data {
     for ( $i = 0; $i < scalar(@items); $i++ ) {
          my $item = $items[$i];
          my @param = $self->{'params'} ? @{ $self->{'params'}[$col] }: ();
+          push @param, 'cust_classnum' => $cust_classnum if @$cust_classnum;
          my $value = $self->$item($speriod, $eperiod, $agentnum, @param);
          push @{$data{data}->[$col++]}, $value;
     }
index b6f3cf3..3029ab5 100644 (file)
@@ -1018,6 +1018,37 @@ sub tables_hashref {
                  ],
     },
 
+    'cust_credit_void' => {
+      'columns' => [
+        'crednum',  'serial',     '', '', '', '', 
+        'custnum',     'int',     '', '', '', '', 
+        '_date',  @date_type,             '', '', 
+        'amount',@money_type,             '', '', 
+        'currency',   'char', 'NULL',  3, '', '',
+        'otaker',  'varchar', 'NULL', 32, '', '', 
+        'usernum',     'int', 'NULL', '', '', '',
+        'reason',     'text', 'NULL', '', '', '', 
+        'reasonnum',   'int', 'NULL', '', '', '', 
+        'addlinfo',   'text', 'NULL', '', '', '',
+        'closed',     'char', 'NULL',  1, '', '', 
+        'pkgnum',      'int', 'NULL', '', '','',
+        'eventnum',    'int', 'NULL', '', '','',
+        'commission_agentnum', 'int', 'NULL', '', '', '',
+        'commission_salesnum', 'int', 'NULL', '', '', '',
+        'commission_pkgnum',   'int', 'NULL', '', '', '',
+        #void fields
+        'void_date',  @date_type,                  '', '', 
+        'void_reason', 'varchar', 'NULL', $char_d, '', '', 
+        'void_usernum',    'int', 'NULL',      '', '', '',
+      ],
+      'primary_key' => 'crednum',
+      'unique' => [],
+      'index' => [ ['custnum'], ['_date'], ['usernum'], ['eventnum'],
+                   [ 'commission_salesnum' ],
+                 ],
+    },
+
+
     'cust_credit_bill' => {
       'columns' => [
         'creditbillnum', 'serial', '', '', '', '', 
index bd92bdc..9678934 100644 (file)
@@ -21,6 +21,7 @@ use FS::reason;
 use FS::cust_event;
 use FS::agent;
 use FS::sales;
+use FS::cust_credit_void;
 
 $me = '[ FS::cust_credit ]';
 $DEBUG = 0;
@@ -203,6 +204,8 @@ the void method instead to leave a record of the deleted credit.
 # very similar to FS::cust_pay::delete
 sub delete {
   my $self = shift;
+  my %opt = @_;
+
   return "Can't delete closed credit" if $self->closed =~ /^Y/i;
 
   local $SIG{HUP} = 'IGNORE';
@@ -238,7 +241,7 @@ sub delete {
     return $error;
   }
 
-  if ( $conf->config('deletecredits') ne '' ) {
+  if ( !$opt{void} and $conf->config('deletecredits') ne '' ) {
 
     my $cust_main = $self->cust_main;
 
@@ -336,6 +339,53 @@ sub check {
   $self->SUPER::check;
 }
 
+=item void [ REASON ]
+
+Voids this credit: deletes the credit and all associated applications and 
+adds a record of the voided credit to the cust_credit_void table.
+
+=cut
+
+# yes, false laziness with cust_pay and cust_bill
+# but frankly I don't have time to fix it now
+
+sub void {
+  my $self = shift;
+  my $reason = 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_credit_void = new FS::cust_credit_void ( {
+      map { $_ => $self->get($_) } $self->fields
+    } );
+  $cust_credit_void->set('void_reason', $reason);
+  my $error = $cust_credit_void->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $error = $self->delete(void => 1); # suppress deletecredits warning
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item cust_credit_refund
 
 Returns all refund applications (see L<FS::cust_credit_refund>) for this credit.
diff --git a/FS/FS/cust_credit_void.pm b/FS/FS/cust_credit_void.pm
new file mode 100644 (file)
index 0000000..ac47d95
--- /dev/null
@@ -0,0 +1,134 @@
+package FS::cust_credit_void; 
+
+use strict;
+use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::Record );
+use FS::Record qw(qsearch qsearchs dbh fields);
+use FS::CurrentUser;
+use FS::access_user;
+use FS::cust_credit;
+
+=head1 NAME
+
+FS::cust_credit_void - Object methods for cust_credit_void objects
+
+=head1 SYNOPSIS
+
+  use FS::cust_credit_void;
+
+  $record = new FS::cust_credit_void \%hash;
+  $record = new FS::cust_credit_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_credit_void object represents a voided credit.  All fields in
+FS::cust_credit are present, as well as:
+
+=over 4
+
+=item void_date - the date (unix timestamp) that the credit was voided
+
+=item void_reason - the reason (a freeform string)
+
+=item void_usernum - the user (L<FS::access_user>) who voided it
+
+=back
+
+=head1 METHODS
+
+=over 4 
+
+=item new HASHREF
+
+Creates a new voided credit record.
+
+=cut
+
+sub table { 'cust_credit_void'; }
+
+=item insert
+
+Adds this voided credit to the database.
+
+=item check
+
+Checks all fields to make sure this is a valid voided credit.  If there is an
+error, returns the error, otherwise returns false.  Called by the insert
+method.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error =
+    $self->ut_numbern('crednum')
+    || $self->ut_number('custnum')
+    || $self->ut_numbern('_date')
+    || $self->ut_money('amount')
+    || $self->ut_alphan('otaker')
+    || $self->ut_textn('reason')
+    || $self->ut_textn('addlinfo')
+    || $self->ut_enum('closed', [ '', 'Y' ])
+    || $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_foreign_keyn('eventnum', 'cust_event', 'eventnum')
+    || $self->ut_foreign_keyn('commission_agentnum',  'agent', 'agentnum')
+    || $self->ut_foreign_keyn('commission_salesnum',  'sales', 'salesnum')
+    || $self->ut_foreign_keyn('commission_pkgnum', 'cust_pkg', 'pkgnum')
+    || $self->ut_numbern('void_date')
+    || $self->ut_textn('void_reason')
+    || $self->ut_foreign_keyn('void_usernum', 'access_user', '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 cust_main
+
+Returns the parent customer object (see L<FS::cust_main>).
+
+=cut
+
+sub cust_main {
+  my $self = shift;
+  qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
+}
+
+=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
+
+Doesn't yet support unvoid.
+
+=head1 SEE ALSO
+
+L<FS::cust_credit>, L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index a9a4cb0..3e36c60 100644 (file)
@@ -1243,13 +1243,14 @@ 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',
-    'cust_pay_void'  => 'voided payments',
-    'cust_refund'    => 'refunds',
+    'cust_bill'         => 'invoices',
+    'cust_bill_void'    => 'voided invoices',
+    'cust_statement'    => 'statements',
+    'cust_credit'       => 'credits',
+    'cust_credit_void'  => 'voided credits',
+    'cust_pay'          => 'payments',
+    'cust_pay_void'     => 'voided payments',
+    'cust_refund'       => 'refunds',
   ;
    
   foreach my $table ( keys %financial_tables ) {
@@ -3732,6 +3733,19 @@ sub cust_credit_pkgnum {
     );
 }
 
+=item cust_credit_void
+
+Returns all voided credits (see L<FS::cust_credit_void>) for this customer.
+
+=cut
+
+sub cust_credit_void {
+  my $self = shift;
+  map { $_ }
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_credit_void', { 'custnum' => $self->custnum } )
+}
+
 =item cust_pay
 
 Returns all the payments (see L<FS::cust_pay>) for this customer.
index 066b987..009c81e 100644 (file)
@@ -35,6 +35,8 @@ use FS::cust_pkg_discount;
 use FS::discount;
 use FS::UI::Web;
 use FS::sales;
+# for modify_charge
+use FS::cust_credit;
 
 # need to 'use' these instead of 'require' in sub { cancel, suspend, unsuspend,
 # setup }
@@ -2256,8 +2258,128 @@ sub set_salesnum {
   $self = $self->replace_old; # just to make sure
   $self->salesnum(shift);
   $self->replace;
+  # XXX this should probably reassign any credit that's already been given
 }
 
+=item modify_charge OPTIONS
+
+Change the properties of a one-time charge.  Currently the only properties
+that can be changed this way are those that have no impact on billing 
+calculations:
+- pkg: the package description
+- classnum: the package class
+- additional: arrayref of additional invoice details to add to this package
+
+If you pass 'adjust_commission' => 1, and the classnum changes, and there are
+commission credits linked to this charge, they will be recalculated.
+
+=cut
+
+sub modify_charge {
+  my $self = shift;
+  my %opt = @_;
+  my $part_pkg = $self->part_pkg;
+  my $pkgnum = $self->pkgnum;
+
+  my $dbh = dbh;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  return "Can't use modify_charge except on one-time charges"
+    unless $part_pkg->freq eq '0';
+
+  if ( length($opt{'pkg'}) and $part_pkg->pkg ne $opt{'pkg'} ) {
+    $part_pkg->set('pkg', $opt{'pkg'});
+  }
+
+  my %pkg_opt = $part_pkg->options;
+  if ( ref($opt{'additional'}) ) {
+    delete $pkg_opt{$_} foreach grep /^additional/, keys %pkg_opt;
+    my $i;
+    for ( $i = 0; exists($opt{'additional'}->[$i]); $i++ ) {
+      $pkg_opt{ "additional_info$i" } = $opt{'additional'}->[$i];
+    }
+    $pkg_opt{'additional_count'} = $i if $i > 0;
+  }
+
+  my $old_classnum;
+  if ( exists($opt{'classnum'}) and $part_pkg->classnum ne $opt{'classnum'} ) {
+    # remember it
+    $old_classnum = $part_pkg->classnum;
+    $part_pkg->set('classnum', $opt{'classnum'});
+  }
+
+  my $error = $part_pkg->replace( options => \%pkg_opt );
+  return $error if $error;
+
+  if (defined $old_classnum) {
+    # fix invoice grouping records
+    my $old_catname = $old_classnum
+                      ? FS::pkg_class->by_key($old_classnum)->categoryname
+                      : '';
+    my $new_catname = $opt{'classnum'}
+                      ? $part_pkg->pkg_class->categoryname
+                      : '';
+    if ( $old_catname ne $new_catname ) {
+      foreach my $cust_bill_pkg ($self->cust_bill_pkg) {
+        # (there should only be one...)
+        my @display = qsearch( 'cust_bill_pkg_display', {
+            'billpkgnum'  => $cust_bill_pkg->billpkgnum,
+            'section'     => $old_catname,
+        });
+        foreach (@display) {
+          $_->set('section', $new_catname);
+          $error = $_->replace;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return $error;
+          }
+        }
+      } # foreach $cust_bill_pkg
+    }
+
+    if ( $opt{'adjust_commission'} ) {
+      # fix commission credits...tricky.
+      foreach my $cust_event ($self->cust_event) {
+        my $part_event = $cust_event->part_event;
+        foreach my $table (qw(sales agent)) {
+          my $class =
+            "FS::part_event::Action::Mixin::credit_${table}_pkg_class";
+          my $credit = qsearchs('cust_credit', {
+              'eventnum' => $cust_event->eventnum,
+          });
+          if ( $part_event->isa($class) ) {
+            # Yes, this results in current commission rates being applied 
+            # retroactively to a one-time charge.  For accounting purposes 
+            # there ought to be some kind of time limit on doing this.
+            my $amount = $part_event->_calc_credit($self);
+            if ( $credit and $credit->amount ne $amount ) {
+              # Void the old credit.
+              $error = $credit->void('Package class changed');
+              if ( $error ) {
+                $dbh->rollback if $oldAutoCommit;
+                return "$error (adjusting commission credit)";
+              }
+            }
+            # redo the event action to recreate the credit.
+            local $@ = '';
+            eval { $part_event->do_action( $self, $cust_event ) };
+            if ( $@ ) {
+              $dbh->rollback if $oldAutoCommit;
+              return $@;
+            }
+          } # if $part_event->isa($class)
+        } # foreach $table
+      } # foreach $cust_event
+    } # if $opt{'adjust_commission'}
+  } # if defined $old_classnum
+
+  $dbh->commit if $oldAutoCommit;
+  '';
+}
+
+
+
 use Storable 'thaw';
 use MIME::Base64;
 use Data::Dumper;
@@ -4161,6 +4283,24 @@ boolean; if true, returns only packages with more than 0 FCC phone lines.
 Limit to packages with a service location in the specified state and country.
 For FCC 477 reporting, mostly.
 
+=item location_cust
+
+Limit to packages whose service location is the same as the customer's 
+default service location.
+
+=item location_nocust
+
+Limit to packages whose service location is not the customer's default 
+service location.
+
+=item location_census
+
+Limit to packages whose service location has a census tract.
+
+=item location_nocensus
+
+Limit to packages whose service location doesn't have a census tract.
+
 =back
 
 =cut
@@ -4393,6 +4533,18 @@ sub search {
   }
 
   ###
+  # location_* flags
+  ###
+  if ( $params->{location_cust} xor $params->{location_nocust} ) {
+    my $op = $params->{location_cust} ? '=' : '!=';
+    push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
+  }
+  if ( $params->{location_census} xor $params->{location_nocensus} ) {
+    my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
+    push @where, "cust_location.censustract $op";
+  }
+
+  ###
   # parse part_pkg
   ###
 
index 9e3b67e..9ce2e96 100644 (file)
@@ -1650,6 +1650,17 @@ sub cust_bill_pkg_recur {
   $cust_bill_pkg->recur;
 }
 
+=item unit_setup CUST_PKG
+
+Returns the setup fee for one unit of the package.
+
+=cut
+
+sub unit_setup {
+  my ($self, $cust_pkg) = @_;
+  $self->option('setup_fee') || 0;
+}
+
 =item format OPTION DATA
 
 Returns data formatted according to the function 'format' described
diff --git a/FS/FS/part_pkg/prorate_calendar.pm b/FS/FS/part_pkg/prorate_calendar.pm
new file mode 100644 (file)
index 0000000..83a80f5
--- /dev/null
@@ -0,0 +1,223 @@
+package FS::part_pkg::prorate_calendar;
+
+use strict;
+use vars qw(@ISA %info);
+use DateTime;
+use Tie::IxHash;
+use base 'FS::part_pkg::flat';
+
+# weird stuff in here
+
+%info = (
+  'name' => 'Prorate to specific calendar day(s), then flat-rate',
+  'shortname' => 'Prorate (calendar cycle)',
+  'inherit_fields' => [ 'flat', 'usage_Mixin', 'global_Mixin' ],
+  'fields' => {
+    'recur_temporality' => {'disabled' => 1},
+    'sync_bill_date' => {'disabled' => 1},# god help us all
+
+    'cutoff_day' => { 'name' => 'Billing day (1 - end of cycle)',
+                      'default' => 1,
+                    },
+
+    # add_full_period is not allowed
+
+    # prorate_round_day is always on
+    'prorate_round_day' => { 'disabled' => 1 },
+    'prorate_defer_bill'=> {
+                        'name' => 'Defer the first bill until the billing day',
+                        'type' => 'checkbox',
+                        },
+    'prorate_verbose' => {
+                        'name' => 'Show prorate details on the invoice',
+                        'type' => 'checkbox',
+                        },
+  },
+  'fieldorder' => [ 'cutoff_day', 'prorate_defer_bill', 'prorate_round_day', 'prorate_verbose' ],
+  'freq' => 'm',
+  'weight' => 20,
+);
+
+my %freq_max_days = ( # the length of the shortest period of each cycle type
+  '1'   => 28,
+  '2'   => 59,   # Jan - Feb
+  '3'   => 90,   # Jan - Mar
+  '4'   => 120,  # Jan - Apr
+  '6'   => 181,  # Jan - Jun
+  '12'  => 365,
+);
+
+my %freq_cutoff_days = (
+  '1'   => [ 31, 28, 31, 30, 31, 30,
+             31, 31, 30, 31, 30, 31 ],
+  '2'   => [ 59, 61, 61, 62, 61, 61 ],
+  '3'   => [ 90, 91, 92, 92 ],
+  '4'   => [ 120, 123, 122 ],
+  '6'   => [ 181, 184 ],
+  '12'  => [ 365 ],
+);
+
+sub check {
+  # yes, this package plan is such a special snowflake it needs its own
+  # check method.
+  my $self = shift;
+
+  if ( !exists($freq_max_days{$self->freq}) ) {
+    return 'Prorate (calendar cycle) billing interval must be an integer factor of one year';
+  }
+  $self->SUPER::check;
+}
+
+sub cutoff_day {
+  my( $self, $cust_pkg ) = @_;
+  my @periods = @{ $freq_cutoff_days{$self->freq} };
+  my @cutoffs = ($self->option('cutoff_day') || 1); # Jan 1 = 1
+  pop @periods; # we don't care about the last one
+  foreach (@periods) {
+    push @cutoffs, $cutoffs[-1] + $_;
+  }
+  @cutoffs;
+}
+
+sub calc_prorate {
+  # it's not the same algorithm
+  my ($self, $cust_pkg, $sdate, $details, $param, @cutoff_days) = @_;
+  die "no cutoff_day" unless @cutoff_days;
+  die "prepaid terms not supported with calendar prorate packages"
+    if $param->{freq_override}; # XXX if we ever use this again
+
+  #XXX should we still be doing this with multi-currency support?
+  my $money_char = FS::Conf->new->config('money_char') || '$';
+
+  my $charge = $self->base_recur($cust_pkg, $sdate) || 0;
+  my $now = DateTime->from_epoch(epoch => $$sdate, time_zone => 'local');
+
+  my $add_period = 0;
+  # if this is the first bill but the bill date has been set
+  # (by prorate_defer_bill), calculate from the setup date,
+  # append the setup fee to @$details, and make sure to bill for 
+  # a full period after the bill date.
+
+  if ( $self->option('prorate_defer_bill', 1)
+    and !$cust_pkg->getfield('last_bill')
+    and $cust_pkg->setup )
+  {
+    $param->{'setup_fee'} = $self->calc_setup($cust_pkg, $$sdate, $details);
+    $now = DateTime->from_epoch(epoch => $cust_pkg->setup, time_zone => 'local');
+    $add_period = 1;
+  }
+
+  # DON'T sync to the existing billing day; cutoff days work differently here.
+
+  $now->truncate(to => 'day');
+  my ($end, $start) = $self->calendar_endpoints($now, @cutoff_days);
+
+  #warn "[prorate_calendar] now = ".$now->ymd.", start = ".$start->ymd.", end = ".$end->ymd."\n";
+
+  my $periods = $end->delta_days($now)->delta_days /
+                $end->delta_days($start)->delta_days;
+  if ( $periods < 1 and $add_period ) {
+    $periods++; # charge for the extra time
+    $start->add(months => $self->freq); # and push the next bill date forward
+  }
+  if ( $self->option('prorate_verbose',1) and $periods > 0 ) {
+    if ( $periods < 1 ) {
+      push @$details,
+        'Prorated (' . $now->strftime('%b %d') .
+        ' - ' . $end->strftime('%b %d') . '): ' . $money_char .
+        sprintf('%.2f', $charge * $periods + 0.00000001);
+    } elsif ( $periods > 1 ) {
+      push @$details,
+        'Prorated (' . $now->strftime('%b %d') .
+        ' - ' . $end->strftime('%b %d') . '): ' . $money_char .
+        sprintf('%.2f', $charge * ($periods - 1) + 0.00000001),
+
+        'First full period: ' . $money_char . sprintf('%.2f', $charge);
+    } # else exactly one period
+  }
+
+  $$sdate = $start->epoch;
+  return sprintf('%.2f', $charge * $periods + 0.00000001);
+}
+
+sub prorate_setup {
+  my $self = shift;
+  my ($cust_pkg, $sdate) = @_;
+  my @cutoff_days = $self->cutoff_day;
+  if ( ! $cust_pkg->bill
+     and $self->option('prorate_defer_bill')
+     and @cutoff_days )
+  {
+    my $now = DateTime->from_epoch(epoch => $sdate, time_zone => 'local');
+    $now->truncate(to => 'day');
+    my ($end, $start) = $self->calendar_endpoints($now, @cutoff_days);
+    if ( $now->compare($start) == 0 ) {
+      $cust_pkg->setup($start->epoch);
+      $cust_pkg->bill($start->epoch);
+    } else {
+      $cust_pkg->bill($end->epoch);
+    }
+    return 1;
+  } else {
+    return 0;
+  }
+}
+
+=item calendar_endpoints NOW CUTOFF_DAYS
+
+Given a current date (DateTime object) and a list of cutoff day-of-year
+numbers, finds the next upcoming cutoff day (in either the current or the 
+upcoming year) and the cutoff day before that, and returns them both.
+
+=cut
+
+sub calendar_endpoints {
+  my $self = shift;
+  my $now = shift;
+  my @cutoff_day = sort {$a <=> $b} @_;
+
+  my $year = $now->year;
+  my $day = $now->day_of_year;
+  # Feb 29 = 60 
+  # For cutoff day purposes, it's the same day as Feb 28
+  $day-- if $now->is_leap_year and $day >= 60;
+
+  # select the first cutoff day that's after the current day
+  my $i = 0;
+  while ( $cutoff_day[$i] and $cutoff_day[$i] <= $day ) {
+    $i++;
+  }
+  # $cutoff_day[$i] is now later in the calendar than today
+  # or today is between the last cutoff day and the end of the year
+
+  my ($start, $end);
+  if ( $i == 0 ) {
+    # then today is on or before the first cutoff day
+    $start = DateTime->from_day_of_year(year => $year - 1,
+                                        day_of_year => $cutoff_day[-1],
+                                        time_zone => 'local');
+    $end =   DateTime->from_day_of_year(year => $year,
+                                        day_of_year => $cutoff_day[0],
+                                        time_zone => 'local');
+  } elsif ( $i > 0 and $i < scalar(@cutoff_day) ) {
+    # today is between two cutoff days
+    $start = DateTime->from_day_of_year(year => $year,
+                                        day_of_year => $cutoff_day[$i - 1],
+                                        time_zone => 'local');
+    $end =   DateTime->from_day_of_year(year => $year,
+                                        day_of_year => $cutoff_day[$i],
+                                        time_zone => 'local');
+  } else {
+    # today is after the last cutoff day
+    $start = DateTime->from_day_of_year(year => $year,
+                                        day_of_year => $cutoff_day[-1],
+                                        time_zone => 'local');
+    $end =   DateTime->from_day_of_year(year => $year + 1,
+                                        day_of_year => $cutoff_day[0],
+                                        time_zone => 'local');
+  }
+  return ($end, $start);
+}
+
+1;
index c8604ab..bdeaf1b 100644 (file)
@@ -131,34 +131,78 @@ sub sales_cust_main {
   qsearchs( 'cust_main', { 'custnum' => $self->sales_custnum } );
 }
 
-sub cust_bill_pkg {
+=item cust_bill_pkg START END OPTIONS
+
+Returns the package line items (see L<FS::cust_bill_pkg>) for which this 
+sales person could receive commission.
+
+START and END are an optional date range to limit the results.
+
+OPTIONS may contain:
+- I<cust_main_sales>: if this is a true value, sales of packages that have no
+package sales person will be included if this is their customer sales person.
+- I<classnum>: limit to this package classnum.
+- I<paid>: limit to sales that have no unpaid balance.
+
+=cut
+
+sub cust_bill_pkg_search {
   my( $self, $sdate, $edate, %search ) = @_;
 
   my $cmp_salesnum = delete $search{'cust_main_sales'}
                        ? ' COALESCE( cust_pkg.salesnum, cust_main.salesnum )'
                        : ' cust_pkg.salesnum ';
 
+  my $salesnum = $self->salesnum;
+  die "bad salesnum" unless $salesnum =~ /^(\d+)$/;
+  my @where = ( "$cmp_salesnum    = $salesnum",
+                "sales_pkg_class.salesnum = $salesnum"
+              );
+  push @where, "cust_bill._date >= $sdate" if $sdate;
+  push @where, "cust_bill._date  < $edate" if $edate;
+
   my $classnum_sql = '';
   if ( exists( $search{'classnum'}  ) ) {
-    my $classnum = $search{'classnum'};
-    $classnum_sql = " AND part_pkg.classnum ". ( $classnum ? " = $classnum "
-                                                           : ' IS NULL '     );
+    my $classnum = $search{'classnum'} || '';
+    die "bad classnum" unless $classnum =~ /^(\d*)$/;
+
+    push @where,
+      "part_pkg.classnum ". ( $classnum ? " = $classnum " : ' IS NULL ' );
   }
 
-  qsearch({ 'table'     => 'cust_bill_pkg',
-            'addl_from' => ' LEFT JOIN cust_bill USING ( invnum ) '.
-                           ' LEFT JOIN cust_pkg  USING ( pkgnum ) '.
-                           ' LEFT JOIN part_pkg  USING ( pkgpart ) '.
-                           ' LEFT JOIN cust_main ON ( cust_pkg.custnum = cust_main.custnum )',
-            'extra_sql' => ( keys %{ $search{'hashref'} }
-                               ? ' AND ' : 'WHERE '
-                           ).
-                           "     cust_bill._date >= $sdate ".
-                           " AND cust_bill._date  < $edate ".
-                           " AND $cmp_salesnum = ". $self->salesnum.
-                           $classnum_sql,
-            #%search,
-         });
+  # sales_pkg_class number-of-months limit, grr
+  # (we should be able to just check for the cust_event record from the 
+  # commission credit, but the report is supposed to act as a check on that)
+  #
+  # Pg-specific, of course
+  my $setup_date = 'TO_TIMESTAMP( cust_pkg.setup )';
+  my $interval = "(sales_pkg_class.commission_duration || ' months')::interval";
+  my $charge_date = 'TO_TIMESTAMP( cust_bill._date )';
+  push @where, "CASE WHEN sales_pkg_class.commission_duration IS NOT NULL ".
+               "THEN $charge_date < $setup_date + $interval ".
+               "ELSE TRUE END";
+
+  if ( $search{'paid'} ) {
+    push @where, FS::cust_bill_pkg->owed_sql . ' <= 0.005';
+  }
+
+  my $extra_sql = "WHERE ".join(' AND ', map {"( $_ )"} @where);
+
+  { 'table'     => 'cust_bill_pkg',
+    'select'    => 'cust_bill_pkg.*',
+    'addl_from' => ' LEFT JOIN cust_bill USING ( invnum ) '.
+                   ' LEFT JOIN cust_pkg  USING ( pkgnum ) '.
+                   ' LEFT JOIN part_pkg  USING ( pkgpart ) '.
+                   ' LEFT JOIN cust_main ON ( cust_pkg.custnum = cust_main.custnum )'.
+                   ' JOIN sales_pkg_class ON ( '.
+                   ' COALESCE( sales_pkg_class.classnum, 0) = COALESCE( part_pkg.classnum, 0) )',
+    'extra_sql' => $extra_sql,
+ };
+}
+
+sub cust_bill_pkg {
+  my $self = shift;
+  qsearch( $self->cust_bill_pkg_search(@_) )
 }
 
 sub cust_credit {
index 5dbe754..7a460da 100644 (file)
@@ -726,3 +726,5 @@ FS/invoice_conf.pm
 t/invoice_conf.t
 FS/cable_provider.pm
 t/cable_provider.t
+FS/cust_credit_void.pm
+t/cust_credit_void.t
diff --git a/FS/t/cust_credit_void.t b/FS/t/cust_credit_void.t
new file mode 100644 (file)
index 0000000..6113ef5
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_credit_void;
+$loaded=1;
+print "ok 1\n";
index 38f06e1..db41fb2 100644 (file)
@@ -10,8 +10,9 @@
 % }
 <%init>
 
+my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('One-time charge');
+  unless $curuser->access_right('One-time charge');
 
 my $error = '';
 my $conf = new FS::conf;
@@ -27,49 +28,76 @@ $param->{"custnum"} =~ /^(\d+)$/
   or $error .= "Illegal customer number " . $param->{"custnum"} . "  ";
 my $custnum = $1;
 
-$param->{"amount"} =~ /^\s*(\d*(?:\.?\d{1,2}))\s*$/
-  or $error .= "Illegal amount " . $param->{"amount"} . "  ";
-my $amount = $1;
+my $cust_main = FS::cust_main->by_key($custnum)
+  or die "custnum $custnum not found";
 
-my $quantity = 1;
-if ( $cgi->param('quantity') =~ /^\s*(\d+)\s*$/ ) {
-  $quantity = $1;
-}
+exists($curuser->agentnums_href->{$cust_main->agentnum})
+  or die "access denied";
 
-$param->{'tax_override'} =~ /^\s*([,\d]*)\s*$/
-  or $error .= "Illegal tax override " . $param->{"tax_override"} . "  ";
-my $override = $1;
+if ( $param->{'pkgnum'} =~ /^(\d+)$/ ) {
+  my $pkgnum = $1;
+  die "access denied"
+    unless $curuser->access_right('Modify one-time charge');
 
-if ( $param->{'taxclass'} eq '(select)' ) {
-  $error .= "Must select a tax class.  "
-    unless ($conf->exists('enable_taxproducts') &&
-             ( $override || $param->{taxproductnum} )
-           );
-  $cgi->param('taxclass', '');
-}
+  my $cust_pkg = FS::cust_pkg->by_key($1)
+    or die "pkgnum $pkgnum not found";
+
+  my $part_pkg = $cust_pkg->part_pkg;
+  die "pkgnum $pkgnum is not a one-time charge" unless $part_pkg->freq eq '0';
+
+  $error = $cust_pkg->modify_charge(
+      'pkg'               => scalar($cgi->param('pkg')),
+      'classnum'          => scalar($cgi->param('classnum')),
+      'additional'        => \@description,
+      'adjust_commission' => ($cgi->param('adjust_commission') ? 1 : 0),
+  );
+
+} else {
+  # the usual case: new one-time charge
+  $param->{"amount"} =~ /^\s*(\d*(?:\.?\d{1,2}))\s*$/
+    or $error .= "Illegal amount " . $param->{"amount"} . "  ";
+  my $amount = $1;
+
+  my $quantity = 1;
+  if ( $cgi->param('quantity') =~ /^\s*(\d+)\s*$/ ) {
+    $quantity = $1;
+  }
+
+  $param->{'tax_override'} =~ /^\s*([,\d]*)\s*$/
+    or $error .= "Illegal tax override " . $param->{"tax_override"} . "  ";
+  my $override = $1;
+
+  if ( $param->{'taxclass'} eq '(select)' ) {
+    $error .= "Must select a tax class.  "
+      unless ($conf->exists('enable_taxproducts') &&
+               ( $override || $param->{taxproductnum} )
+             );
+    $cgi->param('taxclass', '');
+  }
+
+  unless ( $error ) {
+    my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+      or $error .= "Unknown customer number $custnum.  ";
 
-unless ( $error ) {
-  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
-    or $error .= "Unknown customer number $custnum.  ";
-
-  $error ||= $cust_main->charge( {
-    'amount'        => $amount,
-    'quantity'      => $quantity,
-    'bill_now'      => scalar($cgi->param('bill_now')),
-    'invoice_terms' => scalar($cgi->param('invoice_terms')),
-    'start_date'    => ( scalar($cgi->param('start_date'))
-                           ? parse_datetime($cgi->param('start_date'))
-                           : ''
-                       ),
-    'no_auto'       => scalar($cgi->param('no_auto')),
-    'pkg'           => scalar($cgi->param('pkg')),
-    'setuptax'      => scalar($cgi->param('setuptax')),
-    'taxclass'      => scalar($cgi->param('taxclass')),
-    'taxproductnum' => scalar($cgi->param('taxproductnum')),
-    'tax_override'  => $override,
-    'classnum'      => scalar($cgi->param('classnum')),
-    'additional'    => \@description,
-  } );
+    $error ||= $cust_main->charge( {
+      'amount'        => $amount,
+      'quantity'      => $quantity,
+      'bill_now'      => scalar($cgi->param('bill_now')),
+      'invoice_terms' => scalar($cgi->param('invoice_terms')),
+      'start_date'    => ( scalar($cgi->param('start_date'))
+                             ? parse_datetime($cgi->param('start_date'))
+                             : ''
+                         ),
+      'no_auto'       => scalar($cgi->param('no_auto')),
+      'pkg'           => scalar($cgi->param('pkg')),
+      'setuptax'      => scalar($cgi->param('setuptax')),
+      'taxclass'      => scalar($cgi->param('taxclass')),
+      'taxproductnum' => scalar($cgi->param('taxproductnum')),
+      'tax_override'  => $override,
+      'classnum'      => scalar($cgi->param('classnum')),
+      'additional'    => \@description,
+    } );
+  }
 }
 
 </%init>
index 466091d..666ba82 100644 (file)
@@ -104,6 +104,49 @@ function bill_now_changed (what) {
 
 <TABLE ID="QuickChargeTable" BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0 STYLE="background-color: #cccccc">
 
+% if ( $cust_pkg ) {
+
+<INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $cust_pkg->pkgnum %>">
+<& /elements/tr-fixed.html,
+  label => 'Amount',
+  field => 'amount',
+  value => $money_char . sprintf('%.2f',$part_pkg->option('setup_fee')),
+&>
+
+%   if ( $conf->exists('invoice-unitprice') ) {
+<& /elements/tr-fixed.html,
+  label => 'Quantity',
+  field => 'quantity',
+  value => $cust_pkg->quantity
+&>
+%   }
+
+<& /elements/tr-select-pkg_class.html, 'curr_value' => $classnum  &>
+
+% # crudely estimate whether any agent commission credits might exist
+%   my @events = grep { $_->part_event->action =~ /credit/ }
+%                $cust_pkg->cust_event;
+%   if ( scalar @events ) {
+<TR><TD></TD>
+  <TD><INPUT TYPE="checkbox" NAME="adjust_commission" VALUE="Y" CHECKED>
+<% emt('Adjust commission credits if necessary') %>
+</TD>
+</TR>
+%   }
+
+% #display the future or past charge date, but don't allow changes
+% # XXX we probably _could_ let as-yet unbilled charges be rescheduled, but
+% # there's no compelling need yet
+%   if ( $cust_pkg->setup or $cust_pkg->start_date ) {
+%     my $label = $cust_pkg->setup ? emt('Billed on') : emt('Will be billed');
+%     my $field = $cust_pkg->setup ? 'setup' : 'start_date';
+      <& /elements/tr-fixed-date.html,
+        label => $label,
+        value => $cust_pkg->get($field)
+      &>
+%   } # else we don't show anything here
+% } else { # new one-time charge
+
 <TR>
   <TD ALIGN="right"><% mt('Amount') |h %> </TD>
   <TD>
@@ -117,7 +160,7 @@ function bill_now_changed (what) {
   </TD>
 </TR>
 
-% if ( $conf->exists('invoice-unitprice') ) {
+%   if ( $conf->exists('invoice-unitprice') ) {
     <TR>
       <TD ALIGN="right"><% mt('Quantity') |h %> </TD>
       <TD>
@@ -128,9 +171,9 @@ function bill_now_changed (what) {
                onKeyPress = "return enable_quick_charge(event)">
       </TD>
     </TR>
-% }
+%   }
 
-<& /elements/tr-select-pkg_class.html, 'curr_value' => $cgi->param('classnum')  &>
+<& /elements/tr-select-pkg_class.html, 'curr_value' => $classnum  &>
 
 <TR>
   <TD ALIGN="right"><% mt('Invoice now') |h %></TD>
@@ -206,6 +249,8 @@ function bill_now_changed (what) {
 
 <& /elements/tr-select-taxoverride.html, 'onclick' => 'parent.taxoverridemagic(this);', 'curr_value' => $cgi->param('tax_override')  &>
 
+% } # if !$cust_pkg
+
 <TR>
   <TD ALIGN="right"><% mt('Description') |h %> </TD>
   <TD>
@@ -226,11 +271,7 @@ function bill_now_changed (what) {
 </TR>
 
 % my $row = 0;
-%   if ( $cgi->param('error') || $cgi->param('magic') ) {
-%     my $param = $cgi->Vars;
-%
-% for ( $row = 0; exists($param->{"description$row"}); $row++ ) { 
-
+% foreach (@description) {
     <TR>
       <TD></TD>
       <TD>
@@ -238,21 +279,25 @@ function bill_now_changed (what) {
                NAME       = "description<% $row %>"
                SIZE       = "60"
                MAXLENGTH  = "65"
-               VALUE      = "<% $param->{"description$row"} |h %>"
+               VALUE      = "<% $_ |h %>"
                rownum     = "<% $row %>"
                onKeyPress = "return enable_quick_charge(event)"
                onKeyUp    = "return possiblyAddRow(event)"
         >
       </TD>
     </TR>
-% 
+% $row++;
 % } 
 
 
 </TABLE>
 
 <BR>
-<INPUT TYPE="submit" ID="submit" NAME="submit" VALUE="<% mt('Add one-time charge') |h %>" <% $cgi->param('error') ? '' :' DISABLED' %>>
+% my $label = $cust_pkg
+%             ? emt('Modify one-time charge')
+%             : emt('Add one-time charge');
+<INPUT TYPE="submit" ID="submit" NAME="submit" VALUE="<% $label %>" \
+<% ($cgi->param('error') || $cust_pkg) ? '' :' DISABLED' %>>
 
 </FORM>
 
@@ -329,9 +374,25 @@ my $conf = new FS::Conf;
 my $date_format = $conf->config('date_format') || '%m/%d/%Y';
 my $money_char = $conf->config('money_char') || '$';
 
-$cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
-my $custnum = $1;
-my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); #XXX agent-virt
+my ($cust_main, $cust_pkg);
+if ( $cgi->param('change_pkgnum') ) {
+  # change an existing one-time charge
+  die "access denied"
+    unless $curuser->access_right('Modify one-time charge');
+
+  $cgi->param('change_pkgnum') =~ /^(\d+)$/ or die "illegal pkgnum";
+  $cust_pkg = FS::cust_pkg->by_key($1) or die "pkgnum $1 not found";
+  $cust_main = $cust_pkg->cust_main;
+} else {
+  $cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
+  $cust_main = FS::cust_main->by_key($1) or die "custnum $1 not found";
+}
+
+my $custnum = $cust_main->custnum;
+# agent-virt
+if (!exists($curuser->agentnums_href->{$cust_main->agentnum})) {
+  die "custnum $custnum not found";
+}
 
 my $format = "%m/%d/%Y %T %z (%Z)"; #false laziness w/REAL_cust_pkg.cgi?
 my $start_date = $cust_main->next_bill_date;
@@ -360,4 +421,29 @@ if ( $cust_main->invoice_terms ) {
                       );
 }
 
+my @description;
+my %param = $cgi->Vars;
+for (my $i = 0; exists($param{"description$i"}); $i++) {
+  push @description, $param{"description$i"};
+}
+
+my $classnum;
+if ( $cgi->param('classnum') =~ /^(\d+)$/ ) {
+  $classnum = $1;
+}
+
+my $part_pkg;
+
+if ( $cust_pkg ) { # set defaults
+  $part_pkg = $cust_pkg->part_pkg;
+  $pkg ||= $part_pkg->pkg;
+  $classnum ||= $part_pkg->classnum;
+  if (!@description) {
+    for (my $i = 0; $i < ($part_pkg->option('additional_count',1) || 0); $i++) 
+    {
+      push @description, $part_pkg->option("additional_info$i",1);
+    }
+  }
+}
+
 </%init>
index ffc9038..91f55eb 100644 (file)
@@ -7,7 +7,7 @@
 
 <TR>
   <TD ALIGN="right">From date: </TD>
-  <TD><INPUT TYPE="text" NAME="<% $opt{prefix} %>beginning" ID="<% $opt{prefix} %>beginning_text" VALUE="<% $from %>" SIZE=<%$size%> MAXLENGTH=<%$maxlength%>> <IMG SRC="<%$fsurl%>images/calendar.png" ID="<% $opt{prefix} %>beginning_button" STYLE="cursor: pointer" TITLE="Select date"><IMG SRC="<%$fsurl%>images/calendar-disabled.png" ID="<% $opt{prefix} %>beginning_disabled" STYLE="display:none"><BR><i>m/d/y<% $time_hint %></i></TD>
+  <TD><INPUT TYPE="text" NAME="<% $opt{prefix} %>beginning" ID="<% $opt{prefix} %>beginning_text" VALUE="<% time2str($date_format, $from) %>" SIZE=<%$size%> MAXLENGTH=<%$maxlength%>> <IMG SRC="<%$fsurl%>images/calendar.png" ID="<% $opt{prefix} %>beginning_button" STYLE="cursor: pointer" TITLE="Select date"><IMG SRC="<%$fsurl%>images/calendar-disabled.png" ID="<% $opt{prefix} %>beginning_disabled" STYLE="display:none"><BR><i>m/d/y<% $time_hint %></i></TD>
 <SCRIPT TYPE="text/javascript">
   Calendar.setup({
     inputField: "<% $opt{prefix} %>beginning_text",
@@ -26,7 +26,7 @@
 % }
 
   <TD ALIGN="right">To date: </TD>
-  <TD><INPUT TYPE="text" NAME="<% $opt{prefix} %>ending" ID="<% $opt{prefix} %>ending_text" VALUE="<% $to %>" SIZE=<%$size%> MAXLENGTH=<%$maxlength%>> <IMG SRC="<%$fsurl%>images/calendar.png" ID="<% $opt{prefix} %>ending_button" STYLE="cursor: pointer" TITLE="Select date"><IMG SRC="<%$fsurl%>images/calendar-disabled.png" ID="<% $opt{prefix} %>ending_disabled" STYLE="display:none"><BR><i>m/d/y<% $time_hint %></i></TD>
+  <TD><INPUT TYPE="text" NAME="<% $opt{prefix} %>ending" ID="<% $opt{prefix} %>ending_text" VALUE="<% time2str($date_format, $to) %>" SIZE=<%$size%> MAXLENGTH=<%$maxlength%>> <IMG SRC="<%$fsurl%>images/calendar.png" ID="<% $opt{prefix} %>ending_button" STYLE="cursor: pointer" TITLE="Select date"><IMG SRC="<%$fsurl%>images/calendar-disabled.png" ID="<% $opt{prefix} %>ending_disabled" STYLE="display:none"><BR><i>m/d/y<% $time_hint %></i></TD>
 <SCRIPT TYPE="text/javascript">
   Calendar.setup({
     inputField: "<% $opt{prefix} %>ending_text",
index a436d08..e80f586 100644 (file)
@@ -4,12 +4,11 @@
 
 <TABLE>
 
-<% include( '/elements/tr-input-beginning_ending.html',
+<& /elements/tr-input-beginning_ending.html,
                 'datesrequired' => 1,
-                'from' => time2str('%m/%d/%Y',$from),
-                'to' => time2str('%m/%d/%Y',time),
-            ) 
-%>
+                'from' => $from,
+                'to' => time,
+&>
 
 <% include('/elements/tr-select-agent.html',
              'label'         => 'For agent: ',
index d06d0a8..3c6e2ae 100644 (file)
@@ -14,7 +14,7 @@
              
 %#  <FORM METHOD="POST" ACTION="<%$url_string%>loginout/login">
   <FORM METHOD="POST" ACTION="/login">
-    <INPUT TYPE="hidden" NAME="destination" VALUE="<% $r->prev->uri %>">
+    <INPUT TYPE="hidden" NAME="destination" VALUE="<% $r->prev->unparsed_uri %>">
 
     <TABLE CELLSPACING=0 CELLPADDING=4 BGCOLOR="#cccccc">
       <TR>
index 4c5e90f..fc74b54 100644 (file)
@@ -283,24 +283,7 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
 
-# salesnum
-if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
-
-  my $salesnum = $1;
-
-  my $cmp_salesnum = $cgi->param('cust_main_sales')
-                       ? ' COALESCE( cust_pkg.salesnum, cust_main.salesnum )'
-                       : ' cust_pkg.salesnum ';
-
-  push @where, "$cmp_salesnum = $salesnum";
-
-  #because currently we're called from sales_pkg_class.html for a specific
-  # class (or empty class) but not for all classes
-  #will have to do something to distinguish if someone wants the sales report
-  # (report_cust_bill_pkg.html) to have a sales person dropdown
-  $cgi->param('classnum', 0) unless $cgi->param('classnum');
-}
-
+# salesnum--see below
 # refnum
 if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.refnum = $1";
@@ -704,6 +687,28 @@ if ( $cgi->param('credit') ) {
 
 push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields();
 
+#salesnum
+if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
+
+  my $salesnum = $1;
+  my $sales = FS::sales->by_key($salesnum)
+    or die "salesnum $salesnum not found";
+
+  my $subsearch = $sales->cust_bill_pkg_search('', '',
+    'cust_main_sales' => ($cgi->param('cust_main_sales') ? 1 : 0),
+    'paid'            => ($cgi->param('paid') ? 1 : 0),
+    'classnum'        => scalar($cgi->param('classnum'))
+  );
+  $join_pkg .= " JOIN sales_pkg_class ON ( COALESCE(sales_pkg_class.classnum, 0) = COALESCE( part_pkg.classnum, 0) )";
+
+  my $extra_sql = $subsearch->{extra_sql};
+  $extra_sql =~ s/^WHERE//;
+  push @where, $extra_sql;
+
+  $cgi->param('classnum', 0) unless $cgi->param('classnum');
+}
+
+
 my $where = join(' AND ', @where);
 $where &&= "WHERE $where";
 
index 995779a..54bfa00 100755 (executable)
@@ -175,6 +175,10 @@ for my $param (qw( censustract censustract2 )) {
     if grep { $_ eq $param } $cgi->param;
 }
 
+#location flags (checkboxes)
+my @loc = grep /^\w+$/, $cgi->param('loc');
+$search_hash{"location_$_"} = 1 foreach @loc;
+
 my $report_option = $cgi->param('report_option');
 $search_hash{report_option} = $report_option if $report_option;
 
index f9aabfc..b3f2004 100755 (executable)
@@ -8,11 +8,7 @@
 
   <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
 
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left">
-        <FONT SIZE="+1">Customer search options</FONT>
-      </TH>
-    </TR>
+    <& /elements/tr-title.html, value => mt('Customer search options') &>
 
     <& /elements/tr-select-agent.html,
                    'curr_value'    => scalar( $cgi->param('agentnum') ),
 
   <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
 
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left">
-        <FONT SIZE="+1">Package search options</FONT>
-      </TH>
-    </TR>
+    <& /elements/tr-title.html, value => mt('Package search options') &>
 
     <& /elements/tr-select-sales.html,
                   'label'         => 'Package sales person',
                   'disable_empty' => 1,
     &>
 
-    <% include( '/elements/tr-select-cust_pkg-status.html',
+    <& /elements/tr-select-cust_pkg-status.html,
                   'label'    => 'Package status',
                   'onchange' => 'status_changed(this);',
-              )
-    %>
+    &>
 
     <SCRIPT TYPE="text/javascript">
   
 
     </SCRIPT>
 
-    <% include( '/elements/tr-select-pkg_class.html',
+    <& /elements/tr-select-pkg_class.html,
                    'pre_options' => [ '0' => 'all' ],
                    'empty_label' => '(empty class)',
-               )
-    %>
+    &>
 
 %   if ( scalar( qsearch( 'part_pkg_report_option', { 'disabled' => '' } ) ) ) {
 
-    <% include( '/elements/tr-select-table.html',
+    <& /elements/tr-select-table.html,
                    'label'        => 'Report classes',
                    'table'        => 'part_pkg_report_option',
                    'name_col'     => 'name',
                    'hashref'      => { 'disabled' => '' },
                    'element_name' => 'report_option',
                    'multiple'     => 'multiple',
-               )
-    %>
+    &>
 
 %   }
     <TR>
 
     </SCRIPT>
 
-    <% include( '/elements/tr-checkbox.html',
+    <& /elements/tr-checkbox.html,
                 'label' => 'Custom packages',
                 'field' => 'custom',
                 'value' => 1,
                 'onchange' => 'custom_changed(this);',
-              )
-    %> 
-
-    <% include( '/elements/tr-selectmultiple-part_pkg.html' ) %> 
+    &> 
+
+    <& /elements/tr-selectmultiple-part_pkg.html &> 
+
+    <& /elements/tr-title.html, value => mt('Location search options') &>
+
+%   my @location_options = qw(cust nocust census nocensus);
+    <& /elements/tr-checkbox-multiple.html,
+                'label'   => 'Where package location:',
+                'field'   => 'loc',
+                'options' => \@location_options,
+                'labels'  => { 'cust'     => "is the customer's default location",
+                               'nocust'   => "is not the customer's default location",
+                               'census'   => "has a census tract",
+                               'nocensus' => "does not have a census tract",
+                             },
+                'value'   => { map { $_ => 1 } @location_options },
+    &>
 
-    <TR>
-      <TH CLASS="background" COLSPAN=2>&nbsp;</TH>
-    </TR>
+    <& /elements/tr-title.html, value => mt('Display options') &>
 
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1">Display options</FONT></TH>
-    </TR>
-    <% include( '/elements/tr-select-cust-fields.html' ) %>
+    <& /elements/tr-select-cust-fields.html &>
     
   </TABLE>
 
index cc17e6b..792c335 100644 (file)
 
 </SCRIPT>
 
-<& /elements/tr-select-sales.html &>
+<& /elements/tr-select-sales.html,
+    'empty_label' => 'all',
+&>
 
 <& /elements/tr-checkbox.html,
-     'label' => 'Customer sales person if there is no package sales person',
-     'field' => 'cust_main_sales',
-     'value' => 'Y',
+    'label' => 'Customer sales person if there is no package sales person',
+    'field' => 'cust_main_sales',
+    'value' => 'Y',
 &>
 
+<& /elements/tr-checkbox.html,
+    'label' => 'Show paid sales only',
+    'field' => 'paid',
+    'value' => 'Y',
+&> 
+
 <& /elements/tr-input-beginning_ending.html &>
 
 </TABLE>
index d7b7a88..e74f379 100644 (file)
@@ -1,17 +1,22 @@
+% if ( $salesnum ) {
+<% $cgi->redirect($sales_link->[0] . $salesnum) %>
+% } else {
 <& elements/search.html,
      'title'         => $title,
      'name_singular' => 'sales person',
-#     'redirect'      => sub { #my( $sales, $cgi ) = @);
-#                              $saleslink;
-#                            },
-     'header'        => [ 'Sales person', 'Sales', 'Commission', ],
-     'fields'        => [ 'salesperson', $sales_sub, $commission_sub, ],
-     'links'         => [ '', $sales_link, $commission_link ],
-     'align'         => 'lrr',
-     'query'         => { 'table' => 'sales', },
-     'count_query'   => 'SELECT COUNT(*) FROM sales',
+     'header'        => [ 'Sales person', 'One-Time Sales', 'Recurring Sales', 'Commission', ],
+     'fields'        => [ 'salesperson',
+                          $sales_sub_maker->('setup'),
+                          $sales_sub_maker->('recur'),
+                          $commission_sub,
+                        ],
+     'links'         => [ '', $sales_link, $sales_link, $commission_link ],
+     'align'         => 'lrrr',
+     'query'         => \%query,
+     'count_query'   => $count_query,
      'disableable'   => 1,
 &>
+% }
 <%init>
 
 die "access denied"
@@ -25,34 +30,49 @@ my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi, '');
 
 my $date_format = $conf->config('date_format') || '%m/%d/%Y';
 
+my %query = ( 'table' => 'sales' );
+my $count_query = "SELECT COUNT(*) FROM sales";
+
+my $salesnum;
+if ( $cgi->param('salesnum') =~ /^(\d+)$/ ) {
+  $salesnum = $1;
+} else {
+  $cgi->delete('salesnum');
+}
+
 my $title = 'Sales person commission';
 $title .= ': '. time2str($date_format, $beginning). ' to '.
                 time2str($date_format, $ending)
   if $beginning;
 
+my $paid = $cgi->param('paid') ? 1 : 0;
+$title .= ' - paid sales only' if $paid;
+
 my $cust_main_sales = $cgi->param('cust_main_sales') eq 'Y' ? 'Y' : '';
 
 my $sales_link = [ 'sales_pkg_class.html?'.
-                     "begin=$beginning;".
-                     "end=$ending;".
-                     "cust_main_sales=$cust_main_sales;".
-                     "salesnum=",
+                   # pass all of our parameters along
+                   $cgi->query_string. ';salesnum=',
                    'salesnum'
                  ];
 
-my $sales_sub = sub {
-  my $sales = shift;
-
-  #efficiency improvement: ask the db for a sum instead of all the records
-  my $total_recur = 0;
-  my @cust_bill_pkg = $sales->cust_bill_pkg(
-    $beginning,
-    $ending,
-    'cust_main_sales' => $cust_main_sales,
-  );
-  $total_recur += $_->recur foreach @cust_bill_pkg;
-
-  $money_char. sprintf('%.2f', $total_recur);
+my $sales_sub_maker = sub {
+  my $field = shift;
+  sub {
+    my $sales = shift;
+
+    #efficiency improvement: ask the db for a sum instead of all the records
+    my $total = 0;
+    my @cust_bill_pkg = $sales->cust_bill_pkg(
+      $beginning,
+      $ending,
+      'cust_main_sales' => $cust_main_sales,
+      'paid' => $paid,
+    );
+    $total += $_->get($field) foreach @cust_bill_pkg;
+
+    $money_char. sprintf('%.2f', $total);
+  };
 };
 
 my $commission_sub = sub {
index c57aae6..8bb6bde 100644 (file)
@@ -1,10 +1,16 @@
 <& elements/search.html,
      'title'         => $title,
      'name_singular' => 'package class',
-     'header'        => [ 'Package class', 'Sales', 'Commission', ],
-     'fields'        => [ 'classname', $sales_sub, $commission_sub, ],
-     'links'         => [ '', $sales_link, $commission_link ],
-     'align'         => 'lrr',
+     'header'        => [ 'Package class',
+                          'One-Time Sales',
+                          'Recurring Sales',
+                          'Commission', ],
+     'fields'        => [ 'classname',
+                          $sales_sub_maker->('setup'),
+                          $sales_sub_maker->('recur'),
+                          $commission_sub, ],
+     'links'         => [ '', $sales_link, $sales_link, $commission_link ],
+     'align'         => 'lrrr',
      'query'         => { 'table'   => 'sales_pkg_class',
                           'hashref' => { 'salesnum' => $salesnum },
                         },
@@ -34,6 +40,9 @@ $title .= ': '. time2str($date_format, $beginning). ' to '.
   if $beginning;
 
 my $cust_main_sales = $cgi->param('cust_main_sales') eq 'Y' ? 'Y' : '';
+my $paid = $cgi->param('paid') ? 1 : 0;
+
+$title .= " - paid sales only" if $paid;
 
 my $sales_link = [ 'cust_bill_pkg.cgi?'.
                      "begin=$beginning;".
@@ -45,20 +54,22 @@ my $sales_link = [ 'cust_bill_pkg.cgi?'.
                    'classnum'
                  ];
 
-my $sales_sub = sub {
-  my $sales_pkg_class = shift;
-
-  #efficiency improvement: ask the db for a sum instead of all the records
-  my $total_recur = 0;
-  my @cust_bill_pkg = $sales->cust_bill_pkg(
-    $beginning,
-    $ending,
-    'cust_main_sales' => $cust_main_sales,
-    'classnum'        => $sales_pkg_class->classnum,
-  );
-  $total_recur += $_->recur foreach @cust_bill_pkg;
-
-  $money_char. sprintf('%.2f', $total_recur);
+my $sales_sub_maker = sub {
+  my $field = shift;
+  sub {
+    my $sales_pkg_class = shift;
+    # could be even more efficient but this is pretty good
+    my $search = $sales->cust_bill_pkg_search(
+      $beginning,
+      $ending,
+      'cust_main_sales' => $cust_main_sales,
+      'classnum'        => $sales_pkg_class->classnum,
+      'paid'            => $paid,
+    );
+    $search->{'select'} = "SUM(cust_bill_pkg.$field) AS total";
+    my $result = qsearchs($search);
+    $money_char. sprintf('%.2f', $result ? $result->get('total') : 0);
+  };
 };
 
 my $commission_sub = sub {
@@ -76,6 +87,15 @@ my $commission_sub = sub {
   $money_char. sprintf('%.2f', $total_credit);
 };
 
+my $sales_link = [ 'cust_bill_pkg.cgi?'.
+                    "begin=$beginning;".
+                    "end=$ending;".
+                    "cust_main_sales=$cust_main_sales;".
+                    "salesnum=$salesnum;".
+                    "classnum=",
+                   'classnum'
+                 ];
+
 my $commission_link = [ 'cust_credit.html?'.
                           "begin=$beginning;".
                           "end=$ending;".
index 1c8db15..e97c141 100644 (file)
       <TD COLSPAN=2>
         <FONT SIZE=-1>
 
-%         unless ( $cust_pkg->get('cancel') || $opt{no_links} ) {
+%         if ( $part_pkg->freq eq '0' and !$opt{no_links} ) {
+%           # One-time charge.  Nothing you can do with this, unless:
+%           if ( $curuser->access_right('Modify one-time charge') ) {
+                (&nbsp;<%onetime_change_link($cust_pkg)%>&nbsp;)
+                <BR>
+%           }
+%
+%         } elsif ( !$cust_pkg->get('cancel') and !$opt{no_links} ) {
 %
 %           if ( $change_from ) {
 %             # This is the target package for a future change.
 %             # Nothing you can do with it besides modify/cancel the 
 %             # future change, and that's on the current package.
-%           } elsif ( $supplemental or $part_pkg->freq eq '0' ) {
+%           } elsif ( $supplemental ) {
 %             # Supplemental packages can't be changed independently.
-%             # One-time charges don't need to be changed.
-%             # For both of those, we only show "Add comments",
-%             # and "Add invoice details".
+%             # Show only "Add comments" and "Add invoice details".
 %           } else {
 %             # the usual case: links to change package definition,
 %             # discount, and customization
@@ -320,6 +325,19 @@ sub pkg_change_link {
   );
 }
 
+sub onetime_change_link {
+  my $cust_pkg = shift;
+  my $pkgnum = $cust_pkg->pkgnum;
+  include( '/elements/popup_link-cust_pkg.html',
+    'action'      => $p. "edit/quick-charge.html?change_pkgnum=$pkgnum",
+    'label'       => emt('Modify one-time charge'),
+    'actionlabel' => emt('Modify'),
+    'cust_pkg'    => $cust_pkg,
+    'width'       => 690,
+    'height'      => 380,
+  );
+}
+
 sub pkg_change_location_link {
   my $cust_pkg = shift;
   my $pkgpart = $cust_pkg->pkgpart;
index c7bf374..73082ce 100644 (file)
 %                  ? sprintf("-&nbsp;$money_char\%.2f", $item->{'credit'})
 %                  : '';
 %
+%  $credit ||= sprintf( "<DEL>-&nbsp;$money_char\%.2f</DEL>",
+%                       $item->{'void_credit'}
+%                     )
+%    if exists($item->{'void_credit'});
+%
 %  my $refund  = exists($item->{'refund'})
 %                  ? sprintf("$money_char\%.2f", $item->{'refund'})
 %                  : '';
@@ -469,6 +474,15 @@ foreach my $cust_pay_void ($cust_main->cust_pay_void) {
 
 }
 
+#voided credits 
+foreach my $cust_credit_void ($cust_main->cust_credit_void) {
+  push @history, {
+    'date'        => $cust_credit_void->_date,
+    'desc'        => include('payment_history/voided_credit.html', $cust_credit_void, %opt ),
+    'void_credit' => $cust_credit_void->amount,
+  };
+}
+
 #declined payments
 foreach my $cust_pay_pending ($cust_main->cust_pay_pending_attempt) {
   push @history, {
diff --git a/httemplate/view/cust_main/payment_history/voided_credit.html b/httemplate/view/cust_main/payment_history/voided_credit.html
new file mode 100644 (file)
index 0000000..0723a72
--- /dev/null
@@ -0,0 +1,25 @@
+<DEL><% emt("Credit by [_1]", $cust_credit_void->otaker, $reason ) %>\
+<% $reason |h %></DEL>
+<I>
+<% emt("voided [_1]", time2str($date_format, $cust_credit_void->void_date) )%>
+% my $void_user = $cust_credit_void->void_access_user;
+% if ($void_user) {
+<% emt('by [_1]', $void_user->username) %>
+% }
+<% $void_reason |h %>
+</I>
+<%init>
+
+my( $cust_credit_void, %opt ) = @_;
+
+my $date_format = $opt{'date_format'} || '%m/%d/%Y';
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+#my $unvoid = ''; # not yet available
+my $reason = $cust_credit_void->reason;
+$reason = " ($reason)" if $reason;
+
+my $void_reason = $cust_credit_void->void_reason;
+$void_reason = " ($void_reason)" if $void_reason;
+</%init>