Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Tue, 17 Jul 2018 01:43:48 +0000 (18:43 -0700)
committerIvan Kohler <ivan@freeside.biz>
Tue, 17 Jul 2018 01:43:48 +0000 (18:43 -0700)
46 files changed:
FS/FS/AccessRight.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/Conf.pm
FS/FS/IP_Mixin.pm
FS/FS/Misc/FixIPFormat.pm [new file with mode: 0644]
FS/FS/Record.pm
FS/FS/Template_Mixin.pm
FS/FS/Upgrade.pm
FS/FS/addr_block.pm
FS/FS/agent.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Billing_Batch.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_pkg.pm
FS/FS/part_event/Condition/agent.pm
FS/FS/part_event/Condition/cust_birthdate.pm [new file with mode: 0644]
FS/FS/part_event_condition_option.pm
FS/FS/part_pkg/flat.pm
FS/FS/pay_batch/RBC.pm
FS/FS/svc_IP_Mixin.pm
FS/FS/svc_broadband.pm
FS/FS/svc_circuit.pm
FS/FS/svc_hardware.pm
FS/FS/svc_pbx.pm
FS/FS/tower_sector.pm
httemplate/browse/discount.html
httemplate/edit/agent_payment_gateway.html
httemplate/edit/cust_main/name.html
httemplate/edit/cust_main/stateid.html
httemplate/edit/cust_refund.cgi
httemplate/edit/process/cust_refund.cgi
httemplate/elements/cust_payby_new.html [new file with mode: 0644]
httemplate/elements/link-replace_element_text.html [new file with mode: 0644]
httemplate/elements/tr-select-cust_payby.html
httemplate/elements/tr-select-router_block_ip.html
httemplate/misc/download-batch.cgi
httemplate/misc/payment.cgi
httemplate/misc/process/payment.cgi
httemplate/misc/xmlhttp-free_addresses_in_block.json.html [new file with mode: 0644]
httemplate/search/e911.html
httemplate/search/prospect_main.html
httemplate/view/cust_main/contacts.html
httemplate/view/cust_main/menu.html
httemplate/view/prospect_main.html

index 471e32a..1b581b2 100644 (file)
@@ -156,6 +156,8 @@ tie my %rights, 'Tie::IxHash',
     'View package definition costs', #NEWNEW
     'Change package start date',
     'Change package contract end date',
+    'Unmask customer DL',
+    'Unmask customer SSN',
   ],
   
   ###
@@ -509,4 +511,3 @@ L<FS::access_right>, L<FS::access_group>, L<FS::access_user>
 =cut
 
 1;
-
index e4fef95..263b311 100644 (file)
@@ -630,6 +630,8 @@ sub customer_info_short {
     for (@cust_main_editable_fields) {
       $return{$_} = $cust_main->get($_);
     }
+    $return{$_} = $cust_main->masked($_) for qw/ss stateid/;
+
     #maybe a little more expensive, but it should be cached by now
     for (@location_editable_fields) {
       $return{$_} = $cust_main->bill_location->get($_)
@@ -1731,20 +1733,34 @@ sub update_payby {
                            })
     or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} };
 
+  my $cust_main = qsearchs( 'cust_main', {custnum => $cust_payby->custnum} )
+    or return { 'error' => 'unknown custnum '.$cust_payby->custnum };
+
   foreach my $field (
     qw( weight payby payinfo paycvv paydate payname paystate paytype payip )
   ) {
     next unless exists($p->{$field});
     $cust_payby->set($field,$p->{$field});
   }
+  $cust_payby->set( 'paymask' => $cust_payby->mask_payinfo );
 
