Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Fri, 26 Jul 2019 18:14:05 +0000 (11:14 -0700)
committerIvan Kohler <ivan@freeside.biz>
Fri, 26 Jul 2019 18:14:05 +0000 (11:14 -0700)
40 files changed:
FS/FS/Conf.pm
FS/FS/ConfDefaults.pm
FS/FS/Cron/tax_rate_update.pm
FS/FS/TaxEngine/internal.pm
FS/FS/UI/Web.pm
FS/FS/cdr/telapi_voip.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_main/Search.pm
FS/FS/cust_main_county.pm
FS/FS/cust_pay.pm
FS/FS/part_event_option.pm
FS/FS/part_export/saisei.pm
FS/FS/phone_type.pm
FS/bin/freeside-issue-credit-for-taxnums
FS/bin/freeside-svcbroadband_update_speeds
FS/bin/freeside-wa-tax-table-resolve [new file with mode: 0755]
FS/bin/freeside-wa-tax-table-update
fs_selfservice/FS-SelfService/SelfService.pm
fs_selfservice/FS-SelfService/cgi/contact.html
httemplate/config/config-process.cgi
httemplate/edit/msg_template/email.html
httemplate/edit/part_export.cgi
httemplate/edit/process/cust_main_county-add.cgi
httemplate/elements/city.html
httemplate/elements/contact.html
httemplate/elements/progress-init.html
httemplate/elements/select-cust-fields.html
httemplate/elements/tr-amount_fee.html
httemplate/elements/tr-select-cust-fields.html
httemplate/elements/tr-select-payment_options.html
httemplate/misc/process/payment.cgi
httemplate/search/contact.html
httemplate/search/cust_main.html
httemplate/search/elements/cust_main_phones.html [new file with mode: 0644]
httemplate/search/elements/options_cust_contacts.html
httemplate/search/report_cust_main.html
httemplate/view/cust_main/contacts_new.html
httemplate/view/svc_export/run_script.cgi

