Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Tue, 3 Oct 2017 16:36:51 +0000 (09:36 -0700)
committerIvan Kohler <ivan@freeside.biz>
Tue, 3 Oct 2017 16:36:51 +0000 (09:36 -0700)
119 files changed:
FS/FS.pm
FS/FS/API.pm
FS/FS/AccessRight.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/Conf.pm
FS/FS/Cron/backup.pm
FS/FS/Cron/rt_tasks.pm
FS/FS/Mason.pm
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/TaxEngine/compliance_solutions.pm
FS/FS/TaxEngine/internal.pm
FS/FS/TaxEngine/suretax.pm
FS/FS/Upgrade.pm
FS/FS/access_right.pm
FS/FS/access_user_session_log.pm [new file with mode: 0644]
FS/FS/contact.pm
FS/FS/contact/Import.pm [new file with mode: 0644]
FS/FS/cust_main.pm
FS/FS/cust_main/Billing_Realtime.pm
FS/FS/cust_main/Import.pm
FS/FS/cust_main/Search.pm
FS/FS/cust_main_Mixin.pm
FS/FS/cust_pkg.pm
FS/FS/cust_pkg/Import.pm
FS/FS/part_event.pm
FS/FS/part_event/Action/notice_to_emailtovoice.pm [new file with mode: 0644]
FS/FS/part_event/Condition/referred_cust_base_recur.pm [new file with mode: 0644]
FS/FS/part_export/acct_http.pm
FS/FS/part_export/broadband_http.pm
FS/FS/part_export/broadband_shellcommands.pm
FS/FS/part_export/broadband_shellcommands_expect.pm [new file with mode: 0644]
FS/FS/part_export/dsl_http.pm [new file with mode: 0644]
FS/FS/part_export/fiber_http.pm [new file with mode: 0644]
FS/FS/part_export/http.pm
FS/FS/part_export/pbxware.pm
FS/FS/part_export/shellcommands.pm
FS/FS/part_export/shellcommands_expect.pm [new file with mode: 0644]
FS/FS/part_export/vitelity.pm
FS/FS/part_pkg/recur_Common.pm
FS/FS/svc_circuit.pm
FS/FS/tax_rate.pm
FS/FS/tax_rate_location.pm
FS/MANIFEST
FS/bin/freeside-voipinnovations-cdrimport
FS/t/access_user_session_log.t [new file with mode: 0644]
Makefile
bin/cust_main-email_and_rebill [new file with mode: 0644]
bin/freeside-debian-releases.sh
bin/recover-cust_location [new file with mode: 0755]
bin/xmlrpc-advertising_sources-add.pl [new file with mode: 0755]
bin/xmlrpc-advertising_sources-edit.pl [new file with mode: 0755]
bin/xmlrpc-advertising_sources-list.pl [new file with mode: 0755]
bin/xmlrpc-order_package.php [new file with mode: 0755]
conf/invoice_latex
conf/quotation_latex
debian/control
debian/freeside-ng-selfservice.conffiles [new file with mode: 0644]
debian/freeside-ng-selfservice.deb7 [deleted file]
debian/freeside-ng-selfservice.deb8 [deleted file]
fs_selfservice/FS-SelfService/cgi/change_pay.html
fs_selfservice/FS-SelfService/cgi/myaccount_menu.html
fs_selfservice/FS-SelfService/cgi/selfservice.cgi
httemplate/browse/part_pkg_taxproduct/compliance_solutions.html
httemplate/config/config-view.cgi
httemplate/edit/cust_main.cgi
httemplate/edit/deploy_zone-fixed.html
httemplate/edit/deploy_zone-mobile.html
httemplate/edit/process/deploy_zone-fixed.html
httemplate/edit/process/deploy_zone-mobile.html
httemplate/edit/router.cgi
httemplate/edit/svc_acct.cgi
httemplate/elements/change_password.html
httemplate/elements/menu.html
httemplate/elements/searchbar-cust_main.html
httemplate/elements/select-cust_phone.html [new file with mode: 0644]
httemplate/elements/select.html
httemplate/elements/tr-select-cust_location.html
httemplate/elements/tr-select-cust_phone.html [new file with mode: 0644]
httemplate/elements/validate_password.html
httemplate/misc/cancel_pkg.html
httemplate/misc/change_pkg.cgi
httemplate/misc/confirm-censustract.html
httemplate/misc/contact-import.cgi [new file with mode: 0644]
httemplate/misc/cust_pkg-import.html
httemplate/misc/elements/leaflet/images/layers-2x.png [new file with mode: 0644]
httemplate/misc/elements/leaflet/images/layers.png [new file with mode: 0644]
httemplate/misc/elements/leaflet/images/marker-icon-2x.png [new file with mode: 0644]
httemplate/misc/elements/leaflet/images/marker-icon.png [new file with mode: 0644]
httemplate/misc/elements/leaflet/images/marker-shadow.png [new file with mode: 0644]
httemplate/misc/elements/leaflet/leaflet-src.js [new file with mode: 0644]
httemplate/misc/elements/leaflet/leaflet-src.js.map [new file with mode: 0644]
httemplate/misc/elements/leaflet/leaflet.css [new file with mode: 0644]
httemplate/misc/elements/leaflet/leaflet.js [new file with mode: 0644]
httemplate/misc/elements/leaflet/leaflet.js.map [new file with mode: 0644]
httemplate/misc/email-customers.html
httemplate/misc/openmap.html [new file with mode: 0644]
httemplate/misc/process/change-password.html
httemplate/misc/process/contact-import.cgi [new file with mode: 0644]
httemplate/misc/xmlhttp-censustract.html [new file with mode: 0644]
httemplate/search/access_user_log.html
httemplate/search/contact.html
httemplate/search/cust_main.html
httemplate/search/cust_timespan.html [new file with mode: 0644]
httemplate/search/elements/cust_pay_or_refund.html
httemplate/search/elements/options_cust_contacts.html [new file with mode: 0644]
httemplate/search/elements/report_cust_pay_or_refund.html
httemplate/search/employee_audit.html
httemplate/search/report_access_user_log.html
httemplate/search/report_cust_main.html
httemplate/search/report_cust_timespan.html [new file with mode: 0644]
httemplate/search/report_employee_audit.html
httemplate/view/Status-db_size_detail.html [new file with mode: 0644]
httemplate/view/Status.html
httemplate/view/cust_main/packages/package.html
httemplate/view/cust_main/payment_history/payment.html
httemplate/view/svc_acct.cgi
ng_selfservice/payment_cc.php
rt/share/html/Search/Build.html

index 134a34c..9575c3d 100644 (file)
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -67,6 +67,8 @@ L<FS::cust_main::Search> - Customer searching
 
 L<FS::cust_main::Import> - Batch customer importing
 
+L<FS::contact::Import> - Batch contact importing
+
 =head2 Database record classes
 
 L<FS::Record> - Database record base class
index fd3793d..047bb4e 100644 (file)
@@ -1,6 +1,7 @@
 package FS::API;
 
 use strict;
+use Date::Parse;
 use FS::Conf;
 use FS::Record qw( qsearch qsearchs );
 use FS::cust_main;
@@ -16,7 +17,20 @@ FS::API - Freeside backend API
 
 =head1 SYNOPSIS
 
-  use FS::API;
+  use Frontier::Client;
+  use Data::Dumper;
+
+  my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
+                                              # the traffic
+
+  my $xmlrpc = new Frontier::Client url=>$url;
+
+  my $result = $xmlrpc->call( 'FS.API.customer_info',
+                                'secret'  => 'sharingiscaring',
+                                'custnum' => 181318,
+                            );
+
+  print Dumper($result);
 
 =head1 DESCRIPTION
 
