Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Wed, 9 Oct 2013 06:00:26 +0000 (23:00 -0700)
committerIvan Kohler <ivan@freeside.biz>
Wed, 9 Oct 2013 06:00:26 +0000 (23:00 -0700)
Conflicts:
FS/FS/cust_main.pm

104 files changed:
FS/FS/Conf.pm
FS/FS/ConfDefaults.pm
FS/FS/Mason.pm
FS/FS/Misc/Geo.pm
FS/FS/Record.pm
FS/FS/Schema.pm
FS/FS/Template_Mixin.pm
FS/FS/TicketSystem.pm
FS/FS/UI/Web.pm
FS/FS/agent.pm
FS/FS/cable_provider.pm [new file with mode: 0644]
FS/FS/cust_bill.pm
FS/FS/cust_location.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Search.pm
FS/FS/cust_main_Mixin.pm
FS/FS/cust_main_county.pm
FS/FS/cust_payby.pm
FS/FS/cust_pkg.pm
FS/FS/cust_svc.pm
FS/FS/invoice_conf.pm [new file with mode: 0644]
FS/FS/invoice_mode.pm [new file with mode: 0644]
FS/FS/part_event/Action/cust_bill_email.pm
FS/FS/part_event/Action/cust_bill_print.pm
FS/FS/part_event/Action/cust_bill_print_pdf.pm
FS/FS/part_event/Action/cust_bill_send.pm
FS/FS/part_event/Action/cust_bill_send_agent.pm
FS/FS/part_event/Action/cust_bill_send_alternate.pm
FS/FS/part_event/Action/cust_bill_send_if_newest.pm
FS/FS/part_event/Action/cust_bill_send_reminder.pm
FS/FS/part_event/Action/cust_statement_send.pm
FS/FS/part_event/Action/fee.pm
FS/FS/part_event/Condition/pkg_age_Common.pm
FS/FS/part_export/domain_shellcommands.pm
FS/FS/part_export/shellcommands_withdomain.pm
FS/FS/svc_cable.pm
FS/MANIFEST
FS/t/cable_provider.t [new file with mode: 0644]
FS/t/invoice_conf.t [new file with mode: 0644]
FS/t/invoice_mode.t [new file with mode: 0644]
bin/generate-table-module
bin/standardize-locations [new file with mode: 0755]
bin/test-event [changed mode: 0644->0755]
httemplate/browse/cable_provider.html [new file with mode: 0644]
httemplate/browse/invoice_conf.html [new file with mode: 0644]
httemplate/docs/about.html
httemplate/edit/cable_provider.html [new file with mode: 0644]
httemplate/edit/cust_main/bottomfixup.js
httemplate/edit/cust_main/top_misc.html
httemplate/edit/elements/edit.html
httemplate/edit/invoice_conf.html [new file with mode: 0644]
httemplate/edit/process/cable_provider.html [new file with mode: 0644]
httemplate/edit/process/invoice_conf.html [new file with mode: 0644]
httemplate/elements/columnstart.html
httemplate/elements/menu.html
httemplate/elements/select-cable_provider.html [new file with mode: 0644]
httemplate/elements/tr-select-cable_provider.html [new file with mode: 0644]
httemplate/elements/tr-select-invoice_mode.html [new file with mode: 0644]
httemplate/misc/delete-invoice_conf.html [new file with mode: 0644]
httemplate/misc/email-invoice.cgi
httemplate/misc/fax-invoice.cgi
httemplate/misc/print-invoice.cgi
httemplate/misc/send-invoice.cgi
httemplate/misc/xmlhttp-cust_main-duplicates.html
httemplate/search/phone_state.html [new file with mode: 0644]
httemplate/view/cust_bill.cgi
httemplate/view/cust_main.cgi
httemplate/view/cust_statement.html
httemplate/view/elements/cust_bill-typeset
ng_selfservice/.freeside.class.php.swp [deleted file]
ng_selfservice/.index.php.swp [deleted file]
ng_selfservice/.logout.php.swp [deleted file]
ng_selfservice/.main.php.swp [deleted file]
ng_selfservice/.password.php.swp [deleted file]
ng_selfservice/.payment.php.swp [deleted file]
ng_selfservice/.payment_ach.php.swp [deleted file]
ng_selfservice/.payment_cc.php.swp [deleted file]
ng_selfservice/.payment_paypal.php.swp [deleted file]
ng_selfservice/.payment_webpay.php.swp [deleted file]
ng_selfservice/.personal.php.swp [deleted file]
ng_selfservice/.process_login.php.swp [deleted file]
ng_selfservice/.process_ticket_create.php.swp [deleted file]
ng_selfservice/.services.php.swp [deleted file]
ng_selfservice/.services_new.php.swp [deleted file]
ng_selfservice/.ticket.php.swp [deleted file]
ng_selfservice/.ticket_create.php.swp [deleted file]
ng_selfservice/.tickets.php.swp [deleted file]
ng_selfservice/.tickets_resolved.php.swp [deleted file]
ng_selfservice/.usage.php.swp [deleted file]
ng_selfservice/.usage_cdr.php.swp [deleted file]
ng_selfservice/.usage_data.php.swp [deleted file]
ng_selfservice/elements/.card.php.swp [deleted file]
ng_selfservice/elements/.check.php.swp [deleted file]
ng_selfservice/elements/.error.php.swp [deleted file]
ng_selfservice/elements/.header.php.swp [deleted file]
ng_selfservice/elements/.menu.php.swp [deleted file]
ng_selfservice/elements/.menu_footer.php.swp [deleted file]
ng_selfservice/elements/.session.php.swp [deleted file]
ng_selfservice/elements/.ticketlist.php.swp [deleted file]
ng_selfservice/js/.menu.js.swp [deleted file]
rt/lib/RT/Interface/Web.pm
rt/lib/RT/URI/freeside/Internal.pm
rt/share/html/Ticket/Elements/Customers
rt/share/html/Ticket/Update.html

index 03280c4..16bbaad 100644 (file)
@@ -4044,7 +4044,7 @@ and customer address. Include units.',
     'type'        => 'select',
     'multiple'    => 1,
     'select_hash' => [ 
-      #'address1' => 'Billing address',
+      'address' => 'Billing or service address',
     ],
   },
 
@@ -4163,6 +4163,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'previous_balance-payments_since',
+    'section'     => 'invoicing',
+    'description' => 'Instead of showing payments (and credits) applied to the invoice, show those received since the previous invoice date.',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'balance_due_below_line',
     'section'     => 'invoicing',
     'description' => 'Place the balance due message below a line.  Only meaningful when when invoice_sections is false.',
@@ -4182,8 +4189,9 @@ and customer address. Include units.',
     'description' => 'Method for standardizing customer addresses.',
     'type'        => 'select',
     'select_hash' => [ '' => '', 
-                       'usps' => 'U.S. Postal Service',
+                       'usps'     => 'U.S. Postal Service',
                        'ezlocate' => 'EZLocate',
+                       'tomtom'   => 'TomTom',
                      ],
   },
 
@@ -4202,6 +4210,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'tomtom-userid',
+    'section'     => 'UI',
+    'description' => 'TomTom geocoding service API key.  See <a href="http://www.tomtom.com/">the TomTom website</a> to obtain a key.  This is recommended for addresses in the United States only.',
+    'type'        => 'text',
+  },
+
+  {
     'key'         => 'ezlocate-userid',
     'section'     => 'UI',
     'description' => 'User ID for EZ-Locate service.  See <a href="http://www.geocode.com/">the TomTom website</a> for access and pricing information.',
@@ -5505,6 +5520,13 @@ and customer address. Include units.',
     'type'        => 'text',
   },
 
