default to a session cookie instead of setting an explicit timeout, weird timezone... master
authorIvan Kohler <ivan@freeside.biz>
Tue, 18 Jul 2023 23:28:58 +0000 (16:28 -0700)
committerIvan Kohler <ivan@freeside.biz>
Tue, 18 Jul 2023 23:28:58 +0000 (16:28 -0700)
73 files changed:
FS/FS/AccessRight.pm
FS/FS/Auth/internal.pm
FS/FS/AuthCookieHandler.pm
FS/FS/Conf.pm
FS/FS/Cron/backup.pm
FS/FS/GeocodeCache.pm
FS/FS/Mason.pm
FS/FS/Misc.pm
FS/FS/Misc/Geo.pm
FS/FS/Report/FCC_477.pm
FS/FS/Schema.pm
FS/FS/access_user.pm
FS/FS/cdr.pm
FS/FS/cdr/broadsoft.pm
FS/FS/cdr/broadsoft22.pm
FS/FS/cdr/taqua62.pm
FS/FS/cdr/telapi_voip.pm
FS/FS/cust_location.pm
FS/FS/cust_main/Search.pm
FS/FS/cust_payby.pm
FS/FS/cust_pkg/Search.pm
FS/FS/deploy_zone.pm
FS/FS/msg_template.pm
FS/FS/msg_template/email.pm
FS/FS/part_pkg/voip_cdr.pm
FS/FS/password_history.pm
FS/MANIFEST
FS/bin/freeside-cdr-sftp_and_import
FS/bin/freeside-cdrrewrited
FS/bin/freeside-censustract-update
FS/bin/freeside-svc_acct-bulk_change [new file with mode: 0755]
bin/part_pkg-bulk_change
debian/control
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
httemplate/browse/access_user.html
httemplate/browse/deploy_zone.html
httemplate/docs/about.html
httemplate/edit/deploy_zone-fixed.html
httemplate/edit/elements/edit.html
httemplate/edit/part_pkg.cgi
httemplate/edit/process/access_user.html
httemplate/edit/process/cust_location-censustract.html
httemplate/edit/process/cust_main.cgi
httemplate/edit/process/elements/ApplicationCommon.html
httemplate/elements/columnnext.html
httemplate/elements/columnstart.html
httemplate/elements/cust_payby.html
httemplate/elements/dashboard-topnotes.html [new file with mode: 0644]
httemplate/elements/header-full.html
httemplate/elements/menu.html
httemplate/elements/standardize_locations.js
httemplate/elements/tablebreak-tr-title.html
httemplate/elements/tr-justtitle.html
httemplate/elements/tr-note.html [new file with mode: 0644]
httemplate/elements/tr-pkg_svc.html
httemplate/loginout/login.html
httemplate/misc/confirm-censustract.html
httemplate/misc/openmap.html
httemplate/misc/xmlhttp-censustract.html
httemplate/misc/xmlhttp-cust_main-display_recurring.html
httemplate/pref/pref.html
httemplate/pref/set_totp_secret32.html [new file with mode: 0644]
httemplate/search/477.html
httemplate/search/cust_event.html
httemplate/search/cust_main.html
httemplate/search/elements/report_svc_Common.html
httemplate/search/log.html
httemplate/search/report_cust_pkg.html
httemplate/search/report_svc_broadband.html
httemplate/view/cust_main/menu.html
httemplate/view/deploy_zone-geojson.cgi [new file with mode: 0644]
httemplate/view/deploy_zone-kmz.cgi [new file with mode: 0644]
httemplate/view/deploy_zone-shp.cgi [new file with mode: 0644]

index 5badba7..601337e 100644 (file)
@@ -210,6 +210,7 @@ tie my %rights, 'Tie::IxHash',
     { rightname=>'Backdate payment', desc=>'Enable payments to be posted for days other than today.' },
     'Post check payment',
     'Post cash payment',
+    'Post Paypal payment',
     'Post payment batch',
     'Apply payment', #NEWNEW
     { rightname=>'Unapply payment', desc=>'Enable "unapplication" of unclosed payments from specific invoices.' }, #aka. unapplypayments
index dfc5f30..92dff03 100644 (file)
@@ -7,7 +7,7 @@ use FS::Record qw( qsearchs );
 use FS::access_user;
 
 sub authenticate {
-  my($self, $username, $check_password ) = @_;
+  my($self, $username, $check_password, $totp_code ) = @_;
 
   my $access_user =
     ref($username) ? $username
@@ -17,6 +17,7 @@ sub authenticate {
                              )
     or return 0;
 
+  my $pw_check;
   if ( $access_user->_password_encoding eq 'bcrypt' ) {
 
     my( $cost, $salt, $hash ) = split(',', $access_user->_password);
@@ -29,17 +30,21 @@ sub authenticate {
                                            )
                               );
 
-    $hash eq $check_hash;
+    $pw_check = $hash eq $check_hash;
 
-  } else { 
+  } else {
 
     return 0 if $access_user->_password eq 'notyet'
              || $access_user->_password eq '';
 
-    $access_user->_password eq $check_password;
+    $pw_check = $access_user->_password eq $check_password;
 
   }
 
+  return $pw_check if ! $pw_check || ! length($access_user->totp_secret32);
+
+  #2fa
+  $access_user->google_auth->verify( $totp_code, 1 );
 }
 
 sub autocreate { 0; }