@@ -525,6 +539,23 @@ sub update_customer {
 Returns general customer information. Takes a list of keys and values as
 parameters with the following keys: custnum, secret 
 
+Example:
+
+  use Frontier::Client;
+  use Data::Dumper;
+
+  my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
+                                              # the traffic
+
+  my $xmlrpc = new Frontier::Client url=>$url;
+
+  my $result = $xmlrpc->call( 'FS.API.customer_info',
+                                'secret'  => 'sharingiscaring',
+                                'custnum' => 181318,
+                            );
+
+  print Dumper($result);
+
 =cut
 
 sub customer_info {
@@ -542,6 +573,28 @@ sub customer_info {
 Returns customer service information.  Takes a list of keys and values as
 parameters with the following keys: custnum, secret
 
+Example:
+
+  use Frontier::Client;
+  use Data::Dumper;
+
+  my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
+                                              # the traffic
+
+  my $xmlrpc = new Frontier::Client url=>$url;
+
+  my $result = $xmlrpc->call( 'FS.API.customer_list_svcs',
+                                'secret'  => 'sharingiscaring',
+                                'custnum' => 181318,
+                            );
+
+  print Dumper($result);
+
+  foreach my $cust_svc ( @{ $result->{'cust_svc'} } ) {
+    #print $cust_svc->{mac_addr}."\n" if exists $cust_svc->{mac_addr};
+    print $cust_svc->{circuit_id}."\n" if exists $cust_svc->{circuit_id};
+  }
+
 =cut
 
 sub customer_list_svcs {
@@ -597,10 +650,128 @@ sub location_info {
   return \%return;
 }
 
+=item order_package OPTION => VALUE, ...
+
+Orders a new customer package.  Takes a list of keys and values as paramaters
+with the following keys:
+
+=over 4
+
+=item secret
+
+API Secret
+
+=item custnum
+
+=item pkgpart
+
+=item quantity
+
+=item start_date
+
+=item contract_end
+
+=item address1
+
+=item address2
+
+=item city
+
+=item county
+
+=item state
+
+=item zip
+
+=item country
+
+=item setup_fee
+
+Including this implements per-customer custom pricing for this package, overriding package definition pricing
+
+=item recur_fee
+
+Including this implements per-customer custom pricing for this package, overriding package definition pricing
+
+=item invoice_details
+
+A single string for just one detail line, or an array reference of one or more
+lines of detail
+
+=cut
+
+sub order_package {
+  my( $class, %opt ) = @_;
+
+  my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
+    or return { 'error' => 'Unknown custnum' };
+
+  #some conceptual false laziness w/cust_pkg/Import.pm
+
+  my $cust_pkg = new FS::cust_pkg {
+    'pkgpart'    => $opt{'pkgpart'},
+    'quantity'   => $opt{'quantity'} || 1,
+  };
+
+  #start_date and contract_end
+  foreach my $date_field (qw( start_date contract_end )) {
+    if ( $opt{$date_field} =~ /^(\d+)$/ ) {
+      $cust_pkg->$date_field( $opt{$date_field} );
+    } elsif ( $opt{$date_field} ) {
+      $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
+    }
+  }
+
+  #especially this part for custom pkg price
+  # (false laziness w/cust_pkg/Import.pm)
+  my $s = $opt{'setup_fee'};
+  my $r = $opt{'recur_fee'};
+  my $part_pkg = $cust_pkg->part_pkg;
+  if (    ( length($s) && $s != $part_pkg->option('setup_fee') )
+       or ( length($r) && $r != $part_pkg->option('recur_fee') )
+     )
+  {
+    my $custom_part_pkg = $part_pkg->clone;
+    $custom_part_pkg->disabled('Y');
+    my %options = $part_pkg->options;
+    $options{'setup_fee'} = $s if length($s);
+    $options{'recur_fee'} = $r if length($r);
+    my $error = $custom_part_pkg->insert( options=>\%options );
+    return ( 'error' => "error customizing package: $error" ) if $error;
+    $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
+  }
+
+  my %order_pkg = ( 'cust_pkg' => $cust_pkg );
+
+  my @loc_fields = qw( address1 address2 city county state zip country );
+  if ( grep length($opt{$_}), @loc_fields ) {
+     $order_pkg{'cust_location'} = new FS::cust_location {
+       map { $_ => $opt{$_} } @loc_fields, 'custnum'
+     };
+  }
+
+  $order_pkg{'invoice_details'} = $opt{'invoice_details'}
+    if $opt{'invoice_details'};
+
+  my $error = $cust_main->order_pkg( %order_pkg );
+
+  #if ( $error ) {
+    return { 'error'  => $error,
+             #'pkgnum' => '',
+           };
+  #} else {
+  #  return { 'error'  => '',
+  #           #cust_main->order_pkg doesn't actually have a way to return pkgnum
+  #           #'pkgnum' => $pkgnum,
+  #         };
+  #}
+
+}
+
 =item change_package_location
 
 Updates package location. Takes a list of keys and values 
-as paramters with the following keys: 
+as parameters with the following keys: 
 
 pkgnum
 
@@ -719,7 +890,205 @@ sub bill_now {
 }
 
 
-#next.. Advertising sources?
+#next.. Delete Advertising sources?
+
+=item list_advertising_sources OPTION => VALUE, ...
+
+Lists all advertising sources.
+
+=over
+
+=item secret
+
+API Secret
+
+=back
+
+Example:
+
+  my $result = FS::API->list_advertising_sources(
+    'secret'  => 'sharingiscaring',
+  );
+
+  if ( $result->{'error'} ) {
+    die $result->{'error'};
+  } else {
+    # list advertising sources returns an array of hashes for sources.
+    print Dumper($result->{'sources'});
+  }
+
+=cut
+
+#list_advertising_sources
+sub list_advertising_sources {
+  my( $class, %opt ) = @_;
+  return _shared_secret_error() unless _check_shared_secret($opt{secret});
+
+  my @sources = qsearch('part_referral', {}, '', "")
+    or return { 'error' => 'No referrals' };
+
+  my $return = {
+    'sources'       => [ map $_->hashref, @sources ],
+  };
+
+  $return;
+}
+
+=item add_advertising_source OPTION => VALUE, ...
+
+Add a new advertising source.
+
+=over
+
+=item secret
+
+API Secret
+
+=item referral
+
+Referral name
+
+=item disabled
+
+Referral disabled, Y for disabled or nothing for enabled
+
+=item agentnum
+
+Agent ID number
+
+=item title
+
+External referral ID
+
+=back
+
+Example:
+
+  my $result = FS::API->add_advertising_source(
+    'secret'     => 'sharingiscaring',
+    'referral'   => 'test referral',
+
+    #optional
+    'disabled'   => 'Y',
+    'agentnum'   => '2', #agent id number
+    'title'      => 'test title',
+  );
+
+  if ( $result->{'error'} ) {
+    die $result->{'error'};
+  } else {
+    # add_advertising_source returns new source upon success.
+    print Dumper($result);
+  }
+
+=cut
+
+#add_advertising_source
+sub add_advertising_source {
+  my( $class, %opt ) = @_;
+  return _shared_secret_error() unless _check_shared_secret($opt{secret});
+
+  use FS::part_referral;
+
+  my $new_source = $opt{source};
+
+  my $source = new FS::part_referral $new_source;
+
+  my $error = $source->insert;
+
+  my $return = {$source->hash};
+  $return = { 'error' => $error, } if $error;
+
+  $return;
+}
+
+=item edit_advertising_source OPTION => VALUE, ...
+
+Edit a advertising source.
+
+=over
+
+=item secret
+
+API Secret
+
+=item refnum
+
+Referral number to edit
+
+=item source
+
+hash of edited source fields.
+
+=over
+
+=item referral
+
+Referral name
+
+=item disabled
+
+Referral disabled, Y for disabled or nothing for enabled
+
+=item agentnum
+
+Agent ID number
+
+=item title
+
+External referral ID
+
+=back
+
+=back
+
+Example:
+
+  my $result = FS::API->edit_advertising_source(
+    'secret'     => 'sharingiscaring',
+    'refnum'     => '4', # referral number to edit
+    'source'     => {
+       #optional
+       'referral'   => 'test referral',
+       'disabled'   => 'Y',
+       'agentnum'   => '2', #agent id number
+       'title'      => 'test title',
+    }
+  );
+
+  if ( $result->{'error'} ) {
+    die $result->{'error'};
+  } else {
+    # edit_advertising_source returns updated source upon success.
+    print Dumper($result);
+  }
+
+=cut
+
+#edit_advertising_source
+sub edit_advertising_source {
+  my( $class, %opt ) = @_;
+  return _shared_secret_error() unless _check_shared_secret($opt{secret});
+
+  use FS::part_referral;
+
+  my $refnum = $opt{refnum};
+  my $source = $opt{source};
+
+  my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
+  my $new = new FS::part_referral { $old->hash };
+
+  foreach my $key (keys %$source) {
+    $new->$key($source->{$key});
+  }
+
+  my $error = $new->replace;
+
+  my $return = {$new->hash};
+  $return = { 'error' => $error, } if $error;
+
+  $return;
+}
 
 
 ##
index 161e466..471e32a 100644 (file)
@@ -291,6 +291,7 @@ tie my %rights, 'Tie::IxHash',
     { rightname=> 'List rating data', desc=>'Usage reports', global=>1 },
     'Billing event reports',
     'Receivables report',
+    'Basic payment and refund reports',
     'Financial reports',
     { rightname=>'Send reports to customers', global=>1 },
     { rightname=> 'List inventory', global=>1 },
@@ -329,7 +330,7 @@ tie my %rights, 'Tie::IxHash',
     'Usage: Unrateable CDRs',
     'Usage: Time worked',
     #gone in 4.x as a distinct ACL (for now?) { rightname=>'Employees: Commission Report', global=>1 },
-    { rightname=>'Employees: Audit Report', global=>1 },
+    { rightname=>'Employee Reports', global=>1 },
 
     #{ rightname => 'List customers of all agents', global=>1 },
   ],
index 5c86b78..30ab96b 100644 (file)
@@ -882,6 +882,7 @@ sub payment_info {
     if ($cust_payby) {
       $return{payname} = $cust_payby->payname
                          || ( $cust_main->first. ' '. $cust_main->get('last') );
+      $return{custpaybynum} = $cust_payby->custpaybynum;
 
       if ( $cust_payby->payby =~ /^(CARD|DCRD)$/ ) {
         $return{card_type} = cardtype($cust_payby->payinfo);
@@ -980,6 +981,7 @@ sub validate_payment {
   #false laziness w/process/payment.cgi
   my $payinfo;
   my $paycvv = '';
+  my $replace_cust_payby;
   if ( $payby eq 'CHEK' || $payby eq 'DCHK' ) {
   
     $p->{'payinfo1'} =~ /^([\dx]+)$/
@@ -994,6 +996,7 @@ sub validate_payment {
     foreach my $cust_payby ($cust_main->cust_payby('CHEK','DCHK')) {
       if ( $cust_payby->paymask eq $payinfo ) {
         $payinfo = $cust_payby->payinfo;
+        $replace_cust_payby = $cust_payby;
         $achonfile = 1;
         last;
       }
@@ -1014,6 +1017,7 @@ sub validate_payment {
     foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) {
       if ( $cust_payby->paymask eq $payinfo ) {
         $payinfo = $cust_payby->payinfo;
+        $replace_cust_payby = $cust_payby;
         $onfile = 1;
         last;
       }
@@ -1055,6 +1059,8 @@ sub validate_payment {
     'CHEK' => [ qw( ss paytype paystate stateid stateid_state payip ) ],
   );
 
+  my %replace = ( 'replace' => $replace_cust_payby, );
+
   my $card_type = '';
   $card_type = cardtype($payinfo) if $payby eq 'CARD';
 
@@ -1063,7 +1069,7 @@ sub validate_payment {
     'amount'         => sprintf('%.2f', $amount),
     'payby'          => $payby,
     'payinfo'        => $payinfo,
-    'paymask'        => $cust_main->mask_payinfo( $payby, $payinfo ),
+    'paymask'        => FS::payinfo_Mixin->mask_payinfo( $payby, $payinfo ),
     'card_type'      => $card_type,
     'paydate'        => $p->{'year'}. '-'. $p->{'month'}. '-01',
     'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'},
@@ -1076,6 +1082,7 @@ sub validate_payment {
     'payname'        => $payname,
     'discount_term'  => $discount_term,
     'pkgnum'         => $session->{'pkgnum'},
+    %replace,
     map { $_ => $p->{$_} } ( @{ $payby2fields{$payby} },
                              qw( save auto ),
                            )
@@ -1158,6 +1165,7 @@ sub do_process_payment {
 
     my $error = $cust_main->save_cust_payby(
       'payment_payby' => $payby,
+      'replace'       => $validate->{'replace'}, # cust_payby object to replace
       %saveopt
     );
 
index d41cc74..ed72354 100644 (file)
@@ -898,6 +898,14 @@ my $validate_email = sub { $_[0] =~
   },
 
   {
+    'key'         => 'email-to-voice_domain',
+    'section'     => 'email_to_voice_services',
+    'description' => 'The domain name that phone numbers will be attached to for sending email to voice emails via a 3rd party email to voice service.  You will get this domain from your email to voice service provider.  This is utilized on the email customer page or when using the email to voice billing event action.  There you will be able to select the phone number for the email to voice service.',
+    'type'        => 'text',
+    'per_agent'   => 1,
+  },
+
+  {
     'key'         => 'next-bill-ignore-time',
     'section'     => 'billing',
     'description' => 'Ignore the time portion of next bill dates when billing, matching anything from 00:00:00 to 23:59:59 on the billing day.',
@@ -4302,6 +4310,7 @@ and customer address. Include units.',
                        ''       => 'Numeric only',
                        '\d{7}'  => 'Numeric only, exactly 7 digits',
                        'ww?d+'  => 'Numeric with one or two letter prefix',
+                       'd+-w'   => 'Numeric with a dash and one letter suffix',
                      ],
   },
 
@@ -4479,7 +4488,7 @@ and customer address. Include units.',
     'section'     => 'addresses',
     'description' => 'The year to use in census tract lookups.  NOTE: you need to select 2012 or 2013 for Year 2010 Census tract codes.  A selection of 2011 provides Year 2000 Census tract codes.  Use the freeside-censustract-update tool if exisitng customers need to be changed.',
     'type'        => 'select',
-    'select_enum' => [ qw( 2013 2012 2011 ) ],
+    'select_enum' => [ qw( 2017 2016 2015 ) ],
   },
 
   {
@@ -5788,8 +5797,8 @@ and customer address. Include units.',
 
   {
     'key'         => 'logout-timeout',
-    'section'     => 'UI',
-    'description' => 'If set, automatically log users out of the backoffice after this many minutes.',
+    'section'     => 'deprecated',
+    'description' => 'Deprecated.  Used to automatically log users out of the backoffice after this many minutes.  Set session timeouts in employee groups instead.',
     'type'       => 'text',
   },
   
index 7d868c8..5276565 100644 (file)
@@ -25,7 +25,7 @@ sub backup {
 
   my $ext;
   if ( driver_name eq 'Pg' ) {
-    system("pg_dump -Fc $database >/var/tmp/$database.Pg");
+    system("pg_dump -Fc -T h_cdr -T h_queue -T h_queue_arg $database >/var/tmp/$database.Pg");
     $ext = 'Pg';
   } elsif ( driver_name eq 'mysql' ) {
     system("mysqldump $database >/var/tmp/$database.sql");
index 01ea0b5..077f23c 100644 (file)
@@ -31,6 +31,8 @@ sub rt_daily {
   my $system = $FS::TicketSystem::system;
   return if !defined($system) || $system ne 'RT_Internal';
 
+  system('/opt/rt3/sbin/rt-fulltext-indexer --quiet --limit 5400 &');
+
   # if -d or -y is in use, bail out.  There's no reliable way to tell RT 
   # to use an alternate system time.
   if ( $opt{'d'} or $opt{'y'} ) {
index 956ea62..7bdb605 100644 (file)
@@ -262,6 +262,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::cust_category;
   use FS::prospect_main;
   use FS::contact;
+  use FS::contact::Import;
   use FS::phone_type;
   use FS::svc_pbx;
   use FS::discount;
index f2e9e6f..479f9b1 100644 (file)
@@ -2647,7 +2647,7 @@ sub ut_currency {
 =item ut_text COLUMN
 
 Check/untaint text.  Alphanumerics, spaces, and the following punctuation
-symbols are currently permitted: ! @ # $ % & ( ) - + ; : ' " , . ? / = [ ] < >
+symbols are currently permitted: ! @ # $ % & ( ) - + ; : ' " , . ? / = [ ] < > ~
 May not be null.  If there is an error, returns the error, otherwise returns
 false.
 
@@ -2661,7 +2661,7 @@ sub ut_text {
   # \p{Word} = alphanumerics, marks (diacritics), and connectors
   # see perldoc perluniprops
   $self->getfield($field)
-    =~ /^([\p{Word} \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>$money_char]+)$/
+    =~ /^([\p{Word} \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>\~$money_char]+)$/
       or return gettext('illegal_or_empty_text'). " $field: ".
                  $self->getfield($field);
   $self->setfield($field,$1);
index d7c7452..d347c06 100644 (file)
@@ -2335,6 +2335,7 @@ sub tables_hashref {
         'taxratelocationnum', 'serial',  '',     '', '', '', 
         'data_vendor',        'varchar', 'NULL', $char_d, '', '',
         'geocode',            'varchar', '',     20,      '', '', 
+        'district',           'varchar', 'NULL', $char_d, '', '',
         'city',               'varchar', 'NULL', $char_d, '', '',
         'county',             'varchar', 'NULL', $char_d, '', '',
         'state',              'char',    'NULL',       2, '', '', 
@@ -5796,6 +5797,25 @@ sub tables_hashref {
                         ],
     },
 
+    'access_user_session_log' => {
+      'columns' => [
+        'sessionlognum', 'serial', '',      '', '', '',
+        'usernum',          'int', '',      '', '', '',
+        'start_date',  @date_type,              '', '',
+        'last_date',   @date_type,              '', '',
+        'logout_date', @date_type,              '', '',
+        'logout_type',  'varchar', '', $char_d, '', '',
+      ],
+      'primary_key' => 'sessionlognum',
+      'unique'       => [],
+      'index'        => [],
+      'foreign_keys' => [
+                          { columns    => [ 'usernum' ],
+                            table      => 'access_user',
+                          },
+                        ],
+    },
+
     'access_user' => {
       'columns' => [
         'usernum',             'serial',     '',      '', '', '',
@@ -5843,8 +5863,9 @@ sub tables_hashref {
 
     'access_group' => {
       'columns' => [
-        'groupnum',   'serial', '',      '', '', '',
-        'groupname', 'varchar', '', $char_d, '', '',
+        'groupnum',        'serial',     '',      '', '', '',
+        'groupname',      'varchar',     '', $char_d, '', '',
+        'session_timeout',    'int', 'NULL',      '', '', '',
       ],
       'primary_key' => 'groupnum',
       'unique' => [ [ 'groupname' ] ],
@@ -6995,7 +7016,7 @@ sub tables_hashref {
         'vendor_order_status',  'varchar', 'NULL', $char_d,  '', '',
         'endpoint_ip_addr',     'varchar', 'NULL', 40, '', '',
         'endpoint_mac_addr',    'varchar', 'NULL', 12, '', '',
-        'internal_circuit_id',  'varchar',     '', 64, '', '',
+        'internal_circuit_id',  'varchar', 'NULL', 64, '', '',
       ],
       'primary_key' => 'svcnum',
       'unique'      => [],
index 92ca2ce..1f0c166 100644 (file)
@@ -263,7 +263,7 @@ sub make_taxlines {
     # create a tax rate location if there isn't one yet
     my $taxname = $tax_data->{descript};
     my $tax_rate = FS::tax_rate->new({
-        data_vendor   => 'compliance solutions',
+        data_vendor   => 'compliance_solutions',
         taxname       => $taxname,
         taxclassnum   => '',
         taxauth       => $tax_data->{'taxauthtype'}, # federal / state / city / district
@@ -277,13 +277,16 @@ sub make_taxlines {
     $tax_rate = $tax_rate->replace_old;
 
     my $tax_rate_location = FS::tax_rate_location->new({
-        data_vendor => 'compliance solutions',
-        state       => $tax_data->{'state'},
-        country     => $tax_data->{'country'},
+        data_vendor => 'compliance_solutions',
         geocode     => $tax_data->{'geocode'},
+        district    => $tax_data->{'geo_district'},
+        state       => $tax_data->{'geo_state'},
+        county      => $tax_data->{'geo_county'},
+        country     => 'US',
     });
     $error = $tax_rate_location->find_or_insert;
-    die "error inserting tax_rate_location record: $error\n"
+    die 'error inserting tax_rate_location record for '.  $tax_data->{state}.
+        '/'. $tax_data->{country}. ' ('. $tax_data->{'geocode'}. "): $error\n"
       if $error;
     $tax_rate_location = $tax_rate_location->replace_old;
 
index dbe9a99..5f5d229 100644 (file)
@@ -23,7 +23,8 @@ sub add_sale {
   my ($self, $cust_bill_pkg) = @_;
 
   my $part_item = $cust_bill_pkg->part_X;
-  my $location = $cust_bill_pkg->tax_location;
+  my $location = $cust_bill_pkg->tax_location
+    or return;
   my $custnum = $self->{cust_main}->custnum;
 
   push @{ $self->{items} }, $cust_bill_pkg;
index 1a00cda..356f5f3 100644 (file)
@@ -14,7 +14,7 @@ our $DEBUG = 1; # prints progress messages
 #   $DEBUG = 2; # prints decoded request and response (noisy, be careful)
 #   $DEBUG = 3; # prints raw response from the API, ridiculously unreadable
 
-our $json = Cpanel::JSON::XS->new->pretty(1);
+our $json = Cpanel::JSON::XS->new->pretty(0)->shrink(1);
 
 our %taxproduct_cache;
 
@@ -328,13 +328,14 @@ sub make_taxlines {
     return;
   }
 
-  warn "sending SureTax request\n" if $DEBUG;
+  warn "encoding SureTax request\n" if $DEBUG;
   my $request_json = $json->encode($request);
   warn $request_json if $DEBUG > 1;
 
   my $host = $conf->config('suretax-hostname');
   $host ||= 'testapi.taxrating.net';
 
+  warn "sending SureTax request\n" if $DEBUG;
   # We are targeting the "V05" interface:
   # - accepts both telecom and general sales transactions
   # - produces results broken down by "invoice" (Freeside line item)
@@ -346,8 +347,11 @@ sub make_taxlines {
     'Accept'        => 'application/json',
   );
 
+  warn 'received SureTax response: '. $http_response->status_line. "\n"
+    if $DEBUG;
+  die $http_response->status_line. "\n" unless $http_response->is_success;
+
   my $raw_response = $http_response->content;
-  warn "received response\n" if $DEBUG;
   warn $raw_response if $DEBUG > 2;
   my $response;
   if ( $raw_response =~ /^<\?xml/ ) {
@@ -356,8 +360,10 @@ sub make_taxlines {
     $response = XMLin( $raw_response );
     $raw_response = $response->{content};
   }
+
+  warn "decoding SureTax response\n" if $DEBUG;
   $response = eval { $json->decode($raw_response) }
-    or die "$raw_response\n";
+    or die "Can't JSON-decode response: $raw_response\n";
 
   # documentation implies this might be necessary
   $response = $response->{'d'} if exists $response->{'d'};
@@ -375,6 +381,7 @@ sub make_taxlines {
   }
 
   return if !$response->{GroupList};
+  warn "creating FS objects from SureTax data\n" if $DEBUG;
   foreach my $taxable ( @{ $response->{GroupList} } ) {
     # each member of this array here corresponds to what SureTax calls an
     # "invoice" and we call a "line item". The invoice number is 
@@ -420,6 +427,7 @@ sub make_taxlines {
       });
     }
   }
+  warn "TaxEngine/suretax.pm make_taxlines done; returning FS objects\n" if $DEBUG;
   return @elements;
 }
 
index 6edec90..0069e20 100644 (file)
@@ -497,6 +497,10 @@ sub upgrade_data {
     #mark certain taxes as system-maintained,
     # and fix whitespace
     'cust_main_county' => [],
+
+    #'compliance solutions' -> 'compliance_solutions'
+    'tax_rate' => [],
+    'tax_rate_location' => [],
   ;
 
   \%hash;
index 409b441..155da73 100644 (file)
@@ -155,6 +155,7 @@ sub _upgrade_data { # class method
     'Refund payment'  => [ 'Refund credit card payment', 'Refund Echeck payment' ],
     'Regular void'    => [ 'Void payments' ],
     'Unvoid'          => [ 'Unvoid payments', 'Unvoid invoices' ],
+    'Employees: Audit Report' => [ 'Employee Reports' ],
   );
 
   foreach my $oldright (keys %migrate) {
@@ -233,9 +234,7 @@ sub _upgrade_data { # class method
                             'Usage: Unrateable CDRs',
                           ],
     'Provision customer service' => [ 'Edit password' ],
-    'Financial reports' => [ 'Employees: Commission Report',
-                             'Employees: Audit Report',
-                           ],
+    'Financial reports' => 'Employee Reports',
     'Change customer package' => 'Detach customer package',
     'Services: Accounts' => 'Services: Cable Subscribers',
     'Bulk change customer packages' => 'Bulk move customer services',
@@ -261,6 +260,7 @@ sub _upgrade_data { # class method
     'List customers' => 'Customers: Customer churn report',
     'Edit customer note' => 'Delete customer note',
     'Edit customer' => 'Edit customer invoice terms',
+    'Financial reports' => 'Basic payment and refund reports',
   );
 
 #  foreach my $old_acl ( keys %onetime ) {
diff --git a/FS/FS/access_user_session_log.pm b/FS/FS/access_user_session_log.pm
new file mode 100644 (file)
index 0000000..d28ec85
--- /dev/null
@@ -0,0 +1,124 @@
+package FS::access_user_session_log;
+use base qw( FS::Record );
+
+use strict;
+#use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::access_user_session_log - Object methods for access_user_session_log records
+
+=head1 SYNOPSIS
+
+  use FS::access_user_session_log;
+
+  $record = new FS::access_user_session_log \%hash;
+  $record = new FS::access_user_session_log { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::access_user_session_log object represents an log of an employee session.
+FS::access_user_session_log inherits from FS::Record.  The following fields
+are currently supported:
+
+=over 4
+
+=item sessionlognum
+
+primary key
+
+=item usernum
+
+usernum
+
+=item start_date
+
+start_date
+
+=item last_date
+
+last_date
+
+=item logout_date
+
+logout_date
+
+=item logout_type
+
+logout_type
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new log entry.  To add the entry to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'access_user_session_log'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid log entry.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('usernum')
+    || $self->ut_numbern('start_date')
+    || $self->ut_numbern('last_date')
+    || $self->ut_numbern('logout_date')
+    || $self->ut_text('logout_type')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>
+
+=cut
+
+1;
+
index 568d46f..44c5388 100644 (file)
@@ -10,6 +10,7 @@ use FS::Record qw( qsearch qsearchs dbh );
 use FS::Cursor;
 use FS::contact_phone;
 use FS::contact_email;
+use FS::contact::Import;
 use FS::queue;
 use FS::phone_type; #for cgi_contact_fields
 use FS::cust_contact;
diff --git a/FS/FS/contact/Import.pm b/FS/FS/contact/Import.pm
new file mode 100644 (file)
index 0000000..26bdcfa
--- /dev/null
@@ -0,0 +1,161 @@
+package FS::contact::Import;
+
+use strict;
+use vars qw( $DEBUG ); #$conf );
+use Data::Dumper;
+use FS::Misc::DateTime qw( parse_datetime );
+use FS::Record qw( qsearchs );
+use FS::contact;
+use FS::cust_main;
+
+$DEBUG = 0;
+
+=head1 NAME
+
+FS::contact::Import - Batch contact importing
+
+=head1 SYNOPSIS
+
+  use FS::contact::Import;
+
+  #import
+  FS::contact::Import::batch_import( {
+    file      => $file,      #filename
+    type      => $type,      #csv or xls
+    format    => $format,    #default
+    agentnum  => $agentnum,
+    job       => $job,       #optional job queue job, for progressbar updates
+    pkgbatch  => $pkgbatch,  #optional batch unique identifier
+  } );
+  die $error if $error;
+
+  #ajax helper
+  use FS::UI::Web::JSRPC;
+  my $server =
+    new FS::UI::Web::JSRPC 'FS::contact::Import::process_batch_import', $cgi;
+  print $server->process;
+
+=head1 DESCRIPTION
+
+Batch contact importing.
+
+=head1 SUBROUTINES
+
+=item process_batch_import
+
+Load a batch import as a queued JSRPC job
+
+=cut
+
+sub process_batch_import {
+  my $job = shift;
+  my $param = shift;
+  warn Dumper($param) if $DEBUG;
+  
+  my $files = $param->{'uploaded_files'}
+    or die "No files provided.\n";
+
+  my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
+
+  my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
+  #my $dir = '/usr/local/etc/freeside/cache.'. $FS::UID::datasrc. '/';
+  my $file = $dir. $files{'file'};
+
+  my $type;
+  if ( $file =~ /\.(\w+)$/i ) {
+    $type = lc($1);
+  } else {
+    #or error out???
+    warn "can't parse file type from filename $file; defaulting to CSV";
+    $type = 'csv';
+  }
+
+  my $error =
+    FS::contact::Import::batch_import( {
+      job      => $job,
+      file     => $file,
+      type     => $type,
+      agentnum => $param->{'agentnum'},
+      'format' => $param->{'format'},
+    } );
+
+  unlink $file;
+
+  die "$error\n" if $error;
+
+}
+
+=item batch_import
+
+=cut
+
+my %formatfields = (
+  'default'      => [ qw( custnum last first title comment selfservice_access emailaddress phonetypenum1 phonetypenum3 phonetypenum2 ) ],
+);
+
+sub _formatfields {
+  \%formatfields;
+}
+
+## not tested but maybe allow 2nd format to attach location in the future
+my %import_options = (
+  'table'         => 'contact',
+
+  'preinsert_callback'  => sub {
+    my($record, $param) = @_;
+    my @location_params = grep /^location\./, keys %$param;
+    if (@location_params) {
+      my $cust_location = FS::cust_location->new({
+          'custnum' => $record->custnum,
+      });
+      foreach my $p (@location_params) {
+        $p =~ /^location.(\w+)$/;
+        $cust_location->set($1, $param->{$p});
+      }
+
+      my $error = $cust_location->find_or_insert; # this avoids duplicates
+      return "error creating location: $error" if $error;
+      $record->set('locationnum', $cust_location->locationnum);
+    }
+    '';
+  },
+
+);
+
+sub _import_options {
+  \%import_options;
+}
+
+sub batch_import {
+  my $opt = shift;
+
+  my $iopt = _import_options;
+  $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
+
+  my $format = delete $opt->{'format'};
+
+  my $formatfields = _formatfields();
+    die "unknown format $format" unless $formatfields->{$format};
+
+  my @fields;
+  foreach my $field ( @{ $formatfields->{$format} } ) {
+    push @fields, $field;
+  }
+
+  $opt->{'fields'} = \@fields;
+
+  FS::Record::batch_import( $opt );
+
+}
+
+=head1 BUGS
+
+Not enough documentation.
+
+=head1 SEE ALSO
+
+L<FS::contact>
+
+=cut
+
+1;
\ No newline at end of file
index f8157c4..925eb4e 100644 (file)
@@ -4617,6 +4617,8 @@ PAYBYLOOP:
         next if grep(/^$field$/, qw( custpaybynum payby weight ) );
         next if grep(/^$field$/, @preserve );
         next PAYBYLOOP unless $new->get($field) eq $cust_payby->get($field);
+        # check if paymask exists,  if so stop and don't save, no need for a duplicate.
+        return '' if $new->get('paymask') eq $cust_payby->get('paymask');
       }
       # now check fields that can replace if one value is blank
       my $replace = 0;
index d62120b..f16752b 100644 (file)
@@ -6,6 +6,7 @@ use vars qw( $realtime_bop_decline_quiet ); #ugh
 use Carp;
 use Data::Dumper;
 use Business::CreditCard 0.35;
+use Business::OnlinePayment;
 use FS::UID qw( dbh myconnect );
 use FS::Record qw( qsearch qsearchs );
 use FS::payby;
index 6464761..9624529 100644 (file)
@@ -325,6 +325,7 @@ sub batch_import {
     my %svc_x = ();
     my %bill_location = ();
     my %ship_location = ();
+    my $cust_payby = '';
     foreach my $field ( @fields ) {
 
       if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
@@ -409,17 +410,24 @@ sub batch_import {
 
       if ( $cust_main{'payinfo'} =~ /^\s*(\d+\@[\d\.]+)\s*$/ ) {
 
-        $cust_main{'payby'}   = 'CHEK';
-        $cust_main{'payinfo'} = $1;
+        delete $cust_main{'payinfo'};
 
-      } else {
+        $cust_payby = new FS::cust_payby {
+          'payby'   => 'CHEK',
+          'payinfo' => $1,
+        };
 
-        $cust_main{'payby'} = 'CARD';
+      } elsif ($cust_main{'payinfo'} =~ /^\s*([AD]?)(.*)\s*$/) {
 
-        if ($cust_main{'payinfo'} =~ /^\s*([AD]?)(.*)\s*$/) {
-          $cust_main{'payby'} = 'DCRD' if $1 eq 'D';
-          $cust_main{'payinfo'} = $2;
-        }
+        delete $cust_main{'payinfo'};
+
+        $cust_payby = new FS::cust_payby {
+          'payby'   => ($1 eq 'D') ? 'DCRD' : 'CARD',
+          'payinfo' => $2,
+          'paycvv'  => delete $cust_main{'paycvv'},
+          'paydate' => delete $cust_main{'paydate'},
+          'payname' => $cust_main{'first'}. ' '. $cust_main{'last'},
+        };
 
       }
 
@@ -502,7 +510,10 @@ sub batch_import {
       $hash{$cust_pkg} = \@svc_x;
     }
 
-    my $error = $cust_main->insert( \%hash, $invoicing_list );
+    my %options = ('invoicing_list' => $invoicing_list);
+    $options{'cust_payby'} = [ $cust_payby ] if $cust_payby;
+
+    my $error = $cust_main->insert( \%hash, %options );
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
index d668094..2ec87cd 100644 (file)
@@ -93,7 +93,7 @@ sub smart_search {
     my $phonenum = "$1$2$3";
     #my $extension = $4;
 
-    #cust_main phone numbers
+    #cust_main phone numbers and contact phone number
     push @cust_main, qsearch( {
       'table'   => 'cust_main',
       'hashref' => { %options },
@@ -102,20 +102,12 @@ sub smart_search {
                          join(' OR ', map "$_ = '$phonen'",
                                           qw( daytime night mobile fax )
                              ).
+                          " OR phonenum = '$phonenum' ".
                      ' ) '.
                      " AND $agentnums_sql", #agent virtualization
+      'addl_from' => ' left join cust_contact using (custnum) left join contact_phone using (contactnum) ',
     } );
 
-    #contact phone numbers
-    push @cust_main,
-      grep $agentnums_href->{$_->agentnum}, #agent virt
-        grep $_, #skip contacts that don't have cust_main records
-          map $_->contact->cust_main,
-            qsearch({
-                      'table'   => 'contact_phone',
-                      'hashref' => { 'phonenum' => $phonenum },
-                   });
-
     unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
       #try looking for matches with extensions unless one was specified
 
@@ -136,45 +128,40 @@ sub smart_search {
   } 
   
   
-  if ( $search =~ /@/ ) { #email address
-
-      # invoicing email address
-      push @cust_main,
-        grep $agentnums_href->{$_->agentnum}, #agent virt
-         map $_->cust_main,
-             qsearch( {
-                        'table'     => 'cust_main_invoice',
-                        'hashref'   => { 'dest' => $search },
-                      }
-                    );
-
-      # contact email address
-      push @cust_main,
-        grep $agentnums_href->{$_->agentnum}, #agent virt
-          grep $_, #skip contacts that don't have cust_main records
-           map $_->contact->cust_main,
-             qsearch( {
-                        'table'     => 'contact_email',
-                        'hashref'   => { 'emailaddress' => $search },
-                      }
-                    );
+  if ( $search =~ /@/ ) { #email address from cust_main_invoice and contact_email
+
+    push @cust_main, qsearch( {
+      'table'   => 'cust_main',
+      'hashref' => { %options },
+      'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
+                     ' ( '.
+                         join(' OR ', map "$_ = '$search'",
+                                          qw( dest emailaddress )
+                             ).
+                     ' ) '.
+                     " AND $agentnums_sql", #agent virtualization
+      'addl_from' => ' left join cust_main_invoice using (custnum) left join cust_contact using (custnum) left join contact_email using (contactnum) ',
+    } );
 
   # custnum search (also try agent_custid), with some tweaking options if your
   # legacy cust "numbers" have letters
-  } elsif ( $search =~ /^\s*(\d+)\s*$/
-         || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
-              && $search =~ /^\s*(\w\w?\d+)\s*$/
-            )
-         || ( $conf->config('cust_main-custnum-display_special')
-           # it's not currently possible for special prefixes to contain
-           # digits, so just strip off any alphabetic prefix and match 
-           # the rest to custnum
-              && $search =~ /^\s*[[:alpha:]]*(\d+)\s*$/
-            )
-         || ( $conf->exists('address1-search' )
-              && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
-            )
-     )
+  } elsif (    $search =~ /^\s*(\d+)\s*$/
+            or ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
+                 && $search =~ /^\s*(\w\w?\d+)\s*$/
+               )
+            or ( $conf->config('cust_main-agent_custid-format') eq 'd+-w'
+                 && $search =~ /^\s*(\d+-\w)\s*$/
+               )
+            or ( $conf->config('cust_main-custnum-display_special')
+                 # it's not currently possible for special prefixes to contain
+                 # digits, so just strip off any alphabetic prefix and match 
+                 # the rest to custnum
+                 && $search =~ /^\s*[[:alpha:]]*(\d+)\s*$/
+               )
+            or ( $conf->exists('address1-search' )
+                 && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
+               )
+          )
   {
 
     my $num = $1;
@@ -278,8 +265,8 @@ sub smart_search {
     } elsif ( ! $NameParse->parse($value) ) {
 
       my %name = $NameParse->components;
-      $first = $name{'given_name_1'} || $name{'initials_1'}; #wtf NameParse, Ed?
-      $last  = $name{'surname_1'};
+      $first = lc($name{'given_name_1'}) || $name{'initials_1'}; #wtf NameParse, Ed?
+      $last  = lc($name{'surname_1'});
 
     }
 
@@ -289,28 +276,18 @@ sub smart_search {
 
       #exact
       my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
-      $sql .= "( LOWER(cust_main.last) = $q_last AND LOWER(cust_main.first) = $q_first )";
+      $sql .= "( (LOWER(cust_main.last) = $q_last AND LOWER(cust_main.first) = $q_first)
+                 OR (LOWER(contact.last) = $q_last AND LOWER(contact.first) = $q_first) )";
 
-      #cust_main
+      #cust_main and contacts
       push @cust_main, qsearch( {
         'table'     => 'cust_main',
-        'hashref'   => \%options,
+        'select'    => 'cust_main.*, cust_contact.*, contact.contactnum, contact.last as contact_last, contact.first as contact_first, contact.title',
+        'hashref'   => { %options },
         'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
+        'addl_from' => ' left join cust_contact on cust_main.custnum = cust_contact.custnum left join contact using (contactnum) ',
       } );
 
-      #contacts
-      push @cust_main,
-        grep $agentnums_href->{$_->agentnum}, #agent virt
-          grep $_, #skip contacts that don't have cust_main records
-           map $_->cust_main,
-             qsearch( {
-                        'table'     => 'contact',
-                        'hashref'   => { 'first' => $first,
-                                          'last'  => $last,
-                                        }, 
-                      }
-                    );
-
       # or it just be something that was typed in... (try that in a sec)
 
     }
@@ -323,7 +300,9 @@ sub smart_search {
                 OR LOWER(cust_main.last)          = $q_value
                 OR LOWER(cust_main.company)       = $q_value
                 OR LOWER(cust_main.ship_company)  = $q_value
-            ";
+                OR LOWER(contact.first)           = $q_value
+                OR LOWER(contact.last)            = $q_value
+            )";
 
     #address1 (yes, it's a kludge)
     $sql .= "   OR EXISTS ( 
@@ -333,20 +312,12 @@ sub smart_search {
                           )"
       if $conf->exists('address1-search');
 
-    #contacts (look, another kludge)
-    $sql .= "   OR EXISTS ( SELECT 1 FROM contact
-                              WHERE (    LOWER(contact.first) = $q_value
-                                      OR LOWER(contact.last)  = $q_value
-                                    )
-                                AND contact.custnum IS NOT NULL
-                                AND contact.custnum = cust_main.custnum
-                          )
-              ) ";
-
     push @cust_main, qsearch( {
       'table'     => 'cust_main',
-      'hashref'   => \%options,
+      'select'    => 'cust_main.*, cust_contact.*, contact.contactnum, contact.last as contact_last, contact.first as contact_first, contact.title',
+      'hashref'   => { %options },
       'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
+      'addl_from' => 'left join cust_contact on cust_main.custnum = cust_contact.custnum left join contact using (contactnum) ',
     } );
 
     #no exact match, trying substring/fuzzy
@@ -872,10 +843,24 @@ sub search {
   ##
   # with referrals
   ##
-  if ( $params->{'with_referrals'} ) {
+  if ( $params->{with_referrals} =~ /^\s*(\d+)\s*$/ ) {
+
+    my $n = $1;
+  
+    # referral status
+    my $and_status = '';
+    if ( grep { $params->{referral_status} eq $_ } FS::cust_main->statuses() ) {
+      my $method = $params->{referral_status}. '_sql';
+      $and_status = ' AND '. FS::cust_main->$method();
+      $and_status =~ s/ cust_main\./ referred_cust_main./g;
+    }
+
     push @where,
-      ' EXISTS ( SELECT 1 FROM cust_main AS referred_cust_main
-                   WHERE cust_main.custnum = referred_cust_main.referral_custnum )';
+      " $n <= ( SELECT COUNT(*) FROM cust_main AS referred_cust_main
+                  WHERE cust_main.custnum = referred_cust_main.referral_custnum
+                    $and_status
+              )";
+
   }
 
   ##
@@ -1064,8 +1049,48 @@ sub search {
                  FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
                );
 
-  my(@extra_headers) = ();
-  my(@extra_fields)  = ();
+  my @extra_headers     = ();
+  my @extra_fields      = ();
+  my @extra_sort_fields = ();
+
+  ## search contacts
+  if ($params->{'contacts'}) {
+    my $contact_params = $params->{'contacts'};
+
+    $addl_from .=
+      ' LEFT JOIN cust_contact ON ( cust_main.custnum = cust_contact.custnum ) ';
+
+    if ($contact_params->{'contacts_firstname'} || $contact_params->{'contacts_lastname'}) {
+      $addl_from .= ' LEFT JOIN contact ON ( cust_contact.contactnum = contact.contactnum ) ';
+      my $first_query = " AND contact.first = '" . $contact_params->{'contacts_firstname'} . "'"
+        unless !$contact_params->{'contacts_firstname'};
+      my $last_query = " AND contact.last = '" . $contact_params->{'contacts_lastname'} . "'"
+        unless !$contact_params->{'contacts_lastname'};
+      $extra_sql .= " AND ( '1' $first_query $last_query )";
+    }
+
+    if ($contact_params->{'contacts_email'}) {
+      $addl_from .= ' LEFT JOIN contact_email ON ( cust_contact.contactnum = contact_email.contactnum ) ';
+      $extra_sql .= " AND ( contact_email.emailaddress = '" . $contact_params->{'contacts_email'} . "' )";
+    }
+
+    if ($contact_params->{'contacts_homephone'} || $contact_params->{'contacts_workphone'} || $contact_params->{'contacts_mobilephone'}) {
+      $addl_from .= ' LEFT JOIN contact_phone ON ( cust_contact.contactnum = contact_phone.contactnum ) ';
+      my $contacts_mobilephone;
+      foreach my $phone (qw( contacts_homephone contacts_workphone contacts_mobilephone )) {
+        (my $num = $contact_params->{$phone}) =~ s/\W//g;
+        if ( $num =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { $contact_params->{$phone} = "$1$2$3"; }
+      }
+      my $home_query = " AND ( contact_phone.phonetypenum = '2' AND contact_phone.phonenum = '" . $contact_params->{'contacts_homephone'} . "' )"
+        unless !$contact_params->{'contacts_homephone'};
+      my $work_query = " AND ( contact_phone.phonetypenum = '1' AND contact_phone.phonenum = '" . $contact_params->{'contacts_workphone'} . "' )"
+        unless !$contact_params->{'contacts_workphone'};
+      my $mobile_query = " AND ( contact_phone.phonetypenum = '3' AND contact_phone.phonenum = '" . $contact_params->{'contacts_mobilephone'} . "' )"
+        unless !$contact_params->{'contacts_mobilephone'};
+      $extra_sql .= " AND ( '1' $home_query $work_query $mobile_query )";
+    }
+
+  }
 
   if ($params->{'flattened_pkgs'}) {
 
@@ -1110,6 +1135,7 @@ sub search {
                                          my $p = $a[!.--$headercount. q!];
                                          $p;
                                         };!;
+      unshift @extra_sort_fields, '';
     }
 
   }
@@ -1125,21 +1151,23 @@ sub search {
 
     unshift @extra_headers, 'Referrals';
     unshift @extra_fields, 'num_referrals';
+    unshift @extra_sort_fields, 'num_referrals';
 
   }
 
   my $select = join(', ', @select);
 
   my $sql_query = {
-    'table'         => 'cust_main',
-    'select'        => $select,
-    'addl_from'     => $addl_from,
-    'hashref'       => {},
-    'extra_sql'     => $extra_sql,
-    'order_by'      => $orderby,
-    'count_query'   => $count_query,
-    'extra_headers' => \@extra_headers,
-    'extra_fields'  => \@extra_fields,
+    'table'             => 'cust_main',
+    'select'            => $select,
+    'addl_from'         => $addl_from,
+    'hashref'           => {},
+    'extra_sql'         => $extra_sql,
+    'order_by'          => $orderby,
+    'count_query'       => $count_query,
+    'extra_headers'     => \@extra_headers,
+    'extra_fields'      => \@extra_fields,
+    'extra_sort_fields' => \@extra_sort_fields,
   };
   $sql_query;
 
index 1955746..8b6569a 100644 (file)
@@ -262,6 +262,17 @@ sub cust_statuscolor {
     : '000000';
 }
 
+=item agent_name
+
+=cut
+
+sub agent_name {
+  my $self = shift;
+  $self->cust_linked
+    ? $self->cust_main->agent_name
+    : $self->cust_unlinked_msg;
+}
+
 =item prospect_sql
 
 =item active_sql
@@ -397,14 +408,21 @@ use Digest::SHA qw(sha1); # for duplicate checking
 sub email_search_result {
   my($class, $param) = @_;
 
+  my $conf = FS::Conf->new;
+  my $send_to_domain = $conf->config('email-to-voice_domain');
+
   my $msgnum = $param->{msgnum};
   my $from = delete $param->{from};
   my $subject = delete $param->{subject};
   my $html_body = delete $param->{html_body};
   my $text_body = delete $param->{text_body};
   my $to_contact_classnum = delete $param->{to_contact_classnum};
+  my $emailtovoice_name = delete $param->{emailtovoice_contact};
+
   my $error = '';
 
+  my $to = $emailtovoice_name . '@' . $send_to_domain unless !$emailtovoice_name;
+
   my $job = delete $param->{'job'}
     or die "email_search_result must run from the job queue.\n";
   
@@ -465,10 +483,14 @@ sub email_search_result {
       next; # unlinked object; nothing else we can do
     }
 
+my %to = {};
+if ($to) { $to{'to'} = $to; }
+
     my $cust_msg = $msg_template->prepare(
       'cust_main' => $cust_main,
       'object'    => $obj,
       'to_contact_classnum' => $to_contact_classnum,
+      %to,
     );
 
     # For non-cust_main searches, we avoid duplicates based on message
index b256dae..c70a679 100644 (file)
@@ -2622,9 +2622,9 @@ sub change {
     foreach my $old_discount ($self->cust_pkg_discount_active) {
       # don't remove the old discount, we may still need to bill that package.
       my $new_discount = new FS::cust_pkg_discount {
-        'pkgnum'      => $cust_pkg->pkgnum,
-        'discountnum' => $old_discount->discountnum,
-        'months_used' => $old_discount->months_used,
+        'pkgnum' => $cust_pkg->pkgnum,
+        map { $_ => $old_discount->$_() }
+          qw( discountnum months_used end_date usernum setuprecur ),
       };
       $error = $new_discount->insert;
       if ( $error ) {
index 63a9909..b827bcf 100644 (file)
@@ -105,6 +105,7 @@ my %formatfields = (
   'svc_phone'    => [qw( countrycode phonenum sip_password pin )],
   'svc_external' => [qw( id title )],
   'location'     => [qw( address1 address2 city state zip country )],
+  'quan_price'   => [qw( quantity setup_fee recur_fee invoice_details )],
 );
 
 sub _formatfields {
@@ -116,8 +117,11 @@ my %import_options = (
 
   'preinsert_callback'  => sub {
     my($record, $param) = @_;
-    my @location_params = grep /^location\./, keys %$param;
+
+    my @location_params = grep { /^location\./ && length($param->{$_}) }
+                            keys %$param;
     if (@location_params) {
+warn join('-', @location_params);
       my $cust_location = FS::cust_location->new({
           'custnum' => $record->custnum,
       });
@@ -130,12 +134,53 @@ my %import_options = (
       return "error creating location: $error" if $error;
       $record->set('locationnum', $cust_location->locationnum);
     }
+
+    $record->quantity( $param->{'quan_price.quantity'} )
+      if $param->{'quan_price.quantity'} > 0;
+    
+    my $s = $param->{'quan_price.setup_fee'};
+    my $r = $param->{'quan_price.recur_fee'};
+    my $part_pkg = $record->part_pkg;
+    if (    ( length($s) && $s != $part_pkg->option('setup_fee') )
+         or ( length($r) && $r != $part_pkg->option('recur_fee') )
+       )
+    {
+      my $custom_part_pkg = $part_pkg->clone;
+      $custom_part_pkg->disabled('Y');
+      my %options = $part_pkg->options;
+      $options{'setup_fee'} = $s if length($s);
+      $options{'recur_fee'} = $r if length($r);
+      my $error = $custom_part_pkg->insert( options=>\%options );
+      return "error customizing package: $error" if $error;
+      $record->pkgpart( $custom_part_pkg->pkgpart );
+    }
+
+
     '';
   },
 
   'postinsert_callback' => sub {
     my( $record, $param ) = @_;
 
+    if ( $param->{'quan_price.invoice_details'} ) {
+
+      my $weight = 0;
+      foreach my $detail (split(/\|/, $param->{'quan_price.invoice_details'})) {
+
+        my $cust_pkg_detail = new FS::cust_pkg_detail {
+          'pkgnum'     => $record->pkgnum,
+          'detail'     => $detail,
+          'detailtype' => 'I',
+          'weight'     => $weight++,
+        };
+
+        my $error = $cust_pkg_detail->insert;
+        return "error inserting invoice detail: $error" if $error;
+
+      }
+
+    }
+
     my $formatfields = _formatfields;
     foreach my $svc_x ( grep /^svc/, keys %$formatfields ) {
 
@@ -283,17 +328,20 @@ sub batch_import {
     };
   }
 
-  my $formatfields = _formatfields();
+  my @formats = split /-/, $format;
+  foreach my $f (@formats){
 
-  die "unknown format $format" unless $formatfields->{$format};
+    my $formatfields = _formatfields();
+    die "unknown format $format" unless $formatfields->{$f};
 
-  foreach my $field ( @{ $formatfields->{$format} } ) {
+    foreach my $field ( @{ $formatfields->{$f} } ) {
 
-    push @fields, sub {
-      my( $self, $value, $conf, $param ) = @_;
-      $param->{"$format.$field"} = $value;
-    };
+      push @fields, sub {
+        my( $self, $value, $conf, $param ) = @_;
+        $param->{"$f.$field"} = $value;
+      };
 
+    }
   }
 
   $opt->{'fields'} = \@fields;
index 1c23899..ded5715 100644 (file)
@@ -582,9 +582,11 @@ sub actions {
   my( $class, $eventtable ) = @_;
   (
     map  { $_ => $actions{$_} }
-    sort { $actions{$a}->{'default_weight'}<=>$actions{$b}->{'default_weight'} }
-       # || $actions{$a}->{'description'} cmp $actions{$b}->{'description'} }
-    $class->all_actions( $eventtable )
+    sort {
+         $actions{$a}->{'default_weight'} <=> $actions{$b}->{'default_weight'}
+      || $actions{$a}->{'description'}    cmp $actions{$b}->{'description'}
+    }
+      $class->all_actions( $eventtable )
   );
 
 }
diff --git a/FS/FS/part_event/Action/notice_to_emailtovoice.pm b/FS/FS/part_event/Action/notice_to_emailtovoice.pm
new file mode 100644 (file)
index 0000000..3eaa738
--- /dev/null
@@ -0,0 +1,84 @@
+package FS::part_event::Action::notice_to_emailtovoice;
+
+use strict;
+use base qw( FS::part_event::Action );
+use FS::Record qw( qsearchs );
+use FS::msg_template;
+use FS::Conf;
+
+sub description { 'Email a email to voice notice'; }
+
+sub eventtable_hashref {
+    {
+      'cust_main'      => 1,
+      'cust_bill'      => 1,
+      'cust_pkg'       => 1,
+      'cust_pay'       => 1,
+      'cust_pay_batch' => 1,
+      'cust_statement' => 1,
+      'svc_acct'       => 1,
+    };
+}
+
+sub option_fields {
+
+  #my $conf = new FS::Conf;
+  #my $to_domain = $conf->config('email-to-voice_domain');
+
+(
+    'to_name'   => { 'label'            => 'Address To',
+                     'type'             => 'select',
+                     'options'          => [ 'mobile', 'fax', 'daytime' ],
+                     'option_labels'    => { 'mobile'  => 'Mobile Phone #',
+                                             'fax'     => 'Fax #',
+                                             'daytime' => 'Day Time #',
+                                           },
+                     'post_field_label' => ' <font color="red">Make sure you have setup your email-to-voice_domain config option in your Configuration settings.</font>',
+                   },
+
+    'msgnum'    => { 'label'    => 'Template',
+                     'type'     => 'select-table',
+                     'table'    => 'msg_template',
+                     'name_col' => 'msgname',
+                     'hashref'  => { disabled => '' },
+                     'disable_empty' => 1,
+                },
+  );
+
+}
+
+sub default_weight { 56; } #?
+
+sub do_action {
+  my( $self, $object ) = @_;
+
+  my $conf = new FS::Conf;
+  my $to_domain = $conf->config('email-to-voice_domain')
+    or die "Can't send notice with out send-to-domain, being set in global config \n";
+
+  my $cust_main = $self->cust_main($object);
+
+  my $msgnum = $self->option('msgnum');
+  my $name = $self->option('to_name');
+
+  my $msg_template = qsearchs('msg_template', { 'msgnum' => $msgnum } )
+      or die "Template $msgnum not found";
+
+  my $to_name = $cust_main->$name
+    or die "Can't send notice with out " . $cust_main->$name . " number set";
+
+  ## remove - from phone number
+  $to_name =~ s/-//g;
+
+  #my $to = $to_name . '@' . $self->option('to_domain');
+  my $to = $to_name . '@' . $to_domain;
+  
+  $msg_template->send(
+    'to'        => $to,
+    'cust_main' => $cust_main,
+    'object'    => $object,
+  );
+
+}
+
+1;
diff --git a/FS/FS/part_event/Condition/referred_cust_base_recur.pm b/FS/FS/part_event/Condition/referred_cust_base_recur.pm
new file mode 100644 (file)
index 0000000..4ad4da7
--- /dev/null
@@ -0,0 +1,51 @@
+package FS::part_event::Condition::referred_cust_base_recur;
+use base qw( FS::part_event::Condition );
+
+use List::Util qw( sum );
+
+sub description { 'Referred customers recurring per month'; }
+
+sub option_fields {
+  (
+    'recur_times'  => { label => 'Base recurring per month of referred customers is at least this many times base recurring per month of referring customer',
+                        type  => 'text',
+                        value => '1',
+                      },
+    'if_pkg_class' => { label    => 'Only considering package of class',
+                        type     => 'select-pkg_class',
+                        multiple => 1,
+                      },
+  );
+}
+
+sub condition {
+  my($self, $object, %opt) = @_;
+
+  my $cust_main = $self->cust_main($object);
+  my @cust_pkg = $cust_main->billing_pkgs;
+
+  my @referral_cust_main = $cust_main->referral_cust_main;
+  my @referral_cust_pkg = map $_->billing_pkgs, @referral_cust_main;
+
+  my $if_pkg_class = $self->option('if_pkg_class') || {};
+  if ( keys %$if_pkg_class ) {
+    @cust_pkg          = grep $_->part_pkg->classnum, @cust_pkg;
+    @referral_cust_pkg = grep $_->part_pkg->classnum, @referral_cust_pkg;
+  }
+
+  return 0 unless @cust_pkg && @referral_cust_pkg;
+
+  my $recur     = sum map $_->part_pkg->base_recur_permonth, @cust_pkg;
+  my $ref_recur = sum map $_->part_pkg->base_recur_permonth, @referral_cust_pkg;
+
+  $ref_recur >= $self->option('recur_times') * $recur;
+}
+
+#sub condition_sql {
+#  my( $class, $table ) = @_;
+#
+#  #XXX TODO: this optimization
+#}
+
+1;
+
index 414350b..b84e008 100644 (file)
@@ -69,10 +69,16 @@ tie %options, 'Tie::IxHash',
   'no_machine' => 1,
   'notes'   => <<'END'
 Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
-modification and deletion.  For HTTPS support,
-<a href="http://search.cpan.org/dist/Crypt-SSLeay">Crypt::SSLeay</a>
-or <a href="http://search.cpan.org/dist/IO-Socket-SSL">IO::Socket::SSL</a>
-is required.
+modification and deletion.
+<p>Each "Data" option takes a list of <i>name value</i> pairs on successive 
+lines.
+<ul><li><i>name</i> is an unquoted, literal string without whitespace.</li>
+<li><i>value</i> is a Perl expression that will be evaluated.  If it's a 
+literal string, it must be quoted.  This expression has access to the
+svc_acct object as '$svc_x' (or '$new' and '$old' in "Replace Data") 
+and the customer record as '$cust_main'.</li></ul>
+If "Success Regexp" is specified, the response from the server will be
+tested against it to determine if the export succeeded.</p>
 END
 );
 
index 097ff34..cc1e450 100644 (file)
@@ -58,15 +58,12 @@ tie %options, 'Tie::IxHash',
 
 %info = (
   'svc'     => 'svc_broadband',
-  'desc'    => 'Send an HTTP or HTTPS GET or POST request, for accounts.',
+  'desc'    => 'Send an HTTP or HTTPS GET or POST request, for wireless broadband services.',
   'options' => \%options,
   'no_machine' => 1,
   'notes'   => <<'END'
-<p>Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
-modification and deletion.  For HTTPS support,
-<a href="http://search.cpan.org/dist/Crypt-SSLeay">Crypt::SSLeay</a>
-or <a href="http://search.cpan.org/dist/IO-Socket-SSL">IO::Socket::SSL</a>
-is required.</p>
+<p>Send an HTTP or HTTPS GET or POST to the specified URL on wireless broadband service addition,
+modification and deletion.
 <p>Each "Data" option takes a list of <i>name value</i> pairs on successive 
 lines.
 <ul><li><i>name</i> is an unquoted, literal string without whitespace.</li>
index 44280a2..d3e495c 100644 (file)
@@ -70,7 +70,18 @@ sub _export_command {
   my $command = $self->option($action);
   return '' if $command =~ /^\s*$/;
 
-  #set variables for the command
+  my $command_string = $self->_export_subvars( $svc_broadband, $command );
+
+  $self->shellcommands_queue( $svc_broadband->svcnum,
+    user    => $self->option('user')||'root',
+    host    => $self->machine,
+    command => $command_string,
+  );
+}
+
+sub _export_subvars {
+  my( $self, $svc_broadband, $command ) = @_;
+
   no strict 'vars';
   {
     no strict 'refs';
@@ -85,20 +96,25 @@ sub _export_command {
   $locationnum = $cust_pkg ? $cust_pkg->locationnum : '';
   $custnum = $cust_pkg ? $cust_pkg->custnum : '';
 
-  #done setting variables for the command
+  eval(qq("$command"));
+}
 
-  $self->shellcommands_queue( $svc_broadband->svcnum,
+sub _export_replace {
+  my($self, $new, $old ) = (shift, shift, shift);
+  my $command = $self->option('replace');
+
+  my $command_string = $self->_export_subvars_replace( $new, $old, $command );
+
+  $self->shellcommands_queue( $new->svcnum,
     user    => $self->option('user')||'root',
     host    => $self->machine,
-    command => eval(qq("$command")),
+    command => $command_string,
   );
 }
 
-sub _export_replace {
-  my($self, $new, $old ) = (shift, shift, shift);
-  my $command = $self->option('replace');
+sub _export_subvars_replace {
+  my( $self, $new, $old, $command ) = @_;
 
-  #set variable for the command
   no strict 'vars';
   {
     no strict 'refs';
@@ -120,15 +136,10 @@ sub _export_replace {
   $new_locationnum = $new_cust_pkg ? $new_cust_pkg->locationnum : '';
   $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
 
-  #done setting variables for the command
-
-  $self->shellcommands_queue( $new->svcnum,
-    user    => $self->option('user')||'root',
-    host    => $self->machine,
-    command => eval(qq("$command")),
-  );
+  eval(qq("$command"));
 }
 
+
 #a good idea to queue anything that could fail or take any time
 sub shellcommands_queue {
   my( $self, $svcnum ) = (shift, shift);
diff --git a/FS/FS/part_export/broadband_shellcommands_expect.pm b/FS/FS/part_export/broadband_shellcommands_expect.pm
new file mode 100644 (file)
index 0000000..ec525d3
--- /dev/null
@@ -0,0 +1,19 @@
+package FS::part_export::broadband_shellcommands_expect;
+use base qw( FS::part_export::shellcommands_expect );
+
+use strict;
+use FS::part_export::broadband_shellcommands;
+
+our %info = %FS::part_export::shellcommands_expect::info;
+$info{'svc'}  = 'svc_broadband';
+$info{'desc'} = 'Real time export via remote SSH, with interactive ("Expect"-like) scripting, for svc_broadband services';
+
+sub _export_subvars {
+  FS::part_export::broadband_shellcommands::_export_subvars(@_)
+}
+
+sub _export_subvars_replace {
+  FS::part_export::broadband_shellcommands::_export_subvars_replace(@_)
+}
+
+1;
diff --git a/FS/FS/part_export/dsl_http.pm b/FS/FS/part_export/dsl_http.pm
new file mode 100644 (file)
index 0000000..ac61ec8
--- /dev/null
@@ -0,0 +1,72 @@
+package FS::part_export::dsl_http;
+use base qw( FS::part_export::http );
+
+use Tie::IxHash;
+
+tie our %options, 'Tie::IxHash',
+  'method' => { label   =>'Method',
+                type    =>'select',
+                #options =>[qw(POST GET)],
+                options =>[qw(POST)],
+                default =>'POST' },
+  'url'    => { label   => 'URL', default => 'http://', },
+  'ssl_no_verify' => { label => 'Skip SSL certificate validation',
+                       type  => 'checkbox',
+                     },
+  'insert_data' => {
+    label   => 'Insert data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'delete_data' => {
+    label   => 'Delete data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'replace_data' => {
+    label   => 'Replace data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'suspend_data' => {
+    label   => 'Suspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'unsuspend_data' => {
+    label   => 'Unsuspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'success_regexp' => {
+    label  => 'Success Regexp',
+    default => '',
+  },
+;
+
+%info = (
+  'svc'     => 'svc_dsl',
+  'desc'    => 'Send an HTTP or HTTPS GET or POST request, for DSL services.',
+  'options' => \%options,
+  'no_machine' => 1,
+  'notes'   => <<'END'
+Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
+modification and deletion.
+<p>Each "Data" option takes a list of <i>name value</i> pairs on successive 
+lines.
+<ul><li><i>name</i> is an unquoted, literal string without whitespace.</li>
+<li><i>value</i> is a Perl expression that will be evaluated.  If it's a 
+literal string, it must be quoted.  This expression has access to the
+svc_dsl object as '$svc_x' (or '$new' and '$old' in "Replace Data") 
+and the customer record as '$cust_main'.</li></ul>
+If "Success Regexp" is specified, the response from the server will be
+tested against it to determine if the export succeeded.</p>
+END
+);
+
+1;
diff --git a/FS/FS/part_export/fiber_http.pm b/FS/FS/part_export/fiber_http.pm
new file mode 100644 (file)
index 0000000..38b23c4
--- /dev/null
@@ -0,0 +1,73 @@
+package FS::part_export::fiber_http;
+use base qw( FS::part_export::http );
+
+use Tie::IxHash;
+
+tie our %options, 'Tie::IxHash',
+  'method' => { label   =>'Method',
+                type    =>'select',
+                #options =>[qw(POST GET)],
+                options =>[qw(POST)],
+                default =>'POST' },
+  'url'    => { label   => 'URL', default => 'http://', },
+  'ssl_no_verify' => { label => 'Skip SSL certificate validation',
+                       type  => 'checkbox',
+                     },
+  'insert_data' => {
+    label   => 'Insert data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'delete_data' => {
+    label   => 'Delete data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'replace_data' => {
+    label   => 'Replace data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'suspend_data' => {
+    label   => 'Suspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'unsuspend_data' => {
+    label   => 'Unsuspend data',
+    type    => 'textarea',
+    default => join("\n",
+    ),
+  },
+  'success_regexp' => {
+    label  => 'Success Regexp',
+    default => '',
+  },
+;
+
+%info = (
+  'svc'     => 'svc_fiber',
+  'desc'    => 'Send an HTTP or HTTPS GET or POST request, for FTTx services.',
+  'options' => \%options,
+  'no_machine' => 1,
+  'notes'   => <<'END'
+Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
+modification and deletion.
+<p>Each "Data" option takes a list of <i>name value</i> pairs on successive 
+lines.
+<ul><li><i>name</i> is an unquoted, literal string without whitespace.</li>
+<li><i>value</i> is a Perl expression that will be evaluated.  If it's a 
+literal string, it must be quoted.  This expression has access to the
+svc_fiber object as '$svc_x' (or '$new' and '$old' in "Replace Data") 
+and the customer record as '$cust_main'.</li></ul>
+If "Success Regexp" is specified, the response from the server will be
+tested against it to determine if the export succeeded.</p>
+END
+);
+
+1;
+
index 42a35cb..43ccfc5 100644 (file)
@@ -59,14 +59,21 @@ tie %options, 'Tie::IxHash',
 
 %info = (
   'svc'     => 'svc_domain',
-  'desc'    => 'Send an HTTP or HTTPS GET or POST request',
+  'desc'    => 'Send an HTTP or HTTPS GET or POST request, for domains1',
   'options' => \%options,
   'no_machine' => 1,
   'notes'   => <<'END'
-Send an HTTP or HTTPS GET or POST to the specified URL.  For HTTPS support,
-<a href="http://search.cpan.org/dist/Crypt-SSLeay">Crypt::SSLeay</a>
-or <a href="http://search.cpan.org/dist/IO-Socket-SSL">IO::Socket::SSL</a>
-is required.
+Send an HTTP or HTTPS GET or POST to the specified URL on domain addition,
+modification and deletion.
+<p>Each "Data" option takes a list of <i>name value</i> pairs on successive 
+lines.
+<ul><li><i>name</i> is an unquoted, literal string without whitespace.</li>
+<li><i>value</i> is a Perl expression that will be evaluated.  If it's a 
+literal string, it must be quoted.  This expression has access to the
+svc_domain object as '$svc_x' (or '$new' and '$old' in "Replace Data") 
+and the customer record as '$cust_main'.</li></ul>
+If "Success Regexp" is specified, the response from the server will be
+tested against it to determine if the export succeeded.</p>
 END
 );
 
index 4373e7a..9458fca 100644 (file)
@@ -137,7 +137,7 @@ sub import_cdrs {
       # page's IDs or something.
       my $uniqueid = md5_hex(join(',',@$row));
       if ( FS::cdr->row_exists('uniqueid = ?', $uniqueid) ) {
-        warn "skipped duplicate row in page $page\n" if $DEBUG > 1;
+        warn "skipped duplicate row in page $page\n" if $DEBUG;
         next CDR;
       }
 
@@ -186,7 +186,7 @@ local $ENV{'PERL_LWP_SSL_VERIFY_HOSTNAME'} = 0;
     ]
   );
   warn "$me $method\n" if $DEBUG;
-  warn $request->as_string."\n" if $DEBUG > 1;
+  warn $request->as_string."\n" if $DEBUG;
 
   my $ua = LWP::UserAgent->new;
   my $response = $ua->request($request);
index 647dc5f..775af17 100644 (file)
@@ -4,6 +4,7 @@ use vars qw(@ISA %info);
 use Tie::IxHash;
 use Date::Format;
 use String::ShellQuote;
+use Net::OpenSSH;
 use FS::part_export;
 use FS::Record qw( qsearch qsearchs );
 
@@ -296,7 +297,7 @@ sub _export_command_or_super {
   } else {
     $self->_export_command($action, @_);
   }
-};
+}
 
 sub _export_command {
   my ( $self, $action, $svc_acct) = (shift, shift, shift);
@@ -305,6 +306,41 @@ sub _export_command {
   return '' if $command =~ /^\s*$/;
   my $stdin = $self->option($action."_stdin");
 
+  my( $command_string, $stdin_string ) =
+    $self->_export_subvars( $svc_acct, $command, $stdin );
+
+  $self->ssh_or_queue( $svc_acct, $command_string, $stdin_string );
+}
+
+sub ssh_or_queue {
+  my( $self, $svc_acct, $command_string, $stdin_string ) = @_;
+
+  my @ssh_cmd_args = (
+    user          => $self->option('user') || 'root',
+    host          => $self->svc_machine($svc_acct),
+    command       => $command_string,
+    stdin_string  => $stdin_string,
+    ignored_errors    => $self->option('ignored_errors') || '',
+    ignore_all_errors => $self->option('ignore_all_errors'),
+    fail_on_output    => $self->option('fail_on_output'),
+ );
+
+  if ( $self->option($action. '_no_queue') ) {
+    # discard return value just like freeside-queued.
+    eval { ssh_cmd(@ssh_cmd_args) };
+    $error = $@;
+    $error = $error->full_message if ref $error; # Exception::Class::Base
+    return $error.
+             ' ('. $self->exporttype. ' to '. $self->svc_machine($svc_acct). ')'
+      if $error;
+  } else {
+    $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
+  }
+}
+
+sub _export_subvars {
+  my( $self, $svc_acct, $command, $stdin ) = @_;
+
   no strict 'vars';
   {
     no strict 'refs';
@@ -412,27 +448,7 @@ sub _export_command {
   my $command_string = eval(qq("$command"));
   return "error filling in command: $@" if $@;
 
-  my @ssh_cmd_args = (
-    user          => $self->option('user') || 'root',
-    host          => $self->svc_machine($svc_acct),
-    command       => $command_string,
-    stdin_string  => $stdin_string,
-    ignored_errors    => $self->option('ignored_errors') || '',
-    ignore_all_errors => $self->option('ignore_all_errors'),
-    fail_on_output    => $self->option('fail_on_output'),
- );
-
-  if ( $self->option($action. '_no_queue') ) {
-    # discard return value just like freeside-queued.
-    eval { ssh_cmd(@ssh_cmd_args) };
-    $error = $@;
-    $error = $error->full_message if ref $error; # Exception::Class::Base
-    return $error.
-             ' ('. $self->exporttype. ' to '. $self->svc_machine($svc_acct). ')'
-      if $error;
-  } else {
-    $self->shellcommands_queue( $svc_acct->svcnum, @ssh_cmd_args );
-  }
+  ( $command_string, $stdin_string );
 }
 
 sub _export_replace {
@@ -440,6 +456,16 @@ sub _export_replace {
   my $command = $self->option('usermod');
   return '' if $command =~ /^\s*$/;
   my $stdin = $self->option('usermod_stdin');
+
+  my( $command_string, $stdin_string ) =
+    $self->_export_subvars_replace( $new, $old, $command, $stdin );
+
+  $self->ssh_or_queue( $new, $command_string, $stdin_string );
+}
+  
+sub _export_subvars_replace {
+  my( $self, $new, $old, $command, $stdin ) = @_;
+
   no strict 'vars';
   {
     no strict 'refs';
@@ -511,27 +537,7 @@ sub _export_replace {
 
   my $command_string = eval(qq("$command"));
 
-  my @ssh_cmd_args = (
-    user          => $self->option('user') || 'root',
-    host          => $self->svc_machine($new),
-    command       => $command_string,
-    stdin_string  => $stdin_string,
-    ignored_errors    => $self->option('ignored_errors') || '',
-    ignore_all_errors => $self->option('ignore_all_errors'),
-    fail_on_output    => $self->option('fail_on_output'),
-  );
-
-  if($self->option('usermod_no_queue')) {
-    # discard return value just like freeside-queued.
-    eval { ssh_cmd(@ssh_cmd_args) };
-    $error = $@;
-    $error = $error->full_message if ref $error; # Exception::Class::Base
-    return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
-      if $error;
-  }
-  else {
-    $self->shellcommands_queue( $new->svcnum, @ssh_cmd_args );
-  }
+  ( $command_string, $stdin_string );
 }
 
 #a good idea to queue anything that could fail or take any time
@@ -545,7 +551,6 @@ sub shellcommands_queue {
 }
 
 sub ssh_cmd { #subroutine, not method
-  use Net::OpenSSH;
   my $opt = { @_ };
   open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
   my $ssh = Net::OpenSSH->new(
diff --git a/FS/FS/part_export/shellcommands_expect.pm b/FS/FS/part_export/shellcommands_expect.pm
new file mode 100644 (file)
index 0000000..c2a4118
--- /dev/null
@@ -0,0 +1,128 @@
+package FS::part_export::shellcommands_expect;
+use base qw( FS::part_export::shellcommands );
+
+use strict;
+use Tie::IxHash;
+use Net::OpenSSH;
+use Expect;
+#use FS::Record qw( qsearch qsearchs );
+
+tie my %options, 'Tie::IxHash',
+  'user'      => { label =>'Remote username', default=>'root' },
+  'useradd'   => { label => 'Insert commands',    type => 'textarea', },
+  'userdel'   => { label => 'Delete commands',    type => 'textarea', },
+  'usermod'   => { label => 'Modify commands',    type => 'textarea', },
+  'suspend'   => { label => 'Suspend commands',   type => 'textarea', },
+  'unsuspend' => { label => 'Unsuspend commands', type => 'textarea', },
+  'debug'     => { label => 'Enable debugging',
+                   type  => 'checkbox',
+                   value => 1,
+                 },
+;
+
+our %info = (
+  'svc'     => 'svc_acct',
+  'desc'    => 'Real time export via remote SSH, with interactive ("Expect"-like) scripting, for svc_acct services',
+  'options' => \%options,
+  'notes'   => q[
+Interactively run commands via SSH in a remote terminal, like "Expect".  In
+most cases, you probably want a regular shellcommands (or broadband_shellcommands, etc.) export instead, unless
+you have a specific need to interact with a terminal-based interface in an
+"Expect"-like fashion.
+<BR><BR>
+
+Each line specifies a string to match and a command to
+run after that string is found, separated by the first space.  For example, to
+run "exit" after a prompt ending in "#" is sent, "# exit".  You will need to
+<a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
+<BR><BR>
+
+In commands, all variable substitutions of the regular shellcommands (or
+broadband_shellcommands, etc.) export are available (use a backslash to escape
+a literal $).
+]
+);
+
+sub _export_command {
+  my ( $self, $action, $svc_acct) = (shift, shift, shift);
+  my @lines = split("\n", $self->option($action) );
+
+  return '' unless @lines;
+
+  my @commands = ();
+  foreach my $line (@lines) {
+    my($match, $command) = split(' ', $line, 2);
+    my( $command_string ) = $self->_export_subvars( $svc_acct, $command, '' );
+    push @commands, [ $match, $command_string ];
+  }
+
+  $self->shellcommands_expect_queue( $svc_acct->svcnum, @commands );
+}
+
+sub _export_replace {
+  my( $self, $new, $old ) = (shift, shift, shift);
+  my @lines = split("\n", $self->option('replace') );
+
+  return '' unless @lines;
+
+  my @commands = ();
+  foreach my $line (@lines) {
+    my($match, $command) = split(' ', $line, 2);
+    my( $command_string ) = $self->_export_subvars_replace( $new, $old, $command, '' );
+    push @commands, [ $match, $command_string ];
+  }
+
+  $self->shellcommands_expect_queue( $new->svcnum, @commands );
+}
+
+sub shellcommands_expect_queue {
+  my( $self, $svcnum, @commands ) = @_;
+
+  my $queue = new FS::queue {
+    'svcnum' => $svcnum,
+    'job'    => "FS::part_export::shellcommands_expect::ssh_expect",
+  };
+  $queue->insert(
+    user          => $self->option('user') || 'root',
+    host          => $self->machine,
+    debug         => $self->option('debug'),
+    commands      => \@commands,
+  );
+}
+
+sub ssh_expect { #subroutine, not method
+  my $opt = { @_ };
+
+  my $dest = $opt->{'user'}.'@'.$opt->{'host'};
+
+  open my $def_in, '<', '/dev/null' or die "unable to open /dev/null\n";
+  my $ssh = Net::OpenSSH->new( $dest, 'default_stdin_fh' => $def_in );
+  # ignore_all_errors doesn't override SSH connection/auth errors--
+  # probably correct
+  die "Couldn't establish SSH connection to $dest: ". $ssh->error
+    if $ssh->error;
+
+  my ($pty, $pid) = $ssh->open2pty
+    or die "Couldn't start a remote terminal session";
+  my $expect = Expect->init($pty);
+  #not useful #$expect->debug($opt->{debug} ? 3 : 0);
+
+  foreach my $line ( @{ $opt->{commands} } ) {
+    my( $match, $command ) = @$line;
+
+    warn "Waiting for '$match'\n" if $opt->{debug};
+
+    my $matched = $expect->expect(30, $match);
+    unless ( $matched ) {
+      my $err = "Never saw '$match'\n";
+      warn $err;
+      die $err;
+    }
+    warn "Running '$command'\n" if $opt->{debug};
+    $expect->send("$command\n");
+  }
+
+  '';
+}
+
+1;
index 332e457..d715535 100644 (file)
@@ -286,8 +286,8 @@ sub _export_insert {
 
     my $cust_main = $svc_phone->cust_svc->cust_pkg->cust_main;
 
-    return 'Customer company is required'
-      unless $cust_main->company;
+    #return 'Customer company is required'
+    #  unless $cust_main->company;
 
     return 'Customer day phone (for contact, not porting) is required'
       unless $cust_main->daytime;
@@ -306,7 +306,7 @@ sub _export_insert {
       'partial'       => 'no',
       'wireless'      => 'no',
       'carrier'       => $svc_phone->lnp_other_provider,
-      'company'       => $cust_main->company,
+      'company'       => $cust_main->company || $cust_main->contact,
       'accnumber'     => $svc_phone->lnp_other_provider_account,
       'name'          => $svc_phone->phone_name_or_cust,
       'streetnumber'  => $sa->{number},
@@ -410,6 +410,7 @@ sub e911_send {
   return '' if $self->option('disable_e911');
 
   my %location = $svc_phone->location_hash;
+  $location{'zip'} =~ s/\-\d{4}$//;
   my %e911send = (
     'did'     => $svc_phone->phonenum,
     'name'    => $svc_phone->phone_name_or_cust,
@@ -425,7 +426,7 @@ sub e911_send {
 
   my $e911_result = $self->vitelity_command('e911send', %e911send);
 
-  unless ( $e911_result =~ /^(missingdata|invalid)/i ) {
+  unless ( $e911_result =~ /status=(missingdata|invalid)/i ) {
     warn "Vitelity response: $e911_result" if $self->option('debug');
     return '';
   }
index 4ed83a4..729fb61 100644 (file)
@@ -43,12 +43,17 @@ sub cutoff_day {
   my $recur_method = $self->option('recur_method',1) || 'anniversary';
   my $cust_main = $cust_pkg->cust_main;
 
-  if ( $cust_main->force_prorate_day and $cust_main->prorate_day ) {
-     return ( $cust_main->prorate_day );
-  } elsif ($recur_method eq 'prorate' || $recur_method eq 'subscription') {
+  return ( $cust_main->prorate_day )
+    if $cust_main->prorate_day and (    $cust_main->force_prorate_day
+                                     || $recur_method eq 'prorate'
+                                     || $recur_method eq 'subscription'
+                                   );
 
-    return split(/\s*,\s*/, $self->option('cutoff_day', 1) || '1');
-  }
+  return split(/\s*,\s*/, $self->option('cutoff_day', 1) || '1')
+    if $recur_method eq 'prorate'
+    || $recur_method eq 'subscription';
+
+  return ();
 }
 
 sub calc_recur_Common {
index eb0750c..7f49715 100644 (file)
@@ -201,6 +201,7 @@ sub check {
     || $self->ut_textn('vendor_order_status')
     || $self->ut_ipn('endpoint_ip_addr')
     || $self->ut_textn('endpoint_mac_addr')
+    || $self->ut_textn('internal_circuit_id')
   ;
 
   # no canonical values yet for vendor_order_status or _type
index 5416ff5..8bc0c6e 100644 (file)
@@ -2335,7 +2335,15 @@ EOF
 
 }
 
+sub _upgrade_data {
+  my $class = shift;
 
+  my $sql = "UPDATE tax_rate SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'";
+
+  my $sth = dbh->prepare($sql) or die $DBI::errstr;
+  $sth->execute() or die $sth->errstr;
+  
+}
 
 =back
 
index d9646e4..e338591 100644 (file)
@@ -111,6 +111,7 @@ sub check {
     $self->ut_numbern('taxratelocationnum')
     || $self->ut_textn('data_vendor')
     || $self->ut_alpha('geocode')
+    || $self->ut_textn('district')
     || $self->ut_textn('city')
     || $self->ut_textn('county')
     || $self->ut_textn('state')
@@ -118,16 +119,12 @@ sub check {
   ;
   return $error if $error;
 
-  my $t;
-  $t = qsearchs( 'tax_rate_location',
-                 { disabled => '',
-                   ( map { $_ => $self->$_ } qw( data_vendor geocode ) ),
-                 },
-               )
+  my $t = '';
+  $t = $self->existing_search
     unless $self->disabled;
 
   $t = $self->by_key( $self->taxratelocationnum )
-    if ( !$t && $self->taxratelocationnum );
+    if !$t && $self->taxratelocationnum;
 
   return "geocode ". $self->geocode. " already in use for this vendor"
     if ( $t && $t->taxratelocationnum != $self->taxratelocationnum );
@@ -153,11 +150,7 @@ record.
 
 sub find_or_insert {
   my $self = shift;
-  my $existing = qsearchs('tax_rate_location', {
-      disabled    => '',
-      data_vendor => $self->data_vendor,
-      geocode     => $self->geocode
-  });
+  my $existing = $self->existing_search;
   if ($existing) {
     my $update = 0;
     foreach (qw(city county state country)) {
@@ -176,6 +169,16 @@ sub find_or_insert {
   }
 }
 
+sub existing_search {
+  my $self = shift;
+
+  qsearchs( 'tax_rate_location',
+            { disabled => '',
+              map { $_ => $self->$_ } qw( data_vendor geocode )
+            }
+          );
+}
+
 =back
 
 =head1 CLASS METHODS
@@ -392,6 +395,17 @@ sub batch_import {
 
 }
 
+sub _upgrade_data {
+#actually no, we want to leave those records behind now that they're giving us
+# geo_state etc.
+#  my $class = shift;
+#
+#  my $sql = "UPDATE tax_rate_location SET data_vendor = 'compliance_solutions' WHERE data_vendor = 'compliance solutions'";
+#
+#  my $sth = dbh->prepare($sql) or die $DBI::errstr;
+#  $sth->execute() or die $sth->errstr;
+}
+
 =head1 BUGS
 
 Currently somewhat specific to CCH supplied data.
index f6a6400..81087de 100644 (file)
@@ -510,6 +510,7 @@ t/class_Common.t
 FS/category_Common.pm
 t/category_Common.t
 FS/contact.pm
+FS/contact/Import.pm
 t/contact.t
 FS/contact_phone.pm
 t/contact_phone.t
@@ -872,3 +873,7 @@ FS/saved_search.pm
 t/saved_search.t
 FS/sector_coverage.pm
 t/sector_coverage.t
+FS/access_user_session_log.pm
+t/access_user_session_log.t
+FS/access_user_session_log.pm
+t/access_user_session_log.t
index 23ea6bb..d64c870 100755 (executable)
@@ -4,7 +4,8 @@ use strict;
 use Getopt::Std;
 use Date::Format;
 use File::Temp 'tempdir';
-use Net::FTP;
+use Net::SSLGlue::FTP; #at least until the Deb 9 transition is done, then
+                       # regular Net::FTP has SSL support
 use FS::UID qw(adminsuidsetup datasrc dbh);
 use FS::cdr;
 use FS::cdr_batch;
@@ -39,11 +40,14 @@ my $tempdir = tempdir( CLEANUP => !$opt_v );
 my $format = 'voip_innovations';
 my $hostname = 'customercdr.voipinnovations.com';
 
-my $ftp = Net::FTP->new($hostname, Debug => $opt_d)
+my $ftp = Net::FTP->new($hostname, Passive => 1, Debug => $opt_d)
   or die "Can't connect to $hostname: $@\n";
 
+$ftp->starttls()
+  or die "TLS initialization failed: ". $ftp->message. "\n";
+
 $ftp->login($login, $password)
-  or die "Login failed: ".$ftp->message."\n";
+  or die "Login failed: ". $ftp->message. "\n";
 
 ###
 # get the file list
@@ -51,7 +55,7 @@ $ftp->login($login, $password)
 
 warn "Retrieving directory listing\n" if $opt_v;
 
-$ftp->cwd('/');
+#$ftp->cwd('/');
 my @dirs = $ftp->ls();
 warn scalar(@dirs)." directories found.\n" if $opt_v;
 # apply date range
diff --git a/FS/t/access_user_session_log.t b/FS/t/access_user_session_log.t
new file mode 100644 (file)
index 0000000..6306374
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::access_user_session_log;
+$loaded=1;
+print "ok 1\n";
index e18d39d..3486b79 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -236,7 +236,7 @@ perl-modules:
        " blib/lib/FS/part_export/*.pm;\
        perl -p -i -e "\
          s|%%%FREESIDE_CACHE%%%|${FREESIDE_CACHE}|g;\
-       " blib/lib/FS/cust_main/*.pm blib/lib/FS/cust_pkg/*.pm;\
+       " blib/lib/FS/cust_main/*.pm blib/lib/FS/cust_pkg/*.pm blib/lib/FS/contact/*.pm;\
        perl -p -i -e "\
          s|%%%FREESIDE_LOG%%%|${FREESIDE_LOG}|g;\
        " blib/lib/FS/Daemon/*.pm;\
diff --git a/bin/cust_main-email_and_rebill b/bin/cust_main-email_and_rebill
new file mode 100644 (file)
index 0000000..dea1319
--- /dev/null
@@ -0,0 +1,73 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use Date::Parse;
+use FS::UID qw( adminsuidsetup );
+use FS::Record qw( qsearchs );
+use FS::cust_pkg;
+use FS::msg_template;
+
+adminsuidsetup shift or die 'Usage: cust_main-email_and_rebill username\n';
+
+my $DRY_RUN = 1;
+my $msgnum = 17;
+
+my $sep1 = str2time('9/1/2017');
+my $aug15 = str2time('8/15/2017') + 1802;
+
+my $msg_template = qsearchs('msg_template', { 'msgnum' => $msgnum } )
+  or die "unknown msg_template $msgnum\n";
+
+while (<>) {
+  chomp;
+  my $pkgnum = $_;
+
+  #find the package
+  my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum'=>$pkgnum } )
+    or die "pkgnum $pkgnum not found\n";
+
+  #reset its next bill date back to sep 1
+  $cust_pkg->set('bill', $sep1);
+  unless ( $DRY_RUN ) {
+    warn "updating cust_pkg $pkgnum bill to $sep1\n";
+    my $error = $cust_pkg->replace;
+    die $error if $error;
+  } else {
+    warn "DRY RUN: would update cust_pkg $pkgnum bill to $sep1\n";
+  }
+
+  #customer
+  my $cust_main = $cust_pkg->cust_main;
+  my $custnum = $cust_main->custnum;
+
+  #send the custoemr a notice
+  unless ( $DRY_RUN ) {
+    warn "emailing msg_template $msgnum to customer $custnum\n";
+    $msg_template->send( 'cust_main' => $cust_main,
+                         'object'    => $cust_main,
+                       );
+  } else {
+    warn "DRY RUN: emailing msg_template $msgnum to customer $custnum\n";
+  }
+
+  #bill the package
+  unless ( $DRY_RUN ) {
+    warn "billing customer $custnum for package $pkgnum as of $sep1\n";
+    $cust_main->bill( 'time'         => $sep1,
+                      'invoice_time' => $aug15,
+                      'pkg_list'     => [ $cust_pkg ],
+                    );
+  } else {
+    warn "DRY RUN: billing customer $custnum for package $pkgnum as of $sep1\n";
+  }
+
+  #something about removing their pending batch payment??
+  #hmm, there doesn't appear to be anything in a batch
+  #dating the invoices aug 15th will ensure payments for them are batched
+
+  #events will take care of the rest...
+
+}
+
+1;
index c774dd8..84d4e92 100755 (executable)
@@ -12,7 +12,7 @@ fi
 
 DATE=`date +"%Y%m%d"`
 DIR="/home/autobuild/packages/staging/freeside$FS_VERSION/$FS_REPO"
-TARGET="/home/jeremyd/public_html/freeside$FS_VERSION-$DISTRO-$FS_REPO"
+TARGET="/home/autobuild/public_html/freeside$FS_VERSION-$DISTRO-$FS_REPO"
 
 if [ ! -d "$DIR" -a -d $TARGET ]; then
 
@@ -31,13 +31,6 @@ git checkout -- debian/changelog
 git pull
 #STATUS=`git pull`
 
-#Assign the proper config files for freeside-ng-selfservice
-if [ $DISTRO = "wheezy" ]; then
-       ln -s $DIR/freeside/debian/freeside-ng-selfservice.deb7 $DIR/freeside/debian/freeside-ng-selfservice.conffiles
-else
-       ln -s $DIR/freeside/debian/freeside-ng-selfservice.deb8 $DIR/freeside/debian/freeside-ng-selfservice.conffiles
-fi
-
 # Add the build information to changelog
 if [ $FS_REPO != "stable" ]; then
        dch -b --newversion $GIT_VERSION-$DATE "Auto-Build"
@@ -49,7 +42,14 @@ pdebuild --pbuilderroot sudo --debbuildopts "-b -rfakeroot -uc -us" --buildresul
 
 #--buildresult gets the file where it needs to be, may need to clean up DIR
 
-cd $DIR; rm -f freeside_*
-cd $TARGET; rm -f *.gz
-
-$TARGET/APT
+cd $DIR && rm -f freeside_*
+cd $TARGET && rm -f *.gz
+
+apt-ftparchive -qq packages ./ >Packages
+gzip -c Packages >Packages.gz
+#bzip2 -c Packages >Packagez.bz2
+apt-ftparchive -qq sources ./ >Sources
+gzip -c Sources >Sources.gz
+#bzip2 -c Sources >Sources.bz2
+rm *bz2 || true
+apt-ftparchive -qq release ./ >Release
diff --git a/bin/recover-cust_location b/bin/recover-cust_location
new file mode 100755 (executable)
index 0000000..6318eb3
--- /dev/null
@@ -0,0 +1,33 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use FS::UID qw( adminsuidsetup );
+use FS::Record qw( qsearchs );
+use FS::h_cust_location;
+use FS::cust_location;
+
+adminsuidsetup shift or &usage;
+my $start = shift or &usage;
+my $end = shift or &usage;
+
+for my $locationnum ( $start .. $end ) {
+
+  my $h_cust_location = qsearchs({
+    'table'     => 'h_cust_location',  
+    'hashref'   => { 'locationnum' => $locationnum, },
+    'extra_sql' => " AND ( history_action = 'insert' OR history_action = 'replace_new' )",
+    'order_by'  => 'ORDER BY historynum DESC LIMIT 1',
+  })
+    or die "h_cust_location for $locationnum not found\n";
+
+  warn "recovering cust_locaiton $locationnum\n";
+  my $cust_location = new FS::cust_location { $h_cust_location->hash };
+  my $error = $cust_location->insert;
+  die $error if $error;
+
+}
+
+sub usage {
+  die "Usage: recover-cust_location username start_locationnum end_locationnum\n";
+}
diff --git a/bin/xmlrpc-advertising_sources-add.pl b/bin/xmlrpc-advertising_sources-add.pl
new file mode 100755 (executable)
index 0000000..4800ad0
--- /dev/null
@@ -0,0 +1,28 @@
+#!/usr/bin/perl
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+my $uri = new URI 'http://localhost:8008/';
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $result = $server->call(
+  'FS.API.add_advertising_source',
+    'secret' => 'MySecretCode',
+    'source' => {
+               'referral' => 'API test referral',
+               'disabled' => '',
+               'agentnum' => '',
+               'title'    => 'API test title',
+       },
+);
+
+die $result->{'error'} if $result->{'error'};
+
+print Dumper($result);
+
+print "\nAll Done\n";
+
+exit;
\ No newline at end of file
diff --git a/bin/xmlrpc-advertising_sources-edit.pl b/bin/xmlrpc-advertising_sources-edit.pl
new file mode 100755 (executable)
index 0000000..80f9139
--- /dev/null
@@ -0,0 +1,30 @@
+#!/usr/bin/perl
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+my $uri = new URI 'http://localhost:8008/';
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $result = $server->call(
+  'FS.API.edit_advertising_source',
+    'secret' => 'MySecretCode',
+    'refnum' => '4',
+    'source' => {
+               'referral' => 'Edit referral',
+               'title'    => 'Edit Referral title',
+               #'disabled' => 'Y',
+               #'disabled' => '',
+               #'agentnum' => '2',
+       },
+);
+
+die $result->{'error'} if $result->{'error'};
+
+print Dumper($result);
+
+print "\nAll Done\n";
+
+exit;
\ No newline at end of file
diff --git a/bin/xmlrpc-advertising_sources-list.pl b/bin/xmlrpc-advertising_sources-list.pl
new file mode 100755 (executable)
index 0000000..317a38b
--- /dev/null
@@ -0,0 +1,22 @@
+#!/usr/bin/perl
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+my $uri = new URI 'http://localhost:8008/';
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $result = $server->call(
+  'FS.API.list_advertising_sources',
+    'secret'  => 'MySecretCode',
+);
+
+die $result->{'error'} if $result->{'error'};
+
+print Dumper($result);
+
+print "\nAll Done\n";
+
+exit;
\ No newline at end of file
diff --git a/bin/xmlrpc-order_package.php b/bin/xmlrpc-order_package.php
new file mode 100755 (executable)
index 0000000..fccf77a
--- /dev/null
@@ -0,0 +1,81 @@
+#!/usr/bin/php5
+
+<?php
+
+$freeside = new FreesideAPI();
+
+$result = $freeside->order_package( array(
+  'secret'          => 'sharingiscaring', #config setting api_shared_secret
+  'custnum'         => 619797,
+  'pkgpart'         => 2,
+
+  #the rest is optional
+  'quantity'        => 5,
+  'start_date'      => '12/1/2017',
+  'invoice_details' => [ 'detail', 'even more detail' ],
+  'address1'        => '5432 API Lane',
+  'city'            => 'API Town',
+  'state'           => 'AZ',
+  'zip'             => '54321',
+  'country'         => 'US',
+  'setup_fee'       => '23',
+  'recur_fee'       => '19000',
+));
+
+var_dump($result);
+
+#pre-php 5.4 compatible version?
+function flatten($hash) {
+  if ( !is_array($hash) ) return $hash;
+  $flat = array();
+
+  array_walk($hash, function($value, $key, &$to) { 
+    array_push($to, $key, $value);
+  }, $flat);
+
+  if ( PHP_VERSION_ID >= 50400 ) {
+
+    #php 5.4+ (deb 7+)
+    foreach ($hash as $key => $value) {
+      $flat[] = $key;
+      $flat[] = $value;
+    }
+
+  }
+
+  return($flat);
+}
+
+class FreesideAPI  {
+
+    //Change this to match the location of your backoffice XML-RPC interface
+    #var $URL = 'https://localhost/selfservice/xmlrpc.cgi';
+    var $URL = 'http://localhost:8008/';
+
+    function FreesideAPI() {
+      $this;
+    }
+
+    public function __call($name, $arguments) {
+
+        error_log("[FreesideAPI] $name called, sending to ". $this->URL);
+
+        $request = xmlrpc_encode_request("FS.API.$name", flatten($arguments[0]));
+        $context = stream_context_create( array( 'http' => array(
+            'method' => "POST",
+            'header' => "Content-Type: text/xml",
+            'content' => $request
+        )));
+        $file = file_get_contents($this->URL, false, $context);
+        $response = xmlrpc_decode($file);
+        if (isset($response) && is_array($response) && xmlrpc_is_fault($response)) {
+            trigger_error("[FreesideAPI] XML-RPC communication error: $response[faultString] ($response[faultCode])");
+        } else {
+            //error_log("[FreesideAPI] $response");
+            return $response;
+        }
+    }
+
+}
+
+?>
index 5c6090e..a710cdd 100644 (file)
 \r
 \LTchunksize=40\r
 \r
+\r
+\begin{document}\r
+\r
+\r
 \renewcommand{\headrulewidth}{0pt}\r
 \renewcommand{\footrulewidth}{1pt}\r
 \r
   \\\r
 }\r
 \r
-\begin{document}\r
 %      Headers and footers defined for the first page\r
 \addressinset \rule{0.5cm}{0cm} \r
 \makebox{\r
index 25228dc..13fc429 100644 (file)
 \r
 \LTchunksize=40\r
 \r
+\r
+\begin{document}\r
+\r
+\r
 \renewcommand{\headrulewidth}{0pt}\r
 \renewcommand{\footrulewidth}{1pt}\r
 \r
 }\r
 \r
 \r
-\begin{document}\r
 %      Headers and footers defined for the first page\r
 \addressinset \rule{0.5cm}{0cm} \r
 \makebox{\r
index a268ffd..fc3bae1 100644 (file)
@@ -2,22 +2,22 @@ Source: freeside
 Section: misc
 Priority: extra
 Maintainer: Ivan Kohler <ivan-debian@420.am>
-Uploaders: Jeremy Davis <jeremyd@freeside.biz>
 Standards-Version: 3.7.2
 Homepage: http://www.freeside.biz/freeside
 Build-Depends: debhelper (>=5), perl (>=5.8),
  rrdtool,librrds-perl, libxml-libxml-perl, libberkeleydb-perl,
  libtemplate-perl, libproc-daemon-perl, libnet-snmp-perl,
  libapache-session-perl, libjson-perl, libdbix-abstract-perl,
- libdbix-sequence-perl, librrds-perl, apache2, texlive-binaries,
+ libdbix-sequence-perl, apache2, texlive-binaries,
  autotools-dev, liburi-perl, db-util, libtimedate-perl, libcgi-fast-perl
 
 Package: freeside
 Architecture: all
 Pre-Depends: freeside-lib
 # dbconfig-common
-Depends: ${perl:Depends}, ${shlibs:Depends}, ${misc:Depends}, freeside-webui,
- debconf, cron, openbsd-inetd, tcpd, undersmtpd, ssmtp, freeside-lib (>= 4.0~git-20160211)
+Depends: ${perl:Depends}, ${shlibs:Depends}, ${misc:Depends},
+ freeside-webui (= ${binary:Version}), freeside-lib (= ${binary:Version}),
+ debconf, cron, openbsd-inetd, tcpd, undersmtpd, ssmtp
 Description: Billing and trouble ticketing for service providers
  Freeside is a web-based billing, trouble ticketing and network monitoring
  application.  It includes features for ISPs and WISPs, hosting providers and
@@ -44,7 +44,8 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,latex-xcolor,
  libipc-run-perl,libipc-run3-perl,libipc-sharelite-perl,libjavascript-rpc-perl,
  libjson-perl,liblingua-en-inflect-perl,liblingua-en-nameparse-perl,
  liblocale-gettext-perl,liblocale-maketext-fuzzy-perl,
- liblocale-maketext-lexicon-perl,liblocale-subcountry-perl,liblog-dispatch-perl,
+ liblocale-maketext-lexicon-perl,liblocale-subcountry-perl (<< 2),
+ liblog-dispatch-perl,
  libmailtools-perl (>=2.12), libmime-tools-perl (>= 5.504),
  libmodule-versions-report-perl,
  libnet-daemon-perl,libnet-ping-external-perl,libnet-scp-perl,libnet-ssh-perl,
@@ -99,7 +100,8 @@ Depends: aspell-en,gnupg,ghostscript,gsfonts,gzip,latex-xcolor,
  libxml-writer-perl, libio-socket-ssl-perl,
  libmap-splat-perl, libdatetime-format-ical-perl, librest-client-perl,
  libgeo-streetaddress-us-perl, libbusiness-onlinepayment-perl,
- libnet-vitelity-perl (>= 0.05)
+ libnet-vitelity-perl (>= 0.05), libnet-sslglue-perl, libexpect-perl,
+ libspreadsheet-parsexlsx-perl
 Conflicts: libparams-classify-perl (>= 0.013-6)
 Replaces: freeside (<<4)
 Breaks: freeside (<<4)
@@ -111,7 +113,7 @@ Description: Libraries for Freeside billing and trouble ticketing
 
 Package: freeside-webui
 Architecture: all
-Depends: freeside-lib,apache2,apache2-mpm-prefork,apache2-utils,
+Depends: freeside-lib,apache2,apache2-utils,
  libapache-dbi-perl,libapache2-mod-perl2,libapache2-request-perl,
  libapache-session-perl,openssl, libcgi-emulate-psgi-perl,
  libplack-perl (>= 1.0002)
@@ -173,7 +175,7 @@ Description: Self-service portal html/cgi filesfor Freeside billing and trouble
 
 Package: freeside-ng-selfservice
 Architecture: all
-Depends: libapache2-mod-php5,php5-xmlrpc,apache2-mpm-prefork
+Depends: libapache2-mod-php5,php5-xmlrpc,apache2
 Recommends:
 Description: Next Generation Self-service portal for Freeside billing and trouble ticketing
  Freeside is a web-based billing and trouble ticketing application.
diff --git a/debian/freeside-ng-selfservice.conffiles b/debian/freeside-ng-selfservice.conffiles
new file mode 100644 (file)
index 0000000..d6537dd
--- /dev/null
@@ -0,0 +1 @@
+/var/www/html/ng_selfservice-DIST/freeside.class.php
diff --git a/debian/freeside-ng-selfservice.deb7 b/debian/freeside-ng-selfservice.deb7
deleted file mode 100644 (file)
index 58f0d3a..0000000
+++ /dev/null
@@ -1 +0,0 @@
-/var/www/ng_selfservice-DIST/freeside.class.php
diff --git a/debian/freeside-ng-selfservice.deb8 b/debian/freeside-ng-selfservice.deb8
deleted file mode 100644 (file)
index d6537dd..0000000
+++ /dev/null
@@ -1 +0,0 @@
-/var/www/html/ng_selfservice-DIST/freeside.class.php
index e38ba76..f90f6d9 100644 (file)
@@ -60,8 +60,8 @@
   delete $options{'DCRD'} unless $payby eq 'DCRD' || ! exists $options{'CARD'};
   delete $options{'DCHK'} unless $payby eq 'DCHK' || ! exists $options{'CHEK'};
 
-  ## setting payby to default to layer if only one.  should we always display first layer?
-  if (keys %options == 1) { @p = keys %options; $payby = $p[0]; }
+  ## set default layer to first payby.
+  @p = keys %options; $payby = $p[0];
 
   HTML::Widgets::SelectLayers->new(
     options => \%options,
index 6af5e5e..1bc35e3 100644 (file)
@@ -93,9 +93,10 @@ push @menu,
 
 unless ( $access_pkgnum ) {
   push @menu,
-    { title=>'Change billing address',      url=>'change_bill',     indent=>2 },
-    { title=>'Change service address',      url=>'change_ship',     indent=>2 },
-    { title=>'Change payment information',  url=>'change_pay',      indent=>2 },
+    { title=>'Change billing address',          url=>'change_bill',           indent=>2 },
+    { title=>'Change service address',          url=>'change_ship',           indent=>2 },
+    { title=>'Change credit card information',  url=>'change_creditcard_pay', indent=>2 },
+    { title=>'Change check information',        url=>'change_check_pay',      indent=>2 },
   ;
 }
 
index cd9e32c..f194746 100755 (executable)
@@ -12,8 +12,8 @@ use Date::Format;
 use Date::Parse 'str2time';
 use Number::Format 1.50;
 use FS::SelfService qw(
-  access_info login_info login customer_info edit_info invoice
-  payment_info process_payment realtime_collect process_prepay
+  access_info login_info login customer_info edit_info insert_payby update_payby 
+  invoice payment_info process_payment realtime_collect process_prepay
   list_pkgs order_pkg signup_info order_recharge
   part_svc_info provision_acct provision_external provision_phone provision_forward
   unprovision_svc change_pkg suspend_pkg domainselector
@@ -59,6 +59,10 @@ my @actions = ( qw(
   change_bill
   change_ship
   change_pay
+  change_creditcard_pay
+  change_check_pay
+  process_change_creditcard_pay
+  process_change_check_pay
   process_change_bill
   process_change_ship
   process_change_pay
@@ -261,19 +265,30 @@ sub myaccount {
   customer_info( 'session_id' => $session_id ); 
 }
 
-sub change_bill { my $payment_info =
-                    payment_info( 'session_id' => $session_id );
-                  return $payment_info if ( $payment_info->{'error'} );
-                  my $customer_info =
-                    customer_info( 'session_id' => $session_id );
-                  return { 
-                    %$payment_info,
-                    %$customer_info,
-                  };
-                }
+sub change_bill {
+  my $payby = shift;
+  my $payment_info;
+  if ($payby) {
+    $payment_info = payment_info( 'session_id' => $session_id, 'payment_payby' => $payby, );
+  }
+  else {
+    $payment_info = payment_info( 'session_id' => $session_id, );
+  }
+
+  return $payment_info if ( $payment_info->{'error'} );
+  my $customer_info =
+    customer_info( 'session_id' => $session_id );
+  return {
+    %$payment_info,
+    %$customer_info,
+  };
+}
 sub change_ship { change_bill(@_); }
 sub change_pay { change_bill(@_); }
 
+sub change_creditcard_pay { change_bill('CARD'); }
+sub change_check_pay { change_bill('CHEK'); }
+
 sub _process_change_info { 
   my ($erroraction, @fields) = @_;
 
@@ -298,6 +313,30 @@ sub _process_change_info {
   }
 }
 
+sub _process_change_payby {
+  my ($erroraction, @fields) = @_;
+
+  my $results = '';
+
+  $results ||= update_payby (
+    'session_id' => $session_id,
+    map { ($_ => $cgi->param($_)) } grep { defined($cgi->param($_)) } @fields,
+  );
+
+
+  if ( $results->{'error'} ) {
+    no strict 'refs';
+    $action = $erroraction;
+    return {
+      $cgi->Vars,
+      %{&$action()},
+      'error' => '<FONT COLOR="#FF0000">'. $results->{'error'}. '</FONT>',
+    };
+  } else {
+    return $results;
+  }
+}
+
 sub process_change_bill {
         _process_change_info( 'change_bill', 
           qw( first last company address1 address2 city state
@@ -342,6 +381,30 @@ sub process_change_pay {
         _process_change_info( 'change_pay', @list );
 }
 
+sub process_change_creditcard_pay {
+        my $payby  = $cgi->param( 'payby' );
+        $cgi->param('paydate', $cgi->param('year') . '-' . $cgi->param('month') . '-01');
+        my @list =
+          qw( payby payinfo payinfo1 payinfo2 paydate payname custpaybynum 
+              address1 address2 city county state zip country auto paytype
+              paystate ss stateid stateid_state invoicing_list
+            );
+
+        _process_change_payby( 'change_creditcard_pay', @list );
+}
+
+sub process_change_check_pay {
+        my $payby  = $cgi->param( 'payby' );
+        $cgi->param('paydate', $cgi->param('year') . '-' . $cgi->param('month') . '-01');
+        my @list =
+          qw( payby payinfo payinfo1 payinfo2 paydate payname custpaybynum 
+              address1 address2 city county state zip country auto paytype
+              paystate ss stateid stateid_state invoicing_list
+            );
+
+        _process_change_payby( 'change_check_pay', @list );
+}
+
 sub view_invoice {
 
   $cgi->param('invnum') =~ /^(\d+)$/ or die "illegal invnum";
index bbcb687..b5936da 100644 (file)
@@ -53,6 +53,9 @@ function category_changed(what) {
       jopt( $('#product_code'), '', 'Select product code' );
 
       var part_pkg_taxproduct = reply.part_pkg_taxproduct;
+      if ( part_pkg_taxproduct.length == 0 ) {
+        alert('No compliance solutions product codes found; did you run freeside-compliance_solutions-import?');
+      }
       for ( var s = 0; s < part_pkg_taxproduct.length; s=s+2 ) {
         var product_code = part_pkg_taxproduct[s];
         var description = part_pkg_taxproduct[s+1];
index edbda5c..5f09b12 100644 (file)
@@ -212,11 +212,15 @@ invoice language options:
 
             <tr>
               <td id="<% $agentnum.$i->key.$n %>" bgcolor="#ffffff">
-<font size="-2"><pre><% encode_entities(join("\n",
-     map { length($_) > 88 ? substr($_,0,88).'...' : $_ }
-         $conf->config($i->key, $agentnum)
-   ) )
-%></pre></font>
+
+% my $escaped = eval { encode_entities(join("\n",
+%                        map { length($_) > 88 ? substr($_,0,88).'...' : $_ }
+%                          $conf->config($i->key, $agentnum)
+%                      ) );
+%                    };
+% $escaped = $@ ? '('.encode_entities($@).')' : $escaped;
+<font size="-2"><pre><% $escaped %></pre></font>
+
               </td>
             </tr>
 
@@ -435,7 +439,7 @@ my @sections = (qw(
     important
     billing payments payment_batching credit_cards e-checks taxation
     packages suspension cancellation
-    printing print_services
+    printing print_services email_to_voice_services
       invoicing invoice_email invoice_balances invoice_templates quotations 
     notification UI addresses customer_number customer_fields reporting
     localization scalability backup
index e58441d..05bf437 100755 (executable)
@@ -296,8 +296,13 @@ if ( $cgi->param('error') ) {
 
   $custnum='';
   $cust_main = new FS::cust_main ( {} );
+
+  my @agentnums = $curuser->agentnums;
+  $cust_main->agentnum( $agentnums[0] )
+    if scalar(@agentnums) == 1;
   $cust_main->agentnum( $conf->config('default_agentnum') )
     if $conf->exists('default_agentnum');
+
   $cust_main->referral_custnum( $cgi->param('referral_custnum') );
   $cust_main->set('postal_invoice', 'Y')
     unless $conf->exists('disablepostalinvoicedefault');
index b8d9f8b..24e03b0 100644 (file)
@@ -19,6 +19,7 @@
         'file'            => 'Import blocks from text file',
         'censusyear'      => 'as census year',
     },
+
     'fields'        => [
         { field         => 'zonetype',
           type          => 'hidden',
         },
         'description',
         { field         => 'active_date',
-          type          => 'fixed-date',
-          value         => time,
+          type          => 'input-date-field',
+          curr_value_callback => sub {
+            my ($cgi, $object) = @_;
+            $cgi->param('active_date') || $object->active_date || time;
+          },
         },
         { field         => 'agentnum',
           type          => 'select-agent',
index 8cec298..e7f534c 100644 (file)
         },
         'description',
         { field         => 'active_date',
-          type          => 'fixed-date',
-          value         => time,
+          type          => 'input-date-field',
+          curr_value_callback => sub {
+            my ($cgi, $object) = @_;
+            $cgi->param('active_date') || $object->active_date || time;
+          },
         },
         { field         => 'agentnum',
           type          => 'select-agent',
index 0033bbe..b22e630 100644 (file)
@@ -28,6 +28,10 @@ my $precheck_callback = sub {
       $i++;
     }
   }
+  if ( length $cgi->param('active_date') ) {
+    my $date = parse_datetime( $cgi->param('active_date') );
+    $cgi->param('active_date', $date);
+  }
   '';
 };
 </%init>
index d36d5d4..9b205ab 100644 (file)
@@ -21,6 +21,10 @@ my $precheck_callback = sub {
       $i++;
     }
   }
+  if ( length $cgi->param('active_date') ) {
+    my $date = parse_datetime( $cgi->param('active_date') );
+    $cgi->param('active_date', $date);
+  }
   '';
 };
 </%init>
index 0df9b45..a756c61 100755 (executable)
@@ -43,8 +43,7 @@ my $callback = sub {
       { 'type'          => 'tablebreak-tr-title',
         'value'         => 'Select the service types available on this router',
       },
-      { 'field'         => 'svc_part',
-        'type'          => 'checkboxes-table',
+      { 'type'          => 'checkboxes-table',
         'target_table'  => 'part_svc',
         'link_table'    => 'part_svc_router',
         'name_col'      => 'svc',
index 7be5eab..27ea3c5 100755 (executable)
 %   }
 % }
 
-% my %label = ( seconds => 'Time',
-%               upbytes => 'Upload bytes',
-%               downbytes => 'Download bytes',
-%               totalbytes => 'Total bytes',
-%             );
+% tie my %label, 'Tie::IxHash', seconds    => 'Time',
+%                               upbytes    => 'Upload bytes',
+%                               downbytes  => 'Download bytes',
+%                               totalbytes => 'Total bytes',
+% ;
 % foreach my $uf (keys %label) {
 %   my $tf = $uf . "_threshold";
 %   if ( $curuser->access_right('Edit usage') ) { 
index 463384f..7d95e19 100644 (file)
@@ -13,7 +13,7 @@
 % }
 <DIV ID="<%$pre%>form" CLASS="passwordbox">
 % if (!$opt{'noformtag'}) {
-  <FORM METHOD="POST" ACTION="<%$fsurl%>misc/process/change-password.html">
+  <FORM METHOD="POST" ACTION="<%$fsurl%>misc/process/change-password.html" onsubmit="return checkPasswordValidation()">
 % }
 
     <% $change_id_input %>
@@ -33,9 +33,6 @@
          'contactnum' => $opt{'contact_num'},
          'submitid'   => $change_button_id,
     &>
-% if ( $error ) {
-    <BR><SPAN STYLE="color: #ff0000"><% $error |h %></SPAN>
-% }
 
 % if (!$opt{'noformtag'}) {
   </FORM>
@@ -58,6 +55,16 @@ function <%$pre%>toggle(toggle, clear) {
     toggle ? 'none' : 'inline';
 % }
 }
+
+function checkPasswordValidation()  {
+  var validationResult = document.getElementById('<%$pre%>password_result').innerHTML;
+  if (validationResult.match(/Password valid!/)) {
+    return true;
+  }
+  else {
+    return false;
+  }
+}
 </SCRIPT>
 <%init>
 my %opt = @_;
index 58a7d57..defcc49 100644 (file)
@@ -147,6 +147,7 @@ if ( $curuser->access_right('List contacts') ) {
   $report_customers{'separator'} = '';
   $report_customers{'Customer contacts'} = [ $fsurl. 'search/report_contact.html?link=cust_main' ];
   $report_customers{'Customer stored payment information'} = [ $fsurl. 'search/report_cust_payby.html' ];
+  $report_customers{'Customer timespan'} = [ $fsurl. 'search/report_cust_timespan.html' ];
 }
 
 tie my %report_invoices_open, 'Tie::IxHash',
@@ -343,20 +344,25 @@ tie my %report_bill_event, 'Tie::IxHash',
   'Billing event errors' => [ $fsurl.'search/report_cust_event.html?failed=1', 'Failed credit cards, processor or printer problems, etc.' ],
 ;
 
-tie my %report_payments, 'Tie::IxHash',
-  'Payments' => [ $fsurl.'search/report_cust_pay.html', 'Payment report (by type and/or date range)' ],
-  'Payment application detail' => [ $fsurl.'search/report_cust_bill_pay_pkg.html', 'Line item application detail' ],
-;
+tie my %report_payments, 'Tie::IxHash';
+$report_payments{'Payments'} = [ $fsurl.'search/report_cust_pay.html', 'Payment report (by type and/or date range)' ]
+  if $curuser->access_right('Basic payment and refund reports');
+$report_payments{'Payment application detail'} = [ $fsurl.'search/report_cust_bill_pay_pkg.html', 'Line item application detail' ]
+  if $curuser->access_right('Financial reports');
 $report_payments{'Pending Payments'} = [ $fsurl.'search/cust_pay_pending.html?magic=_date;statusNOT=done', 'Pending real-time payments' ]
   if $curuser->access_right('View customer pending payments');
-$report_payments{'Unapplied Payments'} = [ $fsurl.'search/report_cust_pay.html?unapplied=1', 'Unapplied payment report (by type and/or date range)' ];
+$report_payments{'Unapplied Payments'} = [ $fsurl.'search/report_cust_pay.html?unapplied=1', 'Unapplied payment report (by type and/or date range)' ]
+  if $curuser->access_right('Financial reports'); #not enforced
 $report_payments{'Voided Payments'} = [ $fsurl.'search/report_cust_pay.html?void=1', 'Voided payment report (by type and/or date range)' ]
-  if $curuser->access_right('View customer pending payments');
+  if $curuser->access_right('Financial reports'); #not enforced
 $report_payments{'Payment Batches'} = [ $fsurl.'search/pay_batch.html', 'Payment batches (by status and/or date range)' ]
-  if $conf->exists('batch-enable') || $conf->config('batch-enable_payby');
-$report_payments{'Unapplied Payment Aging'} = [ $fsurl.'search/report_unapplied_cust_pay.html', 'Unapplied payment aging report' ];
+  if ( $conf->exists('batch-enable') || $conf->config('batch-enable_payby') )
+  && $curuser->access_right('Financial reports');
+$report_payments{'Unapplied Payment Aging'} = [ $fsurl.'search/report_unapplied_cust_pay.html', 'Unapplied payment aging report' ]
+  if $curuser->access_right('Financial reports');
 $report_payments{'Deleted Payments / Payment history table'} = [ $fsurl.'search/report_h_cust_pay.html', 'Deleted payments / payment history table' ]
-  if $conf->exists('payment-history-report');
+  if $conf->exists('payment-history-report')
+  && $curuser->access_right('Financial reports');
 
 tie my %report_credits, 'Tie::IxHash',
   'Credit Report' => [ $fsurl.'search/report_cust_credit.html', 'Credit report (by employee and/or date range)' ],
@@ -428,8 +434,6 @@ $report_logs{'Billing events'} =  [ $fsurl.'search/report_cust_event.html', 'Sea
   if $curuser->access_right('Billing event reports');
 $report_logs{'Credit limit incidents'} = [ $fsurl.'search/report_cust_main_credit_limit.html', '' ]
   if $curuser->access_right('List rating data');
-$report_logs{'Employee activity'} = [ $fsurl.'search/report_employee_audit.html', '' ]
-  if $curuser->access_right('Employees: Audit Report');
 $report_logs{'System log'} = [ $fsurl.'search/log.html', 'View system events and debugging information.' ],
   if $curuser->access_right('View system logs')
   || $curuser->access_right('Configuration');
@@ -437,6 +441,12 @@ $report_logs{'Outgoing messages'} = [ $fsurl.'search/cust_msg.html', 'View outgo
   if $curuser->access_right('View email logs')
   || $curuser->access_right('Configuration');
 
+tie my %report_employee, 'Tie::IxHash',
+  'Employee activity' => [ $fsurl.'search/report_employee_audit.html', '' ],
+  'Employee sessions' => [ $fsurl.'search/report_access_user_session_log.html', '' ],
+  'Access log statistics' => [ $fsurl.'search/report_access_user_log.html?group_by=path', '' ],
+;
+
 tie my %report_menu, 'Tie::IxHash';
 $report_menu{'Saved searches'} = [ \%report_saved_searches, 'My saved searches' ]
   if keys(%report_saved_searches);
@@ -457,7 +467,7 @@ $report_menu{'Invoices'}       =  [ \%report_invoices,  'Invoice reports'   ]
 $report_menu{'Discounts'}      =  [ \%report_discounts, 'Discount reports'  ]
   if $curuser->access_right('Financial reports');
 $report_menu{'Payments'}       =  [ \%report_payments,  'Payment reports'   ]
-  if $curuser->access_right('Financial reports');
+  if keys %report_payments;
 $report_menu{'Packages'}       =  [ \%report_packages,  'Package reports'   ]
   if $curuser->access_right('List packages');
 $report_menu{'Services'}       =  [ \%report_services,  'Services reports'  ]
@@ -475,6 +485,8 @@ $report_menu{'Financial (Receivables)'} = [ \%report_financial, 'Financial repor
 $report_menu{'Financial (Payables)'} = [ \%report_payable, 'Financial reports (Payables)' ]
   if $curuser->access_right('Financial reports');
 
+$report_menu{'Employees'}      = [ \%report_employee, 'Employee reports' ]
+  if $curuser->access_right('Employee Reports');
 $report_menu{'Logs'}           = [ \%report_logs, 'System and email logs' ]
   if (keys %report_logs); # empty if the user has no rights to it
 $report_menu{'SQL Query'}      = [ $fsurl.'search/report_sql.html', 'SQL Query']
@@ -505,6 +517,7 @@ tie my %tools_importing, 'Tie::IxHash',
   'Package definitions'  => [ $fsurl.'misc/part_pkg-import.html', '' ],
   'Customer packages'    => [ $fsurl.'misc/cust_pkg-import.html', '' ],
   'Customer notes'       => [ $fsurl.'misc/cust_main_note-import.html', '' ],
+  'Customer Contacts'    => [ $fsurl.'misc/contact-import.cgi', '' ],
   'One-time charges'     => [ $fsurl.'misc/cust_main-import_charges.cgi', '' ],
   'Payments'             => [ $fsurl.'misc/cust_pay-import.cgi', '' ],
   'Credits'              => [ $fsurl.'misc/cust_credit-import.html', '' ],
@@ -554,8 +567,6 @@ $tools_system{'Status'} = [ $fsurl.'view/Status.html', 'System status' ]
   if $curuser->access_right('Configuration'); # 'View system status');
 $tools_system{'Job Queue'} =  [ $fsurl.'search/queue.html', 'View pending job queue' ]
   if $curuser->access_right('Job queue');
-$tools_system{'Access log statistics'} = [ $fsurl.'search/report_access_user_log.html?group_by=path', '' ]
-  if $curuser->access_right('Configuration'); # 'View profiling data');
 
 tie my %tools_menu, 'Tie::IxHash', ();
 $tools_menu{'Customers'} = [ \%tools_customers, 'Customer tools' ]
index 5bfef48..7f8f9d8 100644 (file)
@@ -26,7 +26,7 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 my $menu_position = $curuser->option('menu_position') || 'top';
 
 my $cust_width = 246;
-my $cust_label = '(cust #, name, company';
+my $cust_label = '(cust #, name, company, email';
 if ( $conf->exists('address1-search') ) {
   $cust_label .= ', address';
   $cust_width += 56;
diff --git a/httemplate/elements/select-cust_phone.html b/httemplate/elements/select-cust_phone.html
new file mode 100644 (file)
index 0000000..94cd413
--- /dev/null
@@ -0,0 +1,31 @@
+<SELECT NAME="<% $opt{'field_name'} %>" ID="<% $opt{'field_name'} %>">
+
+     <OPTION VALUE="" selected="selected">Select a phone number
+
+% foreach $p (@$phone_types) {
+       <OPTION VALUE="<% $phones_formatted{$p} %>"><% $p |h%> (<% $cust_phones->$p |h %>)              
+%}
+
+</SELECT>
+
+<%init>
+
+my %opt = @_;
+my $cust_num     = $opt{'cust_num'};
+my $phone_types  = $opt{'phone_types'};
+my $format       = $opt{'format'};
+
+my $cust_phones = qsearchs('cust_main', { 'custnum' => $cust_num })
+  or die 'unknown custnum' . $cust_num;
+
+my %phones_formatted = map {
+       $_ => format_phone_number($cust_phones->$_, $format)
+} @$phone_types;
+
+sub format_phone_number {
+       my ($n, $f) = @_;
+       if ($f eq 'xxxxxxxxxx') { $n =~ s/-//g; }       
+       return $n;
+}
+
+</%init>
\ No newline at end of file
index 689566e..100df94 100644 (file)
@@ -69,7 +69,7 @@
 %
 % }
 
-</SELECT>
+</SELECT> <% $opt{'post_field_label'} %>
 
 % }
 <%init>
index 5ff320b..7a2d886 100644 (file)
@@ -214,7 +214,7 @@ Example:
                                         ''
                                       )
                    );
-% } else {
+% } elsif ( $locationnum != -1 ) {
     locationnum_changed(document.getElementById('locationnum'));
 % }
 </SCRIPT>
diff --git a/httemplate/elements/tr-select-cust_phone.html b/httemplate/elements/tr-select-cust_phone.html
new file mode 100644 (file)
index 0000000..cf88392
--- /dev/null
@@ -0,0 +1,12 @@
+  <TR>
+    <TD ALIGN="right"><% $opt{'label'} || 'Customer Phones' %></TD>
+    <TD>
+      <% include( '/elements/select-cust_phone.html', %opt ) %>
+    </TD>
+  </TR>
+
+<%init>
+
+my %opt = @_;
+
+</%init>
index 3d23a55..4057f5d 100644 (file)
@@ -22,6 +22,17 @@ should be the input id plus '_result'.
 <SCRIPT>
 function add_password_validation (fieldid, submitid) {
   var inputfield = document.getElementById(fieldid);
+  inputfield.onkeydown = function(e) {
+    var key;
+    if (window.event) { key = window.event.keyCode; }
+    else { key = e.which; } // for ff browsers
+    // some browsers allow the enter key to submit a form even if the submit button is disabled
+    // below prevents enter key from submiting form if password has not been validated.
+    if (key == '13') {
+      var check = checkPasswordValidation();
+      return check;
+    }
+  }
   inputfield.onkeyup = function () {
     var fieldid = this.id+'_result';
     var resultfield = document.getElementById(fieldid);
index 3c18622..96cf641 100755 (executable)
@@ -197,25 +197,27 @@ my $cust_pkg = qsearchs('cust_pkg', {'pkgnum' => $pkgnum})
 
 my $part_pkg = $cust_pkg->part_pkg;
 
-my @unprovision_warning;
-{
-    my @services_w_export;
-    for ( $cust_pkg->cust_svc ) {
-        push( @services_w_export, ($_->label)[0] . ': ' . ($_->label)[1], )
-          if $_->part_svc->export_svc;
-    }
-    if ( @services_w_export ) {
-        push( @unprovision_warning, 'NOTE: This package has '
-          . @services_w_export . ' ' . PL( "service", @services_w_export )
-          . ' that will be unprovisioned', );
-        if ( @services_w_export < 10 ) {
-            $unprovision_warning[0] .= ':';
-            push( @unprovision_warning, @services_w_export, );
-        }
-        else {
-            $unprovision_warning[0] .= '.';
-        }
+my @unprovision_warning = ();
+unless ( $method =~ /^(resume|uncancel)$/ ) {
+  my @services_w_export = map { my @l = $_->label; $l[0]. ': '. $l[1]; }
+                            grep $_->part_svc->export_svc,
+                              $cust_pkg->cust_svc;
+  if ( @services_w_export ) {
+
+    my $actioned = ($method =~ /^(suspend|adjourn)$/) ? 'suspended'
+                                                      : 'unprovisioned';
+    push @unprovision_warning,
+      'NOTE: This package has '. @services_w_export. ' '.
+      PL( 'service', @services_w_export ). " that will be $actioned";
+
+    if ( @services_w_export < 10 ) {
+      $unprovision_warning[0] .= ':';
+      push @unprovision_warning, @services_w_export;
+    } else {
+      $unprovision_warning[0] .= '.';
     }
+
+  }
 }
 
 $date ||= $cust_pkg->get($method);
index 3237845..243da93 100755 (executable)
@@ -8,7 +8,7 @@
 
 
 <FONT CLASS="fsinnerbox-title"><% mt('Package') |h %></FONT>
-<% ntable('#cccccc') %>
+<TABLE CLASS="fsinnerbox">
 
   <TR>
     <TH ALIGN="right"><% mt('Current package') |h %></TH>
@@ -45,7 +45,7 @@
 &>
 
 <FONT CLASS="fsinnerbox-title"><% mt('Change') |h %></FONT>
-<% ntable('#cccccc') %>
+<TABLE CLASS="fsinnerbox">
 
   <SCRIPT TYPE="text/javascript">
     function delay_changed() {
 %
 % if ( $discount_cust_pkg || $waive_setup_fee ) {
   <FONT CLASS="fsinnerbox-title"><% mt('Discounting') |h %></FONT>
-  <% ntable("#cccccc") %>
+  <TABLE CLASS="fsinnerbox">
     <& /elements/tr-select-pkg-discount.html, disable_recur => 1, &>
   </TABLE><BR>
 
 % }
 
 <FONT CLASS="fsinnerbox-title"><% mt('Location') |h %></FONT>
-<% ntable('#cccccc') %>
+<TABLE CLASS="fsinnerbox">
 
   <& /elements/tr-select-cust_location.html,
                'cgi'       => $cgi,
index 10ae918..b491d49 100644 (file)
@@ -16,13 +16,14 @@ Confirm census tract
 <% $location{address1} |h %> <% $location{address2} |h %><BR>
 <% $location{city} |h %>, <% $location{state} |h %> <% $location{zip} |h %><BR>
 <BR>
-% my $querystring = "census_year=$year&latitude=".$cache->get('latitude').'&longitude='.$cache->get('longitude');
-<A HREF="http://maps.ffiec.gov/FFIECMapper/TGMapSrv.aspx?<% $querystring %>"
+% my $querystring = "census_year=$year&address=$location{address1}, $location{address2}, $location{city}, $location{state}, $location{zip}";
+<A HREF="<%$p%>misc/openmap.html?<% $querystring %>"
    TARGET="_blank">Map service module location</A><BR>
-% $querystring = "census_year=$year&zip_code=".$cache->get('zip');
-<A HREF="http://maps.ffiec.gov/FFIECMapper/TGMapSrv.aspx?<% $querystring %>"
+% $querystring = "census_year=$year&pre=$pre&zip_code=" . $cache->get('zip');
+<A HREF="<%$p%>misc/openmap.html?<% $querystring %>"
    TARGET="_blank">Map zip code center</A><BR>
 <BR>
+<input type="hidden" id="new_tract" name="new_tract" value="<%$new_tract%>">
 <TABLE>
   <TR>
     <TH style="width:50%">Entered census tract</TH>
@@ -31,9 +32,9 @@ Confirm census tract
   <TR>
     <TD><% $old_tract %></TD>
 % if ( $error ) {
-    <TD><FONT COLOR="#ff0000"><% $error %></FONT></TD>
+    <TD><div id='newcensustract'><FONT COLOR="#ff0000"><% $error %></FONT></div></TD>
 % } else {
-    <TD><% $new_tract %></TD>
+    <TD><div id='newcensustract'><% $new_tract %></div></TD>
 % }
   </TR>
   <TR>
@@ -43,30 +44,43 @@ Confirm census tract
       <IMG SRC="<%$p%>images/error.png" ALT=""> Use entered census tract
       </BUTTON>
     </TD>
-%   if ( !$error ) {
     <TD ALIGN="center">
-      <BUTTON TYPE="button"
-              onclick="set_censustract('<% $new_tract %>', '<% $year %>', '<% $pre %>')">
+     <div id="setnewtractdiv"
+% if ( $error ) { ## do not display this block if error finding track.
+      style="display:none"
+% }
+     >
+      <BUTTON TYPE="button" id="setnewtract"
+              onclick="set_censustract(getElementById('new_tract').value, '<% $year %>', '<% $pre %>')">
       <IMG SRC="<%$p%>images/tick.png" ALT=""> Use calculated census tract
       </BUTTON>
-    </TD>
-  </TR>
-  <TR>
-    <TD COLSPAN=2 ALIGN="center">
+     </div>
+     <div id='cancelsubmissiontop'
+% if ( !$error ) { ## do not display this block if there is no error finding a track.
+      style="display:none"
+% }
+     >
       <BUTTON TYPE="button" onclick="submit_abort()">
-      <IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission
+        <IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission
       </BUTTON>
+     </div>
     </TD>
   </TR>
-%   } else { # don't show a button to use the calculated value
-    <TD COLSPAN=1 ALIGN="center">
+  <TR>
+    <TD COLSPAN=2 ALIGN="center">
+     <div id='cancelsubmissionbottom'
+% if ( $error ) { ## do not display this block if error finding track.
+      style="display:none"
+% }
+     >
       <BUTTON TYPE="button" onclick="submit_abort()">
-      <IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission
+        <IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission
       </BUTTON>
+     </div>
     </TD>
   </TR>
-%   }
 </TABLE></CENTER>
+
 <%init>
 
 local $SIG{__DIE__}; #disable Mason error trap
diff --git a/httemplate/misc/contact-import.cgi b/httemplate/misc/contact-import.cgi
new file mode 100644 (file)
index 0000000..ae2e349
--- /dev/null
@@ -0,0 +1,102 @@
+<% include("/elements/header.html",'Batch Contacts Import') %>
+
+Import a file containing customer contact records.
+<BR><BR>
+
+<& /elements/form-file_upload.html,
+     'name'      => 'ContactImportForm',
+     'action'    => 'process/contact-import.cgi',
+     'num_files' => 1,
+     'fields'    => [ 'custbatch', 'format' ],
+     'message'   => 'Customer contacts import successful',
+     'onsubmit'  => "document.ContactImportForm.submitButton.disabled=true;",
+&>
+
+<% &ntable("#cccccc", 2) %>
+
+  <INPUT TYPE="hidden" NAME="custbatch" VALUE="<% $custbatch %>"%>
+
+  <TR>
+    <TH ALIGN="right">Format</TH>
+    <TD>
+      <SELECT NAME="format">
+        <OPTION VALUE="default" SELECTED>Default
+      </SELECT>
+    </TD>
+  </TR>
+
+  <% include( '/elements/file-upload.html',
+                'field' => 'file',
+                'label' => 'Filename',
+            )
+  %>
+
+  <TR>
+    <TD COLSPAN=2 ALIGN="center" STYLE="padding-top:6px">
+      <INPUT TYPE    = "submit"
+             NAME    = "submitButton"
+             ID      = "submitButton"
+             VALUE   = "Import file"
+      >
+    </TD>
+  </TR>
+
+</TABLE>
+
+</FORM>
+
+<BR>
+
+Uploaded files can be CSV (comma-separated value) files or Excel spreadsheets.  The file should have a .CSV or .XLS extension.
+<BR><BR>
+
+Default Format has the following field order:
+<BR>
+<i>custnum<%$req%>, last<%$req%>, first<%$req%>, title<%$req%>, comment, selfservice_access, emailaddress, workphone, mobilephone, homephone</i>
+<BR><BR>
+
+Field information:
+<BR>
+You must include a customer number and either a last name, first name or title.
+
+<ul>
+
+  <li><i>custnum</i>: This is the customer number of the customer the contact is attached to.</li>
+
+  <li><i>last</i>: Last name for contact.</li>
+
+  <li><i>first</i>: First name for contact.</li>
+
+  <li><i>title</i>: Optional title for contact.</li>
+
+  <li><i>comment</i>: Optional comment for contact.</li>
+
+  <li><i>selfservice_access</i>: Empty for no self service access or Y if granting self service access.</li>
+
+  <li><i>emailaddress</i>: Email address for contact.</li>
+
+  <li><i>workphone</i>: Work phone number for contact. Format xxxxxxxxxx</li>
+
+  <li><i>mobilephone</i>: Mobile phone number for contact. Format xxxxxxxxxx</li>
+
+  <li><i>homephone</i>: Home phone number for contact. Format xxxxxxxxxx</li>
+
+</ul>
+
+<BR>
+
+<% include('/elements/footer.html') %>
+
+<%once>
+
+my $req = qq!<font color="#ff0000">*</font>!;
+
+</%once>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+my $custbatch = time2str('webimport-%Y/%m/%d-%T'. "-$$-". rand() * 2**32, time);
+
+</%init>
\ No newline at end of file
index 2ed3c48..3b200e5 100644 (file)
@@ -48,6 +48,9 @@ Import a file containing customer packages.
         <OPTION VALUE="location">Location
         <OPTION VALUE="location-agent_custid">Location with agent_custid
         <OPTION VALUE="location-agent_custid-agent_pkgid">Location with agent_custid and agent_pkgid
+        <OPTION VALUE="location-quan_price-svc_phone">Location, quantity and price customizations with phone service
+        <OPTION VALUE="location-quan_price-svc_phone-agent_custid">Location, quantity and price customizations with phone service and agent_custid
+        <OPTION VALUE="location-quan_price-svc_phone-agent_custid-agent_pkgid">Location, quantity and price customizations with phone service and agent_custid and agent_pkgid
       </SELECT>
     </TD>
   </TR>
@@ -150,7 +153,34 @@ address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country<%$
 </i>
 <BR><BR>
 
-<%$req%> Required fields
+<b>Location, quantity and price customizations with phone service</b> format has the following field order: <i>custnum<%$req%>,
+pkgpart<%$req%>, discountnum,
+start_date, setup, bill, last_bill, susp, adjourn, cancel, expire,
+address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country<%$req%>,
+quantity, setup_fee, recur_fee, invoice_details,
+countrycode, phonenum, sip_password, pin
+</i>
+<BR><BR>
+
+<b>Location, quantity and price customizations with phone service and agent_custid</b> format has the following field order: <i>agent_custid<%$req%>,
+pkgpart<%$req%>, discountnum,
+start_date, setup, bill, last_bill, susp, adjourn, cancel, expire,
+address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country<%$req%>,
+quantity, setup_fee, recur_fee, invoice_details,
+countrycode, phonenum, sip_password, pin
+</i>
+<BR><BR>
+
+<b>Location, quantity and price customizations with phone service and agent_custid and agent_pkgid</b> format has the following field order: <i>agent_custid<%$req%>, agent_pkgid,
+pkgpart<%$req%>, discountnum,
+start_date, setup, bill, last_bill, susp, adjourn, cancel, expire,
+address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country<%$req%>,
+quantity, setup_fee, recur_fee, invoice_details,
+countrycode, phonenum, sip_password, pin
+</i>
+<BR><BR>
+
+<%$req%> Required fields (for address fields, required if an address is specified)
 <BR><BR>
 
 Field information:
@@ -183,6 +213,14 @@ Field information:
 
   <li><i>expire</i>: Indicates a future cancellation on this date.
 
+  <li><i>quantity</i>
+
+  <li><i>setup_fee</i>: Including this fee implements per-customer custom pricing for this package, overriding package definition pricing
+
+  <li><i>recur_fee</i>: Including this fee implements per-customer custom pricing for this package, overriding package definition pricing
+
+  <li><i>invoice_details</i>: Package invoice details (optionally, can include multiple lines of details separated by a vertical bar)
+
 <!--
   <li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified. (Extended and Extended plus company formats)
 -->
diff --git a/httemplate/misc/elements/leaflet/images/layers-2x.png b/httemplate/misc/elements/leaflet/images/layers-2x.png
new file mode 100644 (file)
index 0000000..200c333
Binary files /dev/null and b/httemplate/misc/elements/leaflet/images/layers-2x.png differ
diff --git a/httemplate/misc/elements/leaflet/images/layers.png b/httemplate/misc/elements/leaflet/images/layers.png
new file mode 100644 (file)
index 0000000..1a72e57
Binary files /dev/null and b/httemplate/misc/elements/leaflet/images/layers.png differ
diff --git a/httemplate/misc/elements/leaflet/images/marker-icon-2x.png b/httemplate/misc/elements/leaflet/images/marker-icon-2x.png
new file mode 100644 (file)
index 0000000..e4abba3
Binary files /dev/null and b/httemplate/misc/elements/leaflet/images/marker-icon-2x.png differ
diff --git a/httemplate/misc/elements/leaflet/images/marker-icon.png b/httemplate/misc/elements/leaflet/images/marker-icon.png
new file mode 100644 (file)
index 0000000..950edf2
Binary files /dev/null and b/httemplate/misc/elements/leaflet/images/marker-icon.png differ
diff --git a/httemplate/misc/elements/leaflet/images/marker-shadow.png b/httemplate/misc/elements/leaflet/images/marker-shadow.png
new file mode 100644 (file)
index 0000000..9fd2979
Binary files /dev/null and b/httemplate/misc/elements/leaflet/images/marker-shadow.png differ
diff --git a/httemplate/misc/elements/leaflet/leaflet-src.js b/httemplate/misc/elements/leaflet/leaflet-src.js
new file mode 100644 (file)
index 0000000..d70b16a
--- /dev/null
@@ -0,0 +1,13609 @@
+/* @preserve
+ * Leaflet 1.2.0+Detached: 1ac320ba232cb85b73ac81f3d82780c9d07f0d4e.1ac320b, a JS library for interactive maps. http://leafletjs.com
+ * (c) 2010-2017 Vladimir Agafonkin, (c) 2010-2011 CloudMade
+ */
+(function (global, factory) {
+       typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+       typeof define === 'function' && define.amd ? define(['exports'], factory) :
+       (factory((global.L = {})));
+}(this, (function (exports) { 'use strict';
+
+var version = "1.2.0+HEAD.1ac320b";
+
+/*\r
+ * @namespace Util\r
+ *\r
+ * Various utility functions, used by Leaflet internally.\r
+ */\r
+\r
+var freeze = Object.freeze;\r
+Object.freeze = function (obj) { return obj; };\r
+\r
+// @function extend(dest: Object, src?: Object): Object\r
+// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut.\r
+function extend(dest) {\r
+       var i, j, len, src;\r
+\r
+       for (j = 1, len = arguments.length; j < len; j++) {\r
+               src = arguments[j];\r
+               for (i in src) {\r
+                       dest[i] = src[i];\r
+               }\r
+       }\r
+       return dest;\r
+}\r
+\r
+// @function create(proto: Object, properties?: Object): Object\r
+// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create)\r
+var create = Object.create || (function () {\r
+       function F() {}\r
+       return function (proto) {\r
+               F.prototype = proto;\r
+               return new F();\r
+       };\r
+})();\r
+\r
+// @function bind(fn: Function, …): Function\r
+// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind).\r
+// Has a `L.bind()` shortcut.\r
+function bind(fn, obj) {\r
+       var slice = Array.prototype.slice;\r
+\r
+       if (fn.bind) {\r
+               return fn.bind.apply(fn, slice.call(arguments, 1));\r
+       }\r
+\r
+       var args = slice.call(arguments, 2);\r
+\r
+       return function () {\r
+               return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments);\r
+       };\r
+}\r
+\r
+// @property lastId: Number\r
+// Last unique ID used by [`stamp()`](#util-stamp)\r
+var lastId = 0;\r
+\r
+// @function stamp(obj: Object): Number\r
+// Returns the unique ID of an object, assiging it one if it doesn't have it.\r
+function stamp(obj) {\r
+       /*eslint-disable */\r
+       obj._leaflet_id = obj._leaflet_id || ++lastId;\r
+       return obj._leaflet_id;\r
+       /*eslint-enable */\r
+}\r
+\r
+// @function throttle(fn: Function, time: Number, context: Object): Function\r
+// Returns a function which executes function `fn` with the given scope `context`\r
+// (so that the `this` keyword refers to `context` inside `fn`'s code). The function\r
+// `fn` will be called no more than one time per given amount of `time`. The arguments\r
+// received by the bound function will be any arguments passed when binding the\r
+// function, followed by any arguments passed when invoking the bound function.\r
+// Has an `L.throttle` shortcut.\r
+function throttle(fn, time, context) {\r
+       var lock, args, wrapperFn, later;\r
+\r
+       later = function () {\r
+               // reset lock and call if queued\r
+               lock = false;\r
+               if (args) {\r
+                       wrapperFn.apply(context, args);\r
+                       args = false;\r
+               }\r
+       };\r
+\r
+       wrapperFn = function () {\r
+               if (lock) {\r
+                       // called too soon, queue to call later\r
+                       args = arguments;\r
+\r
+               } else {\r
+                       // call and lock until later\r
+                       fn.apply(context, arguments);\r
+                       setTimeout(later, time);\r
+                       lock = true;\r
+               }\r
+       };\r
+\r
+       return wrapperFn;\r
+}\r
+\r
+// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number\r
+// Returns the number `num` modulo `range` in such a way so it lies within\r
+// `range[0]` and `range[1]`. The returned value will be always smaller than\r
+// `range[1]` unless `includeMax` is set to `true`.\r
+function wrapNum(x, range, includeMax) {\r
+       var max = range[1],\r
+           min = range[0],\r
+           d = max - min;\r
+       return x === max && includeMax ? x : ((x - min) % d + d) % d + min;\r
+}\r
+\r
+// @function falseFn(): Function\r
+// Returns a function which always returns `false`.\r
+function falseFn() { return false; }\r
+\r
+// @function formatNum(num: Number, digits?: Number): Number\r
+// Returns the number `num` rounded to `digits` decimals, or to 5 decimals by default.\r
+function formatNum(num, digits) {\r
+       var pow = Math.pow(10, digits || 5);\r
+       return Math.round(num * pow) / pow;\r
+}\r
+\r
+// @function trim(str: String): String\r
+// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim)\r
+function trim(str) {\r
+       return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, '');\r
+}\r
+\r
+// @function splitWords(str: String): String[]\r
+// Trims and splits the string on whitespace and returns the array of parts.\r
+function splitWords(str) {\r
+       return trim(str).split(/\s+/);\r
+}\r
+\r
+// @function setOptions(obj: Object, options: Object): Object\r
+// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut.\r
+function setOptions(obj, options) {\r
+       if (!obj.hasOwnProperty('options')) {\r
+               obj.options = obj.options ? create(obj.options) : {};\r
+       }\r
+       for (var i in options) {\r
+               obj.options[i] = options[i];\r
+       }\r
+       return obj.options;\r
+}\r
+\r
+// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String\r
+// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}`\r
+// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will\r
+// be appended at the end. If `uppercase` is `true`, the parameter names will\r
+// be uppercased (e.g. `'?A=foo&B=bar'`)\r
+function getParamString(obj, existingUrl, uppercase) {\r
+       var params = [];\r
+       for (var i in obj) {\r
+               params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i]));\r
+       }\r
+       return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&');\r
+}\r
+\r
+var templateRe = /\{ *([\w_\-]+) *\}/g;\r
+\r
+// @function template(str: String, data: Object): String\r
+// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'`\r
+// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string\r
+// `('Hello foo, bar')`. You can also specify functions instead of strings for\r
+// data values — they will be evaluated passing `data` as an argument.\r
+function template(str, data) {\r
+       return str.replace(templateRe, function (str, key) {\r
+               var value = data[key];\r
+\r
+               if (value === undefined) {\r
+                       throw new Error('No value provided for variable ' + str);\r
+\r
+               } else if (typeof value === 'function') {\r
+                       value = value(data);\r
+               }\r
+               return value;\r
+       });\r
+}\r
+\r
+// @function isArray(obj): Boolean\r
+// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray)\r
+var isArray = Array.isArray || function (obj) {\r
+       return (Object.prototype.toString.call(obj) === '[object Array]');\r
+};\r
+\r
+// @function indexOf(array: Array, el: Object): Number\r
+// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf)\r
+function indexOf(array, el) {\r
+       for (var i = 0; i < array.length; i++) {\r
+               if (array[i] === el) { return i; }\r
+       }\r
+       return -1;\r
+}\r
+\r
+// @property emptyImageUrl: String\r
+// Data URI string containing a base64-encoded empty GIF image.\r
+// Used as a hack to free memory from unused images on WebKit-powered\r
+// mobile devices (by setting image `src` to this string).\r
+var emptyImageUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';\r
+\r
+// inspired by http://paulirish.com/2011/requestanimationframe-for-smart-animating/\r
+\r
+function getPrefixed(name) {\r
+       return window['webkit' + name] || window['moz' + name] || window['ms' + name];\r
+}\r
+\r
+var lastTime = 0;\r
+\r
+// fallback for IE 7-8\r
+function timeoutDefer(fn) {\r
+       var time = +new Date(),\r
+           timeToCall = Math.max(0, 16 - (time - lastTime));\r
+\r
+       lastTime = time + timeToCall;\r
+       return window.setTimeout(fn, timeToCall);\r
+}\r
+\r
+var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer;\r
+var cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') ||\r
+               getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); };\r
+\r
+// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number\r
+// Schedules `fn` to be executed when the browser repaints. `fn` is bound to\r
+// `context` if given. When `immediate` is set, `fn` is called immediately if\r
+// the browser doesn't have native support for\r
+// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame),\r
+// otherwise it's delayed. Returns a request ID that can be used to cancel the request.\r
+function requestAnimFrame(fn, context, immediate) {\r
+       if (immediate && requestFn === timeoutDefer) {\r
+               fn.call(context);\r
+       } else {\r
+               return requestFn.call(window, bind(fn, context));\r
+       }\r
+}\r
+\r
+// @function cancelAnimFrame(id: Number): undefined\r
+// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame).\r
+function cancelAnimFrame(id) {\r
+       if (id) {\r
+               cancelFn.call(window, id);\r
+       }\r
+}\r
+
+
+var Util = (Object.freeze || Object)({
+       freeze: freeze,
+       extend: extend,
+       create: create,
+       bind: bind,
+       lastId: lastId,
+       stamp: stamp,
+       throttle: throttle,
+       wrapNum: wrapNum,
+       falseFn: falseFn,
+       formatNum: formatNum,
+       trim: trim,
+       splitWords: splitWords,
+       setOptions: setOptions,
+       getParamString: getParamString,
+       template: template,
+       isArray: isArray,
+       indexOf: indexOf,
+       emptyImageUrl: emptyImageUrl,
+       requestFn: requestFn,
+       cancelFn: cancelFn,
+       requestAnimFrame: requestAnimFrame,
+       cancelAnimFrame: cancelAnimFrame
+});
+
+// @class Class\r
+// @aka L.Class\r
+\r
+// @section\r
+// @uninheritable\r
+\r
+// Thanks to John Resig and Dean Edwards for inspiration!\r
+\r
+function Class() {}\r
+\r
+Class.extend = function (props) {\r
+\r
+       // @function extend(props: Object): Function\r
+       // [Extends the current class](#class-inheritance) given the properties to be included.\r
+       // Returns a Javascript function that is a class constructor (to be called with `new`).\r
+       var NewClass = function () {\r
+\r
+               // call the constructor\r
+               if (this.initialize) {\r
+                       this.initialize.apply(this, arguments);\r
+               }\r
+\r
+               // call all constructor hooks\r
+               this.callInitHooks();\r
+       };\r
+\r
+       var parentProto = NewClass.__super__ = this.prototype;\r
+\r
+       var proto = create(parentProto);\r
+       proto.constructor = NewClass;\r
+\r
+       NewClass.prototype = proto;\r
+\r
+       // inherit parent's statics\r
+       for (var i in this) {\r
+               if (this.hasOwnProperty(i) && i !== 'prototype' && i !== '__super__') {\r
+                       NewClass[i] = this[i];\r
+               }\r
+       }\r
+\r
+       // mix static properties into the class\r
+       if (props.statics) {\r
+               extend(NewClass, props.statics);\r
+               delete props.statics;\r
+       }\r
+\r
+       // mix includes into the prototype\r
+       if (props.includes) {\r
+               checkDeprecatedMixinEvents(props.includes);\r
+               extend.apply(null, [proto].concat(props.includes));\r
+               delete props.includes;\r
+       }\r
+\r
+       // merge options\r
+       if (proto.options) {\r
+               props.options = extend(create(proto.options), props.options);\r
+       }\r
+\r
+       // mix given properties into the prototype\r
+       extend(proto, props);\r
+\r
+       proto._initHooks = [];\r
+\r
+       // add method for calling all hooks\r
+       proto.callInitHooks = function () {\r
+\r
+               if (this._initHooksCalled) { return; }\r
+\r
+               if (parentProto.callInitHooks) {\r
+                       parentProto.callInitHooks.call(this);\r
+               }\r
+\r
+               this._initHooksCalled = true;\r
+\r
+               for (var i = 0, len = proto._initHooks.length; i < len; i++) {\r
+                       proto._initHooks[i].call(this);\r
+               }\r
+       };\r
+\r
+       return NewClass;\r
+};\r
+\r
+\r
+// @function include(properties: Object): this\r
+// [Includes a mixin](#class-includes) into the current class.\r
+Class.include = function (props) {\r
+       extend(this.prototype, props);\r
+       return this;\r
+};\r
+\r
+// @function mergeOptions(options: Object): this\r
+// [Merges `options`](#class-options) into the defaults of the class.\r
+Class.mergeOptions = function (options) {\r
+       extend(this.prototype.options, options);\r
+       return this;\r
+};\r
+\r
+// @function addInitHook(fn: Function): this\r
+// Adds a [constructor hook](#class-constructor-hooks) to the class.\r
+Class.addInitHook = function (fn) { // (Function) || (String, args...)\r
+       var args = Array.prototype.slice.call(arguments, 1);\r
+\r
+       var init = typeof fn === 'function' ? fn : function () {\r
+               this[fn].apply(this, args);\r
+       };\r
+\r
+       this.prototype._initHooks = this.prototype._initHooks || [];\r
+       this.prototype._initHooks.push(init);\r
+       return this;\r
+};\r
+\r
+function checkDeprecatedMixinEvents(includes) {\r
+       if (!L || !L.Mixin) { return; }\r
+\r
+       includes = isArray(includes) ? includes : [includes];\r
+\r
+       for (var i = 0; i < includes.length; i++) {\r
+               if (includes[i] === L.Mixin.Events) {\r
+                       console.warn('Deprecated include of L.Mixin.Events: ' +\r
+                               'this property will be removed in future releases, ' +\r
+                               'please inherit from L.Evented instead.', new Error().stack);\r
+               }\r
+       }\r
+}
+
+/*\r
+ * @class Evented\r
+ * @aka L.Evented\r
+ * @inherits Class\r
+ *\r
+ * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event).\r
+ *\r
+ * @example\r
+ *\r
+ * ```js\r
+ * map.on('click', function(e) {\r
+ *     alert(e.latlng);\r
+ * } );\r
+ * ```\r
+ *\r
+ * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function:\r
+ *\r
+ * ```js\r
+ * function onClick(e) { ... }\r
+ *\r
+ * map.on('click', onClick);\r
+ * map.off('click', onClick);\r
+ * ```\r
+ */\r
+\r
+var Events = {\r
+       /* @method on(type: String, fn: Function, context?: Object): this\r
+        * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`).\r
+        *\r
+        * @alternative\r
+        * @method on(eventMap: Object): this\r
+        * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}`\r
+        */\r
+       on: function (types, fn, context) {\r
+\r
+               // types can be a map of types/handlers\r
+               if (typeof types === 'object') {\r
+                       for (var type in types) {\r
+                               // we don't process space-separated events here for performance;\r
+                               // it's a hot path since Layer uses the on(obj) syntax\r
+                               this._on(type, types[type], fn);\r
+                       }\r
+\r
+               } else {\r
+                       // types can be a string of space-separated words\r
+                       types = splitWords(types);\r
+\r
+                       for (var i = 0, len = types.length; i < len; i++) {\r
+                               this._on(types[i], fn, context);\r
+                       }\r
+               }\r
+\r
+               return this;\r
+       },\r
+\r
+       /* @method off(type: String, fn?: Function, context?: Object): this\r
+        * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener.\r
+        *\r
+        * @alternative\r
+        * @method off(eventMap: Object): this\r
+        * Removes a set of type/listener pairs.\r
+        *\r
+        * @alternative\r
+        * @method off: this\r
+        * Removes all listeners to all events on the object.\r
+        */\r
+       off: function (types, fn, context) {\r
+\r
+               if (!types) {\r
+                       // clear all listeners if called without arguments\r
+                       delete this._events;\r
+\r
+               } else if (typeof types === 'object') {\r
+                       for (var type in types) {\r
+                               this._off(type, types[type], fn);\r
+                       }\r
+\r
+               } else {\r
+                       types = splitWords(types);\r
+\r
+                       for (var i = 0, len = types.length; i < len; i++) {\r
+                               this._off(types[i], fn, context);\r
+                       }\r
+               }\r
+\r
+               return this;\r
+       },\r
+\r
+       // attach listener (without syntactic sugar now)\r
+       _on: function (type, fn, context) {\r
+               this._events = this._events || {};\r
+\r
+               /* get/init listeners for type */\r
+               var typeListeners = this._events[type];\r
+               if (!typeListeners) {\r
+                       typeListeners = [];\r
+                       this._events[type] = typeListeners;\r
+               }\r
+\r
+               if (context === this) {\r
+                       // Less memory footprint.\r
+                       context = undefined;\r
+               }\r
+               var newListener = {fn: fn, ctx: context},\r
+                   listeners = typeListeners;\r
+\r
+               // check if fn already there\r
+               for (var i = 0, len = listeners.length; i < len; i++) {\r
+                       if (listeners[i].fn === fn && listeners[i].ctx === context) {\r
+                               return;\r
+                       }\r
+               }\r
+\r
+               listeners.push(newListener);\r
+       },\r
+\r
+       _off: function (type, fn, context) {\r
+               var listeners,\r
+                   i,\r
+                   len;\r
+\r
+               if (!this._events) { return; }\r
+\r
+               listeners = this._events[type];\r
+\r
+               if (!listeners) {\r
+                       return;\r
+               }\r
+\r
+               if (!fn) {\r
+                       // Set all removed listeners to noop so they are not called if remove happens in fire\r
+                       for (i = 0, len = listeners.length; i < len; i++) {\r
+                               listeners[i].fn = falseFn;\r
+                       }\r
+                       // clear all listeners for a type if function isn't specified\r
+                       delete this._events[type];\r
+                       return;\r
+               }\r
+\r
+               if (context === this) {\r
+                       context = undefined;\r
+               }\r
+\r
+               if (listeners) {\r
+\r
+                       // find fn and remove it\r
+                       for (i = 0, len = listeners.length; i < len; i++) {\r
+                               var l = listeners[i];\r
+                               if (l.ctx !== context) { continue; }\r
+                               if (l.fn === fn) {\r
+\r
+                                       // set the removed listener to noop so that's not called if remove happens in fire\r
+                                       l.fn = falseFn;\r
+\r
+                                       if (this._firingCount) {\r
+                                               /* copy array in case events are being fired */\r
+                                               this._events[type] = listeners = listeners.slice();\r
+                                       }\r
+                                       listeners.splice(i, 1);\r
+\r
+                                       return;\r
+                               }\r
+                       }\r
+               }\r
+       },\r
+\r
+       // @method fire(type: String, data?: Object, propagate?: Boolean): this\r
+       // Fires an event of the specified type. You can optionally provide an data\r
+       // object — the first argument of the listener function will contain its\r
+       // properties. The event can optionally be propagated to event parents.\r
+       fire: function (type, data, propagate) {\r
+               if (!this.listens(type, propagate)) { return this; }\r
+\r
+               var event = extend({}, data, {type: type, target: this});\r
+\r
+               if (this._events) {\r
+                       var listeners = this._events[type];\r
+\r
+                       if (listeners) {\r
+                               this._firingCount = (this._firingCount + 1) || 1;\r
+                               for (var i = 0, len = listeners.length; i < len; i++) {\r
+                                       var l = listeners[i];\r
+                                       l.fn.call(l.ctx || this, event);\r
+                               }\r
+\r
+                               this._firingCount--;\r
+                       }\r
+               }\r
+\r
+               if (propagate) {\r
+                       // propagate the event to parents (set with addEventParent)\r
+                       this._propagateEvent(event);\r
+               }\r
+\r
+               return this;\r
+       },\r
+\r
+       // @method listens(type: String): Boolean\r
+       // Returns `true` if a particular event type has any listeners attached to it.\r
+       listens: function (type, propagate) {\r
+               var listeners = this._events && this._events[type];\r
+               if (listeners && listeners.length) { return true; }\r
+\r
+               if (propagate) {\r
+                       // also check parents for listeners if event propagates\r
+                       for (var id in this._eventParents) {\r
+                               if (this._eventParents[id].listens(type, propagate)) { return true; }\r
+                       }\r
+               }\r
+               return false;\r
+       },\r
+\r
+       // @method once(…): this\r
+       // Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed.\r
+       once: function (types, fn, context) {\r
+\r
+               if (typeof types === 'object') {\r
+                       for (var type in types) {\r
+                               this.once(type, types[type], fn);\r
+                       }\r
+                       return this;\r
+               }\r
+\r
+               var handler = bind(function () {\r
+                       this\r
+                           .off(types, fn, context)\r
+                           .off(types, handler, context);\r
+               }, this);\r
+\r
+               // add a listener that's executed once and removed after that\r
+               return this\r
+                   .on(types, fn, context)\r
+                   .on(types, handler, context);\r
+       },\r
+\r
+       // @method addEventParent(obj: Evented): this\r
+       // Adds an event parent - an `Evented` that will receive propagated events\r
+       addEventParent: function (obj) {\r
+               this._eventParents = this._eventParents || {};\r
+               this._eventParents[stamp(obj)] = obj;\r
+               return this;\r
+       },\r
+\r
+       // @method removeEventParent(obj: Evented): this\r
+       // Removes an event parent, so it will stop receiving propagated events\r
+       removeEventParent: function (obj) {\r
+               if (this._eventParents) {\r
+                       delete this._eventParents[stamp(obj)];\r
+               }\r
+               return this;\r
+       },\r
+\r
+       _propagateEvent: function (e) {\r
+               for (var id in this._eventParents) {\r
+                       this._eventParents[id].fire(e.type, extend({layer: e.target}, e), true);\r
+               }\r
+       }\r
+};\r
+\r
+// aliases; we should ditch those eventually\r
+\r
+// @method addEventListener(…): this\r
+// Alias to [`on(…)`](#evented-on)\r
+Events.addEventListener = Events.on;\r
+\r
+// @method removeEventListener(…): this\r
+// Alias to [`off(…)`](#evented-off)\r
+\r
+// @method clearAllEventListeners(…): this\r
+// Alias to [`off()`](#evented-off)\r
+Events.removeEventListener = Events.clearAllEventListeners = Events.off;\r
+\r
+// @method addOneTimeEventListener(…): this\r
+// Alias to [`once(…)`](#evented-once)\r
+Events.addOneTimeEventListener = Events.once;\r
+\r
+// @method fireEvent(…): this\r
+// Alias to [`fire(…)`](#evented-fire)\r
+Events.fireEvent = Events.fire;\r
+\r
+// @method hasEventListeners(…): Boolean\r
+// Alias to [`listens(…)`](#evented-listens)\r
+Events.hasEventListeners = Events.listens;\r
+\r
+var Evented = Class.extend(Events);
+
+/*\r
+ * @class Point\r
+ * @aka L.Point\r
+ *\r
+ * Represents a point with `x` and `y` coordinates in pixels.\r
+ *\r
+ * @example\r
+ *\r
+ * ```js\r
+ * var point = L.point(200, 300);\r
+ * ```\r
+ *\r
+ * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent:\r
+ *\r
+ * ```js\r
+ * map.panBy([200, 300]);\r
+ * map.panBy(L.point(200, 300));\r
+ * ```\r
+ */\r
+\r
+function Point(x, y, round) {\r
+       // @property x: Number; The `x` coordinate of the point\r
+       this.x = (round ? Math.round(x) : x);\r
+       // @property y: Number; The `y` coordinate of the point\r
+       this.y = (round ? Math.round(y) : y);\r
+}\r
+\r
+Point.prototype = {\r
+\r
+       // @method clone(): Point\r
+       // Returns a copy of the current point.\r
+       clone: function () {\r
+               return new Point(this.x, this.y);\r
+       },\r
+\r
+       // @method add(otherPoint: Point): Point\r
+       // Returns the result of addition of the current and the given points.\r
+       add: function (point) {\r
+               // non-destructive, returns a new point\r
+               return this.clone()._add(toPoint(point));\r
+       },\r
+\r
+       _add: function (point) {\r
+               // destructive, used directly for performance in situations where it's safe to modify existing point\r
+               this.x += point.x;\r
+               this.y += point.y;\r
+               return this;\r
+       },\r
+\r
+       // @method subtract(otherPoint: Point): Point\r
+       // Returns the result of subtraction of the given point from the current.\r
+       subtract: function (point) {\r
+               return this.clone()._subtract(toPoint(point));\r
+       },\r
+\r
+       _subtract: function (point) {\r
+               this.x -= point.x;\r
+               this.y -= point.y;\r
+               return this;\r
+       },\r
+\r
+       // @method divideBy(num: Number): Point\r
+       // Returns the result of division of the current point by the given number.\r
+       divideBy: function (num) {\r
+               return this.clone()._divideBy(num);\r
+       },\r
+\r
+       _divideBy: function (num) {\r
+               this.x /= num;\r
+               this.y /= num;\r
+               return this;\r
+       },\r
+\r
+       // @method multiplyBy(num: Number): Point\r
+       // Returns the result of multiplication of the current point by the given number.\r
+       multiplyBy: function (num) {\r
+               return this.clone()._multiplyBy(num);\r
+       },\r
+\r
+       _multiplyBy: function (num) {\r
+               this.x *= num;\r
+               this.y *= num;\r
+               return this;\r
+       },\r
+\r
+       // @method scaleBy(scale: Point): Point\r
+       // Multiply each coordinate of the current point by each coordinate of\r
+       // `scale`. In linear algebra terms, multiply the point by the\r
+       // [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation)\r
+       // defined by `scale`.\r
+       scaleBy: function (point) {\r
+               return new Point(this.x * point.x, this.y * point.y);\r
+       },\r
+\r
+       // @method unscaleBy(scale: Point): Point\r
+       // Inverse of `scaleBy`. Divide each coordinate of the current point by\r
+       // each coordinate of `scale`.\r
+       unscaleBy: function (point) {\r
+               return new Point(this.x / point.x, this.y / point.y);\r
+       },\r
+\r
+       // @method round(): Point\r
+       // Returns a copy of the current point with rounded coordinates.\r
+       round: function () {\r
+               return this.clone()._round();\r
+       },\r
+\r
+       _round: function () {\r
+               this.x = Math.round(this.x);\r
+               this.y = Math.round(this.y);\r
+               return this;\r
+       },\r
+\r
+       // @method floor(): Point\r
+       // Returns a copy of the current point with floored coordinates (rounded down).\r
+       floor: function () {\r
+               return this.clone()._floor();\r
+       },\r
+\r
+       _floor: function () {\r
+               this.x = Math.floor(this.x);\r
+               this.y = Math.floor(this.y);\r
+               return this;\r
+       },\r
+\r
+       // @method ceil(): Point\r
+       // Returns a copy of the current point with ceiled coordinates (rounded up).\r
+       ceil: function () {\r
+               return this.clone()._ceil();\r
+       },\r
+\r
+       _ceil: function () {\r
+               this.x = Math.ceil(this.x);\r
+               this.y = Math.ceil(this.y);\r
+               return this;\r
+       },\r
+\r
+       // @method distanceTo(otherPoint: Point): Number\r
+       // Returns the cartesian distance between the current and the given points.\r
+       distanceTo: function (point) {\r
+               point = toPoint(point);\r
+\r
+               var x = point.x - this.x,\r
+                   y = point.y - this.y;\r
+\r
+               return Math.sqrt(x * x + y * y);\r
+       },\r
+\r
+       // @method equals(otherPoint: Point): Boolean\r
+       // Returns `true` if the given point has the same coordinates.\r
+       equals: function (point) {\r
+               point = toPoint(point);\r
+\r
+               return point.x === this.x &&\r
+                      point.y === this.y;\r
+       },\r
+\r
+       // @method contains(otherPoint: Point): Boolean\r
+       // Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values).\r
+       contains: function (point) {\r
+               point = toPoint(point);\r
+\r
+               return Math.abs(point.x) <= Math.abs(this.x) &&\r
+                      Math.abs(point.y) <= Math.abs(this.y);\r
+       },\r
+\r
+       // @method toString(): String\r
+       // Returns a string representation of the point for debugging purposes.\r
+       toString: function () {\r
+               return 'Point(' +\r
+                       formatNum(this.x) + ', ' +\r
+                       formatNum(this.y) + ')';\r
+       }\r
+};\r
+\r
+// @factory L.point(x: Number, y: Number, round?: Boolean)\r
+// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values.\r
+\r
+// @alternative\r
+// @factory L.point(coords: Number[])\r
+// Expects an array of the form `[x, y]` instead.\r
+\r
+// @alternative\r
+// @factory L.point(coords: Object)\r
+// Expects a plain object of the form `{x: Number, y: Number}` instead.\r
+function toPoint(x, y, round) {\r
+       if (x instanceof Point) {\r
+               return x;\r
+       }\r
+       if (isArray(x)) {\r
+               return new Point(x[0], x[1]);\r
+       }\r
+       if (x === undefined || x === null) {\r
+               return x;\r
+       }\r
+       if (typeof x === 'object' && 'x' in x && 'y' in x) {\r
+               return new Point(x.x, x.y);\r
+       }\r
+       return new Point(x, y, round);\r
+}
+
+/*\r
+ * @class Bounds\r
+ * @aka L.Bounds\r
+ *\r
+ * Represents a rectangular area in pixel coordinates.\r
+ *\r
+ * @example\r
+ *\r
+ * ```js\r
+ * var p1 = L.point(10, 10),\r
+ * p2 = L.point(40, 60),\r
+ * bounds = L.bounds(p1, p2);\r
+ * ```\r
+ *\r
+ * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this:\r
+ *\r
+ * ```js\r
+ * otherBounds.intersects([[10, 10], [40, 60]]);\r
+ * ```\r
+ */\r
+\r
+function Bounds(a, b) {\r
+       if (!a) { return; }\r
+\r
+       var points = b ? [a, b] : a;\r
+\r
+       for (var i = 0, len = points.length; i < len; i++) {\r
+               this.extend(points[i]);\r
+       }\r
+}\r
+\r
+Bounds.prototype = {\r
+       // @method extend(point: Point): this\r
+       // Extends the bounds to contain the given point.\r
+       extend: function (point) { // (Point)\r
+               point = toPoint(point);\r
+\r
+               // @property min: Point\r
+               // The top left corner of the rectangle.\r
+               // @property max: Point\r
+               // The bottom right corner of the rectangle.\r
+               if (!this.min && !this.max) {\r
+                       this.min = point.clone();\r
+                       this.max = point.clone();\r
+               } else {\r
+                       this.min.x = Math.min(point.x, this.min.x);\r
+                       this.max.x = Math.max(point.x, this.max.x);\r
+                       this.min.y = Math.min(point.y, this.min.y);\r
+                       this.max.y = Math.max(point.y, this.max.y);\r
+               }\r
+               return this;\r
+       },\r
+\r
+       // @method getCenter(round?: Boolean): Point\r
+       // Returns the center point of the bounds.\r
+       getCenter: function (round) {\r
+               return new Point(\r
+                       (this.min.x + this.max.x) / 2,\r
+                       (this.min.y + this.max.y) / 2, round);\r
+       },\r
+\r
+       // @method getBottomLeft(): Point\r
+       // Returns the bottom-left point of the bounds.\r
+       getBottomLeft: function () {\r
+               return new Point(this.min.x, this.max.y);\r
+       },\r
+\r
+       // @method getTopRight(): Point\r
+       // Returns the top-right point of the bounds.\r
+       getTopRight: function () { // -> Point\r
+               return new Point(this.max.x, this.min.y);\r
+       },\r
+\r
+       // @method getTopLeft(): Point\r
+       // Returns the top-left point of the bounds (i.e. [`this.min`](#bounds-min)).\r
+       getTopLeft: function () {\r
+               return this.min; // left, top\r
+       },\r
+\r
+       // @method getBottomRight(): Point\r
+       // Returns the bottom-right point of the bounds (i.e. [`this.max`](#bounds-max)).\r
+       getBottomRight: function () {\r
+               return this.max; // right, bottom\r
+       },\r
+\r
+       // @method getSize(): Point\r
+       // Returns the size of the given bounds\r
+       getSize: function () {\r
+               return this.max.subtract(this.min);\r
+       },\r
+\r
+       // @method contains(otherBounds: Bounds): Boolean\r
+       // Returns `true` if the rectangle contains the given one.\r
+       // @alternative\r
+       // @method contains(point: Point): Boolean\r
+       // Returns `true` if the rectangle contains the given point.\r
+       contains: function (obj) {\r
+               var min, max;\r
+\r
+               if (typeof obj[0] === 'number' || obj instanceof Point) {\r
+                       obj = toPoint(obj);\r
+               } else {\r
+                       obj = toBounds(obj);\r
+               }\r
+\r
+               if (obj instanceof Bounds) {\r
+                       min = obj.min;\r
+                       max = obj.max;\r
+               } else {\r
+                       min = max = obj;\r
+               }\r
+\r
+               return (min.x >= this.min.x) &&\r
+                      (max.x <= this.max.x) &&\r
+                      (min.y >= this.min.y) &&\r
+                      (max.y <= this.max.y);\r
+       },\r
+\r
+       // @method intersects(otherBounds: Bounds): Boolean\r
+       // Returns `true` if the rectangle intersects the given bounds. Two bounds\r
+       // intersect if they have at least one point in common.\r
+       intersects: function (bounds) { // (Bounds) -> Boolean\r
+               bounds = toBounds(bounds);\r
+\r
+               var min = this.min,\r
+                   max = this.max,\r
+                   min2 = bounds.min,\r
+                   max2 = bounds.max,\r
+                   xIntersects = (max2.x >= min.x) && (min2.x <= max.x),\r
+                   yIntersects = (max2.y >= min.y) && (min2.y <= max.y);\r
+\r
+               return xIntersects && yIntersects;\r
+       },\r
+\r
+       // @method overlaps(otherBounds: Bounds): Boolean\r
+       // Returns `true` if the rectangle overlaps the given bounds. Two bounds\r
+       // overlap if their intersection is an area.\r
+       overlaps: function (bounds) { // (Bounds) -> Boolean\r
+               bounds = toBounds(bounds);\r
+\r
+               var min = this.min,\r
+                   max = this.max,\r
+                   min2 = bounds.min,\r
+                   max2 = bounds.max,\r
+                   xOverlaps = (max2.x > min.x) && (min2.x < max.x),\r
+                   yOverlaps = (max2.y > min.y) && (min2.y < max.y);\r
+\r
+               return xOverlaps && yOverlaps;\r
+       },\r
+\r
+       isValid: function () {\r
+               return !!(this.min && this.max);\r
+       }\r
+};\r
+\r
+\r
+// @factory L.bounds(corner1: Point, corner2: Point)\r
+// Creates a Bounds object from two corners coordinate pairs.\r
+// @alternative\r
+// @factory L.bounds(points: Point[])\r
+// Creates a Bounds object from the given array of points.\r
+function toBounds(a, b) {\r
+       if (!a || a instanceof Bounds) {\r
+               return a;\r
+       }\r
+       return new Bounds(a, b);\r
+}
+
+/*\r
+ * @class LatLngBounds\r
+ * @aka L.LatLngBounds\r
+ *\r
+ * Represents a rectangular geographical area on a map.\r
+ *\r
+ * @example\r
+ *\r
+ * ```js\r
+ * var corner1 = L.latLng(40.712, -74.227),\r
+ * corner2 = L.latLng(40.774, -74.125),\r
+ * bounds = L.latLngBounds(corner1, corner2);\r
+ * ```\r
+ *\r
+ * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this:\r
+ *\r
+ * ```js\r
+ * map.fitBounds([\r
+ *     [40.712, -74.227],\r
+ *     [40.774, -74.125]\r
+ * ]);\r
+ * ```\r
+ *\r
+ * Caution: if the area crosses the antimeridian (often confused with the International Date Line), you must specify corners _outside_ the [-180, 180] degrees longitude range.\r
+ */\r
+\r
+function LatLngBounds(corner1, corner2) { // (LatLng, LatLng) or (LatLng[])\r
+       if (!corner1) { return; }\r
+\r
+       var latlngs = corner2 ? [corner1, corner2] : corner1;\r
+\r
+       for (var i = 0, len = latlngs.length; i < len; i++) {\r
+               this.extend(latlngs[i]);\r
+       }\r
+}\r
+\r
+LatLngBounds.prototype = {\r
+\r
+       // @method extend(latlng: LatLng): this\r
+       // Extend the bounds to contain the given point\r
+\r
+       // @alternative\r
+       // @method extend(otherBounds: LatLngBounds): this\r
+       // Extend the bounds to contain the given bounds\r
+       extend: function (obj) {\r
+               var sw = this._southWest,\r
+                   ne = this._northEast,\r
+                   sw2, ne2;\r
+\r
+               if (obj instanceof LatLng) {\r
+                       sw2 = obj;\r
+                       ne2 = obj;\r
+\r
+               } else if (obj instanceof LatLngBounds) {\r
+                       sw2 = obj._southWest;\r
+                       ne2 = obj._northEast;\r
+\r
+                       if (!sw2 || !ne2) { return this; }\r
+\r
+               } else {\r
+                       return obj ? this.extend(toLatLng(obj) || toLatLngBounds(obj)) : this;\r
+               }\r
+\r
+               if (!sw && !ne) {\r
+                       this._southWest = new LatLng(sw2.lat, sw2.lng);\r
+                       this._northEast = new LatLng(ne2.lat, ne2.lng);\r
+               } else {\r
+                       sw.lat = Math.min(sw2.lat, sw.lat);\r
+                       sw.lng = Math.min(sw2.lng, sw.lng);\r
+                       ne.lat = Math.max(ne2.lat, ne.lat);\r
+                       ne.lng = Math.max(ne2.lng, ne.lng);\r
+               }\r
+\r
+               return this;\r
+       },\r
+\r
+       // @method pad(bufferRatio: Number): LatLngBounds\r
+       // Returns bigger bounds created by extending the current bounds by a given percentage in each direction.\r
+       pad: function (bufferRatio) {\r
+               var sw = this._southWest,\r
+                   ne = this._northEast,\r
+                   heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio,\r
+                   widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio;\r
+\r
+               return new LatLngBounds(\r
+                       new LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer),\r
+                       new LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer));\r
+       },\r
+\r
+       // @method getCenter(): LatLng\r
+       // Returns the center point of the bounds.\r
+       getCenter: function () {\r
+               return new LatLng(\r
+                       (this._southWest.lat + this._northEast.lat) / 2,\r
+                       (this._southWest.lng + this._northEast.lng) / 2);\r
+       },\r
+\r
+       // @method getSouthWest(): LatLng\r
+       // Returns the south-west point of the bounds.\r
+       getSouthWest: function () {\r
+               return this._southWest;\r
+       },\r
+\r
+       // @method getNorthEast(): LatLng\r
+       // Returns the north-east point of the bounds.\r
+       getNorthEast: function () {\r
+               return this._northEast;\r
+       },\r
+\r
+       // @method getNorthWest(): LatLng\r
+       // Returns the north-west point of the bounds.\r
+       getNorthWest: function () {\r
+               return new LatLng(this.getNorth(), this.getWest());\r
+       },\r
+\r
+       // @method getSouthEast(): LatLng\r
+       // Returns the south-east point of the bounds.\r
+       getSouthEast: function () {\r
+               return new LatLng(this.getSouth(), this.getEast());\r
+       },\r
+\r
+       // @method getWest(): Number\r
+       // Returns the west longitude of the bounds\r
+       getWest: function () {\r
+               return this._southWest.lng;\r
+       },\r
+\r
+       // @method getSouth(): Number\r
+       // Returns the south latitude of the bounds\r
+       getSouth: function () {\r
+               return this._southWest.lat;\r
+       },\r
+\r
+       // @method getEast(): Number\r
+       // Returns the east longitude of the bounds\r
+       getEast: function () {\r
+               return this._northEast.lng;\r
+       },\r
+\r
+       // @method getNorth(): Number\r
+       // Returns the north latitude of the bounds\r
+       getNorth: function () {\r
+               return this._northEast.lat;\r
+       },\r
+\r
+       // @method contains(otherBounds: LatLngBounds): Boolean\r
+       // Returns `true` if the rectangle contains the given one.\r
+\r
+       // @alternative\r
+       // @method contains (latlng: LatLng): Boolean\r
+       // Returns `true` if the rectangle contains the given point.\r
+       contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean\r
+               if (typeof obj[0] === 'number' || obj instanceof LatLng || 'lat' in obj) {\r
+                       obj = toLatLng(obj);\r
+               } else {\r
+                       obj = toLatLngBounds(obj);\r
+               }\r
+\r
+               var sw = this._southWest,\r
+                   ne = this._northEast,\r
+                   sw2, ne2;\r
+\r
+               if (obj instanceof LatLngBounds) {\r
+                       sw2 = obj.getSouthWest();\r
+                       ne2 = obj.getNorthEast();\r
+               } else {\r
+                       sw2 = ne2 = obj;\r
+               }\r
+\r
+               return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) &&\r
+                      (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng);\r
+       },\r
+\r
+       // @method intersects(otherBounds: LatLngBounds): Boolean\r
+       // Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common.\r
+       intersects: function (bounds) {\r
+               bounds = toLatLngBounds(bounds);\r
+\r
+               var sw = this._southWest,\r
+                   ne = this._northEast,\r
+                   sw2 = bounds.getSouthWest(),\r
+                   ne2 = bounds.getNorthEast(),\r
+\r
+                   latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat),\r
+                   lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng);\r
+\r
+               return latIntersects && lngIntersects;\r
+       },\r
+\r
+       // @method overlaps(otherBounds: Bounds): Boolean\r
+       // Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area.\r
+       overlaps: function (bounds) {\r
+               bounds = toLatLngBounds(bounds);\r
+\r
+               var sw = this._southWest,\r
+                   ne = this._northEast,\r
+                   sw2 = bounds.getSouthWest(),\r
+                   ne2 = bounds.getNorthEast(),\r
+\r
+                   latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat),\r
+                   lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng);\r
+\r
+               return latOverlaps && lngOverlaps;\r
+       },\r
+\r
+       // @method toBBoxString(): String\r
+       // Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data.\r
+       toBBoxString: function () {\r
+               return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(',');\r
+       },\r
+\r
+       // @method equals(otherBounds: LatLngBounds, maxMargin?: Number): Boolean\r
+       // Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds. The margin of error can be overriden by setting `maxMargin` to a small number.\r
+       equals: function (bounds, maxMargin) {\r
+               if (!bounds) { return false; }\r
+\r
+               bounds = toLatLngBounds(bounds);\r
+\r
+               return this._southWest.equals(bounds.getSouthWest(), maxMargin) &&\r
+                      this._northEast.equals(bounds.getNorthEast(), maxMargin);\r
+       },\r
+\r
+       // @method isValid(): Boolean\r
+       // Returns `true` if the bounds are properly initialized.\r
+       isValid: function () {\r
+               return !!(this._southWest && this._northEast);\r
+       }\r
+};\r
+\r
+// TODO International date line?\r
+\r
+// @factory L.latLngBounds(corner1: LatLng, corner2: LatLng)\r
+// Creates a `LatLngBounds` object by defining two diagonally opposite corners of the rectangle.\r
+\r
+// @alternative\r
+// @factory L.latLngBounds(latlngs: LatLng[])\r
+// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds).\r
+function toLatLngBounds(a, b) {\r
+       if (a instanceof LatLngBounds) {\r
+               return a;\r
+       }\r
+       return new LatLngBounds(a, b);\r
+}
+
+/* @class LatLng\r
+ * @aka L.LatLng\r
+ *\r
+ * Represents a geographical point with a certain latitude and longitude.\r
+ *\r
+ * @example\r
+ *\r
+ * ```\r
+ * var latlng = L.latLng(50.5, 30.5);\r
+ * ```\r
+ *\r
+ * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent:\r
+ *\r
+ * ```\r
+ * map.panTo([50, 30]);\r
+ * map.panTo({lon: 30, lat: 50});\r
+ * map.panTo({lat: 50, lng: 30});\r
+ * map.panTo(L.latLng(50, 30));\r
+ * ```\r
+ */\r
+\r
+function LatLng(lat, lng, alt) {\r
+       if (isNaN(lat) || isNaN(lng)) {\r
+               throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')');\r
+       }\r
+\r
+       // @property lat: Number\r
+       // Latitude in degrees\r
+       this.lat = +lat;\r
+\r
+       // @property lng: Number\r
+       // Longitude in degrees\r
+       this.lng = +lng;\r
+\r
+       // @property alt: Number\r
+       // Altitude in meters (optional)\r
+       if (alt !== undefined) {\r
+               this.alt = +alt;\r
+       }\r
+}\r
+\r
+LatLng.prototype = {\r
+       // @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean\r
+       // Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overriden by setting `maxMargin` to a small number.\r
+       equals: function (obj, maxMargin) {\r
+               if (!obj) { return false; }\r
+\r
+               obj = toLatLng(obj);\r
+\r
+               var margin = Math.max(\r
+                       Math.abs(this.lat - obj.lat),\r
+                       Math.abs(this.lng - obj.lng));\r
+\r
+               return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin);\r
+       },\r
+\r
+       // @method toString(): String\r
+       // Returns a string representation of the point (for debugging purposes).\r
+       toString: function (precision) {\r
+               return 'LatLng(' +\r
+                       formatNum(this.lat, precision) + ', ' +\r
+                       formatNum(this.lng, precision) + ')';\r
+       },\r
+\r
+       // @method distanceTo(otherLatLng: LatLng): Number\r
+       // Returns the distance (in meters) to the given `LatLng` calculated using the [Haversine formula](http://en.wikipedia.org/wiki/Haversine_formula).\r
+       distanceTo: function (other) {\r
+               return Earth.distance(this, toLatLng(other));\r
+       },\r
+\r
+       // @method wrap(): LatLng\r
+       // Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees.\r
+       wrap: function () {\r
+               return Earth.wrapLatLng(this);\r
+       },\r
+\r
+       // @method toBounds(sizeInMeters: Number): LatLngBounds\r
+       // Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters/2` meters apart from the `LatLng`.\r
+       toBounds: function (sizeInMeters) {\r
+               var latAccuracy = 180 * sizeInMeters / 40075017,\r
+                   lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat);\r
+\r
+               return toLatLngBounds(\r
+                       [this.lat - latAccuracy, this.lng - lngAccuracy],\r
+                       [this.lat + latAccuracy, this.lng + lngAccuracy]);\r
+       },\r
+\r
+       clone: function () {\r
+               return new LatLng(this.lat, this.lng, this.alt);\r
+       }\r
+};\r
+\r
+\r
+\r
+// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng\r
+// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude).\r
+\r
+// @alternative\r
+// @factory L.latLng(coords: Array): LatLng\r
+// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead.\r
+\r
+// @alternative\r
+// @factory L.latLng(coords: Object): LatLng\r
+// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead.\r
+\r
+function toLatLng(a, b, c) {\r
+       if (a instanceof LatLng) {\r
+               return a;\r
+       }\r
+       if (isArray(a) && typeof a[0] !== 'object') {\r
+               if (a.length === 3) {\r
+                       return new LatLng(a[0], a[1], a[2]);\r
+               }\r
+               if (a.length === 2) {\r
+                       return new LatLng(a[0], a[1]);\r
+               }\r
+               return null;\r
+       }\r
+       if (a === undefined || a === null) {\r
+               return a;\r
+       }\r
+       if (typeof a === 'object' && 'lat' in a) {\r
+               return new LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt);\r
+       }\r
+       if (b === undefined) {\r
+               return null;\r
+       }\r
+       return new LatLng(a, b, c);\r
+}
+
+/*\r
+ * @namespace CRS\r
+ * @crs L.CRS.Base\r
+ * Object that defines coordinate reference systems for projecting\r
+ * geographical points into pixel (screen) coordinates and back (and to\r
+ * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See\r
+ * [spatial reference system](http://en.wikipedia.org/wiki/Coordinate_reference_system).\r
+ *\r
+ * Leaflet defines the most usual CRSs by default. If you want to use a\r
+ * CRS not defined by default, take a look at the\r
+ * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin.\r
+ */\r
+\r
+var CRS = {\r
+       // @method latLngToPoint(latlng: LatLng, zoom: Number): Point\r
+       // Projects geographical coordinates into pixel coordinates for a given zoom.\r
+       latLngToPoint: function (latlng, zoom) {\r
+               var projectedPoint = this.projection.project(latlng),\r
+                   scale = this.scale(zoom);\r
+\r
+               return this.transformation._transform(projectedPoint, scale);\r
+       },\r
+\r
+       // @method pointToLatLng(point: Point, zoom: Number): LatLng\r
+       // The inverse of `latLngToPoint`. Projects pixel coordinates on a given\r
+       // zoom into geographical coordinates.\r
+       pointToLatLng: function (point, zoom) {\r
+               var scale = this.scale(zoom),\r
+                   untransformedPoint = this.transformation.untransform(point, scale);\r
+\r
+               return this.projection.unproject(untransformedPoint);\r
+       },\r
+\r
+       // @method project(latlng: LatLng): Point\r
+       // Projects geographical coordinates into coordinates in units accepted for\r
+       // this CRS (e.g. meters for EPSG:3857, for passing it to WMS services).\r
+       project: function (latlng) {\r
+               return this.projection.project(latlng);\r
+       },\r
+\r
+       // @method unproject(point: Point): LatLng\r
+       // Given a projected coordinate returns the corresponding LatLng.\r
+       // The inverse of `project`.\r
+       unproject: function (point) {\r
+               return this.projection.unproject(point);\r
+       },\r
+\r
+       // @method scale(zoom: Number): Number\r
+       // Returns the scale used when transforming projected coordinates into\r
+       // pixel coordinates for a particular zoom. For example, it returns\r
+       // `256 * 2^zoom` for Mercator-based CRS.\r
+       scale: function (zoom) {\r
+               return 256 * Math.pow(2, zoom);\r
+       },\r
+\r
+       // @method zoom(scale: Number): Number\r
+       // Inverse of `scale()`, returns the zoom level corresponding to a scale\r
+       // factor of `scale`.\r
+       zoom: function (scale) {\r
+               return Math.log(scale / 256) / Math.LN2;\r
+       },\r
+\r
+       // @method getProjectedBounds(zoom: Number): Bounds\r
+       // Returns the projection's bounds scaled and transformed for the provided `zoom`.\r
+       getProjectedBounds: function (zoom) {\r
+               if (this.infinite) { return null; }\r
+\r
+               var b = this.projection.bounds,\r
+                   s = this.scale(zoom),\r
+                   min = this.transformation.transform(b.min, s),\r
+                   max = this.transformation.transform(b.max, s);\r
+\r
+               return new Bounds(min, max);\r
+       },\r
+\r
+       // @method distance(latlng1: LatLng, latlng2: LatLng): Number\r
+       // Returns the distance between two geographical coordinates.\r
+\r
+       // @property code: String\r
+       // Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`)\r
+       //\r
+       // @property wrapLng: Number[]\r
+       // An array of two numbers defining whether the longitude (horizontal) coordinate\r
+       // axis wraps around a given range and how. Defaults to `[-180, 180]` in most\r
+       // geographical CRSs. If `undefined`, the longitude axis does not wrap around.\r
+       //\r
+       // @property wrapLat: Number[]\r
+       // Like `wrapLng`, but for the latitude (vertical) axis.\r
+\r
+       // wrapLng: [min, max],\r
+       // wrapLat: [min, max],\r
+\r
+       // @property infinite: Boolean\r
+       // If true, the coordinate space will be unbounded (infinite in both axes)\r
+       infinite: false,\r
+\r
+       // @method wrapLatLng(latlng: LatLng): LatLng\r
+       // Returns a `LatLng` where lat and lng has been wrapped according to the\r
+       // CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds.\r
+       wrapLatLng: function (latlng) {\r
+               var lng = this.wrapLng ? wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng,\r
+                   lat = this.wrapLat ? wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat,\r
+                   alt = latlng.alt;\r
+\r
+               return new LatLng(lat, lng, alt);\r
+       },\r
+\r
+       // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds\r
+       // Returns a `LatLngBounds` with the same size as the given one, ensuring\r
+       // that its center is within the CRS's bounds.\r
+       // Only accepts actual `L.LatLngBounds` instances, not arrays.\r
+       wrapLatLngBounds: function (bounds) {\r
+               var center = bounds.getCenter(),\r
+                   newCenter = this.wrapLatLng(center),\r
+                   latShift = center.lat - newCenter.lat,\r
+                   lngShift = center.lng - newCenter.lng;\r
+\r
+               if (latShift === 0 && lngShift === 0) {\r
+                       return bounds;\r
+               }\r
+\r
+               var sw = bounds.getSouthWest(),\r
+                   ne = bounds.getNorthEast(),\r
+                   newSw = new LatLng(sw.lat - latShift, sw.lng - lngShift),\r
+                   newNe = new LatLng(ne.lat - latShift, ne.lng - lngShift);\r
+\r
+               return new LatLngBounds(newSw, newNe);\r
+       }\r
+};
+
+/*
+ * @namespace CRS
+ * @crs L.CRS.Earth
+ *
+ * Serves as the base for CRS that are global such that they cover the earth.
+ * Can only be used as the base for other CRS and cannot be used directly,
+ * since it does not have a `code`, `projection` or `transformation`. `distance()` returns
+ * meters.
+ */
+
+var Earth = extend({}, CRS, {
+       wrapLng: [-180, 180],
+
+       // Mean Earth Radius, as recommended for use by
+       // the International Union of Geodesy and Geophysics,
+       // see http://rosettacode.org/wiki/Haversine_formula
+       R: 6371000,
+
+       // distance between two geographical points using spherical law of cosines approximation
+       distance: function (latlng1, latlng2) {
+               var rad = Math.PI / 180,
+                   lat1 = latlng1.lat * rad,
+                   lat2 = latlng2.lat * rad,
+                   a = Math.sin(lat1) * Math.sin(lat2) +
+                       Math.cos(lat1) * Math.cos(lat2) * Math.cos((latlng2.lng - latlng1.lng) * rad);
+
+               return this.R * Math.acos(Math.min(a, 1));
+       }
+});
+
+/*\r
+ * @namespace Projection\r
+ * @projection L.Projection.SphericalMercator\r
+ *\r
+ * Spherical Mercator projection — the most common projection for online maps,\r
+ * used by almost all free and commercial tile providers. Assumes that Earth is\r
+ * a sphere. Used by the `EPSG:3857` CRS.\r
+ */\r
+\r
+var SphericalMercator = {\r
+\r
+       R: 6378137,\r
+       MAX_LATITUDE: 85.0511287798,\r
+\r
+       project: function (latlng) {\r
+               var d = Math.PI / 180,\r
+                   max = this.MAX_LATITUDE,\r
+                   lat = Math.max(Math.min(max, latlng.lat), -max),\r
+                   sin = Math.sin(lat * d);\r
+\r
+               return new Point(\r
+                               this.R * latlng.lng * d,\r
+                               this.R * Math.log((1 + sin) / (1 - sin)) / 2);\r
+       },\r
+\r
+       unproject: function (point) {\r
+               var d = 180 / Math.PI;\r
+\r
+               return new LatLng(\r
+                       (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d,\r
+                       point.x * d / this.R);\r
+       },\r
+\r
+       bounds: (function () {\r
+               var d = 6378137 * Math.PI;\r
+               return new Bounds([-d, -d], [d, d]);\r
+       })()\r
+};
+
+/*\r
+ * @class Transformation\r
+ * @aka L.Transformation\r
+ *\r
+ * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d`\r
+ * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing\r
+ * the reverse. Used by Leaflet in its projections code.\r
+ *\r
+ * @example\r
+ *\r
+ * ```js\r
+ * var transformation = L.transformation(2, 5, -1, 10),\r
+ *     p = L.point(1, 2),\r
+ *     p2 = transformation.transform(p), //  L.point(7, 8)\r
+ *     p3 = transformation.untransform(p2); //  L.point(1, 2)\r
+ * ```\r
+ */\r
+\r
+\r
+// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number)\r
+// Creates a `Transformation` object with the given coefficients.\r
+function Transformation(a, b, c, d) {\r
+       if (isArray(a)) {\r
+               // use array properties\r
+               this._a = a[0];\r
+               this._b = a[1];\r
+               this._c = a[2];\r
+               this._d = a[3];\r
+               return;\r
+       }\r
+       this._a = a;\r
+       this._b = b;\r
+       this._c = c;\r
+       this._d = d;\r
+}\r
+\r
+Transformation.prototype = {\r
+       // @method transform(point: Point, scale?: Number): Point\r
+       // Returns a transformed point, optionally multiplied by the given scale.\r
+       // Only accepts actual `L.Point` instances, not arrays.\r
+       transform: function (point, scale) { // (Point, Number) -> Point\r
+               return this._transform(point.clone(), scale);\r
+       },\r
+\r
+       // destructive transform (faster)\r
+       _transform: function (point, scale) {\r
+               scale = scale || 1;\r
+               point.x = scale * (this._a * point.x + this._b);\r
+               point.y = scale * (this._c * point.y + this._d);\r
+               return point;\r
+       },\r
+\r
+       // @method untransform(point: Point, scale?: Number): Point\r
+       // Returns the reverse transformation of the given point, optionally divided\r
+       // by the given scale. Only accepts actual `L.Point` instances, not arrays.\r
+       untransform: function (point, scale) {\r
+               scale = scale || 1;\r
+               return new Point(\r
+                       (point.x / scale - this._b) / this._a,\r
+                       (point.y / scale - this._d) / this._c);\r
+       }\r
+};\r
+\r
+// factory L.transformation(a: Number, b: Number, c: Number, d: Number)\r
+\r
+// @factory L.transformation(a: Number, b: Number, c: Number, d: Number)\r
+// Instantiates a Transformation object with the given coefficients.\r
+\r
+// @alternative\r
+// @factory L.transformation(coefficients: Array): Transformation\r
+// Expects an coeficients array of the form\r
+// `[a: Number, b: Number, c: Number, d: Number]`.\r
+\r
+function toTransformation(a, b, c, d) {\r
+       return new Transformation(a, b, c, d);\r
+}
+
+/*\r
+ * @namespace CRS\r
+ * @crs L.CRS.EPSG3857\r
+ *\r
+ * The most common CRS for online maps, used by almost all free and commercial\r
+ * tile providers. Uses Spherical Mercator projection. Set in by default in\r
+ * Map's `crs` option.\r
+ */\r
+\r
+var EPSG3857 = extend({}, Earth, {\r
+       code: 'EPSG:3857',\r
+       projection: SphericalMercator,\r
+\r
+       transformation: (function () {\r
+               var scale = 0.5 / (Math.PI * SphericalMercator.R);\r
+               return toTransformation(scale, 0.5, -scale, 0.5);\r
+       }())\r
+});\r
+\r
+var EPSG900913 = extend({}, EPSG3857, {\r
+       code: 'EPSG:900913'\r
+});
+
+// @namespace SVG; @section
+// There are several static functions which can be called without instantiating L.SVG:
+
+// @function create(name: String): SVGElement
+// Returns a instance of [SVGElement](https://developer.mozilla.org/docs/Web/API/SVGElement),
+// corresponding to the class name passed. For example, using 'line' will return
+// an instance of [SVGLineElement](https://developer.mozilla.org/docs/Web/API/SVGLineElement).
+function svgCreate(name) {
+       return document.createElementNS('http://www.w3.org/2000/svg', name);
+}
+
+// @function pointsToPath(rings: Point[], closed: Boolean): String
+// Generates a SVG path string for multiple rings, with each ring turning
+// into "M..L..L.." instructions
+function pointsToPath(rings, closed) {
+       var str = '',
+       i, j, len, len2, points, p;
+
+       for (i = 0, len = rings.length; i < len; i++) {
+               points = rings[i];
+
+               for (j = 0, len2 = points.length; j < len2; j++) {
+                       p = points[j];
+                       str += (j ? 'L' : 'M') + p.x + ' ' + p.y;
+               }
+
+               // closes the ring for polygons; "x" is VML syntax
+               str += closed ? (svg ? 'z' : 'x') : '';
+       }
+
+       // SVG complains about empty path strings
+       return str || 'M0 0';
+}
+
+/*\r
+ * @namespace Browser\r
+ * @aka L.Browser\r
+ *\r
+ * A namespace with static properties for browser/feature detection used by Leaflet internally.\r
+ *\r
+ * @example\r
+ *\r
+ * ```js\r
+ * if (L.Browser.ielt9) {\r
+ *   alert('Upgrade your browser, dude!');\r
+ * }\r
+ * ```\r
+ */\r
+\r
+var style$1 = document.documentElement.style;\r
+\r
+// @property ie: Boolean; `true` for all Internet Explorer versions (not Edge).\r
+var ie = 'ActiveXObject' in window;\r
+\r
+// @property ielt9: Boolean; `true` for Internet Explorer versions less than 9.\r
+var ielt9 = ie && !document.addEventListener;\r
+\r
+// @property edge: Boolean; `true` for the Edge web browser.\r
+var edge = 'msLaunchUri' in navigator && !('documentMode' in document);\r
+\r
+// @property webkit: Boolean;\r
+// `true` for webkit-based browsers like Chrome and Safari (including mobile versions).\r
+var webkit = userAgentContains('webkit');\r
+\r
+// @property android: Boolean\r
+// `true` for any browser running on an Android platform.\r
+var android = userAgentContains('android');\r
+\r
+// @property android23: Boolean; `true` for browsers running on Android 2 or Android 3.\r
+var android23 = userAgentContains('android 2') || userAgentContains('android 3');\r
+\r
+// @property opera: Boolean; `true` for the Opera browser\r
+var opera = !!window.opera;\r
+\r
+// @property chrome: Boolean; `true` for the Chrome browser.\r
+var chrome = userAgentContains('chrome');\r
+\r
+// @property gecko: Boolean; `true` for gecko-based browsers like Firefox.\r
+var gecko = userAgentContains('gecko') && !webkit && !opera && !ie;\r
+\r
+// @property safari: Boolean; `true` for the Safari browser.\r
+var safari = !chrome && userAgentContains('safari');\r
+\r
+var phantom = userAgentContains('phantom');\r
+\r
+// @property opera12: Boolean\r
+// `true` for the Opera browser supporting CSS transforms (version 12 or later).\r
+var opera12 = 'OTransition' in style$1;\r
+\r
+// @property win: Boolean; `true` when the browser is running in a Windows platform\r
+var win = navigator.platform.indexOf('Win') === 0;\r
+\r
+// @property ie3d: Boolean; `true` for all Internet Explorer versions supporting CSS transforms.\r
+var ie3d = ie && ('transition' in style$1);\r
+\r
+// @property webkit3d: Boolean; `true` for webkit-based browsers supporting CSS transforms.\r
+var webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23;\r
+\r
+// @property gecko3d: Boolean; `true` for gecko-based browsers supporting CSS transforms.\r
+var gecko3d = 'MozPerspective' in style$1;\r
+\r
+// @property any3d: Boolean\r
+// `true` for all browsers supporting CSS transforms.\r
+var any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantom;\r
+\r
+// @property mobile: Boolean; `true` for all browsers running in a mobile device.\r
+var mobile = typeof orientation !== 'undefined' || userAgentContains('mobile');\r
+\r
+// @property mobileWebkit: Boolean; `true` for all webkit-based browsers in a mobile device.\r
+var mobileWebkit = mobile && webkit;\r
+\r
+// @property mobileWebkit3d: Boolean\r
+// `true` for all webkit-based browsers in a mobile device supporting CSS transforms.\r
+var mobileWebkit3d = mobile && webkit3d;\r
+\r
+// @property msPointer: Boolean\r
+// `true` for browsers implementing the Microsoft touch events model (notably IE10).\r
+var msPointer = !window.PointerEvent && window.MSPointerEvent;\r
+\r
+// @property pointer: Boolean\r
+// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx).\r
+var pointer = !!(window.PointerEvent || msPointer);\r
+\r
+// @property touch: Boolean\r
+// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events).\r
+// This does not necessarily mean that the browser is running in a computer with\r
+// a touchscreen, it only means that the browser is capable of understanding\r
+// touch events.\r
+var touch = !window.L_NO_TOUCH && (pointer || 'ontouchstart' in window ||\r
+               (window.DocumentTouch && document instanceof window.DocumentTouch));\r
+\r
+// @property mobileOpera: Boolean; `true` for the Opera browser in a mobile device.\r
+var mobileOpera = mobile && opera;\r
+\r
+// @property mobileGecko: Boolean\r
+// `true` for gecko-based browsers running in a mobile device.\r
+var mobileGecko = mobile && gecko;\r
+\r
+// @property retina: Boolean\r
+// `true` for browsers on a high-resolution "retina" screen.\r
+var retina = (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1;\r
+\r
+\r
+// @property canvas: Boolean\r
+// `true` when the browser supports [`<canvas>`](https://developer.mozilla.org/docs/Web/API/Canvas_API).\r
+var canvas = (function () {\r
+       return !!document.createElement('canvas').getContext;\r
+}());\r
+\r
+// @property svg: Boolean\r
+// `true` when the browser supports [SVG](https://developer.mozilla.org/docs/Web/SVG).\r
+var svg = !!(document.createElementNS && svgCreate('svg').createSVGRect);\r
+\r
+// @property vml: Boolean\r
+// `true` if the browser supports [VML](https://en.wikipedia.org/wiki/Vector_Markup_Language).\r
+var vml = !svg && (function () {\r
+       try {\r
+               var div = document.createElement('div');\r
+               div.innerHTML = '<v:shape adj="1"/>';\r
+\r
+               var shape = div.firstChild;\r
+               shape.style.behavior = 'url(#default#VML)';\r
+\r
+               return shape && (typeof shape.adj === 'object');\r
+\r
+       } catch (e) {\r
+               return false;\r
+       }\r
+}());\r
+\r
+\r
+function userAgentContains(str) {\r
+       return navigator.userAgent.toLowerCase().indexOf(str) >= 0;\r
+}\r
+
+
+var Browser = (Object.freeze || Object)({
+       ie: ie,
+       ielt9: ielt9,
+       edge: edge,
+       webkit: webkit,
+       android: android,
+       android23: android23,
+       opera: opera,
+       chrome: chrome,
+       gecko: gecko,
+       safari: safari,
+       phantom: phantom,
+       opera12: opera12,
+       win: win,
+       ie3d: ie3d,
+       webkit3d: webkit3d,
+       gecko3d: gecko3d,
+       any3d: any3d,
+       mobile: mobile,
+       mobileWebkit: mobileWebkit,
+       mobileWebkit3d: mobileWebkit3d,
+       msPointer: msPointer,
+       pointer: pointer,
+       touch: touch,
+       mobileOpera: mobileOpera,
+       mobileGecko: mobileGecko,
+       retina: retina,
+       canvas: canvas,
+       svg: svg,
+       vml: vml
+});
+
+/*
+ * Extends L.DomEvent to provide touch support for Internet Explorer and Windows-based devices.
+ */
+
+
+var POINTER_DOWN =   msPointer ? 'MSPointerDown'   : 'pointerdown';
+var POINTER_MOVE =   msPointer ? 'MSPointerMove'   : 'pointermove';
+var POINTER_UP =     msPointer ? 'MSPointerUp'     : 'pointerup';
+var POINTER_CANCEL = msPointer ? 'MSPointerCancel' : 'pointercancel';
+var TAG_WHITE_LIST = ['INPUT', 'SELECT', 'OPTION'];
+var _pointers = {};
+var _pointerDocListener = false;
+
+// DomEvent.DoubleTap needs to know about this
+var _pointersCount = 0;
+
+// Provides a touch events wrapper for (ms)pointer events.
+// ref http://www.w3.org/TR/pointerevents/ https://www.w3.org/Bugs/Public/show_bug.cgi?id=22890
+
+function addPointerListener(obj, type, handler, id) {
+       if (type === 'touchstart') {
+               _addPointerStart(obj, handler, id);
+
+       } else if (type === 'touchmove') {
+               _addPointerMove(obj, handler, id);
+
+       } else if (type === 'touchend') {
+               _addPointerEnd(obj, handler, id);
+       }
+
+       return this;
+}
+
+function removePointerListener(obj, type, id) {
+       var handler = obj['_leaflet_' + type + id];
+
+       if (type === 'touchstart') {
+               obj.removeEventListener(POINTER_DOWN, handler, false);
+
+       } else if (type === 'touchmove') {
+               obj.removeEventListener(POINTER_MOVE, handler, false);
+
+       } else if (type === 'touchend') {
+               obj.removeEventListener(POINTER_UP, handler, false);
+               obj.removeEventListener(POINTER_CANCEL, handler, false);
+       }
+
+       return this;
+}
+
+function _addPointerStart(obj, handler, id) {
+       var onDown = bind(function (e) {
+               if (e.pointerType !== 'mouse' && e.pointerType !== e.MSPOINTER_TYPE_MOUSE && e.pointerType !== e.MSPOINTER_TYPE_MOUSE) {
+                       // In IE11, some touch events needs to fire for form controls, or
+                       // the controls will stop working. We keep a whitelist of tag names that
+                       // need these events. For other target tags, we prevent default on the event.
+                       if (TAG_WHITE_LIST.indexOf(e.target.tagName) < 0) {
+                               preventDefault(e);
+                       } else {
+                               return;
+                       }
+               }
+
+               _handlePointer(e, handler);
+       });
+
+       obj['_leaflet_touchstart' + id] = onDown;
+       obj.addEventListener(POINTER_DOWN, onDown, false);
+
+       // need to keep track of what pointers and how many are active to provide e.touches emulation
+       if (!_pointerDocListener) {
+               // we listen documentElement as any drags that end by moving the touch off the screen get fired there
+               document.documentElement.addEventListener(POINTER_DOWN, _globalPointerDown, true);
+               document.documentElement.addEventListener(POINTER_MOVE, _globalPointerMove, true);
+               document.documentElement.addEventListener(POINTER_UP, _globalPointerUp, true);
+               document.documentElement.addEventListener(POINTER_CANCEL, _globalPointerUp, true);
+
+               _pointerDocListener = true;
+       }
+}
+
+function _globalPointerDown(e) {
+       _pointers[e.pointerId] = e;
+       _pointersCount++;
+}
+
+function _globalPointerMove(e) {
+       if (_pointers[e.pointerId]) {
+               _pointers[e.pointerId] = e;
+       }
+}
+
+function _globalPointerUp(e) {
+       delete _pointers[e.pointerId];
+       _pointersCount--;
+}
+
+function _handlePointer(e, handler) {
+       e.touches = [];
+       for (var i in _pointers) {
+               e.touches.push(_pointers[i]);
+       }
+       e.changedTouches = [e];
+
+       handler(e);
+}
+
+function _addPointerMove(obj, handler, id) {
+       var onMove = function (e) {
+               // don't fire touch moves when mouse isn't down
+               if ((e.pointerType === e.MSPOINTER_TYPE_MOUSE || e.pointerType === 'mouse') && e.buttons === 0) { return; }
+
+               _handlePointer(e, handler);
+       };
+
+       obj['_leaflet_touchmove' + id] = onMove;
+       obj.addEventListener(POINTER_MOVE, onMove, false);
+}
+
+function _addPointerEnd(obj, handler, id) {
+       var onUp = function (e) {
+               _handlePointer(e, handler);
+       };
+
+       obj['_leaflet_touchend' + id] = onUp;
+       obj.addEventListener(POINTER_UP, onUp, false);
+       obj.addEventListener(POINTER_CANCEL, onUp, false);
+}
+
+/*\r
+ * Extends the event handling code with double tap support for mobile browsers.\r
+ */\r
+\r
+var _touchstart = msPointer ? 'MSPointerDown' : pointer ? 'pointerdown' : 'touchstart';
+var _touchend = msPointer ? 'MSPointerUp' : pointer ? 'pointerup' : 'touchend';
+var _pre = '_leaflet_';\r
+\r
+// inspired by Zepto touch code by Thomas Fuchs\r
+function addDoubleTapListener(obj, handler, id) {\r
+       var last, touch$$1,\r
+           doubleTap = false,\r
+           delay = 250;\r
+\r
+       function onTouchStart(e) {\r
+               var count;\r
+\r
+               if (pointer) {\r
+                       if ((!edge) || e.pointerType === 'mouse') { return; }\r
+                       count = _pointersCount;\r
+               } else {\r
+                       count = e.touches.length;\r
+               }\r
+\r
+               if (count > 1) { return; }\r
+\r
+               var now = Date.now(),\r
+                   delta = now - (last || now);\r
+\r
+               touch$$1 = e.touches ? e.touches[0] : e;\r
+               doubleTap = (delta > 0 && delta <= delay);\r
+               last = now;\r
+       }\r
+\r
+       function onTouchEnd(e) {\r
+               if (doubleTap && !touch$$1.cancelBubble) {\r
+                       if (pointer) {\r
+                               if ((!edge) || e.pointerType === 'mouse') { return; }\r
+                               // work around .type being readonly with MSPointer* events\r
+                               var newTouch = {},\r
+                                   prop, i;\r
+\r
+                               for (i in touch$$1) {\r
+                                       prop = touch$$1[i];\r
+                                       newTouch[i] = prop && prop.bind ? prop.bind(touch$$1) : prop;\r
+                               }\r
+                               touch$$1 = newTouch;\r
+                       }\r
+                       touch$$1.type = 'dblclick';\r
+                       handler(touch$$1);\r
+                       last = null;\r
+               }\r
+       }\r
+\r
+       obj[_pre + _touchstart + id] = onTouchStart;\r
+       obj[_pre + _touchend + id] = onTouchEnd;\r
+       obj[_pre + 'dblclick' + id] = handler;\r
+\r
+       obj.addEventListener(_touchstart, onTouchStart, false);\r
+       obj.addEventListener(_touchend, onTouchEnd, false);\r
+\r
+       // On some platforms (notably, chrome<55 on win10 + touchscreen + mouse),\r
+       // the browser doesn't fire touchend/pointerup events but does fire\r
+       // native dblclicks. See #4127.\r
+       // Edge 14 also fires native dblclicks, but only for pointerType mouse, see #5180.\r
+       obj.addEventListener('dblclick', handler, false);\r
+\r
+       return this;\r
+}\r
+\r
+function removeDoubleTapListener(obj, id) {\r
+       var touchstart = obj[_pre + _touchstart + id],\r
+           touchend = obj[_pre + _touchend + id],\r
+           dblclick = obj[_pre + 'dblclick' + id];\r
+\r
+       obj.removeEventListener(_touchstart, touchstart, false);\r
+       obj.removeEventListener(_touchend, touchend, false);\r
+       if (!edge) {\r
+               obj.removeEventListener('dblclick', dblclick, false);\r
+       }\r
+\r
+       return this;\r
+}
+
+/*\r
+ * @namespace DomEvent\r
+ * Utility functions to work with the [DOM events](https://developer.mozilla.org/docs/Web/API/Event), used by Leaflet internally.\r
+ */\r
+\r
+// Inspired by John Resig, Dean Edwards and YUI addEvent implementations.\r
+\r
+// @function on(el: HTMLElement, types: String, fn: Function, context?: Object): this\r
+// Adds a listener function (`fn`) to a particular DOM event type of the\r
+// element `el`. You can optionally specify the context of the listener\r
+// (object the `this` keyword will point to). You can also pass several\r
+// space-separated types (e.g. `'click dblclick'`).\r
+\r
+// @alternative\r
+// @function on(el: HTMLElement, eventMap: Object, context?: Object): this\r
+// Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}`\r
+function on(obj, types, fn, context) {\r
+\r
+       if (typeof types === 'object') {\r
+               for (var type in types) {\r
+                       addOne(obj, type, types[type], fn);\r
+               }\r
+       } else {\r
+               types = splitWords(types);\r
+\r
+               for (var i = 0, len = types.length; i < len; i++) {\r
+                       addOne(obj, types[i], fn, context);\r
+               }\r
+       }\r
+\r
+       return this;\r
+}\r
+\r
+var eventsKey = '_leaflet_events';\r
+\r
+// @function off(el: HTMLElement, types: String, fn: Function, context?: Object): this\r
+// Removes a previously added listener function. If no function is specified,\r
+// it will remove all the listeners of that particular DOM event from the element.\r
+// Note that if you passed a custom context to on, you must pass the same\r
+// context to `off` in order to remove the listener.\r
+\r
+// @alternative\r
+// @function off(el: HTMLElement, eventMap: Object, context?: Object): this\r
+// Removes a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}`\r
+\r
+// @alternative\r
+// @function off(el: HTMLElement): this\r
+// Removes all known event listeners\r
+function off(obj, types, fn, context) {\r
+\r
+       if (typeof types === 'object') {\r
+               for (var type in types) {\r
+                       removeOne(obj, type, types[type], fn);\r
+               }\r
+       } else if (types) {\r
+               types = splitWords(types);\r
+\r
+               for (var i = 0, len = types.length; i < len; i++) {\r
+                       removeOne(obj, types[i], fn, context);\r
+               }\r
+       } else {\r
+               for (var j in obj[eventsKey]) {\r
+                       removeOne(obj, j, obj[eventsKey][j]);\r
+               }\r
+               delete obj[eventsKey];\r
+       }\r
+\r
+       return this;\r
+}\r
+\r
+function addOne(obj, type, fn, context) {\r
+       var id = type + stamp(fn) + (context ? '_' + stamp(context) : '');\r
+\r
+       if (obj[eventsKey] && obj[eventsKey][id]) { return this; }\r
+\r
+       var handler = function (e) {\r
+               return fn.call(context || obj, e || window.event);\r
+       };\r
+\r
+       var originalHandler = handler;\r
+\r
+       if (pointer && type.indexOf('touch') === 0) {\r
+               // Needs DomEvent.Pointer.js\r
+               addPointerListener(obj, type, handler, id);\r
+\r
+       } else if (touch && (type === 'dblclick') && addDoubleTapListener &&\r
+                  !(pointer && chrome)) {\r
+               // Chrome >55 does not need the synthetic dblclicks from addDoubleTapListener\r
+               // See #5180\r
+               addDoubleTapListener(obj, handler, id);\r
+\r
+       } else if ('addEventListener' in obj) {\r
+\r
+               if (type === 'mousewheel') {\r
+                       obj.addEventListener('onwheel' in obj ? 'wheel' : 'mousewheel', handler, false);\r
+\r
+               } else if ((type === 'mouseenter') || (type === 'mouseleave')) {\r
+                       handler = function (e) {\r
+                               e = e || window.event;\r
+                               if (isExternalTarget(obj, e)) {\r
+                                       originalHandler(e);\r
+                               }\r
+                       };\r
+                       obj.addEventListener(type === 'mouseenter' ? 'mouseover' : 'mouseout', handler, false);\r
+\r
+               } else {\r
+                       if (type === 'click' && android) {\r
+                               handler = function (e) {\r
+                                       filterClick(e, originalHandler);\r
+                               };\r
+                       }\r
+                       obj.addEventListener(type, handler, false);\r
+               }\r
+\r
+       } else if ('attachEvent' in obj) {\r
+               obj.attachEvent('on' + type, handler);\r
+       }\r
+\r
+       obj[eventsKey] = obj[eventsKey] || {};\r
+       obj[eventsKey][id] = handler;\r
+}\r
+\r
+function removeOne(obj, type, fn, context) {\r
+\r
+       var id = type + stamp(fn) + (context ? '_' + stamp(context) : ''),\r
+           handler = obj[eventsKey] && obj[eventsKey][id];\r
+\r
+       if (!handler) { return this; }\r
+\r
+       if (pointer && type.indexOf('touch') === 0) {\r
+               removePointerListener(obj, type, id);\r
+\r
+       } else if (touch && (type === 'dblclick') && removeDoubleTapListener) {\r
+               removeDoubleTapListener(obj, id);\r
+\r
+       } else if ('removeEventListener' in obj) {\r
+\r
+               if (type === 'mousewheel') {\r
+                       obj.removeEventListener('onwheel' in obj ? 'wheel' : 'mousewheel', handler, false);\r
+\r
+               } else {\r
+                       obj.removeEventListener(\r
+                               type === 'mouseenter' ? 'mouseover' :\r
+                               type === 'mouseleave' ? 'mouseout' : type, handler, false);\r
+               }\r
+\r
+       } else if ('detachEvent' in obj) {\r
+               obj.detachEvent('on' + type, handler);\r
+       }\r
+\r
+       obj[eventsKey][id] = null;\r
+}\r
+\r
+// @function stopPropagation(ev: DOMEvent): this\r
+// Stop the given event from propagation to parent elements. Used inside the listener functions:\r
+// ```js\r
+// L.DomEvent.on(div, 'click', function (ev) {\r
+//     L.DomEvent.stopPropagation(ev);\r
+// });\r
+// ```\r
+function stopPropagation(e) {\r
+\r
+       if (e.stopPropagation) {\r
+               e.stopPropagation();\r
+       } else if (e.originalEvent) {  // In case of Leaflet event.\r
+               e.originalEvent._stopped = true;\r
+       } else {\r
+               e.cancelBubble = true;\r
+       }\r
+       skipped(e);\r
+\r
+       return this;\r
+}\r
+\r
+// @function disableScrollPropagation(el: HTMLElement): this\r
+// Adds `stopPropagation` to the element's `'mousewheel'` events (plus browser variants).\r
+function disableScrollPropagation(el) {\r
+       addOne(el, 'mousewheel', stopPropagation);\r
+       return this;\r
+}\r
+\r
+// @function disableClickPropagation(el: HTMLElement): this\r
+// Adds `stopPropagation` to the element's `'click'`, `'doubleclick'`,\r
+// `'mousedown'` and `'touchstart'` events (plus browser variants).\r
+function disableClickPropagation(el) {\r
+       on(el, 'mousedown touchstart dblclick', stopPropagation);\r
+       addOne(el, 'click', fakeStop);\r
+       return this;\r
+}\r
+\r
+// @function preventDefault(ev: DOMEvent): this\r
+// Prevents the default action of the DOM Event `ev` from happening (such as\r
+// following a link in the href of the a element, or doing a POST request\r
+// with page reload when a `<form>` is submitted).\r
+// Use it inside listener functions.\r
+function preventDefault(e) {\r
+       if (e.preventDefault) {\r
+               e.preventDefault();\r
+       } else {\r
+               e.returnValue = false;\r
+       }\r
+       return this;\r
+}\r
+\r
+// @function stop(ev): this\r
+// Does `stopPropagation` and `preventDefault` at the same time.\r
+function stop(e) {\r
+       preventDefault(e);\r
+       stopPropagation(e);\r
+       return this;\r
+}\r
+\r
+// @function getMousePosition(ev: DOMEvent, container?: HTMLElement): Point\r
+// Gets normalized mouse position from a DOM event relative to the\r
+// `container` or to the whole page if not specified.\r
+function getMousePosition(e, container) {\r
+       if (!container) {\r
+               return new Point(e.clientX, e.clientY);\r
+       }\r
+\r
+       var rect = container.getBoundingClientRect();\r
+\r
+       return new Point(\r
+               e.clientX - rect.left - container.clientLeft,\r
+               e.clientY - rect.top - container.clientTop);\r
+}\r
+\r
+// Chrome on Win scrolls double the pixels as in other platforms (see #4538),\r
+// and Firefox scrolls device pixels, not CSS pixels\r
+var wheelPxFactor =\r
+       (win && chrome) ? 2 * window.devicePixelRatio :\r
+       gecko ? window.devicePixelRatio : 1;\r
+\r
+// @function getWheelDelta(ev: DOMEvent): Number\r
+// Gets normalized wheel delta from a mousewheel DOM event, in vertical\r
+// pixels scrolled (negative if scrolling down).\r
+// Events from pointing devices without precise scrolling are mapped to\r
+// a best guess of 60 pixels.\r
+function getWheelDelta(e) {\r
+       return (edge) ? e.wheelDeltaY / 2 : // Don't trust window-geometry-based delta\r
+              (e.deltaY && e.deltaMode === 0) ? -e.deltaY / wheelPxFactor : // Pixels\r
+              (e.deltaY && e.deltaMode === 1) ? -e.deltaY * 20 : // Lines\r
+              (e.deltaY && e.deltaMode === 2) ? -e.deltaY * 60 : // Pages\r
+              (e.deltaX || e.deltaZ) ? 0 :     // Skip horizontal/depth wheel events\r
+              e.wheelDelta ? (e.wheelDeltaY || e.wheelDelta) / 2 : // Legacy IE pixels\r
+              (e.detail && Math.abs(e.detail) < 32765) ? -e.detail * 20 : // Legacy Moz lines\r
+              e.detail ? e.detail / -32765 * 60 : // Legacy Moz pages\r
+              0;\r
+}\r
+\r
+var skipEvents = {};\r
+\r
+function fakeStop(e) {\r
+       // fakes stopPropagation by setting a special event flag, checked/reset with skipped(e)\r
+       skipEvents[e.type] = true;\r
+}\r
+\r
+function skipped(e) {\r
+       var events = skipEvents[e.type];\r
+       // reset when checking, as it's only used in map container and propagates outside of the map\r
+       skipEvents[e.type] = false;\r
+       return events;\r
+}\r
+\r
+// check if element really left/entered the event target (for mouseenter/mouseleave)\r
+function isExternalTarget(el, e) {\r
+\r
+       var related = e.relatedTarget;\r
+\r
+       if (!related) { return true; }\r
+\r
+       try {\r
+               while (related && (related !== el)) {\r
+                       related = related.parentNode;\r
+               }\r
+       } catch (err) {\r
+               return false;\r
+       }\r
+       return (related !== el);\r
+}\r
+\r
+var lastClick;\r
+\r
+// this is a horrible workaround for a bug in Android where a single touch triggers two click events\r
+function filterClick(e, handler) {\r
+       var timeStamp = (e.timeStamp || (e.originalEvent && e.originalEvent.timeStamp)),\r
+           elapsed = lastClick && (timeStamp - lastClick);\r
+\r
+       // are they closer together than 500ms yet more than 100ms?\r
+       // Android typically triggers them ~300ms apart while multiple listeners\r
+       // on the same event should be triggered far faster;\r
+       // or check if click is simulated on the element, and if it is, reject any non-simulated events\r
+\r
+       if ((elapsed && elapsed > 100 && elapsed < 500) || (e.target._simulatedClick && !e._simulated)) {\r
+               stop(e);\r
+               return;\r
+       }\r
+       lastClick = timeStamp;\r
+\r
+       handler(e);\r
+}\r
+\r
+\r
+
+
+var DomEvent = (Object.freeze || Object)({
+       on: on,
+       off: off,
+       stopPropagation: stopPropagation,
+       disableScrollPropagation: disableScrollPropagation,
+       disableClickPropagation: disableClickPropagation,
+       preventDefault: preventDefault,
+       stop: stop,
+       getMousePosition: getMousePosition,
+       getWheelDelta: getWheelDelta,
+       fakeStop: fakeStop,
+       skipped: skipped,
+       isExternalTarget: isExternalTarget,
+       addListener: on,
+       removeListener: off
+});
+
+/*\r
+ * @namespace DomUtil\r
+ *\r
+ * Utility functions to work with the [DOM](https://developer.mozilla.org/docs/Web/API/Document_Object_Model)\r
+ * tree, used by Leaflet internally.\r
+ *\r
+ * Most functions expecting or returning a `HTMLElement` also work for\r
+ * SVG elements. The only difference is that classes refer to CSS classes\r
+ * in HTML and SVG classes in SVG.\r
+ */\r
+\r
+\r
+// @property TRANSFORM: String\r
+// Vendor-prefixed transform style name (e.g. `'webkitTransform'` for WebKit).\r
+var TRANSFORM = testProp(\r
+       ['transform', 'WebkitTransform', 'OTransform', 'MozTransform', 'msTransform']);\r
+\r
+// webkitTransition comes first because some browser versions that drop vendor prefix don't do\r
+// the same for the transitionend event, in particular the Android 4.1 stock browser\r
+\r
+// @property TRANSITION: String\r
+// Vendor-prefixed transition style name.\r
+var TRANSITION = testProp(\r
+       ['webkitTransition', 'transition', 'OTransition', 'MozTransition', 'msTransition']);\r
+\r
+// @property TRANSITION_END: String\r
+// Vendor-prefixed transitionend event name.\r
+var TRANSITION_END =\r
+       TRANSITION === 'webkitTransition' || TRANSITION === 'OTransition' ? TRANSITION + 'End' : 'transitionend';\r
+\r
+\r
+// @function get(id: String|HTMLElement): HTMLElement\r
+// Returns an element given its DOM id, or returns the element itself\r
+// if it was passed directly.\r
+function get(id) {\r
+       return typeof id === 'string' ? document.getElementById(id) : id;\r
+}\r
+\r
+// @function getStyle(el: HTMLElement, styleAttrib: String): String\r
+// Returns the value for a certain style attribute on an element,\r
+// including computed values or values set through CSS.\r
+function getStyle(el, style) {\r
+       var value = el.style[style] || (el.currentStyle && el.currentStyle[style]);\r
+\r
+       if ((!value || value === 'auto') && document.defaultView) {\r
+               var css = document.defaultView.getComputedStyle(el, null);\r
+               value = css ? css[style] : null;\r
+       }\r
+       return value === 'auto' ? null : value;\r
+}\r
+\r
+// @function create(tagName: String, className?: String, container?: HTMLElement): HTMLElement\r
+// Creates an HTML element with `tagName`, sets its class to `className`, and optionally appends it to `container` element.\r
+function create$1(tagName, className, container) {\r
+       var el = document.createElement(tagName);\r
+       el.className = className || '';\r
+\r
+       if (container) {\r
+               container.appendChild(el);\r
+       }\r
+       return el;\r
+}\r
+\r
+// @function remove(el: HTMLElement)\r
+// Removes `el` from its parent element\r
+function remove(el) {\r
+       var parent = el.parentNode;\r
+       if (parent) {\r
+               parent.removeChild(el);\r
+       }\r
+}\r
+\r
+// @function empty(el: HTMLElement)\r
+// Removes all of `el`'s children elements from `el`\r
+function empty(el) {\r
+       while (el.firstChild) {\r
+               el.removeChild(el.firstChild);\r
+       }\r
+}\r
+\r
+// @function toFront(el: HTMLElement)\r
+// Makes `el` the last child of its parent, so it renders in front of the other children.\r
+function toFront(el) {\r
+       var parent = el.parentNode;\r
+       if (parent.lastChild !== el) {\r
+               parent.appendChild(el);\r
+       }\r
+}\r
+\r
+// @function toBack(el: HTMLElement)\r
+// Makes `el` the first child of its parent, so it renders behind the other children.\r
+function toBack(el) {\r
+       var parent = el.parentNode;\r
+       if (parent.firstChild !== el) {\r
+               parent.insertBefore(el, parent.firstChild);\r
+       }\r
+}\r
+\r
+// @function hasClass(el: HTMLElement, name: String): Boolean\r
+// Returns `true` if the element's class attribute contains `name`.\r
+function hasClass(el, name) {\r
+       if (el.classList !== undefined) {\r
+               return el.classList.contains(name);\r
+       }\r
+       var className = getClass(el);\r
+       return className.length > 0 && new RegExp('(^|\\s)' + name + '(\\s|$)').test(className);\r
+}\r
+\r
+// @function addClass(el: HTMLElement, name: String)\r
+// Adds `name` to the element's class attribute.\r
+function addClass(el, name) {\r
+       if (el.classList !== undefined) {\r
+               var classes = splitWords(name);\r
+               for (var i = 0, len = classes.length; i < len; i++) {\r
+                       el.classList.add(classes[i]);\r
+               }\r
+       } else if (!hasClass(el, name)) {\r
+               var className = getClass(el);\r
+               setClass(el, (className ? className + ' ' : '') + name);\r
+       }\r
+}\r
+\r
+// @function removeClass(el: HTMLElement, name: String)\r
+// Removes `name` from the element's class attribute.\r
+function removeClass(el, name) {\r
+       if (el.classList !== undefined) {\r
+               el.classList.remove(name);\r
+       } else {\r
+               setClass(el, trim((' ' + getClass(el) + ' ').replace(' ' + name + ' ', ' ')));\r
+       }\r
+}\r
+\r
+// @function setClass(el: HTMLElement, name: String)\r
+// Sets the element's class.\r
+function setClass(el, name) {\r
+       if (el.className.baseVal === undefined) {\r
+               el.className = name;\r
+       } else {\r
+               // in case of SVG element\r
+               el.className.baseVal = name;\r
+       }\r
+}\r
+\r
+// @function getClass(el: HTMLElement): String\r
+// Returns the element's class.\r
+function getClass(el) {\r
+       return el.className.baseVal === undefined ? el.className : el.className.baseVal;\r
+}\r
+\r
+// @function setOpacity(el: HTMLElement, opacity: Number)\r
+// Set the opacity of an element (including old IE support).\r
+// `opacity` must be a number from `0` to `1`.\r
+function setOpacity(el, value) {\r
+       if ('opacity' in el.style) {\r
+               el.style.opacity = value;\r
+       } else if ('filter' in el.style) {\r
+               _setOpacityIE(el, value);\r
+       }\r
+}\r
+\r
+function _setOpacityIE(el, value) {\r
+       var filter = false,\r
+           filterName = 'DXImageTransform.Microsoft.Alpha';\r
+\r
+       // filters collection throws an error if we try to retrieve a filter that doesn't exist\r
+       try {\r
+               filter = el.filters.item(filterName);\r
+       } catch (e) {\r
+               // don't set opacity to 1 if we haven't already set an opacity,\r
+               // it isn't needed and breaks transparent pngs.\r
+               if (value === 1) { return; }\r
+       }\r
+\r
+       value = Math.round(value * 100);\r
+\r
+       if (filter) {\r
+               filter.Enabled = (value !== 100);\r
+               filter.Opacity = value;\r
+       } else {\r
+               el.style.filter += ' progid:' + filterName + '(opacity=' + value + ')';\r
+       }\r
+}\r