-  my $error = $cust_payby->replace;
-  if ( $error ) {
-    return { 'error' => $error };
-  } else {
-    return { 'custpaybynum' => $cust_payby->custpaybynum };
+  # Update column if given a value, and the given value wasn't
+  # the value generated by $cust_main->masked($column);
+  $cust_main->set( $_, $p->{$_} )
+    for grep{ $p->{$_} !~ /^x/i; }
+        grep{ exists $p->{$_} }
+        qw/ss stateid/;
+
+  # Perform updates within a transaction
+  local $FS::UID::AutoCommit = 0;
+
+  if ( my $error = $cust_payby->replace || $cust_main->replace ) {
+    dbh->rollback;
+    return { error => $error };
   }
-  
+
+  dbh->commit;
+  return { custpaybynum => $cust_payby->custpaybynum };
 }
 
 sub verify_payby {
@@ -3900,4 +3916,3 @@ sub _custoragent_session_custnum {
 }
 
 1;
-
index 76ba306..bac6a76 100644 (file)
@@ -2788,6 +2788,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'manual_process-single_invoice_amount',
+    'section'     => 'deprecated',
+    'description' => 'When entering manual credit card and ACH payments, amount will not autofill if the customer has more than one open invoice',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'manual_process-pkgpart',
     'section'     => 'payments',
     'description' => 'Package to add to each manual credit card and ACH payment entered by employees from the backend.  WARNING: Although recently permitted to US merchants in general, specific consumer protection laws may prohibit or restrict this practice in California, Colorado, Connecticut, Florda, Kansas, Maine, Massachusetts, New York, Oklahome, and Texas. Surcharging is also generally prohibited in most countries outside the US, AU and UK.',
index 3ec7693..fc3a014 100644 (file)
@@ -94,6 +94,15 @@ sub ip_check {
     $self->ip_addr('');
   }
 
+  # strip user-entered leading 0's from IPv4 addresses
+  # Parsers like NetAddr::IP interpret them as octal instead of decimal
+  $self->ip_addr(
+    join( '.', (
+        map{ int($_) }
+        split( /\./, $self->ip_addr )
+    ))
+  ) if $self->ip_addr =~ /\./ && $self->ip_addr =~ /[\.^]0/;
+
   if ( $self->ip_addr
        and !$self->router
        and $self->conf->exists('auto_router') ) {
@@ -264,18 +273,22 @@ sub router {
   FS::router->by_key($self->routernum);
 }
 
-=item used_addresses [ BLOCK ]
+=item used_addresses [ FS::addr_block ]
+
+Returns a list of all addresses in use within the given L<FS::addr_block>.
 
-Returns a list of all addresses (in BLOCK, or in all blocks)
-that are in use.  If called as an instance method, excludes 
-that instance from the search.
+If called as an instance method, excludes that instance from the search.
 
 =cut
 
 sub used_addresses {
-  my $self = shift;
-  my $block = shift;
-  return ( map { $_->_used_addresses($block, $self) } @subclasses );
+  my ($self, $block) = @_;
+
+  (
+    $block->ip_gateway ? $block->ip_gateway : (),
+    $block->NetAddr->broadcast->addr,
+    map { $_->_used_addresses($block, $self ) } @subclasses
+  );
 }
 
 sub _used_addresses {
diff --git a/FS/FS/Misc/FixIPFormat.pm b/FS/FS/Misc/FixIPFormat.pm
new file mode 100644 (file)
index 0000000..3f9a19b
--- /dev/null
@@ -0,0 +1,124 @@
+package FS::Misc::FixIPFormat;
+use strict;
+use warnings;
+use FS::Record qw(dbh qsearchs);
+use FS::upgrade_journal;
+
+=head1 NAME
+
+FS::Misc::FixIPFormat - Functions to repair bad IP address input
+
+=head1 DESCRIPTION
+
+Provides functions for freeside_upgrade to check IP address storage for
+user-entered leading 0's in IP addresses.  When read from database, NetAddr::IP
+would treat the number as octal isntead of decimal.  If a user entered
+10.0.0.052, this may get invisibly translated to 10.0.0.42 when exported.
+Base8:52 = Base0:42
+
+Tied to freeside_upgrade with journal name TABLE__fixipformat
+
+see: RT# 80555
+
+=head1 SYNOPSIS
+
+Usage:
+
+    # require, not use - this module is only run once
+    require FS::Misc::FixIPFormat;
+
+    my $error = FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'svc_broadband', 'svcnum', 'ip_addr'
+    );
+    die "oh no!" if $error;
+
+=head2 fix_bad_addresses_in_table TABLE, ID_COLUMN, IP_COLUMN
+
+$error = fix_bad_addresses_in_table( 'svc_broadband', 'svcnum', 'ip_addr' );
+
+=cut
+
+sub fix_bad_addresses_in_table {
+  my ( $table ) = @_;
+  return if FS::upgrade_journal->is_done("${table}__fixipformat");
+  for my $id ( find_bad_addresses_in_table( @_ )) {
+    if ( my $error = fix_ip_for_record( $id, @_ )) {
+      die "fix_bad_addresses_in_table(): $error";
+    }
+  }
+  FS::upgrade_journal->set_done("${table}__fixipformat");
+  0;
+}
+
+=head2 find_bad_addresses_in_table TABLE, ID_COLUMN, IP_COLUMN
+
+@id = find_bad_addresses_in_table( 'svc_broadband', 'svcnum', 'ip_addr' );
+
+=cut
+
+sub find_bad_addresses_in_table {
+  my ( $table, $id_col, $ip_col ) = @_;
+  my @fix_ids;
+
+  # using DBI directly for performance
+  my $sql_statement = "
+    SELECT $id_col, $ip_col
+    FROM $table
+    WHERE $ip_col IS NOT NULL
+  ";
+  my $sth = dbh->prepare( $sql_statement ) || die "SQL ERROR ".dbh->errstr;
+  $sth->execute || die "SQL ERROR ".dbh->errstr;
+  while ( my $row = $sth->fetchrow_hashref ) {
+    push @fix_ids, $row->{ $id_col }
+      if $row->{ $ip_col } =~ /[\.^]0\d/;
+  }
+  @fix_ids;
+}
+
+=head2 fix_ip_for_record ID, TABLE, ID_COLUMN, IP_COLUMN
+
+Attempt to strip the leading 0 from a stored IP address record.  If
+the corrected IP address would be a duplicate of another record in the
+same table, thow an exception.
+
+$error = fix_ip_for_record( 1001, 'svc_broadband', 'svcnum', 'ip_addr', );
+
+=cut
+
+sub fix_ip_for_record {
+  my ( $id, $table, $id_col, $ip_col ) = @_;
+
+  my $row = qsearchs($table, {$id_col => $id})
+    || die "Error finding $table record for id $id";
+
+  my $ip = $row->getfield( $ip_col );
+  my $fixed_ip = join( '.',
+    map{ int($_) }
+    split( /\./, $ip )
+  );
+
+  return undef unless $ip ne $fixed_ip;
+
+  if ( my $dupe_row = qsearchs( $table, {$ip_col => $fixed_ip} )) {
+    if ( $dupe_row->getfield( $id_col ) != $row->getfield( $id_col )) {
+      # Another record in the table has this IP address
+      # Eg one ip is provisioned as 10.0.0.51 and another is
+      # provisioned as 10.0.0.051.  Cannot auto-correct by simply
+      # trimming leading 0.  Die, let support decide how to fix.
+
+      die "Invalid IP address could not be auto-corrected - ".
+          "($table - $id_col = $id, $ip_col = $ip) ".
+           "colission with another reocrd - ".
+           "($table - $id_col = ".$dupe_row->getfield( $id_col )." ".
+           "$ip_col = ",$dupe_row->getfield( $ip_col )." ) - ".
+         "The entry must be corrected to continue";
+    }
+  }
+
+  warn "Autocorrecting IP address problem for ".
+       "($table - $id_col = $id, $ip_col = $ip) $fixed_ip\n";
+  $row->setfield( $ip_col, $fixed_ip );
+  $row->replace;
+}
+
+1;
index 5de4ca7..cf8ec4d 100644 (file)
@@ -2881,11 +2881,9 @@ to 127.0.0.1.
 sub ut_ip {
   my( $self, $field ) = @_;
   $self->setfield($field, '127.0.0.1') if $self->getfield($field) eq '::1';
-  $self->getfield($field) =~ /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
-    or return "Illegal (IP address) $field: ". $self->getfield($field);
-  for ( $1, $2, $3, $4 ) { return "Illegal (IP address) $field" if $_ > 255; }
-  $self->setfield($field, "$1.$2.$3.$4");
-  '';
+  return "Illegal (IP address) $field: ".$self->getfield($field)
+    unless $self->getfield($field) =~ /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
+  $self->ut_ip46($field);
 }
 
 =item ut_ipn COLUMN
@@ -2913,7 +2911,17 @@ Check/untaint IPv4 or IPv6 address.
 
 sub ut_ip46 {
   my( $self, $field ) = @_;
-  my $ip = NetAddr::IP->new($self->getfield($field))
+  my $ip_addr = $self->getfield( $field );
+
+  # strip user-entered leading 0's from IPv4 addresses
+  # Parsers like NetAddr::IP interpret them as octal instead of decimal
+  $ip_addr = join( '.', (
+        map{ int($_) }
+        split( /\./, $ip_addr )
+    )
+  ) if $ip_addr =~ /\./ && $ip_addr =~ /[\.^]0/;
+
+  my $ip = NetAddr::IP->new( $ip_addr )
     or return "Illegal (IP address) $field: ".$self->getfield($field);
   $self->setfield($field, lc($ip->addr));
   return '';
index 6463125..1b3df00 100644 (file)
@@ -1346,27 +1346,36 @@ sub print_generic {
   #$tax_section->{'summarized'} = ''; #why? $summarypage && !$tax_weight ? 'Y' : '';
   #$tax_section->{'sort_weight'} = $tax_weight;
 
+  my $invoice_sections_with_taxes = $conf->config_bool(
+    'invoice_sections_with_taxes', $cust_main->agentnum
+  );
+
   foreach my $tax ( @items_tax ) {
 
-    $taxtotal += $tax->{'amount'};
 
     my $description = &$escape_function( $tax->{'description'} );
     my $amount      = sprintf( '%.2f', $tax->{'amount'} );
 
     if ( $multisection ) {
+      if ( !$invoice_sections_with_taxes ) {
 
-      push @detail_items, {
-        ext_description => [],
-        ref          => '',
-        quantity     => '',
-        description  => $description,
-        amount       => $money_char. $amount,
-        product_code => '',
-        section      => $tax_section,
-      };
+        $taxtotal += $tax->{'amount'};
+
+        push @detail_items, {
+          ext_description => [],
+          ref          => '',
+          quantity     => '',
+          description  => $description,
+          amount       => $money_char. $amount,
+          product_code => '',
+          section      => $tax_section,
+        };
 
+      }
     } else {
 
+      $taxtotal += $tax->{'amount'};
+
       push @total_items, {
         'total_item'   => $description,
         'total_amount' => $other_money_char. $amount,
@@ -1387,6 +1396,14 @@ sub print_generic {
       $other_money_char. sprintf('%.2f', $self->charged - $taxtotal );
 
     if ( $multisection ) {
+
+      if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
+        # If all tax items are displayed in location/category sections,
+        # remove the empty tax section
+        @sections = grep{ $_ ne $tax_section } @sections
+          unless grep{ $_->{section} eq $tax_section } @detail_items;
+      }
+
       if ( $taxtotal > 0 ) {
         # there are taxes, so prepare the section to be displayed.
         # $taxtotal already includes any line items that were already in the
@@ -1400,18 +1417,12 @@ sub print_generic {
         $tax_section->{'description'} = $self->mt($tax_description);
         $tax_section->{'summarized'} = '';
 
-        if ( $conf->config_bool('invoice_sections_with_taxes', $cust_main->agentnum) ) {
-
-          # remove tax section if taxes are itemized within other sections
-          @sections = grep{ $_ ne $tax_section } @sections;
+        # append tax section unless it's already there
+        push @sections, $tax_section
+          unless grep {$_ eq $tax_section} @sections;
 
-        } elsif ( !grep $tax_section, @sections ) {
-
-          # append it if it's not already there
-          push @sections, $tax_section;
-          push @summary_subtotals, $tax_section;
-
-        }
+        push @summary_subtotals, $tax_section
+          unless grep {$_ eq $tax_section} @summary_subtotals;
 
       }
     } else {
@@ -2262,8 +2273,7 @@ sub generate_email {
       warn "$me generating plain text invoice"
         if $DEBUG;
 
-      # 'print_text' argument is no longer used
-      @text = map Encode::encode_utf8($_), $self->print_text(\%args);
+      @text = $self->print_text(\%args);
 
     } else {
 
@@ -2279,7 +2289,11 @@ sub generate_email {
     'Encoding'    => 'quoted-printable',
     'Charset'     => 'UTF-8',
     #'Encoding'    => '7bit',
-    'Data'        => \@text,
+    'Data'        => [
+      map
+        { Encode::encode('UTF-8', $_, Encode::FB_WARN | Encode::LEAVE_SRC ) }
+        @text
+    ],
     'Disposition' => 'inline',
   );
 
@@ -2358,7 +2372,11 @@ sub generate_email {
                          '    </title>',
                          '  </head>',
                          '  <body bgcolor="#e8e8e8">',
-                         Encode::encode_utf8($html),
+                         Encode::encode(
+                           'UTF-8',
+                           $html,
+                           Encode::FB_WARN | Encode::LEAVE_SRC
+                         ),
                          '  </body>',
                          '</html>',
                        ],
@@ -3121,7 +3139,9 @@ sub _items_fee {
   my @cust_bill_pkg = grep { $_->feepart } $self->cust_bill_pkg;
   my $escape_function = $options{escape_function};
 
-  my $locale = $self->cust_main->locale;
+  my $locale = $self->cust_main
+             ? $self->cust_main->locale
+             : $self->prospect_main->locale;
 
   my @items;
   foreach my $cust_bill_pkg (@cust_bill_pkg) {
@@ -3134,16 +3154,30 @@ sub _items_fee {
       warn "fee definition not found for line item #".$cust_bill_pkg->billpkgnum."\n";
       next;
     }
-    if ( exists($options{section}) and exists($options{section}{category}) )
-    {
-      my $categoryname = $options{section}{category};
-      # then filter for items that have that section
-      if ( $part_fee->categoryname ne $categoryname ) {
-        warn "skipping fee '".$part_fee->itemdesc."'--not in section $categoryname\n" if $DEBUG;
-        next;
-      }
-    } # otherwise include them all in the main section
-    # XXX what to do when sectioning by location?
+
+    # If _items_fee is called while building a sectioned invoice,
+    #   - invoice_sections_method: category
+    #     Skip fee records that do not match the section category.
+    #   - invoice_sections_method: location
+    #     Skip fee records always for location sections.
+    #     The fee records will be presented in the tax/fee section instead.
+    if (
+      exists( $options{section} )
+      and
+      (
+        (
+          exists( $options{section}{category} )
+          and
+          $part_fee->categoryname ne $options{section}{category}
+        )
+        or
+        exists( $options{section}{location})
+      )
+    ) {
+      warn "skipping fee '".$part_fee->itemdesc.
+           "'--not in section $options{section}{category}\n" if $DEBUG;
+      next;
+    }
 
     my @ext_desc;
     my %base_invnums; # invnum => invoice date
@@ -3171,6 +3205,7 @@ sub _items_fee {
 
     push @items,
       { feepart     => $cust_bill_pkg->feepart,
+        billpkgnum  => $cust_bill_pkg->billpkgnum,
         amount      => sprintf('%.2f', $cust_bill_pkg->setup + $cust_bill_pkg->recur),
         description => $desc,
         pkg_tax     => \@pkg_tax,
index 92a056c..aebfc29 100644 (file)
@@ -504,6 +504,23 @@ sub upgrade_data {
     #'compliance solutions' -> 'compliance_solutions'
     'tax_rate' => [],
     'tax_rate_location' => [],
+
+    #upgrade part_event_condition_option agentnum to a multiple hash value
+    'part_event_condition_option' =>[],
+
+    #fix ip format
+    'svc_circuit' => [],
+
+    #fix ip format
+    'svc_hardware' => [],
+
+    #fix ip format
+    'svc_pbx' => [],
+
+    #fix ip format
+    'tower_sector' => [],
+
+
   ;
 
   \%hash;
@@ -714,4 +731,3 @@ Sure.
 =cut
 
 1;
-
index ba0f61d..5fd64bf 100755 (executable)
@@ -207,6 +207,27 @@ sub cidr {
   $self->NetAddr->cidr;
 }
 
+=item free_addrs
+
+Returns an aref sorted list of free addresses in the block.
+
+=cut
+
+sub free_addrs {
+  my $self = shift;
+
+  my %used_addr_map =
+    map {$_ => 1}
+    FS::IP_Mixin->used_addresses($self),
+    FS::Conf->new()->config('exclude_ip_addr');
+
+  [
+    grep { !exists $used_addr_map{$_} }
+    map { $_->addr }
+    $self->NetAddr->hostenum
+  ];
+}
+
 =item next_free_addr
 
 Returns a NetAddr::IP object corresponding to the first unassigned address 
@@ -416,4 +437,3 @@ now because that's the smallest block that makes any sense at all.
 =cut
 
 1;
-
index 8107093..8aff96a 100644 (file)
@@ -294,8 +294,8 @@ sub payment_gateway {
     }
   }
 
-  my $cardtype_search = "AND cardtype != 'ACH'";
-  $cardtype_search = "AND cardtype = 'ACH'" if $options{method} eq 'ECHECK';
+  my $cardtype_search = "AND ( cardtype IS NULL OR cardtype <> 'ACH')";
+  $cardtype_search = "AND ( cardtype IS NULL OR cardtype = 'ACH' )" if $options{method} eq 'ECHECK';
 
   my $override =
       qsearchs({
index f6b40f6..1262c38 100644 (file)
@@ -1881,7 +1881,29 @@ sub _pkg_tax_list {
   #   Duplicates can be identified by billpkgtaxlocationnum column.
 
   my $self = shift;
-  return unless $self->pkgnum;
+
+  my $search_selector;
+  if ( $self->pkgnum ) {
+
+    # For taxes applied to normal billing items
+    $search_selector =
+      ' cust_bill_pkg_tax_location.pkgnum = '
+      . dbh->quote( $self->pkgnum );
+
+  } elsif ( $self->feepart ) {
+
+    # For taxes applied to fees, when the fee is not attached to a package
+    # i.e. late fees, billing events fees
+    $search_selector =
+      ' cust_bill_pkg_tax_location.taxable_billpkgnum = '
+      . dbh->quote( $self->billpkgnum );
+
+  } else {
+    warn "_pkg_tax_list() unhandled case breaking taxes into sections";
+    warn "_pkg_tax_list() $_: ".$self->$_
+      for qw(pkgnum billpkgnum feepart);
+    return;
+  }
 
   map +{
       billpkgtaxlocationnum => $_->billpkgtaxlocationnum,
@@ -1907,7 +1929,7 @@ sub _pkg_tax_list {
       ' WHERE '.
       ' cust_bill_pkg.invnum = ' . dbh->quote( $self->invnum ) .
       ' AND '.
-      ' cust_bill_pkg_tax_location.pkgnum = ' . dbh->quote( $self->pkgnum ),
+      $search_selector
   });
 
 }
index 7c9868d..3bffa3a 100644 (file)
@@ -3922,6 +3922,27 @@ sub name {
   $name;
 }
 
+=item batch_payment_payname
+
+Returns a name string for this customer, either "cust_batch_payment->payname" or "First Last" or "Company,
+based on if a company name exists and is the account being used a business account.
+
+=cut
+
+sub batch_payment_payname {
+  my $self = shift;
+  my $cust_pay_batch = shift;
+  my $name;
+
+  if ($cust_pay_batch->{Hash}->{payby} eq "CARD") { $name = $cust_pay_batch->payname; }
+  else { $name = $self->first .' '. $self->last; }
+
+  $name = $self->company
+    if (($cust_pay_batch->{Hash}->{paytype} eq "Business checking" || $cust_pay_batch->{Hash}->{paytype} eq "Business savings") && $self->company);
+
+  $name;
+}
+
 =item service_contact
 
 Returns the L<FS::contact> object for this customer that has the 'Service'
index 51b49e4..9cf9b56 100644 (file)
@@ -1052,10 +1052,8 @@ sub _make_lines {
         }
     }
 
-    if ($cust_pkg->waive_setup && $part_pkg->plan eq "prorate") {
-      $lineitems++;
-      $setup = 0 if $part_pkg->prorate_setup($cust_pkg, $time);
-    }
+    $lineitems++
+    if $cust_pkg->waive_setup && $part_pkg->can('prorate_setup') && $part_pkg->prorate_setup($cust_pkg, $time);
 
     if ( $cust_pkg->get('setup') ) {
       # don't change it
index 74748ec..eb66436 100644 (file)
@@ -114,7 +114,7 @@ sub batch_card {
   } );
 
   foreach (qw( address1 address2 city state zip country latitude longitude
-               payby payinfo paydate payname paycode ))
+               payby payinfo paydate payname paycode paytype ))
   {
     $options{$_} = '' unless exists($options{$_});
   }
@@ -147,7 +147,7 @@ sub batch_card {
     'payname'  => $options{payname}  || $cust_payby->payname,
     'paytype'  => $options{paytype}  || $cust_payby->paytype,
     'amount'   => $amount,                         # consolidating
-    'paycode'  => $options{paycode}  || $cust_payby->paycode,
+    'paycode'  => $options{paycode}  || '',
   } );
   
   $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
index 78f2cbc..f4d87dd 100644 (file)
@@ -1531,7 +1531,7 @@ sub realtime_refund_bop {
  
     my $payment_gateway =
       $self->agent->payment_gateway( 'method'  => $options{method} );
-    my( $processor, $login, $password, $namespace ) =
+    ( $processor, $login, $password, $namespace ) =
       map { my $method = "gateway_$_"; $payment_gateway->$method }
         qw( module username password namespace );
 
index 07c5a47..d00f039 100644 (file)
@@ -2487,6 +2487,12 @@ sub change {
     $keep_dates = 0;
     $hash{'last_bill'} = '';
     $hash{'bill'} = '';
+
+    # Optionally, carry over the next bill date from the changed cust_pkg
+    # so an invoice isn't generated until the customer's usual billing date
+    if ( $self->part_pkg->option('prorate_defer_change_bill', 1) ) {
+      $hash{bill} = $self->bill;
+    }
   }
 
   if ( $keep_dates ) {
index bdd4e12..917cf46 100644 (file)
@@ -13,7 +13,7 @@ sub description {
 
 sub option_fields {
   (
-    'agentnum'   => { label=>'Agent', type=>'select-agent', },
+    'agentnum'   => { label=>'Agent', type=>'select-agent', multiple => '1' },
   );
 }
 
@@ -22,16 +22,15 @@ sub condition {
 
   my $cust_main = $self->cust_main($object);
 
-  my $agentnum = $self->option('agentnum');
-
-  $cust_main->agentnum == $agentnum;
+  my $hashref = $self->option('agentnum') || {};
+  grep $hashref->{ $_->agentnum }, $cust_main->agent;
 
 }
 
 sub condition_sql {
   my( $class, $table, %opt ) = @_;
 
-  "cust_main.agentnum = " . $class->condition_sql_option_integer('agentnum', $opt{'driver_name'});
+  "cust_main.agentnum IN " . $class->condition_sql_option_option_integer('agentnum', $opt{'driver_name'});
 }
 
 1;
diff --git a/FS/FS/part_event/Condition/cust_birthdate.pm b/FS/FS/part_event/Condition/cust_birthdate.pm
new file mode 100644 (file)
index 0000000..874e3ac
--- /dev/null
@@ -0,0 +1,64 @@
+package FS::part_event::Condition::cust_birthdate;
+use base qw( FS::part_event::Condition );
+use strict;
+use warnings;
+use DateTime;
+
+=head2 NAME
+
+FS::part_event::Condition::cust_birthdate
+
+=head1 DESCRIPTION
+
+Billing event triggered by the time until the customer's next
+birthday (cust_main.birthdate)
+
+=cut
+
+sub description {
+  'Customer birthdate occurs within the given timeframe';
+}
+
+sub option_fields {
+  (
+    timeframe => {
+      label => 'Timeframe',
+      type   => 'freq',
+      value  => '1m',
+    }
+  );
+}
+
+sub condition {
+  my( $self, $object, %opt ) = @_;
+  my $cust_main = $self->cust_main($object);
+
+  my $birthdate = $cust_main->birthdate || return 0;
+
+  my %timeframe;
+  if ( $self->option('timeframe') =~ /(\d+)([mwdh])/ ) {
+    my $k = {qw|m months w weeks d days h hours|}->{$2};
+    $timeframe{ $k } = $1;
+  } else {
+    die "Unparsable timeframe given: ".$self->option('timeframe');
+  }
+
+  my $ck_dt = DateTime->from_epoch( epoch => $opt{time} );
+  my $bd_dt = DateTime->from_epoch( epoch => $birthdate );
+
+  # Find the birthday for this calendar year.  If customer birthday
+  # has already passed this year, find the birthday for next year.
+  my $next_bd_dt = DateTime->new(
+    month => $bd_dt->month,
+    day   => $bd_dt->day,
+    year  => $ck_dt->year,
+  );
+  $next_bd_dt->add( years => 1 )
+    if DateTime->compare( $next_bd_dt, $ck_dt ) == -1;
+
+  # Does next birthday occur between now and specified duration?
+  $ck_dt->add( %timeframe );
+  DateTime->compare( $next_bd_dt, $ck_dt ) != 1 ? 1 : 0;
+}
+
+1;
index 3256dc0..f1d1b6a 100644 (file)
@@ -138,6 +138,39 @@ sub optionvalue {
   }
 }
 
+use FS::upgrade_journal;
+sub _upgrade_data { #class method
+  my ($class, %opts) = @_;
+
+  # migrate part_event_condition_option agentnum to part_event_condition_option_option agentnum
+  unless ( FS::upgrade_journal->is_done('agentnum_to_hash') ) {
+
+    foreach my $condition_option (qsearch('part_event_condition_option', { optionname => 'agentnum', })) {
+      my %options;
+      my $optionvalue = $condition_option->get("optionvalue");
+      if ($optionvalue eq 'HASH' ) { next; }
+      elsif ($optionvalue eq '') {
+        foreach my $agent (qsearch('agent', {})) {
+          $options{$agent->agentnum} = '1';
+        }
+
+      }
+      else {
+        $options{$optionvalue} = '1';
+      }
+
+      $condition_option->optionvalue(ref(\%options));
+      my $error = $condition_option->replace(\%options);
+      die $error if $error;
+
+    }
+
+    FS::upgrade_journal->set_done('agentnum_to_hash');
+
+  }
+
+}
+
 =back
 
 =head1 SEE ALSO
index 6fd9c7d..c06328b 100644 (file)
@@ -57,6 +57,12 @@ tie my %contract_years, 'Tie::IxHash', (
                                     'the customer\'s next bill date',
                           'type' => 'checkbox',
                         },
+    'prorate_defer_change_bill' => {
+                          'name' => 'When synchronizing, defer bill for '.
+                                    'package changes until the customer\'s '.
+                                    'next bill date',
+                          'type' => 'checkbox',
+                        },
     'prorate_round_day' => {
                           'name' => 'When synchronizing, round the prorated '.
                                     'period',
@@ -87,7 +93,8 @@ tie my %contract_years, 'Tie::IxHash', (
   },
   'fieldorder' => [ qw( recur_temporality 
                         start_1st
-                        sync_bill_date prorate_defer_bill prorate_round_day
+                        sync_bill_date prorate_defer_bill
+                        prorate_defer_change_bill prorate_round_day
                         suspend_bill unsuspend_adjust_bill
                         bill_recur_on_cancel
                         bill_suspend_as_cancel
index 1577a7f..3d1d98b 100644 (file)
@@ -175,9 +175,7 @@ $name = 'RBC';
     }
 
     ## set custname to business name if business checking or savings account is used otherwise leave as first and last name.
-    my $custname = $cust_pay_batch->cust_main->first . ' ' . $cust_pay_batch->cust_main->last;
-    $custname = $cust_pay_batch->cust_main->company
-      if (($cust_pay_batch->{Hash}->{paytype} eq "Business checking" || $cust_pay_batch->{Hash}->{paytype} eq "Business savings") && $cust_pay_batch->cust_main->company);
+    my $custname = $cust_pay_batch->cust_main->batch_payment_payname($cust_pay_batch);
 
     $i++;
 
@@ -230,5 +228,10 @@ $name = 'RBC';
   },
 );
 
+## this format can handle credit transactions
+sub can_handle_credits {
+  1;
+}
+
 1;
 
index d282611..4c2180e 100644 (file)
@@ -3,7 +3,8 @@ use base 'FS::IP_Mixin';
 
 use strict;
 use NEXT;
-use FS::Record qw(qsearchs qsearch);
+use Carp qw(croak carp);
+use FS::Record qw(qsearchs qsearch dbh);
 use FS::Conf;
 use FS::router;
 use FS::part_svc_router;
@@ -90,21 +91,71 @@ sub svc_ip_check {
 }
 
 sub _used_addresses {
-  my ($class, $block, $exclude) = @_;
-  my $ip_field = $class->table_info->{'ip_field'}
-    or return ();
-  # if the service doesn't have an ip_field, then it has no IP addresses 
-  # in use, yes? 
-
-  my %hash = ( $ip_field => { op => '!=', value => '' } );
-  #$hash{'blocknum'} = $block->blocknum if $block;
-  $hash{'svcnum'} = { op => '!=', value => $exclude->svcnum } if ref $exclude;
-  map { my $na = $_->NetAddr; $na ? $na->addr : () }
-    qsearch({
-        table     => $class->table,
-        hashref   => \%hash,
-        extra_sql => " AND $ip_field != '0e0'",
-    });
+  my ($class, $block, $exclude_svc) = @_;
+
+  croak "_used_addresses() requires an FS::addr_block parameter"
+    unless ref $block && $block->isa('FS::addr_block');
+
+  my $ip_field = $class->table_info->{'ip_field'};
+  if ( !$ip_field ) {
+    carp "_used_addresses() skipped, no ip_field";
+    return;
+  }
+
+  my %qsearch = ( $ip_field => { op => '!=', value => '' });
+  $qsearch{svcnum} = { op => '!=', value => $exclude_svc->svcnum }
+    if ref $exclude_svc && $exclude_svc->svcnum;
+
+  my $block_na = $block->NetAddr;
+
+  my $octets;
+  if ($block->ip_netmask >= 24) {
+    $octets = 3;
+  } elsif ($block->ip_netmask >= 16) {
+    $octets = 2;
+  } elsif ($block->ip_netmask >= 8) {
+    $octets = 1;
+  }
+
+  #  e.g.
+  # SELECT ip_addr
+  # FROM svc_broadband
+  # WHERE ip_addr != ''
+  #   AND ip_addr != '0e0'
+  #   AND ip_addr LIKE '10.0.2.%';
+  #
+  # For /24, /16 and /8 this approach is fast, even when svc_broadband table
+  # contains 650,000+ ip records.  For other allocations, this approach is
+  # not speedy, but usable.
+  #
+  # Note: A use case like this would could greatly benefit from a qsearch()
+  #       parameter to bypass FS::Record objects creation and just
+  #       return hashrefs from DBI.  200,000 hashrefs are many seconds faster
+  #       than 200,000 FS::Record objects
+  my %qsearch_param = (
+      table     => $class->table,
+      select    => $ip_field,
+      hashref   => \%qsearch,
+      extra_sql => " AND $ip_field != '0e0' ",
+  );
+  if ( $octets ) {
+    my $block_str = join('.', (split(/\D/, $block_na->first))[0..$octets-1]);
+    $qsearch_param{extra_sql}
+      .= " AND $ip_field LIKE ".dbh->quote("${block_str}.%");
+  }
+
+  if ( $block->ip_netmask % 8 ) {
+    # Some addresses returned by qsearch may be outside the network block,
+    # so each ip address is tested to be in the block before it's returned.
+    return
+      grep { $block_na->contains( NetAddr::IP->new( $_ ) ) }
+      map { $_->$ip_field }
+      qsearch( \%qsearch );
+  }
+
+  return
+    map { $_->$ip_field }
+    qsearch( \%qsearch_param );
 }
 
 sub _is_used {
index 078df55..b8b1a6e 100755 (executable)
@@ -134,6 +134,7 @@ sub table_info {
                          #select_table => 'radius_group',
                          #select_key   => 'groupnum',
                          #select_label => 'groupname',
+                         disable_select => 1,
                          disable_inventory => 1,
                          multiple => 1,
                        },
@@ -501,6 +502,11 @@ sub _upgrade_data {
     #next SVC;
   }
 
+  require FS::Misc::FixIPFormat;
+  FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'svc_broadband', 'svcnum', 'ip_addr',
+  );
+
   '';
 }
 
@@ -523,4 +529,3 @@ FS::part_svc, schema.html from the base documentation.
 =cut
 
 1;
-
index 7f49715..7f2ef80 100644 (file)
@@ -236,6 +236,17 @@ sub search_sql_addl_from {
   'LEFT JOIN circuit_type USING ( typenum )';
 }
 
+sub _upgrade_data {
+
+  require FS::Misc::FixIPFormat;
+  FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'svc_circuit', 'svcnum', 'endpoint_ip_addr',
+  );
+
+  '';
+
+}
+
 =back
 
 =head1 SEE ALSO
@@ -245,4 +256,3 @@ L<FS::Record>
 =cut
 
 1;
-
index 4bff483..019a564 100644 (file)
@@ -245,6 +245,17 @@ sub display_hw_addr {
     join(':', $self->hw_addr =~ /../g) : $self->hw_addr)
 }
 
+sub _upgrade_data {
+
+  require FS::Misc::FixIPFormat;
+  FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'svc_hardware', 'svcnum', 'ip_addr',
+  );
+
+  '';
+
+}
+
 =back
 
 =head1 SEE ALSO
@@ -254,4 +265,3 @@ L<FS::Record>, L<FS::svc_Common>, schema.html from the base documentation.
 =cut
 
 1;
-
index a5e181d..b0f6e8d 100644 (file)
@@ -387,6 +387,17 @@ sub sum_cdrs {
   qsearchs ( $psearch->{query} );
 }
 
+sub _upgrade_data {
+
+  require FS::Misc::FixIPFormat;
+  FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'svc_pbx', 'svcnum', 'ip_addr',
+  );
+
+  '';
+
+}
+
 =back
 
 =head1 BUGS
@@ -399,4 +410,3 @@ L<FS::cust_pkg>, schema.html from the base documentation.
 =cut
 
 1;
-
index 800d498..eb00d33 100644 (file)
@@ -247,7 +247,7 @@ sub check {
     $self->ut_numbern('sectornum')
     || $self->ut_number('towernum', 'tower', 'towernum')
     || $self->ut_text('sectorname')
-    || $self->ut_textn('ip_addr')
+    || $self->ut_ip46n('ip_addr')
     || $self->ut_floatn('height')
     || $self->ut_numbern('freq_mhz')
     || $self->ut_numbern('direction')
@@ -471,6 +471,17 @@ sub process_generate_coverage {
   die $error if $error;
 }
 
+sub _upgrade_data {
+
+  require FS::Misc::FixIPFormat;
+  FS::Misc::FixIPFormat::fix_bad_addresses_in_table(
+      'tower_sector', 'sectornum', 'ip_addr',
+  );
+
+  '';
+
+}
+
 =head1 BUGS
 
 =head1 SEE ALSO
@@ -480,4 +491,3 @@ L<FS::tower>, L<FS::Record>, schema.html from the base documentation.
 =cut
 
 1;
-
index 9b2298a..deb98c3 100644 (file)
@@ -1,22 +1,18 @@
 <% include( 'elements/browse.html',
                  'title'       => 'Discounts',
                  'name'        => 'discounts',
-                 'menubar'     => [ 'Add a new discount' =>
-                                      $p.'edit/discount.html',
-                                  ],
-                 'query'       => { 'table' => 'discount', },
+                 'menubar'     => \@menubar,
+                 'query'       => \%query,
+                 'order_by_sql' => { description => 'discountnum' },
                  'count_query' => 'SELECT COUNT(*) FROM discount',
                  'disableable' => 1,
                  'disabled_statuspos' => 1,
-                 'header'      => [ 'Name', 'Comment', 'Class', 'Discount', ],
+                 'header'      => [ 'Name', 'Class', 'Discount', ],
                  'fields'      => [ 'name',
-                                    'comment',
                                     'classname',
                                     'description',
                                   ],
-                 'links'       => [ $link,
-                                    $link,
-                                  ],
+                 'links'       => \@links
              )
 %>
 <%init>
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
 
-my $link = [ "${p}edit/discount.html?", 'discountnum' ];
+my @links = (
+  [ "${p}edit/discount.html?", 'discountnum' ],
+  [ "${p}edit/discount_class.html?", 'classnum' ],
+);
+
+my %query = (
+  select => 'discount.*, discount_class.*',
+  table => 'discount',
+  addl_from => 'LEFT JOIN discount_class USING(classnum)',
+);
+
+my @menubar = (
+  'Add a new discount' => $p.'edit/discount.html',
+  'Discount classes' => $p.'browse/discount_class.html',
+);
 
 </%init>
index 6d15164..38411f1 100644 (file)
@@ -18,9 +18,12 @@ Use gateway <SELECT NAME="gatewaynum">
 
   <OPTION VALUE="<% $payment_gateway->gatewaynum %>"><% $payment_gateway->gateway_module %> (<% $payment_gateway->gateway_username %>)
 % } 
-
 </SELECT>
-<BR><BR>
+<BR>
+
+<INPUT TYPE="checkbox" NAME="cardtype" VALUE="ACH"> for ACH only.
+<BR>
+<BR>
 
 <INPUT TYPE="submit" VALUE="Add gateway override">
 </FORM>
index 713f54c..120475b 100644 (file)
@@ -1,7 +1,18 @@
 <%def .namepart>
-% my ($field, $value, $label, $extra) = @_;
+% my ($field, $value, $label, $extra, $unmask_field) = @_;
 <DIV STYLE="display: inline-block" ID="<% $field %>_input">
   <INPUT TYPE="text" NAME="<% $field %>" VALUE="<% $value |h %>" <%$extra%>>
+% if (
+%   $value
+%   && ref $unmask_field
+%   && !$unmask_field->{unmask_ss}
+%   && $FS::CurrentUser::CurrentUser->access_right( $unmask_field->{access_right} )
+% ) {
+  <& /elements/link-replace_element_text.html, {
+      target_id    => $unmask_field->{target_id},
+      replace_text => $unmask_field->{replace_text},
+  } &>
+% }
   <BR><FONT SIZE="-1" COLOR="#333333"><% emt($label) %></FONT>
 </DIV>
 </%def>
         <& .namepart, 'first', $cust_main->first, 'First' &>
 % if ( $conf->exists('show_ss') ) {
         &nbsp;
-        <& .namepart, 'ss', $ss, 'SS#', "SIZE=11" &>
+        <& .namepart, 'ss', $ss, 'SS#', "SIZE=11 ID='ss'", {
+          target_id    => 'ss',
+          replace_text => $cust_main->ss,
+          access_right => 'Unmask customer SSN',
+          unmask_ss    => $conf->exists('unmask_ss'),
+        } &>
 % } else  {
         <INPUT TYPE="hidden" NAME="ss" VALUE="<% $ss %>">
 % }
index 3500d63..0f28809 100644 (file)
@@ -1,7 +1,12 @@
 % if ( $conf->exists('show_stateid') ) {
 <TR>
   <TH ALIGN="right"><% $stateid_label %></TH>
-  <TD><INPUT TYPE="text" NAME="stateid" VALUE="<% $stateid %>" SIZE=12></TD>
+  <TD>
+    <INPUT TYPE="text" NAME="stateid" VALUE="<% $stateid %>" SIZE=12 ID="stateid">
+% if ( $stateid && $FS::CurrentUser::CurrentUser->access_right( 'Unmask customer DL' )) {
+    <& /elements/link-replace_element_text.html, {target_id => 'stateid', replace_text => $cust_main->stateid} &>
+% }
+  </TD>
   <TD><& /elements/select-state.html,
           state   => $cust_main->stateid_state,
           country => $cust_main->country, # how does this work on new customer?
index e1975ed..f3dec98 100755 (executable)
@@ -34,7 +34,7 @@
 %  }
 
   <BR>Payment
-  <% ntable("#cccccc", 2) %>
+  <TABLE class="fsinnerbox">
 
     <TR>
       <TD ALIGN="right">Amount</TD><TD BGCOLOR="#ffffff">$<% $cust_pay->paid %></TD>
@@ -85,7 +85,8 @@
 
 
 <BR>Refund
-<% ntable("#cccccc", 2) %>
+
+<TABLE class="fsinnerbox">
 
   <TR>
     <TD ALIGN="right">Date</TD>
       <TD ALIGN="right">Check #</TD>
       <TD COLSPAN=2><INPUT TYPE="text" NAME="payinfo" VALUE="<% $payinfo %>" SIZE=10></TD>
     </TR>
+    </TABLE>
 % }
-%  elsif ($payby eq 'CHEK') {
+% elsif ($payby eq 'CHEK' || $payby eq 'CARD') {
 %
+<SCRIPT TYPE="text/javascript">
+  function cust_payby_changed (what) {
+    var custpaybynum = what.options[what.selectedIndex].value
+    if ( custpaybynum == '' || custpaybynum == '0' ) {
+       //what.form.payinfo.disabled = false;
+       $('#cust_payby').slideDown();
+    } else {
+       //what.form.payinfo.value = '';
+       //what.form.payinfo.disabled = true;
+       $('#cust_payby').slideUp();
+    }
+  }
+</SCRIPT>
 % my @cust_payby = ();
 % if ( $payby eq 'CARD' ) {
 %   @cust_payby = $cust_main->cust_payby('CARD','DCRD');
 % my $custpaybynum = length(scalar($cgi->param('custpaybynum')))
 %                      ? scalar($cgi->param('custpaybynum'))
 %                      : scalar(@cust_payby) && $cust_payby[0]->custpaybynum;
-<& /elements/tr-select-cust_payby.html,
+
+% if ($cust_pay) {
+  <INPUT TYPE="hidden" NAME="payinfo" VALUE="<% $payinfo %>" SIZE=10>
+% }
+% else {
+  <& /elements/tr-select-cust_payby.html,
      'cust_payby' => \@cust_payby,
      'curr_value' => $custpaybynum,
      'onchange'   => 'cust_payby_changed(this)',
-&>
-    <INPUT TYPE="hidden" NAME="batch" VALUE="1">
+  &>
+% }
+
+% if ( $conf->exists("batch-enable")
+%      || grep $payby eq $_, $conf->config('batch-enable_payby')
+% ) {
+%     if ( grep $payby eq $_, $conf->config('realtime-disable_payby') ) {
+          <INPUT TYPE="hidden" NAME="batch" VALUE="1">
+%     } else {
+        <TR>
+          <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="batch" VALUE="1" ID="batch" <% ($batchnum || $batch) ? 'checked' : '' %> ></TD>
+          <TH ALIGN="left">&nbsp;&nbsp;&nbsp;<% mt('Add to current batch') |h %></TH>
+        </TR>
+%     }
+% }
+
+    </TABLE>
+<P>
+
+%   if ( !$cust_pay ) {
+<DIV ID="cust_payby"
+  <% $custpaybynum ? 'STYLE="display:none"'
+                   : ''
+  %>
+>
+<TABLE class="fsinnerbox">
+
+    <& /elements/cust_payby_new.html,
+        'cust_payby' => \@cust_payby,
+        'curr_value' => $custpaybynum,
+    &>
+
+</TABLE>
+</DIV>
+%   } # end if cust_pay
+
 %  } else {
     <INPUT TYPE="hidden" NAME="payinfo" VALUE="">
+    </TABLE>
 % }
 
+<P>
+<TABLE class="fsinnerbox">
 <& /elements/tr-select-reason.html,
               'field'          => 'reasonnum',
               'reason_class'   => 'F',
@@ -159,16 +216,18 @@ my $payby   = $cgi->param('payby');
 my $payinfo = $cgi->param('payinfo');
 my $reason  = $cgi->param('reason');
 my $link    = $cgi->param('popup') ? 'popup' : '';
+my $batch   = $cgi->param('batch');
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->refund_access_right($payby);
 
-my( $paynum, $cust_pay ) = ( '', '' );
+my( $paynum, $cust_pay, $batchnum ) = ( '', '', '' );
 if ( $cgi->param('paynum') =~ /^(\d+)$/ ) {
   $paynum = $1;
   $cust_pay = qsearchs('cust_pay', { paynum=>$paynum } )
     or die "unknown payment # $paynum";
   $refund ||= $cust_pay->unrefunded;
+  $batchnum = $cust_pay->batchnum;
   if ( $custnum ) {
     die "payment # $paynum is not for specified customer # $custnum"
       unless $custnum == $cust_pay->custnum;
index 0a3d550..1f96456 100755 (executable)
@@ -53,7 +53,7 @@ if ( $error ) {
              'CHEK' => 'electronic check (ACH)',
              );
 
-my( $cust_payby, $payinfo, $paycvv, $month, $year, $payname );
+my( $cust_pay, $cust_payby, $payinfo, $paycvv, $month, $year, $payname );
 my $paymask = '';
 if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
 
@@ -71,6 +71,18 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
   $paycvv = $cust_payby->paycvv; # pass it if we got it, running a transaction will clear it
   ( $month, $year ) = $cust_payby->paydate_mon_year;
   $payname = $cust_payby->payname;
+  $cgi->param(-name=>"paytype", -value=>$cust_payby->paytype) unless $cgi->param("paytype");
+
+} elsif ( $cgi->param('paynum') > 0) {
+
+  $cust_pay = qsearchs({
+    'table'     => 'cust_pay',
+    'hashref'   => { 'paynum' => $cgi->param('paynum') },
+    'select'    => 'cust_pay.*, cust_pay_batch.payname ',
+    'addl_from' => "left join cust_pay_batch on cust_pay_batch.batchnum = cust_pay.batchnum and cust_pay_batch.custnum = $custnum ",
+  });
+  $payinfo = $cust_pay->payinfo;
+  $payname = $cust_pay->payname;
 
 } else {
 
@@ -192,16 +204,19 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
   my $refund = "$1$2";
   $cgi->param('paynum') =~ /^(\d*)$/ or die "Illegal paynum!";
   my $paynum = $1;
-  my $paydate = $cgi->param('exp_year'). '-'. $cgi->param('exp_month'). '-01';
-  $options{'paydate'} = $paydate if $paydate =~ /^\d{2,4}-\d{1,2}-01$/;
+  my $paydate;
+  unless ($paynum) {
+    if ($cust_payby->paydate) { $paydate = "$year-$month-01"; }
+    else { $paydate = "2037-12-01"; }
+  }
 
   if ( $cgi->param('batch') ) {
-
+    $paydate = "2037-12-01" unless $paydate;
     $error ||= $cust_main->batch_card(
                                      'payby'    => $payby,
                                      'amount'   => $refund,
                                      'payinfo'  => $payinfo,
-                                     'paydate'  => "$year-$month-01",
+                                     'paydate'  => $paydate,
                                      'payname'  => $payname,
                                      'paycode'  => 'C',
                                      map { $_ => scalar($cgi->param($_)) }
@@ -209,28 +224,23 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
                                    );
     errorpage($error) if $error;
 
-#### post refund #####
     my %hash = map {
       $_, scalar($cgi->param($_))
     } fields('cust_refund');
-    $paynum = $cgi->param('paynum');
-    $paynum =~ /^(\d*)$/ or die "Illegal paynum!";
-    if ($paynum) {
-      my $cust_pay = qsearchs('cust_pay',{ 'paynum' => $paynum });
-      die "Could not find paynum $paynum" unless $cust_pay;
-      $error = $cust_pay->refund(\%hash);
-    } else {
-      my $new = new FS::cust_refund ( \%hash );
-      $error = $new->insert;
-    }
-    # if not a batch refund run realtime.
+
+    my $new = new FS::cust_refund ( { 'paynum' => $paynum,
+                                      %hash,
+                                  } );
+    $error = $new->insert;
+
+  # if not a batch refund run realtime.
   } else {
     $error = $cust_main->realtime_refund_bop( $bop, 'amount' => $refund,
                                                   'paynum' => $paynum,
                                                   'reasonnum' => scalar($cgi->param('reasonnum')),
                                                   %options );
   }
-} else {
+} else { # run cash refund.
   my %hash = map {
     $_, scalar($cgi->param($_))
   } fields('cust_refund');
diff --git a/httemplate/elements/cust_payby_new.html b/httemplate/elements/cust_payby_new.html
new file mode 100644 (file)
index 0000000..7ed0496
--- /dev/null
@@ -0,0 +1,222 @@
+% my $auto = 0;
+% if ( $payby eq 'CARD' ) {
+%
+%   my( $payinfo, $paycvv, $month, $year ) = ( '', '', '', '' );
+%   my $payname = $cust_main->first. ' '. $cust_main->getfield('last');
+%   my $location = $cust_main->bill_location;
+
+    <TR>
+      <TH ALIGN="right"><% mt('Card number') |h %></TH>
+      <TD COLSPAN=7>
+        <TABLE>
+          <TR>
+            <TD>
+              <INPUT TYPE="text" NAME="payinfo" SIZE=20 MAXLENGTH=19 VALUE="<%$payinfo%>"> </TD>
+            <TH><% mt('Exp.') |h %></TH>
+            <TD>
+              <SELECT NAME="month">
+% for ( ( map "0$_", 1 .. 9 ), 10 .. 12 ) { 
+
+                  <OPTION<% $_ == $month ? ' SELECTED' : '' %>><% $_ %>
+% } 
+
+              </SELECT>
+            </TD>
+            <TD> / </TD>
+            <TD>
+              <SELECT NAME="year">
+% my @a = localtime; for ( $a[5]+1900 .. $a[5]+1915 ) { 
+
+                  <OPTION<% $_ == $year ? ' SELECTED' : '' %>><% $_ %>
+% } 
+
+              </SELECT>
+            </TD>
+          </TR>
+        </TABLE>
+      </TD>
+    </TR>
+    <TR>
+      <TH ALIGN="right"><% mt('CVV2') |h %></TH>
+      <TD><INPUT TYPE="text" NAME="paycvv" VALUE="<% $paycvv %>" SIZE=4 MAXLENGTH=4>
+          (<A HREF="javascript:void(0);" onClick="overlib( OLiframeContent('../docs/cvv2.html', 480, 352, 'cvv2_popup' ), CAPTION, 'CVV2 Help', STICKY, AUTOSTATUSCAP, CLOSECLICK, DRAGGABLE ); return false;"><% mt('help') |h %></A>)
+      </TD>
+    </TR>
+    <TR>
+      <TH ALIGN="right"><% mt('Exact name on card') |h %></TH>
+      <TD><INPUT TYPE="text" SIZE=32 MAXLENGTH=80 NAME="payname" VALUE="<%$payname%>"></TD>
+    </TR>
+
+    <& /elements/location.html,
+                  'object'         => $location,
+                  'no_asterisks'   => 1,
+                  'address1_label' => emt('Card billing address'),
+    &>
+
+% } elsif ( $payby eq 'CHEK' ) {
+%
+%   my( $account, $aba, $branch, $payname, $ss, $paytype, $paystate,
+%       $stateid, $stateid_state )
+%     = ( '', '', '', '', '', '', '', '', '' );
+%
+%  #false laziness w/{edit,view}/cust_main/billing.html
+%  my $routing_label = $conf->config('echeck-country') eq 'US'
+%                        ? 'ABA/Routing number'
+%                        : 'Routing number';
+%  my $routing_size      = $conf->config('echeck-country') eq 'CA' ? 4 : 10;
+%  my $routing_maxlength = $conf->config('echeck-country') eq 'CA' ? 3 : 9;
+
+    <INPUT TYPE="hidden" NAME="month" VALUE="12">
+    <INPUT TYPE="hidden" NAME="year" VALUE="2037">
+    <TR>
+      <TD ALIGN="right"><% mt('Account number') |h %></TD>
+      <TD><INPUT TYPE="text" SIZE=10 NAME="payinfo1" VALUE="<%$account%>"></TD>
+      <TD ALIGN="right"><% mt('Type') |h %></TD>
+      <TD><SELECT NAME="paytype"><% join('', map { qq!<OPTION VALUE="$_" !.($paytype eq $_ ? 'SELECTED' : '').">$_</OPTION>" } FS::cust_payby->paytypes) %></SELECT></TD>
+    </TR>
+    <TR>
+      <TD ALIGN="right"><% mt($routing_label) |h %></TD>
+      <TD>
+        <INPUT TYPE="text" SIZE="<% $routing_size %>" MAXLENGTH="<% $routing_maxlength %>" NAME="payinfo2" VALUE="<%$aba%>">
+        (<A HREF="javascript:void(0);" onClick="overlib( OLiframeContent('../docs/ach.html', 380, 240, 'ach_popup' ), CAPTION, 'ACH Help', STICKY, AUTOSTATUSCAP, CLOSECLICK, DRAGGABLE ); return false;"><% mt('help') |h %></A>)
+      </TD>
+    </TR>
+%   if ( $conf->config('echeck-country') eq 'CA' ) {
+      <TR>
+        <TD ALIGN="right"><% mt('Branch number') |h %></TD>
+        <TD>
+          <INPUT TYPE="text" NAME="payinfo3" VALUE="<%$branch%>" SIZE=6 MAXLENGTH=5>
+        </TD>
+      </TR>
+%   }
+    <TR>
+      <TD ALIGN="right"><% mt('Bank name') |h %></TD>
+      <TD><INPUT TYPE="text" NAME="payname" VALUE="<%$payname%>"></TD>
+    </TR>
+
+%   if ( $conf->exists('show_bankstate') ) {
+      <TR>
+        <TD ALIGN="right"><% mt('Bank state') |h %></TD>
+        <TD><& /elements/select-state.html,
+                         'disable_empty' => 0,
+                         'empty_label'   => emt('(choose)'),
+                         'state'         => $paystate,
+                         'country'       => $cust_main->country,
+                         'prefix'        => 'pay',
+            &>
+        </TD>
+      </TR>
+%   } else {
+      <INPUT TYPE="hidden" NAME="paystate" VALUE="<% $paystate %>">
+%   }
+
+%   if ( $conf->exists('show_ss') ) {
+      <TR>
+        <TD ALIGN="right">
+          <% mt('Account holder') |h %><BR>
+          <% mt('Social security or tax ID #') |h %> 
+        </TD>
+        <TD><INPUT TYPE="text" NAME="ss" VALUE="<% $ss %>"></TD>
+      </TR>
+%   } else {
+      <INPUT TYPE="hidden" NAME="ss" VALUE="<% $ss %>"></TD>
+%   }
+
+%   if ( $conf->exists('show_stateid') ) {
+      <TR>
+        <TD ALIGN="right">
+          <% mt('Account holder') |h %><BR>
+          <% mt("Driver's license or state ID #") |h %> 
+        </TD>
+        <TD><INPUT TYPE="text" NAME="stateid" VALUE="<% $stateid %>"></TD>
+        <TD ALIGN="right"><% mt('State') |h %></TD>
+        <TD><& /elements/select-state.html,
+                         'disable_empty' => 0,
+                         'empty_label'   => emt('(choose)'),
+                         'state'         => $stateid_state,
+                         'country'       => $cust_main->country,
+                         'prefix'        => 'stateid_',
+            &>
+        </TD>
+      </TR>
+%   } else {
+      <INPUT TYPE="hidden" NAME="stateid" VALUE="<% $stateid %>">
+      <INPUT TYPE="hidden" NAME="stateid_state" VALUE="<% $stateid_state %>">
+%   }
+
+% } #end CARD/CHEK-specific section
+
+
+<TR>
+  <TD COLSPAN=8>
+    <INPUT TYPE="checkbox" CHECKED NAME="save" VALUE="1">
+    <% mt('Remember this information') |h %>
+  </TD>
+</TR>
+
+<TR>
+  <TD COLSPAN=8>
+    <INPUT TYPE="checkbox"<% $auto ? ' CHECKED' : '' %> NAME="auto" VALUE="1" onClick="if (this.checked) { document.OneTrueForm.save.checked=true; }">
+    <% mt("Charge future payments to this [_1] automatically",$type{$payby}) |h %> 
+% if ( @cust_payby ) {
+    <% mt('as') |h %>
+    <SELECT NAME="weight">
+%     for ( 1 .. 1+scalar(grep { $_->payby =~ /^(CARD|CHEK)$/ } @cust_payby) ) {
+        <OPTION VALUE="<%$_%>"><% mt( $weight{$_} ) |h %>
+%     }
+    </SELECT>
+% } else {
+    <INPUT TYPE="hidden" NAME="weight" VALUE="1">
+% }
+  </TD>
+</TR>
+
+<%once>
+
+my %weight = (
+  1 => 'Primary',
+  2 => 'Secondary',
+  3 => 'Tertiary',
+  4 => 'Fourth',
+  5 => 'Fifth',
+  6 => 'Sixth',
+  7 => 'Seventh',
+);
+
+</%once>
+
+<%init>
+
+my %opt = @_;
+
+my @cust_payby = @{$opt{cust_payby}};
+
+my %type = ( 'CARD' => 'credit card',
+             'CHEK' => 'electronic check (ACH)',
+           );
+
+$cgi->param('payby') =~ /^(CARD|CHEK)$/
+  or die "unknown payby ". $cgi->param('payby');
+my $payby = $1;
+
+$cgi->param('custnum') =~ /^(\d+)$/
+  or die "illegal custnum ". $cgi->param('custnum');
+my $custnum = $1;
+
+my $cust_main = qsearchs( 'cust_main', { 'custnum'=>$custnum } );
+die "unknown custnum $custnum" unless $cust_main;
+
+my $balance = $cust_main->balance;
+
+my $payinfo = '';
+
+my $conf = new FS::Conf;
+
+#false laziness w/selfservice make_payment.html shortcut for one-country
+my %states = map { $_->state => 1 }
+               qsearch('cust_main_county', {
+                 'country' => $conf->config('countrydefault') || 'US'
+               } );
+my @states = sort { $a cmp $b } keys %states;
+
+</%init>
\ No newline at end of file
diff --git a/httemplate/elements/link-replace_element_text.html b/httemplate/elements/link-replace_element_text.html
new file mode 100644 (file)
index 0000000..8e61195
--- /dev/null
@@ -0,0 +1,45 @@
+<%doc>
+
+Display a link with javascript to replace text within a element.
+
+Usage:
+
+<& /elements/link-replace_element_text.html, {
+      target_id    => 'input_id',
+      replace_text => 'hello',
+
+      element_type => 'input', # Uses jquery val()  method to replace text
+      element_type => 'div',   # Uses jquery text() method to replace text
+
+      href  => ...
+      style => ...
+      class => ...
+   }
+&>
+
+</%doc>
+<a href="<% $param{href} %>"
+   style="<% $param{style} %>"
+% if ($param{class}) {
+   class="<% $param{class} %>"
+% }
+   onClick="$('#<% $param{target_id} %>').<% $param{jmethod} %>('<% $param{replace_text} |h %>');">&#x25C1;</a>
+<%init>
+
+die "template call requires a parameter hashref" unless ref $_[0];
+
+# Defaults that can be overridden in param hashref
+my %param = (
+    target_id    => 'SPECIFY_AN_INPUT_ELEMENT_ID',
+    replace_text => 'REPLACEMENT_TEXT_FOR_INPUT_ELEMENT',
+    element_type => 'input',
+
+    link_text    => '%#x25C1;', # ◁
+    href         => 'javascript:void(0)',
+    style        => 'text-decoration:none;',
+    class        => undef,
+
+    %{ $_[0] },
+);
+$param{jmethod} = $param{element_type} eq 'input' ? 'val' : 'text';
+</%init>
index e2b2e09..e5ace4d 100644 (file)
@@ -1,4 +1,4 @@
-% if ( scalar(@{ $opt{'cust_payby'} }) == 0 ) { 
+% if ( scalar(@{ $opt{'cust_payby'} }) == 0 ) {
 
   <INPUT TYPE="hidden" NAME="<% $opt{'element_name'} || $opt{'field'} || 'custpaybynum' %>" VALUE="">
 
index 2aa715e..72640d3 100644 (file)
 var manual_addr_routernum = <% encode_json(\%manual_addr_routernum) %>;
 var ip_addr_curr_value = <% $opt{'ip_addr'} |js_string %>;
 var blocknum_curr_value = <% $opt{'blocknum'} |js_string %>;
-function update_ip_addr(obj, i) {
-  var routernum = document.getElementById('router_select_0').value;
-  var select_blocknum = document.getElementById('router_select_1');
-  var blocknum = select_blocknum.value;
-  var input_ip_addr = document.getElementById('input_ip_addr');
+
+function update_ip_addr() {
+  var routernum = $('#router_select_0').val() || "";
+  var blocknum  = $('#router_select_1').val() || "";
+  var e_input_ip_addr = $('#input_ip_addr');
+  var e_router_select_1 = $('#router_select_1');
+
+  <% # Is block is automatically selected for this router? %>
   if ( manual_addr_routernum[routernum] == 'Y' ) {
-%# hide block selection and default ip address to its previous value
-    select_blocknum.style.display = 'none';
-    input_ip_addr.value = ip_addr_curr_value;
-  }
-  else {
-%# the reverse
-    select_blocknum.style.display = '';
-%# default ip address to null, unless the router/block are set to the 
-%# previous value, in which case default it to current value
+    show_ip_input();
+    hide_ip_select();
+    e_router_select_1.hide();
+    e_input_ip_addr.val( ip_addr_curr_value );
+  } else {
+    e_router_select_1.show();
+    e_input_ip_addr.attr('placeholder', <% mt('(automatic)') | js_string %> );
     if ( routernum == router_curr_values[0] &&
-         blocknum  == router_curr_values[1] ) {
-      input_ip_addr.value = ip_addr_curr_value;
+         blocknum == router_curr_values[1] ) {
+      e_input_ip_addr.val( ip_addr_curr_value );
     } else {
-      input_ip_addr.value = <% mt('(automatic)') |js_string %>;
+      e_input_ip_addr.val('');
     }
   }
+  show_or_hide_toggle_ip();
+  populate_ip_select();
+}
+
+function toggle_ip_input() {
+  if ( $('#input_ip_addr').is(':hidden') ) {
+    show_ip_input();
+  } else {
+    show_ip_select();
+  }
+}
+
+function show_ip_input() {
+  $('#input_ip_addr').show();
+  $('#select_ip_addr').hide();
+  depopulate_ip_select();
+}
+
+function show_ip_select() {
+  var e_input_ip_addr = $('#input_ip_addr');
+  var e_select_ip_addr = $('#select_ip_addr');
+
+  e_select_ip_addr.width( e_input_ip_addr.width() );
+  e_input_ip_addr.hide();
+  e_select_ip_addr.show();
+  populate_ip_select();
+}
+
+function populate_ip_select() {
+  depopulate_ip_select();
+  var e = $('#select_ip_addr');
+  var blocknum = $('#router_select_1').val();
+
+  var opts = [ '<option value="">loading...</option>' ];
+  e.html(opts.join(''));
+
+% if ( $opt{ip_addr} ) {
+  opts = [
+    '<option value="<% $opt{ip_addr} |h %>"><% $opt{ip_addr} |h %></option>',
+    '<option value="">-----------</option>'
+  ];
+% } else {
+  opts = [ '<option value=""><% mt('(automatic)') |h %></option>' ];
+% }
+  if ( blocknum && $.isNumeric(blocknum) && ! e.is(':hidden')) {
+    $.getJSON(
+      '<% $p %>misc/xmlhttp-free_addresses_in_block.json.html',
+      {blocknum: blocknum},
+      function(ip_json) {
+        $.each( ip_json, function(idx, val) {
+          opts.push(
+            '<option' + (val == ip_addr_curr_value ? 'selected' : '') + '>'
+            + val
+            + '</option>'
+          );
+        });
+        e.html(opts.join(''));
+      }
+    );
+  }
 }
-function clearhint_ip_addr (what) {
-  if ( what.value == <% mt('(automatic)') |js_string %> )
-    what.value = '';
+
+function depopulate_ip_select() {
+  $('#select_ip_addr').children().remove();
 }
+
+function propogate_ip_select() {
+  $('#input_ip_addr').val( $('#select_ip_addr').val() );
+}
+
+function show_or_hide_toggle_ip() {
+  if ( $('#router_select_1').val() ) {
+    $('#toggle_ip').show();
+  } else {
+    show_ip_input();
+    $('#toggle_ip').hide();
+  }
+}
+
 </script>
+
 <& /elements/tr-td-label.html, label => ($opt{'label'} || 'Router'), required => $opt{'required'} &>
 <td>
   <& /elements/select-tiered.html, prefix => 'router_', tiers => [
@@ -58,14 +134,20 @@ function clearhint_ip_addr (what) {
 </td></tr>
 <& /elements/tr-td-label.html, label => ($opt{'ip_addr_label'} || 'IP address'), required => $opt{'ip_addr_required'} &>
 <td>
-% #warn Dumper \%fixed;
 % if ( exists $fixed{$ip_field} ) {
   <input type="hidden" id="input_ip_addr" name="<% $ip_field %>" 
     value="<% $opt{'ip_addr'} |h%>"><% $opt{'ip_addr'} || '' %>
 % }
 % else {
-  <input type="text" id="input_ip_addr" name="<% $ip_field %>" 
-  value="<% $opt{'ip_addr'} |h%>" onfocus="clearhint_ip_addr(this)">
+    <input type="text"
+           id="input_ip_addr"
+           name="<% $ip_field %>"
+           value="<% $opt{'ip_addr'} | h %>"
+           onfocus="clearhint_ip_addr(this)">
+    <select id="select_ip_addr" style="display: none;" onChange='javascript:propogate_ip_select();'>
+      <option><% mt('loading') |h %>...</option>
+    </select>
+    <button type="button" onClick='javascript:toggle_ip_input();' id="toggle_ip" style="display: none;">&#9660;</button>
 % }
 </td> </tr>
 <script type="text/javascript">
index c4bc37e..c6a0b68 100644 (file)
@@ -20,9 +20,18 @@ elsif ( $cgi->param('format') =~ /^([\w\- ]+)$/ ) {
   $opt{'format'} = $1;
 }
 
-my $pay_batch = qsearchs('pay_batch', { batchnum => $batchnum } );
+my $credit_transactions = "EXISTS (SELECT 1 FROM cust_pay_batch WHERE batchnum = $batchnum AND paycode = 'C') AS arecredits";
+my $pay_batch = qsearchs({ 'select'    => "*, $credit_transactions",
+                           'table'     => 'pay_batch',
+                           'hashref'   => { batchnum => $batchnum },
+                         });
 die "Batch not found: '$batchnum'" if !$pay_batch;
 
+if ($pay_batch->{Hash}->{arecredits}) {
+  my $export_format = "FS::pay_batch::".$opt{'format'};
+    die "This format can not handle refunds." unless $export_format->can('can_handle_credits');
+}
+
 my $exporttext = $pay_batch->export_batch(%opt);
 unless ($exporttext) {
   http_header('Content-Type' => 'text/html' );
index 4f6f7ef..80cb15d 100644 (file)
@@ -135,178 +135,10 @@ function change_batch_checkbox () {
 >
 <TABLE class="fsinnerbox">
 
-% my $auto = 0;
-% if ( $payby eq 'CARD' ) {
-%
-%   my( $payinfo, $paycvv, $month, $year ) = ( '', '', '', '' );
-%   my $payname = $cust_main->first. ' '. $cust_main->getfield('last');
-%   my $location = $cust_main->bill_location;
-
-    <TR>
-      <TH ALIGN="right"><% mt('Card number') |h %></TH>
-      <TD COLSPAN=7>
-        <TABLE>
-          <TR>
-            <TD>
-              <INPUT TYPE="text" NAME="payinfo" SIZE=20 MAXLENGTH=19 VALUE="<%$payinfo%>"> </TD>
-            <TH><% mt('Exp.') |h %></TH>
-            <TD>
-              <SELECT NAME="month">
-% for ( ( map "0$_", 1 .. 9 ), 10 .. 12 ) { 
-
-                  <OPTION<% $_ == $month ? ' SELECTED' : '' %>><% $_ %>
-% } 
-
-              </SELECT>
-            </TD>
-            <TD> / </TD>
-            <TD>
-              <SELECT NAME="year">
-% my @a = localtime; for ( $a[5]+1900 .. $a[5]+1915 ) { 
-
-                  <OPTION<% $_ == $year ? ' SELECTED' : '' %>><% $_ %>
-% } 
-
-              </SELECT>
-            </TD>
-          </TR>
-        </TABLE>
-      </TD>
-    </TR>
-    <TR>
-      <TH ALIGN="right"><% mt('CVV2') |h %></TH>
-      <TD><INPUT TYPE="text" NAME="paycvv" VALUE="<% $paycvv %>" SIZE=4 MAXLENGTH=4>
-          (<A HREF="javascript:void(0);" onClick="overlib( OLiframeContent('../docs/cvv2.html', 480, 352, 'cvv2_popup' ), CAPTION, 'CVV2 Help', STICKY, AUTOSTATUSCAP, CLOSECLICK, DRAGGABLE ); return false;"><% mt('help') |h %></A>)
-      </TD>
-    </TR>
-    <TR>
-      <TH ALIGN="right"><% mt('Exact name on card') |h %></TH>
-      <TD><INPUT TYPE="text" SIZE=32 MAXLENGTH=80 NAME="payname" VALUE="<%$payname%>"></TD>
-    </TR>
-
-    <& /elements/location.html,
-                  'object'         => $location,
-                  'no_asterisks'   => 1,
-                  'address1_label' => emt('Card billing address'),
-    &>
-
-% } elsif ( $payby eq 'CHEK' ) {
-%
-%   my( $account, $aba, $branch, $payname, $ss, $paytype, $paystate,
-%       $stateid, $stateid_state )
-%     = ( '', '', '', '', '', '', '', '', '' );
-%
-%  #false laziness w/{edit,view}/cust_main/billing.html
-%  my $routing_label = $conf->config('echeck-country') eq 'US'
-%                        ? 'ABA/Routing number'
-%                        : 'Routing number';
-%  my $routing_size      = $conf->config('echeck-country') eq 'CA' ? 4 : 10;
-%  my $routing_maxlength = $conf->config('echeck-country') eq 'CA' ? 3 : 9;
-
-    <INPUT TYPE="hidden" NAME="month" VALUE="12">
-    <INPUT TYPE="hidden" NAME="year" VALUE="2037">
-    <TR>
-      <TD ALIGN="right"><% mt('Account number') |h %></TD>
-      <TD><INPUT TYPE="text" SIZE=10 NAME="payinfo1" VALUE="<%$account%>"></TD>
-      <TD ALIGN="right"><% mt('Type') |h %></TD>
-      <TD><SELECT NAME="paytype"><% join('', map { qq!<OPTION VALUE="$_" !.($paytype eq $_ ? 'SELECTED' : '').">$_</OPTION>" } FS::cust_payby->paytypes) %></SELECT></TD>
-    </TR>
-    <TR>
-      <TD ALIGN="right"><% mt($routing_label) |h %></TD>
-      <TD>
-        <INPUT TYPE="text" SIZE="<% $routing_size %>" MAXLENGTH="<% $routing_maxlength %>" NAME="payinfo2" VALUE="<%$aba%>">
-        (<A HREF="javascript:void(0);" onClick="overlib( OLiframeContent('../docs/ach.html', 380, 240, 'ach_popup' ), CAPTION, 'ACH Help', STICKY, AUTOSTATUSCAP, CLOSECLICK, DRAGGABLE ); return false;"><% mt('help') |h %></A>)
-      </TD>
-    </TR>
-%   if ( $conf->config('echeck-country') eq 'CA' ) {
-      <TR>
-        <TD ALIGN="right"><% mt('Branch number') |h %></TD>
-        <TD>
-          <INPUT TYPE="text" NAME="payinfo3" VALUE="<%$branch%>" SIZE=6 MAXLENGTH=5>
-        </TD>
-      </TR>
-%   }
-    <TR>
-      <TD ALIGN="right"><% mt('Bank name') |h %></TD>
-      <TD><INPUT TYPE="text" NAME="payname" VALUE="<%$payname%>"></TD>
-    </TR>
-
-%   if ( $conf->exists('show_bankstate') ) {
-      <TR>
-        <TD ALIGN="right"><% mt('Bank state') |h %></TD>
-        <TD><& /elements/select-state.html,
-                         'disable_empty' => 0,
-                         'empty_label'   => emt('(choose)'),
-                         'state'         => $paystate,
-                         'country'       => $cust_main->country,
-                         'prefix'        => 'pay',
-            &>
-        </TD>
-      </TR>
-%   } else {
-      <INPUT TYPE="hidden" NAME="paystate" VALUE="<% $paystate %>">
-%   }
-
-%   if ( $conf->exists('show_ss') ) {
-      <TR>
-        <TD ALIGN="right">
-          <% mt('Account holder') |h %><BR>
-          <% mt('Social security or tax ID #') |h %> 
-        </TD>
-        <TD><INPUT TYPE="text" NAME="ss" VALUE="<% $ss %>"></TD>
-      </TR>
-%   } else {
-      <INPUT TYPE="hidden" NAME="ss" VALUE="<% $ss %>"></TD>
-%   }
-
-%   if ( $conf->exists('show_stateid') ) {
-      <TR>
-        <TD ALIGN="right">
-          <% mt('Account holder') |h %><BR>
-          <% mt("Driver's license or state ID #") |h %> 
-        </TD>
-        <TD><INPUT TYPE="text" NAME="stateid" VALUE="<% $stateid %>"></TD>
-        <TD ALIGN="right"><% mt('State') |h %></TD>
-        <TD><& /elements/select-state.html,
-                         'disable_empty' => 0,
-                         'empty_label'   => emt('(choose)'),
-                         'state'         => $stateid_state,
-                         'country'       => $cust_main->country,
-                         'prefix'        => 'stateid_',
-            &>
-        </TD>
-      </TR>
-%   } else {
-      <INPUT TYPE="hidden" NAME="stateid" VALUE="<% $stateid %>">
-      <INPUT TYPE="hidden" NAME="stateid_state" VALUE="<% $stateid_state %>">
-%   }
-
-% } #end CARD/CHEK-specific section
-
-
-<TR>
-  <TD COLSPAN=8>
-    <INPUT TYPE="checkbox" CHECKED NAME="save" VALUE="1">
-    <% mt('Remember this information') |h %>
-  </TD>
-</TR>
-
-<TR>
-  <TD COLSPAN=8>
-    <INPUT TYPE="checkbox"<% $auto ? ' CHECKED' : '' %> NAME="auto" VALUE="1" onClick="if (this.checked) { document.OneTrueForm.save.checked=true; }">
-    <% mt("Charge future payments to this [_1] automatically",$type{$payby}) |h %> 
-% if ( @cust_payby ) {
-    <% mt('as') |h %>
-    <SELECT NAME="weight">
-%     for ( 1 .. 1+scalar(grep { $_->payby =~ /^(CARD|CHEK)$/ } @cust_payby) ) {
-        <OPTION VALUE="<%$_%>"><% mt( $weight{$_} ) |h %>
-%     }
-    </SELECT>
-% } else {
-    <INPUT TYPE="hidden" NAME="weight" VALUE="1">
-% }
-  </TD>
-</TR>
+<& /elements/cust_payby_new.html,
+     'cust_payby' => \@cust_payby,
+     'curr_value' => $custpaybynum,
+&>
 
 </TABLE>
 </DIV>
@@ -355,13 +187,6 @@ my $payinfo = '';
 
 my $conf = new FS::Conf;
 
-#false laziness w/selfservice make_payment.html shortcut for one-country
-my %states = map { $_->state => 1 }
-               qsearch('cust_main_county', {
-                 'country' => $conf->config('countrydefault') || 'US'
-               } );
-my @states = sort { $a cmp $b } keys %states;
-
 my $payunique = "webui-payment-". time. "-$$-". rand() * 2**32;
 
 </%init>
index 717d57c..5620b5b 100644 (file)
@@ -90,6 +90,7 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
   $paycvv = $cust_payby->paycvv; # pass it if we got it, running a transaction will clear it
   ( $month, $year ) = $cust_payby->paydate_mon_year;
   $payname = $cust_payby->payname;
+  $cgi->param(-name=>"paytype", -value=>$cust_payby->paytype) unless $cgi->param("paytype");
 
 } else {
 
@@ -208,6 +209,10 @@ if ( (my $custpaybynum = scalar($cgi->param('custpaybynum'))) > 0 ) {
 
 my $error = '';
 my $paynum = '';
+my $paydate;
+if ($cust_payby->paydate) { $paydate = "$year-$month-01"; }
+else { $paydate = "2037-12-01"; }
+
 if ( $cgi->param('batch') ) {
 
   $error = 'Prepayment discounts not supported with batched payments' 
@@ -217,7 +222,7 @@ if ( $cgi->param('batch') ) {
                                      'payby'    => $payby,
                                      'amount'   => $amount,
                                      'payinfo'  => $payinfo,
-                                     'paydate'  => "$year-$month-01",
+                                     'paydate'  => $paydate,
                                      'payname'  => $payname,
                                      map { $_ => scalar($cgi->param($_)) } 
                                        @{$payby2fields{$payby}}
diff --git a/httemplate/misc/xmlhttp-free_addresses_in_block.json.html b/httemplate/misc/xmlhttp-free_addresses_in_block.json.html
new file mode 100644 (file)
index 0000000..801718d
--- /dev/null
@@ -0,0 +1,18 @@
+<%doc>
+  Return a json array containing all free ip addresses within a given block
+  Unless block is larger than /24 - Does somebody really want to populate
+  65k addresses into a HTML selectbox?
+</%doc>
+<% encode_json($json) %>\
+<%init>
+
+my $json = [];
+
+my $blocknum = $cgi->param('blocknum');
+
+my $addr_block = qsearchs( addr_block => { blocknum => $blocknum });
+
+$json = $addr_block->free_addrs
+  if ref $addr_block && $addr_block->ip_netmask >= 24;
+
+</%init>
index 75dbef7..6d387d5 100644 (file)
@@ -1,6 +1,23 @@
+<%doc>
+
+  E911 Fee Report
+
+  Finds billing totals for a given pkgpart where the bill item matches
+  cust_pkg.pkgpart or cust_bill_pkg.pkgpart_override columns.
+
+  Given date range, filter by when the invoice was paid.
+
+  * E911 access lines - SUM(cust_bill_pkg.quantity)
+  * Total fees charged - SUM(cust_bill_pay_pkg.amount)
+  * Fee payments collected - SUM(cust_bill_pkg.setup) + SUM(cust_bill_pkg.recur)
+
+  * Administrative fee (1%) - 1% of Fee Payments Collected
+  * Amount due - 99% of Fee Payments Collected
+
+</%doc>
 % if ( $row ) {
-%# pretty minimal report
 <& /elements/header.html, 'E911 Fee Report' &>
+
 <& /elements/table-grid.html &>
 <STYLE TYPE="text/css">
 table.grid TD:first-child { font-weight: normal }
@@ -8,27 +25,27 @@ table.grid TD { font-weight: bold;
                 text-align: right;
                 padding: 1px 2px }
 </STYLE>
+
   <TR><TH COLSPAN=2><% $legend %></TH></TR>
   <TR>
-    <TD>E911 access lines:</TD>
-    <TD><% $row->{quantity} || 0 %></TD>
+    <TD><% mt('E911 access lines') %>:</TD>
+    <TD><% $report{e911_access_lines} %></TD>
   </TR>
   <TR>
-    <TD>Total fees charged: </TD>
-    <TD><% $money_char.sprintf('%.2f', $row->{charged_amount}) %></TD>
+    <TD><% mt('Total fees charged') %>: </TD>
+    <TD><% $money_char.$report{fees_charged} %></TD>
   </TD>
   <TR>
-    <TD>Fee payments collected: </TD>
-    <TD><% $money_char.sprintf('%.2f', $row->{paid_amount}) %></TD>
+    <TD><% mt('Fee payments collected') %>: </TD>
+    <TD><% $money_char.$report{fees_collected} %></TD>
   </TR>
   <TR>
-    <TD>Administrative fee (1%): </TD>
-    <TD><% $money_char.sprintf('%.2f', $row->{paid_amount} * $admin_fee) %></TD>
+    <TD><% mt('Administrative fee') %> (1%): </TD>
+    <TD><% $money_char.$report{admin_fee} %></TD>
   </TR>
   <TR>
-    <TD>Amount due: </TD>
-    <TD><% $money_char.sprintf('%.2f', $row->{paid_amount} * (1-$admin_fee) ) %>
-    </TD>
+    <TD><% mt('Amount due') %>: </TD>
+    <TD><% $money_char.$report{e911_amount_due} %></TD>
   </TR>
 </TABLE>
 <& /elements/footer.html &>
@@ -38,6 +55,8 @@ table.grid TD { font-weight: bold;
 % }
 <%init>
 
+our $DEBUG;
+
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
@@ -56,56 +75,89 @@ my $agentnum = $1;
 # package classes, etc.), do NOT simply loop through this and do a 
 # bazillion scalar_sql queries.  Use a properly grouped aggregate query.
 
-my $select = 'SELECT cust_bill_pkg.billpkgnum, cust_bill_pkg.quantity, '.
-'cust_bill_pkg.setup, SUM(cust_bill_pay_pkg.amount) AS paid_amount';
-
-my $from = 'FROM cust_pkg
-  JOIN cust_bill_pkg      USING (pkgnum)
-  JOIN cust_bill          USING (invnum)
-  LEFT JOIN cust_bill_pay_pkg  USING (billpkgnum)
-  LEFT JOIN cust_bill_pay      USING (billpaynum)
-';
-# going by payment application date here, which should be
-# max(invoice date, payment date)
-my $where = "WHERE cust_pkg.pkgpart = $pkgpart
-AND ( (cust_bill_pay._date >= $begin AND cust_bill_pay._date < $end)
-      OR cust_bill_pay.paynum IS NULL )";
+my $sql_statement = "
+  SELECT
+    sum(cust_bill_pkg.quantity) as quantity,
+    sum(cust_bill_pay_pkg.amount) as amount,
+    sum(cust_bill_pkg.setup) as setup,
+    sum(cust_bill_pkg.recur) as recur
 
+  FROM cust_pkg
+    LEFT JOIN cust_bill_pkg      USING (pkgnum)
+    LEFT JOIN cust_bill_pay_pkg  USING (billpkgnum)
+    LEFT JOIN cust_bill_pay      USING (billpaynum)
+";
 if ( $agentnum ) {
-  $from .= '  JOIN cust_main ON (cust_pkg.custnum = cust_main.custnum)';
-  $where .= "\n AND cust_main.agentnum = $agentnum";
+  $sql_statement .= "
+      LEFT JOIN cust_main          USING (custnum)
+    WHERE
+      cust_main.agentnum = ?
+      AND ";
+} else {
+  $sql_statement .= "
+    WHERE
+  "
 }
+$sql_statement .= "
+    ( cust_bill_pkg.pkgpart_override = ? OR cust_pkg.pkgpart = ? )
+    AND (
+      ( cust_bill_pay._date >= ? AND cust_bill_pay._date < ? )
+      OR cust_bill_pay.paynum IS NULL
+    );
+";
+
+# Preserving this oddball, unexplained epoch substitution
+$end = '' if $end == 4294967295;
 
-my $subquery = "$select $from $where
-GROUP BY cust_bill_pkg.billpkgnum, cust_bill_pkg.quantity";
-# This has one row for each E911 line item that has any payments applied.
-# Fields are the billpkgnum of the item (currently unused), the number of
-# E911 charges, and the total amount paid (always > 0).
+my @bind_values = (
+    $agentnum ? $agentnum : (),
+    $pkgpart,
+    $pkgpart,
+    $begin || 0,
+    $end || time(),
+);
 
-# now sum those rows.
-my $sql = "SELECT SUM(quantity) AS quantity, SUM(setup) AS charged_amount,
-SUM(paid_amount) AS paid_amount FROM ($subquery) AS paid_fees"; # no grouping
+if ( $DEBUG ) {
+  warn "\$sql_statement: $sql_statement\n";
+  warn "\@bind_values: ".join(', ',@bind_values)."\n";
+}
 
-my $sth = dbh->prepare($sql);
-$sth->execute;
+my $sth = dbh->prepare( $sql_statement );
+$sth->execute( @bind_values ) || die $sth->errstr;
 my $row = $sth->fetchrow_hashref;
 
-my $admin_fee = 0.01; # 1% admin fee, allowed in Texas
+my %report = (
+  e911_access_lines => $row->{quantity} || 0,
 
-$end = '' if $end == 4294967295;
-my $legend = '';
-if ( $agentnum ) {
-  $legend = FS::agent->by_key($agentnum)->agent . ', ';
-}
-if ( $begin and $end ) {
-  $legend .= time2str('%h %o %Y', $begin) . '&mdash;' .
-             time2str('%h %o %Y', $end);
+  fees_charged => sprintf(
+    "%.2f",
+    ( $row->{setup} + $row->{recur} ) || 0,
+  ),
+
+  fees_collected => sprintf(
+    "%.2f",
+    ( $row->{amount} || 0 ),
+  ),
+);
+
+# Does everybody use this 1% admin fee?  Should this be configurable?
+$report{admin_fee} = sprintf( "%.2f", $report{fees_collected} * 0.01 );
+$report{e911_amount_due} = $report{fees_collected} - $report{admin_fee};
+
+my $begin_text =
+  $begin
+    ? DateTime->from_epoch(epoch => $begin)->mdy('/')
+    : mt('Anytime');
+
+my $end_text =  DateTime->from_epoch(epoch => ( $end || time ))->mdy('/');
+
+my $legend = FS::agent->by_key($agentnum)->agent . ', ' if $agentnum;
+if ( $begin && $end ) {
+  $legend .= "$begin_text &#x2194; $end_text";
 } elsif ( $begin ) {
-  $legend .= time2str('after %h %o %Y', $begin);
-} elsif ( $end ) {
-  $legend .= time2str('before %h %o %Y', $end);
+  $legend .= mt('After')." $begin_text";
 } else {
-  $legend .= 'any time';
+  $legend .= mt('Through')." $end_text"
 }
-$legend = ucfirst($legend);
+
 </%init>
index d65d4d1..0eb45f3 100644 (file)
@@ -17,7 +17,6 @@
                                           }
                                           $pm->prospect_contact
                                     ];
-                                    ''
                                   },
                                   sub {
                                     my $pr = shift->part_referral;
index 1660c1c..11efcd5 100644 (file)
     <TD COLSPAN=5><% $cust_main->contact |h %></TD>
 %   if ( $conf->exists('show_ss') ) {
     <TH ALIGN="right"><% mt('SS#') |h %></TH>
-    <TD><% $conf->exists('unmask_ss')
-                              ? $cust_main->ss
-                              : $cust_main->masked('ss') || '&nbsp;' %></TD>
+    <TD>
+      <span id="ss_span" style="white-space:nowrap;">
+      <% $conf->exists('unmask_ss')
+           ? $cust_main->ss
+           : $cust_main->masked('ss') || '&nbsp;' %>
+%   if (
+%         $cust_main->ss
+%         && !$conf->exists('unmask_ss')
+%         && $FS::CurrentUser::CurrentUser->access_right('Unmask customer SSN')
+%   ) {
+      <& /elements/link-replace_element_text.html, {
+           target_id    => 'ss_span',
+           replace_text => $cust_main->ss,
+           element_type => 'span'
+      } &>
+%   }
+      </span>
+    </TD>
 %   }
   </TR>
 %   if ( $conf->exists('cust_main-enable_spouse') and
 
 <TR>
     <TH ALIGN="right"><% $stateid_label %></TH>
-    <TD><% $cust_main->masked('stateid') || '&nbsp' %></TD>
+    <TD>
+      <span id="stateid_span" style="white-space:nowrap;">
+      <% $cust_main->masked('stateid') || '&nbsp' %>
+%   if (
+%         $cust_main->stateid
+%         && $FS::CurrentUser::CurrentUser->access_right('Unmask customer DL')
+%   ) {
+      <& /elements/link-replace_element_text.html, {
+           target_id => 'stateid_span',
+           replace_text => $cust_main->stateid,
+           element_type => 'span'
+      } &>
+%   }
+      </span>
+    </TD>
     <TH ALIGN="right"><% $stateid_state_label %></TH>
     <TD><% $cust_main->stateid_state || '&nbsp' %></TD>
   </TR>
index f3aca21..7ec4d07 100644 (file)
@@ -460,7 +460,7 @@ my @menu = (
         ##  condition   => sub { $payby{MCHK} },
         #},
         {
-           label       => 'Batch Electronic check refund',
+           label       => 'Enter electronic check refund',
            popup       => "edit/cust_refund.cgi?popup=1;payby=CHEK;custnum=$custnum",
            actionlabel => 'Enter electronic check refund',
            width       => 440,
index f4dd414..504a5a8 100644 (file)
 % foreach my $prospect_contact ( $prospect_main->prospect_contact ) {
 %   my $contact = $prospect_contact->contact;
     <TR>
-      <TH ALIGN="right"><% $prospect_contact->contact_classname %> Contact</TD>
-      <TD BGCOLOR="#FFFFFF"><% $contact->line %></TD>
+      <TH ALIGN="right" VALIGN="top"><% $prospect_contact->contact_classname %> Contact</TH>
+      <TD BGCOLOR="#FFFFFF">
+          <% $contact->line %><br>
+          <table>
+%         for my $row ( $contact->contact_email ) {
+            <tr><th>E-Mail:</th><td><% $row->emailaddress %></td></tr>
+%         }
+%         for my $row ( $contact->contact_phone ) {
+            <tr><th><% $row->phone_type->typename %>:</th><td><% $row->phonenum_pretty %></td></tr>
+%         }
+%         if ( $prospect_contact->comment ) {
+            <tr><th>Comment:</th><td><% $prospect_contact->comment %></td></tr>
+%         }
+          </table>
+      </TD>
     </TR>
 %}