Merge branch 'FREESIDE_3_BRANCH' of git.freeside.biz:/home/git/freeside into FREESIDE...
authorIvan Kohler <ivan@freeside.biz>
Sat, 14 May 2016 00:28:09 +0000 (17:28 -0700)
committerIvan Kohler <ivan@freeside.biz>
Sat, 14 May 2016 00:28:09 +0000 (17:28 -0700)
17 files changed:
FS/FS/API.pm
FS/FS/Password_Mixin.pm
FS/FS/Schema.pm
FS/FS/cdr/FS/FS/cdr/vvs.pm
FS/FS/cdr/vss.pm [deleted file]
FS/FS/cust_bill_pkg.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/part_pkg/sql_external.pm
bin/bulk_void [new file with mode: 0755]
bin/rate-intl.import [new file with mode: 0755]
httemplate/edit/process/cust_main.cgi
httemplate/elements/errorpage.html
httemplate/misc/make_appointment.html
httemplate/misc/process/payment.cgi
httemplate/search/cust_main.cgi
httemplate/search/report_cust_bill_void.html

index 4e6cb6c..4ff1a3a 100644 (file)
@@ -65,6 +65,10 @@ Amount paid
 
 Option date for payment
 
+=item order_number
+
+Optional order number
+
 =back
 
 Example:
@@ -77,6 +81,7 @@ Example:
 
     #optional
     '_date'   => 1397977200, #UNIX timestamp
