merging RT 4.0.6
authorIvan Kohler <ivan@freeside.biz>
Sat, 30 Jun 2012 08:03:13 +0000 (01:03 -0700)
committerIvan Kohler <ivan@freeside.biz>
Sat, 30 Jun 2012 08:03:13 +0000 (01:03 -0700)
217 files changed:
FS/FS.pm
FS/FS/AccessRight.pm
FS/FS/ClientAPI/MasonComponent.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/ClientAPI/SGNG.pm [deleted file]
FS/FS/ClientAPI/Signup.pm
FS/FS/ClientAPI_XMLRPC.pm
FS/FS/Conf.pm
FS/FS/Conf_compat17.pm
FS/FS/Cron/bill.pm
FS/FS/Cron/check.pm
FS/FS/Cron/upload.pm
FS/FS/Mason.pm
FS/FS/Mason/Request.pm
FS/FS/Misc.pm
FS/FS/Misc/Invoicing.pm [new file with mode: 0644]
FS/FS/PagedSearch.pm [new file with mode: 0644]
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/Setup.pm
FS/FS/UI/Web/small_custview.pm
FS/FS/access_groupsales.pm [new file with mode: 0644]
FS/FS/access_right.pm
FS/FS/cdr/cia.pm
FS/FS/cdr/infinite.pm
FS/FS/cdr/troop.pm
FS/FS/cdr/troop2.pm [new file with mode: 0644]
FS/FS/cust_bill.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_location.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Import.pm
FS/FS/cust_main/Location.pm [new file with mode: 0644]
FS/FS/cust_main/Packages.pm
FS/FS/cust_main/Search.pm
FS/FS/cust_main_county.pm
FS/FS/cust_main_exemption.pm
FS/FS/cust_pay.pm
FS/FS/cust_pkg.pm
FS/FS/cust_pkg_reason.pm
FS/FS/cust_svc.pm
FS/FS/detail_format/sum_duration_prefix.pm
FS/FS/ftp_target.pm [new file with mode: 0644]
FS/FS/h_radius_usergroup.pm [new file with mode: 0644]
FS/FS/h_svc_Radius_Mixin.pm [new file with mode: 0644]
FS/FS/h_svc_acct.pm
FS/FS/h_svc_broadband.pm
FS/FS/inventory_item.pm
FS/FS/msg_template.pm
FS/FS/option_Common.pm
FS/FS/part_event/Action/cust_bill_email.pm
FS/FS/part_event/Action/cust_bill_send_csv_ftp.pm
FS/FS/part_event/Action/cust_bill_spool_csv.pm
FS/FS/part_event/Condition/balance_age_under.pm [new file with mode: 0644]
FS/FS/part_event/Condition/has_referral_custnum.pm
FS/FS/part_event/Condition/once_percust_every.pm [new file with mode: 0644]
FS/FS/part_event/Condition/pkg_dundate_age.pm [new file with mode: 0644]
FS/FS/part_export/acct_xmlrpc.pm [new file with mode: 0644]
FS/FS/part_export/broadband_sqlradius.pm
FS/FS/part_export/netsapiens.pm
FS/FS/part_export/sqlradius.pm
FS/FS/part_pkg/flat.pm
FS/FS/part_pkg/flat_introrate.pm
FS/FS/part_pkg/prorate.pm
FS/FS/part_pkg/prorate_Mixin.pm
FS/FS/part_pkg/recur_Common.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/part_pkg/voip_inbound.pm
FS/FS/part_pkg/voip_tiered.pm
FS/FS/part_referral.pm
FS/FS/part_svc.pm
FS/FS/part_svc_class.pm [new file with mode: 0644]
FS/FS/pay_batch/eft_canada.pm
FS/FS/prospect_main.pm
FS/FS/sales.pm [new file with mode: 0644]
FS/FS/svc_Common.pm
FS/FS/svc_Radius_Mixin.pm
FS/FS/svc_broadband.pm
FS/FS/svc_pbx.pm
FS/FS/svc_phone.pm
FS/FS/tax_rate.pm
FS/MANIFEST
FS/bin/freeside-check
FS/t/access_groupsales.t [new file with mode: 0644]
FS/t/ftp_target.t [new file with mode: 0644]
FS/t/part_svc_class.t [new file with mode: 0644]
FS/t/sales.t [new file with mode: 0644]
Makefile
conf/invoice-unitprice [new file with mode: 0644]
fs_selfservice/FS-SelfService/SelfService.pm
fs_selfservice/FS-SelfService/cgi/myaccount.html
fs_selfservice/FS-SelfService/cgi/small_custview.html [new file with mode: 0644]
fs_selfservice/FS-SelfService/cgi/ticket_summary.html
fs_selfservice/php/freeside.class.new.php [new file with mode: 0644]
htetc/handler.pl
httemplate/browse/ftp_target.html [new file with mode: 0644]
httemplate/browse/msg_template.html
httemplate/browse/part_pkg.cgi
httemplate/browse/part_referral.html
httemplate/browse/part_svc_class.html [new file with mode: 0644]
httemplate/browse/sales.cgi [new file with mode: 0755]
httemplate/config/config-process.cgi
httemplate/config/config-view.cgi
httemplate/config/config.cgi
httemplate/docs/about.html
httemplate/docs/part_svc-table.html [new file with mode: 0644]
httemplate/edit/cust_main.cgi
httemplate/edit/cust_main/after_bill_location.html [new file with mode: 0644]
httemplate/edit/cust_main/before_bill_location.html [new file with mode: 0644]
httemplate/edit/cust_main/billing.html
httemplate/edit/cust_main/birthdate.html
httemplate/edit/cust_main/bottomfixup.js
httemplate/edit/cust_main/company.html [new file with mode: 0644]
httemplate/edit/cust_main/fax.html [new file with mode: 0644]
httemplate/edit/cust_main/name.html [new file with mode: 0644]
httemplate/edit/cust_main/phones.html [new file with mode: 0644]
httemplate/edit/cust_main/stateid.html [new file with mode: 0644]
httemplate/edit/cust_main/top_misc.html
httemplate/edit/cust_pay.cgi
httemplate/edit/elements/edit.html
httemplate/edit/elements/svc_Common.html
httemplate/edit/ftp_target.html [new file with mode: 0755]
httemplate/edit/invoice_template.html
httemplate/edit/msg_template.html
httemplate/edit/part_referral.html
httemplate/edit/part_svc.cgi
httemplate/edit/part_svc_class.html [new file with mode: 0644]
httemplate/edit/process/cust_location.cgi
httemplate/edit/process/cust_main.cgi
httemplate/edit/process/cust_pay.cgi
httemplate/edit/process/elements/process.html
httemplate/edit/process/ftp_target.html [new file with mode: 0644]
httemplate/edit/process/msg_template.html
httemplate/edit/process/part_svc_class.html [new file with mode: 0644]
httemplate/edit/process/quick-cust_pkg.cgi
httemplate/edit/process/sales.cgi [new file with mode: 0644]
httemplate/edit/prospect_main.html
httemplate/edit/quick-charge.html
httemplate/edit/sales.cgi [new file with mode: 0755]
httemplate/edit/svc_broadband.cgi
httemplate/edit/tower.html
httemplate/elements/customer-table.html
httemplate/elements/freeside.css
httemplate/elements/header.html
httemplate/elements/init_overlib.html
httemplate/elements/location.html
httemplate/elements/menu.html
httemplate/elements/progress-init.html
httemplate/elements/select-part_svc_class.html [new file with mode: 0644]
httemplate/elements/select-table.html
httemplate/elements/standardize_locations.js
httemplate/elements/tr-cust_svc.html
httemplate/elements/tr-fixed.html
httemplate/elements/tr-select-agent.html
httemplate/elements/tr-select-cust_location.html
httemplate/elements/tr-select-part_svc_class.html [new file with mode: 0644]
httemplate/loginout/logout.html
httemplate/misc/batch-cust_pay.html
httemplate/misc/cancel_pkg.html
httemplate/misc/cust_main-cancel.cgi
httemplate/misc/cust_main-import.cgi
httemplate/misc/cust_main-suspend.cgi [new file with mode: 0755]
httemplate/misc/cust_main-unsuspend.cgi [new file with mode: 0755]
httemplate/misc/delete-ftp_target.html [new file with mode: 0644]
httemplate/misc/order_pkg.html
httemplate/misc/process/batch-cust_pay.cgi
httemplate/misc/process/cancel_pkg.html
httemplate/misc/suspend_cust.html [new file with mode: 0644]
httemplate/misc/unsuspend_cust.html [new file with mode: 0644]
httemplate/misc/xmlhttp-cust_bill-search.html [new file with mode: 0644]
httemplate/misc/xmlhttp-cust_main-search.cgi
httemplate/pref/pref-process.html
httemplate/pref/pref.html
httemplate/search/cust_bill_pkg.cgi
httemplate/search/cust_main.html
httemplate/search/cust_pkg_summary.html
httemplate/search/cust_svc.html
httemplate/search/elements/cust_main_dayranges.html
httemplate/search/prepaid_income.html [new file with mode: 0644]
httemplate/search/report_cust_main.html
httemplate/search/report_prepaid_income.cgi [deleted file]
httemplate/search/report_prepaid_income.html
httemplate/search/report_receivables.html
httemplate/search/report_svc_acct.html
httemplate/search/report_svc_broadband.html
httemplate/search/report_svc_hardware.html
httemplate/search/report_tax.cgi
httemplate/search/unearned_detail.html [new file with mode: 0644]
httemplate/view/cust_main.cgi
httemplate/view/cust_main/billing.html
httemplate/view/cust_main/contacts.html
httemplate/view/cust_main/custom_content.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.birthdate.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.small_custview.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.svc_Common.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.svc_acct.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.svc_hardware.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/.svc_phone.html.swp [new file with mode: 0644]
httemplate/view/cust_main/custom_content/birthdate.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/small_custview.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/spouse_birthdate.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/svc_Common.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/svc_acct.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/svc_hardware.html [new file with mode: 0644]
httemplate/view/cust_main/custom_content/svc_phone.html [new file with mode: 0644]
httemplate/view/cust_main/locations.html
httemplate/view/cust_main/misc.html
httemplate/view/cust_main/packages/services.html
httemplate/view/cust_main/packages/status.html
httemplate/view/cust_main/payment_history.html
httemplate/view/directions.html
httemplate/view/svc_acct.cgi
httemplate/view/svc_acct/basics.html
rt/lib/RT/Action/Accumulate.pm
rt/lib/RT/Action/EscalateQueue.pm

index 3716212..8bbff12 100644 (file)
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -3,7 +3,7 @@ package FS;
 use strict;
 use vars qw($VERSION);
 
-$VERSION = '%%%VERSION%%%';
+$VERSION = '3.0git';
 
 #find missing entries in this file with:
 # for a in `ls *pm | cut -d. -f1`; do grep 'L<FS::'$a'>' ../FS.pm >/dev/null || echo "missing $a" ; done
@@ -95,6 +95,8 @@ L<FS::access_usergroup> - Employee group membership
 
 L<FS::access_groupagent> - Group reseller access
 
+L<FS::access_groupsales> - Group sales access
+
 L<FS::access_right> - Access rights
 
 L<FS::svc_acct_pop> - POP (Point of Presence, not Post
@@ -212,6 +214,8 @@ L<FS::inventory_item> - Inventory items
 
 L<FS::part_svc> - Service definition class
 
+L<FS::part_svc_class> - Service class class
+
 L<FS::part_svc_column> - Column constraint class
 
 L<FS::export_svc> - Class linking service definitions (see L<FS::part_svc>)
@@ -262,6 +266,8 @@ L<FS::rate_tier_details> - Rater tier details for call billing
 
 L<FS::usage_class> - Usage class class
 
+L<FS::sales> - Sales person class
+
 L<FS::agent> - Agent (reseller) class
 
 L<FS::agent_type> - Agent type class
index d2417f0..eb9974a 100644 (file)
@@ -111,6 +111,8 @@ tie my %rights, 'Tie::IxHash',
     'Edit customer tags',
     'Edit referring customer',
     'View customer history',
+    'Suspend customer',
+    'Unsuspend customer',
     'Cancel customer',
     'Complimentary customer', #aka users-allow_comp 
     'Merge customer',
@@ -138,6 +140,7 @@ tie my %rights, 'Tie::IxHash',
     'Unsuspend customer package',
     'Cancel customer package immediately',
     'Cancel customer package later',
+    'Un-cancel customer package',
     'Delay suspension events',
     'Add on-the-fly cancel reason', #NEW
     'Add on-the-fly suspend reason', #NEW
@@ -188,6 +191,7 @@ tie my %rights, 'Tie::IxHash',
   'Customer payment rights' => [
     'View payments',
     { rightname=>'Post payment', desc=>'Make check or cash payments.' },
+    { rightname=>'Backdate payment', desc=>'Enable payments to be posted for days other than today.' },
     'Post check payment',
     'Post cash payment',
     'Post payment batch',
@@ -254,6 +258,7 @@ tie my %rights, 'Tie::IxHash',
   'Reporting/listing rights' => [
     'List customers',
     'List all customers',
+    'Advanced customer search',
     'List zip codes', #NEW
     'List invoices',
     'List packages',
@@ -269,6 +274,27 @@ tie my %rights, 'Tie::IxHash',
     { rightname=>'View email logs', global=>1 },
 
     'Download report data',
+    'Services: Accounts',
+    'Services: Accounts: Advanced search',
+    'Services: Domains',
+    'Services: Certificates',
+    'Services: Mail forwards',
+    'Services: Virtual hosting services',
+    'Services: Wireless broadband services',
+    'Services: Wireless broadband services: Advanced search',
+    'Services: DSLs',
+    'Services: Dish services',
+    'Services: Hardware',
+    'Services: Hardware: Advanced search',
+    'Services: Phone numbers',
+    'Services: PBXs',
+    'Services: Ports',
+    'Services: Mailing lists',
+    'Services: External services',
+    'Usage: RADIUS sessions',
+    'Usage: Call Detail Records (CDRs)',
+    'Usage: Unrateable CDRs',
+    'Usage: Time worked',
 
     #{ rightname => 'List customers of all agents', global=>1 },
   ],
@@ -310,6 +336,8 @@ tie my %rights, 'Tie::IxHash',
     'Edit billing events',
     { rightname=>'Edit global billing events', global=>1 },
 
+    'View templates',
+    { rightname=>'View global templates', global=>1 },
     'Edit templates',
     { rightname=>'Edit global templates', global=>1 },
 
index 37cf7ef..534b48a 100644 (file)
@@ -36,7 +36,7 @@ my %session_callbacks = (
     my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
       or return "unknown custnum $custnum";
     my %args = @$argsref;
-    $args{object} = $cust_main;
+    $args{object} = $cust_main->bill_location;
     @$argsref = ( %args );
     return ''; #no error
   },
index 7bc3011..54799b8 100644 (file)
@@ -46,18 +46,17 @@ use FS::msg_template;
 $DEBUG = 0;
 $me = '[FS::ClientAPI::MyAccount]';
 
-use vars qw( @cust_main_editable_fields );
+use vars qw( @cust_main_editable_fields @location_editable_fields );
 @cust_main_editable_fields = qw(
-  first last company address1 address2 city
-    county state zip country
-    daytime night fax mobile
-  ship_first ship_last ship_company ship_address1 ship_address2 ship_city
-    ship_state ship_zip ship_country
-    ship_daytime ship_night ship_fax ship_mobile
+  first last daytime night fax mobile
   locale
   payby payinfo payname paystart_month paystart_year payissue payip
   ss paytype paystate stateid stateid_state
 );
+@location_editable_fields = qw(
+  address1 address2 city county state zip country
+);
+
 
 BEGIN { #preload to reduce time customer_info takes
   if ( $FS::TicketSystem::system ) {
@@ -115,7 +114,7 @@ sub skin_info {
       ( map { $_ => scalar( $conf->config($_, $agentnum) ) }
         qw( company_name date_format ) ),
       ( map { $_ => scalar( $conf->config("selfservice-$_", $agentnum ) ) }
-        qw( body_bgcolor box_bgcolor
+        qw( body_bgcolor box_bgcolor stripe1_bgcolor stripe2_bgcolor
             text_color link_color vlink_color hlink_color alink_color
             font title_color title_align title_size menu_bgcolor menu_fontsize
           )
@@ -196,6 +195,8 @@ sub login {
 
   } else {
 
+warn Dumper($p);
+
     my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
       or return { error => 'Domain '. $p->{'domain'}. ' not found' };
 
@@ -381,10 +382,16 @@ sub customer_info {
     my $cust_main = qsearchs('cust_main', $search )
       or return { 'error' => "unknown custnum $custnum" };
 
+    $return{display_custnum} = $cust_main->display_custnum;
+
     if ( $session->{'pkgnum'} ) { 
       $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
+      #next_bill_date from cust_pkg?
     } else {
       $return{balance} = $cust_main->balance;
+      $return{next_bill_date} = $cust_main->next_bill_date;
+      $return{next_bill_date_pretty} =
+        time2str('%m/%d/%Y', $return{next_bill_date} );
     }
 
     my @tickets = $cust_main->tickets;
@@ -416,21 +423,45 @@ sub customer_info {
                        };
                      } $cust_main->open_cust_bill;
       $return{open_invoices} = \@open;
+
+      my $sql = 'SELECT MAX(_date) FROM cust_bill WHERE custnum = ?';
+      my $sth = dbh->prepare($sql) or die  dbh->errstr;
+      $sth->execute($custnum)      or die $sth->errstr;
+      $return{'last_invoice_date'} = $sth->fetchrow_arrayref->[0];
+      $return{'last_invoice_date_pretty'} =
+        time2str('%m/%d/%Y', $return{'last_invoice_date'} );
     }
 
+    $return{countrydefault} = scalar($conf->config('countrydefault'));
+
     $return{small_custview} =
       small_custview( $cust_main,
-                      scalar($conf->config('countrydefault')),
+                      $return{countrydefault},
                       ( $session->{'pkgnum'} ? 1 : 0 ), #nobalance
                     );
 
     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
-    $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
+
+    $return{has_ship_address} = $cust_main->has_ship_address;
+    $return{status} = $cust_main->status;
+    $return{statuscolor} = $cust_main->statuscolor;
 
     for (@cust_main_editable_fields) {
       $return{$_} = $cust_main->get($_);
     }
 
+    for (@location_editable_fields) {
+      $return{$_} = $cust_main->bill_location->get($_);
+      $return{'ship_'.$_} = $cust_main->ship_location->get($_);
+    }
+    $return{has_ship_address} = $cust_main->has_ship_address;
+    # compatibility: some places in selfservice use this to determine
+    # if there's a ship address
+    if ( $return{has_ship_address} ) {
+      $return{ship_last}  = $cust_main->last;
+      $return{ship_first} = $cust_main->first;
+    }
+
     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
       $return{payinfo} = $cust_main->paymask;
       @return{'month', 'year'} = $cust_main->paydate_monthyear;
@@ -444,7 +475,7 @@ sub customer_info {
     if (scalar($conf->config('support_packages'))) {
       my @support_services = ();
       foreach ($cust_main->support_services) {
-        my $seconds = $_->svc_x->seconds;
+        my $seconds = $_->svc_x->seconds || 0;
         my $time_remaining = (($seconds < 0) ? '-' : '' ).
                              int(abs($seconds)/3600)."h".
                              sprintf("%02d",(abs($seconds)%3600)/60)."m";
@@ -485,8 +516,8 @@ sub customer_info {
 
   }
 
-  return { 'error'          => '',
-           'custnum'        => $custnum,
+  return { 'error'   => '',
+           'custnum' => $custnum,
            %return,
          };
 
@@ -509,14 +540,17 @@ sub customer_info_short {
     my $cust_main = qsearchs('cust_main', $search )
       or return { 'error' => "unknown custnum $custnum" };
 
+    $return{display_custnum} = $cust_main->display_custnum;
+
+    $return{countrydefault} = scalar($conf->config('countrydefault'));
+
     $return{small_custview} =
       small_custview( $cust_main,
-                      scalar($conf->config('countrydefault')),
+                      $return{countrydefault},
                       1, ##nobalance
                     );
 
     $return{name} = $cust_main->first. ' '. $cust_main->get('last');
-    $return{ship_name} = $cust_main->ship_first. ' '. $cust_main->get('ship_last');
 
     $return{payby} = $cust_main->payby;
 
@@ -524,7 +558,12 @@ sub customer_info_short {
     for (@cust_main_editable_fields) {
       $return{$_} = $cust_main->get($_);
     }
-    
+    #maybe a little more expensive, but it should be cached by now
+    for (@location_editable_fields) {
+      $return{$_} = $cust_main->bill_location->get($_);
+      $return{'ship_'.$_} = $cust_main->ship_location->get($_);
+    }
     if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
       $return{payinfo} = $cust_main->paymask;
       @return{'month', 'year'} = $cust_main->paydate_monthyear;
@@ -558,6 +597,103 @@ sub customer_info_short {
          };
 }
 
+sub billing_history {
+  my $p = shift;
+
+  my($context, $session, $custnum) = _custoragent_session_custnum($p);
+  return { 'error' => $session } if $context eq 'error';
+
+  return { 'error' => 'No customer' } unless $custnum;
+
+  my $search = { 'custnum' => $custnum };
+  $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent';
+  my $cust_main = qsearchs('cust_main', $search )
+    or return { 'error' => "unknown custnum $custnum" };
+
+  my %return = ();
+
+  if ( $session->{'pkgnum'} ) { 
+    #$return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} );
+    #next_bill_date from cust_pkg?
+    return { 'error' => 'No history for package' };
+  }
+
+  $return{balance} = $cust_main->balance;
+  $return{next_bill_date} = $cust_main->next_bill_date;
+  $return{next_bill_date_pretty} =
+    time2str('%m/%d/%Y', $return{next_bill_date} );
+
+  my @history = ();
+
+  my $conf = new FS::Conf;
+
+  if ( $conf->exists('selfservice-billing_history-line_items') ) {
+
+    foreach my $cust_bill ( $cust_main->cust_bill ) {
+
+      push @history, {
+        'type'        => 'Line item',
+        'description' => $_->desc. ( $_->sdate && $_->edate
+                                       ? ' '. time2str('%d-%b-%Y', $_->sdate).
+                                         ' To '. time2str('%d-%b-%Y', $_->edate)
+                                       : ''
+                                   ),
+        'amount'      => sprintf('%.2f', $_->setup + $_->recur ),
+        'date'        => $cust_bill->_date,
+        'date_pretty' =>  time2str('%m/%d/%Y', $cust_bill->_date ),
+      }
+        foreach $cust_bill->cust_bill_pkg;
+
+    }
+
+  } else {
+
+    push @history, {
+                     'type'        => 'Invoice',
+                     'description' => 'Invoice #'. $_->display_invnum,
+                     'amount'      => sprintf('%.2f', $_->charged ),
+                     'date'        => $_->_date,
+                     'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                   }
+      foreach $cust_main->cust_bill;
+
+  }
+
+  push @history, {
+                   'type'        => 'Payment',
+                   'description' => 'Payment', #XXX type
+                   'amount'      => sprintf('%.2f', 0 - $_->paid ),
+                   'date'        => $_->_date,
+                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                 }
+    foreach $cust_main->cust_pay;
+
+  push @history, {
+                   'type'        => 'Credit',
+                   'description' => 'Credit', #more info?
+                   'amount'      => sprintf('%.2f', 0 -$_->amount ),
+                   'date'        => $_->_date,
+                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                 }
+    foreach $cust_main->cust_credit;
+
+  push @history, {
+                   'type'        => 'Refund',
+                   'description' => 'Refund', #more info?  type, like payment?
+                   'amount'      => $_->refund,
+                   'date'        => $_->_date,
+                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                 }
+    foreach $cust_main->cust_refund;
+
+  @history = sort { $b->{'date'} <=> $a->{'date'} } @history;
+
+  $return{'history'} = \@history;
+
+  return \%return;
+
+}
+
 sub edit_info {
   my $p = shift;
   my $session = _cache->get($p->{'session_id'})
@@ -570,9 +706,32 @@ sub edit_info {
     or return { 'error' => "unknown custnum $custnum" };
 
   my $new = new FS::cust_main { $cust_main->hash };
+
   $new->set( $_ => $p->{$_} )
     foreach grep { exists $p->{$_} } @cust_main_editable_fields;
 
+  if ( exists($p->{address1}) ) {
+    my $bill_location = FS::cust_location->new({
+        map { $_ => $p->{$_} } @location_editable_fields
+    });
+    # if this is unchanged from before, cust_main::replace will ignore it
+    $new->set('bill_location' => $bill_location);
+  }
+
+  if ( exists($p->{ship_address1}) ) {
+    my $ship_location = FS::cust_location->new({
+        map { $_ => $p->{"ship_$_"} } @location_editable_fields
+    });
+    if ( !grep { length($p->{"ship_$_"}) } @location_editable_fields ) {
+      # Selfservice unfortunately tries to indicate "same as billing 
+      # address" by sending all fields empty.  Did this ever work?
+      $ship_location = $cust_main->bill_location;
+    }
+    $new->set('ship_location' => $ship_location);
+  }
+  # but if it hasn't been passed in at all, leave ship_location alone--
+  # DON'T change it to match bill_location.
+
   my $payby = '';
   if (exists($p->{'payby'})) {
     $p->{'payby'} =~ /^([A-Z]{4})$/
@@ -710,7 +869,8 @@ sub payment_info {
   $return{payname} = $cust_main->payname
                      || ( $cust_main->first. ' '. $cust_main->get('last') );
 
-  $return{$_} = $cust_main->get($_) for qw(address1 address2 city state zip);
+  $return{$_} = $cust_main->bill_location->get($_) 
+    for qw(address1 address2 city state zip);
 
   $return{payby} = $cust_main->payby;
   $return{stateid_state} = $cust_main->stateid_state;
@@ -729,7 +889,7 @@ sub payment_info {
     $return{payinfo2} = $payinfo2;
     $return{paytype}  = $cust_main->paytype;
     $return{paystate} = $cust_main->paystate;
-
+    $return{payname}  = $cust_main->payname;   # override 'first/last name' default from above, if any.  Is instution-name here.  (#15819)
   }
 
   if ( $conf->config('prepayment_discounts-credit_type') ) {
@@ -852,6 +1012,8 @@ sub validate_payment {
     'card_type'      => $card_type,
     'paydate'        => $p->{'year'}. '-'. $p->{'month'}. '-01',
     'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'},
+    'month'          => $p->{'month'},
+    'year'           => $p->{'year'},
     'payname'        => $payname,
     'paybatch'       => $paybatch, #this doesn't actually do anything
     'paycvv'         => $paycvv,
@@ -876,7 +1038,9 @@ sub store_payment {
   _cache->set( 'payment_'.$p->{'session_id'}, $validate, $timeout );
 
   +{ map { $_=>$validate->{$_} }
-      qw( card_type paymask payname paydate_pretty amount )
+      qw( card_type paymask payname paydate_pretty month year amount
+          address1 address2 city state zip country
+        )
   };
 
 }
@@ -927,9 +1091,16 @@ sub do_process_payment {
     my $new = new FS::cust_main { $cust_main->hash };
     if ($payby eq 'CARD' || $payby eq 'DCRD') {
       $new->set( $_ => $validate->{$_} )
-        foreach qw( payname paystart_month paystart_year payissue payip
-                    address1 address2 city state zip country );
+        foreach qw( payname paystart_month paystart_year payissue payip );
       $new->set( 'payby' => $validate->{'auto'} ? 'CARD' : 'DCRD' );
+
+      my $bill_location = FS::cust_location->new({
+          map { $_ => $validate->{$_} } 
+          qw(address1 address2 city state country zip)
+      }); # county?
+      $new->set('bill_location' => $bill_location);
+      # but don't allow the service address to change this way.
+
     } elsif ($payby eq 'CHEK' || $payby eq 'DCHK') {
       $new->set( $_ => $validate->{$_} )
         foreach qw( payname payip paytype paystate
@@ -1375,6 +1546,7 @@ sub list_pkgs {
                           my $primary_cust_svc = $_->primary_cust_svc;
                           +{ $_->hash,
                             $_->part_pkg->hash,
+                            pkg_label => $_->pkg_label,
                             status => $_->status,
                             part_svc =>
                               [ map $_->hashref, $_->available_part_svc ],
@@ -1467,12 +1639,14 @@ sub list_svcs {
             my $part_pkg = $cust_pkg->part_pkg;
 
             my %hash = (
-              'svcnum'     => $_->svcnum,
-              'svcdb'      => $svcdb,
-              'label'      => $label,
-              'value'      => $value,
-              'pkg_status' => $cust_pkg->status,
-              'readonly'   => ( $part_svc->selfservice_access eq 'readonly' ),
+              'svcnum'         => $_->svcnum,
+              'display_svcnum' => $_->display_svcnum,
+              'svcdb'          => $svcdb,
+              'label'          => $label,
+              'value'          => $value,
+              'pkg_label'      => $cust_pkg->pkg_label,
+              'pkg_status'     => $cust_pkg->status,
+              'readonly'       => ($part_svc->selfservice_access eq 'readonly'),
             );
 
             if ( $svcdb eq 'svc_acct' ) {
@@ -1770,6 +1944,8 @@ sub list_support_usage {
 
 sub _list_cdr_usage {
   # XXX CDR type support...
+  # XXX any way to do a paged search on this?
+  # we have to return the results all at once...
   my($svc_phone, $begin, $end, %opt) = @_;
   map [ $_->downstream_csv(%opt, 'keeparray' => 1) ],
     $svc_phone->get_cdrs( 'begin'=>$begin, 'end'=>$end, );
diff --git a/FS/FS/ClientAPI/SGNG.pm b/FS/FS/ClientAPI/SGNG.pm
deleted file mode 100644 (file)
index 7f784dc..0000000
+++ /dev/null
@@ -1,277 +0,0 @@
-#this stuff is SG-specific (i.e. multi-customer company username hack)
-
-package FS::ClientAPI::SGNG;
-
-use strict;
-use vars qw( $cache $DEBUG );
-use Time::Local qw(timelocal timelocal_nocheck);
-use Business::CreditCard;
-use FS::Record qw( qsearch qsearchs );
-use FS::Conf;
-use FS::cust_main;
-use FS::cust_pkg;
-use FS::ClientAPI::MyAccount; #qw( payment_info process_payment )
-
-$DEBUG = 0;
-
-sub _cache {
-  $cache ||= new FS::ClientAPI_SessionCache( {
-               'namespace' => 'FS::ClientAPI::MyAccount', #yes, share session_ids
-             } );
-}
-
-sub ping {
-  #my $p = shift;
-
-  return { 'pong' => '1' };
-
-}
-
-#this might almost be general-purpose
-sub decompify_pkgs {
-  my $p = shift;
-
-  my $session = _cache->get($p->{'session_id'})
-    or return { 'error' => "Can't resume session" }; #better error message
-
-  my $custnum = $session->{'custnum'};
-
-  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
-    or return { 'error' => "unknown custnum $custnum" };
-
-  return { 'error' => 'Not a complimentary customer' }
-    unless $cust_main->payby eq 'COMP';
-
-  my $paydate =
-    $cust_main->paydate =~ /^\S+$/ ? $cust_main->paydate : '2037-12-31';
-
-  my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
-
-  my $date = timelocal(0,0,0,$payday,--$paymonth,$payyear);
-
-  foreach my $cust_pkg (
-    qsearch({ 'table'     => 'cust_pkg',
-              'hashref'   => { 'custnum' => $custnum,
-                               'bill'    => '',
-                             },
-              'extra_sql' => ' AND '. FS::cust_pkg->active_sql,
-           })
-  ) {
-    $cust_pkg->set('bill', $date);
-    my $error = $cust_pkg->replace;
-    return { 'error' => $error } if $error;
-  }
-
-  return { 'error' => '' };
-
-}
-
-#find old payment info
-# (should work just like MyAccount::payment_info, except returns previous info
-#  too)
-# definitly sg-specific, no one else stores past customer records like this
-sub previous_payment_info {
-  my $p = shift;
-
-  my $session = _cache->get($p->{'session_id'})
-    or return { 'error' => "Can't resume session" }; #better error message
-
-  my $payment_info = FS::ClientAPI::MyAccount::payment_info($p);
-
-  my $custnum = $session->{'custnum'};
-
-  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
-    or return { 'error' => "unknown custnum $custnum" };
-
-  #?
-  return $payment_info if $cust_main->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/;
-
-  foreach my $prev_cust_main (
-    reverse _previous_cust_main( 'custnum'       => $custnum, 
-                                 'username'      => $cust_main->company,
-                                 'with_payments' => 1,
-                               )
-  ) {
-
-    next unless $prev_cust_main->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/;
-
-    if ( $prev_cust_main->payby =~ /^(CARD|DCRD)$/ ) {
-
-      #card expired?
-      my ($payyear,$paymonth,$payday) = split (/-/, $cust_main->paydate);
-
-      my $expdate = timelocal_nocheck(0,0,0,1,$paymonth,$payyear);
-
-      next if $expdate < time;
-
-    } elsif ( $prev_cust_main->payby =~ /^(CHEK|DCHK)$/ ) {
-
-      #any check?  or just skip these in favor of cards?
-
-    }
-
-    return { %$payment_info,
-             #$prev_cust_main->payment_info
-             _cust_main_payment_info( $prev_cust_main ),
-             'previous_custnum' => $prev_cust_main->custnum,
-           };
-
-  }
-
-  #still nothing?  return an error?
-  return $payment_info;
-
-}
-
-#this is really FS::cust_main::payment_info, but here for now
-sub _cust_main_payment_info {
-  my $self = shift;
-
-  my %return = ();
-
-  $return{balance} = $self->balance;
-
-  $return{payname} = $self->payname
-                     || ( $self->first. ' '. $self->get('last') );
-
-  $return{$_} = $self->get($_) for qw(address1 address2 city state zip);
-
-  $return{payby} = $self->payby;
-  $return{stateid_state} = $self->stateid_state;
-
-  if ( $self->payby =~ /^(CARD|DCRD)$/ ) {
-    $return{card_type} = cardtype($self->payinfo);
-    $return{payinfo} = $self->paymask;
-
-    @return{'month', 'year'} = $self->paydate_monthyear;
-
-  }
-
-  if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
-    my ($payinfo1, $payinfo2) = split '@', $self->paymask;
-    $return{payinfo1} = $payinfo1;
-    $return{payinfo2} = $payinfo2;
-    $return{paytype}  = $self->paytype;
-    $return{paystate} = $self->paystate;
-
-  }
-
-  #doubleclick protection
-  my $_date = time;
-  $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
-
-  %return;
-
-}
-
-#find old cust_main records (with payments)
-sub _previous_cust_main {
-
-  #safety check!  return nothing unless we're enabled explicitly
-  return () unless FS::Conf->new->exists('sg-multicustomer_hack');
-
-  my %opt = @_;
-  my $custnum  = $opt{'custnum'};
-  my $username = $opt{'username'};
-  
-  my %search = ();
-  if ( $opt{'with_payments'} ) {
-    $search{'extra_sql'} =
-      ' AND 0 < ( SELECT COUNT(*) FROM cust_pay
-                    WHERE cust_pay.custnum = cust_main.custnum
-                )
-      ';
-  }
-
-  qsearch( {
-    'table'    => 'cust_main', 
-    'hashref'  => { 'company' => { op => 'ILIKE', value => $opt{'username'} },
-                    'custnum' => { op => '!=',    value => $opt{'custnum'}  },
-                  },
-    'order_by' => 'ORDER BY custnum',
-    %search,
-  } );
-
-}
-
-#since we could be passing masked old CC data, need to look that up and
-#replace it (like regular process_payment does) w/info from old customer record
-sub previous_process_payment {
-  my $p = shift;
-
-  return FS::ClientAPI::MyAccount::process_payment($p)
-    unless $p->{'previous_custnum'}
-        && (    ( $p->{'payby'} =~ /^(CARD|DCRD)$/ && $p->{'payinfo'}  =~ /x/i )
-             || ( $p->{'payby'} =~ /^(CHEK|DCHK)$/ && $p->{'payinfo1'} =~ /x/i )
-           );
-
-  my $session = _cache->get($p->{'session_id'})
-    or return { 'error' => "Can't resume session" }; #better error message
-
-  my $custnum = $session->{'custnum'};
-
-  my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
-    or return { 'error' => "unknown custnum $custnum" };
-
-  #make sure this is really a previous custnum of this customer
-  my @previous_cust_main =
-    grep { $_->custnum == $p->{'previous_custnum'} }
-         _previous_cust_main( 'custnum'       => $custnum, 
-                              'username'      => $cust_main->company,
-                              'with_payments' => 1,
-                            );
-
-  my $previous_cust_main = $previous_cust_main[0];
-
-  #causes problems with old data w/old masking method
-  #if $previous_cust_main->paymask eq $payinfo;
-  
-  if ( $p->{'payby'} =~ /^(CHEK|DCHK)$/ && $p->{'payinfo1'} =~ /x/i ) {
-    ( $p->{'payinfo1'}, $p->{'payinfo2'} ) =
-      split('@', $previous_cust_main->payinfo);
-  } elsif ( $p->{'payby'} =~ /^(CARD|DCRD)$/ && $p->{'payinfo'} =~ /x/i ) {
-    $p->{'payinfo'} = $previous_cust_main->payinfo;
-  }
-
-  FS::ClientAPI::MyAccount::process_payment($p);
-
-}
-
-sub previous_payment_info_renew_info {
-  my $p = shift;
-  my $renew_info   = renew_info($p);
-  my $payment_info = previous_payment_info($p);
-  return { %$renew_info,
-           %$payment_info,
-         };
-}
-
-sub previous_process_payment_order_pkg {
-  my $p = shift;
-
-  my $hr = previous_process_payment($p);
-  return $hr if $hr->{'error'};
-
-  order_pkg($p);
-}
-
-sub previous_process_payment_change_pkg {
-  my $p = shift;
-
-  my $hr = previous_process_payment($p);
-  return $hr if $hr->{'error'};
-
-  change_pkg($p);
-}
-
-sub previous_process_payment_order_renew {
-  my $p = shift;
-
-  my $hr = previous_process_payment($p);
-  return $hr if $hr->{'error'};
-
-  order_renew($p);
-}
-
-1;
-
index f17752a..b7dcdbb 100644 (file)
@@ -405,8 +405,8 @@ sub signup_info {
            && $agent->agent_cust_main ) {
 
         my $cust_main = $agent->agent_cust_main;
-        my $prefix = length($cust_main->ship_last) ? 'ship_' : '';
-        $signup_info_cache_agent->{"ship_$_"} = $cust_main->get("$prefix$_")
+        my $location = $cust_main->ship_location;
+        $signup_info_cache_agent->{"ship_$_"} = $location->get($_)
           foreach qw( address1 city county state zip country );
 
       }
@@ -509,6 +509,13 @@ sub new_customer {
                 || $conf->config('signup_server-default_agentnum');
   }
 
+  my ($bill_hash, $ship_hash);
+  foreach my $f (FS::cust_main->location_fields) {
+    # avoid having to change this in front-end code
+    $bill_hash->{$f} = $packet->{"bill_$f"} || $packet->{$f};
+    $ship_hash->{$f} = $packet->{"ship_$f"};
+  }
+
   #shares some stuff with htdocs/edit/process/cust_main.cgi... take any
   # common that are still here and library them.
   my $template_custnum = $conf->config('signup_server-prepaid-template-custnum');
@@ -517,6 +524,7 @@ sub new_customer {
 
     my $template_cust = qsearchs('cust_main', { 'custnum' => $template_custnum } );
     return { 'error' => 'Configuration error' } unless $template_cust;
+    #XXX Copy template customer's locations
     $cust_main = new FS::cust_main ( {
       'agentnum'      => $agentnum,
       'refnum'        => $packet->{refnum}
@@ -556,41 +564,48 @@ sub new_customer {
                          || $conf->config('signup_server-default_refnum'),
 
       map { $_ => $packet->{$_} } qw(
-
-        last first ss company address1 address2
-        city county state zip country
+        last first ss company 
         daytime night fax stateid stateid_state
-
-        ship_last ship_first ship_ss ship_company ship_address1 ship_address2
-        ship_city ship_county ship_state ship_zip ship_country
-        ship_daytime ship_night ship_fax
-
         payby
         payinfo paycvv paydate payname paystate paytype
         paystart_month paystart_year payissue
         payip
         override_ban_warn
-
         referral_custnum comments
-      )
+      ),
 
     } );
   }
 
+  my $bill_location = FS::cust_location->new($bill_hash);
+  my $ship_location;
   my $agent = qsearchs('agent', { 'agentnum' => $agentnum } );
   if ( $conf->exists('agent-ship_address', $agentnum) 
     && $agent->agent_custnum ) {
 
     my $agent_cust_main = $agent->agent_cust_main;
     my $prefix = length($agent_cust_main->ship_last) ? 'ship_' : '';
-    $cust_main->set("ship_$_", $agent_cust_main->get("$prefix$_") )
-      foreach qw( address1 city county state zip country );
-
-    $cust_main->set("ship_$_", $cust_main->get($_))
-      foreach qw( last first );
+    $ship_location = FS::cust_location->new({ 
+        $agent_cust_main->ship_location->location_hash
+    });
 
   }
+  # we don't have an equivalent of the "same" checkbox in selfservice
+  # so is there a ship address, and if so, is it different from the billing 
+  # address?
+  elsif ( length($ship_hash->{address1}) > 0 and
+          grep { $bill_hash->{$_} ne $ship_hash->{$_} } keys(%$ship_hash)
+         ) {
+
+    $ship_location = FS::cust_location->new( $ship_hash );
+  
+  }
+  else {
+    $ship_location = $bill_location;
+  }
 
+  $cust_main->set('bill_location' => $bill_location);
+  $cust_main->set('ship_location' => $ship_location);
 
   return { 'error' => "Illegal payment type" }
     unless grep { $_ eq $packet->{'payby'} }
index 98e1910..7dd20c6 100644 (file)
@@ -104,6 +104,7 @@ sub ss2clientapi {
   'switch_acct'               => 'MyAccount/switch_acct',
   'customer_info'             => 'MyAccount/customer_info',
   'customer_info_short'       => 'MyAccount/customer_info_short',
+  'billing_history'           => 'MyAccount/billing_history',
   'edit_info'                 => 'MyAccount/edit_info',     #add to ss cgi!
   'invoice'                   => 'MyAccount/invoice',
   'invoice_pdf'               => 'MyAccount/invoice_pdf',
@@ -176,22 +177,6 @@ sub ss2clientapi {
   'call_time'                 => 'PrepaidPhone/call_time',
   'call_time_nanpa'           => 'PrepaidPhone/call_time_nanpa',
   'phonenum_balance'          => 'PrepaidPhone/phonenum_balance',
-  #izoom
-  #'bulk_processrow'           => 'Bulk/processrow',
-  #conflicts w/Agentone# 'check_username'            => 'Bulk/check_username',
-  #sg
-  'ping'                      => 'SGNG/ping',
-  'decompify_pkgs'            => 'SGNG/decompify_pkgs',
-  'previous_payment_info'     => 'SGNG/previous_payment_info',
-  'previous_payment_info_renew_info'
-                              => 'SGNG/previous_payment_info_renew_info',
-  'previous_process_payment'  => 'SGNG/previous_process_payment',
-  'previous_process_payment_order_pkg'
-                              => 'SGNG/previous_process_payment_order_pkg',
-  'previous_process_payment_change_pkg'
-                              => 'SGNG/previous_process_payment_change_pkg',
-  'previous_process_payment_order_renew'
-                              => 'SGNG/previous_process_payment_order_renew',
   };
 }
 
index 8144363..13625da 100644 (file)
@@ -13,6 +13,7 @@ use FS::payby;
 use FS::conf;
 use FS::Record qw(qsearch qsearchs);
 use FS::UID qw(dbh datasrc use_confcompat);
+use FS::Misc::Invoicing qw( spool_formats );
 use FS::Misc::Geo;
 
 $base_dir = '%%%FREESIDE_CONF%%%';
@@ -183,7 +184,7 @@ sub exists {
   my $self = shift;
   return $self->_usecompat('exists', @_) if use_confcompat;
 
-  my($name, $agentnum)=@_;
+  #my($name, $agentnum)=@_;
 
   carp "FS::Conf->exists(". join(', ', @_). ") called"
     if $DEBUG > 1;
@@ -191,6 +192,54 @@ sub exists {
   defined($self->_config(@_));
 }
 
+#maybe this should just be the new exists instead of getting a method of its
+#own, but i wanted to avoid possible fallout
+
+sub config_bool {
+  my $self = shift;
+  return $self->_usecompat('exists', @_) if use_confcompat;
+
+  my($name,$agentnum,$agentonly) = @_;
+
+  carp "FS::Conf->config_bool(". join(', ', @_). ") called"
+    if $DEBUG > 1;
+
+  #defined($self->_config(@_));
+
+  #false laziness w/_config
+  my $hashref = { 'name' => $name };
+  local $FS::Record::conf = undef;  # XXX evil hack prevents recursion
+  my $cv;
+  my @a = (
+    ($agentnum || ()),
+    ($agentonly && $agentnum ? () : '')
+  );
+  my @l = (
+    ($self->{locale} || ()),
+    ($self->{localeonly} && $self->{locale} ? () : '')
+  );
+  # try with the agentnum first, then fall back to no agentnum if allowed
+  foreach my $a (@a) {
+    $hashref->{agentnum} = $a;
+    foreach my $l (@l) {
+      $hashref->{locale} = $l;
+      $cv = FS::Record::qsearchs('conf', $hashref);
+      if ( $cv ) {
+        if ( $cv->value eq '0'
+               && ($hashref->{agentnum} || $hashref->{locale} )
+           ) 
+        {
+          return 0; #an explicit false override, don't continue looking
+        } else {
+          return 1;
+        }
+      }
+    }
+  }
+  return 0;
+
+}
+
 =item config_orbase KEY SUFFIX
 
 Returns the configuration value or values (depending on context) for 
@@ -269,8 +318,13 @@ sub touch {
   return $self->_usecompat('touch', @_) if use_confcompat;
 
   my($name, $agentnum) = @_;
-  unless ( $self->exists($name, $agentnum) ) {
-    $self->set($name, '', $agentnum);
+  #unless ( $self->exists($name, $agentnum) ) {
+  unless ( $self->config_bool($name, $agentnum) ) {
+    if ( $agentnum && $self->exists($name) && $self->config($name,$agentnum) eq '0' ) {
+      $self->delete($name, $agentnum);
+    } else {
+      $self->set($name, '', $agentnum);
+    }
   }
 }
 
@@ -357,6 +411,31 @@ sub delete {
   }
 }
 
+#maybe this should just be the new delete instead of getting a method of its
+#own, but i wanted to avoid possible fallout
+
+sub delete_bool {
+  my $self = shift;
+  return $self->_usecompat('delete', @_) if use_confcompat;
+
+  my($name, $agentnum) = @_;
+
+  warn "[FS::Conf] DELETE $name\n" if $DEBUG;
+
+  my $cv = FS::Record::qsearchs('conf', { name     => $name,
+                                          agentnum => $agentnum,
+                                          locale   => $self->{locale},
+                                        });
+
+  if ( $cv ) {
+    my $error = $cv->delete;
+    die $error if $error;
+  } elsif ( $agentnum ) {
+    $self->set($name, '0', $agentnum);
+  }
+
+}
+
 =item import_config_item CONFITEM DIR 
 
   Imports the item specified by the CONFITEM (see L<FS::ConfItem>) into
@@ -1420,6 +1499,7 @@ and customer address. Include units.',
     'description' => 'Send payment receipts.',
     'type'        => 'checkbox',
     'per_agent'   => 1,
+    'agent_bool'  => 1,
   },
 
   {
@@ -1846,6 +1926,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'unmask_ss',
+    'section'     => 'UI',
+    'description' => "Don't mask social security numbers in the web interface.",
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'show_stateid',
     'section'     => 'UI',
     'description' => "Turns on display/collection of driver's license/state issued id numbers in the web interface.  Sometimes required by electronic check (ACH) processors.",
@@ -2955,7 +3042,7 @@ and customer address. Include units.',
     'section'     => 'invoicing',
     'description' => 'Enable FTP of raw invoice data - format.',
     'type'        => 'select',
-    'select_enum' => [ '', 'default', 'oneline', 'billco', ],
+    'options'     => [ spool_formats() ],
   },
 
   {
@@ -2991,7 +3078,7 @@ and customer address. Include units.',
     'section'     => 'invoicing',
     'description' => 'Enable spooling of raw invoice data - format.',
     'type'        => 'select',
-    'select_enum' => [ '', 'default', 'oneline', 'billco', ],
+    'options'     => [ spool_formats() ],
   },
 
   {
@@ -3002,6 +3089,32 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'bridgestone-batch_counter',
+    'section'     => '',
+    'description' => 'Batch counter for spool files.  Increments every time a spool file is uploaded.',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
+    'key'         => 'bridgestone-prefix',
+    'section'     => '',
+    'description' => 'Agent identifier for uploading to BABT printing service.',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
+    'key'         => 'bridgestone-confirm_template',
+    'section'     => '',
+    'description' => 'Confirmation email template for uploading to BABT service.  Text::Template format, with variables "$zipfile" (name of the zipped file), "$seq" (sequence number), "$prefix" (user ID string), and "$rows" (number of records in the file).  Should include Subject: and To: headers, separated from the rest of the message by a blank line.',
+    # this could use a true message template, but it's hard to see how that
+    # would make the world a better place
+    'type'        => 'textarea',
+    'per_agent'   => 1,
+  },
+
+  {
     'key'         => 'svc_acct-usage_suspend',
     'section'     => 'billing',
     'description' => 'Suspends the package an account belongs to when svc_acct.seconds or a bytecount is decremented to 0 or below (accounts with an empty seconds and up|down|totalbytes value are ignored).  Typically used in conjunction with prepaid packages and freeside-sqlradius-radacctd.',
@@ -3052,6 +3165,16 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'cust_location-label_prefix',
+    'section'     => 'UI',
+    'description' => 'Optional "site ID" to show in the location label',
+    'type'        => 'select',
+    'select_hash' => [ '' => '',
+                       'CoStAg' => 'CoStAgXXXXX (country, state, agent name, locationnum)',
+                      ],
+  },
+
+  {
     'key'         => 'cust_pkg-display_times',
     'section'     => 'UI',
     'description' => 'Display full timestamps (not just dates) for customer packages.  Useful if you are doing real-time things like hourly prepaid.',
@@ -3446,7 +3569,14 @@ and customer address. Include units.',
   {
     'key'         => 'cust_main-enable_birthdate',
     'section'     => 'UI',
-    'descritpion' => 'Enable tracking of a birth date with each customer record',
+    'description' => 'Enable tracking of a birth date with each customer record',
+    'type'        => 'checkbox',
+  },
+
+  {
+    'key'         => 'cust_main-enable_spouse_birthdate',
+    'section'     => 'UI',
+    'description' => 'Enable tracking of a spouse birth date with each customer record',
     'type'        => 'checkbox',
   },
 
@@ -3952,6 +4082,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'unsuspend_email_admin',
+    'section'     => '',
+    'description' => 'Destination admin email address to enable unsuspension notices',
+    'type'        => 'text',
+  },
+  
+  {
     'key'         => 'email_report-subject',
     'section'     => '',
     'description' => 'Subject for reports emailed by freeside-fetch.  Defaults to "Freeside report".',
@@ -4001,6 +4138,22 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'selfservice-stripe1_bgcolor',
+    'section'     => 'self-service',
+    'description' => 'HTML color for self-service interface lists (primary stripe), for example, #FFFFFF',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
+    'key'         => 'selfservice-stripe2_bgcolor',
+    'section'     => 'self-service',
+    'description' => 'HTML color for self-service interface lists (alternate stripe), for example, #DDDDDD',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
     'key'         => 'selfservice-text_color',
     'section'     => 'self-service',
     'description' => 'HTML text color for the self-service interface, for example, #000000',
@@ -4361,34 +4514,6 @@ and customer address. Include units.',
   },
 
   {
-    'key'         => 'sg-multicustomer_hack',
-    'section'     => '',
-    'description' => "Don't use this.",
-    'type'        => 'checkbox',
-  },
-
-  {
-    'key'         => 'sg-ping_username',
-    'section'     => '',
-    'description' => "Don't use this.",
-    'type'        => 'text',
-  },
-
-  {
-    'key'         => 'sg-ping_password',
-    'section'     => '',
-    'description' => "Don't use this.",
-    'type'        => 'text',
-  },
-
-  {
-    'key'         => 'sg-login_username',
-    'section'     => '',
-    'description' => "Don't use this.",
-    'type'        => 'text',
-  },
-
-  {
     'key'         => 'mc-outbound_packages',
     'section'     => '',
     'description' => "Don't use this.",
@@ -4493,6 +4618,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'tax-cust_exempt-groups-require_individual_nums',
+    'section'     => '',
+    'description' => 'When using tax-cust_exempt-groups, require an individual tax exemption number for each exemption from different taxes.',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'cust_main-default_view',
     'section'     => 'UI',
     'description' => 'Default customer view, for users who have not selected a default view in their preferences.',
@@ -4540,14 +4672,14 @@ and customer address. Include units.',
   {
     'key'         => 'cust_main-edit_signupdate',
     'section'     => 'UI',
-    'descritpion' => 'Enable manual editing of the signup date.',
+    'description' => 'Enable manual editing of the signup date.',
     'type'        => 'checkbox',
   },
 
   {
     'key'         => 'svc_acct-disable_access_number',
     'section'     => 'UI',
-    'descritpion' => 'Disable access number selection.',
+    'description' => 'Disable access number selection.',
     'type'        => 'checkbox',
   },
 
@@ -4650,6 +4782,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'cust_main-custom_content',
+    'section'     => 'UI',
+    'description' => 'As an alternative to cust_main-custom_link (leave it blank), the contant to display on this customer page, one item per line.  Available iems are: small_custview, birthdate, spouse_birthdate, svc_acct, svc_phone and svc_external.',
+    'type'        => 'textarea',
+  },
+
+  {
     'key'         => 'cust_main-custom_title',
     'section'     => 'UI',
     'description' => 'Title for the "Custom" tab in the View Customer page.',
@@ -4857,6 +4996,13 @@ and customer address. Include units.',
     },
     'option_sub'  => sub { FS::Locales->description(shift) },
   },
+
+  {
+    'key'         => 'cust_main-require_locale',
+    'section'     => 'UI',
+    'description' => 'Require an explicit locale to be chosen for new customers.',
+    'type'        => 'checkbox',
+  },
   
   {
     'key'         => 'translate-auto-insert',
@@ -4916,6 +5062,20 @@ and customer address. Include units.',
     'type'        => 'checkbox',
   },
 
+  {
+    'key'         => 'selfservice-billing_history-line_items',
+    'section'     => 'self-service',
+    'description' => 'Return line item billing detail for the self-service billing_history API call.',
+    'type'        => 'checkbox',
+  },
+
+  {
+    'key'         => 'logout-timeout',
+    'section'     => 'UI',
+    'description' => 'If set, automatically log users out of the backoffice after this many minutes.',
+    'type'       => 'text',
+  },
+
   { key => "apacheroot", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachine", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachines", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
index 6685935..2e4bb05 100644 (file)
@@ -2458,6 +2458,13 @@ httemplate/docs/config.html
   },
 
   {
+    'key'         => 'unsuspend_email_admin',
+    'section'     => '',
+    'description' => 'Destination admin email address to enable unsuspension notices',
+    'type'        => 'text',
+  },
+  
+  {
     'key'         => 'email_report-subject',
     'section'     => '',
     'description' => 'Subject for reports emailed by freeside-fetch.  Defaults to "Freeside report".',
index 8d1223b..a9df376 100644 (file)
@@ -200,15 +200,15 @@ sub bill_where {
   # select * from cust_main where
   my $where_pkg = <<"END";
     EXISTS(
-      SELECT 1 FROM cust_pkg
+      SELECT 1 FROM cust_pkg LEFT JOIN part_pkg USING ( pkgpart )
         WHERE cust_main.custnum = cust_pkg.custnum
           AND ( cancel IS NULL OR cancel = 0 )
-          AND (    ( ( setup IS NULL OR setup =  0 )
+          AND (    ( ( cust_pkg.setup IS NULL OR cust_pkg.setup =  0 )
                      AND ( start_date IS NULL OR start_date = 0
                            OR ( start_date IS NOT NULL AND start_date <= $^T )
                          )
                    )
-                OR bill  IS NULL OR bill  <= $billtime 
+                OR ( freq != '0' AND ( bill IS NULL OR bill  <= $billtime ) )
                 OR ( expire  IS NOT NULL AND expire  <= $^T )
                 OR ( adjourn IS NOT NULL AND adjourn <= $^T )
                 OR ( resume  IS NOT NULL AND resume  <= $^T )
index 9d3ffbd..75247fb 100644 (file)
@@ -16,7 +16,6 @@ use FS::cust_pay_pending;
 @ISA = qw( Exporter );
 @EXPORT_OK = qw(
   check_queued check_selfservice check_apache check_bop_failures
-  check_sg check_sg_login check_sgng
   alert error_msg
 );
 
@@ -48,79 +47,6 @@ sub check_selfservice {
   return 1;
 }
 
-sub check_sg {
-  my $conf = new FS::Conf;
-  #different trigger if they ever stop using multicustomer_hack ?
-  return 1 unless $conf->exists('sg-multicustomer_hack');
-
-  my $ua = new LWP::UserAgent;
-  $ua->agent("FreesideCronCheck/0.1 " . $ua->agent);
-
-  my $USER = $conf->config('sg-ping_username');
-  my $PASS = $conf->config('sg-ping_password');
-  my $req = new HTTP::Request GET=>"https://$USER:$PASS\@localhost/sg/ping.cgi";
-  my $res = $ua->request($req);
-
-  return 1 if $res->is_success
-           && $res->content =~ /OK/
-           && $res->content !~ /error/i; #doh, the error message includes "OK"
-
-  $error_msg = $res->is_success ? $res->content : $res->status_line;
-  return 0;
-}
-
-sub check_sg_login {
-  my $conf = new FS::Conf;
-  #different trigger if they ever stop using multicustomer_hack ?
-  return 1 unless $conf->exists('sg-multicustomer_hack');
-
-  my $ua = new LWP::UserAgent;
-  $ua->agent("FreesideCronCheck/0.1 " . $ua->agent);
-
-  my $USER = $conf->config('sg-ping_username');
-  my $PASS = $conf->config('sg-ping_password');
-  my $USERNAME = $conf->config('sg-login_username');
-  my $req = new HTTP::Request
-    GET=>"https://$USER:$PASS\@localhost/sg/start.cgi?".
-         'username='. uri_escape($USERNAME);
-  my $res = $ua->request($req);
-
-  return 1 if $res->is_success
-           && $res->content =~ /[\da-f]{32}/i #session_id
-           && $res->content !~ /error/i;
-
-  $error_msg = $res->is_success ? $res->content : $res->status_line;
-  return 0;
-}
-
-sub check_sgng {
-  my $conf = new FS::Conf;
-  #different trigger if they ever stop using multicustomer_hack ?
-  return 1 unless $conf->exists('sg-multicustomer_hack');
-
-  eval 'use RPC::XML; use RPC::XML::Client;';
-  if ($@) { $error_msg = $@; return 0; };
-
-  my $cli = RPC::XML::Client->new('https://localhost/selfservice/xmlrpc.cgi');
-  my $resp = $cli->send_request('FS.SelfService.XMLRPC.ping');
-
-  return 1 if ref($resp)
-           && ! $resp->is_fault
-           && ref($resp->value)
-           && $resp->value->{'pong'} == 1;
-
-  #hua
-  $error_msg = ref($resp)
-                 ? ( $resp->is_fault
-                       ? $resp->string
-                       : ( ref($resp->value) ? $resp->value->{'error'}
-                                             : $resp->value
-                         )
-                 )
-                 : $resp;
-  return 0;
-}
-
 sub _check_fsproc {
   my $arg = shift;
   _check_pidfile( "freeside-$arg.pid" );
index dceead6..51e0d68 100644 (file)
@@ -9,6 +9,8 @@ use FS::Record qw( qsearch qsearchs );
 use FS::Conf;
 use FS::queue;
 use FS::agent;
+use FS::Misc qw( send_email ); #for bridgestone
+use FS::ftp_target;
 use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Request::Common;
@@ -39,42 +41,78 @@ sub upload {
 
   warn "$me upload called\n" if $DEBUG;
 
-  my $conf = new FS::Conf;
-  my @agent = grep { $conf->config( 'billco-username', $_->agentnum, 1 ) }
-              grep { $conf->config( 'billco-password', $_->agentnum, 1 ) }
-              qsearch( 'agent', {} );
+  my @tasks;
 
   my $date =  time2str('%Y%m%d%H%M%S', $^T); # more?
 
-  @agent = grep { $_ == $opt{'a'} } @agent if $opt{'a'};
+  my $conf = new FS::Conf;
+
+  my @agents = $opt{'a'} ? FS::agent->by_key($opt{'a'}) : qsearch('agent', {});
+
+  my %task = (
+    'date'      => $date,
+    'l'         => $opt{'l'},
+    'm'         => $opt{'m'},
+    'v'         => $opt{'v'},
+  );
+
+  my @agentnums = ('', map {$_->agentnum} @agents);
+
+  foreach my $target (qsearch('ftp_target', {})) {
+    # We don't know here if it's spooled on a per-agent basis or not.
+    # (It could even be both, via different events.)  So queue up an 
+    # upload for each agent, plus one with null agentnum, and we'll 
+    # upload as many files as we find.
+    foreach my $a (@agentnums) {
+      push @tasks, {
+        %task,
+        'agentnum'  => $a,
+        'targetnum' => $target->targetnum,
+        'handling'  => $target->handling,
+      };
+    }
+  }
 
-  foreach my $agent ( @agent ) {
+  # deprecated billco method
+  foreach (@agents) {
+    my $agentnum = $_->agentnum;
+
+    if ( $conf->config( 'billco-username', $agentnum, 1 ) ) {
+      my $username = $conf->config('billco-username', $agentnum, 1);
+      my $password = $conf->config('billco-password', $agentnum, 1);
+      my $clicode  = $conf->config('billco-clicode',  $agentnum, 1);
+      my $url      = $conf->config('billco-url',      $agentnum);
+      push @tasks, {
+        %task,
+        'agentnum' => $agentnum,
+        'username' => $username,
+        'password' => $password,
+        'url'      => $url,
+        'clicode'  => $clicode,
+        'handling' => 'billco',
+      };
+    }
+  } # foreach @agents
 
-    my $agentnum = $agent->agentnum;
+  foreach (@tasks) {
+
+    my $agentnum = $_->{agentnum};
 
     if ( $opt{'m'} ) {
 
       if ( $opt{'r'} ) {
         warn "DRY RUN: would add agent $agentnum for queued upload\n";
       } else {
-
         my $queue = new FS::queue {
-          'job'      => 'FS::Cron::upload::billco_upload',
+          'job'      => 'FS::Cron::upload::spool_upload',
         };
-        my $error = $queue->insert(
-                                    'agentnum' => $agentnum,
-                                    'date'     => $date,
-                                    'l'        => $opt{'l'} || '',
-                                    'm'        => $opt{'m'} || '',
-                                    'v'        => $opt{'v'} || '',
-                                  );
-
+        my $error = $queue->insert( %$_ );
       }
 
     } else {
 
-      eval "&billco_upload( 'agentnum' => $agentnum, 'date' => $date );";
-      warn "billco_upload failed: $@\n"
+      eval { spool_upload(%$_) };
+      warn "spool_upload failed: $@\n"
         if $@;
 
     }
@@ -83,26 +121,14 @@ sub upload {
 
 }
 
-sub billco_upload {
+sub spool_upload {
   my %opt = @_;
 
-  warn "$me billco_upload called\n" if $DEBUG;
+  warn "$me spool_upload called\n" if $DEBUG;
   my $conf = new FS::Conf;
   my $dir = '%%%FREESIDE_EXPORT%%%/export.'. $FS::UID::datasrc. '/cust_bill';
 
-  my $agentnum = $opt{agentnum} or die "no agentnum provided\n";
-  my $url      = $conf->config( 'billco-url', $agentnum )
-    or die "no url for agent $agentnum\n";
-  $url =~ s/^\s+//; $url =~ s/\s+$//;
-  my $username = $conf->config( 'billco-username', $agentnum, 1 )
-    or die "no username for agent $agentnum\n";
-  my $password = $conf->config( 'billco-password', $agentnum, 1 )
-    or die "no password for agent $agentnum\n";
-  my $clicode  = $conf->config( 'billco-clicode', $agentnum, 1 );
-    #or die "no clicode for agent $agentnum\n";
-
-  die "no date provided\n" unless $opt{date};
-  my $zipfile  = "$dir/agentnum$agentnum-$opt{date}.zip";
+  my $date = $opt{date} or die "no date provided\n";
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -115,86 +141,228 @@ sub billco_upload {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $agent = qsearchs( 'agent', { agentnum => $agentnum } )
-    or die "no such agent: $agentnum";
-  $agent->select_for_update; #mutex 
-
-  unless ( -f "$dir/agentnum$agentnum-header.csv" ||
-           -f "$dir/agentnum$agentnum-detail.csv" )
-  {
-    warn "$me neither $dir/agentnum$agentnum-header.csv nor ".
-         "$dir/agentnum$agentnum-detail.csv found\n" if $DEBUG;
-    $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-    return;
+  my $agentnum = $opt{agentnum};
+  my $agent;
+  if ( $agentnum ) {
+    $agent = qsearchs( 'agent', { agentnum => $agentnum } )
+      or die "no such agent: $agentnum";
+    $agent->select_for_update; #mutex 
   }
 
-  # a better way?
-  if ($opt{m}) {
-    my $sql = "SELECT count(*) FROM queue LEFT JOIN cust_main USING(custnum) ".
-      "WHERE queue.job='FS::cust_main::queued_bill' AND cust_main.agentnum = ?";
-    my $sth = $dbh->prepare($sql) or die $dbh->errstr;
-    while (1) {
-      $sth->execute( $agentnum )
-        or die "Unexpected error executing statement $sql: ". $sth->errstr;
-      last if $sth->fetchow_arrayref->[0];
-      sleep 300;
+  if ( $opt{'handling'} eq 'billco' ) {
+
+    my $file = "agentnum$agentnum";
+    my $zipfile  = "$dir/$file-$date.zip";
+
+    unless ( -f "$dir/$file-header.csv" ||
+             -f "$dir/$file-detail.csv" )
+    {
+      warn "$me neither $dir/$file-header.csv nor ".
+           "$dir/$file-detail.csv found\n" if $DEBUG > 1;
+      $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+      return;
     }
-  }
 
-  foreach ( qw ( header detail ) ) {
-    rename "$dir/agentnum$agentnum-$_.csv",
-           "$dir/agentnum$agentnum-$opt{date}-$_.csv";
-  }
+    my $url      = $opt{url} or die "no url for agent $agentnum\n";
+    $url =~ s/^\s+//; $url =~ s/\s+$//;
+
+    my $username = $opt{username} or die "no username for agent $agentnum\n";
+    my $password = $opt{password} or die "no password for agent $agentnum\n";
+
+    # a better way?
+    if ($opt{m}) {
+      my $sql = "SELECT count(*) FROM queue LEFT JOIN cust_main USING(custnum) ".
+        "WHERE queue.job='FS::cust_main::queued_bill' AND cust_main.agentnum = ?";
+      my $sth = $dbh->prepare($sql) or die $dbh->errstr;
+      while (1) {
+        $sth->execute( $agentnum )
+          or die "Unexpected error executing statement $sql: ". $sth->errstr;
+        last if $sth->fetchrow_arrayref->[0];
+        sleep 300;
+      }
+    }
 
-  my $command = "cd $dir; zip $zipfile ".
-                "agentnum$agentnum-$opt{date}-header.csv ".
-                "agentnum$agentnum-$opt{date}-detail.csv";
+    foreach ( qw ( header detail ) ) {
+      rename "$dir/$file-$_.csv",
+             "$dir/$file-$date-$_.csv";
+    }
 
-  system($command) and die "$command failed\n";
+    my $command = "cd $dir; zip $zipfile ".
+                  "$file-$date-header.csv ".
+                  "$file-$date-detail.csv";
 
-  unlink "agentnum$agentnum-$opt{date}-header.csv",
-         "agentnum$agentnum-$opt{date}-detail.csv";
+    system($command) and die "$command failed\n";
 
-  if ( $url =~ /^http/i ) {
+    unlink "$file-$date-header.csv",
+           "$file-$date-detail.csv";
 
-    my $ua = new LWP::UserAgent;
-    my $res = $ua->request( POST( $url,
-                                  'Content_Type' => 'form-data',
-                                  'Content' => [ 'username' => $username,
-                                                 'pass'     => $password,
-                                                 'custid'   => $username,
-                                                 'clicode'  => $clicode,
-                                                 'file1'    => [ $zipfile ],
-                                               ],
-                                )
-                          );
+    if ( $url =~ /^http/i ) {
 
-    die "upload failed: ". $res->status_line. "\n"
-      unless $res->is_success;
+      my $ua = new LWP::UserAgent;
+      my $res = $ua->request( POST( $url,
+                                    'Content_Type' => 'form-data',
+                                    'Content' => [ 'username' => $username,
+                                                   'pass'     => $password,
+                                                   'custid'   => $username,
+                                                   'clicode'  => $opt{clicode},
+                                                   'file1'    => [ $zipfile ],
+                                                 ],
+                                  )
+                            );
 
-  } elsif ( $url =~ /^ftp:\/\/([\w\.]+)(\/.*)$/i ) {
+      die "upload failed: ". $res->status_line. "\n"
+        unless $res->is_success;
 
-    my($hostname, $path) = ($1, $2);
+    } elsif ( $url =~ /^ftp:\/\/([\w\.]+)(\/.*)$/i ) {
 
-    my $ftp = new Net::FTP($hostname) #, Passive=>1 )
-      or die "can't connect to $hostname: $@\n";
-    $ftp->login($username, $password)
-      or die "can't login to $hostname: ". $ftp->message."\n";
-    unless ( $ftp->cwd($path) ) {
-      my $msg = "can't cd $path on $hostname: ". $ftp->message. "\n";
-      ( $path eq '/' ) ? warn $msg : die $msg;
-    }
-    $ftp->binary
-      or die "can't set binary mode on $hostname\n";
+      my($hostname, $path) = ($1, $2);
+
+      my $ftp = new Net::FTP($hostname, Passive=>1)
+        or die "can't connect to $hostname: $@\n";
+      $ftp->login($username, $password)
+        or die "can't login to $hostname: ". $ftp->message."\n";
+      unless ( $ftp->cwd($path) ) {
+        my $msg = "can't cd $path on $hostname: ". $ftp->message. "\n";
+        ( $path eq '/' ) ? warn $msg : die $msg;
+      }
+      $ftp->binary
+        or die "can't set binary mode on $hostname\n";
 
-    $ftp->put($zipfile)
-      or die "can't put $zipfile: ". $ftp->message. "\n";
+      $ftp->put($zipfile)
+        or die "can't put $zipfile: ". $ftp->message. "\n";
 
-    $ftp->quit;
+      $ftp->quit;
+
+    } else {
+      die "unknown scheme in URL $url\n";
+    }
 
-  } else {
-    die "unknown scheme in URL $url\n";
   }
+  else { #not billco
+
+    my $targetnum = $opt{targetnum};
+    my $ftp_target = FS::ftp_target->by_key($targetnum)
+      or die "FTP target $targetnum not found\n";
+
+    $dir .= "/target$targetnum";
+    chdir($dir);
+
+    my $file  = $agentnum ? "agentnum$agentnum" : 'spool'; #.csv
+
+    unless ( -f "$dir/$file.csv" ) {
+      warn "$me $dir/$file.csv not found\n" if $DEBUG > 1;
+      $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+      return;
+    }
+
+    rename "$dir/$file.csv", "$dir/$file-$date.csv";
+
+    if ( $opt{'handling'} eq 'bridgestone' ) {
+
+      my $prefix = $conf->config('bridgestone-prefix', $agentnum);
+      unless ( $prefix ) {
+        warn "$me agent $agentnum has no bridgestone-prefix, skipped\n";
+        $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+        return;
+      }
+
+      my $seq = $conf->config('bridgestone-batch_counter', $agentnum) || 1;
+
+      # extract zip code
+      join(' ',$conf->config('company_address', $agentnum)) =~ 
+        /(\d{5}(\-\d{4})?)\s*$/;
+      my $ourzip = $1 || ''; #could be an explicit option if really needed
+      $ourzip  =~ s/\D//;
+      my $newfile = sprintf('%s_%s_%0.6d.dat', 
+                            $prefix,
+                            time2str('%Y%m%d', time),
+                            $seq);
+      warn "copying spool to $newfile\n" if $DEBUG;
+
+      my ($in, $out);
+      open $in, '<', "$dir/$file-$date.csv" 
+        or die "unable to read $file-$date.csv\n";
+      open $out, '>', "$dir/$newfile" or die "unable to write $newfile\n";
+      #header--not sure how much of this generalizes at all
+      my $head = sprintf(
+        "%-6s%-4s%-27s%-6s%0.6d%-5s%-9s%-9s%-7s%0.8d%-7s%0.6d\n",
+        ' COMP:', 'VISP', '', ',SEQ#:', $seq, ',ZIP:', $ourzip, ',VERS:1.1',
+        ',RUNDT:', time2str('%m%d%Y', $^T),
+        ',RUNTM:', time2str('%H%M%S', $^T),
+      );
+      warn "HEADER: $head" if $DEBUG;
+      print $out $head;
+
+      my $rows = 0;
+      while( <$in> ) {
+        print $out $_;
+        $rows++;
+      }
+
+      #trailer
+      my $trail = sprintf(
+        "%-6s%-4s%-27s%-6s%0.6d%-7s%0.9d%-9s%0.9d\n",
+        ' COMP:', 'VISP', '', ',SEQ:', $seq,
+        ',LINES:', $rows+2, ',LETTERS:', $rows,
+      );
+      warn "TRAILER: $trail" if $DEBUG;
+      print $out $trail;
+
+      close $in;
+      close $out;
+
+      my $zipfile = sprintf('%s_%0.6d.zip', $prefix, $seq);
+      my $command = "cd $dir; zip $zipfile $newfile";
+      warn "compressing to $zipfile\n$command\n" if $DEBUG;
+      system($command) and die "$command failed\n";
+
+      my $connection = $ftp_target->connect; # dies on error
+      $connection->put($zipfile);
+
+      my $template = join("\n",$conf->config('bridgestone-confirm_template'));
+      if ( $template ) {
+        my $tmpl_obj = Text::Template->new(
+          TYPE => 'STRING', SOURCE => $template
+        );
+        my $content = $tmpl_obj->fill_in( HASH =>
+          {
+            zipfile => $zipfile,
+            prefix  => $prefix,
+            seq     => $seq,
+            rows    => $rows,
+          }
+        );
+        my ($head, $body) = split("\n\n", $content, 2);
+        $head =~ /^subject:\s*(.*)$/im;
+        my $subject = $1;
+
+        $head =~ /^to:\s*(.*)$/im;
+        my $to = $1;
+
+        send_email(
+          to      => $to,
+          from    => $conf->config('invoice_from', $agentnum),
+          subject => $subject,
+          body    => $body,
+        );
+      } else { #!$template
+        warn "$me agent $agentnum has no bridgestone-confirm_template, no email sent\n";
+      }
+
+      $seq++;
+      warn "setting batch counter to $seq\n" if $DEBUG;
+      $conf->set('bridgestone-batch_counter', $seq, $agentnum);
+
+    } else { # not bridgestone
+
+      # this is the usual case
+
+      my $connection = $ftp_target->connect; # dies on error
+      $connection->put("$file-$date.csv");
+
+    }
+
+  } #opt{handling}
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
index f6ad714..16c9afd 100644 (file)
@@ -122,6 +122,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::UID qw( getotaker dbh datasrc driver_name );
   use FS::Record qw( qsearch qsearchs fields dbdef
                     str2time_sql str2time_sql_closing
+                    midnight_sql
                    );
   use FS::Conf;
   use FS::CGI qw(header menubar table itable ntable idiot
@@ -303,7 +304,11 @@ if ( -e $addl_handler_use_file ) {
   use FS::discount_plan;
   use FS::tower;
   use FS::tower_sector;
+  use FS::sales;
+  use FS::access_groupsales;
   use FS::contact_class;
+  use FS::part_svc_class;
+  use FS::ftp_target;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index d8fd77a..0d21df4 100644 (file)
@@ -33,9 +33,28 @@ sub new {
 #override alter_superclass ala RT::Interface::Web::Request ??
 # for Mason 1.39 vs. Perl 5.10.0
 
+my $protect_fds;
+
 sub freeside_setup {
     my( $class, $filename, $mode ) = @_;
 
+    #from rt/bin/webmux.pl(.in)
+    if ( !$protect_fds && $ENV{'MOD_PERL'} && exists $ENV{'MOD_PERL_API_VERSION'}
+        && $ENV{'MOD_PERL_API_VERSION'} >= 2
+    ) {
+        # under mod_perl2, STDIN and STDOUT get closed and re-opened,
+        # however they are not on FD 0 and 1.  In this case, the next
+        # socket that gets opened will occupy one of these FDs, and make
+        # all system() and open "|-" calls dangerous; for example, the
+        # DBI handle can get this FD, which later system() calls will
+        # close by putting garbage into the socket.
+        $protect_fds = [];
+        push @{$protect_fds}, IO::Handle->new_from_fd(0, "r")
+            if fileno(STDIN) != 0;
+        push @{$protect_fds}, IO::Handle->new_from_fd(1, "w")
+            if fileno(STDOUT) != 1;
+    }
+
     if ( $filename =~ qr(/REST/\d+\.\d+/NoAuth/) ) {
 
       package HTML::Mason::Commands; #?
index 297e39f..2be9ec2 100644 (file)
@@ -913,6 +913,16 @@ sub ocr_image {
   @lines;
 }
 
+=item spool_formats
+  
+Returns a list of the invoice spool formats.
+
+=cut
+
+sub spool_formats {
+  qw(default oneline billco bridgestone)
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/Misc/Invoicing.pm b/FS/FS/Misc/Invoicing.pm
new file mode 100644 (file)
index 0000000..2fc52a9
--- /dev/null
@@ -0,0 +1,26 @@
+package FS::Misc::Invoicing;
+use base qw( Exporter );
+
+use vars qw( @EXPORT_OK );
+@EXPORT_OK = qw( spool_formats );
+
+=head1 NAME
+
+FS::Misc::Invoicing - Invoice subroutines
+
+=head1 SYNOPSIS
+
+use FS::Misc::Invoicing qw( spool_formats );
+
+=item spool_formats
+  
+Returns a list of the invoice spool formats.
+
+=cut
+
+sub spool_formats {
+  qw(default oneline billco bridgestone)
+}
+
+1;
+
diff --git a/FS/FS/PagedSearch.pm b/FS/FS/PagedSearch.pm
new file mode 100644 (file)
index 0000000..09d05c4
--- /dev/null
@@ -0,0 +1,189 @@
+package FS::PagedSearch;
+
+use strict;
+use vars qw($DEBUG $default_limit @EXPORT_OK);
+use base qw( Exporter );
+use FS::Record qw(qsearch dbdef);
+use Data::Dumper;
+
+$DEBUG = 0;
+$default_limit = 100;
+
+@EXPORT_OK = 'psearch';
+
+=head1 NAME
+
+FS::PagedSearch - Iterator for querying large data sets
+
+=head1 SYNOPSIS
+
+use FS::PagedSearch qw(psearch);
+
+my $search = psearch('table', { field => 'value' ... });
+$search->limit(100); #optional
+while ( my $row = $search->fetch ) {
+...
+}
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item psearch ARGUMENTS
+
+A wrapper around L<FS::Record::qsearch>.  Accepts all the same arguments 
+as qsearch, except for the arrayref union query mode, and returns an 
+FS::PagedSearch object to access the rows of the query one at a time.  
+If the query doesn't contain an ORDER BY clause already, it will be ordered
+by the table's primary key.
+
+=cut
+
+sub psearch {
+  # deep-copy qsearch args
+  my $q;
+  if ( ref($_[0]) eq 'ARRAY' ) {
+    die "union query not supported with psearch"; #yet
+  }
+  elsif ( ref($_[0]) eq 'HASH' ) {
+    %$q = %{ $_[0] };
+  }
+  else {
+    $q = {
+      'table'     => shift,
+      'hashref'   => shift,
+      'select'    => shift,
+      'extra_sql' => shift,
+      'cache_obj' => shift,
+      'addl_from' => shift,
+    };
+  }
+  warn Dumper($q) if $DEBUG > 1;
+
+  # clean up query
+  my $dbdef = dbdef->table($q->{table});
+  # qsearch just appends order_by to extra_sql, so do that ourselves
+  $q->{extra_sql} ||= '';
+  $q->{extra_sql} .= ' '.$q->{order_by} if $q->{order_by};
+  $q->{order_by} = '';
+  # and impose an ordering if needed
+  if ( not $q->{extra_sql} =~ /order by/i ) {
+    $q->{extra_sql} .= ' ORDER BY '.$dbdef->primary_key;
+  }
+  # and then we'll use order_by for LIMIT/OFFSET
+
+  my $self = {
+    query     => $q,
+    buffer    => [],
+    offset    => 0,
+    limit     => $default_limit,
+    increment => 1,
+  };
+  bless $self, 'FS::PagedSearch';
+
+  $self;
+}
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item fetch
+
+Fetch the next row from the search results and remove it from the buffer.
+Returns undef if there are no more rows.
+
+=cut
+
+sub fetch {
+  my $self = shift;
+  my $b = $self->{buffer};
+  $self->refill if @$b == 0;
+  $self->{offset} += $self->{increment} if @$b;
+  return shift @$b;
+}
+
+=item adjust ROWS
+
+Add ROWS to the offset counter.  This won't cause rows to be skipped in the
+current buffer but will affect the starting point of the next refill.
+
+=cut
+
+sub adjust {
+  my $self = shift;
+  my $r = shift;
+  $self->{offset} += $r;
+}
+
+=item limit [ VALUE ]
+
+Set/get the number of rows to retrieve per page.  The default is 100.
+
+=cut
+
+sub limit {
+  my $self = shift;
+  my $new_limit = shift;
+  if ( defined($new_limit) ) {
+    $self->{limit} = $new_limit;
+  }
+  $self->{limit};
+}
+
+=item increment [ VALUE ]
+
+Set/get the number of rows to increment the offset for each row that's
+retrieved.  Defaults to 1.  If the rows are being modified in a way that 
+removes them from the result set of the query, it's probably wise to set 
+this to zero.  Setting it to anything else is probably nonsense.
+
+=cut
+
+sub increment {
+  my $self = shift;
+  my $new_inc = shift;
+  if ( defined($new_inc) ) {
+    $self->{increment} = $new_inc;
+  }
+  $self->{increment};
+}
+
+
+=item refill
+
+Run the query, skipping a number of rows set by the row offset, and replace 
+the contents of the buffer with the result.  If there are no more rows, 
+this will just empty the buffer.  Called automatically as needed; don't call 
+this from outside.
+
+=cut
+
+sub refill {
+  my $self = shift;
+  my $b = $self->{buffer};
+  warn "refilling (limit ".$self->{limit}.", offset ".$self->{offset}.")\n"
+    if $DEBUG;
+  warn "discarding ".scalar(@$b)." rows\n" if $DEBUG and @$b;
+  if ( $self->{limit} > 0 ) {
+    $self->{query}->{order_by} = 'LIMIT ' . $self->{limit} . 
+                                 ' OFFSET ' . $self->{offset};
+  }
+  @$b = qsearch( $self->{query} );
+  my $rows = scalar @$b;
+  warn "$rows returned\n" if $DEBUG;
+
+  $rows;
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
index dfc2abf..0ac269f 100644 (file)
@@ -39,6 +39,7 @@ use Tie::IxHash;
 @EXPORT_OK = qw(
   dbh fields hfields qsearch qsearchs dbdef jsearch
   str2time_sql str2time_sql_closing regexp_sql not_regexp_sql concat_sql
+  midnight_sql
 );
 
 $DEBUG = 0;
@@ -2562,6 +2563,22 @@ sub ut_enumn {
     : '';
 }
 
+=item ut_flag COLUMN
+
+Check/untaint a column if it contains either an empty string or 'Y'.  This
+is the standard form for boolean flags in Freeside.
+
+=cut
+
+sub ut_flag {
+  my( $self, $field ) = @_;
+  my $value = uc($self->getfield($field));
+  if ( $value eq '' or $value eq 'Y' ) {
+    $self->setfield($field, $value);
+    return '';
+  }
+  return "Illegal (flag) field $field: $value";
+}
 
 =item ut_foreign_key COLUMN FOREIGN_TABLE FOREIGN_COLUMN
 
@@ -3030,7 +3047,7 @@ sub not_regexp_sql {
 
 =item concat_sql [ DRIVER_NAME ] ITEMS_ARRAYREF
 
-Returns the items concatendated based on database type, using "CONCAT()" for
+Returns the items concatenated based on database type, using "CONCAT()" for
 mysql and " || " for Pg and other databases.
 
 You can pass an optional driver name such as "Pg", "mysql" or
@@ -3051,6 +3068,24 @@ sub concat_sql {
 
 }
 
+=item midnight_sql DATE
+
+Returns an SQL expression to convert DATE (a unix timestamp) to midnight 
+on that day in the system timezone, using the default driver name.
+
+=cut
+
+sub midnight_sql {
+  my $driver = driver_name;
+  my $expr = shift;
+  if ( $driver =~ /^mysql/i ) {
+    "UNIX_TIMESTAMP(DATE(FROM_UNIXTIME($expr)))";
+  }
+  else {
+    "EXTRACT( EPOCH FROM DATE(TO_TIMESTAMP($expr)) )";
+  }
+}
+
 =back
 
 =head1 BUGS
index 3894f65..a90c73a 100644 (file)
@@ -473,6 +473,18 @@ sub tables_hashref {
       'index' => [ ['typenum'], ['disabled'], ['agent_custnum'] ],
     },
 
+    'sales' => {
+      'columns' => [
+        'salesnum',          'serial',    '',       '', '', '', 
+        'salesperson',      'varchar',    '',  $char_d, '', '', 
+        'agentnum',             'int', 'NULL',      '', '', '', 
+        'disabled',            'char', 'NULL',       1, '', '', 
+      ],
+      'primary_key' => 'salesnum',
+      'unique' => [],
+      'index' => [ ['salesnum'], ['disabled'] ],
+    },
+
     'agent_type' => {
       'columns' => [
         'typenum',   'serial',  '', '', '', '', 
@@ -845,16 +857,17 @@ sub tables_hashref {
         'stateid', 'varchar', 'NULL', $char_d, '', '', 
         'stateid_state', 'varchar', 'NULL', $char_d, '', '', 
         'birthdate' ,@date_type, '', '', 
+        'spouse_birthdate' ,@date_type, '', '', 
         'signupdate',@date_type, '', '', 
         'dundate',   @date_type, '', '', 
         'company',  'varchar', 'NULL', $char_d, '', '', 
-        'address1', 'varchar', '',     $char_d, '', '', 
+        'address1', 'varchar', 'NULL', $char_d, '', '', 
         'address2', 'varchar', 'NULL', $char_d, '', '', 
-        'city',     'varchar', '',     $char_d, '', '', 
+        'city',     'varchar', 'NULL', $char_d, '', '', 
         'county',   'varchar', 'NULL', $char_d, '', '', 
         'state',    'varchar', 'NULL', $char_d, '', '', 
         'zip',      'varchar', 'NULL', 10, '', '', 
-        'country',  'char', '',     2, '', '', 
+        'country',  'char',    'NULL',  2, '', '', 
         'latitude', 'decimal', 'NULL', '10,7', '', '', 
         'longitude','decimal', 'NULL', '10,7', '', '', 
         'coord_auto',  'char', 'NULL',  1, '', '',
@@ -883,7 +896,7 @@ sub tables_hashref {
         'payby',    'char', '',     4, '', '', 
         'payinfo',  'varchar', 'NULL', 512, '', '', 
         'paycvv',   'varchar', 'NULL', 512, '', '', 
-       'paymask', 'varchar', 'NULL', $char_d, '', '', 
+        'paymask', 'varchar', 'NULL', $char_d, '', '', 
         #'paydate',  @date_type, '', '', 
         'paydate',  'varchar', 'NULL', 10, '', '', 
         'paystart_month', 'int', 'NULL', '', '', '', 
@@ -915,6 +928,9 @@ sub tables_hashref {
         'edit_subject', 'char', 'NULL', 1, '', '',
         'locale', 'varchar', 'NULL', 16, '', '', 
         'calling_list_exempt', 'char', 'NULL', 1, '', '',
+        'invoice_noemail', 'char', 'NULL', 1, '', '',
+        'bill_locationnum', 'int', 'NULL', '', '', '',
+        'ship_locationnum', 'int', 'NULL', '', '', '',
       ],
       'primary_key' => 'custnum',
       'unique' => [ [ 'agentnum', 'agent_custid' ] ],
@@ -925,16 +941,6 @@ sub tables_hashref {
                    [ 'referral_custnum' ],
                    [ 'payby' ], [ 'paydate' ],
                    [ 'archived' ],
-                   #billing
-                   [ 'last' ], [ 'company' ],
-                   [ 'county' ], [ 'state' ], [ 'country' ],
-                   [ 'zip' ],
-                   [ 'daytime' ], [ 'night' ], [ 'fax' ], [ 'mobile' ],
-                   #shipping
-                   [ 'ship_last' ], [ 'ship_company' ],
-                   [ 'ship_county' ], [ 'ship_state' ], [ 'ship_country' ],
-                   [ 'ship_zip' ],
-                   [ 'ship_daytime' ], [ 'ship_night' ], [ 'ship_fax' ], [ 'ship_mobile' ]
                  ],
     },
 
@@ -1067,6 +1073,8 @@ sub tables_hashref {
         'country',            'char',     '',       2, '', '', 
         'geocode',         'varchar', 'NULL',      20, '', '',
         'district',        'varchar', 'NULL',      20, '', '',
+        'censustract',     'varchar', 'NULL',      20, '', '',
+        'censusyear',         'char', 'NULL',       4, '', '',
         'location_type',   'varchar', 'NULL',      20, '', '',
         'location_number', 'varchar', 'NULL',      20, '', '',
         'location_kind',      'char', 'NULL',       1, '', '',
@@ -1076,6 +1084,7 @@ sub tables_hashref {
       'unique'      => [],
       'index'       => [ [ 'prospectnum' ], [ 'custnum' ],
                          [ 'county' ], [ 'state' ], [ 'country' ], [ 'zip' ],
+                         [ 'city' ], [ 'district' ]
                        ],
     },
 
@@ -1167,9 +1176,10 @@ sub tables_hashref {
 
     'cust_main_exemption' => {
       'columns' => [
-        'exemptionnum', 'serial', '',      '', '', '',
-        'custnum',         'int', '',      '', '', '', 
-        'taxname',     'varchar', '', $char_d, '', '',
+        'exemptionnum',   'serial',     '',      '', '', '',
+        'custnum',           'int',     '',      '', '', '', 
+        'taxname',       'varchar',     '', $char_d, '', '',
+        'exempt_number', 'varchar', 'NULL', $char_d, '', '',
         #start/end dates?  for reporting?
       ],
       'primary_key' => 'exemptionnum',
@@ -1500,6 +1510,8 @@ sub tables_hashref {
         'adjourn',        @date_type,             '', '', 
         'resume',         @date_type,             '', '', 
         'cancel',         @date_type,             '', '', 
+        'uncancel',       @date_type,             '', '', 
+        'uncancel_pkgnum',     'int', 'NULL', '', '', '',
         'expire',         @date_type,             '', '', 
         'contract_end',   @date_type,             '', '',
         'dundate',        @date_type,             '', '',
@@ -1649,14 +1661,15 @@ sub tables_hashref {
 
     'cust_svc' => {
       'columns' => [
-        'svcnum',    'serial',    '',   '', '', '', 
-        'pkgnum',    'int',    'NULL',   '', '', '', 
-        'svcpart',   'int',    '',   '', '', '', 
-        'overlimit', @date_type, '', '', 
+        'svcnum',      'serial',     '', '', '', '', 
+        'pkgnum',         'int', 'NULL', '', '', '', 
+        'svcpart',        'int',     '', '', '', '', 
+        'agent_svcid',    'int', 'NULL', '', '', '',
+        'overlimit',           @date_type,   '', '', 
       ],
       'primary_key' => 'svcnum',
       'unique' => [],
-      'index' => [ ['svcnum'], ['pkgnum'], ['svcpart'] ],
+      'index' => [ ['svcnum'], ['pkgnum'], ['svcpart'], [ 'agent_svcid' ] ],
     },
 
     'cust_svc_option' => {
@@ -1833,6 +1846,7 @@ sub tables_hashref {
         'disabled',              'char', 'NULL',         1, '', '', 
         'preserve',              'char', 'NULL',         1, '', '',
         'selfservice_access', 'varchar', 'NULL',   $char_d, '', '',
+        'classnum',               'int', 'NULL',        '', '', '',
       ],
       'primary_key' => 'svcpart',
       'unique' => [],
@@ -1841,18 +1855,29 @@ sub tables_hashref {
 
     'part_svc_column' => {
       'columns' => [
-        'columnnum',   'serial',         '', '', '', '', 
-        'svcpart',     'int',         '', '', '', '', 
-        'columnname',  'varchar',     '', 64, '', '', 
+        'columnnum',   'serial',      '',      '', '', '', 
+        'svcpart',     'int',         '',      '', '', '', 
+        'columnname',  'varchar',     '',      64, '', '', 
         'columnlabel', 'varchar', 'NULL', $char_d, '', '',
-        'columnvalue', 'varchar', 'NULL', $char_d, '', '', 
-        'columnflag',  'char',    'NULL', 1, '', '', 
+        'columnvalue', 'varchar', 'NULL',     512, '', '', 
+        'columnflag',  'char',    'NULL',       1, '', '', 
       ],
       'primary_key' => 'columnnum',
       'unique' => [ [ 'svcpart', 'columnname' ] ],
       'index' => [ [ 'svcpart' ] ],
     },
 
+    'part_svc_class' => {
+      'columns' => [
+        'classnum',    'serial',   '',      '', '', '', 
+        'classname',   'varchar',  '', $char_d, '', '', 
+        'disabled',    'char', 'NULL',       1, '', '', 
+      ],
+      'primary_key' => 'classnum',
+      'unique' => [],
+      'index' => [ ['disabled'] ],
+    },
+
     #(this should be renamed to part_pop)
     'svc_acct_pop' => {
       'columns' => [
@@ -2556,7 +2581,7 @@ sub tables_hashref {
         'plan_id',             'varchar', 'NULL', $char_d, '', '',
       ],
       'primary_key' => 'svcnum',
-      'unique'      => [ [ 'mac_addr' ] ],
+      'unique'      => [ [ 'ip_addr' ], [ 'mac_addr' ] ],
       'index'       => [],
     },
 
@@ -2994,7 +3019,6 @@ sub tables_hashref {
         ###
 
         'upstream_currency',      'char', 'NULL',       3, '', '',
-        'upstream_price',      'decimal', 'NULL',  '10,4', '', '', 
         'upstream_rateplanid',     'int', 'NULL',      '', '', '', #?
 
         # how it was rated internally...
@@ -3019,6 +3043,10 @@ sub tables_hashref {
 
         'charged_party',       'varchar', 'NULL', $char_d, '', '',
 
+        'upstream_price',      'decimal', 'NULL',  '10,4', '', '', 
+        'upstream_src_regionname', 'varchar', 'NULL', $char_d, '', '',
+        'upstream_dst_regionname', 'varchar', 'NULL', $char_d, '', '',
+
         # how it was rated internally...
         'rated_pretty_dst',       'varchar', 'NULL', $char_d, '', '',
         'rated_regionname',       'varchar', 'NULL', $char_d, '', '',
@@ -3154,11 +3182,12 @@ sub tables_hashref {
 
     'inventory_item' => {
       'columns' => [
-        'itemnum',  'serial',      '',      '', '', '',
-        'classnum', 'int',         '',      '', '', '',
-        'agentnum', 'int',     'NULL',      '', '', '',
-        'item',     'varchar',     '', $char_d, '', '',
-        'svcnum',   'int',     'NULL',      '', '', '',
+        'itemnum',   'serial',      '',      '', '', '',
+        'classnum',  'int',         '',      '', '', '',
+        'agentnum',  'int',     'NULL',      '', '', '',
+        'item',      'varchar',     '', $char_d, '', '',
+        'svcnum',    'int',     'NULL',      '', '', '',
+        'svc_field', 'varchar', 'NULL', $char_d, '', '',
       ],
       'primary_key' => 'itemnum',
       'unique' => [ [ 'classnum', 'item' ] ],
@@ -3235,6 +3264,17 @@ sub tables_hashref {
       'index'  => [ [ 'groupnum' ] ],
     },
 
+    'access_groupsales' => {
+      'columns' => [
+        'groupsalesnum', 'serial', '', '', '', '',
+        'groupnum',         'int', '', '', '', '',
+        'salesnum',         'int', '', '', '', '',
+      ],
+      'primary_key' => 'groupsalesnum',
+      'unique' => [ [ 'groupnum', 'salesnum' ] ],
+      'index'  => [ [ 'groupnum' ] ],
+    },
+
     'access_right' => {
       'columns' => [
         'rightnum',   'serial', '',      '', '', '',
@@ -3640,6 +3680,23 @@ sub tables_hashref {
       'index' => [ [ 'upgrade' ] ],
     },
 
+    'ftp_target' => {
+      'columns' => [
+        'targetnum', 'serial', '', '', '', '',
+        'agentnum', 'int', 'NULL', '', '', '',
+        'hostname', 'varchar', '', $char_d, '', '',
+        'port', 'int', '', '', '', '',
+        'username', 'varchar', '', $char_d, '', '',
+        'password', 'varchar', '', $char_d, '', '',
+        'path', 'varchar', '', $char_d, '', '',
+        'secure', 'char', 'NULL', 1, '', '',
+        'handling', 'varchar', 'NULL', $char_d, '', '',
+      ],
+      'primary_key' => 'targetnum',
+      'unique' => [ [ 'targetnum' ] ],
+      'index' => [],
+    },
+
     %{ tables_hashref_torrus() },
 
     # tables of ours for doing torrus virtual port combining
index e2c5a5a..e27b66f 100644 (file)
@@ -209,6 +209,14 @@ sub populate_initial_data {
 sub initial_data {
   my %opt = @_;
 
+  my $cust_location = FS::cust_location->new({
+      'address1'  => '1234 System Lane',
+      'city'      => 'Systemtown',
+      'state'     => 'CA',
+      'zip'       => '54321',
+      'country'   => 'US',
+  });
+
   #tie my %hash, 'Tie::DxHash', 
   tie my %hash, 'Tie::IxHash', 
 
@@ -351,14 +359,11 @@ sub initial_data {
         'refnum'    => 1, #XXX
         'first'     => 'System',
         'last'      => 'Accounts',
-        'address1'  => '1234 System Lane',
-        'city'      => 'Systemtown',
-        'state'     => 'CA',
-        'zip'       => '54321',
-        'country'   => 'US',
         'payby'     => 'COMP',
         'payinfo'   => 'system', #or something
         'paydate'   => '1/2037',
+        'bill_location' => $cust_location,
+        'ship_location' => $cust_location,
       },
     ],
 
index 36dd30c..2c42a6b 100644 (file)
@@ -29,7 +29,7 @@ sub small_custview {
                   : qsearchs('cust_main', { 'custnum' => $arg } )
     or die "unknown custnum $arg";
 
-  my $html;
+  my $html = '<DIV ID="fs_small_custview">';
   
   $html = qq!View <A HREF="$url?! . $cust_main->custnum . '">'
     if $url;
@@ -82,45 +82,23 @@ sub small_custview {
 
   $html .= '</TD></TR></TABLE></TD>';
 
-  if ( defined $cust_main->dbdef_table->column('ship_last') ) {
-
-    my $pre = $cust_main->ship_last ? 'ship_' : '';
-
-    $html .= '<TD VALIGN="top">'. ntable("#cccccc",2).
-      '<TR><TD ALIGN="right" VALIGN="top">Service<BR>Address</TD><TD BGCOLOR="#ffffff">'.
-      $cust_main->get("${pre}last"). ', '.
-      $cust_main->get("${pre}first"). '<BR>';
-    $html .= $cust_main->get("${pre}company"). '<BR>'
-      if $cust_main->get("${pre}company");
-    $html .= $cust_main->get("${pre}address1"). '<BR>';
-    $html .= $cust_main->get("${pre}address2"). '<BR>'
-      if $cust_main->get("${pre}address2");
-    $html .= $cust_main->get("${pre}city"). ', '.
-             $cust_main->get("${pre}state"). '  '.
-             $cust_main->get("${pre}zip"). '<BR>';
-    $html .= $cust_main->get("${pre}country"). '<BR>'
-      if $cust_main->get("${pre}country")
-         && $cust_main->get("${pre}country") ne $countrydefault;
-
-    $html .= '</TD></TR><TR><TD></TD><TD BGCOLOR="#ffffff">';
-
-    if ( $cust_main->get("${pre}daytime") && $cust_main->get("${pre}night") ) {
-      use FS::Msgcat;
-      $html .= ( FS::Msgcat::_gettext('daytime') || 'Day' ).
-               ' '. $cust_main->get("${pre}daytime").
-               '<BR>'. ( FS::Msgcat::_gettext('night') || 'Night' ).
-               ' '. $cust_main->get("${pre}night");
-    } elsif ( $cust_main->get("${pre}daytime")
-              || $cust_main->get("${pre}night") ) {
-      $html .= $cust_main->get("${pre}daytime")
-               || $cust_main->get("${pre}night");
-    }
-    if ( $cust_main->get("${pre}fax") ) {
-      $html .= '<BR>Fax '. $cust_main->get("${pre}fax");
-    }
+  my $ship = $cust_main->ship_location;
 
-    $html .= '</TD></TR></TABLE></TD>';
-  }
+  $html .= '<TD VALIGN="top">'. ntable("#cccccc",2).
+    '<TR><TD ALIGN="right" VALIGN="top">Service<BR>Address</TD><TD BGCOLOR="#ffffff">';
+  $html .= join('<BR>', 
+    grep $_,
+      $cust_main->contact,
+      $cust_main->company,
+      $ship->address1,
+      $ship->address2,
+      ($ship->city . ', ' . $ship->state . '  ' . $ship->zip),
+      ($ship->country eq $countrydefault ? '' : $ship->country ),
+  );
+
+  # ship phone numbers no longer exist...
+
+  $html .= '</TD></TR></TABLE></TD>';
 
   $html .= '</TR></TABLE>';
 
@@ -129,6 +107,8 @@ sub small_custview {
 
   # last payment might be good here too?
 
+  $html .= '</DIV>';
+
   $html;
 }
 
diff --git a/FS/FS/access_groupsales.pm b/FS/FS/access_groupsales.pm
new file mode 100644 (file)
index 0000000..31b07d9
--- /dev/null
@@ -0,0 +1,153 @@
+package FS::access_groupsales;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::access_groupsales - Object methods for access_groupsales records
+
+=head1 SYNOPSIS
+
+  use FS::access_groupsales;
+
+  $record = new FS::access_groupsales \%hash;
+  $record = new FS::access_groupsales { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::access_groupsales object represents an example.  FS::access_groupsales inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item groupsalesnum
+
+primary key
+
+=item groupnum
+
+groupnum
+
+=item salesnum
+
+salesnum
+
+
+=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
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'access_groupsales'; }
+
+=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 example.  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_numbern('groupsalesnum')
+    || $self->ut_number('groupnum')
+    || $self->ut_number('salesnum')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=item sales
+
+Returns the associated FS::agent object.
+
+=cut
+
+sub sales {
+  my $self = shift;
+  qsearchs('sales', { 'salesnum' => $self->salesnum } );
+}
+
+=item access_group
+
+Returns the associated FS::access_group object.
+
+=cut
+
+sub access_group {
+  my $self = shift;
+  qsearchs('access_group', { 'groupnum' => $self->groupnum } );
+}
+
+=back
+
+
+=head1 BUGS
+
+The author forgot to customize this manpage.
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 341055b..1e65ca3 100644 (file)
@@ -2,7 +2,9 @@ package FS::access_right;
 
 use strict;
 use vars qw( @ISA );
+use Tie::IxHash;
 use FS::Record qw( qsearch qsearchs );
+use FS::upgrade_journal;
 
 @ISA = qw(FS::Record);
 
@@ -182,19 +184,56 @@ sub _upgrade_data { # class method
 
   my @all_groups = qsearch('access_group', {});
 
-  my %onetime = (
-    'List customers' => 'List all customers',
-    'List packages'  => 'Summarize packages',
-  );
+  tie my %onetime, 'Tie::IxHash',
+    'List customers'                      => 'List all customers',
+    'List all customers'                  => 'Advanced customer search',
+    'List packages'                       => 'Summarize packages',
+    'Post payment'                        => 'Backdate payment',
+    'Cancel customer package immediately' => 'Un-cancel customer package',
+    'Suspend customer package'            => 'Suspend customer',
+    'Unsuspend customer package'          => 'Unsuspend customer',
+
+    'List services'    => [ 'Services: Accounts',
+                            'Services: Domains',
+                            'Services: Certificates',
+                            'Services: Mail forwards',
+                            'Services: Virtual hosting services',
+                            'Services: Wireless broadband services',
+                            'Services: DSLs',
+                            'Services: Dish services',
+                            'Services: Hardware',
+                            'Services: Phone numbers',
+                            'Services: PBXs',
+                            'Services: Ports',
+                            'Services: Mailing lists',
+                            'Services: External services',
+                          ],
+
+    'Services: Accounts' => 'Services: Accounts: Advanced search',
+    'Services: Wireless broadband services' => 'Services: Wireless broadband services: Advanced search',
+    'Services: Hardware' => 'Services: Hardware: Advanced search',
+
+    'List rating data' => [ 'Usage: RADIUS sessions',
+                            'Usage: Call Detail Records (CDRs)',
+                            'Usage: Unrateable CDRs',
+                          ],
+  ;
 
   foreach my $old_acl ( keys %onetime ) {
-    my $new_acl = $onetime{$old_acl}; #support arrayref too?
-    ( my $journal = 'ACL_'.lc($new_acl) ) =~ s/ /_/g;
-    next if FS::upgrade_journal->is_done($journal);
 
-    # grant $new_acl to all groups who have $old_acl
-    for my $group (@all_groups) {
-      if ( $group->access_right($old_acl) ) {
+    my @new_acl = ref($onetime{$old_acl})
+                    ? @{ $onetime{$old_acl} }
+                    :  ( $onetime{$old_acl} );
+
+    foreach my $new_acl ( @new_acl ) {
+
+      ( my $journal = 'ACL_'.lc($new_acl) ) =~ s/\W/_/g;
+      next if FS::upgrade_journal->is_done($journal);
+
+      # grant $new_acl to all groups who have $old_acl
+      for my $group (@all_groups) {
+        next unless $group->access_right($old_acl);
+        next if     $group->access_right($new_acl);
         my $access_right = FS::access_right->new( {
             'righttype'   => 'FS::access_group',
             'rightobjnum' => $group->groupnum,
@@ -203,9 +242,11 @@ sub _upgrade_data { # class method
         my $error = $access_right->insert;
         die $error if $error;
       }
-    }
     
-    FS::upgrade_journal->set_done($journal);
+      FS::upgrade_journal->set_done($journal);
+
+    }
+
   }
 
   ### ACL_download_report_data
index 070f3fb..ca44c0f 100644 (file)
@@ -20,11 +20,12 @@ use FS::cdr qw(_cdr_date_parser_maker);
     skip(2),          # Conference Start Time, Conference End Time
     _cdr_date_parser_maker('startdate'),  # Connect Time
     _cdr_date_parser_maker('enddate'),    # Disconnect Time
+    skip(1),          # Duration
     sub { my($cdr, $data, $conf, $param) = @_;
           $cdr->duration($data);
           $cdr->billsec( $data);
-    },                # Duration
-    skip(2),          # Roundup Duration, User Name
+    },                # Roundup Duration
+    skip(1),          # User Name
     'dst',            # DNIS
     'src',            # ANI
     skip(2),          # Call Type, Toll Free, 
index 90560c8..02ff9df 100644 (file)
@@ -6,6 +6,8 @@ use FS::cdr qw(_cdr_date_parser_maker);
 
 @ISA = qw(FS::cdr);
 
+my $date_parser = _cdr_date_parser_maker('startdate');
+
 %info = (
   'name'          => 'Infinite Conferencing',
   'weight'        => 520,
@@ -13,26 +15,38 @@ use FS::cdr qw(_cdr_date_parser_maker);
   'type'          => 'csv',
   'sep_char'      => ',',
   'import_fields' => [
-    'uniqueid',       # billid
-    skip(3),          # confid, invoicenum, acctgrpid
-    'accountcode',    # accountid ("Room Confirmation Number")
-    skip(2),          # billingcode ("Room Billingcode"), confname
-    skip(1),          # participant_type
-    'startdate',      # starttime_t
-    skip(2),          # startdate, starttime
+    'uniqueid',       # A. billid
+    skip(3),          # B-D. confid, invoicenum, acctgrpid
+    skip(1),          # E. accountid ("Room Confirmation Number")
+    skip(2),          # F-G. billingcode ("Room Billingcode"), confname
+    skip(1),          # H. participant_type
+    skip(1),          # I. starttime_t - timezone is unreliable
+    sub {             # J. startdate
+      my ($cdr, $data, $conf, $param) = @_;
+      $param->{'date_part'} = $data; # stash this and combine with the time
+      '';
+    },
+    sub {             # K. starttime
+      my ($cdr, $data, $conf, $param) = @_;
+      my $datestring = delete($param->{'date_part'}) . ' ' . $data;
+      &{ $date_parser }($cdr, $datestring);
+    },
     sub { my($cdr, $data, $conf, $param) = @_;
           $cdr->duration($data * 60);
           $cdr->billsec( $data * 60);
-    },                # minutes
-    'dst',            # dnis
-    'src',            # ani
-    skip(8),          # calltype, calltype_text, confstart_t, confstartdate,
+    },                # L. minutes
+    skip(1),          # M. dnis
+    'src',            # N. ani
+    'dst',            # O. calltype
+    skip(7),          # P-V. calltype_text, confstart_t, confstartdate, 
                       # confstarttime, confminutes, conflegs, ppm
-    'upstream_price', # callcost
-    skip(13),         # confcost, rppm, rcallcost, rconfcost,
-                      # auxdata[1..4], ldval, sysname, username, cec, pec
-    'userfield',      # unnamed field
-    ],
+    'upstream_price', # W. callcost
+    skip(11),         # X-AH. confcost, rppm, rcallcost, rconfcost,
+                      # auxdata[1..4], ldval, sysname, username
+    'accountcode',    # AI. Chairperson Entry Code
+    skip(1),          # AJ. Participant Entry Code
+    'description',    # AK. contact name
+  ],
 
 );
 
index 020af2b..429c25a 100644 (file)
@@ -7,7 +7,7 @@ use Time::Local;
 #use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
 
 %info = (
-  'name'          => 'Troop',
+  'name'          => 'Troop (old?)',
   'weight'        => 220,
   'header'        => 2,
   'type'          => 'xls',
diff --git a/FS/FS/cdr/troop2.pm b/FS/FS/cdr/troop2.pm
new file mode 100644 (file)
index 0000000..ee64740
--- /dev/null
@@ -0,0 +1,94 @@
+package FS::cdr::troop2;
+
+use strict;
+use base qw( FS::cdr );
+use vars qw( %info $tmp_date $tmp_src_city $tmp_dst_city );
+use Date::Parse;
+#use Time::Local;
+##use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
+
+%info = (
+  'name' => 'Troop',
+  'weight' => 219,
+  'header' => 1,
+  'type'   => 'xls',
+
+  'import_fields' => [
+
+    'userfield', #account_num  (userfield?)
+
+    #call_date
+    sub { my($cdr, $date) = @_;
+          #is this an excel date?  or just text?
+          $tmp_date = $date;
+        },
+
+    #call_time
+    sub { my($cdr, $time) = @_;
+          #is this an excel time?  or just text?
+          $cdr->startdate( str2time("$tmp_date $time") );
+        },
+
+    'src', #orig_tn
+    'dst', #term_tn
+
+     #call_dur
+    sub { my($cdr, $duration) = @_;
+          $cdr->duration($duration);
+          $cdr->billsec($duration);
+        },
+
+    'clid', #auth_code_ani (clid?)
+
+    'accountcode', #account_code
+
+    #ovs_type
+    # OVS Type / Maybe / add "011" to international calls
+    # N = DOM LD / normal
+    # Z = INTL LD
+    # O = INTL LD
+    # others...?
+    sub { my($cdr, $ovs) = @_;
+          my $pre = ( $ovs =~ /^\s*[OZ]\s*$/i ) ? '011' : '1';
+          $cdr->dst( $pre. $cdr->dst ) unless $cdr->dst =~ /^$pre/;
+        },
+
+    #orig_city
+    sub { (my $cdr, $tmp_src_city) = @_; },
+
+    #orig_prov_state
+    sub { my($cdr, $state) = @_;
+          $cdr->upstream_src_regionname("$tmp_src_city, $state");
+        },
+
+    #term_city
+    sub { (my $cdr, $tmp_dst_city) = @_; },
+
+    #term_prov_state
+    sub { my($cdr, $state) = @_;
+          $cdr->upstream_dst_regionname("$tmp_dst_city, $state");
+        },
+
+    #term_ovs
+    '', #CANADA / UNITED STATES / BELL.  huh.  country or terminating provider?
+
+    '', #cc_ind (what's this?)
+
+    'upstream_price', #call_charge
+
+    #important?
+    '', #creation_date
+    '', #creation_time
+
+    #additional upstream pricing details we don't need?
+    '', #net_charge
+    '', #surcharge
+    '', #gst
+    '', #pst
+    '', #hst
+
+  ],
+
+);
+
+1;
index a76170a..35ab9f3 100644 (file)
@@ -388,8 +388,10 @@ sub previous {
   my $self = shift;
   my $total = 0;
   my @cust_bill = sort { $a->_date <=> $b->_date }
-    grep { $_->owed != 0 && $_->_date < $self->_date }
-      qsearch( 'cust_bill', { 'custnum' => $self->custnum } ) 
+    grep { $_->owed != 0 }
+      qsearch( 'cust_bill', { 'custnum' => $self->custnum,
+                              '_date'   => { op=>'<', value=>$self->_date },
+                            } ) 
   ;
   foreach ( @cust_bill ) { $total += $_->owed; }
   $total, @cust_bill;
@@ -1314,14 +1316,16 @@ sub send {
     $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
   }
 
+  my $cust_main = $self->cust_main;
+
   return 'N/A' unless ! $agentnums
-                   or grep { $_ == $self->cust_main->agentnum } @$agentnums;
+                   or grep { $_ == $cust_main->agentnum } @$agentnums;
 
   return ''
-    unless $self->cust_main->total_owed_date($self->_date) > $balance_over;
+    unless $cust_main->total_owed_date($self->_date) > $balance_over;
 
   $invoice_from ||= $self->_agent_invoice_from ||    #XXX should go away
-                    $conf->config('invoice_from', $self->cust_main->agentnum );
+                    $conf->config('invoice_from', $cust_main->agentnum );
 
   my %opt = (
     'template'     => $template,
@@ -1329,11 +1333,12 @@ sub send {
     'notice_name'  => ( $notice_name || 'Invoice' ),
   );
 
-  my @invoicing_list = $self->cust_main->invoicing_list;
+  my @invoicing_list = $cust_main->invoicing_list;
 
   #$self->email_invoice(\%opt)
   $self->email(\%opt)
-    if grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list;
+    if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
+    && ! $self->invoice_noemail;
 
   #$self->print_invoice(\%opt)
   $self->print(\%opt)
@@ -1748,13 +1753,21 @@ Options are:
 
 =over 4
 
-=item format - 'default' or 'billco'
+=item format - any of FS::Misc::::Invoicing::spool_formats
 
-=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the customer has the corresponding invoice destinations set (see L<FS::cust_main_invoice>).
+=item dest - if set (to POST, EMAIL or FAX), only sends spools invoices if the
+customer has the corresponding invoice destinations set (see
+L<FS::cust_main_invoice>).
 
-=item agent_spools - if set to a true value, will spool to per-agent files rather than a single global file
+=item agent_spools - if set to a true value, will spool to per-agent files
+rather than a single global file
 
-=item balanceover - if set, only spools the invoice if the total amount owed on this invoice and all older invoices is greater than the specified amount.
+=item ftp_targetnum - if set to an FTP target (see L<FS::ftp_target>), will
+append to that spool.  L<FS::Cron::upload> will then send the spool file to
+that destination.
+
+=item balanceover - if set, only spools the invoice if the total amount owed on
+this invoice and all older invoices is greater than the specified amount.
 
 =back
 
@@ -1782,11 +1795,23 @@ sub spool_csv {
 
   my $tracctnum = $self->invnum. time2str('-%Y%m%d%H%M%S', time);
 
-  my $file =
-    "$spooldir/".
-    ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
-    ( lc($opt{'format'}) eq 'billco' ? '-header' : '' ) .
-    '.csv';
+  my $file;
+  if ( $opt{'agent_spools'} ) {
+    $file = 'agentnum'.$cust_main->agentnum;
+  } else {
+    $file = 'spool';
+  }
+
+  if ( $opt{'ftp_targetnum'} ) {
+    $spooldir .= '/target'.$opt{'ftp_targetnum'};
+    mkdir $spooldir, 0700 unless -d $spooldir;
+  } # otherwise it just goes into export.xxx/cust_bill
+
+  if ( lc($opt{'format'}) eq 'billco' ) {
+    $file .= '-header';
+  }
+
+  $file = "$spooldir/$file.csv";
   
   my ( $header, $detail ) = $self->print_csv(%opt, 'tracctnum' => $tracctnum );
 
@@ -1801,10 +1826,7 @@ sub spool_csv {
     flock(CSV, LOCK_UN);
     close CSV;
 
-    $file =
-      "$spooldir/".
-      ( $opt{'agent_spools'} ? 'agentnum'.$cust_main->agentnum : 'spool' ).
-      '-detail.csv';
+    $file =~ s/-header.csv$/-detail.csv/;
 
     open(CSV,">>$file") or die "can't open $file: $!";
     flock(CSV, LOCK_EX);
@@ -1826,7 +1848,7 @@ Returns CSV data for this invoice.
 
 Options are:
 
-format - 'default' or 'billco'
+format - 'default', 'billco', 'oneline', 'bridgestone'
 
 Returns a list consisting of two scalars.  The first is a single line of CSV
 header information for this invoice.  The second is one or more lines of CSV
@@ -1835,7 +1857,8 @@ detail information for this invoice.
 If I<format> is not specified or "default", the fields of the CSV file are as
 follows:
 
-record_type, invnum, custnum, _date, charged, first, last, company, address1, address2, city, state, zip, country, pkg, setup, recur, sdate, edate
+record_type, invnum, custnum, _date, charged, first, last, company, address1, 
+address2, city, state, zip, country, pkg, setup, recur, sdate, edate
 
 =over 4
 
@@ -1940,6 +1963,26 @@ If I<format> is "billco", the fields of the detail CSV file are as follows:
   9     | Grouping Code              | GROUP     | CHAR |     2
   10    | User Defined               | ACCT CODE | CHAR |    15
 
+If format is 'oneline', there is no detail file.  Each invoice has a 
+header line only, with the fields:
+
+Agent number, agent name, customer number, first name, last name, address
+line 1, address line 2, city, state, zip, invoice date, invoice number,
+amount charged, amount due,
+
+and then, for each line item, three columns containing the package number,
+description, and amount.
+
+If format is 'bridgestone', there is no detail file.  Each invoice has a 
+header line with the following fields in a fixed-width format:
+
+Customer number (in display format), date, name (first last), company,
+address 1, address 2, city, state, zip.
+
+This is a mailing list format, and has no per-invoice fields.  To avoid
+sending redundant notices, the spooling event should have a "once" or 
+"once_percust_every" condition.
+
 =cut
 
 sub print_csv {
@@ -2036,6 +2079,31 @@ sub print_csv {
       @items,
     );
 
+  } elsif ( lc($opt{'format'}) eq 'bridgestone' ) {
+
+    # bypass the CSV stuff and just return this
+    my $longdate = time2str('%B %d, %Y', time); #current time, right?
+    my $zip = $cust_main->zip;
+    $zip =~ s/\D//;
+    my $prefix = $self->conf->config('bridgestone-prefix', $cust_main->agentnum)
+      || '';
+    return (
+      sprintf(
+        "%-5s%-15s%-20s%-30s%-30s%-30s%-30s%-20s%-2s%-9s\n",
+        $prefix,
+        $cust_main->display_custnum,
+        $longdate,
+        uc(substr($cust_main->contact_firstlast,0,30)),
+        uc(substr($cust_main->company          ,0,30)),
+        uc(substr($cust_main->address1         ,0,30)),
+        uc(substr($cust_main->address2         ,0,30)),
+        uc(substr($cust_main->city             ,0,20)),
+        uc($cust_main->state),
+        $zip
+      ),
+      '' #detail
+      );
+
   } else {
   
     $csv->combine(
@@ -2777,11 +2845,13 @@ sub print_generic {
   $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
 
   my $countrydefault = $conf->config('countrydefault') || 'US';
-  my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
-  foreach ( qw( contact company address1 address2 city state zip country fax) ){
-    my $method = $prefix.$_;
+  foreach ( qw( address1 address2 city state zip country fax) ){
+    my $method = 'ship_'.$_;
     $invoice_data{"ship_$_"} = _latex_escape($cust_main->$method);
   }
+  foreach ( qw( contact company ) ) { #compatibility
+    $invoice_data{"ship_$_"} = _latex_escape($cust_main->$_);
+  }
   $invoice_data{'ship_country'} = ''
     if ( $invoice_data{'ship_country'} eq $countrydefault );
   
@@ -2978,6 +3048,12 @@ sub print_generic {
   my $late_sections = [];
   my $extra_sections = [];
   my $extra_lines = ();
+
+  my $default_section = { 'description' => '',
+                          'subtotal'    => '', 
+                          'no_subtotal' => 1,
+                        };
+
   if ( $multisection ) {
     ($extra_sections, $extra_lines) =
       $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
@@ -3009,8 +3085,7 @@ sub print_generic {
     }
   } else {# not multisection
     # make a default section
-    push @sections, { 'description' => '', 'subtotal' => '', 
-      'no_subtotal' => 1 };
+    push @sections, $default_section;
     # and calculate the finance charge total, since it won't get done otherwise.
     # XXX possibly other totals?
     # XXX possibly finance_pkgclass should not be used in this manner?
@@ -3043,7 +3118,8 @@ sub print_generic {
       };
       $detail->{'ref'} = $line_item->{'pkgnum'};
       $detail->{'quantity'} = 1;
-      $detail->{'section'} = $previous_section;
+      $detail->{'section'} = $multisection ? $previous_section
+                                           : $default_section;
       $detail->{'description'} = &$escape_function($line_item->{'description'});
       if ( exists $line_item->{'ext_description'} ) {
         @{$detail->{'ext_description'}} = map {
@@ -3882,17 +3958,20 @@ sub _items_sections {
         if ( $display->post_total && !$summarypage ) {
           if (! $type || $type eq 'S') {
             $late_subtotal{$section} += $cust_bill_pkg->setup
-              if $cust_bill_pkg->setup != 0;
+              if $cust_bill_pkg->setup != 0
+              || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
             $late_subtotal{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
             $late_subtotal{$section} += $cust_bill_pkg->recur - $usage
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
           
           if ($type && $type eq 'U') {
@@ -3906,17 +3985,20 @@ sub _items_sections {
 
           if (! $type || $type eq 'S') {
             $subtotal{$section} += $cust_bill_pkg->setup
-              if $cust_bill_pkg->setup != 0;
+              if $cust_bill_pkg->setup != 0
+              || $cust_bill_pkg->setup_show_zero;
           }
 
           if (! $type) {
             $subtotal{$section} += $cust_bill_pkg->recur
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
 
           if ($type && $type eq 'R') {
             $subtotal{$section} += $cust_bill_pkg->recur - $usage
-              if $cust_bill_pkg->recur != 0;
+              if $cust_bill_pkg->recur != 0
+              || $cust_bill_pkg->recur_show_zero;
           }
           
           if ($type && $type eq 'U') {
@@ -4909,6 +4991,8 @@ sub _items_cust_bill_pkg {
       }
     }
 
+    my @cust_bill_pkg_display = $cust_bill_pkg->cust_bill_pkg_display;
+
     warn "$me _items_cust_bill_pkg considering cust_bill_pkg ".
          $cust_bill_pkg->billpkgnum. ", pkgnum ". $cust_bill_pkg->pkgnum. "\n"
       if $DEBUG > 1;
@@ -4919,7 +5003,7 @@ sub _items_cust_bill_pkg {
                                }
                           #grep { !$_->summary || !$summary_page } # bunk!
                           grep { !$_->summary || $multisection }
-                          $cust_bill_pkg->cust_bill_pkg_display
+                          @cust_bill_pkg_display
                         )
     {
 
@@ -5421,6 +5505,7 @@ sub process_re_X {
 }
 
 sub re_X {
+  # spool_invoice ftp_invoice fax_invoice print_invoice
   my($method, $job, %param ) = @_;
   if ( $DEBUG ) {
     warn "re_X $method for job $job with param:\n".
index 1ee5c09..4220d3c 100644 (file)
@@ -955,8 +955,6 @@ sub cust_bill_pkg_display {
   my $default =
     new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
 
-  return ( $default ) unless defined dbdef->table('cust_bill_pkg_display');#hmmm
-
   my $type = $opt{type} if exists $opt{type};
   my @result;
 
@@ -1043,26 +1041,125 @@ sub cust_bill_pkg_discount {
 
 =cut
 
-sub recur_show_zero {
-  #my $self = shift;
-  #   $self->recur == 0
-  #&& $self->pkgnum
-  #&& $self->cust_pkg->part_pkg->recur_show_zero;
+sub recur_show_zero { shift->_X_show_zero('recur'); }
+sub setup_show_zero { shift->_X_show_zero('setup'); }
+
+sub _X_show_zero {
+  my( $self, $what ) = @_;
 
-  shift->_X_show_zero('recur');
+  return 0 unless $self->$what() == 0 && $self->pkgnum;
 
+  $self->cust_pkg->_X_show_zero($what);
 }
 
-sub setup_show_zero {
-  shift->_X_show_zero('setup');
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item usage_sql
+
+Returns an SQL expression for the total usage charges in details on
+an item.
+
+=cut
+
+my $usage_sql =
+  '(SELECT COALESCE(SUM(cust_bill_pkg_detail.amount),0) 
+    FROM cust_bill_pkg_detail 
+    WHERE cust_bill_pkg_detail.billpkgnum = cust_bill_pkg.billpkgnum)';
+
+sub usage_sql { $usage_sql }
+
+# this makes owed_sql, etc. much more concise
+sub charged_sql {
+  my ($class, $start, $end, %opt) = @_;
+  my $charged = 
+    $opt{setuprecur} =~ /^s/ ? 'cust_bill_pkg.setup' :
+    $opt{setuprecur} =~ /^r/ ? 'cust_bill_pkg.recur' :
+    'cust_bill_pkg.setup + cust_bill_pkg.recur';
+
+  if ($opt{no_usage} and $charged =~ /recur/) { 
+    $charged = "$charged - $usage_sql"
+  }
+
+  $charged;
 }
 
-sub _X_show_zero {
-  my( $self, $what ) = @_;
 
-  return 0 unless $self->$what() == 0 && $self->pkgnum;
+=item owed_sql [ BEFORE, AFTER, OPTIONS ]
+
+Returns an SQL expression for the amount owed.  BEFORE and AFTER specify
+a date window.  OPTIONS may include 'no_usage' (excludes usage charges)
+and 'setuprecur' (set to "setup" or "recur" to limit to one or the other).
+
+=cut
+
+sub owed_sql {
+  my $class = shift;
+  '(' . $class->charged_sql(@_) . 
+  ' - ' . $class->paid_sql(@_) .
+  ' - ' . $class->credited_sql(@_) . ')'
+}
+
+=item paid_sql [ BEFORE, AFTER, OPTIONS ]
+
+Returns an SQL expression for the sum of payments applied to this item.
+
+=cut
+
+sub paid_sql {
+  my ($class, $start, $end, %opt) = @_;
+  my $s = $start ? "AND cust_bill_pay._date <= $start" : '';
+  my $e = $end   ? "AND cust_bill_pay._date >  $end"   : '';
+  my $setuprecur = 
+    $opt{setuprecur} =~ /^s/ ? 'setup' :
+    $opt{setuprecur} =~ /^r/ ? 'recur' :
+    '';
+  $setuprecur &&= "AND setuprecur = '$setuprecur'";
+
+  my $paid = "( SELECT COALESCE(SUM(cust_bill_pay_pkg.amount),0)
+     FROM cust_bill_pay_pkg JOIN cust_bill_pay USING (billpaynum)
+     WHERE cust_bill_pay_pkg.billpkgnum = cust_bill_pkg.billpkgnum
+           $s $e$setuprecur )";
+
+  if ( $opt{no_usage} ) {
+    # cap the amount paid at the sum of non-usage charges, 
+    # minus the amount credited against non-usage charges
+    "LEAST($paid, ". 
+      $class->charged_sql($start, $end, %opt) . ' - ' .
+      $class->credited_sql($start, $end, %opt).')';
+  }
+  else {
+    $paid;
+  }
+
+}
+
+sub credited_sql {
+  my ($class, $start, $end, %opt) = @_;
+  my $s = $start ? "AND cust_credit_bill._date <= $start" : '';
+  my $e = $end   ? "AND cust_credit_bill._date >  $end"   : '';
+  my $setuprecur = 
+    $opt{setuprecur} =~ /^s/ ? 'setup' :
+    $opt{setuprecur} =~ /^r/ ? 'recur' :
+    '';
+  $setuprecur &&= "AND setuprecur = '$setuprecur'";
+
+  my $credited = "( SELECT COALESCE(SUM(cust_credit_bill_pkg.amount),0)
+     FROM cust_credit_bill_pkg JOIN cust_credit_bill USING (creditbillnum)
+     WHERE cust_credit_bill_pkg.billpkgnum = cust_bill_pkg.billpkgnum
+           $s $e $setuprecur )";
+
+  if ( $opt{no_usage} ) {
+    # cap the amount credited at the sum of non-usage charges
+    "LEAST($credited, ". $class->charged_sql($start, $end, %opt).')';
+  }
+  else {
+    $credited;
+  }
 
-  $self->cust_pkg->_X_show_zero($what);
 }
 
 =back
index a5250ec..2810dc9 100644 (file)
@@ -4,7 +4,7 @@ use base qw( FS::geocode_Mixin FS::Record );
 use strict;
 use vars qw( $import );
 use Locale::Country;
-use FS::UID qw( dbh );
+use FS::UID qw( dbh driver_name );
 use FS::Record qw( qsearch ); #qsearchs );
 use FS::Conf;
 use FS::prospect_main;
@@ -113,11 +113,16 @@ otherwise returns false.
 
 sub insert {
   my $self = shift;
+  my $conf = new FS::Conf;
+
+  if ( $self->censustract ) {
+    $self->set('censusyear' => $conf->config('census_year') || 2012);
+  }
+
   my $error = $self->SUPER::insert(@_);
 
   #false laziness with cust_main, will go away eventually
-  my $conf = new FS::Conf;
-  if ( !$error and $conf->config('tax_district_method') ) {
+  if ( !$import and !$error and $conf->config('tax_district_method') ) {
 
     my $queue = new FS::queue {
       'job' => 'FS::geocode_Mixin::process_district_update'
@@ -144,21 +149,14 @@ sub replace {
   my $self = shift;
   my $old = shift;
   $old ||= $self->replace_old;
-  my $error = $self->SUPER::replace($old);
-
-  #false laziness with cust_main, will go away eventually
-  my $conf = new FS::Conf;
-  if ( !$error and $conf->config('tax_district_method') 
-    and $self->get('address1') ne $old->get('address1') ) {
-
-    my $queue = new FS::queue {
-      'job' => 'FS::geocode_Mixin::process_district_update'
-    };
-    $error = $queue->insert( ref($self), $self->locationnum );
-
+  # the following fields are immutable
+  foreach (qw(address1 address2 city state zip country)) {
+    if ( $self->$_ ne $old->$_ ) {
+      return "can't change cust_location field $_";
+    }
   }
 
-  $error || '';
+  $self->SUPER::replace($old);
 }
 
 
@@ -174,6 +172,7 @@ and replace methods.
 #fields anyway...
 sub check {
   my $self = shift;
+  my $conf = new FS::Conf;
 
   my $error = 
     $self->ut_numbern('locationnum')
@@ -185,7 +184,7 @@ sub check {
     || $self->ut_textn('county')
     || $self->ut_textn('state')
     || $self->ut_country('country')
-    || $self->ut_zip('zip', $self->country)
+    || (!$import && $self->ut_zip('zip', $self->country))
     || $self->ut_coordn('latitude')
     || $self->ut_coordn('longitude')
     || $self->ut_enum('coord_auto', [ '', 'Y' ])
@@ -194,22 +193,36 @@ sub check {
     || $self->ut_enum('location_kind', [ '', 'R', 'B' ] )
     || $self->ut_alphan('geocode')
     || $self->ut_alphan('district')
+    || $self->ut_numbern('censusyear')
   ;
   return $error if $error;
+  if ( $self->censustract ne '' ) {
+    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
+      or return "Illegal census tract: ". $self->censustract;
+
+    $self->censustract("$1.$2");
+  }
+
+  if ( $conf->exists('cust_main-require_address2') and 
+       !$self->ship_address2 =~ /\S/ ) {
+    return "Unit # is required";
+  }
 
   $self->set_coord
     unless $import || ($self->latitude && $self->longitude);
 
-  return "No prospect or customer!" unless $self->prospectnum || $self->custnum;
+  # tricky...we have to allow for the customer to not be inserted yet
+  return "No prospect or customer!" unless $self->prospectnum 
+                                        || $self->custnum
+                                        || $self->get('custnum_pending');
   return "Prospect and customer!"       if $self->prospectnum && $self->custnum;
 
-  my $conf = new FS::Conf;
   return 'Location kind is required'
     if $self->prospectnum
     && $conf->exists('prospect_main-alt_address_format')
     && ! $self->location_kind;
 
-  unless ( qsearch('cust_main_county', {
+  unless ( $import or qsearch('cust_main_county', {
     'country' => $self->country,
     'state'   => '',
    } ) ) {
@@ -266,19 +279,40 @@ location_kind.
 
 =cut
 
-=item move_to HASHREF
+=item disable_if_unused
+
+Sets the "disabled" flag on the location if it is no longer in use as a 
+prospect location, package location, or a customer's billing or default
+service address.
+
+=cut
+
+sub disable_if_unused {
+
+  my $self = shift;
+  my $locationnum = $self->locationnum;
+  return '' if FS::cust_main->count('bill_locationnum = '.$locationnum)
+            or FS::cust_main->count('ship_locationnum = '.$locationnum)
+            or FS::contact->count(      'locationnum  = '.$locationnum)
+            or FS::cust_pkg->count('cancel IS NULL AND 
+                                         locationnum  = '.$locationnum)
+          ;
+  $self->disabled('Y');
+  $self->replace;
+
+}
+
+=item move_to
 
-Takes a hashref with one or more cust_location fields.  Creates a duplicate 
-of the existing location with all fields set to the values in the hashref.  
-Moves all packages that use the existing location to the new one, then sets 
-the "disabled" flag on the old location.  Returns nothing on success, an 
-error message on error.
+Takes a new L<FS::cust_location> object.  Moves all packages that use the 
+existing location to the new one, then sets the "disabled" flag on the old
+location.  Returns nothing on success, an error message on error.
 
 =cut
 
 sub move_to {
   my $old = shift;
-  my $hashref = shift;
+  my $new = shift;
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -292,16 +326,12 @@ sub move_to {
   my $dbh = dbh;
   my $error = '';
 
-  my $new = FS::cust_location->new({
-      $old->location_hash,
-      'custnum'     => $old->custnum,
-      'prospectnum' => $old->prospectnum,
-      %$hashref
-    });
-  $error = $new->insert;
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "Error creating location: $error";
+  if ( !$new->locationnum ) {
+    $error = $new->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error creating location: $error";
+    }
   }
 
   my @pkgs = qsearch('cust_pkg', { 
@@ -319,15 +349,14 @@ sub move_to {
     }
   }
 
-  $old->disabled('Y');
-  $error = $old->replace;
+  $error = $old->disable_if_unused;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return "Error disabling old location: $error";
   }
 
   $dbh->commit if $oldAutoCommit;
-  return;
+  '';
 }
 
 =item alternize
@@ -408,11 +437,100 @@ sub dealternize {
   '';
 }
 
+=item location_label
+
+Returns the label of the location object, with an optional site ID
+string (based on the cust_location-label_prefix config option).
+
+=cut
+
+sub location_label {
+  my $self = shift;
+  my %opt = @_;
+  my $conf = new FS::Conf;
+  my $prefix = '';
+  my $format = $conf->config('cust_location-label_prefix') || '';
+  my $cust_or_prospect;
+  if ( $self->custnum ) {
+    $cust_or_prospect = FS::cust_main->by_key($self->custnum);
+  }
+  elsif ( $self->prospectnum ) {
+    $cust_or_prospect = FS::prospect_main->by_key($self->prospectnum);
+  }
+
+  if ( $format eq 'CoStAg' ) {
+    my $agent = $conf->config('cust_main-custnum-display_prefix',
+                  $cust_or_prospect->agentnum)
+                || $cust_or_prospect->agent->agent;
+    # else this location is invalid
+    $prefix = uc( join('',
+        $self->country,
+        ($self->state =~ /^(..)/),
+        ($agent =~ /^(..)/),
+        sprintf('%05d', $self->locationnum)
+    ) );
+  }
+  elsif ( $self->custnum and 
+          $self->locationnum == $cust_or_prospect->ship_locationnum ) {
+    $prefix = 'Default service location';
+  }
+  $prefix .= ($opt{join_string} ||  ': ') if $prefix;
+  $prefix . $self->SUPER::location_label(%opt);
+}
+
 =back
 
-=head1 BUGS
+=head1 CLASS METHODS
 
-Not yet used for cust_main billing and shipping addresses.
+=item in_county_sql OPTIONS
+
+Returns an SQL expression to test membership in a cust_main_county 
+geographic area.  By default, this requires district, city, county,
+state, and country to match exactly.  Pass "ornull => 1" to allow 
+partial matches where some fields are NULL in the cust_main_county 
+record but not in the location.
+
+Pass "param => 1" to receive a parameterized expression (rather than
+one that requires a join to cust_main_county) and a list of parameter
+names in order.
+
+=cut
+
+sub in_county_sql {
+  # replaces FS::cust_pkg::location_sql
+  my ($class, %opt) = @_;
+  my $ornull = $opt{ornull} ? ' OR ? IS NULL' : '';
+  my $x = $ornull ? 3 : 2;
+  my @fields = (('district') x 3,
+                ('city') x 3,
+                ('county') x $x,
+                ('state') x $x,
+                'country');
+
+  my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
+
+  my @where = (
+    "cust_location.district = ? OR ? = '' OR CAST(? AS $text) IS NULL",
+    "cust_location.city     = ? OR ? = '' OR CAST(? AS $text) IS NULL",
+    "cust_location.county   = ? OR (? = '' AND cust_location.county IS NULL) $ornull",
+    "cust_location.state    = ? OR (? = '' AND cust_location.state IS NULL ) $ornull",
+    "cust_location.country = ?"
+  );
+  my $sql = join(' AND ', map "($_)\n", @where);
+  if ( $opt{param} ) {
+    return $sql, @fields;
+  }
+  else {
+    # do the substitution here
+    foreach (@fields) {
+      $sql =~ s/\?/cust_main_county.$_/;
+      $sql =~ s/cust_main_county.$_ = ''/cust_main_county.$_ IS NULL/;
+    }
+    return $sql;
+  }
+}
+
+=head1 BUGS
 
 =head1 SEE ALSO
 
index 845d098..b382232 100644 (file)
@@ -6,6 +6,7 @@ use strict;
 use base qw( FS::cust_main::Packages FS::cust_main::Status
              FS::cust_main::Billing FS::cust_main::Billing_Realtime
              FS::cust_main::Billing_Discount
+             FS::cust_main::Location
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
              FS::geocode_Mixin
              FS::o2m_Common
@@ -14,7 +15,7 @@ use base qw( FS::cust_main::Packages FS::cust_main::Status
 use vars qw( $DEBUG $me $conf
              @encrypted_fields
              $import
-             $ignore_expired_card $ignore_illegal_zip $ignore_banned_card
+             $ignore_expired_card $ignore_banned_card $ignore_illegal_zip
              $skip_fuzzyfiles
              @paytypes
            );
@@ -71,6 +72,7 @@ use FS::cust_main_note;
 use FS::cust_attachment;
 use FS::contact;
 use FS::Locales;
+use FS::upgrade_journal;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -80,7 +82,6 @@ $me = '[FS::cust_main]';
 
 $import = 0;
 $ignore_expired_card = 0;
-$ignore_illegal_zip = 0;
 $ignore_banned_card = 0;
 
 $skip_fuzzyfiles = 0;
@@ -178,28 +179,6 @@ Cocial security number (optional)
 
 (optional)
 
-=item address1
-
-=item address2
-
-(optional)
-
-=item city
-
-=item county
-
-(optional, see L<FS::cust_main_county>)
-
-=item state
-
-(see L<FS::cust_main_county>)
-
-=item zip
-
-=item country
-
-(see L<FS::cust_main_county>)
-
 =item daytime
 
 phone (optional)
@@ -216,56 +195,6 @@ phone (optional)
 
 phone (optional)
 
-=item ship_first
-
-Shipping first name
-
-=item ship_last
-
-Shipping last name
-
-=item ship_company
-
-(optional)
-
-=item ship_address1
-
-=item ship_address2
-
-(optional)
-
-=item ship_city
-
-=item ship_county
-
-(optional, see L<FS::cust_main_county>)
-
-=item ship_state
-
-(see L<FS::cust_main_county>)
-
-=item ship_zip
-
-=item ship_country
-
-(see L<FS::cust_main_county>)
-
-=item ship_daytime
-
-phone (optional)
-
-=item ship_night
-
-phone (optional)
-
-=item ship_fax
-
-phone (optional)
-
-=item ship_mobile
-
-phone (optional)
-
 =item payby
 
 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
@@ -364,6 +293,12 @@ sub table { 'cust_main'; }
 Adds this customer to the database.  If there is an error, returns the error,
 otherwise returns false.
 
+Usually the customer's location will not yet exist in the database, and
+the C<bill_location> and C<ship_location> pseudo-fields must be set to 
+uninserted L<FS::cust_location> objects.  These will be inserted and linked
+(in both directions) to the new customer record.  If they're references 
+to the same object, they will become the same location.
+
 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
 are inserted atomicly, or the transaction is rolled back.  Passing an empty
@@ -399,8 +334,9 @@ The I<noexport> option is deprecated.  If I<noexport> is set true, no
 provisioning jobs (exports) are scheduled.  (You can schedule them later with
 the B<reexport> method.)
 
-The I<tax_exemption> option can be set to an arrayref of tax names.
-FS::cust_main_exemption records will be created and inserted.
+The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
+of tax names and exemption numbers.  FS::cust_main_exemption records will be
+created and inserted.
 
 If I<prospectnum> is set, moves contacts and locations from that prospect.
 
@@ -461,13 +397,47 @@ sub insert {
 
   }
 
+  # insert locations
+  foreach my $l (qw(bill_location ship_location)) {
+    my $loc = delete $self->hashref->{$l};
+    # XXX if we're moving a prospect's locations, do that here
+    if ( !$loc ) {
+      return "$l not set";
+    }
+    
+    if ( !$loc->locationnum ) {
+      # warn the location that we're going to insert it with no custnum
+      $loc->set(custnum_pending => 1);
+      warn "  inserting $l\n"
+        if $DEBUG > 1;
+      my $error = $loc->insert;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        my $label = $l eq 'ship_location' ? 'service' : 'billing';
+        return "$error (in $label location)";
+      }
+    }
+    elsif ( ($loc->custnum || 0) > 0 or $loc->prospectnum ) {
+      # then it somehow belongs to another customer--shouldn't happen
+      $dbh->rollback if $oldAutoCommit;
+      return "$l belongs to customer ".$loc->custnum;
+    }
+    # else it already belongs to this customer 
+    # (happens when ship_location is identical to bill_location)
+
+    $self->set($l.'num', $loc->locationnum);
+
+    if ( $self->get($l.'num') eq '' ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$l not set";
+    }
+  }
+
   warn "  inserting $self\n"
     if $DEBUG > 1;
 
   $self->signupdate(time) unless $self->signupdate;
 
-  $self->censusyear($conf->config('census_year')||'2012') if $self->censustract;
-
   $self->auto_agent_custid()
     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
 
@@ -478,6 +448,20 @@ sub insert {
     return $error;
   }
 
+  # now set cust_location.custnum
+  foreach my $l (qw(bill_location ship_location)) {
+    warn "  setting $l.custnum\n"
+      if $DEBUG > 1;
+    my $loc = $self->$l;
+    $loc->set(custnum => $self->custnum);
+    $error ||= $loc->replace;
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error setting $l custnum: $error";
+    }
+  }
+
   warn "  setting invoicing list\n"
     if $DEBUG > 1;
 
@@ -545,10 +529,15 @@ sub insert {
 
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
-    foreach my $taxname ( @$tax_exemption ) {
+
+    $tax_exemption = { map { $_ => '' } @$tax_exemption }
+      if ref($tax_exemption) eq 'ARRAY';
+
+    foreach my $taxname ( keys %$tax_exemption ) {
       my $cust_main_exemption = new FS::cust_main_exemption {
-        'custnum' => $self->custnum,
-        'taxname' => $taxname,
+        'custnum'       => $self->custnum,
+        'taxname'       => $taxname,
+        'exempt_number' => $tax_exemption->{$taxname},
       };
       my $error = $cust_main_exemption->insert;
       if ( $error ) {
@@ -1312,7 +1301,7 @@ sub merge {
 
   }
 
-  my $name = $self->ship_name;
+  my $name = $self->ship_name; #?
 
   my $locationnum = '';
   foreach my $cust_pkg ( $self->all_pkgs ) {
@@ -1448,10 +1437,13 @@ sub merge {
 
 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
 
-
 Replaces the OLD_RECORD with this one in the database.  If there is an error,
 returns the error, otherwise returns false.
 
+To change the customer's address, set the pseudo-fields C<bill_location> and
+C<ship_location>.  The address will still only change if at least one of the
+address fields differs from the existing values.
+
 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
 expected and rollback the entire transaction; it is not necessary to call 
@@ -1461,8 +1453,9 @@ check_invoicing_list first.  Here's an example:
 
 Currently available options are: I<tax_exemption>.
 
-The I<tax_exemption> option can be set to an arrayref of tax names.
-FS::cust_main_exemption records will be deleted and inserted as appropriate.
+The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
+of tax names and exemption numbers.  FS::cust_main_exemption records will be
+deleted and inserted as appropriate.
 
 =cut
 
@@ -1487,41 +1480,19 @@ sub replace {
     return "You are not permitted to create complimentary accounts.";
   }
 
-  if ( $old->get('geocode') && $old->get('geocode') eq $self->get('geocode')
-       && $conf->exists('enable_taxproducts')
-     )
-  {
-    my $pre = ($conf->exists('tax-ship_address') && $self->ship_zip)
-                ? 'ship_' : '';
-    $self->set('geocode', '')
-      if $old->get($pre.'zip') ne $self->get($pre.'zip')
-      && length($self->get($pre.'zip')) >= 10;
-  }
-
-  for my $pre ( grep $old->get($_.'coord_auto'), ( '', 'ship_' ) ) {
-
-    $self->set($pre.'coord_auto', '') && next
-      if $self->get($pre.'latitude') && $self->get($pre.'longitude')
-      && (    $self->get($pre.'latitude')  != $old->get($pre.'latitude')
-           || $self->get($pre.'longitude') != $old->get($pre.'longitude')
-         );
-
-    $self->set_coord($pre)
-      if $old->get($pre.'address1') ne $self->get($pre.'address1')
-      || $old->get($pre.'city')     ne $self->get($pre.'city')
-      || $old->get($pre.'state')    ne $self->get($pre.'state')
-      || $old->get($pre.'country')  ne $self->get($pre.'country');
-
-  }
+  # should be unnecessary--geocode will default to null on new locations
+  #if ( $old->get('geocode') && $old->get('geocode') eq $self->get('geocode')
+  #     && $conf->exists('enable_taxproducts')
+  #   )
+  #{
+  #  my $pre = ($conf->exists('tax-ship_address') && $self->ship_zip)
+  #              ? 'ship_' : '';
+  #  $self->set('geocode', '')
+  #    if $old->get($pre.'zip') ne $self->get($pre.'zip')
+  #    && length($self->get($pre.'zip')) >= 10;
+  #}
 
-  unless ( $import ) {
-    $self->set_coord
-      if ! $self->coord_auto && ! $self->latitude && ! $self->longitude;
-
-    $self->set_coord('ship_')
-      if $self->has_ship_address && ! $self->ship_coord_auto
-      && ! $self->ship_latitude && ! $self->ship_longitude;
-  }
+  # set_coord/coord_auto stuff is now handled by cust_location
 
   local($ignore_expired_card) = 1
     if $old->payby  =~ /^(CARD|DCRD)$/
@@ -1533,11 +1504,10 @@ sub replace {
          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
-  if ( $self->censustract ne '' and $self->censustract ne $old->censustract ) {
-    # update censusyear whenever tract code changes
-    $self->censusyear($conf->config('census_year')||'2012');
-  }
-
+  return "Invoicing locale is required"
+    if $old->locale
+    && ! $self->locale
+    && $conf->exists('cust_main-require_locale');
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -1550,6 +1520,47 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
+  for my $l (qw(bill_location ship_location)) {
+    my $old_loc = $old->$l;
+    my $new_loc = $self->$l;
+
+    if ( !$new_loc->locationnum ) {
+      # changing location
+      # If the new location is all empty fields, or if it's identical to 
+      # the old location in all fields, don't replace.
+      my @nonempty = grep { $new_loc->$_ } $self->location_fields;
+      next if !@nonempty;
+      my @unlike = grep { $new_loc->$_ ne $old_loc->$_ } $self->location_fields;
+
+      if ( @unlike or $old_loc->disabled ) {
+        warn "  changed $l fields: ".join(',',@unlike)."\n"
+          if $DEBUG;
+        $new_loc->set(custnum => $self->custnum);
+
+        # insert it--the old location will be disabled later
+        my $error = $new_loc->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+
+      } else {
+      # no fields have changed and $old_loc isn't disabled, so don't change it
+        next;
+      }
+
+    }
+    elsif ( $new_loc->custnum ne $self->custnum or $new_loc->prospectnum ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "$l belongs to customer ".$new_loc->custnum;
+    }
+    # else the new location belongs to this customer so we're good
+
+    # set the foo_locationnum now that we have one.
+    $self->set($l.'num', $new_loc->locationnum);
+
+  } #for $l
+
   my $error = $self->SUPER::replace($old);
 
   if ( $error ) {
@@ -1557,6 +1568,27 @@ sub replace {
     return $error;
   }
 
+  # now move packages to the new service location
+  $self->set('ship_location', ''); #flush cache
+  if ( $old->ship_locationnum and # should only be null during upgrade...
+       $old->ship_locationnum != $self->ship_locationnum ) {
+    $error = $old->ship_location->move_to($self->ship_location);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+  # don't move packages based on the billing location, but 
+  # disable it if it's no longer in use
+  if ( $old->bill_locationnum and
+       $old->bill_locationnum != $self->bill_locationnum ) {
+    $error = $old->bill_location->disable_if_unused;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
     my $invoicing_list = shift @param;
     $error = $self->check_invoicing_list( $invoicing_list );
@@ -1594,17 +1626,27 @@ sub replace {
   my $tax_exemption = delete $options{'tax_exemption'};
   if ( $tax_exemption ) {
 
+    $tax_exemption = { map { $_ => '' } @$tax_exemption }
+      if ref($tax_exemption) eq 'ARRAY';
+
     my %cust_main_exemption =
       map { $_->taxname => $_ }
           qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
 
-    foreach my $taxname ( @$tax_exemption ) {
+    foreach my $taxname ( keys %$tax_exemption ) {
 
-      next if delete $cust_main_exemption{$taxname};
+      if ( $cust_main_exemption{$taxname} && 
+           $cust_main_exemption{$taxname}->exempt_number eq $tax_exemption->{$taxname}
+         )
+      {
+        delete $cust_main_exemption{$taxname};
+        next;
+      }
 
       my $cust_main_exemption = new FS::cust_main_exemption {
-        'custnum' => $self->custnum,
-        'taxname' => $taxname,
+        'custnum'       => $self->custnum,
+        'taxname'       => $taxname,
+        'exempt_number' => $tax_exemption->{$taxname},
       };
       my $error = $cust_main_exemption->insert;
       if ( $error ) {
@@ -1648,24 +1690,7 @@ sub replace {
     }
   }
 
-  # FS::geocode_Mixin::after_replace ?
-  # though this will go away anyway once we move customer bill/service 
-  # locations into cust_location
-  # We can trigger this on any address change--just have to make sure 
-  # not to trigger it on itself.
-  if ( $conf->config('tax_district_method') and !$import 
-      and ( $self->get('ship_address1') ne $old->get('ship_address1')
-        or  $self->get('address1')      ne $old->get('address1') ) ) {
-    my $queue = new FS::queue {
-      'job'     => 'FS::geocode_Mixin::process_district_update',
-      'custnum' => $self->custnum,
-    };
-    my $error = $queue->insert( ref($self), $self->custnum );
-    if ( $error ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "queueing tax district update: $error";
-    }
-  }
+  # tax district update in cust_location
 
   # cust_main exports!
 
@@ -1710,16 +1735,14 @@ sub queue_fuzzyfiles_update {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $queue = new FS::queue { 'job' => 'FS::cust_main::Search::append_fuzzyfiles' };
-  my $error = $queue->insert( map $self->getfield($_), @FS::cust_main::Search::fuzzyfields );
-  if ( $error ) {
-    $dbh->rollback if $oldAutoCommit;
-    return "queueing job (transaction rolled back): $error";
-  }
-
-  if ( $self->ship_last ) {
-    $queue = new FS::queue { 'job' => 'FS::cust_main::Search::append_fuzzyfiles' };
-    $error = $queue->insert( map $self->getfield("ship_$_"), @FS::cust_main::Search::fuzzyfields );
+  my @locations = $self->bill_location;
+  push @locations, $self->ship_location if $self->has_ship_address;
+  foreach my $location (@locations) {
+    my $queue = new FS::queue { 
+      'job' => 'FS::cust_main::Search::append_fuzzyfiles'
+    };
+    my @args = map $location->get($_), @FS::cust_main::Search::fuzzyfields;
+    my $error = $queue->insert( @args );
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
       return "queueing job (transaction rolled back): $error";
@@ -1750,6 +1773,8 @@ sub check {
     || $self->ut_number('agentnum')
     || $self->ut_textn('agent_custid')
     || $self->ut_number('refnum')
+    || $self->ut_foreign_key('bill_locationnum', 'cust_location','locationnum')
+    || $self->ut_foreign_key('ship_locationnum', 'cust_location','locationnum')
     || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
@@ -1757,34 +1782,20 @@ sub check {
     || $self->ut_snumbern('birthdate')
     || $self->ut_snumbern('signupdate')
     || $self->ut_textn('company')
-    || $self->ut_text('address1')
-    || $self->ut_textn('address2')
-    || $self->ut_text('city')
-    || $self->ut_textn('county')
-    || $self->ut_textn('state')
-    || $self->ut_country('country')
-    || $self->ut_coordn('latitude')
-    || $self->ut_coordn('longitude')
-    || $self->ut_enum('coord_auto', [ '', 'Y' ])
-    || $self->ut_numbern('censusyear')
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
     || $self->ut_textn('stateid')
     || $self->ut_textn('stateid_state')
     || $self->ut_textn('invoice_terms')
-    || $self->ut_alphan('geocode')
-    || $self->ut_alphan('district')
     || $self->ut_floatn('cdr_termination_percentage')
     || $self->ut_floatn('credit_limit')
     || $self->ut_numbern('billday')
     || $self->ut_enum('edit_subject', [ '', 'Y' ] )
     || $self->ut_enum('calling_list_exempt', [ '', 'Y' ] )
+    || $self->ut_enum('invoice_noemail', [ '', 'Y' ] )
     || $self->ut_enum('locale', [ '', FS::Locales->locales ])
   ;
 
-  $self->set_coord
-    unless $import || ($self->latitude && $self->longitude);
-
   #barf.  need message catalogs.  i18n.  etc.
   $error .= "Please select an advertising source."
     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
@@ -1800,13 +1811,6 @@ sub check {
     unless ! $self->referral_custnum 
            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
 
-  if ( $self->censustract ne '' ) {
-    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
-      or return "Illegal census tract: ". $self->censustract;
-    
-    $self->censustract("$1.$2");
-  }
-
   if ( $self->ss eq '' ) {
     $self->ss('');
   } else {
@@ -1817,23 +1821,7 @@ sub check {
     $self->ss("$1-$2-$3");
   }
 
-
-# bad idea to disable, causes billing to fail because of no tax rates later
-# except we don't fail any more
-  unless ( $import ) {
-    unless ( qsearch('cust_main_county', {
-      'country' => $self->country,
-      'state'   => '',
-     } ) ) {
-      return "Unknown state/county/country: ".
-        $self->state. "/". $self->county. "/". $self->country
-        unless qsearch('cust_main_county',{
-          'state'   => $self->state,
-          'county'  => $self->county,
-          'country' => $self->country,
-        } );
-    }
-  }
+  # cust_main_county verification now handled by cust_location check
 
   $error =
        $self->ut_phonen('daytime', $self->country)
@@ -1843,12 +1831,8 @@ sub check {
   ;
   return $error if $error;
 
-  unless ( $ignore_illegal_zip ) {
-    $error = $self->ut_zip('zip', $self->country);
-    return $error if $error;
-  }
-
   if ( $conf->exists('cust_main-require_phone', $self->agentnum)
+       && ! $import
        && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
      ) {
 
@@ -1867,71 +1851,7 @@ sub check {
   
   }
 
-  if ( $self->has_ship_address
-       && scalar ( grep { $self->getfield($_) ne $self->getfield("ship_$_") }
-                        $self->addr_fields )
-     )
-  {
-    my $error =
-      $self->ut_name('ship_last')
-      || $self->ut_name('ship_first')
-      || $self->ut_textn('ship_company')
-      || $self->ut_text('ship_address1')
-      || $self->ut_textn('ship_address2')
-      || $self->ut_text('ship_city')
-      || $self->ut_textn('ship_county')
-      || $self->ut_textn('ship_state')
-      || $self->ut_country('ship_country')
-      || $self->ut_coordn('ship_latitude')
-      || $self->ut_coordn('ship_longitude')
-      || $self->ut_enum('ship_coord_auto', [ '', 'Y' ] )
-    ;
-    return $error if $error;
-
-    $self->set_coord('ship_')
-      unless $import || ($self->ship_latitude && $self->ship_longitude);
-
-    #false laziness with above
-    unless ( qsearchs('cust_main_county', {
-      'country' => $self->ship_country,
-      'state'   => '',
-     } ) ) {
-      return "Unknown ship_state/ship_county/ship_country: ".
-        $self->ship_state. "/". $self->ship_county. "/". $self->ship_country
-        unless qsearch('cust_main_county',{
-          'state'   => $self->ship_state,
-          'county'  => $self->ship_county,
-          'country' => $self->ship_country,
-        } );
-    }
-    #eofalse
-
-    $error =
-         $self->ut_phonen('ship_daytime', $self->ship_country)
-      || $self->ut_phonen('ship_night',   $self->ship_country)
-      || $self->ut_phonen('ship_fax',     $self->ship_country)
-      || $self->ut_phonen('ship_mobile',  $self->ship_country)
-    ;
-    return $error if $error;
-
-    unless ( $ignore_illegal_zip ) {
-      $error = $self->ut_zip('ship_zip', $self->ship_country);
-      return $error if $error;
-    }
-    return "Unit # is required."
-      if $self->ship_address2 =~ /^\s*$/
-      && $conf->exists('cust_main-require_address2');
-
-  } else { # ship_ info eq billing info, so don't store dup info in database
-
-    $self->setfield("ship_$_", '')
-      foreach $self->addr_fields;
-
-    return "Unit # is required."
-      if $self->address2 =~ /^\s*$/
-      && $conf->exists('cust_main-require_address2');
-
-  }
+  #ship_ fields are gone
 
   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
   #  or return "Illegal payby: ". $self->payby;
@@ -1957,7 +1877,9 @@ sub check {
   # check the credit card.
   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
 
-  if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
+  # Need some kind of global flag to accept invalid cards, for testing
+  # on scrubbed data.
+  if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
@@ -2139,6 +2061,11 @@ sub check {
     $self->payname($1);
   }
 
+  return "Please select an invoicing locale"
+    if ! $self->locale
+    && ! $self->custnum
+    && $conf->exists('cust_main-require_locale');
+
   foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
     $self->$flag($1);
@@ -2174,7 +2101,7 @@ Returns true if this customer record has a separate shipping address.
 
 sub has_ship_address {
   my $self = shift;
-  scalar( grep { $self->getfield("ship_$_") ne '' } $self->addr_fields );
+  $self->bill_locationnum != $self->ship_locationnum;
 }
 
 =item location_hash
@@ -2185,6 +2112,11 @@ shipping address is used if present.
 
 =cut
 
+sub location_hash {
+  my $self = shift;
+  $self->ship_location->location_hash;
+}
+
 =item cust_location
 
 Returns all locations (see L<FS::cust_location>) for this customer.
@@ -2193,7 +2125,8 @@ Returns all locations (see L<FS::cust_location>) for this customer.
 
 sub cust_location {
   my $self = shift;
-  qsearch('cust_location', { 'custnum' => $self->custnum } );
+  qsearch('cust_location', { 'custnum' => $self->custnum,
+                             'prospectnum' => '' } );
 }
 
 =item cust_contact
@@ -2590,6 +2523,8 @@ sub batch_card {
     $options{$_} = '' unless exists($options{$_});
   }
 
+  my $loc = $self->bill_location;
+
   my $cust_pay_batch = new FS::cust_pay_batch ( {
     'batchnum' => $pay_batch->batchnum,
     'invnum'   => $invnum || 0,                    # is there a better value?
@@ -2599,16 +2534,16 @@ sub batch_card {
     'custnum'  => $self->custnum,
     'last'     => $self->getfield('last'),
     'first'    => $self->getfield('first'),
-    'address1' => $options{address1} || $self->address1,
-    'address2' => $options{address2} || $self->address2,
-    'city'     => $options{city}     || $self->city,
-    'state'    => $options{state}    || $self->state,
-    'zip'      => $options{zip}      || $self->zip,
-    'country'  => $options{country}  || $self->country,
-    'payby'    => $options{payby}    || $self->payby,
-    'payinfo'  => $options{payinfo}  || $self->payinfo,
-    'exp'      => $options{paydate}  || $self->paydate,
-    'payname'  => $options{payname}  || $self->payname,
+    'address1' => $options{address1} || $loc->address1,
+    'address2' => $options{address2} || $loc->address2,
+    'city'     => $options{city}     || $loc->city,
+    'state'    => $options{state}    || $loc->state,
+    'zip'      => $options{zip}      || $loc->zip,
+    'country'  => $options{country}  || $loc->country,
+    'payby'    => $options{payby}    || $loc->payby,
+    'payinfo'  => $options{payinfo}  || $loc->payinfo,
+    'exp'      => $options{paydate}  || $loc->paydate,
+    'payname'  => $options{payname}  || $loc->payname,
     'amount'   => $amount,                         # consolidating
   } );
   
@@ -3000,7 +2935,8 @@ sub payment_info {
   $return{payname} = $self->payname
                      || ( $self->first. ' '. $self->get('last') );
 
-  $return{$_} = $self->get($_) for qw(address1 address2 city state zip);
+  $return{$_} = $self->bill_location->$_
+    for qw(address1 address2 city state zip);
 
   $return{payby} = $self->payby;
   $return{stateid_state} = $self->stateid_state;
@@ -4010,6 +3946,27 @@ sub name {
   $name;
 }
 
+=item service_contact
+
+Returns the L<FS::contact> object for this customer that has the 'Service'
+contact class, or undef if there is no such contact.  Deprecated; don't use
+this in new code.
+
+=cut
+
+sub service_contact {
+  my $self = shift;
+  if ( !exists($self->{service_contact}) ) {
+    my $classnum = $self->scalar_sql(
+      'SELECT classnum FROM contact_class WHERE classname = \'Service\''
+    ) || 0; #if it's zero, qsearchs will return nothing
+    $self->{service_contact} = qsearchs('contact', { 
+        'classnum' => $classnum, 'custnum' => $self->custnum
+      }) || undef;
+  }
+  $self->{service_contact};
+}
+
 =item ship_name
 
 Returns a name string for this (service/shipping) contact, either
@@ -4019,13 +3976,10 @@ Returns a name string for this (service/shipping) contact, either
 
 sub ship_name {
   my $self = shift;
-  if ( $self->get('ship_last') ) { 
-    my $name = $self->ship_contact;
-    $name = $self->ship_company. " ($name)" if $self->ship_company;
-    $name;
-  } else {
-    $self->name;
-  }
+
+  my $name = $self->ship_contact;
+  $name = $self->company. " ($name)" if $self->company;
+  $name;
 }
 
 =item name_short
@@ -4048,13 +4002,9 @@ or "First Last".
 
 sub ship_name_short {
   my $self = shift;
-  if ( $self->get('ship_last') ) { 
-    $self->ship_company !~ /^\s*$/
-      ? $self->ship_company
-      : $self->ship_contact_firstlast;
-  } else {
-    $self->name_company_or_firstlast;
-  }
+  $self->service_contact 
+    ? $self->ship_contact_firstlast 
+    : $self->name_short
 }
 
 =item contact
@@ -4076,9 +4026,8 @@ Returns this customer's full (shipping) contact name only, "Last, First"
 
 sub ship_contact {
   my $self = shift;
-  $self->get('ship_last')
-    ? $self->get('ship_last'). ', '. $self->ship_first
-    : $self->contact;
+  my $contact = $self->service_contact || $self;
+  $contact->get('last') . ', ' . $contact->get('first');
 }
 
 =item contact_firstlast
@@ -4100,9 +4049,8 @@ Returns this customer's full (shipping) contact name only, "First Last".
 
 sub ship_contact_firstlast {
   my $self = shift;
-  $self->get('ship_last')
-    ? $self->first. ' '. $self->get('ship_last')
-    : $self->contact_firstlast;
+  my $contact = $self->service_contact || $self;
+  $contact->get('first') . ' '. $contact->get('last');
 }
 
 =item country_full
@@ -5077,39 +5025,71 @@ sub process_censustract_update {
   return;
 }
 
+#starting to take quite a while for big dbs
+# - seq scan of h_cust_main (yuck), but not going to index paycvv, so
+# - seq scan of cust_main on signupdate... index signupdate?  will that help?
+# - seq scan of cust_main on paydate... index on substrings?  maybe set an
+#    upgrade journal flag now that we have that, yyyy-m-dd paydates are ancient
+# - seq scan of cust_main on payinfo.. certainly not going toi ndex that...
+#    upgrade journal again?  this is also an ancient problem
+# - otaker upgrade?  journal and call it good?  (double check to make sure
+#    we're not still setting otaker here)
+#
+#only going to get worse with new location stuff...
+
 sub _upgrade_data { #class method
   my ($class, %opts) = @_;
 
   my @statements = (
     'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL',
-    'UPDATE cust_main SET signupdate = (SELECT signupdate FROM h_cust_main WHERE signupdate IS NOT NULL AND h_cust_main.custnum = cust_main.custnum ORDER BY historynum DESC LIMIT 1) WHERE signupdate IS NULL',
   );
-  # fix yyyy-m-dd formatted paydates
-  if ( driver_name =~ /^mysql/i ) {
+
+  #this seems to be the only expensive one.. why does it take so long?
+  unless ( FS::upgrade_journal->is_done('cust_main__signupdate') ) {
     push @statements,
-    "UPDATE cust_main SET paydate = CONCAT( SUBSTRING(paydate FROM 1 FOR 5), '0', SUBSTRING(paydate FROM 6) ) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
+      'UPDATE cust_main SET signupdate = (SELECT signupdate FROM h_cust_main WHERE signupdate IS NOT NULL AND h_cust_main.custnum = cust_main.custnum ORDER BY historynum DESC LIMIT 1) WHERE signupdate IS NULL';
+    FS::upgrade_journal->set_done('cust_main__signupdate');
   }
-  else { # the SQL standard
-    push @statements, 
-    "UPDATE cust_main SET paydate = SUBSTRING(paydate FROM 1 FOR 5) || '0' || SUBSTRING(paydate FROM 6) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
+
+  unless ( FS::upgrade_journal->is_done('cust_main__paydate') ) {
+
+    # fix yyyy-m-dd formatted paydates
+    if ( driver_name =~ /^mysql/i ) {
+      push @statements,
+      "UPDATE cust_main SET paydate = CONCAT( SUBSTRING(paydate FROM 1 FOR 5), '0', SUBSTRING(paydate FROM 6) ) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
+    } else { # the SQL standard
+      push @statements, 
+      "UPDATE cust_main SET paydate = SUBSTRING(paydate FROM 1 FOR 5) || '0' || SUBSTRING(paydate FROM 6) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
+    }
+    FS::upgrade_journal->set_done('cust_main__paydate');
   }
 
-  push @statements, #fix the weird BILL with a cc# in payinfo problem
-    #DCRD to be safe
-    "UPDATE cust_main SET payby = 'DCRD' WHERE payby = 'BILL' and length(payinfo) = 16 and payinfo ". regexp_sql. q( '^[0-9]*$' );
+  unless ( FS::upgrade_journal->is_done('cust_main__payinfo') ) {
 
+    push @statements, #fix the weird BILL with a cc# in payinfo problem
+      #DCRD to be safe
+      "UPDATE cust_main SET payby = 'DCRD' WHERE payby = 'BILL' and length(payinfo) = 16 and payinfo ". regexp_sql. q( '^[0-9]*$' );
+
+    FS::upgrade_journal->set_done('cust_main__payinfo');
+    
+  }
+
+  my $t = time;
   foreach my $sql ( @statements ) {
     my $sth = dbh->prepare($sql) or die dbh->errstr;
     $sth->execute or die $sth->errstr;
+    #warn ( (time - $t). " seconds\n" );
+    #$t = time;
   }
 
   local($ignore_expired_card) = 1;
-  local($ignore_illegal_zip) = 1;
   local($ignore_banned_card) = 1;
   local($skip_fuzzyfiles) = 1;
   local($import) = 1; #prevent automatic geocoding (need its own variable?)
   $class->_upgrade_otaker(%opts);
 
+  FS::cust_main::Location->_upgrade_data(%opts);
+
 }
 
 =back
index ca8d996..e7b9530 100644 (file)
@@ -721,6 +721,11 @@ jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
 sub calculate_taxes {
   my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
 
+  # $taxlisthash is a hashref
+  # keys are identifiers, values are arrayrefs
+  # each arrayref starts with a tax object (cust_main_county or tax_rate)
+  # then any cust_bill_pkg objects the tax applies to
+
   local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
 
   warn "$me calculate_taxes\n"
@@ -746,9 +751,15 @@ sub calculate_taxes {
   my %tax_rate_location = ();
 
   foreach my $tax ( keys %$taxlisthash ) {
+    # $tax is a tax identifier
     my $tax_object = shift @{ $taxlisthash->{$tax} };
+    # $tax_object is a cust_main_county or tax_rate 
+    # (with pkgnum and locationnum set)
+    # the rest of @{ $taxlisthash->{$tax} } is cust_bill_pkg objects
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
     warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
+    # taxline calculates the tax on all cust_bill_pkgs in the 
+    # first (arrayref) argument
     my $hashref_or_error =
       $tax_object->taxline( $taxlisthash->{$tax},
                             'custnum'      => $self->custnum,
@@ -767,8 +778,10 @@ sub calculate_taxes {
 
     $tax{ $tax } += $amount;
 
+    # link records between cust_main_county/tax_rate and cust_location
     $tax_location{ $tax } ||= [];
-    if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
+    $tax_rate_location{ $tax } ||= [];
+    if ( ref($tax_object) eq 'FS::cust_main_county' ) {
       push @{ $tax_location{ $tax }  },
         {
           'taxnum'      => $tax_object->taxnum, 
@@ -778,9 +791,7 @@ sub calculate_taxes {
           'amount'      => sprintf('%.2f', $amount ),
         };
     }
-
-    $tax_rate_location{ $tax } ||= [];
-    if ( ref($tax_object) eq 'FS::tax_rate' ) {
+    elsif ( ref($tax_object) eq 'FS::tax_rate' ) {
       my $taxratelocationnum =
         $tax_object->tax_rate_location->taxratelocationnum;
       push @{ $tax_rate_location{ $tax }  },
@@ -952,7 +963,6 @@ sub _make_lines {
   # bill recurring fee
   ### 
 
-  #XXX unit stuff here too
   my $recur = 0;
   my $unitrecur = 0;
   my @recur_discounts = ();
@@ -1011,6 +1021,9 @@ sub _make_lines {
     return "$@ running $method for $cust_pkg\n"
       if ( $@ );
 
+    #base_cancel???
+    $unitrecur = $cust_pkg->part_pkg->base_recur || $recur; #XXX uuh
+
     if ( $increment_next_bill ) {
 
       my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
@@ -1206,21 +1219,12 @@ sub _handle_taxes {
     } else {
 
       my @loc_keys = qw( district city county state country );
-      my %taxhash;
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        my $cust_location = $cust_pkg->cust_location;
-        %taxhash = map { $_ => $cust_location->$_()    } @loc_keys;
-      } else {
-        my $prefix = 
-          ( $conf->exists('tax-ship_address') && length($self->ship_last) )
-          ? 'ship_'
-          : '';
-        %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
-      }
+      my $location = $cust_pkg->tax_location;
+      my %taxhash = map { $_ => $location->$_ } @loc_keys;
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
-      my @taxes = ();
+      my @taxes = (); # entries are cust_main_county objects
       my %taxhash_elim = %taxhash;
       my @elim = qw( district city county state );
       do { 
@@ -1243,11 +1247,13 @@ sub _handle_taxes {
                     @taxes
         if $self->cust_main_exemption; #just to be safe
 
-      if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
-        foreach (@taxes) {
-          $_->set('pkgnum',      $cust_pkg->pkgnum );
-          $_->set('locationnum', $cust_pkg->locationnum );
-        }
+      # all packages now have a locationnum and should get a 
+      # cust_bill_pkg_tax_location record.  The tax_locationnum
+      # may be the package's locationnum, or the customer's bill 
+      # or service location.
+      foreach (@taxes) {
+        $_->set('pkgnum',      $cust_pkg->pkgnum);
+        $_->set('locationnum', $cust_pkg->tax_locationnum);
       }
 
       $taxes{''} = [ @taxes ];
@@ -1274,17 +1280,27 @@ sub _handle_taxes {
 
   my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
   foreach my $key (keys %tax_cust_bill_pkg) {
+    # $key is "setup", "recur", or a usage class name. ('' is a usage class.)
+    # $tax_cust_bill_pkg{$key} is a cust_bill_pkg for that component of 
+    # the line item.
+    # $taxes{$key} is an arrayref of cust_main_county or tax_rate objects that
+    # apply to $key-class charges.
     my @taxes = @{ $taxes{$key} || [] };
     my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
 
     my %localtaxlisthash = ();
     foreach my $tax ( @taxes ) {
 
+      # this is the tax identifier, not the taxname
       my $taxname = ref( $tax ). ' '. $tax->taxnum;
 #      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
 #                  ' locationnum'. $cust_pkg->locationnum
 #        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
 
+      # $taxlisthash: keys are "setup", "recur", and usage classes
+      # values are arrayrefs, first the tax object (cust_main_county
+      # or tax_rate) and then any cust_bill_pkg objects that the 
+      # tax applies to
       $taxlisthash->{ $taxname } ||= [ $tax ];
       push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
 
@@ -1525,17 +1541,23 @@ sub retry_realtime {
     cust_bill_batch
   );
 
-  my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
-                                                  @realtime_events
-                                     ).
-                          ' ) ';
+  my $is_realtime_event =
+    ' part_event.action IN ( '.
+        join(',', map "'$_'", @realtime_events ).
+    ' ) ';
+
+  my $batch_or_statustext =
+    "( part_event.action = 'cust_bill_batch'
+       OR ( statustext IS NOT NULL AND statustext != '' )
+     )";
+
 
   my @cust_event = qsearch({
     'table'     => 'cust_event',
     'select'    => 'cust_event.*',
     'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
     'hashref'   => { 'status' => 'done' },
-    'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
+    'extra_sql' => " AND $batch_or_statustext ".
                    " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
   });
 
index 7f5a3f0..6681f9e 100644 (file)
@@ -13,6 +13,7 @@ use FS::cust_main;
 use FS::svc_acct;
 use FS::svc_external;
 use FS::svc_phone;
+use FS::svc_hardware;
 use FS::part_referral;
 
 $DEBUG = 0;
@@ -197,6 +198,22 @@ sub batch_import {
     push @fields, map "svc_phone.$_", qw( countrycode phonenum sip_password pin)
       if $format eq 'svc_external_svc_phone';
     $payby = 'BILL';
+  } elsif ( $format eq 'birthdates-acct_phone_hardware') {
+    @fields = qw( agent_custid refnum
+                  last first company address1 address2 city state zip country
+                  daytime night
+                  ship_last ship_first ship_company ship_address1 ship_address2
+                  ship_city ship_state ship_zip ship_country
+                  birthdate spouse_birthdate
+                  payinfo paycvv paydate
+                  invoicing_list
+                  cust_pkg.pkgpart cust_pkg.bill
+                  svc_acct.username svc_acct._password 
+                );
+   push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
+   push @fields, map "svc_hardware.$_", qw(typenum ip_addr hw_addr serial);
+
+    $payby = 'BILL';
   } else {
     die "unknown format $format";
   }
@@ -314,7 +331,11 @@ sub batch_import {
 
       } elsif ( $field =~ /^svc_phone\.(countrycode|phonenum|sip_password|pin)$/ ) {
         $svc_x{$1} = shift @columns;
-       
+      
+      } elsif ( $field =~ /^svc_hardware\.(typenum|ip_addr|hw_addr|serial)$/ ) {
+
+        $svc_x{$1} = shift @columns;
+
       } else {
 
         #refnum interception
@@ -353,6 +374,9 @@ sub batch_import {
       }
     }
 
+    $cust_main{$_} = parse_datetime($cust_main{$_})
+      foreach grep $cust_main{$_}, qw( birthdate spouse_birthdate );
+
     my $invoicing_list = $cust_main{'invoicing_list'}
                            ? [ delete $cust_main{'invoicing_list'} ]
                            : [];
@@ -387,11 +411,19 @@ sub batch_import {
       if ( $svc_x{'countrycode'} || $svc_x{'phonenum'} ) {
         $svc_phone = FS::svc_phone->new( {
           map { $_ => delete($svc_x{$_}) }
-              qw( countrycode phonenum sip_password pin)
+              qw( countrycode phonenum sip_password pin )
         } );
       }
 
-      if ( $svcdb || $svc_phone ) {
+      my $svc_hardware = '';
+      if ( $svc_x{'typenum'} ) {
+        $svc_hardware = FS::svc_hardware->new( {
+          map { $_ => delete($svc_x{$_}) }
+            qw( typenum ip_addr hw_addr serial )
+        } );
+      }
+
+      if ( $svcdb || $svc_phone || $svc_hardware ) {
         my $part_pkg = $cust_pkg->part_pkg;
        unless ( $part_pkg ) {
          $dbh->rollback if $oldAutoCommit;
@@ -406,6 +438,11 @@ sub batch_import {
           $svc_phone->svcpart( $part_pkg->svcpart_unique_svcdb('svc_phone') );
           push @svc_x, $svc_phone;
         }
+        if ( $svc_hardware ) {
+          $svc_hardware->svcpart( $part_pkg->svcpart_unique_svcdb('svc_hardware') );
+          push @svc_x, $svc_hardware;
+        }
+
       }
 
       $hash{$cust_pkg} = \@svc_x;
diff --git a/FS/FS/cust_main/Location.pm b/FS/FS/cust_main/Location.pm
new file mode 100644 (file)
index 0000000..8e30bb6
--- /dev/null
@@ -0,0 +1,252 @@
+package FS::cust_main::Location;
+
+use strict;
+use vars qw( $DEBUG $me @location_fields );
+use FS::Record qw(qsearch qsearchs);
+use FS::UID qw(dbh);
+use FS::cust_location;
+
+use Carp qw(carp);
+
+$DEBUG = 0;
+$me = '[FS::cust_main::Location]';
+
+my $init = 0;
+BEGIN {
+  # set up accessors for location fields
+  if (!$init) {
+    no strict 'refs';
+    @location_fields = 
+      qw( address1 address2 city county state zip country district
+        latitude longitude coord_auto censustract censusyear geocode );
+
+    foreach my $f (@location_fields) {
+      *{"FS::cust_main::Location::$f"} = sub {
+        carp "WARNING: tried to set cust_main.$f with accessor" if (@_ > 1);
+        shift->bill_location->$f
+      };
+      *{"FS::cust_main::Location::ship_$f"} = sub {
+        carp "WARNING: tried to set cust_main.ship_$f with accessor" if (@_ > 1);
+        shift->ship_location->$f
+      };
+    }
+    $init++;
+  }
+}
+
+#debugging shim--probably a performance hit, so remove this at some point
+sub get {
+  my $self = shift;
+  my $field = shift;
+  if ( $DEBUG and grep (/^(ship_)?($field)$/, @location_fields) ) {
+    carp "WARNING: tried to get() location field $field";
+    $self->$field;
+  }
+  $self->FS::Record::get($field);
+}
+
+=head1 NAME
+
+FS::cust_main::Location - Location-related methods for cust_main
+
+=head1 DESCRIPTION
+
+These methods are available on FS::cust_main objects;
+
+=head1 METHODS
+
+=over 4
+
+=item bill_location
+
+Returns an L<FS::cust_location> object for the customer's billing address.
+
+=cut
+
+sub bill_location {
+  my $self = shift;
+  $self->hashref->{bill_location} 
+    ||= FS::cust_location->by_key($self->bill_locationnum);
+}
+
+=item ship_location
+
+Returns an L<FS::cust_location> object for the customer's service address.
+
+=cut
+
+sub ship_location {
+  my $self = shift;
+  $self->hashref->{ship_location}
+    ||= FS::cust_location->by_key($self->ship_locationnum);
+}
+
+=item location TYPE
+
+An alternative way of saying "bill_location or ship_location, depending on 
+if TYPE is 'bill' or 'ship'".
+
+=cut
+
+sub location {
+  my $self = shift;
+  return $self->bill_location if $_[0] eq 'bill';
+  return $self->ship_location if $_[0] eq 'ship';
+  die "bad location type '$_[0]'";
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item location_fields
+
+Returns a list of fields found in the location objects.  All of these fields
+can be read (but not written) by calling them as methods on the 
+L<FS::cust_main> object (prefixed with 'ship_' for the service address 
+fields).
+
+=cut
+
+sub location_fields { @location_fields }
+
+sub _upgrade_data {
+  my $class = shift;
+  eval "use FS::contact;
+        use FS::contact_class;
+        use FS::contact_phone;
+        use FS::phone_type";
+
+  local $FS::cust_location::import = 1;
+  local $DEBUG = 0;
+  my $error;
+
+  # Step 0: set up contact classes and phone types
+  my $service_contact_class = 
+    qsearchs('contact_class', { classname => 'Service'})
+    || new FS::contact_class { classname => 'Service'};
+
+  if ( !$service_contact_class->classnum ) {
+    $error = $service_contact_class->insert;
+    die "error creating contact class for Service: $error" if $error;
+  }
+  my %phone_type = ( # fudge slightly
+    daytime => 'Work',
+    night   => 'Home',
+    mobile  => 'Mobile',
+    fax     => 'Fax'
+  );
+  my $w = 10;
+  foreach (keys %phone_type) {
+    $phone_type{$_} = qsearchs('phone_type', { typename => $phone_type{$_}})
+                      || new FS::phone_type  { typename => $phone_type{$_},
+                                               weight   => $w };
+    # just in case someone still doesn't have these
+    if ( !$phone_type{$_}->phonetypenum ) {
+      $error = $phone_type{$_}->insert;
+      die "error creating phone type '$_': $error" if $error;
+    }
+  }
+
+  foreach my $cust_main (qsearch('cust_main', { bill_locationnum => '' })) {
+    # Step 1: extract billing and service addresses into cust_location
+    my $custnum = $cust_main->custnum;
+    my $bill_location = FS::cust_location->new(
+      {
+        custnum => $custnum,
+        map { $_ => $cust_main->get($_) } location_fields()
+      }
+    );
+    $error = $bill_location->insert;
+    die "error migrating billing address for customer $custnum: $error"
+      if $error;
+
+    $cust_main->set(bill_locationnum => $bill_location->locationnum);
+
+    if ( $cust_main->get('ship_address1') ) {
+      my $ship_location = FS::cust_location->new(
+        {
+          custnum => $custnum,
+          map { $_ => $cust_main->get("ship_$_") } location_fields()
+        }
+      );
+      $error = $ship_location->insert;
+      die "error migrating service address for customer $custnum: $error"
+        if $error;
+
+      $cust_main->set(ship_locationnum => $ship_location->locationnum);
+
+      # Step 2: Extract shipping address contact fields into contact
+      my %unlike = map { $_ => 1 }
+        grep { $cust_main->get($_) ne $cust_main->get("ship_$_") }
+        qw( last first company daytime night fax mobile );
+
+      if ( %unlike ) {
+        # then there IS a service contact
+        my $contact = FS::contact->new({
+          'custnum'     => $custnum,
+          'classnum'    => $service_contact_class->classnum,
+          'locationnum' => $ship_location->locationnum,
+          'last'        => $cust_main->get('ship_last'),
+          'first'       => $cust_main->get('ship_first'),
+        });
+        if ( $unlike{'company'} ) {
+          # there's no contact.company field, but keep a record of it
+          $contact->set(comment => 'Company: '.$cust_main->get('ship_company'));
+        }
+        $error = $contact->insert;
+        die "error migrating service contact for customer $custnum: $error"
+          if $error;
+
+        foreach ( grep { $unlike{$_} } qw( daytime night fax mobile ) ) {
+          my $phone = $cust_main->get("ship_$_");
+          next if !$phone;
+          my $contact_phone = FS::contact_phone->new({
+            'contactnum'    => $contact->contactnum,
+            'phonetypenum'  => $phone_type{$_}->phonetypenum,
+            FS::contact::_parse_phonestring( $phone )
+          });
+          $error = $contact_phone->insert;
+          # die "whose responsible this"
+          die "error migrating service contact phone for customer $custnum: $error"
+            if $error;
+          $cust_main->set("ship_$_" => '');
+        }
+
+        $cust_main->set("ship_$_" => '') foreach qw(last first company);
+      } #if %unlike
+    } #if ship_address1
+    else {
+      $cust_main->set(ship_locationnum => $bill_location->locationnum);
+    }
+
+    # Step 3: Wipe the migrated fields and update the cust_main
+
+    $cust_main->set("ship_$_" => '') foreach location_fields();
+    $cust_main->set($_ => '') foreach location_fields();
+
+    $error = $cust_main->replace;
+    die "error migrating addresses for customer $custnum: $error"
+      if $error;
+
+    # Step 4: set packages at the "default service location" to ship_location
+    foreach my $cust_pkg (
+      qsearch('cust_pkg', { custnum => $custnum, locationnum => '' })  
+    ) {
+      # not a location change
+      $cust_pkg->set('locationnum', $cust_main->ship_locationnum);
+      $error = $cust_pkg->replace;
+      die "error migrating package ".$cust_pkg->pkgnum.": $error"
+        if $error;
+    }
+
+  } #foreach $cust_main
+}
+
+=back
+
+=cut
+
+1;
index 06331d3..957043a 100644 (file)
@@ -40,7 +40,8 @@ FS::cust_pkg object
 
 =item cust_location
 
-Optional FS::cust_location object
+Optional FS::cust_location object.  If not specified, the customer's 
+ship_location will be used.
 
 =item svcs
 
@@ -105,6 +106,9 @@ sub order_pkg {
     }
     $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
   }
+  else {
+    $cust_pkg->locationnum($self->ship_locationnum);
+  }
 
   $cust_pkg->custnum( $self->custnum );
 
@@ -351,6 +355,7 @@ Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
 
 sub suspended_pkgs {
   my $self = shift;
+  return $self->num_suspended_pkgs unless wantarray;
   grep { $_->susp } $self->ncancelled_pkgs;
 }
 
@@ -377,6 +382,7 @@ this customer.
 
 sub unsuspended_pkgs {
   my $self = shift;
+  return $self->num_unsuspended_pkgs unless wantarray;
   grep { ! $_->susp } $self->ncancelled_pkgs;
 }
 
@@ -438,6 +444,16 @@ sub num_ncancelled_pkgs {
   shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
 }
 
+sub num_suspended_pkgs {
+  shift->num_pkgs("     ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+                    AND cust_pkg.susp IS NOT NULL AND cust_pkg.susp != 0   ");
+}
+
+sub num_unsuspended_pkgs {
+  shift->num_pkgs("     ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
+                    AND ( cust_pkg.susp   IS NULL OR cust_pkg.susp   = 0 ) ");
+}
+
 sub num_pkgs {
   my( $self ) = shift;
   my $sql = scalar(@_) ? shift : '';
index 31b89cd..b528a68 100644 (file)
@@ -85,8 +85,7 @@ sub smart_search {
       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                      ' ( '.
                          join(' OR ', map "$_ = '$phonen'",
-                                          qw( daytime night fax
-                                              ship_daytime ship_night ship_fax )
+                                          qw( daytime night fax )
                              ).
                      ' ) '.
                      " AND $agentnums_sql", #agent virtualization
@@ -101,8 +100,7 @@ sub smart_search {
         'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                        ' ( '.
                            join(' OR ', map "$_ LIKE '$phonen\%'",
-                                            qw( daytime night
-                                                ship_daytime ship_night )
+                                            qw( daytime night )
                                ).
                        ' ) '.
                        " AND $agentnums_sql", #agent virtualization
@@ -142,10 +140,12 @@ sub smart_search {
     my $num = $1;
 
     if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
+      my $agent_custid_null = $conf->exists('cust_main-default_agent_custid')
+                                ? ' AND agent_custid IS NULL ' : '';
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { 'custnum' => $num, %options },
-        'extra_sql' => " AND $agentnums_sql", #agent virtualization
+        'extra_sql' => " AND $agentnums_sql $agent_custid_null",
       } );
     }
 
@@ -175,16 +175,17 @@ sub smart_search {
     if ( $conf->exists('address1-search') ) {
       my $len = length($num);
       $num = lc($num);
-      foreach my $prefix ( '', 'ship_' ) {
-        push @cust_main, qsearch( {
-          'table'     => 'cust_main',
-          'hashref'   => { %options, },
-          'extra_sql' => 
-            ( keys(%options) ? ' AND ' : ' WHERE ' ).
-            " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
-            " AND $agentnums_sql",
-        } );
-      }
+      # probably the Right Thing: return customers that have any associated
+      # locations matching the string, not just bill/ship location
+      push @cust_main, qsearch( {
+        'table'     => 'cust_main',
+        'addl_from' => ' JOIN cust_location USING (custnum) ',
+        'hashref'   => { %options, },
+        'extra_sql' => 
+          ( keys(%options) ? ' AND ' : ' WHERE ' ).
+          " LOWER(SUBSTRING(cust_location.address1 FROM 1 FOR $len)) = '$num' ".
+          " AND $agentnums_sql",
+      } );
     }
 
   } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
@@ -196,20 +197,19 @@ sub smart_search {
     #so just do an exact search (but case-insensitive, so USPS standardization
     #doesn't throw a wrench in the works)
 
-    foreach my $prefix ( '', 'ship_' ) {
-      push @cust_main, qsearch( {
+    push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => { %options },
         'extra_sql' => 
-          ( keys(%options) ? ' AND ' : ' WHERE ' ).
-          join(' AND ',
-            " LOWER(${prefix}first)   = ". dbh->quote(lc($first)),
-            " LOWER(${prefix}last)    = ". dbh->quote(lc($last)),
-            " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
-            $agentnums_sql,
-          ),
-      } );
-    }
+        ( keys(%options) ? ' AND ' : ' WHERE ' ).
+        join(' AND ',
+          " LOWER(first)   = ". dbh->quote(lc($first)),
+          " LOWER(last)    = ". dbh->quote(lc($last)),
+          " LOWER(company) = ". dbh->quote(lc($company)),
+          $agentnums_sql,
+        ),
+      } ),
+    #contacts?
 
   } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
                                               # try (ship_){last,company}
@@ -247,16 +247,14 @@ sub smart_search {
 
       #exact
       my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-      $sql .= "
-        (     ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
-           OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
-        )";
+      $sql .= "( LOWER(cust_main.last) = $q_last AND LOWER(cust_main.first) = $q_first )";
 
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
         'hashref'   => \%options,
         'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
       } );
+      #contacts?
 
       # or it just be something that was typed in... (try that in a sec)
 
@@ -268,11 +266,13 @@ sub smart_search {
     my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
     $sql .= " (    LOWER(last)          = $q_value
                 OR LOWER(company)       = $q_value
-                OR LOWER(ship_last)     = $q_value
-                OR LOWER(ship_company)  = $q_value
             ";
-    $sql .= "   OR LOWER(address1)      = $q_value
-                OR LOWER(ship_address1) = $q_value
+    #yes, it's a kludge
+    $sql .= "   OR EXISTS( 
+                SELECT 1 FROM cust_location 
+                WHERE LOWER(cust_location.address1) = $q_value
+                  AND cust_location.custnum = cust_main.custnum
+            )
             "
       if $conf->exists('address1-search');
     $sql .= " )";
@@ -294,32 +294,21 @@ sub smart_search {
 
       my @hashrefs = (
         { 'company'      => { op=>'ILIKE', value=>"%$value%" }, },
-        { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
       );
 
       if ( $first && $last ) {
+        #contacts? ship_first/ship_last are gone
 
         push @hashrefs,
           { 'first'        => { op=>'ILIKE', value=>"%$first%" },
             'last'         => { op=>'ILIKE', value=>"%$last%" },
           },
-          { 'ship_first'   => { op=>'ILIKE', value=>"%$first%" },
-            'ship_last'    => { op=>'ILIKE', value=>"%$last%" },
-          },
         ;
 
       } else {
 
         push @hashrefs,
           { 'last'         => { op=>'ILIKE', value=>"%$value%" }, },
-          { 'ship_last'    => { op=>'ILIKE', value=>"%$value%" }, },
-        ;
-      }
-
-      if ( $conf->exists('address1-search') ) {
-        push @hashrefs,
-          { 'address1'      => { op=>'ILIKE', value=>"%$value%" }, },
-          { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
         ;
       }
 
@@ -335,27 +324,38 @@ sub smart_search {
 
       }
 
+      if ( $conf->exists('address1-search') ) {
+
+        push @cust_main, qsearch( {
+          'table'     => 'cust_main',
+          'addl_from' => 'JOIN cust_location USING (custnum)',
+          'extra_sql' => 'WHERE cust_location.address1 ILIKE '.
+                          dbh->quote("%$value%"),
+        } );
+
+      }
+
       #fuzzy
-      my @fuzopts = (
-        \%options,                #hashref
-        '',                       #select
-        " AND $agentnums_sql",    #extra_sql  #agent virtualization
+      my %fuzopts = (
+        'hashref'   => \%options,
+        'select'    => '',
+        'extra_sql' => " AND $agentnums_sql",    #agent virtualization
       );
 
       if ( $first && $last ) {
         push @cust_main, FS::cust_main::Search->fuzzy_search(
           { 'last'   => $last,    #fuzzy hashref
             'first'  => $first }, #
-          @fuzopts
+          %fuzopts
         );
       }
       foreach my $field ( 'last', 'company' ) {
         push @cust_main,
-          FS::cust_main::Search->fuzzy_search( { $field => $value }, @fuzopts );
+          FS::cust_main::Search->fuzzy_search( { $field => $value }, %fuzopts );
       }
       if ( $conf->exists('address1-search') ) {
         push @cust_main,
-          FS::cust_main::Search->fuzzy_search( { 'address1' => $value }, @fuzopts );
+          FS::cust_main::Search->fuzzy_search( { 'address1' => $value }, %fuzopts );
       }
 
     }
@@ -467,6 +467,14 @@ bool
 
 listref of start date, end date
 
+=item birthdate
+
+listref of start date, end date
+
+=item spouse_birthdate
+
+listref of start date, end date
+
 =item payby
 
 listref
@@ -558,18 +566,28 @@ sub search {
   ##
   if ( $params->{'address'} =~ /\S/ ) {
     my $address = dbh->quote('%'. lc($params->{'address'}). '%');
-    push @where, '('. join(' OR ',
-                             map "LOWER($_) LIKE $address",
-                               qw(address1 address2 ship_address1 ship_address2)
-                          ).
-                 ')';
+    push @where, "EXISTS(
+      SELECT 1 FROM cust_location 
+      WHERE cust_location.custnum = cust_main.custnum
+        AND (LOWER(cust_location.address1) LIKE $address OR
+             LOWER(cust_location.address2) LIKE $address)
+    )";
   }
 
   ###
   # refnum
   ###
-  if ( $params->{'refnum'} =~ /^(\d+)$/ ) {
-    push @where, "refnum = $1";
+  if ( $params->{'refnum'}  ) {
+
+    my @refnum = ref( $params->{'refnum'} )
+                   ? @{ $params->{'refnum'} }
+                   :  ( $params->{'refnum'} );
+
+    @refnum = grep /^(\d*)$/, @refnum;
+
+    push @where, '( '. join(' OR ', map "cust_main.refnum = $_", @refnum ). ' )'
+      if @refnum;
+
   }
 
   ##
@@ -599,7 +617,7 @@ sub search {
   # dates
   ##
 
-  foreach my $field (qw( signupdate )) {
+  foreach my $field (qw( signupdate birthdate spouse_birthdate )) {
 
     next unless exists($params->{$field});
 
@@ -610,7 +628,7 @@ sub search {
       "cust_main.$field >= $beginning",
       "cust_main.$field <= $ending";
 
-    if(defined $hour) {
+    if($field eq 'signupdate' && defined $hour) {
       if ($dbh->{Driver}->{Name} =~ /Pg/i) {
         push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
       }
@@ -770,22 +788,33 @@ sub search {
   if ($params->{'flattened_pkgs'}) {
 
     #my $pkg_join = '';
+    $addl_from .= ' LEFT JOIN cust_pkg USING ( custnum ) ';
 
     if ($dbh->{Driver}->{Name} eq 'Pg') {
 
-      push @select, "array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
+      push @select, "
+        ARRAY_TO_STRING(
+          ARRAY(
+            SELECT pkg FROM cust_pkg LEFT JOIN part_pkg USING ( pkgpart )
+              WHERE cust_main.custnum = cust_pkg.custnum $pkgwhere
+          ), '|'
+        ) AS magic
+      ";
 
     } elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
       push @select, "GROUP_CONCAT(part_pkg.pkg SEPARATOR '|') as magic";
-      $addl_from .= ' LEFT JOIN cust_pkg USING ( custnum ) '; #Pg too w/flatpkg?
       $addl_from .= ' LEFT JOIN part_pkg USING ( pkgpart ) ';
       #$pkg_join  .= ' LEFT JOIN part_pkg USING ( pkgpart ) ';
     } else {
       warn "warning: unknown database type ". $dbh->{Driver}->{Name}. 
-           "omitting packing information from report.";
+           "omitting package information from report.";
     }
 
-    my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
+    my $header_query = "
+      SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count
+        FROM cust_main $addl_from $extra_sql $pkgwhere
+          GROUP BY cust_main.custnum ORDER BY count DESC LIMIT 1
+    ";
 
     my $sth = dbh->prepare($header_query) or die dbh->errstr;
     $sth->execute() or die $sth->errstr;
@@ -831,20 +860,27 @@ sub search {
 
 }
 
-=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
+=item fuzzy_search FUZZY_HASHREF [ OPTS ]
 
 Performs a fuzzy (approximate) search and returns the matching FS::cust_main
 records.  Currently, I<first>, I<last>, I<company> and/or I<address1> may be
-specified (the appropriate ship_ field is also searched).
+specified.
 
 Additional options are the same as FS::Record::qsearch
 
 =cut
 
 sub fuzzy_search {
-  my( $self, $fuzzy, $hash, @opt) = @_;
-  #$self
-  $hash ||= {};
+  my( $self, $fuzzy ) = @_;
+  # sensible defaults, then merge in any passed options
+  my %fuzopts = (
+    'table'     => 'cust_main',
+    'addl_from' => '',
+    'extra_sql' => '',
+    'hashref'   => {},
+    @_
+  );
+
   my @cust_main = ();
 
   check_and_rebuild_fuzzyfiles();
@@ -858,8 +894,25 @@ sub fuzzy_search {
 
     my @fcust = ();
     foreach ( keys %match ) {
-      push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
-      push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
+      if ( $field eq 'address1' ) {
+        #because it lives outside the table
+        my $addl_from = $fuzopts{addl_from} .
+                        'JOIN cust_location USING (custnum)';
+        my $extra_sql = $fuzopts{extra_sql} .
+                        " AND cust_location.address1 = ".dbh->quote($_);
+        push @fcust, qsearch({
+            %fuzopts,
+            'addl_from' => $addl_from,
+            'extra_sql' => $extra_sql,
+        });
+      } else {
+        my $hash = $fuzopts{hashref};
+        $hash->{$field} = $_;
+        push @fcust, qsearch({
+            %fuzopts,
+            'hashref' => $hash
+        });
+      }
     }
     my %fsaw = ();
     push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
index e937b20..6316f23 100644 (file)
@@ -176,7 +176,7 @@ with different tax classes.
 sub sql_taxclass_sameregion {
   my $self = shift;
 
-  my $same_query = 'SELECT taxclass FROM cust_main_county '.
+  my $same_query = 'SELECT DISTINCT taxclass FROM cust_main_county '.
                    ' WHERE taxnum != ? AND country = ?';
   my @same_param = ( 'taxnum', 'country' );
   foreach my $opt_field (qw( state county )) {
index 06d22b7..c6f3d5e 100644 (file)
@@ -3,6 +3,7 @@ package FS::cust_main_exemption;
 use strict;
 use base qw( FS::Record );
 use FS::Record qw( qsearch qsearchs );
+use FS::Conf;
 use FS::cust_main;
 
 =head1 NAME
@@ -44,6 +45,9 @@ Customer (see L<FS::cust_main>)
 
 taxname
 
+=item exempt_number
+
+Exemption number
 
 =back
 
@@ -108,9 +112,15 @@ sub check {
     $self->ut_numbern('exemptionnum')
     || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
     || $self->ut_text('taxname')
+    || $self->ut_textn('exempt_number')
   ;
   return $error if $error;
 
+  my $conf = new FS::Conf;
+  if ( ! $self->exempt_number && $conf->exists('tax-cust_exempt-groups-require_individual_nums') ) {
+    return 'Tax exemption number required for '. $self->taxname. ' exemption';
+  }
+
   $self->SUPER::check;
 }
 
index ef30809..2a2b9d0 100644 (file)
@@ -22,6 +22,7 @@ use FS::cust_pay_refund;
 use FS::cust_main;
 use FS::cust_pkg;
 use FS::cust_pay_void;
+use FS::upgrade_journal;
 
 $DEBUG = 0;
 
@@ -87,7 +88,7 @@ order taker (see L<FS::access_user>)
 
 =item payby
 
-Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
+Payment Type (See L<FS::payinfo_Mixin> for valid values)
 
 =item payinfo
 
@@ -582,7 +583,7 @@ sub send_receipt {
 
   my $conf = new FS::Conf;
 
-  return '' unless $conf->exists('payment_receipt', $cust_main->agentnum);
+  return '' unless $conf->config_bool('payment_receipt', $cust_main->agentnum);
 
   my @invoicing_list = $cust_main->invoicing_list_emailonly;
   return '' unless @invoicing_list;
@@ -760,6 +761,12 @@ objects.  Returns a list, each element representing the status of inserting the
 corresponding payment - empty.  If there is an error inserting any payment, the
 entire transaction is rolled back, i.e. all payments are inserted or none are.
 
+FS::cust_pay objects may have the pseudo-field 'apply_to', containing a 
+reference to an array of (uninserted) FS::cust_bill_pay objects.  If so,
+those objects will be inserted with the paynum of the payment, and for 
+each one, an error message or an empty string will be inserted into the 
+list of errors.
+
 For example:
 
   my @errors = FS::cust_pay->batch_insert(@cust_pay);
@@ -786,19 +793,35 @@ sub batch_insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $errors = 0;
+  my $num_errors = 0;
   
-  my @errors = map {
-    my $error = $_->insert( 'manual' => 1 );
-    if ( $error ) { 
-      $errors++;
-    } else {
-      $_->cust_main->apply_payments;
+  my @errors;
+  foreach my $cust_pay (@_) {
+    my $error = $cust_pay->insert( 'manual' => 1 );
+    push @errors, $error;
+    $num_errors++ if $error;
+
+    if ( ref($cust_pay->get('apply_to')) eq 'ARRAY' ) {
+
+      foreach my $cust_bill_pay ( @{ $cust_pay->apply_to } ) {
+        if ( $error ) { # insert placeholders if cust_pay wasn't inserted
+          push @errors, '';
+        }
+        else {
+          $cust_bill_pay->set('paynum', $cust_pay->paynum);
+          my $apply_error = $cust_bill_pay->insert;
+          push @errors, $apply_error || '';
+          $num_errors++ if $apply_error;
+        }
+      }
+
+    } elsif ( !$error ) { #normal case: apply payments as usual
+      $cust_pay->cust_main->apply_payments;
     }
-    $error;
-  } @_;
 
-  if ( $errors ) {
+  }
+
+  if ( $num_errors ) {
     $dbh->rollback if $oldAutoCommit;
   } else {
     $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -853,93 +876,103 @@ sub _upgrade_data {  #class method
   # otaker/ivan upgrade
   ##
 
-  #not the most efficient, but hey, it only has to run once
+  unless ( FS::upgrade_journal->is_done('cust_pay__otaker_ivan') ) {
 
-  my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
-              "  AND usernum IS NULL ".
-              "  AND 0 < ( SELECT COUNT(*) FROM cust_main                 ".
-              "              WHERE cust_main.custnum = cust_pay.custnum ) ";
+    #not the most efficient, but hey, it only has to run once
 
-  my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
+    my $where = "WHERE ( otaker IS NULL OR otaker = '' OR otaker = 'ivan' ) ".
+                "  AND usernum IS NULL ".
+                "  AND 0 < ( SELECT COUNT(*) FROM cust_main                 ".
+                "              WHERE cust_main.custnum = cust_pay.custnum ) ";
 
-  my $sth = dbh->prepare($count_sql) or die dbh->errstr;
-  $sth->execute or die $sth->errstr;
-  my $total = $sth->fetchrow_arrayref->[0];
-  #warn "$total cust_pay records to update\n"
-  #  if $DEBUG;
-  local($DEBUG) = 2 if $total > 1000; #could be a while, force progress info
+    my $count_sql = "SELECT COUNT(*) FROM cust_pay $where";
 
-  my $count = 0;
-  my $lastprog = 0;
+    my $sth = dbh->prepare($count_sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+    my $total = $sth->fetchrow_arrayref->[0];
+    #warn "$total cust_pay records to update\n"
+    #  if $DEBUG;
+    local($DEBUG) = 2 if $total > 1000; #could be a while, force progress info
 
-  my @cust_pay = qsearch( {
-      'table'     => 'cust_pay',
-      'hashref'   => {},
-      'extra_sql' => $where,
-      'order_by'  => 'ORDER BY paynum',
-  } );
+    my $count = 0;
+    my $lastprog = 0;
 
-  foreach my $cust_pay (@cust_pay) {
+    my @cust_pay = qsearch( {
+        'table'     => 'cust_pay',
+        'hashref'   => {},
+        'extra_sql' => $where,
+        'order_by'  => 'ORDER BY paynum',
+    } );
 
-    my $h_cust_pay = $cust_pay->h_search('insert');
-    if ( $h_cust_pay ) {
-      next if $cust_pay->otaker eq $h_cust_pay->history_user;
-      #$cust_pay->otaker($h_cust_pay->history_user);
-      $cust_pay->set('otaker', $h_cust_pay->history_user);
-    } else {
-      $cust_pay->set('otaker', 'legacy');
-    }
+    foreach my $cust_pay (@cust_pay) {
+
+      my $h_cust_pay = $cust_pay->h_search('insert');
+      if ( $h_cust_pay ) {
+        next if $cust_pay->otaker eq $h_cust_pay->history_user;
+        #$cust_pay->otaker($h_cust_pay->history_user);
+        $cust_pay->set('otaker', $h_cust_pay->history_user);
+      } else {
+        $cust_pay->set('otaker', 'legacy');
+      }
 
-    delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
-    my $error = $cust_pay->replace;
+      delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
+      my $error = $cust_pay->replace;
 
-    if ( $error ) {
-      warn " *** WARNING: Error updating order taker for payment paynum ".
-           $cust_pay->paynun. ": $error\n";
-      next;
-    }
+      if ( $error ) {
+        warn " *** WARNING: Error updating order taker for payment paynum ".
+             $cust_pay->paynun. ": $error\n";
+        next;
+      }
+
+      $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
 
-    $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
+      $count++;
+      if ( $DEBUG > 1 && $lastprog + 30 < time ) {
+        warn "$me $count/$total (".sprintf('%.2f',100*$count/$total). '%)'."\n";
+        $lastprog = time;
+      }
 
-    $count++;
-    if ( $DEBUG > 1 && $lastprog + 30 < time ) {
-      warn "$me $count/$total (". sprintf('%.2f',100*$count/$total). '%)'. "\n";
-      $lastprog = time;
     }
 
+    FS::upgrade_journal->set_done('cust_pay__otaker_ivan');
   }
 
   ###
   # payinfo N/A upgrade
   ###
 
-  #XXX remove the 'N/A (tokenized)' part (or just this entire thing)
+  unless ( FS::upgrade_journal->is_done('cust_pay__payinfo_na') ) {
 
-  my @na_cust_pay = qsearch( {
-    'table'     => 'cust_pay',
-    'hashref'   => {}, #could be encrypted# { 'payinfo' => 'N/A' },
-    'extra_sql' => "WHERE ( payinfo = 'N/A' OR paymask = 'N/AA' OR paymask = 'N/A (tokenized)' ) AND payby IN ( 'CARD', 'CHEK' )",
-  } );
+    #XXX remove the 'N/A (tokenized)' part (or just this entire thing)
 
-  foreach my $na ( @na_cust_pay ) {
+    my @na_cust_pay = qsearch( {
+      'table'     => 'cust_pay',
+      'hashref'   => {}, #could be encrypted# { 'payinfo' => 'N/A' },
+      'extra_sql' => "WHERE ( payinfo = 'N/A' OR paymask = 'N/AA' OR paymask = 'N/A (tokenized)' ) AND payby IN ( 'CARD', 'CHEK' )",
+    } );
 
-    next unless $na->payinfo eq 'N/A';
+    foreach my $na ( @na_cust_pay ) {
+
+      next unless $na->payinfo eq 'N/A';
+
+      my $cust_pay_pending =
+        qsearchs('cust_pay_pending', { 'paynum' => $na->paynum } );
+      unless ( $cust_pay_pending ) {
+        warn " *** WARNING: not-yet recoverable N/A card for payment ".
+             $na->paynum. " (no cust_pay_pending)\n";
+        next;
+      }
+      $na->$_($cust_pay_pending->$_) for qw( payinfo paymask );
+      my $error = $na->replace;
+      if ( $error ) {
+        warn " *** WARNING: Error updating payinfo for payment paynum ".
+             $na->paynun. ": $error\n";
+        next;
+      }
 
-    my $cust_pay_pending =
-      qsearchs('cust_pay_pending', { 'paynum' => $na->paynum } );
-    unless ( $cust_pay_pending ) {
-      warn " *** WARNING: not-yet recoverable N/A card for payment ".
-           $na->paynum. " (no cust_pay_pending)\n";
-      next;
-    }
-    $na->$_($cust_pay_pending->$_) for qw( payinfo paymask );
-    my $error = $na->replace;
-    if ( $error ) {
-      warn " *** WARNING: Error updating payinfo for payment paynum ".
-           $na->paynun. ": $error\n";
-      next;
     }
 
+    FS::upgrade_journal->set_done('cust_pay__payinfo_na');
   }
 
   ###
index bee1b82..22559e9 100644 (file)
@@ -10,9 +10,9 @@ use List::Util qw(max);
 use Tie::IxHash;
 use Time::Local qw( timelocal timelocal_nocheck );
 use MIME::Entity;
-use FS::UID qw( getotaker dbh );
+use FS::UID qw( getotaker dbh driver_name );
 use FS::Misc qw( send_email );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs fields );
 use FS::CurrentUser;
 use FS::cust_svc;
 use FS::part_pkg;
@@ -879,6 +879,154 @@ sub cancel_if_expired {
   '';
 }
 
+=item uncancel
+
+"Un-cancels" this package: Orders a new package with the same custnum, pkgpart,
+locationnum, (other fields?).  Attempts to re-provision cancelled services
+using history information (errors at this stage are not fatal).
+
+cust_pkg: pass a scalar reference, will be filled in with the new cust_pkg object
+
+svc_fatal: service provisioning errors are fatal
+
+svc_errors: pass an array reference, will be filled in with any provisioning errors
+
+=cut
+
+sub uncancel {
+  my( $self, %options ) = @_;
+
+  #in case you try do do $uncancel-date = $cust_pkg->uncacel 
+  return '' unless $self->get('cancel');
+
+  ##
+  # Transaction-alize
+  ##
+
+  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;
+
+  ##
+  # insert the new package
+  ##
+
+  my $cust_pkg = new FS::cust_pkg {
+    last_bill       => ( $options{'last_bill'} || $self->get('last_bill') ),
+    bill            => ( $options{'bill'}      || $self->get('bill')      ),
+    uncancel        => time,
+    uncancel_pkgnum => $self->pkgnum,
+    map { $_ => $self->get($_) } qw(
+      custnum pkgpart locationnum
+      setup
+      susp adjourn resume expire start_date contract_end dundate
+      change_date change_pkgpart change_locationnum
+      manual_flag no_auto quantity agent_pkgid recur_show_zero setup_show_zero
+    ),
+  };
+
+  my $error = $cust_pkg->insert(
+    'change' => 1, #supresses any referral credit to a referring customer
+  );
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  ##
+  # insert services
+  ##
+
+  #find historical services within this timeframe before the package cancel
+  # (incompatible with "time" option to cust_pkg->cancel?)
+  my $fuzz = 2 * 60; #2 minutes?  too much?   (might catch separate unprovision)
+                     #            too little? (unprovisioing export delay?)
+  my($end, $start) = ( $self->get('cancel'), $self->get('cancel') - $fuzz );
+  my @h_cust_svc = $self->h_cust_svc( $end, $start );
+
+  my @svc_errors;
+  foreach my $h_cust_svc (@h_cust_svc) {
+    my $h_svc_x = $h_cust_svc->h_svc_x( $end, $start );
+    #next unless $h_svc_x; #should this happen?
+    (my $table = $h_svc_x->table) =~ s/^h_//;
+    require "FS/$table.pm";
+    my $class = "FS::$table";
+    my $svc_x = $class->new( {
+      'pkgnum'  => $cust_pkg->pkgnum,
+      'svcpart' => $h_cust_svc->svcpart,
+      map { $_ => $h_svc_x->get($_) } fields($table)
+    } );
+
+    # radius_usergroup
+    if ( $h_svc_x->isa('FS::h_svc_Radius_Mixin') ) {
+      $svc_x->usergroup( [ $h_svc_x->h_usergroup($end, $start) ] );
+    }
+
+    my $svc_error = $svc_x->insert;
+    if ( $svc_error && $options{svc_fatal} ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $svc_error;
+    } else {
+      my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svc_x->svcnum });
+      if ( $cust_svc ) {
+        my $cs_error = $cust_svc->delete;
+        if ( $cs_error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $cs_error;
+        }
+      }
+    }
+    push @svc_errors, $svc_error if $svc_error;
+  }
+
+  #these are pretty rare, but should handle them
+  # - dsl_device (mac addresses)
+  # - phone_device (mac addresses)
+  # - dsl_note (ikano notes)
+  # - domain_record (i.e. restore DNS information w/domains)
+  # - inventory_item(?) (inventory w/un-cancelling service?)
+  # - nas (svc_broaband nas stuff)
+  #this stuff is unused in the wild afaik
+  # - mailinglistmember
+  # - router.svcnum?
+  # - svc_domain.parent_svcnum?
+  # - acct_snarf (ancient mail fetching config)
+  # - cgp_rule (communigate)
+  # - cust_svc_option (used by our Tron stuff)
+  # - acct_rt_transaction (used by our time worked stuff)
+
+  ##
+  # also move over any services that didn't unprovision at cancellation
+  ## 
+
+  foreach my $cust_svc ( qsearch('cust_svc', { pkgnum => $self->pkgnum } ) ) {
+    $cust_svc->pkgnum( $cust_pkg->pkgnum );
+    my $error = $cust_svc->replace;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  ##
+  # Finish
+  ##
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  ${ $options{cust_pkg} }   = $cust_pkg   if ref($options{cust_pkg});
+  @{ $options{svc_errors} } = @svc_errors if ref($options{svc_errors});
+
+  '';
+}
+
 =item unexpire
 
 Cancels any pending expiration (sets the expire field to null).
@@ -1239,6 +1387,8 @@ sub unsuspend {
   
   } #if $date 
 
+  my @labels = ();
+
   foreach my $cust_svc (
     qsearch('cust_svc',{'pkgnum'=> $self->pkgnum } )
   ) {
@@ -1258,6 +1408,8 @@ sub unsuspend {
         $dbh->rollback if $oldAutoCommit;
         return $error;
       }
+      my( $label, $value ) = $cust_svc->label;
+      push @labels, "$label: $value";
     }
 
   }
@@ -1288,6 +1440,29 @@ sub unsuspend {
     return $error;
   }
 
+  if ( $conf->config('unsuspend_email_admin') ) {
+    my $error = send_email(
+      'from'    => $conf->config('invoice_from', $self->cust_main->agentnum),
+                                 #invoice_from ??? well as good as any
+      'to'      => $conf->config('unsuspend_email_admin'),
+      'subject' => 'FREESIDE NOTIFICATION: Customer package unsuspended',       'body'    => [
+        "This is an automatic message from your Freeside installation\n",
+        "informing you that the following customer package has been unsuspended:\n",
+        "\n",
+        'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
+        'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
+        ( map { "Service : $_\n" } @labels ),
+      ],
+    );
+
+    if ( $error ) {
+      warn "WARNING: can't send unsuspension admin email (unsuspending anyway): ".
+           "$error\n";
+    }
+
+  }
+
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
   ''; #no errors
@@ -1895,7 +2070,7 @@ sub cust_svc {
   }
   if ( $opt{'svcdb'} ) {
     $search{addl_from} = ' LEFT JOIN part_svc USING ( svcpart ) ';
-    $search{hashref}->{svcdb} = $opt{'svcdb'};
+    $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
   }
 
   cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
@@ -2029,11 +2204,14 @@ field, I<num_avail>, which specifies the number of available services.
 
 sub available_part_svc {
   my $self = shift;
+
+  my $pkg_quantity = $self->quantity || 1;
+
   grep { $_->num_avail > 0 }
     map {
           my $part_svc = $_->part_svc;
           $part_svc->{'Hash'}{'num_avail'} = #evil encapsulation-breaking
-            $_->quantity - $self->num_cust_svc($_->svcpart);
+            $pkg_quantity * $_->quantity - $self->num_cust_svc($_->svcpart);
 
          # more evil encapsulation breakage
          if($part_svc->{'Hash'}{'num_avail'} > 0) {
@@ -2075,6 +2253,8 @@ sub part_svc {
   my $self = shift;
   my %opt = @_;
 
+  my $pkg_quantity = $self->quantity || 1;
+
   #XXX some sort of sort order besides numeric by svcpart...
   my @part_svc = sort { $a->svcpart <=> $b->svcpart } map {
     my $pkg_svc = $_;
@@ -2082,7 +2262,7 @@ sub part_svc {
     my $num_cust_svc = $self->num_cust_svc($part_svc->svcpart);
     $part_svc->{'Hash'}{'num_cust_svc'} = $num_cust_svc; #more evil
     $part_svc->{'Hash'}{'num_avail'}    =
-      max( 0, $pkg_svc->quantity - $num_cust_svc );
+      max( 0, $pkg_quantity * $pkg_svc->quantity - $num_cust_svc );
     $part_svc->{'Hash'}{'cust_pkg_svc'} =
         $num_cust_svc ? [ $self->cust_svc($part_svc->svcpart) ] : []
       unless exists($opt{summarize_size}) && $opt{summarize_size} > 0
@@ -2441,6 +2621,39 @@ Returns the label of the location object (see L<FS::cust_location>).
 
 #end of subs in location_Mixin.pm now... unfortunately the POD doesn't mixin
 
+=item tax_locationnum
+
+Returns the foreign key to a L<FS::cust_location> object for calculating  
+tax on this package, as determined by the C<tax-pkg_address> and 
+C<tax-ship_address> configuration flags.
+
+=cut
+
+sub tax_locationnum {
+  my $self = shift;
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('tax-pkg_address') ) {
+    return $self->locationnum;
+  }
+  elsif ( $conf->exists('tax-ship_address') ) {
+    return $self->cust_main->ship_locationnum;
+  }
+  else {
+    return $self->cust_main->bill_locationnum;
+  }
+}
+
+=item tax_location
+
+Returns the L<FS::cust_location> object for tax_locationnum.
+
+=cut
+
+sub tax_location {
+  my $self = shift;
+  FS::cust_location->by_key( $self->tax_locationnum )
+}
+
 =item seconds_since TIMESTAMP
 
 Returns the number of seconds all accounts (see L<FS::svc_acct>) in this
@@ -3427,6 +3640,25 @@ sub fcc_477_count {
 
 }
 
+=item tax_locationnum_sql
+
+Returns an SQL expression for the tax location for a package, based
+on the settings of 'tax-pkg_address' and 'tax-ship_address'.
+
+=cut
+
+sub tax_locationnum_sql {
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('tax-pkg_address') ) {
+    'cust_pkg.locationnum';
+  }
+  elsif ( $conf->exists('tax-ship_address') ) {
+    'cust_main.ship_locationnum';
+  }
+  else {
+    'cust_main.bill_locationnum';
+  }
+}
 
 =item location_sql
 
@@ -3445,7 +3677,13 @@ sub location_sql {
 
   # '?' placeholders in _location_sql_where
   my $x = $ornull ? 3 : 2;
-  my @bill_param = ( ('city')x3, ('county')x$x, ('state')x$x, 'country' );
+  my @bill_param = ( 
+    ('district')x3,
+    ('city')x3, 
+    ('county')x$x,
+    ('state')x$x,
+    'country'
+  );
 
   my $main_where;
   my @main_param;
@@ -3504,16 +3742,19 @@ sub _location_sql_where {
 
   $ornull = $ornull ? ' OR ? IS NULL ' : '';
 
-  my $or_empty_city   = " OR ( ? = '' AND $table.${prefix}city   IS NULL ) ";
-  my $or_empty_county = " OR ( ? = '' AND $table.${prefix}county IS NULL ) ";
-  my $or_empty_state =  " OR ( ? = '' AND $table.${prefix}state  IS NULL ) ";
+  my $or_empty_city     = " OR ( ? = '' AND $table.${prefix}city     IS NULL )";
+  my $or_empty_county   = " OR ( ? = '' AND $table.${prefix}county   IS NULL )";
+  my $or_empty_state    = " OR ( ? = '' AND $table.${prefix}state    IS NULL )";
+
+  my $text = (driver_name =~ /^mysql/i) ? 'char' : 'text';
 
 #        ( $table.${prefix}city    = ? $or_empty_city   $ornull )
   "
-        ( $table.${prefix}city    = ? OR ? = '' OR CAST(? AS text) IS NULL )
-    AND ( $table.${prefix}county  = ? $or_empty_county $ornull )
-    AND ( $table.${prefix}state   = ? $or_empty_state  $ornull )
-    AND   $table.${prefix}country = ?
+        ( $table.district = ? OR ? = '' OR CAST(? AS $text) IS NULL )
+    AND ( $table.${prefix}city     = ? OR ? = '' OR CAST(? AS $text) IS NULL )
+    AND ( $table.${prefix}county   = ? $or_empty_county $ornull )
+    AND ( $table.${prefix}state    = ? $or_empty_state  $ornull )
+    AND   $table.${prefix}country  = ?
   ";
 }
 
index 641605f..c29a2f9 100644 (file)
@@ -4,6 +4,7 @@ use strict;
 use vars qw( $ignore_empty_action );
 use base qw( FS::otaker_Mixin FS::Record );
 use FS::Record qw( qsearch qsearchs );
+use FS::upgrade_journal;
 
 $ignore_empty_action = 0;
 
@@ -209,6 +210,25 @@ sub _upgrade_data { # class method
   }
 
   #remove nullability if scalar(@migrated) - $count == 0 && ->column('action');
+
+  unless ( FS::upgrade_journal->is_done('cust_pkg_reason__missing_reason') ) {
+    $class->_upgrade_missing_reason(%opts);
+    FS::upgrade_journal->set_done('cust_pkg_reason__missing_reason');
+  }
+
+  #still can't fill in an action?  don't abort the upgrade
+  local($ignore_empty_action) = 1;
+
+  $class->_upgrade_otaker(%opts);
+
+}
+
+sub _upgrade_missing_reason {
+  my ($class, %opts) = @_;
+
+  #false laziness w/above
+  my $action_replace =
+    " AND ( history_action = 'replace_old' OR history_action = 'replace_new' )";
   
   #seek expirations/adjourns without reason
   foreach my $field (qw( expire adjourn cancel susp )) {
@@ -309,10 +329,6 @@ sub _upgrade_data { # class method
     }
   }
 
-  #still can't fill in an action?  don't abort the upgrade
-  local($ignore_empty_action) = 1;
-
-  $class->_upgrade_otaker(%opts);
 }
 
 =back
index 6bd8cb8..2ec8f12 100644 (file)
@@ -69,6 +69,8 @@ The following fields are currently supported:
 
 =item svcpart - Service definition (see L<FS::part_svc>)
 
+=item agent_svcid - Optional legacy service ID
+
 =item overlimit - date the service exceeded its usage limit
 
 =back
@@ -319,6 +321,7 @@ sub check {
     $self->ut_numbern('svcnum')
     || $self->ut_numbern('pkgnum')
     || $self->ut_number('svcpart')
+    || $self->ut_numbern('agent_svcid')
     || $self->ut_numbern('overlimit')
   ;
   return $error if $error;
@@ -341,6 +344,18 @@ sub check {
   $self->SUPER::check;
 }
 
+=item display_svcnum 
+
+Returns the displayed service number for this service: agent_svcid if it has a
+value, svcnum otherwise
+
+=cut
+
+sub display_svcnum {
+  my $self = shift;
+  $self->agent_svcid || $self->svcnum;
+}
+
 =item part_svc
 
 Returns the definition for this service, as a FS::part_svc object (see
@@ -831,24 +846,37 @@ customers, this always requires an exact match.
 =cut
 
 # though perhaps it should be fuzzy in some cases?
+
 sub smart_search {
+  my %param = __PACKAGE__->smart_search_param(@_);
+  qsearch(\%param);
+}
+
+sub smart_search_param {
+  my $class = shift;
   my %opt = @_;
-  # some false laziness w/ search/cust_svc.html
+
   my $string = $opt{'search'};
   $string =~ s/(^\s+|\s+$)//; #trim leading & trailing whitespace
 
-  my @extra_sql = ' ( '. join(' OR ',
-    map { my $table = $_;
-      my $search_sql = "FS::$table"->search_sql($string);
-      " ( svcdb = '$table'
-      AND 0 < ( SELECT COUNT(*) FROM $table
-      WHERE $table.svcnum = cust_svc.svcnum
-      AND $search_sql
-      )
-      ) ";
-    }
-    FS::part_svc->svc_tables
-  ). ' ) ';
+  my @or = 
+      map { my $table = $_;
+            my $search_sql = "FS::$table"->search_sql($string);
+            " ( svcdb = '$table'
+               AND 0 < ( SELECT COUNT(*) FROM $table
+                           WHERE $table.svcnum = cust_svc.svcnum
+                             AND $search_sql
+                       )
+             ) ";
+          }
+      FS::part_svc->svc_tables;
+
+  if ( $string =~ /^(\d+)$/ ) {
+    unshift @or, " ( agent_svcid IS NOT NULL AND agent_svcid = $1 ) ";
+  }
+
+  my @extra_sql = ' ( '. join(' OR ', @or). ' ) ';
+
   push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql(
     'null_right' => 'View/link unlinked services'
   );
@@ -858,14 +886,16 @@ sub smart_search {
                   ' LEFT JOIN cust_main USING ( custnum )'.
                   ' LEFT JOIN part_svc  USING ( svcpart )';
 
-  qsearch({
-      'table'     => 'cust_svc',
-      'addl_from' => $addl_from,
-      'hashref'   => {},
-      'extra_sql' => $extra_sql,
-  });
+  (
+    'table'     => 'cust_svc',
+    'addl_from' => $addl_from,
+    'hashref'   => {},
+    'extra_sql' => $extra_sql,
+  );
 }
 
+=back
+
 =head1 BUGS
 
 Behaviour of changing the svcpart of cust_svc records is undefined and should
index d70ad0e..0459041 100644 (file)
@@ -71,7 +71,7 @@ sub finish {
       $prefix,
       map({ 
           $_->{count},
-          (int($_->{duration}/60) . ' min'),
+          sprintf('%.01f min', $_->{duration}/60),
         } @subtotals ),
       $self->money_char . sprintf('%.02f',$total_amount),
     );
diff --git a/FS/FS/ftp_target.pm b/FS/FS/ftp_target.pm
new file mode 100644 (file)
index 0000000..bf9fc89
--- /dev/null
@@ -0,0 +1,194 @@
+package FS::ftp_target;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use vars qw($me $DEBUG);
+
+$DEBUG = 0;
+
+=head1 NAME
+
+FS::ftp_target - Object methods for ftp_target records
+
+=head1 SYNOPSIS
+
+  use FS::ftp_target;
+
+  $record = new FS::ftp_target \%hash;
+  $record = new FS::ftp_target { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::ftp_target object represents an account on a remote FTP or SFTP 
+server for transferring files.  FS::ftp_target inherits from FS::Record.
+
+=over 4
+
+=item targetnum - primary key
+
+=item agentnum - L<FS::agent> foreign key; can be null
+
+=item hostname - the DNS name of the FTP site
+
+=item username - username
+
+=item password - password
+
+=item path - the working directory to change to upon connecting
+
+=item secure - a flag ('Y' or null) for whether to use SFTP
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+sub table { 'ftp_target'; }
+
+=item new HASHREF
+
+Creates a new FTP target.  To add it to the database, see L<"insert">.
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=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.
+
+=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;
+
+  if ( !$self->get('port') ) {
+    if ( $self->secure ) {
+      $self->set('port', 22);
+    } else {
+      $self->set('port', 21);
+    }
+  }
+
+  my $error = 
+    $self->ut_numbern('targetnum')
+    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+    || $self->ut_text('hostname')
+    || $self->ut_text('username')
+    || $self->ut_text('password')
+    || $self->ut_number('port')
+    || $self->ut_text('path')
+    || $self->ut_flag('secure')
+    || $self->ut_enum('handling', [ $self->handling_types ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item connect
+
+Creates a Net::FTP or Net::SFTP::Foreign object (according to the setting
+of the 'secure' flag), connects to 'hostname', attempts to log in with 
+'username' and 'password', and changes the working directory to 'path'.
+On success, returns the object.  On failure, dies with an error message.
+
+=cut
+
+sub connect {
+  my $self = shift;
+  if ( $self->secure ) {
+    eval "use Net::SFTP::Foreign;";
+    die $@ if $@;
+    my %args = (
+      port      => $self->port,
+      user      => $self->username,
+      password  => $self->password,
+      more      => ($DEBUG ? '-v' : ''),
+      timeout   => 30,
+      autodie   => 1, #we're doing this anyway
+    );
+    my $sftp = Net::SFTP::Foreign->new($self->hostname, %args);
+    $sftp->setcwd($self->path);
+    return $sftp;
+  }
+  else {
+    eval "use Net::FTP;";
+    die $@ if $@;
+    my %args = ( 
+      Debug   => $DEBUG,
+      Port    => $self->port,
+      Passive => 1,# optional?
+    );
+    my $ftp = Net::FTP->new($self->hostname, %args)
+      or die "connect to ".$self->hostname." failed: $@";
+    $ftp->login($self->username, $self->password)
+      or die "login to ".$self->username.'@'.$self->hostname." failed: $@";
+    $ftp->binary; #optional?
+    $ftp->cwd($self->path)
+      or ($self->path eq '/')
+      or die "cwd to ".$self->hostname.'/'.$self->path." failed: $@";
+
+    return $ftp;
+  }
+}
+
+=item label
+
+Returns a descriptive label for this target.
+
+=cut
+
+sub label {
+  my $self = shift;
+  $self->targetnum . ': ' . $self->username . '@' . $self->hostname;
+}
+
+=item handling_types
+
+Returns a list of values for the "handling" field, corresponding to the 
+known ways to preprocess a file before uploading.  Currently those are 
+implemented somewhat crudely in L<FS::Cron::upload>.
+
+=cut
+
+sub handling_types {
+  '',
+  #'billco', #not implemented this way yet
+  'bridgestone',
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/h_radius_usergroup.pm b/FS/FS/h_radius_usergroup.pm
new file mode 100644 (file)
index 0000000..bbccd6b
--- /dev/null
@@ -0,0 +1,24 @@
+package FS::h_radius_usergroup;
+
+use strict;
+use base qw( FS::h_Common FS::radius_usergroup );
+
+sub table { 'h_radius_usergroup' };
+
+=head1 NAME
+
+FS::h_radius_usergroup - Historical RADIUS usergroup records.
+
+=head1 DESCRIPTION
+
+An FS::h_radius_usergroup object represents historical changes to an account's
+RADIUS group (L<FS::radius_usergroup>).
+
+=head1 SEE ALSO
+
+L<FS::radius_usergroup>,  L<FS::h_Common>, L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/h_svc_Radius_Mixin.pm b/FS/FS/h_svc_Radius_Mixin.pm
new file mode 100644 (file)
index 0000000..af29770
--- /dev/null
@@ -0,0 +1,17 @@
+package FS::h_svc_Radius_Mixin;
+
+use strict;
+use FS::Record qw( qsearch );
+use FS::h_radius_usergroup;
+
+sub h_usergroup {
+  my $self = shift;
+  map { $_->groupnum } 
+    qsearch( 'h_radius_usergroup',
+             { svcnum => $self->svcnum },
+             FS::h_radius_usergroup->sql_h_searchs(@_),
+           );
+}
+
+1;
+
index 247d20c..f525f82 100644 (file)
@@ -1,16 +1,13 @@
 package FS::h_svc_acct;
+use base qw( FS::h_svc_Radius_Mixin FS::h_Common FS::svc_acct );
 
 use strict;
 use vars qw( @ISA $DEBUG );
 use Carp qw(carp);
 use FS::Record qw(qsearchs);
-use FS::h_Common;
-use FS::svc_acct;
 use FS::svc_domain;
 use FS::h_svc_domain;
 
-@ISA = qw( FS::h_Common FS::svc_acct );
-
 $DEBUG = 0;
 
 sub table { 'h_svc_acct' };
index d6038fb..01477fe 100644 (file)
@@ -1,11 +1,8 @@
 package FS::h_svc_broadband;
+use base qw( FS::h_svc_Radius_Mixin FS::h_Common FS::svc_broadband );
 
 use strict;
 use vars qw( @ISA );
-use FS::h_Common;
-use FS::svc_broadband;
-
-@ISA = qw( FS::h_Common FS::svc_broadband );
 
 sub table { 'h_svc_broadband' };
 
index 39a0dff..477c934 100644 (file)
@@ -111,6 +111,7 @@ sub check {
                                            'Edit global inventory'] )
     || $self->ut_text('item')
     || $self->ut_foreign_keyn('svcnum', 'cust_svc', 'svcnum' )
+    || $self->ut_alphan('svc_field')
   ;
   return $error if $error;
 
index e47776c..ffb4f52 100644 (file)
@@ -465,14 +465,12 @@ sub substitutions {
       name name_short contact contact_firstlast
       address1 address2 city county state zip
       country
-      daytime night fax
+      daytime night mobile fax
 
       has_ship_address
-      ship_last ship_first ship_company
       ship_name ship_name_short ship_contact ship_contact_firstlast
       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
       ship_country
-      ship_daytime ship_night ship_fax
 
       paymask payname paytype payip
       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
@@ -485,6 +483,15 @@ sub substitutions {
       signupdate dundate
       packages recurdates
       ),
+      #compatibility: obsolete ship_ fields - use the non-ship versions
+      map (
+        { my $field = $_;
+          [ "ship_$field"   => sub { shift->$field } ]
+        }
+        qw( last first company daytime night fax )
+      ),
+      # ship_name, ship_name_short, ship_contact, ship_contact_firstlast
+      # still work, though
       [ expdate           => sub { shift->paydate_epoch } ], #compatibility
       [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
       [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
index 968dcdf..c1dda22 100644 (file)
@@ -65,7 +65,10 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $self->SUPER::insert;
+  my $error;
+  
+  $error = $self->check_options($options) 
+           || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -197,7 +200,17 @@ sub replace {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error = $self->SUPER::replace($old);
+  my $error;
+  
+  if ($options_supplied) {
+    $error = $self->check_options($options);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+  
+  $error = $self->SUPER::replace($old);
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -274,6 +287,21 @@ sub replace {
 
 }
 
+=item check_options HASHREF
+
+This method is called by 'insert' and 'replace' to check the options that were supplied.
+
+Return error-message, or false.
+
+(In this class, this is a do-nothing routine that always returns false.  Override as necessary.  No need to call superclass.)
+
+=cut
+
+sub check_options {
+       my ($self, $options) = @_;
+       '';
+}
+
 =item option_objects
 
 Returns all options as FS::I<tablename>_option objects.
index a5cd861..1a3bca4 100644 (file)
@@ -17,7 +17,7 @@ sub do_action {
   #my $cust_main = $self->cust_main($cust_bill);
   my $cust_main = $cust_bill->cust_main;
 
-  $cust_bill->email;
+  $cust_bill->email unless $cust_main->invoice_noemail;
 }
 
 1;
index bf47268..56ba680 100644 (file)
@@ -2,6 +2,7 @@ package FS::part_event::Action::cust_bill_send_csv_ftp;
 
 use strict;
 use base qw( FS::part_event::Action );
+use FS::Misc::Invoicing qw( spool_formats );
 
 sub description { 'Upload CSV invoice data to an FTP server'; }
 
@@ -15,10 +16,7 @@ sub option_fields {
   (
     'ftpformat'   => { label   => 'Format',
                        type    =>'select',
-                       options => ['default', 'billco'],
-                       option_labels => { 'default' => 'Default',
-                                          'billco'  => 'Billco',
-                                        },
+                       options => [ spool_formats() ],
                      },
     'ftpserver'   => 'FTP server',
     'ftpusername' => 'FTP username',
index 11ecbc5..14349a9 100644 (file)
@@ -2,6 +2,7 @@ package FS::part_event::Action::cust_bill_spool_csv;
 
 use strict;
 use base qw( FS::part_event::Action );
+use FS::Misc::Invoicing qw( spool_formats );
 
 sub description { 'Spool CSV invoice data'; }
 
@@ -15,10 +16,7 @@ sub option_fields {
   (
     'spoolformat'       => { label   => 'Format',
                              type    => 'select',
-                             options => ['default', 'billco'],
-                             option_labels => { 'default' => 'Default',
-                                                'billco'  => 'Billco',
-                                              },
+                             options => [ spool_formats() ],
                            },
     'spoolbalanceover'  => { label =>
                                'If balance (this invoice and previous) over',
@@ -28,6 +26,13 @@ sub option_fields {
                              type  => 'checkbox',
                              value => '1',
                            },
+    'ftp_targetnum'     => { label    => 'Upload spool to FTP target',
+                             type     => 'select-table',
+                             table    => 'ftp_target',
+                             name_col => 'label',
+                             empty_label => '(do not upload)',
+                             order_by => 'targetnum',
+                           },
   );
 }
 
@@ -43,6 +48,7 @@ sub do_action {
     'format'       => $self->option('spoolformat'),
     'balanceover'  => $self->option('spoolbalanceover'),
     'agent_spools' => $self->option('spoolagent_spools'),
+    'ftp_targetnum'=> $self->option('ftp_targetnum'),
   );
 }
 
diff --git a/FS/FS/part_event/Condition/balance_age_under.pm b/FS/FS/part_event/Condition/balance_age_under.pm
new file mode 100644 (file)
index 0000000..ac6d786
--- /dev/null
@@ -0,0 +1,52 @@
+package FS::part_event::Condition::balance_age_under;
+
+use strict;
+use base qw( FS::part_event::Condition );
+
+sub description { 'Customer balance age (under)'; }
+
+sub option_fields {
+  (
+    'balance' => { 'label'      => 'Balance under (or equal to)',
+                   'type'       => 'money',
+                   'value'      => '0.00', #default
+                 },
+    'age'     => { 'label'      => 'Age',
+                   'type'       => 'freq',
+                 },
+  );
+}
+
+sub condition {
+  my($self, $object, %opt) = @_;
+
+  my $cust_main = $self->cust_main($object);
+
+  my $under = $self->option('balance');
+  $under = 0 unless length($under);
+
+  my $age = $self->option_age_from('age', $opt{'time'} );
+
+  $cust_main->balance_date($age) <= $under;
+}
+
+sub condition_sql {
+  my( $class, $table, %opt ) = @_;
+
+  my $under   = $class->condition_sql_option('balance');
+  my $age     = $class->condition_sql_option_age_from('age', $opt{'time'});
+
+  my $balance_sql = FS::cust_main->balance_date_sql( $age );
+
+  "$balance_sql <= CAST( $under AS DECIMAL(10,2) )";
+}
+
+sub order_sql {
+  shift->condition_sql_option_age('age');
+}
+
+sub order_sql_weight {
+  10;
+}
+
+1;
index 70c9c7f..dee240f 100644 (file)
@@ -13,30 +13,49 @@ sub option_fields {
                   'type'  => 'checkbox',
                   'value' => 'Y',
                 },
+    'check_bal' => { 'label' => 'Check referring custoemr balance',
+                     'type'  => 'checkbox',
+                     'value' => 'Y',
+                   },
+    'balance' => { 'label'      => 'Referring customer balance under (or equal to)',
+                   'type'       => 'money',
+                   'value'      => '0.00', #default
+                 },
+    'age'     => { 'label'      => 'Referring customer balance age',
+                   'type'       => 'freq',
+                 },
   );
 }
 
 sub condition {
-  my($self, $object) = @_;
+  my($self, $object, %opt) = @_;
 
   my $cust_main = $self->cust_main($object);
 
   if ( $self->option('active') ) {
-
     return 0 unless $cust_main->referral_custnum;
-
     #check for no cust_main for referral_custnum? (deleted?)
+    return 0 unless $cust_main->referral_custnum_cust_main->status eq 'active';
+  } else {
+    return 0 unless $cust_main->referral_custnum; # ? 1 : 0;
+  }
 
-    $cust_main->referral_custnum_cust_main->status eq 'active';
+  return 1 unless $self->option('check_bal');
 
-  } else {
+  my $referring_cust_main = $cust_main->referral_custnum_cust_main;
 
-    $cust_main->referral_custnum; # ? 1 : 0;
+  #false laziness w/ balance_age_under
+  my $under = $self->option('balance');
+  $under = 0 unless length($under);
 
-  }
+  my $age = $self->option_age_from('age', $opt{'time'} );
+
+  $referring_cust_main->balance_date($age) <= $under;
 
 }
 
+#this is incomplete wrt checking referring customer balances, but that's okay.
+# false positives are acceptable here, its just an optimizaiton
 sub condition_sql {
   my( $class, $table ) = @_;
 
diff --git a/FS/FS/part_event/Condition/once_percust_every.pm b/FS/FS/part_event/Condition/once_percust_every.pm
new file mode 100644 (file)
index 0000000..9e2ec1f
--- /dev/null
@@ -0,0 +1,58 @@
+package FS::part_event::Condition::once_percust_every;
+
+use strict;
+use FS::Record qw( qsearch );
+use FS::part_event;
+use FS::cust_event;
+
+use base qw( FS::part_event::Condition );
+
+sub description { "Don't run this event more than once per customer in the specified interval"; }
+
+sub eventtable_hashref {
+    { 'cust_main' => 0,
+      'cust_bill' => 1,
+      'cust_pkg'  => 1,
+    };
+}
+
+# Runs the event at most "once every X", per customer.
+
+sub option_fields {
+  (
+    'run_delay'  => { label=>'Interval', type=>'freq', value=>'1m', },
+  );
+}
+
+sub condition {
+  my($self, $object, %opt) = @_;
+
+  my $obj_pkey = $object->primary_key;
+  my $obj_table = $object->table;
+  my $custnum = $object->custnum;
+
+  my @where = (
+    "tablenum IN ( SELECT $obj_pkey FROM $obj_table WHERE custnum = $custnum )"
+  );
+  if ( $opt{'cust_event'}->eventnum =~ /^(\d+)$/ ) {
+    push @where, " eventnum != $1 ";
+  }
+  my $extra_sql = ' AND '. join(' AND ', @where);
+  my $max_date = $self->option_age_from('run_delay', $opt{'time'});
+  my @existing = qsearch( {
+    'table'     => 'cust_event',
+    'hashref'   => {
+                     'eventpart' => $self->eventpart,
+                     'status'    => { op=>'!=', value=>'failed'  },
+                     '_date'     => { op=>'>',  value=>$max_date },
+                   },
+    'extra_sql' => $extra_sql,
+  } );
+
+  ! scalar(@existing);
+
+}
+
+1;
diff --git a/FS/FS/part_event/Condition/pkg_dundate_age.pm b/FS/FS/part_event/Condition/pkg_dundate_age.pm
new file mode 100644 (file)
index 0000000..75fce1f
--- /dev/null
@@ -0,0 +1,43 @@
+package FS::part_event::Condition::pkg_dundate_age;
+use base qw( FS::part_event::Condition );
+
+use strict;
+
+sub description {
+  "Skip until specified # of days before package suspension delay date";
+}
+
+
+sub option_fields {
+  (
+    'age'     => { 'label'      => 'Time before suspension delay date',
+                   'type'       => 'freq',
+                 },
+  );
+}
+
+sub eventtable_hashref {
+  { 'cust_main' => 0,
+    'cust_bill' => 0,
+    'cust_pkg'  => 1,
+  };
+}
+
+sub condition {
+  my($self, $cust_pkg, %opt) = @_;
+
+  my $age = $self->option_age_from('age', $opt{'time'} );
+
+  $cust_pkg->dundate <= $age;
+}
+
+sub condition_sql {
+  my( $class, $table, %opt ) = @_;
+  return 'true' unless $table eq 'cust_pkg';
+  
+  my $age = $class->condition_sql_option_age_from('age', $opt{'time'});
+  
+  "COALESCE($table.dundate,0) <= ". $age;
+}
+
+1;
diff --git a/FS/FS/part_export/acct_xmlrpc.pm b/FS/FS/part_export/acct_xmlrpc.pm
new file mode 100644 (file)
index 0000000..d746f29
--- /dev/null
@@ -0,0 +1,268 @@
+package FS::part_export::acct_xmlrpc;
+use base qw( FS::part_export );
+
+use vars qw( %info ); # $DEBUG );
+#use Data::Dumper;
+use Tie::IxHash;
+use Frontier::Client; #to avoid adding a dependency on RPC::XML just now
+#use FS::Record qw( qsearch qsearchs );
+use FS::Schema qw( dbdef );
+
+#$DEBUG = 1;
+
+tie my %options, 'Tie::IxHash',
+  'xmlrpc_url'       => { label => 'XML-RPC URL', },
+  'param_style'      => { label   => 'Parameter style',
+                          type    => 'select',
+                          options => [ 'Individual values',
+                                       'Struct of name/value pairs',
+                                     ],
+                        },
+  'insert_method'    => { label => 'Insert method', },
+  'insert_params'    => { label => 'Insert parameters', type=>'textarea', },
+  'replace_method'   => { label => 'Replace method', },
+  'replace_params'   => { label => 'Replace parameters', type=>'textarea', },
+  'delete_method'    => { label => 'Delete method', },
+  'delete_params'    => { label => 'Delete parameters', type=>'textarea', },
+  'suspend_method'   => { label => 'Suspend method', },
+  'suspend_params'   => { label => 'Suspend parameters', type=>'textarea', },
+  'unsuspend_method' => { label => 'Unsuspend method', },
+  'unsuspend_params' => { label => 'Unsuspend parameters', type=>'textarea', },
+;
+
+%info = (
+  'svc'     => 'svc_acct',
+  'desc'    => 'Configurable provisioning of accounts via the XML-RPC protocol',
+  'options' => \%options,
+  'notes'   => <<'END',
+Configurable, real-time export of accounts via the XML-RPC protocol.<BR>
+<BR>
+If using "Individual values" parameter style, specfify one parameter per line.<BR>
+<BR>
+If using "Struct of name/value pairs" parameter style, specify one name and
+value on each line, separated by whitespace.<BR>
+<BR>
+The following variables are available for interpolation (prefixed with new_ or
+old_ for replace operations):
+<UL>
+  <LI><code>$username</code>
+  <LI><code>$_password</code>
+  <LI><code>$crypt_password</code> - encrypted password
+  <LI><code>$ldap_password</code> - Password in LDAP/RFC2307 format (for example, "{PLAIN}himom", "{CRYPT}94pAVyK/4oIBk" or "{MD5}5426824942db4253f87a1009fd5d2d4")
+  <LI><code>$uid</code>
+  <LI><code>$gid</code>
+  <LI><code>$finger</code> - Real name
+  <LI><code>$dir</code> - home directory
+  <LI><code>$shell</code>
+  <LI><code>$quota</code>
+  <LI><code>@radius_groups</code>
+<!--  <LI><code>$reasonnum (when suspending)</code>
+  <LI><code>$reasontext (when suspending)</code>
+  <LI><code>$reasontypenum (when suspending)</code>
+  <LI><code>$reasontypetext (when suspending)</code>
+-->
+<!--
+  <LI><code>$pkgnum</code>
+  <LI><code>$custnum</code>
+-->
+  <LI>All other fields in <b>svc_acct</b> are also available.
+<!--  <LI>The following fields from <b>cust_main</b> are also available (except during replace): company, address1, address2, city, state, zip, county, daytime, night, fax, otaker, agent_custid, locale. -->
+</UL>
+
+END
+);
+
+sub _export_insert    { shift->_export_command('insert',    @_) }
+sub _export_delete    { shift->_export_command('delete',    @_) }
+sub _export_suspend   { shift->_export_command('suspend',   @_) }
+sub _export_unsuspend { shift->_export_command('unsuspend', @_) }
+
+sub _export_command {
+  my ( $self, $action, $svc_acct) = (shift, shift, shift);
+  my $method = $self->option($action.'_method');
+  return '' if $method =~ /^\s*$/;
+
+  my @params = split("\n", $self->option($action.'_params') );
+
+  my( @x_param ) = ();
+  my( %x_struct ) = ();
+  foreach my $param (@params) {
+
+    my($name, $value) = ('', '');
+    if ($self->option('param_style') eq 'Struct of name/value pairs' ) {
+      ($name, $value) = split(/\s+/, $param);
+    } else { #'Individual values'
+      $value = $param;
+    }
+
+    if ( $value =~ /^\s*(\$|\@)(\w+)\s*$/ ) {
+      $value = $self->_export_value($2, $svc_acct);
+    }
+
+    if ($self->option('param_style') eq 'Struct of name/value pairs' ) {
+      $x_struct{$name} = $value;
+    } else { #'Individual values'
+      push @x_param, $value;
+    }
+
+  }
+
+  my @x = ();
+  if ($self->option('param_style') eq 'Struct of name/value pairs' ) {
+    @x = ( \%x_struct );
+  } else { #'Individual values'
+    @x = @x_param;
+  }
+
+  #option to queue (or not) ?
+
+  my $conn = Frontier::Client->new( url => $self->option('xmlrpc_url') );
+
+  my $result = $conn->call($method, @x);
+
+  #XXX error checking?  $result?  from the call?
+  '';
+}
+
+sub _export_replace {
+  my( $self, $new, $old ) = (shift, shift, shift);
+
+  my $method = $self->option($action.'_method');
+  return '' if $method =~ /^\s*$/;
+
+  my @params = split("\n", $self->option($action.'_params') );
+
+  my( @x_param ) = ();
+  my( %x_struct ) = ();
+  foreach my $param (@params) {
+
+    my($name, $value) = ('', '');
+    if ($self->option('param_style') eq 'Struct of name/value pairs' ) {
+      ($name, $value) = split(/\s+/, $param);
+    } else { #'Individual values'
+      $value = $param;
+    }
+
+    if ( $value =~ /^\s*(\$|\@)(old|new)_(\w+)\s*$/ ) {
+      if ($2 eq 'old' ) {
+        $value = $self->_export_value($3, $old);
+      } elsif ( $2 eq 'new' ) {
+        $value = $self->_export_value($3, $new);
+      } else {
+        die 'guru meditation stella blue';
+      }
+    }
+
+    if ($self->option('param_style') eq 'Struct of name/value pairs' ) {
+      $x_struct{$name} = $value;
+    } else { #'Individual values'
+      push @x_param, $value;
+    }
+
+  }
+
+  my @x = ();
+  if ($self->option('param_style') eq 'Struct of name/value pairs' ) {
+    @x = ( \%x_struct );
+  } else { #'Individual values'
+    @x = @x_param;
+  }
+
+  #option to queue (or not) ?
+
+  my $conn = Frontier::Client->new( url => $self->option('xmlrpc_url') );
+
+  my $result = $conn->call($method, @x);
+
+  #XXX error checking?  $result?  from the call?
+  '';
+
+}
+
+#comceptual false laziness w/shellcommands.pm
+sub _export_value {
+  my( $self, $value, $svc_acct) = (shift, shift, shift);
+
+  my %fields = map { $_=>1 } $svc_acct->fields;
+
+  if ( $fields{$value} ) {
+    my $type = dbdef->table('svc_acct')->column($value)->type;
+    if ( $type =~ /^(int|serial)/i ) {
+      return Frontier::Client->new->int( $svc_acct->$value() );
+    } elsif ( $value =~ /^last_log/ ) {
+      return Frontier::Client->new->date_time( $svc_acct->$value() ); #conversion?
+    } else {
+      return Frontier::Client->new->string( $svc_acct->$value() );
+    }
+  } elsif ( $value eq 'domain' ) {
+    return Frontier::Client->new->string( $svc_acct->domain );
+  } elsif ( $value eq 'crypt_password' ) {
+    return Frontier::Client->new->string( $svc_acct->crypt_password( $self->option('crypt') ) );
+  } elsif ( $value eq 'ldap_password' ) {
+    return Frontier::Client->new->string( $svc_acct->ldap_password($self->option('crypt') ) );
+  } elsif ( $value eq 'radius_groups' ) {
+    my @radius_groups = $svc_acct->radius_groups;
+    #XXX
+  }
+
+#  my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
+#  if ( $cust_pkg ) {
+#    no strict 'vars';
+#    {
+#      no strict 'refs';
+#      foreach my $custf (qw( company address1 address2 city state zip country
+#                             daytime night fax otaker agent_custid locale
+#                        ))
+#      {
+#        ${$custf} = $cust_pkg->cust_main->$custf();
+#      }
+#    }
+#    $email = ( grep { $_ !~ /^(POST|FAX)$/ } $cust_pkg->cust_main->invoicing_list )[0];
+#  } else {
+#    $email = '';
+#  }
+
+#  my ($reasonnum, $reasontext, $reasontypenum, $reasontypetext);
+#  if ( $cust_pkg && $action eq 'suspend' &&
+#       (my $r = $cust_pkg->last_reason('susp')) )
+#  {
+#    $reasonnum = $r->reasonnum;
+#    $reasontext = $r->reason;
+#    $reasontypenum = $r->reason_type;
+#    $reasontypetext = $r->reasontype->type;
+#
+#    my %reasonmap = $self->_groups_susp_reason_map;
+#    my $userspec = '';
+#    $userspec = $reasonmap{$reasonnum}
+#      if exists($reasonmap{$reasonnum});
+#    $userspec = $reasonmap{$reasontext}
+#      if (!$userspec && exists($reasonmap{$reasontext}));
+#
+#    my $suspend_user;
+#    if ( $userspec =~ /^\d+$/ ) {
+#      $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
+#    } elsif ( $userspec =~ /^\S+\@\S+$/ ) {
+#      my ($username,$domain) = split(/\@/, $userspec);
+#      for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
+#        $suspend_user = $user if $userspec eq $user->email;
+#      }
+#    } elsif ($userspec) {
+#      $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
+#    }
+#  
+#  @radius_groups = $suspend_user->radius_groups
+#    if $suspend_user;  
+#  
+#  } else {
+#    $reasonnum = $reasontext = $reasontypenum = $reasontypetext = '';
+#  }
+
+#  $pkgnum = $cust_pkg ? $cust_pkg->pkgnum : '';
+#  $custnum = $cust_pkg ? $cust_pkg->custnum : '';
+
+  '';
+
+}
+
+1;
+
index 29bd288..5806362 100644 (file)
@@ -1,7 +1,7 @@
 package FS::part_export::broadband_sqlradius;
 
 use strict;
-use vars qw($DEBUG @ISA %options %info $conf);
+use vars qw($DEBUG @ISA @pw_set %options %info $conf);
 use Tie::IxHash;
 use FS::Conf;
 use FS::Record qw( dbh str2time_sql ); #qsearch qsearchs );
@@ -13,6 +13,8 @@ FS::UID->install_callback(sub { $conf = new FS::Conf });
 
 $DEBUG = 0;
 
+@pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '.', ',' );
+
 tie %options, 'Tie::IxHash',
   'datasrc'  => { label=>'DBI data source ' },
   'username' => { label=>'Database username' },
@@ -106,8 +108,65 @@ sub radius_check {
   %check;
 }
 
-sub _export_suspend {}
-sub _export_unsuspend {}
+sub radius_check_suspended {
+  my($self, $svc_broadband) = (shift, shift);
+
+  return () unless $self->option('mac_as_password')
+                || length( $self->option('radius_password',1));
+
+  my $password_attrib = $conf->config('radius-password') || 'Password';
+  (
+    $password_attrib => join('',map($pw_set[ int(rand $#pw_set) ], (0..7) ) )
+  );
+}
+
+#false laziness w/sqlradius.pm
+sub _export_suspend {
+  my( $self, $svc_broadband ) = (shift, 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 @newgroups = $self->suspended_usergroups($svc_broadband);
+
+  unless (@newgroups) { #don't change password if assigning to a suspended group
+
+    my $err_or_queue = $self->sqlradius_queue(
+       $svc_broadband->svcnum, 'insert',
+      'check', $self->export_username($svc_broadband),
+      $self->radius_check_suspended($svc_broadband)
+    );
+    unless ( ref($err_or_queue) ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $err_or_queue;
+    }
+
+  }
+
+  my $error =
+    $self->sqlreplace_usergroups(
+      $svc_broadband->svcnum,
+      $self->export_username($svc_broadband),
+      '',
+      [ $svc_broadband->radius_groups('hashref') ],
+      \@newgroups,
+    );
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+}
 
 sub update_svc {} #do nothing
 
index 867c19a..6e2ee8a 100644 (file)
@@ -5,6 +5,7 @@ use MIME::Base64;
 use Tie::IxHash;
 use FS::part_export;
 use Date::Format qw( time2str );
+use Regexp::Common qw/URI/;
 
 @ISA = qw(FS::part_export);
 $me = '[FS::part_export::netsapiens]';
@@ -42,6 +43,11 @@ my %features = (
   'sim' => 'Simultaneous Ring',
 );
 
+my %feature_param = (
+  'dnd' => 'n/a',
+  'sim' => '$phonenum',
+);
+
 tie my %options, 'Tie::IxHash',
   'login'           => { label=>'NetSapiens tac2 User API username' },
   'password'        => { label=>'NetSapiens tac2 User API password' },
@@ -80,6 +86,22 @@ END
 
 sub rebless { shift; }
 
+
+sub check_options {
+  my ($self, $options) = @_;
+       
+  my $rex = qr/$RE{URI}{HTTP}{-scheme => qr|https?|}/;                 # match any "http:" or "https:" URL
+       
+  for my $key (qw/url device_url/) {
+    if ($$options{$key} && ($$options{$key} !~ $rex)) {
+      return "Invalid (URL): " . $$options{$key};
+    }
+  }
+  return '';
+}
+
+
+
 sub ns_command {
   my $self = shift;
   $self->_ns_command('', @_);
@@ -245,11 +267,14 @@ sub ns_create_or_update {
   ###
   foreach $feature (split /\s+/, $self->option('features') ) {
 
+    my $param= exists($feature_param{$feature}) ? $feature_param{$feature} : '';
+    $param = $phonenum if $param eq '$phonenum';
+
     my $nsf = $self->ns_command( 'PUT', $self->ns_feature($svc_phone, $feature),
       'control'    => 'd', #User Control, disable
       'expires'    => 'never',
       #'ts'         => '', #?
-      #'parameters' => '',
+      'parameters' => $param,
       'hour_match' => '*',
       'time_frame' => '*',
       'activation' => 'now',
index 910346b..c360c9e 100644 (file)
@@ -111,6 +111,7 @@ END
   'options'  => \%options,
   'nodomain' => 'Y',
   'nas'      => 'Y', # show export_nas selection in UI
+  'default_svc_class' => 'Internet',
   'notes'    => $notes1.
                 'This export does not export RADIUS realms (see also '.
                 'sqlradius_withdomain).  '.
@@ -250,6 +251,7 @@ sub _export_replace {
   '';
 }
 
+#false laziness w/broadband_sqlradius.pm
 sub _export_suspend {
   my( $self, $svc_acct ) = (shift, shift);
 
@@ -297,7 +299,7 @@ sub _export_suspend {
 }
 
 sub _export_unsuspend {
-  my( $self, $svc_acct ) = (shift, shift);
+  my( $self, $svc_x ) = (shift, shift);
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -310,21 +312,21 @@ sub _export_unsuspend {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $err_or_queue = $self->sqlradius_queue( $svc_acct->svcnum, 'insert',
-    'check', $self->export_username($svc_acct), $svc_acct->radius_check );
+  my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'insert',
+    'check', $self->export_username($svc_x), $self->radius_check($svc_x) );
   unless ( ref($err_or_queue) ) {
     $dbh->rollback if $oldAutoCommit;
     return $err_or_queue;
   }
 
   my $error;
-  my (@oldgroups) = $self->suspended_usergroups($svc_acct);
+  my (@oldgroups) = $self->suspended_usergroups($svc_x);
   $error = $self->sqlreplace_usergroups(
-    $svc_acct->svcnum,
-    $self->export_username($svc_acct),
+    $svc_x->svcnum,
+    $self->export_username($svc_x),
     '',
     \@oldgroups,
-    [ $svc_acct->radius_groups('hashref') ],
+    [ $svc_x->radius_groups('hashref') ],
   );
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
@@ -358,14 +360,16 @@ sub sqlradius_queue {
 }
 
 sub suspended_usergroups {
-  my ($self, $svc_acct) = (shift, shift);
+  my ($self, $svc_x) = (shift, shift);
+
+  return () unless $svc_x;
 
-  return () unless $svc_acct;
+  my $svc_table = $svc_x->table;
 
   #false laziness with FS::part_export::shellcommands
   #subclass part_export?
 
-  my $r = $svc_acct->cust_svc->cust_pkg->last_reason('susp');
+  my $r = $svc_x->cust_svc->cust_pkg->last_reason('susp');
   my %reasonmap = $self->_groups_susp_reason_map;
   my $userspec = '';
   if ($r) {
@@ -374,19 +378,19 @@ sub suspended_usergroups {
     $userspec = $reasonmap{$r->reason}
       if (!$userspec && exists($reasonmap{$r->reason}));
   }
-  my $suspend_user;
-  if ($userspec =~ /^\d+$/ ){
-    $suspend_user = qsearchs( 'svc_acct', { 'svcnum' => $userspec } );
-  }elsif ($userspec =~ /^\S+\@\S+$/){
+  my $suspend_svc;
+  if ( $userspec =~ /^\d+$/ ){
+    $suspend_svc = qsearchs( $svc_table, { 'svcnum' => $userspec } );
+  } elsif ( $userspec =~ /^\S+\@\S+$/ && $svc_table eq 'svc_acct' ){
     my ($username,$domain) = split(/\@/, $userspec);
     for my $user (qsearch( 'svc_acct', { 'username' => $username } )){
-      $suspend_user = $user if $userspec eq $user->email;
+      $suspend_svc = $user if $userspec eq $user->email;
     }
-  }elsif ($userspec){
-    $suspend_user = qsearchs( 'svc_acct', { 'username' => $userspec } );
+  }elsif ( $userspec && $svc_table eq 'svc_acct'  ){
+    $suspend_svc = qsearchs( 'svc_acct', { 'username' => $userspec } );
   }
   #esalf
-  return $suspend_user->radius_groups('hashref') if $suspend_user;
+  return $suspend_svc->radius_groups('hashref') if $suspend_svc;
   ();
 }
 
@@ -756,7 +760,7 @@ sub usage_sessions {
 
 }
 
-=item update_svc_acct
+=item update_svc
 
 =cut
 
@@ -1154,8 +1158,13 @@ sub _upgrade_exporttype {
 
 sub import_attrs {
   my $self = shift;
-  my $dbh = sqlradius_connect( map $self->option($_),
+  my $dbh =  DBI->connect( map $self->option($_),
                                    qw( datasrc username password ) );
+  unless ( $dbh ) {
+    warn "Error connecting to RADIUS server: $DBI::errstr\n";
+    return;
+  }
+
   my $usergroup = $self->option('usergroup') || 'usergroup';
   my $error;
   warn "Importing RADIUS groups and attributes from ".$self->option('datasrc').
index 0e44f5d..22eb698 100644 (file)
@@ -151,8 +151,9 @@ sub calc_recur {
     if $self->recur_temporality eq 'preceding' && !$last_bill;
 
   my $charge = $self->base_recur($cust_pkg, $sdate);
-  if ( my $cutoff_day = $self->cutoff_day($cust_pkg) ) {
-    $charge = $self->calc_prorate(@_, $cutoff_day);
+  # always treat cutoff_day as a list
+  if ( my @cutoff_day = $self->cutoff_day($cust_pkg) ) {
+    $charge = $self->calc_prorate(@_, @cutoff_day);
   }
   elsif ( $param->{freq_override} ) {
     # XXX not sure if this should be mutually exclusive with sync_bill_date.
@@ -161,6 +162,9 @@ sub calc_recur {
     $charge *= $param->{freq_override} if $param->{freq_override};
   }
 
+  my $quantity = $cust_pkg->quantity || 1;
+  $charge *= $quantity;
+
   my $discount = $self->calc_discount($cust_pkg, $sdate, $details, $param);
   return sprintf('%.2f', $charge - $discount);
 }
@@ -174,7 +178,7 @@ sub cutoff_day {
       return (localtime($next_bill))[3];
     }
   }
-  return 0;
+  return ();
 }
 
 sub base_recur {
index 33cc3d4..10c2056 100644 (file)
@@ -32,8 +32,8 @@ sub base_recur {
   warn "flat_introrate base_recur requires date!" if !$time;
   my $now = $time ? $$time : time;
 
-  my ($duration) = ($self->option('intro_duration') =~ /^(\d+)$/);
-  unless ($duration) {
+  my ($duration) = ($self->option('intro_duration') =~ /^\s*(\d+)\s*$/);
+  unless (length($duration)) {
     die "Invalid intro_duration: " . $self->option('intro_duration');
   }
   my $intro_end = $self->add_freq($cust_pkg->setup, $duration);
index f930d41..f8d03dc 100644 (file)
@@ -49,7 +49,7 @@ sub calc_recur {
 
 sub cutoff_day {
   my $self = shift;
-  $self->option('cutoff_day', 1) || 1;
+  split(/\s*,\s*/, $self->option('cutoff_day', 1) || '1');
 }
 
 1;
index a01b5c4..d148c96 100644 (file)
@@ -4,6 +4,7 @@ use strict;
 use vars qw( %info );
 use Time::Local qw( timelocal timelocal_nocheck );
 use Date::Format qw( time2str );
+use List::Util qw( min );
 
 %info = ( 
   'disabled'  => 1,
@@ -76,8 +77,8 @@ day arrives.
 =cut
 
 sub calc_prorate {
-  my ($self, $cust_pkg, $sdate, $details, $param, $cutoff_day) = @_;
-  die "no cutoff_day" unless $cutoff_day;
+  my ($self, $cust_pkg, $sdate, $details, $param, @cutoff_days) = @_;
+  die "no cutoff_day" unless @cutoff_days;
   die "can't prorate non-monthly package\n" if $self->freq =~ /\D/;
 
   my $money_char = FS::Conf->new->config('money_char') || '$';
@@ -103,8 +104,19 @@ sub calc_prorate {
     $add_period = 1;
   }
 
+  # if the customer alreqady has a billing day-of-month established,
+  # and it's a valid cutoff day, try to respect it
+  my $next_bill_day;
+  if ( my $next_bill = $cust_pkg->cust_main->next_bill_date ) {
+    $next_bill_day = (localtime($next_bill))[3];
+    if ( grep {$_ == $next_bill_day} @cutoff_days ) {
+      # by removing all other cutoff days from the list
+      @cutoff_days = ($next_bill_day);
+    }
+  }
+
   my ($mend, $mstart);
-  ($mnow, $mend, $mstart) = $self->_endpoints($mnow, $cutoff_day);
+  ($mnow, $mend, $mstart) = $self->_endpoints($mnow, @cutoff_days);
 
   # next bill date will be figured as $$sdate + one period
   $$sdate = $mstart;
@@ -155,12 +167,12 @@ set, in which case it postpones the next bill to the cutoff day.
 sub prorate_setup {
   my $self = shift;
   my ($cust_pkg, $sdate) = @_;
-  my $cutoff_day = $self->cutoff_day($cust_pkg);
+  my @cutoff_days = $self->cutoff_day($cust_pkg);
   if ( ! $cust_pkg->bill
       and $self->option('prorate_defer_bill',1)
-      and $cutoff_day
+      and @cutoff_days
   ) {
-    my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, $cutoff_day);
+    my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days);
     # If today is the cutoff day, set the next bill and setup both to 
     # midnight today, so that the customer will be billed normally for a 
     # month starting today.
@@ -186,7 +198,9 @@ before the end of the prorate interval.
 =cut
 
 sub _endpoints {
-  my ($self, $mnow, $cutoff_day) = @_;
+  my $self = shift;
+  my $mnow = shift;
+  my @cutoff_days = sort {$a <=> $b} @_;
 
   # only works for freq >= 1 month; probably can't be fixed
   my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5];
@@ -202,12 +216,20 @@ sub _endpoints {
   }
   my $mend;
   my $mstart;
+  # select the first cutoff day that's on or after the current day
+  my $cutoff_day = min( grep { $_ >= $mday } @cutoff_days );
+  # if today is after the last cutoff, choose the first one
+  $cutoff_day ||= $cutoff_days[0];
+
+  # then, if today is on or after the selected day, set period to
+  # (cutoff day this month) - (cutoff day next month)
   if ( $mday >= $cutoff_day ) {
     $mend = 
       timelocal_nocheck(0,0,0,$cutoff_day,$mon == 11 ? 0 : $mon + 1,$year+($mon==11));
     $mstart =
       timelocal_nocheck(0,0,0,$cutoff_day,$mon,$year);
   }
+  # otherwise, set period to (cutoff day last month) - (cutoff day this month)
   else {
     $mend = 
       timelocal_nocheck(0,0,0,$cutoff_day,$mon,$year);
index 7233cc6..9d7341b 100644 (file)
@@ -45,7 +45,7 @@ sub cutoff_day {
   if ( $recur_method eq 'prorate' or $recur_method eq 'subscription' ) {
     return $self->option('cutoff_day',1) || 1;
   } else {
-    return 0;
+    return ();
   }
 }
 
@@ -58,26 +58,26 @@ sub calc_recur_Common {
   if ( $param->{'increment_next_bill'} ) {
 
     my $recur_method = $self->option('recur_method', 1) || 'anniversary';
-    my $cutoff_day = $self->cutoff_day($cust_pkg);
+    my @cutoff_day = $self->cutoff_day($cust_pkg);
 
     $charges = $self->base_recur($cust_pkg);
     $charges += $param->{'override_charges'} if $param->{'override_charges'};
 
     if ( $recur_method eq 'prorate' ) {
 
-      $charges = $self->calc_prorate(@_, $cutoff_day);
+      $charges = $self->calc_prorate(@_, @cutoff_day);
       $charges += $param->{'override_charges'} if $param->{'override_charges'};
 
     } elsif ( $recur_method eq 'subscription' ) {
 
       my ($day, $mon, $year) = ( localtime($$sdate) )[ 3..5 ];
 
-      if ( $day < $cutoff_day ) {
+      if ( $day < $cutoff_day[0] ) {
         if ( $mon == 0 ) { $mon=11; $year--; }
         else { $mon--; }
       }
 
-      $$sdate = timelocal(0, 0, 0, $cutoff_day, $mon, $year);
+      $$sdate = timelocal(0, 0, 0, $cutoff_day[0], $mon, $year);
 
     }#$recur_method
 
index aaad974..8c3d80d 100644 (file)
@@ -401,9 +401,10 @@ sub calc_usage {
     #my @invoice_details_sort;
 
     #first rate any outstanding CDRs not yet rated
-    foreach my $cdr (
-      $svc_x->get_cdrs( %options )
-    ) {
+    my $cdr_search = $svc_x->psearch_cdrs(%options);
+    $cdr_search->limit(1000);
+    $cdr_search->increment(0); # because we're changing their status as we go
+    while ( my $cdr = $cdr_search->fetch ) {
 
       my $error = $cdr->rate(
         'part_pkg'                          => $self,
@@ -414,14 +415,19 @@ sub calc_usage {
       );
       die $error if $error; #??
 
+      $cdr_search->adjust(1) if $cdr->freesidestatus eq '';
+      # it was skipped without changing status, so increment the 
+      # offset so that we don't re-fetch it on refill
+
     } # $cdr
 
     #then add details to invoices & get a total
     $options{'status'} = 'rated';
 
-    foreach my $cdr (
-      $svc_x->get_cdrs( %options ) 
-    ) {
+    $cdr_search = $svc_x->psearch_cdrs(%options);
+    $cdr_search->limit(1000);
+    $cdr_search->increment(0);
+    while ( my $cdr = $cdr_search->fetch ) {
       my $error;
       # at this point we officially Do Not Care about the rating method
       if ( $included_calls > 0 ) {
@@ -436,7 +442,9 @@ sub calc_usage {
       }
       die $error if $error;
       $formatter->append($cdr);
-    }
+
+      $cdr_search->adjust(1) if $cdr->freesidestatus eq 'rated';
+    } #$cdr
   }
 
   $formatter->finish; #writes into $details
index f4e5183..9054f7b 100644 (file)
@@ -227,19 +227,22 @@ sub calc_usage {
   ) {
     my $svc_phone = $cust_svc->svc_x;
 
-    foreach my $cdr ( $svc_phone->get_cdrs(
+    my $cdr_search = $svc_phone->psearch_cdrs(
       'inbound'        => 1,
       'default_prefix' => $self->option('default_prefix'),
       'status'         => '', # unprocessed only
       'for_update'     => 1,
-      )
-    ) {
+    );
+    $cdr_search->limit(1000);
+    $cdr_search->increment(0);
+    while ( my $cdr = $cdr_search->fetch ) {
 
       my $reason = $self->check_chargable( $cdr,
                                            'option_cache' => \%opt_cache,
                                          );
       if ( $reason ) {
         warn "not charging for CDR ($reason)\n" if $DEBUG;
+        $cdr_search->adjust(1);
         next;
       }
 
@@ -310,6 +313,8 @@ sub calc_usage {
       die $error if $error;
       $formatter->append($cdr);
 
+      $cdr_search->adjust(1) if $cdr->freesidestatus eq '';
+
     } #$cdr
   } # $cust_svc
 #  unshift @$details, { format => 'C',
index e5dcf6d..d8d74c1 100644 (file)
@@ -132,9 +132,11 @@ sub calc_usage {
 
       $options{'inbound'} = ( $pass eq 'inbound' );
 
-      foreach my $cdr (
-        $svc_x->get_cdrs( %options )
-      ) {
+      my $cdr_search = $svc_x->psearch_cdrs(%options);
+      $cdr_search->limit(1000);
+      $cdr_search->increment(0);
+      while ( my $cdr = $cdr_search->fetch ) {
+
         if ( $DEBUG > 1 ) {
           warn "rating CDR $cdr\n".
                join('', map { "  $_ => ". $cdr->{$_}. "\n" } keys %$cdr );
@@ -173,6 +175,8 @@ sub calc_usage {
 
         $total += $charge_min;
 
+        $cdr_search->adjust(1) if $cdr->freesidestatus eq '';
+
       } # $cdr
 
     } # $pass
@@ -213,9 +217,10 @@ sub calc_usage {
       # tell the formatter what we're sending it
       $formatter->inbound($options{'inbound'});
 
-      foreach my $cdr (
-        $svc_x->get_cdrs( %options )
-      ) {
+      my $cdr_search = $svc_x->psearch_cdrs(%options);
+      $cdr_search->limit(1000);
+      $cdr_search->increment(0);
+      while ( my $cdr = $cdr_search->fetch ) {
 
         my $object = $options{'inbound'}
                        ? $cdr->cdr_termination( 1 ) #1: inbound
@@ -242,6 +247,8 @@ sub calc_usage {
 
         $formatter->append($cdr);
 
+        $cdr_search->adjust(1) if $cdr->freesidestatus eq 'processing-tiered';
+
       } # $cdr
 
     } # $pass
index c94c57e..992e1c5 100644 (file)
@@ -163,10 +163,16 @@ simply using rather than editing advertising sources).
 
 sub all_part_referral {
   my $self = shift;
+  my $global = @_ ? shift : '';
+  my $disabled = @_ ? shift : '';
+
+  my $hashref = $disabled ? {} : { 'disabled' => '' };
+  my $and = $disabled ? ' WHERE ' : ' AND ';
 
   qsearch({
     'table'     => 'part_referral',
-    'extra_sql' => ' WHERE '. $self->acl_agentnum_sql(@_). ' ORDER BY refnum ',
+    'hashref'   => $hashref,
+    'extra_sql' => $and. $self->acl_agentnum_sql($global). ' ORDER BY refnum ',
   });
 
 }
index 7e592bf..dd18e87 100644 (file)
@@ -9,6 +9,7 @@ use FS::part_svc_column;
 use FS::part_export;
 use FS::export_svc;
 use FS::cust_svc;
+use FS::part_svc_class;
 
 @ISA = qw(FS::Record);
 
@@ -51,6 +52,8 @@ FS::Record.  The following fields are currently supported:
 =item svcdb - table used for this service.  See L<FS::svc_acct>,
 L<FS::svc_domain>, and L<FS::svc_forward>, among others.
 
+=item classnum - Optional service class (see L<FS::svc_class>)
+
 =item disabled - Disabled flag, empty or `Y'
 
 =item preserve - Preserve after cancellation, empty or 'Y'
@@ -387,6 +390,7 @@ sub check {
     || $self->ut_enum('disabled', [ '', 'Y' ] )
     || $self->ut_enum('preserve', [ '', 'Y' ] )
     || $self->ut_enum('selfservice_access', [ '', 'hidden', 'readonly' ] )
+    || $self->ut_foreign_keyn('classnum', 'part_svc_class', 'classnum' )
   ;
   return $error if $error;
 
diff --git a/FS/FS/part_svc_class.pm b/FS/FS/part_svc_class.pm
new file mode 100644 (file)
index 0000000..d1c9915
--- /dev/null
@@ -0,0 +1,126 @@
+package FS::part_svc_class;
+use base qw( FS::class_Common );
+
+use strict;
+use FS::Record; # qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::part_svc_class - Object methods for part_svc_class records
+
+=head1 SYNOPSIS
+
+  use FS::part_svc_class;
+
+  $record = new FS::part_svc_class \%hash;
+  $record = new FS::part_svc_class { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_svc_class object represents a service class.  FS::part_svc_class
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item classnum
+
+primary key
+
+=item classname
+
+classname
+
+=item disabled
+
+disabled
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new service class.  To add the service class 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
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'part_svc_class'; }
+
+=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 service class.  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_numbern('classnum')
+    || $self->ut_text('classname')
+    || $self->ut_enum('disabled', [ '', '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;
+
index 23dcc2d..ea9d584 100644 (file)
@@ -17,7 +17,51 @@ $name = 'eft_canada';
 
 my ($trans_code, $process_date);
 
+#ref http://gocanada.about.com/od/canadatravelplanner/a/canada_holidays.htm
+my %holiday_yearly = (
+   1 => { map {$_=>1}  1 }, #new year's
+  11 => { map {$_=>1} 11 }, #remembrance day
+  12 => { map {$_=>1} 25 }, #christmas
+  12 => { map {$_=>1} 26 }, #boxing day
+);
+my %holiday = (
+  2012 => {
+             7 => { map {$_=>1}  2 }, #canada day
+             8 => { map {$_=>1}  6 }, #First Monday of August Civic Holiday
+             9 => { map {$_=>1}  3 }, #labour day
+            10 => { map {$_=>1}  8 }, #thanksgiving
+          },
+  2013 => {  2 => { map {$_=>1} 18 }, #family day
+             3 => { map {$_=>1} 29 }, #good friday
+             4 => { map {$_=>1}  1 }, #easter monday
+             5 => { map {$_=>1} 20 }, #victoria day
+             7 => { map {$_=>1}  1 }, #canada day
+             8 => { map {$_=>1}  5 }, #First Monday of August Civic Holiday
+             9 => { map {$_=>1}  2 }, #labour day
+            10 => { map {$_=>1} 14 }, #thanksgiving
+          },
+  2014 => {  2 => { map {$_=>1} 17 }, #family day
+             4 => { map {$_=>1} 18 }, #good friday
+             4 => { map {$_=>1} 21 }, #easter monday
+             5 => { map {$_=>1} 19 }, #victoria day
+             7 => { map {$_=>1}  1 }, #canada day
+             8 => { map {$_=>1}  4 }, #First Monday of August Civic Holiday
+             9 => { map {$_=>1}  1 }, #labour day
+            10 => { map {$_=>1} 13 }, #thanksgiving
+          },
+  2015 => {  2 => { map {$_=>1} 16 }, #family day
+             4 => { map {$_=>1}  3 }, #good friday
+             4 => { map {$_=>1}  6 }, #easter monday
+             5 => { map {$_=>1} 18 }, #victoria day
+             7 => { map {$_=>1}  1 }, #canada day
+             8 => { map {$_=>1}  3 }, #First Monday of August Civic Holiday
+             9 => { map {$_=>1}  7 }, #labour day
+            10 => { map {$_=>1} 12 }, #thanksgiving
+          },
+);
+
 %export_info = (
+
   init => sub {
     my $conf = shift;
     my @config = $conf->config('batchconfig-eft_canada'); 
@@ -25,9 +69,24 @@ my ($trans_code, $process_date);
     my $process_delay;
     ($trans_code, $process_delay) = @config[2,3];
     $process_delay ||= 1; # days
-    $process_date = time2str('%D', time + ($process_delay * 86400));
+
+    my $pt = time + ($process_delay * 86400);
+    my @lt = localtime($pt);
+    while (    $lt[6] == 0 #Sunday
+            || $lt[6] == 6 #Saturday
+            || $holiday_yearly{ $lt[4]+1 }{ $lt[3] }
+            || $holiday{ $lt[5]+1900 }{ $lt[4]+1 }{ $lt[3] }
+          )
+    {
+      $pt += 86400;
+      @lt = localtime($pt);
+    }
+
+    $process_date = time2str('%D', $pt);
   },
+
   delimiter => '', # avoid blank lines for header/footer
+
   # EFT Upload Specification for .CSV Files, Rev. 2.0
   # not a true CSV format--strings aren't quoted, so be careful
   row => sub {
index 5a4048f..6adc852 100644 (file)
@@ -244,7 +244,8 @@ Returns the locations (see L<FS::cust_location>) associated with this prospect.
 
 sub cust_location {
   my $self = shift;
-  qsearch( 'cust_location', { 'prospectnum' => $self->prospectnum } );
+  qsearch( 'cust_location', { 'prospectnum' => $self->prospectnum,
+                              'custnum'     => '' } );
 }
 
 =item qual
diff --git a/FS/FS/sales.pm b/FS/FS/sales.pm
new file mode 100644 (file)
index 0000000..3cb61fd
--- /dev/null
@@ -0,0 +1,142 @@
+package FS::sales;
+
+use strict;
+use vars qw( @ISA );
+use base qw( FS::Record );
+use Business::CreditCard 0.28;
+use FS::Record qw( dbh qsearch qsearchs );
+use FS::cust_main;
+use FS::cust_pkg;
+use FS::agent_type;
+use FS::reg_code;
+use FS::TicketSystem;
+#use FS::Conf;
+
+@ISA = qw( FS::m2m_Common FS::Record );
+
+=head1 NAME
+
+FS::sales - Object methods for sales records
+
+=head1 SYNOPSIS
+
+  use FS::sales;
+
+  $record = new FS::sales \%hash;
+  $record = new FS::sales { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::sales object represents an example.  FS::sales inherits from
+FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item salesnum
+
+primary key
+
+=item agentnum
+
+agentnum
+
+=item disabled
+
+disabled
+
+
+=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
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'sales'; }
+
+=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 example.  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_numbern('salesnum')
+    || $self->ut_numbern('agentnum')
+  ;
+  return $error if $error;
+
+  if ( $self->dbdef_table->column('disabled') ) {
+    $error = $self->ut_enum('disabled', [ '', 'Y' ] );
+    return $error if $error;
+  }
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+The author forgot to customize this manpage.
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index ff00ce0..a6daf44 100644 (file)
@@ -5,6 +5,7 @@ use vars qw( @ISA $noexport_hack $DEBUG $me
              $overlimit_missing_cust_svc_nonfatal_kludge );
 use Carp qw( cluck carp croak confess ); #specify cluck have to specify them all
 use Scalar::Util qw( blessed );
+use Lingua::EN::Inflect qw( PL_N );
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs fields dbh );
 use FS::cust_main_Mixin;
@@ -243,6 +244,7 @@ sub insert {
 
   my $svcnum = $self->svcnum;
   my $cust_svc = $svcnum ? qsearchs('cust_svc',{'svcnum'=>$self->svcnum}) : '';
+  my $inserted_cust_svc = 0;
   #unless ( $svcnum ) {
   if ( !$svcnum or !$cust_svc ) {
     $cust_svc = new FS::cust_svc ( {
@@ -256,6 +258,7 @@ sub insert {
       $dbh->rollback if $oldAutoCommit;
       return $error;
     }
+    $inserted_cust_svc  = 1;
     $svcnum = $self->svcnum($cust_svc->svcnum);
   } else {
     #$cust_svc = qsearchs('cust_svc',{'svcnum'=>$self->svcnum});
@@ -274,6 +277,10 @@ sub insert {
               || $self->preinsert_hook
               || $self->SUPER::insert;
   if ( $error ) {
+    if ( $inserted_cust_svc ) {
+      my $derror = $cust_svc->delete;
+      die $derror if $derror;
+    }
     $dbh->rollback if $oldAutoCommit;
     return $error;
   }
@@ -844,8 +851,7 @@ sub set_auto_inventory {
         qsearchs('inventory_class', { 'classnum' => $classnum } );
       return "Can't find inventory_class.classnum $classnum"
         unless $inventory_class;
-      return "Out of ". $inventory_class->classname. "s\n"; #Lingua:: BS
-                                                            #for pluralizing
+      return "Out of ". PL_N($inventory_class->classname);
     }
 
     next if $columnflag eq 'M' && $inventory_item->svcnum == $self->svcnum;
@@ -853,31 +859,38 @@ sub set_auto_inventory {
     $self->setfield( $field, $inventory_item->item );
       #if $columnflag eq 'A' && $self->$field() eq '';
 
-    $inventory_item->svcnum( $self->svcnum );
-    my $ierror = $inventory_item->replace();
-    if ( $ierror ) {
-      $dbh->rollback if $oldAutoCommit;
-      return "Error provisioning inventory: $ierror";
-    }
-
     if ( $old && $old->$field() && $old->$field() ne $self->$field() ) {
       my $old_inv = qsearchs({
-        'table'   => 'inventory_item',
-        'hashref' => { 'classnum' => $classnum,
-                       'svcnum'   => $old->svcnum,
-                       'item'     => $old->$field(),
-                     },
+        'table'     => 'inventory_item',
+        'hashref'   => { 'classnum' => $classnum,
+                         'svcnum'   => $old->svcnum,
+                       },
+        'extra_sql' => ' AND '.
+          '( ( svc_field IS NOT NULL AND svc_field = '.$dbh->quote($field).' )'.
+          '  OR ( svc_field IS NULL AND item = '. dbh->quote($old->$field).' )'.
+          ')',
       });
       if ( $old_inv ) {
         $old_inv->svcnum('');
+        $old_inv->svc_field('');
         my $oerror = $old_inv->replace;
         if ( $oerror ) {
           $dbh->rollback if $oldAutoCommit;
           return "Error unprovisioning inventory: $oerror";
         }
+      } else {
+        warn "old inventory_item not found for $field ". $self->$field;
       }
     }
 
+    $inventory_item->svcnum( $self->svcnum );
+    $inventory_item->svc_field( $field );
+    my $ierror = $inventory_item->replace();
+    if ( $ierror ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Error provisioning inventory: $ierror";
+    }
+
   }
 
  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -906,6 +919,7 @@ sub return_inventory {
 
   foreach my $inventory_item ( $self->inventory_item ) {
     $inventory_item->svcnum('');
+    $inventory_item->svc_field('');
     my $error = $inventory_item->replace();
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
index 731c832..ac97eab 100644 (file)
@@ -1,11 +1,14 @@
 package FS::svc_Radius_Mixin;
+use base qw( FS::m2m_Common FS::svc_Common );
 
 use strict;
-use base qw(FS::m2m_Common FS::svc_Common);
-use FS::Record qw(qsearch);
+use FS::Record qw( qsearch dbh );
 use FS::radius_group;
 use FS::radius_usergroup;
-use Carp qw(confess);
+use Carp qw( confess );
+
+# not really a mixin since it overrides insert/replace/delete and has svc_Common
+#  as a base class, should probably be renamed svc_Radius_Common
 
 =head1 NAME
 
@@ -17,15 +20,34 @@ FS::svc_Radius_Mixin - partial base class for services with RADIUS groups
 
 =cut
 
-
 sub insert {
   my $self = shift;
-  $self->SUPER::insert(@_)
-  || $self->process_m2m(
-    'link_table' => 'radius_usergroup',
-    'target_table' => 'radius_group',
-    'params' => $self->usergroup,
-  );
+
+  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 $error =  $self->SUPER::insert(@_)
+            || $self->process_m2m(
+                                   'link_table'   => 'radius_usergroup',
+                                   'target_table' => 'radius_group',
+                                   'params'       => $self->usergroup,
+                                 );
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
 }
 
 sub replace  {
@@ -33,22 +55,63 @@ sub replace  {
   my $old = shift;
   $old = $new->replace_old if !defined($old);
 
+  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;
+
   $old->usergroup; # make sure this is cached for exports
-  $new->process_m2m(
-    'link_table' => 'radius_usergroup',
-    'target_table' => 'radius_group',
-    'params' => $new->usergroup,
-  ) || $new->SUPER::replace($old, @_);
+
+  my $error =  $new->process_m2m(
+                                 'link_table'   => 'radius_usergroup',
+                                 'target_table' => 'radius_group',
+                                 'params'       => $new->usergroup,
+                               )
+            || $new->SUPER::replace($old, @_);
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
 }
 
 sub delete {
   my $self = shift;
-  $self->SUPER::delete(@_)
-  || $self->process_m2m(
-    'link_table' => 'radius_usergroup',
-    'target_table' => 'radius_group',
-    'params' => [],
-  );
+
+  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 $error =  $self->SUPER::delete(@_)
+            || $self->process_m2m(
+                                   'link_table'   => 'radius_usergroup',
+                                   'target_table' => 'radius_group',
+                                   'params'       => [],
+                                 );
+
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
 }
 
 sub usergroup {
index 64cc377..8210269 100755 (executable)
@@ -543,9 +543,9 @@ sub _check_ip_addr {
 
 sub _check_duplicate {
   my $self = shift;
-
-  $self->lock_table;
-
+  # Not a reliable check because the table isn't locked, but 
+  # that's why we have a unique index.  This is just to give a
+  # friendlier error message.
   my @dup;
   @dup = $self->find_duplicates('global', 'ip_addr');
   if ( @dup ) {
index f8b9605..4182a13 100644 (file)
@@ -3,6 +3,7 @@ package FS::svc_pbx;
 use strict;
 use base qw( FS::svc_External_Common );
 use FS::Record qw( qsearch qsearchs dbh );
+use FS::PagedSearch qw( psearch );
 use FS::Conf;
 use FS::cust_svc;
 use FS::svc_phone;
@@ -259,11 +260,13 @@ sub _check_duplicate {
   return '';
 }
 
-=item get_cdrs
+=item psearch_cdrs OPTIONS
 
-Returns a set of Call Detail Records (see L<FS::cdr>) associated with this 
-service.  By default, "associated with" means that the "charged_party" field of
-the CDR matches the "title" field of the service.
+Returns a paged search (L<FS::PagedSearch>) for Call Detail Records 
+associated with this service.  By default, "associated with" means that 
+the "charged_party" field of the CDR matches the "title" field of the 
+service.  To access the CDRs themselves, call "->fetch" on the resulting
+object.
 
 =over 2
 
@@ -295,7 +298,7 @@ to allow title to indicate a range of IP addresses.
 
 =cut
 
-sub get_cdrs {
+sub psearch_cdrs {
   my($self, %options) = @_;
   my %hash = ();
   my @where = ();
@@ -343,15 +346,26 @@ sub get_cdrs {
   my $extra_sql = ( keys(%hash) ? ' AND ' : ' WHERE ' ). join(' AND ', @where )
     if @where;
 
-  my @cdrs =
-    qsearch( {
+  psearch( {
       'table'      => 'cdr',
       'hashref'    => \%hash,
       'extra_sql'  => $extra_sql,
       'order_by'   => "ORDER BY startdate $for_update",
-    } );
+  } );
+}
+
+=item get_cdrs (DEPRECATED)
+
+Like psearch_cdrs, but returns all the L<FS::cdr> objects at once, in a 
+single list.  Arguments are the same as for psearch_cdrs.  This can take
+an unreasonably large amount of memory and is best avoided.
 
-  @cdrs;
+=cut
+
+sub get_cdrs {
+  my $self = shift;
+  my $psearch = $self->psearch_cdrs($_);
+  qsearch ( $psearch->{query} )
 }
 
 =back
index b395ea6..1296c1e 100644 (file)
@@ -7,6 +7,7 @@ use Data::Dumper;
 use Scalar::Util qw( blessed );
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs dbh );
+use FS::PagedSearch qw( psearch );
 use FS::Msgcat qw(gettext);
 use FS::part_svc;
 use FS::phone_device;
@@ -648,11 +649,13 @@ sub cust_location_or_main {
   $cust_pkg ? $cust_pkg->cust_location_or_main : '';
 }
 
-=item get_cdrs
+=item psearch_cdrs OPTIONS
 
-Returns a set of Call Detail Records (see L<FS::cdr>) associated with this 
-service.  By default, "associated with" means that either the "src" or the 
-"charged_party" field of the CDR matches the "phonenum" field of the service.
+Returns a paged search (L<FS::PagedSearch>) for Call Detail Records 
+associated with this service.  By default, "associated with" means that 
+either the "src" or the "charged_party" field of the CDR matches the 
+"phonenum" field of the service.  To access the CDRs themselves, call
+"->fetch" on the resulting object.
 
 =over 2
 
@@ -676,11 +679,16 @@ with the chosen prefix.
 
 =item by_svcnum: not supported for svc_phone
 
+=item billsec_sum: Instead of returning all of the CDRs, return a single
+record (as an L<FS::cdr> object) with the sum of the 'billsec' field over 
+the entire result set.
+
 =back
 
 =cut
 
-sub get_cdrs {
+sub psearch_cdrs {
+
   my($self, %options) = @_;
   my @fields;
   my %hash;
@@ -739,18 +747,30 @@ sub get_cdrs {
 
   my $extra_sql = ( keys(%hash) ? ' AND ' : ' WHERE ' ). join(' AND ', @where );
 
-  my @cdrs =
-    qsearch( {
+  psearch( {
       'table'      => 'cdr',
       'hashref'    => \%hash,
       'extra_sql'  => $extra_sql,
       'order_by'   => $options{'billsec_sum'} ? '' : "ORDER BY startdate $for_update",
       'select'     => $options{'billsec_sum'} ? 'sum(billsec) as billsec_sum' : '*',
-    } );
+  } );
+}
+
+=item get_cdrs (DEPRECATED)
+
+Like psearch_cdrs, but returns all the L<FS::cdr> objects at once, in a 
+single list.  Arguments are the same as for psearch_cdrs.  This can take 
+an unreasonably large amount of memory and is best avoided.
 
-  @cdrs;
+=cut
+
+sub get_cdrs {
+  my $self = shift;
+  my $psearch = $self->psearch_cdrs(@_);
+  qsearch ( $psearch->{query} )
 }
 
+
 =back
 
 =head1 BUGS
index 48c0196..e9496e4 100644 (file)
@@ -993,7 +993,7 @@ sub _perform_batch_import {
     }
 
     push @insert_list,
-      'DETAIL', "$dir/".$files{detail}, \&FS::tax_rate::batch_import, $format
+      'DETAIL', "$dir/".$files{detailfile}, \&FS::tax_rate::batch_import, $format
       if $format =~ /update/;
 
     $error ||= _perform_cch_tax_import( $job,
index 0c9cc54..e7aba20 100644 (file)
@@ -37,7 +37,6 @@ FS/ClientAPI/Bulk.pm
 FS/ClientAPI/MasonComponent.pm
 FS/ClientAPI/MyAccount.pm
 FS/ClientAPI/PrepaidPhone.pm
-FS/ClientAPI/SGNG.pm
 FS/ClientAPI/Signup.pm
 FS/Conf.pm
 FS/ConfItem.pm
@@ -634,3 +633,11 @@ FS/contact_class.pm
 t/contact_class.t
 FS/upgrade_journal.pm
 t/upgrade_journal.t
+FS/sales.pm
+t/sales.t
+FS/access_groupsales.pm
+t/access_groupsales.t
+FS/part_svc_class.pm
+t/part_svc_class.t
+FS/ftp_target.pm
+t/ftp_target.t
index 9930aae..fdfc66a 100644 (file)
@@ -5,7 +5,6 @@ use strict;
 use FS::UID qw( adminsuidsetup );
 use FS::Cron::check qw(
   check_queued check_selfservice check_apache check_bop_failures
-  check_sg check_sg_login check_sgng
   alert error_msg
 );
 
@@ -21,11 +20,5 @@ check_queued       or alert('Queue daemon not running', @emails);
 check_selfservice  or alert(error_msg(), @emails);
 check_apache       or alert('Apache not running: '. error_msg(), @emails);
 
-#no-ops unless you are sg
-my $sg = 'FS::ClientAPI::SG';
-check_sg           or alert("$sg not responding: ".     error_msg(), @emails);
-check_sg_login     or alert("$sg login errort: ".       error_msg(), @emails);
-check_sgng         or alert("${sg}NG not responding: ". error_msg(), @emails);
-
 check_bop_failures or alert(error_msg(), @emails);
 
diff --git a/FS/t/access_groupsales.t b/FS/t/access_groupsales.t
new file mode 100644 (file)
index 0000000..50993cf
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::access_groupsales;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/ftp_target.t b/FS/t/ftp_target.t
new file mode 100644 (file)
index 0000000..1a59281
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::ftp_target;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_svc_class.t b/FS/t/part_svc_class.t
new file mode 100644 (file)
index 0000000..e838c0b
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_svc_class;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/sales.t b/FS/t/sales.t
new file mode 100644 (file)
index 0000000..e47eb39
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::sales;
+$loaded=1;
+print "ok 1\n";
index b603b8c..95ffbf2 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -115,13 +115,19 @@ RT_PATH = /opt/rt3
 FREESIDE_PATH = `pwd`
 PERL_INC_DEV_KLUDGE = /usr/local/share/perl/5.14.2/
 
-VERSION=3.0git
-TAG=freeside_3_0
+VERSION := `grep '^$$VERSION' FS/FS.pm | cut -d\' -f2`
+TAG := freeside_`grep '^$$VERSION' FS/FS.pm | cut -d\' -f2 | perl -pe 's/\./_/g'`
 
-DEBVERSION = `echo ${VERSION} | perl -pe 's/(\d)([a-z])/\1~\2/'`-1
+#DEBVERSION = `echo ${VERSION} | perl -pe 's/(\d)([a-z])/\1~\2/'`-1
 
 TEXMFHOME := "\$$TEXMFHOME"
 
+ver:
+       @echo "${VERSION}"
+
+tag:
+       @echo "${TAG}"
+
 help:
        @echo "supported targets:"
        @echo "                   create-database create-config"
@@ -180,9 +186,6 @@ perl-modules:
        [ -e Makefile ] || perl Makefile.PL; \
        make; \
        perl -p -i -e "\
-         s/%%%VERSION%%%/${VERSION}/g;\
-       " blib/lib/FS.pm;\
-       perl -p -i -e "\
          s|%%%FREESIDE_CONF%%%|${FREESIDE_CONF}|g;\
          s|%%%FREESIDE_CACHE%%%|${FREESIDE_CACHE}|g;\
          s'%%%FREESIDE_DOCUMENT_ROOT%%%'${FREESIDE_DOCUMENT_ROOT}'g; \
@@ -337,7 +340,7 @@ configure-rt:
                    --with-db-dba=${DB_USER} \
                    --with-db-database=${RT_DB_DATABASE} \
                    --with-db-rt-user=${DB_USER} \
-                   --with-db-rt-pass=${DB_PASSWORD} \
+                   --with-db-rt-pass="${DB_PASSWORD}" \
                    --with-web-user=freeside \
                    --with-web-group=freeside \
                    --with-rt-group=freeside \
diff --git a/conf/invoice-unitprice b/conf/invoice-unitprice
new file mode 100644 (file)
index 0000000..e69de29
index af04fcc..c22e426 100644 (file)
@@ -32,6 +32,7 @@ $socket .= '.'.$tag if defined $tag && length($tag);
   'switch_acct'               => 'MyAccount/switch_acct',
   'customer_info'             => 'MyAccount/customer_info',
   'customer_info_short'       => 'MyAccount/customer_info_short',
+  'billing_history'           => 'MyAccount/billing_history',
   'edit_info'                 => 'MyAccount/edit_info',     #add to ss cgi!
   'invoice'                   => 'MyAccount/invoice',
   'invoice_pdf'               => 'MyAccount/invoice_pdf',
@@ -104,22 +105,6 @@ $socket .= '.'.$tag if defined $tag && length($tag);
   'call_time'                 => 'PrepaidPhone/call_time',
   'call_time_nanpa'           => 'PrepaidPhone/call_time_nanpa',
   'phonenum_balance'          => 'PrepaidPhone/phonenum_balance',
-  #izoom
-  #'bulk_processrow'           => 'Bulk/processrow',
-  #conflicts w/Agent one# 'check_username'            => 'Bulk/check_username',
-  #sg
-  'ping'                      => 'SGNG/ping',
-  'decompify_pkgs'            => 'SGNG/decompify_pkgs',
-  'previous_payment_info'     => 'SGNG/previous_payment_info',
-  'previous_payment_info_renew_info'
-                              => 'SGNG/previous_payment_info_renew_info',
-  'previous_process_payment'  => 'SGNG/previous_process_payment',
-  'previous_process_payment_order_pkg'
-                              => 'SGNG/previous_process_payment_order_pkg',
-  'previous_process_payment_change_pkg'
-                              => 'SGNG/previous_process_payment_change_pkg',
-  'previous_process_payment_order_renew'
-                              => 'SGNG/previous_process_payment_order_renew',
 );
 @EXPORT_OK = (
   keys(%autoload),
index d27f688..5370f7c 100644 (file)
@@ -2,7 +2,9 @@
 <%= include('header', 'My Account') %>
 
 Hello <%= $name %>!<BR><BR>
-<%= $small_custview %>
+
+<%= include('small_custview') %>
+
 <BR>
 <%= if ( $access_pkgnum ) {
       $OUT .= qq!Balance: <B>\$$balance</B><BR><BR>!;
@@ -34,17 +36,17 @@ Hello <%= $name %>!<BR><BR>
     $OUT .= '<TABLE BORDER=0 CELLSPACING=0 CELLPADDING=2 BGCOLOR="#eeeeee">'.
             '<TR><TH BGCOLOR="#ff6666" COLSPAN=5>Open Invoices</TH></TR>';
     my $link = qq!<A HREF="<%= $url %>myaccount!;
-    my $col1 = "ffffff";
-    my $col2 = "dddddd";
+    my $col1 = $stripe1_bgcolor || '#ffffff';
+    my $col2 = $stripe2_bgcolor || '#dddddd';
     my $col = $col1;
 
     foreach my $invoice ( @open_invoices ) {
-      my $td = qq!<TD BGCOLOR="#$col">!;
+      my $td = qq!<TD BGCOLOR="$col">!;
       my $a=qq!<A HREF="${url}view_invoice;invnum=!. $invoice->{'invnum'}. '">';
       $OUT .=
         "<TR>$td${a}Invoice #". $invoice->{'invnum'}. "</A></TD>$td</TD>".
         "$td$a". $invoice->{'date'}. "</A></TD>$td</TD>".
-        qq!<TD BGCOLOR="#$col" ALIGN="right">$a\$!. $invoice->{'owed'}.
+        qq!<TD BGCOLOR="$col" ALIGN="right">$a\$!. $invoice->{'owed'}.
           '</A></TD>'.
         '</TR>';
       $col = $col eq $col1 ? $col2 : $col1;
@@ -61,12 +63,12 @@ Hello <%= $name %>!<BR><BR>
             '<TR><TH BGCOLOR="#ff6666" COLSPAN="3">Support Time Remaining</TH>'.
             '</TR><TR><TH>Package</TH><TH></TH>'.
             '<TH>Time Remaining</TH></TR>';
-    my $col1 = "ffffff";
-    my $col2 = "dddddd";
+    my $col1 = $stripe1_bgcolor || '#ffffff';
+    my $col2 = $stripe2_bgcolor || '#dddddd';
     my $col = $col1;
 
     foreach my $support ( @support_services ) {
-      my $td = qq!<TD BGCOLOR="#$col">!;
+      my $td = qq!<TD BGCOLOR="$col">!;
       my $a = qq!<A HREF="${url}view_support_details;svcnum=!.
               $support->{'svcnum'}. '">';
       $OUT .=
diff --git a/fs_selfservice/FS-SelfService/cgi/small_custview.html b/fs_selfservice/FS-SelfService/cgi/small_custview.html
new file mode 100644 (file)
index 0000000..8d6e073
--- /dev/null
@@ -0,0 +1,69 @@
+<DIV ID="fs_small_custview">
+  
+Customer #<B><%= $custnum %></B>
+ - <B><FONT COLOR="#<%= $statuscolor %>"><%= ucfirst($status)%></FONT></B>
+
+<TABLE BORDER=0 CELLSPACING=0 CELLPADDING=2 BGCOLOR="<%= $box_bgcolor ||= '#c0c0c0' %>">
+  <TR>
+    <TD VALIGN="top">
+      <%= $has_ship_address
+            ? '<I><FONT SIZE="-1">Billing Address</FONT></I><BR>'
+            : ''
+      %>
+      <%= $first %> <%= $last %><BR>
+      <%= $company ? $company.'<BR>' : '' %>
+      <%= $address1 %><BR>
+      <%= $address2 ? $address2.'<BR>' : '' %>
+      <%= $city %>, <%= $state %> <%= $zip %><BR>
+      <%= $country && $country ne ($countrydefault||'US')
+            ? $country.'<BR>'
+            : ''
+      %>
+      <%= if ( $daytime && $night ) {
+            $OUT .= "<BR>Day Phone $daytime<BR>Night Phone $night";
+          } elsif ( $daytime || $night ) {
+            $OUT .= '<BR>'. ($daytime || $night);
+          }
+          '';
+      %>
+      <%= $fax ? "<BR>Fax $fax" : '' %>
+
+    </TD>
+
+<%= if ( $has_ship_address ) {
+
+  $OUT .= '<TD>&nbsp;</TD>'.
+          '<TD VALIGN="top">'.
+          '<I><FONT SIZE="-1">Service Address</FONT></I><BR>'.
+          "$ship_first $ship_last<BR>";
+  $OUT .= "$ship_company<BR>" if $ship_company;
+  $OUT .= "$ship_address1<BR>";
+  $OUT .= "$ship_address2<BR>" if $ship_address2;
+  $OUT .= "$ship_city, $ship_state  $ship_zip<BR>";
+  $OUT .= "$ship_country<BR>"
+    if $ship_country && $ship_country ne ($countrydefault||'US');
+
+
+  if ( $ship_daytime && $ship_night ) {
+    $OUT .= "<BR>Day Phone $ship_daytime<BR>Night Phone $ship_night";
+  } elsif ( $ship_daytime || $ship_night ) {
+    $OUT .= '<BR'. ($ship_daytime || $ship_night);
+  }
+
+  $OUT .= "<BR>Fax $ship_fax" if $ship_fax;
+
+  $OUT .= '</TD>';
+}
+'';
+%>
+
+</TR></TABLE>
+
+<%= unless ( $access_pkgnum ) {
+      $OUT .= '<BR>Balance: <B>$'. $balance. '</B><BR>';
+    }
+    '';
+%>
+
+</DIV>
+
index c840ca9..4333c4e 100644 (file)
@@ -1,10 +1,10 @@
 <SCRIPT TYPE="text/javascript" SRC="overlibmws.js"></SCRIPT>
 <TABLE BORDER=0 CELLSPACING=0 CELLPADDING=2 BGCOLOR="#eeeeee">
-<TR><TH BGCOLOR="#ff6666" COLSPAN=15>Open Tickets</TH></TR>
+<TR><TH BGCOLOR="#ff6666" COLSPAN=16>Open Tickets</TH></TR>
 <TR>
 <%=
-my $col1 = "ffffff";
-my $col2 = "dddddd";
+my $col1 = $stripe1_bgcolor || "#ffffff";
+my $col2 = $stripe2_bgcolor || "#dddddd";
 my $col = $col1;
 
 my $can_set_priority = 
@@ -26,12 +26,15 @@ my @titles = ('#', qw(Subject Queue Status Created Due));
 push @titles, 'Estimated<BR>Hours';
 push @titles, 'Priority';
 
-$OUT .= join("\n", map { "<TH VALIGN=\"top\">$_</TH><TH>&nbsp;</TH>" } @titles)
+$box_bgcolor ||= '#c0c0c0';
+my $th = qq(<TH BGCOLOR="$box_bgcolor");
+
+$OUT .= join("\n", map { "$th VALIGN=\"top\">$_</TH>$th>&nbsp;</TH>" } @titles)
       . '</TR>';
 
 foreach my $ticket ( @tickets ) {
   my $id = $ticket->{'id'};
-  my $td = qq!<TD BGCOLOR="#$col">!;
+  my $td = qq!<TD BGCOLOR="$col">!;
   my $space = $td.'&nbsp;</TD>';
   my $link = qq!<A HREF="${url}tktview;ticket_id=$id">!;
   $OUT .= '<TR>' . 
@@ -41,31 +44,34 @@ $td. $ticket->{'queue'} . '</TD>'. $space .
 $td. $ticket->{'status'} . '</TD>'. $space .
 $td. $date_formatter->($ticket->{'created'}) . '</TD>'. $space .
 $td. $date_formatter->($ticket->{'due'}) . '</TD>'. $space .
-qq!<TD BGCOLOR="#$col" ALIGN="right">!. 
+qq!<TD BGCOLOR="$col" ALIGN="right">!. 
   ($ticket->{'timeestimated'} ? 
     sprintf('%.1f', $ticket->{'timeestimated'} / 60.0) # .1f?
   : ''
   ) .  '</TD>'. $space .
-qq!<TD BGCOLOR="#$col" ALIGN="right">!;
+qq!<TD BGCOLOR="$col" ALIGN="right">!;
   if ( $can_set_priority ) {
     $OUT .= '<INPUT TYPE="hidden" NAME="ticket'.$id.'" VALUE="1">' .
             '<INPUT TYPE="text" SIZE=4 NAME="priority'.$id.'"' .
             'VALUE="'.$ticket->{'_selfservice_priority'}.'"></TD>';
     if ( exists($ticket_error{$id}) ) {
       # display error message compactly
-      $OUT .= '<TD><FONT COLOR="#ff0000" onmouseover="'.
+      $OUT .= $td. '<FONT COLOR="#ff0000" onmouseover="'.
               "return overlib('".$ticket_error{$id}."', AUTOSTATUS, WRAP);" .
               '" onmouseout="nd();">*</FONT></TD>';
+    } else {
+      $OUT .= $td.'</TD>';
     }
   }
   else {
-    $OUT .= ($ticket->{'content'} || $ticket->{'priority'}) . '</TD>';
+    $OUT .= ($ticket->{'content'} || $ticket->{'priority'}) . '</TD>'.
+            $td.'</TD>';
   }
   $OUT .= '</TR>';
   $col = $col eq $col1 ? $col2 : $col1;
 } #foreach my $ticket
 if ( $can_set_priority ) {
-  $OUT .= '<TR><TD COLSPAN=15 ALIGN="right">
+  $OUT .= '<TR><TD COLSPAN=16 ALIGN="right">
 <INPUT TYPE="submit" VALUE="Save changes"></TD></TR></FORM>';
 }
 %>
diff --git a/fs_selfservice/php/freeside.class.new.php b/fs_selfservice/php/freeside.class.new.php
new file mode 100644 (file)
index 0000000..b9d125e
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+#pre-php 5.4 compatible version?
+function flatten($hash) {
+  if ( !is_array($hash) ) return $hash;
+  $flat = array();
+
+  array_walk($hash, function($value, $key, &$to) { 
+    array_push($to, $key, $value);
+  }, $flat);
+
+  if ( PHP_VERSION_ID >= 50400 ) {
+
+    #php 5.4+ (deb 7+)
+    foreach ($hash as $key => $value) {
+      $flat[] = $key;
+      $flat[] = $value;
+    }
+
+  }
+
+  return($flat);
+}
+
+#php 5.4+?
+#function flatten($hash) {
+#  if ( !is_array($hash) ) return $hash;
+#
+#  $flat = array();
+#
+#  foreach ($hash as $key => $value) {
+#    $flat[] = $key;
+#    $flat[] = $value;
+#  }
+#
+#  return($flat);
+#}
+
+class FreesideSelfService  {
+
+    //Change this to match the location of your selfservice xmlrpc.cgi or daemon
+    #var $URL = 'https://localhost/selfservice/xmlrpc.cgi';
+    var $URL = 'http://localhost/selfservice/xmlrpc.cgi';
+
+    function FreesideSelfService() {
+      $this;
+    }
+
+    public function __call($name, $arguments) {
+
+        error_log("[FreesideSelfService] $name called, sending to ". $this->URL);
+
+        $request = xmlrpc_encode_request("FS.ClientAPI_XMLRPC.$name", flatten($arguments[0]));
+        $context = stream_context_create( array( 'http' => array(
+            'method' => "POST",
+            'header' => "Content-Type: text/xml",
+            'content' => $request
+        )));
+        $file = file_get_contents($this->URL, false, $context);
+        $response = xmlrpc_decode($file);
+        if (xmlrpc_is_fault($response)) {
+            trigger_error("[FreesideSelfService] XML-RPC communication error: $response[faultString] ($response[faultCode])");
+        } else {
+            //error_log("[FreesideSelfService] $response");
+            return $response;
+        }
+    }
+
+}
+
+?>
index e065f09..cea3661 100644 (file)
@@ -31,11 +31,30 @@ my $ah = new HTML::Mason::ApacheHandler (
 #
 #chown (Apache->server->uid, Apache->server->gid, $interp->files_written);
 
+my $protect_fds;
+
 sub handler
 {
     #($r) = @_;
     my $r = shift;
 
+    #from rt/bin/webmux.pl(.in)
+    if ( !$protect_fds && $ENV{'MOD_PERL'} && exists $ENV{'MOD_PERL_API_VERSION'}
+        && $ENV{'MOD_PERL_API_VERSION'} >= 2
+    ) {
+        # under mod_perl2, STDIN and STDOUT get closed and re-opened,
+        # however they are not on FD 0 and 1.  In this case, the next
+        # socket that gets opened will occupy one of these FDs, and make
+        # all system() and open "|-" calls dangerous; for example, the
+        # DBI handle can get this FD, which later system() calls will
+        # close by putting garbage into the socket.
+        $protect_fds = [];
+        push @{$protect_fds}, IO::Handle->new_from_fd(0, "r")
+            if fileno(STDIN) != 0;
+        push @{$protect_fds}, IO::Handle->new_from_fd(1, "w")
+            if fileno(STDOUT) != 1;
+    }
+
     # If you plan to intermix images in the same directory as
     # components, activate the following to prevent Mason from
     # evaluating image files as components.
@@ -65,7 +84,7 @@ sub handler
       local $SIG{__WARN__};
       local $SIG{__DIE__};
 
-      RT::Init();
+      my_rt_init();
 
       $ah->interp($rt_interp);
 
@@ -74,7 +93,7 @@ sub handler
       local $SIG{__WARN__};
       local $SIG{__DIE__};
 
-      RT::Init() if $RT::VERSION; #for lack of something else
+      my_rt_init();
 
       #we don't want the RT error handlers under FS
       {
@@ -109,4 +128,19 @@ sub handler
     $status;
 }
 
+my $rt_initialized = 0;
+
+sub my_rt_init {
+  return unless $RT::VERSION;
+
+  if ( $rt_initialized ) {
+    RT::ConnectToDatabase();
+    RT::InitSignalHandlers();
+  } else {
+    RT::LoadConfig();
+    RT::Init();
+    $rt_initialized++;
+  }
+}
+
 1;
diff --git a/httemplate/browse/ftp_target.html b/httemplate/browse/ftp_target.html
new file mode 100644 (file)
index 0000000..4a57820
--- /dev/null
@@ -0,0 +1,56 @@
+<& elements/browse.html,
+  'title'       => 'FTP targets',
+  'menubar'     => [ 'Add a target' => $p.'edit/ftp_target.html', ],
+  'name'        => 'FTP targets',
+  'query'       => { 'table'   => 'ftp_target',
+                     'hashref' => {},
+                   },
+  'count_query' => $count_query,
+  'header'      => [ '#',
+                     'Server',
+                     'Username',
+                     'Password',
+                     'Path',
+                     'Protocol',
+                     '', #handling
+                   ],
+  'fields'      => [ 'targetnum',
+                     'hostname',
+                     'username',
+                     'password',
+                     'path',
+                     sub { 
+                       my $ftp_target = shift;
+                       my $label;
+                       if ($ftp_target->secure) {
+                         $label = 'SFTP';
+                         $label .= ' (port '.$ftp_target->port.')'
+                           if $ftp_target->port != 22;
+                       }
+                       else {
+                         $label = 'FTP';
+                         $label .= ' (port '.$ftp_target->port.')'
+                           if $ftp_target->port != 21;
+                       }
+                       $label;
+                     },
+                     'handling',
+                   ],
+  'links'       => [ $link, $link ],
+&>
+</TABLE>
+
+<% include('/elements/footer.html') %>
+
+<%once>
+
+my $count_query = 'SELECT COUNT(*) FROM ftp_target';
+
+</%once>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $link = [ $p.'edit/ftp_target.html?', 'targetnum' ];
+</%init>
index 8a6ccf7..50afc28 100644 (file)
@@ -1,29 +1,32 @@
 <% include( 'elements/browse.html',
               'title'         => 'Message templates',
               'name_singular' => 'template',
-              'menubar'     => [ 'Add a new template' =>
-                                   $p.'edit/msg_template.html',
-                               ],
-              'query'       => { 'table' => 'msg_template', },
-              'count_query' => 'SELECT COUNT(*) FROM msg_template',
-              'disableable' => 1,
+              'menubar'       => \@menubar,
+              'query'         => { 'table' => 'msg_template', },
+              'count_query'   => 'SELECT COUNT(*) FROM msg_template',
+              'disableable'   => 1,
               'disabled_statuspos' => 2,
               'agent_virt'         => 1,
-              'agent_null_right'   => ['Edit global templates','Configuration'],
+              'agent_null_right'   => ['View global templates','Edit global templates'],
               'agent_pos'          => 1,
-              'header' => [ 'Name', '', ('' x scalar @locales) ],
-              'fields' => [ 'msgname', @locales ],
-              'links'  => [ $link, @locale_links ],
-              'cell_style' => 
-                          [ '', '', ($locale_style) x (scalar @locales) ],
+              'header'     => [ 'Name', '', map '', @locales ],
+              'fields'     => [ 'msgname', @locales ],
+              'links'      => [ $link, @locale_links ],
+              'cell_style' => [ '', '', map $locale_style, @locales ],
           )
 %>
 <%init>
 
+my $curuser = $FS::CurrentUser::CurrentUser;
+
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Edit templates')
-  ||     $FS::CurrentUser::CurrentUser->access_right('Edit global templates')
-  ||     $FS::CurrentUser::CurrentUser->access_right('Configuration');
+  unless $curuser->access_right([ 'View templates', 'View global templates',
+                                  'Edit templates', 'Edit global templates', ]);
+
+my @menubar = ();
+if ( $curuser->access_right(['Edit templates', 'Edit global templates']) ) {
+  push @menubar, 'Add a new template' => $p.'edit/msg_template.html';
+}
 
 my $link = [ "${p}edit/msg_template.html?msgnum=", 'msgnum' ];
 
index 4ca78d7..e3d9de1 100755 (executable)
@@ -436,7 +436,7 @@ push @fields,
 
                       [ map { 
                               [
-                                { 'data'  => $_,
+                                { 'data'  => "$_: ",
                                   'align' => 'right',
                                 },
                                 { 'data'  => $part_pkg->format($_,$options{$_}),
index 9cc32c4..c737467 100755 (executable)
@@ -6,6 +6,13 @@ Where a customer heard about your service. Tracked for informational purposes.
 <A HREF="<% $p %>edit/part_referral.html"><I>Add a new advertising source</I></A>
 <BR><BR>
 
+<% $cgi->param('showdisabled')
+    ? do { $cgi->param('showdisabled', 0);
+           '( <a href="'. $cgi->self_url. '">hide disabled advertising sources</a> )'; }
+    : do { $cgi->param('showdisabled', 1);
+           '( <a href="'. $cgi->self_url. '">show disabled advertising sources</a> )'; }
+%>
+
 <% include('/elements/table-grid.html') %>
 % my $bgcolor1 = '#eeeeee';
 %   my $bgcolor2 = '#ffffff';
@@ -13,8 +20,12 @@ Where a customer heard about your service. Tracked for informational purposes.
 
 <TR>
   <TH CLASS="grid" BGCOLOR="#cccccc" COLSPAN=2 ROWSPAN=2>Advertising source</TH>
-% if ( $show_agentnums ) { 
 
+%       if ( ! $cgi->param('showdisabled') ) { 
+          <TH CLASS="grid" BGCOLOR="#cccccc" ALIGN="center" ROWSPAN=2></TH>
+%       }
+
+% if ( $show_agentnums ) { 
     <TH CLASS="grid" BGCOLOR="#cccccc" ROWSPAN=2>Agent</TH>
 % } 
 
@@ -27,7 +38,7 @@ Where a customer heard about your service. Tracked for informational purposes.
 
 </TR>
 
-%foreach my $part_referral ( FS::part_referral->all_part_referral(1) ) {
+%foreach my $part_referral ( FS::part_referral->all_part_referral(1,!scalar($cgi->param('showdisabled'))) ) {
 %
 %  if ( $bgcolor eq $bgcolor1 ) {
 %    $bgcolor = $bgcolor2;
@@ -55,6 +66,16 @@ Where a customer heard about your service. Tracked for informational purposes.
 % } 
 
           <% $part_referral->referral %><% $a ? '</A>' : '' %></TD>
+
+%       if ( ! $cgi->param('showdisabled') ) { 
+          <TD CLASS="grid" BGCOLOR="<% $bgcolor %>" ALIGN="center">
+            <% $part_referral->disabled
+                 ? '<FONT COLOR="#FF0000"><B>DISABLED</B></FONT>'
+                 : '<FONT COLOR="#00CC00"><B>Active</B></FONT>'
+            %>
+          </TD>
+%       }
+
 % if ( $show_agentnums ) { 
 
           <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $part_referral->agentnum ? $part_referral->agent->agent : '(global)' %></TD>
@@ -73,11 +94,11 @@ Where a customer heard about your service. Tracked for informational purposes.
             <TABLE CLASS="inv" CELLSPACING=0 CELLPADDING=0>
               <TR>
                 <TD ALIGN="right"><B><% $num_cust %></B></TD>
-                <TD ALIGN="left">customers</TD>
+                <TD ALIGN="left">&nbsp;customers&nbsp;</TD>
               </TR>
               <TR>
                 <TD ALIGN="right"><B><% $num_pkg %></B></TD>
-                <TD ALIGN="left">packages</TD>
+                <TD ALIGN="left">&nbsp;packages&nbsp;</TD>
               </TR>
             </TABLE>
           </TD>
@@ -94,7 +115,7 @@ Where a customer heard about your service. Tracked for informational purposes.
 %    or die dbh->errstr;
 
       <TR>
-        <TD BGCOLOR="#dddddd" ALIGN="center" COLSPAN=3><B>Total</B></TD>
+        <TD BGCOLOR="#dddddd" ALIGN="center" COLSPAN=<% 2 + $show_agentnums + ! $cgi->param('showdisabled') %><B>Total</B></TD>
 % for my $period ( keys %after ) {
 %          my @param = ( $today-$after{$period},
 %                        $today+$before{$period},
@@ -108,11 +129,11 @@ Where a customer heard about your service. Tracked for informational purposes.
             <TABLE CLASS="inv" CELLSPACING=0 CELLPADDING=0>
               <TR>
                 <TD ALIGN="right"><B><% $num_cust %></B></TD>
-                <TD ALIGN="left">customers</TD>
+                <TD ALIGN="left">&nbsp;customers&nbsp;</TD>
               </TR>
               <TR>
                 <TD ALIGN="right"><B><% $num_pkg %></B></TD>
-                <TD ALIGN="left">packages</TD>
+                <TD ALIGN="left">&nbsp;packages&nbsp;</TD>
               </TR>
             </TABLE>
           </TD>
diff --git a/httemplate/browse/part_svc_class.html b/httemplate/browse/part_svc_class.html
new file mode 100644 (file)
index 0000000..73bd603
--- /dev/null
@@ -0,0 +1,34 @@
+<% include( 'elements/browse.html',
+                 'title'       => 'Service classes',
+                 'html_init'   => $html_init,
+                 'name'        => 'service classes',
+                 'disableable' => 1,
+                 'disabled_statuspos' => 1,
+                 'query'       => { 'table'     => 'part_svc_class',
+                                    'hashref'   => {},
+                                    'order_by' => 'ORDER BY classnum',
+                                  },
+                 'count_query' => $count_query,
+                 'header'      => $header,
+                 'fields'      => $fields,
+                 'links'       => $links,
+             )
+%>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $html_init = 
+  'Service classes are user-defined, informational types for services.<BR><BR>'.
+  qq!<A HREF="${p}edit/part_svc_class.html"><I>Add a service class</I></A><BR><BR>!;
+
+my $count_query = 'SELECT COUNT(*) FROM part_svc_class';
+
+my $link = [ $p.'edit/part_svc_class.html?', 'classnum' ];
+
+my $header = [ '#', 'Class' ];
+my $fields = [ 'classnum', 'classname' ];
+my $links  = [ $link, $link ];
+
+</%init>
diff --git a/httemplate/browse/sales.cgi b/httemplate/browse/sales.cgi
new file mode 100755 (executable)
index 0000000..af09812
--- /dev/null
@@ -0,0 +1,100 @@
+<% include("/elements/header.html",'Sales Listing', menubar(
+  'Add new sales person' => '../edit/sales.cgi'
+)) %>
+Sales people bring in business.<BR><BR>
+% if ( dbdef->table('sales')->column('disabled') ) { 
+
+  <% $cgi->param('showdisabled')
+      ? do { $cgi->param('showdisabled', 0);
+             '( <a href="'. $cgi->self_url. '">hide disabled sales people</a> )'; }
+      : do { $cgi->param('showdisabled', 1);
+             '( <a href="'. $cgi->self_url. '">show disabled sales people</a> )'; }
+  %>
+% } 
+
+
+<% include('/elements/table-grid.html') %>
+% my $bgcolor1 = '#eeeeee';
+%   my $bgcolor2 = '#ffffff';
+%   my $bgcolor = '';
+
+<TR>
+  <TH CLASS="grid" BGCOLOR="#cccccc" COLSPAN=<% ( $cgi->param('showdisabled') || !dbdef->table('sales')->column('disabled') ) ? 2 : 3 %>>Sales person</TH>
+  <TH CLASS="grid" BGCOLOR="#cccccc">Agent</TH>
+  <TH CLASS="grid" BGCOLOR="#cccccc">Access Groups</TH>
+</TR>
+
+%foreach my $sales ( sort { 
+%  $a->getfield('salesnum') cmp $b->getfield('salesnum')
+%} qsearch('sales', \%search ) ) {
+%
+%  if ( $bgcolor eq $bgcolor1 ) {
+%    $bgcolor = $bgcolor2;
+%  } else {
+%    $bgcolor = $bgcolor1;
+%  }
+
+      <TR>
+
+        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+          <A HREF="<%$p%>edit/sales.cgi?<% $sales->salesnum %>"><% $sales->salesnum %></A>
+        </TD>
+
+        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+          <A HREF="<%$p%>edit/sales.cgi?<% $sales->salesnum %>"><% $sales->salesperson %></A>
+        </TD>
+
+%       if ( ! $cgi->param('showdisabled') ) { 
+          <TD CLASS="grid" BGCOLOR="<% $bgcolor %>" ALIGN="center">
+            <% $sales->disabled ? '<FONT COLOR="#FF0000"><B>DISABLED</B></FONT>'
+                                : '<FONT COLOR="#00CC00"><B>Active</B></FONT>'
+            %>
+          </TD>
+%       } 
+
+%       my ($agent) = qsearch('agent', { 'agentnum' => $sales->agentnum });
+
+        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+          <A HREF="<%$p%>edit/sales.cgi?<% $sales->agentnum %>"><% $sales->agentnum %></A>
+          <A HREF="<%$p%>edit/agent.cgi?<% $agent->agentnum %>">(<% $agent->agent %>)<BR>
+        </TD>
+
+        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+%         foreach my $access_group (
+%           map $_->access_group,
+%               qsearch('access_groupsales', { 'salesnum' => $sales->salesnum })
+%         ) {
+            <A HREF="<%$p%>edit/access_group.html?<% $access_group->groupnum %>"><% $access_group->groupname |h %><BR>
+%         }
+        </TD>
+
+      </TR>
+% } 
+
+    </TABLE>
+
+<SCRIPT TYPE="text/javascript">
+  function areyousure(what, href) {
+    if ( confirm("Are you sure you want to " + what + "?") == true )
+      window.location.href = href;
+  }
+</SCRIPT>
+
+  </BODY>
+</HTML>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my %search;
+if ( $cgi->param('showdisabled')
+     || !dbdef->table('agent')->column('disabled') ) {
+  %search = ();
+} else {
+  %search = ( 'disabled' => '' );
+}
+
+my $conf = new FS::Conf;
+
+</%init>
index 3dcb1d3..f1cbb18 100644 (file)
@@ -47,7 +47,7 @@
           '</pre></font>';
 
 %     } elsif ( $type eq 'checkbox' ) {
-%       if ( $conf->exists($i->key, $agentnum) ) {
+%       if ( $conf->config_bool($i->key, $agentnum) ) {
           configCell.style.backgroundColor = '#00ff00';
           configCell.innerHTML = 'YES';
 %       } else {
@@ -184,7 +184,7 @@ foreach my $type ( ref($i->type) ? @{$i->type} : $i->type ) {
 }
 # warn @touch;
 $conf->touch($_, $agentnum) foreach @touch;
-$conf->delete($_, $agentnum) foreach @delete;
+$conf->delete_bool($_, $agentnum) foreach @delete;
 
 if (scalar(@error)) {
   $cgi->param('error', join(' ', @error));
index 02acd58..02a24ad 100644 (file)
@@ -211,7 +211,7 @@ invoice language options:
 %   } elsif ( $type eq 'checkbox' ) {
 
             <tr>
-              <td id="<% $agentnum.$i->key.$n %>" bgcolor="#<% $conf->exists($i->key, $agentnum) ? '00ff00">YES' : 'ff0000">NO' %></td>
+              <td id="<% $agentnum.$i->key.$n %>" bgcolor="#<% $conf->config_bool($i->key, $agentnum) ? '00ff00">YES' : 'ff0000">NO' %></td>
             </tr>
 
 %   } elsif ( $type eq 'select' && $i->select_hash ) {
index 6a1eaec..a4f9890 100644 (file)
@@ -40,12 +40,14 @@ Setting <b><% $key %></b>
 <table><tr><td>
 
 % my $n = 0;
+% my $submit = 0;
 % foreach my $type (@types) {
 %   if ( $type eq '' ) {
 
   <font color="#ff0000">no type</font>
 
 %   } elsif ( $type eq 'image' ) { 
+%     $submit++;
 
   <% $conf->exists($key, $agentnum)
        ? 'Current image<br>'.
@@ -59,24 +61,37 @@ Setting <b><% $key %></b>
   New image filename <input type="file" name="<% "$key$n" %>">
 
 %   } elsif ( $type eq 'binary' ) { 
+%     $submit++;
 
   Filename <input type="file" name="<% "$key$n" %>">
 
 %   } elsif ( $type eq 'textarea' ) { 
+%     $submit++;
 
   <textarea name="<% "$key$n" %>" rows=12 cols=78 wrap="off"><% join("\n", $conf->config($key, $agentnum)) |h %></textarea>
 
 %   } elsif ( $type eq 'checkbox' ) { 
+%
+%     if ( $agentnum && $conf->exists($key) && ! $agent_bool ) {
 
-  <input name="<% "$key$n" %>" type="checkbox" value="1"
-    <% $conf->exists($key, $agentnum) ? 'CHECKED' : '' %> >
+        <input name="<% "$key$n" %>" type="checkbox" value="1" CHECKED DISABLED>
+        <FONT SIZE="-1"><I>(global setting cannot yet be overridden)</I></FONT>
 
-%   } elsif ( $type eq 'text' )  { 
+%     } else {
+%       $submit++;
 
-  <input name="<% "$key$n" %>" type="text" value="<% $conf->exists($key, $agentnum) ? $conf->config($key, $agentnum) : '' |h %>">
+        <input name="<% "$key$n" %>" type="checkbox" value="1"
+          <% $conf->config_bool($key, $agentnum) ? 'CHECKED' : '' %> >
+%     }
 
-%   } elsif ( $type eq 'select' || $type eq 'selectmultiple' )  { 
+%   } elsif ( $type eq 'text' )  {
+%     $submit++;
 
+  <input name="<% "$key$n" %>" type="text" value="<% $conf->exists($key, $agentnum) ? $conf->config($key, $agentnum) : '' |h %>">
+
+%   } elsif ( $type eq 'select' || $type eq 'selectmultiple' )  {
+%     $submit++;
   <select name="<% "$key$n" %>" <% $type eq 'selectmultiple' ? 'MULTIPLE' : '' %>>
 
 %
@@ -131,7 +146,8 @@ Setting <b><% $key %></b>
 
   </select>
 
-%   } elsif ( $type eq 'select-sub' ) { 
+%   } elsif ( $type eq 'select-sub' ) {
+%     $submit++;
 
   <select name="<% "$key$n" %>" <% $config_item->multiple ? 'MULTIPLE' : '' %>>
 
@@ -167,8 +183,8 @@ Setting <b><% $key %></b>
 
   </select>
 
-%   } elsif ( $type eq 'editlist' ) { 
-%
+%   } elsif ( $type eq 'editlist' ) {
+%     $submit++;
   <script>
     function doremove<% "$key$n" %>() {
       fromObject = document.OneTrueForm.<% "$key$n" %>;
@@ -284,6 +300,7 @@ Setting <b><% $key %></b>
   </tr></table>
 
 %   } elsif ( $element_types{$type} ) {
+%     $submit++;
 %
 %     my %opt = ( 'element_name' => "$key$n",
 %                 'empty_label'  => ' ',
@@ -313,7 +330,10 @@ Setting <b><% $key %></b>
 % }
 </tr>
 </table>
-<INPUT TYPE="submit" VALUE="<% $title %>">
+
+% if ( $submit ) {
+  <INPUT TYPE="submit" VALUE="<% $title %>">
+% }
 </FORM>
 
 </BODY>
@@ -365,5 +385,6 @@ my $config_item = $confitems{$key};
 my $description = $config_item->description;
 my $config_type = $config_item->type;
 my @types = ref($config_type) ? @$config_type : ($config_type);
+my $agent_bool = $config_item->agent_bool;
 
 </%init>
index e3bb282..33b21a3 100644 (file)
@@ -10,7 +10,7 @@
 %   my $url = $conf->config('company_url', $agentnum);
 %   if ( $url ) {
       <BR><BR>
-      <A HREF="<% $conf->config('company_url', $agentnum) %>" TARGET="_blank"><%$title%> homepage</A>
+      <FONT SIZE="+1"><A HREF="<% $conf->config('company_url', $agentnum) %>" TARGET="_top"><%$title%> homepage</A></FONT>
 %   }
 
 % } else {
@@ -46,7 +46,7 @@ GNU <b>Affero</b> General Public License.<BR>
 
 
 <BR><BR>
-<A HREF="http://www.freeside.biz/freeside" TARGET="_blank">Freeside homepage</A>
+<A HREF="http://www.freeside.biz/freeside" TARGET="_top">Freeside homepage</A>
 % if ( $agentnum ) {
   </FONT>
 % }
@@ -56,7 +56,7 @@ GNU <b>Affero</b> General Public License.<BR>
 
 % unless ( $agentnum ) {
   <CENTER>
-  <FONT SIZE="-3">"A selfish heart is trouble, but a foolish heart is worse" -R. Hunter</FONT>
+<!--  <FONT SIZE="-3">"" -R. Hunter</FONT> -->
   </CENTER>
 % }
 
diff --git a/httemplate/docs/part_svc-table.html b/httemplate/docs/part_svc-table.html
new file mode 100644 (file)
index 0000000..48841f5
--- /dev/null
@@ -0,0 +1,63 @@
+<& /elements/header-popup.html &>
+
+<TABLE>
+  <TR>
+    <TH ALIGN="left">Generic</TH>
+    <TH ALIGN="left">Access</TH>
+    <TH ALIGN="left">Telephony</TH>
+<!--    <TH>Hosting</TH>
+    <TH>Colocation</TH>
+-->
+  </TR>
+  <TR>
+    <TD VALIGN="top">
+      <UL STYLE="margin:0">
+        <LI><B>svc_acct</B>: Accounts - anything with a username (mailbox, shell, RADIUS, etc.)
+        <LI><B>svc_hardware</B>: Equipment supplied to customers
+        <LI><B>svc_external</B>: Externally-tracked service
+      </UL>
+    </TD>
+    <TD VALIGN="top">
+      <UL STYLE="margin:0">
+        <LI><B>svc_dsl</B>: DSL
+        <LI><B>svc_broadband</B>: Wireless broadband
+        <LI><B>svc_dish</B>: DISH Network
+      </UL>
+    </TD>
+    <TD VALIGN="top">
+      <UL STYLE="margin:0">
+        <LI><B>svc_phone</B>: Customer phone number
+        <LI><B>svc_pbx</B>: Customer PBX
+      </UL>
+    </TD>
+  </TR>
+</TABLE>
+<BR>
+<TABLE>
+  <TR>
+    <TH ALIGN="left">Hosting</TH>
+    <TH ALIGN="left">Colocation</TH>
+  </TR>
+    <TD VALIGN="top">
+      <UL STYLE="margin:0">
+        <LI><B>svc_domain</B>: Domain
+        <LI><B>svc_cert</B>: Certificate
+        <LI><B>svc_forward</B>: Mail forwarding
+        <LI><B>svc_mailinglist</B>: Mailing list
+        <LI><B>svc_www</B>: Virtual domain website
+      </UL>
+    </TD>
+    <TD VALIGN="top">
+      <UL STYLE="margin:0">
+        <LI><B>svc_port</B>: Customer router/switch port
+      </UL>
+    </TD>
+  </TR>
+<TABLE>
+<!--   <LI>svc_charge - One-time charges (Partially unimplemented)
+       <LI>svc_wo - Work orders (Partially unimplemented)
+-->
+
+</BODY>
+</HTML>
+
index 3994313..ef81eba 100755 (executable)
 % } 
 
 %# agent, agent_custid, refnum (advertising source), referral_custnum
+%# better section title for this?
+<FONT CLASS="fsinnerbox-title"><% mt('Basics') |h %></FONT>
 <& cust_main/top_misc.html, $cust_main, 'custnum' => $custnum  &>
 
 %# birthdate
-% if ( $conf->exists('cust_main-enable_birthdate') ) {
+% if (    $conf->exists('cust_main-enable_birthdate')
+%      || $conf->exists('cust_main-enable_spouse_birthdate')
+%    )
+% {
   <BR>
   <& cust_main/birthdate.html, $cust_main &>
 % }
-
-%# contact info
-
-%  my $same_checked = '';
-%  my $ship_disabled = '';
-%  my @ship_style = ();
-%  unless ( $cust_main->ship_last && $same ne 'Y' ) {
-%    $same_checked = 'CHECKED';
-%    $ship_disabled = 'DISABLED';
-%    push @ship_style, 'background-color:#dddddd';
-%    foreach (
-%      qw( last first company address1 address2 city county state zip country
-%          latitude longitude coord_auto
-%          daytime night fax mobile )
-%    ) {
-%      $cust_main->set("ship_$_", $cust_main->get($_) );
-%    }
-%  }
-
+% my $has_ship_address = '';
+% if ( $cgi->param('error') ) {
+%   $has_ship_address = !$same;
+% } elsif ( $cust_main->custnum ) {
+%   $has_ship_address = $cust_main->has_ship_address;
+% }
 <BR>
-<FONT CLASS="fsinnerbox-title"><% mt('Billing address') |h %></FONT>
-
-<& cust_main/contact.html,
-             'cust_main'    => $cust_main,
-             'pre'          => '',
-             'onchange'     => 'bill_changed(this)',
-             'disabled'     => '',
-             'ss'           => $ss,
-             'stateid'      => $stateid,
-             'same_checked' => $same_checked, #for address2 "Unit #" labeling
-&>
+<TABLE> <TR>
+  <TD STYLE="width:650px">
+%#; padding-right:2px; vertical-align:top">
+    <FONT CLASS="fsinnerbox-title"><% mt('Billing address') |h %></FONT>
+    <TABLE CLASS="fsinnerbox">
+    <& cust_main/before_bill_location.html, $cust_main &>
+    <& /elements/location.html,
+        object => $cust_main->bill_location,
+        prefix => 'bill_',
+    &>
+    <& cust_main/after_bill_location.html, $cust_main &>
+    </TABLE>
+  </TD>
+</TR>
+<TR><TD STYLE="height:40px"></TD></TR>
+<TR>
+  <TD STYLE="width:650px">
+%#; padding-left:2px; vertical-align:top">
+    <FONT CLASS="fsinnerbox-title"><% mt('Service address') |h %></FONT>
+    <INPUT TYPE="checkbox" 
+           NAME="same"
+           ID="same"
+           onclick="samechanged(this)"
+           onkeyup="samechanged(this)"
+           VALUE="Y"
+           <% $has_ship_address ? '' : 'CHECKED' %>
+    ><% mt('same as billing address') |h %>
+    <TABLE CLASS="fsinnerbox" ID="table_ship_location">
+    <& /elements/location.html,
+        object => $cust_main->ship_location,
+        prefix => 'ship_',
+        enable_censustract => 1,
+        enable_district => 1,
+    &>
+    </TABLE>
+    <TABLE CLASS="fsinnerbox" ID="table_ship_location_blank"
+    STYLE="display:none">
+    <TR><TD></TD></TR>
+    </TABLE>
+  </TD>
+</TR></TABLE>
 
 <SCRIPT>
-function bill_changed(what) {
-  if ( what.form.same.checked ) {
-% for (qw( last first company address1 address2 city zip latitude longitude coord_auto daytime night fax mobile )) { 
-    what.form.ship_<%$_%>.value = what.form.<%$_%>.value;
-% } 
-
-    what.form.ship_country.selectedIndex = what.form.country.selectedIndex;
-
-    function fix_ship_city() {
-      what.form.ship_city_select.selectedIndex = what.form.city_select.selectedIndex;
-      what.form.ship_city.style.display = what.form.city.style.display;
-      what.form.ship_city_select.style.display = what.form.city_select.style.display;
-    }
-
-    function fix_ship_county() {
-      what.form.ship_county.selectedIndex = what.form.county.selectedIndex;
-      ship_county_changed(what.form.ship_county, fix_ship_city );
-    }
-
-    function fix_ship_state() {
-      what.form.ship_state.selectedIndex = what.form.state.selectedIndex;
-      ship_state_changed(what.form.ship_state, fix_ship_county );
-    }
-
-    ship_country_changed(what.form.ship_country, fix_ship_state );
-
-  }
-}
 function samechanged(what) {
+%# not display = 'none', because we still want it to take up space
+%#  document.getElementById('table_ship_location').style.visibility = 
+%#    what.checked ? 'hidden' : 'visible';
+  var t1 = document.getElementById('table_ship_location');
+  var t2 = document.getElementById('table_ship_location_blank');
   if ( what.checked ) {
-    bill_changed(what);
-
-%   my @fields = qw( last first company address1 address2 city city_select county state zip country latitude longitude daytime night fax mobile );
-%   for (@fields) { 
-      what.form.ship_<%$_%>.disabled = true;
-      what.form.ship_<%$_%>.style.backgroundColor = '#dddddd';
-%   } 
-
-%   if ( $conf->exists('cust_main-require_address2') ) {
-      document.getElementById('address2_required').style.visibility = '';
-      document.getElementById('address2_label').style.visibility = '';
-      document.getElementById('ship_address2_required').style.visibility = 'hidden';
-      document.getElementById('ship_address2_label').style.visibility = 'hidden';
-%   }
-
-  } else {
-
-%   for (@fields) { 
-      what.form.ship_<%$_%>.disabled = false;
-      what.form.ship_<%$_%>.style.backgroundColor = '#ffffff';
-%   } 
-
-%   if ( $conf->exists('cust_main-require_address2') ) {
-      document.getElementById('address2_required').style.visibility = 'hidden';
-      document.getElementById('address2_label').style.visibility = 'hidden';
-      document.getElementById('ship_address2_required').style.visibility = '';
-      document.getElementById('ship_address2_label').style.visibility = '';
-%   }
-
+    t2.style.width  = t1.clientWidth  + 'px';
+    t2.style.height = t1.clientHeight + 'px';
+    t1.style.display = 'none';
+    t2.style.display = '';
+  }
+  else {
+    t2.style.display = 'none';
+    t1.style.display = '';
   }
 }
+samechanged(document.getElementById('same'));
 </SCRIPT>
 
 <BR>
-<FONT CLASS="fsinnerbox-title"><% mt('Service address') |h %></FONT>
-
-<INPUT TYPE="checkbox" NAME="same" VALUE="Y" onClick="samechanged(this)" <%$same_checked%>><% mt('same as billing address') |h %>
-<& cust_main/contact.html,
-             'cust_main' => $cust_main,
-             'pre'       => 'ship_',
-             'onchange'  => '',
-             'disabled'  => $ship_disabled,
-             'style'     => \@ship_style
-&>
 
 <& cust_main/contacts_new.html,
              'cust_main' => $cust_main,
@@ -229,27 +198,49 @@ my $conf = new FS::Conf;
 #get record
 
 my($custnum, $cust_main, $ss, $stateid, $payinfo, @invoicing_list);
-my $same = '';
 my $pkgpart_svcpart = ''; #first_pkg
 my($username, $password, $popnum, $saved_domsvc) = ( '', '', 0, 0 ); #svc_acct
 my %svc_phone = ();
 my %svc_dsl = ();
 my $prospectnum = '';
 my $locationnum = '';
+my $same = '';
+
 
 if ( $cgi->param('error') ) {
 
+  $same = ($cgi->param('same') || '') eq 'Y';
+  # false laziness w/ edit/process/cust_main.cgi
+  my %locations;
+  for my $pre (qw(bill ship)) {
+    my %hash;
+    foreach ( FS::cust_main->location_fields ) {
+      $hash{$_} = scalar($cgi->param($pre.'_'.$_));
+    }
+    $hash{'custnum'} = $cgi->param('custnum');
+    $locations{$pre} = qsearchs('cust_location', \%hash)
+                       || FS::cust_location->new( \%hash );
+  }
+  if ( $same ) {
+    $locations{ship} = $locations{bill};
+  }
+
   $cust_main = new FS::cust_main ( {
-    map { $_, scalar($cgi->param($_)) } fields('cust_main')
+    map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main')),
+    map { ( "ship_$_", '' ) } (FS::cust_main->location_fields)
   } );
 
+  for my $pre (qw(bill ship)) {
+    $cust_main->set($pre.'_location', $locations{$pre});
+    $cust_main->set($pre.'_locationnum', $locations{$pre}->locationnum);
+  }
+
   $custnum = $cust_main->custnum;
 
   die "access denied"
     unless $curuser->access_right($custnum ? 'Edit customer' : 'New customer');
 
   @invoicing_list = split( /\s*,\s*/, $cgi->param('invoicing_list') );
-  $same = $cgi->param('same');
   $cust_main->setfield('paid' => $cgi->param('paid')) if $cgi->param('paid');
   $ss = $cust_main->ss;           # don't mask an entered value on errors
   $stateid = $cust_main->stateid; # don't mask an entered value on errors
@@ -296,7 +287,7 @@ if ( $cgi->param('error') ) {
     $cust_main->paycvv($paycvv);
   }
   @invoicing_list = $cust_main->invoicing_list;
-  $ss = $cust_main->masked('ss');
+  $ss = $conf->exists('unmask_ss') ? $cust_main->ss : $cust_main->masked('ss');
   $stateid = $cust_main->masked('stateid');
   $payinfo = $cust_main->paymask;
 
@@ -352,6 +343,20 @@ if ( $cgi->param('error') ) {
     $svc_dsl{$_} = $qual->$_
       foreach qw( phonenum vendor_qual_id );
   }
+  else {
+    my $countrydefault = $conf->config('countrydefault') || 'US';
+    my $statedefault = $conf->config('statedefault') || 'CA';
+    $cust_main->set('bill_location', 
+      FS::cust_location->new(
+        { country => $countrydefault, state => $statedefault }
+      )
+    );
+    $cust_main->set('ship_location',
+      FS::cust_location->new(
+        { country => $countrydefault, state => $statedefault }
+      )
+    );
+  }
 
   if ( $cgi->param('lock_pkgpart') =~ /^(\d+)$/ ) {
     my $pkgpart = $1;
@@ -364,7 +369,7 @@ if ( $cgi->param('error') ) {
 }
 
 my %keep = map { $_=>1 } qw( error tagnum lock_agentnum lock_pkgpart );
-$cgi->delete( grep !$keep{$_}, $cgi->param );
+$cgi->delete( grep { !$keep{$_} && $_ !~ /^tax_/ } $cgi->param );
 
 my $title = $custnum ? 'Edit Customer' : 'Add Customer';
 $title = mt($title);
diff --git a/httemplate/edit/cust_main/after_bill_location.html b/httemplate/edit/cust_main/after_bill_location.html
new file mode 100644 (file)
index 0000000..2f4c3b5
--- /dev/null
@@ -0,0 +1,12 @@
+% if ( ! $conf->exists('cust-edit-alt-field-order') ) {
+  <& phones.html, $cust_main &>
+  <& fax.html, $cust_main &>
+% } else {
+  <& fax.html, $cust_main &>
+  <& company.html, $cust_main &>
+% }
+<& stateid.html, $cust_main &>
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+</%init>
diff --git a/httemplate/edit/cust_main/before_bill_location.html b/httemplate/edit/cust_main/before_bill_location.html
new file mode 100644 (file)
index 0000000..973201e
--- /dev/null
@@ -0,0 +1,10 @@
+<& name.html, $cust_main &>
+% if ( ! $conf->exists('cust-edit-alt-field-order') ) {
+  <& company.html, $cust_main &>
+% } else {
+  <& phones.html, $cust_main &>
+% }
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+</%init>
index 9f4cb74..d7082f2 100644 (file)
             //why? select.selectedIndex = 0;
         }
     }
+
+    function tax_changed(what) {
+      var num = document.getElementById(what.id + '_num'); 
+      if ( what.checked ) {
+        num.disabled = false;
+      } else {
+        num.disabled = true;
+      }
+    }
     
   </SCRIPT>
 
 
 %   my @exempt_groups = grep /\S/, $conf->config('tax-cust_exempt-groups');
 
-%   if ( $conf->exists('cust_class-tax_exempt') ) {
+%   if (    $conf->exists('cust_class-tax_exempt')
+%        || $conf->exists('tax-cust_exempt-groups-require_individual_nums')
+%      )
+%   {
 
       <INPUT TYPE="hidden" NAME="tax" VALUE="<% $cust_main->tax eq 'Y' ? 'Y' : '' %>">
 
 %   }
 
 %   foreach my $exempt_group ( @exempt_groups ) {
-%     #escape $exempt_group for NAME
+%     my $cust_main_exemption = $cust_main->tax_exemption($exempt_group);
+%     #escape $exempt_group for NAME etc.
+%     my $checked = ($cust_main_exemption || $cgi->param("tax_$exempt_group"));
       <TR>
-        <TD WIDTH="608" COLSPAN="2">&nbsp;&nbsp;<INPUT TYPE="checkbox" NAME="tax_<% $exempt_group %>" VALUE="Y" <% $cust_main->tax_exemption($exempt_group) ? 'CHECKED' : '' %>> Tax Exempt (<% $exempt_group %> taxes)<TD>
+        <TD>&nbsp;&nbsp;<INPUT TYPE="checkbox" NAME="tax_<% $exempt_group %>" ID="tax_<% $exempt_group %>" VALUE="Y" <% $checked ? 'CHECKED' : '' %> onChange="tax_changed(this)"> Tax Exempt (<% $exempt_group %> taxes)</TD>
+        <TD> - Exemption number <INPUT TYPE="text" NAME="tax_<% $exempt_group %>_num" ID="tax_<% $exempt_group %>_num" VALUE="<% $cgi->param("tax_$exempt_group".'_num') || ( $cust_main_exemption ? $cust_main_exemption->exempt_number : '' ) |h %>" <% $checked ? '' : 'DISABLED' %>></TD>
       </TR>
 %   }
 
           ? 'CHECKED'
           : ''
 
-        %>> <% mt('Postal mail invoice') |h %> 
+        %>> <% mt('Postal mail invoices') |h %> 
 
       </TD>
     </TR>
           ? 'CHECKED'
           : ''
 
-        %>> <% mt('Fax invoice') |h %> 
+        %>> <% mt('Fax invoices') |h %> 
 
       </TD>
     </TR>
 
 % }
 
-% unless ( $conf->exists('cust-email-high-visibility')) {
     <TR>
+      <TD WIDTH="608" COLSPAN="2"><INPUT TYPE="checkbox" NAME="invoice_email" VALUE="Y" <%
+
+        ( $cust_main->invoice_noemail eq 'Y' )
+          ? ''
+          : 'CHECKED'
+
+        %>> <% mt('Email invoices') |h %> 
+
+      </TD>
+    </TR>
+
+% unless ( $conf->exists('cust-email-high-visibility')) {
+   <TR>
       <TD ALIGN="right" WIDTH="200">
         <% $conf->exists('cust_main-require_invoicing_list_email', $agentnum) 
             ? $r : '' %>Email address(es)
@@ -570,20 +597,25 @@ function toggle(obj) {
 
 %my @available_locales = $conf->config('available-locales');
 %if ( scalar(@available_locales) ) {
-%   push @available_locales, '';
-%    my %locale_labels = map { 
-%        my %ll;
-%        my %info = FS::Locales->locale_info($_);
-%        $ll{$_} = $info{name} . " (" . $info{country} . ")";
-%        %ll;
-%    } FS::Locales->locales;
-  <& /elements/tr-select.html, 
-        'label'         => emt('Invoicing locale'),
-        'field'         => 'locale',
-        'options'       => \@available_locales,
-        'labels'        => \%locale_labels,
-        'curr_value'    => $cust_main->locale,
-  &>
+%  push @available_locales, ''
+%    unless $cust_main->locale && $conf->exists('cust_main-require_locale');
+%  my %locale_labels = map { 
+%    my %ll;
+%    my %info = FS::Locales->locale_info($_);
+%    $ll{$_} = $info{name} . " (" . $info{country} . ")";
+%    %ll;
+%  } FS::Locales->locales;
+%    
+%  my $label = ( $conf->exists('cust_main-require_locale') ? $r : '' ).
+%              emt('Invoicing locale');
+
+    <& /elements/tr-select.html, 
+         'label'         => $label,
+         'field'         => 'locale',
+         'options'       => \@available_locales,
+         'labels'        => \%locale_labels,
+         'curr_value'    => $cust_main->locale,
+    &>
 % }
 
   </TABLE>
index b4e78e3..5d6a123 100644 (file)
@@ -1,16 +1,33 @@
 <% ntable("#cccccc", 2) %>
-  <% include( '/elements/tr-input-date-field.html',
-                'birthdate',
-                $cust_main->birthdate,
-                'Date of Birth',
-                ( $conf->config('date_format') || "%m/%d/%Y" ),
-                1
-            )
+% # maybe put after the contact names?
+% if ( $conf->exists('cust_main-enable_birthdate') ) {
+  <% include( '/elements/tr-input-date-field.html', {
+                'name'        => 'birthdate',
+                'value'       => $cust_main->birthdate,
+                'label'       => 'Date of Birth',
+                'format'      => ( $conf->config('date_format') || "%m/%d/%Y" ),
+                'usedatetime' => 1,
+                'noinit'      => $noinit++,
+            })
   %>
+% }
+% if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
+  <% include( '/elements/tr-input-date-field.html', {
+                'name'        => 'spouse_birthdate',
+                'value'       => $cust_main->spouse_birthdate,
+                'label'       => 'Spouse Date of Birth',
+                'format'      => ( $conf->config('date_format') || "%m/%d/%Y" ),
+                'usedatetime' => 1,
+                'noinit'      => $noinit++,
+            })
+  %>
+% }
 </TABLE>
 <%init>
 
 my( $cust_main, %opt ) = @_;
 my $conf = new FS::Conf;
 
+my $noinit = 0;
+
 </%init>
index 800864b..77d4294 100644 (file)
@@ -66,21 +66,25 @@ function copy_payby_fields() {
 %# call submit_continue() on completion...
 %# otherwise not touching standardize_locations for now
 <% include( '/elements/standardize_locations.js',
-            'callback' => 'submit_continue();'
+            'callback' => 'submit_continue();',
+            'main_prefix' => 'bill_',
+            'no_company' => 1,
           )
 %>
 
+var prefix;
 function fetch_censustract() {
 
   //alert('fetch census tract data');
+  prefix = document.getElementById('same').checked ? 'bill_' : 'ship_';
   var cf = document.CustomerForm;
-  var state_el = cf.elements['ship_state'];
+  var state_el = cf.elements[prefix + 'state'];
   var census_data = new Array(
     'year',     <% $conf->config('census_year') || '2012' %>,
-    'address1', cf.elements['ship_address1'].value,
-    'city',     cf.elements['ship_city'].value,
+    'address1', cf.elements[prefix + 'address1'].value,
+    'city',     cf.elements[prefix + 'city'].value,
     'state',    state_el.options[ state_el.selectedIndex ].value,
-    'zip',      cf.elements['ship_zip'].value
+    'zip',      cf.elements[prefix + 'zip'].value
   );
 
   censustract( census_data, update_censustract );
@@ -109,19 +113,21 @@ function update_censustract(arg) {
 
   set_censustract = function () {
 
-    cf.elements['censustract'].value = newcensus;
+    cf.elements[prefix + 'censustract'].value = newcensus;
     submit_continue();
 
   }
 
-  if (error || cf.elements['censustract'].value != newcensus) {
+  if (error || cf.elements[prefix + 'censustract'].value != newcensus) {
     // popup an entry dialog
 
     if (error) { newcensus = error; }
     newcensus.replace(/.*ndefined.*/, 'Not found');
 
-    var latitude = cf.elements['latitude' ].value || '<% $company_latitude %>';
-    var longitude= cf.elements['longitude'].value || '<% $company_longitude %>';
+    var latitude = cf.elements[prefix + 'latitude'].value 
+                   || '<% $company_latitude %>';
+    var longitude= cf.elements[prefix + 'longitude'].value 
+                   || '<% $company_longitude %>';
 
     var choose_censustract =
       '<CENTER><BR><B>Confirm censustract</B><BR>' +
@@ -132,14 +138,14 @@ function update_censustract(arg) {
       '" target="_blank">Map service module location</A><BR>' +
       '<A href="http://maps.ffiec.gov/FFIECMapper/TGMapSrv.aspx?' +
       'census_year=<% $conf->config('census_year') || '2012' %>' +
-      '&zip_code=' + cf.elements['ship_zip'].value +
+      '&zip_code=' + cf.elements[prefix + 'zip'].value +
       '" target="_blank">Map zip code center</A><BR><BR>' +
       '<TABLE>';
     
     choose_censustract = choose_censustract + 
       '<TR><TH style="width:50%">Entered census tract</TH>' +
         '<TH style="width:50%">Calculated census tract</TH></TR>' +
-      '<TR><TD>' + cf.elements['censustract'].value +
+      '<TR><TD>' + cf.elements[prefix + 'censustract'].value +
         '</TD><TD>' + newcensus + '</TD></TR>' +
         '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
 
diff --git a/httemplate/edit/cust_main/company.html b/httemplate/edit/cust_main/company.html
new file mode 100644 (file)
index 0000000..8a6ed0b
--- /dev/null
@@ -0,0 +1,7 @@
+% my $cust_main = shift;
+<TR ID="company_row" <% $cust_main->company ? '' : 'STYLE="display:none"' %>>
+  <TD ALIGN="right"><% mt('Company') |h %></TD>
+  <TD COLSPAN=6><INPUT TYPE="text" NAME="company" ID="company" SIZE=60
+             VALUE="<% $cust_main->company |h %>">
+  </TD>
+</TR>
diff --git a/httemplate/edit/cust_main/fax.html b/httemplate/edit/cust_main/fax.html
new file mode 100644 (file)
index 0000000..237d4be
--- /dev/null
@@ -0,0 +1,5 @@
+% my $cust_main = shift;
+<TR>
+  <TD ALIGN="right"><% mt('Fax') |h %></TD>
+  <TD><INPUT TYPE="text" NAME="fax" VALUE="<% $cust_main->fax %>" SIZE=18></TD>
+</TR>
diff --git a/httemplate/edit/cust_main/name.html b/httemplate/edit/cust_main/name.html
new file mode 100644 (file)
index 0000000..2641ec9
--- /dev/null
@@ -0,0 +1,53 @@
+<%def .namepart>
+% my ($field, $value, $label, $extra) = @_;
+<TD>
+  <INPUT TYPE="text" NAME="<% $field %>" VALUE="<% $value |h %>" <%$extra%>>
+  <BR><FONT SIZE=-1><% mt($label) %></FONT>
+</TD>
+</%def>
+
+<TR>
+  <TH VALIGN="top" ALIGN="right"><%$r%><% mt('Contact name') |h %></TH>
+  <TD COLSPAN=6>
+    <TABLE CELLSPACING=0 CELLPADDING=0>
+      <TR>
+        <& .namepart, 'last', $cust_main->last, 'Last' &>
+        <TD VALIGN="top"> , </TD>
+        <& .namepart, 'first', $cust_main->first, 'First' &>
+% if ( $conf->exists('show_ss') ) {
+        <TD>&nbsp;</TD>
+        <& .namepart, 'ss', $ss, 'SS#', "SIZE=11" &>
+% } else  {
+        <INPUT TYPE="hidden" NAME="ss" VALUE="<% $ss %>">
+% }
+      </TR>
+    </TABLE>
+  </TD>
+</TR>
+
+% if ( $conf->exists('cust-email-high-visibility') ) {
+<TR>
+  <TD ALIGN="right">
+    <% $conf->exists('cust_main-require_invoicing_list_email', $agentnum)
+        ? $r
+        : '' %>Email address(es)
+  </TD>
+  <TD BGCOLOR="#FFFF00">
+    <INPUT TYPE="text" NAME="invoicing_list" 
+           VALUE=<% $cust_main->invoicing_list_emailonly_scalar %>>
+  </TD>
+</TR>
+% }
+<%init>
+my $cust_main = shift;
+my $agentnum = $cust_main->agentnum if $cust_main->custnum;
+my $conf = FS::Conf->new;
+my $r = '<font color="#ff0000">*</font>&nbsp;';
+my $ss;
+
+if ( $cgi->param('error') or $conf->exists('unmask_ss') ) {
+  $ss = $cust_main->ss;
+} else {
+  $ss = $cust_main->masked('ss');
+}
+</%init>
diff --git a/httemplate/edit/cust_main/phones.html b/httemplate/edit/cust_main/phones.html
new file mode 100644 (file)
index 0000000..9b23e07
--- /dev/null
@@ -0,0 +1,29 @@
+<TR>
+  <TD VALIGN="top" ALIGN="right"><% mt('Phones') |h %></TD>
+  <TD COLSPAN=6>
+    <TABLE CELLSPACING=0 CELLPADDING=0>
+      <TR>
+% foreach my $phone (qw(daytime night mobile)) {
+        <TD>
+          <INPUT TYPE="text"
+                 NAME="<% $phone %>"
+                 VALUE="<% $cust_main->get($phone) %>"
+                 SIZE=18
+          >
+          <BR><FONT SIZE=-1><% mt($phone_label{$phone}) |h %></FONT>
+        </TD>
+        <TD>&nbsp;</TD>
+% }
+      </TR>
+    </TABLE>
+  </TD>
+</TR>
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+my %phone_label = (
+  daytime => 'Day Phone',
+  night   => 'Night Phone',
+  mobile  => 'Mobile',
+);
+</%init>
diff --git a/httemplate/edit/cust_main/stateid.html b/httemplate/edit/cust_main/stateid.html
new file mode 100644 (file)
index 0000000..2655f51
--- /dev/null
@@ -0,0 +1,39 @@
+% if ( $conf->exists('show_stateid') ) {
+<TR>
+  <TD ALIGN="right"><% $stateid_label %></TD>
+  <TD><INPUT TYPE="text" NAME="stateid" VALUE="<% $stateid %>" SIZE=12></TD>
+  <TD><& /elements/select-state.html,
+          state   => $cust_main->stateid_state,
+          country => $cust_main->country, # how does this work on new customer?
+          prefix  => 'stateid_',
+          disable_countyupdate => 1,
+      &></TD>
+</TR>
+% } else {
+<INPUT TYPE="hidden" NAME="stateid" VALUE="<% $stateid %>">
+<INPUT TYPE="hidden" NAME="stateid_state" VALUE="<% $cust_main->stateid_state %>">
+% }
+
+<%init>
+my $cust_main = shift;
+my $conf = FS::Conf->new;
+my $stateid;
+if ( $cgi->param('error') ) {
+  $stateid = $cust_main->stateid;
+} elsif ( $cust_main->custnum ) {
+  $stateid = $cust_main->masked('stateid');
+} else {
+  $stateid = '';
+}
+$cust_main->set('stateid_state' => $cust_main->state) 
+  unless $cust_main->stateid_state;
+
+my $stateid_label = FS::Msgcat::_gettext('stateid') =~ /^(stateid)?$/
+                  ? 'Driver&rsquo;s License'
+                  : FS::Msgcat::_gettext('stateid') || 'Driver&rsquo;s License';
+
+my $stateid_state_label = 
+                  FS::Msgcat::_gettext('stateid_state') =~ /^(stateid_state)?$/
+                  ? 'Driver&rsquo;s License State'
+                  : FS::Msgcat::_gettext('stateid') || 'Driver&rsquo;s License State';
+</%init>
index 7ba167b..7ce283c 100644 (file)
        <% $cust_main->residential_commercial eq 'Commercial' ? 'CHECKED' : '' %>
   ></TD>
 </TR>
-
 <SCRIPT TYPE="text/javascript">
-  function rescom_changed() {
-    var f = document.CustomerForm;
-
-    if        ( f.residential_commercial_Residential.checked ) {
-      document.getElementById('contacts_div').style.display = 'none';
-    } else { // if ( f.residential_commercial_Commercial.checked ) {
-      document.getElementById('contacts_div').style.display = '';
-    }
-
-    if        ( f.residential_commercial_Residential.checked && ! f.company.value.length ) {
-      document.getElementById('company_row').style.display = 'none'
-    } else { // if ( f.residential_commercial_Commercial.checked ) {
+  function rescom_changed(what) {
+    if ( what.checked == (what.value == 'Commercial' ) ) {
       document.getElementById('company_row').style.display = '';
-    }
-
-    if        ( f.residential_commercial_Residential.checked && ! f.ship_company.value.length ) {
-      document.getElementById('ship_company_row').style.display = 'none'
-    } else { // if ( f.residential_commercial_Commercial.checked ) {
-      document.getElementById('ship_company_row').style.display = '';
+      document.getElementById('contacts_div').style.display = '';
+    } else {
+      if ( document.getElementById('company').value.length == 0 ) {
+        document.getElementById('company_row').style.display = 'none';
+      }
+      document.getElementById('contacts_div').style.display = 'none';
     }
   }
 </SCRIPT>
index 7a1bb00..d4414e4 100755 (executable)
@@ -23,6 +23,7 @@
 <% mt('Payment') |h %> 
 <% ntable("#cccccc", 2) %>
 
+% if ( $FS::CurrentUser::CurrentUser->access_right('Backdate payment') ) {
 <TR>
   <TD ALIGN="right"><% mt('Date') |h %></TD>
   <TD COLSPAN=2>
     align:      "BR"
   });
 </SCRIPT>
+% }
+% else {
+<TR>
+  <TD ALIGN="right"><% mt('Date') |h %></TD>
+  <TD COLSPAN=2>
+    <% time2str($date_format.' %r',$_date) %>
+  </TD>
+</TR>
+% }
 
 <TR>
   <TD ALIGN="right"><% mt('Amount') |h %></TD>
index 73faad4..a24f238 100644 (file)
@@ -292,6 +292,9 @@ Example:
 %     #& deprecated weird value hashref used only by reason.html
 %     'value'         => $f->{'value'},
 %
+%     #fixed
+%     'noescape'      => $f->{'noescape'},
+%
 %     #select(-*)
 %     'options'       => $f->{'options'},
 %     'labels'        => $f->{'labels'},
@@ -308,6 +311,7 @@ Example:
 %
 %     #umm.  for select-agent_types at least
 %     'disabled'      => $f->{'disabled'},
+%     'fixed'         => $f->{'fixed'},
 %
 %     #any?
 %     'colspan'       => $f->{'colspan'},
@@ -317,7 +321,7 @@ Example:
 %   $include_common{$_} = $f->{$_} foreach grep exists($f->{$_}),
 %     qw( js_only html_only select_only layers_only cell_style ),#selectlayers,?
 %     qw( empty_label ),                                   # select-*
-%     qw( value_col ),                                     # select-table
+%     qw( value_col compare_sub ),                         # select-table
 %     qw( table name_col ),                           #(select,checkboxes)-table
 %     qw( target_table link_table ),                       #checkboxes-table
 %     qw( hashref agent_virt agent_null agent_null_right ),#*-table
@@ -751,13 +755,15 @@ Example:
 
   <BR>
 
-  <INPUT TYPE     = "submit"
-         ID       = "submit"
-         VALUE    = "<% ( !$clone && $object->$pkey() )
-                          ? "Apply changes"
-                          : "Add ". ( $opt{'name'} || $opt{'name_singular'} )
-                     %>"
-  >
+%   unless ($opt{'no_submit'}) {
+      <INPUT TYPE     = "submit"
+             ID       = "submit"
+             VALUE    = "<% ( !$clone && $object->$pkey() )
+                              ? "Apply changes"
+                              : "Add ". ($opt{'name'} || $opt{'name_singular'})
+                         %>"
+      >
+%   }
 
   </FORM>
 
index 38716f0..0d9d36c 100644 (file)
                      $f->{'extra_sql'}  .= ' OR svcnum = '. $object->svcnum
                        if $object->svcnum;
                      $f->{'extra_sql'}  .= ' ) ';
-                     $f->{'disable_empty'} = $object->svcnum ? 1 : 0,
+                     $f->{'disable_empty'} = $object->svcnum ? 1 : 0;
+                     if ( $f->{'field'} eq 'mac_addr' ) {
+                       $f->{'compare_sub'} = sub {
+                         my($a, $b) = @_;
+                         $a =~ s/[-: ]//g;
+                         $b =~ s/[-: ]//g;
+                         lc($a) eq lc($b);
+                       };
+                     }
                    } elsif ( $flag eq 'H' ) {
                      $f->{'type'}        = 'select-hardware_type';
                      $f->{'hashref'}     = {
                      $object->set('custnum', $cust_pkg->custnum);
                    }
 
+                   if ( my $cb = $opt{'svc_field_callback'} ) {
+                     &{ $cb }( $cgi, $object, $f);
+                   }
+
                  },
 
                  'html_init' => sub {
diff --git a/httemplate/edit/ftp_target.html b/httemplate/edit/ftp_target.html
new file mode 100755 (executable)
index 0000000..aebf9aa
--- /dev/null
@@ -0,0 +1,46 @@
+<& elements/edit.html,
+  'post_url'    => popurl(1).'process/ftp_target.html',
+  'name'        => 'FTP target',
+  'table'       => 'ftp_target',
+  'viewall_url' => "${p}browse/ftp_target.html",
+  'labels'      => { targetnum => 'Target',
+                     hostname  => 'Server',
+                     username  => 'Username',
+                     password  => 'Password',
+                     path      => 'Directory',
+                     port      => 'Port',
+                     secure    => 'Use SFTP',
+                     handling  => 'Special handling',
+                   },
+  'fields'      => [
+                     { field => 'hostname', size => 40 },
+                     { field => 'port', size => 8 },
+                     { field => 'secure', type => 'checkbox', value => 'Y' },
+                     'username',
+                     'password',
+                     { field => 'path', size => 40 },
+                     { field => 'handling', 
+                       type => 'select',
+                       options => [ FS::ftp_target->handling_types ],
+                     },
+                   ],
+  'menubar'     => \@menubar,
+  'edit_callback' => $edit_callback,
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Configuration');
+
+my @menubar = ('View all FTP targets' => $p.'browse/ftp_target.html');
+my $edit_callback = sub {
+  my ($cgi, $object) = @_;
+  if ( $object->targetnum ) {
+    push @menubar, 'Delete this target', 
+                   $p.'misc/delete-ftp_target.html?'.$object->targetnum;
+  }
+};
+
+</%init>
index 9cec62c..3553c61 100644 (file)
@@ -8,7 +8,8 @@
 <FORM ACTION="process/invoice_template.html" METHOD="POST">
 <INPUT TYPE="hidden" NAME="confname" VALUE="<% $confname %>">
 
-% if ( $type eq 'html' ) {
+% #if ( $type eq 'html' ) {
+% if ( 0 ) { #this seems to broken, using a text editor for everything for now
 
   <% include('/elements/htmlarea.html',
                'field'      => 'value',
index 9415545..115032a 100644 (file)
@@ -1,14 +1,57 @@
-<% include( 'elements/edit.html',  
-    'html_init'     => '<TABLE id="outerTable"><TR><TD>',
-    'body_etc'      => $body_etc,
-    'name_singular' => 'template',
-    'table'         => 'msg_template',
-    'viewall_dir'   => 'browse',
-    'agent_virt'    => 1,
-    'agent_null'    => 1,
-    'agent_null_right' => ['Edit global templates', 'Configuration'],
+<& elements/edit.html,
+     'html_init'        => '<TABLE id="outerTable"><TR><TD>',
+     'body_etc'         => $body_etc,
+     'name_singular'    => 'template',
+     'table'            => 'msg_template',
+     'viewall_dir'      => 'browse',
+     'agent_virt'       => 1,
+     'agent_null'       => 1,
+     'agent_null_right' => [ 'View global templates', 'Edit global templates' ],
 
-    'fields' => [
+     'fields'           => \@fields,
+     'labels'           => { 
+                             'msgnum'    => 'Template',
+                             'agentnum'  => 'Agent',
+                             'msgname'   => 'Template name',
+                             'from_addr' => 'From: ',
+                             'bcc_addr'  => 'Bcc: ',
+                             'locale'    => 'Language',
+                             'subject'   => 'Subject: ',
+                             'body'      => 'Message body',
+                           },
+     'edit_callback'    => \&edit_callback,
+     'error_callback'   => \&edit_callback,
+     'html_bottom'      => '</DIV>',
+     'html_foot'        => ( $no_submit ? '' : "</TD>$sidebar</TR></TABLE>" ),
+     'no_submit'        => $no_submit,
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right([ 'View templates', 'View global templates',
+                                  'Edit templates', 'Edit global templates',
+                               ]);
+
+my $body_etc = '';
+$body_etc = q!onload="document.getElementById('locale').onchange()"!
+  if $cgi->param('locale') eq 'new';
+
+my $msgnum = $cgi->param('msgnum');
+my $msg_template = $msgnum ? qsearchs('msg_template', {msgnum=>$msgnum}) : '';
+
+my $no_submit = 0;
+my @fields = ();
+if ( $curuser->access_right('Edit global templates') 
+     || (    $curuser->access_right('Edit templates')
+          && $msg_template
+          && $msg_template->agentnum
+          && $curuser->agentnums_href->{$msg_template->agentnum}
+        )
+   )
+{
+  push @fields,
       { field => 'agentnum',
         type  => 'select-agent',
       },
         type  => 'htmlarea',
         width => 763
       },
-    ],
-    'labels' => { 
-      'msgnum'    => 'Template',
-      'agentnum'  => 'Agent',
-      'msgname'   => 'Template name',
-      'from_addr' => 'From: ',
-      'bcc_addr'  => 'Bcc: ',
-      'locale'    => 'Language',
-      'subject'   => 'Subject: ',
-      'body'      => 'Message body',
-    },
-    'edit_callback'   => \&edit_callback,
-    'error_callback'  => \&edit_callback,
-    'html_bottom' => '</DIV>',
-    'html_foot' => "</TD>$sidebar</TR></TABLE>",
-    )
-    %>
-<%init>
+  ;
+} else { #readonly
 
-die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Edit templates')
-  ||     $FS::CurrentUser::CurrentUser->access_right('Edit global templates')
-  ||     $FS::CurrentUser::CurrentUser->access_right('Configuration');
+  $no_submit = 1;
 
-my $body_etc = '';
-$body_etc = q!onload="document.getElementById('locale').onchange()"!
-  if $cgi->param('locale') eq 'new';
+  push @fields,
+      { field => 'agentnum',
+        type  => 'select-agent',
+        fixed => 1,
+      },
+      { field => 'msgname',   type => 'fixed', },
+      { field => 'from_addr', type => 'fixed', },
+      { field => 'bcc_addr',  type => 'fixed', },
+      { type  => 'tablebreak-tabs',
+        include_opt_callback => \&menubar_opt_callback,
+      },
+      # template_content fields
+      { field => 'locale',  type => 'hidden' },
+      { field => 'subject', type => 'fixed', },
+      { field    => 'body',
+        type     => 'fixed',
+        noescape => 1,
+      },
+  ;
+
+}
 
 sub new_callback {
   my ($cgi, $object, $fields_listref, $opt_hashref) = @_;
@@ -182,8 +224,18 @@ my %substitutions = (
     '$country'        => 'Country',
     '$daytime'        => 'Day phone',
     '$night'          => 'Night phone',
+    '$mobile'         => 'Mobile phone',
     '$fax'            => 'Fax',
   ],
+  'service' => [
+    '$ship_address1'  => 'Address line 1',
+    '$ship_address2'  => 'Address line 2',
+    '$ship_city'      => 'City',
+    '$ship_county'    => 'County',
+    '$ship_state'     => 'State',
+    '$ship_zip'       => 'Zip',
+    '$ship_country'   => 'Country',
+  ],
   'cust_bill' => [
     '$invnum'         => 'Invoice#',
   ],
@@ -238,15 +290,10 @@ my %substitutions = (
     '$error'          => 'Decline reason',
   ],
 );
-my @c = @{ $substitutions{'contact'} };
-for (my $i=0; $i<scalar(@c); $i += 2) {
-  $c[$i] =~ s/\$(.*)/\$ship_$1/;
-}
-$substitutions{'shipping'} = \@c;
 
 tie my %sections, 'Tie::IxHash', (
 'contact'   => 'Name and contact info (billing)',
-'shipping'  => 'Name and contact info (shipping)',
+'service'   => 'Service address',
 'cust_main' => 'Customer status and payment info',
 'cust_pkg'  => 'Package fields',
 'cust_bill' => 'Invoice fields',
index daf8773..e9fd794 100755 (executable)
@@ -3,9 +3,12 @@
                 'table'       => 'part_referral',
                 'fields'      => [ 'referral',
                                    { field=>'agentnum', type=>'select-agent', },
+                                   { field=>'disabled', type=>'checkbox', value=>'Y'  } ,
                                  ],
-                'labels'      => { 'referral' => 'Advertising source',
+                'labels'      => { 'refnum'   => 'Ad Source',
+                                   'referral' => 'Advertising source',
                                    'agentnum' => 'Agent',
+                                   'disabled' => 'Disabled',
                                  },
                 'viewall_dir' => 'browse',
            )
index fae8961..4bd0837 100755 (executable)
@@ -1,17 +1,27 @@
-<% include('/elements/header.html', "$action Service Definition",
+<& /elements/header.html, "$action Service Definition",
            menubar('View all service definitions' => "${p}browse/part_svc.cgi"),
            #" onLoad=\"visualize()\""
-          )
-%>
+&>
+
+<& /elements/init_overlib.html &>
+
+<BR>
 
 <FORM NAME="dummy">
 
-      Service Part #<% $part_svc->svcpart ? $part_svc->svcpart : "(NEW)" %>
-<BR><BR>
-Service  <INPUT TYPE="text" NAME="svc" VALUE="<% $hashref->{svc} %>"><BR>
+<FONT CLASS="fsinnerbox-title">Service Part #<% $part_svc->svcpart ? $part_svc->svcpart : "(NEW)" %></FONT>
+<TABLE CLASS="fsinnerbox">
+<TR>
+  <TD ALIGN="right">Service</TD>
+  <TD><INPUT TYPE="text" NAME="svc" VALUE="<% $hashref->{svc} %>"></TD>
+<TR>
+
+<& /elements/tr-select-part_svc_class.html, curr_value=>$hashref->{classnum} &>
 
-Self-service access:
-<SELECT NAME="selfservice_access">
+<TR>
+  <TD ALIGN="right">Self-service access</TD>
+  <TD>
+    <SELECT NAME="selfservice_access">
 % tie my %selfservice_access, 'Tie::IxHash', #false laziness w/browse/part_svc
 %   ''         => 'Yes',
 %   'hidden'   => 'Hidden',
@@ -22,12 +32,22 @@ Self-service access:
           <% $_ eq $hashref->{'selfservice_access'} ? 'SELECTED' : '' %>
   ><% $selfservice_access{$_} %>
 % }
-</SELECT><BR>
+    </SELECT>
+  </TD>
+</TR>
 
-<INPUT TYPE="checkbox" NAME="disabled" VALUE="Y"<% $hashref->{disabled} eq 'Y' ? ' CHECKED' : '' %>>&nbsp;Disable new orders<BR>
 
-<INPUT TYPE="checkbox" NAME="preserve" VALUE="Y"<% $hashref->{'preserve'} eq 'Y' ? ' CHECKED' : '' %>>&nbsp;Preserve this service on package cancellation<BR>
+<TR>
+  <TD ALIGN="right">Disable new orders</TD>
+  <TD><INPUT TYPE="checkbox" NAME="disabled" VALUE="Y"<% $hashref->{disabled} eq 'Y' ? ' CHECKED' : '' %>></TD>
+</TR>
 
+<TR>
+  <TD ALIGN="right">Preserve this service on package cancellation</TD>
+  <TD><INPUT TYPE="checkbox" NAME="preserve" VALUE="Y"<% $hashref->{'preserve'} eq 'Y' ? ' CHECKED' : '' %>>&nbsp;</TD>
+</TR>
+
+</TABLE>
 
 <INPUT TYPE="hidden" NAME="svcpart" VALUE="<% $hashref->{svcpart} %>">
 
@@ -76,6 +96,18 @@ Self-service access:
 %             ? ( $hashref->{svcdb} )
 %             : FS::part_svc->svc_tables();
 %
+%  my $help = '';
+%  unless ( $hashref->{svcpart} ) {
+%    $help = '&nbsp;'.
+%            include('/elements/popup_link.html',
+%                      'action' => $p.'docs/part_svc-table.html',
+%                      'label'  => 'help',
+%                      'actionlabel' => 'Service table help',
+%                      'width'       => 763,
+%                      #'height'      => 400,
+%                    );
+%  }
+%
 %  tie my %svcdb, 'Tie::IxHash', map { $_=>$_ } grep dbdef->table($_), @dbs;
 %  my $widget = new HTML::Widgets::SelectLayers(
 %    #'selected_layer' => $p_svcdb,
@@ -84,15 +116,16 @@ Self-service access:
 %    'form_name'      => 'dummy',
 %    #'form_action'    => 'process/part_svc.cgi',
 %    'form_action'    => 'part_svc.cgi', #self
-%    'form_text'      => [ qw( svc svcpart ) ],
-%    'form_select'    => [ 'selfservice_access' ],
-%    'form_checkbox'  => [ 'disabled', 'preserve' ],
+%    'form_elements'  => [qw( svc svcpart classnum selfservice_access
+%                             disabled preserve
+%                        )],
+%    'html_between'   => $help,
 %    'layer_callback' => sub {
 %      my $layer = shift;
 %      
 %      my $html = qq!<INPUT TYPE="hidden" NAME="svcdb" VALUE="$layer">!;
 %
-%      $html .= $svcdb_info;
+%      #$html .= $svcdb_info;
 %
 %      my $columns = 3;
 %      my $count = 0;
@@ -267,6 +300,7 @@ Self-service access:
 %
 %          $html .= include('/elements/select-table.html',
 %                             'element_name' => "${layer}__${field}_classnum",
+%                             'id'           => "${layer}__${field}_classnum",
 %                             'element_etc'  => ( $is_inv
 %                                                   ? $disabled
 %                                                   : $nodisplay
@@ -349,6 +383,7 @@ Self-service access:
 %          $html .= include('/elements/select-hardware_class.html',
 %                             'curr_value'    => $value,
 %                             'element_name'  => "${layer}__${field}_classnum",
+%                             'id'            => "${layer}__${field}_classnum",
 %                             'element_etc'   => $flag ne 'H' && $nodisplay,
 %                             'empty_label'   => 'Select hardware class',
 %                          );
@@ -382,7 +417,8 @@ Self-service access:
 %
 %      $html .= include('/elements/progress-init.html',
 %                         $layer, #form name
-%                         [ qw(svc svcpart selfservice_access disabled preserve
+%                         [ qw(svc svcpart classnum selfservice_access
+%                              disabled preserve
 %                              exportnum),
 %                           @fields ],
 %                         'process/part_svc.cgi',
@@ -401,9 +437,8 @@ Self-service access:
 %
 %    },
 %  );
-%
-%
 
+<BR>
 Table <% $widget->html %>
 
 <% include('/elements/footer.html') %>
@@ -451,66 +486,6 @@ my %communigate_fields = (
   #'svc_cert'        => { map { $_=>1 } qw( ) },
 );
 
-my $svcdb_info = '
-<TABLE>
-  <TR>
-    <TH ALIGN="left">Generic</TH>
-    <TH ALIGN="left">Access</TH>
-    <TH ALIGN="left">Telephony</TH>
-<!--    <TH>Hosting</TH>
-    <TH>Colocation</TH>
--->
-  </TR>
-  <TR>
-    <TD VALIGN="top">
-      <UL STYLE="margin:0">
-        <LI><B>svc_acct</B>: Accounts - anything with a username (mailbox, shell, RADIUS, etc.)
-        <LI><B>svc_hardware</B>: Equipment supplied to customers
-        <LI><B>svc_external</B>: Externally-tracked service
-      </UL>
-    </TD>
-    <TD VALIGN="top">
-      <UL STYLE="margin:0">
-        <LI><B>svc_dsl</B>: DSL
-        <LI><B>svc_broadband</B>: Wireless broadband
-        <LI><B>svc_dish</B>: DISH Network
-      </UL>
-    </TD>
-    <TD VALIGN="top">
-      <UL STYLE="margin:0">
-        <LI><B>svc_phone</B>: Customer phone number
-        <LI><B>svc_pbx</B>: Customer PBX
-      </UL>
-    </TD>
-  </TR>
-</TABLE>
-<BR>
-<TABLE>
-  <TR>
-    <TH ALIGN="left">Hosting</TH>
-    <TH ALIGN="left">Colocation</TH>
-  </TR>
-    <TD VALIGN="top">
-      <UL STYLE="margin:0">
-        <LI><B>svc_domain</B>: Domain
-        <LI><B>svc_cert</B>: Certificate
-        <LI><B>svc_forward</B>: Mail forwarding
-        <LI><B>svc_mailinglist</B>: Mailing list
-        <LI><B>svc_www</B>: Virtual domain website
-      </UL>
-    </TD>
-    <TD VALIGN="top">
-      <UL STYLE="margin:0">
-        <LI><B>svc_port</B>: Customer router/switch port
-      </UL>
-    </TD>
-  </TR>
-<TABLE>
-<!--   <LI>svc_charge - One-time charges (Partially unimplemented)
-       <LI>svc_wo - Work orders (Partially unimplemented)
--->
-';
-
 my $mod_info = '
 For the selected table, you can give fields default or fixed (unchangable)
 values, or select an inventory class to manually or automatically fill in
diff --git a/httemplate/edit/part_svc_class.html b/httemplate/edit/part_svc_class.html
new file mode 100644 (file)
index 0000000..0d9a007
--- /dev/null
@@ -0,0 +1,6 @@
+<% include( 'elements/class_Common.html',
+              'name'   => 'Service class',
+              'table'  => 'part_svc_class',
+             'nocat' => 1,
+          )
+%>
index 790fc8e..b9f93db 100644 (file)
@@ -28,10 +28,12 @@ my $cust_location = qsearchs({
 });
 die "unknown locationnum $locationnum" unless $cust_location;
 
-my $new = {
+my $new = FS::cust_location->new({
+  custnum     => $cust_location->custnum,
+  prospectnum => $cust_location->prospectnum,
   map { $_ => scalar($cgi->param($_)) }
     qw( address1 address2 city county state zip country )
-};
+});
 
 my $error = $cust_location->move_to($new);
 
index 44fbb4f..5ee553b 100755 (executable)
@@ -57,17 +57,40 @@ push @invoicing_list, 'POST' if $cgi->param('invoicing_list_POST');
 push @invoicing_list, 'FAX' if $cgi->param('invoicing_list_FAX');
 $cgi->param('invoicing_list', join(',', @invoicing_list) );
 
+# is this actually used?  if so, we need to clone locations...
+# but I can't find anything that sets this parameter to a non-empty value
+$cgi->param('duplicate_of_custnum') =~ /^(\d+)$/;
+my $duplicate_of = $1;
+
+my %locations;
+for my $pre (qw(bill ship)) {
+
+  my %hash;
+  foreach ( FS::cust_main->location_fields ) {
+    $hash{$_} = scalar($cgi->param($pre.'_'.$_));
+  }
+  $hash{'custnum'} = $cgi->param('custnum');
+  warn Dumper \%hash if $DEBUG;
+  # if we can qsearchs it, then it's unchanged, so use that
+  $locations{$pre} = qsearchs('cust_location', \%hash)
+                     || FS::cust_location->new( \%hash );
+
+}
+
+if ( ($cgi->param('same') || '') eq 'Y' ) {
+  $locations{ship} = $locations{bill};
+}
 
 #create new record object
+# but explicitly avoid setting ship_ fields
 
 my $new = new FS::cust_main ( {
-  map {
-    $_, scalar($cgi->param($_))
-  } fields('cust_main')
+  map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main')),
+  map { ( "ship_$_", '' ) } (FS::cust_main->location_fields)
 } );
 
-$cgi->param('duplicate_of_custnum') =~ /^(\d+)$/;
-my $duplicate_of = $1;
+$new->invoice_noemail( ($cgi->param('invoice_email') eq 'Y') ? '' : 'Y' );
+
 if ( $duplicate_of ) {
   # then negate all changes to the customer; the only change we should
   # make is to order a package, if requested
@@ -76,11 +99,9 @@ if ( $duplicate_of ) {
     or die "nonexistent existing customer (custnum $duplicate_of)";
 }
 
-if ( defined($cgi->param('same')) && $cgi->param('same') eq "Y" ) {
-  $new->setfield("ship_$_", '') foreach qw(
-    last first company address1 address2 city county state zip
-    country daytime night fax
-  );
+for my $pre (qw(bill ship)) {
+  $new->set($pre.'_location', $locations{$pre});
+  $new->set($pre.'_locationnum', $locations{$pre}->locationnum);
 }
 
 if ( $cgi->param('no_credit_limit') ) {
@@ -89,9 +110,11 @@ if ( $cgi->param('no_credit_limit') ) {
 
 $new->tagnum( [ $cgi->param('tagnum') ] );
 
-my %usedatetime = ( 'birthdate' => 1 );
+my %usedatetime = ( 'birthdate'        => 1,
+                    'spouse_birthdate' => 1,
+                  );
 
-foreach my $dfield (qw( birthdate signupdate )) {
+foreach my $dfield (qw( birthdate spouse_birthdate signupdate )) {
 
   if ( $cgi->param($dfield) && $cgi->param($dfield) =~ /^([ 0-9\-\/]{0,10})$/) {
 
@@ -130,6 +153,7 @@ $new->setfield('paid', $cgi->param('paid') )
 
 my @exempt_groups = grep /\S/, $conf->config('tax-cust_exempt-groups');
 my @tax_exempt = grep { $cgi->param("tax_$_") eq 'Y' } @exempt_groups;
+my %tax_exempt = map { $_ => scalar($cgi->param("tax_$_".'_num')) } @tax_exempt;
 
 #perhaps this stuff should go to cust_main.pm
 if ( $new->custnum eq '' or $duplicate_of ) {
@@ -237,7 +261,7 @@ if ( $new->custnum eq '' or $duplicate_of ) {
   else {
     # create the customer
     $error ||= $new->insert( \%hash, \@invoicing_list,
-                           'tax_exemption'=> \@tax_exempt,
+                           'tax_exemption'=> \%tax_exempt,
                            'prospectnum'  => scalar($cgi->param('prospectnum')),
                            );
 
@@ -256,6 +280,7 @@ if ( $new->custnum eq '' or $duplicate_of ) {
 
   my $old = qsearchs( 'cust_main', { 'custnum' => $new->custnum } ); 
   $error ||= "Old record not found!" unless $old;
+
   if ( length($old->paycvv) && $new->paycvv =~ /^\s*\*+\s*$/ ) {
     $new->paycvv($old->paycvv);
   }
@@ -294,8 +319,11 @@ if ( $new->custnum eq '' or $duplicate_of ) {
   local($FS::cust_main::DEBUG) = $DEBUG if $DEBUG;
   local($FS::Record::DEBUG)    = $DEBUG if $DEBUG;
 
+  local($Data::Dumper::Sortkeys) = 1;
+  warn Dumper({ new => $new, old => $old }) if $DEBUG;
+
   $error ||= $new->replace( $old, \@invoicing_list,
-                            'tax_exemption' => \@tax_exempt,
+                            'tax_exemption' => \%tax_exempt,
                           );
 
   warn "$me returned from replace" if $DEBUG;
index 06f5e64..ce0ec32 100755 (executable)
@@ -39,7 +39,13 @@ $cgi->param('link') =~ /^(custnum|invnum|popup)$/
 my $field = my $link = $1;
 $field = 'custnum' if $field eq 'popup';
 
-my $_date = parse_datetime($cgi->param('_date'));
+my $_date;
+if ( $FS::CurrentUser::CurrentUser->access_right('Backdate payment') ) {
+  $_date = parse_datetime($cgi->param('_date'));
+}
+else {
+  $_date = time;
+}
 
 my $new = new FS::cust_pay ( {
   $field => $linknum,
index 12b3bd9..2d39e9d 100644 (file)
@@ -250,8 +250,6 @@ foreach my $value ( @values ) {
 
     }
 
-    $error ||= $new->check;
-
     my @args = ();
     if ( !$error && $opt{'args_callback'} ) {
       @args = &{ $opt{'args_callback'} }( $cgi, $new );
diff --git a/httemplate/edit/process/ftp_target.html b/httemplate/edit/process/ftp_target.html
new file mode 100644 (file)
index 0000000..35f56c4
--- /dev/null
@@ -0,0 +1,12 @@
+<& elements/process.html,
+           'table'            => 'ftp_target',
+           'viewall_dir'      => 'browse',
+           'agent_null'       => 1,
+&>
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Configuration');
+
+</%init>
index 47fe978..b19f5c5 100644 (file)
@@ -9,9 +9,7 @@
 %>
 <%init>
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Edit templates')
-  ||     $FS::CurrentUser::CurrentUser->access_right('Edit global templates')
-  ||     $FS::CurrentUser::CurrentUser->access_right('Configuration');
+  unless $FS::CurrentUser::CurrentUser->access_right(['Edit templates','Edit global templates']);
 
 sub precheck_callback {
   my $cgi = shift;
diff --git a/httemplate/edit/process/part_svc_class.html b/httemplate/edit/process/part_svc_class.html
new file mode 100644 (file)
index 0000000..16165dd
--- /dev/null
@@ -0,0 +1,11 @@
+<% include( 'elements/process.html',
+               'table'       => 'part_svc_class',
+               'viewall_dir' => 'browse',
+           )
+%>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+</%init>
index fab8525..ba4c5b1 100644 (file)
@@ -48,6 +48,9 @@ die 'unknown custnum' unless $cust_main;
 $cgi->param('pkgpart') =~ /^(\d+)$/
   or die 'illegal pkgpart '. $cgi->param('pkgpart');
 my $pkgpart = $1;
+$cgi->param('quantity') =~ /^(\d+)$/
+  or die 'illegal quantity '. $cgi->param('quantity');
+my $quantity = $1;
 $cgi->param('refnum') =~ /^(\d*)$/
   or die 'illegal refnum '. $cgi->param('refnum');
 my $refnum = $1;
@@ -78,6 +81,7 @@ if ( $cgi->param('qualnum') ) {
 my $cust_pkg = new FS::cust_pkg {
   'custnum'              => $custnum,
   'pkgpart'              => $pkgpart,
+  'quantity'             => $quantity,
   'start_date'           => ( scalar($cgi->param('start_date'))
                                 ? parse_datetime($cgi->param('start_date'))
                                 : ''
diff --git a/httemplate/edit/process/sales.cgi b/httemplate/edit/process/sales.cgi
new file mode 100644 (file)
index 0000000..edef4d6
--- /dev/null
@@ -0,0 +1,23 @@
+<% include( 'elements/process.html',
+              'table'       => 'sales',
+              'viewall_dir' => 'browse',
+              'viewall_ext' => 'cgi',
+              'debug'       => '1',
+              'process_m2m' => { 'link_table'   => 'access_groupsales',
+                                 'target_table' => 'access_group',
+                               },
+              'edit_ext'    => 'cgi',
+          )
+%>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+if ( FS::Conf->new->exists('disable_acl_changes') ) {
+  errorpage('ACL changes disabled in public demo.');
+  die "shouldn't be reached";
+}
+
+</%init>
+
index c8c8e98..d398541 100644 (file)
@@ -36,6 +36,9 @@
          'empty_label'   => 'No address',
          'disable_empty' => $conf->exists('prospect_main-location_required'),
          'alt_format'    => $conf->exists('prospect_main-alt_address_format'),
+         'include_opt_callback' => sub { 
+            'prospect_main' => shift
+          },
        },
      ],
      'new_callback'    => $new_callback,
index 2784106..1d9647f 100644 (file)
 
 <SCRIPT TYPE="text/javascript">
 
-function enable_quick_charge () {
+function enable_quick_charge (e) {
+
   if (    document.QuickChargeForm.amount.value
        && document.QuickChargeForm.pkg.value    ) {
     document.QuickChargeForm.submit.disabled = false;
   } else {
     document.QuickChargeForm.submit.disabled = true;
   }
+
+% if ( $curuser->option('disable_enter_submit_onetimecharge') ) {
+
+    var key;
+    if (window.event)
+          key = window.event.keyCode; //IE
+    else
+
+          key = e.which; //firefox, others
+
+    return (key != 13);
+
+% } else {
+    return true;
+% }
+
 }
 
 function validate_quick_charge () {
@@ -76,7 +93,12 @@ function bill_now_changed (what) {
 
 </SCRIPT>
 
-<FORM ACTION="process/quick-charge.cgi" NAME="QuickChargeForm" ID="QuickChargeForm" METHOD="POST" onsubmit="document.QuickChargeForm.submit.disabled=true;return validate_quick_charge();">
+<FORM ACTION   = "process/quick-charge.cgi"
+      NAME     = "QuickChargeForm"
+      ID       = "QuickChargeForm"
+      METHOD   = "POST"
+      onSubmit = "document.QuickChargeForm.submit.disabled=true; return validate_quick_charge();"
+>
 
 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
 
@@ -85,7 +107,13 @@ function bill_now_changed (what) {
 <TR>
   <TD ALIGN="right"><% mt('Amount') |h %> </TD>
   <TD>
-    <% $money_char %><INPUT TYPE="text" NAME="amount" SIZE=6 VALUE="<% $amount %>" onChange="enable_quick_charge()" onKeyPress="enable_quick_charge()">
+    <% $money_char %><INPUT TYPE       = "text"
+                            NAME       = "amount"
+                            SIZE       = 6
+                            VALUE      = "<% $amount %>"
+                            onChange   = "return enable_quick_charge(event)"
+                            onKeyPress = "return enable_quick_charge(event)"
+                     >
   </TD>
 </TR>
 
@@ -93,7 +121,11 @@ function bill_now_changed (what) {
     <TR>
       <TD ALIGN="right"><% mt('Quantity') |h %> </TD>
       <TD>
-        <INPUT TYPE="text" NAME="quantity" SIZE=4 VALUE="<% $quantity %>">
+        <INPUT TYPE       = "text"
+               NAME       = "quantity"
+               SIZE       = 4
+               VALUE      = "<% $quantity %>"
+               onKeyPress = "return enable_quick_charge(event)">
       </TD>
     </TR>
 % }
@@ -107,6 +139,7 @@ function bill_now_changed (what) {
            NAME  = "bill_now"
            VALUE = "1"
            <% $cgi->param('bill_now') ? 'CHECKED' : '' %>
+           onClick  = "bill_now_changed(this);"
            onChange = "bill_now_changed(this);"
     >
     <% mt('with terms') |h %> 
@@ -127,7 +160,11 @@ function bill_now_changed (what) {
            SIZE  = 32
            ID    = "start_date_text"
            VALUE = "<% $start_date %>"
-           <% $cgi->param('bill_now') ? 'STYLE = "background-color:#dddddd" DISABLED' : '' %>
+           onKeyPress="return enable_quick_charge(event)"
+           <% $cgi->param('bill_now')
+                ? 'STYLE = "background-color:#dddddd" DISABLED'
+                : ''
+           %>
     >
     <IMG SRC   = "<%$fsurl%>images/calendar.png"
          ID    = "start_date_button"
@@ -173,7 +210,14 @@ function bill_now_changed (what) {
 <TR>
   <TD ALIGN="right"><% mt('Description') |h %> </TD>
   <TD>
-    <INPUT TYPE="text" NAME="pkg" SIZE="50" MAXLENGTH="50" VALUE="<% $pkg %>" onChange="enable_quick_charge()" onKeyPress="enable_quick_charge()">
+    <INPUT TYPE       = "text"
+           NAME       = "pkg"
+           SIZE       = "50"
+           MAXLENGTH  = "50"
+           VALUE      = "<% $pkg %>"
+           onChange   = "return enable_quick_charge(event)"
+           onKeyPress = "return enable_quick_charge(event)"
+    >
   </TD>
 </TR>
 
@@ -191,7 +235,15 @@ function bill_now_changed (what) {
     <TR>
       <TD></TD>
       <TD>
-        <INPUT TYPE="text" NAME="description<% $row %>" SIZE="60" MAXLENGTH="65" VALUE="<% $param->{"description$row"} |h %>" rownum="<% $row %>" onkeyup = "possiblyAddRow;" >
+        <INPUT TYPE       = "text"
+               NAME       = "description<% $row %>"
+               SIZE       = "60"
+               MAXLENGTH  = "65"
+               VALUE      = "<% $param->{"description$row"} |h %>"
+               rownum     = "<% $row %>"
+               onKeyPress = "return enable_quick_charge(event)"
+               onKeyUp    = "return possiblyAddRow(event)"
+        >
       </TD>
     </TR>
 % } 
@@ -210,10 +262,26 @@ function bill_now_changed (what) {
 
   var rownum = <% $row %>;
 
-  function possiblyAddRow() {
+  function possiblyAddRow(e) {
+
     if ( ( rownum - this.getAttribute('rownum') ) == 1 ) {
       addRow();
     }
+
+%   if ( $curuser->option('disable_enter_submit_onetimecharge') ) {
+
+      var key;
+      if (window.event)
+            key = window.event.keyCode; //IE
+      else
+            key = e.which; //firefox, others
+
+      return (key != 13);
+
+%   } else {
+      return true;
+%   }
+
   }
 
   function addRow() {
@@ -228,14 +296,16 @@ function bill_now_changed (what) {
 
     var description_cell = document.createElement('TD');
 
-      var description_input = document.createElement('INPUT');
-      description_input.setAttribute('name', 'description'+rownum);
-      description_input.setAttribute('id',   'description'+rownum);
-      description_input.setAttribute('size', 60);
-      description_input.setAttribute('maxLength', 65);
-      description_input.setAttribute('rownum',   rownum);
-      description_input.onkeyup = possiblyAddRow;
-      description_cell.appendChild(description_input);
+      //var description_input = document.createElement('INPUT');
+      var di = document.createElement('INPUT');
+      di.setAttribute('name', 'description'+rownum);
+      di.setAttribute('id',   'description'+rownum);
+      di.setAttribute('size', 60);
+      di.setAttribute('maxLength', 65);
+      di.setAttribute('rownum',   rownum);
+      di.onkeyup = possiblyAddRow;
+      di.onkeypress = enable_quick_charge;
+      description_cell.appendChild(di);
 
     row.appendChild(description_cell);
 
@@ -251,8 +321,10 @@ function bill_now_changed (what) {
 </HTML>
 <%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 $conf = new FS::Conf;
 my $date_format = $conf->config('date_format') || '%m/%d/%Y';
diff --git a/httemplate/edit/sales.cgi b/httemplate/edit/sales.cgi
new file mode 100755 (executable)
index 0000000..3497de5
--- /dev/null
@@ -0,0 +1,79 @@
+<% include("/elements/header.html","$action Sales Person", menubar(
+  'View all sales people' => $p. 'browse/sales.cgi',
+)) %>
+
+<% include('/elements/error.html') %>
+
+<FORM METHOD   = POST
+      ACTION   = "<%popurl(1)%>process/sales.cgi"
+>
+
+<INPUT TYPE="hidden" NAME="salesnum" VALUE="<% $sales->salesnum %>">
+Sales #<% $sales->salesnum ? $sales->salesnum : "(NEW)" %>
+
+<% &ntable("#cccccc", 2, '') %>
+
+  <TR>
+    <TH ALIGN="right">Sales</TH>
+    <TD><INPUT TYPE="text" NAME="salesperson" SIZE=32 VALUE="<% $sales->salesperson %>"></TD>
+  </TR>
+
+  <TR>
+    <TD ALIGN="right"><% emt('Agent') %></TD>
+    <TD>
+      <& /elements/select-agent.html,
+                     'curr_value' => $sales->salesnum,
+                     'disable_empty' => 1,
+      &>
+    </TD>
+  </TR>
+
+  <TR>
+    <TD ALIGN="right">Disable</TD>
+    <TD><INPUT TYPE="checkbox" NAME="disabled" VALUE="Y"<% $sales->disabled eq 'Y' ? ' CHECKED' : '' %>></TD>
+  </TR>
+
+  <TR>
+    <TD ALIGN="right">Access Groups</TD>
+    <TD><% include('/elements/checkboxes-table.html',
+                     'source_obj'   => $sales,
+                     'link_table'   => 'access_groupsales',
+                     'target_table' => 'access_group',
+                     'name_col'     => 'groupname',
+                     'target_link'  => $p. 'edit/access_group.html?',
+                  )
+        %>
+    </TD>
+  </TR>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% $sales->salesnum ? "Apply changes" : "Add sales" %>">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $sales;
+if ( $cgi->param('error') ) {
+  $sales = new FS::sales ( {
+    map { $_, scalar($cgi->param($_)) } fields('sales')
+  } );
+} elsif ( $cgi->keywords ) {
+  my($query) = $cgi->keywords;
+  $query =~ /^(\d+)$/;
+  $sales = qsearchs( 'sales', { 'salesnum' => $1 } );
+} else { #adding
+  $sales = new FS::sales {};
+}
+my $action = $sales->salesnum ? 'Edit' : 'Add';
+
+my $conf = new FS::Conf;
+
+</%init>
index b266928..0d4b989 100644 (file)
@@ -3,7 +3,7 @@
      'name'                 => 'broadband service',
      'table'                => 'svc_broadband',
      'fields'               => \@fields, 
-     'field_callback'       => $field_callback,
+     'svc_field_callback'   => $svc_field_callback,
      'svc_new_callback'     => $svc_edit_callback,
      'svc_edit_callback'    => $svc_edit_callback,
      'svc_error_callback'   => $svc_edit_callback,
@@ -161,20 +161,14 @@ my $svc_edit_callback = sub {
   }
 };
 
-my $field_callback = sub {
+my $svc_field_callback = sub {
   my ($cgi, $object, $fieldref) = @_;
 
   my $columndef = $part_svc->part_svc_column($fieldref->{'field'});
-  if ($columndef->columnflag eq 'F') {
-    $fieldref->{'type'} = length($columndef->columnvalue)
-                            ? 'fixed'
-                            : 'hidden';
-    $fieldref->{'value'} = $columndef->columnvalue;
+  if ($fieldref->{field} eq 'usergroup' && $columndef->columnflag eq 'F') {
     
-    if ( $fieldref->{field} eq 'usergroup' ) {
-      $fieldref->{'formatted_value'} = 
-        [ $object->radius_groups('long_description') ];
-    }
+    $fieldref->{'formatted_value'} = 
+      [ $object->radius_groups('long_description') ];
   }
 
 }; 
index 8251308..03b488e 100644 (file)
@@ -20,8 +20,8 @@
                         'sectornum' => 'Sector',
                         'disabled'  => 'Disabled',
                         'default_ip_addr' => 'Tower IP address',
-                        'latitude', => 'Latitude',
-                        'longitude', => 'Longitude',
+                        'latitude' => 'Latitude',
+                        'longitude' => 'Longitude',
                       },
 &>
 <%init>
index a517ece..79443dc 100644 (file)
@@ -41,6 +41,8 @@ Example:
 
 <SCRIPT TYPE="text/javascript">
 
+  var num_open_invoices = new Array;
+
   function clearhint_invnum() {
 
     if ( this.value == 'Not found' || this.value == 'Multiple' ) {
@@ -90,7 +92,7 @@ Example:
           customer_select.style.display = 'none';
           return false;
 
-      } else if ( customerArray.length == 5 ) {
+      } else if ( customerArray.length == 6 ) {
 
           custnum_obj.value = customerArray[0];
           custnum_obj.style.color = '#000000';
@@ -99,6 +101,7 @@ Example:
           update_balance_text(searchrow, customerArray[2]);
           update_status_text( searchrow, customerArray[3]);
           update_status_color(searchrow, '#'+customerArray[4]);
+          update_num_open(searchrow, customerArray[5]);
 
           customer.style.display = '';
           customer_select.style.display = 'none';
@@ -140,6 +143,7 @@ Example:
     update_balance_text(searchrow, '');
     update_status_text(searchrow, '');
     update_status_color(searchrow, '#000000');
+    update_num_open(searchrow, 0);
 
     function search_invnum_update(customers) {
       
@@ -175,15 +179,16 @@ Example:
     if ( ( <% $opt{prefix} %>rownum - searchrow ) == 1 ) {
       <% $opt{prefix} %>addRow();
     }
-    var customer = document.getElementById('customer'+searchrow);
-    customer.value = 'searching...';
-    customer.disabled = true;
-    customer.style.color = '#000000';
-    customer.style.backgroundColor = '#dddddd';
+
+    var customer_obj = document.getElementById('customer'+searchrow);
+    customer_obj.value = 'searching...';
+    customer_obj.disabled = true;
+    customer_obj.style.color = '#000000';
+    customer_obj.style.backgroundColor = '#dddddd';
 
     var customer_select = document.getElementById('cust_select'+searchrow);
 
-    customer.style.display = '';
+    customer_obj.style.display = '';
     customer_select.style.display = 'none';
 
     var invnum = document.getElementById('invnum'+searchrow);
@@ -192,15 +197,44 @@ Example:
     update_balance_text(searchrow, '');
     update_status_text( searchrow, '');
     update_status_color(searchrow, '#000000');    
+    update_num_open(searchrow, 0);
 
     function search_custnum_update(customers) {
 
-      var customerArray = eval('(' + customers + ')') || [];
-      update_customer(searchrow, customerArray);
+      var customerArrayArray = eval('(' + customers + ')') || [];
+
+      if ( customerArrayArray.length == 1 ) {
 
+        update_customer(searchrow, customerArrayArray[0]);
 % if ( $opt{custnum_update_callback} ) {
-        <% $opt{custnum_update_callback} %>(searchrow, '<% $opt{prefix} %>')
+          <% $opt{custnum_update_callback} %>(searchrow, '<% $opt{prefix} %>')
 % }
+
+      } else {
+
+        custnum_obj.value = 'Multiple'; // or something
+        custnum_obj.style.color = '#ff0000';
+
+        //blank the current list
+        customer_select.options.length = 0;
+
+        opt(customer_select, '', 'Multiple customers match "' + custnum + '" - select one', '#ff0000');
+        //add the multiple customers
+        for ( var s = 0; s < customerArrayArray.length; s++ ) {
+          opt(customer_select,
+              JSON.stringify(customerArrayArray[s]),
+              customerArrayArray[s][1],
+              '#000000');
+        }
+
+        opt(customer_select, 'cancel', '(Edit search string)', '#000000');
+
+        customer_obj.style.display = 'none';
+
+        customer_select.style.display = '';
+
+      }
+
     }
 
     custnum_search(custnum, search_custnum_update );
@@ -245,7 +279,7 @@ Example:
 
       if ( customerArrayArray.length == 1 ) {
 
-        update_customer(customerArrayArray[1]);
+        update_customer(searchrow, customerArrayArray[0]);
 % if ( $opt{custnum_update_callback} ) {
         <% $opt{custnum_update_callback} %>(searchrow, '<% $opt{prefix} %>')
 % }
@@ -337,6 +371,9 @@ Example:
     document.getElementById('balance'+rownum+'_text').innerHTML = newval;
   }
 
+  function update_num_open(rownum, newval) {
+    num_open_invoices[rownum] = newval;
+  }
 
 
 </SCRIPT>
@@ -356,7 +393,7 @@ Example:
 % my $row = 0;
 % for ( $row = 0; exists($param->{"custnum$row"}); $row++ ) { 
 
-    <TR>
+    <TR id="row<%$row%>" rownum="<%$row%>">
       <TD>
        <INPUT TYPE      = "text"
                NAME      = "invnum<% $row %>"
@@ -458,19 +495,24 @@ Example:
 %     my $color = $opt{color}->[$col];
 %     my $font = $color ? qq(<FONT COLOR="$color">) : '';
 %     my $onchange = '';
-%     if ( $opt{footer}->[$col] eq '_TOTAL' ) {
+%     if ( $opt{onchange}->[$col] ) {
+%       $onchange = 'onchange="'.$opt{onchange}->[$col].'"';
+%     }
+%     elsif ( $opt{footer}->[$col] eq '_TOTAL' ) {
 %       $total[$col] += $value;
 %       $onchange = $opt{prefix}. "calc_total$col();";
 %       $onchange = qq(onchange="$onchange" onkeyup="$onchange");
 %     }
       <TD ALIGN="<% $align %>">
-%     if (! $types->[$col] || $types->[$col] eq 'text') {
-        <INPUT TYPE  = "text"
+%     my $type = $types->[$col] || 'text';
+%     if ($type eq 'text' or $type eq 'checkbox') {
+        <INPUT TYPE  = "<% $type %>"
                NAME  = "<% $name %>"
                ID    = "<% $name %>"
                SIZE  = "<% $size %>"
                STYLE = "text-align: <% $align %>;"
                VALUE = "<% $value %>"
+               rownum    = "<% $row %>"
                <% $onchange %>
         >
 %     } elsif ($types->[$col] eq 'immutable') {
@@ -485,7 +527,7 @@ Example:
     </TR>
 % } 
 
-<TR>
+<TR id="row_total">
   <TH COLSPAN=5 ID="<% $opt{'prefix'} %>_TOTAL_TOTAL">
     Total <% $row ? $row-1 : 0 %>
     <% PL($opt{name_singular} || 'customer', ( $row ? $row-1 : 0 ) ) %>
@@ -559,7 +601,8 @@ Example:
     var table = document.getElementById('<% $opt{prefix} %>OneTrueTable');
     var tablebody = table.getElementsByTagName('tbody').item(0);
 
-    var row = table.insertRow(rownum+1);
+    var row = table.insertRow(table.rows.length - 1);
+    row.setAttribute('id', 'row'+rownum);
     
     var invnum_cell = document.createElement('TD');
 
@@ -676,7 +719,7 @@ Example:
 %       } else {
 %         $value = $param->{"$field$row"}; 
 %       }
-        var my_text = document.createTextNode('<% $value %>');
+        var my_text = document.createTextNode(<% $value |js_string %>);
         my_cell.appendChild(my_text);
 %     }
 
@@ -686,10 +729,17 @@ Example:
       my_input.setAttribute('id',   '<% $name %>'+<% $opt{prefix} %>rownum);
       my_input.style.textAlign = '<% $align{ $opt{align}->[$col] || 'l' } %>';
       my_input.setAttribute('size', <% $sizes->[$col] || 10 %>);
-%     if ($types->[$col] eq 'immutable') {
+      my_input.setAttribute('rownum', <% $opt{prefix} %>rownum);
+%     if ( $types->[$col] eq 'immutable' ) {
         my_input.setAttribute('type', 'hidden');
 %     }
-%     if ( $opt{footer}->[$col] eq '_TOTAL' ) {
+%     elsif ( $types->[$col] eq 'checkbox' ) {
+        my_input.setAttribute('type', 'checkbox');
+%     }
+%     if ( $opt{onchange}->[$col] ) {
+        my_input.onchange   = <% $opt{onchange}->[$col] %>;
+%     }
+%     elsif ( $opt{footer}->[$col] eq '_TOTAL' ) {
         my_input.onchange   = <% $opt{prefix} %>calc_total<%$col%>;
         my_input.onkeyup    = <% $opt{prefix} %>calc_total<%$col%>;
 %     }
@@ -713,6 +763,11 @@ Example:
           + ' <% PL($opt{name_singular} || 'customer') %>';
     }
 
+% if ( $opt{add_row_callback} ) {
+    <% $opt{add_row_callback} %>(<% $opt{prefix} %>rownum,
+                                 '<% $opt{prefix} %>');
+% }
+
     <% $opt{prefix} %>rownum++;
 
   }
index c291e1e..82eb9b5 100644 (file)
@@ -108,8 +108,8 @@ a.fstab {
          -moz-border-radius-topright:8px;
          -webkit-border-radius-topleft:8px;
          -webkit-border-radius-topright:8px;
-         border-radius-topleft:8px;
-         border-radius-topright:8px;
+         border-top-left-radius:8px;
+         border-top-right-radius:8px;
          /*font-weight:bold;*/
          /*padding-left:12px;
          padding-right:12px;*/
@@ -141,8 +141,8 @@ a.fstabselected {
          -moz-border-radius-topright:8px;
          -webkit-border-radius-topleft:8px;
          -webkit-border-radius-topright:8px;
-         border-radius-topleft:8px;
-         border-radius-topright:8px;
+         border-top-left-radius:8px;
+         border-top-right-radius:8px;
          /*font-weight:bold;*/
          /*padding-left:12px;
          padding-right:12px;*/
@@ -175,8 +175,8 @@ div.fstabcontainer {
          -moz-border-radius-bottomright:8px;
          -webkit-border-radius-bottomleft:8px;
          -webkit-border-radius-bottomright:8px;
-         border-radius-bottomleft:8px;
-         border-radius-bottomright:8px;
+         border-bottom-left-radius:8px;
+         border-bottom-right-radius:8px;
   -moz-box-shadow: #666666 1px 1px 2px;
   -webkit-box-shadow: #666666 1px 1px 2px;
   box-shadow: #666666 1px 1px 2px;
@@ -206,8 +206,8 @@ div.fstabcontainer {
          -moz-border-radius-bottomright:8px;
          -webkit-border-radius-bottomleft:8px;
          -webkit-border-radius-bottomright:8px;
-         border-radius-bottomleft:8px;
-         border-radius-bottomright:8px;
+         border-bottom-left-radius:8px;
+         border-bottom-right-radius:8px;
   -moz-box-shadow: #666666 1px 1px 2px;
   -webkit-box-shadow: #666666 1px 1px 2px;
   box-shadow: #666666 1px 1px 2px;
@@ -238,8 +238,8 @@ div.fstabcontainer {
          -moz-border-radius-topright:8px;
          -webkit-border-radius-topleft:8px;
          -webkit-border-radius-topright:8px;
-         border-radius-topleft:8px;
-         border-radius-topright:8px;
+         border-top-left-radius:8px;
+         border-top-right-radius:8px;
   -moz-box-shadow:  1px 0px 1px #999999;
   -webkit-box-shadow:  1px 0px 1px #999999;
   box-shadow: 1px 0px 1px #999999;
index d0ab305..c6ad3c3 100644 (file)
@@ -42,6 +42,12 @@ Example:
     <% include('init_overlib.html') |n %>
     <% include('rs_init_object.html') |n %>
     <% include('logout.html') |n %>
+%   my $timeout =  $conf->config('logout-timeout');
+%   if ( $timeout && $timeout =~ /^\s*\d+\s*$/ ) {
+      <script type="text/javascript">
+        setTimeout('logout()', <% 60000 * $timeout %>);
+      </script>
+%   }
 
     <% $head |n %>
 
index d27ca3b..986adec 100644 (file)
@@ -1,9 +1,16 @@
 % for my $file (@files) {
   <SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/<%$file%>.js"></SCRIPT>
 % }
+<%shared>
+my $initialized = 0; #won't work if component is "preloaded"... so don't do that
+</%shared>
 <%init>
 
-my @files = map "overlibmws$_", ( '', qw( _iframe _draggable _crossframe ) );
-push @files, map { "${_}contentmws" } qw( iframe ajax );
+my @files = ();
+if ( ! $initialized ) {
+  push @files, map "overlibmws$_", ( '', qw( _iframe _draggable _crossframe ) );
+  push @files, map { "${_}contentmws" } qw( iframe ajax );
+  $initialized++;
+}
 
 </%init>
index c606523..7672318 100644 (file)
@@ -3,16 +3,16 @@
 Example:
 
   include( '/elements/location.html',
-             'object'         => $cust_main,  # or $cust_location
-             'prefix'         => $pre,        #only for cust_main objects
+             'object'         => $cust_location
+             'prefix'         => $pre, # prefixed to form field names
              'onchange'       => $javascript,
-             'disabled'       => $disabled,
-             'same_checked'   => $same_checked,
              'geocode'        => $geocode, #passed through
              'censustract'    => $censustract, #passed through
              'no_asterisks'   => 0, #set true to disable the red asterisks next
                                     #to required fields
              'address1_label' => 'Address', #label for address
+             'enable_district' => 1, #show tax district field
+             'enable_censustract' => 1, #show censustract field
          )
 
 </%doc>
@@ -40,12 +40,12 @@ Example:
 % } 
 
 <TR>
-  <<%$th%> ALIGN="right"><%$r%><% $opt{'address1_label'} || emt('Address') %></<%$th%>>
+  <<%$th%> STYLE="width:16ex" ALIGN="right"><%$r%><% $opt{'address1_label'} || emt('Address') %></<%$th%>>
   <TD COLSPAN=7>
     <INPUT TYPE     = "text"
            NAME     = "<%$pre%>address1"
            ID       = "<%$pre%>address1"
-           VALUE    = "<% $object->get($pre.'address1') |h %>"
+           VALUE    = "<% $object->get('address1') |h %>"
            SIZE     = 54
            onChange = "<% $onchange %>"
            <% $disabled %>
@@ -62,7 +62,7 @@ Example:
         <INPUT TYPE     = "text"
                NAME     = "<%$pre%>address2"
                ID       = "<%$pre%>address2"
-               VALUE    = "<% $object->get($pre.'address2') |h %>"
+               VALUE    = "<% $object->get('address2') |h %>"
                SIZE     = 54
                onChange = "<% $onchange %>"
                <% $disabled %>
@@ -75,7 +75,7 @@ Example:
 
       <INPUT TYPE  = "hidden"
              NAME  = "<%$pre%>address2"
-             VALUE = "<% $object->get($pre.'address2') |h %>"
+             VALUE = "<% $object->get('address2') |h %>"
       >
 
 <TR>
@@ -83,7 +83,7 @@ Example:
     <TD COLSPAN=7>
 
 %     my $location_type = scalar($cgi->param('location_type'))
-%                           || $object->get($pre.'location_type');
+%                           || $object->get('location_type');
 %     #my $location_number = scalar($cgi->param('location_number'))
 %     #                        || $object->get($pre.'location_number');
 %
@@ -130,7 +130,7 @@ Example:
     <INPUT TYPE="text" 
                NAME  = "location_number"
                ID    = "location_number"
-               VALUE = "<% scalar($cgi->param('location_number')) || $object->get($pre.'location_number') |h %>"
+               VALUE = "<% scalar($cgi->param('location_number')) || $object->get('location_number') |h %>"
                SIZE  = "5"
                <% $disabled || ($location_type ? '' : 'DISABLED') %>
                <% $style %>
@@ -161,7 +161,7 @@ Example:
     <INPUT TYPE     = "text"
            NAME     = "<%$pre%>zip"
            ID       = "<%$pre%>zip"
-           VALUE    = "<% $object->get($pre.'zip') |h %>"
+           VALUE    = "<% $object->get('zip') |h %>"
            SIZE     = 10
            onChange = "<% $onchange %>"
            <% $disabled %>
@@ -181,7 +181,7 @@ Example:
     <INPUT TYPE  = "text"
            NAME  = "<%$pre%>latitude"
            ID    = "<%$pre%>latitude"
-           VALUE = "<% $object->get($pre.'latitude') |h %>"
+           VALUE = "<% $object->get('latitude') |h %>"
            <% $disabled %>
            <% $style %>
     >
@@ -189,36 +189,44 @@ Example:
     <INPUT TYPE  = "text"
            NAME  = "<%$pre%>longitude"
            ID    = "<%$pre%>longitude"
-           VALUE = "<% $object->get($pre.'longitude') |h %>"
+           VALUE = "<% $object->get('longitude') |h %>"
            <% $disabled %>
            <% $style %>
     >
   </TD>
 </TR>
-<INPUT TYPE="hidden" NAME="<%$pre%>coord_auto" VALUE="<% $object->get($pre.'coord_auto') %>">
+<INPUT TYPE="hidden" NAME="<%$pre%>coord_auto" VALUE="<% $object->coord_auto %>">
 
-% if ( !$pre ) { 
-  <INPUT TYPE="hidden" NAME="geocode" VALUE="<% $opt{geocode} %>">
+<INPUT TYPE="hidden" NAME="<%$pre%>geocode" VALUE="<% $object->geocode %>">
+<INPUT TYPE="hidden" NAME="<%$pre%>censusyear" VALUE="<% $object->censusyear %>">
+<TR>
+% if ( $opt{enable_censustract} ) {
+  <TD ALIGN="right">Census&nbsp;tract</TD>
+  <TD COLSPAN=8>
+    <INPUT TYPE="text" SIZE=15
+           NAME="<%$pre%>censustract" 
+           VALUE="<% $object->censustract %>">
+    <% '(automatic)' %>
+  </TD>
 % } else {
-%   if ( $pre eq 'ship_' && $conf->exists('cust_main-require_censustract') ) {
-      <TR><<%$th%> ALIGN="right">Census tract<BR>(automatic)</<%$th%>>
-        <TD>
-          <INPUT TYPE="text" NAME="censustract" VALUE="<% $opt{censustract} %>">
-          <INPUT TYPE="hidden" NAME="censusyear" VALUE="<% $object->get('censusyear') %>">
-        </TD>
-      </TR>
+  <INPUT TYPE="hidden" NAME="<%$pre%>censustract" VALUE="<% $object->censustract %>">
+% } 
+</TR>
+% if ( $conf->config('tax_district_method') ) {
+  <TR>
+%   if ( $opt{enable_district} ) {
+    <TD ALIGN="right">Tax&nbsp;district</TD>
+    <TD COLSPAN=8>
+      <INPUT TYPE="text" SIZE=15
+             NAME="<%$pre%>district" 
+             VALUE="<% $object->district %>">
+    <% '(automatic)' %>
+    </TD>
 %   } else {
-      <INPUT TYPE="hidden" NAME="censustract" VALUE="<% $opt{censustract} %>">
-%   } 
-%   if ( $conf->config('tax_district_method') or $object->get('district') ) {
-    <TR>
-      <<%$th%> ALIGN="right">Tax district<BR>(automatic)</<%$th%>>
-      <TD>
-        <INPUT TYPE="text" NAME="district" VALUE="<%$object->get('district')%>">
-      </TD>
-    </TR>
+    <INPUT TYPE="hidden" NAME="<%$pre%>district" VALUE="<% $object->district %>">
 %   }
-% } 
+  </TR>
+% }
 
 <%init>
 
@@ -233,16 +241,13 @@ my $conf = new FS::Conf;
 
 my $r = $opt{'no_asterisks'} ? '' : qq!<font color="#ff0000">*</font>&nbsp;!;
 
-#false laziness with ship state
 my $countrydefault = $conf->config('countrydefault') || 'US';
-$object->set($pre.'country', $countrydefault )
-  unless $object->get($pre.'country');
-
-my $statedefault = $conf->config('statedefault')
+my $statedefault = $conf->config('statedefault') 
                    || ($countrydefault eq 'US' ? 'CA' : '');
-$object->set($pre.'state', $statedefault )
-  unless $object->get($pre.'state')
-         || $object->get($pre.'country') ne $countrydefault;
+$object ||= FS::cust_location->new({
+  'country' => $countrydefault,
+  'state'   => $statedefault,
+});
 
 my $alt_err = ($opt{'alt_format'} && !$disabled) ? $object->alternize : '';
 
@@ -255,8 +260,8 @@ push @address2_label_style, 'visibility:hidden'
   || ! $conf->exists('cust_main-require_address2')
   || ( !$pre && !$opt{'same_checked'} );
 
-my @counties = counties( $object->get($pre.'state'),
-                         $object->get($pre.'country'),
+my @counties = counties( $object->get('state'),
+                         $object->get('country'),
                        );
 my @county_style = ();
 push @county_style, 'display:none' # 'visibility:hidden'
@@ -276,10 +281,10 @@ my $county_style =
     : '';
 
 my %select_hash = (
-  'city'     => $object->get($pre.'city'),
-  'county'   => $object->get($pre.'county'),
-  'state'    => $object->get($pre.'state'),
-  'country'  => $object->get($pre.'country'),
+  'city'     => $object->get('city'),
+  'county'   => $object->get('county'),
+  'state'    => $object->get('state'),
+  'country'  => $object->get('country'),
   'prefix'   => $pre,
   'onchange' => $onchange,
   'disabled' => $disabled,
index e5f3748..5a17d6d 100644 (file)
@@ -110,8 +110,7 @@ $report_customers{'List customers'} = [ \%report_customers_lists, 'List customer
 $report_customers{'Zip code distribution'}     = [ $fsurl. 'search/report_cust_main-zip.html', 'Zip codes by number of customers' ];
 $report_customers{'Customer signup report'}       = [ $fsurl. 'graph/report_cust_signup.html', 'New customer signups by date' ],
 $report_customers{'Advanced customer reports'} = [ $fsurl. 'search/report_cust_main.html', 'by status, signup date, agent, etc.' ]
-  if    $curuser->access_right('List customers')
-     && $curuser->access_right('List packages');
+  if $curuser->access_right('Advanced customer search');
 
 tie my %report_invoices_open, 'Tie::IxHash',
   'All open invoices' => [ $fsurl.'search/cust_bill.html?OPEN_date', 'All invoices with an unpaid balance' ],
@@ -201,10 +200,10 @@ foreach my $svcdb ( FS::part_svc->svc_tables() ) {
       ];
   }
 
-  if ( $svcdb =~ /^svc_(acct|broadband|hardware)$/ ) {
     $report_svc{"Advanced $lcsname reports"} = 
-      [ $fsurl."search/report_$svcdb.html", '' ];
-  }
+        [ $fsurl."search/report_$svcdb.html", '' ]
+      if $svcdb =~ /^svc_(acct|broadband|hardware)$/
+      && $curuser->access_right("Services: $name: Advanced search");
 
   if ( $svcdb eq 'svc_phone' ) {
 
@@ -221,7 +220,8 @@ foreach my $svcdb ( FS::part_svc->svc_tables() ) {
 
   }
 
-  $report_services{$name} = [ \%report_svc, $longname ];
+  $report_services{$name} = [ \%report_svc, $longname ]
+    if $curuser->access_right("Services: $name");
 
 }
 
@@ -253,14 +253,17 @@ tie my %report_inventory, 'Tie::IxHash',
   'Inventory activity' => [ $fsurl.'search/report_h_inventory_item.html', '' ],
 ;
 
-tie my %report_rating, 'Tie::IxHash',
-  'RADIUS sessions' => [ $fsurl.'search/sqlradius.html', '' ],
-  'Call Detail Records (CDRs)' => [ $fsurl.'search/report_cdr.html', '' ],
-  'Unrateable CDRs' => [ $fsurl.'search/cdr.html?freesidestatus=failed'.
-                                               ';cdrbatchnum=_ALL_' ],
-  'Time worked' => [ $fsurl.'search/report_rt_transaction.html', '' ],
-  'Time worked summary' => [ $fsurl.'search/report_rt_ticket.html', '' ],
-;
+tie my %report_rating, 'Tie::IxHash';
+$report_rating{'RADIUS sessions'} = [ $fsurl.'search/sqlradius.html', '' ]
+  if $curuser->access_right("Usage: RADIUS sessions");
+$report_rating{'Call Detail Records (CDRs)'} = [ $fsurl.'search/report_cdr.html', '' ]
+  if $curuser->access_right("Usage: Call Detail Records (CDRs)");
+$report_rating{'Unrateable CDRs'} = [ $fsurl.'search/cdr.html?freesidestatus=failed;cdrbatchnum=_ALL_' ]
+  if $curuser->access_right("Usage: Unrateable CDRs");
+if ( $curuser->access_right("Usage: Time worked") ) {
+  $report_rating{'Time worked'} = [ $fsurl.'search/report_rt_transaction.html', '' ];
+  $report_rating{'Time worked summary'} = [ $fsurl.'search/report_rt_ticket.html', '' ];
+}
 
 tie my %report_ticketing_statistics, 'Tie::IxHash',
   'Tickets per day per Queue'         => [ $fsurl.'rt/RTx/Statistics/CallsQueueDay', 'View the number of tickets created, resolved or deleted in a specific Queue, over the requested period of days' ],
@@ -462,6 +465,7 @@ tie my %config_radius, 'Tie::IxHash',
 tie my %config_export_svc, 'Tie::IxHash', ();
 if ( $curuser->access_right('Configuration') ) {
   $config_export_svc{'Service definitions'} = [ $fsurl.'browse/part_svc.cgi', 'Services are items you offer to your customers' ];
+  $config_export_svc{'Service classes'} = [ $fsurl.'browse/part_svc_class.html', 'Services classes are user-defined, informational types for services' ];
   $config_export_svc{'Provisioning exports'} = [ $fsurl.'browse/part_export.cgi', 'Provisioning services to external machines, databases and APIs' ];
 }
 $config_export_svc{'Dialup'}  = [ \%config_dialup, ''    ]
@@ -515,6 +519,10 @@ tie my %config_agent, 'Tie::IxHash',
   'Agent payment gateways'         => [ $fsurl.'browse/payment_gateway.html', 'Credit card and electronic check processors for agent overrides' ];
 ;
 
+tie my %config_sales, 'Tie::IxHash',
+  'Sales'      => [ $fsurl.'browse/sales.cgi', 'Sales bring in new business.' ],
+;
+
 tie my %config_billing_rates, 'Tie::IxHash',
   'Rate plans' => [ $fsurl.'browse/rate.cgi', 'Manage rate plans' ],
   'Regions and prefixes' => [ $fsurl.'browse/rate_region.html', 'Manage regions and prefixes' ],
@@ -597,9 +605,8 @@ tie my %config_nms, 'Tie::IxHash',
 
 tie my %config_misc, 'Tie::IxHash';
 $config_misc{'Message templates'} = [ $fsurl.'browse/msg_template.html', 'Templates for customer notices' ]
-  if $curuser->access_right('Edit templates')
-  || $curuser->access_right('Edit global templates')
-  || $curuser->access_right('Configuration');
+  if $curuser->access_right(['View templates', 'View global templates',
+                             'Edit templates', 'Edit global templates', ]);
 $config_misc{'Advertising sources'} = [ $fsurl.'browse/part_referral.html', 'Where a customer heard about your service.' ]
   if $curuser->access_right('Edit advertising sources')
   || $curuser->access_right('Edit global advertising sources');
@@ -612,6 +619,9 @@ $config_misc{'Inventory classes and inventory'} = [ $fsurl.'browse/inventory_cla
   || $curuser->access_right('Edit global inventory')
   || $curuser->access_right('Configuration');
 
+$config_misc{'FTP targets'} = [ $fsurl.'browse/ftp_target.html', 'FTP servers for billing and payment processing' ]
+  if $curuser->access_right('Configuration');
+
 tie my %config_menu, 'Tie::IxHash';
 if ( $curuser->access_right('Configuration' ) ) {
   %config_menu = (
@@ -619,6 +629,7 @@ if ( $curuser->access_right('Configuration' ) ) {
     'separator'     => '', #its a separator!
     'Employees'     => [ \%config_employees, '' ],
     'Resellers'     => [ \%config_agent, '' ],
+    'Sales People'  => [ \%config_sales, '' ],
     'separator2'    => '', #its a separator!
     'Customers'     => [ \%config_cust, '' ],
     #or this? 'Customers and Contacts' => [ \%config_cust, '' ],
@@ -663,18 +674,18 @@ my $doc_link = $conf->config('support-key')
 eval "use RT;"
   if $conf->config('ticket_system') eq 'RT_Internal';
 
-tie my %help_menu, 'Tie::IxHash', 'Billing documentation' => [ $doc_link, 'Freeside documentation' ];
-$help_menu{'Ticketing documentation'} = [ 'http://wiki.bestpractical.com/', 'Request Tracker Wiki' ]
-  if $conf->config('ticket_system') eq 'RT_Internal';
-$help_menu{'Networking monitoring documentation'} = [ 'http://torrus.org/userguide.pod.html', 'Torrus User Guide' ]
-  if $conf->config('network_monitoring_system') eq 'Torrus_Internal';
-$help_menu{'separator'} = '';
-
+tie my %help_menu, 'Tie::IxHash';
 my $agentnum = $conf->config('brand-agent');
 if ( $agentnum ) {
   my $company_name = $conf->config('company_name', $agentnum);
   $help_menu{"About $company_name"} = [ "javascript:about_freeside()", '' ];
 } else {
+  $help_menu{'Billing documentation'} = [ $doc_link, 'Freeside documentation' ];
+  $help_menu{'Ticketing documentation'} = [ 'http://wiki.bestpractical.com/', 'Request Tracker Wiki' ]
+    if $conf->config('ticket_system') eq 'RT_Internal';
+  $help_menu{'Networking monitoring documentation'} = [ 'http://torrus.org/userguide.pod.html', 'Torrus User Guide' ]
+    if $conf->config('network_monitoring_system') eq 'Torrus_Internal';
+  $help_menu{'separator'} = '';
   $help_menu{"About Freeside v$FS::VERSION"} = [ "javascript:about_freeside()", '' ];
   $help_menu{"About RT v$RT::VERSION"} = [ 'http://www.bestpractical.com/rt', 'Request Tracker Homepage' ]
     if $conf->config('ticket_system') eq 'RT_Internal';
index 2ec248e..7a282a3 100644 (file)
@@ -54,10 +54,7 @@ sub process_whatever { #class method
            )
 %>
 
-% if (!$noinit) { 
 <& /elements/init_overlib.html &>
-%   $noinit = 1;
-% }
 
 <SCRIPT TYPE="text/javascript">
 
@@ -117,9 +114,6 @@ function <%$key%>myCallback( jobnum ) {
 
 </SCRIPT>
 
-<%once>
-my $noinit = 0;
-</%once>
 <%init>
 
 my( $formname, $fields, $action, $url_or_message, $key ) = @_;
diff --git a/httemplate/elements/select-part_svc_class.html b/httemplate/elements/select-part_svc_class.html
new file mode 100644 (file)
index 0000000..280e3e1
--- /dev/null
@@ -0,0 +1,22 @@
+<% include( '/elements/select-table.html',
+                 'table'       => 'part_svc_class',
+                 'name_col'    => 'classname',
+                 'value'       => $classnum,
+                 'empty_label' => '(none)',
+                 'hashref'     => \%hash,
+                 %opt,
+             )
+%>
+<%init>
+
+my %opt = @_;
+my $classnum = $opt{'curr_value'} || $opt{'value'};
+
+my %hash = ();
+$hash{'disabled'} = '' unless $opt{'showdisabled'};
+
+
+$opt{'records'} = delete $opt{'part_svc_class'}
+  if $opt{'part_svc_class'};
+
+</%init>
index c0dde74..127028e 100644 (file)
@@ -93,10 +93,17 @@ Example:
 %                    )
 % {
 %   my $recvalue = $record->$key();
+%   my $selected;
+%   if ( $opt{'all_selected'} ) {
+%     $selected = 1;
+%   } elsif ( $opt{'compare_sub'} && !ref($value) ) {
+%     $selected = &{ $opt{'compare_sub'} }( $value, $recvalue );
+%   } else {
+%     $selected =    ( ref($value) && $value->{$recvalue} )
+%                 || ( $value && $value eq $recvalue ); #not == because of value_col
+%   }
     <OPTION VALUE="<% $recvalue %>"
-            <% $opt{'all_selected'} || ref($value) && $value->{$recvalue} || $value && $value eq $recvalue # not == because of value_col
-               ? ' SELECTED' : ''
-            %>
+            <% $selected ? ' SELECTED' : '' %>
 %           foreach my $att ( @{ $opt{'extra_option_attributes'} } ) {
               data-<% $att %>="<% $record->$att() |h %>"
 %           }
index e6a4aa6..86f8d2b 100644 (file)
@@ -10,7 +10,7 @@ function standardize_locations() {
     'onlyship', 1,
 % } else {
 %   if ( $withfirm ) {
-    'company',  cf.elements['<% $main_prefix %>company'].value,
+    'company',  cf.elements['company'].value,
 %   }
     'address1', cf.elements['<% $main_prefix %>address1'].value,
     'address2', cf.elements['<% $main_prefix %>address2'].value,
@@ -18,9 +18,6 @@ function standardize_locations() {
     'state',    state_el.options[ state_el.selectedIndex ].value,
     'zip',      cf.elements['<% $main_prefix %>zip'].value,
 % }
-% if ( $withfirm ) {
-    'ship_company',  cf.elements['<% $ship_prefix %>company'].value,
-% }
     'ship_address1', cf.elements['<% $ship_prefix %>address1'].value,
     'ship_address2', cf.elements['<% $ship_prefix %>address2'].value,
     'ship_city',     cf.elements['<% $ship_prefix %>city'].value,
index ca5de86..1ca22f6 100644 (file)
@@ -24,7 +24,7 @@ Usage:
   <TD ALIGN="right" VALIGN="top"><% 
 FS::UI::Web::svc_link($m, $part_svc, $cust_svc)
 %></TD>
-  <TD STYLE="padding-bottom:0px"><B><%
+  <TD STYLE="padding-bottom:0px"><B><% $cust_svc->agent_svcid ? $cust_svc->agent_svcid.': ' : '' %><%
 FS::UI::Web::svc_label_link($m, $part_svc, $cust_svc)
 %></B></TD>
 <TD ALIGN="right"><% FS::UI::Web::svc_export_links($m, $part_svc, $cust_svc) %>
index f358343..dd07d90 100644 (file)
@@ -13,13 +13,15 @@ my %opt = @_;
 my $style = $opt{'cell_style'} ? 'STYLE="'. $opt{'cell_style'}. '"' : '';
 
 my $value = $opt{'formatted_value'} || $opt{'curr_value'} || $opt{'value'};
-#compatibility with select-table and friends
-if ( $opt{'multiple'} ) {
-  $value = [ split(/\s*,\s*/, $value) ] if !ref $value;
-  $value = join('<BR>', map {encode_entities($_)} @$value);
-}
-else {
-  $value = encode_entities($value)
+
+unless ( $opt{'noescape'} ) {
+  #compatibility with select-table and friends
+  if ( $opt{'multiple'} ) {
+    $value = [ split(/\s*,\s*/, $value) ] if !ref $value;
+    $value = join('<BR>', map {encode_entities($_)} @$value);
+  } else {
+    $value = encode_entities($value)
+  }
 }
 
 </%init>
index ce03c40..321bd6b 100644 (file)
@@ -22,7 +22,7 @@ Example:
   );
 
 </%doc>
-% if ( scalar(@agents) == 1 ) { 
+% if ( scalar(@agents) == 1 || $opt{'fixed'} ) { 
 
   <INPUT TYPE  = "hidden"
          NAME  = "<% $opt{'field'} || 'agentnum' %>"
@@ -30,9 +30,20 @@ Example:
          VALUE = "<% $agents[0]->agentnum %>"
   >
 
-%# YUCK.  empty row so we don't throw g_row in edit.html off :/
-  <TR>
-  </TR>
+%   if ( scalar(@agents) != 1 ) {
+      <TR>
+        <TD ALIGN="right"><% $opt{'label'} || emt('Agent') %></TD>
+        <TD BGCOLOR="#dddddd" <% $colspan %>>
+%         my $agent = qsearchs('agent', { 'agentnum' => $agentnum });
+          <% $agent ? $agent->agent : '(all)' |h %>
+        </TD>
+      </TR>
+
+%   } else { # YUCK.  empty row so we don't throw g_row in edit.html off :/
+      <TR>
+      </TR>
+%   }
+%
 % } else { 
 
   <TR>
index 0ca255b..d9e3e9e 100644 (file)
@@ -11,7 +11,6 @@ Example:
 
             #optional
             'empty_label'   => '(default service address)',
-            'disable_empty' => 0, #1 to disable
          )
 
 </%doc>
@@ -52,11 +51,12 @@ Example:
       var ftype = what.form.<%$_%>.tagName;
       if( ftype != 'SELECT') what.form.<%$_%>.style.backgroundColor = '#ffffff';
 %   } 
-
+%   if ( $opt{'alt_format'} ) {
     if ( what.form.location_type.options[what.form.location_type.selectedIndex].value ) {
       what.form.location_number.disabled = false;
       what.form.location_number.style.backgroundColor = '#ffffff';
     }
+%   }
   }
 
   function locationnum_changed(what) {
@@ -101,25 +101,8 @@ Example:
       return;
     }
 
-    if ( locationnum == 0 ) { //(default service address)
-%     if ( $cust_main ) {
-      what.form.address1.value = <% $cust_main->get($prefix.'address1') |js_string %>;
-      what.form.address2.value = <% $cust_main->get($prefix.'address2') |js_string %>;
-      what.form.city.value = <% $cust_main->get($prefix.'city') |js_string %>;
-      what.form.zip.value = <% $cust_main->get($prefix.'zip') |js_string %>;
-
-      changeSelect(what.form.country, <% $cust_main->get($prefix.'country') | js_string %> );
-
-      country_changed( what.form.country,
-                       fix_state_factory( <% $cust_main->get($prefix.'state') | js_string %>,
-                                          <% $cust_main->get($prefix.'county') | js_string %>
-                                        )
-                     );
-%     }
-
-    } else {
-      get_location( locationnum, update_location );
-    } 
+%# default service address is now just another location
+    get_location( locationnum, update_location );
 
 %   if ( $editable ) {
       if ( locationnum == 0 ) {
@@ -203,14 +186,16 @@ Example:
             ID       = "locationnum"
             onChange = "locationnum_changed(this);"
     >
-% if ( !$prospect_main && !$opt{'disable_empty'} ) {
-      <OPTION VALUE=""><% $opt{'empty_label'} || '(default service address)' |h %>
+% if ( $cust_main ) {
+      <OPTION VALUE="<% $cust_main->ship_locationnum %>"><% $opt{'empty_label'} || '(default service address)' |h %>
 % }
 % if ( $opt{'is_optional'} ) {
     <OPTION VALUE="-2" <% $locationnum == -2 ? 'SELECTED' : ''%>><% $opt{'optional_label'} || '(not required)' |h %>
 % }
 %
 %     foreach my $loc ( @cust_location ) {
+%       # don't show the ship_location redundantly
+%       next if $cust_main && $cust_main->ship_locationnum == $loc->locationnum;
         <OPTION VALUE="<% $loc->locationnum %>"
                 <% $locationnum == $loc->locationnum ? 'SELECTED' : '' %>
         ><% $loc->line |h %>
@@ -233,7 +218,9 @@ Example:
              'alt_format'   => $opt{'alt_format'},
           )
 %>
-
+<SCRIPT TYPE="text/javascript">
+  locationnum_changed(document.getElementById('locationnum'));
+</SCRIPT>
 <%init>
 
 my $conf = new FS::Conf;
@@ -246,8 +233,7 @@ my $cgi           = $opt{'cgi'};
 my $cust_pkg      = $opt{'cust_pkg'};
 my $cust_main     = $opt{'cust_main'};
 my $prospect_main = $opt{'prospect_main'};
-
-my $prefix = ($cust_main && length($cust_main->ship_last)) ? 'ship_' : '';
+die "cust_main or prospect_main required" unless $cust_main or $prospect_main;
 
 my $locationnum = '';
 if ( $cgi->param('error') ) {
@@ -259,9 +245,9 @@ if ( $cgi->param('error') ) {
   } elsif ($prospect_main) {
     my @cust_location = $prospect_main->cust_location;
     $locationnum = $cust_location[0]->locationnum if scalar(@cust_location)==1;
-  } else { #?
+  } else { #$cust_main
     $cgi->param('locationnum') =~ /^(\-?\d*)$/ or die "illegal locationnum";
-    $locationnum = $1;
+    $locationnum = $1 || $cust_main->ship_locationnum;
   }
 }
 
@@ -277,7 +263,7 @@ if ( $opt{'alt_format'} ) {
     push @location_fields, qw( location_type location_number location_kind );
 }
 
-my $cust_location;
+my $cust_location; #the one that shows by default in the location edit space
 if ( $locationnum && $locationnum > 0 ) {
   $cust_location = qsearchs('cust_location', { 'locationnum' => $locationnum } )
     or die "unknown locationnum";
@@ -290,7 +276,7 @@ if ( $locationnum && $locationnum > 0 ) {
     $cust_location->$_( $pkg_location->$_ ) foreach @location_fields;
     $opt{'empty_label'} ||= 'package address: '.$pkg_location->line;
   } elsif ( $cust_main ) {
-    $cust_location->$_( $cust_main->get($prefix.$_) ) foreach @location_fields;
+    $cust_location = $cust_main->ship_location; #I think
   }
 }
 
@@ -301,7 +287,7 @@ my $location_sort = sub {
   or lc($a->address2) cmp lc($b->address2)
 };
 
-my @cust_location = ();
+my @cust_location;
 push @cust_location, $cust_main->cust_location if $cust_main;
 push @cust_location, $prospect_main->cust_location if $prospect_main;
 push @cust_location, $cust_location
@@ -311,14 +297,14 @@ push @cust_location, $cust_location
 @cust_location = sort $location_sort grep !$_->disabled, @cust_location;
 
 $cust_location = $cust_location[0]
-  if ( $prospect_main || $opt{'disable_empty'} )
+  if ( $prospect_main )
   && !$opt{'is_optional'}
   && @cust_location;
 
 my $disabled =
   ( $locationnum < 0
     || ( $editable && $locationnum )
-    || ( ( $prospect_main || $opt{'disable_empty'} )
+    || ( $prospect_main
          && !$opt{'is_optional'} && !@cust_location && $addnew
        )
   )
diff --git a/httemplate/elements/tr-select-part_svc_class.html b/httemplate/elements/tr-select-part_svc_class.html
new file mode 100644 (file)
index 0000000..2f4b093
--- /dev/null
@@ -0,0 +1,27 @@
+% if ( scalar(@{ $opt{'part_svc_class'} }) == 0 ) { 
+
+  <INPUT TYPE="hidden" NAME="<% $opt{'element_name'} || $opt{'field'} || 'classnum' %>" VALUE="">
+
+% } else { 
+
+  <TR>
+    <TD ALIGN="right"><% $opt{'label'} || 'Service class' %></TD>
+    <TD>
+      <% include( '/elements/select-part_svc_class.html',
+                    'curr_value' => $classnum,
+                    %opt
+                )
+      %>
+    </TD>
+  </TR>
+
+% } 
+
+<%init>
+
+my %opt = @_;
+my $classnum = $opt{'curr_value'} || $opt{'value'};
+
+$opt{'part_svc_class'} ||= [ qsearch( 'part_svc_class', { disabled=>'' } ) ];
+
+</%init>
index 40371d5..d8e1c63 100644 (file)
@@ -8,11 +8,11 @@
   <BODY>
     <BR><BR>
     <CENTER>
-      You have logged out
+      You have logged out.
     </CENTER>
     <BR><BR>
     <CENTER>
-      Return to <a href="..">freeside</a>
+      You can <a href="..">log in</a> again.
     </CENTER>
   </BODY>
 </HTML>
index 2e79865..887b924 100644 (file)
@@ -1,6 +1,9 @@
-<% include('/elements/header.html', 'Quick payment entry') %>
+<& /elements/header.html, {
+  title => 'Quick payment entry',
+  etc   => 'onload="preload()"'
+} &>
 
-<% include('/elements/error.html') %>
+<& /elements/error.html &>
 
 <SCRIPT TYPE="text/javascript">
 function warnUnload() {
@@ -14,6 +17,21 @@ function warnUnload() {
 }
 window.onbeforeunload = warnUnload;
 
+function add_row_callback(rownum, prefix) {
+  document.getElementById('enable_app'+rownum).disabled = true;
+}
+
+function custnum_update_callback(rownum, prefix) {
+  var custnum = document.getElementById('custnum'+rownum).value;
+  document.getElementById('enable_app'+rownum).disabled = (
+    custnum == 0 || 
+    num_open_invoices[rownum] < 2
+  );
+% if ( $use_discounts ) {
+  select_discount_term(rownum, prefix);
+% }
+}
+
 function select_discount_term(row, prefix) {
   var custnum_obj = document.getElementById('custnum'+prefix+row);
   var select_obj = document.getElementById('discount_term'+prefix+row);
@@ -46,6 +64,265 @@ function select_discount_term(row, prefix) {
   discount_terms(custnum_obj.value, select_discount_term_update);
 
 }
+
+var invoices_for_row = new Object;
+
+function update_invoices(rownum, invoices) {
+  invoices_for_row[rownum] = new Object;
+  // only called before create_application_row
+  for ( var i=0; i<invoices.length; i++ ) {
+    invoices_for_row[rownum][ invoices[i].invnum ] = invoices[i];
+  }
+}
+
+function toggle_application_row(ev, next) {
+  if (!next) next = function(){}; //optional continuation
+  var rownum = this.getAttribute('rownum');
+  if ( this.checked ) {
+    var custnum = document.getElementById('custnum'+rownum).value;
+    if (!custnum) return;
+    lock_payment_row(rownum, true);
+    custnum_search_open( custnum, 
+      function(returned) {
+        update_invoices(rownum, JSON.parse(returned));
+        create_application_row(rownum, 0);
+        next.call(this, rownum);
+      }
+    );
+  }
+}
+
+function lock_payment_row(rownum, flag) {
+% foreach (qw(invnum custnum customer)) {
+  obj = document.getElementById('<% $_ %>'+rownum);
+  obj.readOnly = flag;
+% }
+  document.getElementById('enable_app'+rownum).disabled = flag;
+}
+
+function delete_application_row() {
+  var rownum = this.getAttribute('rownum');
+  var appnum = this.getAttribute('appnum');
+  var tr_app = document.getElementById('row'+rownum+'.'+appnum);
+  var select_invnum = document.getElementById('invnum'+rownum+'.'+appnum);
+  if ( select_invnum.value ) {
+    invoices_for_row[rownum][ select_invnum.value ] = select_invnum.curr_invoice;
+  }
+    
+  tr_app.parentNode.removeChild(tr_app);
+  if ( appnum > 0 ) {
+    document.getElementById('delete'+rownum+'.'+(appnum-1)).style.display = '';
+  }
+  else {
+    lock_payment_row(rownum, false);
+    document.getElementById('enable_app'+rownum).checked = false;
+  }
+}
+
+function amount_unapplied(rownum) {
+  var appnum = 0;
+  var total = 0;
+  var payment_amount = parseFloat(document.getElementById('paid'+rownum).value)
+                       || 0;
+  while (true) {
+    var input_amount = document.getElementById('amount'+rownum+'.'+appnum);
+    if ( input_amount ) {
+      total += parseFloat(input_amount.value || 0);
+      appnum++;
+    }
+    else {
+      return payment_amount - total;
+    }
+  }
+}
+
+var change_app_amount;
+
+function choose_app_invnum() {
+  var rownum = this.getAttribute('rownum');
+  var appnum = this.getAttribute('appnum');
+  var last_invoice = this.curr_invoice;
+  if ( last_invoice ) {
+    invoices_for_row[rownum][ last_invoice['invnum'] ] = last_invoice;
+  }
+
+  if ( this.value ) {
+    var this_invoice = invoices_for_row[rownum][this.value];
+    this.curr_invoice = invoices_for_row[rownum][this.value];
+    var span_owed = document.getElementById('owed'+rownum+'.'+appnum);
+    span_owed.innerHTML = this_invoice['owed'] + '&nbsp;';
+    delete invoices_for_row[rownum][this.value];
+
+    var input_amount = document.getElementById('amount'+rownum+'.'+appnum);
+    if ( input_amount.value == '' ) {
+      input_amount.value = 
+        Math.max(
+          0, Math.min( amount_unapplied(rownum), this_invoice['owed'])
+        ).toFixed(2);
+      // trigger onchange
+      change_app_amount.call(input_amount);
+    }
+  }
+}
+
+function focus_app_invnum() {
+% # invoice numbers just display as invoice numbers
+  var rownum = this.getAttribute('rownum');
+  var add_opt = function(obj, value) {
+    var o = document.createElement('OPTION');
+    o.text = value;
+    o.value = value;
+    obj.add(o);
+  }
+  this.options.length = 0;
+  var this_invoice = this.curr_invoice;
+  if ( this_invoice ) {
+    add_opt(this, this_invoice.invnum);
+  } else {
+    add_opt(this, '');
+  }
+  for ( var x in invoices_for_row[rownum] ) {
+    add_opt(this, invoices_for_row[rownum][x].invnum);
+  }
+}
+
+function change_app_amount() {
+  var rownum = this.getAttribute('rownum');
+  var appnum = this.getAttribute('appnum');
+%# maybe some kind of warning if amount_unapplied < 0?
+%# only spawn a new application row if there are open invoices left,
+%# and this is the highest-numbered application row for the customer,
+%# and the sum of the applied amounts is < the amount of the payment,
+  if ( Object.keys(invoices_for_row[rownum]).length > 0
+       && !document.getElementById( 'row'+rownum+'.'+(parseInt(appnum) + 1) )
+       && amount_unapplied(rownum) > 0 ) {
+
+    create_application_row(rownum, parseInt(appnum) + 1);
+
+  }
+}
+
+function create_application_row(rownum, appnum) {
+  var payment_row = document.getElementById('row'+rownum);
+  var tr_app = document.createElement('TR');
+  tr_app.setAttribute('rownum', rownum);
+  tr_app.setAttribute('appnum', appnum);
+  tr_app.setAttribute('id', 'row'+rownum+'.'+appnum);
+  
+  var td_invnum = document.createElement('TD');
+  td_invnum.setAttribute('colspan', 4);
+  td_invnum.style.textAlign = 'right';
+  td_invnum.appendChild(
+    document.createTextNode('<% mt('Apply to Invoice ') %>')
+  );
+  var select_invnum = document.createElement('SELECT');
+  select_invnum.setAttribute('rownum', rownum);
+  select_invnum.setAttribute('appnum', appnum);
+  select_invnum.setAttribute('id', 'invnum'+rownum+'.'+appnum);
+  select_invnum.setAttribute('name', 'invnum'+rownum+'.'+appnum);
+  select_invnum.style.textAlign = 'right';
+  select_invnum.style.width = '50px';
+  select_invnum.onchange = choose_app_invnum;
+  select_invnum.onfocus  = focus_app_invnum;
+  
+  td_invnum.appendChild(select_invnum);
+  tr_app.appendChild(td_invnum);
+
+  var td_owed = document.createElement('TD');
+  td_owed.style.textAlign= 'right';
+  var span_owed = document.createElement('SPAN');
+  span_owed.setAttribute('rownum', rownum);
+  span_owed.setAttribute('appnum', appnum);
+  span_owed.setAttribute('id', 'owed'+rownum+'.'+appnum);
+  td_owed.appendChild(span_owed);
+  tr_app.appendChild(td_owed);
+
+  var td_amount = document.createElement('TD');
+  td_amount.style.textAlign = 'right';
+  var input_amount = document.createElement('INPUT');
+  input_amount.size = 6;
+  input_amount.setAttribute('rownum', rownum);
+  input_amount.setAttribute('appnum', appnum);
+  input_amount.setAttribute('name', 'amount'+rownum+'.'+appnum);
+  input_amount.setAttribute('id', 'amount'+rownum+'.'+appnum);
+  input_amount.style.textAlign = 'right';
+  input_amount.onchange = change_app_amount;
+  td_amount.appendChild(input_amount);
+  tr_app.appendChild(td_amount);
+
+  var td_delete = document.createElement('TD');
+  td_delete.setAttribute('colspan', <% scalar(@fields)-2 %>);
+  var button_delete = document.createElement('INPUT');
+  button_delete.setAttribute('rownum', rownum);
+  button_delete.setAttribute('appnum', appnum);
+  button_delete.setAttribute('id', 'delete'+rownum+'.'+appnum);
+  button_delete.setAttribute('type', 'button');
+  button_delete.setAttribute('value', 'X');
+  button_delete.onclick = delete_application_row;
+  button_delete.style.color = '#ff0000';
+  button_delete.style.fontWeight = 'bold';
+  button_delete.style.paddingLeft = '2px';
+  button_delete.style.paddingRight = '2px';
+  td_delete.appendChild(button_delete);
+  tr_app.appendChild(td_delete);
+
+  var td_error = document.createElement('TD');
+  var span_error = document.createElement('SPAN');
+  span_error.setAttribute('rownum', rownum);
+  span_error.setAttribute('appnum', appnum);
+  span_error.setAttribute('id', 'error'+rownum+'.'+appnum);
+  span_error.style.color = '#ff0000';
+  td_error.appendChild(span_error);
+  tr_app.appendChild(td_error);
+
+  if ( appnum > 0 ) {
+    //remove delete button on the previous row
+    document.getElementById('delete'+rownum+'.'+(appnum-1)).style.display = 'none';
+  }
+  rownum++;
+  var next_row = document.getElementById('row'+rownum); // always exists
+  payment_row.parentNode.insertBefore(tr_app, next_row);
+
+}
+
+%# for error handling--ugly, but the alternative is translating the whole 
+%# process of creating rows into Mason
+var row_array = <% encode_json(\@rows) %>;
+function preload() {
+  var rownum;
+  var appnum;
+  for (rownum=0; rownum < row_array.length; rownum++) {
+    if ( row_array[rownum].length ) {
+      var enable = document.getElementById('enable_app'+rownum);
+      enable.checked = true;
+      var preload_row = function(r) {//continuation from toggle_application_row
+        for (appnum=0; appnum < row_array[r].length; appnum++) {
+          this_app = row_array[r][appnum];
+          var x = r + '.' + appnum;
+          //set invnum
+          var select_invnum = document.getElementById('invnum'+x);
+          focus_app_invnum.call(select_invnum);
+          for (i=0; i<select_invnum.options.length; i++) {
+            if (select_invnum.options[i].value == this_app.invnum) {
+              select_invnum.selectedIndex = i;
+            }
+          }
+          choose_app_invnum.call(select_invnum);
+          //set amount
+          var input_amount = document.getElementById('amount'+x);
+          input_amount.value = this_app.amount;
+
+          //set error
+          var span_error = document.getElementById('error'+x);
+          span_error.innerHTML = this_app.error;
+          change_app_amount.call(input_amount); //creates next row
+        } //for appnum
+      }; //preload_row function
+      toggle_application_row.call(enable, null, preload_row);
+    } // if row_array[rownum].length
+  } //for rownum
+}
+
 </SCRIPT>
 
 <% include('/elements/xmlhttp.html',
@@ -57,21 +334,26 @@ function select_discount_term(row, prefix) {
 <FORM ACTION="process/batch-cust_pay.cgi" NAME="OneTrueForm" METHOD="POST" onsubmit="document.OneTrueForm.btnsubmit.disabled=true;window.onbeforeunload = null;">
 
 <!-- <B>Batch</B> <INPUT TYPE="text" NAME="paybatch"><BR><BR> -->
+<& /elements/xmlhttp.html,
+    url => $p.'misc/xmlhttp-cust_bill-search.html',
+    subs => ['custnum_search_open']
+&>
 
-<% include( "/elements/customer-table.html",
-              name_singular => 'payment',
-              header  => \@header,
-              fields  => \@fields,
-              type    => \@types,
-              align   => \@align,
-              size    => \@sizes,
-              color   => \@colors,
-              param   => \%param,
-              footer  => \@footer,
-              footer_align => \@footer_align,
-              custnum_update_callback => $custnum_update_callback,
-          )
-%>
+<& /elements/customer-table.html,
+    name_singular => 'payment',
+    header  => \@header,
+    fields  => \@fields,
+    type    => \@types,
+    align   => \@align,
+    size    => \@sizes,
+    color   => \@colors,
+    param   => \%param,
+    footer  => \@footer,
+    footer_align => \@footer_align,
+    onchange => \@onchange,
+    custnum_update_callback => 'custnum_update_callback',
+    add_row_callback => 'add_row_callback',
+&>
 
 <BR>
 <INPUT TYPE="button" VALUE="Post payment batch" name="btnsubmit" onclick="window.onbeforeunload = null; document.OneTrueForm.submit(); this.disabled = true;">
@@ -105,7 +387,8 @@ my @colors  = ( '', '' );
 my %param   = ();
 my @footer  = ( '_TOTAL', '' );
 my @footer_align = ( 'r', 'r' );
-my $custnum_update_callback = '';
+my @onchange = ( '', '' );;
+my $use_discounts = '';
 
 if ( FS::Record->scalar_sql('SELECT COUNT(*) FROM part_pkg_discount') ) {
   #push @header, 'Discount';
@@ -117,9 +400,20 @@ if ( FS::Record->scalar_sql('SELECT COUNT(*) FROM part_pkg_discount') ) {
   push @colors, '';
   push @footer, '';
   push @footer_align, '';
-  $custnum_update_callback = 'select_discount_term';
+  push @onchange, '';
+  $use_discounts = 'Y';
 }
 
+push @header, 'Allocate';
+push @fields, 'enable_app';
+push @types, 'checkbox';
+push @align, 'c';
+push @sizes, '0';
+push @colors, '';
+push @footer, '';
+push @footer_align, '';
+push @onchange, 'toggle_application_row';
+
 #push @header, 'Error';
 push @header, '';
 push @fields, 'error';
@@ -129,7 +423,34 @@ push @sizes, '0';
 push @colors, '#ff0000';
 push @footer, '';
 push @footer_align, '';
+push @onchange, '';
 
 $m->comp('/elements/handle_uri_query');
 
+# set up for preloading
+my @rows;
+my @row_errors;
+if ( $cgi->param('error') ) {
+  my $param = $cgi->Vars;
+  my $enum = 0; #errors numbered separately
+  for( my $row = 0; exists($param->{"custnum$row"}); $row++ ) {
+    $rows[$row] = [];
+    $row_errors[$row] = $param->{"error$enum"};
+    $enum++;
+    for( my $app = 0; exists($param->{"invnum$row.$app"}); $app++ ) {
+      next if !$param->{"invnum$row.$app"};
+      my %this_app = map { $_ => ($param->{$_.$row.'.'.$app} || '') } 
+        qw( invnum amount );
+      $this_app{'error'} = $param->{"error$enum"} || '';
+      $param->{"error$enum"} = ''; # don't pass this error through
+      $rows[$row][$app] = \%this_app;
+      $enum++;
+    }
+  }
+  for( my $row = 0; $row < @row_errors; $row++ ) {
+    $param->{"error$row"} = $row_errors[$row];
+  }
+}
+#warn Dumper {rows => \@rows, row_errors => \@row_errors };
+
 </%init>
index 4b5df86..348f0a6 100755 (executable)
 % my $date_init = 0;
 % if ($method eq 'expire' || $method eq 'adjourn' || $method eq 'resume') {
 %   $submit =~ /^(\w*)\s/;
-<& /elements/tr-input-date-field.html, {
-    'name'    => 'date',
-    'value'   => $date,
-    'label'   => mt("$1 package on"),
-    'format'  => $date_format,
-} &>
+  <& /elements/tr-input-date-field.html, {
+      'name'    => 'date',
+      'value'   => $date,
+      'label'   => mt("$1 package on"),
+      'format'  => $date_format,
+  } &>
 %   $date_init = 1;
 % }
 
-% unless ( $method eq 'resume' ) { #the only one that doesn't need a reason
-<& /elements/tr-select-reason.html,
-     'field'          => 'reasonnum',
-     'reason_class'   => $class,
-     'curr_value'     => $reasonnum,
-     'control_button' => "document.getElementById('confirm_cancel_pkg_button')",
-&>
+% if ($method eq 'uncancel' ) {
+%
+% #XXX customer also requested setup
+% # setup: what usefulness is changing or blanking this?  re-charge setup fee?
+% #        an option that says that would be better if that's what we want to do
+
+% # last_bill: isn't this informational?  what good would editing it do?
+% #            something about invoice display?
+  <& /elements/tr-input-date-field.html, {
+      'name'    => 'last_bill',
+      'value'   => ( $cgi->param('last_bill') || $cust_pkg->get('last_bill') ),
+      'label'   => mt("Last bill date"),
+      'format'  => $date_format,
+  } &>
+
+  <& /elements/tr-input-date-field.html, {
+      'name'    => 'bill',
+      'value'   => ( $cgi->param('bill') || $cust_pkg->get('bill') ),
+      'label'   => mt("Next bill date"),
+      'format'  => $date_format,
+  } &>
+
+  <& /elements/tr-checkbox.html,
+       'label'  => mt("Uncancel even if a service can't be re-provisioned"),
+       'field'  => 'svc_not_fatal',
+       'value'  => 'Y',
+  &>
+
+%   $date_init = 1;
+% }
+
+% unless ( $method eq 'resume' || $method eq 'uncancel' ) {
+  <& /elements/tr-select-reason.html,
+       field          => 'reasonnum',
+       reason_class   => $class,
+       curr_value     => $reasonnum,
+       control_button => "document.getElementById('confirm_cancel_pkg_button')",
+  &>
 % }
 
 % if ( ( $method eq 'adjourn' or $method eq 'suspend' ) and 
 %                     ? str2time($cgi->param('resume_date'))
 %                     : $cust_pkg->get('resume');
 
-<& /elements/tr-input-date-field.html, {
-    'name'    => 'resume_date',
-    'value'   => $resume_date,
-    'label'   => mt('Unsuspend on'),
-    'format'  => $date_format,
-    'noinit'  => $date_init,
-} &>
+  <& /elements/tr-input-date-field.html, {
+      'name'    => 'resume_date',
+      'value'   => $resume_date,
+      'label'   => mt('Unsuspend on'),
+      'format'  => $date_format,
+      'noinit'  => $date_init,
+  } &>
 % }
 </TABLE>
 
 <BR>
 <INPUT TYPE="submit" NAME="submit" ID="confirm_cancel_pkg_button" 
   VALUE="<% mt($submit) |h %>"
-  <% $method ne 'resume' ? 'DISABLED' : '' %>>
+  <% $method !~ /^(resume|uncancel)$/ ? 'DISABLED' : '' %>>
 
 </FORM>
 </BODY>
 </HTML>
 
 <%init>
+use Date::Parse qw(str2time);
 
 my $conf = new FS::Conf;
 my $date_format = $conf->config('date_format') || '%m/%d/%Y';
@@ -99,6 +131,10 @@ if ($method eq 'cancel') {
   $class  = '';
   $submit = 'Unsuspend Later';
   $right  = 'Unsuspend customer package'; #later?
+} elsif ( $method eq 'uncancel') {
+  $class  = '';
+  $submit = 'Un-Cancel';
+  $right  = 'Un-cancel customer package'; #later?
 } else {
   die 'illegal query (unknown method param)';
 }
@@ -107,6 +143,7 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied" unless $curuser->access_right($right);
 
 my $title = ucfirst($method) . ' Package';
+$title =~ s/Uncancel/Un-cancel/;
 
 my $cust_pkg = qsearchs('cust_pkg', {'pkgnum' => $pkgnum})
   or die "Unknown pkgnum: $pkgnum";
index 002c001..2ae9f10 100755 (executable)
@@ -1,4 +1,4 @@
-<& /elements/header.html, mt("Customer cancelled") &>
+<& /elements/header-popup.html, mt("Customer cancelled") &>
   <SCRIPT TYPE="text/javascript">
     window.top.location.reload();
   </SCRIPT>
index 6646510..74f9b4c 100644 (file)
@@ -35,6 +35,7 @@ Import a file containing customer records.
         <OPTION VALUE="extended-plus_company_and_options">Extended plus company and options
         <OPTION VALUE="svc_external">External service
         <OPTION VALUE="svc_external_svc_phone">External service and phone service
+        <OPTION VALUE="birthdates-acct_phone_hardware">Birthdates and account, phone and hardware services
       </SELECT>
     </TD>
   </TR>
@@ -106,6 +107,9 @@ Uploaded files can be CSV (comma-separated value) files or Excel spreadsheets.
 <b>External service and phone service</b> format has the following field order: <i>agent_custid, refnum<%$req%>, last<%$req%>, first<%$req%>, company, address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country, daytime, night, ship_last, ship_first, ship_company, ship_address1, ship_address2, ship_city, ship_state, ship_zip, ship_country, payinfo, paycvv, paydate, invoicing_list, pkgpart, next_bill_date, id, title, countrycode, phonenum, sip_password, pin</i>
 <BR><BR>
 
+<b>Birthdates and account, phone and hardware services</b> format has the following field order: <i>agent_custid, refnum<%$req%>, last<%$req%>, first<%$req%>, company, address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country, daytime, night, ship_last, ship_first, ship_company, ship_address1, ship_address2, ship_city, ship_state, ship_zip, ship_country, birthdate, spouse_birthdate, payinfo, paycvv, paydate, invoicing_list, pkgpart, next_bill_date, username, _password, countrycode, phonenum, sip_password, pin, typenum, ip_addr, hw_addr, serial</i>
+<BR><BR>
+
 <%$req%> Required fields
 <BR><BR>
 
diff --git a/httemplate/misc/cust_main-suspend.cgi b/httemplate/misc/cust_main-suspend.cgi
new file mode 100755 (executable)
index 0000000..6185136
--- /dev/null
@@ -0,0 +1,75 @@
+<& /elements/header-popup.html, mt("Customer suspended") &>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.reload();
+  </SCRIPT>
+  </BODY>
+</HTML>
+<%init>
+
+#false laziness w/cust_main-cancel.cgi
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Suspend customer');
+
+my $custnum;
+my $adjourn = '';
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  $custnum = $1;
+  $adjourn = $cgi->param('adjourn');
+} else {
+  my($query) = $cgi->keywords;
+  $query =~ /^(\d+)$/ || die "Illegal custnum";
+  $custnum = $1;
+}
+
+#false laziness w/process/cancel_pkg.html
+
+#untaint reasonnum
+my $reasonnum = $cgi->param('reasonnum');
+$reasonnum =~ /^(-?\d+)$/ || die "Illegal reasonnum";
+$reasonnum = $1;
+
+if ($reasonnum == -1) {
+  $reasonnum = {
+    'typenum' => scalar( $cgi->param('newreasonnumT') ),
+    'reason'  => scalar( $cgi->param('newreasonnum' ) ),
+  };
+}
+
+#eslaf
+
+my $cust_main = qsearchs( {
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+} );
+
+my @errors;
+if($cgi->param('now_or_later')) {
+  $adjourn = parse_datetime($adjourn);
+  if($adjourn) {
+    #warn "setting adjourn dates on custnum#$custnum\n";
+    my @pkgs = $cust_main->unsuspended_pkgs;
+    @errors = grep {$_} map { $_->suspend(
+      'reason'  => $reasonnum,
+      'date'    => $adjourn,
+    ) } @pkgs;
+  }
+  else {
+    @errors = ("error parsing adjourn date: ".$cgi->param('adjourn'));
+  }
+}
+else {
+  warn "suspending $cust_main";
+  @errors = $cust_main->suspend(
+    'reason' => $reasonnum,
+  );
+}
+my $error = join(' / ', @errors) if scalar(@errors);
+
+if ( $error ) {
+  $cgi->param('error', $error);
+  print $cgi->redirect(popurl(1). "suspend_cust.html?". $cgi->query_string );
+}
+
+</%init>
diff --git a/httemplate/misc/cust_main-unsuspend.cgi b/httemplate/misc/cust_main-unsuspend.cgi
new file mode 100755 (executable)
index 0000000..eb4a2c8
--- /dev/null
@@ -0,0 +1,56 @@
+<& /elements/header-popup.html, mt("Customer unsuspended") &>
+  <SCRIPT TYPE="text/javascript">
+    window.top.location.reload();
+  </SCRIPT>
+  </BODY>
+</HTML>
+<%init>
+
+#false laziness w/cust_main-cancel.cgi
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Unsuspend customer');
+
+my $custnum;
+my $resume = '';
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  $custnum = $1;
+  $resume = $cgi->param('resume');
+} else {
+  my($query) = $cgi->keywords;
+  $query =~ /^(\d+)$/ || die "Illegal custnum";
+  $custnum = $1;
+}
+
+my $cust_main = qsearchs( {
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+} );
+
+my @errors;
+if($cgi->param('now_or_later')) {
+  $resume = parse_datetime($resume);
+  if($resume) {
+    #warn "setting resume dates on custnum#$custnum\n";
+    my @pkgs = $cust_main->suspended_pkgs;
+    @errors = grep {$_} map { $_->unsuspend(
+      'date'    => $resume,
+    ) } @pkgs;
+  }
+  else {
+    @errors = ("error parsing adjourn date: ".$cgi->param('adjourn'));
+  }
+}
+else {
+  warn "unsuspending $cust_main";
+  @errors = $cust_main->unsuspend;
+}
+my $error = join(' / ', @errors) if scalar(@errors);
+
+if ( $error ) {
+  $cgi->param('error', $error);
+  print $cgi->redirect(popurl(1). "unsuspend_cust.html?". $cgi->query_string );
+}
+
+</%init>
diff --git a/httemplate/misc/delete-ftp_target.html b/httemplate/misc/delete-ftp_target.html
new file mode 100644 (file)
index 0000000..c8bd297
--- /dev/null
@@ -0,0 +1,18 @@
+% if ( $error ) {
+%   errorpage($error);
+% } else {
+<% $cgi->redirect("${p}browse/ftp_target.html") %>
+% }
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my($query) = $cgi->keywords;
+$query =~ /^(\d+)$/ || die "Illegal targetnum";
+my $targetnum = $1;
+
+my $target = qsearchs('ftp_target',{'targetnum'=>$targetnum});
+my $error = $target->delete;
+
+</%init>
index 2332f20..7aa024a 100644 (file)
     &>
 % }
 
+% if ( $conf->exists('invoice-unitprice') ) {
+    <TR>
+      <TH ALIGN="right"><% mt('Quantity') |h %> </TD>
+      <TD>
+        <INPUT TYPE="text" NAME="quantity" SIZE=4 VALUE="<% $quantity %>">
+      </TD>
+    </TR>
+% }
+
 <TR>
   <TH ALIGN="right"><% mt('Start date') |h %> </TD>
   <TD COLSPAN=6>
@@ -163,6 +172,11 @@ if ( $cgi->param('lock_pkgpart') ) {
 
 my $pkgpart = $part_pkg ? $part_pkg->pkgpart : scalar($cgi->param('pkgpart'));
 
+my $quantity = 1;
+if ( $cgi->param('quantity') =~ /^\s*(\d+)\s*$/ ) {
+  $quantity = $1;
+}
+
 my $format = $date_format. ' %T %z (%Z)'; #false laziness w/REAL_cust_pkg.cgi?
 my $start_date = '';
 if( ! $conf->exists('order_pkg-no_start_date') ) {
index a6b90ea..1105af9 100644 (file)
@@ -1,51 +1,69 @@
-%  die "access denied"
-%    unless $FS::CurrentUser::CurrentUser->access_right('Post payment batch');
-%
-%  my $param = $cgi->Vars;
-%
-%  #my $paybatch = $param->{'paybatch'};
-%  my $paybatch = time2str('webbatch-%Y/%m/%d-%T'. "-$$-". rand() * 2**32, time);
-%
-%  my @cust_pay = ();
-%  #my $row = 0;
-%  #while ( exists($param->{"custnum$row"}) ) {
-%  for ( my $row = 0; exists($param->{"custnum$row"}); $row++ ) {
-%    my $custnum = $param->{"custnum$row"};
-%    my $cust_main;
-%    if ( $custnum =~ /^(\d+)$/ and $1 <= 2147483647 ) {
-%      $cust_main = qsearchs({ 
-%        'table'     => 'cust_main',
-%        'hashref'   => { 'custnum' => $1 },
-%        'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
-%      });
-%    }
-%    if ( length($custnum) and !$cust_main ) { # not found, try agent_custid
-%      $cust_main = qsearchs({ 
-%        'table'     => 'cust_main',
-%        'hashref'   => { 'agent_custid' => $custnum },
-%        'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
-%      });
-%    }
-%    $custnum = $cust_main->custnum if $cust_main;
-%    # if !$cust_main, then this will throw an error on batch_insert
-%
-%    push @cust_pay, new FS::cust_pay {
-%                      'custnum'        => $custnum,
-%                      'paid'           => $param->{"paid$row"},
-%                      'payby'          => 'BILL',
-%                      'payinfo'        => $param->{"payinfo$row"},
-%                      'discount_term'  => $param->{"discount_term$row"},
-%                      'paybatch'       => $paybatch,
-%                    }
-%      if    $param->{"custnum$row"}
-%         || $param->{"paid$row"}
-%         || $param->{"payinfo$row"};
-%    #$row++;
-%  }
-%
-%  my @errors = FS::cust_pay->batch_insert(@cust_pay);
-%  my $num_errors = scalar(grep $_, @errors);
-%
+<%init>
+my $DEBUG = 0;
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Post payment batch');
+
+my $param = $cgi->Vars;
+warn Dumper($param) if $DEBUG;
+
+#my $paybatch = $param->{'paybatch'};
+my $paybatch = time2str('webbatch-%Y/%m/%d-%T'. "-$$-". rand() * 2**32, time);
+
+my @cust_pay = ();
+#my $row = 0;
+#while ( exists($param->{"custnum$row"}) ) {
+for ( my $row = 0; exists($param->{"custnum$row"}); $row++ ) {
+  my $custnum = $param->{"custnum$row"};
+  my $cust_main;
+  if ( $custnum =~ /^(\d+)$/ and $1 <= 2147483647 ) {
+    $cust_main = qsearchs({ 
+      'table'     => 'cust_main',
+      'hashref'   => { 'custnum' => $1 },
+      'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+    });
+  }
+  if ( length($custnum) and !$cust_main ) { # not found, try agent_custid
+    $cust_main = qsearchs({ 
+      'table'     => 'cust_main',
+      'hashref'   => { 'agent_custid' => $custnum },
+      'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+    });
+  }
+  $custnum = $cust_main->custnum if $cust_main;
+  # if !$cust_main, then this will throw an error on batch_insert
+
+  my $cust_pay = new FS::cust_pay {
+                    'custnum'        => $custnum,
+                    'paid'           => $param->{"paid$row"},
+                    'payby'          => 'BILL',
+                    'payinfo'        => $param->{"payinfo$row"},
+                    'discount_term'  => $param->{"discount_term$row"},
+                    'paybatch'       => $paybatch,
+                  }
+    if    $param->{"custnum$row"}
+       || $param->{"paid$row"}
+       || $param->{"payinfo$row"};
+  next if !$cust_pay;
+  #$row++;
+
+  # payment applications, if any
+  my @cust_bill_pay = ();
+  for ( my $app = 0; exists($param->{"invnum$row.$app"}); $app++ ) {
+    next if !$param->{"invnum$row.$app"};
+    push @cust_bill_pay, new FS::cust_bill_pay {
+                            'invnum'  => $param->{"invnum$row.$app"},
+                            'amount'  => $param->{"amount$row.$app"}
+                          };
+  }
+  $cust_pay->set('apply_to', \@cust_bill_pay) if scalar(@cust_bill_pay) > 0;
+
+  push @cust_pay, $cust_pay;
+
+}
+
+my @errors = FS::cust_pay->batch_insert(@cust_pay);
+my $num_errors = scalar(grep $_, @errors);
+</%init>
 %  if ( $num_errors ) {
 %
 %    $cgi->param('error', "$num_errors error". ($num_errors>1 ? 's' : '').
@@ -65,4 +83,3 @@
 %    
 <% $cgi->redirect(popurl(3). "search/cust_pay.html?magic=paybatch;paybatch=$paybatch") %>
 % } 
-
index 662a776..b2d7bfa 100755 (executable)
@@ -6,19 +6,21 @@
 </HTML>
 <%once>
 
-my %past = ( 'cancel'  => 'cancelled',
-             'expire'  => 'expired',
-             'suspend' => 'suspended',
-             'adjourn' => 'adjourned',
-             'resume'  => 'scheduled to resume',
+my %past = ( 'cancel'   => 'cancelled',
+             'expire'   => 'expired',
+             'suspend'  => 'suspended',
+             'adjourn'  => 'adjourned',
+             'resume'   => 'scheduled to resume',
+             'uncancel' => 'un-cancelled',
            );
 
 #i'm sure this is false laziness with somewhere, at least w/misc/cancel_pkg.html
-my %right = ( 'cancel'  => 'Cancel customer package immediately',
-              'expire'  => 'Cancel customer package later',
-              'suspend' => 'Suspend customer package',
-              'adjourn' => 'Suspend customer package later',
-              'resume'  => 'Unsuspend customer package', #later?
+my %right = ( 'cancel'   => 'Cancel customer package immediately',
+              'expire'   => 'Cancel customer package later',
+              'suspend'  => 'Suspend customer package',
+              'adjourn'  => 'Suspend customer package later',
+              'resume'   => 'Unsuspend customer package', #later?
+              'uncancel' => 'Un-cancel customer package',
             );
 
 </%once>
@@ -26,7 +28,8 @@ my %right = ( 'cancel'  => 'Cancel customer package immediately',
 
 #untaint method
 my $method = $cgi->param('method');
-$method =~ /^(cancel|expire|suspend|adjourn|resume)$/ or die "Illegal method";
+$method =~ /^(cancel|expire|suspend|adjourn|resume|uncancel)$/
+  or die "Illegal method";
 $method = $1;
 my $past_method = $past{$method};
 
@@ -39,7 +42,7 @@ $pkgnum =~ /^(\d+)$/ or die "Illegal pkgnum";
 $pkgnum = $1;
 
 my $date = time;
-if ($method eq 'expire' || $method eq 'adjourn' || $method eq 'resume'){
+if ($method eq 'expire' || $method eq 'adjourn' || $method eq 'resume') {
   #untaint date
   $date = $cgi->param('date'); #huh?
   parse_datetime($cgi->param('date')) =~ /^(\d+)$/ or die "Illegal date";
@@ -59,7 +62,7 @@ my $cust_pkg = qsearchs( 'cust_pkg', {'pkgnum'=>$pkgnum} );
 
 #untaint reasonnum
 my $reasonnum = $cgi->param('reasonnum');
-if ( $method ne 'unsuspend' ) { #i.e. 'resume'
+if ( $method !~ /^(unsuspend|uncancel)$/ ) {
   $reasonnum =~ /^(-?\d+)$/ or die "Illegal reasonnum";
   $reasonnum = $1;
 
@@ -71,9 +74,21 @@ if ( $method ne 'unsuspend' ) { #i.e. 'resume'
   }
 }
 
+#for uncancel
+my $last_bill =
+  $cgi->param('last_bill') ? parse_datetime($cgi->param('last_bill')) : '';
+my $bill =
+  $cgi->param('bill')      ? parse_datetime($cgi->param('bill'))      : '';
+
+my $svc_fatal = ( $cgi->param('svc_not_fatal') ne 'Y' );
+
 my $error = $cust_pkg->$method( 'reason'      => $reasonnum,
                                 'date'        => $date,
-                                'resume_date' => $resume_date );
+                                'resume_date' => $resume_date,
+                                'last_bill'   => $last_bill,
+                                'bill'        => $bill,
+                                'svc_fatal'   => $svc_fatal,
+                              );
 
 if ($error) {
   $cgi->param('error', $error);
diff --git a/httemplate/misc/suspend_cust.html b/httemplate/misc/suspend_cust.html
new file mode 100644 (file)
index 0000000..b41f36f
--- /dev/null
@@ -0,0 +1,79 @@
+<& /elements/header-popup.html, mt('Suspend customer')  &>
+
+<& /elements/error.html &>
+
+<FORM NAME="cust_suspend_popup" ACTION="<% popurl(1) %>cust_main-suspend.cgi" METHOD=POST>
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+
+ <P ALIGN="center"><B><% mt('Suspend this customer?') |h %></B>
+
+<TABLE BORDER="0" CELLSPACING="2"
+STYLE="margin-left:auto; margin-right:auto">
+<TR>
+  <TD ALIGN="right">
+    <INPUT TYPE="radio" NAME="now_or_later" VALUE="0" onclick="toggle(false)" CHECKED />
+  </TD>
+  <TD ALIGN="left"><% mt('Suspend now') |h %></TD>
+</TR>
+<TR>
+  <TD ALIGN="right">
+    <INPUT TYPE="radio" NAME="now_or_later" VALUE="1" onclick="toggle(true)" />
+  </TD>
+  <TD ALIGN="left"><% mt('Suspend on date: ') |h %> 
+  <& /elements/input-date-field.html, {
+              'name'    => 'adjourn',
+              'value'   => time,
+    }  &>
+  </TD>
+</TR>
+</TABLE>
+<SCRIPT type="text/javascript">
+function toggle(val) {
+  document.getElementById("adjourn_text").disabled = !val;
+  document.getElementById("adjourn_button").style.visibility = 
+    val ? 'visible' : 'hidden';
+}
+toggle(false);
+</SCRIPT> 
+
+<TABLE BGCOLOR="#cccccc", BORDER="0" CELLSPACING="2"
+STYLE="margin-left:auto; margin-right:auto">
+<& /elements/tr-select-reason.html,
+             'field'          => 'reasonnum',
+             'reason_class'   => 'C',
+             'cgi'            => $cgi,
+             'control_button' => "document.getElementById('confirm_suspend_cust_button')",
+&>
+
+</TABLE>
+
+<BR>
+<P ALIGN="CENTER">
+<INPUT TYPE="submit" NAME="submit" ID="confirm_suspend_cust_button" VALUE="<% mt('Suspend customer') |h %>" DISABLED> 
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+<INPUT TYPE="BUTTON" VALUE="<% mt("Don't suspend") |h %>" onClick="parent.cClick();"> 
+
+</FORM>
+</BODY>
+</HTML>
+
+<%init>
+
+#false laziness w/cancel_cust.html
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
+my $custnum = $1;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied" unless $curuser->access_right('Suspend customer');
+
+my $cust_main = qsearchs( {
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+} );
+die "No customer # $custnum" unless $cust_main;
+
+</%init>
+
diff --git a/httemplate/misc/unsuspend_cust.html b/httemplate/misc/unsuspend_cust.html
new file mode 100644 (file)
index 0000000..600eb26
--- /dev/null
@@ -0,0 +1,68 @@
+<& /elements/header-popup.html, mt('Unsuspend customer')  &>
+
+<& /elements/error.html &>
+
+<FORM NAME="cust_unsuspend_popup" ACTION="<% popurl(1) %>cust_main-unsuspend.cgi" METHOD=POST>
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+
+ <P ALIGN="center"><B><% mt('Unsuspend this customer?') |h %></B>
+
+<TABLE BORDER="0" CELLSPACING="2"
+STYLE="margin-left:auto; margin-right:auto">
+<TR>
+  <TD ALIGN="right">
+    <INPUT TYPE="radio" NAME="now_or_later" VALUE="0" onclick="toggle(false)" CHECKED />
+  </TD>
+  <TD ALIGN="left"><% mt('Unsuspend now') |h %></TD>
+</TR>
+<TR>
+  <TD ALIGN="right">
+    <INPUT TYPE="radio" NAME="now_or_later" VALUE="1" onclick="toggle(true)" />
+  </TD>
+  <TD ALIGN="left"><% mt('Unsuspend on date: ') |h %> 
+  <& /elements/input-date-field.html, {
+              'name'    => 'resume',
+              'value'   => time,
+    }  &>
+  </TD>
+</TR>
+</TABLE>
+<SCRIPT type="text/javascript">
+function toggle(val) {
+  document.getElementById("resume_text").disabled = !val;
+  document.getElementById("resume_button").style.visibility = 
+    val ? 'visible' : 'hidden';
+}
+toggle(false);
+</SCRIPT> 
+
+<BR>
+<P ALIGN="CENTER">
+<INPUT TYPE="submit" NAME="submit" ID="confirm_unsuspend_cust_button" VALUE="<% mt('Unsuspend customer') |h %>"> 
+&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
+<INPUT TYPE="BUTTON" VALUE="<% mt("Don't unsuspend") |h %>" onClick="parent.cClick();"> 
+
+</FORM>
+</BODY>
+</HTML>
+
+<%init>
+
+#false laziness w/cancel_cust.html
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
+my $custnum = $1;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied" unless $curuser->access_right('Unsuspend customer');
+
+my $cust_main = qsearchs( {
+  'table'     => 'cust_main',
+  'hashref'   => { 'custnum' => $custnum },
+  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+} );
+die "No customer # $custnum" unless $cust_main;
+
+</%init>
+
diff --git a/httemplate/misc/xmlhttp-cust_bill-search.html b/httemplate/misc/xmlhttp-cust_bill-search.html
new file mode 100644 (file)
index 0000000..46f15d1
--- /dev/null
@@ -0,0 +1,18 @@
+<% encode_json(\@return) %>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+die 'access denied' unless $curuser->access_right('View invoices');
+my @return;
+if ( $cgi->param('sub') eq 'custnum_search_open' ) { 
+  my $custnum = $cgi->param('arg');
+  #warn "searching invoices for $custnum\n";
+  my $cust_main = FS::cust_main->by_key($custnum);
+  @return = map { 
+    +{ $_->hash, 
+      'owed' => $_->owed }
+  } $cust_main->open_cust_bill
+    if $curuser->agentnums_href->{ $cust_main->agentnum };
+}
+
+</%init>
index 436501e..16f7cd2 100644 (file)
@@ -1,9 +1,9 @@
 % if ( $sub eq 'custnum_search' ) { 
 %   my $custnum = $cgi->param('arg');
 %   my $return = [];
-%   if ( $custnum =~ /^(\d+)$/ ) {
-%      $return = findbycustnum($1,0);
-%      $return = findbycustnum($1,1) if(!scalar(@$return));
+%   if ( $custnum =~ /^(\d+)$/ ) { #should also handle
+%                                  # cust_main-agent_custid-format') eq 'ww?d+'
+%      $return = findbycustnum_or_agent_custid($1);
 %   }
 <% objToJson($return) %>
 % } elsif ( $sub eq 'smart_search' ) {
 %   my @cust_main = smart_search( 'search' => $string,
 %                                 'no_fuzzy_on_exact' => 1, #pref?
 %                               );
-%   my $return = [ map [ $_->custnum, $_->name, $_->balance, $_->ucfirst_status, $_->statuscolor ], @cust_main ];
+%   my $return = [ map [ $_->custnum,
+%                        $_->name,
+%                        $_->balance,
+%                        $_->ucfirst_status,
+%                        $_->statuscolor,
+%                        scalar($_->open_cust_bill)
+%                      ],
+%                    @cust_main
+%                ];
 %     
 <% objToJson($return) %>
 % } elsif ( $sub eq 'invnum_search' ) {
@@ -20,7 +28,7 @@
 %   my $string = $cgi->param('arg');
 %   if ( $string =~ /^(\d+)$/ ) {
 %     my $inv = qsearchs('cust_bill', { 'invnum' => $1 });
-%     my $return = $inv ? findbycustnum($inv->custnum,0) : [];
+%     my $return = $inv ? findbycustnum($inv->custnum) : [];
 <% objToJson($return) %>
 %   } else { #return nothing
 []
 % }
 <%init>
 
-my $conf = new FS::Conf;
-
 my $sub = $cgi->param('sub');
 
-sub findbycustnum{
-    my $custnum = shift;
-    my $agent = shift;
-    my $hashref = { 'custnum' => $custnum };
-    $hashref = { 'agent_custid' => $custnum } if $agent;
-    my $c = qsearchs({
-               'table'   => 'cust_main',
-               'hashref' => $hashref,
-               'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
-               });
-   return [ $c->custnum, $c->name, $c->balance, $c->ucfirst_status, $c->statuscolor ] 
-       if $c;
-   [];
+sub findbycustnum {
+
+  my $c = qsearchs({
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => shift },
+    'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+  }) or return [];
+
+  [ $c->custnum,
+    $c->name,
+    $c->balance,
+    $c->ucfirst_status,
+    $c->statuscolor,
+    scalar($c->open_cust_bill)
+  ];
+}
+
+sub findbycustnum_or_agent_custid {
+  my $num = shift;
+
+  my @or = ( 'agent_custid = ?' );
+  my @param = ( $num );
+
+  if ( $num =~ /^\d+$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
+    my $conf = new FS::Conf;
+    if ( $conf->exists('cust_main-default_agent_custid') ) {
+      push @or, "( agent_custid IS NULL AND custnum = $num )";
+    } else {
+      push @or, "custnum = $num";
+    }
+  }
+
+  my $extra_sql = ' WHERE '. $FS::CurrentUser::CurrentUser->agentnums_sql.
+                  ' AND ( '. join(' OR ', @or). ' )';
+                      
+  [ map [ $_->custnum,
+          $_->name,
+          $_->balance,
+          $_->ucfirst_status,
+          $_->statuscolor,
+          scalar($_->open_cust_bill),
+        ],
+
+      qsearch({
+        'table'       => 'cust_main',
+        'hashref'     => {},
+        'extra_sql'   => $extra_sql,
+        'extra_param' => \@param,
+      })
+  ];
 }
+
 </%init>
index 974b96d..bd6bb86 100644 (file)
@@ -49,7 +49,7 @@ unless ( $error ) { # if ($access_user) {
 
   #XXX autogen
   my @paramlist = qw( locale menu_position default_customer_view mobile_menu
-                      disable_html_editor
+                      disable_html_editor disable_enter_submit_onetimecharge
                       email_address
                       snom-ip snom-username snom-password
                       vonage-fromnumber vonage-username vonage-password
index 8fd1eaa..8e56355 100644 (file)
@@ -32,7 +32,7 @@ Interface
 
   <TR>
     <TH ALIGN="right">Locale: </TH>
-    <TD>
+    <TD COLSPAN=2>
       <SELECT NAME="locale">
 %       foreach my $locale ( FS::Locales->locales ) {
 %         my %info = FS::Locales->locale_info($locale);
@@ -83,6 +83,13 @@ Interface
     </TD>
   </TR>
 
+  <TR>
+    <TH ALIGN="right" COLSPAN=1>Disable submission on [Enter] key - one-time charges: </TH>
+    <TD ALIGN="left" COLSPAN=2>
+      <INPUT TYPE="checkbox" NAME="disable_enter_submit_onetimecharge" VALUE="1" <% $curuser->option('disable_enter_submit_onetimecharge') ? 'CHECKED' : '' %>>
+    </TD>
+  </TR>
+
 </TABLE>
 <BR>
 
index 94860d3..f9dd4a2 100644 (file)
@@ -9,7 +9,9 @@
                  'header'      => [
                    emt('Description'),
                    ( $unearned
-                     ? ( emt('Unearned'), emt('Owed'), emt('Payment date') )
+                     ? ( emt('Unearned'), 
+                         emt('Owed'), # useful in 'paid' mode?
+                         emt('Payment date') )
                      : ( emt('Setup charge') )
                    ),
                    ( $use_usage eq 'usage'
                    # they're not applicable to pkg_tax search
                    sub { my $cust_bill_pkg = shift;
                          if ( $unearned ) {
-                           my $period =
-                             $cust_bill_pkg->edate - $cust_bill_pkg->sdate;
-                           my $elapsed = $unearned - $cust_bill_pkg->sdate;
-                           $elapsed = 0 if $elapsed < 0;
 
-                           my $remaining = 1 - $elapsed/$period;
-
-                           sprintf($money_char. '%.2f',
-                             $remaining * $cust_bill_pkg->recur );
+                           sprintf($money_char.'%.2f', 
+                             $cust_bill_pkg->unearned_revenue)
 
                          } else {
                            sprintf($money_char.'%.2f', $cust_bill_pkg->setup );
@@ -53,7 +49,7 @@
                    ),
                    sub { my $row = shift;
                          my $value = 0;
-                         if ( $use_usage eq 'recurring' ) {
+                         if ( $use_usage eq 'recurring' or $unearned ) {
                            $value = $row->recur - $row->usage;
                          } elsif ( $use_usage eq 'usage' ) {
                            $value = $row->usage;
                        },
                    ( $unearned
                      ? ( sub { time2str('%b %d %Y', shift->sdate ) },
-                         sub { time2str('%b %d %Y', shift->edate ) },
+                       # shift edate back a day
+                       # 82799 = 3600*23 - 1
+                       # (to avoid skipping a day during DST)
+                         sub { time2str('%b %d %Y', shift->edate - 82799 ) },
                        )
                      : ()
                    ),
                    '',
                    'setup', #broken in $unearned case i guess
                    ( $unearned ? ('', '') : () ),
-                   ( $use_usage eq 'recurring' ? 'recur - usage' :
-                     $use_usage eq 'usage'     ? 'usage'
-                                               : 'recur'
+                   ( $use_usage eq 'recurring' or $unearned
+                        ? 'recur - usage' :
+                     $use_usage eq 'usage' 
+                        ? 'usage'
+                        : 'recur'
                    ),
                    ( $unearned ? ('sdate', 'edate') : () ),
                    'invnum',
@@ -137,6 +138,12 @@ die "access denied"
 my $conf = new FS::Conf;
 
 my $unearned = '';
+my $unearned_mode = '';
+my $unearned_base = '';
+my $unearned_sql = '';
+
+my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
+my ($join_cust, $join_pkg ) = ('', '');
 
 #here is the agent virtualization
 my $agentnums_sql =
@@ -146,14 +153,18 @@ my @where = ( $agentnums_sql );
 
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 
+if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
+  push @where, FS::cust_main->cust_status_sql . " = '$1'";
+}
+
 if ( $cgi->param('distribute') == 1 ) {
   push @where, "sdate <= $ending",
                "edate >  $beginning",
   ;
 }
 else {
-  push @where, "_date >= $beginning",
-               "_date <= $ending";
+  push @where, "cust_bill._date >= $beginning",
+               "cust_bill._date <= $ending";
 }
 
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
@@ -218,7 +229,7 @@ if ( $cgi->param('taxclass')
 
 }
 
-my @loc_param = qw( city county state country );
+my @loc_param = qw( district city county state country );
 
 if ( $cgi->param('out') ) {
 
@@ -266,7 +277,7 @@ if ( $cgi->param('out') ) {
 
           my %ph = ( 'county' => dbh->quote($_),
                      map { $_ => dbh->quote( $cgi->param($_) ) }
-                       qw( city state country )
+                       qw( district city state country )
                    );
 
           my ( $loc_sql, @param ) = FS::cust_pkg->location_sql;
@@ -330,21 +341,71 @@ if ( $cgi->param('out') ) {
 
   push @where, FS::tax_rate_location->location_sql(
                  map { $_ => (scalar($cgi->param($_)) || '') }
-                   qw( city county state locationtaxid )
+                   qw( district city county state locationtaxid )
                );
 
-} elsif ( $cgi->param('unearned_now') =~ /^(\d+)$/ ) {
+}
+
+# unearned revenue mode
+if ( $cgi->param('unearned_now') =~ /^(\d+)$/ ) {
 
   $unearned = $1;
+  $unearned_mode = $cgi->param('mode');
 
   push @where, "cust_bill_pkg.sdate < $unearned",
                "cust_bill_pkg.edate > $unearned",
                "cust_bill_pkg.recur != 0",
-               "part_pkg.freq != '0'",
+               "part_pkg.freq != '0'";
+
+  if ( !$cgi->param('include_monthly') ) {
+    push @where,
                "part_pkg.freq != '1'",
                "part_pkg.freq NOT LIKE '%h'",
                "part_pkg.freq NOT LIKE '%d'",
                "part_pkg.freq NOT LIKE '%w'";
+  }
+
+  my $usage_sql = FS::cust_bill_pkg->usage_sql;
+  push @select, "($usage_sql) AS usage"; # we need this
+  my $paid_sql = 'GREATEST(' .
+    FS::cust_bill_pkg->paid_sql($unearned, '', setuprecur => 'recur') .
+    " - $usage_sql, 0)";
+
+  push @select, "$paid_sql AS paid_no_usage"; # need this either way
+
+  if ( $unearned_mode eq 'paid' ) {
+    # then use the amount paid, minus usage charges
+    $unearned_base = $paid_sql;
+  }
+  else {
+    # use the amount billed, minus usage charges and credits
+    $unearned_base = "GREATEST( cust_bill_pkg.recur - ".
+      FS::cust_bill_pkg->credited_sql($unearned, '', setuprecur => 'recur') .
+      " - $usage_sql, 0)";
+      # include only rows that have some non-usage, non-credited portion
+  }
+  # whatever we're using as the base, only show rows where it's positive
+  push @where, "$unearned_base > 0";
+
+  my $period = "CAST(cust_bill_pkg.edate - cust_bill_pkg.sdate AS REAL)";
+  my $elapsed = "GREATEST( $unearned - cust_bill_pkg.sdate, 0 )";
+  my $remaining = "(1 - $elapsed/$period)";
+
+  $unearned_sql = "CAST( $unearned_base * $remaining AS DECIMAL(10,2) )";
+  push @select, "$unearned_sql AS unearned_revenue";
+
+  # last payment/credit date
+  my %t = (pay => 'cust_bill_pay', credit => 'cust_credit_bill');
+  foreach my $x (qw(pay credit)) {
+    my $table = $t{$x};
+    my $link = $table.'_pkg';
+    my $pkey = dbdef->table($table)->primary_key;
+    my $last_date_sql = "SELECT MAX(_date) 
+    FROM $table JOIN $link USING ($pkey)
+    WHERE $link.billpkgnum = cust_bill_pkg.billpkgnum 
+    AND $table._date <= $unearned";
+    push @select, "($last_date_sql) AS last_$x";
+  }
 
 }
 
@@ -463,12 +524,12 @@ if ( $cgi->param('pkg_tax') ) {
     $count_query = "SELECT COUNT(DISTINCT billpkgnum), ";
   }
 
-  if ( $use_usage eq 'recurring' ) {
-    $count_query .= "SUM(setup + recur - usage)";
+  if ( $unearned ) {
+    $count_query .= "SUM( $unearned_base ), SUM( $unearned_sql )";
+  } elsif ( $use_usage eq 'recurring' ) {
+    $count_query .= "SUM(cust_bill_pkg.setup + cust_bill_pkg.recur - usage)";
   } elsif ( $use_usage eq 'usage' ) {
     $count_query .= "SUM(usage)";
-  } elsif ( $unearned ) {
-    $count_query .= "SUM(cust_bill_pkg.recur)";
   } elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
     $count_query .= "SUM( COALESCE(cust_bill_pkg_tax_rate_location.amount, cust_bill_pkg.setup + cust_bill_pkg.recur))";
   } elsif ( $cgi->param('iscredit') eq 'rate') {
@@ -477,38 +538,17 @@ if ( $cgi->param('pkg_tax') ) {
     $count_query .= "SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)";
   }
 
-  if ( $unearned ) {
-
-    #false laziness w/report_prepaid_income.cgi
-
-    my $float = 'REAL'; #'DOUBLE PRECISION';
-
-    my $period = "CAST(cust_bill_pkg.edate - cust_bill_pkg.sdate AS $float)";
-    my $elapsed = "(CASE WHEN cust_bill_pkg.sdate > $unearned
-                     THEN 0
-                     ELSE ($unearned - cust_bill_pkg.sdate)
-                   END)";
-    #my $elapsed = "CAST($unearned - cust_bill_pkg.sdate AS $float)";
-
-    my $remaining = "(1 - $elapsed/$period)";
-
-    $count_query .= ", SUM($remaining * cust_bill_pkg.recur)";
-
-  }
-
 }
 
-my $join_cust =  '      JOIN cust_bill USING ( invnum ) 
-                   LEFT JOIN cust_main USING ( custnum ) ';
-
+$join_cust =  '        JOIN cust_bill USING ( invnum )
+                  LEFT JOIN cust_main USING ( custnum ) ';
 
-my $join_pkg;
 if ( $cgi->param('nottax') ) {
 
-  $join_pkg =  ' LEFT JOIN cust_pkg USING ( pkgnum )
-                 LEFT JOIN part_pkg USING ( pkgpart )
-                 LEFT JOIN part_pkg AS override
-                   ON pkgpart_override = override.pkgpart ';
+  $join_pkg .=  ' LEFT JOIN cust_pkg USING ( pkgnum )
+                  LEFT JOIN part_pkg USING ( pkgpart )
+                  LEFT JOIN part_pkg AS override
+                    ON pkgpart_override = override.pkgpart ';
   $join_pkg .= ' LEFT JOIN cust_location USING ( locationnum ) '
     if $conf->exists('tax-pkg_address');
 
@@ -567,9 +607,6 @@ if ($use_usage) {
   $count_query .= " FROM cust_bill_pkg $join_cust $join_pkg $where";
 }
 
-my @select = ( 'cust_bill_pkg.*',
-               'cust_bill._date', );
-
 push @select, 'part_pkg.pkg',
               'part_pkg.freq',
   unless $cgi->param('istax');
@@ -581,9 +618,9 @@ my $query = {
   'table'     => 'cust_bill_pkg',
   'addl_from' => "$join_cust $join_pkg",
   'hashref'   => {},
-  'select'    => join(', ', @select ),
+  'select'    => join(",\n", @select ),
   'extra_sql' => $where,
-  'order_by'  => 'ORDER BY _date, billpkgnum',
+  'order_by'  => 'ORDER BY cust_bill._date, billpkgnum',
 };
 
 my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
@@ -593,9 +630,8 @@ my $conf = new FS::Conf;
 my $money_char = $conf->config('money_char') || '$';
 
 my $owed_sub = sub {
-  $money_char. shift->owed_recur; #_recur :/
+  $money_char . shift->get('owed') # owed_recur is not correct here
 };
-
 my $payment_date_sub = sub {
   #my $cust_bill_pkg = shift;
   my @cust_pay = sort { $a->_date <=> $b->_date }
index 498024b..e164b98 100755 (executable)
@@ -33,9 +33,7 @@
 <%init>
 
 die "access denied"
-  unless ( $FS::CurrentUser::CurrentUser->access_right('List customers') &&
-           $FS::CurrentUser::CurrentUser->access_right('List packages')
-         );
+  unless $FS::CurrentUser::CurrentUser->access_right('Advanced customer search');
 
 my %search_hash = ();
 
@@ -47,7 +45,6 @@ my @scalars = qw (
   no_censustract with_geocode custbatch usernum
   cancelled_pkgs
   cust_fields flattened_pkgs
-  refnum
 );
 
 for my $param ( @scalars ) {
@@ -56,7 +53,7 @@ for my $param ( @scalars ) {
 }
 
 #lists
-for my $param (qw( classnum payby tagnum )) {
+for my $param (qw( classnum refnum payby tagnum )) {
   $search_hash{$param} = [ $cgi->param($param) ];
 }
 
@@ -64,14 +61,22 @@ for my $param (qw( classnum payby tagnum )) {
 # parse dates
 ###
 
-foreach my $field (qw( signupdate )) {
+foreach my $field (qw( signupdate birthdate spouse_birthdate )) {
 
   my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi, $field);
 
-  next if $beginning == 0 && $ending == 4294967295 && !defined($cgi->param('signuphour'));
+  next if $beginning == 0 && $ending == 4294967295 && ( $field ne 'signupdate' || !defined($cgi->param('signuphour')) );
        #or $disable{$cgi->param('status')}->{$field};
 
-  $search_hash{$field} = [ $beginning, $ending, $cgi->param('signuphour') ];
+  unless ( $field eq 'signupdate' ) {
+    $beginning -= 43200;
+    $ending    -= 43200;
+  }
+
+  my @ary = ( $beginning, $ending );
+  push @ary, scalar($cgi->param('signuphour')) if $field eq 'signupdate';
+
+  $search_hash{$field} = \@ary;
 
 }
 
index f9adf04..8c05f73 100644 (file)
@@ -23,6 +23,6 @@
 <%init>
 
 die "access denied"
-  unless $curuser->access_right('Summarize packages');
+  unless $FS::CurrentUser::CurrentUser->access_right('Summarize packages');
 
 </%init>
index 9cf4bbd..2adcbd7 100644 (file)
                                 sub { 
                                   #$_[0]->svc. ': '. $_[0]->label;
                                   my($label, $value, $svcdb) = $_[0]->label;
-                                  "$label: $value";
+                                   my $id = $_[0]->agent_svcid
+                                              ? $_[0]->agent_svcid.': '
+                                              : '';
+                                  "$label: $id$value";
                                 },
                                 # package?
                                 \&FS::UI::Web::cust_fields,
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('List services');
 
-my $addl_from = ' LEFT JOIN part_svc  USING ( svcpart ) '.
-                ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
-                ' LEFT JOIN cust_main USING ( custnum ) ';
+my $sql_query;
 
-my @extra_sql = ();
 my $orderby = 'ORDER BY svcnum'; #has to be ordered by something
                                  #for pagination to work
+
 if ( length( $cgi->param('search_svc') ) ) {
 
-  my $string = $cgi->param('search_svc');
-  $string =~ s/(^\s+|\s+$)//; #trim leading & trailing whitespace
-
-  # implement fuzzy searching in subclasses too at some point?
-  # service searching maybe shouldn't be fuzzy...
-
-  push @extra_sql,
-    ' ( '. join(' OR ',
-      map { my $table = $_;
-            my $search_sql = "FS::$table"->search_sql($string);
-            " ( svcdb = '$table'
-               AND 0 < ( SELECT COUNT(*) FROM $table
-                           WHERE $table.svcnum = cust_svc.svcnum
-                             AND $search_sql
-                       )
-             ) ";
-          }
-      FS::part_svc->svc_tables
-    ). ' ) ';
-
-} elsif ( $cgi->param('magic') =~ /^(all|unlinked)$/ ) {
-
-  $cgi->param('svcdb') =~ /^(svc_\w+)$/ or die "unknown svcdb";
-  push @extra_sql, "svcdb = '$1'";
-  $addl_from .= " LEFT JOIN $1 USING ( svcnum ) ";
-
-  push @extra_sql, 'pkgnum IS NULL'
-    if $cgi->param('magic') eq 'unlinked';
-
-  if ( $cgi->param('sortby') =~ /^(\w+)$/ ) {
-    my $sortby = $1;
-    $orderby = "ORDER BY $sortby";
+  $sql_query = {
+    FS::cust_svc->smart_search_param(
+     'search' => scalar($cgi->param('search_svc'))
+    )
+  };
+
+} else {
+
+  my $addl_from = ' LEFT JOIN part_svc  USING ( svcpart ) '.
+                  ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
+                  ' LEFT JOIN cust_main USING ( custnum ) ';
+
+  my @extra_sql = ();
+
+  if ( $cgi->param('magic') =~ /^(all|unlinked)$/ ) {
+
+    $cgi->param('svcdb') =~ /^(svc_\w+)$/ or die "unknown svcdb";
+    push @extra_sql, "svcdb = '$1'";
+    $addl_from .= " LEFT JOIN $1 USING ( svcnum ) ";
+
+    push @extra_sql, 'pkgnum IS NULL'
+      if $cgi->param('magic') eq 'unlinked';
+
+    if ( $cgi->param('sortby') =~ /^(\w+)$/ ) {
+      my $sortby = $1;
+      $orderby = "ORDER BY $sortby";
+    }
+
+  } elsif ( $cgi->param('svcpart') =~ /^(\d+)$/ ) {
+
+    push @extra_sql, "svcpart = $1";
+
+  } else {
+    errorpage("No search term specified");
   }
 
-} elsif ( $cgi->param('svcpart') =~ /^(\d+)$/ ) {
+  #here is the agent virtualization
+  push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql( 
+                     'null_right' => 'View/link unlinked services'
+                   );
 
-  push @extra_sql, "svcpart = $1";
+  my $extra_sql = ' WHERE '. join(' AND ', @extra_sql );
+
+  $sql_query = {
+    'table'      => 'cust_svc',
+    'addl_from'  => $addl_from,
+    'hashref'    => {},
+    'extra_sql'  => $extra_sql,
+  };
 
-} else {
-  errorpage("No search term specified");
 }
 
-#here is the agent virtualization
-push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql( 
-                   'null_right' => 'View/link unlinked services'
-                 );
-
-my $extra_sql = ' WHERE '. join(' AND ', @extra_sql );
-
-my $sql_query = {
-  'select'     => join(', ',
-                    'cust_svc.*',
-                   'part_svc.*',
-                   'cust_main.custnum',
-                   FS::UI::Web::cust_sql_fields(),
-                  ),
-  'table'      => 'cust_svc',
-  'addl_from'  => $addl_from,
-  'hashref'    => {},
-  'extra_sql'  => $extra_sql,
-  'order_by'   => $orderby,
-};
+$sql_query->{'select'} = join(', ',
+                                    'cust_svc.*',
+                                    'part_svc.*',
+                                    'cust_main.custnum',
+                                    FS::UI::Web::cust_sql_fields(),
+                             );
+$sql_query->{'order_by'} = $orderby;
 
-my $count_query = "SELECT COUNT(*) FROM cust_svc $addl_from $extra_sql";
+my $count_query = "SELECT COUNT(*) FROM cust_svc ". $sql_query->{addl_from}.
+                                               ' '. $sql_query->{extra_sql};
 
 my $link = sub {
   my $cust_svc = shift;
index cc16014..eb75664 100644 (file)
@@ -108,13 +108,14 @@ my $ranges = $opt{'ranges'} ? delete($opt{'ranges'}) : [
 
 my $range_sub = delete($opt{'range_sub'}); #or die
 
-my $offset = 0;
+my $as_of;
 if($cgi->param('as_of')) {
-  $offset = int((time - parse_datetime($cgi->param('as_of'))) / 86400);
-  $opt{'title'} .= ' ('.$cgi->param('as_of').')' if $offset > 0;
+  $as_of = parse_datetime($cgi->param('as_of')) || '';
+  $opt{'title'} .= ' ('.$cgi->param('as_of').')' if $as_of;
 }
 
-my $range_cols = join(',', map call_range_sub($range_sub, @$_, 'offset' => $offset ), @$ranges );
+my $range_cols = join(',', 
+  map call_range_sub($range_sub, @$_, 'as_of' => $as_of ), @$ranges );
 
 my $select_count_pkgs = FS::cust_main->select_count_pkgs_sql;
 
@@ -144,7 +145,7 @@ unless ( $cgi->param('all_customers') ) {
   my $negative = $cgi->param('negative') || 0;
 
   push @where,
-    call_range_sub($range_sub, $days, 0, 'offset' => $offset, 'no_as'=>1). 
+    call_range_sub($range_sub, $days, 0, 'as_of' => $as_of, 'no_as'=>1). 
     ($negative ? ' != 0' : ' > 0');
 }
 
@@ -186,7 +187,9 @@ my $sql_query = {
 
 my $total_sql =
   "SELECT ".
-      join(',', map call_range_sub( $range_sub, @$_, 'offset' => $offset, 'sum'=>1 ), @$ranges).
+      join(',', 
+        map call_range_sub( $range_sub, @$_, 'as_of' => $as_of, 'sum'=>1 ), 
+        @$ranges).
     " FROM cust_main $where";
 
 my $total_sth = dbh->prepare($total_sql) or die dbh->errstr;
@@ -251,10 +254,11 @@ sub call_range_sub {
 
   my $as = $opt{'no_as'} ? '' : " AS rangecol_${startdays}_$enddays";
 
-  my $offset = $opt{'offset'} || 0;
-  # Always use $offset - 1day + 1sec = the last second of that day
-  my $cutoff = DateTime->now->set(hour => 23, minute => 59, second => 59);
-  $cutoff->subtract(days => $offset);
+  my $as_of = $opt{'as_of'} || time;
+  my $cutoff = DateTime->from_epoch(epoch => $as_of, time_zone => 'local');
+  $cutoff->truncate(to => 'day'); # local midnight on the report day
+  $cutoff->add(days => 1); # the day after that
+  $cutoff->subtract(seconds => 1); # the last second of the report day
 
   my $start = $cutoff->clone;
   $start->subtract(days => $startdays);
@@ -262,7 +266,7 @@ sub call_range_sub {
   my $end = $cutoff->clone;
   $end->subtract(days => $enddays);
 
-  #warn "offset $offset (".$cutoff->epoch."), range $startdays-$enddays (".$start->epoch . '-' . ($enddays ? $end->epoch : '').")\n";
+  #warn "cutoff ".$cutoff->epoch.", range $startdays-$enddays (".$start->epoch . '-' . ($enddays ? $end->epoch : '').")\n";
   my $sql = &{$range_sub}( $start->epoch, 
                            $enddays ? $end->epoch : '', 
                            $cutoff->epoch ); #%opt?
diff --git a/httemplate/search/prepaid_income.html b/httemplate/search/prepaid_income.html
new file mode 100644 (file)
index 0000000..ebac5a2
--- /dev/null
@@ -0,0 +1,240 @@
+<% include("/elements/header.html", 'Prepaid Income (Unearned Revenue) Report') %>
+
+<% include( '/elements/table-grid.html' ) %>
+
+  <TR>
+%   if ( scalar(@agentnums) > 1 ) {
+      <TH CLASS="grid" BGCOLOR="#cccccc">Agent</TH>
+%   }
+    <TH CLASS="grid" BGCOLOR="#cccccc"><% $actual_label %>Unearned Revenue</TH>
+%   if ( $legacy ) {
+      <TH CLASS="grid" BGCOLOR="#cccccc">Legacy Unearned Revenue</TH>
+%   }
+  </TR>
+
+% my $bgcolor1 = '#eeeeee';
+% my $bgcolor2 = '#ffffff';
+% my $bgcolor;
+%
+% push @agentnums, 0 unless scalar(@agentnums) < 2;
+% foreach my $agentnum (@agentnums) {  
+%
+%   if ( $bgcolor eq $bgcolor1 ) {
+%     $bgcolor = $bgcolor2;
+%   } else {
+%     $bgcolor = $bgcolor1;
+%   }
+%
+%   my $alink = $agentnum ? "$link;agentnum=$agentnum" : $link;
+%
+%   my $agent_name = 'Total';
+%   if ( $agentnum ) {
+%     my $agent = qsearchs('agent', { 'agentnum' => $agentnum })
+%       or die "unknown agentnum $agentnum";
+%     $agent_name = $agent->agent;
+%   }
+
+    <TR>
+
+%     if ( scalar(@agentnums) > 1 ) {
+        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $agent_name |h %></TD>
+%     }
+
+      <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>"><A HREF="<% $alink %>"><% $money_char %><% $total{$agentnum} %></A></TD>
+
+%     if ( $legacy ) {
+        <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
+          <% $now == $time ? $money_char.$total_legacy{$agentnum} : '<i>N/A</i>'%>
+        </TD>
+%     }
+
+    </TR>
+
+%  }
+
+</TABLE>
+
+<BR>
+<% $actual_label %><% $actual_label ? 'u' : 'U' %>nearned revenue
+is the as-yet-unearned portion of revenue
+<% $actual_label ? 'Freeside has actually' : '' %>
+invoiced for packages with 
+<% $cgi->param('include_monthly') ? 'terms extending into the future.'
+                                  : 'longer-than monthly terms.' %>
+
+% if ( $legacy ) {
+  <BR><BR>
+  Legacy unearned revenue is the amount of unearned revenue represented by 
+  customer packages.  This number may be larger than actual unearned 
+  revenue if you have imported longer-than monthly customer packages from
+  a previous billing system.
+% }
+
+<% include('/elements/footer.html') %>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $legacy = $conf->exists('enable_legacy_prepaid_income');
+my $actual_label = $legacy ? 'Actual ' : '';
+
+#doesn't yet deal with daily/weekly packages
+
+my $mode = $cgi->param('mode');
+
+my $time = time;
+
+my $now = $cgi->param('date') && parse_datetime($cgi->param('date')) || $time;
+$now =~ /^(\d+)$/ or die "unparsable date?";
+$now = $1;
+
+my $dt = DateTime->from_epoch(epoch => $now, time_zone => 'local');
+$dt->truncate(to => 'day'); # local midnight on the report day
+$dt->add(days => 1); # the day after that
+$dt->subtract(seconds => 1); # the last second of the report day
+$now = $dt->epoch;
+
+my $link = "unearned_detail.html?date=$now;mode=$mode";
+
+if ( $cgi->param('include_monthly') ) {
+  $link .= ';include_monthly=1';
+}
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+my $agentnum = '';
+my @agentnums = ();
+$agentnum ? ($agentnum) : $curuser->agentnums;
+if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
+  @agentnums = ($1);
+  #XXX#push @where, "agentnum = $agentnum";
+  #XXX#$link .= ";agentnum=$agentnum";
+} else {
+  @agentnums = $curuser->agentnums;
+}
+
+my @where = ();
+
+#here is the agent virtualization
+push @where, $curuser->agentnums_sql( 'table'=>'cust_main' );
+
+my $status = '';
+if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
+  $status = $1;
+  $link .= ";status=$status";
+  push @where, FS::cust_main->cust_status_sql . " = '$status'";
+}
+
+my %total = ();
+my %total_legacy = ();
+foreach my $agentnum (@agentnums) {
+  
+  my $where = join(' AND ', @where, "cust_main.agentnum = $agentnum");
+  $where = "AND $where" if $where;
+
+  my( $total, $total_legacy ) = ( 0, 0 );
+
+  my @opt = ($now, '', setuprecur => 'recur', no_usage => 1);
+  # balance owed, recurring only, not including usage charges
+  my $unearned_base;
+  if ( $mode eq 'billed' ) {
+    $unearned_base = '( ' . 
+                     FS::cust_bill_pkg->charged_sql(@opt) . ' - ' .
+                     FS::cust_bill_pkg->credited_sql(@opt) . ' )';
+  } elsif ( $mode eq 'paid' ) {
+    $unearned_base = FS::cust_bill_pkg->paid_sql(@opt);
+  }
+  
+  my $edate_zero = midnight_sql('edate');
+  my $sdate_zero = midnight_sql('sdate');
+  my $period = "CAST( ($edate_zero - $sdate_zero) / 86400.0 AS DECIMAL(10,0) )";
+  my $remaining = "GREATEST(
+    CAST( ($edate_zero - $now) / 86400.0 AS DECIMAL(10,0) ),
+    0)";
+  my $fraction = "$remaining / $period";
+  
+  my $unearned_sql = "CAST(
+  GREATEST( $unearned_base * $fraction, 0 )
+    AS DECIMAL(10,2)
+  )";
+
+  my $select = "SUM( $unearned_sql )";
+
+  if ( !$cgi->param('include_monthly') ) {
+    # all except freq != 0; one-time charges should never be included
+    $where .= "
+                 AND part_pkg.freq != '1'
+                 AND part_pkg.freq NOT LIKE '%h'
+                 AND part_pkg.freq NOT LIKE '%d'
+                 AND part_pkg.freq NOT LIKE '%w'";
+  }
+
+  # $mode actually doesn't matter here, since unpaid invoices have zero
+  # unearned revenue
+
+  my $sql = 
+  "SELECT $select FROM cust_bill_pkg
+                  LEFT JOIN cust_pkg  ON (cust_bill_pkg.pkgnum = cust_pkg.pkgnum)
+                  LEFT JOIN part_pkg  USING ( pkgpart )
+                  LEFT JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)
+               WHERE pkgpart > 0
+                 AND cust_bill_pkg.sdate < $now
+                 AND cust_bill_pkg.edate > $now
+                 AND cust_bill_pkg.recur != 0
+                 AND part_pkg.freq != '0'
+                 $where
+             ";
+
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+  my $total = $sth->fetchrow_arrayref->[0];
+
+  $total = sprintf('%.2f', $total);
+  $total{$agentnum} = $total;
+  $total{0} += $total;
+
+  if ( $legacy ) {
+
+    #not yet rewritten in sql, but now not enabled by default
+
+    my @cust_pkg = 
+      grep { $_->part_pkg->recur != 0
+             && $_->part_pkg->freq !~ /^([01]|\d+[dw])$/
+           }
+        qsearch({
+          'select'    => 'cust_pkg.*',
+          'table'     => 'cust_pkg',
+          'addl_from' => ' LEFT JOIN cust_main USING ( custnum ) ',
+          'hashref'   => { 'bill' => { op=>'>', value=>$now } },
+          'extra_sql' => $where,
+        });
+
+    foreach my $cust_pkg ( @cust_pkg ) {
+      my $period = $cust_pkg->bill - $cust_pkg->last_bill;
+   
+      my $elapsed = $now - $cust_pkg->last_bill;
+      $elapsed = 0 if $elapsed < 0;
+   
+      my $remaining = 1 - $elapsed/$period;
+   
+      my $unearned = $remaining * $cust_pkg->part_pkg->recur; #!! only works for flat/legacy
+      $total_legacy += $unearned;
+   
+    }
+
+    $total_legacy = sprintf('%.2f', $total_legacy);
+    $total_legacy{$agentnum} = $total_legacy;
+    $total_legacy{0} += $total_legacy;
+
+  }
+
+}
+
+$total{0} = sprintf('%.2f', $total{0});
+$total_legacy{0} = sprintf('%.2f', $total_legacy{0});
+  
+</%init>
index 0ef5a51..39cf695 100755 (executable)
                   'all_selected' => 1,
     &>
 
+    <& /elements/tr-select-part_referral.html,
+                  'label'        => emt('Advertising Source'),
+                  'multiple'     => 1,
+                  'all_selected' => 1,
+    &>
+
     <TR>
       <TD ALIGN="right" VALIGN="center"><% mt('Address') |h %></TD>
       <TD><INPUT TYPE="text" NAME="address" SIZE=54></TD>
         </TD>
     </TR>
 
+%    if ( $conf->exists('cust_main-enable_birthdate') ) {
+      <TR>
+          <TD ALIGN="right" VALIGN="center"><% mt('Date of Birth') |h %></TD>
+          <TD>
+          <TABLE>
+              <& /elements/tr-input-beginning_ending.html,
+                        prefix   => 'birthdate',
+                        layout   => 'horiz',
+              &>
+          </TABLE>
+          </TD>
+      </TR>
+%   }
+
+%    if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
+      <TR>
+          <TD ALIGN="right" VALIGN="center"><% mt('Spouse Date of Birth') |h %></TD>
+          <TD>
+          <TABLE>
+              <& /elements/tr-input-beginning_ending.html,
+                        prefix   => 'spouse_birthdate',
+                        layout   => 'horiz',
+              &>
+          </TABLE>
+          </TD>
+      </TR>
+%   }
+
     <& /elements/tr-select-cust_tag.html,
                   'cgi'                 => $cgi,
                   'is_report'    => 1,
     &>
 
     <TR>
-      <TD ALIGN="right" VALIGN="center"><% mt('Include cancelled packages') |h %></TD>
-        <TD><INPUT TYPE="checkbox" NAME="cancelled_pkgs"></TD>
-    </TR>
-
-    <TR>
       <TD ALIGN="right" VALIGN="center"><% mt('Without census tract') |h %></TD>
         <TD><INPUT TYPE="checkbox" NAME="no_censustract"></TD>
     </TR>
       <TD ALIGN="right" VALIGN="center"><% mt('Add package columns') |h %></TD>
         <TD><INPUT TYPE="checkbox" NAME="flattened_pkgs"></TD>
     </TR>
+
+    <TR>
+      <TD ALIGN="right" VALIGN="center"><% mt('Include cancelled packages') |h %></TD>
+        <TD><INPUT TYPE="checkbox" NAME="cancelled_pkgs"></TD>
+    </TR>
+
   </TABLE>
 
 <BR>
 <%init>
 
 die "access denied"
-  unless ( $FS::CurrentUser::CurrentUser->access_right('List customers') &&
-           $FS::CurrentUser::CurrentUser->access_right('List packages')
-         );
+  unless $FS::CurrentUser::CurrentUser->access_right('Advanced customer search');
 
 my $conf = new FS::Conf;
 
diff --git a/httemplate/search/report_prepaid_income.cgi b/httemplate/search/report_prepaid_income.cgi
deleted file mode 100644 (file)
index 2fe5b6f..0000000
+++ /dev/null
@@ -1,231 +0,0 @@
-<% include("/elements/header.html", 'Prepaid Income (Unearned Revenue) Report') %>
-
-<% include( '/elements/table-grid.html' ) %>
-
-  <TR>
-%   if ( scalar(@agentnums) > 1 ) {
-      <TH CLASS="grid" BGCOLOR="#cccccc">Agent</TH>
-%   }
-    <TH CLASS="grid" BGCOLOR="#cccccc"><% $actual_label %>Unearned Revenue</TH>
-%   if ( $legacy ) {
-      <TH CLASS="grid" BGCOLOR="#cccccc">Legacy Unearned Revenue</TH>
-%   }
-  </TR>
-
-% my $bgcolor1 = '#eeeeee';
-% my $bgcolor2 = '#ffffff';
-% my $bgcolor;
-%
-% push @agentnums, 0 unless scalar(@agentnums) < 2;
-% foreach my $agentnum (@agentnums) {  
-%
-%   if ( $bgcolor eq $bgcolor1 ) {
-%     $bgcolor = $bgcolor2;
-%   } else {
-%     $bgcolor = $bgcolor1;
-%   }
-%
-%   my $alink = $agentnum ? "$link;agentnum=$agentnum" : $link;
-%
-%   my $agent_name = 'Total';
-%   if ( $agentnum ) {
-%     my $agent = qsearchs('agent', { 'agentnum' => $agentnum })
-%       or die "unknown agentnum $agentnum";
-%     $agent_name = $agent->agent;
-%   }
-
-    <TR>
-
-%     if ( scalar(@agentnums) > 1 ) {
-        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $agent_name |h %></TD>
-%     }
-
-      <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>"><A HREF="<% $alink %>"><% $money_char %><% $total{$agentnum} %></A></TD>
-
-%     if ( $legacy ) {
-        <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
-          <% $now == $time ? $money_char.$total_legacy{$agentnum} : '<i>N/A</i>'%>
-        </TD>
-%     }
-
-    </TR>
-
-%  }
-
-</TABLE>
-
-<BR>
-<% $actual_label %><% $actual_label ? 'u' : 'U' %>nearned revenue
-is the amount of unearned revenue
-<% $actual_label ? 'Freeside has actually' : '' %>
-invoiced for packages with longer-than monthly terms.
-
-% if ( $legacy ) {
-  <BR><BR>
-  Legacy unearned revenue is the amount of unearned revenue represented by 
-  customer packages.  This number may be larger than actual unearned 
-  revenue if you have imported longer-than monthly customer packages from
-  a previous billing system.
-% }
-
-<% include('/elements/footer.html') %>
-<%init>
-
-die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
-
-my $conf = new FS::Conf;
-my $money_char = $conf->config('money_char') || '$';
-
-my $legacy = $conf->exists('enable_legacy_prepaid_income');
-my $actual_label = $legacy ? 'Actual ' : '';
-
-#doesn't yet deal with daily/weekly packages
-
-my $time = time;
-
-my $now = $cgi->param('date') && parse_datetime($cgi->param('date')) || $time;
-$now =~ /^(\d+)$/ or die "unparsable date?";
-$now = $1;
-
-my $link = "cust_bill_pkg.cgi?nottax=1;unearned_now=$now";
-
-my $curuser = $FS::CurrentUser::CurrentUser;
-
-my $agentnum = '';
-my @agentnums = ();
-$agentnum ? ($agentnum) : $curuser->agentnums;
-if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
-  @agentnums = ($1);
-  #XXX#push @where, "agentnum = $agentnum";
-  #XXX#$link .= ";agentnum=$agentnum";
-} else {
-  @agentnums = $curuser->agentnums;
-}
-
-my @where = ();
-
-#here is the agent virtualization
-push @where, $curuser->agentnums_sql( 'table'=>'cust_main' );
-
-my %total = ();
-my %total_legacy = ();
-foreach my $agentnum (@agentnums) {
-  
-  my $where = join(' AND ', @where, "cust_main.agentnum = $agentnum");
-  $where = "AND $where" if $where;
-
-  my( $total, $total_legacy ) = ( 0, 0 );
-
-  # my @cust_bill_pkg =
-  #   grep { $_->cust_pkg && $_->cust_pkg->part_pkg->freq !~ /^([01]|\d+[hdw])$/ }
-  #     qsearch({
-  #       'select'    => 'cust_bill_pkg.*',
-  #       'table'     => 'cust_bill_pkg',
-  #       'addl_from' => ' LEFT JOIN cust_bill USING ( invnum  ) '.
-  #                      ' LEFT JOIN cust_main USING ( custnum ) ',
-  #       'hashref'   => {
-  #                        'recur' => { op=>'!=', value=>0    },
-  #                        'sdate' => { op=>'<',  value=>$now },
-  #                        'edate' => { op=>'>',  value=>$now },
-  #                      },
-  #       'extra_sql' => $where,
-  #     });
-  #
-  #    foreach my $cust_bill_pkg ( @cust_bill_pkg) { 
-  #      my $period = $cust_bill_pkg->edate - $cust_bill_pkg->sdate;
-  #   
-  #      my $elapsed = $now - $cust_bill_pkg->sdate;
-  #      $elapsed = 0 if $elapsed < 0;
-  #   
-  #      my $remaining = 1 - $elapsed/$period;
-  #   
-  #      my $unearned = $remaining * $cust_bill_pkg->recur;
-  #      $total += $unearned;
-  #   
-  #    }
-
-  #re-written in sql:
-
-  #false laziness w/cust_bill_pkg.cgi
-
-  my $float = 'REAL'; #'DOUBLE PRECISION';
-
-  my $period = "CAST(cust_bill_pkg.edate - cust_bill_pkg.sdate AS $float)";
-  my $elapsed = "(CASE WHEN cust_bill_pkg.sdate > $now
-                   THEN 0
-                   ELSE ($now - cust_bill_pkg.sdate)
-                 END)";
-  #my $elapsed = "CAST($unearned - cust_bill_pkg.sdate AS $float)";
-
-  my $remaining = "(1 - $elapsed/$period)";
-
-  my $select = "SUM($remaining * cust_bill_pkg.recur)";
-
-  #[...]
-
-  my $sql = "SELECT $select FROM cust_bill_pkg
-                            LEFT JOIN cust_pkg  USING ( pkgnum )
-                            LEFT JOIN part_pkg  USING ( pkgpart )
-                            LEFT JOIN cust_main USING ( custnum )
-               WHERE pkgpart > 0
-                 AND sdate < $now
-                 AND edate > $now
-                 AND cust_bill_pkg.recur != 0
-                 AND part_pkg.freq != '0'
-                 AND part_pkg.freq != '1'
-                 AND part_pkg.freq NOT LIKE '%h'
-                 AND part_pkg.freq NOT LIKE '%d'
-                 AND part_pkg.freq NOT LIKE '%w'
-                 $where
-             ";
-
-  my $sth = dbh->prepare($sql) or die dbh->errstr;
-  $sth->execute or die $sth->errstr;
-  my $total = $sth->fetchrow_arrayref->[0];
-
-  $total = sprintf('%.2f', $total);
-  $total{$agentnum} = $total;
-  $total{0} += $total;
-
-  if ( $legacy ) {
-
-    #not yet rewritten in sql, but now not enabled by default
-
-    my @cust_pkg = 
-      grep { $_->part_pkg->recur != 0
-             && $_->part_pkg->freq !~ /^([01]|\d+[dw])$/
-           }
-        qsearch({
-          'select'    => 'cust_pkg.*',
-          'table'     => 'cust_pkg',
-          'addl_from' => ' LEFT JOIN cust_main USING ( custnum ) ',
-          'hashref'   => { 'bill' => { op=>'>', value=>$now } },
-          'extra_sql' => $where,
-        });
-
-    foreach my $cust_pkg ( @cust_pkg ) {
-      my $period = $cust_pkg->bill - $cust_pkg->last_bill;
-   
-      my $elapsed = $now - $cust_pkg->last_bill;
-      $elapsed = 0 if $elapsed < 0;
-   
-      my $remaining = 1 - $elapsed/$period;
-   
-      my $unearned = $remaining * $cust_pkg->part_pkg->recur; #!! only works for flat/legacy
-      $total_legacy += $unearned;
-   
-    }
-
-    $total_legacy = sprintf('%.2f', $total_legacy);
-    $total_legacy{$agentnum} = $total_legacy;
-    $total_legacy{0} += $total_legacy;
-
-  }
-
-}
-
-$total{0} = sprintf('%.2f', $total{0});
-$total_legacy{0} = sprintf('%.2f', $total_legacy{0});
-  
-</%init>
index 061b24c..4743e2d 100644 (file)
@@ -2,7 +2,7 @@
 
 <% include('/elements/init_calendar.html') %>
 
-<FORM ACTION="report_prepaid_income.cgi" METHOD="GET">
+<FORM ACTION="prepaid_income.html" METHOD="GET">
 
 <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
 
@@ -13,7 +13,7 @@
   </TR>
 
   <TR>
-    <TD>As of </TD>
+    <TD ALIGN="right">As of </TD>
     <TD>
       <INPUT TYPE="text" NAME="date" ID="date_text" VALUE="now">
       <IMG SRC="../images/calendar.png" ID="date_button" STYLE="cursor: pointer" TITLE="Select date">
   </TR>
 
   <% include( '/elements/tr-select-agent.html', 'disable_empty'=>0 ) %>
-
+  
+  <& /elements/tr-select-cust_main-status.html,
+      label => mt('Customer Status') &>
+  <& /elements/tr-select.html,
+      label => 'Invoice Status',
+      field => 'mode',
+      options => [ qw(billed paid) ] &>
+  <TR>
+    <TD ALIGN="right">
+      <INPUT TYPE="checkbox" NAME="include_monthly" VALUE=1>
+    </TD>
+    <TD ALIGN="left"><% mt('Include packages with period &le; 1 month') %>
+    </TD>
+  </TR>
   <TR>
     <TD COLSPAN=2>&nbsp;</TD>
   </TR>
index 19f8635..5cff0f4 100755 (executable)
@@ -52,7 +52,7 @@ function toggle(obj) {
   <& /elements/tr-input-date-field.html, {
                 'name'      => 'as_of',
                 'value'     => time,
-                'label'     => emt('As of date '),
+                'label'     => emt('At the end of date '),
                 'format'    => FS::Conf->new->config('date_format') || '%m/%d/%Y',
                 } 
   &>
index 14c284f..74bf553 100755 (executable)
 <%init>
 
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('List packages'); #?
+  unless $FS::CurrentUser::CurrentUser->access_right('Services: Accounts: Advanced search'); #?
 
 my $title = emt('Account Report');
 
index 37f21b7..d7422ee 100755 (executable)
@@ -76,7 +76,7 @@
 <%init>
 
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('List packages'); #?
+  unless $FS::CurrentUser::CurrentUser->access_right('Services: Wireless broadband services: Advanced search');
 
 my $title = 'Broadband Service Report';
 my $routernum = [ $cgi->param('routernum') || '' ];
index 61ba4ab..b0bfc08 100755 (executable)
@@ -61,7 +61,7 @@ OR (SELECT COUNT(*) FROM svc_hardware
 <%init>
 
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('List packages'); #?
+  unless $FS::CurrentUser::CurrentUser->access_right('Services: Hardware: Advanced search');
 
 my $title = 'Hardware Service Report';
 
index 0cd652d..2786f57 100755 (executable)
@@ -239,6 +239,8 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
 
 <%init>
 
+my $DEBUG = $cgi->param('debug') || 0;
+
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
@@ -252,15 +254,19 @@ my $join_cust =     '     JOIN cust_bill      USING ( invnum  )
                       LEFT JOIN cust_main     USING ( custnum ) ';
 my $join_cust_pkg = $join_cust.
                     ' LEFT JOIN cust_pkg      USING ( pkgnum  )
-                      LEFT JOIN part_pkg      USING ( pkgpart ) ';
-$join_cust_pkg .=   ' LEFT JOIN cust_location USING ( locationnum )'
-  if $conf->exists('tax-pkg_address');
+                      LEFT JOIN part_pkg      USING ( pkgpart ) 
+                      LEFT JOIN cust_location 
+                        ON ( cust_location.locationnum = ' .
+                        FS::cust_pkg->tax_locationnum_sql . ' )';
 
 my $from_join_cust_pkg = " FROM cust_bill_pkg $join_cust_pkg "; 
 
 my $where = "WHERE _date >= $beginning AND _date <= $ending ";
 
-my( $location_sql, @base_param ) = FS::cust_pkg->location_sql;
+# this query will be run once per cust_main_county,
+# or maybe once per country/state/city tuple,
+# or maybe once per country/state...it's hard to say.
+my ($location_sql, @base_param) = FS::cust_location->in_county_sql(param => 1);
 $where .= " AND $location_sql ";
 
 my $agentname = '';
@@ -275,7 +281,10 @@ sub gotcust {
   my $table = shift;
   my $prefix = @_ ? shift : '';
   "
-        ( $table.${prefix}city  = cust_main_county.city
+        ( $table.district = cust_main_county.district
+          OR cust_main_county.district = ''
+          OR cust_main_county.district IS NULL )
+    AND ( $table.${prefix}city  = cust_main_county.city
           OR cust_main_county.city = ''
           OR cust_main_county.city IS NULL )
     AND ( $table.${prefix}county  = cust_main_county.county
@@ -288,59 +297,29 @@ sub gotcust {
   ";
 }
 
-my $gotcust;
-if ( $conf->exists('tax-ship_address') ) {
-
-  $gotcust = "
-               (    cust_main_county.country = cust_main.country
-                 OR cust_main_county.country = cust_main.ship_country
-               )
-
-               AND
-
-               ( 
-                 (     ( ship_last IS NULL     OR  ship_last = '' )
-                   AND ". gotcust('cust_main'). "
-                 )
-                 OR
-                 (       ship_last IS NOT NULL AND ship_last != ''
-                   AND ". gotcust('cust_main', 'ship_'). "
-                 )
-               )
-  ";
-
-} else {
-
-  $gotcust = gotcust('cust_main');
-
-}
-if ( $conf->exists('tax-pkg_address') ) {
-  $gotcust = "
-       ( cust_pkg.locationnum IS     NULL AND $gotcust)
-    OR ( cust_pkg.locationnum IS NOT NULL AND ". gotcust('cust_location'). " )";
-  $gotcust =
-    "WHERE 0 < ( SELECT COUNT(*) FROM cust_pkg
-                                 LEFT JOIN cust_main USING ( custnum )
-                                 LEFT JOIN cust_location USING ( locationnum )
-                   WHERE $gotcust
-                   LIMIT 1
-               )
-    ";
-} else {
-  $gotcust =
-    "WHERE 0 < ( SELECT COUNT(*) FROM cust_main WHERE $gotcust LIMIT 1 )";
-}
+#non-parameterized form
+my $location_in_county = FS::cust_location->in_county_sql;
+my $gotcust = "WHERE EXISTS(
+  SELECT 1 FROM cust_location WHERE $location_in_county AND disabled IS NULL
+)";
 
 my $out = 'Out of taxable region(s)';
+# these are actually tax labels, not regions
 my %regions = ();
 
+# Phase 1: Taxable and exempt sales
+# Collect for each cust_main_county, and assign to a bin based on label.
+# Note that "label" includes city if show_cities is on, and taxclass if
+# show_taxclasses is on.
 foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
                           'extra_sql' => $gotcust,
+                          'debug' => $DEBUG,
                        })
               )
 {
-  #warn $r->county. ' '. $r->state. ' '. $r->country. "\n";
+  warn $r->county. ' '. $r->state. ' '. $r->country. "\n" if $DEBUG > 1;
 
+  # set up a %regions entry for this region's tax label
   my $label = getlabel($r);
   $regions{$label}->{'label'} = $label;
 
@@ -366,6 +345,7 @@ foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
 
   } else {
 
+    # SQL for "taxclass doesn't match any other tax in the region"
     my $same_sql = $r->sql_taxclass_sameregion;
     $mywhere .= " AND $same_sql" if $same_sql;
 
@@ -375,42 +355,24 @@ foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
 
   }
 
+  # FROM cust_bill_pkg JOIN (whatever is needed to determine tax location)
+  # WHERE (matches tax location and agentnum and taxclass)
+  # takes parameters in @base_param, plus taxclass if there is one
   my $fromwhere = "$from_join_cust_pkg $mywhere"; # AND payby != 'COMP' ";
 
-#  my $label = getlabel($r);
-#  $regions{$label}->{'label'} = $label;
-
   my $nottax = 'pkgnum != 0';
 
-  ## calculate total for this region
+  ## calculate total of sales (non-tax line items) for this region
 
   my $t_sql =
    "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur) $fromwhere AND $nottax";
   my $t = scalar_sql($r, \@param, $t_sql);
   $regions{$label}->{'total'} += $t;
 
-  #if ( $label eq $out ) # && $t ) {
-  #  warn "adding $t for ".
-  #       join('/', map $r->$_, qw( taxclass county state country ) ). "\n";
-  #  #warn $t_sql if $r->state eq 'FL';
-  #}
+  #$regions{$label}->{subtotals}->{$r->taxnum} = $t; #useful debug
 
   ## calculate customer-exemption for this region
 
-##  my $taxable = $t;
-
-#  my($taxable, $x_cust) = (0, 0);
-#  foreach my $e ( grep { $r->get($_.'tax') !~ /^Y/i }
-#                       qw( cust_bill_pkg.setup cust_bill_pkg.recur ) ) {
-#    $taxable += scalar_sql($r, \@param, 
-#      "SELECT SUM($e) $fromwhere AND $nottax AND ( tax != 'Y' OR tax IS NULL )"
-#    );
-#
-#    $x_cust += scalar_sql($r, \@param, 
-#      "SELECT SUM($e) $fromwhere AND $nottax AND tax = 'Y'"
-#    );
-#  }
-
   #false laziness -ish w/report_tax.cgi
   my $cust_exempt;
   if ( $r->taxname ) {
@@ -486,10 +448,12 @@ foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
   } else {
     $regions{$label}->{'rate'} = $r->tax.'%';
   }
-
 }
+warn Dumper(\%regions) if $DEBUG > 1;
+# $regions{$label} now contains 'total', 'exempt_cust', 'exempt_pkg', 
+# 'exempt_monthly', summed over each set of regions with the same label.
 
-my $distinct = "country, state, county, city, 
+my $distinct = "country, state, county, city, district,
                 CASE WHEN taxname IS NULL THEN '' ELSE taxname END AS taxname";
 my $taxclass_distinct = 
   #a little bit unsure of this part... test?
@@ -501,38 +465,44 @@ my $taxclass_distinct =
   )." AS taxclass";
 
 
+# Phase 2: invoiced/credited tax items
+# Collect this data for each country/state/city/district/taxname(/taxclass).
 my %qsearch = (
   'select'    => "DISTINCT $distinct, $taxclass_distinct",
   'table'     => 'cust_main_county',
   'hashref'   => {},
   'extra_sql' => $gotcust,
+  'debug' => $DEBUG,
 );
 
-my $taxfromwhere = " FROM cust_bill_pkg $join_cust ";
+# Join to cust_main the same as before (we need agentnum)
+# but not to cust_pkg (because tax line items don't have a package)
+# and then to cust_location via cust_bill_pkg_tax_location
+my $taxfromwhere = "FROM cust_bill_pkg $join_cust 
+                    LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
+                    LEFT JOIN cust_location USING ( locationnum )
+                    ";
 my $taxwhere = $where;
-if ( $conf->exists('tax-pkg_address') ) {
-
-  $taxfromwhere .= 'LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
-                    LEFT JOIN cust_location USING ( locationnum ) ';
 
-  #quelle kludge
-  $taxwhere =~ s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g;
-
-}
 my $creditfromwhere = $taxfromwhere. 
-   " JOIN cust_credit_bill_pkg USING (billpkgnum";
-$creditfromwhere .= " ,billpkgtaxlocationnum"
-   if $conf->exists('tax-pkg_address');
-$creditfromwhere .= ")";
+   " JOIN cust_credit_bill_pkg USING (billpkgnum, billpkgtaxlocationnum)";
 
 $taxfromwhere .= " $taxwhere "; #AND payby != 'COMP' ";
 $creditfromwhere .= " $taxwhere AND billpkgtaxratelocationnum IS NULL"; #AND payby != 'COMP' ";
 
-my @taxparam = @base_param;
+#should i be a cust_main_county method or something
+# yes. yes, you should.
+
+# $taxfromwhere: Most of a query to find cust_bill_pkg records linked to a 
+# customer matching a given state/county/city/district (and within the date 
+# range for the report).
+# @base_param: A list of the fields from cust_main_county to use as parameters.
 
+# $_taxamount_sub: Takes a cust_main_county and returns the sum of taxes billed
+# within the report period for all customers located in that county.  If 
+# the cust_main_county has a taxname, limits to taxes with that name; otherwise
+# includes all line items with pkgnum = 0 and description either 'Tax' or empty.
 
-#should i be a cust_main_county method or something
-#need to pass in $taxfromwhere & @taxparam???
 my $_taxamount_sub = sub {
   my $r = shift;
 
@@ -545,9 +515,11 @@ my $_taxamount_sub = sub {
   my $sql = "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur) ".
             " $taxfromwhere AND cust_bill_pkg.pkgnum = 0 $named_tax";
 
-  scalar_sql($r, \@taxparam, $sql );
+  scalar_sql($r, [ @base_param ], $sql );
 };
 
+# $_creditamount_sub: As above, but returns the sum of credits applied 
+
 my $_creditamount_sub = sub {
   my $r = shift;
 
@@ -560,7 +532,7 @@ my $_creditamount_sub = sub {
   my $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
             " $creditfromwhere AND cust_bill_pkg.pkgnum = 0 $named_tax";
 
-  scalar_sql($r, \@taxparam, $sql );
+  scalar_sql($r, [ @base_param ], $sql );
 };
 
 #tax-report_groups filtering
@@ -611,6 +583,10 @@ foreach my $r ( qsearch(\%qsearch) ) {
 
 }
 
+# Phase 3: Non-taxclassed totals for invoiced/credited tax
+# (If show_taxclasses is not in use, this was phase 2, but it 
+# displays somewhere different.)
+# Don't filter by report_groups.
 my %base_regions = ();
 if ( $cgi->param('show_taxclasses') ) {
 
@@ -734,7 +710,8 @@ sub getlabel {
   my $label;
   if (
     $r->tax == 0 
-    && ! scalar( qsearch('cust_main_county', { 'city'    => $r->city,
+    && ! scalar( qsearch('cust_main_county', { 'district'=> $r->district,
+                                               'city'    => $r->city,
                                                'county'  => $r->county,
                                                'state'   => $r->state,
                                                'country' => $r->country,
@@ -747,10 +724,6 @@ sub getlabel {
     #kludge to avoid "will not stay shared" warning
     my $out = 'Out of taxable region(s)';
     $label = $out;
-#  } elsif ( $r->taxname && count_taxname($r->taxname) == 1 ) {
-#    $label = $r->taxname;
-##    $regions{$label}->{'taxname'} = $label;
-##    push @{$regions{$label}->{$_}}, $r->$_() foreach qw( county state country );
   } else {
     $label = $r->country;
     $label = $r->state.", $label" if $r->state;
diff --git a/httemplate/search/unearned_detail.html b/httemplate/search/unearned_detail.html
new file mode 100644 (file)
index 0000000..02d514c
--- /dev/null
@@ -0,0 +1,257 @@
+<& elements/search.html,
+  'title'       => emt("Unearned revenue - ".ucfirst($unearned_mode)) . ' (' .
+                   time2str('%b %d %Y', $unearned) . ')',
+  'name'        => emt('line items'),
+  'query'       => $query,
+  'count_query' => $count_query,
+  'count_addl'  => [ $money_char. '%.2f total',
+                     $money_char. '%.2f unearned revenue' 
+                   ],
+  'header'      => [ map( {emt $_} 
+    'Description',
+    'Unearned', # depends on mode
+    'Recurring charge', #recur - usage
+    'Owed', #recur - usage - credits - payments
+    'Paid', #payments
+    'Payment date', #of last payment
+    'Credit date', #of last credit
+    'Charge start',
+    'Charge end',
+    'Invoice',
+    'Date'
+    ),
+    FS::UI::Web::cust_header(),
+  ],
+  'fields'      => [
+    #Description
+    sub { $_[0]->pkgnum > 0
+      ? $_[0]->get('pkg')      # possibly use override.pkg
+      : $_[0]->get('itemdesc') # but i think this correct
+    },
+    #Unearned
+    money_sub('unearned_revenue'),
+    #Recurring charge
+    money_sub('recur_no_usage'),
+    #Owed
+    money_sub('owed_no_usage'),
+    #Paid
+    money_sub('paid_no_usage'),
+    #Payment date
+    date_sub('last_pay'),
+    #Credit date
+    date_sub('last_credit'),
+    #Charge start
+    date_sub('sdate'),
+    #Charge end, minus most of a day
+    date_sub('before_edate'),
+    #Invoice
+    'invnum',
+    #Invoice date
+    date_sub('_date'),
+    \&FS::UI::Web::cust_fields,
+  ],
+  'sort_fields' => [
+    'pkg',
+    # SQL expressions work as sort keys...
+    'unearned_revenue',
+    'recur_no_usage',
+    'owed_no_usage',
+    'paid_no_usage',
+    'last_pay',
+    'last_credit',
+    'sdate',
+    'edate',
+    'invnum',
+    '_date',
+  ],
+  'links'       => [
+    ('' x 9),
+    $ilink,
+    $ilink,
+    ( map { $_ ne 'Cust. Status' ? $clink : '' }
+      FS::UI::Web::cust_header()
+    ),
+  ],
+  'align' => 'lrrcrccrc'.  FS::UI::Web::cust_aligns(),
+  'color' => [ 
+    ('' x 11),
+    FS::UI::Web::cust_colors(),
+  ],
+  'style' => [ 
+    ('' x 11),
+    FS::UI::Web::cust_styles(),
+  ],
+&>
+<%init>
+
+# Separated from cust_bill_pkg.cgi to simplify things.
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $conf = new FS::Conf;
+
+my $unearned = '';
+my $unearned_mode = '';
+my $unearned_base = '';
+my $unearned_sql = '';
+
+my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
+my ($join_cust, $join_pkg ) = ('', '');
+
+#here is the agent virtualization
+my $agentnums_sql =
+  $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
+
+my @where = ( $agentnums_sql );
+
+my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
+
+if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
+  push @where, FS::cust_main->cust_status_sql . " = '$1'";
+}
+
+push @where, "cust_bill._date >= $beginning",
+             "cust_bill._date <= $ending";
+
+if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
+  push @where, "cust_main.agentnum = $1";
+}
+
+# no pkgclass, no taxclass, no tax location...
+
+# unearned revenue mode
+$cgi->param('date') =~ /^(\d+)$/
+  or die "date required";
+
+$unearned = $1;
+$unearned_mode = $cgi->param('mode');
+
+push @where, "cust_bill_pkg.sdate < $unearned",
+             "cust_bill_pkg.edate > $unearned",
+             "cust_bill_pkg.recur != 0",
+             "part_pkg.freq != '0'";
+
+if ( !$cgi->param('include_monthly') ) {
+  push @where,
+             "part_pkg.freq != '1'",
+             "part_pkg.freq NOT LIKE '%h'",
+             "part_pkg.freq NOT LIKE '%d'",
+             "part_pkg.freq NOT LIKE '%w'";
+}
+
+my @opt = (
+  $unearned, #before this date
+  '',        #after this date
+  setuprecur => 'recur',
+  no_usage => 1
+);
+
+my $charged = FS::cust_bill_pkg->charged_sql(@opt);
+push @select, "($charged) AS recur_no_usage";
+
+my $owed_sql = FS::cust_bill_pkg->owed_sql(@opt);
+push @select, "($owed_sql) AS owed_no_usage";
+
+my $paid_sql = FS::cust_bill_pkg->paid_sql(@opt);
+push @select, "$paid_sql AS paid_no_usage";
+
+if ( $unearned_mode eq 'paid' ) {
+  # then use the amount paid, minus usage charges
+  $unearned_base = $paid_sql;
+}
+else {
+  # use the amount billed, minus usage charges and credits
+  $unearned_base = "( $charged - " . 
+                    FS::cust_bill_pkg->credited_sql(@opt) . ' )';
+}
+# whatever we're using as the base, only show rows where it's positive
+push @where, "$unearned_base > 0";
+
+my $edate_zero = midnight_sql('edate');
+my $sdate_zero = midnight_sql('sdate');
+# $unearned is one second before midnight on the date requested for the report.
+
+# suppress partial days for more accounting-like behavior
+my $period = "CAST( ($edate_zero - $sdate_zero) / 86400.0 AS DECIMAL(10,0) )";
+
+my $remaining = "GREATEST( 
+  CAST( ($edate_zero - $unearned) / 86400.0 AS DECIMAL(10,0) ),
+  0)";
+my $fraction = "$remaining / $period";
+
+$unearned_sql = "CAST( $unearned_base * $fraction AS DECIMAL(10,2) )";
+push @select, "$unearned_sql AS unearned_revenue";
+
+# last payment/credit date
+my %t = (pay => 'cust_bill_pay', credit => 'cust_credit_bill');
+foreach my $x (qw(pay credit)) {
+  my $table = $t{$x};
+  my $link = $table.'_pkg';
+  my $pkey = dbdef->table($table)->primary_key;
+  my $last_date_sql = "SELECT MAX(_date) 
+  FROM $table JOIN $link USING ($pkey)
+  WHERE $link.billpkgnum = cust_bill_pkg.billpkgnum 
+  AND $table._date <= $unearned";
+  push @select, "($last_date_sql) AS last_$x";
+}
+
+push @select, '(edate - 82799) AS before_edate';
+
+#no itemdesc
+#no tax report group kludge
+#no tax exemption
+#usage always excluded
+
+# always 'nottax', not 'istax'
+$join_cust =  '        JOIN cust_bill USING ( invnum )
+                  LEFT JOIN cust_main USING ( custnum ) ';
+
+$join_pkg .=  ' LEFT JOIN cust_pkg USING ( pkgnum )
+                LEFT JOIN part_pkg USING ( pkgpart )
+                LEFT JOIN part_pkg AS override
+                  ON pkgpart_override = override.pkgpart ';
+
+my $where = ' WHERE '. join(' AND ', @where);
+
+my $count_query = "SELECT COUNT(DISTINCT billpkgnum), 
+  SUM( $unearned_base ), SUM( $unearned_sql )
+  FROM cust_bill_pkg $join_cust $join_pkg $where";
+
+push @select, 'part_pkg.pkg',
+              'part_pkg.freq',
+              'cust_main.custnum',
+              FS::UI::Web::cust_sql_fields();
+
+my $query = {
+  'table'     => 'cust_bill_pkg',
+  'addl_from' => "$join_cust $join_pkg",
+  'hashref'   => {},
+  'select'    => join(",\n", @select ),
+  'extra_sql' => $where,
+  'order_by'  => 'ORDER BY cust_bill._date, billpkgnum',
+};
+
+my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
+my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
+
+my $money_char;
+
+sub money_sub {
+  $conf ||= new FS::Conf;
+  $money_char ||= $conf->config('money_char') || '$';
+  my $field = shift;
+  sub {
+    $money_char . sprintf('%.2f', $_[0]->get($field));
+  };
+}
+
+sub date_sub {
+  my $field = shift;
+  sub {
+    my $value = $_[0]->get($field);
+    $value ? time2str('%b %d %Y', $value) : '';
+  };
+};
+
+</%init>
index 9ce55b0..ec31919 100755 (executable)
@@ -46,10 +46,39 @@ function areyousure(href, message) {
   <A HREF="<% $p %>edit/cust_main.cgi?<% $custnum %>"><% mt('Edit this customer') |h %></A> | 
 % } 
 
-% if ( $curuser->access_right('Cancel customer')
-%        && $cust_main->ncancelled_pkgs
+% if ( $curuser->access_right('Suspend customer')
+%        && scalar($cust_main->unsuspended_pkgs)
 %      ) {
+  <& /elements/popup_link-cust_main.html,
+              { 'action'      => $p. 'misc/suspend_cust.html',
+                'label'       => emt('Suspend this customer'),
+                'actionlabel' => emt('Confirm Suspension'),
+                'color'       => '#ff9900',
+                'cust_main'   => $cust_main,
+                'width'       => 616, #make room for reasons
+                'height'      => 366,
+              }
+  &> | 
+% }
 
+% if ( $curuser->access_right('Unsuspend customer')
+%        && scalar($cust_main->suspended_pkgs)
+%      ) {
+  <& /elements/popup_link-cust_main.html,
+              { 'action'      => $p. 'misc/unsuspend_cust.html',
+                'label'       => emt('Unsuspend this customer'),
+                'actionlabel' => emt('Confirm Unsuspension'),
+                #'color'       => '#ff9900',
+                'cust_main'   => $cust_main,
+                #'width'       => 616, #make room for reasons
+                #'height'      => 366,
+              }
+  &> | 
+% }
+
+% if ( $curuser->access_right('Cancel customer')
+%        && scalar($cust_main->ncancelled_pkgs)
+%      ) {
   <& /elements/popup_link-cust_main.html,
               { 'action'      => $p. 'misc/cancel_cust.html',
                 'label'       => emt('Cancel this customer'),
@@ -60,11 +89,9 @@ function areyousure(href, message) {
                 'height'      => 366,
               }
   &> | 
-
 % }
 
 % if ( $curuser->access_right('Merge customer') ) {
-
   <& /elements/popup_link-cust_main.html,
               { 'action'      => $p. 'misc/merge_cust.html',
                 'label'       => emt('Merge this customer'),
@@ -74,7 +101,6 @@ function areyousure(href, message) {
                 'height'      => 192,
               }
   &> | 
-
 % } 
 
 % if ( $conf->exists('deletecustomers')
@@ -277,7 +303,13 @@ function areyousure(href, message) {
 % }
 
 % if ( $view eq 'custom' ) { 
+%   if ( $conf->config('cust_main-custom_link') ) {
 <& cust_main/custom.html, $cust_main &>
+%   } elsif ( $conf->config('cust_main-custom_content') ) {
+      <& cust_main/custom_content.html, $cust_main &>
+%   #} else {
+%   #  warn "custom view without cust_main-custom_link or -custom_content?";
+%   }
 % }
 
 </DIV>
@@ -326,7 +358,8 @@ $views{emt('Payment History')} =  'payment_history'
 $views{emt('Change History')}  =  'change_history'
   if $curuser->access_right('View customer history');
 $views{$conf->config('cust_main-custom_title') || emt('Custom')} =  'custom'
-  if $conf->config('cust_main-custom_link');
+  if $conf->config('cust_main-custom_link')
+  || $conf->config('cust_main-custom_content');
 $views{emt('Jumbo')}           =  'jumbo';
 
 my %viewname = reverse %views;
index 4d55f70..b2a0efd 100644 (file)
 % if ( $cust_main->payinfo ) { 
 
 <TR>
-  <TD ALIGN="right"<% mt('P.O.') |h %></TD>
+  <TD ALIGN="right"><% mt('P.O.') |h %></TD>
   <TD BGCOLOR="#ffffff"><% $cust_main->payinfo %></TD>
 </TR>
 % } 
 
 % my @exempt_groups = grep /\S/, $conf->config('tax-cust_exempt-groups');
 
-% unless ( $conf->exists('cust_class-tax_exempt') ) {
+% unless (    $conf->exists('cust_class-tax_exempt')
+%          || $conf->exists('tax-cust_exempt-groups-require_individual_nums')
+%        )
+% {
     <TR>
       <TD ALIGN="right"><% mt('Tax exempt') |h %><% @exempt_groups ? ' ('.emt('all taxes').')' : '' %></TD>
       <TD BGCOLOR="#ffffff"><% $cust_main->tax ? $yes : $no %></TD>
 % }
 
 % foreach my $exempt_group ( @exempt_groups ) {
+%   my $cust_main_exemption = $cust_main->tax_exemption($exempt_group);
     <TR>
       <TD ALIGN="right"><% mt('Tax exempt') |h %> (<% $exempt_group %> taxes)</TD>
-      <TD BGCOLOR="#ffffff"><% $cust_main->tax_exemption($exempt_group) ? $yes : $no %></TD>
+      <TD BGCOLOR="#ffffff"><% $cust_main_exemption ? $yes : $no %>
+        <% $cust_main_exemption ? $cust_main_exemption->exempt_number : '' |h %>
+      </TD>
     </TR>
 % }
 
 </TR>
 % }
 <TR>
-  <TD ALIGN="right"><% mt('Postal invoices') |h %></TD>
+  <TD ALIGN="right"><% mt('Postal mail invoices') |h %></TD>
   <TD BGCOLOR="#ffffff">
     <% ( grep { $_ eq 'POST' } @invoicing_list ) ? $yes : $no %>
   </TD>
 </TR>
 <TR>
-  <TD ALIGN="right"><% mt('FAX invoices') |h %></TD>
+  <TD ALIGN="right"><% mt('Fax invoices') |h %></TD>
   <TD BGCOLOR="#ffffff">
     <% ( grep { $_ eq 'FAX' } @invoicing_list ) ? $yes : $no %>
   </TD>
 </TR>
-% unless ( $conf->exists('cust-email-high-visibility')) {
 <TR>
   <TD ALIGN="right"><% mt('Email invoices') |h %></TD>
   <TD BGCOLOR="#ffffff">
+    <% $cust_main->invoice_noemail ? $no : $yes %>
+  </TD>
+</TR>
+% unless ( $conf->exists('cust-email-high-visibility')) {
+<TR>
+  <TD ALIGN="right"><% mt('Email address(es)') |h %></TD>
+  <TD BGCOLOR="#ffffff">
     <% join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ) || $no %>
   </TD>
 </TR>
index b3e52b5..6213f27 100644 (file)
-% my %which = (
-%   ''      => emt('Billing'),
-%   'ship_' => emt('Service'),
-% );
-% foreach my $which ( '', 'ship_' ) {
-%   my $pre = $cust_main->get("${which}last") ? $which : '';
-
-<FONT CLASS="fsinnerbox-title"><% $which{$which} %> <% mt('address') |h %></FONT>
+% my %addr_label = ('bill' => 'Billing address', 'ship' => 'Service address');
+
+%# Locations (possibly break this out)
+% my @which = ('bill');
+% push @which, 'ship' if $cust_main->has_ship_address;
+% while (@which) {
+%   my $this = shift @which;
+%   my $method = $this.'_location';
+%   my $location = $cust_main->$method;
+<FONT CLASS="fsinnerbox-title"><% mt( $addr_label{$this} ) |h %></FONT>
 <TABLE CLASS="fsinnerbox">
-<TR>
-  <TD ALIGN="right"><% mt('Contact name') |h %></TD>
-  <TD COLSPAN=5 BGCOLOR="#ffffff">
-    <% $cust_main->get("${pre}last"). ', '. $cust_main->get("${pre}first") |h %>
-  </TD>
-% if ( $which eq '' && $conf->exists('show_ss') ) { 
-    <TD ALIGN="right"><% mt('SS#') |h %></TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->masked('ss') || '&nbsp' %></TD>
-% } 
-</TR>
 
-% if ( $conf->exists('cust-email-high-visibility') && $which eq '') {
+% if ( $this eq 'bill' ) {
+%   #billing contact fields
+  <TR>
+    <TD ALIGN="right"><% mt('Contact name') |h %></TD>
+    <TD COLSPAN=5 BGCOLOR="#ffffff"><% $cust_main->contact |h %></TD>
+%   if ( $conf->exists('show_ss') ) {
+    <TD ALIGN="right"><% mt('SS#') |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $conf->exists('unmask_ss')
+                              ? $cust_main->ss
+                              : $cust_main->masked('ss') || '&nbsp;' %></TD>
+%   }
+  </TR>
+%   if ( $conf->exists('cust-email-high-visibility') ) {
   <TR>
-    <TD ALIGN="right"><% mt('Email invoices') |h %></TD>
+    <TD ALIGN="right"><% mt('Email address(es)') |h %></TD>
     <TD BGCOLOR="#ffff00">
-      <% join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ) || $no %>
+      <% $cust_main->invoicing_list_emailonly_scalar || $no %>
     </TD>
   </TR>
-% }
-
-% if ( $cust_main->get("${pre}company") ) {
+%   }
+%   if ( $cust_main->company ) {
   <TR>
     <TD ALIGN="right"><% mt('Company') |h %></TD>
-    <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}company") |h %></TD>
+    <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->company %></TD>
   </TR>
-% }
-
+%   }
+% } # if $this eq 'bill'
+% # now the actual address
 <TR>
   <TD ALIGN="right"><% mt('Address') |h %></TD>
-  <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address1") |h %></TD>
+  <TD COLSPAN=7 BGCOLOR="#ffffff"><% $location->address1 |h %></TD>
 </TR>
 
-% if ( $cust_main->get("${pre}address2") ) { 
-%   my $address2_label =
-%     ( $conf->exists('cust_main-require_address2')
-%       && ! ( $pre xor $cust_main->has_ship_address )
-%     )
-%       ? emt('Unit #')
-%       : ' ';
+% if ( $location->get('address2') ) {
+%   my $address2_label = $conf->exists('cust_main-require_address2') 
+%                        ? emt('Unit #')
+%                        : ' ';
 
-  <TR>
-    <TD ALIGN="right"><% $address2_label %></TD>
-    <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address2") |h %></TD>
-  </TR>
+<TR>
+  <TD ALIGN="right"><% $address2_label %></TD>
+  <TD COLSPAN=7 BGCOLOR="#ffffff"><% $location->address2 |h %></TD>
+</TR>
 
 % } 
 
 <TR>
   <TD ALIGN="right"><% mt('City') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}city") |h %></TD>
-% if ( $cust_main->get("${pre}county") ) {
+  <TD BGCOLOR="#ffffff"><% $location->city |h %></TD>
+% if ( $location->county ) {
     <TD ALIGN="right"><% mt('County') |h %></TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}county") |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $location->county |h %></TD>
 % }
   <TD ALIGN="right"><% mt('State') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% state_label( $cust_main->get("${pre}state"), $cust_main->get("${pre}country") ) |h %></TD>
+  <TD BGCOLOR="#ffffff"><% state_label( $location->state, $location->country ) |h %></TD>
   <TD ALIGN="right"><% mt('Zip') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}zip") %></TD>
+  <TD BGCOLOR="#ffffff"><% $location->zip %></TD>
 </TR>
 <TR>
   <TD ALIGN="right"><% mt('Country') |h %></TD>
-  <TD BGCOLOR="#ffffff"><% code2country( $cust_main->get("${pre}country") ) %></TD>
+  <TD BGCOLOR="#ffffff"><% code2country( $location->country ) %></TD>
 </TR>
 
-% if ( $cust_main->get($pre.'latitude') && $cust_main->get($pre.'longitude') ) {
-  <& /elements/tr-coords.html, $cust_main->get($pre.'latitude'),
-                               $cust_main->get($pre.'longitude'),
+% if ( $location->latitude && $location->longitude ) {
+  <& /elements/tr-coords.html, $location->latitude,
+                               $location->longitude,
                                $cust_main->name_short,
                                $cust_main->agentnum,
   &>
 % }
+  
+% if ( $this eq 'bill' ) {
+%   # billing contact phone numbers
+%   foreach my $phone (qw(daytime night mobile)) {
+%     next if !$cust_main->get($phone);
+<TR>
+  <TD ALIGN="right"><% $phone_label{$phone} %></TD>
+  <TD COLSPAN=3 BGCOLOR="#ffffff">
+    <& /elements/phonenumber.html,
+        $cust_main->get($phone),
+        callable => 1,
+        calling_list_exempt => $cust_main->calling_list_exempt,
+    &>
+  </TD>
+</TR>
 
-% foreach my $phone (grep $cust_main->get($pre.$_), qw( daytime night mobile )){
-
-  <TR>
-    <TD ALIGN="right"><% $phone_label{$phone} %></TD>
-    <TD COLSPAN=3 BGCOLOR="#ffffff">
-      <& /elements/phonenumber.html,
-                    $cust_main->get($pre.$phone),
-                    'callable'=>1,
-                    'calling_list_exempt'=>$cust_main->calling_list_exempt,
-      &>
-    </TD>
-  </TR>
-
-% }
+%   } #foreach $phone
+%   if ( $cust_main->get('fax') ) {
 
-% if ( $cust_main->get("${pre}fax") ) {
   <TR>
     <TD ALIGN="right"><% mt('Fax') |h %></TD>
     <TD COLSPAN=3 BGCOLOR="#ffffff">
-      <% $cust_main->get("${pre}fax") || '&nbsp' %>
+      <% $cust_main->get('fax') || '&nbsp;' %>
     </TD>
   </TR>
-% }
 
-% if ( $which eq '' && $conf->exists('show_stateid') ) { 
-  <TR>
+%   }
+%
+%   if ( $conf->exists('show_stateid') ) { 
+
+<TR>
     <TD ALIGN="right"><% $stateid_label %></TD>
     <TD BGCOLOR="#ffffff"><% $cust_main->masked('stateid') || '&nbsp' %></TD>
     <TD ALIGN="right"><% $stateid_state_label %></TD>
     <TD BGCOLOR="#ffffff"><% $cust_main->stateid_state || '&nbsp' %></TD>
   </TR>
-% } 
 
+%   }
+% } #if $this eq 'bill'
 </TABLE>
-% if ( $which ne 'ship_' ) {
+% if ( @which ) {
 <BR>
 % }
-% } 
+% } #while @which
 <%once>
 
 my %phone_label = (
@@ -147,7 +153,7 @@ my $stateid_state_label = FS::Msgcat::_gettext('stateid_state') =~ /^(stateid_st
 </%once>
 <%init>
 
-my( $cust_main ) = @_;
+my $cust_main = shift;
 my $conf = new FS::Conf;
 my @invoicing_list = $cust_main->invoicing_list;
 my $no = emt('no');
diff --git a/httemplate/view/cust_main/custom_content.html b/httemplate/view/cust_main/custom_content.html
new file mode 100644 (file)
index 0000000..dd3d1f0
--- /dev/null
@@ -0,0 +1,31 @@
+% foreach my $item (@content) {
+%
+%   if ( $item =~ /^\s*$/ ) {
+      <BR>
+%     next;
+%   }
+%
+%   if ( $items{$item} ) {
+      <& "custom_content/$item.html", $cust_main &>
+%   } else {
+      Unknown item <% $item |h %><BR>
+%   }
+% }
+<%init>
+
+my($cust_main) = @_;
+
+my $conf = new FS::Conf;
+
+my @content = $conf->config('cust_main-custom_content');
+
+my %items = map { $_=>1 } qw(
+  small_custview
+  birthdate
+  spouse_birthdate
+  svc_acct
+  svc_phone
+  svc_hardware
+);
+
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/.birthdate.html.swp b/httemplate/view/cust_main/custom_content/.birthdate.html.swp
new file mode 100644 (file)
index 0000000..9571d22
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.birthdate.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/.small_custview.html.swp b/httemplate/view/cust_main/custom_content/.small_custview.html.swp
new file mode 100644 (file)
index 0000000..a39f52d
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.small_custview.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp b/httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp
new file mode 100644 (file)
index 0000000..0042012
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_Common.html.swp b/httemplate/view/cust_main/custom_content/.svc_Common.html.swp
new file mode 100644 (file)
index 0000000..15591b9
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.svc_Common.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_acct.html.swp b/httemplate/view/cust_main/custom_content/.svc_acct.html.swp
new file mode 100644 (file)
index 0000000..e2db6d5
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.svc_acct.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_hardware.html.swp b/httemplate/view/cust_main/custom_content/.svc_hardware.html.swp
new file mode 100644 (file)
index 0000000..1106f9e
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.svc_hardware.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_phone.html.swp b/httemplate/view/cust_main/custom_content/.svc_phone.html.swp
new file mode 100644 (file)
index 0000000..79b8185
Binary files /dev/null and b/httemplate/view/cust_main/custom_content/.svc_phone.html.swp differ
diff --git a/httemplate/view/cust_main/custom_content/birthdate.html b/httemplate/view/cust_main/custom_content/birthdate.html
new file mode 100644 (file)
index 0000000..1f16963
--- /dev/null
@@ -0,0 +1,15 @@
+<TABLE CLASS="fsinnerbox">
+  <& /elements/tr-td-label.html, 'label' => mt('Date of Birth') &>
+  <TD BGCOLOR="#ffffff"><% $dt ? $dt->strftime($date_format) : '' %></TD>
+</TR>
+</TABLE>
+<%init>
+my($cust_main) = @_;
+my $conf = new FS::Conf;
+my $date_format = ($conf->config('date_format') || "%m/%d/%Y");
+my $dt = $cust_main->birthdate ne ''
+           ? DateTime->from_epoch( 'epoch'     => $cust_main->birthdate,
+                                   'time_zone' =>'floating',
+                                 )
+           : '';
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/small_custview.html b/httemplate/view/cust_main/custom_content/small_custview.html
new file mode 100644 (file)
index 0000000..7c724c7
--- /dev/null
@@ -0,0 +1,4 @@
+<& /elements/small_custview.html, $cust_main &>
+<%init>
+my($cust_main) = @_;
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/spouse_birthdate.html b/httemplate/view/cust_main/custom_content/spouse_birthdate.html
new file mode 100644 (file)
index 0000000..c78fd26
--- /dev/null
@@ -0,0 +1,15 @@
+<TABLE CLASS="fsinnerbox">
+  <& /elements/tr-td-label.html, 'label' => mt('Spouse Date of Birth') &>
+  <TD BGCOLOR="#ffffff"><% $dt ? $dt->strftime($date_format) : '' %></TD>
+</TR>
+</TABLE>
+<%init>
+my($cust_main) = @_;
+my $conf = new FS::Conf;
+my $date_format = ($conf->config('date_format') || "%m/%d/%Y");
+my $dt = $cust_main->spouse_birthdate ne ''
+           ? DateTime->from_epoch( 'epoch'     => $cust_main->spouse_birthdate,
+                                   'time_zone' =>'floating',
+                                 )
+           : '';
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/svc_Common.html b/httemplate/view/cust_main/custom_content/svc_Common.html
new file mode 100644 (file)
index 0000000..bddb8bf
--- /dev/null
@@ -0,0 +1,40 @@
+% foreach my $cust_svc (@cust_svc) {
+%   my $svc_x = $cust_svc->svc_x;
+<TABLE CLASS="fsinnerbox">
+  <TR>
+    <TH COLSPAN=2><% $cust_svc->part_svc->svc |h %></TH>
+  </TR>
+%   foreach my $field ( grep $svc_x->$_(), @{ $opt{fields} } ) {
+      <& /elements/tr-td-label.html, 'label' => $labels{$field} &>
+      <TD BGCOLOR="#ffffff"><% $svc_x->$field() |h %></TD>
+    </TR>
+%   }
+</TABLE>
+% }
+<%init>
+my($cust_main, %opt) = @_;
+
+my $table = $opt{table};
+my @cust_svc = ();
+foreach my $cust_pkg (
+  grep $_->num_cust_svc( 'svcdb'=>$table ),
+       $cust_main->all_pkgs
+) { 
+  my @wtf = $cust_pkg->cust_svc( 'svcdb'=>$table );
+  push @cust_svc, $cust_pkg->cust_svc( 'svcdb'=>$table );
+}
+
+my %labels;
+if ( UNIVERSAL::can("FS::$table", 'table_info') ) {
+#  $opt{'name'}   = "FS::$table"->table_info->{'name'};
+
+  my $fields = "FS::$table"->table_info->{'fields'};
+  %labels = map { $_ =>  ( ref($fields->{$_})
+                            ? $fields->{$_}{'label'}
+                            : $fields->{$_}
+                        );
+                }
+            keys %$fields;
+}
+
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/svc_acct.html b/httemplate/view/cust_main/custom_content/svc_acct.html
new file mode 100644 (file)
index 0000000..49b9798
--- /dev/null
@@ -0,0 +1,7 @@
+<& svc_Common.html, $cust_main,
+  'table' => 'svc_acct',
+  'fields' => [qw( username _password )],
+&>
+<%init>
+my($cust_main) = @_;
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/svc_hardware.html b/httemplate/view/cust_main/custom_content/svc_hardware.html
new file mode 100644 (file)
index 0000000..f5d53a2
--- /dev/null
@@ -0,0 +1,7 @@
+<& svc_Common.html, $cust_main,
+  'table' => 'svc_hardware',
+  'fields' => [qw( ip_addr hw_addr serial )],
+&>
+<%init>
+my($cust_main) = @_;
+</%init>
diff --git a/httemplate/view/cust_main/custom_content/svc_phone.html b/httemplate/view/cust_main/custom_content/svc_phone.html
new file mode 100644 (file)
index 0000000..46ec476
--- /dev/null
@@ -0,0 +1,7 @@
+<& svc_Common.html, $cust_main,
+  'table' => 'svc_phone',
+  'fields' => [qw( phonenum )],
+&>
+<%init>
+my($cust_main) = @_;
+</%init>
index 98c9336..b29d0ce 100755 (executable)
@@ -5,12 +5,17 @@ span.loclabel {
   background-color: #cccccc;
   border: 1px solid black
 }
+table.location {
+  width: 100%;
+  padding: 1px;
+  border-spacing: 0px;
+}
 </STYLE>
 % foreach my $locationnum (@sorted) {
 %   my $packages = $packages_in{$locationnum};
 %   my $loc = $locations{$locationnum};
 %   next if $loc->disabled and scalar(@$packages) == 0;
-<& /elements/table-grid.html &>
+<TABLE CLASS="grid location">
 <TR><TH COLSPAN=3 ALIGN="left" VALIGN="bottom" 
 STYLE="padding-bottom: 0px; 
   padding-left: 0px; 
@@ -18,10 +23,7 @@ STYLE="padding-bottom: 0px;
   border-bottom-color: black;
   border-bottom-width: 1px;">
 <SPAN CLASS="loclabel">
-%   if (! $locationnum) {
-<% mt('Default service location:') |h %> 
-%   }
-%   elsif ( $loc->disabled ) {
+%   if ( $loc->disabled ) {
 <FONT COLOR="#808080"><I>
 %   }
 <% $loc->location_label %></SPAN>
@@ -49,8 +51,7 @@ my %locations = map { $_->locationnum => $_ } qsearch({
     'order_by'  => 'ORDER BY country, state, city, address1, locationnum',
   });
 my @sections = keys %locations;
-$locations{''} = $cust_main;
-my %packages_in = map { $_ => [] } ('', @sections);
+my %packages_in = map { $_ => [] } (@sections);
 
 my %active = (); # groups with non-canceled packages
 foreach my $cust_pkg ( @$all_packages ) {
@@ -58,10 +59,13 @@ foreach my $cust_pkg ( @$all_packages ) {
   push @{ $packages_in{$key} }, $cust_pkg;
   $active{$key} = 1 if !$cust_pkg->getfield('cancel');
 }
+# prevent disabling these
+$active{$cust_main->ship_locationnum} = 1;
+$active{$cust_main->bill_locationnum} = 1;
 
 my @sorted = (
-  '',
-  grep ( { $active{$_} } @sections),
+  $cust_main->ship_locationnum,
+  grep ( { $active{$_} && $_ != $cust_main->ship_locationnum } @sections),
   grep ( { !$active{$_} } @sections),
 );
 
index 2953287..a0ab403 100644 (file)
 
 % }
 
+% if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
+%   my $dt = $cust_main->spouse_birthdate ne ''
+%              ? DateTime->from_epoch( 'epoch'  => $cust_main->spouse_birthdate,
+%                                      'time_zone' =>'floating',
+%                                    )
+%              : '';
+
+  <TR>
+    <TD ALIGN="right"><% mt('Spouse Date of Birth') |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $dt ? $dt->strftime($date_format) : '' %></TD>
+  </TR>
+
+% }
+
 % if ( $conf->exists('cust_main-require_censustract') ) {
 
   <TR>
     <TD ALIGN="right">
-      <% mt('Census tract ([_1])', $cust_main->censusyear) |h %>
+      <% mt('Census tract ([_1])', $cust_main->ship_location->censusyear) |h %>
     </TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->censustract  %></TD>
+    <TD BGCOLOR="#ffffff"><% $cust_main->ship_location->censustract  %></TD>
   </TR>
 
 % }
 
   <TR>
     <TD ALIGN="right"><% mt('Tax district') |h %></TD>
-    <TD BGCOLOR="#ffffff"><% $cust_main->district %></TD>
+    <TD BGCOLOR="#ffffff"><% $cust_main->ship_location->district %></TD>
   </TR>
 
 % }
index 5f458e6..c0a56d0 100644 (file)
@@ -3,7 +3,7 @@
 % ###
 
   <TD CLASS="inv" BGCOLOR="<% $bgcolor %>">
-    <TABLE CLASS="inv" BORDER=0 CELLSPACING=0 CELLPADDING=0 WIDTH="100%">
+    <TABLE CLASS="inv" BORDER=0 CELLSPACING=0 CELLPADDING=2 WIDTH="100%">
     <SCRIPT TYPE="text/javascript">
 function clearhint_search_cust_svc(obj, str) {
   if (obj.value == str) obj.value = '';
index 28df9da..4aec90e 100644 (file)
 
 %   } 
 %
-% } else { 
+%   if ( $part_pkg->freq ) { #?
+
+      <TR>
+        <TD COLSPAN=<%$colspan%>>
+          <FONT SIZE=-1>
+%           if ( $curuser->access_right('Un-cancel customer package') ) { 
+              (&nbsp;<% pkg_uncancel_link($cust_pkg) %>&nbsp;)
+%           } 
+          <FONT>
+        </TD>
+      </TR>
+%   }
+%
+% } else {
 %
 %   if ( $cust_pkg->get('susp') ) { #status: suspended
 %     my $cpr = $cust_pkg->last_cust_pkg_reason('susp');
@@ -56,6 +69,8 @@
       <% pkg_status_row($cust_pkg, emt('Setup'), 'setup', %opt ) %>
 %   } 
 
+    <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
+
     <% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
     <% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
 %   if ( $part_pkg->option('suspend_bill', 1) ) {
              )
           %>
 
+          <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
+
           <TR>
             <TD COLSPAN=<%$colspan%>>
               <FONT SIZE=-1>
           <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
 
           <% pkg_status_row_if($cust_pkg, emt('Start billing'), 'start_date', %opt) %>
+          <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
 
 %       } 
 %
 
           <% pkg_status_row_discount( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
 
+          <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
+
 %       } else { 
 %
 %         my $num_cust_svc = $cust_pkg->num_cust_svc;
 
           <% pkg_status_row($cust_pkg, emt('Setup'), 'setup', %opt) %>
 
+          <% pkg_status_row_if($cust_pkg, emt('Un-cancelled'), 'uncancel', %opt ) %>
+
 %       } 
 %
 %     } 
@@ -467,6 +489,16 @@ sub pkg_cancel_link {
          )
 }
 
+sub pkg_uncancel_link {
+  include( '/elements/popup_link-cust_pkg.html',
+             'action'      => $p. 'misc/cancel_pkg.html?method=uncancel',
+             'label'       => emt('Un-cancel'),
+             'actionlabel' => emt('Un-cancel'),
+             #'color'       =>  #?
+             'cust_pkg'    => shift,
+         )
+}
+
 sub pkg_expire_link {
   include( '/elements/popup_link-cust_pkg.html',
              'action'      => $p. 'misc/cancel_pkg.html?method=expire',
index c7a7c80..9e08c0c 100644 (file)
@@ -491,7 +491,7 @@ foreach my $cust_credit ($cust_main->cust_credit) {
 foreach my $cust_refund ($cust_main->cust_refund) {
   push @history, {
     'date'   => $cust_refund->_date,
-    'desc'   => include('payment_history/refund.html', $cust_refund),
+    'desc'   => include('payment_history/refund.html', $cust_refund, %opt),
     'refund' => $cust_refund->refund,
   };
 
index 599d049..f14a11a 100644 (file)
@@ -22,7 +22,6 @@ body { height: 100%; margin: 0px; padding: 0px }
 
 #map_canvas {
   height: 100%;
-  margin-right: 320px;
 }
 
 #directions_panel {
index de97a07..3ad21bb 100755 (executable)
@@ -47,6 +47,7 @@
 <& svc_acct/basics.html,
               'svc_acct' => $svc_acct,
               'part_svc' => $part_svc,
+              'cust_svc' => $cust_svc,
               %gopt,
 &>
 
@@ -79,6 +80,7 @@
 <& /elements/table-tickets.html, object => $cust_svc &>
 % }
 
+<BR>
 <% joblisting({'svcnum'=>$svcnum}, 1) %>
 
 <& /elements/footer.html &>
index 8f180b6..bcd8469 100644 (file)
@@ -1,6 +1,9 @@
 <% &ntable("#cccccc") %><TR><TD><% &ntable("#cccccc",2) %>
 
 <& /view/elements/tr.html, label=>mt('Service'),  value=>$part_svc->svc &>
+% if ( $opt{cust_svc}->agent_svcid ) {
+  <& /view/elements/tr.html, label=>mt('Legacy ID'),  value=>$opt{cust_svc}->agent_svcid &>
+% }
 <& /view/elements/tr.html, label=>mt('Username'), value=>$svc_acct->username &>
 <& /view/elements/tr.html, label=>mt('Domain'),   value=>$domain &>
 
index c4ca667..14675b8 100644 (file)
@@ -23,7 +23,8 @@ the same name, and should be single-valued fields.
 sub Prepare {
     my $self = shift;
     my $cfname = $self->Argument or return 0;
-    $self->{'inc_by'} = $self->TransactionObj->FirstCustomFieldValue($cfname);
+    $self->{'inc_by'} = $self->TransactionObj->FirstCustomFieldValue($cfname) 
+                        || '';
     return ( $self->{'inc_by'} =~ /^(\d+)$/ );
 }
 
index adafbdf..2775a83 100755 (executable)
@@ -116,15 +116,18 @@ sub Commit {
     if ( my $due_in = $new_queue->DefaultDueIn ) {
         $Due->SetToNow;
         $Due->AddDays( $due_in );
-    }
-    ( $val, $msg ) = $ticket->_Set(
-      Field => 'Due',
-      Value => $Due->ISO,
-      RecordTransaction => 0,
-    );
-    if (! $val) {
-        $RT::Logger->error( "Couldn't set new due date: $msg" );
-        return (0, $msg);
+        
+        if ( $Due->ISO ne $ticket->Due ) {
+            ( $val, $msg ) = $ticket->_Set(
+              Field => 'Due',
+              Value => $Due->ISO,
+              RecordTransaction => 0,
+            );
+            if (! $val) {
+                $RT::Logger->error( "Couldn't set new due date: $msg" );
+                return (0, $msg);
+            }
+        }
     }
     return 1;
 }