+  {
+    'key'         => 'allow_invalid_cards',
+    'section'     => '',
+    'description' => 'Accept invalid credit card numbers.  Useful for testing with fictitious customers.  There is no good reason to enable this in production.',
+    'type'        => 'checkbox',
+  },
+
   { key => "apacheroot", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachine", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachines", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
index de65b44..191ff85 100644 (file)
@@ -38,15 +38,15 @@ sub cust_fields_avail { (
   'Cust# | Cust. Status | Name | Company' =>
     'custnum | Status | Last, First | Company',
 
-  'Cust. Status | (bill) Customer | (service) Customer' =>
-    'Status | Last, First or Company (Last, First) | (same for service contact if present)',
-  'Cust# | Cust. Status | (bill) Customer | (service) Customer' =>
-    'custnum | Status | Last, First or Company (Last, First) | (same for service contact if present)',
+  'Cust. Status | Customer' =>
+    'Status | Last, First or Company (Last, First)',
+  'Cust# | Cust. Status | Customer' =>
+    'custnum | Status | Last, First or Company (Last, First)',
 
-  'Cust. Status | (bill) Name | (bill) Company | (service) Name | (service) Company' =>
-    'Status | Last, First | Company | (same for service contact if present)',
-  'Cust# | Cust. Status | (bill) Name | (bill) Company | (service) Name | (service) Company' =>
-    'custnum | Status | Last, First | Company | (same for service contact if present)',
+  'Cust. Status | Name | Company' =>
+    'Status | Last, First | Company',
+  'Cust# | Cust. Status | Name | Company' =>
+    'custnum | Status | Last, First | Company',
 
   'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Invoicing email(s)' => 
     'custnum | Status | Last, First | Company | (address) | Day phone | Night phone | Invoicing email(s)',
@@ -57,13 +57,13 @@ sub cust_fields_avail { (
   'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Fax number | Invoicing email(s) | Payment Type | Current Balance' => 
     'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type | Current Balance',
 
-  'Cust# | Cust. Status | (bill) Name | (bill) Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Day phone | (bill) Night phone | (service) Name | (service) Company | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Day phone | (service) Night phone | Invoicing email(s)' => 
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Day phone | Night phone | Invoicing email(s)' => 
     'custnum | Status | Last, First | Company | (address) | Day phone | Night phone | (service address) | Invoicing email(s)',
 
-  'Cust# | Cust. Status | (bill) Name | (bill) Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Day phone | (bill) Night phone | (bill) Fax number | (service) Name | (service) Company | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Day phone | (service) Night phone | (service) Fax number | Invoicing email(s) | Payment Type' => 
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type' => 
     'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type',
 
-  'Cust# | Cust. Status | (bill) Name | (bill) Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Day phone | (bill) Night phone | (bill) Fax number | (service) Name | (service) Company | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Day phone | (service) Night phone | (service) Fax number | Invoicing email(s) | Payment Type | Current Balance' => 
+  'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Day phone | Night phone | Fax number | Invoicing email(s) | Payment Type | Current Balance' => 
     'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
 
   'Invoicing email(s)' => 'Invoicing email(s)',
index 780e3ff..1215ca4 100644 (file)
@@ -353,6 +353,9 @@ if ( -e $addl_handler_use_file ) {
   use FS::sales_pkg_class;
   use FS::svc_alarm;
   use FS::cable_model;
+  use FS::invoice_mode;
+  use FS::invoice_conf;
+  use FS::cable_provider;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
index a93d98f..b5cc325 100644 (file)
@@ -10,6 +10,7 @@ use HTML::TokeParser;
 use URI::Escape 3.31;
 use Data::Dumper;
 use FS::Conf;
+use Locale::Country;
 
 FS::UID->install_callback( sub {
   $conf = new FS::Conf;
@@ -410,6 +411,155 @@ sub standardize_ezlocate {
   \%result;
 }
 
+sub standardize_tomtom {
+  # post-2013 TomTom API
+  # much better, but incompatible with ezlocate
+  my $self = shift;
+  my $location = shift;
+  my $class = 'Geo::TomTom::Geocoding';
+  eval "use $class";
+  die $@ if $@;
+
+  my $key = $conf->config('tomtom-userid')
+    or die "no tomtom-userid configured\n";
+
+  my $country = code2country($location->{country});
+  my ($address1, $address2) = ($location->{address1}, $location->{address2});
+  # try to fix some cases of the address fields being switched
+  if ( $address2 =~ /^\d/ and $address1 !~ /^\d/ ) {
+    $address2 = $address1;
+    $address1 = $location->{address2};
+  }
+  my $result = $class->query(
+    key => $key,
+    T   => $address1,
+    L   => $location->{city},
+    AA  => $location->{state},
+    PC  => $location->{zip},
+    CC  => country2code($country, LOCALE_CODE_ALPHA_3),
+  );
+  unless ( $result->is_success ) {
+    die "TomTom geocoding error: ".$result->message."\n";
+  }
+  my ($match) = $result->locations;
+  if (!$match) {
+    die "Location not found.\n";
+  }
+  my $type = $match->{type};
+  warn "tomtom returned $type match\n" if $DEBUG;
+  warn Dumper($match) if $DEBUG > 1;
+  my $tract = '';
+  if ( defined $match->{censusTract} ) {
+    $tract = $match->{censusStateCode}. $match->{censusFipsCountyCode}.
+             join('.', $match->{censusTract} =~ /(....)(..)/);
+  }
+  # match levels below "intersection" should not be considered clean
+  my $clean = ($type eq 'addresspoint'  ||
+               $type eq 'poi'           ||
+               $type eq 'house'         ||
+               $type eq 'intersection'
+              ) ? 'Y' : '';
+
+  $address2 = normalize_address2($address2, $location->{country});
+
+  $address1 = '';
+  $address1 = $match->{houseNumber} . ' ' if length($match->{houseNumber});
+  $address1 .= $match->{street} if $match->{street};
+
+  return +{
+    address1    => $address1,
+    address2    => $address2,
+    city        => $match->{city},
+    state       => $location->{state},    # this will never change
+    country     => $location->{country},  # ditto
+    zip         => ($match->{standardPostalCode} || $match->{postcode}),
+    latitude    => $match->{latitude},
+    longitude   => $match->{longitude},
+    censustract => $tract,
+    addr_clean  => $clean,
+  };
+}
+
+=iten normalize_address2 STRING, COUNTRY
+
+Given an 'address2' STRING, normalize it for COUNTRY postal standards.
+Currently only works for US and CA.
+
+=cut
+
+# XXX really ought to be a separate module
+my %address2_forms = (
+  # Postal Addressing Standards, Appendix C
+  # (plus correction of "hanger" to "hangar")
+  US => {qw(
+    APARTMENT     APT
+    BASEMENT      BSMT
+    BUILDING      BLDG
+    DEPARTMENT    DEPT
+    FLOOR         FL
+    FRONT         FRNT
+    HANGAR        HNGR
+    HANGER        HNGR
+    KEY           KEY
+    LOBBY         LBBY
+    LOT           LOT
+    LOWER         LOWR
+    OFFICE        OFC
+    PENTHOUSE     PH
+    PIER          PIER
+    REAR          REAR
+    ROOM          RM
+    SIDE          SIDE
+    SLIP          SLIP
+    SPACE         SPC
+    STOP          STOP
+    SUITE         STE
+    TRAILER       TRLR
+    UNIT          UNIT
+    UPPER         UPPR
+  )},
+  # Canada Post Addressing Guidelines 4.3
+  CA => {qw(
+    APARTMENT     APT
+    APPARTEMENT   APP
+    BUREAU        BUREAU
+    SUITE         SUITE
+    UNIT          UNIT
+    UNITÉ         UNITÉ
+  )},
+);
+sub normalize_address2 {
+  # Some things seen in the address2 field:
+  # Whitespace
+  # The complete address (with address1 containing part of the company name, 
+  # or an ATTN or DBA line, or P.O. Box, or department name, or building/suite
+  # number, etc.)
+  my ($addr2, $country) = @_;
+  $addr2 = uc($addr2);
+  if ( exists($address2_forms{$country}) ) {
+    my $dict = $address2_forms{$country};
+    # protect this
+    $addr2 =~ s/#\s*(\d)/NUMBER$1/; # /g?
+    my @words;
+    # remove all punctuation and spaces
+    foreach my $w (split(/\W+/, $addr2)) {
+      if ( exists($dict->{$w}) ) {
+        push @words, $dict->{$w};
+      } else {
+        push @words, $w;
+      }
+    }
+    my $result = join(' ', @words);
+    # correct spacing of pound sign + number
+    $result =~ s/NUMBER(\d)/# $1/;
+    warn "normalizing '$addr2' to '$result'\n" if $DEBUG > 1;
+    $addr2 = $result;
+  }
+  $addr2;
+}
+
+
 =back
 
 =cut
index fd03524..71eddc1 100644 (file)
@@ -3038,13 +3038,8 @@ Checks to see if the string is encrypted and returns true or false (1/0) to indi
 
 sub is_encrypted {
   my ($self, $value) = @_;
-  # Possible Bug - Some work may be required here....
-
-  if ($value =~ /^M/ && length($value) > 80) {
-    return 1;
-  } else {
-    return 0;
-  }
+  # could be more precise about it, but this will do for now
+  $value =~ /^M/ && length($value) > 80;
 }
 
 =item decrypt($value)
index ed37904..e44b74e 100644 (file)
@@ -4272,6 +4272,8 @@ sub tables_hashref {
     'svc_cable' => {
       'columns' => [
         'svcnum',        'int',     '',      '', '', '', 
+        'providernum',   'int', 'NULL',      '', '', '',
+        # XXX "Circuit ID/Order number"
         'modelnum',      'int', 'NULL',      '', '', '',
         'serialnum', 'varchar', 'NULL', $char_d, '', '',
         'mac_addr',  'varchar', 'NULL',      12, '', '', 
@@ -4292,6 +4294,17 @@ sub tables_hashref {
       'index'  => [],
     },
 
+    'cable_provider' => {
+      'columns' => [
+        'providernum', 'serial',     '',      '', '', '',
+        'provider',   'varchar',     '', $char_d, '', '',
+        'disabled',      'char', 'NULL',       1, '', '', 
+      ],
+      'primary_key' => 'providernum',
+      'unique' => [ [ 'provider' ], ],
+      'index'  => [],
+    },
+
     'vend_main' => {
       'columns' => [
         'vendnum',   'serial',     '',      '', '', '',
@@ -4377,6 +4390,52 @@ sub tables_hashref {
       'index'       => [ [ 'derivenum', ], ],
     },
 
+    'invoice_mode' => {
+      'columns' => [
+        'modenum',      'serial', '', '', '', '',
+        'agentnum',        'int', 'NULL', '', '', '',
+        'modename',    'varchar', '', 32, '', '',
+      ],
+      'primary_key' => 'modenum',
+      'unique'      => [ ],
+      'index'       => [ ],
+    },
+
+    'invoice_conf' => {
+      'columns' => [
+        'confnum',              'serial',   '', '', '', '',
+        'modenum',              'int',      '', '', '', '',
+        'locale',               'varchar',  'NULL', 16, '', '',
+        'notice_name',          'varchar',  'NULL', 64, '', '',
+        'subject',              'varchar',  'NULL', 64, '', '',
+        'htmlnotes',            'text',     'NULL', '', '', '',
+        'htmlfooter',           'text',     'NULL', '', '', '',
+        'htmlsummary',          'text',     'NULL', '', '', '',
+        'htmlreturnaddress',    'text',     'NULL', '', '', '',
+        'latexnotes',           'text',     'NULL', '', '', '',
+        'latexfooter',          'text',     'NULL', '', '', '',
+        'latexsummary',         'text',     'NULL', '', '', '',
+        'latexcoupon',          'text',     'NULL', '', '', '',
+        'latexsmallfooter',     'text',     'NULL', '', '', '',
+        'latexreturnaddress',   'text',     'NULL', '', '', '',
+        'latextopmargin',       'varchar',  'NULL', 16, '', '',
+        'latexheadsep',         'varchar',  'NULL', 16, '', '',
+        'latexaddresssep',      'varchar',  'NULL', 16, '', '',
+        'latextextheight',      'varchar',  'NULL', 16, '', '',
+        'latexextracouponspace','varchar',  'NULL', 16, '', '',
+        'latexcouponfootsep',   'varchar',  'NULL', 16, '', '',
+        'latexcouponamountenclosedsep', 'varchar',  'NULL', 16, '', '',
+        'latexcoupontoaddresssep',      'varchar',  'NULL', 16, '', '',
+        'latexverticalreturnaddress',      'char',  'NULL',  1, '', '',
+        'latexcouponaddcompanytoaddress',  'char',  'NULL',  1, '', '',
+        'logo_png',             'blob',     'NULL', '', '', '',
+        'logo_eps',             'blob',     'NULL', '', '', '',
+        'lpr',                  'varchar',  'NULL', $char_d, '', '',
+      ],
+      'primary_key' => 'confnum',
+      'unique' => [ [ 'modenum', 'locale' ] ],
+      'index'  => [ ],
+    },
 
     # name type nullability length default local
 
index db38854..840df75 100644 (file)
@@ -18,6 +18,7 @@ use FS::Record qw( qsearch qsearchs );
 use FS::Misc qw( generate_ps generate_pdf );
 use FS::pkg_category;
 use FS::pkg_class;
+use FS::invoice_mode;
 use FS::L10N;
 
 $DEBUG = 0;
@@ -30,12 +31,51 @@ FS::UID->install_callback( sub {
   $date_format_long = $conf->config('date_format_long') || '%b %o, %Y';
 } );
 
-=item print_text HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+=item conf [ MODE ]
+
+Returns a configuration handle (L<FS::Conf>) set to the customer's locale.
+
+If the "mode" pseudo-field is set on the object, the configuration handle
+will be an L<FS::invoice_conf> for that invoice mode (and the customer's
+locale).
+
+=cut
+
+sub conf {
+  my $self = shift;
+  my $mode = $self->get('mode');
+  if ($self->{_conf} and !defined($mode)) {
+    return $self->{_conf};
+  }
+
+  my $cust_main = $self->cust_main;
+  my $locale = $cust_main ? $cust_main->locale : '';
+  my $conf;
+  if ( $mode ) {
+    if ( ref $mode and $mode->isa('FS::invoice_mode') ) {
+      $mode = $mode->modenum;
+    } elsif ( $mode =~ /\D/ ) {
+      die "invalid invoice mode $mode";
+    }
+    $conf = qsearchs('invoice_conf', { modenum => $mode, locale => $locale });
+    if (!$conf) {
+      $conf = qsearchs('invoice_conf', { modenum => $mode, locale => '' });
+      # it doesn't have a locale, but system conf still might
+      $conf->set('locale' => $locale) if $conf;
+    }
+  }
+  # if $mode is unspecified, or if there is no invoice_conf matching this mode
+  # and locale, then use the system config only (but with the locale)
+  $conf ||= FS::Conf->new({ 'locale' => $locale });
+  # cache it
+  return $self->{_conf} = $conf;
+}
+
+=item print_text OPTIONS
 
 Returns an text invoice, as a list of lines.
 
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
+Options can be passed as a hash.
 
 I<time>, if specified, is used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
@@ -50,25 +90,19 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_text {
   my $self = shift;
-  my( $today, $template, %opt );
+  my %params;
   if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
+    %params = %{ shift() };
   } else {
-    ( $today, $template, %opt ) = @_;
+    %params = @_;
   }
 
-  my %params = ( 'format' => 'template' );
-  $params{'time'} = $today if $today;
-  $params{'template'} = $template if $template;
-  $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name );
+  $params{'format'} = 'template'; # for some reason
 
   $self->print_generic( %params );
 }
 
-=item print_latex HASHREF | [ TIME [ , TEMPLATE [ , OPTION => VALUE ... ] ] ]
+=item print_latex HASHREF
 
 Internal method - returns a filename of a filled-in LaTeX template for this
 invoice (Note: add ".tex" to get the actual filename), and a filename of
@@ -76,15 +110,16 @@ an associated logo (with the .eps extension included).
 
 See print_ps and print_pdf for methods that return PostScript and PDF output.
 
-Options can be passed as a hashref (recommended) or as a list of time, template
-and then any key/value pairs for any other options.
+Options can be passed as a hash.
 
 I<time>, if specified, is used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
 It is specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
 L<Time::Local> and L<Date::Parse> for conversion functions.
 
-I<template>, if specified, is the name of a suffix for alternate invoices.
+I<template>, if specified, is the name of a suffix for alternate invoices.  
+This is strongly deprecated; see L<FS::invoice_conf> for the right way to
+customize invoice templates for different purposes.
 
 I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
 
@@ -92,22 +127,20 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 sub print_latex {
   my $self = shift;
-  my $conf = $self->conf;
-  my( $today, $template, %opt );
+  my %params;
+
   if ( ref($_[0]) ) {
-    %opt = %{ shift() };
-    $today = delete($opt{'time'}) || '';
-    $template = delete($opt{template}) || '';
+    %params = %{ shift() };
   } else {
-    ( $today, $template, %opt ) = @_;
+    %params = @_;
   }
 
-  my %params = ( 'format' => 'latex' );
-  $params{'time'} = $today if $today;
-  $params{'template'} = $template if $template;
-  $params{$_} = $opt{$_} 
-    foreach grep $opt{$_}, qw( unsquelch_cdr notice_name no_date no_number );
+  $params{'format'} = 'latex';
+  my $conf = $self->conf;
 
+  # this needs to go away
+  my $template = $params{'template'};
+  # and this especially
   $template ||= $self->_agent_template
     if $self->can('_agent_template');
 
@@ -191,7 +224,8 @@ Non optional options include
 
 Optional options include
 
-template - a value used as a suffix for a configuration template
+template - a value used as a suffix for a configuration template.  Please 
+don't use this.
 
 time - a value used to control the printing of overdue messages.  The
 default is now.  It isn't the date of the invoice; that's the `_date' field.
@@ -214,6 +248,7 @@ locale - override customer's locale
 sub print_generic {
   my( $self, %params ) = @_;
   my $conf = $self->conf;
+
   my $today = $params{today} ? $params{today} : time;
   warn "$me print_generic called on $self with suffix $params{template}\n"
     if $DEBUG;
@@ -227,6 +262,8 @@ sub print_generic {
     unless $cust_main->payname
         && $cust_main->payby !~ /^(CARD|DCRD|CHEK|DCHK)$/;
 
+  my $locale = $params{'locale'} || $cust_main->locale;
+
   my %delimiters = ( 'latex'    => [ '[@--', '--@]' ],
                      'html'     => [ '<%=', '%>' ],
                      'template' => [ '{', '}' ],
@@ -235,11 +272,18 @@ sub print_generic {
   warn "$me print_generic creating template\n"
     if $DEBUG > 1;
 
+  # set the notice name here, and nowhere else.
+  my $notice_name =  $params{notice_name}
+                  || $conf->config('notice_name')
+                  || $self->notice_name;
+
   #create the template
   my $template = $params{template} ? $params{template} : $self->_agent_template;
   my $templatefile = $self->template_conf. $format;
   $templatefile .= "_$template"
     if length($template) && $conf->exists($templatefile."_$template");
+
+  # the base template
   my @invoice_template = map "$_\n", $conf->config($templatefile)
     or die "cannot load config data $templatefile";
 
@@ -380,6 +424,7 @@ sub print_generic {
 
   # generate template variables
   my $returnaddress;
+
   if (
          defined( $conf->config_orbase( "invoice_${format}returnaddress",
                                         $template
@@ -457,7 +502,7 @@ sub print_generic {
     'today'           => time2str($date_format_long, $today),
     'terms'           => $self->terms,
     'template'        => $template, #params{'template'},
-    'notice_name'     => ($params{'notice_name'} || $self->notice_name),#escape_function?
+    'notice_name'     => $notice_name, # escape?
     'current_charges' => sprintf("%.2f", $self->charged),
     'duedate'         => $self->due_date2str($rdate_format), #date_format?
 
@@ -499,7 +544,7 @@ sub print_generic {
   );
  
   #localization
-  my $lh = FS::L10N->get_handle( $params{'locale'} || $cust_main->locale );
+  my $lh = FS::L10N->get_handle( $locale );
   $invoice_data{'emt'} = sub { &$escape_function($self->mt(@_)) };
   my %info = FS::Locales->locale_info($cust_main->locale || 'en_US');
   # eval to avoid death for unimplemented languages
@@ -608,23 +653,12 @@ sub print_generic {
   # summary formats
   $invoice_data{'last_bill'} = {};
 
-  # returns the last unpaid bill, not the last bill
-  #my $last_bill = $pr_cust_bill[-1];
-
   if ( $self->custnum && $self->invnum ) {
 
-    # THIS returns the customer's last bill before  this one
-    my $last_bill = qsearchs({
-        'table'   => 'cust_bill',
-        'hashref' => { 'custnum' => $self->custnum,
-                       'invnum'  => { op => '<', value => $self->invnum },
-                     },
-        'order_by'  => ' ORDER BY invnum DESC LIMIT 1'
-    });
-    if ( $last_bill ) {
+    if ( $self->previous_bill ) {
+      my $last_bill = $self->previous_bill;
       $invoice_data{'last_bill'} = {
         '_date'     => $last_bill->_date, #unformatted
-        # all we need for now
       };
       my (@payments, @credits);
       # for formats that itemize previous payments
@@ -766,6 +800,7 @@ sub print_generic {
   my $taxtotal = 0;
   my $tax_section = { 'description' => $self->mt('Taxes, Surcharges, and Fees'),
                       'subtotal'    => $taxtotal,   # adjusted below
+                      'tax_section' => 1,
                     };
   my $tax_weight = _pkg_category($tax_section->{description})
                         ? _pkg_category($tax_section->{description})->weight
@@ -952,9 +987,6 @@ sub print_generic {
 
   foreach my $section (@sections, @$late_sections) {
 
-    warn "$me adding section \n". Dumper($section)
-      if $DEBUG > 1;
-
     # begin some normalization
     $section->{'subtotal'} = $section->{'amount'}
       if $multisection
@@ -1167,7 +1199,7 @@ sub print_generic {
         $adjust_section->{'pretotal'} = $self->mt('New charges total').' '.
           $other_money_char.  sprintf('%.2f', $self->charged );
       } 
-    }else{
+    } else {
       push @total_items, $total;
     }
     push @buf,['','-----------'];
@@ -1554,12 +1586,9 @@ sub print_html {
   my %params;
   if ( ref($_[0]) ) {
     %params = %{ shift() }; 
-  }else{
-    $params{'time'} = shift;
-    $params{'template'} = shift;
-    $params{'cid'} = shift;
+  } else {
+    %params = @_;
   }
-
   $params{'format'} = 'html';
   
   $self->print_generic( %params );
index c1c69fa..fa54e0b 100644 (file)
@@ -342,6 +342,21 @@ sub _upgrade_data {
     or die $dbh->errstr;
   $cve_2013_3373_sth->execute or die $cve_2013_3373_sth->errstr;
 
+  # Remove dangling customer links, if any
+  my %target_pkey = ('cust_main' => 'custnum', 'cust_svc' => 'svcnum');
+  for my $table (keys %target_pkey) {
+    my $pkey = $target_pkey{$table};
+    my $rows = $dbh->do(
+      "DELETE FROM links WHERE id IN(".
+        "SELECT links.id FROM links LEFT JOIN $table ON (links.target = ".
+        "'freeside://freeside/$table/' || $table.$pkey) ".
+        "WHERE links.target like 'freeside://freeside/$table/%' ".
+        "AND $table.$pkey IS NULL".
+      ")"
+    ) or die $dbh->errstr;
+    warn "Removed $rows dangling ticket-$table links\n" if $rows > 0;
+  }
+
   return;
 }
 
index ccba1de..d7f998b 100644 (file)
@@ -229,18 +229,25 @@ sub cust_header {
     'Cust#'                    => 'custnum',
     'Name'                     => 'contact',
     'Company'                  => 'company',
+
+    # obsolete but might still be referenced in configuration
     '(bill) Customer'          => 'name',
     '(service) Customer'       => 'ship_name',
     '(bill) Name'              => 'contact',
     '(service) Name'           => 'ship_contact',
     '(bill) Company'           => 'company',
     '(service) Company'        => 'ship_company',
+    '(bill) Day phone'         => 'daytime',
+    '(bill) Night phone'       => 'night',
+    '(bill) Fax number'        => 'fax',
+    'Customer'                 => 'name',
     'Address 1'                => 'bill_address1',
     'Address 2'                => 'bill_address2',
     'City'                     => 'bill_city',
     'State'                    => 'bill_state',
     'Zip'                      => 'bill_zip',
-    'Country'                  => 'country_full',
+    'Country'                  => 'bill_country_full',
     'Day phone'                => 'daytime', # XXX should use msgcat, but how?
     'Night phone'              => 'night',   # XXX should use msgcat, but how?
     'Fax number'               => 'fax',
@@ -249,19 +256,13 @@ sub cust_header {
     '(bill) City'              => 'bill_city',
     '(bill) State'             => 'bill_state',
     '(bill) Zip'               => 'bill_zip',
-    '(bill) Country'           => 'country_full',
-    '(bill) Day phone'         => 'daytime', # XXX should use msgcat, but how?
-    '(bill) Night phone'       => 'night',   # XXX should use msgcat, but how?
-    '(bill) Fax number'        => 'fax',
+    '(bill) Country'           => 'bill_country_full',
     '(service) Address 1'      => 'ship_address1',
     '(service) Address 2'      => 'ship_address2',
     '(service) City'           => 'ship_city',
     '(service) State'          => 'ship_state',
     '(service) Zip'            => 'ship_zip',
     '(service) Country'        => 'ship_country_full',
-    '(service) Day phone'      => 'ship_daytime', # XXX should use msgcat, how?
-    '(service) Night phone'    => 'ship_night',   # XXX should use msgcat, how?
-    '(service) Fax number'     => 'ship_fax',
     'Invoicing email(s)'       => 'invoicing_list_emailonly_scalar',
     'Payment Type'             => 'payby',
     'Current Balance'          => 'current_balance',
@@ -348,8 +349,10 @@ sub cust_sql_fields {
       }
     }
   }
-  
-  push @fields, 'payby' if grep { $_ eq 'payby'} @cust_fields;
+
+  foreach my $field (qw(daytime night fax payby)) {
+    push @fields, $field if (grep { $_ eq $field } @cust_fields);
+  }
   push @fields, 'agent_custid';
 
   my @extra_fields = ();
index 0cd07ef..d70ff18 100644 (file)
@@ -378,6 +378,23 @@ sub payment_gateway {
   $payment_gateway;
 }
 
+=item invoice_modes
+
+Returns all L<FS::invoice_mode> objects that are valid for this agent (i.e.
+those with this agentnum or null agentnum).
+
+=cut
+
+sub invoice_modes {
+  my $self = shift;
+  qsearch( {
+      table     => 'invoice_mode',
+      hashref   => { agentnum => $self->agentnum },
+      extra_sql => ' OR agentnum IS NULL',
+      order_by  => ' ORDER BY modename',
+  } );
+}
+
 =item num_prospect_cust_main
 
 Returns the number of prospects (customers with no packages ever ordered) for
diff --git a/FS/FS/cable_provider.pm b/FS/FS/cable_provider.pm
new file mode 100644 (file)
index 0000000..e988192
--- /dev/null
@@ -0,0 +1,112 @@
+package FS::cable_provider;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::cable_provider - Object methods for cable_provider records
+
+=head1 SYNOPSIS
+
+  use FS::cable_provider;
+
+  $record = new FS::cable_provider \%hash;
+  $record = new FS::cable_provider { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cable_provider object represents a cable service provider.
+FS::cable_provider inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item providernum
+
+primary key
+
+=item provider
+
+provider
+
+=item disabled
+
+disabled
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new provider.  To add the provider 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
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'cable_provider'; }
+
+=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 provider.  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_numbern('providernum')
+    || $self->ut_text('provider')
+    || $self->ut_enum('disabled', [ '', 'Y' ] )
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index fc6a7dd..a747a78 100644 (file)
@@ -80,7 +80,7 @@ FS::cust_bill - Object methods for cust_bill records
   $tax_amount = $record->tax;
 
   @lines = $cust_bill->print_text;
-  @lines = $cust_bill->print_text $time;
+  @lines = $cust_bill->print_text('time' => $time);
 
 =head1 DESCRIPTION
 
@@ -153,7 +153,13 @@ Invoices are normally created by calling the bill method of a customer object
 =cut
 
 sub table { 'cust_bill'; }
-sub notice_name { 'Invoice'; }
+
+# should be the ONLY occurrence of "Invoice" in invoice rendering code.
+# (except email_subject and invnum_date_pretty)
+sub notice_name {
+  my $self = shift;
+  $self->conf->config('notice_name') || 'Invoice'
+}
 
 sub cust_linked { $_[0]->cust_main_custnum; } 
 sub cust_unlinked_msg {
@@ -422,6 +428,25 @@ sub display_invnum {
   }
 }
 
+=item previous_bill
+
+Returns the customer's last invoice before this one.
+
+=cut
+
+sub previous_bill {
+  my $self = shift;
+  if ( !$self->get('previous_bill') ) {
+    $self->set('previous_bill', qsearchs({
+          'table'     => 'cust_bill',
+          'hashref'   => { 'custnum'  => $self->custnum,
+                           '_date'    => { op=>'<', value=>$self->_date } },
+          'order_by'  => 'ORDER BY _date DESC LIMIT 1',
+    }) );
+  }
+  $self->get('previous_bill');
+}
+
 =item previous
 
 Returns a list consisting of the total previous balance for this customer, 
@@ -1024,7 +1049,7 @@ Options:
 
 sender address, required
 
-=item tempate
+=item template
 
 alternate template name, optional
 
@@ -1058,15 +1083,10 @@ sub generate_email {
 
   my %return = (
     'from'      => $args{'from'},
-    'subject'   => (($args{'subject'}) ? $args{'subject'} : 'Invoice'),
+    'subject'   => ($args{'subject'} || $self->email_subject),
   );
 
-  my %opt = (
-    'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
-    'template'      => $args{'template'},
-    'notice_name'   => ( $args{'notice_name'} || 'Invoice' ),
-    'no_coupon'     => $args{'no_coupon'},
-  );
+  $args{'unsquelch_cdr'} = $conf->exists('voip-cdr_email');
 
   my $cust_main = $self->cust_main;
 
@@ -1108,7 +1128,7 @@ sub generate_email {
       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
         $data = $args{'print_text'};
       } else {
-        $data = [ $self->print_text(\%opt) ];
+        $data = [ $self->print_text(\%args) ];
       }
 
     }
@@ -1165,10 +1185,10 @@ sub generate_email {
           'Filename'   => 'barcode.png',
           'Content-ID' => "<$barcode_content_id>",
         ;
-        $opt{'barcode_cid'} = $barcode_content_id;
+        $args{'barcode_cid'} = $barcode_content_id;
       }
 
-      $htmldata = $self->print_html({ 'cid'=>$content_id, %opt });
+      $htmldata = $self->print_html({ 'cid'=>$content_id, %args });
     }
 
     $alternative->attach(
@@ -1230,7 +1250,7 @@ sub generate_email {
 
       $related->add_part($image) if $image;
 
-      my $pdf = build MIME::Entity $self->mimebuild_pdf(\%opt);
+      my $pdf = build MIME::Entity $self->mimebuild_pdf(\%args);
 
       $return{'mimeparts'} = [ $related, $pdf, @otherparts ];
 
@@ -1262,7 +1282,7 @@ sub generate_email {
 
       #mime parts arguments a la MIME::Entity->build().
       $return{'mimeparts'} = [
-        { $self->mimebuild_pdf(\%opt) }
+        { $self->mimebuild_pdf(\%args) }
       ];
     }
   
@@ -1282,7 +1302,7 @@ sub generate_email {
       if ( ref($args{'print_text'}) eq 'ARRAY' ) {
         $return{'body'} = $args{'print_text'};
       } else {
-        $return{'body'} = [ $self->print_text(\%opt) ];
+        $return{'body'} = [ $self->print_text(\%args) ];
       }
 
     }
@@ -1311,105 +1331,48 @@ sub mimebuild_pdf {
   );
 }
 
-=item send HASHREF | [ TEMPLATE [ , AGENTNUM [ , INVOICE_FROM [ , AMOUNT ] ] ] ]
+=item send HASHREF
 
 Sends this invoice to the destinations configured for this customer: sends
 email, prints and/or faxes.  See L<FS::cust_main_invoice>.
 
-Options can be passed as a hashref (recommended) or as a list of up to 
-four values for templatename, agentnum, invoice_from and amount.
+Options can be passed as a hashref.  Positional parameters are no longer
+allowed.
 
-I<template>, if specified, is the name of a suffix for alternate invoices.
+I<template>: a suffix for alternate invoices
 
-I<agentnum>, if specified, means that this invoice will only be sent for customers
-of the specified agent or agent(s).  AGENTNUM can be a scalar agentnum (for a
-single agent) or an arrayref of agentnums.
+I<agentnum>: obsolete, now does nothing.
 
-I<invoice_from>, if specified, overrides the default email invoice From: address.
+I<invoice_from> overrides the default email invoice From: address.
 
-I<amount>, if specified, only sends the invoice if the total amount owed on this
-invoice and all older invoices is greater than the specified amount.
+I<amount>: obsolete, does nothing
 
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+I<notice_name> overrides "Invoice" as the name of the sent document 
+(templates from 10/2009 or newer required).
 
-I<lpr>, if specified, is passed to 
+I<lpr> overrides the system 'lpr' option as the command to print a document
+from standard input.
 
 =cut
 
-sub queueable_send {
-  my %opt = @_;
-
-  my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
-    or die "invalid invoice number: " . $opt{invnum};
-
-  my @args = ( $opt{template}, $opt{agentnum} );
-  push @args, $opt{invoice_from}
-    if exists($opt{invoice_from}) && $opt{invoice_from};
-
-  my $error = $self->send( @args );
-  die $error if $error;
-
-}
-
 sub send {
   my $self = shift;
+  my $opt = ref($_[0]) ? $_[0] : +{ @_ };
   my $conf = $self->conf;
 
-  my( $template, $invoice_from, $notice_name );
-  my $agentnums = '';
-  my $balance_over = 0;
-  my $lpr = '';
-
-  if ( ref($_[0]) ) {
-    my $opt = shift;
-    $template = $opt->{'template'} || '';
-    if ( $agentnums = $opt->{'agentnum'} ) {
-      $agentnums = [ $agentnums ] unless ref($agentnums);
-    }
-    $invoice_from = $opt->{'invoice_from'};
-    $balance_over = $opt->{'balance_over'} if $opt->{'balance_over'};
-    $notice_name = $opt->{'notice_name'};
-    $lpr = $opt->{'lpr'}
-  } else {
-    $template = scalar(@_) ? shift : '';
-    if ( scalar(@_) && $_[0]  ) {
-      $agentnums = ref($_[0]) ? shift : [ shift ];
-    }
-    $invoice_from = shift if scalar(@_);
-    $balance_over = shift if scalar(@_) && $_[0] !~ /^\s*$/;
-  }
-
   my $cust_main = $self->cust_main;
 
-  return 'N/A' unless ! $agentnums
-                   or grep { $_ == $cust_main->agentnum } @$agentnums;
-
-  return ''
-    unless $cust_main->total_owed_date($self->_date) > $balance_over;
-
-  $invoice_from ||= $self->_agent_invoice_from ||    #XXX should go away
-                    $conf->config('invoice_from', $cust_main->agentnum );
-
-  my %opt = (
-    'template'     => $template,
-    'invoice_from' => $invoice_from,
-    'notice_name'  => ( $notice_name || 'Invoice' ),
-  );
-
   my @invoicing_list = $cust_main->invoicing_list;
 
-  #$self->email_invoice(\%opt)
-  $self->email(\%opt)
+  $self->email($opt)
     if ( grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list or !@invoicing_list )
     && ! $self->invoice_noemail;
 
-  $opt{'lpr'} = $lpr;
-  #$self->print_invoice(\%opt)
-  $self->print(\%opt)
+  $self->print($opt)
     if grep { $_ eq 'POST' } @invoicing_list; #postal
 
   #this has never been used post-$ORIGINAL_ISP afaik
-  $self->fax_invoice(\%opt)
+  $self->fax_invoice($opt)
     if grep { $_ eq 'FAX' } @invoicing_list; #fax
 
   '';
@@ -1418,16 +1381,17 @@ sub send {
 
 =item email HASHREF | [ TEMPLATE [ , INVOICE_FROM ] ] 
 
-Emails this invoice.
+Sends this invoice to the customer's email destination(s).
 
-Options can be passed as a hashref (recommended) or as a list of up to 
-two values for templatename and invoice_from.
+Options must be passed as a hashref.  Positional parameters are no longer
+allowed.
 
 I<template>, if specified, is the name of a suffix for alternate invoices.
 
-I<invoice_from>, if specified, overrides the default email invoice From: address.
+I<invoice_from>, if specified, overrides the default email invoice From: 
+address.
 
-I<notice_name>, if specified, overrides "Invoice" as the name of the sent document (templates from 10/2009 or newer required)
+I<notice_name> is the name of the sent document.
 
 =cut
 
@@ -1437,38 +1401,30 @@ sub queueable_email {
   my $self = qsearchs('cust_bill', { 'invnum' => $opt{invnum} } )
     or die "invalid invoice number: " . $opt{invnum};
 
-  my %args = ( 'template' => $opt{template} );
-  $args{$_} = $opt{$_}
-    foreach grep { exists($opt{$_}) && $opt{$_} }
-              qw( invoice_from notice_name no_coupon );
+  my %args = map {$_ => $opt{$_}} 
+             grep { $opt{$_} }
+              qw( invoice_from notice_name no_coupon template );
 
   my $error = $self->email( \%args );
   die $error if $error;
 
 }
 
-#sub email_invoice {
 sub email {
   my $self = shift;
   return if $self->hide;
   my $conf = $self->conf;
-
-  my( $template, $invoice_from, $notice_name, $no_coupon );
-  if ( ref($_[0]) ) {
-    my $opt = shift;
-    $template = $opt->{'template'} || '';
-    $invoice_from = $opt->{'invoice_from'};
-    $notice_name = $opt->{'notice_name'} || 'Invoice';
-    $no_coupon = $opt->{'no_coupon'} || 0;
-  } else {
-    $template = scalar(@_) ? shift : '';
-    $invoice_from = shift if scalar(@_);
-    $notice_name = 'Invoice';
-    $no_coupon = 0;
+  my $opt = shift;
+  if ($opt and !ref($opt)) {
+    die "FS::cust_bill::email called with positional parameters";
   }
 
-  $invoice_from ||= $self->_agent_invoice_from ||    #XXX should go away
-                    $conf->config('invoice_from', $self->cust_main->agentnum );
+  my $template = $opt->{template};
+  my $from = delete $opt->{invoice_from};
+
+  # this is where we set the From: address
+  $from ||= $self->_agent_invoice_from ||    #XXX should go away
+            $conf->config('invoice_from', $self->cust_main->agentnum );
 
   my @invoicing_list = grep { $_ !~ /^(POST|FAX)$/ } 
                             $self->cust_main->invoicing_list;
@@ -1478,20 +1434,19 @@ sub email {
       die 'No recipients for customer #'. $self->custnum;
     } else {
       #default: better to notify this person than silence
-      @invoicing_list = ($invoice_from);
+      @invoicing_list = ($from);
     }
   }
 
+  # this is where we set the Subject:
   my $subject = $self->email_subject($template);
 
   my $error = send_email(
     $self->generate_email(
-      'from'        => $invoice_from,
+      'from'        => $from,
       'to'          => [ grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ],
       'subject'     => $subject,
-      'template'    => $template,
-      'notice_name' => $notice_name,
-      'no_coupon'   => $no_coupon,
+      %$opt, # template, etc.
     )
   );
   die "can't email invoice: $error\n" if $error;
@@ -1518,12 +1473,12 @@ sub email_subject {
   eval qq("$subject");
 }
 
-=item lpr_data HASHREF | [ TEMPLATE ]
+=item lpr_data HASHREF
 
 Returns the postscript or plaintext for this invoice as an arrayref.
 
-Options can be passed as a hashref (recommended) or as a single optional value
-for template.
+Options must be passed as a hashref.  Positional parameters are no longer 
+allowed.
 
 I<template>, if specified, is the name of a suffix for alternate invoices.
 
@@ -1534,31 +1489,21 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 sub lpr_data {
   my $self = shift;
   my $conf = $self->conf;
-  my( $template, $notice_name );
-  if ( ref($_[0]) ) {
-    my $opt = shift;
-    $template = $opt->{'template'} || '';
-    $notice_name = $opt->{'notice_name'} || 'Invoice';
-  } else {
-    $template = scalar(@_) ? shift : '';
-    $notice_name = 'Invoice';
+  my $opt = shift;
+  if ($opt and !ref($opt)) {
+    # nobody does this anyway
+    die "FS::cust_bill::lpr_data called with positional parameters";
   }
 
-  my %opt = (
-    'template'    => $template,
-    'notice_name' => $notice_name,
-  );
-
   my $method = $conf->exists('invoice_latex') ? 'print_ps' : 'print_text';
-  [ $self->$method( \%opt ) ];
+  [ $self->$method( $opt ) ];
 }
 
-=item print HASHREF | [ TEMPLATE ]
+=item print HASHREF
 
 Prints this invoice.
 
-Options can be passed as a hashref (recommended) or as a single optional
-value for template.
+Options must be passed as a hashref.
 
 I<template>, if specified, is the name of a suffix for alternate invoices.
 
@@ -1566,48 +1511,34 @@ I<notice_name>, if specified, overrides "Invoice" as the name of the sent docume
 
 =cut
 
-#sub print_invoice {
 sub print {
   my $self = shift;
   return if $self->hide;
   my $conf = $self->conf;
-
-  my( $template, $notice_name, $lpr );
-  if ( ref($_[0]) ) {
-    my $opt = shift;
-    $template = $opt->{'template'} || '';
-    $notice_name = $opt->{'notice_name'} || 'Invoice';
-    $lpr = $opt->{'lpr'}
-  } else {
-    $template = scalar(@_) ? shift : '';
-    $notice_name = 'Invoice';
-    $lpr = '';
+  my $opt = shift;
+  if ($opt and !ref($opt)) {
+    die "FS::cust_bill::print called with positional parameters";
   }
 
-  my %opt = (
-    'template'    => $template,
-    'notice_name' => $notice_name,
-  );
-
+  my $lpr = delete $opt->{lpr};
   if($conf->exists('invoice_print_pdf')) {
     # Add the invoice to the current batch.
-    $self->batch_invoice(\%opt);
+    $self->batch_invoice($opt);
   }
   else {
     do_print(
-      $self->lpr_data(\%opt),
+      $self->lpr_data($opt),
       'agentnum' => $self->cust_main->agentnum,
       'lpr'      => $lpr,
     );
   }
 }
 
-=item fax_invoice HASHREF | [ TEMPLATE ] 
+=item fax_invoice HASHREF
 
 Faxes this invoice.
 
-Options can be passed as a hashref (recommended) or as a single optional
-value for template.
+Options must be passed as a hashref.
 
 I<template>, if specified, is the name of a suffix for alternate invoices.
 
@@ -1619,15 +1550,9 @@ sub fax_invoice {
   my $self = shift;
   return if $self->hide;
   my $conf = $self->conf;
-
-  my( $template, $notice_name );
-  if ( ref($_[0]) ) {
-    my $opt = shift;
-    $template = $opt->{'template'} || '';
-    $notice_name = $opt->{'notice_name'} || 'Invoice';
-  } else {
-    $template = scalar(@_) ? shift : '';
-    $notice_name = 'Invoice';
+  my $opt = shift;
+  if ($opt and !ref($opt)) {
+    die "FS::cust_bill::fax_invoice called with positional parameters";
   }
 
   die 'FAX invoice destination not (yet?) supported with plain text invoices.'
@@ -1636,12 +1561,7 @@ sub fax_invoice {
   my $dialstring = $self->cust_main->getfield('fax');
   #Check $dialstring?
 
-  my %opt = (
-    'template'    => $template,
-    'notice_name' => $notice_name,
-  );
-
-  my $error = send_fax( 'docdata'    => $self->lpr_data(\%opt),
+  my $error = send_fax( 'docdata'    => $self->lpr_data($opt),
                         'dialstring' => $dialstring,
                       );
   die $error if $error;
@@ -1730,29 +1650,6 @@ sub spool_invoice {
   );
 }
 
-=item send_if_newest [ TEMPLATENAME [ , AGENTNUM [ , INVOICE_FROM ] ] ]
-
-Like B<send>, but only sends the invoice if it is the newest open invoice for
-this customer.
-
-=cut
-
-sub send_if_newest {
-  my $self = shift;
-
-  return ''
-    if scalar(
-               grep { $_->owed > 0 } 
-                    qsearch('cust_bill', {
-                      'custnum' => $self->custnum,
-                      #'_date'   => { op=>'>', value=>$self->_date },
-                      'invnum'  => { op=>'>', value=>$self->invnum },
-                    } )
-             );
-    
-  $self->send(@_);
-}
-
 =item send_csv OPTION => VALUE, ...
 
 Sends invoice as a CSV data-file to a remote host with the specified protocol.
@@ -3109,12 +3006,25 @@ sub _items_credits {
 
   my @b;
   #credits
-  foreach ( $self->cust_credited ) {
+  my @objects;
+  if ( $self->conf->exists('previous_balance-payments_since') ) {
+    my $date = 0;
+    $date = $self->previous_bill->_date if $self->previous_bill;
+    @objects = qsearch('cust_credit', {
+        'custnum' => $self->custnum,
+        '_date'   => {op => '>=', value => $date},
+      });
+      # hard to do this in the qsearch...
+    @objects = grep { $_->_date < $self->_date } @objects;
+  } else {
+    @objects = $self->cust_credited;
+  }
 
-    #something more elaborate if $_->amount ne $_->cust_credit->credited ?
+  foreach my $obj ( @objects ) {
+    my $cust_credit = $obj->isa('FS::cust_credit') ? $obj : $obj->cust_credit;
 
-    my $reason = substr($_->cust_credit->reason, 0, $trim_len);
-    $reason .= '...' if length($reason) < length($_->cust_credit->reason);
+    my $reason = substr($cust_credit->reason, 0, $trim_len);
+    $reason .= '...' if length($reason) < length($cust_credit->reason);
     $reason = " ($reason) " if $reason;
 
     push @b, {
@@ -3122,8 +3032,8 @@ sub _items_credits {
       #                 " (". time2str("%x",$_->cust_credit->_date) .")".
       #                 $reason,
       'description' => $self->mt('Credit applied').' '.
-                       time2str($date_format,$_->cust_credit->_date). $reason,
-      'amount'      => sprintf("%.2f",$_->amount),
+                       time2str($date_format,$obj->_date). $reason,
+      'amount'      => sprintf("%.2f",$obj->amount),
     };
   }
 
@@ -3135,21 +3045,31 @@ sub _items_payments {
   my $self = shift;
 
   my @b;
-  #get & print payments
-  foreach ( $self->cust_bill_pay ) {
-
-    #something more elaborate if $_->amount ne ->cust_pay->paid ?
+  my $detailed = $self->conf->exists('invoice_payment_details');
+  my @objects;
+  if ( $self->conf->exists('previous_balance-payments_since') ) {
+    my $date = 0;
+    $date = $self->previous_bill->_date if $self->previous_bill;
+    @objects = qsearch('cust_pay', {
+        'custnum' => $self->custnum,
+        '_date'   => {op => '>=', value => $date},
+      });
+    @objects = grep { $_->_date < $self->_date } @objects;
+  } else {
+    @objects = $self->cust_bill_pay;
+  }
 
+  foreach my $obj (@objects) {
+    my $cust_pay = $obj->isa('FS::cust_pay') ? $obj : $obj->cust_pay;
     my $desc = $self->mt('Payment received').' '.
-               time2str($date_format,$_->cust_pay->_date );
-    $desc   .= $self->mt(' via ' . $_->cust_pay->payby_payinfo_pretty)
-      if ( $self->conf->exists('invoice_payment_details') );
+               time2str($date_format, $cust_pay->_date );
+    $desc .= $self->mt(' via ' . $cust_pay->payby_payinfo_pretty)
+      if $detailed;
+
     push @b, {
       'description' => $desc,
-      'amount'      => sprintf("%.2f", $_->amount )
+      'amount'      => sprintf("%.2f", $obj->amount )
     };
-
   }
 
   @b;
index b98ade1..11e97ec 100644 (file)
@@ -10,6 +10,8 @@ use FS::Conf;
 use FS::prospect_main;
 use FS::cust_main;
 use FS::cust_main_county;
+use FS::GeocodeCache;
+use Date::Format qw( time2str );
 
 $import = 0;
 
@@ -677,6 +679,13 @@ sub process_censustract_update {
   return;
 }
 
+=item process_set_coord
+
+Queueable function to find and fill in coordinates for all locations that 
+lack them.  Because this uses the Google Maps API, it's internally rate
+limited and must run in a single process.
+
+=cut
 
 sub process_set_coord {
   my $job = shift;
@@ -716,6 +725,67 @@ sub process_set_coord {
   return;
 }
 
+=item process_standardize [ LOCATIONNUMS ]
+
+Performs address standardization on locations with unclean addresses,
+using whatever method you have configured.  If the standardize_* method 
+returns a I<clean> address match, the location will be updated.  This is 
+always an in-place update (because the physical location is the same, 
+and is just being referred to by a more accurate name).
+
+Disabled locations will be skipped, as nobody cares.
+
+If any LOCATIONNUMS are provided, only those locations will be updated.
+
+=cut
+
+sub process_standardize {
+  my $job = shift;
+  my @others = qsearch('queue', {
+      'status'  => 'locked',
+      'job'     => $job->job,
+      'jobnum'  => {op=>'!=', value=>$job->jobnum},
+  });
+  return if @others;
+  my @locationnums = grep /^\d+$/, @_;
+  my $where = "AND locationnum IN(".join(',',@locationnums).")"
+    if scalar(@locationnums);
+  my @locations = qsearch({
+      table     => 'cust_location',
+      hashref   => { addr_clean => '', disabled => '' },
+      extra_sql => $where,
+  });
+  my $n_todo = scalar(@locations);
+  my $n_done = 0;
+
+  # special: log this
+  my $log;
+  eval "use Text::CSV";
+  open $log, '>', "$FS::UID::cache_dir/process_standardize-" . 
+                  time2str('%Y%m%d',time) .
+                  ".csv";
+  my $csv = Text::CSV->new({binary => 1, eol => "\n"});
+
+  foreach my $cust_location (@locations) {
+    $job->update_statustext( int(100 * $n_done/$n_todo) . ",$n_done / $n_todo locations" ) if $job;
+    my $result = FS::GeocodeCache->standardize($cust_location);
+    if ( $result->{addr_clean} and !$result->{error} ) {
+      my @cols = ($cust_location->locationnum);
+      foreach (keys %$result) {
+        push @cols, $cust_location->get($_), $result->{$_};
+        $cust_location->set($_, $result->{$_});
+      }
+      # bypass immutable field restrictions
+      my $error = $cust_location->FS::Record::replace;
+      warn "location ".$cust_location->locationnum.": $error\n" if $error;
+      $csv->print($log, \@cols);
+    }
+    $n_done++;
+    dbh->commit; # so that we can resume if interrupted
+  }
+  close $log;
+}
+
 =head1 BUGS
 
 =head1 SEE ALSO
index 641d54a..a9a4cb0 100644 (file)
@@ -17,6 +17,7 @@ use vars qw( $DEBUG $me $conf
              @encrypted_fields
              $import
              $ignore_expired_card $ignore_banned_card $ignore_illegal_zip
+             $ignore_invalid_card
              $skip_fuzzyfiles
              @paytypes
            );
@@ -89,6 +90,7 @@ $me = '[FS::cust_main]';
 $import = 0;
 $ignore_expired_card = 0;
 $ignore_banned_card = 0;
+$ignore_invalid_card = 0;
 
 $skip_fuzzyfiles = 0;
 
@@ -102,6 +104,7 @@ sub nohistory_fields { ('payinfo', 'paycvv'); }
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
   #yes, need it for stuff below (prolly should be cached)
+  $ignore_invalid_card = $conf->exists('allow_invalid_cards');
 };
 
 sub _cache {
@@ -1826,7 +1829,8 @@ sub check {
 
   # Need some kind of global flag to accept invalid cards, for testing
   # on scrubbed data.
-  if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
+  if ( !$import && !$ignore_invalid_card && $check_payinfo && 
+    $self->payby =~ /^(CARD|DCRD)$/ ) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
@@ -1898,7 +1902,8 @@ sub check {
       $self->payissue('');
     }
 
-  } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
+  } elsif ( !$ignore_invalid_card && $check_payinfo && 
+    $self->payby =~ /^(CHEK|DCHK)$/ ) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/[^\d\@\.]//g;
@@ -4071,6 +4076,16 @@ sub ship_contact_firstlast {
 #  code2country($self->country);
 #}
 
+sub bill_country_full {
+  my $self = shift;
+  code2country($self->bill_location->country);
+}
+
+sub ship_country_full {
+  my $self = shift;
+  code2country($self->ship_location->country);
+}
+
 =item county_state_county [ PREFIX ]
 
 Returns a string consisting of just the county, state and country.
@@ -4910,9 +4925,9 @@ sub queueable_print {
   my %opt = @_;
 
   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
-    or die "invalid customer number: " . $opt{custvnum};
+    or die "invalid customer number: " . $opt{custnum};
 
-  my $error = $self->print( $opt{template} );
+  my $error = $self->print( { 'template' => $opt{template} } );
   die $error if $error;
 }
 
@@ -5145,7 +5160,7 @@ sub _upgrade_data { #class method
 
     };
 
-    FS::upgrade_journal->set_done('cust_main__trimspaces');
+    FS::upgrade_journal->set_done('cust_main__cust_payby');
   }
 
   $class->_upgrade_otaker(%opts);
index f14f897..182527f 100644 (file)
@@ -610,14 +610,24 @@ sub search {
   ##
   # address
   ##
-  if ( $params->{'address'} =~ /\S/ ) {
-    my $address = dbh->quote('%'. lc($params->{'address'}). '%');
-    push @where, "EXISTS(
-      SELECT 1 FROM cust_location 
-      WHERE cust_location.custnum = cust_main.custnum
-        AND (LOWER(cust_location.address1) LIKE $address OR
-             LOWER(cust_location.address2) LIKE $address)
-    )";
+  if ( $params->{'address'} ) {
+    # allow this to be an arrayref
+    my @values = ($params->{'address'});
+    @values = @{$values[0]} if ref($values[0]);
+    my @orwhere;
+    foreach (grep /\S/, @values) {
+      my $address = dbh->quote('%'. lc($_). '%');
+      push @orwhere,
+        "LOWER(cust_location.address1) LIKE $address",
+        "LOWER(cust_location.address2) LIKE $address";
+    }
+    if (@orwhere) {
+      push @where, "EXISTS(
+        SELECT 1 FROM cust_location 
+        WHERE cust_location.custnum = cust_main.custnum
+          AND (".join(' OR ',@orwhere).")
+        )";
+    }
   }
 
   ##
index 212c04e..f584b41 100644 (file)
@@ -131,9 +131,12 @@ linked to a customer.
 
 sub country_full {
   my $self = shift;
-  $self->cust_linked
-    ? FS::cust_main::country_full($self)
-    : $self->cust_unlinked_msg;
+  if ( $self->locationnum ) {  # cust_pkg has this
+    my $location = FS::cust_location->by_key($self->locationnum);
+    $location ? $location->country_full : '';
+  } elsif ( $self->cust_linked ) {
+    $self->cust_main->bill_country_full;
+  }
 }
 
 =item invoicing_list_emailonly
index a61d67e..10a007c 100644 (file)
@@ -244,7 +244,7 @@ are inserted.
 In addition to calculating the tax for the line items, this will calculate
 any appropriate tax exemptions and attach them to the line items.
 
-Options may include 'custnum' and 'invoice_date' in case the cust_bill_pkg
+Options may include 'custnum' and 'invoice_time' in case the cust_bill_pkg
 objects belong to an invoice that hasn't been inserted yet.
 
 Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
@@ -276,7 +276,7 @@ sub taxline {
 
   my $cust_bill = $taxables->[0]->cust_bill;
   my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
-  my $invoice_date = $cust_bill ? $cust_bill->_date : $opt{'invoice_date'};
+  my $invoice_time = $cust_bill ? $cust_bill->_date : $opt{'invoice_time'};
   my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
   if (!$cust_main) {
     # better way to handle this?  should we just assume that it's taxable?
@@ -364,22 +364,36 @@ sub taxline {
   
     if ( $self->exempt_amount && $self->exempt_amount > 0 
       and $taxable_charged > 0 ) {
-      #my ($mon,$year) = (localtime($cust_bill_pkg->sdate) )[4,5];
-      my ($mon,$year) =
-        (localtime( $cust_bill_pkg->sdate || $invoice_date ) )[4,5];
-      $mon++;
-      $year += 1900;
-      my $freq = $cust_bill_pkg->freq;
-      unless ($freq) {
-        $freq = $part_pkg->freq || 1;  # less trustworthy fallback
-      }
-      if ( $freq !~ /(\d+)$/ ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "daily/weekly package definitions not (yet?)".
-               " compatible with monthly tax exemptions";
+      # If the billing period extends across multiple calendar months, 
+      # there may be several months of exemption available.
+      my $sdate = $cust_bill_pkg->sdate || $invoice_time;
+      my $start_month = (localtime($sdate))[4] + 1;
+      my $start_year  = (localtime($sdate))[5] + 1900;
+      my $edate = $cust_bill_pkg->edate || $invoice_time;
+      my $end_month   = (localtime($edate))[4] + 1;
+      my $end_year    = (localtime($edate))[5] + 1900;
+
+      # If the partial last month + partial first month <= one month,
+      # don't use the exemption in the last month
+      # (unless the last month is also the first month, e.g. one-time
+      # charges)
+      if ( (localtime($sdate))[3] >= (localtime($edate))[3]
+           and ($start_month != $end_month or $start_year != $end_year)
+      ) { 
+        $end_month--;
+        if ( $end_month == 0 ) {
+          $end_year--;
+          $end_month = 12;
+        }
       }
-      my $taxable_per_month =
-        sprintf("%.2f", $taxable_charged / $freq );
+
+      # number of months of exemption available
+      my $freq = ($end_month - $start_month) +
+                 ($end_year  - $start_year) * 12 +
+                 1;
+
+      # divide equally among all of them
+      my $permonth = sprintf('%.2f', $taxable_charged / $freq);
 
       #call the whole thing off if this customer has any old
       #exemption records...
@@ -392,9 +406,15 @@ sub taxline {
           'run bin/fs-migrate-cust_tax_exempt?';
       }
 
-      foreach my $which_month ( 1 .. $freq ) {
-  
-        #maintain the new exemption table now
+      my ($mon, $year) = ($start_month, $start_year);
+      while ($taxable_charged > 0.005 and 
+             ($year < $end_year or
+               ($year == $end_year and $mon <= $end_month)
+             )
+      ) {
+        # find the sum of the exemption used by this customer, for this tax,
+        # in this month
         my $sql = "
           SELECT SUM(amount)
             FROM cust_tax_exempt_pkg
@@ -408,7 +428,7 @@ sub taxline {
         ";
         my $sth = dbh->prepare($sql) or do {
           $dbh->rollback if $oldAutoCommit;
-          return "fatal: can't lookup exising exemption: ". dbh->errstr;
+          return "fatal: can't lookup existing exemption: ". dbh->errstr;
         };
         $sth->execute(
           $custnum,
@@ -417,10 +437,11 @@ sub taxline {
           $mon,
         ) or do {
           $dbh->rollback if $oldAutoCommit;
-          return "fatal: can't lookup exising exemption: ". dbh->errstr;
+          return "fatal: can't lookup existing exemption: ". dbh->errstr;
         };
         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
 
+        # add any exemption we're already using for another line item
         foreach ( grep { $_->taxnum == $self->taxnum &&
                          $_->exempt_monthly eq 'Y'   &&
                          $_->month  == $mon          &&
@@ -430,13 +451,15 @@ sub taxline {
         {
           $existing_exemption += $_->amount;
         }
-        
+
         my $remaining_exemption =
           $self->exempt_amount - $existing_exemption;
         if ( $remaining_exemption > 0 ) {
-          my $addl = $remaining_exemption > $taxable_per_month
-            ? $taxable_per_month
+          my $addl = $remaining_exemption > $permonth
+            ? $permonth
             : $remaining_exemption;
+          $addl = $taxable_charged if $addl > $taxable_charged;
+
           push @new_exemptions, FS::cust_tax_exempt_pkg->new({
               amount          => sprintf('%.2f', $addl),
               exempt_monthly  => 'Y',
@@ -445,7 +468,6 @@ sub taxline {
             });
           $taxable_charged -= $addl;
         }
-        last if $taxable_charged < 0.005;
         # if they're using multiple months of exemption for a multi-month
         # package, then record the exemptions in separate months
         $mon++;
@@ -454,7 +476,7 @@ sub taxline {
           $year++;
         }
 
-      } #foreach $which_month
+      }
     } # if exempt_amount
 
     $_->taxnum($self->taxnum) foreach @new_exemptions;
index 5914ab5..d78c574 100644 (file)
@@ -7,9 +7,11 @@ use FS::Record qw( qsearchs ); #qsearch;
 use FS::payby;
 use FS::cust_main;
 use Business::CreditCard qw( validate cardtype );
+use FS::Msgcat qw( gettext );
 
 use vars qw( $conf @encrypted_fields
              $ignore_expired_card $ignore_banned_card
+             $ignore_invalid_card
            );
 
 @encrypted_fields = ('payinfo', 'paycvv');
@@ -17,10 +19,12 @@ sub nohistory_fields { ('payinfo', 'paycvv'); }
 
 $ignore_expired_card = 0;
 $ignore_banned_card = 0;
+$ignore_invalid_card = 0;
 
 install_callback FS::UID sub { 
   $conf = new FS::Conf;
   #yes, need it for stuff below (prolly should be cached)
+  $ignore_invalid_card = $conf->exists('allow_invalid_cards');
 };
 
 =head1 NAME
@@ -197,7 +201,8 @@ sub check {
   # Need some kind of global flag to accept invalid cards, for testing
   # on scrubbed data.
   #XXX if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
-  if ( $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
+  if ( !$ignore_invalid_card && 
+    $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
@@ -269,7 +274,8 @@ sub check {
       $self->payissue('');
     }
 
-  } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
+  } elsif ( !$ignore_invalid_card && 
+    $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/[^\d\@\.]//g;
index 19ef1f3..0cb1b50 100644 (file)
@@ -2613,14 +2613,30 @@ sub part_pkg_currency_option {
 
 =item cust_svc [ OPTION => VALUE ... ] (current usage)
 
+=item cust_svc_unsorted [ OPTION => VALUE ... ] 
+
 Returns the services for this package, as FS::cust_svc objects (see
 L<FS::cust_svc>).  Available options are svcpart and svcdb.  If either is
 spcififed, returns only the matching services.
 
+As an optimization, use the cust_svc_unsorted version if you are not displaying
+the results.
+
 =cut
 
 sub cust_svc {
   my $self = shift;
+  cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
+  $self->_sort_cust_svc( $self->cust_svc_unsorted_arrayref );
+}
+
+sub cust_svc_unsorted {
+  my $self = shift;
+  @{ $self->cust_svc_unsorted_arrayref };
+}
+
+sub cust_svc_unsorted_arrayref {
+  my $self = shift;
 
   return () unless $self->num_cust_svc(@_);
 
@@ -2645,13 +2661,7 @@ sub cust_svc {
     $search{extra_sql} = ' AND svcdb = '. dbh->quote( $opt{'svcdb'} );
   }
 
-  cluck "cust_pkg->cust_svc called" if $DEBUG > 2;
-
-  #if ( $self->{'_svcnum'} ) {
-  #  values %{ $self->{'_svcnum'}->cache };
-  #} else {
-    $self->_sort_cust_svc( [ qsearch(\%search) ] );
-  #}
+  [ qsearch(\%search) ];
 
 }
 
index d6d7d4c..9582090 100644 (file)
@@ -113,6 +113,10 @@ my $rt_session;
 
 sub delete {
   my $self = shift;
+
+  my $cust_pkg = $self->cust_pkg;
+  my $custnum = $cust_pkg->custnum if $cust_pkg;
+
   my $error = $self->SUPER::delete;
   return $error if $error;
 
@@ -126,7 +130,15 @@ sub delete {
     $links->Limit(FIELD => 'Target', 
                   VALUE => 'freeside://freeside/cust_svc/'.$svcnum);
     while ( my $l = $links->Next ) {
-      my ($val, $msg) = $l->Delete;
+      my ($val, $msg);
+      if ( $custnum ) {
+        # re-link to point to the customer instead
+        ($val, $msg) =
+          $l->SetTarget('freeside://freeside/cust_main/'.$custnum);
+      } else {
+        # unlinked service
+        ($val, $msg) = $l->Delete;
+      }
       # can't do anything useful on error
       warn "error unlinking ticket $svcnum: $msg\n" if !$val;
     }
diff --git a/FS/FS/invoice_conf.pm b/FS/FS/invoice_conf.pm
new file mode 100644 (file)
index 0000000..043cab0
--- /dev/null
@@ -0,0 +1,274 @@
+package FS::invoice_conf;
+
+use strict;
+use base qw( FS::Record FS::Conf );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::invoice_conf - Object methods for invoice_conf records
+
+=head1 SYNOPSIS
+
+  use FS::invoice_conf;
+
+  $record = new FS::invoice_conf \%hash;
+  $record = new FS::invoice_conf { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::invoice_conf object represents a set of localized invoice 
+configuration values.  FS::invoice_conf inherits from FS::Record and FS::Conf,
+and supports the FS::Conf interface.  The following fields are supported:
+
+=over 4
+
+=item confnum - primary key
+
+=item modenum - L<FS::invoice_mode> foreign key
+
+=item locale - locale string (see L<FS::Locales>)
+
+=item notice_name - the title to display on the invoice
+
+=item subject - subject line of the email
+
+=item htmlnotes - "notes" section (HTML)
+
+=item htmlfooter - footer (HTML)
+
+=item htmlsummary - summary header, for invoices in summary format (HTML)
+
+=item htmlreturnaddress - return address (HTML)
+
+=item latexnotes - "notes" section (LaTeX)
+
+=item latexfooter - footer (LaTeX)
+
+=item latexsummary - summary header, for invoices in summary format (LaTeX)
+
+=item latexreturnaddress - return address (LaTeX)
+
+=item latexcoupon - payment coupon section (LaTeX)
+
+=item latexsmallfooter - footer for pages after the first (LaTeX)
+
+=item latextopmargin - top margin
+
+=item latexheadsep - distance from bottom of header to top of body
+
+=item latexaddresssep - distance from top of body to customer address
+
+=item latextextheight - maximum height of invoice body text
+
+=item latexextracouponspace - additional footer space to allow for coupon
+
+=item latexcouponfootsep - distance from bottom of coupon content to top
+of page footer
+
+=item latexcouponamountenclosedsep - distance from coupon balance line to
+"Amount Enclosed" box
+
+=item latexcoupontoaddresssep - distance from "Amount Enclosed" box to 
+coupon mailing address
+
+=item latexverticalreturnaddress - 'Y' to place the return address below 
+the company logo rather than beside it
+
+=item latexcouponaddcompanytoaddress - 'Y' to add the company name to the 
+address on the payment coupon
+
+=item logo_png - company logo, as a PNG, for HTML invoices
+
+=item logo_eps - company logo, as an EPS, for LaTeX invoices
+
+=item lpr - command to print the invoice (passed on stdin as a PDF)
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new invoice configuration.  To add it to the database, see 
+L<"insert">.
+
+=cut
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'invoice_conf'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# slightly special: you can insert/replace the invoice mode this way
+
+sub insert {
+  my $self = shift;
+  if (!$self->modenum) {
+    my $invoice_mode = FS::invoice_mode->new({
+        'modename' => $self->modename,
+        'agentnum' => $self->agentnum,
+    });
+    my $error = $invoice_mode->insert;
+    return $error if $error;
+    $self->set('modenum' => $invoice_mode->modenum);
+  } else {
+    my $invoice_mode = FS::invoice_mode->by_key($self->modenum);
+    my $changed = 0;
+    foreach (qw(agentnum modename)) {
+      $changed ||= ($invoice_mode->get($_) eq $self->get($_));
+      $invoice_mode->set($_, $self->get($_));
+    }
+    my $error = $invoice_mode->replace if $changed;
+    return $error if $error;
+  }
+  $self->SUPER::insert(@_);
+}
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+sub delete {
+  my $self = shift;
+  my $error = $self->FS::Record::delete; # not Conf::delete
+  return $error if $error;
+  my $invoice_mode = FS::invoice_mode->by_key($self->modenum);
+  if ( $invoice_mode and
+       FS::invoice_conf->count('modenum = '.$invoice_mode->modenum) == 0 ) {
+    $error = $invoice_mode->delete;
+    return $error if $error;
+  }
+  '';
+}
+
+=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.
+
+=cut
+
+sub replace {
+  my $self = shift;
+  my $error = $self->SUPER::replace(@_);
+  return $error if $error;
+
+  my $invoice_mode = FS::invoice_mode->by_key($self->modenum);
+  my $changed = 0;
+  foreach (qw(agentnum modename)) {
+    $changed ||= ($invoice_mode->get($_) eq $self->get($_));
+    $invoice_mode->set($_, $self->get($_));
+  }
+  $error = $invoice_mode->replace if $changed;
+  return $error if $error;
+}
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('confnum')
+    || $self->ut_number('modenum')
+    || $self->ut_textn('locale')
+    || $self->ut_anything('notice_name')
+    || $self->ut_anything('subject')
+    || $self->ut_anything('htmlnotes')
+    || $self->ut_anything('htmlfooter')
+    || $self->ut_anything('htmlsummary')
+    || $self->ut_anything('htmlreturnaddress')
+    || $self->ut_anything('latexnotes')
+    || $self->ut_anything('latexfooter')
+    || $self->ut_anything('latexsummary')
+    || $self->ut_anything('latexcoupon')
+    || $self->ut_anything('latexsmallfooter')
+    || $self->ut_anything('latexreturnaddress')
+    || $self->ut_textn('latextopmargin')
+    || $self->ut_textn('latexheadsep')
+    || $self->ut_textn('latexaddresssep')
+    || $self->ut_textn('latextextheight')
+    || $self->ut_textn('latexextracouponspace')
+    || $self->ut_textn('latexcouponfootsep')
+    || $self->ut_textn('latexcouponamountenclosedsep')
+    || $self->ut_textn('latexcoupontoaddresssep')
+    || $self->ut_flag('latexverticalreturnaddress')
+    || $self->ut_flag('latexcouponaddcompanytoaddress')
+    || $self->ut_anything('logo_png')
+    || $self->ut_anything('logo_eps')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+# hook _config to substitute our own values; let FS::Conf do the rest of 
+# the interface
+
+sub _config {
+  my $self = shift;
+  # if we fall back, we still want FS::Conf to respect our locale
+  $self->{locale} = $self->get('locale');
+  my ($key, $agentnum, $nodefault) = @_;
+  # some fields, but not all, start with invoice_
+  my $colname = $key;
+  if ( $key =~ /^invoice_(.*)$/ ) {
+    $colname = $1;
+  }
+  if ( length($self->get($colname)) ) {
+    return FS::conf->new({ 'name'   => $key,
+                           'value'  => $self->get($colname) });
+  } else {
+    return $self->FS::Conf::_config(@_);
+  }
+}
+
+# disambiguation
+sub set {
+  my $self = shift;
+  $self->FS::Record::set(@_);
+}
+
+sub exists {
+  my $self = shift;
+  $self->FS::Conf::exists(@_);
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Template_Mixin>, L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/invoice_mode.pm b/FS/FS/invoice_mode.pm
new file mode 100644 (file)
index 0000000..115dd44
--- /dev/null
@@ -0,0 +1,157 @@
+package FS::invoice_mode;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::invoice_conf;
+
+=head1 NAME
+
+FS::invoice_mode - Object methods for invoice_mode records
+
+=head1 SYNOPSIS
+
+  use FS::invoice_mode;
+
+  $record = new FS::invoice_mode \%hash;
+  $record = new FS::invoice_mode { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::invoice_mode object represents an invoice rendering style.  
+FS::invoice_mode inherits from FS::Record.  The following fields are 
+currently supported:
+
+=over 4
+
+=item modenum - primary key
+
+=item agentnum - the agent who owns this invoice mode (can be null)
+
+=item modename - descriptive name for internal use
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new invoice mode.  To add the object 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
+
+# the new method can be inherited from FS::Record, if a table method is defined
+
+sub table { 'invoice_mode'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+# the insert method can be inherited from FS::Record
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+# the delete method can be inherited from FS::Record
+
+=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.
+
+=cut
+
+# the replace method can be inherited from FS::Record
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+# the check method should currently be supplied - FS::Record contains some
+# data checking routines
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('modenum')
+    || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
+    || $self->ut_text('modename')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item invoice_conf [ LOCALE ]
+
+Returns the L<FS::invoice_conf> for this invoice mode, with the specified
+locale.  If there isn't one with that locale, returns the one with null 
+locale.  If that doesn't exist, returns nothing.
+
+=cut
+
+sub invoice_conf {
+  my $self = shift;
+  my $locale = shift;
+  my $invoice_conf;
+  if ( $locale ) {
+    $invoice_conf = qsearchs('invoice_conf', {
+        modenum => $self->modenum,
+        locale  => $locale,
+    });
+  }
+  $invoice_conf ||= qsearchs('invoice_conf', {
+      modenum => $self->modenum,
+      locale  => '',
+  });
+  $invoice_conf;
+}
+
+=item agent
+
+Returns the agent associated with this invoice mode, if any.
+
+=cut
+
+sub agent {
+  my $self = shift;
+  $self->agentnum ? FS::agent->by_key($self->agentnum) : '';
+}
+
+=back
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 1a3bca4..3331a4c 100644 (file)
@@ -9,14 +9,22 @@ sub eventtable_hashref {
   { 'cust_bill' => 1 };
 }
 
+sub option_fields {
+  (
+    'modenum' => {  label => 'Invoice mode',
+                    type  => 'select-invoice_mode',
+                 },
+  );
+}
+
 sub default_weight { 51; }
 
 sub do_action {
   my( $self, $cust_bill ) = @_;
 
-  #my $cust_main = $self->cust_main($cust_bill);
   my $cust_main = $cust_bill->cust_main;
 
+  $cust_bill->set('mode' => $self->option('modenum'));
   $cust_bill->email unless $cust_main->invoice_noemail;
 }
 
index 6b3e6f4..ea6e0aa 100644 (file)
@@ -9,6 +9,14 @@ sub eventtable_hashref {
   { 'cust_bill' => 1 };
 }
 
+sub option_fields {
+  (
+    'modenum' => {  label => 'Invoice mode',
+                    type  => 'select-invoice_mode',
+                 },
+  );
+}
+
 sub default_weight { 51; }
 
 sub do_action {
@@ -17,6 +25,7 @@ sub do_action {
   #my $cust_main = $self->cust_main($cust_bill);
   my $cust_main = $cust_bill->cust_main;
 
+  $cust_bill->set('mode' => $self->option('modenum'));
   $cust_bill->print;
 }
 
index 6b37f38..6c01d42 100644 (file)
@@ -9,6 +9,14 @@ sub eventtable_hashref {
   { 'cust_bill' => 1 };
 }
 
+sub option_fields {
+  (
+    'modenum' => {  label => 'Invoice mode',
+                    type  => 'select-invoice_mode'
+                 },
+  );
+}
+
 sub default_weight { 51; }
 
 sub do_action {
@@ -20,6 +28,7 @@ sub do_action {
   my $opt = { $self->options };
   $opt->{'notice_name'} ||= 'Invoice';
 
+  $cust_bill->set('mode' => $self->option('modenum'));
   $cust_bill->batch_invoice($opt);
 }
 
index 587a7c6..c6928dc 100644 (file)
@@ -9,11 +9,20 @@ sub eventtable_hashref {
   { 'cust_bill' => 1 };
 }
 
+sub option_fields {
+  (
+    'modenum'     => { label  => 'Invoice mode',
+                       type   => 'select-invoice_mode',
+                     },
+  );
+}
+
 sub default_weight { 50; }
 
 sub do_action {
   my( $self, $cust_bill ) = @_;
 
+  $cust_bill->set('mode' => $self->option('modenum'));
   $cust_bill->send;
 }
 
index 670a32c..bbb757b 100644 (file)
@@ -7,6 +7,9 @@ sub description {
   'Send invoice (email/print/fax) with alternate template, for specific agents';
 }
 
+# this event is just cust_bill_send_alternate + an implicit (and inefficient)
+# 'agent' condition
+
 sub eventtable_hashref {
   { 'cust_bill' => 1 };
 }
@@ -17,6 +20,9 @@ sub option_fields {
                               type     => 'select-agent',
                               multiple => 1
                             },
+    'modenum' => {  label => 'Invoice mode',
+                    type  => 'select-invoice_mode',
+                 },
     'agent_templatename' => { label    => 'Template',
                               type     => 'select-invoice_template',
                             },
@@ -32,10 +38,15 @@ sub do_action {
   #my $cust_main = $self->cust_main($cust_bill);
   my $cust_main = $cust_bill->cust_main;
 
+  my %agentnums = map { $_=>1 } split(/\s*,\s*/, $self->option('agentnum'));
+  if (keys(%agentnums) and !exists($agentnums{$cust_main->agentnum})) {
+    return;
+  }
+
+  $cust_bill->set('mode' => $self->option('modenum'));
   $cust_bill->send(
-    $self->option('agent_templatename'),
-    [ split(/\s*,\s*/, $self->option('agentnum') ) ],
-    $self->option('agent_invoice_from'),
+    'template'      => $self->option('agent_templatename'),
+    'invoice_from'  => $self->option('agent_invoice_from'),
   );
 }
 
index cfd9264..fb71a5a 100644 (file)
@@ -11,6 +11,8 @@ sub eventtable_hashref {
 
 sub option_fields {
   (
+    'modenum'      => { label    => 'Invoice mode',
+                        type     => 'select-invoice_mode' },
     'templatename' => { label    => 'Template',
                         type     => 'select-invoice_template',
                       },
@@ -25,7 +27,8 @@ sub do_action {
   #my $cust_main = $self->cust_main($cust_bill);
   my $cust_main = $cust_bill->cust_main;
 
-  $cust_bill->send( $self->option('templatename') );
+  $cust_bill->set('mode' => $self->option('modenum'));
+  $cust_bill->send({'template' => $self->option('templatename')});
 }
 
 1;
index 083da8b..c744362 100644 (file)
@@ -18,6 +18,9 @@ sub eventtable_hashref {
 
 sub option_fields {
   (
+    'modenum' => {  label => 'Invoice mode',
+                    type  => 'select-invoice_mode',
+                 },
     'if_newest_templatename' => { label    => 'Template',
                                   type     => 'select-invoice_template',
                                 },
@@ -29,10 +32,17 @@ sub default_weight { 50; }
 sub do_action {
   my( $self, $cust_bill ) = @_;
 
-  #my $cust_main = $self->cust_main($cust_bill);
-  my $cust_main = $cust_bill->cust_main;
-
-  $cust_bill->send( $self->option('templatename') );
+  my $invnum = $cust_bill->invnum;
+  my $custnum = $cust_bill->custnum;
+  return '' if scalar(
+    grep { $_->owed > 0 }
+      qsearch('cust_bill', {
+          'custnum' => $custnum,
+          'invnum'  => { op=>'>', value=>$invnum },
+        })
+    );
+  $cust_bill->set('mode' => $self->option('modenum'));
+  $cust_bill->send( 'template' => $self->option('templatename') );
 }
 
 1;
index 073bb8f..354f969 100644 (file)
@@ -11,9 +11,13 @@ sub eventtable_hashref {
 
 sub option_fields {
   (
+    'modenum' => {  label => 'Invoice mode',
+                    type  => 'select-invoice_mode',
+                 },
+    # totally unnecessary, since the invoice mode can set notice_name and lpr,
+    # but for compatibility...
     'notice_name' => 'Reminder name',
-    #'notes'      => { 'label' => 'Reminder notes' }, 
-    #include standard notes?  no/prepend/append
+    #'notes'      => { 'label' => 'Reminder notes' },  # invoice mode does this
     'lpr'         => 'Optional alternate print command',
   );
 }
@@ -23,9 +27,7 @@ sub default_weight { 50; }
 sub do_action {
   my( $self, $cust_bill ) = @_;
 
-  #my $cust_main = $self->cust_main($cust_bill);
-  #my $cust_main = $cust_bill->cust_main;
-
+  $cust_bill->set('mode' => $self->option('modenum'));
   $cust_bill->send({
     'notice_name' => $self->option('notice_name'),
     'lpr'         => $self->option('lpr'),
index 74cc48c..67a94aa 100644 (file)
@@ -19,7 +19,8 @@ sub default_weight {
 sub do_action {
   my( $self, $cust_statement ) = @_;
 
-  $cust_statement->send( 'statement' ); #XXX configure
+  $cust_statement->send( 'template' => 'statement' ); #XXX configure
+                                                      #XXX use an invoice mode?
 
 }
 
index cd9e200..c2b4673 100644 (file)
@@ -32,7 +32,48 @@ sub _calc_fee {
     if ( $balance >= 0 ) {
       return 0;
     } elsif ( (-1 * $balance) < $self->option('charge') ) {
-      return -1 * $balance;
+      my $total = -1 * $balance;
+      # if it's tax exempt, then we're done
+      # XXX we also bail out if you're using external tax tables, because
+      # they're definitely NOT linear and we haven't yet had a reason to 
+      # make that case work.
+      return $total if $self->option('setuptax') eq 'Y'
+                    or FS::Conf->new->exists('enable_taxproducts');
+
+      # estimate tax rate
+      # false laziness with xmlhttp-calculate_taxes, cust_main::Billing, etc.
+      # XXX not accurate with monthly exemptions
+      my $cust_main = $cust_object->cust_main;
+      my $taxlisthash = {};
+      my $charge = FS::cust_bill_pkg->new({
+          setup => $total,
+          recur => 0,
+          details => []
+      });
+      my $part_pkg = FS::part_pkg->new({
+          taxclass => $self->option('taxclass')
+      });
+      my $error = $cust_main->_handle_taxes(
+        FS::part_pkg->new({ taxclass => ($self->option('taxclass') || '') }),
+        $taxlisthash,
+        $charge,
+        FS::cust_pkg->new({custnum => $cust_main->custnum}),
+      );
+      if ( $error ) {
+        warn "error estimating taxes for breakage charge: custnum ".$cust_main->custnum."\n";
+        return $total;
+      }
+      # $taxlisthash: tax identifier => [ cust_main_county, cust_bill_pkg... ]
+      my $total_rate = 0;
+      my @taxes = map { $_->[0] } values %$taxlisthash;
+      foreach (@taxes) {
+        $total_rate += $_->tax;
+      }
+      return $total if $total_rate == 0; # no taxes apply
+
+      my $total_cents = $total * 100;
+      my $charge_cents = sprintf('%.0f', $total_cents * 100/(100 + $total_rate));
+      return ($charge_cents / 100);
     }
   }
 
index 726b01d..33e49b8 100644 (file)
@@ -49,7 +49,7 @@ sub condition {
 }
 
 sub pkg_age_age {
-  my( $self, $cust_pkg, %opt );
+  my( $self, $cust_pkg, %opt ) = @_;
   $self->option_age_from('age', $opt{'time'} );
 }
 
index 582e292..8e85d71 100644 (file)
@@ -49,8 +49,7 @@ The following variables are available for interpolation (prefixed with <code>new
   <LI><code>$uid</code> - of catchall account
   <LI><code>$gid</code> - of catchall account
   <LI><code>$dir</code> - home directory of catchall account
-  <LI>All other fields in
-    <a href="../docs/schema.html#svc_domain">svc_domain</a> are also available.
+  <LI>All other fields in <b>svc_domain</b> are also available.
 </UL>
 END
 );
index 1b59589..29715b7 100644 (file)
@@ -141,7 +141,32 @@ The following variables are available for interpolation (prefixed with
   <LI><code>$shell</code>
   <LI><code>$quota</code>
   <LI><code>@radius_groups</code>
-  <LI>All other fields in <a href="../docs/schema.html#svc_acct">svc_acct</a> are also available.
+  <LI><code>$reasonnum (when suspending)</code>
+  <LI><code>$reasontext (when suspending)</code>
+  <LI><code>$reasontypenum (when suspending)</code>
+  <LI><code>$reasontypetext (when suspending)</code>
+  <LI><code>$pkgnum</code>
+  <LI><code>$custnum</code>
+  <LI>All other fields in <b>svc_acct</b> are also available.
+  <LI>The following fields from <b>cust_main</b> are also available (except during replace): company, address1, address2, city, state, zip, county, daytime, night, fax, otaker, agent_custid, locale.  When used on the command line (rather than STDIN), they will be quoted for the shell already (do not add additional quotes).
+</UL>
+For the package changed command only, the following fields are also available:
+<UL>
+  <LI>$old_pkgnum and $new_pkgnum
+  <LI>$old_pkgpart and $new_pkgpart
+  <LI>$old_agent_pkgid and $new_agent_pkgid
+  <LI>$old_order_date and $new_order_date
+  <LI>$old_start_date and $new_start_date
+  <LI>$old_setup and $new_setup
+  <LI>$old_bill and $new_bill
+  <LI>$old_last_bill and $new_last_bill
+  <LI>$old_susp and $new_susp
+  <LI>$old_adjourn and $new_adjourn
+  <LI>$old_resume and $new_resume
+  <LI>$old_cancel and $new_cancel
+  <LI>$old_unancel and $new_unancel
+  <LI>$old_expire and $new_expire
+  <LI>$old_contract_end and $new_contract_end
 </UL>
 END
 );
index 1980c0e..596f699 100644 (file)
@@ -4,6 +4,7 @@ use base qw( FS::svc_Common ); #qw( FS::device_Common FS::svc_Common );
 use strict;
 use Tie::IxHash;
 use FS::Record qw( qsearchs ); # qw( qsearch qsearchs );
+use FS::cable_provider;
 use FS::cable_model;
 
 =head1 NAME
@@ -72,24 +73,35 @@ sub search_sql {
 sub table_info {
 
   tie my %fields, 'Tie::IxHash',
-    'svcnum'     => 'Service',
-    'modelnum'   => { label             => 'Model',
-                      type              => 'select-cable_model',
-                      disable_inventory => 1,
-                      disable_select    => 1,
-                      value_callback    => sub {
-                                             my $svc = shift;
-                                             $svc->cable_model->model_name;
-                                           },
-                    },
-    'serialnum'  => 'Serial number',
-    'mac_addr'   => { label          => 'MAC address',
-                      type           => 'input-mac_addr',
-                      value_callback => sub {
-                                          my $svc = shift;
-                                          join(':', $svc->mac_addr =~ /../g);
-                                        },
-                    },
+    'svcnum'      => 'Service',
+    'providernum' => { label             => 'Provider',
+                       type              => 'select-cable_provider',
+                       disable_inventory => 1,
+                       disable_select    => 1,
+                       value_callback    => sub {
+                                              my $svc = shift;
+                                              my $p = $svc->cable_provider;
+                                              $p ? $p->provider : '';
+                                            },
+                     },
+    #XXX "Circuit ID/Order number"
+    'modelnum'    => { label             => 'Model',
+                       type              => 'select-cable_model',
+                       disable_inventory => 1,
+                       disable_select    => 1,
+                       value_callback    => sub {
+                                              my $svc = shift;
+                                              $svc->cable_model->model_name;
+                                            },
+                     },
+    'serialnum'   => 'Serial number',
+    'mac_addr'    => { label          => 'MAC address',
+                       type           => 'input-mac_addr',
+                       value_callback => sub {
+                                           my $svc = shift;
+                                           join(':', $svc->mac_addr =~ /../g);
+                                         },
+                     },
   ;
 
   {
@@ -130,6 +142,7 @@ sub check {
 
   my $error = 
        $self->ut_numbern('svcnum')
+    || $self->ut_foreign_key('providernum', 'cable_provider', 'providernum')
     || $self->ut_foreign_key('modelnum', 'cable_model', 'modelnum')
     || $self->ut_alpha('serialnum')
     || $self->ut_mac_addr('mac_addr')
@@ -139,6 +152,17 @@ sub check {
   $self->SUPER::check;
 }
 
+=item cable_provider
+
+Returns the cable_provider object for this record.
+
+=cut
+
+sub cable_provider {
+  my $self = shift;
+  qsearchs('cable_provider', { 'providernum'=>$self->providernum } );
+}
+
 =item cable_model
 
 Returns the cable_model object for this record.
index a3b11f7..5dbe754 100644 (file)
@@ -720,3 +720,9 @@ FS/svc_alarm.pm
 t/svc_alarm.t
 FS/cable_model.pm
 t/cable_model.t
+FS/invoice_mode.pm
+t/invoice_mode.t
+FS/invoice_conf.pm
+t/invoice_conf.t
+FS/cable_provider.pm
+t/cable_provider.t
diff --git a/FS/t/cable_provider.t b/FS/t/cable_provider.t
new file mode 100644 (file)
index 0000000..c794379
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cable_provider;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/invoice_conf.t b/FS/t/invoice_conf.t
new file mode 100644 (file)
index 0000000..b707fa3
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::invoice_conf;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/invoice_mode.t b/FS/t/invoice_mode.t
new file mode 100644 (file)
index 0000000..5f945f0
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::invoice_mode;
+$loaded=1;
+print "ok 1\n";
index b536360..37a5812 100755 (executable)
@@ -95,10 +95,10 @@ close TEST;
 # add them to MANIFEST
 ###
 
-#system('cvs edit FS/MANIFEST');
-
 open(MANIFEST,">>FS/MANIFEST") or die $!;
 print MANIFEST "FS/$table.pm\n",
                "t/$table.t\n";
 close MANIFEST;
 
+system("git add FS/FS/$table.pm FS/t/$table.t");
+
diff --git a/bin/standardize-locations b/bin/standardize-locations
new file mode 100755 (executable)
index 0000000..6e5fd3c
--- /dev/null
@@ -0,0 +1,25 @@
+#!/usr/bin/perl -w
+
+use strict;
+use FS::UID 'adminsuidsetup';
+use FS::Conf;
+use FS::queue;
+
+my $user = shift or die "usage:\n  standardize-locations user";
+adminsuidsetup($user);
+my $conf = FS::Conf->new;
+my $method = $conf->config('address_standardize_method')
+  or die "No address standardization method configured.\n";
+if ($method eq 'usps') {
+  # we're not supposed to do this
+  # (allow it anyway with a warning?)
+  die "USPS standardization does not allow batch processing.\n";
+}
+my $job = FS::queue->new({
+  job => 'FS::cust_location::process_standardize'
+});
+my $error = $job->insert('_JOB');
+die $error if $error;
+print "Address standardization job scheduled.\n";
+
+1;
old mode 100644 (file)
new mode 100755 (executable)
index d3a9f11..73c9d31
@@ -34,7 +34,10 @@ print "\n";
 
 my @conditions = $part_event->part_event_condition;
 foreach my $condition ( @conditions ) {
-  my $sat = $condition->condition( $object, 'cust_event' => $cust_event );
+  my $sat = $condition->condition( $object,
+                                   'cust_event' => $cust_event,
+                                   'time' => time,
+                                 );
 
   my $sql = $condition->condition_sql();
   
diff --git a/httemplate/browse/cable_provider.html b/httemplate/browse/cable_provider.html
new file mode 100644 (file)
index 0000000..0d34498
--- /dev/null
@@ -0,0 +1,32 @@
+<& elements/browse.html,
+     'title'              => 'Cable providers',
+     'html_init'          => $html_init,
+     'name'               => 'providers',
+     'disableable'        => 1,
+     'disabled_statuspos' => 1,
+     'query'              => { 'table'     => 'cable_provider',
+                               'hashref'   => {},
+                               'order_by' => 'ORDER BY provider',
+                             },
+     'count_query'        => $count_query,
+     'header'             => $header,
+     'fields'             => $fields,
+     'links'              => $links,
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $html_init =
+  qq!<A HREF="${p}edit/cable_provider.html"><I>Add a provider</I></A><BR><BR>!;
+
+my $count_query = 'SELECT COUNT(*) FROM cable_provider';
+
+my $link = [ $p.'edit/cable_provider.html?', 'providernum' ];
+
+my $header = [ 'Provider' ];
+my $fields = [ 'provider' ];
+my $links  = [ $link ];
+
+</%init>
diff --git a/httemplate/browse/invoice_conf.html b/httemplate/browse/invoice_conf.html
new file mode 100644 (file)
index 0000000..c8fd1bf
--- /dev/null
@@ -0,0 +1,70 @@
+<& elements/browse.html,
+  'title'         => 'Invoice modes',
+  'name_singular' => 'configuration',
+  'menubar'       => \@menubar,
+  'query'         => {
+                      'select'    => $select,
+                      'table'     => 'invoice_conf',
+                      'addl_from' => ' JOIN invoice_mode USING (modenum)',
+                      'extra_sql' => ' WHERE '.$curuser->agentnums_sql(
+                        'table'       => 'invoice_mode',
+                        'null_right'  => ['Edit global templates'],
+                      ),
+                      'order_by'  => q( ORDER BY modename asc, COALESCE(locale,'') asc),
+                     },
+  'count_query'   => 'SELECT COUNT(*) FROM invoice_conf JOIN invoice_mode USING (modenum)',
+  'header'        => [ 'Name', 'Agent', 'Locale', 'Overrides', ],
+  'fields'        => [  $modename,
+                        $agent,
+                        $locale_label,
+                        $overrides,
+                     ],
+  'align'         => 'llcl',
+  'links'         => [ '', '', $link ],
+  'disable_maxselect' => 1,
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right([ 'View templates', 'View global templates',
+                                  'Edit templates', 'Edit global templates', ]);
+
+my @overrides = grep {$_ ne 'modenum' and $_ ne 'confnum'} FS::invoice_conf->fields;                                 
+my $select = join(',', 'modename', 'agentnum', 'confnum', 'invoice_conf.*');
+
+my @menubar = ();
+if ( $curuser->access_right(['Edit templates', 'Edit global templates']) ) {
+  push @menubar, 'Add a new invoice mode' => $p.'edit/invoice_conf.html';
+}
+
+my $locale_style = 'font-size:0.8em; padding:3px; background-color:';
+
+my $last_modenum = 0;
+my $modename = sub {
+  return '' if $_[0]->modenum == $last_modenum;
+  $_[0]->modename;
+};
+
+my $agent = sub {
+  return '' if $_[0]->modenum == $last_modenum;
+  $last_modenum = $_[0]->modenum;
+  $_[0]->agentnum ? FS::agent->by_key($_[0]->agentnum)->agent : '(global)';
+};
+
+my $locale_label = sub {
+  my $l = $_[0]->locale;
+  $l ? +{ FS::Locales->locale_info($l) }->{'label'} : '(default)';
+};
+
+my $overrides = sub {
+  my $invoice_conf = shift;
+  [ map { [ { data => $_ } ] }
+    grep { length $invoice_conf->get($_) }
+    @overrides
+  ],
+};
+
+my $link = [ $p.'edit/invoice_conf.html?', 'confnum' ];
+</%init>
index c2ba4e4..80d9488 100644 (file)
@@ -56,7 +56,7 @@ GNU <b>Affero</b> General Public License.<BR>
 
 % unless ( $agentnum ) {
   <CENTER>
-  <FONT SIZE="-3">"I can't figure out ... if it's an end or the beginning" - R. Hunter</FONT>
+  <FONT SIZE="-3">"" - R. Hunter</FONT>
   </CENTER>
 % }
 
diff --git a/httemplate/edit/cable_provider.html b/httemplate/edit/cable_provider.html
new file mode 100644 (file)
index 0000000..9a911cc
--- /dev/null
@@ -0,0 +1,20 @@
+<& elements/edit.html,
+     'name_singular' => 'Provider',
+     'table'         => 'cable_provider',
+     'fields'        => [
+                          'provider',
+                          { field=>'disabled', type=>'checkbox', value=>'Y', },
+                        ],
+     'labels'        => {
+                          'providernum' => 'Provider',
+                          'provider'    => 'Provider',
+                          'disabled'    => 'Disabled',
+                        },
+     'viewall_dir'   => 'browse',
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+</%init>
index 9e18fa0..ecfcb3c 100644 (file)
@@ -10,17 +10,20 @@ my @fixups = ('copy_payby_fields', 'standardize_locations');
 push @fixups, 'confirm_censustract'
     if $conf->exists('cust_main-require_censustract');
 
-# currently doesn't work; disable to avoid problems
-#push @fixups, 'check_unique'
-#    if $conf->exists('cust_main-check_unique') and !$opt{'custnum'};
+my $uniqueness = $conf->config('cust_main-check_unique');
+push @fixups, 'check_unique'
+    if $uniqueness and !$opt{'custnum'};
 
 push @fixups, 'do_submit'; # always last
 </%init>
-
 var fixups = <% encode_json(\@fixups) %>;
 var fixup_position;
 var running = false;
 
+<&| /elements/onload.js &>
+submit_abort();
+</&>
+
 %# state machine to deal with all the asynchronous stuff we're doing
 %# call this after each fixup on success:
 function submit_continue() {
@@ -132,10 +135,14 @@ function set_censustract(tract, year) {
 }
 
 function check_unique() {
-  var search_hash = new Object;
-% foreach ($conf->config('cust_main-check_unique')) {
-  search_hash['<% $_ %>'] = document.CustomerForm.elements['<% $_ %>'].value;
+  var search_hash = {};
+% if ($uniqueness eq 'address') {
+  search_hash['address'] = [
+    document.CustomerForm.elements['bill_address1'].value,
+    document.CustomerForm.elements['ship_address1'].value
+  ];
 % }
+%# no other options yet
 
 %# supported in IE8+, Firefox 3.5+, WebKit, Opera 10.5+
   duplicates_form(JSON.stringify(search_hash), confirm_unique);
index ebd9b92..e25506f 100644 (file)
 
 % } else { 
 
-   <& /elements/tr-select-part_referral.html,
-                'curr_value' => $refnum
-   &>
+  <& /elements/tr-select-part_referral.html,
+                'curr_value' => $refnum,
+                'label'      => "<B>${r}".emt('Advertising source')."</B>"
+  &>
 % } 
 
 
index 0602811..6c96532 100644 (file)
@@ -338,6 +338,7 @@ Example:
 %     qw( width height config ),                           #htmlarea
 %     qw( alt_format ),                                    #select-cust_location
 %     qw( classnum ),                                   # select-inventory_item
+%     qw( aligned ),                                    # columnstart
 %   ;
 %
 %   #select-table
diff --git a/httemplate/edit/invoice_conf.html b/httemplate/edit/invoice_conf.html
new file mode 100644 (file)
index 0000000..b7b3a4e
--- /dev/null
@@ -0,0 +1,296 @@
+<& elements/edit.html,
+    'body_etc'          => $body_etc,
+    'name_singular'     => 'invoice configuration',
+    'table'             => 'invoice_conf',
+    'viewall_dir'       => 'browse',
+    'fields'            => \@fields,
+    'labels'            => \%labels,
+    'new_callback'      => \&new_callback,
+    'edit_callback'     => \&edit_callback,
+    'error_callback'    => \&error_callback,
+    'html_init'         => \&html_init,
+    'html_table_bottom' => \&html_table_bottom,
+    'html_bottom'       => '</DIV>', # close tablebreak-tabs
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+# ???
+die "access denied"
+  unless $curuser->access_right([ 'Edit templates', 'Edit global templates' ]);
+
+my $body_etc = '';
+$body_etc = q!onload="document.getElementById('locale').onchange()"!
+  if $cgi->param('locale') eq 'new';
+
+my $modenum = $cgi->param('modenum');
+my $mode = $modenum ? qsearchs('invoice_mode', { modenum => $modenum }) : '';
+
+my %textarea = (type => 'textarea', rows => 10, cols => 40);
+my @fields = (
+      { field => 'modenum', type => 'hidden' },
+      { field => 'agentnum',
+        type  => 'select-agent',
+      },
+      { field => 'modename',  size=>60, },
+      { type  => 'tablebreak-tabs',
+        include_opt_callback => \&menubar_opt_callback,
+      },
+      { field => 'locale', type => 'hidden' },
+      { field => 'notice_name',   size=>60, },
+      { field => 'subject',       size=>60, },
+      { field => 'lpr',           size=>60, },
+
+      { type  => 'columnstart', aligned => 1 },
+      { type  => 'title', value => '<BR>' },
+      map ( { +{ type => 'justtitle', value => $_ } }
+        'Notes',
+        'Footer',
+        'Summary header',
+        'Return address',
+        'Coupon',
+        'Small footer',
+        'Top margin',
+        'Header separation',
+        'Address separation',
+        'Text height',
+        'Coupon height',
+        'Footer separation',
+      ),
+
+      { type  => 'columnnext' },
+      { type  => 'title', value => 'LaTeX' },
+      { field => 'latexnotes',          %textarea },
+      { field => 'latexfooter',         %textarea },
+      { field => 'latexsummary',        %textarea },
+      { field => 'latexreturnaddress',  %textarea },
+      { field => 'latexcoupon',         %textarea },
+      { field => 'latexsmallfooter',    %textarea },
+      { field => 'latextopmargin',                size => 16 },
+      { field => 'latexheadsep',                  size => 16 },
+      { field => 'latexaddresssep',               size => 16 },
+      { field => 'latextextheight',               size => 16 },
+      { field => 'latexextracouponspace',         size => 16 },
+      { field => 'latexcouponfootsep',            size => 16 },
+      # are these still used?
+      #{ field => 'latexcouponamountenclosedsep',  size => 16 },
+      #{ field => 'latexverticalreturnaddress',    type => 'checkbox' },
+      #{ field => 'latexcouponaddcompanytoaddress',type => 'checkbox' },
+      # logo -- implement if someone really needs it...
+
+      { type  => 'columnnext' },
+      { type  => 'title', value => 'HTML' },
+      { field => 'htmlnotes',           %textarea }, #htmlarea?
+      { field => 'htmlfooter',          %textarea },
+      { field => 'htmlsummary',         %textarea },
+      { field => 'htmlreturnaddress',   %textarea },
+      # logo
+
+      { type  => 'columnend' },
+);
+
+my %labels = (
+  'confnum'             => 'Configuration',
+  'locale'              => 'Locale',
+  'agentnum'            => 'Agent',
+  'modename'            => 'Mode name',
+  'notice_name'         => 'Notice name',
+  'subject'             => 'Email Subject: header',
+  'lpr'                 => 'Alternate lpr command',
+
+  map { $_ => '' } (qw(
+    latexnotes
+    latexfooter
+    latexsummary
+    latexreturnaddress
+    latexcoupon
+    latexsmallfooter
+    latextopmargin
+    latexheadsep
+    latexaddresssep
+    latextextheight
+    latexextracouponspace
+    latexcouponfootsep
+    htmlnotes
+    htmlfooter
+    htmlsummary
+    htmlreturnaddress
+    logo_png
+    logo_eps
+  ) ),
+
+); 
+
+sub get_invoice_mode { # because we can't quite use agent_virt here
+  my $modenum = shift;
+  qsearchs({
+    'table'     => 'invoice_mode',
+    'hashref'   => { 'modenum' => $modenum },
+    'extra_sql' => ' AND '.
+      $FS::CurrentUser::CurrentUser->agentnums_sql(
+        'null_right' => 'Edit global templates',
+        'viewall_right' => 'Edit global templates' ),
+  });
+};
+
+sub error_callback {
+  my ($cgi, $object) = @_;
+  foreach (qw(modename agentnum)) {
+    $object->set($_, $cgi->param($_));
+  }
+  if ($object->confnum) {
+    return edit_callback(@_);
+  } else {
+    return new_callback(@_);
+  }
+}
+
+sub new_callback {
+  my ($cgi, $object, $fields_arrayref, $opt_hashref) = @_;
+  my $modenum;
+  if ( $cgi->param('locale') =~ /^(\w+)$/ ) {
+    $object->set('locale' => $1);
+  }
+
+  if ( $cgi->param('modenum') =~ /^(\d+)$/ ) {
+    $modenum = $1; # we're adding a locale to an existing mode
+    $object->set('modenum' => $modenum);
+    my $invoice_mode = get_invoice_mode($modenum)
+      or die "invoice mode $modenum not found";
+
+    $object->set('modename', $invoice_mode->modename);
+    $object->set('agentnum', $invoice_mode->agentnum);
+
+    # also, need to select a locale
+    # make a list of available locales
+    my %existing_locales = map { $_->locale }
+                          qsearch('invoice_conf', { modenum => $modenum });
+
+    my @locales = grep { !exists($existing_locales{$_}) } 
+                         FS::Conf->new->config('available-locales');
+    my %labels;
+    foreach (@locales) {
+      my %info = FS::Locales->locale_info($_);
+      $labels{$_} = $info{'label'};
+    }
+    unshift @locales, 'new';
+    $labels{'new'} = 'Select language';
+
+    # insert a field def
+    my $i = 0;
+    $i++ until ( $fields_arrayref->[$i]->{'field'} eq 'locale' );
+    my $locale_field = $fields_arrayref->[$i];
+
+    my $onchange_locale = "document.getElementById('submit').disabled = 
+    (this.options[this.selectedIndex].value == 'new');";
+
+    %$locale_field = (
+      field   => 'locale',
+      type    => 'select',
+      options => \@locales,
+      labels  => \%labels,
+      curr_value  => 'new',
+      onchange    => $onchange_locale,
+    );
+
+  } # otherwise it's a completely new mode, so the locale is default
+
+}
+
+sub edit_callback {
+  # massive false laziness with msg_template UI
+  my ($cgi, $object, $fields_arrayref, $opt_hashref) = @_;
+
+  # a little different here in that we treat the content object
+  # as "primary" (this is edit/invoice_conf.html, etc.)
+  # so all we need from the invoice_mode is its name
+  # (and agent identity)
+  my $modenum = $object->modenum;
+  my $invoice_mode = get_invoice_mode($modenum)
+    or die "invoice mode $modenum not found";
+  $object->set('modename', $invoice_mode->modename);
+  $object->set('agentnum', $invoice_mode->agentnum);
+
+}
+
+sub menubar_opt_callback {
+  my $object = shift;
+  my $modenum = $object->modenum or return; 
+  my (@tabs, @options, %labels);
+  my $display_new = 0;
+  my $selected = '';
+  foreach my $l ('', FS::Conf->new->config('available-locales')) {
+    my $invoice_conf =
+      qsearchs('invoice_conf', { modenum => $modenum, locale => $l });
+    if ( $invoice_conf ) {
+      my %info = FS::Locales->locale_info($l) if $l;
+      my $label = $info{'label'} || mt('Default');
+      push @tabs, $label, $invoice_conf->confnum;
+      $selected = $label if $object->locale eq $l;
+    }
+    else {
+      $display_new = 1; # there is at least one unused locale left
+    }
+  }
+  push @tabs, mt('New'), "modenum=$modenum;locale=new" if $display_new;
+  $selected = mt('New') if $object->locale eq 'new';
+  $selected ||= mt('Default');
+  (
+    'url_base' => $cgi->url() . '?',
+    'selected' => $selected,
+    'tabs'     => \@tabs
+  );
+}
+
+sub html_init {
+q!
+<STYLE>
+.fstabcontainer th { vertical-align: middle; text-align: center }
+</STYLE>
+!
+}
+
+sub html_table_bottom {
+  my $object = shift;
+  my $locale = '';
+  my $modenum = '';
+
+  if ($object->locale =~ /^(\w+)$/) {
+    $locale = $1;
+  }
+  if ($object->modenum =~ /^(\d+)$/) {
+    $modenum = $1;
+  }
+  my $html;
+  my $show_delete = 1;
+  # don't allow the default locale to be removed unless it's the last one
+  # in the mode
+  $show_delete = 0 if (
+    $locale eq 'new' or
+    $modenum eq '' or
+    ($locale eq '' and
+     FS::invoice_conf->count("modenum = $modenum and locale is not null") > 0
+    )
+  );
+
+  if ( $show_delete ) {    
+    # set up a delete link
+    my $confnum = $object->confnum;
+    my $url = $p."misc/delete-invoice_conf.html?$confnum";
+    my $link = qq!<A HREF="javascript:areyousure('$url','Really delete this configuration?')">! .
+      'Delete this configuration' .
+      '</A>';
+    $html = qq!<TR><TD></TD>
+      <TD STYLE="font-style: italic; font-size: small">$link</TD></TR>
+      <SCRIPT TYPE="text/javascript">
+      function areyousure(url, message) {
+          if (confirm(message)) window.location.href = url;
+      }
+      </SCRIPT>
+      !;
+  }
+  $html;
+}
+
+</%init>
diff --git a/httemplate/edit/process/cable_provider.html b/httemplate/edit/process/cable_provider.html
new file mode 100644 (file)
index 0000000..ecffaf6
--- /dev/null
@@ -0,0 +1,10 @@
+<& elements/process.html,
+     'table'       => 'cable_provider',
+     'viewall_dir' => 'browse',
+&>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+</%init>
diff --git a/httemplate/edit/process/invoice_conf.html b/httemplate/edit/process/invoice_conf.html
new file mode 100644 (file)
index 0000000..1d45e12
--- /dev/null
@@ -0,0 +1,21 @@
+<& elements/process.html,
+  'table'       => 'invoice_conf',
+  'viewall_dir' => 'browse',
+  'fields'      => [ FS::invoice_conf->fields, 'modename', 'agentnum' ],
+  'precheck_callback' => \&precheck_callback,
+&>
+<%init>
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right(['Edit templates','Edit global templates']);
+
+sub precheck_callback {
+  my $cgi = shift;
+  $cgi->param('locale') =~ /^(\w*)$/;
+  my $locale = $1;
+  return mt('Language required') if $locale eq 'new'; # the user didn't choose
+  die "unknown locale $locale" if ( $locale and 
+                                    !FS::Locales->locale_info($locale) );
+}
+# invoice_conf itself knows to create/update invoice_mode if necessary,
+# so nothing special here
+</%init>
index be37d81..1ffbcb9 100644 (file)
@@ -1,6 +1,81 @@
+<%doc>
+<table>
+  <& /elements/columnstart.html &>
+    <tr> ... </tr>
+    <tr> ... </tr>
+  <& /elements/columnnext.html &>
+    ...
+  <& /elements/columnend.html &>
+</table>
+
+Pass 'aligned' => 1 to have corresponding rows in the columns line up.
+</%doc>
+% my $id = sprintf('table%08d', rand(100000000));
 <TR>
   <TD CLASS="background" COLSPAN=99>
-    <TABLE BORDER=0 CELLSPACING=0 CELLPADDING=0>
+    <TABLE BORDER=0 CELLSPACING=0 CELLPADDING=0 id="<%$id%>">
       <TR>
         <TD VALIGN="top">
           <TABLE BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
+% if ( $aligned ) {
+%# Instead of changing all the tr-* elements to sometimes output table
+%# cells without wrapping them in a row, we're just going to completely
+%# rebuild the table on the client side.
+<script type="text/javascript">
+<&| onload.js &>
+  var table = document.getElementById('<%$id%>'); // has one row, always
+  var rows = []; // row contents, each containing 
+  var n_rows = []; // rows in each subtable
+  var n_cols = []; // cols in each subtable
+  var total_rows = 0; // max(n_rows)
+  for(var i=0; i < table.rows[0].cells.length; i++) {
+    // these are cells created by columnstart/columnnext
+    // each contains a table, and nothing else
+    var subtable = table.rows[0].cells[i].children[0];
+    n_rows[i] = subtable.rows.length;
+    if ( total_rows < n_rows[i] ) {
+      total_rows = n_rows[i];
+    }
+    n_cols[i] = 0;
+    var subrows = []; // the rows of this table
+    for(var j=0; j < n_rows[i]; j++) {
+      // these are the actual tr-* rows within the table, and 
+      // can contain multiple cells
+      subrows[j] = [];
+      var tr = subtable.rows[j];
+      if ( n_cols[i] < tr.cells.length ) {
+        n_cols[i] = tr.cells.length;
+      }
+      for(var k=0; k < tr.cells.length; k++) {
+        subrows[j][k] = tr.cells[k];
+      }
+    } // for(j)
+    rows[i] = subrows;
+  } // for(i)
+  var new_table = document.createElement('TABLE');
+  for (var j = 0; j < total_rows; j++) {
+    var tr = document.createElement('TR');
+    for (var i = 0; i < rows.length; i++) { // subtables
+      var k = 0; // subrow position
+      if ( j < n_rows[i] ) { // then subtable i has this row
+        for (k = 0; k < rows[i][j].length; k++) { // cells
+          tr.appendChild(rows[i][j][k]);
+        }
+      } // else k is just 0
+      if ( k < n_cols[i] ) { // then we need a spacer
+        var spacer = document.createElement('TD');
+        spacer.setAttribute('colspan', n_cols[i] - k);
+        tr.appendChild(spacer);
+      }
+    } // for(i); subtables
+    // tr is complete
+    new_table.appendChild(tr);
+  } // for(j); rows
+  table.parentNode.insertBefore( new_table, table );
+  table.parentNode.removeChild(table);
+</&>
+</script>
+% } # if $aligned
+<%args>
+$aligned => 0
+</%args>
index 8cbbd17..8cb9675 100644 (file)
@@ -197,6 +197,10 @@ foreach my $svcdb ( FS::part_svc->svc_tables() ) {
       [ $fsurl. 'search/report_svc_phone_usage.html',
         'Total usage (minutes, and amount billed) for the specified time period, per phone number.',
       ];
+    $report_svc{"${name} by state"} =
+      [ $fsurl. 'search/phone_state.html',
+        'Current or historical phone services broken down by state.',
+      ];
 
   }
 
@@ -517,6 +521,7 @@ tie my %config_radius, 'Tie::IxHash',
 ;
 
 tie my %config_cable, 'Tie::IxHash',
+  'Cable providers' => [ $fsurl.'browse/cable_provider.html', '' ],
   'Cable modem models' => [ $fsurl.'browse/cable_model.html', '' ],
 ;
 
@@ -600,6 +605,7 @@ $config_billing{'Billing events'} = [ $fsurl.'browse/part_event.html', 'Billing
     || $curuser->access_right('Edit global billing events');
 if ( $curuser->access_right('Configuration') ) {
   #$config_billing{'Invoice events'}         = [ $fsurl.'browse/part_bill_event.cgi', 'Deprecated, old-style actions for overdue invoices' ];
+  $config_billing{'Invoice configurations'}      = [ $fsurl.'browse/invoice_conf.html', 'Adjust invoice settings for special-purpose notices' ];
   $config_billing{'Invoice templates'}      = [ $fsurl.'browse/invoice_template.html', 'Edit templates for HTML, plaintext and typeset invoices' ];
   $config_billing{'separator'} = ''; #its a separator!
   $config_billing{'Prepaid cards'}          = [ $fsurl.'search/prepay_credit.html', 'View outstanding cards, generate new cards' ];
diff --git a/httemplate/elements/select-cable_provider.html b/httemplate/elements/select-cable_provider.html
new file mode 100644 (file)
index 0000000..9530b78
--- /dev/null
@@ -0,0 +1,7 @@
+<% include( '/elements/select-table.html',
+    'table'            => 'cable_provider',
+    'name_col'         => 'provider',
+    'empty_label'      => 'Select provider',
+    @_,
+   )
+%>
diff --git a/httemplate/elements/tr-select-cable_provider.html b/httemplate/elements/tr-select-cable_provider.html
new file mode 100644 (file)
index 0000000..abb8564
--- /dev/null
@@ -0,0 +1,12 @@
+% #if ( scalar(@domains) < 2 ) {
+% #} else {
+  <TR>
+    <TD ALIGN="right"><% $opt{'label'} || 'Provider' %></TD>
+    <TD>
+      <% include( '/elements/select-cable_provider.html', %opt) %>
+    </TD>
+   </TR>
+% #}
+<%init>
+  my %opt = @_;
+</%init>
diff --git a/httemplate/elements/tr-select-invoice_mode.html b/httemplate/elements/tr-select-invoice_mode.html
new file mode 100644 (file)
index 0000000..3dccdcc
--- /dev/null
@@ -0,0 +1,10 @@
+<& tr-select-table.html,
+  'label'       => 'Invoice mode',
+  'table'       => 'invoice_mode',
+  'field'       => 'modenum',
+  'name_col'    => 'modename',
+  'agent_virt'  => 1,
+  'agent_null'  => 1,
+  'empty_label' => '(none)',
+  @_
+&>
diff --git a/httemplate/misc/delete-invoice_conf.html b/httemplate/misc/delete-invoice_conf.html
new file mode 100644 (file)
index 0000000..6cc6ddc
--- /dev/null
@@ -0,0 +1,19 @@
+<%init>
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied"
+  unless $curuser->access_right(['Edit templates', 'Edit global templates']);
+
+my ($query) = $cgi->keywords;
+$query =~ /^(\d+)$/ or die "bad confnum";
+my $invoice_conf = FS::invoice_conf->by_key($1)
+  or die "couldn't find invoice_conf #$1";
+if ( !$curuser->access_right('Edit global templates') ) {
+  my $agentnum = FS::invoice_mode->by_key($invoice_conf->modenum)->agentnum;
+  die "access denied"
+    unless $curuser->agentnums_href->{$agentnum};
+}
+
+my $error = $invoice_conf->delete; # may also delete the invoice_mode
+my $url = $p.'browse/invoice_conf.html';
+</%init>
+<% $cgi->redirect($url) %>
index 269722f..b24e042 100755 (executable)
@@ -12,7 +12,7 @@ my $invnum = $3;
 my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
 die "Can't find invoice!\n" unless $cust_bill;
 
-$cust_bill->email($template); 
+$cust_bill->email({ 'template' => $template }); 
 
 my $custnum = $cust_bill->getfield('custnum');
 
index 2591fce..f72fc7e 100755 (executable)
@@ -12,7 +12,7 @@ my $invnum = $3;
 my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
 die "Can't find invoice!\n" unless $cust_bill;
 
-$cust_bill->fax_invoice($template);
+$cust_bill->fax_invoice({ 'template' => $template });
 
 my $custnum = $cust_bill->getfield('custnum');
 
index aeef687..5ce6e76 100755 (executable)
@@ -12,7 +12,7 @@ my $invnum = $3;
 my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
 die "Can't find invoice!\n" unless $cust_bill;
 
-$cust_bill->print($template);
+$cust_bill->print({ 'template' => $template});
 
 my $custnum = $cust_bill->getfield('custnum');
 
index 32dfe27..08dd0e0 100644 (file)
@@ -13,6 +13,10 @@ my $invnum      = $cgi->param('invnum');
 my $template    = $cgi->param('template');
 my $notice_name = $cgi->param('notice_name') if $cgi->param('notice_name');
 my $method      = $cgi->param('method');
+my $mode;
+if ( $cgi->param('mode') =~ /^(\d+)$/ ) {
+  $mode = $1;
+}
 
 $method .= '_invoice' if $method eq 'fax'; #!
 
@@ -21,6 +25,7 @@ die "unknown method $method" unless $method{$method};
 my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
 die "Can't find invoice!\n" unless $cust_bill;
 
+$cust_bill->set('mode' => $mode) if $mode;
 $cust_bill->$method({ 'template'    => $template,
                       'notice_name' => $notice_name,
                    }); 
index 7ee00af..7cd4633 100644 (file)
@@ -50,7 +50,9 @@ my $conf = new FS::Conf;
 
 my $sub = $cgi->param('sub');
 my $hashref = decode_json($cgi->param('arg'));
-my @cust_main = qsearch('cust_main', $hashref);
+my $search = FS::cust_main->search($hashref);
+#warn Dumper($search);
+my @cust_main = qsearch( $search );
 
 my $set_to_customer = <<EOF;
   var custnum_array = document.getElementsByName('dup_custnum');
diff --git a/httemplate/search/phone_state.html b/httemplate/search/phone_state.html
new file mode 100644 (file)
index 0000000..67965b7
--- /dev/null
@@ -0,0 +1,167 @@
+<& elements/search.html,
+  'title'       => $title,
+  'name'        => 'states',
+  'query'       => $query,
+  'count_query' => $count_query,
+  'count_addl'  => $count_addl,
+  'header'      => [ 'State', # if we add more group fields, change this
+                     'Count',
+                     'Phone numbers'
+                   ],
+  'fields'      => [ 'state',
+                     'num_svcnums',
+                     $detail_sub
+                   ],
+  'html_init'   => include('.head', $time),
+&>
+<%def .head>
+% my $time = shift;
+<FORM STYLE="display:inline" ACTION=<% $cgi->url %> METHOD="GET">
+Active phone services as of <& /elements/input-date-field.html, {
+  'name'    => 'date',
+  'value'   => $time,
+  'format'  => FS::Conf->new->config('date_format') || '%m/%d/%Y'
+} &>
+<INPUT TYPE="hidden" NAME="order_by" VALUE="<% $cgi->param('order_by') %>">
+<INPUT TYPE="submit" VALUE="Refresh">
+</FORM>
+<BR>
+<BR>
+</%def>
+<%init>
+# svc_phone-specific for now; may change later
+my $curuser = $FS::CurrentUser::CurrentUser;
+die "access denied" unless $curuser->access_right('Services: Phone numbers');
+my $title = 'Phone services by state';
+
+my $time = time;
+if ( $cgi->param('date') ) {
+  $time = parse_datetime($cgi->param('date'));
+  $title .= time2str(' (%b %o, %Y)', $time);
+}
+
+my @tables = qw(svc_phone cust_svc cust_pkg cust_location cust_main);
+my @pkeys =  qw(svcnum    svcnum   pkgnum   locationnum   custnum);
+my @h_tables = map "h_$_", @tables;
+
+my $addl_from = '';
+my @where;
+
+for(my $i = 0; $i < scalar(@tables); $i++) {
+  my $last_table = $h_tables[$i-1];
+  my $pkey = $pkeys[$i];
+  my $table = $tables[$i];
+  my $h_table = $h_tables[$i];
+  # alias the preceding table, and join to a subquery that finds the most 
+  # recent change to $table.$pkey before $time
+  my $alias = $h_table;
+  my $inside = '';
+  if ( $i > 0 ) {
+    $alias = "t$i";
+    $inside = " AS $alias";
+  }
+  $inside .= "
+      JOIN 
+        (SELECT $pkey AS num, MAX(history_date) AS history_date
+                FROM $h_table 
+                WHERE history_date <= $time AND 
+                      history_action IN ('insert', 'replace_new') 
+                GROUP BY $pkey
+        ) AS mostrecent_$table
+        ON ($alias.$pkey = mostrecent_$table.num AND
+            $alias.history_date = mostrecent_$table.history_date AND
+            $alias.history_action IN ('insert', 'replace_new')
+        )
+      LEFT JOIN
+        (SELECT $pkey AS num, MAX(history_date) AS history_date, 1 AS deleted
+                FROM $h_table
+                WHERE history_date <= $time AND
+                      history_action = 'delete'
+                GROUP BY $pkey
+        ) AS deleted_$table
+        ON (mostrecent_$table.num = deleted_$table.num AND
+            mostrecent_$table.history_date < deleted_$table.history_date
+        )
+";
+  # join to the preceding table if there is one, and filter out 
+  # deleted records
+  if ( $i > 0 ) {
+    # special case to make pre-3.x data work; remove this later
+    if ( $table eq 'cust_main' ) {
+      $last_table = 'h_cust_pkg';
+    }
+    $addl_from .= "
+      LEFT JOIN ( $h_table $inside ) AS $h_table
+        ON ($h_table.$pkey = $last_table.$pkey)";
+    push @where, "$h_table.deleted IS NULL";
+  } else {
+    $addl_from .= $inside;
+    push @where, "deleted_$table.deleted IS NULL";
+  }
+}
+
+# so that we know which services are still active
+$addl_from .= "
+  LEFT JOIN svc_phone ON (h_svc_phone.svcnum = svc_phone.svcnum AND
+                          h_svc_phone.phonenum = svc_phone.phonenum)";
+
+#warn "\n\nJOIN EXPRESSION:\n$addl_from\n\n";
+
+push @where, $curuser->agentnums_sql(
+  'table' => 'h_cust_main',
+  'null_right' => 'View/link unlinked services'
+);
+my $where = " WHERE ".join(' AND ', map "($_)", @where);
+
+# for pre-3.x data
+my $group_field = 'COALESCE(h_cust_location.state, h_cust_main.ship_state, h_cust_main.state)';
+
+my @select = (
+  "$group_field AS state",
+  'count(DISTINCT h_svc_phone.svcnum) AS num_svcnums',
+  # don't DISTINCT these (it reorders them)
+  "array_to_string(array_agg(h_svc_phone.phonenum), ',') AS all_phonenums",
+  "array_to_string(array_agg(h_svc_phone.svcnum), ',') AS all_svcnums",
+  "array_to_string(array_agg(svc_phone.svcnum), ',') AS active_svcnums",
+);
+
+my $query = {
+  'select'    => join(',', @select),
+  'table'     => 'h_svc_phone',
+  'addl_from' => $addl_from,
+  'extra_sql' => " $where GROUP BY $group_field",
+};
+
+# DISTINCT on these because of cross-producting effects when a cust_pkg 
+# record (usually) was replaced more than once within one second.
+my $count_query =
+  "SELECT COUNT(DISTINCT $group_field), COUNT(DISTINCT h_svc_phone.svcnum) ".
+  "FROM h_svc_phone $addl_from $where";  
+my $count_addl = [ '%d phone services' ];
+
+my $detail_sub = sub {
+  my $rec = shift;
+  warn Dumper $rec;
+  my @svcnums = split(',', $rec->all_svcnums);
+  my @phonenums = split(',', $rec->all_phonenums);
+  # identifies services that still exist with the same svcnum+phonenum
+  my %active = map { $_ => 1 } split(',', $rec->active_svcnums);
+  # make a single column of phonenums
+  my @return;
+  my %seen;
+  while (my $svcnum = shift @svcnums) {
+    my $phonenum = shift @phonenums;
+    next if $seen{$svcnum};
+    $seen{$svcnum} = 1;
+    my $link = $active{$svcnum} ?
+                $p.'view/svc_phone.cgi?'.$svcnum :
+                '';
+    push @return, [ { data => $phonenum,
+                      link => $link,
+                      data_style => ($active{$svcnum} ? '' : 'i')
+                     } ];
+  }
+  \@return;
+};
+
+</%init>
index 95ce60b..4822ab7 100755 (executable)
 % my $br = 0;
 % if ( $cust_bill->num_cust_event ) { $br++;
 <A HREF="<%$p%>search/cust_event.html?invnum=<% $cust_bill->invnum %>">( <% mt('View invoice events') |h %> )</A> 
-% } 
+% }
 
 % if ( $cust_bill->num_cust_bill_event ) { $br++;
 <A HREF="<%$p%>search/cust_bill_event.cgi?invnum=<% $cust_bill->invnum %>">( <% mt('View deprecated, old-style invoice events') |h %> )</A> 
 % }
 
+% my @modes = grep {! $_->disabled} 
+%   $cust_bill->cust_main->agent->invoice_modes;
+% if ( @modes ) {
+( <% mt('View as:') %>
+<FORM STYLE="display:inline" ACTION="<% $cgi->url %>" METHOD="GET">
+<INPUT NAME="invnum" VALUE="<% $invnum %>" TYPE="hidden">
+<& /elements/select-table.html,
+  table       => 'invoice_mode',
+  field       => 'mode',
+  curr_value  => scalar($cgi->param('mode')),
+  records     => \@modes,
+  name_col    => 'modename',
+  onchange    => 'change_invoice_mode',
+  empty_label => '(default)',
+&> )
+<SCRIPT TYPE="text/javascript">
+function change_invoice_mode(obj) {
+  obj.form.submit();
+}
+</SCRIPT>
+% $br++;
+% }
+
 <% $br ? '<BR><BR>' : '' %>
 
 % if ( $conf->exists('invoice_html') ) { 
@@ -126,7 +149,9 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
   unless $curuser->access_right('View invoices');
 
-my( $invnum, $template, $notice_name );
+my $conf = FS::Conf->new;
+
+my( $invnum, $mode, $template, $notice_name );
 my($query) = $cgi->keywords;
 if ( $query =~ /^((.+)-)?(\d+)$/ ) {
   $template = $2;
@@ -136,10 +161,9 @@ if ( $query =~ /^((.+)-)?(\d+)$/ ) {
   $invnum = $cgi->param('invnum');
   $template = $cgi->param('template');
   $notice_name = $cgi->param('notice_name');
+  $mode = $cgi->param('mode');
 }
 
-my $conf = new FS::Conf;
-
 my %opt = (
   'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
   'template'      => $template,
@@ -163,10 +187,13 @@ my $cust_bill = qsearchs({
 });
 die "Invoice #$invnum not found!" unless $cust_bill;
 
+$cust_bill->set('mode' => $mode);
+
 my $custnum = $cust_bill->custnum;
 my $display_custnum = $cust_bill->cust_main->display_custnum;
 
 my $link = "invnum=$invnum";
+$link .= ';mode=' . $mode if $mode;
 $link .= ';template='. uri_escape($template) if $template;
 $link .= ';notice_name='. $notice_name if $notice_name;
 
index 430c50c..3919881 100755 (executable)
@@ -147,7 +147,6 @@ function areyousure(href, message) {
 % if ( $br ) {
   <BR><BR>
 % }
-</%doc>
 
 %my $signupurl = $conf->config('signupurl');
 %if ( $signupurl ) {
index 3e1345e..5d37b31 100755 (executable)
 
 % if ( $conf->exists('invoice_html') ) { 
 
-  <% join('', $cust_statement->print_html('', $templatename) ) %>
+  <% join('', $cust_statement->print_html('template' => $templatename) ) %>
 % } else { 
 
-  <PRE><% join('', $cust_statement->print_text('', $templatename) ) %></PRE>
+  <PRE><% join('', $cust_statement->print_text('template' => $templatename) ) %></PRE>
 % } 
 
 <% include('/elements/footer.html') %>
index 00f503f..778e538 100644 (file)
@@ -6,7 +6,7 @@ die "access denied"
 
 my $type = shift;
 
-my( $invnum, $template, $notice_name );
+my( $invnum, $mode, $template, $notice_name );
 my($query) = $cgi->keywords;
 if ( $query =~ /^((.+)-)?(\d+)(.pdf)?$/ ) { #probably not necessary anymore?
   $template = $2;
@@ -16,7 +16,8 @@ if ( $query =~ /^((.+)-)?(\d+)(.pdf)?$/ ) { #probably not necessary anymore?
   $invnum = $cgi->param('invnum');
   $invnum =~ s/\.pdf//i; #probably not necessary anymore
   $template = $cgi->param('template');
-  $notice_name = ( $cgi->param('notice_name') || 'Invoice' );
+  $notice_name = $cgi->param('notice_name');
+  $mode = $cgi->param('mode');
 }
 
 my $conf = new FS::Conf;
@@ -36,6 +37,8 @@ my $cust_bill = qsearchs({
 });
 die "Invoice #$invnum not found!" unless $cust_bill;
 
+$cust_bill->set(mode => $mode);
+
 my $method = "print_$type";
 
 my $content = $cust_bill->$method(\%opt);
diff --git a/ng_selfservice/.freeside.class.php.swp b/ng_selfservice/.freeside.class.php.swp
deleted file mode 100644 (file)
index 5c39524..0000000
Binary files a/ng_selfservice/.freeside.class.php.swp and /dev/null differ
diff --git a/ng_selfservice/.index.php.swp b/ng_selfservice/.index.php.swp
deleted file mode 100644 (file)
index 50c9cfb..0000000
Binary files a/ng_selfservice/.index.php.swp and /dev/null differ
diff --git a/ng_selfservice/.logout.php.swp b/ng_selfservice/.logout.php.swp
deleted file mode 100644 (file)
index ec27faa..0000000
Binary files a/ng_selfservice/.logout.php.swp and /dev/null differ
diff --git a/ng_selfservice/.main.php.swp b/ng_selfservice/.main.php.swp
deleted file mode 100644 (file)
index cc55626..0000000
Binary files a/ng_selfservice/.main.php.swp and /dev/null differ
diff --git a/ng_selfservice/.password.php.swp b/ng_selfservice/.password.php.swp
deleted file mode 100644 (file)
index e1e968f..0000000
Binary files a/ng_selfservice/.password.php.swp and /dev/null differ
diff --git a/ng_selfservice/.payment.php.swp b/ng_selfservice/.payment.php.swp
deleted file mode 100644 (file)
index 2b705a3..0000000
Binary files a/ng_selfservice/.payment.php.swp and /dev/null differ
diff --git a/ng_selfservice/.payment_ach.php.swp b/ng_selfservice/.payment_ach.php.swp
deleted file mode 100644 (file)
index 1a87a2d..0000000
Binary files a/ng_selfservice/.payment_ach.php.swp and /dev/null differ
diff --git a/ng_selfservice/.payment_cc.php.swp b/ng_selfservice/.payment_cc.php.swp
deleted file mode 100644 (file)
index 369d104..0000000
Binary files a/ng_selfservice/.payment_cc.php.swp and /dev/null differ
diff --git a/ng_selfservice/.payment_paypal.php.swp b/ng_selfservice/.payment_paypal.php.swp
deleted file mode 100644 (file)
index 3abff2f..0000000
Binary files a/ng_selfservice/.payment_paypal.php.swp and /dev/null differ
diff --git a/ng_selfservice/.payment_webpay.php.swp b/ng_selfservice/.payment_webpay.php.swp
deleted file mode 100644 (file)
index 6ef3df9..0000000
Binary files a/ng_selfservice/.payment_webpay.php.swp and /dev/null differ
diff --git a/ng_selfservice/.personal.php.swp b/ng_selfservice/.personal.php.swp
deleted file mode 100644 (file)
index f5e8c23..0000000
Binary files a/ng_selfservice/.personal.php.swp and /dev/null differ
diff --git a/ng_selfservice/.process_login.php.swp b/ng_selfservice/.process_login.php.swp
deleted file mode 100644 (file)
index c530f11..0000000
Binary files a/ng_selfservice/.process_login.php.swp and /dev/null differ
diff --git a/ng_selfservice/.process_ticket_create.php.swp b/ng_selfservice/.process_ticket_create.php.swp
deleted file mode 100644 (file)
index c286792..0000000
Binary files a/ng_selfservice/.process_ticket_create.php.swp and /dev/null differ
diff --git a/ng_selfservice/.services.php.swp b/ng_selfservice/.services.php.swp
deleted file mode 100644 (file)
index e063e40..0000000
Binary files a/ng_selfservice/.services.php.swp and /dev/null differ
diff --git a/ng_selfservice/.services_new.php.swp b/ng_selfservice/.services_new.php.swp
deleted file mode 100644 (file)
index 8d0c657..0000000
Binary files a/ng_selfservice/.services_new.php.swp and /dev/null differ
diff --git a/ng_selfservice/.ticket.php.swp b/ng_selfservice/.ticket.php.swp
deleted file mode 100644 (file)
index e9b2503..0000000
Binary files a/ng_selfservice/.ticket.php.swp and /dev/null differ
diff --git a/ng_selfservice/.ticket_create.php.swp b/ng_selfservice/.ticket_create.php.swp
deleted file mode 100644 (file)
index 65b00fe..0000000
Binary files a/ng_selfservice/.ticket_create.php.swp and /dev/null differ
diff --git a/ng_selfservice/.tickets.php.swp b/ng_selfservice/.tickets.php.swp
deleted file mode 100644 (file)
index 7b4d67b..0000000
Binary files a/ng_selfservice/.tickets.php.swp and /dev/null differ
diff --git a/ng_selfservice/.tickets_resolved.php.swp b/ng_selfservice/.tickets_resolved.php.swp
deleted file mode 100644 (file)
index 1b3c634..0000000
Binary files a/ng_selfservice/.tickets_resolved.php.swp and /dev/null differ
diff --git a/ng_selfservice/.usage.php.swp b/ng_selfservice/.usage.php.swp
deleted file mode 100644 (file)
index 61fd4fa..0000000
Binary files a/ng_selfservice/.usage.php.swp and /dev/null differ
diff --git a/ng_selfservice/.usage_cdr.php.swp b/ng_selfservice/.usage_cdr.php.swp
deleted file mode 100644 (file)
index 83c270a..0000000
Binary files a/ng_selfservice/.usage_cdr.php.swp and /dev/null differ
diff --git a/ng_selfservice/.usage_data.php.swp b/ng_selfservice/.usage_data.php.swp
deleted file mode 100644 (file)
index e5a9272..0000000
Binary files a/ng_selfservice/.usage_data.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.card.php.swp b/ng_selfservice/elements/.card.php.swp
deleted file mode 100644 (file)
index 15d30ce..0000000
Binary files a/ng_selfservice/elements/.card.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.check.php.swp b/ng_selfservice/elements/.check.php.swp
deleted file mode 100644 (file)
index fe08303..0000000
Binary files a/ng_selfservice/elements/.check.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.error.php.swp b/ng_selfservice/elements/.error.php.swp
deleted file mode 100644 (file)
index 1a6eb28..0000000
Binary files a/ng_selfservice/elements/.error.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.header.php.swp b/ng_selfservice/elements/.header.php.swp
deleted file mode 100644 (file)
index 2371770..0000000
Binary files a/ng_selfservice/elements/.header.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.menu.php.swp b/ng_selfservice/elements/.menu.php.swp
deleted file mode 100644 (file)
index 0c29ff9..0000000
Binary files a/ng_selfservice/elements/.menu.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.menu_footer.php.swp b/ng_selfservice/elements/.menu_footer.php.swp
deleted file mode 100644 (file)
index 4bd2b30..0000000
Binary files a/ng_selfservice/elements/.menu_footer.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.session.php.swp b/ng_selfservice/elements/.session.php.swp
deleted file mode 100644 (file)
index ddd0137..0000000
Binary files a/ng_selfservice/elements/.session.php.swp and /dev/null differ
diff --git a/ng_selfservice/elements/.ticketlist.php.swp b/ng_selfservice/elements/.ticketlist.php.swp
deleted file mode 100644 (file)
index db3d0fe..0000000
Binary files a/ng_selfservice/elements/.ticketlist.php.swp and /dev/null differ
diff --git a/ng_selfservice/js/.menu.js.swp b/ng_selfservice/js/.menu.js.swp
deleted file mode 100644 (file)
index 8df94a9..0000000
Binary files a/ng_selfservice/js/.menu.js.swp and /dev/null differ
index 4a6bfda..2dc16e3 100644 (file)
@@ -2070,7 +2070,6 @@ sub _ProcessUpdateMessageRecipients {
     if (grep $_ eq 'Requestor' || $_ eq 'Requestors', @{ $args{ARGSRef}->{'SkipNotification'} || [] }) {
         push @txn_squelch, map $_->address, Email::Address->parse( $message_args->{Requestor} );
         push @txn_squelch, $args{TicketObj}->Requestors->MemberEmailAddresses;
-
     }
 
     push @txn_squelch, @{$args{ARGSRef}{SquelchMailTo}} if $args{ARGSRef}{SquelchMailTo};
@@ -2092,6 +2091,39 @@ sub _ProcessUpdateMessageRecipients {
     }
 }
 
+sub ProcessAttachments {
+    my %args = (
+        ARGSRef => {},
+        @_
+    );
+
+    my $ARGSRef = $args{ARGSRef} || {};
+    # deal with deleting uploaded attachments
+    foreach my $key ( keys %$ARGSRef ) {
+        if ( $key =~ m/^DeleteAttach-(.+)$/ ) {
+            delete $session{'Attachments'}{$1};
+        }
+        $session{'Attachments'} = { %{ $session{'Attachments'} || {} } };
+    }
+
+    # store the uploaded attachment in session
+    if ( defined $ARGSRef->{'Attach'} && length $ARGSRef->{'Attach'} )
+    {    # attachment?
+        my $attachment = MakeMIMEEntity( AttachmentFieldName => 'Attach' );
+
+        my $file_path = Encode::decode_utf8("$ARGSRef->{'Attach'}");
+        $session{'Attachments'} =
+          { %{ $session{'Attachments'} || {} }, $file_path => $attachment, };
+    }
+
+    # delete temporary storage entry to make WebUI clean
+    unless ( keys %{ $session{'Attachments'} } and $ARGSRef->{'UpdateAttach'} )
+    {
+        delete $session{'Attachments'};
+    }
+}
+
+
 =head2 MakeMIMEEntity PARAMHASH
 
 Takes a paramhash Subject, Body and AttachmentFieldName.
@@ -2174,37 +2206,6 @@ sub MakeMIMEEntity {
 
 }
 
-sub ProcessAttachments {
-    my %args = (
-        ARGSRef => {},
-        @_
-    );
-
-    my $ARGSRef = $args{ARGSRef} || {};
-    # deal with deleting uploaded attachments
-    foreach my $key ( keys %$ARGSRef ) {
-        if ( $key =~ m/^DeleteAttach-(.+)$/ ) {
-            delete $session{'Attachments'}{$1};
-        }
-        $session{'Attachments'} = { %{ $session{'Attachments'} || {} } };
-    }
-
-    # store the uploaded attachment in session
-    if ( defined $ARGSRef->{'Attach'} && length $ARGSRef->{'Attach'} )
-    {    # attachment?
-        my $attachment = MakeMIMEEntity( AttachmentFieldName => 'Attach' );
-
-        my $file_path = Encode::decode_utf8("$ARGSRef->{'Attach'}");
-        $session{'Attachments'} =
-          { %{ $session{'Attachments'} || {} }, $file_path => $attachment, };
-    }
-
-    # delete temporary storage entry to make WebUI clean
-    unless ( keys %{ $session{'Attachments'} } and $ARGSRef->{'UpdateAttach'} )
-    {
-        delete $session{'Attachments'};
-    }
-}
 
 
 =head2 ParseDateToISO
index b096286..d1479b5 100644 (file)
@@ -152,6 +152,9 @@ sub AsStringLong {
   if ( $table eq 'cust_main' ) {
 
     my $rec = $self->_FreesideGetRecord();
+    if (!$rec) {
+      return '<I>Customer #'.$self->{'fspkey'}.' (not found)</I>';
+    }
     return '<A HREF="' . $self->HREF . '">' .
                         small_custview( $rec->{'_object'},
                            scalar(FS::Conf->new->config('countrydefault')),
@@ -192,21 +195,37 @@ sub CustomerResolver {
   }
   elsif ( $self->{fstable} eq 'cust_svc' ) {
     my $rec = $self->_FreesideGetRecord();
-    return if !$rec;
-    my $cust_pkg = $rec->{'_object'}->cust_pkg;
-    if ( $cust_pkg ) {
-      my $URI = RT::URI->new($self->CurrentUser);
-      $URI->FromURI('freeside://freeside/cust_main/'.$cust_pkg->custnum);
-      return $URI->Resolver;
+    if ($rec) {
+      my $cust_pkg = $rec->{'_object'}->cust_pkg;
+      if ( $cust_pkg ) {
+        my $URI = RT::URI->new($self->CurrentUser);
+        $URI->FromURI('freeside://freeside/cust_main/'.$cust_pkg->custnum);
+        return $URI->Resolver;
+      }
     }
+    return;
   }
   return;
 }
 
 sub CustomerInfo {
   my $self = shift;
-  $self = $self->CustomerResolver or return;
-  my $rec = $self->_FreesideGetRecord() or return;
+  $self = $self->CustomerResolver;
+  my $rec = $self->_FreesideGetRecord() if $self;
+  if (!$rec) {
+    # AsStringLong will report an error;
+    # here, just avoid breaking things
+    my $error = {
+      AgentName     => '',
+      CustomerClass => '',
+      CustomerTags  => [],
+      Referral      => '',
+      InvoiceEmail  => '',
+      BillingType   => '',
+    };
+    return $error;
+  }
+
   my $cust_main = delete $rec->{_object};
   my $agent = $cust_main->agent;
   my $class = $cust_main->cust_class;
index d90ef1c..fed6783 100644 (file)
@@ -43,10 +43,12 @@ while (my $link = $customers->Next) {
   } elsif ( $uri =~ /cust_svc\/(\d+)/ ) {
     my $svc = $link->TargetURI->Resolver;
     my $cust = $svc->CustomerResolver;
-    my $custnum = $cust->{fspkey};
-    $cust_main{$custnum} ||= $cust;
-    $cust_svc{$custnum} ||= [];
-    push @{$cust_svc{$custnum}}, $svc;
+    if ( $cust ) {
+      my $custnum = $cust->{fspkey};
+      $cust_main{$custnum} ||= $cust if $cust;
+      $cust_svc{$custnum} ||= [];
+      push @{$cust_svc{$custnum}}, $svc if $svc;
+    }
   }
 }
 @custnums = sort { $a <=> $b } keys %cust_main;
index 26a37e8..8a3d8e3 100755 (executable)
@@ -290,6 +290,7 @@ if ( $ARGS{'SubmitTicket'} ) {
 
     my %squelched = ProcessTransactionSquelching( \%ARGS );
     $ARGS{'SquelchMailTo'} = [keys %squelched] if keys %squelched;
+warn @{ $ARGS{'SquelchMailTo'} } if $ARGS{'SquelchMailTo'};
 
     my $CFs = $TicketObj->TransactionCustomFields;
     my $ValidCFs = $m->comp(
@@ -311,6 +312,7 @@ if ( $ARGS{'SubmitTicket'} ) {
     );
     $checks_failure = 1 unless $status;
 }
+warn @{ $ARGS{'SquelchMailTo'} } if $ARGS{'SquelchMailTo'};
 
 # check email addresses for RT's
 {