+    'order_number' => '12345',
   );
 
   if ( $result->{'error'} ) {
index b807081..2e400ec 100644 (file)
@@ -60,7 +60,7 @@ sub is_password_allowed {
 
   # basic checks using Data::Password;
   # options for Data::Password
-  $DICTIONARY = 4;   # minimum length of disallowed words
+  $DICTIONARY = 0;   # minimum length of disallowed words, false value disables dictionary checking
   $MINLEN = $conf->config('passwordmin') || 6;
   $MAXLEN = $conf->config('passwordmax') || 8;
   $GROUPS = 4;       # must have all 4 'character groups': numbers, symbols, uppercase, lowercase
@@ -70,9 +70,23 @@ sub is_password_allowed {
   # # lists of disallowed words
   # @DICTIONARIES = qw( /usr/share/dict/web2 /usr/share/dict/words /usr/share/dict/linux.words );
 
+  # first, no dictionary checking but require 4 char groups
   my $error = IsBadPassword($password);
-  $error = 'must contain at least one each of numbers, symbols, and lowercase and uppercase letters'
-    if $error eq 'contains less than 4 character groups'; # avoid confusion
+
+  # but they can get away with 3 char groups, so long as they're not using a word
+  if ($error eq 'contains less than 4 character groups') {
+    $DICTIONARY = 4; # default from Data::Password is 5
+    $GROUPS = 3;
+    $error = IsBadPassword($password);
+    # take note--we never actually report dictionary word errors;
+    # 4 char groups is the rule, 3 char groups and no dictionary words is an acceptable exception
+    $error = 'should contain at least one each of numbers, symbols, lowercase and uppercase letters'
+      if $error;
+  }
+
+  # maybe also at some point add an exception for any passwords of sufficient length,
+  # see https://xkcd.com/936/
+
   $error = 'Invalid password - ' . $error if $error;
   return $error if $error;
 
index c40f6c7..c8f8c81 100644 (file)
@@ -1774,7 +1774,7 @@ sub tables_hashref {
         'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK
         'processor',  'varchar', 'NULL', $char_d, '', '', # module name
         'auth',       'varchar','NULL',16, '', '', # CC auth number
-        'order_number','varchar','NULL',$char_d, '', '', # transaction number
+        'order_number','varchar','NULL',256, '', '', # transaction number
       ],
       'primary_key' => 'paynum',
       #i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it# 'unique' => [ [ 'payunique' ] ],
@@ -1809,7 +1809,7 @@ sub tables_hashref {
         'gatewaynum',     'int', 'NULL', '', '', '', # payment_gateway FK
         'processor',  'varchar', 'NULL', $char_d, '', '', # module name
         'auth',       'varchar','NULL',16, '', '', # CC auth number
-        'order_number', 'varchar','NULL',$char_d, '', '', # transaction number
+        'order_number', 'varchar','NULL',256, '', '', # transaction number
 
         #void fields
         'void_date', @date_type, '', '', 
index 63a647e..db7e72a 100644 (file)
@@ -18,12 +18,11 @@ use FS::cdr qw(_cdr_date_parser_maker);
         'src',          # caller
         'dst',          # called
         skip(2),        # reason
-                       # call id
+                        # call id
         _cdr_date_parser_maker('startdate'),       # time
         'billsec',      # duration
-        skip(3),        # ringtime
-                        # status
-                        # resller_charge
+        skip(2),        # ringtime
+                        # reseller_charge
        'upstream_price',# customer_charge
   ],
 );
diff --git a/FS/FS/cdr/vss.pm b/FS/FS/cdr/vss.pm
deleted file mode 100644 (file)
index a550303..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-package FS::cdr::vss;
-
-use strict;
-use vars qw( @ISA %info $tmp_mon $tmp_mday $tmp_year );
-use Time::Local;
-use FS::cdr qw(_cdr_date_parser_maker);
-
-@ISA = qw(FS::cdr);
-
-%info = (
-  'name'          => 'VSS',
-  'weight'        => 120,
-  'header'        => 1,
-  'import_fields' => [
-
-        skip(1),        # i_customer
-        'accountcode',  # account_id
-        'src',          # caller
-        'dst',          # called
-        skip(2),        # reason
-                       # call id
-        _cdr_date_parser_maker('startdate'),       # time
-        'billsec',      # duration
-        skip(3),        # ringtime
-                        # status
-                        # resller_charge
-       'upstream_price',# customer_charge
-  ],
-);
-
-sub skip { map {''} (1..$_[0]) }
-
-1;
index 4448da6..a15d7a2 100644 (file)
@@ -1102,17 +1102,34 @@ sub cust_bill_pkg_tax_Xlocation {
 
 =item recur_show_zero
 
-=cut
+Whether to show a zero recurring amount. This is true if the package or its
+definition has the recur_show_zero flag, and the recurring fee is actually
+zero for this period.
 
-sub recur_show_zero { shift->_X_show_zero('recur'); }
-sub setup_show_zero { shift->_X_show_zero('setup'); }
+=cut
 
-sub _X_show_zero {
+sub recur_show_zero {
   my( $self, $what ) = @_;
 
-  return 0 unless $self->$what() == 0 && $self->pkgnum;
+  return 0 unless $self->get('recur') == 0 && $self->pkgnum;
+
+  $self->cust_pkg->_X_show_zero('recur');
+}
+
+=item setup_show_zero
 
-  $self->cust_pkg->_X_show_zero($what);
+Whether to show a zero setup charge. This requires the package or its
+definition to have the setup_show_zero flag, but it also returns false if
+the package's setup date is before this line item's start date.
+
+=cut
+
+sub setup_show_zero {
+  my $self = shift;
+  return 0 unless $self->get('setup') == 0 && $self->pkgnum;
+  my $cust_pkg = $self->cust_pkg;
+  return 0 if ( $self->sdate || 0 ) > ( $cust_pkg->setup || 0 );
+  return $cust_pkg->_X_show_zero('setup');
 }
 
 =item credited [ BEFORE, AFTER, OPTIONS ]
index 2f9eecd..7752956 100644 (file)
@@ -1261,6 +1261,9 @@ sub _make_lines {
   my $unitrecur = 0;
   my @recur_discounts = ();
   my $sdate;
+
+  my $override_quantity;
+
   # Conditions for billing the recurring fee:
   # - the package doesn't have a future start date
   # - and it's not suspended
@@ -1356,6 +1359,10 @@ sub _make_lines {
     #base_cancel???
     $unitrecur = $cust_pkg->base_recur( \$sdate ) || $recur; #XXX uuh, better
 
+    if ( $param{'override_quantity'} ) {
+      $override_quantity = $param{'override_quantity'};
+    }
+
     if ( $increment_next_bill ) {
 
       my $next_bill;
@@ -1410,7 +1417,7 @@ sub _make_lines {
         }
     }
 
-  }
+  } # end of recurring fee
 
   warn "\$setup is undefined" unless defined($setup);
   warn "\$recur is undefined" unless defined($recur);
@@ -1477,7 +1484,7 @@ sub _make_lines {
         'unitsetup' => sprintf('%.2f', $unitsetup),
         'recur'     => $recur,
         'unitrecur' => sprintf('%.2f', $unitrecur),
-        'quantity'  => $cust_pkg->quantity,
+        'quantity'  => $override_quantity || $cust_pkg->quantity,
         'details'   => \@details,
         'discounts' => [ @setup_discounts, @recur_discounts ],
         'hidden'    => $part_pkg->hidden,
index 6c0b655..2a9e869 100644 (file)
@@ -522,6 +522,8 @@ sub realtime_bop {
           ? uc($options{'paytype'})
           : uc($self->getfield('paytype')) || 'PERSONAL CHECKING';
 
+      $content{company} = $self->company if $self->company;
+
       if ( $content{account_type} =~ /BUSINESS/i && $self->company ) {
         $content{account_name} = $self->company;
       } else {
index 813e808..48d89a0 100644 (file)
@@ -6,6 +6,14 @@ use vars qw( %info );
 use DBI;
 #use FS::Record qw(qsearch qsearchs);
 
+tie our %query_style, 'Tie::IxHash', (
+  'simple'    => 'Simple (a single value for the recurring charge)',
+  'detailed'  => 'Detailed (multiple rows for invoice details)',
+);
+
+our @detail_cols = ( qw(amount format duration phonenum accountcode
+                        startdate regionname detail)
+                   );
 %info = (
   'name' => 'Base charge plus additional fees for external services from a configurable SQL query',
   'shortname' => 'External SQL query',
@@ -34,10 +42,17 @@ use DBI;
     'query' => { 'name' => 'SQL query',
                  'default' => '',
                },
+
+    'query_style' => {
+      'name' => 'Query output style',
+      'type' => 'select',
+      'select_options' => \%query_style,
+    },
+
   },
   'fieldorder' => [qw( recur_method cutoff_day ),
                    FS::part_pkg::prorate_Mixin::fieldorder,
-                   qw( datasrc db_username db_password query 
+                   qw( datasrc db_username db_password query query_style
                   )],
   'weight' => '58',
 );
@@ -53,6 +68,7 @@ sub calc_recur {
   my $self = shift;
   my($cust_pkg, $sdate, $details, $param ) = @_;
   my $price = 0;
+  my $quantity; # can be overridden; if not we use the default
 
   my $dbh = DBI->connect( map { $self->option($_) }
                               qw( datasrc db_username db_password )
@@ -67,9 +83,60 @@ sub calc_recur {
   ) {
     my $id = $cust_svc->svc_x->id;
     $sth->execute($id) or die $sth->errstr;
-    $price += $sth->fetchrow_arrayref->[0];
+
+    if ( $self->option('query_style') eq 'detailed' ) {
+
+      while (my $row = $sth->fetchrow_hashref) {
+        if (exists $row->{amount}) {
+          if ( $row->{amount} eq '' ) {
+            # treat as zero
+          } elsif ( $row->{amount} =~ /^\d+(?:\.\d+)?$/ ) {
+            $price += $row->{amount};
+          } else {
+            die "sql_external query returned non-numeric amount: $row->{amount}";
+          }
+        }
+        if (exists $row->{quantity}) {
+          $quantity ||= 0;
+          if ( $row->{quantity} eq '' ) {
+            # treat as zero
+          } elsif ( $row->{quantity} =~ /^\d+$/ ) {
+            $quantity += $row->{quantity};
+          } else {
+            die "sql_external query returned non-integer quantity: $row->{quantity}";
+          }
+        }
+
+        my $detail = FS::cust_bill_pkg_detail->new;
+        foreach my $field (@detail_cols) {
+          if (exists $row->{$field}) {
+            $detail->set($field, $row->{$field});
+          }
+        }
+        if (!$detail->get('detail')) {
+          die "sql_external query did not return detail description";
+          # or make something up?
+          # or just don't insert the detail?
+        }
+
+        push @$details, $detail;
+      } # while $row
+
+    } else {
+
+      # simple style: returns only a single value, which is the price
+      $price += $sth->fetchrow_arrayref->[0];
+
+    }
+  }
+  $price = sprintf('%.2f', $price);
+
+  # XXX probably shouldn't allow package quantity > 1 on these packages.
+  if ($cust_pkg->quantity > 1) {
+    warn "sql_external package #".$cust_pkg->pkgnum." has quantity > 1\n";
   }
 
+  $param->{'override_quantity'} = $quantity;
   $param->{'override_charges'} = $price;
   ($cust_pkg->quantity || 1) * $self->calc_recur_Common($cust_pkg,$sdate,$details,$param);
 }
diff --git a/bin/bulk_void b/bin/bulk_void
new file mode 100755 (executable)
index 0000000..a142818
--- /dev/null
@@ -0,0 +1,75 @@
+#!/usr/bin/perl
+
+use FS::Misc::Getopt;
+use FS::Record qw(qsearch qsearchs dbh);
+
+getopts('cpifXr:');
+my $dbh = dbh;
+$FS::UID::AutoCommit = 0;
+
+sub usage() {
+  "Usage: bulk_void  -s start -e end
+                  -r void_reason
+                  { -c | -p | -i }
+                  [ -X ]
+                  <user>
+-s, -e: date range (required)
+-r: void reason text (required)
+-c, -p, -i, -f: void credits, payments, invoices
+-X: commit changes
+";
+}
+
+if (!$opt{start} or !$opt{end} or !$opt{r}) {
+  die usage;
+}
+
+print "DRY RUN--changes will not be committed.\n" unless $opt{X};
+
+my $date = " WHERE _date >= $opt{start} AND _date <= $opt{end}";
+
+my %tables = (
+  c => 'cust_credit',
+  p => 'cust_pay',
+  i => 'cust_bill',
+);
+
+my $reason = $opt{r};
+
+foreach my $k (keys %tables) {
+  next unless $opt{$k};
+  my $table = $tables{$k};
+  debug("$table:");
+  my $done_count = 0;
+  my $error_count = 0;
+
+  my $cursor = FS::Cursor->new({
+    table     => $table,
+    extra_sql => $date,
+  });
+  my $error;
+  while (my $record = $cursor->fetch) {
+    $error = $record->void($reason);
+    if ( $error ) {
+      $error = "$table #" . $record->get($record->primary_key) . ": $error";
+      print "$error\n";
+      $error_count++;
+      if ( $opt{X} ) {
+        $dbh->rollback;
+        exit(1);
+      }
+    } else {
+      $done_count++;
+    }
+  }
+  print " $table voided: $done_count\n errors: $error_count\n";
+}
+
+if ( $opt{X} ) {
+  $dbh->commit;
+  print "Committed changes.\n";
+} else {
+  $dbh->rollback;
+  print "Reverted changes.\n";
+}
+
diff --git a/bin/rate-intl.import b/bin/rate-intl.import
new file mode 100755 (executable)
index 0000000..7eef587
--- /dev/null
@@ -0,0 +1,113 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV;
+use FS::Misc::Getopt;
+use FS::Record qw(qsearchs qsearch dbh);
+use FS::rate;
+use FS::rate_region;
+use FS::rate_prefix;
+use FS::rate_detail;
+
+getopts('');
+
+$FS::UID::AutoCommit = 0;
+my $dbh = dbh;
+
+my $file = shift or usage();
+open my $in, '<', $file or die "$file: $!\n";
+my $csv = Text::CSV->new({ binary => 1, auto_diag => 2 });
+# set header row
+$csv->column_names($csv->getline($in));
+
+my $error;
+
+my $granularity = 1;
+# default is to charge per second; edit this if needed
+
+while (my $row = $csv->getline_hr($in)) {
+  print $csv->string;
+
+  # ProfileKey is just a number
+  my $rate = qsearchs('rate', { 'ratename' => $row->{'ProfileKey'} });
+  if (!$rate) {
+    $rate = FS::rate->new({ 'ratename' => $row->{'ProfileKey'} });
+    $error = $rate->insert;
+    die $error if $error;
+  }
+
+  # DestinationId looks like "Country - City" or "Country - Mobile -
+  # Carrier" (or sometimes just "Country - Mobile").
+  my $region = qsearchs('rate_region', {
+      'regionname' => $row->{'DestinationId'}
+  });
+  if (!$region) {
+    $region = FS::rate_region->new({
+      'regionname' => $row->{'DestinationId'}
+    });
+    $error = $region->insert;
+    die $error if $error;
+  }
+
+  # Prefix strings found in there look like
+  # "e164:123-45-6nnnnnnn-"
+  # The first group of digits is the country code, any others are the
+  # prefix. Sometimes the nnnn's are NNNN's. The dashes are not guaranteed
+  # to be anywhere specific.
+  # Catchall prefixes start with "-A", which has a meaning like "match
+  # anything, but at a lower priority than a digit match".
+  # NANPA numbers use "1-", and for a catchall area code use "1-AAA-".
+  my $cc_long = $row->{CountryCodeLong};
+  $cc_long =~ /^e164:(\d+)-([\d-]*)A*-?n+-$/i;
+  my $countrycode = $1;
+  if (!$countrycode) { # totally legit reasons for this, e.g. 1-AAA-411
+    warn "can't parse number prefix:\n$cc_long\n";
+    next;
+  }
+  my $prefix = $2;
+  $prefix =~ s/-//g;
+
+  my %prefix = (
+      'regionnum'   => $region->regionnum,
+      'countrycode' => $countrycode,
+      'npa'         => $prefix,
+  );
+  my $rate_prefix = qsearchs('rate_prefix', \%prefix);
+  if (!$rate_prefix) {
+    $rate_prefix = FS::rate_prefix->new(\%prefix);
+    $error = $rate_prefix->insert;
+    die $error if $error;
+  }
+
+  # enough to identify the detail
+  my %detail = (
+    'ratenum'         => $rate->ratenum,
+    'dest_regionnum'  => $region->regionnum,
+    'cdrtypenum'      => '',
+    'ratetimenum'     => '',
+  );
+  my $dest_detail = qsearchs('rate_detail', \%detail);
+  # ProfileRate is 5 decimal places, same as rate_detail.min_charge
+  if (!$dest_detail) {
+    $dest_detail = FS::rate_detail->new({
+        %detail,
+        'min_included'    => 0,
+        'min_charge'      => $row->{ProfileRate},
+        'sec_granularity' => $granularity,
+    });
+    $error = $dest_detail->insert;
+  } else {
+    local $FS::Record::nowarn_identical = 1;
+    $dest_detail->set('min_charge' => $row->{ProfileRate});
+    $error = $dest_detail->replace;
+  }
+  die $error if $error;
+}
+dbh->commit;
+print "Finished.\n";
+
+
+sub usage {
+  die "Usage: rate-intl.import <user> <file>.csv\n\n";
+}
+
index 82ec50c..6961d18 100755 (executable)
@@ -97,8 +97,8 @@ if ( ($cgi->param('same') || '') eq 'Y' ) {
 # but explicitly avoid setting ship_ fields
 
 my $new = new FS::cust_main ( {
-  map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main')),
-  map { ( "ship_$_", '' ) } (FS::cust_main->location_fields)
+  (map { ( $_, scalar($cgi->param($_)) ) } (fields('cust_main'))),
+  (map { ( "ship_$_", '' ) } (FS::cust_main->location_fields))
 } );
 
 $new->invoice_noemail( ($cgi->param('invoice_email') eq 'Y') ? '' : 'Y' );
index 7d66e7c..d001bfa 100644 (file)
@@ -1,10 +1,10 @@
-% my $error = shift;
+% my $error = $_[0];
 % $m->notes('error', $error);
 <& /elements/header.html, mt("Error") &>
 
 % while (@_) {
 
-<P><FONT SIZE="+1" COLOR="#ff0000"><% $error |h %></FONT>
+<P><FONT SIZE="+1" COLOR="#ff0000"><% shift |h %></FONT>
 
 %}
 % $m->flush_buffer();
index 6f308e0..79c3c2c 100644 (file)
@@ -6,13 +6,10 @@
 
 <INPUT TYPE="hidden" NAME="custnum" VALUE="<% $cgi->param('custnum') |h %>">
 
-% my @sched_item = qsearch('sched_item', { 'disabled' => '', });
-% my @username = map $_->access_user->username, @sched_item;
-% foreach my $username (@username) { 
-  <INPUT TYPE="hidden" NAME="username" VALUE="<% $username |h %>">
-% }
-
-Length: 
+<TABLE>
+<TR>
+<TD STYLE="text-align: right">Length:</TD>
+<TD>
 <SELECT NAME="LengthMin">
 %  for ( my $hours = .5; $hours < 10.5; $hours += .5 ) {
 %    my $min = $hours * 60;
@@ -21,9 +18,26 @@ Length:
      ><% $hours %> hour<% $hours > 1 ? 's' : '' %>
 %  }
 </SELECT>
-<BR>
-<BR>
+</TD>
+</TR>
+
+% my @sched_item = qsearch('sched_item', { 'disabled' => '', });
+% my @username = map $_->access_user->username, @sched_item;
+
+<TR>
+<TD STYLE="text-align: right">Installer:</TD>
+<TD>
+<SELECT NAME="username" ID="username_select" MULTIPLE>
+% foreach my $username (@username) { 
+  <OPTION SELECTED><% $username |h %></OPTION>
+% }
+</SELECT>
+</TD>
+</TR>
 
+</TABLE>
+
+<BR>
 <INPUT TYPE="submit" VALUE="Schedule appointment">
 
 </FORM>
index a475786..dcfcc0b 100644 (file)
@@ -164,7 +164,10 @@ if ( $cgi->param('save') ) {
   #false laziness w/FS:;cust_main::realtime_bop - check both to make sure
   # working correctly
   if ( $payby eq 'CARD' &&
-       grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save') ) {
+       ( (grep { $_ eq cardtype($payinfo) } $conf->config('cvv-save')) 
+         || $conf->exists('business-onlinepayment-verification') 
+       )
+  ) {
     $new->set( 'paycvv' => $paycvv );
   } else {
     $new->set( 'paycvv' => '');
index 4e624eb..aa8c079 100755 (executable)
@@ -704,12 +704,20 @@ sub address2search {
       or errorpage(emt("Illegal address2"));
   my $address2 = $1;
 
-  push @cust_main, qsearch( 'cust_main',
-                            { 'address2' => { 'op'    => 'ILIKE',
-                                              'value' => $address2 } } );
-  push @cust_main, qsearch( 'cust_main',
-                            { 'ship_address2' => { 'op'    => 'ILIKE',
-                                                   'value' => $address2 } } );
+  # matching at the start or end of an address, but not in the middle
+  my @where;
+  foreach my $toggle (0,1) {
+    push @where, 'LOWER(cust_location.address2) LIKE LOWER('
+                 . dbh->quote($toggle ? $address2 . '%' : '%' . $address2)
+                 . ')';
+  }
+
+  push @cust_main, qsearch({
+    'debug'     => 1,
+    'table'     => 'cust_main',
+    'addl_from' => 'JOIN cust_location ON (cust_location.locationnum IN (cust_main.bill_locationnum, cust_main.ship_locationnum))',
+    'extra_sql' => 'WHERE ' . join(' OR ',@where),
+  });
 
   \@cust_main;
 }
index cb13b78..9e9f590 100644 (file)
@@ -24,7 +24,7 @@
       label         => mt('Customer Class'),
       field         => 'cust_classnum',
       multiple      => 1,
-     'pre_options'  => [ '' => emt('(none)') ],
+     'pre_options'  => [ '0' => emt('(none)') ],
      'all_selected' => 1,
   &>