diff options
author | Ivan Kohler <ivan@freeside.biz> | 2013-10-08 23:00:26 -0700 |
---|---|---|
committer | Ivan Kohler <ivan@freeside.biz> | 2013-10-08 23:00:26 -0700 |
commit | fe4515eb37d76849dd08c62782d86bc7ba311dcd (patch) | |
tree | 6952cc3598de0c72b6a3eab1d53bde07a16c27f2 /FS | |
parent | f2766e203e1aa144d046a26cf13e01e1f5b00f64 (diff) | |
parent | 81ae0992cf8506c6a77485548ebde25eb946a9a9 (diff) |
Merge branch 'master' of git.freeside.biz:/home/git/freeside
Conflicts:
FS/FS/cust_main.pm
Diffstat (limited to 'FS')
40 files changed, 1464 insertions, 388 deletions
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm index 03280c484..16bbaad60 100644 --- a/FS/FS/Conf.pm +++ b/FS/FS/Conf.pm @@ -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" }, diff --git a/FS/FS/ConfDefaults.pm b/FS/FS/ConfDefaults.pm index de65b44a9..191ff8537 100644 --- a/FS/FS/ConfDefaults.pm +++ b/FS/FS/ConfDefaults.pm @@ -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)', diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm index 780e3ffaf..1215ca414 100644 --- a/FS/FS/Mason.pm +++ b/FS/FS/Mason.pm @@ -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 ) { diff --git a/FS/FS/Misc/Geo.pm b/FS/FS/Misc/Geo.pm index a93d98f93..b5cc325d1 100644 --- a/FS/FS/Misc/Geo.pm +++ b/FS/FS/Misc/Geo.pm @@ -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 diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm index fd035249b..71eddc1eb 100644 --- a/FS/FS/Record.pm +++ b/FS/FS/Record.pm @@ -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) diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm index ed3790452..e44b74edc 100644 --- a/FS/FS/Schema.pm +++ b/FS/FS/Schema.pm @@ -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 diff --git a/FS/FS/Template_Mixin.pm b/FS/FS/Template_Mixin.pm index db3885443..840df7558 100644 --- a/FS/FS/Template_Mixin.pm +++ b/FS/FS/Template_Mixin.pm @@ -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 ); diff --git a/FS/FS/TicketSystem.pm b/FS/FS/TicketSystem.pm index c1c69fa3f..fa54e0bbd 100644 --- a/FS/FS/TicketSystem.pm +++ b/FS/FS/TicketSystem.pm @@ -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; } diff --git a/FS/FS/UI/Web.pm b/FS/FS/UI/Web.pm index ccba1de3a..d7f998bdf 100644 --- a/FS/FS/UI/Web.pm +++ b/FS/FS/UI/Web.pm @@ -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 = (); diff --git a/FS/FS/agent.pm b/FS/FS/agent.pm index 0cd07ef15..d70ff18b4 100644 --- a/FS/FS/agent.pm +++ b/FS/FS/agent.pm @@ -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 index 000000000..e988192f4 --- /dev/null +++ b/FS/FS/cable_provider.pm @@ -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; + diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm index fc6a7ddbe..a747a782d 100644 --- a/FS/FS/cust_bill.pm +++ b/FS/FS/cust_bill.pm @@ -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; diff --git a/FS/FS/cust_location.pm b/FS/FS/cust_location.pm index b98ade157..11e97ecfe 100644 --- a/FS/FS/cust_location.pm +++ b/FS/FS/cust_location.pm @@ -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 diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm index 641d54a30..a9a4cb0ef 100644 --- a/FS/FS/cust_main.pm +++ b/FS/FS/cust_main.pm @@ -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); diff --git a/FS/FS/cust_main/Search.pm b/FS/FS/cust_main/Search.pm index f14f897ea..182527ff9 100644 --- a/FS/FS/cust_main/Search.pm +++ b/FS/FS/cust_main/Search.pm @@ -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).") + )"; + } } ## diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm index 212c04e0f..f584b415e 100644 --- a/FS/FS/cust_main_Mixin.pm +++ b/FS/FS/cust_main_Mixin.pm @@ -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 diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm index a61d67e11..10a007c57 100644 --- a/FS/FS/cust_main_county.pm +++ b/FS/FS/cust_main_county.pm @@ -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; diff --git a/FS/FS/cust_payby.pm b/FS/FS/cust_payby.pm index 5914ab5c5..d78c574e4 100644 --- a/FS/FS/cust_payby.pm +++ b/FS/FS/cust_payby.pm @@ -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; diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm index 19ef1f326..0cb1b50a2 100644 --- a/FS/FS/cust_pkg.pm +++ b/FS/FS/cust_pkg.pm @@ -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) ]; } diff --git a/FS/FS/cust_svc.pm b/FS/FS/cust_svc.pm index d6d7d4cf1..958209049 100644 --- a/FS/FS/cust_svc.pm +++ b/FS/FS/cust_svc.pm @@ -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 index 000000000..043cab03c --- /dev/null +++ b/FS/FS/invoice_conf.pm @@ -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 index 000000000..115dd4469 --- /dev/null +++ b/FS/FS/invoice_mode.pm @@ -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; + diff --git a/FS/FS/part_event/Action/cust_bill_email.pm b/FS/FS/part_event/Action/cust_bill_email.pm index 1a3bca4b7..3331a4cb6 100644 --- a/FS/FS/part_event/Action/cust_bill_email.pm +++ b/FS/FS/part_event/Action/cust_bill_email.pm @@ -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; } diff --git a/FS/FS/part_event/Action/cust_bill_print.pm b/FS/FS/part_event/Action/cust_bill_print.pm index 6b3e6f460..ea6e0aa8e 100644 --- a/FS/FS/part_event/Action/cust_bill_print.pm +++ b/FS/FS/part_event/Action/cust_bill_print.pm @@ -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; } diff --git a/FS/FS/part_event/Action/cust_bill_print_pdf.pm b/FS/FS/part_event/Action/cust_bill_print_pdf.pm index 6b37f389f..6c01d4294 100644 --- a/FS/FS/part_event/Action/cust_bill_print_pdf.pm +++ b/FS/FS/part_event/Action/cust_bill_print_pdf.pm @@ -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); } diff --git a/FS/FS/part_event/Action/cust_bill_send.pm b/FS/FS/part_event/Action/cust_bill_send.pm index 587a7c664..c6928dc00 100644 --- a/FS/FS/part_event/Action/cust_bill_send.pm +++ b/FS/FS/part_event/Action/cust_bill_send.pm @@ -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; } diff --git a/FS/FS/part_event/Action/cust_bill_send_agent.pm b/FS/FS/part_event/Action/cust_bill_send_agent.pm index 670a32c5b..bbb757b59 100644 --- a/FS/FS/part_event/Action/cust_bill_send_agent.pm +++ b/FS/FS/part_event/Action/cust_bill_send_agent.pm @@ -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'), ); } diff --git a/FS/FS/part_event/Action/cust_bill_send_alternate.pm b/FS/FS/part_event/Action/cust_bill_send_alternate.pm index cfd9264d8..fb71a5a39 100644 --- a/FS/FS/part_event/Action/cust_bill_send_alternate.pm +++ b/FS/FS/part_event/Action/cust_bill_send_alternate.pm @@ -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; diff --git a/FS/FS/part_event/Action/cust_bill_send_if_newest.pm b/FS/FS/part_event/Action/cust_bill_send_if_newest.pm index 083da8b08..c744362ce 100644 --- a/FS/FS/part_event/Action/cust_bill_send_if_newest.pm +++ b/FS/FS/part_event/Action/cust_bill_send_if_newest.pm @@ -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; diff --git a/FS/FS/part_event/Action/cust_bill_send_reminder.pm b/FS/FS/part_event/Action/cust_bill_send_reminder.pm index 073bb8fd3..354f969d4 100644 --- a/FS/FS/part_event/Action/cust_bill_send_reminder.pm +++ b/FS/FS/part_event/Action/cust_bill_send_reminder.pm @@ -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'), diff --git a/FS/FS/part_event/Action/cust_statement_send.pm b/FS/FS/part_event/Action/cust_statement_send.pm index 74cc48ca8..67a94aaa1 100644 --- a/FS/FS/part_event/Action/cust_statement_send.pm +++ b/FS/FS/part_event/Action/cust_statement_send.pm @@ -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? } diff --git a/FS/FS/part_event/Action/fee.pm b/FS/FS/part_event/Action/fee.pm index cd9e200c8..c2b4673fa 100644 --- a/FS/FS/part_event/Action/fee.pm +++ b/FS/FS/part_event/Action/fee.pm @@ -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); } } diff --git a/FS/FS/part_event/Condition/pkg_age_Common.pm b/FS/FS/part_event/Condition/pkg_age_Common.pm index 726b01d70..33e49b8a6 100644 --- a/FS/FS/part_event/Condition/pkg_age_Common.pm +++ b/FS/FS/part_event/Condition/pkg_age_Common.pm @@ -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'} ); } diff --git a/FS/FS/part_export/domain_shellcommands.pm b/FS/FS/part_export/domain_shellcommands.pm index 582e29217..8e85d71e1 100644 --- a/FS/FS/part_export/domain_shellcommands.pm +++ b/FS/FS/part_export/domain_shellcommands.pm @@ -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 ); diff --git a/FS/FS/part_export/shellcommands_withdomain.pm b/FS/FS/part_export/shellcommands_withdomain.pm index 1b59589bf..29715b75b 100644 --- a/FS/FS/part_export/shellcommands_withdomain.pm +++ b/FS/FS/part_export/shellcommands_withdomain.pm @@ -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 ); diff --git a/FS/FS/svc_cable.pm b/FS/FS/svc_cable.pm index 1980c0ee9..596f69995 100644 --- a/FS/FS/svc_cable.pm +++ b/FS/FS/svc_cable.pm @@ -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. diff --git a/FS/MANIFEST b/FS/MANIFEST index a3b11f717..5dbe754c1 100644 --- a/FS/MANIFEST +++ b/FS/MANIFEST @@ -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 index 000000000..c794379a9 --- /dev/null +++ b/FS/t/cable_provider.t @@ -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 index 000000000..b707fa3f0 --- /dev/null +++ b/FS/t/invoice_conf.t @@ -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 index 000000000..5f945f0d4 --- /dev/null +++ b/FS/t/invoice_mode.t @@ -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"; |