index 0f774d1..9e68ffc 100644 (file)
@@ -2683,6 +2683,17 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'processing-fee_on_separate_invoice',
+    'section'     => 'payments',
+    'description' => 'Places the processing fee on a separate invoice by itself.  Only works with real time processing.',
+    'type'        => 'checkbox',
+    'validate'    => sub {
+                        my $conf = new FS::Conf;
+                        !$conf->config('batch-enable_payby') ? '' : 'You can not set this option while batch processing is enabled.';
+                     },
+  },
+
+  {
     'key'         => 'banned_pay-pad',
     'section'     => 'credit_cards',
     'description' => 'Padding for encrypted storage of banned credit card hashes.  If you already have new-style SHA512 entries in the banned_pay table, do not change as this will invalidate the old entries.',
@@ -3840,6 +3851,11 @@ and customer address. Include units.',
     'description' => 'Enable batch processing for the specified payment types.',
     'type'        => 'selectmultiple',
     'select_enum' => [qw( CARD CHEK )],
+    'validate'    => sub {
+                        ## can not create a new invoice and pay it silently with batch processing, only realtime processing.
+                        my $conf = new FS::Conf;
+                        !$conf->exists('processing-fee_on_separate_invoice') ? '' : 'You can not enable batch processing while processing-fee_on_separate_invoice option is enabled.';
+                     },
   },
 
   {
index bd58934..34987f4 100644 (file)
@@ -36,9 +36,9 @@ sub cust_fields_avail { (
   'Agent | Agent Cust# or Cust# | Cust. Status | Customer' =>
     'Agent | Agent Cust# | Status | Last, First or Company (Last, First)',
 
-  'Customer | Day phone | Night phone | Mobile phone | Fax number' =>
+  'Customer | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s)' =>
     'Customer | (all phones)',
-  'Cust# | Customer | Day phone | Night phone | Mobile phone | Fax number' =>
+  'Cust# | Customer | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s)' =>
     'custnum | Customer | (all phones)',
 
   'Cust. Status | Name | Company' =>
@@ -56,28 +56,28 @@ sub cust_fields_avail { (
   'Cust# | Cust. Status | Name | Company' =>
     'custnum | Status | Last, First | Company',
 
-  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Contact email(s) | Invoices | Messages' =>
+  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | Contact email(s) | Invoices | Messages' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | Contact email(s)',
 
-  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s)' =>
+  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | Invoicing email(s)' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s)',
 
-  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Current Balance' =>
+  'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | Invoicing email(s) | Current Balance' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s)' =>
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s)' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s)',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
     'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
+  'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
     'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance' =>
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance' =>
     'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance',
 
-  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance | Advertising Source' =>
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | Contact phone(s) | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance | Advertising Source' =>
     'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance | Advertising Source',
 
   'Invoicing email(s)' => 'Invoicing email(s)',
index bb9d4d1..fd291af 100755 (executable)
@@ -294,6 +294,14 @@ sub wa_sales_update_tax_table {
     )
   );
 
+  unless ( wa_sales_update_tax_table_sanity_check() ) {
+    log_error_and_die(
+      'Duplicate district rows exist in the Washington state sales tax table. '.
+      'These must be resolved before updating the tax tables. '.
+      'See "freeside-wa-tax-table-resolve --check" to repair the tax tables. '
+    );
+  }
+
   $args->{temp_dir} ||= tempdir();
 
   $args->{filename} ||= wa_sales_fetch_xlsx_file( $args );
@@ -326,6 +334,8 @@ sub wa_sales_update_cust_main_county {
   my $update_count = 0;
   my $same_count   = 0;
 
+  $args->{taxname} ||= 'State Sales Tax';
+
   # Work within a SQL transaction
   local $FS::UID::AutoCommit = 0;
 
@@ -351,7 +361,7 @@ sub wa_sales_update_cust_main_county {
         cust_main_county => {
           source    => 'wa_sales',
           district  => { op => '!=', value => undef },
-          tax_class => $taxclass,
+          taxclass => $taxclass,
         }
       )
     ) {
@@ -376,24 +386,38 @@ sub wa_sales_update_cust_main_county {
       $cust_main_county{$district} = $row;
     }
 
-    # Merge any dupes, place resulting non-dupe row in %cust_main_county
-    #  Merge, even if one of the dupes has a $0 tax, or some other
-    #  variation on tax row data.  Data for this row will get corrected
-    #  during the following tax import
-    for my $dupe_district_aref ( values %cust_main_county_dupe ) {
-      my $row_to_keep = shift @$dupe_district_aref;
-      while ( my $row_to_merge = shift @$dupe_district_aref ) {
-        $row_to_merge->_merge_into(
-          $row_to_keep,
-          { identical_record_check => 0 },
-        );
-      }
-      $cust_main_county{$row_to_keep->district} = $row_to_keep;
+    # # Merge any dupes, place resulting non-dupe row in %cust_main_county
+    # #  Merge, even if one of the dupes has a $0 tax, or some other
+    # #  variation on tax row data.  Data for this row will get corrected
+    # #  during the following tax import
+    # for my $dupe_district_aref ( values %cust_main_county_dupe ) {
+    #   my $row_to_keep = shift @$dupe_district_aref;
+    #   while ( my $row_to_merge = shift @$dupe_district_aref ) {
+    #     $row_to_merge->_merge_into(
+    #       $row_to_keep,
+    #       { identical_record_check => 0 },
+    #     );
+    #   }
+    #   $cust_main_county{$row_to_keep->district} = $row_to_keep;
+    # }
+
+    # If there are duplicate rows, it may be unsafe to auto-resolve them
+    if ( %cust_main_county_dupe ) {
+      warn "Unable to continue!";
+      log_error_and_die( sprintf(
+        'Tax district duplicate rows detected(%s) - '.
+        'WA Sales tax tables cannot be updated without resolving duplicates - '.
+        'Please use tool freeside-wa-tax-table-resolve for tax table repair',
+            join( ',', keys %cust_main_county_dupe )
+      ));
     }
 
-    for my $district ( @{ $args->{tax_districts} } ) {
+    DIST: for my $district ( @{ $args->{tax_districts} } ) {
       if ( my $row = $cust_main_county{ $district->{district} } ) {
 
+        # Strip whitespace from input
+        $district->{$_} =~ s/(^\s+|\s+$)//g for keys %$district;
+
         # District already exists in this taxclass, update if necessary
         #
         # If admin updates value of conf tax_district_taxname, instead of
@@ -405,20 +429,20 @@ sub wa_sales_update_cust_main_county {
           no warnings 'uninitialized';
 
           if (
-            $row->tax == ( $district->{tax_combined} * 100 )
+            sprintf('%.4f',$row->tax) == sprintf('%.4f',($district->{tax_combined} * 100))
             &&    $row->taxname eq    $args->{taxname}
             && uc $row->county  eq uc $district->{county}
             && uc $row->city    eq uc $district->{city}
           ) {
             $same_count++;
-            next;
+            next DIST;
           }
         }
 
         $row->city( uc $district->{city} );
         $row->county( uc $district->{county} );
         $row->taxclass( $taxclass );
-        $row->taxname( $args->{taxname} || undef );
+        $row->taxname( $args->{taxname} );
         $row->tax( $district->{tax_combined} * 100 );
 
         if ( my $error = $row->replace ) {
@@ -466,6 +490,8 @@ sub wa_sales_update_cust_main_county {
         $insert_count++;
       }
 
+      update_non_sales_tax_rows( $taxclass, $district );
+
     } # /foreach $district
   } # /foreach $taxclass
 
@@ -483,6 +509,47 @@ sub wa_sales_update_cust_main_county {
 
 }
 
+=head2 update_non_sales_tax_rows tax_class, $district_href
+
+The customer may have created additional taxes, such as Universal Service Fund.
+
+Ensure the columns for city and county are consistant between
+the user-created tax rows and the wa-sales-managed tax rows.
+
+=cut
+
+sub update_non_sales_tax_rows {
+  my ( $taxclass, $district ) = @_;
+
+  return unless ref $district && $district->{district};
+
+  my @rows = qsearch( cust_main_county => {
+    taxclass => $taxclass,
+    district => $district->{district},
+    state    => 'WA',
+    country  => 'US',
+    source   => { op => '!=', value => 'wa_sales' },
+  });
+
+  for my $row ( @rows ) {
+    $row->city( uc $district->{city} );
+    $row->county( uc $district->{county} );
+
+    if ( my $error = $row->replace ) {
+      dbh->rollback;
+      local $FS::UID::AutoCommit = 1;
+      log_error_and_die(
+        sprintf
+          "Error updating cust_main_county row %s for district %s: %s",
+          $row->taxnum,
+          $district->{district},
+          $error
+      );
+    }
+  }
+
+}
+
 =head2 wa_sales_parse_xlsx_file \%args
 
 Parse given XLSX file for tax district information
@@ -635,6 +702,26 @@ sub wa_sales_fetch_xlsx_file {
 
 }
 
+=head2 wa_sales_update_tax_table_sanity_check
+
+There should be no duplicate tax table entries in the tax table,
+with the same district value, within a tax class, where source=wa_sales.
+
+If there are, custome taxes may have been user-entered in the
+freeside UI, and incorrectly labelled as source=wa_sales.  Or, the
+dupe record may have been created by issues with older wa_sales code.
+
+If these dupes exist, the sysadmin must solve the problem by hand
+with the freeeside-wa-tax-table-resolve script
+
+Returns 1 unless problem sales tax entries are detected
+
+=cut
+
+sub wa_sales_update_tax_table_sanity_check {
+  FS::cust_main_county->find_wa_tax_dupes ? 0 : 1;
+}
+
 sub log {
   state $log = FS::Log->new('tax_rate_update');
   $log;
@@ -655,6 +742,7 @@ sub log_warn_and_warn {
 sub log_error_and_die {
   my $log_message = shift;
   &log()->error( $log_message );
+  warn( "$log_message\n" );
   die( "$log_message\n" );
 }
 
index 6fb1ca7..d680af8 100644 (file)
@@ -39,10 +39,28 @@ sub add_sale {
   my @taxes = (); # entries are cust_main_county objects
   my %taxhash_elim = %taxhash;
   my @elim = qw( district city county state );
+
+  # WA state district city names are not stable in the WA tax tables
+  # Allow districts to match with just a district id
+  if ( $taxhash{district} ) {
+    @taxes = qsearch( cust_main_county => {
+      district => $taxhash{district},
+      taxclass => $taxhash{taxclass},
+    });
+    if ( !scalar(@taxes) && $taxhash{taxclass} ) {
+      qsearch( cust_main_county => {
+        district => $taxhash{district},
+        taxclass => '',
+      });
+    }
+  }
+
   do {
 
     #first try a match with taxclass
-    @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+    if ( !scalar(@taxes) ) {
+      @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+    }
 
     if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
       #then try a match without taxclass
index b6dda8f..2d12f7d 100644 (file)
@@ -7,7 +7,7 @@ use Carp qw( confess );
 use HTML::Entities;
 use FS::Conf;
 use FS::Misc::DateTime qw( parse_datetime day_end );
-use FS::Record qw(dbdef);
+use FS::Record qw(dbdef qsearch);
 use FS::cust_main;  # are sql_balance and sql_date_balance in the right module?
 
 #use vars qw(@ISA);
@@ -357,6 +357,24 @@ sub cust_header {
   $header2method{'Cust#'} = 'display_custnum'
     if $conf->exists('cust_main-default_agent_custid');
 
+foreach my $phone_type ( FS::phone_type->get_phone_types() ) {
+  $header2method{'Contact '.$phone_type->typename.' phone(s)'} = sub {
+    my $self = shift;
+    my $num = $phone_type->phonetypenum;
+
+    my @phones;
+    foreach (FS::cust_main::contact_list_name_phones($self)) {
+      my $data = [
+        {
+          'data'  => $_->first.' '.$_->last.' '.FS::contact_phone::phonenum_pretty($_),
+        },
+      ];
+      push @phones, $data if $_->phonetypenum eq $phone_type->phonetypenum;
+    }
+  return \@phones;
+  };
+}
+
   my %header2colormethod = (
     'Cust. Status' => 'cust_statuscolor',
   );
@@ -457,7 +475,7 @@ sub cust_sql_fields {
     }
   }
 
-  foreach my $field (qw(daytime night mobile fax )) {
+  foreach my $field (qw(daytime night mobile fax)) {
     push @fields, $field if (grep { $_ eq $field } @cust_fields);
   }
   push @fields, 'agent_custid';
index abc7d5b..687c431 100644 (file)
@@ -7,46 +7,22 @@ use FS::Record qw( qsearch );
 use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
 
 %info = (
-  'name'          => 'telapi_voip',
+  'name'          => 'telapi_voip (csv file)',
   'weight'        => 601,
   'header'        => 1,
   'type'          => 'csv',
   'import_fields' => [
-    _cdr_date_parser_maker('startdate'),  #'date gmt'
+    skip(1),                              # Inbound/Outbound
+    _cdr_date_parser_maker('startdate'),  # date
+    skip(1),                              # cost per minute
+    'upstream_price',                     # call cost
+    'billsec',                            # duration
     'src',                                # source
     'dst',                                # destination
-    'clid',                               # callerid
     skip(1),                              # hangup code
-    skip(1),                              # sip account
-    'src_ip_addr',                        # orig ip
-    'duration',                           # duration
-    skip(1),                              # per minute
-    'upstream_price',                     # callcost
-    sub {
-      my($cdr, $cdrtypename, $conf, $param) = @_;
-      return unless length($cdrtypename);
-      _init_cdr_types();
-      unless (defined $CDR_TYPES->{$cdrtypename}) {
-        warn "Skipping Record: CDR type name $cdrtypename does not exist!";
-        $param->{skiprow} = 1;
-      }
-      $cdr->cdrtypenum($CDR_TYPES->{$cdrtypename});
-    },                                   # type 
-    _cdr_min_parser_maker('billsec'),     #PriceDurationMins
   ],
 );
 
 sub skip { map {''} (1..$_[0]) }
 
-sub _init_cdr_types {
-  unless ($CDR_TYPES) {
-    $CDR_TYPES = {};
-    foreach my $cdr_type ( qsearch('cdr_type') ) {
-      die "multiple cdr_types with same cdrtypename".$cdr_type->cdrtypename
-        if defined $CDR_TYPES->{$cdr_type->cdrtypename};
-      $CDR_TYPES->{$cdr_type->cdrtypename} = $cdr_type->cdrtypenum;
-    }
-  }
-}
-
 1;
\ No newline at end of file
index 10433ed..57d598a 100644 (file)
@@ -3175,6 +3175,32 @@ sub contact_list_email_destinations {
     });
 }
 
+=item contact_list_name_phones
+
+Returns a list of contact phone numbers.
+{ phonetypenum => '1', phonenum => 'xxxxxxxxxx', first => 'firstname', last => 'lastname', countrycode => '1' }
+
+=cut
+
+sub contact_list_name_phones {
+  my $self = shift;
+  my $phone_type = shift;
+
+  warn "$me contact_list_phones" if $DEBUG;
+
+  return () if !$self->custnum; # not yet inserted
+  return map { $_ }
+    qsearch({
+        table     => 'cust_contact',
+        select    => 'phonetypenum, phonenum, first, last, countrycode',
+        addl_from => ' JOIN contact USING (contactnum) '.
+                     ' JOIN contact_phone USING (contactnum)',
+        hashref   => { 'custnum' => $self->custnum, 'phonetypenum' => $phone_type, },
+        order_by  => 'ORDER BY custcontactnum DESC',
+        extra_sql => '',
+    });
+}
+
 =item contact_list_emailonly
 
 Returns an array of hashes containing the emails. Used for displaying contact email field in advanced customer reports.
index 47cbbf1..c0c15e4 100644 (file)
@@ -1651,10 +1651,28 @@ sub _handle_taxes {
     my @taxes = (); # entries are cust_main_county objects
     my %taxhash_elim = %taxhash;
     my @elim = qw( district city county state );
+
+    # WA state district city names are not stable in the WA tax tables
+    # Allow districts to match with just a district id
+    if ( $taxhash{district} ) {
+      @taxes = qsearch( cust_main_county => {
+        district => $taxhash{district},
+        taxclass => $taxhash{taxclass},
+      });
+      if ( !scalar(@taxes) && $taxhash{taxclass} ) {
+        qsearch( cust_main_county => {
+          district => $taxhash{district},
+          taxclass => '',
+        });
+      }
+    }
+
     do { 
 
       #first try a match with taxclass
-      @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+      if ( !scalar(@taxes) ) {
+        @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+      }
 
       if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
         #then try a match without taxclass
index 1ac12ce..b65860e 100644 (file)
@@ -323,7 +323,8 @@ sub _bop_cust_payby_options {
 
     if ( $cust_payby->locationnum ) {
       my $cust_location = $cust_payby->cust_location;
-      $options->{$_} = $cust_location->$_() for qw( address1 address2 city state zip );
+      $options->{$_} = $cust_location->$_()
+        for qw( address1 address2 city state zip country );
     }
   }
 }
@@ -984,17 +985,20 @@ sub _realtime_bop_result {
     savepoint_create( $savepoint_label );
 
     #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
-
-    my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+    my $error = $cust_pay->insert(
+      $options{'manual'} ? ( 'manual' => 1 ) : (),
+      $options{'processing-fee'} > 0 ? ( 'processing-fee' => $options{'processing-fee'} ) : (),
+    );
 
     if ( $error ) {
       savepoint_rollback( $savepoint_label );
 
       $cust_pay->invnum(''); #try again with no specific invnum
       $cust_pay->paynum('');
-      my $error2 = $cust_pay->insert( $options{'manual'} ?
-                                      ( 'manual' => 1 ) : ()
-                                    );
+      my $error2 = $cust_pay->insert(
+        $options{'manual'} ? ( 'manual' => 1 ) : (),
+        $options{'processing-fee'} > 0 ? ( 'processing-fee' => $options{'processing-fee'} ) : (),
+      );
       if ( $error2 ) {
         # gah.  but at least we have a record of the state we had to abort in
         # from cust_pay_pending now.
@@ -1136,11 +1140,23 @@ sub _realtime_bop_result {
     if ($options{'processing-fee'} > 0) {
       my $pf_cust_pkg;
       my $processing_fee_text = 'Payment Processing Fee';
+
+      my $conf = new FS::Conf;
+
+      my $pf_seperate_bill;
+      my $pf_bill_now;
+      if ($conf->exists('processing-fee_on_separate_invoice')) {
+        $pf_seperate_bill = 'Y';
+        $pf_bill_now = '1';
+      }
+
       my $pf_change_error = $self->charge({
             'amount'  => $options{'processing-fee'},
             'pkg'   => $processing_fee_text,
             'setuptax'  => 'Y',
             'cust_pkg_ref' => \$pf_cust_pkg,
+            'separate_bill' => $pf_seperate_bill,
+            'bill_now' => $pf_bill_now,
       });
 
       if($pf_change_error) {
@@ -1155,17 +1171,41 @@ sub _realtime_bop_result {
         # but keep going...
       }
 
-      my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
-      unless ( $cust_bill ) {
-        warn "race condition + invoice deletion just happened";
-        return '';
-      }
+      if ($conf->exists('processing-fee_on_separate_invoice')) {
+        my $cust_bill_pkg = qsearchs( 'cust_bill_pkg', { 'pkgnum' => $pf_cust_pkg->pkgnum } );
+
+        my $pf_cust_bill = qsearchs('cust_bill', { 'invnum' => $cust_bill_pkg->invnum });
+        unless ( $pf_cust_bill ) {
+          warn "no processing fee inv found!";
+          return '';
+        }
+
+        my $pf_apply_error = $pf_cust_bill->apply_payments_and_credits;
+
+        my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
+        unless ( $cust_bill ) {
+          warn "race condition + invoice deletion just happened";
+         return '';
+        }
+
+        my $grand_pf_error = $cust_bill->apply_payments_and_credits;
+
+        warn "cannot apply Processing fee to invoice #$invnum: $grand_pf_error - $pf_apply_error"
+          if $grand_pf_error || $pf_apply_error;
+      } ## processing-fee_on_separate_invoice
+      else {
+        my $cust_bill = qsearchs('cust_bill', { 'invnum' => $invnum });
+        unless ( $cust_bill ) {
+          warn "race condition + invoice deletion just happened";
+          return '';
+        }
 
-      my $grand_pf_error =
-        $cust_bill->add_cc_surcharge($pf_cust_pkg->pkgnum,$options{'processing-fee'});
+        my $grand_pf_error =
+          $cust_bill->add_cc_surcharge($pf_cust_pkg->pkgnum,$options{'processing-fee'});
 
-      warn "cannot add Processing fee to invoice #$invnum: $grand_pf_error"
-        if $grand_pf_error;
+        warn "cannot add Processing fee to invoice #$invnum: $grand_pf_error"
+          if $grand_pf_error;
+      } ## no processing-fee_on_separate_invoice
     } #end if $options{'processing-fee'}
 
       } #end if ( $options{'cc_surcharge'} > 0 || $options{'processing-fee'} > 0)
index ae219c8..25216c6 100644 (file)
@@ -789,6 +789,21 @@ sub search {
     )";
   }
 
+  ##
+  # phones
+  ##
+
+  foreach my $phonet (qw(daytime night mobile fax)) {
+    if ($params->{$phonet}) {
+      $params->{$phonet} =~ s/\D//g;
+      $params->{$phonet} =~ /^(\d{3})(\d{3})(\d{4})(\d*)$/
+        or next;
+      my $phonen = "$1-$2-$3";
+      if ($4) { push @where, "cust_main.".$phonet." = '".$phonen." x$4'"; }
+      else { push @where, "cust_main.".$phonet." like '".$phonen."%'"; }
+    }
+  }
+
   ###
   # refnum
   ###
@@ -1090,25 +1105,24 @@ sub search {
               ) ";
     }
 
-    if ($contact_params->{'contacts_homephone'} || $contact_params->{'contacts_workphone'} || $contact_params->{'contacts_mobilephone'}) {
-      foreach my $phone (qw( contacts_homephone contacts_workphone contacts_mobilephone )) {
+    if ( grep { /^contacts_phonetypenum(\d+)$/ } keys %{ $contact_params } ) {
+      my $phone_query;
+      foreach my $phone ( grep { /^contacts_phonetypenum(\d+)$/ } keys %{ $contact_params } ) {
+        $phone =~ /^contacts_phonetypenum(\d+)$/ or die "No phone type num $1 from $phone";
+        my $phonetypenum = $1;
         (my $num = $contact_params->{$phone}) =~ s/\W//g;
         if ( $num =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { $contact_params->{$phone} = "$1$2$3"; }
+        $phone_query .= " AND ( contact_phone.phonetypenum = '".$phonetypenum."' AND contact_phone.phonenum = '" . $contact_params->{$phone} . "' )"
+        unless !$contact_params->{$phone};
       }
-      my $home_query = " AND ( contact_phone.phonetypenum = '2' AND contact_phone.phonenum = '" . $contact_params->{'contacts_homephone'} . "' )"
-        unless !$contact_params->{'contacts_homephone'};
-      my $work_query = " AND ( contact_phone.phonetypenum = '1' AND contact_phone.phonenum = '" . $contact_params->{'contacts_workphone'} . "' )"
-        unless !$contact_params->{'contacts_workphone'};
-      my $mobile_query = " AND ( contact_phone.phonetypenum = '3' AND contact_phone.phonenum = '" . $contact_params->{'contacts_mobilephone'} . "' )"
-        unless !$contact_params->{'contacts_mobilephone'};
       push @where,
       "EXISTS ( SELECT 1 FROM contact_phone
                 JOIN cust_contact USING (contactnum)
                 WHERE cust_contact.custnum = cust_main.custnum
-                $home_query $work_query $mobile_query
+                $phone_query
               ) ";
     }
-}
+  }
 
 
   ##
index 2bd7342..9582334 100644 (file)
@@ -562,6 +562,40 @@ sub taxline {
   return $tax_item;
 }
 
+=head1 find_wa_tax_dupes
+
+Return a list of cust_main_county Record objects that are detected
+as duplicate washington state sales tax rows (source=wa_state)
+within their respective tax classes
+
+=cut
+
+sub find_wa_tax_dupes {
+  my %cust_main_county;
+  my @dupes;
+
+  for my $row ( qsearch( cust_main_county => { source => 'wa_sales' } ) ) {
+    my $taxclass = $row->taxclass || 'none';
+    $cust_main_county{$taxclass} ||= {};
+
+    my $district = $row->district || 'none';
+    $cust_main_county{$taxclass}->{$district} ||= [];
+
+    push @{ $cust_main_county{$taxclass}->{$district} }, $row;
+  }
+
+  for my $taxclass ( keys %cust_main_county ) {
+    for my $district ( keys %{ $cust_main_county{$taxclass} } ) {
+      my $tax_rows = $cust_main_county{$taxclass}->{$district};
+      if ( scalar @$tax_rows > 1 ) {
+        push @dupes, @$tax_rows;
+      }
+    }
+  }
+
+  @dupes;
+}
+
 =back
 
 =head1 SUBROUTINES
index 915cb33..c0a2541 100644 (file)
@@ -395,6 +395,8 @@ sub insert {
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
 
+  $self->{'processing_fee'} = $options{'processing-fee'};
+
   #payment receipt
   my $trigger = $conf->config('payment_receipt-trigger', 
                               $self->cust_main->agentnum) || 'cust_pay';
@@ -735,6 +737,8 @@ sub send_message_receipt {
 
       my %substitutions = ();
       $substitutions{invnum} = $cust_bill->invnum if $cust_bill;
+      $substitutions{'processing_fee'} = $self->{'processing_fee'};
+
 
       my $msg_template = qsearchs('msg_template',{ msgnum => $msgnum});
       unless ($msg_template) {
index 1421f6f..6c34c68 100644 (file)
@@ -189,7 +189,8 @@ sub check {
 
   if ( my %option_fields = $self->option_fields ) {
     if ( my $option_field = $option_fields{ $self->optionname } ) {
-      if ( my $validation_method = $option_field->{validation} ) {
+      if ( ref $option_field && $option_field->{validation} ) {
+        my $validation_method = $option_field->{validation};
         $error = $self->$validation_method('optionvalue');
       }
     }
index 8376c7e..9dba56b 100644 (file)
@@ -42,6 +42,7 @@ Create a tower and add a sector to that tower.  The sector name will be the name
 Make sure you have set the up and down rate limit for the tower and the sector.  This is required to be able to export the access point.
 The tower and sector will be set up as access points at Saisei upon the creation of the tower or sector.  They will be modified at Saisei when modified in freeside.
 Each sector will be attached to its tower access point using the Saisei uplink field.
+Each access point will be attached to the interface set in the export config.  If left blank access point will be attached to the default interface.  Most setups can leave this blank.
 
 Create a package for the above created service, and order this package for a customer.
 
@@ -65,6 +66,27 @@ tie my %scripts, 'Tie::IxHash',
                                       error_url  => '/edit/part_export.cgi?',
                                       success_message => 'Saisei export of provisioned services successful',
                                     },
+  'export_all_towers_sectors'    => { component => '/elements/popup_link.html',
+                                      label     => 'Export of all towers and sectors',
+                                      description => 'Will force an export of all towers and sectors to Saisei as access points.',
+                                      html_label => '<b>Export all towers and sectors.</b>',
+                                      error_url  => '/edit/part_export.cgi?',
+                                      success_message => 'Saisei export of towers and sectors as access points successful',
+                                    },
+  'force_export_all_users'       => { component => '/elements/popup_link.html',
+                                      label     => 'Force update of all Saisei users from freeside provisioned services',
+                                      description => 'Will force an update of Saisei users description and map location from freeside provisioned services.',
+                                      html_label => '<b>Force update of all Saisei users from freeside provisioned services</b>',
+                                      error_url  => '/edit/part_export.cgi?',
+                                      success_message => 'Export of freeside provisioned services as Saisei users was successful',
+                                    },
+  'force_export_all_virtual_ap'  => { component => '/elements/popup_link.html',
+                                      label     => 'Force update of all virtual Access Points',
+                                      description => 'Will force an update of all virtual access points.',
+                                      html_label => '<b>Force update of all virtual Access Points</b>',
+                                      error_url  => '/edit/part_export.cgi?',
+                                      success_message => 'Export of all virtual access points to Saisei was successful',
+                                    },
 ;
 
 tie my %options, 'Tie::IxHash',
@@ -74,6 +96,8 @@ tie my %options, 'Tie::IxHash',
                           default => '' },
   'password'         => { label => 'Saisei API Password',
                           default => '' },
+  'interface'        => { label => 'Saisei Access Point Interface',
+                          default => '' },
   'debug'            => { type => 'checkbox',
                           label => 'Enable debug warnings' },
 ;
@@ -105,6 +129,7 @@ Create a tower and add a sector to that tower.  The sector name will be the name
 Make sure you have set the up and down rate limit for the tower and the sector.  This is required to be able to export the access point.
 The tower and sector will be set up as access points at Saisei upon the creation of the tower or sector.  They will be modified at Saisei when modified in freeside.
 Each sector will be attached to its tower access point using the Saisei uplink field.
+Each access point will be attached to the interface set in the export config.  If left blank access point will be attached to the default interface.  Most setups can leave this blank.
 </LI>
 <P>
 <LI>
@@ -129,7 +154,7 @@ END
 );
 
 sub _export_insert {
-  my ($self, $svc_broadband) = @_;
+  my ($self, $svc_broadband, $force_update) = @_;
 
   my $rateplan_name = $self->get_rateplan_name($svc_broadband);
 
@@ -137,16 +162,19 @@ sub _export_insert {
   my $existing_rateplan;
   $existing_rateplan = $self->api_get_rateplan($rateplan_name) unless $self->{'__saisei_error'};
 
+  die ("Please double check your credentials as ".$existing_rateplan->{message}."\n") if $existing_rateplan->{message};
+
   # if no existing rate plan create one and modify it.
-  $self->api_create_rateplan($svc_broadband, $rateplan_name) unless $existing_rateplan;
-  $self->api_modify_rateplan($svc_broadband, $rateplan_name) unless ($self->{'__saisei_error'} || $existing_rateplan);
+  $self->api_create_rateplan($svc_broadband, $rateplan_name) unless $existing_rateplan->{collection};
+  $self->api_modify_rateplan($svc_broadband, $rateplan_name) unless ($self->{'__saisei_error'} || $existing_rateplan->{collection});
   return $self->api_error if $self->{'__saisei_error'};
 
   # set rateplan to existing one or newly created one.
-  my $rateplan = $existing_rateplan ? $existing_rateplan : $self->api_get_rateplan($rateplan_name);
+  my $rateplan = $existing_rateplan->{collection} ? $existing_rateplan : $self->api_get_rateplan($rateplan_name);
 
   my $username = $svc_broadband->{Hash}->{svcnum};
   my $description = $svc_broadband->{Hash}->{description};
+  my $svc_location = get_svc_location($self, $svc_broadband);
 
   if (!$username) {
     $self->{'__saisei_error'} = 'no username - can not export';
@@ -158,7 +186,7 @@ sub _export_insert {
     $existing_user = $self->api_get_user($username) unless $self->{'__saisei_error'};
  
     # if no existing user create one.
-    $self->api_create_user($username, $description) unless $existing_user;
+    $self->api_create_user($username, $description, $svc_location) unless $existing_user;
     return $self->api_error if $self->{'__saisei_error'};
 
     # set user to existing one or newly created one.
@@ -173,13 +201,18 @@ sub _export_insert {
                       tower_sector.sectorname,
                       tower_sector.towernum,
                       tower_sector.up_rate_limit as sector_upratelimit,
-                      tower_sector.down_rate_limit as sector_downratelimit ',
+                      tower_sector.down_rate_limit as sector_downratelimit,
+                      tower.latitude,
+                      tower.longitude',
       'addl_from' => 'LEFT JOIN tower USING ( towernum )',
       'hashref'   => {
                         'sectornum' => $svc_broadband->{Hash}->{sectornum},
                      },
     });
 
+    my $tower_location;
+    $tower_location = $tower_sector->{Hash}->{latitude}.','.$tower_sector->{Hash}->{longitude} if ($tower_sector->{Hash}->{latitude} && $tower_sector->{Hash}->{longitude});
+
     my $tower_name = $tower_sector->{Hash}->{towername};
     $tower_name =~ s/\s/_/g;
 
@@ -189,6 +222,7 @@ sub _export_insert {
       'tower_uprate_limit'   => $tower_sector->{Hash}->{tower_upratelimit},
       'tower_downrate_limit' => $tower_sector->{Hash}->{tower_downratelimit},
     };
+    $tower_opt->{'location'} = $tower_location if $tower_location;
 
     my $tower_ap = process_tower($self, $tower_opt);
     return $self->api_error if $self->{'__saisei_error'};
@@ -204,6 +238,8 @@ sub _export_insert {
       'sector_downrate_limit' => $tower_sector->{Hash}->{sector_downratelimit},
       'rateplan'              => $rateplan_name,
     };
+    $sector_opt->{'location'} = $tower_location if $tower_location;
+
     my $accesspoint = process_sector($self, $sector_opt);
     return $self->api_error if $self->{'__saisei_error'};
 
@@ -212,24 +248,31 @@ sub _export_insert {
       'table'     => 'cust_pkg',
       'hashref'   => { 'pkgnum' => $svc_broadband->{Hash}->{pkgnum}, },
     });
+
     my $virtual_ap_name = $cust_pkg->{Hash}->{custnum}.'_'.$cust_pkg->{Hash}->{pkgpart}.'_'.$svc_broadband->{Hash}->{speed_down}.'_'.$svc_broadband->{Hash}->{speed_up};
+    my $modify_existing_virtual_ap = '1' if $force_update->{'update_virtual_ap'};
 
     my $virtual_ap_opt = {
       'virtual_name'           => $virtual_ap_name,
       'sector_name'            => $sector_name,
       'virtual_uprate_limit'   => $svc_broadband->{Hash}->{speed_up},
       'virtual_downrate_limit' => $svc_broadband->{Hash}->{speed_down},
+      'location'               => $svc_location,
+      'modify_existing'        => $modify_existing_virtual_ap,
     };
     my $virtual_ap = process_virtual_ap($self, $virtual_ap_opt);
     return $self->api_error if $self->{'__saisei_error'};
 
     ## tie host to user add sector name as access point.
-    $self->api_add_host_to_user(
-      $user->{collection}->[0]->{name},
-      $rateplan->{collection}->[0]->{name},
-      $svc_broadband->{Hash}->{ip_addr},
-      $virtual_ap->{collection}->[0]->{name},
-    ) unless $self->{'__saisei_error'};
+    my $host_opt = {
+      'user'        => $user->{collection}->[0]->{name},
+      'rateplan'    => $rateplan->{collection}->[0]->{name},
+      'ip'          => $svc_broadband->{Hash}->{ip_addr},
+      'accesspoint' => $virtual_ap->{collection}->[0]->{name},
+      'location'    => $svc_location,
+    };
+    $self->api_add_host_to_user($host_opt)
+      unless $self->{'__saisei_error'};
   }
 
   return $self->api_error;
@@ -331,6 +374,9 @@ sub export_tower_sector {
     return;
   }
 
+  my $tower_location;
+  $tower_location = $tower->{Hash}->{latitude}.','.$tower->{Hash}->{longitude} if ($tower->{Hash}->{latitude} && $tower->{Hash}->{longitude});
+
   #modify tower or create it.
   my $tower_name = $tower->{Hash}->{towername};
   $tower_name =~ s/\s/_/g;
@@ -341,6 +387,7 @@ sub export_tower_sector {
     'tower_downrate_limit' => $tower->{Hash}->{down_rate_limit},
     'modify_existing'      => '1', # modify an existing access point with this info
   };
+  $tower_opt->{'location'} = $tower_location if $tower_location;
 
   my $tower_access_point = process_tower($self, $tower_opt);
     return $tower_access_point if $tower_access_point->{error};
@@ -354,6 +401,7 @@ sub export_tower_sector {
 
   #for each one modify or create it.
   foreach my $tower_sector ( FS::Record::qsearch($hash_opt) ) {
+    next if $tower_sector->{Hash}->{sectorname} eq "_default";
     my $sector_name = $tower_sector->{Hash}->{sectorname};
     $sector_name =~ s/\s/_/g;
     my $sector_opt = {
@@ -364,6 +412,8 @@ sub export_tower_sector {
       'sector_downrate_limit' => $tower_sector->{Hash}->{down_rate_limit},
       'modify_existing'       => '1', # modify an existing access point with this info
     };
+    $sector_opt->{'location'} = $tower_location if $tower_location;
+
     my $sector_access_point = process_sector($self, $sector_opt) unless ($sector_name eq "_default");
       return $sector_access_point if $sector_access_point->{error};
   }
@@ -371,6 +421,16 @@ sub export_tower_sector {
   return { error => $self->api_error, };
 }
 
+sub export_user {
+  my ($self, $username, $description, $location) = @_;
+
+  $self->api_create_user($username, $description, $location);
+
+  return $self->api_error if $self->{'__saisei_error'};
+
+  return '';
+}
+
 ## creates the rateplan name
 sub get_rateplan_name {
   my ($self, $svc_broadband, $svc_name) = @_;
@@ -449,7 +509,7 @@ sub api_call {
     return;
   }
   else {
-    $self->{'__saisei_error'} = "Received Bad response from server during $method , we received responce code: " . $client->responseCode();
+    $self->{'__saisei_error'} = "Received Bad response from server during $method $path $data, we received responce code: " . $client->responseCode() . " " . $client->responseContent;
     warn "Saisei Response Content is\n".$client->responseContent."\n" if $self->option('debug');
     return; 
   }
@@ -650,14 +710,17 @@ Creates a user.
 =cut
 
 sub api_create_user {
-  my ($self,$user, $description) = @_;
+  my ($self,$user, $description, $location) = @_;
+
+  my $user_hash = {
+    'description' => $description,
+  };
+  $user_hash->{'map_location'} = $location if $location;
 
   my $new_user = $self->api_call(
-      "PUT", 
+      "PUT",
       "/users/$user",
-      {
-        'description' => $description,
-      },
+      $user_hash,
   );
 
   $self->{'__saisei_error'} = "Saisei could not create the user $user"
@@ -667,6 +730,33 @@ sub api_create_user {
 
 }
 
+=head2 api_modify_user
+
+Modify a user.
+
+=cut
+
+sub api_modify_user {
+  my ($self,$user, $description, $location) = @_;
+
+  my $user_hash = {
+    'description' => $description,
+  };
+  $user_hash->{'map_location'} = $location if $location;
+
+  my $modify_user = $self->api_call(
+      "PUT",
+      "/users/$user",
+      $user_hash,
+  );
+
+  $self->{'__saisei_error'} = "Saisei could not modify the user $user"
+    unless ($modify_user || $self->{'__saisei_error'}); # should never happen
+
+  return $modify_user;
+
+}
+
 =head2 api_create_accesspoint
 
 Creates a access point.
@@ -674,15 +764,19 @@ Creates a access point.
 =cut
 
 sub api_create_accesspoint {
-  my ($self,$accesspoint, $upratelimit, $downratelimit) = @_;
+  my ($self,$accesspoint, $upratelimit, $downratelimit, $location) = @_;
+
+  my $ap_hash = {
+    'downstream_rate_limit' => $downratelimit,
+    'upstream_rate_limit'   => $upratelimit,
+    'interface'             => $self->option('interface'),
+  };
+  $ap_hash->{'map_location'} = $location if $location;
 
   my $new_accesspoint = $self->api_call(
       "PUT",
       "/access_points/$accesspoint",
-      {
-         'downstream_rate_limit' => $downratelimit,
-         'upstream_rate_limit' => $upratelimit,
-      },
+      $ap_hash,
   );
 
   $self->{'__saisei_error'} = "Saisei could not create the access point $accesspoint"
@@ -698,14 +792,18 @@ Modify a new access point.
 =cut
 
 sub api_modify_accesspoint {
-  my ($self, $accesspoint, $uplink) = @_;
+  my ($self, $accesspoint, $uplink, $location) = @_;
+
+  my $ap_hash = {
+    'uplink'    => $uplink,
+    'interface' => $self->option('interface'),
+  };
+  $ap_hash->{'map_location'} = $location if $location;
 
   my $modified_accesspoint = $self->api_call(
     "PUT",
     "/access_points/$accesspoint",
-    {
-      'uplink' => $uplink, # name of attached access point
-    },
+    $ap_hash,
   );
 
   $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint after it was created."
@@ -722,20 +820,24 @@ Modify a existing accesspoint.
 =cut
 
 sub api_modify_existing_accesspoint {
-  my ($self, $accesspoint, $uplink, $upratelimit, $downratelimit) = @_;
+  my ($self, $accesspoint, $uplink, $upratelimit, $downratelimit, $location) = @_;
+
+  my $ap_hash = {
+    'downstream_rate_limit' => $downratelimit,
+    'upstream_rate_limit'   => $upratelimit,
+    'interface'             => $self->option('interface'),
+#   'uplink'                => $uplink, # name of attached access point
+  };
+  $ap_hash->{'map_location'} = $location if $location;
 
   my $modified_accesspoint = $self->api_call(
     "PUT",
     "/access_points/$accesspoint",
-    {
-      'downstream_rate_limit' => $downratelimit,
-      'upstream_rate_limit' => $upratelimit,
-#      'uplink' => $uplink, # name of attached access point
-    },
+    $ap_hash,
   );
 
-    $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint."
-      unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
+  $self->{'__saisei_error'} = "Saisei could not modify the access point $accesspoint."
+    unless ($modified_accesspoint || $self->{'__saisei_error'}); # should never happen
 
   return;
 
@@ -748,16 +850,22 @@ ties host to user, rateplan and default access point.
 =cut
 
 sub api_add_host_to_user {
-  my ($self,$user, $rateplan, $ip, $accesspoint) = @_;
+#  my ($self,$user, $rateplan, $ip, $accesspoint, $location) = @_;
+  my ($self,$opt) = @_;
+  my $ip = $opt->{'ip'};
+  my $location = $opt->{'location'};
+
+  my $newhost_hash = {
+    'user'         => $opt->{'user'},
+    'rate_plan'    => $opt->{'rateplan'},
+    'access_point' => $opt->{'accesspoint'},
+  };
+  $newhost_hash->{'map_location'} = $location if $location;
 
   my $new_host = $self->api_call(
       "PUT", 
       "/hosts/$ip",
-      {
-        'user'      => $user,
-        'rate_plan' => $rateplan,
-        'access_point' => $accesspoint,
-      },
+      $newhost_hash,
   );
 
   $self->{'__saisei_error'} = "Saisei could not create the host $ip"
@@ -811,6 +919,7 @@ sub process_tower {
 
   my $existing_tower_ap;
   my $tower_name = $opt->{tower_name};
+  my $location = $opt->{location};
 
   #check if tower has been set up as an access point.
   $existing_tower_ap = $self->api_get_accesspoint($tower_name) unless $self->{'__saisei_error'};
@@ -821,6 +930,7 @@ sub process_tower {
     '', # tower does not have a uplink on sectors.
     $opt->{tower_uprate_limit},
     $opt->{tower_downrate_limit},
+    $location,
   ) if $existing_tower_ap->{collection} && $opt->{modify_existing};
 
   #if tower does not exist as an access point create it.
@@ -828,6 +938,7 @@ sub process_tower {
       $tower_name,
       $opt->{tower_uprate_limit},
       $opt->{tower_downrate_limit},
+      $location,
   ) unless $existing_tower_ap->{collection};
 
   my $accesspoint = $self->api_get_accesspoint($tower_name);
@@ -851,6 +962,7 @@ sub process_sector {
 
   my $existing_sector_ap;
   my $sector_name = $opt->{sector_name};
+  my $location = $opt->{location};
 
   #check if sector has been set up as an access point.
   $existing_sector_ap = $self->api_get_accesspoint($sector_name);
@@ -861,6 +973,7 @@ sub process_sector {
     $opt->{tower_name},
     $opt->{sector_uprate_limit},
     $opt->{sector_downrate_limit},
+    $location,
   ) if $existing_sector_ap && $opt->{modify_existing};
 
   #if sector does not exist as an access point create it.
@@ -868,10 +981,11 @@ sub process_sector {
     $sector_name,
     $opt->{sector_uprate_limit},
     $opt->{sector_downrate_limit},
+    $location,
   ) unless $existing_sector_ap;
 
   # Attach newly created sector to it's tower.
-  $self->api_modify_accesspoint($sector_name, $opt->{tower_name}) unless ($self->{'__saisei_error'} || $existing_sector_ap);
+  $self->api_modify_accesspoint($sector_name, $opt->{tower_name}, $location) unless ($self->{'__saisei_error'} || $existing_sector_ap);
 
   # set access point to existing one or newly created one.
   my $accesspoint = $existing_sector_ap ? $existing_sector_ap : $self->api_get_accesspoint($sector_name);
@@ -880,6 +994,30 @@ sub process_sector {
   return $accesspoint;
 }
 
+=head2 get_svc_location
+
+sets location to lat and long from service, if no service location gets it from package, if still no location returns null.
+
+=cut
+
+sub get_svc_location {
+  my ($self, $svc) = @_;
+
+  my $svc_location = '';
+  $svc_location = $svc->{Hash}->{latitude}.','.$svc->{Hash}->{longitude} if ($svc->{Hash}->{latitude} && $svc->{Hash}->{longitude});
+
+  if (!$svc_location) {
+    my $pkg_location = FS::Record::qsearchs({
+      'table'   => 'cust_pkg',
+      'addl_from' => 'LEFT JOIN cust_location USING (locationnum)',
+      'hashref' => { 'pkgnum' => $svc->{Hash}->{pkgnum} },
+    });
+    $svc_location = $pkg_location->{Hash}->{latitude}.','.$pkg_location->{Hash}->{longitude} if ($pkg_location->{Hash}->{latitude} && $pkg_location->{Hash}->{longitude});
+  }
+
+  return $svc_location;
+}
+
 =head2 require_tower_and_sector
 
 sets whether the service export requires a sector with it's tower.
@@ -931,6 +1069,7 @@ sub process_virtual_ap {
     $opt->{sector_name},
     $opt->{virtual_uprate_limit},
     $opt->{virtual_downrate_limit},
+    $opt->{location},
   ) if $existing_virtual_ap && $opt->{modify_existing};
 
   #if virtual ap does not exist as an access point create it.
@@ -938,6 +1077,7 @@ sub process_virtual_ap {
     $virtual_name,
     $opt->{virtual_uprate_limit},
     $opt->{virtual_downrate_limit},
+    $opt->{location},
   ) unless $existing_virtual_ap;
 
   my $update_sector;
@@ -946,7 +1086,7 @@ sub process_virtual_ap {
   }
 
   # Attach newly created virtual ap to tower sector ap or if sector has changed.
-  $self->api_modify_accesspoint($virtual_name, $opt->{sector_name}) unless ($self->{'__saisei_error'} || ($existing_virtual_ap && !$update_sector));
+  $self->api_modify_accesspoint($virtual_name, $opt->{sector_name}, $opt->{location}) unless ($self->{'__saisei_error'} || ($existing_virtual_ap && !$update_sector));
 
   # set access point to existing one or newly created one.
   my $accesspoint = $existing_virtual_ap ? $existing_virtual_ap : $self->api_get_accesspoint($virtual_name);
@@ -957,6 +1097,7 @@ sub process_virtual_ap {
 sub export_provisioned_services {
   my $job = shift;
   my $param = shift;
+  my $force_update = shift;
 
   my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
   or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
@@ -989,7 +1130,9 @@ sub export_provisioned_services {
     my $host = api_get_host($part_export, $svc->{Hash}->{ip_addr});
     die ("Please double check your credentials as ".$host->{message}."\n") if $host->{message};
     warn "Exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
-    my $export_error = _export_insert($part_export,$svc) unless $host->{collection};
+    my $export_error;
+    if ($force_update) { $export_error = _export_insert($part_export,$svc,$force_update); }
+    else { $export_error = _export_insert($part_export,$svc) unless $host->{collection}; }
     if ($export_error) {
       warn "Error exporting service ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
       die ("$export_error\n");
@@ -1001,6 +1144,94 @@ sub export_provisioned_services {
 
 }
 
+sub export_all_towers_sectors {
+  my $job = shift;
+  my $param = shift;
+
+  my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
+  or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
+  bless $part_export;
+
+  my @towers = FS::Record::qsearch({
+    'table' => 'tower',
+  });
+  my $tower_count = scalar @towers;
+
+  my %status = {};
+  for (my $c=1; $c <=100; $c=$c+1) { $status{int($tower_count * ($c/100))} = $c; }
+
+  my $process_count=0;
+  foreach my $tower (@towers) {
+    if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
+    my $export_error = export_tower_sector($part_export,$tower);
+    if ($export_error->{'error'}) {
+      warn "Error exporting tower/sector (".$tower->{Hash}->{towername}.")\n" if ($part_export->option('debug'));
+      die ($export_error->{'error'}."\n");
+    }
+    $process_count++;
+  }
+
+  return;
+
+}
+
+sub force_export_all_virtual_ap {
+  my $job = shift;
+  my $param = shift;
+  my $force_update = { 'update_virtual_ap' => '1', };
+
+  export_provisioned_services($job,$param,$force_update);
+
+  return;
+}
+
+sub force_export_all_users {
+  my $job = shift;
+  my $param = shift;
+
+  my $part_export = FS::Record::qsearchs('part_export', { 'exportnum' => $param->{export_provisioned_services_exportnum}, } )
+  or die "You are trying to use an unknown exportnum $param->{export_provisioned_services_exportnum}.  This export does not exist.\n";
+  bless $part_export;
+
+  my @svcparts = FS::Record::qsearch({
+    'table' => 'export_svc',
+    'addl_from' => 'LEFT JOIN part_svc USING ( svcpart  ) ',
+    'hashref'   => { 'exportnum' => $param->{export_provisioned_services_exportnum}, },
+  });
+  my $part_count = scalar @svcparts;
+
+  my $parts = join "', '", map { $_->{Hash}->{svcpart} } @svcparts;
+
+  my @svcs = FS::Record::qsearch({
+    'table' => 'cust_svc',
+    'addl_from' => 'LEFT JOIN svc_broadband USING ( svcnum  ) ',
+    'extra_sql' => " WHERE svcpart in ('".$parts."')",
+  }) unless !$parts;
+
+  my $svc_count = scalar @svcs;
+
+  my %status = {};
+  for (my $c=1; $c <=100; $c=$c+1) { $status{int($svc_count * ($c/100))} = $c; }
+
+  my $process_count=0;
+  foreach my $svc (@svcs) {
+    my $description = $svc->{Hash}->{description};
+    my $user = $svc->{Hash}->{svcnum};
+    my $svc_location = get_svc_location($job, $svc);
+    if ($status{$process_count}) { my $s = $status{$process_count}; $job->update_statustext($s); }
+    warn "Exporting user ".$svc->{Hash}->{ip_addr}."\n" if ($part_export->option('debug'));
+    my $export_error = export_user($part_export,$user,$description, $svc_location);
+    if ($export_error) {
+      warn "Error exporting user ".$svc->{Hash}->{svcnum}."\n" if ($part_export->option('debug'));
+      die ($export_error->{'error'}."\n");
+    }
+    $process_count++;
+  }
+
+  return;
+
+}
+
 sub test_export_report {
   my ($self, $opts) = @_;
   my @export_error;
index ded6b91..de56527 100644 (file)
@@ -91,6 +91,18 @@ sub check {
   $self->SUPER::check;
 }
 
+=item get_phone_types
+
+returns a list of phone_types.
+
+=cut
+
+sub get_phone_types {
+  ## not using Home and Fax right now. false laziness with  /elements/contact.html
+  my @phone_types = qsearch({table=>'phone_type', order_by=>'ORDER BY weight DESC', extra_sql => " WHERE typename NOT IN ('Home','Fax')"});
+  return @phone_types;
+}
+
 # Used by FS::Setup to initialize a new database.
 sub _populate_initial_data {
   my ($class, %opts) = @_;
index 8b7a122..016c14e 100755 (executable)
@@ -293,18 +293,18 @@ sub validate_opts {
 
   error_and_help( '--csv_dir is required' )
     unless $csv_dir;
-  error_and_help( '--start_date is required' )
+  error_and_help( '--start-date is required' )
     unless $start_date;
   error_and_help( '--end-date is required' )
     unless $end_date;
   error_and_help( '--taxnums is required' )
     unless @taxnums;
-  error_and_help( '--credit-reasonnum is required with --apply-credits' )
+  error_and_help( '--credit-reasonnum is required with --insert-credits' )
     if $insert_credits && !$credit_reasonnum;
-  error_and_help( '--credit-addlinfo is required with --apply-credits' )
+  error_and_help( '--credit-addlinfo is required with --insert-credits' )
     if $insert_credits && !$credit_addlinfo;
 
-  error_and_help( "csv dir ($csv_dir) is not a writable directoryu" )
+  error_and_help( "csv dir ($csv_dir) is not a writable directory" )
     unless -d $csv_dir && -r $csv_dir;
 
   error_and_help( "start_date($start_date) is not a valid date string")
index 3a77592..8a6f33f 100755 (executable)
@@ -19,7 +19,7 @@ adminsuidsetup $user;
 
 sub usage { "
   Usage:
-      svc_broadband_update_speeds: [ -h help] [ -v verbose] [ -n only update services with a null up/down speed] [ -e export service ] [ -a update tower_sector_num ] [ -s service_part_num (required) ] [ -c sibling service_part_num ] [ -r (speed rate in KB 'up,down') ] [ -p (get speed from package fcc rate) ] [ -t tower_sector_num ] [ -d directory for exception file (required) ] user (required)\n
+      freeside-svcbroadband_update_speeds: [ -h help] [ -v verbose] [ -n only update services with a null up/down speed] [ -e export service ] [ -a update tower_sector_num ] [ -s service_part_num (required) ] [ -c sibling service_part_num ] [ -r (speed rate in KB 'up,down') ] [ -p (get speed from package fcc rate) ] [ -t tower_sector_num ] [ -d directory for exception file (required) ] user (required)\n
       A directory for the exception file, freeside user name and a service to update is required.\n
       Must set one or more of options p, c, or r. \n
       Also must run this report as user freeside.\n
@@ -47,12 +47,12 @@ my @services = qsearch({
 });
 
 ### get list of all unprovisioned services
-my $ups_extra_sql = "where cust_pkg.cancel is null and pkg_svc.quantity > 0 and  pkg_svc.quantity > (select count(1) from cust_svc where  cust_svc.pkgnum = cust_pkg.pkgnum and  cust_svc.svcpart = pkg_svc.svcpart)  and pkg_svc.svcpart = $opt_s";
-my @unprovisioned_services = qsearchs({
-    'table'     => 'cust_pkg',
-    'addl_from' => 'JOIN pkg_svc using (pkgpart)',
-    'extra_sql' => $ups_extra_sql,
-});
+#my $ups_extra_sql = "where cust_pkg.cancel is null and pkg_svc.quantity > 0 and  pkg_svc.quantity > (select count(1) from cust_svc where  cust_svc.pkgnum = cust_pkg.pkgnum and  cust_svc.svcpart = pkg_svc.svcpart)  and pkg_svc.svcpart = $opt_s";
+#my @unprovisioned_services = qsearch({
+#    'table'     => 'cust_pkg',
+#    'addl_from' => 'JOIN pkg_svc using (pkgpart)',
+#    'extra_sql' => $ups_extra_sql,
+#});
 
 my $speed;
 $speed = 'package' if $opt_p;
@@ -144,7 +144,7 @@ sub _update_service {
 
 exit;
 
-=head2 svc_broadband_update_speeds
+=head2 freeside-svcbroadband_update_speeds
 
 This script allows for the mas update of up and down speeds for a svc_broadband service.
 
@@ -159,7 +159,7 @@ Script must be run as user freeside.
 Options -s, -d and freeside user are required.
 
 example:
-sudo -u freeside ./svc_broadband_update_speeds -v -s 4 -c 2 -r 148000,248000 -p -d /home/freeside/ freesideuser
+sudo -u freeside ./freeside-svcbroadband_update_speeds -v -s 4 -c 2 -r 148000,248000 -p -d /home/freeside/ freesideuser
 
 available options:
 [ -h help]
diff --git a/FS/bin/freeside-wa-tax-table-resolve b/FS/bin/freeside-wa-tax-table-resolve
new file mode 100755 (executable)
index 0000000..790dce2
--- /dev/null
@@ -0,0 +1,452 @@
+#!/usr/bin/env perl
+use v5.10;
+use strict;
+use warnings;
+
+our $VERSION = '1.0';
+
+use Data::Dumper;
+use FS::cust_main_county;
+use FS::Log;
+use FS::Record qw( qsearch qsearchs );
+use FS::UID qw( adminsuidsetup );
+use Getopt::Long;
+use Pod::Usage;
+
+# Begin transaction
+local $FS::UID::AutoCommit = 0;
+
+my(
+  $dbh,
+  $freeside_user,
+  $opt_check,
+  $opt_fix_usf,
+  @opt_merge,
+  $opt_merge_all,
+  @opt_set_source_null,
+);
+
+GetOptions(
+  'check'             => \$opt_check,
+  'fix-usf'           => \$opt_fix_usf,
+  'merge=s'           => \@opt_merge,
+  'merge-all'         => \$opt_merge_all,
+  'set-source-null=s' => \@opt_set_source_null,
+);
+@opt_merge = split(',',join(',',@opt_merge));
+@opt_set_source_null = split(',',join(',',@opt_set_source_null));
+
+
+# say Dumper({
+#   check => $opt_check,
+#   merge => \@opt_merge,
+#   set_source_numm => \@opt_set_source_null,
+# });
+
+validate_opts();
+
+$dbh = adminsuidsetup( $freeside_user )
+  or die "Bad  username: $freeside_user\n";
+
+my $log = FS::Log->new('freeside-wa-tax-table-resolve');
+
+if ( $opt_check ) {
+  check();
+} elsif ( @opt_merge ) {
+  merge();
+} elsif ( @opt_set_source_null ) {
+  set_source_null();
+} elsif ( $opt_merge_all ) {
+  merge_all();
+} elsif ( $opt_fix_usf ) {
+  fix_usf();
+} else {
+  error_and_help('No options selected');
+}
+
+# Commit transaction
+$dbh->commit;
+local $FS::UID::AutoCommit = 1;
+
+exit;
+
+
+sub set_source_null {
+  my @cust_main_county;
+  for my $taxnum ( @opt_set_source_null ) {
+    my $row = qsearchs( cust_main_county => { taxnum => $taxnum } );
+    if ( $row ) {
+      push @cust_main_county, $row;
+    } else {
+      error_and_help("Invalid taxnum specified: $taxnum");
+    }
+  }
+
+  say "=== Specified tax rows ===";
+  print_taxnum($_) for @cust_main_county;
+
+  confirm_to_continue("
+
+    The source column will be set to NULL for each of the
+    tax rows listed.  The tax row will no longer be managed
+    by the washington state sales tax table update utilities.
+
+    The listed taxes should be manually created taxes, that
+    were never intended to be managed by the auto updater.
+
+  ");
+
+  for my $row ( @cust_main_county ) {
+
+    $row->setfield( source => undef );
+    my $error = $row->replace;
+
+    if ( $error ) {
+      $dbh->rollback;
+
+      my $message = sprintf 'Error setting source=null taxnum %s: %s',
+          $row->taxnum, $error;
+
+      $log->error( $message );
+      say $message;
+
+      return;
+    }
+
+    my $message = sprintf 'Source column set to null for taxnum %s',
+      $row->taxnum;
+
+    $log->warn( $message );
+    say $message;
+  }
+}
+
+sub merge {
+  my $source = qsearchs( cust_main_county => { taxnum => $opt_merge[0] });
+  my $target = qsearchs( cust_main_county => { taxnum => $opt_merge[1] });
+
+  error_and_help("Invalid source taxnum: $opt_merge[0]")
+    unless $source;
+  error_and_help("Invalid target taxnum: $opt_merge[1]")
+    unless $target;
+
+  local $| = 1; # disable output buffering
+
+  say '==== source row ====';
+  print_taxnum( $source );
+
+  say '==== target row ====';
+  print_taxnum( $target );
+
+  confirm_to_continue("
+  
+    The source tax will be merged into the target tax.
+    All references to the source tax on customer invoices
+    will be replaced with references to the target tax.
+    The source tax will be removed from the tax tables.
+
+  ");
+
+  merge_into( $source, $target );
+}
+
+sub merge_into {
+  my ( $source, $target ) = @_;
+
+  local $@;
+  eval { $source->_merge_into( $target, { identical_record_check => 0 } ) };
+  if ( $@ ) {
+    $dbh->rollback;
+  
+    my $message = sprintf 'Failed to merge wa sales tax %s into %s: %s',
+        $source->taxnum, $target->taxnum, $@;
+
+    say $message;
+    $log->error( $message );
+
+  } else {
+    my $message = sprintf 'Merged wa sales tax %s into %s for district %s',
+        $source->taxnum, $target->taxnum, $source->district;
+
+    say $message;
+    $log->warn( $message );
+  }
+}
+
+sub merge_all {
+  my @dupes = FS::cust_main_county->find_wa_tax_dupes;
+
+  unless ( @dupes ) {
+    say 'No duplicate tax rows detected for WA sales tax districts';
+    return;
+  }
+
+  confirm_to_continue(sprintf "
+
+    %s blocking duplicate rows detected
+
+    Duplicate rows will be merged using FS::cust_main_county::_merge_into()
+
+    Rows are considered duplicates when they:
+    - Share the same tax class
+    - Share the same district
+    - Contain 'wa_sales' in the source column
+
+  ", scalar @dupes);
+
+  # Sort dupes into buckets to be merged, by taxclass and district
+  # $to_merge{taxclass}->{district} = [ @rows_to_merge ]
+  my %to_merge;
+  for my $row ( @dupes ) {
+    my $taxclass = $row->taxclass || 'none';
+    $to_merge{$taxclass} ||= {};
+    $to_merge{$taxclass}->{$row->district} ||= [];
+    push @{ $to_merge{$taxclass}->{$row->district} }, $row;
+  }
+
+  # Merge the duplicates
+  for my $taxclass ( keys %to_merge ) {
+    for my $district ( keys %{ $to_merge{$taxclass} }) {
+
+      # Keep the first row in the list as the target.
+      # Merge the remaining rows into the target
+      my $rows = $to_merge{$taxclass}->{$district};
+      my $target = shift @$rows;
+
+      while ( @$rows ) {
+        merge_into( shift(@$rows), $target );
+      }
+    }
+  }
+
+  say "
+
+    Merge operations completed
+
+    Please run freeside-wa-tax-table-update.  This will update
+    the merged district rows with correct county and city names
+
+  ";
+
+}
+
+sub fix_usf {
+  confirm_to_continue("
+
+    Search for duplicate districts within the tax tables with
+    - duplicate district column values
+    - source = NULL
+    - district = NOT NULL
+    - taxclass = USF
+    - tax > 17
+
+    Merge these rows into a single USF row for each tax district
+
+  ");
+
+  my @rows = qsearch( cust_main_county => {
+    taxclass => 'USF',
+    source   => undef,
+    state    => 'WA',
+    country  => 'US',
+    tax      => { op => '>',  value => 17 },
+    district => { op => '!=', value => undef },
+  });
+
+  my %to_merge;
+  for my $row (@rows) {
+    $to_merge{$row->district} ||= [];
+    push @{ $to_merge{$row->district} }, $row;
+  }
+
+  for my $dist_rows ( values %to_merge ) {
+    my $target = shift @$dist_rows;
+    while ( @$dist_rows ) {
+      merge_into( shift(@$dist_rows), $target );
+    }
+  }
+
+  say "
+
+    USF clean up completed
+
+    Please run freeside-wa-tax-table-update.  This will update
+    the merged district rows with correct county and city names
+
+  ";
+}
+
+sub validate_opts {
+
+  $freeside_user = shift @ARGV
+    or error_and_help('freeside_user parameter required');
+
+  if ( @opt_merge ) {
+    error_and_help(( '--merge requires a comma separated list of two taxnums'))
+      unless scalar(@opt_merge) == 2
+          && $opt_merge[0] =~ /^\d+$/
+          && $opt_merge[1] =~ /^\d+$/;
+  }
+
+  for my $taxnum ( @opt_set_source_null ) {
+    if ( $taxnum =~ /\D/ ) {
+      error_and_help( "Invalid taxnum ($taxnum)" );
+    }
+  }
+}
+
+sub check {
+  my @dupes = FS::cust_main_county->find_wa_tax_dupes;
+
+  unless ( @dupes ) {
+    say 'No duplicate tax rows detected for WA sales tax districts';
+    return;
+  }
+
+  say sprintf '=== Detected %s duplicate tax rows ===', scalar @dupes;
+
+  print_taxnum($_) for sort { $a->district <=> $b->district } @dupes;
+
+  $log->error(
+    sprintf 'Detected %s duplicate wa sales tax rows: %s',
+      scalar( @dupes ),
+      join( ',', map{ $_->taxnum } @dupes )
+  );
+
+  say "
+
+    Rows are considered duplicates when they:
+    - Share the same tax class
+    - Share the same district
+    - Contain 'wa_sales' in the source column
+
+  ";
+}
+
+sub print_taxnum {
+  my $taxnum = shift;
+  die unless ref $taxnum;
+
+  say 'taxnum: '.$taxnum->taxnum;
+  say join "\n" => (
+    map { sprintf('  %s:%s', $_, $taxnum->$_ ) }
+    qw/district city county state tax taxname taxclass source/
+  );
+  print "\n";
+}
+
+sub confirm_to_continue {
+  say shift;
+  print "Confirm: [y/N]: ";
+  my $yn = <STDIN>;
+  chomp $yn;
+  if ( lc $yn ne 'y' ) {
+    say "\nAborted\n";
+    exit;
+  }
+}
+
+sub error_and_help {
+  pod2usage({
+    -message => sprintf( "\n\nError:\n\t%s\n\n", shift ),
+    -exitval => 2,
+    verbose => 1,
+  });
+  exit;
+}
+
+__END__
+
+=head1 name
+
+freeside-wa-tax-table-resolve
+
+=head1 SYNOPSIS
+
+  freeside-wa-tax-table-resolve --help
+  freeside-wa-tax-table-resolve --check [freeside_user]
+  freeside-wa-tax-table-resolve --merge 123,234 [freeside_user]
+  freeside-wa-tax-table-resolve --set-source-null 1337,6553 [freeside_user]
+  freeside-wa-tax-table-resolve --merge-all [freeside_user]
+  freeside-wa-tax-table-resolve --fix-usf [freeside_user]
+
+=head1 OPTIONS
+
+=over 4
+
+=item B<--help>
+
+Display help and exit
+
+=item B<--check>
+
+Display info on any taxnums considered blocking duplicates
+
+=item B<--merge> [source-taxnum],[target-taxnum]
+
+Update all records referring to [source-taxnum], so they now
+refer to [target-taxnum].  [source-taxnum] is deleted.
+
+Used to merge duplicate taxnums
+
+=item B<--set-source-null> [taxnum],[taxnum],...
+
+Update all records for the given taxnums, by setting the
+I<source> column to NULL.
+
+Used for manually entered tax entries, incorrectly labelled
+as created and managed for Washington State Sales Taxes
+
+=item B<--merge-all>
+
+Automatically merge all blocking duplicate taxnums.
+
+If after reviewing all blocking duplicate taxnum rows with --check,
+if all duplicate rows are safe to merge, this option will merge them all.
+
+=item B<--fix-usf>
+
+Fix routine for a particular USF issue
+
+Search for duplicate districts within the tax tables with
+
+  - duplicate district column values
+  - source = NULL
+  - district = NOT NULL
+  - taxclass = USF
+  - tax > 17
+
+Merge these rows into a single USF row for each tax district
+
+=back
+
+=head1 DESCRIPTION
+
+Tool to resolve tax table issues for customer using Washington state
+sales tax districts.
+
+If Freeside detects duplicate rows within the wa sales tax tables,
+tax table updates are blocked, and a log message directs the
+sysadmin to this tool.
+
+Duplicate rows may be manually entered taxes, not related
+to WA sales tax.  Or duplicate rows may have been manually entered
+into freeside for other tax purposes.
+
+Use --check to display which tax entries were detected as dupes.
+
+For each tax entry, decide if it is a duplicate wa sales tax entry,
+or some other manually entered tax.
+
+if the row is a duplicate, merge the duplicates with the --merge
+option of this script
+
+If the row is a manually entered tax, not for WA state sales taxes,
+keep the tax but remove the flag incorrectly labeling it as WA state
+sales taxes with the --set-source-null option of this script
+
+Once --check no longer returns problematic tax entries, the
+wa state tax tables will be able to complete their automatic
+tax rate updates
+
+=cut
index ad14687..53c7324 100755 (executable)
@@ -60,6 +60,12 @@ https://dor.wa.gov/sites/default/files/legacy/Docs/forms/ExcsTx/LocSalUseTx/Exce
 
 https://dor.wa.gov/sites/default/files/legacy/downloads/Add_DataRates2018Q4.zip
 
+=item Other district tax rows
+
+When this tool updates the tax tables, any additional tax table rows with
+a district set, where the 'source' column is not 'wa_sales', will have the
+country, state, county, and city values kept updated to match the data
+provided in the state tax tables
 
 =item Address lookup API tool
 
@@ -106,8 +112,8 @@ $log->info('Begin wa_tax_rate_update');
   };
 
   if ( $@ ) {
-    $log->error( "Error: $@" );
     warn "Error: $@\n";
+    $log->error( "Error: $@" );
   } else {
     $log->info( 'Finished wa_tax_rate_update' );
     warn "Finished wa_tax_rate_update\n";
index a516a97..af989ed 100644 (file)
@@ -2440,7 +2440,10 @@ sub regionselector {
 
   my $prefix = exists($param->{'prefix'}) ? $param->{'prefix'} : '';
 
-  my $countyflag = 0;
+  my $disabled = $param->{'disabled'};
+
+  my $countyflag = $param->{selected_county} ? 1 : 0;
+  my $cityflag = $param->{selected_city} ? 1 : 0;
 
   my %cust_main_county;
 
@@ -2450,17 +2453,17 @@ sub regionselector {
     foreach my $c ( @{ $param->{'locales'} } ) {
       #$countyflag=1 if $c->county;
       $countyflag=1 if $c->{county};
+      $cityflag=1 if ($c->{city} && $cityflag);
       #push @{$cust_main_county{$c->country}{$c->state}}, $c->county;
       #$cust_main_county{$c->country}{$c->state}{$c->county} = 1;
-      $cust_main_county{$c->{country}}{$c->{state}}{$c->{county}} = 1;
+      $cust_main_county{$c->{country}}{$c->{state}}{$c->{county}}{$c->{city}} = 1;
     }
 #  }
-  $countyflag=1 if $param->{selected_county};
 
   my $script_html = <<END;
     <SCRIPT>
-    function opt(what,value,text) {
-      var optionName = new Option(text, value, false, false);
+    function opt(what,value,text,selected) {
+      var optionName = new Option(text, value, false, selected);
       var length = what.length;
       what.options[length] = optionName;
     }
@@ -2500,8 +2503,37 @@ END
           #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
           foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
             my $text = $county || '(n/a)';
-            $script_html .=
-              qq!opt(what.form.${prefix}county, "$county", "$text");\n!;
+            if (!$county) {
+              if ( $cityflag) {
+                $script_html .= qq!what.form.${prefix}city.style.display='';\n
+                                what.form.${prefix}city_select.style.display='none';\n!
+              }
+              $script_html .= qq!opt(what.form.${prefix}county, "$county", "$text");\n!
+              #$script_html .= qq!what.form.${prefix}county.style.display='none';\n!
+            }
+            else {
+              $script_html .= qq!var countySelected = false; if ("$param->{selected_county}" == "$text") { countySelected = true; }\n
+                              opt(what.form.${prefix}county, "$county", "$text", countySelected);\n
+                              what.form.${prefix}county.style.display='';\n
+                              county = what.form.${prefix}county.options[what.form.${prefix}county.selectedIndex].text;\n!;
+              if ( $cityflag) {
+                $script_html .= qq!\nif ( county == \"$county\" ) {\n!;
+                foreach my $city ( sort keys %{$cust_main_county{$country}{$state}{$county}} ) {
+                  my $text = $city || '(n/a)';
+                  if (!$city) {
+                    $script_html .= qq!what.form.${prefix}city.style.display='';\n
+                                    what.form.${prefix}city_select.style.display='none';\n!
+                  }
+                  else {
+                    $script_html .= qq!var citySelected = false; if ("$param->{selected_city}" == "$text") { citySelected = true; }\n
+                                    opt(what.form.${prefix}city_select, "$city", "$text", citySelected);\n
+                                    what.form.${prefix}city.style.display='none';\n
+                                    what.form.${prefix}city_select.style.display='';\n!
+                  }
+                }
+                $script_html .= "}\n";
+              }
+            }
           }
         $script_html .= "}\n";
       }
@@ -2511,12 +2543,89 @@ END
 
   $script_html .= <<END;
     }
+    function ${prefix}county_changed(what) {
+END
+
+  if ( $cityflag) {
+    $script_html .= <<END;
+      saved_city = "$param->{selected_city}";
+      county = what.options[what.selectedIndex].text;
+      state = what.form.${prefix}state.options[what.form.${prefix}state.selectedIndex].text;
+      country = what.form.${prefix}country.options[what.form.${prefix}country.selectedIndex].text;
+      for ( var i = what.form.${prefix}city_select.length; i >= 0; i-- )
+          what.form.${prefix}city_select.options[i] = null;
+END
+
+    foreach my $country ( sort keys %cust_main_county ) {
+      $script_html .= "\nif ( country == \"$country\" ) {\n";
+      foreach my $state ( sort keys %{$cust_main_county{$country}} ) {
+        $script_html .= "\nif ( state == \"$state\" ) {\n";
+        #foreach my $county ( sort @{$cust_main_county{$country}{$state}} ) {
+        foreach my $county ( sort keys %{$cust_main_county{$country}{$state}} ) {
+          $script_html .= "\nif ( county == \"$county\" ) {\n";
+            foreach my $city ( sort keys %{$cust_main_county{$country}{$state}{$county}} ) {
+              my $text = $city || '(n/a)';
+              if (!$city) {
+                $script_html .= qq!what.form.${prefix}city.style.display='';\n
+                                what.form.${prefix}city_select.style.display='none';\n!
+              }
+              else {
+                $script_html .= qq!var citySelected = false; if (saved_city == "$text") { citySelected = true; }\n
+                                opt(what.form.${prefix}city_select, "$city", "$text", citySelected);\n
+                                what.form.${prefix}city.style.display='none';\n
+                                what.form.${prefix}city_select.style.display='';\n!
+              }
+            }
+          $script_html .= "}\n";
+        }
+        $script_html .= "}\n";
+      }
+      $script_html .= "}\n";
+    }
+  }
+
+  $script_html .= <<END;
+    }
+    function ${prefix}city_select_changed(what) {
+END
+
+  if ( $cityflag ) {
+    $script_html .= <<END;
+      what.form.${prefix}city.value = what.options[what.selectedIndex].value;
+END
+  }
+
+  $script_html .= <<END;
+    }
     </SCRIPT>
 END
 
+  my $city_html = '';
+  if ( $cityflag ) {
+    if ( scalar (keys %{ $cust_main_county{$param->{'selected_country'}}{$param->{'selected_state'}}{$param->{'selected_county'}} }) > 1 ) {
+      $city_html .= qq!<SELECT NAME="${prefix}city_select" onChange="${prefix}city_select_changed(this); $param->{'onchange'}">!;
+      foreach my $city (
+        sort keys %{ $cust_main_county{$param->{'selected_country'}}{$param->{'selected_state'}}{$param->{'selected_county'}} }
+      ) {
+        my $text = $city || '(n/a)';
+        $city_html .= qq!<OPTION VALUE="$city"!.
+                      ($city eq $param->{'selected_city'} ?
+                        ' SELECTED>' :
+                        '>'
+                      ).
+                      $text;
+      }
+      $city_html .= qq!</OPTION><INPUT TYPE="text" ID="${prefix}city" NAME="${prefix}city" VALUE="$param->{'selected_city'}" style="display:none">!;
+    } else {
+      $city_html .= qq!<SELECT NAME="${prefix}city_select" onChange="${prefix}city_select_changed(this); $param->{'onchange'}" style="display:none"></SELECT>
+                    <INPUT TYPE="text" ID="${prefix}city" NAME="${prefix}city" VALUE="$param->{'selected_city'}" style="display:''">!;
+    }
+  }
+
   my $county_html = $script_html;
   if ( $countyflag ) {
-    $county_html .= qq!<SELECT NAME="${prefix}county" onChange="$param->{'onchange'}">!;
+    $county_html .= qq!<SELECT NAME="${prefix}county" !.
+                    qq!onChange="${prefix}county_changed(this); $param->{'onchange'}">!;
     foreach my $county ( 
       sort keys %{ $cust_main_county{$param->{'selected_country'}}{$param->{'selected_state'}} }
     ) {
@@ -2570,7 +2679,7 @@ END
 
   }
 
-  ($county_html, $state_html, $country_html);
+  ($county_html, $state_html, $country_html, $city_html);
 
 }
 
index 7ae0d48..798af70 100644 (file)
 </TR>
 
 <TR>
-  <TH ALIGN="right"><%=$r%>City</TH>
-  <TD>
-    <INPUT TYPE="text" ID="<%=$pre%>city" NAME="<%=$pre%>city" VALUE="<%= encode_entities(${$pre.'city'}) %>" onChange="<%= $onchange %>" <%=$disabled%>>
-  </TD>
   <%= 
-    ($county_html, $state_html, $country_html) = 
+    ($county_html, $state_html, $country_html, $city_html) =
       FS::SelfService::regionselector( {
         prefix           => $pre,
+        selected_city    => ${$pre.'city'},
         selected_county  => ${$pre.'county'},
         selected_state   => ${$pre.'state'},
         selected_country => ${$pre.'country'},
@@ -58,6 +55,8 @@
         locales          => \@cust_main_county,
       } );
 
+  $OUT .= qq!<TH ALIGN="right">${r}City</TH>!;
+  $OUT .= qq!<TD>$city_html</TD>!;
   $OUT .= qq!<TH ALIGN="right">${r}State/County</TH>!;
   $OUT .= qq!<TD>$county_html $state_html</TD>!;
   $OUT .= qq!<TH>${r}Zip</TH>!;
@@ -73,7 +72,7 @@
   <%=
     if ( $disabled ) {
       $OUT .= qq!var what = document.getElementById("${pre}city");!;
-      for (qw( county state country ) ) {
+      for (qw( city county state country ) ) {
         $OUT .= "what.form.$pre$_.disabled = true;";
         $OUT .= "what.form.$pre$_.style.backgroundColor = '#dddddd';";
       }
index d84edce..6af7d23 100644 (file)
@@ -155,7 +155,9 @@ foreach my $type ( ref($i->type) ? @{$i->type} : $i->type ) {
     }
   } elsif ( $type eq 'checkbox' ) {
     if ( defined $cgi->param($i->key.$n) ) {
-      push @touch, $i->key;
+      my $error = &{$i->validate}('', $n) if $i->validate;
+      push @error, $error if $error;
+      push @touch, $i->key if !$error;
     } else {
       push @delete, $i->key;
     }
@@ -167,7 +169,7 @@ foreach my $type ( ref($i->type) ? @{$i->type} : $i->type ) {
     if ( scalar(@{[ $cgi->param($i->key.$n) ]}) && $cgi->param($i->key.$n) ne '' ) {
       my $error = &{$i->validate}([ $cgi->param($i->key.$n) ], $n) if $i->validate;
       push @error, $error if $error;
-      $conf->set($i->key, join("\n", @{[ $cgi->param($i->key.$n) ]} ), $agentnum);
+      $conf->set($i->key, join("\n", @{[ $cgi->param($i->key.$n) ]} ), $agentnum) if !$error;
     } else {
       $conf->delete($i->key, $agentnum);
     }
index 53f538b..a400bc8 100644 (file)
@@ -297,6 +297,7 @@ my %substitutions = (
   'cust_pay'  => [
     '$paynum'         => 'Payment#',
     '$paid'           => 'Amount',
+    '$processing_fee' => 'Processing fee',
     '$payby'          => 'Payment method',
     '$date'           => 'Payment date',
     '$payinfo'        => 'Card/account# (masked)',
@@ -372,6 +373,7 @@ Substitutions: '
 Enclose substitutions and other Perl expressions in braces:
 <BR>{ $name } = ExampleCo (Smith, John)
 <BR>{ time2str("%D", time) } = '.time2str("%D", time).'
+<BR>{ "processing fee of $processing_fee" if $processing_fee; } = Will display text if there is a processing fee
 </P>';
 $sidebar .= include('/elements/template_image-dialog.html',
               'callback' => 'insertHtml'
index f8a46c7..30e4218 100644 (file)
@@ -296,7 +296,7 @@ my $widget = new HTML::Widgets::SelectLayers(
       $html .= '<TR><TD ALIGN="left" COLSPAN=2>' .
         include('/elements/progress-init.html',
               $part_export->exporttype,
-              [ $script.'_exportnum', $script.'_script' ],
+              [ $script.'_exportnum' ],
               rooturl().'view/svc_export/run_script.cgi',
               {
                 'error_url' => rooturl().$exports->{$layer}{scripts}{$script}->{error_url}."exportnum=".$part_export->{Hash}->{exportnum},
@@ -307,7 +307,6 @@ my $widget = new HTML::Widgets::SelectLayers(
               $script,
         ) .
         '<INPUT TYPE="hidden" NAME="'.$script.'_exportnum" VALUE="'.$part_export->{Hash}->{exportnum}.'">
-         <INPUT TYPE="hidden" NAME="'.$script.'_script" VALUE="'.$script.'">
         <A HREF="#" onClick="'.$script.'process();">'.$exports->{$layer}{scripts}{$script}->{html_label}.'</A></TD></TR>';
     }
 
index fcc138f..fcd6be4 100755 (executable)
@@ -27,26 +27,59 @@ my @expansion = split /[\n\r]{1,2}/, $cgi->param('expansion');
   $1;
 } @expansion;
 
-foreach ( @expansion ) {
-  my(%hash)=$cust_main_county->hash;
-  my($new)=new FS::cust_main_county \%hash;
-  $new->setfield('taxnum','');
-  $new->setfield('taxclass', '');
-  if ( $cgi->param('what') eq 'state' ) { #??
-    $new->setfield('state',$_);
-    $new->setfield('county', '');
-    $new->setfield('city', '');
-  } elsif ( $cgi->param('what') eq 'county' ) {
-    $new->setfield('county',$_);
-    $new->setfield('city', '');
-  } elsif ( $cgi->param('what') eq 'city' ) {
-    #uppercase cities in the US to try and agree with USPS validation
-    $new->setfield('city', $new->country eq 'US' ? uc($_) : $_ );
-  } else { #???
-    die 'unknown what '. $cgi->param('what');
+my $what = $cgi->param('what');
+foreach my $new_tax_area ( @expansion ) {
+
+  # Clone specific tax columns from original tax row
+  #
+  # UI Note:  Preserving original behavior, of cloning
+  #   tax amounts into new tax record, against better
+  #   judgement.  If the new city/county/state has a
+  #   different tax value than the one being populated
+  #   (rather likely?) now the user must remember to
+  #   revisit each newly created tax row, and correct
+  #   the possibly incorrect tax values that were populated.
+  #   Values would be easier to identify and correct if
+  #   they were initially populated with 0% tax rates
+  # District Note: The 'district' column is NOT cloned
+  #   to the new tax row.   Manually entered taxes
+  #   are not be divided into road maintenance districts
+  #   like Washington state sales taxes
+  my $new = FS::cust_main_county->new({
+    map { $_ => $cust_main_county->getfield($_) }
+    qw/
+      charge_prediscount
+      exempt_amount
+      exempt_amount_currency
+      recurtax
+      setuptax
+      tax
+      taxname
+    /
+  });
+
+  # Clone additional location columns, based on the $what value
+  my %clone_cols_for = (
+    state  => [qw/country /],
+    county => [qw/country state/],
+    city   => [qw/country state county/],
+  );
+
+  die "unknown what: $what"
+    unless grep { $_ eq $what } keys %clone_cols_for;
+
+  $new->setfield( $_ => $cust_main_county->getfield($_) )
+    for @{ $clone_cols_for{ $cgi->param('what') } };
+
+  # In the US, store cities upper case for USPS validation
+  $new_tax_area = uc($new_tax_area)
+    if $what eq 'city'
+    && $new->country eq 'US';
+
+  $new->setfield( $what, $new_tax_area );
+  if ( my $error = $new->insert ) {
+    die $error;
   }
-  my $error = $new->insert;
-  die $error if $error;
 }
 
 </%init>
index 3c5e917..f710d04 100644 (file)
@@ -131,6 +131,10 @@ function <% $pre %>county_changed(what, callback) {}
             <% $select_style %>
     >
 
+%   if ( $opt{city} ) {
+      <OPTION VALUE="<% $opt{city} %>" SELECTED><% $opt{city} %></OPTION>
+%   }
+
 %   unless ( $opt{'disable_empty'} ) {
       <OPTION VALUE="" <% $opt{city} eq '' ? 'SELECTED' : '' %>><% $opt{empty_label} %></OPTION>
 %   }
index 599f662..308b846 100644 (file)
@@ -180,7 +180,7 @@ unless ($opt{'for_prospect'}) {
 }
 
 my $first = 0;
-foreach my $phone_type ( qsearch({table=>'phone_type', order_by=>'weight'}) ) {
+foreach my $phone_type ( FS::phone_type->get_phone_types() ) {
   next if $phone_type->typename =~ /^(Home|Fax)$/;
   my $f = 'phonetypenum'.$phone_type->phonetypenum;
   $label{$f} = $phone_type->typename. ' phone';
index 2a62c5e..de3c6b7 100644 (file)
@@ -126,6 +126,8 @@ function <%$key%>process () {
     }
   }
 
+  Hash.push('key', '<%$key%>');
+
   // jsrsPOST = true;
   // jsrsExecute( '<% $action %>', <%$key%>myCallback, 'start_job', Hash );
 
index 5e30638..833f7c1 100644 (file)
@@ -1,8 +1,14 @@
 <%init>
   my( $cust_fields, %opt ) = @_;
 
-  use FS::ConfDefaults;
-  $opt{'avail_fields'} ||= [ FS::ConfDefaults->cust_fields_avail() ];
+  my @fields = FS::ConfDefaults->cust_fields_avail();
+  my $contact_phone_list;
+  foreach my $phone_type ( FS::phone_type->get_phone_types() ) {
+    $contact_phone_list .= " | Contact ".$phone_type->typename." phone(s)";
+  }
+  @fields = map {s/ \| Contact phone\(s\)/$contact_phone_list/g; $_; } @fields;
+
+  $opt{'avail_fields'} ||= [ @fields ];
 
   tie my %hash, 'Tie::IxHash', @{ $opt{'avail_fields'} };
 </%init>
index 94795de..40f55e7 100644 (file)
@@ -5,7 +5,7 @@
         <% $money_char %><INPUT NAME     = "amount"
                                 ID       = "amount"
                                 TYPE     = "text"
-                                VALUE    = "<% $amount %>"
+                                VALUE    = "0.00"
                                 SIZE     = 8
                                 STYLE    = "text-align:right;"
 %                               if ( $fee || $surcharge_percentage || $surcharge_flatfee || $processing_fee) {
@@ -44,7 +44,7 @@
         <TD>
           <TABLE><TR>
             <TD BGCOLOR="#ffffff">
-             <INPUT TYPE="checkbox" NAME="processing_fee" ID="processing_fee" VALUE="<% $processing_fee %>" onclick="<% $opt{prefix} %>process_fee_changed()">
+             <INPUT TYPE="checkbox" NAME="processing_fee" ID="processing_fee" VALUE="<% $processing_fee %>" onclick="<% $opt{prefix} %>process_fee_changed()" checked>
             </TD>
             <TD ID="ajax_processingfee_cell" BGCOLOR="#dddddd" STYLE="border:1px solid blue">
              <FONT SIZE="+1">A processing fee of <% $processing_fee %> is being applied to this transaction.</FONT>
@@ -140,6 +140,8 @@ if ( $amount ) {
 
   $amount += $surcharge;
 
+  $amount += $processing_fee; ## needed if processing fee is checked on default.
+
   $amount = sprintf("%.2f", $amount);
 }
 
index dd85133..cbac202 100644 (file)
@@ -6,6 +6,13 @@
 
 my( $cust_fields, %opt ) = @_;
 
-$opt{'avail_fields'} ||= [ FS::ConfDefaults->cust_fields_avail() ];
+my @fields = FS::ConfDefaults->cust_fields_avail();
+my $contact_phone_list;
+foreach my $phone_type ( FS::phone_type->get_phone_types() ) {
+  $contact_phone_list .= " | Contact ".$phone_type->typename." phone(s)";
+}
+@fields = map {s/ \| Contact phone\(s\)/$contact_phone_list/g; $_; } @fields;
+
+$opt{'avail_fields'} ||= [ @fields ];
 
 </%init>
index c5b84e7..27df962 100644 (file)
@@ -60,7 +60,7 @@ Example:
 
       $('#payment_option_row').<% $payment_option_row %>();
       $('#payment_amount_row').<% $payment_amount_row %>();
-      $('#ajax_processingfee_cell').hide();
+      $('#ajax_processingfee_cell').show();
 
       if($('#payment_amount_row').is(':visible')) {
         var surcharge;
index 56bcfd8..5b18367 100644 (file)
@@ -186,15 +186,22 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
       %saveopt = map { $_ => scalar($cgi->param($_)) } @{$payby2fields{$payby}};
     }
 
-    my $error = $cust_main->save_cust_payby(
-      'saved_cust_payby' => \$cust_payby,
-      'payment_payby' => $payby,
-      'auto'          => scalar($cgi->param('auto')),
-      'weight'        => scalar($cgi->param('weight')),
-      'payinfo'       => $payinfo,
-      'payname'       => $payname,
-      %saveopt
-    );
+    my $error;
+    {
+      local $@;
+      eval {
+        $error = $cust_main->save_cust_payby(
+          'saved_cust_payby' => \$cust_payby,
+          'payment_payby' => $payby,
+          'auto'          => scalar($cgi->param('auto')),
+          'weight'        => scalar($cgi->param('weight')),
+          'payinfo'       => $payinfo,
+          'payname'       => $payname,
+          %saveopt
+        );
+      };
+      $error ||= $@;
+    }
 
     errorpage("error saving info, payment not processed: $error")
       if $error;       
index 24cb237..5def389 100644 (file)
@@ -38,7 +38,6 @@ my $classnum_null = grep{ $_ eq 0           } $cgi->param('classnum');
 # Catch destination values from dest multi-checkbox, default to message
 # irrelevant to prospect contacts
 my @dest = grep{ /^(message|invoice)$/ } $cgi->param('dest');
-@dest = ('message') unless @dest;
 
 # Cache the contact_class table
 my %classname =
@@ -125,7 +124,7 @@ if (@classnum || $classnum_null) {
 if (@dest && $link eq 'cust_main') {
   my @stm;
   push @stm, "cust_contact.${_}_dest IS NOT NULL" for @dest;
-  $extra_sql .= "\nAND (".join(' OR ',@stm).') ';
+  $extra_sql .= "\nAND (".join(' AND ',@stm).') ';
 }
 
 if ($DEBUG) {
@@ -141,20 +140,23 @@ if ($DEBUG) {
 
 # Prepare to display phone numbers
 # adds 3 additional queries per table record :-(
-my %phonetype = (qw/1 Work 2 Home 3 Mobile 4 Fax/);
-my %phoneid   = (qw/Work 1 Home 2 Mobile 3 Fax 4/);
 my $get_phone_sub = sub {
   my $type = shift;
   return sub {
     my $rec = shift;
     my @p = qsearch('contact_phone', {
       contactnum => $rec->contact_contactnum,
-      phonetypenum => $phoneid{$type}
+      phonetypenum => $type,
     });
-    @p ? (join ', ',map{$_->phonenum} @p) : undef;
+    @p ? (join ', ',map{$_->phonenum_pretty} @p) : undef;
   };
 };
 
+my @phones;
+foreach my $phone_type ( FS::phone_type->get_phone_types() ) {
+  push @phones, { label => $phone_type->typename.' Phone', field => $get_phone_sub->($phone_type->phonetypenum), };
+}
+
 # Cache contact types
 my %classname =
   map {$_->classnum => $_->classname}
@@ -166,9 +168,7 @@ my @report = (
   { label => 'Last',   field => 'contact_last'  },
   { label => 'Title',  field => 'contact_title' },
   { label => 'E-Mail', field => 'contact_email_emailaddress' },
-  { label => 'Work Phone',   field => $get_phone_sub->('Work') },
-  { label => 'Mobile Phone', field => $get_phone_sub->('Mobile') },
-  { label => 'Home Phone',   field => $get_phone_sub->('Home') },
+  @phones,
   { label => 'Type',
     field => sub {
       my $rec = shift;
index 83ca721..46e35da 100755 (executable)
@@ -51,6 +51,7 @@ my %search_hash = ();
 my @scalars = qw (
   agentnum salesnum status
   address city county state zip country location_history
+  daytime night mobile fax
   invoice_terms
   no_censustract with_geocode with_email tax no_tax POST no_POST
   custbatch usernum
diff --git a/httemplate/search/elements/cust_main_phones.html b/httemplate/search/elements/cust_main_phones.html
new file mode 100644 (file)
index 0000000..366d098
--- /dev/null
@@ -0,0 +1,28 @@
+<TR>
+  <TH VALIGN="top" ALIGN="right"><% mt('Phones') |h %></TD>
+  <TD COLSPAN=6>
+    <TABLE CELLSPACING=0 CELLPADDING=0>
+      <TR>
+% foreach my $phone (qw(daytime night mobile fax)) {
+        <TD>
+          <INPUT TYPE="text"
+                 NAME="<% $phone %>"
+                 VALUE=""
+                 SIZE=18
+          >
+          <BR><FONT SIZE=-1 COLOR="#333333"><% mt($phone_label{$phone}) |h %></FONT>
+        </TD>
+        <TD>&nbsp;</TD>
+% }
+      </TR>
+    </TABLE>
+  </TD>
+</TR>
+<%init>
+my %phone_label = (
+  daytime => 'Day Phone',
+  night   => 'Night Phone',
+  mobile  => 'Mobile Phone',
+  fax     => 'Fax Number',
+);
+</%init>
\ No newline at end of file
index cfbf834..372bc67 100644 (file)
       <TH ALIGN="right" VALIGN="center"><% mt('Email') |h %></TH>
       <TD><INPUT TYPE="text" NAME="<%$field_prefix%>email" SIZE=54></TD>
     </TR>
-
-    <TR>
-      <TH ALIGN="right" VALIGN="center"><% mt('Home Phone') |h %></TH>
-      <TD><INPUT TYPE="text" NAME="<%$field_prefix%>homephone" SIZE=54></TD>
-    </TR>
-
-    <TR>
-      <TH ALIGN="right" VALIGN="center"><% mt('Work Phone') |h %></TH>
-      <TD><INPUT TYPE="text" NAME="<%$field_prefix%>workphone" SIZE=54></TD>
-    </TR>
-
+% foreach my $phone_type ( FS::phone_type->get_phone_types() ) {
     <TR>
-      <TH ALIGN="right" VALIGN="center"><% mt('Mobile Phone') |h %></TH>
-      <TD><INPUT TYPE="text" NAME="<%$field_prefix%>mobilephone" SIZE=54></TD>
+      <TH ALIGN="right" VALIGN="center"><% $phone_type->typename. ' Phone' |h %></TH>
+      <TD><INPUT TYPE="text" NAME="<% $field_prefix %>phonetypenum<% $phone_type->phonetypenum %>" SIZE=54></TD>
     </TR>
+% }
 
 <%init>
 
index da2f1a4..3dd92af 100755 (executable)
   <FONT CLASS="fsinnerbox-title"><% emt('Location search options') %></FONT>
   <TABLE CLASS="fsinnerbox">
     <& elements/options_cust_location.html &>
+    <& elements/cust_main_phones.html &>
   </TABLE>
   <BR>
 
index 9252b21..0fdcc53 100644 (file)
@@ -13,7 +13,7 @@
   <%$th%>Send messages</TH>
   <%$th%>Self-service</TH>
 % foreach my $phone_type (@phone_type) {
-    <%$th%><% $phone_type->typename |h %></TH>
+    <%$th%><% $phone_type->typename |h %> phone</TH>
 % }
   <%$th%>Comment</TH>
 </TR>
@@ -80,7 +80,7 @@
 %}
 <%once>
 
-my @phone_type = qsearch({table=>'phone_type', order_by=>'weight'});
+my @phone_type = FS::phone_type->get_phone_types();
 
 </%once>
 <%init>
index ba58bbd..f052499 100644 (file)
@@ -14,17 +14,13 @@ my %param = ();
     }
   }
 
-my $exportnum;
-my $method;
-for (grep /^*_script$/, keys %param) { 
-       $exportnum = $param{$param{$_}.'_exportnum'};
-       $method = $param{$param{$_}.'_script'};
-}
+my $run_script = $param{'key'};
+my $exportnum = $param{$run_script.'_exportnum'};
 
 my $part_export = qsearchs('part_export', { 'exportnum'=> $exportnum, } )
        or die "unknown exportnum $exportnum";
 
-my $class = 'FS::part_export::'.$part_export->{Hash}->{exporttype}.'::'.$method;
+my $class = 'FS::part_export::'.$part_export->{Hash}->{exporttype}.'::'.$run_script;
 
 my $server = new FS::UI::Web::JSRPC $class, $cgi;