index 93d8ea6..b7d0dbf 100644 (file)
@@ -13,13 +13,13 @@ sub useragent_ip {
 }
 
 sub authen_cred {
-  my( $self, $r, $username, $password ) = @_;
+  my( $self, $r, $username, $password, $totp_code ) = @_;
 
   preuser_setup();
 
   my $info = {};
 
-  unless ( FS::Auth->authenticate($username, $password, $info) ) {
+  unless ( FS::Auth->authenticate($username, $password, $totp_code, $info) ) {
     warn "failed auth $username from ". $self->useragent_ip($r). "\n";
     return undef;
   }
index cbccffb..57a8867 100644 (file)
@@ -2452,8 +2452,8 @@ and customer address. Include units.',
 
   {
     'key'         => 'selfservice-timeout',
-    'section'     => 'self-service',
-    'description' => 'Timeout for the self-service login cookie, in seconds.  Defaults to 1 hour.',
+    'section'     => 'deprecated',
+    'description' => 'Deprecated.  Was the timeout for the self-service login cookie, in seconds.  Defaulted to 1 hour.',
     'type'        => 'text',
   },
 
@@ -2672,7 +2672,7 @@ and customer address. Include units.',
     'section'     => 'payments',
     'description' => 'Available payment types.',
     'type'        => 'selectmultiple',
-    'select_enum' => [ qw(CARD DCRD CHEK DCHK) ], #BILL CASH WEST MCRD MCHK PPAL) ],
+    'select_enum' => [ qw(CARD DCRD CHEK DCHK PPAL) ], #BILL CASH WEST MCRD MCHK PPAL) ],
   },
 
   {
@@ -4203,6 +4203,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'dashboard-topnotes',
+    'section'     => 'UI',
+    'description' => 'Note to display on the top of the front page',
+    'type'        => 'textarea',
+  },
+
+  {
     'key'         => 'dashboard-toplist',
     'section'     => 'UI',
     'description' => 'List of items to display on the top of the front page',
@@ -4581,13 +4588,25 @@ and customer address. Include units.',
 
   {
     'key'         => 'census_year',
-    'section'     => 'addresses',
-    'description' => 'The year to use in census tract lookups.  NOTE: you need to select 2012 or 2013 for Year 2010 Census tract codes.  A selection of 2011 provides Year 2000 Census tract codes.  Use the freeside-censustract-update tool if exisitng customers need to be changed.',
+    'section'     => 'deprecated',
+    'description' => 'Deprecated.  Used to control the year used for census lookups.  2020 census data is now the default.  Use the freeside-censustract-update tool if exisitng customers need to be changed.  See the <a href ="#census_legacy">census_legacy</a> configuration option if you need old census data to re-file pre-2022 FCC 477 reports.',
     'type'        => 'select',
     'select_enum' => [ qw( 2017 2016 2015 ) ],
   },
 
   {
+    'key'         => 'census_legacy',
+    'section'     => 'addresses',
+    'description' => 'Use old census data (and source).  Should only be needed if re-filing pre-2022 FCC 477 reports.',
+    'type'        => 'select',
+    'select_hash' => [ '' => 'Disabled (2020)',
+                       '2015' => '2015',
+                       '2016' => '2016',
+                       '2017' => '2017',
+                     ],
+  },
+
+  {
     'key'         => 'tax_district_method',
     'section'     => 'taxation',
     'description' => 'The method to use to look up tax district codes.',
@@ -5188,6 +5207,14 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'cdr-skip_duplicate_rewrite-sipcallid',
+    'section'     => 'telephony',
+    'description' => 'Use the freeside-cdrrewrited daemon to prevent billing CDRs with a sipcallid identical to an existing CDR',
+    'type'        => 'checkbox',
+  },
+
+
+  {
     'key'         => 'cdr-charged_party_rewrite',
     'section'     => 'telephony',
     'description' => 'Do charged party rewriting in the freeside-cdrrewrited daemon; useful if CDRs are being dropped off directly in the database and require special charged_party processing such as cdr-charged_party-accountcode or cdr-charged_party-truncate*.',
index 8264ae1..65d7acb 100644 (file)
@@ -25,7 +25,11 @@ sub backup {
 
   my $ext;
   if ( driver_name eq 'Pg' ) {
-    system("pg_dump -Fc -T h_cdr -T h_queue -T h_queue_arg -T sessions $database >/var/tmp/$database.Pg");
+    system('pg_dump -Fc '. join(' ', map { "--exclude-table-data $_" }
+                                       qw( h_cdr h_queue h_queue_arg sessions )
+                               ).
+                           " $database >/var/tmp/$database.Pg"
+          );
     $ext = 'Pg';
   } elsif ( driver_name eq 'mysql' ) {
     system("mysqldump $database >/var/tmp/$database.sql");
index 7829c4d..9a30f00 100644 (file)
@@ -110,23 +110,23 @@ Look up the censustract, if it's not already filled in, and return it.
 On error, sets 'error' and returns nothing.
 
 This uses the "get_censustract_*" methods in L<FS::Misc::Geo>; currently
-the only one is 'ffiec'.
+available are 'uscensus' (default) or 'ffiec' (legacy, used if the
+census_legacy configuration option is set).
 
 =cut
 
 sub set_censustract {
   my $self = shift;
 
-  if ( $self->get('censustract') =~ /^\d{9}\.\d{2}$/ ) {
+  if ( $self->get('censustract') =~ /^\d{9}(\.\d{2}|\d{6})$/ ) {
     return $self->get('censustract');
   }
-  my $censusyear = $conf->config('census_year');
-  return if !$censusyear;
 
-  my $method = 'ffiec';
-  # configurable censustract-only lookup goes here if it's ever needed.
+  my $year = $conf->config('census_legacy') || 2020;
+  my $method = ($year==2020) ? 'uscensus' : 'ffiec';
+
   $method = "get_censustract_$method";
-  my $censustract = eval { FS::Misc::Geo->$method($self, $censusyear) };
+  my $censustract = eval { FS::Misc::Geo->$method($self, $year) };
   $self->set("censustract_error", $@);
   $self->set("censustract", $censustract);
 }
index 8dd72ac..ebd40ad 100644 (file)
@@ -85,6 +85,7 @@ if ( -e $addl_handler_use_file ) {
     die $@ if $@;
   }
   use Text::CSV_XS;
+  use Archive::Zip;
   use Spreadsheet::WriteExcel;
   use Spreadsheet::WriteExcel::Utility;
   use OLE::Storage_Lite;
@@ -120,7 +121,10 @@ if ( -e $addl_handler_use_file ) {
   use Locale::Currency::Format;
   use Number::Phone::Country qw( noexport );
   use Business::US::USPS::WebTools::AddressStandardization;
-  use Geo::GoogleEarth::Pluggable;
+  use Geo::GoogleEarth::Pluggable 0.16;
+  use Geo::Shapelib;
+  use Geo::JSON;
+  use Geo::JSON::FeatureCollection;
   use LWP::UserAgent;
   use Storable qw( nfreeze thaw );
   use FS;
index 5eba874..139f05d 100644 (file)
@@ -15,7 +15,7 @@ use Encode;
 #instead
 
 @ISA = qw( Exporter );
-@EXPORT_OK = qw( send_email generate_email send_fax
+@EXPORT_OK = qw( send_email generate_email send_fax _sendmail
                  states_hash counties cities state_label
                  card_types
                  pkg_freqs
@@ -272,28 +272,6 @@ sub send_email {
 
   #send the email
 
-  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
-                   'helo' => $domain,
-                 );
-
-  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
-  $smtp_opt{'port'} = $port;
-
-  my $error = '';
-  if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
-    $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
-  } elsif ( defined($enc) && $enc eq 'starttls') {
-    $error = "SMTP settings misconfiguration: STARTTLS enabled in ".
-            "smtp-encryption but smtp-username or smtp-password missing";
-  }
-
-  if ( defined($enc) ) {
-    $smtp_opt{'ssl'} = 'starttls' if $enc eq 'starttls';
-    $smtp_opt{'ssl'} = 1          if $enc eq 'tls';
-  }
-
-  my $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
-  
   push @to, $options{bcc} if defined($options{bcc});
   # fully unpack all addresses found in @to (including Bcc) to make the
   # envelope list
@@ -302,22 +280,11 @@ sub send_email {
     push @env_to, map { $_->address } Email::Address->parse($dest);
   }
 
-  unless ( length($error) ) {
-
-    local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
-    local $@; # just in case
-    eval { sendmail($message, { transport => $transport,
-                                from      => $from,
-                                to        => \@env_to }) };
-
-    if (ref($@) and $@->isa('Email::Sender::Failure')) {
-      $error = $@->code.' ' if $@->code;
-      $error .= $@->message;
-    } else {
-      $error = $@;
-    }
-
-  }
+  my $error = _sendmail( $message, { 'from'    => $from,
+                                     'to'      => \@env_to,
+                                     'domain'  => $domain,
+                                   }
+                       );
 
   # Logging
   if ( $conf->exists('log_sent_mail') ) {
@@ -341,6 +308,48 @@ sub send_email {
    
 }
 
+sub _sendmail {
+  my($message, $options) = @_;
+  my $domain = delete $options->{'domain'};
+
+  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
+                   'helo' => $domain,
+                 );
+
+  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
+  $smtp_opt{'port'} = $port;
+
+  if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
+    $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
+  } elsif ( defined($enc) && $enc eq 'starttls') {
+    return "SMTP settings misconfiguration: STARTTLS enabled in ".
+           "smtp-encryption but smtp-username or smtp-password missing";
+  }
+
+  if ( defined($enc) ) {
+    $smtp_opt{'ssl'} = 'starttls' if $enc eq 'starttls';
+    $smtp_opt{'ssl'} = 1          if $enc eq 'tls';
+  }
+
+  $options->{'transport'} = Email::Sender::Transport::SMTP->new( %smtp_opt );
+
+  my $error = '';
+
+  local $SIG{__DIE__}; # don't want Mason __DIE__ handler active
+  local $@; # just in case
+  eval { sendmail($message, $options) };
+
+  if (ref($@) and $@->isa('Email::Sender::Failure')) {
+    $error = $@->code.' ' if $@->code;
+    $error .= $@->message;
+  } else {
+    $error = $@;
+  }
+
+  $error;
+
+}
+
 =item generate_email OPTION => VALUE ...
 
 Options:
index bc020a2..6f923f5 100644 (file)
@@ -38,6 +38,10 @@ Given a location hash (see L<FS::location_Mixin>) and a census map year,
 returns a census tract code (consisting of state, county, and tract 
 codes) or an error message.
 
+Data source: Federal Financial Institutions Examination Council
+
+Note: This is the old method for pre-2022 (census year 2020) reporting.
+
 =cut
 
 sub get_censustract_ffiec {
@@ -105,6 +109,84 @@ sub get_censustract_ffiec {
   }
 }
 
+=item get_censustract_uscensus LOCATION YEAR
+
+Given a location hash (see L<FS::location_Mixin>) and a census map year,
+returns a census tract code (consisting of state, county, tract, and block
+codes) or an error message.
+
+Data source: US Census Bureau
+
+This is the new method for 2022+ (census year 2020) reporting.
+
+=cut
+
+sub get_censustract_uscensus {
+  my $class    = shift;
+  my $location = shift;
+  my $year     = shift || 2020;
+
+  if ( length($location->{country}) and uc($location->{country}) ne 'US' ) {
+    return '';
+  }
+
+  warn Dumper($location, $year) if $DEBUG;
+
+  my $url = 'https://geocoding.geo.census.gov/geocoder/geographies/address?';
+
+  my $address1 = $location->{address1};
+  $address1 =~ s/(apt|ste|suite|unit)[\s\d]\w*\s*$//i;
+
+  my $query_hash = {
+                     street     => $address1,
+                     city       => $location->{city},
+                     state      => $location->{state},
+                     benchmark  => 'Public_AR_Current',
+                     vintage    => 'Census'.$year.'_Current',
+                     format     => 'json',
+                   };
+
+  my $full_url = URI->new($url);
+  $full_url->query_form($query_hash);
+
+  warn "Full Request URL: \n".$full_url if $DEBUG;
+
+  my $ua = new LWP::UserAgent;
+  my $res = $ua->get( $full_url );
+
+  warn $res->as_string if $DEBUG > 2;
+
+  if (!$res->is_success) {
+    die 'Census tract lookup error: '.$res->message;
+  }
+
+  local $@;
+  my $content = eval { decode_json($res->content) };
+  die "Census tract JSON error: $@\n" if $@;
+
+  warn Dumper($content) if $DEBUG;
+
+  my $addressMatches_ref = $content->{result}->{addressMatches};
+
+  if ( $addressMatches_ref && scalar @{$addressMatches_ref} ) {
+
+    my $tract = $addressMatches_ref->[0]->{geographies}->{'Census Blocks'}[0]->{GEOID};
+    return $tract;
+
+  } else {
+
+    my $error = 'Lookup failed, but with no status message.';
+
+    if ( $content->{errors} ) {
+      $error = join("\n", $content->{errors});
+    }
+
+    die "$error\n";
+
+  }
+}
+
+
 #sub get_district_methods {
 #  ''         => '',
 #  'wa_sales' => 'Washington sales tax',
@@ -660,6 +742,7 @@ sub subloc_address2 {
   ($subloc, $addr2);
 }
 
+#is anyone still using this?
 sub standardize_melissa {
   my $class = shift;
   my $location = shift;
index 75ddee0..3749bfe 100644 (file)
@@ -365,10 +365,12 @@ sub fbd_sql {
     'adv_speed_down',
     'adv_speed_up',
     'CASE WHEN is_business IS NOT NULL THEN 1 ELSE 0 END',
-    'cir_speed_down',
-    'cir_speed_up',
   );
-  push @select, 'blocknum' if $opt{detail};
+  push @select, 'cir_speed_down', 'cir_speed_up'
+    if $opt{date} < 1569826800; #9/30/2019, halfway between the two filing
+                                # "as of" dates when it changed
+  push @select, 'blocknum'
+    if $opt{detail};
 
   my $from = 'deploy_zone_block
     JOIN deploy_zone USING (zonenum)
@@ -380,9 +382,10 @@ sub fbd_sql {
   );
   push @where, "agentnum = $agentnum" if $agentnum;
 
-  my $order_by = 'censusblock, agentnum, technology, is_consumer, is_business';
+  #my $order_by = 'censusblock, agentnum, technology, is_consumer, is_business';
+  my $order_by = 'censusblock, technology';
 
-  "SELECT ".join(', ', @select) . "
+  "SELECT DISTINCT ".join(', ', @select) . "
   FROM $from
   WHERE ".join(' AND ', @where)."
   ORDER BY $order_by
@@ -396,7 +399,7 @@ sub fbs_sql {
   my $agentnum = $opt{agentnum};
   my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)';
 
-  my $censustract = "replace(cust_location.censustract, '.', '')";
+  my $censustract = "substr( replace(cust_location.censustract, '.', ''), 1, 11)";
 
   my @select = (
     "$censustract AS censustract",
@@ -471,7 +474,7 @@ sub fvs_sql {
   my $date = $opt{date} || time;
   my $agentnum = $opt{agentnum};
   my $q = $opt{ignore_quantity} ? '1' : 'COALESCE(cust_pkg.quantity, 1)';
-  my $censustract = "replace(cust_location.censustract, '.', '')";
+  my $censustract = "substr( replace(cust_location.censustract, '.', ''), 1, 11)";
 
   my @select = (
     "$censustract AS censustract",
index 569401b..61b793b 100644 (file)
@@ -5736,6 +5736,9 @@ sub tables_hashref {
         # FK to cust_bill_pkg_detail; having a value here absolutely means
         # that the CDR appears on an invoice
         'detailnum',     'bigint',    'NULL',      '', '', '',
+
+        #for mediation/deduplication
+        'sipcallid',    'varchar',    'NULL',     255, '', '',
       ],
       'primary_key' => 'acctid',
       'unique' => [],
@@ -5749,7 +5752,7 @@ sub tables_hashref {
                    [ 'freesidestatus' ], [ 'freesiderewritestatus' ],
                    [ 'cdrbatchnum' ],
                    [ 'src_ip_addr' ], [ 'dst_ip_addr' ], [ 'dst_term' ],
-                   [ 'detailnum' ],
+                   [ 'detailnum' ], [ 'sipcallid' ],
                  ],
       #no FKs on cdr table... choosing not to throw errors no matter what's
       # thrown in here.  better to have the data.
@@ -5928,6 +5931,7 @@ sub tables_hashref {
         'username',           'varchar',     '', $char_d, '', '',
         '_password',          'varchar', 'NULL', $char_d, '', '',
         '_password_encoding', 'varchar', 'NULL', $char_d, '', '',
+        'totp_secret32',         'char', 'NULL',      32, '', '',
         'last',               'varchar', 'NULL', $char_d, '', '', 
         'first',              'varchar', 'NULL', $char_d, '', '', 
         'user_custnum',           'int', 'NULL',      '', '', '',
@@ -7450,6 +7454,7 @@ sub tables_hashref {
         'is_business',    'char',    'NULL', 1,       '', '',
         'active_date',    @date_type,                 '', '',
         'expire_date',    @date_type,                 '', '',
+        'disabled',       'char',    'NULL',       1, '', '',
       ],
       'primary_key' => 'zonenum',
       'unique' => [],
index f23aa77..270f8bb 100644 (file)
@@ -13,6 +13,7 @@ use FS::agent;
 use FS::cust_main;
 use FS::sales;
 use Carp qw( croak );
+use Auth::GoogleAuth;
 
 $DEBUG = 0;
 $me = '[FS::access_user]';
@@ -239,6 +240,7 @@ sub check {
     $self->ut_numbern('usernum')
     || $self->ut_alpha_lower('username')
     || $self->ut_textn('_password')
+    || $self->ut_alphan('totp_secret32')
     || $self->ut_textn('last')
     || $self->ut_textn('first')
     || $self->ut_foreign_keyn('user_custnum', 'cust_main', 'custnum')
@@ -733,6 +735,44 @@ sub change_password_fields {
   FS::Auth->auth_class->change_password_fields( @_ );
 }
 
+=item google_auth
+
+=cut
+
+sub google_auth {
+  my( $self ) = @_;
+  my $issuer = FS::Conf->new->config('company_name'). ' Freeside';
+  my $label = $issuer. ':'. $self->username;
+
+  Auth::GoogleAuth->new({
+    secret => $self->totp_secret32,
+    issuer => $issuer,
+    key_id => $label,
+  });
+
+}
+
+=item set_totp_secret32
+
+=cut
+
+sub set_totp_secret32 {
+  my( $self ) = @_;
+
+  $self->totp_secret32( $self->google_auth->generate_secret32 );
+  $self->replace;
+}
+
+=item totp_qr_code_url
+
+=cut
+
+sub totp_qr_code_url {
+  my( $self ) = @_;
+
+  $self->google_auth->qr_code;
+}
+
 =item locale
 
 =cut
index 85fccac..e0c4bd4 100644 (file)
@@ -185,6 +185,8 @@ following fields are currently supported:
 
 =item detailnum - Link to invoice detail (L<FS::cust_bill_pkg_detail>)
 
+=item sipcallid - SIP Call-ID
+
 =back
 
 =head1 METHODS
@@ -1870,10 +1872,11 @@ sub batch_import {
   my $iopt = _import_options;
   $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
 
-  if ( defined $opt->{'cdrtypenum'} ) {
-        $opt->{'preinsert_callback'} = sub {
-                my($record,$param) = (shift,shift);
-                $record->cdrtypenum($opt->{'cdrtypenum'});
+  if ( grep defined $opt->{$_}, qw(cdrtypenum carrierid) ) {
+        $opt->{preinsert_callback} = sub {
+                my($record, $param) = @_;
+                $record->$_($opt->{$_})
+                  foreach grep defined $opt->{$_}, qw(cdrtypenum carrierid);
                 '';
         };
   }
index a6f4d01..ab48150 100644 (file)
 package FS::cdr::broadsoft;
 
+=head1 NAME
+
+FS::cdr::broadsoft - CDR parse module for Broadsoft
+
+=head1 DESCRIPTION
+
+Ref: BW-AccountingCDRInterfaceSpec-R22.pdf
+
+=cut
+
 use strict;
 use base qw( FS::cdr );
 use vars qw( %info );
 use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
 
 %info = (
-  'name'          => 'Broadsoft',
-  'weight'        => 500,
-  'header'        => 1,     #0 default, set to 1 to ignore the first line, or
-                            # to higher numbers to ignore that number of lines
-  'type'          => 'csv',
-  'sep_char'      => ',',   #for csv, defaults to ,
-  'disabled'      => 0,     #0 default, set to 1 to disable
-
-  #listref of what to do with each field from the CDR, in order
-  'import_fields' => [
-    
+  name     => 'Broadsoft',
+  weight   => 500,
+  header   => 1,
+  type     => 'csv',
+  sep_char => ',',
+  disabled => 0,
+
+  #deal with broadsoft's awful non-standard CSV escaping :/
+  row_callback => sub {
+    my $line = shift;
+    $line = qq("$line");       # put " at the beginning and end
+    $line =~ s/(?<!\\),/","/g; # all commas not after a \ become ","
+    $line =~ s/\\,/,/g;        # and now we can turn \, into ,
+    #XXX embedded \r and \n ?  none in my test data, and might be better to 
+    # leave escaped and deal with it from there?
+    $line =~ s/\\\\/\\/g;     # undo double backslashes?  none in my test data
+
+    #and now we have a properly formed CSV line
+    $line;
+  },
+
+  import_fields => [
+
+    # 1: recordId
+    # 2: serviceProvider
     skip(2),
-    sub { my($cdr, $data, $conf, $param) = @_;
-          $param->{skiprow} = 1 if lc($data) ne 'normal';
-          '' },                                   #  3: type
-            
+
+    # 3: type
+    sub {
+      my ( $cdr, $data, $conf, $param ) = @_;
+      $param->{skiprow} = 1
+        if lc($data) ne 'normal';
+      '';
+    },
+
+    # 4: userNumber
+    # 5: groupNumber
     skip(2),
-    'dcontext',                                          #  6: direction
-    trim('src'),                                  #  7: callingNumber
+
+    # 6: direction
+    'dcontext',
+
+    # 7: callingNumber
+    trim('src'),
+
+    # 8: callingPresentationINdicator
     skip(1),
-    trim('dst'),                                  #  9: calledNumber
 
-    _cdr_date_parser_maker('startdate'),          # 10: startTime
+    # 9: calledNumber
+    trim('dst'),
+
+    # 10: startTime
+    _cdr_date_parser_maker('startdate'),
+
+    # 11: userTimeZone
     skip(1),
-    sub { my($cdr, $data) = @_;
-          $cdr->disposition(
-            lc($data) eq 'yes' ? 
-            'ANSWERED' : 'NO ANSWER') },          # 12: answerIndicator
-    _cdr_date_parser_maker('answerdate'),         # 13: answerTime
-    _cdr_date_parser_maker('enddate'),            # 14: releaseTime
-    skip(17),
-    sub { my($cdr, $accountcode) = @_;
-    if ($cdr->is_tollfree){
-       my $dst = substr($cdr->dst,0,32);
-        $cdr->set('accountcode', $dst);
-    } else {
-        $cdr->set('accountcode', $accountcode);
-    }},
+
+    # 12: answerIndicator
+    sub {
+      my( $cdr, $data ) = @_;
+      $cdr->disposition( $data =~ /^yes/i ? 'ANSWERED' : 'NO ANSWER');
+    },
+
+    # 13: answerTime
+    _cdr_date_parser_maker('answerdate'),
+
+    # 14: releaseTime
+    _cdr_date_parser_maker('enddate'),
+
+    # 15: terminationCause
+    # 16: networkType
+    # 17: carrierIdentificationCode
+    # 18: dialedDigits
+    # 19: callCategory
+    # 20: networkCallType
+    # 21: networkTranslatedNumber
+    # 22: networkTranslatedGroup
+    # 23: releasingParty
+    # 24: route
+    skip(10),
+
+    # 25: networkCallID
+    'sipcallid',
+
+    # 26: codedc
+    # 27: accessDeviceAddress
+    # 28: accessCallID
+    # 29: spare
+    # 30: failoverCorrelationId
+    # 31: spare
+    # 32: group
+    # 33: department
+    skip(8),
+
+    # 34: accountCode
+    'accountcode',
+
+    # 35: authorizationCode
+    # 36: originalCalledNumber
+    # 37: originalCalledPresentationIndicator
+    # 38: originalCalledReason
+    # 39: redirectingNumber
+    # 40: redirectingPresentationIndicator
+    # 41: redirectingReason
+    # 42: chargeIndicator
+    # 43: typeOfNetwork
+    # 44: voicePortalCalling.invocationTime
+    # 45: localCallId
+    # 46: remoteCallId
+    # 47: callingPartyCategory
+    #
+    # Also... cols 48 - 448 see Broadsoft documentation
+    skip(87), #35-121 inclusive
+
+    #122: otherPartyName
+    'clid',
+
+    #123: otherPartyNamePresentationIndicator
+    sub {
+      my( $cdr, $data ) = @_;
+      $cdr->clid( $data ) unless $data =~ /^Public$/i;
+    },
+
+    skip(22), #124-145 inclusive
+
+    # 146: chargedNumber
+    'charged_party',
+
   ],
 
 );
index 437d31e..a90e592 100644 (file)
 package FS::cdr::broadsoft22;
 
-=head1 NAME
-
-FS::cdr::broadsoft22 - CDR parse module for Broadsoft R22.0
-
-=head1 DESCRIPTION
-
-Ref: BW-AccountingCDRInterfaceSpec-R22.pdf
-
-=cut
-
 use strict;
 use base qw( FS::cdr );
 use vars qw( %info );
-use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
 
 %info = (
-  name     => 'Broadsoft R22',
-  weight   => 500,
-  header   => 1,
-  type     => 'csv',
-  sep_char => ',',
-  disabled => 0,
-
-  import_fields => [
-
-    # 1: recordId
-    # 2: serviceProvider
-    skip(2),
-
-    # 3: type
-    sub {
-      my ( $cdr, $data, $conf, $param ) = @_;
-      $param->{skiprow} = 1
-        if lc($data) ne 'normal';
-      '';
-    },
-
-    # 4: userNumber
-    # 5: groupNumber
-    skip(2),
-
-    # 6: direction
-    'dcontext',
-
-    # 7: callingNumber
-    trim('src'),
-
-    # 8: callingPresentationINdicator
-    skip(1),
-
-    # 9: calledNumber
-    trim('dst'),
-
-    # 10: startTime
-    _cdr_date_parser_maker('startdate'),
-
-    # 11: userTimeZone
-    skip(1),
-
-    # 12: answerIndicator
-    sub {
-      my( $cdr, $data ) = @_;
-      $cdr->disposition( lc($data) eq 'yes' ? 'ANSWERED' : 'NO ANSWER');
-    },
-
-    # 13: answerTime
-    _cdr_date_parser_maker('answerdate'),
-
-    # 14: releaseTime
-    _cdr_date_parser_maker('enddate'),
-
-    # 15: terminationCause
-    # 16: networkType
-    # 17: carrierIdentificationCode
-    # 18: dialedDigits
-    # 19: callCategory
-    # 20: networkCallType
-    # 21: networkTranslatedNumber
-    # 22: networkTranslatedGroup
-    # 23: releasingParty
-    # 24: route
-    # 25: networkCallID
-    # 26: codedc
-    # 27: accessDeviceAddress
-    # 28: accessCallID
-    # 29: spare
-    # 30: failoverCorrelationId
-    # 31: spare
-    # 32: group
-    # 33: department
-    skip(19),
-
-    # 34: accountCode
-    sub {
-      my( $cdr, $data ) = @_;
-      $cdr->set(
-        'accountcode',
-        $cdr->is_tollfree ? substr( $cdr->dst, 0, 32 ) : $data
-      );
-    },
-
-    # 35: authorizationCode
-    # 36: originalCalledNumber
-    # 37: originalCalledPresentationIndicator
-    # 38: originalCalledReason
-    # 39: redirectingNumber
-    # 40: redirectingPresentationIndicator
-    # 41: redirectingReason
-    # 42: chargeIndicator
-    # 43: typeOfNetwork
-    # 44: voicePortalCalling.invocationTime
-    # 45: localCallId
-    # 46: remoteCallId
-    # 47: callingPartyCategory
-    #
-    # Also... cols 48 - 448 see Broadsoft documentation
-
-  ],
-
+  name     => 'Broadsoft R22 (deprecated)',
+  disabled => 1,
 );
 
-sub trim {
-  my $fieldname = shift;
-  return sub {
-    my($cdr, $data) = @_;
-    $data =~ s/^\+1//;
-    $cdr->$fieldname($data);
-    ''
-  }
-}
-
-sub skip {
-  map { undef } (1..$_[0]);
-}
-
 1;
-
-__END__
-
-list of freeside CDR fields, useful ones marked with *
-
-           acctid - primary key
-    *[1]   calldate - Call timestamp (SQL timestamp)
-           clid - Caller*ID with text
-7   *      src - Caller*ID number / Source number
-9   *      dst - Destination extension
-           dcontext - Destination context
-           channel - Channel used
-           dstchannel - Destination channel if appropriate
-           lastapp - Last application if appropriate
-           lastdata - Last application data
-10  *      startdate - Start of call (UNIX-style integer timestamp)
-13         answerdate - Answer time of call (UNIX-style integer timestamp)
-14  *      enddate - End time of call (UNIX-style integer timestamp)
-    *      duration - Total time in system, in seconds
-    *      billsec - Total time call is up, in seconds
-12  *[2]   disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
-           amaflags - What flags to use: BILL, IGNORE etc, specified on a per
-           channel basis like accountcode.
-4   *[3]   accountcode - CDR account number to use: account
-           uniqueid - Unique channel identifier
-           userfield - CDR user-defined field
-           cdr_type - CDR type - see FS::cdr_type (Usage = 1, S&E = 7, OC&C = 8)
-    *[4]   charged_party - Service number to be billed
-           upstream_currency - Wholesale currency from upstream
-    *[5]   upstream_price - Wholesale price from upstream
-           upstream_rateplanid - Upstream rate plan ID
-           rated_price - Rated (or re-rated) price
-           distance - km (need units field?)
-           islocal - Local - 1, Non Local = 0
-    *[6]   calltypenum - Type of call - see FS::cdr_calltype
-           description - Description (cdr_type 7&8 only) (used for
-           cust_bill_pkg.itemdesc)
-           quantity - Number of items (cdr_type 7&8 only)
-           carrierid - Upstream Carrier ID (see FS::cdr_carrier)
-           upstream_rateid - Upstream Rate ID
-           svcnum - Link to customer service (see FS::cust_svc)
-           freesidestatus - NULL, done (or something)
-
-[1] Auto-populated from startdate if not present
-[2] Package options available to ignore calls without a specific disposition
-[3] When using 'cdr-charged_party-accountcode' config
-[4] Auto-populated from src (normal calls) or dst (toll free calls) if not present
-[5] When using 'upstream_simple' rating method.
-[6] Set to usage class classnum when using pre-rated CDRs and usage class-based
-    taxation (local/intrastate/interstate/international)
index aa94630..c6a40ed 100644 (file)
@@ -159,7 +159,7 @@ use FS::cdr qw(_cdr_date_parser_maker);
 
     #60
     
-    '',       #OrigIPCallID
+    'sipcallid',       #OrigIPCallID
     '',       #ESAIPTrunkGroup
     '',       #ESAReason
     '',       #BearerlessCall
index 687c431..c238d2a 100644 (file)
@@ -2,27 +2,31 @@ package FS::cdr::telapi_voip;
 use base qw( FS::cdr );
 
 use strict;
-use vars qw( @ISA %info $CDR_TYPES );
-use FS::Record qw( qsearch );
-use FS::cdr qw( _cdr_date_parser_maker _cdr_min_parser_maker );
+use vars qw( %info );
+use FS::cdr qw( _cdr_date_parser_maker );
 
 %info = (
-  'name'          => 'telapi_voip (csv file)',
+  'name'          => 'TeleAPI VoIP (CSV file)',
   'weight'        => 601,
   'header'        => 1,
   'type'          => 'csv',
   'import_fields' => [
-    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
-    skip(1),                              # hangup code
+    _cdr_date_parser_maker('startdate', 'gmt'=>1 ),  # date gmt
+    'src',                                           # source
+    'dst',                                           # destination
+    'clid',                                          # callerid
+    'disposition',                                   # hangup code
+    'userfield',                                     # sip account
+    'src_ip_addr',                                   # orig ip
+    'billsec',                                       # duration
+    skip(1),                                  # per minute (add "upstream_rate"?
+    'upstream_price',                                # call cost
+    'dcontext',                                      # type
+    'uniqueid',                                      # uuid
+    'lastapp',                                       # direction
   ],
 );
 
 sub skip { map {''} (1..$_[0]) }
 
-1;
\ No newline at end of file
+1;
index 21bf92f..73821cc 100644 (file)
@@ -252,7 +252,7 @@ sub insert {
   }
 
   if ( $self->censustract ) {
-    $self->set('censusyear' => $conf->config('census_year') || 2012);
+    $self->set('censusyear' => $conf->config('census_legacy') || 2020);
   }
 
   my $oldAutoCommit = $FS::UID::AutoCommit;
@@ -419,10 +419,13 @@ sub check {
   ;
   return $error if $error;
   if ( $self->censustract ne '' ) {
-    $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/
-      or return "Illegal census tract: ". $self->censustract;
-
-    $self->censustract("$1.$2");
+    if ( $self->censustract =~ /^\s*(\d{9})\.?(\d{2})\s*$/ ) { #old
+      $self->censustract("$1.$2");
+    } elsif ($self->censustract =~ /^\s*(\d{15})\s*$/ ) { #new
+      $self->censustract($1);
+    } else {
+      return "Illegal census tract: ". $self->censustract;
+    }
   }
 
   #yikes... this is ancient, pre-dates cust_location and will be harder to
@@ -879,7 +882,7 @@ sub process_censustract_update {
     qsearchs( 'cust_location', { locationnum => $locationnum })
       or die "locationnum '$locationnum' not found!\n";
 
-  my $new_year = $conf->config('census_year') or return;
+  my $new_year = $conf->config('census_legacy') || 2020;
   my $loc = FS::GeocodeCache->new( $cust_location->location_hash );
   $loc->set_censustract;
   my $error = $loc->get('censustract_error');
index 25216c6..37b8ec8 100644 (file)
@@ -790,6 +790,20 @@ sub search {
   }
 
   ##
+  # no_censustract
+  ##
+  if ( $params->{'no_censustract'} ) {
+    push @where, "EXISTS(
+      SELECT 1 FROM cust_location
+      WHERE locationnum = cust_main.ship_locationnum
+        AND cust_location.country = 'US'
+        AND (    cust_location.censusyear IS NULL
+              OR cust_location.censusyear != '2020'
+            )
+    )";
+  }
+
+  ##
   # phones
   ##
 
index 410d690..03864ef 100644 (file)
@@ -160,9 +160,8 @@ sub insert {
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
 
-  my $error =  $self->check_payinfo_cardtype if $self->payby =~/^(CARD|DCRD)$/;
-  $self->SUPER::insert unless $error;
-
+  my $error =  $self->check_payinfo_cardtype
+            || $self->SUPER::insert;
   if ( $error ) {
     $dbh->rollback if $oldAutoCommit;
     return $error;
@@ -348,14 +347,16 @@ sub check {
   if ( !$ignore_invalid_card && 
     $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
-    my $payinfo = $self->payinfo;
-    $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,19}|\d{8,9})$/
-      or return gettext('invalid_card'); #. ": ". $self->payinfo;
-    $payinfo = $1;
-    $self->payinfo($payinfo);
-    validate($payinfo)
-      or return gettext('invalid_card'); # . ": ". $self->payinfo;
+    unless ( $self->tokenized ) {
+      my $payinfo = $self->payinfo;
+      $payinfo =~ s/\D//g;
+      $payinfo =~ /^(\d{13,19}|\d{8,9})$/
+        or return gettext('invalid_card'); #. ": ". $self->payinfo;
+      $payinfo = $1;
+      $self->payinfo($payinfo);
+      validate($payinfo)
+        or return gettext('invalid_card'); # . ": ". $self->payinfo;
+    }
 
     # see parallel checks in check_payinfo_cardtype & payinfo_Mixin::payinfo_check
     my $cardtype = $self->paycardtype;
@@ -557,7 +558,7 @@ sub check_payinfo_cardtype {
 
   return '' if $ignore_cardtype;
 
-  return '' unless $self->payby =~ /^(CARD|CHEK)$/;
+  return '' unless $self->payby =~ /^(CARD|DCRD)$/;
 
   my $payinfo = $self->payinfo;
   $payinfo =~ s/\D//g;
index 3e1ca82..c5a7eb9 100644 (file)
@@ -402,9 +402,12 @@ sub search {
     my $op = $params->{location_cust} ? '=' : '!=';
     push @where, "cust_location.locationnum $op cust_main.ship_locationnum";
   }
-  if ( $params->{location_census} xor $params->{location_nocensus} ) {
-    my $op = $params->{location_census} ? "IS NOT NULL" : "IS NULL";
-    push @where, "cust_location.censustract $op";
+  if ( $params->{location_census} ) {
+    push @where, "cust_location.censustract IS NOT NULL",
+                 "cust_location.censusyear  =  '2020'  ";
+  } elsif ( $params->{location_nocensus} ) {
+    push @where, "(    cust_location.censustract IS NULL    ".
+                 "  OR cust_location.censusyear  != '2020' )";
   }
   if ( $params->{location_geocode} xor $params->{location_nogeocode} ) {
     my $op = $params->{location_geocode} ? "IS NOT NULL" : "IS NULL";
index 306b4fb..c618fb9 100644 (file)
@@ -10,8 +10,12 @@ use Cpanel::JSON::XS;
 use LWP::UserAgent;
 use HTTP::Request::Common;
 
-# update this in 2020, along with the URL for the TIGERweb service
-our $CENSUS_YEAR = 2010;
+use Geo::JSON::Polygon;
+use Geo::JSON::Feature;
+
+our $CENSUS_YEAR = 2020;
+
+our $tech_label  = FS::part_pkg_fcc_option->technology_labels;
 
 =head1 NAME
 
@@ -187,8 +191,15 @@ returns the error, otherwise returns false.
 
 =cut
 
-# the replace method can be inherited from FS::Record
+sub replace {
+  my $self = shift;
+  my $old = shift || $self->replace_old;
+
+  $self->expire_date(time)
+    if $self->disabled eq 'Y' && ! $old->disabled && ! $self->expire_date;
 
+  $self->SUPER::replace($old, @_);
+}
 =item check
 
 Checks all fields to make sure this is a valid zone record.  If there is
@@ -268,6 +279,27 @@ sub deploy_zone_vertex {
   });
 }
 
+=item shapefile_add SHAPEFILE
+
+Adds this deployment zone to the supplied Geo::Shapelib shapefile.
+
+=cut
+
+sub shapefile_add {
+  my( $self, $shapefile ) = @_;
+
+  my @coordinates = map { [ $_->longitude, $_->latitude, 0, 0 ] }
+                      $self->deploy_zone_vertex;
+  push @coordinates, $coordinates[0];
+
+  push @{$shapefile->{Shapes}}, { 'Vertices' => \@coordinates };
+  push @{$shapefile->{ShapeRecords}}, [ $tech_label->{$self->technology},
+                                        $self->adv_speed_down,
+                                        $self->adv_speed_up,
+                                      ];
+  '';
+}
+
 =item vertices_json
 
 Returns the vertex list for this zone, as a JSON string of
@@ -282,6 +314,51 @@ sub vertices_json {
   encode_json(\@vertices);
 }
 
+=item geo_json_feature
+
+Returns this zone as a Geo::JSON::Feature object
+
+=cut
+
+sub geo_json_feature {
+  my $self = shift;
+
+  my @coordinates = map { [ $_->longitude, $_->latitude ] }
+                      $self->deploy_zone_vertex;
+  push @coordinates, $coordinates[0];
+
+  Geo::JSON::Feature->new({
+    geometry   => Geo::JSON::Polygon->new({ coordinates => [ \@coordinates ] }),
+    properties => { 'Technology' => $tech_label->{$self->technology},
+                    'Down'       => $self->adv_speed_down,
+                    'Up'         => $self->adv_speed_up,
+                  },
+  })
+}
+
+=item kml_add
+
+Adds this deployment zone to the supplied Geo::GoogleEarth::Pluggable object.
+
+=cut
+
+sub kml_polygon {
+  my( $self, $kml ) = @_;
+
+  my $name = $self->description. ' ('. $self->adv_speed_down. '/'.
+                                       $self->adv_speed_up. ')';
+
+  $kml->Polygon( 'name'        => $name,
+                 'coordinates' => [ [ #outerBoundary
+                                      map { [ $_->longitude, $_->latitude, 0 ] }
+                                        $self->deploy_zone_vertex
+                                    ],
+                                    #[ #innerBoundary
+                                    #]
+                                  ]
+               );
+}
+
 =head2 SUBROUTINES
 
 =over 4
@@ -379,7 +456,7 @@ sub process_block_lookup {
     inSR            => 4326,
     outSR           => 4326,
     spatialRel      => 'esriSpatialRelIntersects', # the test to perform
-    outFields       => 'OID,GEOID',
+    outFields       => 'GEOID',
     returnGeometry  => 'false',
     orderByFields   => 'OID',
   );
@@ -403,16 +480,12 @@ sub process_block_lookup {
 
   #warn "Census block lookup: $count\n";
 
-  # we have to do our own pagination on this, because the census bureau
-  # doesn't support resultOffset (maybe they don't have ArcGIS 10.3 yet).
-  # that's why we're ordering by OID, it's globally unique
-  my $last_oid = 0;
   my $done = 0;
   while (!$done) {
     $response = $ua->request(
       POST $url, Content => [
         %query,
-        where => "OID>$last_oid",
+        resultOffset => $inserted,
       ]
     );
     die $response->status_line unless $response->is_success;
@@ -437,7 +510,6 @@ sub process_block_lookup {
     }
 
     #warn "Inserted $inserted records\n";
-    $last_oid = $data->{features}[-1]{attributes}{OID};
     $done = 1 unless $data->{exceededTransferLimit};
   }
 
index 0a16724..33e150a 100644 (file)
@@ -11,6 +11,7 @@ use FS::cust_msg;
 use FS::template_content;
 
 use Date::Format qw(time2str);
+use PDF::WebKit;
 
 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
 
@@ -411,8 +412,6 @@ Options are as for 'prepare', but 'from' and 'to' are meaningless.
 
 sub render {
   my $self = shift;
-  eval "use PDF::WebKit";
-  die $@ if $@;
   my %opt = @_;
   my %hash = $self->prepare(%opt);
   my $html = $hash{'html_body'};
index c2c3707..8950359 100644 (file)
@@ -16,9 +16,8 @@ use HTML::TreeBuilder;
 use Encode;
 
 # needed to send email
-use FS::Misc qw( generate_email );
+use FS::Misc qw( generate_email _sendmail );
 use FS::Conf;
-use Email::Sender::Simple qw( sendmail );
 
 use FS::Record qw( qsearch qsearchs );
 
@@ -543,40 +542,13 @@ sub send_prepared {
   # through Email::Address to make sure
   my @env_to = map { $_->address } Email::Address->parse($cust_msg->env_to);
 
-  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
-                   'helo' => $domain );
-
-  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
-  $smtp_opt{'port'} = $port;
-  
-  my $transport;
-  if ( defined($enc) && $enc eq 'starttls' ) {
-    $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
-    $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
-  } else {
-    if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
-      $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);     
-    } 
-    $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
-    $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
-  }
-
-  warn "$me sending message\n" if $DEBUG;
   my $message = join("\n", $cust_msg->header, $cust_msg->body);
-  local $@;
-  eval {
-    sendmail( $message, { transport => $transport,
-                          from      => $cust_msg->env_from,
-                          to        => \@env_to })
-  };
-  my $error = '';
-  if(ref($@) and $@->isa('Email::Sender::Failure')) {
-    $error = $@->code.' ' if $@->code;
-    $error .= $@->message;
-  }
-  else {
-    $error = $@;
-  }
+
+  my $error = _sendmail( $message, { 'from'    => $cust_msg->env_from,
+                                     'to'      => \@env_to,
+                                     'domain'  => $domain,
+                                   }
+                       );
 
   $cust_msg->set('error', $error);
   $cust_msg->set('status', $error ? 'failed' : 'sent');
index 3e2082b..d91c4f0 100644 (file)
@@ -196,6 +196,10 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash',
     'skip_dcontext' => { 'name' => 'Do not charge for CDRs where dcontext is set to any of these (comma-separated) values: ',
                        },
 
+    'noskip_dcontext_tollfree' => { 'name' => 'Do charge for CDRs where dcontext is set to any of the specified values, if the CDR is tollfree',
+                                                  'type' => 'checkbox',
+                                                },
+
     'skip_dcontext_prefix' => { 'name' => 'Do not charge for CDRs where dcontext starts with: ',
                        },
 
@@ -231,6 +235,10 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash',
     'skip_dst_length_less' => { 'name' => 'Do not charge for CDRs where the destination is less than this many digits:',
                               },
 
+    'noskip_dst_length_n11' => { 'name' => 'Do charge for CDRs where dst is less than the specified digits, when dst is N11 (i.e. 411, 611)',
+                                                  'type' => 'checkbox',
+                                                },
+
     'noskip_dst_length_accountcode_tollfree' => { 'name' => 'Do charge for CDRs where dst is less than the specified digits, when accountcode is toll free',
                                                   'type' => 'checkbox',
                                                 },
@@ -347,12 +355,13 @@ tie my %accountcode_tollfree_field, 'Tie::IxHash',
                        use_cdrtypenum ignore_cdrtypenum
                        use_calltypenum ignore_calltypenum
                        ignore_disposition disposition_in disposition_prefix
-                       skip_dcontext skip_dcontext_prefix skip_dcontext_suffix
+                       skip_dcontext noskip_dcontext_tollfree
+                       skip_dcontext_prefix skip_dcontext_suffix
                        skip_dst_prefix 
                        skip_dstchannel_prefix skip_src_length_more 
                        noskip_src_length_accountcode_tollfree
                        accountcode_tollfree_ratenum accountcode_tollfree_field
-                       skip_dst_length_less
+                       skip_dst_length_less noskip_dst_length_n11
                        noskip_dst_length_accountcode_tollfree
                        skip_lastapp
                        skip_max_callers
@@ -447,8 +456,6 @@ sub calc_usage {
     rounding  => ($self->option_cacheable('rounding') || 2),
   );
 
-  my $use_duration = $self->option('use_duration');
-
   my($svc_table, $svc_field, $by_ip_addr) = split('\.', $cdr_svc_method);
 
   my @cust_svc;
@@ -625,7 +632,10 @@ sub check_chargable {
 
   return "dcontext IN ( ". $self->option_cacheable('skip_dcontext'). " )"
     if $self->option_cacheable('skip_dcontext') =~ /\S/
-    && grep { $cdr->dcontext eq $_ } split(/\s*,\s*/, $self->option_cacheable('skip_dcontext'));
+    && grep { $cdr->dcontext eq $_ } split(/\s*,\s*/, $self->option_cacheable('skip_dcontext'))
+    && ! (    $self->option_cacheable('noskip_dcontext_tollfree')
+           && $cdr->is_tollfree
+         );
 
   my $len_dcontext_prefix =
     length($self->option_cacheable('skip_dcontext_prefix'));
@@ -646,8 +656,11 @@ sub check_chargable {
   my $dst_length = $self->option_cacheable('skip_dst_length_less');
   return "destination less than $dst_length digits"
     if $dst_length && length($cdr->dst) < $dst_length
-    && ! ( $self->option_cacheable('noskip_dst_length_accountcode_tollfree')
-            && $cdr->is_tollfree('accountcode')
+    && ! (    $self->option_cacheable('noskip_dst_length_n11')
+           && $cdr->dst =~ /^\d11$/
+         )
+    && ! (    $self->option_cacheable('noskip_dst_length_accountcode_tollfree')
+           && $cdr->is_tollfree('accountcode')
          );
 
   return "lastapp is ". $self->option_cacheable('skip_lastapp')
index 13d1601..1915a21 100644 (file)
@@ -173,6 +173,7 @@ sub _upgrade_schema {
     push @where, "
       ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )";
   }
+  return '' unless @where;
   my @recs = qsearch({
       'table'     => 'password_history',
       'extra_sql' => ' WHERE ' . join(' AND ', @where),
index 1e26a3c..d66bca7 100644 (file)
@@ -880,3 +880,4 @@ t/access_user_session_log.t
 FS/svc_group.pm
 FS/h_svc_group.pm
 FS/Misc/DepositSlip.pm
+bin/freeside-svc_acct-bulk_change
index aa4bf64..f2bf294 100755 (executable)
@@ -12,8 +12,8 @@ use FS::cdr;
 # parse command line
 ###
 
-use vars qw( $opt_m $opt_p $opt_r $opt_e $opt_d $opt_v $opt_P $opt_a $opt_c $opt_g $opt_s $opt_b );
-getopts('c:m:p:r:e:d:v:P:agsb');
+use vars qw( $opt_m $opt_p $opt_r $opt_e $opt_d $opt_v $opt_P $opt_a $opt_c $opt_i $opt_g $opt_s $opt_b );
+getopts('c:i:m:p:r:e:d:v:P:agsb');
 
 $opt_e ||= 'csv';
 #$opt_e = ".$opt_e" unless $opt_e =~ /^\./;
@@ -21,7 +21,8 @@ $opt_e =~ s/^\.//;
 
 $opt_p ||= '';
 
-die "invalid cdrtypenum" if $opt_c && $opt_c !~ /^\d+$/;
+die "invalid cdrtypenum" if defined $opt_c && $opt_c !~ /^\d+$/;
+die "invalid carrierid"  if defined $opt_i && $opt_i !~ /^\d+$/;
 
 my %options = ();
 
@@ -113,7 +114,8 @@ foreach my $filename ( @$ls ) {
     'batch_namevalue' => $file_timestamp,
     'empty_ok'        => 1,
   };
-  $import_options->{'cdrtypenum'} = $opt_c if $opt_c;
+  $import_options->{'cdrtypenum'} = $opt_c if defined $opt_c;
+  $import_options->{'carrierid'}  = $opt_i if defined $opt_i;
   
   my $error = FS::cdr::batch_import($import_options);
 
@@ -162,7 +164,7 @@ foreach my $filename ( @$ls ) {
 
 sub usage {
   "Usage:
-  cdr.sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ] 
+  freeside-cdr-sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ]
     [ -r remotefolder ] [ -d donefolder ] [ -v level ] [ -P port ]
     [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] user format [sftpuser@]servername
   ";
@@ -205,7 +207,8 @@ freeside-cdr-sftp_and_import - Download CDR files from a remote server via SFTP
 
   freeside-cdr-sftp_and_import [ -m method ] [ -p prefix ] [ -e extension ] 
     [ -r remotefolder ] [ -d donefolder ] [ -v level ] [ -P port ]
-    [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] user format [sftpuser@]servername
+    [ -a ] [ -g ] [ -s ] [ -c cdrtypenum ] [ -i carrierid]
+    user format [sftpuser@]servername
 
 =head1 DESCRIPTION
 
@@ -233,6 +236,8 @@ or FTP and then import them into the database.
 
 -c: cdrtypenum to set, defaults to none
 
+-i: carrierid to set, defaults to none
+
 -g: File is gzipped
 
 -s: Warn and skip files which could not be imported rather than abort
index d117f56..bcc7615 100755 (executable)
@@ -54,7 +54,8 @@ while (1) {
 
   #order matters for removing dupes--only the first is preserved
   $extra_sql .= ' ORDER BY acctid '
-    if $conf->exists('cdr-skip_duplicate_rewrite');
+    if $conf->exists('cdr-skip_duplicate_rewrite')
+    || $conf->exists('cdr-skip_duplicate_rewrite-sipcallid');
 
   my $found = 0;
   my %skip = (); #used only by taqua
@@ -92,6 +93,22 @@ while (1) {
       }
     }
 
+    if ($conf->exists('cdr-skip_duplicate_rewrite-sipcallid')) {
+      my $sth = dbh->prepare(
+        'SELECT 1 FROM cdr WHERE sipcallid=? AND acctid < ? LIMIT 1'
+      ) or die dbh->errstr;
+      $sth->execute($cdr->sipcallid, $cdr->acctid) or die $sth->errstr;
+      my $isdup = $sth->fetchrow_hashref;
+      $sth->finish;
+      if ($isdup) {
+        #we only act on this cdr, not touching previous dupes
+        #if a dupe somehow creeped in previously, too late to fix it
+        $cdr->freesidestatus('skipped'); #prevent it from being billed
+        push(@status,'duplicate');
+      }
+    }
+
+
     if ( $conf->exists('cdr-asterisk_forward_rewrite')
          && $cdr->dstchannel =~ /^Local\/(\d+)/i && $1 ne $cdr->dst
        )
@@ -268,6 +285,7 @@ sub _shouldrun {
   || $conf->exists('cdr-intl_to_domestic_rewrite')
   || $conf->exists('cdr-userfield_dnis_rewrite')
   || $conf->exists('cdr-skip_duplicate_rewrite')
+  || $conf->exists('cdr-skip_duplicate_rewrite-sipcallid')
   || 0
   ;
 }
@@ -296,6 +314,11 @@ of the following config options are enabled:
 Marks as 'skipped' (prevents billing for) any CDRs with 
 a src, dst and calldate identical to an existing CDR
 
+=item cdr-skip_duplicate_rewrite-sipcallid
+
+Marks as 'skipped' (prevents billing for) any CDRs with 
+a sipcallid identical to an existing CDR
+
 =item cdr-asterisk_australia_rewrite
 
 Classifies Australian numbers as domestic, mobile, tollfree, international, or
index f9b6d11..49505ee 100755 (executable)
@@ -18,8 +18,7 @@ $FS::UID::AutoCommit = 0;
 my $dbh = dbh;
 
 my $conf = FS::Conf->new;
-my $current_year = $conf->config('census_year') 
-  or die "No current census year configured.\n";
+my $current_year = $conf->config('census_legacy') || '2020';
 my $date = str2time($opt{d}) if $opt{d};
 $date ||= time;
 # This now operates on cust_location, not cust_main.
@@ -36,9 +35,14 @@ my %h_cust_location = map { $_->locationnum => $_ }
 
 # Find all locations that don't have censusyear = the current
 # year as of now.
-my @cust_location = qsearch( 'cust_location',
-  { censusyear => { op => '!=', value => $current_year } },
-);
+my @cust_location = qsearch({
+     'table'     => 'cust_location',
+     'hashref'   => { 'country' => 'US', },
+     'extra_sql' => " AND (    censusyear  != '$current_year'
+                            OR censustract IS NULL
+                          )
+                    ",
+});
 
 warn scalar(@cust_location)." records found.\n";
 my $queued = 0; my $updated = 0;
@@ -85,8 +89,8 @@ freeside-censustract-update - Update census tract codes to the current year.
 =head1 DESCRIPTION
 
 Finds all customers whose census tract codes don't appear to be current 
-and updates them to the current year.  The "current year" is defined by 
-the I<census_tract> configuration variable, not the calendar year.
+and updates them to the current year.  The "current year" is 2020, unless the
+I<census_legacy> configuration variable is set.
 
 The -d option tells the script to assume that tract codes last modified
 after some date are already current.  Those customers will just have 
diff --git a/FS/bin/freeside-svc_acct-bulk_change b/FS/bin/freeside-svc_acct-bulk_change
new file mode 100755 (executable)
index 0000000..c10d2c1
--- /dev/null
@@ -0,0 +1,56 @@
+#!/usr/bin/perl -w
+
+use strict;
+use vars qw( $opt_p $opt_g );
+use Getopt::Std;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw( qsearch ); #qsearchs );
+use FS::cust_svc;
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+getopts('p:g:');
+
+my @svc_x = ();
+if ( $opt_p ) {
+  push @svc_x, map { $_->svc_acct } qsearch('cust_svc', { svcpart=>$opt_p } );
+  die "no services with svcpart $opt_p found\n" unless @svc_x;
+} else {
+  die &usage;
+}
+
+foreach my $svc_x ( @svc_x ) {
+  next if $opt_g && $svc_x->gid == $opt_g;
+  $svc_x->gid($opt_g) if $opt_g;
+  my $error = $svc_x->replace;
+  die $error if $error;
+}
+
+sub usage {
+  return "Usage:\n\n  freeside-svc_acct-bulk_change user -p svcpart -g gid\n";
+}
+
+=head1 NAME
+
+freeside-svc_acct-bulk_change - Command line tool to make bulk changes to svc_acct (account) records
+
+=head1 SYNOPSIS
+
+  freeside-svc_acct-bulk_change user -p svcpart -g gid
+
+=head1 DESCRIPTION
+
+  For the servcies of the given svcpart, changes the GID as specified.
+
+  Note: Unless you are changing the GID to match an new, fixed value in the
+  service definition, you will need to enable the B<svc_acct-edit_gid>
+  configuration setting prior to running this script.
+
+=head1 SEE ALSO
+
+L<FS::svc_acct>
+
+=cut
+
+1;
index 5347da6..582ab39 100755 (executable)
@@ -39,6 +39,12 @@ foreach my $part_pkg ( qsearch({ 'table'     => 'part_pkg',
 
     my $part_pkg_option = qsearchs('part_pkg_option', \%hash);
 
+    unless ( defined $opt_v ) {
+      my $error = $part_pkg_option && $part_pkg_option->delete;
+      die $error if $error;
+      next;
+    }
+
     if ( $part_pkg_option ) {
       next if $part_pkg_option->optionvalue eq $opt_v;
       $part_pkg_option->optionvalue($opt_v);
@@ -130,7 +136,7 @@ Search options:
 
 Change options:
 
--o: part_pkg_option optionname
+-o: part_pkg_option optionname (use without -v to unset)
 
 -v: part_pkg_option optionvalue
 
index 3d787b7..043294f 100644 (file)
@@ -78,7 +78,8 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,
  libipc-run-safehandles-perl,libpoe-perl,libsoap-lite-perl,libxmlrpc-lite-perl,
  libhtml-tableextract-perl,libhtml-element-extended-perl,libcam-pdf-perl,
  libnet-openssh-perl,libgd-barcode-perl,sam2p,libsys-sigaction-perl,
- libgeo-googleearth-pluggable-perl,libgeo-coder-googlev3-perl,libnet-snmp-perl,
+ libgeo-googleearth-pluggable-perl (>=0.16),libgeo-coder-googlev3-perl,
+ libnet-snmp-perl,
  libcrypt-openssl-rsa-perl,libregexp-common-perl,libnet-cidr-perl,
  libregexp-ipv6-perl,libhtml-quoted-perl,libtext-password-pronounceable-perl,
  libconvert-color-perl,liburi-perl,libhtml-rewriteattributes-perl,
@@ -106,8 +107,10 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,
  libnet-vitelity-perl (>= 0.05), libnet-sslglue-perl, libexpect-perl,
  libspreadsheet-parsexlsx-perl, libunicode-truncate-perl (>= 0.303-1),
  libspreadsheet-xlsx-perl, libpod-simple-perl, libwebservice-northern911-perl,
- liblocale-codes-perl, liblocale-po-perl
-Conflicts: libparams-classify-perl (= 0.013-6)
+ liblocale-codes-perl, liblocale-po-perl, libgeo-uscensus-geocoding-perl,
+ libnet-sftp-foreign-perl, libpdf-webkit-perl, libgeo-shapelib-perl,
+ libgeo-json-perl, libauth-googleauth-perl
+Conflicts: libparams-classify-perl (>= 0.013-6)
 Replaces: freeside (<<4)
 Breaks: freeside (<<4)
 Description: Libraries for Freeside billing and trouble ticketing
index 6eab11d..b1fea7d 100755 (executable)
@@ -1250,10 +1250,8 @@ sub do_template {
   $fill_in->{$_} = $access_info->{$_} foreach keys %$access_info;
 
   # update the user's authentication
-  my $timeout = $access_info->{'timeout'} || '3600';
   my $cookie = CGI::Cookie->new('-name'     => 'session',
                                 '-value'    => $session_id,
-                                '-expires'  => '+'.$timeout.'s',
                                 #'-secure'   => 1, # would be a good idea...
                                );
   if ( $name eq 'logout' ) {
index 446bfe0..6587627 100644 (file)
@@ -49,6 +49,11 @@ my $groups_sub = sub {
 
 };
 
+my $goog_auth_sub = sub {
+  my $access_user = shift;
+  $access_user->totp_secret32 ? 'Enabled' : '';
+};
+
 my $installer_sub = sub {
   my $access_user = shift;
   my @sched_item = $access_user->sched_item or return '';
@@ -66,11 +71,23 @@ my $count_query = 'SELECT COUNT(*) FROM access_user';
 my $link = [ $p.'edit/access_user.html?', 'usernum' ];
 
 my @header = (
-  'Username', 'Full name', 'Groups',    'Installer',    'Customer' );
+  'Username',
+  'Full name',
+  'Groups',
+  'Google Auth',
+  'Installer',
+  'Customer',
+);
 my @fields = (
-  'username', 'name',      $groups_sub, $installer_sub, $cust_sub, );
-my $align = 'lllcl';
-my @links = ( $link, $link, $link, '', '', $cust_link );
+  'username',
+  'name',
+  $groups_sub,
+  $goog_auth_sub,
+  $installer_sub,
+  $cust_sub,
+);
+my $align = 'lllccl';
+my @links = ( $link, $link, $link, '', '', '', $cust_link );
 
 #if ( FS::Conf->new->config('ticket_system') ) {
 #  push @header, 'Ticketing';
index a1bd57f..0533d6e 100644 (file)
                         'Contractual Mbps',
                         'Vertices',
                         'Census blocks',
+                        'Shapefile',
+                        'KMZ',
+                        'GeoJSON',
+                     ],
+  footer          => [ '',
+                       'All fixed zones',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '',
+                       '<A HREF="'. $fixed_shp.  '">download</A>',
+                       '<A HREF="'. $fixed_kmz.  '">download</A>',
+                       '<A HREF="'. $fixed_json. '">download</A>',
                      ],
   fields          => [  'zonenum',
                         'description',
                         sub { my $self = shift;
                               FS::deploy_zone_block->count('zonenum = '.$self->zonenum)
                             },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
                        '(adv_speed_down, adv_speed_up)',
                        '(cir_speed_down, cir_speed_up)',
                      ],
-  links           => [  $link_fixed, $link_fixed, ],
+  links           => [  $link_fixed, $link_fixed, '', '', '', '', '', '', $link_shp, $link_kmz, $link_json, ],
   align           => 'cllllrrr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
   disable_total     => 1,
+  disableable       => 1,
+  disabled_statuspos => 2,
 &>
 <P><FONT SIZE="+1"><B>Mobile Zones</B></FONT></P>
 <& elements/browse.html,
                         'Service Type',
                         'Advertised Mbps',
                         'Vertices', # number of vertices? not so useful
+                        'Shapefile',
+                        'KMZ',
+                        'GeoJSON',
                      ],
   fields          => [  'zonenum',
                         'description',
                         sub { my $self = shift;
                               FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
                             },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
+                        sub { my $self = shift;
+                              FS::deploy_zone_vertex->count('zonenum = '.$self->zonenum)
+                              ? 'download' : ''
+                            },
                      ],
   sort_fields     => [ 'zonenum',
                        'description',
                        '(is_voice is not null, is_broadband is not null)',
                        '(adv_speed_down, adv_speed_up)',
                      ],
-  links           => [  '', $link_mobile, ],
+  links           => [  $link_mobile, $link_mobile, '', '', '', '', '', $link_shp, $link_kmz, $link_json, ],
   align           => 'clllllr',
   nohtmlheader    => 1,
   disable_maxselect => 1,
   disable_total     => 1,
+  disableable       => 1,
+  disabled_statuspos => 2,
 &>
 
 <& /elements/footer.html &>
 <%init>
+
 my $curuser = $FS::CurrentUser::CurrentUser;
 my $acl_edit = $curuser->access_right('Edit FCC report configuration');
 my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
 die "access denied"
   unless $acl_edit or $acl_edit_global;
 
-my $link_fixed = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
-my $link_mobile= [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
+my $link_fixed  = [ $p.'edit/deploy_zone-fixed.html?', 'zonenum' ];
+my $link_mobile = [ $p.'edit/deploy_zone-mobile.html?', 'zonenum' ];
+my $link_shp    = [ $p.'view/deploy_zone-shp.cgi?', 'zonenum' ];
+my $link_kmz    = [ $p.'view/deploy_zone-kmz.cgi?', 'zonenum' ];
+my $link_json   = [ $p.'view/deploy_zone-geojson.cgi?', 'zonenum' ];
+
+my $fixed_shp    = $p.'view/deploy_zone-shp.cgi?zonetype=B';
+my $fixed_kmz    = $p.'view/deploy_zone-kmz.cgi?zonetype=B';
+my $fixed_json   = $p.'view/deploy_zone-geojson.cgi?zonetype=B';
+
+my $tech_label  = FS::part_pkg_fcc_option->technology_labels;
+my $spec_label  = FS::part_pkg_fcc_option->spectrum_labels;
 
-my $tech_label = FS::part_pkg_fcc_option->technology_labels;
-my $spec_label = FS::part_pkg_fcc_option->spectrum_labels;
 </%init>
index e91eb36..b225c84 100644 (file)
@@ -28,7 +28,7 @@
 % } else {
   <FONT SIZE="-1">
 % }
-&copy; 2019 Freeside Internet Services, Inc.<BR>
+&copy; 2022 Freeside Internet Services, Inc.<BR>
 All rights reserved.<BR>
 Licensed under the terms of the<BR>
 GNU <b>Affero</b> General Public License.<BR>
index 24e03b0..fd13854 100644 (file)
@@ -4,6 +4,7 @@
     'post_url'      => popurl(1).'process/deploy_zone-fixed.html',
     'viewall_dir'   => 'browse',
     'labels'        => {
+        'zonenum'         => 'Deployment zone',
         'description'     => 'Description',
         'agentnum'        => 'Agent',
         'dbaname'         => 'Business name (if different from agent)',
@@ -14,6 +15,7 @@
         'cir_speed_down'  => 'Downstream',
         'is_consumer'     => 'Consumer/mass market',
         'is_business'     => 'Business/government',
+        'disabled'        => 'Disabled',
         'blocknum'        => '',
         'active_date'     => 'Active since',
         'file'            => 'Import blocks from text file',
@@ -37,6 +39,9 @@
             $cgi->param('active_date') || $object->active_date || time;
           },
         },
+        { field         => 'expire_date',
+          type          => 'hidden',
+        },
         { field         => 'agentnum',
           type          => 'select-agent',
           disable_empty => 1,
         },
         { field         => 'is_consumer', type => 'checkbox', value=>'Y' },
         { field         => 'is_business', type => 'checkbox', value=>'Y' },
+        { field         => 'disabled', type=>'checkbox', value=>'Y', },
         { type => 'tablebreak-tr-title',
           value => 'Advertised maximum speed (Mbps)' },
         'adv_speed_down',
         'adv_speed_up',
         { type => 'tablebreak-tr-title',
           value => 'Contractually guaranteed speed (Mbps)' },
+        { type => 'note',
+          value => 'Only required for filings as of June 30th, 2019 (due Sep. 3rd, 2019) and before',
+        },
         'cir_speed_down',
         'cir_speed_up',
         { type => 'tablebreak-tr-title', value => 'Footprint'},
index a32e99c..829b776 100644 (file)
@@ -263,12 +263,12 @@ Example:
 
 %   unless ( $opt{'no_pkey_display'} ) {
 
-      <FONT SIZE="+1"><B>
+      <FONT CLASS="fsinnerbox-title">
       <% ( $opt{labels} && exists $opt{labels}->{$pkey} )
             ? $opt{labels}->{$pkey}
             : $pkey
       %>
-      </B></FONT>
+      </FONT>
       #<% ( !$clone && $object->$pkey() ) || "(NEW)" %>
 
 %   }
@@ -277,9 +277,7 @@ Example:
 
 % my $tablenum = $opt{'tablenum'} || 0;
 <TABLE ID="TableNumber<% $tablenum++ %>"
-       <% $opt{html_table_class} ? 'CLASS="'. $opt{html_table_class}. '"'
-                                 : 'BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0'
-       %>
+       CLASS="<% $opt{html_table_class} || 'fsinnerbox' %>"
 >
 
 % my $g_row = 0;
index 78e4427..0a0b01e 100755 (executable)
                      #recurring frequency
                      #recurring fee (auto-disable)
 
-                   { type => 'columnnext' },
+                   { type => 'columnnext', value=>'Taxation', },
 
-                     {type=>'justtitle', value=>'Taxation' },
                      {field=>'setuptax', type=>'checkbox', value=>'Y'},
                      {field=>'recurtax', type=>'checkbox', value=>'Y'},
                      {field=>'taxclass', type=>'select-taxclass' },
                          )
                      ),
 
-                   { type => 'columnnext' },
-
-                     {type=>'justtitle', value=>'Agent (reseller) types' },
+                   { type => 'columnnext', value=>'Agent (reseller) types' },
 
                      { field       => 'agent_type',
                        type        => 'select-agent_type',
@@ -857,8 +854,9 @@ my $javascript = <<'END';
         supp_pkg_rows[0].style.display = 'none';
         var button = document.getElementById('show_supp_pkgs');
         button.onclick = show_supp_pkgs_click;
-        button.style.backgroundColor = '#cccccc';
-        button.style.border = '1px solid #7e0079';
+        //button.style.backgroundColor = '#cccccc';
+        //button.style.border = '1px solid #7e0079';
+        button.style.border = 'thin solid #999999';
         button.style.padding = '1px';
       }
     }
@@ -914,7 +912,7 @@ my $html_bottom = sub {
   my $layer_callback = sub {
   
     my $layer = shift;
-    my $html = ntable("#cccccc",2);
+    my $html = '<TABLE CLASS="fsinnerbox">';
   
     #$html .= '
     #  <TR>
index c272620..8e264c1 100644 (file)
@@ -5,7 +5,7 @@
 <%   include( 'elements/process.html',
                  'table'          => 'access_user',
                  'viewall_dir'    => 'browse',
-                 'copy_on_empty'  => [ '_password', '_password_encoding' ],
+                 'copy_on_empty'  => [ '_password', '_password_encoding', 'totp_secret32' ],
                  'clear_on_error' => [ '_password', '_password2' ],
                  'process_m2m'    => { 'link_table'   => 'access_usergroup',
                                        'target_table' => 'access_group',
index 40c83c6..1bf4d76 100644 (file)
@@ -28,7 +28,7 @@ my $cust_location = qsearchs({
 });
 die "unknown locationnum $locationnum" unless $cust_location;
 
-$cust_location->set('censustract', $cgi->param('enter_censustract'));
+$cust_location->set('censustract', scalar($cgi->param('enter_censustract')));
 my $error = $cust_location->replace;
 
 </%init>
index baacd5e..d4578d2 100755 (executable)
@@ -44,7 +44,7 @@ my $error = '';
 
 $cgi->param('tax','') unless defined $cgi->param('tax');
 
-$cgi->param('refnum', (split(/:/, ($cgi->param('refnum'))[0] ))[0] );
+$cgi->param('refnum', (split(/:/, ($cgi->multi_param('refnum'))[0] ))[0] );
 
 #my @invoicing_list = split( /\s*\,\s*/, $cgi->param('invoicing_list') );
 #push @invoicing_list, 'POST' if $cgi->param('invoicing_list_POST');
@@ -62,7 +62,7 @@ $cgi->param('duplicate_of_custnum') =~ /^(\d+)$/;
 my $duplicate_of = $1;
 
 # if this is enabled, enforce it
-if ( $conf->exists('agent-ship_address', $cgi->param('agentnum')) ) {
+if ( $conf->exists('agent-ship_address', scalar($cgi->param('agentnum'))) ) {
   my $agent = FS::agent->by_key($cgi->param('agentnum'));
   my $agent_cust_main = $agent->agent_cust_main;
   if ( $agent_cust_main ) {
index b7501d4..5007319 100644 (file)
@@ -55,8 +55,12 @@ my $cust_main = qsearchs('cust_main', { 'custnum' => $src->custnum } )
 my $custnum = $cust_main->custnum;
 
 my @subnames = grep { /.+/ } map { /^subnum(\d+)$/ ? $1 : '' } $cgi->param;
-my @subitems = map { [ $cgi->param("subnum$_"), $cgi->param("subamount$_"), $cgi->param("taxXlocationnum$_") ] }
-               @subnames;
+my @subitems = map { [ scalar($cgi->param("subnum$_")),
+                       scalar($cgi->param("subamount$_")),
+                       scalar($cgi->param("taxXlocationnum$_"))
+                     ]
+                   }
+                 @subnames;
 { local $^W = 0; @subitems = grep { $_->[1] + 0 } @subitems; }
 
 my %options = ();
index 4dfe82f..da8286e 100644 (file)
@@ -1,4 +1,10 @@
           </TABLE>
         </TD>
         <TD VALIGN="top" STYLE="padding-left:12px">
-          <TABLE BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
+          <FONT CLASS="fsinnerbox-title"><% $opt{value} %></FONT><BR>
+          <TABLE CLASS="fsinnerbox">
+<%init>
+
+my %opt = @_;
+
+</%init>
index 245c308..40f7cad 100644 (file)
@@ -16,7 +16,7 @@ Pass 'aligned' => 1 to have corresponding rows in the columns line up.
     <TABLE BORDER=0 CELLSPACING=0 CELLPADDING=0 id="<%$id%>">
       <TR>
         <TD VALIGN="top">
-          <TABLE BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
+          <TABLE CLASS="fsinnerbox">
 % if ( $aligned ) {
 %# Instead of changing all the tr-* elements to sometimes output table
 %# cells without wrapping them in a row, we're just going to completely
index 6404bcb..6765df0 100644 (file)
@@ -306,8 +306,11 @@ if ( $curr_value ) {
   $cust_payby = new FS::cust_payby {};
 }
 my $sel_payby = $cgi->param($name.'_payby') || $cust_payby->payby;
+# add a weight for CARD/CHEK imports, so we don't turn off auto-charge on edit
 # convert DCRD to CARD + no weight, and the same for DCHK/CHEK
-if ($sel_payby eq 'DCRD') {
+if ( $cust_payby->custpaybynum && $sel_payby =~ /^(CARD|CHEK)$/ && ! $cust_payby->weight ) {
+  $cust_payby->weight(1);
+} elsif ($sel_payby eq 'DCRD') {
   $sel_payby = 'CARD';
   $cust_payby->weight('');
 } elsif ($sel_payby eq 'DCHK') {
diff --git a/httemplate/elements/dashboard-topnotes.html b/httemplate/elements/dashboard-topnotes.html
new file mode 100644 (file)
index 0000000..28ca025
--- /dev/null
@@ -0,0 +1,15 @@
+% if ( $notes ) {
+  <TABLE CLASS="fsinnerbox">
+    <TR><TD>
+      <% $notes %>
+    </TD></TR>
+  </TABLE>
+  <BR>
+% }
+<%init>
+
+my $conf = new FS::Conf;
+
+my $notes = join('<BR>', map encode_entities($_), $conf->config('dashboard-topnotes') );
+
+</%init>
index 5a5db96..a56e0b1 100644 (file)
@@ -96,6 +96,14 @@ Example:
           <BR></FONT>
         </td>
       </tr>
+%     foreach my $top_warning ( @top_warnings ) {
+        <TR>
+          <TD COLSPAN=4>
+            <IMG SRC="<% $fsurl %>images/error.png">
+            <FONT COLOR="#FF0000" SIZE="+1"><% $top_warning %></FONT>
+          </TD>
+        </TR>
+%     }
     </table>
 
     <TABLE WIDTH="100%" CELLSPACING=0 CELLPADDING=0>
@@ -276,4 +284,23 @@ if ( scalar(@agentnums) == 1 ) {
 my %status_color = ( 'status' => '#eeffee', 'warning' => '#fefbd0', 'error' => '#f97c7c', );
 my %status_image = ( 'status' => 'images/tick.png', 'warning' => 'images/tick.png', 'error' => 'images/error.png', );
 
+my @top_warnings = ();
+my $deb_version = int(slurp('/etc/debian_version'));
+#per wiki.debian.org/LTS
+push @top_warnings, deb_warning($deb_version)
+  if ( $deb_version <= 8 )
+  or ( $deb_version == 9  && time > 1656658800 ) #7/1/2022
+  or ( $deb_version == 10 && time > 1719817200 ) #7/1/2024
+  or ( $deb_version == 11 && time > 1782889200 ) #7/1/2026
+;
+
+sub deb_warning {
+  my $ver = shift;
+  <<"END";
+WARNING: Your operating system (Debian v$ver) is EOL and no longer supported.
+This is insecure and a violation of PCI data security standard.
+Contact <a href="mailto:sales\@freeside.biz?subject=Debian OS upgrade">sales\@freeside.biz</a> to schedule an upgrade ASAP.
+END
+}
+
 </%init>
index f6fc254..dd38cd0 100644 (file)
@@ -129,6 +129,8 @@ $report_customers_lists{'by active trouble tickets'} = [ $fsurl. 'search/cust_ma
   if $conf->config('ticket_system');
 $report_customers_lists{'with USPS-unvalidated addresses'} = [ $fsurl. 'search/cust_main.cgi?browse=uspsunvalid', '' ]
   if $conf->config('usps_webtools-userid') && $conf->config('usps_webtools-password');
+$report_customers_lists{'with missing/outdated census tract'} = [ $fsurl. 'search/cust_main.html?no_censustract=1&ship_country=US', '' ]
+  ;#if $conf->config('cust_main-require_censustract');
 $report_customers_lists{'with referrals'} = [ $fsurl. 'search/cust_main.html?with_referrals=1' ];
 
 tie my %report_customers, 'Tie::IxHash';
index 54a554f..140ed70 100644 (file)
@@ -325,6 +325,12 @@ function set_censustract(tract, year) {
   var form = document.<% $formname %>;
   form.elements['censustract'].value = tract;
   form.elements['censusyear'].value = year;
+
+  var enter = form.elements['enter_censustract'];
+  if ( enter ) {
+    enter.value = tract;
+  }
+
   <% $post_censustract %>;
 }
 
index ee28312..ce64d47 100644 (file)
@@ -1,9 +1,9 @@
 </TABLE>
+<BR>
 
-<TABLE <% $id %> BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
-
-<% include('tr-title.html', @_ ) %>
+<FONT CLASS="fsinnerbox-title" <%$title_id%>><% $opt{value} %></FONT>
 
+<TABLE <% $id %> CLASS="fsinnerbox">
 <%init>
 
 my %opt = @_;
@@ -11,4 +11,7 @@ my %opt = @_;
 my $id = '';
 $id = 'ID="'. $opt{'table_id'}. '"' if $opt{'table_id'};
 
+my $title_id;
+my $title_id = 'ID="'.$opt{id}.'"' if $opt{id};
+
 </%init>
index b87f7e1..316ea69 100644 (file)
@@ -1,7 +1,7 @@
 <TR>
-  <TH CLASS="background" COLSPAN=<% $opt{colspan} || 2 %> ALIGN="left" <%$id%>>
-    <FONT SIZE="+1"><% $opt{value} %></FONT>
-  </TH>
+  <TD CLASS="background" COLSPAN=<% $opt{colspan} || 2 %> ALIGN="left" <%$id%>>
+    <FONT CLASS="fsinnerbox-title"><% $opt{value} %></FONT>
+  </TD>
 </TR>
 
 <%init>
diff --git a/httemplate/elements/tr-note.html b/httemplate/elements/tr-note.html
new file mode 100644 (file)
index 0000000..8493e5a
--- /dev/null
@@ -0,0 +1,12 @@
+<TR>
+  <TD COLSPAN=<% $opt{colspan} || 2 %> ALIGN="left" <%$id%>>
+    <I><% $opt{value} %></I>
+  </TD>
+</TR>
+
+<%init>
+
+my %opt = @_;
+my $id = 'ID="'.$opt{id}.'"' if $opt{id};
+
+</%init>
index de3f95a..2324545 100644 (file)
@@ -131,8 +131,8 @@ provision_hold_init();
 
 % if ( scalar(@possible_exports) > 0 || scalar(@mapped_exports) > 0 ) {
    <TABLE><TR>
-       <TH BGCOLOR="#dcdcdc">Export</TH>
-       <TH BGCOLOR="#dcdcdc">Vendor Package Id <FONT SIZE="-2">(blank to delete)</FONT></TH>
+       <TH>Export</TH>
+       <TH>Vendor Package Id <FONT SIZE="-2">(blank to delete)</FONT></TH>
        </TR>
 %   foreach my $export ( @mapped_exports ) {
        <TR>
@@ -166,14 +166,14 @@ my $cgi = $opt{'cgi'};
 my $thead_count = 0;
 sub pkg_svc_thead {
   $thead_count += 1;
-  return "\n\n". ntable('#cccccc', 2).
+  return "\n\n". '<TABLE CLASS="fsinnerbox">'.
          '<TR>'.
-         '<TH BGCOLOR="#dcdcdc"><FONT SIZE=-1>Quan.</FONT></TH>'.
-         '<TH BGCOLOR="#dcdcdc"><FONT SIZE=-1>Primary</FONT></TH>'.
-         '<TH BGCOLOR="#dcdcdc">Service</TH>'.
-         '<TH BGCOLOR="#dcdcdc"><FONT SIZE=-1>Hide<BR>from<BR>Invoices</FONT></TH>'.
-         '<TH BGCOLOR="#dcdcdc"><FONT SIZE=-1>Bulk<BR>Charge</FONT></TH>'.
-         '<TH BGCOLOR="#dcdcdc" ID="th_provision_hold' . $thead_count . '"><FONT SIZE=-1>Remove Hold After Provisioning</FONT></TH>'.
+         '<TH><FONT SIZE=-1>Quan.</FONT></TH>'.
+         '<TH><FONT SIZE=-1>Primary</FONT></TH>'.
+         '<TH>Service</TH>'.
+         '<TH><FONT SIZE=-1>Hide<BR>from<BR>Invoices</FONT></TH>'.
+         '<TH><FONT SIZE=-1>Bulk<BR>Charge</FONT></TH>'.
+         '<TH ID="th_provision_hold' . $thead_count . '"><FONT SIZE=-1>Remove Hold After Provisioning</FONT></TH>'.
          '</TR>'.
          qq!<SCRIPT>provision_hold_td.push(document.getElementById('th_provision_hold$thead_count'))</SCRIPT>!;
 ;
index 72e9525..1785ea7 100644 (file)
         <TD ALIGN="right">Password: </TD>
         <TD><INPUT TYPE="password" NAME="credential_1" SIZE="13"></TD>
       </TR>
+      <TR>
+        <TD ALIGN="right">One-time code: </TD>
+        <TD><INPUT TYPE="text" NAME="credential_2" SIZE="13"></TD>
+      </TR>
     </TABLE>
     <BR>
  
@@ -42,7 +46,7 @@
 my %error = (
   'no_cookie'       => '', #First login, don't display an error
   'bad_cookie'      => 'Bad Cookie', #timed out?
-  'bad_credentials' => 'Incorrect username / password',
+  'bad_credentials' => 'Incorrect username / password / one-time code',
   #'logout'          => 'You have been logged out.',
 );
 
index 0f115e5..02847f8 100644 (file)
@@ -16,12 +16,18 @@ Confirm census tract
 <% $location{address1} |h %> <% $location{address2} |h %><BR>
 <% $location{city} |h %>, <% $location{state} |h %> <% $location{zip} |h %><BR>
 <BR>
-% my $querystring = "census_year=$year&address=$location{address1}, $location{address2}, $location{city}, $location{state}";
+% my $address1 = $location{address1};
+% $address1 =~ s/(apt|ste|suite|unit)[\s\d]\w*\s*$//i;
+% my $querystring = "census_year=$year&address=$address1, $location{address2}, $location{city}, $location{state}";
 <A HREF="<%$p%>misc/openmap.html?<% $querystring %>"
-   TARGET="_blank">Map service module location</A><BR>
+   REL="opener"
+   TARGET="_blank"
+>Map service location</A><BR>
 % $querystring = "census_year=$year&pre=$pre&zip_code=" . $cache->get('zip');
 <A HREF="<%$p%>misc/openmap.html?<% $querystring %>"
-   TARGET="_blank">Map zip code center</A><BR>
+   REL="opener"
+   TARGET="_blank"
+>Map zip code center</A><BR>
 <BR>
 <input type="hidden" id="new_tract" name="new_tract" value="<%$new_tract%>">
 <TABLE>
@@ -103,7 +109,7 @@ my %location = (
 my $old_tract = $q->{$pre.'censustract'};
 my $cache = eval { FS::GeocodeCache->new(%location) };
 $cache->set_censustract;
-my $year = FS::Conf->new->config('census_year');
+my $year = FS::Conf->new->config('census_legacy') || '2020';
 my $new_tract = $cache->get('censustract');
 my $error = $cache->get('censustract_error');
 
index 88f64c1..15de288 100644 (file)
@@ -44,8 +44,7 @@
       function getCensusTract(lat, lon) {
        var url = 'xmlhttp-censustract.html?lat=' + lat + '&lon=' + lon + '&census_year=<%$census_year%>';
        $.getJSON(url,function(data){
-         var tract = (data.Block.FIPS.substr(0, 11) / 100).toFixed(2);
-         document.getElementById("mycensustract").innerHTML = tract;
+         document.getElementById("mycensustract").innerHTML = data.Block.FIPS;
        });
       }
 
@@ -79,6 +78,6 @@ my $census_year = $cgi->param('census_year');
 my $pre         = $cgi->param('pre');
 my $zip_code    = $cgi->param('zip_code');
 my $address     = $cgi->param('address');
-my $loc = $zip_code ? $zip_code : $address;
+my $loc = $zip_code ? $zip_code.', United States' : $address;
 
-</%init>
\ No newline at end of file
+</%init>
index 985fb90..855e172 100644 (file)
@@ -5,12 +5,19 @@ my $DEBUG = 0;
 
 my $conf = new FS::Conf;
 
-my $return = {};
-
 ## new api link, see doc https://geo.fcc.gov/api/census/
 my $url = "https://geo.fcc.gov/api/census/block/find?format=json&censusYear=" . $cgi->param('census_year') . "&latitude=" . $cgi->param('lat') . "&longitude=" . $cgi->param('lon');
 
-use LWP::Simple;
-my $return = get $url;
+my $ua = new LWP::UserAgent;
+$ua->agent('Freeside/'. $FS::VERSION); #libwww* elicits "403 Forbidden"
+my $res = $ua->get($url);
+
+my $return = '';
+if ( $res->is_success ) {
+  $return = $res->decoded_content;
+} else {
+  #better error handling?  well, hopefully the site is reliable enough
+  warn 'Error from geo.fcc.gov: '. $res->status_line. "\n";
+}
 
-</%init>
\ No newline at end of file
+</%init>
index dd9ed3b..6c9cf69 100644 (file)
@@ -1,7 +1,7 @@
 <% encode_json($return) %>\
 <%init>
 
-my %arg = $cgi->param('arg');
+my %arg = $cgi->multi_param('arg');
 my $custnum = delete($arg{'custnum'});
 
 my $error;
index 56fde6d..5f68d3e 100644 (file)
     </TABLE>
     <BR>
 
+    <FONT CLASS="fsinnerbox-title"><% emt('Google Authenticator') %></FONT>
+    <TABLE CLASS="fsinnerbox">
+      <TR>
+%       if ( $curuser->totp_secret32 ) {
+          <TD><IMG SRC="<% $curuser->totp_qr_code_url %>"</IMG></TD>
+%       } else {
+          <TD><A HREF="<%$p%>pref/set_totp_secret32.html">Enable</A></TD>
+%       }
+      </TR>
+    </TABLE>
+    <BR>
+
 % }
 
 <FONT CLASS="fsinnerbox-title"><% emt("Interface") %></FONT>
diff --git a/httemplate/pref/set_totp_secret32.html b/httemplate/pref/set_totp_secret32.html
new file mode 100644 (file)
index 0000000..f5676bc
--- /dev/null
@@ -0,0 +1,19 @@
+<& /elements/header.html, mt('Google Authenticator for [_1]', $FS::CurrentUser::CurrentUser->username) &>
+
+Scan this code with the Google Authenticator application on your phone.
+<BR><BR>
+
+<IMG SRC="<% $access_user->totp_qr_code_url %>"></IMG>
+<BR><BR>
+
+Future logins will require a 6-digit code generated by the application.
+
+<& /elements/footer.html &>
+<%init>
+
+my $access_user = $FS::CurrentUser::CurrentUser;
+
+my $error = $access_user->set_totp_secret32 unless length($access_user->totp_secret32);
+die $error if $error; #better error handling for this "shouldn't happen" case?
+
+</%init>
index 84fd7fb..9a41261 100644 (file)
@@ -67,6 +67,9 @@ a.download {
     <a class="download" href="<% $cgi->self_url %>">Download</a>
   </caption>
 %   my $header = ".header_$partname";
+%   $header .= '_old' if $partname eq 'fbd' && $date < 1569826800; #9/30/2019
+%              # ( halfway between the two filing "as of" dates when it changed
+
 %   my $data = $this_part->{data};
 %   my $error = $this_part->{error};
   <thead>
@@ -168,7 +171,7 @@ if ( $cgi->param('type') eq 'csv' ) {
 my $part_titles = FS::Report::FCC_477->parts;
 
 </%init>
-<%def .header_fbd>
+<%def .header_fbd_old>
   <TR CLASS="head">
     <TD ROWSPAN=2>Census Block</TD>
     <TD ROWSPAN=2>DBA Name</TD>
@@ -185,6 +188,20 @@ my $part_titles = FS::Report::FCC_477->parts;
     <TD>Up</TD>
   </TR>
 </%def>
+<%def .header_fbd>
+  <TR CLASS="head">
+    <TD ROWSPAN=2>Census Block</TD>
+    <TD ROWSPAN=2>DBA Name</TD>
+    <TD ROWSPAN=2>Technology</TD>
+    <TD ROWSPAN=2>Consumer?</TD>
+    <TD COLSPAN=2>Advertised Speed (Mbps)</TD>
+    <TD ROWSPAN=2>Business?</TD>
+  </TR>
+  <TR CLASS="subhead">
+    <TD>Down</TD>
+    <TD>Up</TD>
+  </TR>
+</%def>
 <%def .header_fbs>
   <TR CLASS="head">
     <TD ROWSPAN=2>Census Tract</TD>
index 8bbd1af..56bf767 100644 (file)
@@ -163,7 +163,7 @@ die "access denied"
               || $cgi->param('pkgnum')  =~ /^(\d+)$/
             );
 
-my @statuses = $cgi->param('event_status');
+my @statuses = $cgi->multi_param('event_status');
 my $title = 'Billing events';
 if ( $statuses[0] eq 'failed' and !defined($statuses[1]) ) {
   # tweak the title if we're showing only failed events
index 46e35da..8ef068f 100755 (executable)
@@ -70,7 +70,7 @@ for my $param ( @scalars ) {
 
 #lists
 for my $param (qw( classnum refnum pkg_classnum )) {
-  $search_hash{$param} = [ $cgi->param($param) ];
+  $search_hash{$param} = [ $cgi->multi_param($param) ];
 }
 
 my $params = $cgi->Vars;
index 4341970..d57e64f 100644 (file)
@@ -20,11 +20,8 @@ Example:
 <INPUT TYPE="hidden" NAME="magic" VALUE="advanced">
 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
 
-  <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
-
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1"><% mt('Search options') |h %></FONT></TH>
-    </TR>
+  <FONT CLASS="fsinnerbox-title"><% emt('Search options') %></FONT>
+  <TABLE CLASS="fsinnerbox">
 
 % unless ( $custnum ) {
 
@@ -38,12 +35,15 @@ Example:
          field         => 'cust_status',
     &>
 
-    <& /elements/tr-select-payby.html,
-         label         => emt('Payment method:'),
-         payby_type    => 'cust',
-         multiple      => 1,
-         all_selected  => 1,
-    &>
+%# meaning-less in the post-4.x world, customers can have multiple payment
+%# methods now
+
+%#    <& /elements/tr-select-payby.html,
+%#         label         => emt('Payment method:'),
+%#         payby_type    => 'cust',
+%#         multiple      => 1,
+%#         all_selected  => 1,
+%#    &>
 
     <& /elements/tr-input-money.html,
          label         => 'Balance over',
@@ -72,13 +72,11 @@ Example:
          'label' => 'Services',
     &> 
 
-    <TR>
-      <TH CLASS="background" COLSPAN=2>&nbsp;</TH>
-    </TR>
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1"><% mt('Display options') |h %></FONT></TH>
-    </TR>
+  </TABLE>
+  <BR>
+
+  <FONT CLASS="fsinnerbox-title"><% emt('Display options') %></FONT>
+  <TABLE CLASS="fsinnerbox">
 
 %   #"package fields" ala advanced svc_acct search?
 %   #move to /elements/tr-select-cust_pkg-fields and use it from there if so...
index 9be0b7d..96a6f5b 100644 (file)
@@ -216,7 +216,9 @@ $cgi->param('max_level', 5) unless defined($cgi->param('max_level'));
 
 my %search = ();
 $search{'date'} = [ FS::UI::Web::parse_beginning_ending($cgi) ];
-$search{'level'} = [ $cgi->param('min_level'), $cgi->param('max_level') ];
+$search{'level'} = [ scalar($cgi->param('min_level')),
+                     scalar($cgi->param('max_level'))
+                   ];
 foreach my $param (qw(agentnum context context_height tablename tablenum custnum message)) {
   if ( $cgi->param($param) ) {
     $search{$param} = $cgi->param($param);
index 4e17d1f..a976ef9 100755 (executable)
                 'options' => \@location_options,
                 'labels'  => { 'cust'     => "is the customer's default location",
                                'nocust'   => "is not the customer's default location",
-                               'census'   => "has a census tract",
-                               'nocensus' => "does not have a census tract",
+                               'census'   => "has an up-to-date census tract",
+                               'nocensus' => "does not have an up-to-date census tract",
                                'nogeocode'=> 'has an implicit tax location',
                                'geocode'  => 'has a hardcoded tax location',
                              },
index 45246c7..377064a 100755 (executable)
@@ -1,14 +1,13 @@
 <% include('/elements/header.html', $title ) %>
 
+%# extensive false laziness with svc_acct
+
 <FORM ACTION="svc_broadband.cgi" METHOD="POST">
 <INPUT TYPE="hidden" NAME="magic" VALUE="advanced">
 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
-%# extensive false laziness with svc_acct
-  <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
 
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1">Search options</FONT></TH>
-    </TR>
+  <FONT CLASS="fsinnerbox-title"><% emt('Search options') %></FONT>
+  <TABLE CLASS="fsinnerbox">
 
 % unless ( $custnum ) {
     <% include( '/elements/tr-select-agent.html',
 %     }
 
 %   }
-    <TR>
-      <TH CLASS="background" COLSPAN=2>&nbsp;</TH>
-    </TR>
-    <TR>
-      <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1">Display options</FONT></TH>
-    </TR>
 
+  </TABLE>
+  <BR>
+
+  <FONT CLASS="fsinnerbox-title"><% emt('Display options') %></FONT>
+  <TABLE CLASS="fsinnerbox">
 %   #move to /elements/tr-select-cust_pkg-fields if anything else needs it...
     <TR>
-      <TD ALIGN="right">Package fields</TD>
+      <TH ALIGN="right">Package fields</TD>
       <TD>
         <SELECT NAME="cust_pkg_fields">
           <OPTION VALUE="">(none)
index 63d5c9a..b1464b5 100644 (file)
@@ -273,7 +273,7 @@ my @menu = (
       actionlabel => 'Order new package',
       color       => '#333399',
       width       => 960,
-      height      => 740,
+      height      => 850,
       acl         => 'Order customer package',
     },
     {
@@ -380,6 +380,15 @@ my @menu = (
         #  acl         => [ 'Post payment', ],
         ##  condition   => sub { $payby{MCHK} },
         #},
+        {
+          label       => 'Record manual (non-Freeside) Paypal payment',
+          popup       => "edit/cust_pay.cgi?popup=1;payby=PPAL;custnum=$custnum",
+          actionlabel => 'Enter Paypal payment',
+          width       => 763,
+          height      => 392,
+          acl         => [ 'Post Payment', 'Post Paypal payment', ],
+          condition   => sub { $payby{PPAL} },
+        },
       ],
     },
 
diff --git a/httemplate/view/deploy_zone-geojson.cgi b/httemplate/view/deploy_zone-geojson.cgi
new file mode 100644 (file)
index 0000000..0c9d193
--- /dev/null
@@ -0,0 +1,42 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my($name, $content);
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $content = $deploy_zone->geo_json_feature->to_json;
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+   my $fc = Geo::JSON::FeatureCollection->new({
+     features => [ map $_->geo_json_feature, @deploy_zone ],
+   });
+
+   $content = $fc->to_json;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+http_header('Content-Type'        => 'application/geo+json' );
+http_header('Content-Disposition' => "filename=$name.geojson" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>
diff --git a/httemplate/view/deploy_zone-kmz.cgi b/httemplate/view/deploy_zone-kmz.cgi
new file mode 100644 (file)
index 0000000..d2af171
--- /dev/null
@@ -0,0 +1,42 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $kml = Geo::GoogleEarth::Pluggable->new;
+
+my $name;
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $deploy_zone->kml_polygon($kml);
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+  $_->kml_polygon($kml) foreach @deploy_zone;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+my $content = $kml->archive;
+
+http_header('Content-Type' => 'application/vnd.google-earth.kmz' ); #kmz
+http_header('Content-Disposition' => "filename=$name.kmz" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>
diff --git a/httemplate/view/deploy_zone-shp.cgi b/httemplate/view/deploy_zone-shp.cgi
new file mode 100644 (file)
index 0000000..4b5dfbb
--- /dev/null
@@ -0,0 +1,76 @@
+<% $content %>\
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+my $acl_edit = $curuser->access_right('Edit FCC report configuration');
+my $acl_edit_global = $curuser->access_right('Edit FCC report configuration for all agents');
+die "access denied"
+  unless $acl_edit or $acl_edit_global;
+
+my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+
+my %shapelib_opts = (
+  Shapetype  => Geo::Shapelib::POLYGON,
+  FieldNames => [ 'Tech', 'Down', 'Up' ],
+  FieldTypes => [ 'String:32', 'Double', 'Double' ],
+);
+
+my( $name, $shapefile );
+
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ || $cgi->param('zonenum') =~ /^(\d+$)/ ) {
+  my $zonenum = $1;
+  $name = $zonenum;
+  my $deploy_zone = qsearchs('deploy_zone', { 'zonenum' => $zonenum })
+    or die 'unknown zonenum';
+
+  $shapefile = new Geo::Shapelib {
+    Name => "$dir/$zonenum-$$",
+    %shapelib_opts
+  };
+
+  $deploy_zone->shapefile_add($shapefile);
+
+} elsif ( $cgi->param('zonetype') =~ /^(\w)$/ ) {
+  my $zonetype = $1;
+  $name = $zonetype;
+  my @deploy_zone = qsearch('deploy_zone', { 'zonetype' => $zonetype,
+                                             'disabled' => '',        });
+
+  $shapefile = new Geo::Shapelib {
+    Name => "$dir/$zonetype-$$",
+    %shapelib_opts
+  };
+
+  $_->shapefile_add($shapefile) foreach @deploy_zone;
+
+} else {
+  die "no zonenum or zonetype\n";
+}
+
+$shapefile->set_bounds;
+
+$shapefile->save;
+
+#slurp up .shp .shx and .dbf files and put them in a zip.. return that
+#and delete the files
+
+my $content = '';
+open(my $fh, '>', \$content);
+
+my $zip = new Archive::Zip;
+$zip->addFile("$dir/$name-$$.$_", "$name.$_") foreach qw( shp shx dbf );
+unless ( $zip->writeToFileHandle($fh) == Archive::Zip::AZ_OK() ) {
+  die "failed to create .shz file\n";
+}
+close $fh;
+
+unlink("$dir/$name-$$.$_") foreach qw( shp shx dbf );
+
+#http_header('Content-Type'        => 'x-gis/x-shapefile' );
+http_header('Content-Type'        => 'archive/zip' );
+http_header('Content-Disposition' => "filename=$name.shz" );
+http_header('Content-Length'      => length($content) );
+http_header('Cache-control'       => 'max-age=60' );
+
+</%init>