time parts of cust_main upgrade, RT#17629
[freeside.git] / FS / FS / cust_main.pm
1 package FS::cust_main;
2
3 require 5.006;
4 use strict;
5              #FS::cust_main:_Marketgear when they're ready to move to 2.1
6 use base qw( FS::cust_main::Packages FS::cust_main::Status
7              FS::cust_main::Billing FS::cust_main::Billing_Realtime
8              FS::cust_main::Billing_Discount
9              FS::cust_main::Location
10              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
11              FS::geocode_Mixin
12              FS::o2m_Common
13              FS::Record
14            );
15 use vars qw( $DEBUG $me $conf
16              @encrypted_fields
17              $import
18              $ignore_expired_card $ignore_banned_card $ignore_illegal_zip
19              $skip_fuzzyfiles
20              @paytypes
21            );
22 use Carp;
23 use Scalar::Util qw( blessed );
24 use Time::Local qw(timelocal);
25 use Storable qw(thaw);
26 use MIME::Base64;
27 use Data::Dumper;
28 use Tie::IxHash;
29 use Digest::MD5 qw(md5_base64);
30 use Date::Format;
31 #use Date::Manip;
32 use File::Temp; #qw( tempfile );
33 use Business::CreditCard 0.28;
34 use Locale::Country;
35 use FS::UID qw( getotaker dbh driver_name );
36 use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
37 use FS::Misc qw( generate_email send_email generate_ps do_print );
38 use FS::Msgcat qw(gettext);
39 use FS::CurrentUser;
40 use FS::TicketSystem;
41 use FS::payby;
42 use FS::cust_pkg;
43 use FS::cust_svc;
44 use FS::cust_bill;
45 use FS::legacy_cust_bill;
46 use FS::cust_pay;
47 use FS::cust_pay_pending;
48 use FS::cust_pay_void;
49 use FS::cust_pay_batch;
50 use FS::cust_credit;
51 use FS::cust_refund;
52 use FS::part_referral;
53 use FS::cust_main_county;
54 use FS::cust_location;
55 use FS::cust_class;
56 use FS::cust_main_exemption;
57 use FS::cust_tax_adjustment;
58 use FS::cust_tax_location;
59 use FS::agent;
60 use FS::cust_main_invoice;
61 use FS::cust_tag;
62 use FS::prepay_credit;
63 use FS::queue;
64 use FS::part_pkg;
65 use FS::part_export;
66 #use FS::cust_event;
67 use FS::type_pkgs;
68 use FS::payment_gateway;
69 use FS::agent_payment_gateway;
70 use FS::banned_pay;
71 use FS::cust_main_note;
72 use FS::cust_attachment;
73 use FS::contact;
74 use FS::Locales;
75
76 # 1 is mostly method/subroutine entry and options
77 # 2 traces progress of some operations
78 # 3 is even more information including possibly sensitive data
79 $DEBUG = 0;
80 $me = '[FS::cust_main]';
81
82 $import = 0;
83 $ignore_expired_card = 0;
84 $ignore_banned_card = 0;
85
86 $skip_fuzzyfiles = 0;
87
88 @encrypted_fields = ('payinfo', 'paycvv');
89 sub nohistory_fields { ('payinfo', 'paycvv'); }
90
91 @paytypes = ('', 'Personal checking', 'Personal savings', 'Business checking', 'Business savings');
92
93 #ask FS::UID to run this stuff for us later
94 #$FS::UID::callback{'FS::cust_main'} = sub { 
95 install_callback FS::UID sub { 
96   $conf = new FS::Conf;
97   #yes, need it for stuff below (prolly should be cached)
98 };
99
100 sub _cache {
101   my $self = shift;
102   my ( $hashref, $cache ) = @_;
103   if ( exists $hashref->{'pkgnum'} ) {
104     #@{ $self->{'_pkgnum'} } = ();
105     my $subcache = $cache->subcache( 'pkgnum', 'cust_pkg', $hashref->{custnum});
106     $self->{'_pkgnum'} = $subcache;
107     #push @{ $self->{'_pkgnum'} },
108     FS::cust_pkg->new_or_cached($hashref, $subcache) if $hashref->{pkgnum};
109   }
110 }
111
112 =head1 NAME
113
114 FS::cust_main - Object methods for cust_main records
115
116 =head1 SYNOPSIS
117
118   use FS::cust_main;
119
120   $record = new FS::cust_main \%hash;
121   $record = new FS::cust_main { 'column' => 'value' };
122
123   $error = $record->insert;
124
125   $error = $new_record->replace($old_record);
126
127   $error = $record->delete;
128
129   $error = $record->check;
130
131   @cust_pkg = $record->all_pkgs;
132
133   @cust_pkg = $record->ncancelled_pkgs;
134
135   @cust_pkg = $record->suspended_pkgs;
136
137   $error = $record->bill;
138   $error = $record->bill %options;
139   $error = $record->bill 'time' => $time;
140
141   $error = $record->collect;
142   $error = $record->collect %options;
143   $error = $record->collect 'invoice_time'   => $time,
144                           ;
145
146 =head1 DESCRIPTION
147
148 An FS::cust_main object represents a customer.  FS::cust_main inherits from 
149 FS::Record.  The following fields are currently supported:
150
151 =over 4
152
153 =item custnum
154
155 Primary key (assigned automatically for new customers)
156
157 =item agentnum
158
159 Agent (see L<FS::agent>)
160
161 =item refnum
162
163 Advertising source (see L<FS::part_referral>)
164
165 =item first
166
167 First name
168
169 =item last
170
171 Last name
172
173 =item ss
174
175 Cocial security number (optional)
176
177 =item company
178
179 (optional)
180
181 =item daytime
182
183 phone (optional)
184
185 =item night
186
187 phone (optional)
188
189 =item fax
190
191 phone (optional)
192
193 =item mobile
194
195 phone (optional)
196
197 =item payby
198
199 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
200
201 =item payinfo
202
203 Payment Information (See L<FS::payinfo_Mixin> for data format)
204
205 =item paymask
206
207 Masked payinfo (See L<FS::payinfo_Mixin> for how this works)
208
209 =item paycvv
210
211 Card Verification Value, "CVV2" (also known as CVC2 or CID), the 3 or 4 digit number on the back (or front, for American Express) of the credit card
212
213 =item paydate
214
215 Expiration date, mm/yyyy, m/yyyy, mm/yy or m/yy
216
217 =item paystart_month
218
219 Start date month (maestro/solo cards only)
220
221 =item paystart_year
222
223 Start date year (maestro/solo cards only)
224
225 =item payissue
226
227 Issue number (maestro/solo cards only)
228
229 =item payname
230
231 Name on card or billing name
232
233 =item payip
234
235 IP address from which payment information was received
236
237 =item tax
238
239 Tax exempt, empty or `Y'
240
241 =item usernum
242
243 Order taker (see L<FS::access_user>)
244
245 =item comments
246
247 Comments (optional)
248
249 =item referral_custnum
250
251 Referring customer number
252
253 =item spool_cdr
254
255 Enable individual CDR spooling, empty or `Y'
256
257 =item dundate
258
259 A suggestion to events (see L<FS::part_bill_event">) to delay until this unix timestamp
260
261 =item squelch_cdr
262
263 Discourage individual CDR printing, empty or `Y'
264
265 =item edit_subject
266
267 Allow self-service editing of ticket subjects, empty or 'Y'
268
269 =item calling_list_exempt
270
271 Do not call, empty or 'Y'
272
273 =back
274
275 =head1 METHODS
276
277 =over 4
278
279 =item new HASHREF
280
281 Creates a new customer.  To add the customer to the database, see L<"insert">.
282
283 Note that this stores the hash reference, not a distinct copy of the hash it
284 points to.  You can ask the object for a copy with the I<hash> method.
285
286 =cut
287
288 sub table { 'cust_main'; }
289
290 =item insert [ CUST_PKG_HASHREF [ , INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
291
292 Adds this customer to the database.  If there is an error, returns the error,
293 otherwise returns false.
294
295 Usually the customer's location will not yet exist in the database, and
296 the C<bill_location> and C<ship_location> pseudo-fields must be set to 
297 uninserted L<FS::cust_location> objects.  These will be inserted and linked
298 (in both directions) to the new customer record.  If they're references 
299 to the same object, they will become the same location.
300
301 CUST_PKG_HASHREF: If you pass a Tie::RefHash data structure to the insert
302 method containing FS::cust_pkg and FS::svc_I<tablename> objects, all records
303 are inserted atomicly, or the transaction is rolled back.  Passing an empty
304 hash reference is equivalent to not supplying this parameter.  There should be
305 a better explanation of this, but until then, here's an example:
306
307   use Tie::RefHash;
308   tie %hash, 'Tie::RefHash'; #this part is important
309   %hash = (
310     $cust_pkg => [ $svc_acct ],
311     ...
312   );
313   $cust_main->insert( \%hash );
314
315 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
316 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
317 expected and rollback the entire transaction; it is not necessary to call 
318 check_invoicing_list first.  The invoicing_list is set after the records in the
319 CUST_PKG_HASHREF above are inserted, so it is now possible to set an
320 invoicing_list destination to the newly-created svc_acct.  Here's an example:
321
322   $cust_main->insert( {}, [ $email, 'POST' ] );
323
324 Currently available options are: I<depend_jobnum>, I<noexport>,
325 I<tax_exemption> and I<prospectnum>.
326
327 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
328 on the supplied jobnum (they will not run until the specific job completes).
329 This can be used to defer provisioning until some action completes (such
330 as running the customer's credit card successfully).
331
332 The I<noexport> option is deprecated.  If I<noexport> is set true, no
333 provisioning jobs (exports) are scheduled.  (You can schedule them later with
334 the B<reexport> method.)
335
336 The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
337 of tax names and exemption numbers.  FS::cust_main_exemption records will be
338 created and inserted.
339
340 If I<prospectnum> is set, moves contacts and locations from that prospect.
341
342 =cut
343
344 sub insert {
345   my $self = shift;
346   my $cust_pkgs = @_ ? shift : {};
347   my $invoicing_list = @_ ? shift : '';
348   my %options = @_;
349   warn "$me insert called with options ".
350        join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
351     if $DEBUG;
352
353   local $SIG{HUP} = 'IGNORE';
354   local $SIG{INT} = 'IGNORE';
355   local $SIG{QUIT} = 'IGNORE';
356   local $SIG{TERM} = 'IGNORE';
357   local $SIG{TSTP} = 'IGNORE';
358   local $SIG{PIPE} = 'IGNORE';
359
360   my $oldAutoCommit = $FS::UID::AutoCommit;
361   local $FS::UID::AutoCommit = 0;
362   my $dbh = dbh;
363
364   my $prepay_identifier = '';
365   my( $amount, $seconds, $upbytes, $downbytes, $totalbytes ) = (0, 0, 0, 0, 0);
366   my $payby = '';
367   if ( $self->payby eq 'PREPAY' ) {
368
369     $self->payby('BILL');
370     $prepay_identifier = $self->payinfo;
371     $self->payinfo('');
372
373     warn "  looking up prepaid card $prepay_identifier\n"
374       if $DEBUG > 1;
375
376     my $error = $self->get_prepay( $prepay_identifier,
377                                    'amount_ref'     => \$amount,
378                                    'seconds_ref'    => \$seconds,
379                                    'upbytes_ref'    => \$upbytes,
380                                    'downbytes_ref'  => \$downbytes,
381                                    'totalbytes_ref' => \$totalbytes,
382                                  );
383     if ( $error ) {
384       $dbh->rollback if $oldAutoCommit;
385       #return "error applying prepaid card (transaction rolled back): $error";
386       return $error;
387     }
388
389     $payby = 'PREP' if $amount;
390
391   } elsif ( $self->payby =~ /^(CASH|WEST|MCRD)$/ ) {
392
393     $payby = $1;
394     $self->payby('BILL');
395     $amount = $self->paid;
396
397   }
398
399   # insert locations
400   foreach my $l (qw(bill_location ship_location)) {
401     my $loc = delete $self->hashref->{$l};
402     # XXX if we're moving a prospect's locations, do that here
403     if ( !$loc ) {
404       return "$l not set";
405     }
406     
407     if ( !$loc->locationnum ) {
408       # warn the location that we're going to insert it with no custnum
409       $loc->set(custnum_pending => 1);
410       warn "  inserting $l\n"
411         if $DEBUG > 1;
412       my $error = $loc->insert;
413       if ( $error ) {
414         $dbh->rollback if $oldAutoCommit;
415         my $label = $l eq 'ship_location' ? 'service' : 'billing';
416         return "$error (in $label location)";
417       }
418     }
419     elsif ( ($loc->custnum || 0) > 0 or $loc->prospectnum ) {
420       # then it somehow belongs to another customer--shouldn't happen
421       $dbh->rollback if $oldAutoCommit;
422       return "$l belongs to customer ".$loc->custnum;
423     }
424     # else it already belongs to this customer 
425     # (happens when ship_location is identical to bill_location)
426
427     $self->set($l.'num', $loc->locationnum);
428
429     if ( $self->get($l.'num') eq '' ) {
430       $dbh->rollback if $oldAutoCommit;
431       return "$l not set";
432     }
433   }
434
435   warn "  inserting $self\n"
436     if $DEBUG > 1;
437
438   $self->signupdate(time) unless $self->signupdate;
439
440   $self->auto_agent_custid()
441     if $conf->config('cust_main-auto_agent_custid') && ! $self->agent_custid;
442
443   my $error = $self->SUPER::insert;
444   if ( $error ) {
445     $dbh->rollback if $oldAutoCommit;
446     #return "inserting cust_main record (transaction rolled back): $error";
447     return $error;
448   }
449
450   # now set cust_location.custnum
451   foreach my $l (qw(bill_location ship_location)) {
452     warn "  setting $l.custnum\n"
453       if $DEBUG > 1;
454     my $loc = $self->$l;
455     $loc->set(custnum => $self->custnum);
456     $error ||= $loc->replace;
457
458     if ( $error ) {
459       $dbh->rollback if $oldAutoCommit;
460       return "error setting $l custnum: $error";
461     }
462   }
463
464   warn "  setting invoicing list\n"
465     if $DEBUG > 1;
466
467   if ( $invoicing_list ) {
468     $error = $self->check_invoicing_list( $invoicing_list );
469     if ( $error ) {
470       $dbh->rollback if $oldAutoCommit;
471       #return "checking invoicing_list (transaction rolled back): $error";
472       return $error;
473     }
474     $self->invoicing_list( $invoicing_list );
475   }
476
477   warn "  setting customer tags\n"
478     if $DEBUG > 1;
479
480   foreach my $tagnum ( @{ $self->tagnum || [] } ) {
481     my $cust_tag = new FS::cust_tag { 'tagnum'  => $tagnum,
482                                       'custnum' => $self->custnum };
483     my $error = $cust_tag->insert;
484     if ( $error ) {
485       $dbh->rollback if $oldAutoCommit;
486       return $error;
487     }
488   }
489
490   my $prospectnum = delete $options{'prospectnum'};
491   if ( $prospectnum ) {
492
493     warn "  moving contacts and locations from prospect $prospectnum\n"
494       if $DEBUG > 1;
495
496     my $prospect_main =
497       qsearchs('prospect_main', { 'prospectnum' => $prospectnum } );
498     unless ( $prospect_main ) {
499       $dbh->rollback if $oldAutoCommit;
500       return "Unknown prospectnum $prospectnum";
501     }
502     $prospect_main->custnum($self->custnum);
503     $prospect_main->disabled('Y');
504     my $error = $prospect_main->replace;
505     if ( $error ) {
506       $dbh->rollback if $oldAutoCommit;
507       return $error;
508     }
509
510     my @contact = $prospect_main->contact;
511     my @cust_location = $prospect_main->cust_location;
512     my @qual = $prospect_main->qual;
513
514     foreach my $r ( @contact, @cust_location, @qual ) {
515       $r->prospectnum('');
516       $r->custnum($self->custnum);
517       my $error = $r->replace;
518       if ( $error ) {
519         $dbh->rollback if $oldAutoCommit;
520         return $error;
521       }
522     }
523
524   }
525
526   warn "  setting cust_main_exemption\n"
527     if $DEBUG > 1;
528
529   my $tax_exemption = delete $options{'tax_exemption'};
530   if ( $tax_exemption ) {
531
532     $tax_exemption = { map { $_ => '' } @$tax_exemption }
533       if ref($tax_exemption) eq 'ARRAY';
534
535     foreach my $taxname ( keys %$tax_exemption ) {
536       my $cust_main_exemption = new FS::cust_main_exemption {
537         'custnum'       => $self->custnum,
538         'taxname'       => $taxname,
539         'exempt_number' => $tax_exemption->{$taxname},
540       };
541       my $error = $cust_main_exemption->insert;
542       if ( $error ) {
543         $dbh->rollback if $oldAutoCommit;
544         return "inserting cust_main_exemption (transaction rolled back): $error";
545       }
546     }
547   }
548
549   if ( $self->can('start_copy_skel') ) {
550     my $error = $self->start_copy_skel;
551     if ( $error ) {
552       $dbh->rollback if $oldAutoCommit;
553       return $error;
554     }
555   }
556
557   warn "  ordering packages\n"
558     if $DEBUG > 1;
559
560   $error = $self->order_pkgs( $cust_pkgs,
561                               %options,
562                               'seconds_ref'    => \$seconds,
563                               'upbytes_ref'    => \$upbytes,
564                               'downbytes_ref'  => \$downbytes,
565                               'totalbytes_ref' => \$totalbytes,
566                             );
567   if ( $error ) {
568     $dbh->rollback if $oldAutoCommit;
569     return $error;
570   }
571
572   if ( $seconds ) {
573     $dbh->rollback if $oldAutoCommit;
574     return "No svc_acct record to apply pre-paid time";
575   }
576   if ( $upbytes || $downbytes || $totalbytes ) {
577     $dbh->rollback if $oldAutoCommit;
578     return "No svc_acct record to apply pre-paid data";
579   }
580
581   if ( $amount ) {
582     warn "  inserting initial $payby payment of $amount\n"
583       if $DEBUG > 1;
584     $error = $self->insert_cust_pay($payby, $amount, $prepay_identifier);
585     if ( $error ) {
586       $dbh->rollback if $oldAutoCommit;
587       return "inserting payment (transaction rolled back): $error";
588     }
589   }
590
591   unless ( $import || $skip_fuzzyfiles ) {
592     warn "  queueing fuzzyfiles update\n"
593       if $DEBUG > 1;
594     $error = $self->queue_fuzzyfiles_update;
595     if ( $error ) {
596       $dbh->rollback if $oldAutoCommit;
597       return "updating fuzzy search cache: $error";
598     }
599   }
600
601   # FS::geocode_Mixin::after_insert or something?
602   if ( $conf->config('tax_district_method') and !$import ) {
603     # if anything non-empty, try to look it up
604     my $queue = new FS::queue {
605       'job'     => 'FS::geocode_Mixin::process_district_update',
606       'custnum' => $self->custnum,
607     };
608     my $error = $queue->insert( ref($self), $self->custnum );
609     if ( $error ) {
610       $dbh->rollback if $oldAutoCommit;
611       return "queueing tax district update: $error";
612     }
613   }
614
615   # cust_main exports!
616   warn "  exporting\n" if $DEBUG > 1;
617
618   my $export_args = $options{'export_args'} || [];
619
620   my @part_export =
621     map qsearch( 'part_export', {exportnum=>$_} ),
622       $conf->config('cust_main-exports'); #, $agentnum
623
624   foreach my $part_export ( @part_export ) {
625     my $error = $part_export->export_insert($self, @$export_args);
626     if ( $error ) {
627       $dbh->rollback if $oldAutoCommit;
628       return "exporting to ". $part_export->exporttype.
629              " (transaction rolled back): $error";
630     }
631   }
632
633   #foreach my $depend_jobnum ( @$depend_jobnums ) {
634   #    warn "[$me] inserting dependancies on supplied job $depend_jobnum\n"
635   #      if $DEBUG;
636   #    foreach my $jobnum ( @jobnums ) {
637   #      my $queue = qsearchs('queue', { 'jobnum' => $jobnum } );
638   #      warn "[$me] inserting dependancy for job $jobnum on $depend_jobnum\n"
639   #        if $DEBUG;
640   #      my $error = $queue->depend_insert($depend_jobnum);
641   #      if ( $error ) {
642   #        $dbh->rollback if $oldAutoCommit;
643   #        return "error queuing job dependancy: $error";
644   #      }
645   #    }
646   #  }
647   #
648   #}
649   #
650   #if ( exists $options{'jobnums'} ) {
651   #  push @{ $options{'jobnums'} }, @jobnums;
652   #}
653
654   warn "  insert complete; committing transaction\n"
655     if $DEBUG > 1;
656
657   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
658   '';
659
660 }
661
662 use File::CounterFile;
663 sub auto_agent_custid {
664   my $self = shift;
665
666   my $format = $conf->config('cust_main-auto_agent_custid');
667   my $agent_custid;
668   if ( $format eq '1YMMXXXXXXXX' ) {
669
670     my $counter = new File::CounterFile 'cust_main.agent_custid';
671     $counter->lock;
672
673     my $ym = 100000000000 + time2str('%y%m00000000', time);
674     if ( $ym > $counter->value ) {
675       $counter->{'value'} = $agent_custid = $ym;
676       $counter->{'updated'} = 1;
677     } else {
678       $agent_custid = $counter->inc;
679     }
680
681     $counter->unlock;
682
683   } else {
684     die "Unknown cust_main-auto_agent_custid format: $format";
685   }
686
687   $self->agent_custid($agent_custid);
688
689 }
690
691 =item PACKAGE METHODS
692
693 Documentation on customer package methods has been moved to
694 L<FS::cust_main::Packages>.
695
696 =item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
697
698 Recharges this (existing) customer with the specified prepaid card (see
699 L<FS::prepay_credit>), specified either by I<identifier> or as an
700 FS::prepay_credit object.  If there is an error, returns the error, otherwise
701 returns false.
702
703 Optionally, five scalar references can be passed as well.  They will have their
704 values filled in with the amount, number of seconds, and number of upload,
705 download, and total bytes applied by this prepaid card.
706
707 =cut
708
709 #the ref bullshit here should be refactored like get_prepay.  MyAccount.pm is
710 #the only place that uses these args
711 sub recharge_prepay { 
712   my( $self, $prepay_credit, $amountref, $secondsref, 
713       $upbytesref, $downbytesref, $totalbytesref ) = @_;
714
715   local $SIG{HUP} = 'IGNORE';
716   local $SIG{INT} = 'IGNORE';
717   local $SIG{QUIT} = 'IGNORE';
718   local $SIG{TERM} = 'IGNORE';
719   local $SIG{TSTP} = 'IGNORE';
720   local $SIG{PIPE} = 'IGNORE';
721
722   my $oldAutoCommit = $FS::UID::AutoCommit;
723   local $FS::UID::AutoCommit = 0;
724   my $dbh = dbh;
725
726   my( $amount, $seconds, $upbytes, $downbytes, $totalbytes) = ( 0, 0, 0, 0, 0 );
727
728   my $error = $self->get_prepay( $prepay_credit,
729                                  'amount_ref'     => \$amount,
730                                  'seconds_ref'    => \$seconds,
731                                  'upbytes_ref'    => \$upbytes,
732                                  'downbytes_ref'  => \$downbytes,
733                                  'totalbytes_ref' => \$totalbytes,
734                                )
735            || $self->increment_seconds($seconds)
736            || $self->increment_upbytes($upbytes)
737            || $self->increment_downbytes($downbytes)
738            || $self->increment_totalbytes($totalbytes)
739            || $self->insert_cust_pay_prepay( $amount,
740                                              ref($prepay_credit)
741                                                ? $prepay_credit->identifier
742                                                : $prepay_credit
743                                            );
744
745   if ( $error ) {
746     $dbh->rollback if $oldAutoCommit;
747     return $error;
748   }
749
750   if ( defined($amountref)  ) { $$amountref  = $amount;  }
751   if ( defined($secondsref) ) { $$secondsref = $seconds; }
752   if ( defined($upbytesref) ) { $$upbytesref = $upbytes; }
753   if ( defined($downbytesref) ) { $$downbytesref = $downbytes; }
754   if ( defined($totalbytesref) ) { $$totalbytesref = $totalbytes; }
755
756   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
757   '';
758
759 }
760
761 =item get_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , OPTION => VALUE ... ]
762
763 Looks up and deletes a prepaid card (see L<FS::prepay_credit>),
764 specified either by I<identifier> or as an FS::prepay_credit object.
765
766 Available options are: I<amount_ref>, I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.  The scalars (provided by references) will be
767 incremented by the values of the prepaid card.
768
769 If the prepaid card specifies an I<agentnum> (see L<FS::agent>), it is used to
770 check or set this customer's I<agentnum>.
771
772 If there is an error, returns the error, otherwise returns false.
773
774 =cut
775
776
777 sub get_prepay {
778   my( $self, $prepay_credit, %opt ) = @_;
779
780   local $SIG{HUP} = 'IGNORE';
781   local $SIG{INT} = 'IGNORE';
782   local $SIG{QUIT} = 'IGNORE';
783   local $SIG{TERM} = 'IGNORE';
784   local $SIG{TSTP} = 'IGNORE';
785   local $SIG{PIPE} = 'IGNORE';
786
787   my $oldAutoCommit = $FS::UID::AutoCommit;
788   local $FS::UID::AutoCommit = 0;
789   my $dbh = dbh;
790
791   unless ( ref($prepay_credit) ) {
792
793     my $identifier = $prepay_credit;
794
795     $prepay_credit = qsearchs(
796       'prepay_credit',
797       { 'identifier' => $identifier },
798       '',
799       'FOR UPDATE'
800     );
801
802     unless ( $prepay_credit ) {
803       $dbh->rollback if $oldAutoCommit;
804       return "Invalid prepaid card: ". $identifier;
805     }
806
807   }
808
809   if ( $prepay_credit->agentnum ) {
810     if ( $self->agentnum && $self->agentnum != $prepay_credit->agentnum ) {
811       $dbh->rollback if $oldAutoCommit;
812       return "prepaid card not valid for agent ". $self->agentnum;
813     }
814     $self->agentnum($prepay_credit->agentnum);
815   }
816
817   my $error = $prepay_credit->delete;
818   if ( $error ) {
819     $dbh->rollback if $oldAutoCommit;
820     return "removing prepay_credit (transaction rolled back): $error";
821   }
822
823   ${ $opt{$_.'_ref'} } += $prepay_credit->$_()
824     for grep $opt{$_.'_ref'}, qw( amount seconds upbytes downbytes totalbytes );
825
826   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
827   '';
828
829 }
830
831 =item increment_upbytes SECONDS
832
833 Updates this customer's single or primary account (see L<FS::svc_acct>) by
834 the specified number of upbytes.  If there is an error, returns the error,
835 otherwise returns false.
836
837 =cut
838
839 sub increment_upbytes {
840   _increment_column( shift, 'upbytes', @_);
841 }
842
843 =item increment_downbytes SECONDS
844
845 Updates this customer's single or primary account (see L<FS::svc_acct>) by
846 the specified number of downbytes.  If there is an error, returns the error,
847 otherwise returns false.
848
849 =cut
850
851 sub increment_downbytes {
852   _increment_column( shift, 'downbytes', @_);
853 }
854
855 =item increment_totalbytes SECONDS
856
857 Updates this customer's single or primary account (see L<FS::svc_acct>) by
858 the specified number of totalbytes.  If there is an error, returns the error,
859 otherwise returns false.
860
861 =cut
862
863 sub increment_totalbytes {
864   _increment_column( shift, 'totalbytes', @_);
865 }
866
867 =item increment_seconds SECONDS
868
869 Updates this customer's single or primary account (see L<FS::svc_acct>) by
870 the specified number of seconds.  If there is an error, returns the error,
871 otherwise returns false.
872
873 =cut
874
875 sub increment_seconds {
876   _increment_column( shift, 'seconds', @_);
877 }
878
879 =item _increment_column AMOUNT
880
881 Updates this customer's single or primary account (see L<FS::svc_acct>) by
882 the specified number of seconds or bytes.  If there is an error, returns
883 the error, otherwise returns false.
884
885 =cut
886
887 sub _increment_column {
888   my( $self, $column, $amount ) = @_;
889   warn "$me increment_column called: $column, $amount\n"
890     if $DEBUG;
891
892   return '' unless $amount;
893
894   my @cust_pkg = grep { $_->part_pkg->svcpart('svc_acct') }
895                       $self->ncancelled_pkgs;
896
897   if ( ! @cust_pkg ) {
898     return 'No packages with primary or single services found'.
899            ' to apply pre-paid time';
900   } elsif ( scalar(@cust_pkg) > 1 ) {
901     #maybe have a way to specify the package/account?
902     return 'Multiple packages found to apply pre-paid time';
903   }
904
905   my $cust_pkg = $cust_pkg[0];
906   warn "  found package pkgnum ". $cust_pkg->pkgnum. "\n"
907     if $DEBUG > 1;
908
909   my @cust_svc =
910     $cust_pkg->cust_svc( $cust_pkg->part_pkg->svcpart('svc_acct') );
911
912   if ( ! @cust_svc ) {
913     return 'No account found to apply pre-paid time';
914   } elsif ( scalar(@cust_svc) > 1 ) {
915     return 'Multiple accounts found to apply pre-paid time';
916   }
917   
918   my $svc_acct = $cust_svc[0]->svc_x;
919   warn "  found service svcnum ". $svc_acct->pkgnum.
920        ' ('. $svc_acct->email. ")\n"
921     if $DEBUG > 1;
922
923   $column = "increment_$column";
924   $svc_acct->$column($amount);
925
926 }
927
928 =item insert_cust_pay_prepay AMOUNT [ PAYINFO ]
929
930 Inserts a prepayment in the specified amount for this customer.  An optional
931 second argument can specify the prepayment identifier for tracking purposes.
932 If there is an error, returns the error, otherwise returns false.
933
934 =cut
935
936 sub insert_cust_pay_prepay {
937   shift->insert_cust_pay('PREP', @_);
938 }
939
940 =item insert_cust_pay_cash AMOUNT [ PAYINFO ]
941
942 Inserts a cash payment in the specified amount for this customer.  An optional
943 second argument can specify the payment identifier for tracking purposes.
944 If there is an error, returns the error, otherwise returns false.
945
946 =cut
947
948 sub insert_cust_pay_cash {
949   shift->insert_cust_pay('CASH', @_);
950 }
951
952 =item insert_cust_pay_west AMOUNT [ PAYINFO ]
953
954 Inserts a Western Union payment in the specified amount for this customer.  An
955 optional second argument can specify the prepayment identifier for tracking
956 purposes.  If there is an error, returns the error, otherwise returns false.
957
958 =cut
959
960 sub insert_cust_pay_west {
961   shift->insert_cust_pay('WEST', @_);
962 }
963
964 sub insert_cust_pay {
965   my( $self, $payby, $amount ) = splice(@_, 0, 3);
966   my $payinfo = scalar(@_) ? shift : '';
967
968   my $cust_pay = new FS::cust_pay {
969     'custnum' => $self->custnum,
970     'paid'    => sprintf('%.2f', $amount),
971     #'_date'   => #date the prepaid card was purchased???
972     'payby'   => $payby,
973     'payinfo' => $payinfo,
974   };
975   $cust_pay->insert;
976
977 }
978
979 =item reexport
980
981 This method is deprecated.  See the I<depend_jobnum> option to the insert and
982 order_pkgs methods for a better way to defer provisioning.
983
984 Re-schedules all exports by calling the B<reexport> method of all associated
985 packages (see L<FS::cust_pkg>).  If there is an error, returns the error;
986 otherwise returns false.
987
988 =cut
989
990 sub reexport {
991   my $self = shift;
992
993   carp "WARNING: FS::cust_main::reexport is deprectated; ".
994        "use the depend_jobnum option to insert or order_pkgs to delay export";
995
996   local $SIG{HUP} = 'IGNORE';
997   local $SIG{INT} = 'IGNORE';
998   local $SIG{QUIT} = 'IGNORE';
999   local $SIG{TERM} = 'IGNORE';
1000   local $SIG{TSTP} = 'IGNORE';
1001   local $SIG{PIPE} = 'IGNORE';
1002
1003   my $oldAutoCommit = $FS::UID::AutoCommit;
1004   local $FS::UID::AutoCommit = 0;
1005   my $dbh = dbh;
1006
1007   foreach my $cust_pkg ( $self->ncancelled_pkgs ) {
1008     my $error = $cust_pkg->reexport;
1009     if ( $error ) {
1010       $dbh->rollback if $oldAutoCommit;
1011       return $error;
1012     }
1013   }
1014
1015   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1016   '';
1017
1018 }
1019
1020 =item delete [ OPTION => VALUE ... ]
1021
1022 This deletes the customer.  If there is an error, returns the error, otherwise
1023 returns false.
1024
1025 This will completely remove all traces of the customer record.  This is not
1026 what you want when a customer cancels service; for that, cancel all of the
1027 customer's packages (see L</cancel>).
1028
1029 If the customer has any uncancelled packages, you need to pass a new (valid)
1030 customer number for those packages to be transferred to, as the "new_customer"
1031 option.  Cancelled packages will be deleted.  Did I mention that this is NOT
1032 what you want when a customer cancels service and that you really should be
1033 looking at L<FS::cust_pkg/cancel>?  
1034
1035 You can't delete a customer with invoices (see L<FS::cust_bill>),
1036 statements (see L<FS::cust_statement>), credits (see L<FS::cust_credit>),
1037 payments (see L<FS::cust_pay>) or refunds (see L<FS::cust_refund>), unless you
1038 set the "delete_financials" option to a true value.
1039
1040 =cut
1041
1042 sub delete {
1043   my( $self, %opt ) = @_;
1044
1045   local $SIG{HUP} = 'IGNORE';
1046   local $SIG{INT} = 'IGNORE';
1047   local $SIG{QUIT} = 'IGNORE';
1048   local $SIG{TERM} = 'IGNORE';
1049   local $SIG{TSTP} = 'IGNORE';
1050   local $SIG{PIPE} = 'IGNORE';
1051
1052   my $oldAutoCommit = $FS::UID::AutoCommit;
1053   local $FS::UID::AutoCommit = 0;
1054   my $dbh = dbh;
1055
1056   if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
1057      $dbh->rollback if $oldAutoCommit;
1058      return "Can't delete a master agent customer";
1059   }
1060
1061   #use FS::access_user
1062   if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
1063      $dbh->rollback if $oldAutoCommit;
1064      return "Can't delete a master employee customer";
1065   }
1066
1067   tie my %financial_tables, 'Tie::IxHash',
1068     'cust_bill'      => 'invoices',
1069     'cust_statement' => 'statements',
1070     'cust_credit'    => 'credits',
1071     'cust_pay'       => 'payments',
1072     'cust_refund'    => 'refunds',
1073   ;
1074    
1075   foreach my $table ( keys %financial_tables ) {
1076
1077     my @records = $self->$table();
1078
1079     if ( @records && ! $opt{'delete_financials'} ) {
1080       $dbh->rollback if $oldAutoCommit;
1081       return "Can't delete a customer with ". $financial_tables{$table};
1082     }
1083
1084     foreach my $record ( @records ) {
1085       my $error = $record->delete;
1086       if ( $error ) {
1087         $dbh->rollback if $oldAutoCommit;
1088         return "Error deleting ". $financial_tables{$table}. ": $error\n";
1089       }
1090     }
1091
1092   }
1093
1094   my @cust_pkg = $self->ncancelled_pkgs;
1095   if ( @cust_pkg ) {
1096     my $new_custnum = $opt{'new_custnum'};
1097     unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
1098       $dbh->rollback if $oldAutoCommit;
1099       return "Invalid new customer number: $new_custnum";
1100     }
1101     foreach my $cust_pkg ( @cust_pkg ) {
1102       my %hash = $cust_pkg->hash;
1103       $hash{'custnum'} = $new_custnum;
1104       my $new_cust_pkg = new FS::cust_pkg ( \%hash );
1105       my $error = $new_cust_pkg->replace($cust_pkg,
1106                                          options => { $cust_pkg->options },
1107                                         );
1108       if ( $error ) {
1109         $dbh->rollback if $oldAutoCommit;
1110         return $error;
1111       }
1112     }
1113   }
1114   my @cancelled_cust_pkg = $self->all_pkgs;
1115   foreach my $cust_pkg ( @cancelled_cust_pkg ) {
1116     my $error = $cust_pkg->delete;
1117     if ( $error ) {
1118       $dbh->rollback if $oldAutoCommit;
1119       return $error;
1120     }
1121   }
1122
1123   #cust_tax_adjustment in financials?
1124   #cust_pay_pending?  ouch
1125   #cust_recon?
1126   foreach my $table (qw(
1127     cust_main_invoice cust_main_exemption cust_tag cust_attachment contact
1128     cust_location cust_main_note cust_tax_adjustment
1129     cust_pay_void cust_pay_batch queue cust_tax_exempt
1130   )) {
1131     foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
1132       my $error = $record->delete;
1133       if ( $error ) {
1134         $dbh->rollback if $oldAutoCommit;
1135         return $error;
1136       }
1137     }
1138   }
1139
1140   my $sth = $dbh->prepare(
1141     'UPDATE cust_main SET referral_custnum = NULL WHERE referral_custnum = ?'
1142   ) or do {
1143     my $errstr = $dbh->errstr;
1144     $dbh->rollback if $oldAutoCommit;
1145     return $errstr;
1146   };
1147   $sth->execute($self->custnum) or do {
1148     my $errstr = $sth->errstr;
1149     $dbh->rollback if $oldAutoCommit;
1150     return $errstr;
1151   };
1152
1153   #tickets
1154
1155   my $ticket_dbh = '';
1156   if ($conf->config('ticket_system') eq 'RT_Internal') {
1157     $ticket_dbh = $dbh;
1158   } elsif ($conf->config('ticket_system') eq 'RT_External') {
1159     my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
1160     $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
1161       #or die "RT_External DBI->connect error: $DBI::errstr\n";
1162   }
1163
1164   if ( $ticket_dbh ) {
1165
1166     my $ticket_sth = $ticket_dbh->prepare(
1167       'DELETE FROM Links WHERE Target = ?'
1168     ) or do {
1169       my $errstr = $ticket_dbh->errstr;
1170       $dbh->rollback if $oldAutoCommit;
1171       return $errstr;
1172     };
1173     $ticket_sth->execute('freeside://freeside/cust_main/'.$self->custnum)
1174       or do {
1175         my $errstr = $ticket_sth->errstr;
1176         $dbh->rollback if $oldAutoCommit;
1177         return $errstr;
1178       };
1179
1180     #check and see if the customer is the only link on the ticket, and
1181     #if so, set the ticket to deleted status in RT?
1182     #maybe someday, for now this will at least fix tickets not displaying
1183
1184   }
1185
1186   #delete the customer record
1187
1188   my $error = $self->SUPER::delete;
1189   if ( $error ) {
1190     $dbh->rollback if $oldAutoCommit;
1191     return $error;
1192   }
1193
1194   # cust_main exports!
1195
1196   #my $export_args = $options{'export_args'} || [];
1197
1198   my @part_export =
1199     map qsearch( 'part_export', {exportnum=>$_} ),
1200       $conf->config('cust_main-exports'); #, $agentnum
1201
1202   foreach my $part_export ( @part_export ) {
1203     my $error = $part_export->export_delete( $self ); #, @$export_args);
1204     if ( $error ) {
1205       $dbh->rollback if $oldAutoCommit;
1206       return "exporting to ". $part_export->exporttype.
1207              " (transaction rolled back): $error";
1208     }
1209   }
1210
1211   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1212   '';
1213
1214 }
1215
1216 =item merge NEW_CUSTNUM [ , OPTION => VALUE ... ]
1217
1218 This merges this customer into the provided new custnum, and then deletes the
1219 customer.  If there is an error, returns the error, otherwise returns false.
1220
1221 The source customer's name, company name, phone numbers, agent,
1222 referring customer, customer class, advertising source, order taker, and
1223 billing information (except balance) are discarded.
1224
1225 All packages are moved to the target customer.  Packages with package locations
1226 are preserved.  Packages without package locations are moved to a new package
1227 location with the source customer's service/shipping address.
1228
1229 All invoices, statements, payments, credits and refunds are moved to the target
1230 customer.  The source customer's balance is added to the target customer.
1231
1232 All notes, attachments, tickets and customer tags are moved to the target
1233 customer.
1234
1235 Change history is not currently moved.
1236
1237 =cut
1238
1239 sub merge {
1240   my( $self, $new_custnum, %opt ) = @_;
1241
1242   return "Can't merge a customer into self" if $self->custnum == $new_custnum;
1243
1244   unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
1245     return "Invalid new customer number: $new_custnum";
1246   }
1247
1248   local $SIG{HUP} = 'IGNORE';
1249   local $SIG{INT} = 'IGNORE';
1250   local $SIG{QUIT} = 'IGNORE';
1251   local $SIG{TERM} = 'IGNORE';
1252   local $SIG{TSTP} = 'IGNORE';
1253   local $SIG{PIPE} = 'IGNORE';
1254
1255   my $oldAutoCommit = $FS::UID::AutoCommit;
1256   local $FS::UID::AutoCommit = 0;
1257   my $dbh = dbh;
1258
1259   if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
1260      $dbh->rollback if $oldAutoCommit;
1261      return "Can't merge a master agent customer";
1262   }
1263
1264   #use FS::access_user
1265   if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
1266      $dbh->rollback if $oldAutoCommit;
1267      return "Can't merge a master employee customer";
1268   }
1269
1270   if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum,
1271                                      'status'  => { op=>'!=', value=>'done' },
1272                                    }
1273               )
1274   ) {
1275      $dbh->rollback if $oldAutoCommit;
1276      return "Can't merge a customer with pending payments";
1277   }
1278
1279   tie my %financial_tables, 'Tie::IxHash',
1280     'cust_bill'      => 'invoices',
1281     'cust_statement' => 'statements',
1282     'cust_credit'    => 'credits',
1283     'cust_pay'       => 'payments',
1284     'cust_pay_void'  => 'voided payments',
1285     'cust_refund'    => 'refunds',
1286   ;
1287    
1288   foreach my $table ( keys %financial_tables ) {
1289
1290     my @records = $self->$table();
1291
1292     foreach my $record ( @records ) {
1293       $record->custnum($new_custnum);
1294       my $error = $record->replace;
1295       if ( $error ) {
1296         $dbh->rollback if $oldAutoCommit;
1297         return "Error merging ". $financial_tables{$table}. ": $error\n";
1298       }
1299     }
1300
1301   }
1302
1303   my $name = $self->ship_name; #?
1304
1305   my $locationnum = '';
1306   foreach my $cust_pkg ( $self->all_pkgs ) {
1307     $cust_pkg->custnum($new_custnum);
1308
1309     unless ( $cust_pkg->locationnum ) {
1310       unless ( $locationnum ) {
1311         my $cust_location = new FS::cust_location {
1312           $self->location_hash,
1313           'custnum' => $new_custnum,
1314         };
1315         my $error = $cust_location->insert;
1316         if ( $error ) {
1317           $dbh->rollback if $oldAutoCommit;
1318           return $error;
1319         }
1320         $locationnum = $cust_location->locationnum;
1321       }
1322       $cust_pkg->locationnum($locationnum);
1323     }
1324
1325     my $error = $cust_pkg->replace;
1326     if ( $error ) {
1327       $dbh->rollback if $oldAutoCommit;
1328       return $error;
1329     }
1330
1331     # add customer (ship) name to svc_phone.phone_name if blank
1332     my @cust_svc = $cust_pkg->cust_svc;
1333     foreach my $cust_svc (@cust_svc) {
1334       my($label, $value, $svcdb) = $cust_svc->label;
1335       next unless $svcdb eq 'svc_phone';
1336       my $svc_phone = $cust_svc->svc_x;
1337       next if $svc_phone->phone_name;
1338       $svc_phone->phone_name($name);
1339       my $error = $svc_phone->replace;
1340       if ( $error ) {
1341         $dbh->rollback if $oldAutoCommit;
1342         return $error;
1343       }
1344     }
1345
1346   }
1347
1348   #not considered:
1349   # cust_tax_exempt (texas tax exemptions)
1350   # cust_recon (some sort of not-well understood thing for OnPac)
1351
1352   #these are moved over
1353   foreach my $table (qw(
1354     cust_tag cust_location contact cust_attachment cust_main_note
1355     cust_tax_adjustment cust_pay_batch queue
1356   )) {
1357     foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
1358       $record->custnum($new_custnum);
1359       my $error = $record->replace;
1360       if ( $error ) {
1361         $dbh->rollback if $oldAutoCommit;
1362         return $error;
1363       }
1364     }
1365   }
1366
1367   #these aren't preserved
1368   foreach my $table (qw(
1369     cust_main_exemption cust_main_invoice
1370   )) {
1371     foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
1372       my $error = $record->delete;
1373       if ( $error ) {
1374         $dbh->rollback if $oldAutoCommit;
1375         return $error;
1376       }
1377     }
1378   }
1379
1380
1381   my $sth = $dbh->prepare(
1382     'UPDATE cust_main SET referral_custnum = ? WHERE referral_custnum = ?'
1383   ) or do {
1384     my $errstr = $dbh->errstr;
1385     $dbh->rollback if $oldAutoCommit;
1386     return $errstr;
1387   };
1388   $sth->execute($new_custnum, $self->custnum) or do {
1389     my $errstr = $sth->errstr;
1390     $dbh->rollback if $oldAutoCommit;
1391     return $errstr;
1392   };
1393
1394   #tickets
1395
1396   my $ticket_dbh = '';
1397   if ($conf->config('ticket_system') eq 'RT_Internal') {
1398     $ticket_dbh = $dbh;
1399   } elsif ($conf->config('ticket_system') eq 'RT_External') {
1400     my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
1401     $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
1402       #or die "RT_External DBI->connect error: $DBI::errstr\n";
1403   }
1404
1405   if ( $ticket_dbh ) {
1406
1407     my $ticket_sth = $ticket_dbh->prepare(
1408       'UPDATE Links SET Target = ? WHERE Target = ?'
1409     ) or do {
1410       my $errstr = $ticket_dbh->errstr;
1411       $dbh->rollback if $oldAutoCommit;
1412       return $errstr;
1413     };
1414     $ticket_sth->execute('freeside://freeside/cust_main/'.$new_custnum,
1415                          'freeside://freeside/cust_main/'.$self->custnum)
1416       or do {
1417         my $errstr = $ticket_sth->errstr;
1418         $dbh->rollback if $oldAutoCommit;
1419         return $errstr;
1420       };
1421
1422   }
1423
1424   #delete the customer record
1425
1426   my $error = $self->delete;
1427   if ( $error ) {
1428     $dbh->rollback if $oldAutoCommit;
1429     return $error;
1430   }
1431
1432   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1433   '';
1434
1435 }
1436
1437 =item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
1438
1439 Replaces the OLD_RECORD with this one in the database.  If there is an error,
1440 returns the error, otherwise returns false.
1441
1442 To change the customer's address, set the pseudo-fields C<bill_location> and
1443 C<ship_location>.  The address will still only change if at least one of the
1444 address fields differs from the existing values.
1445
1446 INVOICING_LIST_ARYREF: If you pass an arrarref to the insert method, it will
1447 be set as the invoicing list (see L<"invoicing_list">).  Errors return as
1448 expected and rollback the entire transaction; it is not necessary to call 
1449 check_invoicing_list first.  Here's an example:
1450
1451   $new_cust_main->replace( $old_cust_main, [ $email, 'POST' ] );
1452
1453 Currently available options are: I<tax_exemption>.
1454
1455 The I<tax_exemption> option can be set to an arrayref of tax names or a hashref
1456 of tax names and exemption numbers.  FS::cust_main_exemption records will be
1457 deleted and inserted as appropriate.
1458
1459 =cut
1460
1461 sub replace {
1462   my $self = shift;
1463
1464   my $old = ( blessed($_[0]) && $_[0]->isa('FS::Record') )
1465               ? shift
1466               : $self->replace_old;
1467
1468   my @param = @_;
1469
1470   warn "$me replace called\n"
1471     if $DEBUG;
1472
1473   my $curuser = $FS::CurrentUser::CurrentUser;
1474   if (    $self->payby eq 'COMP'
1475        && $self->payby ne $old->payby
1476        && ! $curuser->access_right('Complimentary customer')
1477      )
1478   {
1479     return "You are not permitted to create complimentary accounts.";
1480   }
1481
1482   # should be unnecessary--geocode will default to null on new locations
1483   #if ( $old->get('geocode') && $old->get('geocode') eq $self->get('geocode')
1484   #     && $conf->exists('enable_taxproducts')
1485   #   )
1486   #{
1487   #  my $pre = ($conf->exists('tax-ship_address') && $self->ship_zip)
1488   #              ? 'ship_' : '';
1489   #  $self->set('geocode', '')
1490   #    if $old->get($pre.'zip') ne $self->get($pre.'zip')
1491   #    && length($self->get($pre.'zip')) >= 10;
1492   #}
1493
1494   # set_coord/coord_auto stuff is now handled by cust_location
1495
1496   local($ignore_expired_card) = 1
1497     if $old->payby  =~ /^(CARD|DCRD)$/
1498     && $self->payby =~ /^(CARD|DCRD)$/
1499     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
1500
1501   local($ignore_banned_card) = 1
1502     if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
1503          || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
1504     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
1505
1506   return "Invoicing locale is required"
1507     if $old->locale
1508     && ! $self->locale
1509     && $conf->exists('cust_main-require_locale');
1510
1511   local $SIG{HUP} = 'IGNORE';
1512   local $SIG{INT} = 'IGNORE';
1513   local $SIG{QUIT} = 'IGNORE';
1514   local $SIG{TERM} = 'IGNORE';
1515   local $SIG{TSTP} = 'IGNORE';
1516   local $SIG{PIPE} = 'IGNORE';
1517
1518   my $oldAutoCommit = $FS::UID::AutoCommit;
1519   local $FS::UID::AutoCommit = 0;
1520   my $dbh = dbh;
1521
1522   for my $l (qw(bill_location ship_location)) {
1523     my $old_loc = $old->$l;
1524     my $new_loc = $self->$l;
1525
1526     if ( !$new_loc->locationnum ) {
1527       # changing location
1528       # If the new location is all empty fields, or if it's identical to 
1529       # the old location in all fields, don't replace.
1530       my @nonempty = grep { $new_loc->$_ } $self->location_fields;
1531       next if !@nonempty;
1532       my @unlike = grep { $new_loc->$_ ne $old_loc->$_ } $self->location_fields;
1533
1534       if ( @unlike or $old_loc->disabled ) {
1535         warn "  changed $l fields: ".join(',',@unlike)."\n"
1536           if $DEBUG;
1537         $new_loc->set(custnum => $self->custnum);
1538
1539         # insert it--the old location will be disabled later
1540         my $error = $new_loc->insert;
1541         if ( $error ) {
1542           $dbh->rollback if $oldAutoCommit;
1543           return $error;
1544         }
1545
1546       } else {
1547       # no fields have changed and $old_loc isn't disabled, so don't change it
1548         next;
1549       }
1550
1551     }
1552     elsif ( $new_loc->custnum ne $self->custnum or $new_loc->prospectnum ) {
1553       $dbh->rollback if $oldAutoCommit;
1554       return "$l belongs to customer ".$new_loc->custnum;
1555     }
1556     # else the new location belongs to this customer so we're good
1557
1558     # set the foo_locationnum now that we have one.
1559     $self->set($l.'num', $new_loc->locationnum);
1560
1561   } #for $l
1562
1563   my $error = $self->SUPER::replace($old);
1564
1565   if ( $error ) {
1566     $dbh->rollback if $oldAutoCommit;
1567     return $error;
1568   }
1569
1570   # now move packages to the new service location
1571   $self->set('ship_location', ''); #flush cache
1572   if ( $old->ship_locationnum and # should only be null during upgrade...
1573        $old->ship_locationnum != $self->ship_locationnum ) {
1574     $error = $old->ship_location->move_to($self->ship_location);
1575     if ( $error ) {
1576       $dbh->rollback if $oldAutoCommit;
1577       return $error;
1578     }
1579   }
1580   # don't move packages based on the billing location, but 
1581   # disable it if it's no longer in use
1582   if ( $old->bill_locationnum and
1583        $old->bill_locationnum != $self->bill_locationnum ) {
1584     $error = $old->bill_location->disable_if_unused;
1585     if ( $error ) {
1586       $dbh->rollback if $oldAutoCommit;
1587       return $error;
1588     }
1589   }
1590
1591   if ( @param && ref($param[0]) eq 'ARRAY' ) { # INVOICING_LIST_ARYREF
1592     my $invoicing_list = shift @param;
1593     $error = $self->check_invoicing_list( $invoicing_list );
1594     if ( $error ) {
1595       $dbh->rollback if $oldAutoCommit;
1596       return $error;
1597     }
1598     $self->invoicing_list( $invoicing_list );
1599   }
1600
1601   if ( $self->exists('tagnum') ) { #so we don't delete these on edit by accident
1602
1603     #this could be more efficient than deleting and re-inserting, if it matters
1604     foreach my $cust_tag (qsearch('cust_tag', {'custnum'=>$self->custnum} )) {
1605       my $error = $cust_tag->delete;
1606       if ( $error ) {
1607         $dbh->rollback if $oldAutoCommit;
1608         return $error;
1609       }
1610     }
1611     foreach my $tagnum ( @{ $self->tagnum || [] } ) {
1612       my $cust_tag = new FS::cust_tag { 'tagnum'  => $tagnum,
1613                                         'custnum' => $self->custnum };
1614       my $error = $cust_tag->insert;
1615       if ( $error ) {
1616         $dbh->rollback if $oldAutoCommit;
1617         return $error;
1618       }
1619     }
1620
1621   }
1622
1623   my %options = @param;
1624
1625   my $tax_exemption = delete $options{'tax_exemption'};
1626   if ( $tax_exemption ) {
1627
1628     $tax_exemption = { map { $_ => '' } @$tax_exemption }
1629       if ref($tax_exemption) eq 'ARRAY';
1630
1631     my %cust_main_exemption =
1632       map { $_->taxname => $_ }
1633           qsearch('cust_main_exemption', { 'custnum' => $old->custnum } );
1634
1635     foreach my $taxname ( keys %$tax_exemption ) {
1636
1637       if ( $cust_main_exemption{$taxname} && 
1638            $cust_main_exemption{$taxname}->exempt_number eq $tax_exemption->{$taxname}
1639          )
1640       {
1641         delete $cust_main_exemption{$taxname};
1642         next;
1643       }
1644
1645       my $cust_main_exemption = new FS::cust_main_exemption {
1646         'custnum'       => $self->custnum,
1647         'taxname'       => $taxname,
1648         'exempt_number' => $tax_exemption->{$taxname},
1649       };
1650       my $error = $cust_main_exemption->insert;
1651       if ( $error ) {
1652         $dbh->rollback if $oldAutoCommit;
1653         return "inserting cust_main_exemption (transaction rolled back): $error";
1654       }
1655     }
1656
1657     foreach my $cust_main_exemption ( values %cust_main_exemption ) {
1658       my $error = $cust_main_exemption->delete;
1659       if ( $error ) {
1660         $dbh->rollback if $oldAutoCommit;
1661         return "deleting cust_main_exemption (transaction rolled back): $error";
1662       }
1663     }
1664
1665   }
1666
1667   if ( $self->payby =~ /^(CARD|CHEK|LECB)$/
1668        && ( ( $self->get('payinfo') ne $old->get('payinfo')
1669               && $self->get('payinfo') !~ /^99\d{14}$/ 
1670             )
1671             || grep { $self->get($_) ne $old->get($_) } qw(paydate payname)
1672           )
1673      )
1674   {
1675
1676     # card/check/lec info has changed, want to retry realtime_ invoice events
1677     my $error = $self->retry_realtime;
1678     if ( $error ) {
1679       $dbh->rollback if $oldAutoCommit;
1680       return $error;
1681     }
1682   }
1683
1684   unless ( $import || $skip_fuzzyfiles ) {
1685     $error = $self->queue_fuzzyfiles_update;
1686     if ( $error ) {
1687       $dbh->rollback if $oldAutoCommit;
1688       return "updating fuzzy search cache: $error";
1689     }
1690   }
1691
1692   # tax district update in cust_location
1693
1694   # cust_main exports!
1695
1696   my $export_args = $options{'export_args'} || [];
1697
1698   my @part_export =
1699     map qsearch( 'part_export', {exportnum=>$_} ),
1700       $conf->config('cust_main-exports'); #, $agentnum
1701
1702   foreach my $part_export ( @part_export ) {
1703     my $error = $part_export->export_replace( $self, $old, @$export_args);
1704     if ( $error ) {
1705       $dbh->rollback if $oldAutoCommit;
1706       return "exporting to ". $part_export->exporttype.
1707              " (transaction rolled back): $error";
1708     }
1709   }
1710
1711   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1712   '';
1713
1714 }
1715
1716 =item queue_fuzzyfiles_update
1717
1718 Used by insert & replace to update the fuzzy search cache
1719
1720 =cut
1721
1722 use FS::cust_main::Search;
1723 sub queue_fuzzyfiles_update {
1724   my $self = shift;
1725
1726   local $SIG{HUP} = 'IGNORE';
1727   local $SIG{INT} = 'IGNORE';
1728   local $SIG{QUIT} = 'IGNORE';
1729   local $SIG{TERM} = 'IGNORE';
1730   local $SIG{TSTP} = 'IGNORE';
1731   local $SIG{PIPE} = 'IGNORE';
1732
1733   my $oldAutoCommit = $FS::UID::AutoCommit;
1734   local $FS::UID::AutoCommit = 0;
1735   my $dbh = dbh;
1736
1737   my @locations = $self->bill_location;
1738   push @locations, $self->ship_location if $self->has_ship_address;
1739   foreach my $location (@locations) {
1740     my $queue = new FS::queue { 
1741       'job' => 'FS::cust_main::Search::append_fuzzyfiles'
1742     };
1743     my @args = map $location->get($_), @FS::cust_main::Search::fuzzyfields;
1744     my $error = $queue->insert( @args );
1745     if ( $error ) {
1746       $dbh->rollback if $oldAutoCommit;
1747       return "queueing job (transaction rolled back): $error";
1748     }
1749   }
1750
1751   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
1752   '';
1753
1754 }
1755
1756 =item check
1757
1758 Checks all fields to make sure this is a valid customer record.  If there is
1759 an error, returns the error, otherwise returns false.  Called by the insert
1760 and replace methods.
1761
1762 =cut
1763
1764 sub check {
1765   my $self = shift;
1766
1767   warn "$me check BEFORE: \n". $self->_dump
1768     if $DEBUG > 2;
1769
1770   my $error =
1771     $self->ut_numbern('custnum')
1772     || $self->ut_number('agentnum')
1773     || $self->ut_textn('agent_custid')
1774     || $self->ut_number('refnum')
1775     || $self->ut_foreign_key('bill_locationnum', 'cust_location','locationnum')
1776     || $self->ut_foreign_key('ship_locationnum', 'cust_location','locationnum')
1777     || $self->ut_foreign_keyn('classnum', 'cust_class', 'classnum')
1778     || $self->ut_textn('custbatch')
1779     || $self->ut_name('last')
1780     || $self->ut_name('first')
1781     || $self->ut_snumbern('birthdate')
1782     || $self->ut_snumbern('signupdate')
1783     || $self->ut_textn('company')
1784     || $self->ut_anything('comments')
1785     || $self->ut_numbern('referral_custnum')
1786     || $self->ut_textn('stateid')
1787     || $self->ut_textn('stateid_state')
1788     || $self->ut_textn('invoice_terms')
1789     || $self->ut_floatn('cdr_termination_percentage')
1790     || $self->ut_floatn('credit_limit')
1791     || $self->ut_numbern('billday')
1792     || $self->ut_enum('edit_subject', [ '', 'Y' ] )
1793     || $self->ut_enum('calling_list_exempt', [ '', 'Y' ] )
1794     || $self->ut_enum('invoice_noemail', [ '', 'Y' ] )
1795     || $self->ut_enum('locale', [ '', FS::Locales->locales ])
1796   ;
1797
1798   #barf.  need message catalogs.  i18n.  etc.
1799   $error .= "Please select an advertising source."
1800     if $error =~ /^Illegal or empty \(numeric\) refnum: /;
1801   return $error if $error;
1802
1803   return "Unknown agent"
1804     unless qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
1805
1806   return "Unknown refnum"
1807     unless qsearchs( 'part_referral', { 'refnum' => $self->refnum } );
1808
1809   return "Unknown referring custnum: ". $self->referral_custnum
1810     unless ! $self->referral_custnum 
1811            || qsearchs( 'cust_main', { 'custnum' => $self->referral_custnum } );
1812
1813   if ( $self->ss eq '' ) {
1814     $self->ss('');
1815   } else {
1816     my $ss = $self->ss;
1817     $ss =~ s/\D//g;
1818     $ss =~ /^(\d{3})(\d{2})(\d{4})$/
1819       or return "Illegal social security number: ". $self->ss;
1820     $self->ss("$1-$2-$3");
1821   }
1822
1823   # cust_main_county verification now handled by cust_location check
1824
1825   $error =
1826        $self->ut_phonen('daytime', $self->country)
1827     || $self->ut_phonen('night',   $self->country)
1828     || $self->ut_phonen('fax',     $self->country)
1829     || $self->ut_phonen('mobile',  $self->country)
1830   ;
1831   return $error if $error;
1832
1833   if ( $conf->exists('cust_main-require_phone', $self->agentnum)
1834        && ! $import
1835        && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
1836      ) {
1837
1838     my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
1839                           ? 'Day Phone'
1840                           : FS::Msgcat::_gettext('daytime');
1841     my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
1842                         ? 'Night Phone'
1843                         : FS::Msgcat::_gettext('night');
1844
1845     my $mobile_label = FS::Msgcat::_gettext('mobile') =~ /^(mobile)?$/
1846                         ? 'Mobile Phone'
1847                         : FS::Msgcat::_gettext('mobile');
1848
1849     return "$daytime_label, $night_label or $mobile_label is required"
1850   
1851   }
1852
1853   #ship_ fields are gone
1854
1855   #$self->payby =~ /^(CARD|DCRD|CHEK|DCHK|LECB|BILL|COMP|PREPAY|CASH|WEST|MCRD)$/
1856   #  or return "Illegal payby: ". $self->payby;
1857   #$self->payby($1);
1858   FS::payby->can_payby($self->table, $self->payby)
1859     or return "Illegal payby: ". $self->payby;
1860
1861   $error =    $self->ut_numbern('paystart_month')
1862            || $self->ut_numbern('paystart_year')
1863            || $self->ut_numbern('payissue')
1864            || $self->ut_textn('paytype')
1865   ;
1866   return $error if $error;
1867
1868   if ( $self->payip eq '' ) {
1869     $self->payip('');
1870   } else {
1871     $error = $self->ut_ip('payip');
1872     return $error if $error;
1873   }
1874
1875   # If it is encrypted and the private key is not availaible then we can't
1876   # check the credit card.
1877   my $check_payinfo = ! $self->is_encrypted($self->payinfo);
1878
1879   # Need some kind of global flag to accept invalid cards, for testing
1880   # on scrubbed data.
1881   if ( !$import && $check_payinfo && $self->payby =~ /^(CARD|DCRD)$/ ) {
1882
1883     my $payinfo = $self->payinfo;
1884     $payinfo =~ s/\D//g;
1885     $payinfo =~ /^(\d{13,16}|\d{8,9})$/
1886       or return gettext('invalid_card'); # . ": ". $self->payinfo;
1887     $payinfo = $1;
1888     $self->payinfo($payinfo);
1889     validate($payinfo)
1890       or return gettext('invalid_card'); # . ": ". $self->payinfo;
1891
1892     return gettext('unknown_card_type')
1893       if $self->payinfo !~ /^99\d{14}$/ #token
1894       && cardtype($self->payinfo) eq "Unknown";
1895
1896     unless ( $ignore_banned_card ) {
1897       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
1898       if ( $ban ) {
1899         if ( $ban->bantype eq 'warn' ) {
1900           #or others depending on value of $ban->reason ?
1901           return '_duplicate_card'.
1902                  ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
1903                  ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
1904                  ' (ban# '. $ban->bannum. ')'
1905             unless $self->override_ban_warn;
1906         } else {
1907           return 'Banned credit card: banned on '.
1908                  time2str('%a %h %o at %r', $ban->_date).
1909                  ' by '. $ban->otaker.
1910                  ' (ban# '. $ban->bannum. ')';
1911         }
1912       }
1913     }
1914
1915     if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
1916       if ( cardtype($self->payinfo) eq 'American Express card' ) {
1917         $self->paycvv =~ /^(\d{4})$/
1918           or return "CVV2 (CID) for American Express cards is four digits.";
1919         $self->paycvv($1);
1920       } else {
1921         $self->paycvv =~ /^(\d{3})$/
1922           or return "CVV2 (CVC2/CID) is three digits.";
1923         $self->paycvv($1);
1924       }
1925     } else {
1926       $self->paycvv('');
1927     }
1928
1929     my $cardtype = cardtype($payinfo);
1930     if ( $cardtype =~ /^(Switch|Solo)$/i ) {
1931
1932       return "Start date or issue number is required for $cardtype cards"
1933         unless $self->paystart_month && $self->paystart_year or $self->payissue;
1934
1935       return "Start month must be between 1 and 12"
1936         if $self->paystart_month
1937            and $self->paystart_month < 1 || $self->paystart_month > 12;
1938
1939       return "Start year must be 1990 or later"
1940         if $self->paystart_year
1941            and $self->paystart_year < 1990;
1942
1943       return "Issue number must be beween 1 and 99"
1944         if $self->payissue
1945           and $self->payissue < 1 || $self->payissue > 99;
1946
1947     } else {
1948       $self->paystart_month('');
1949       $self->paystart_year('');
1950       $self->payissue('');
1951     }
1952
1953   } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
1954
1955     my $payinfo = $self->payinfo;
1956     $payinfo =~ s/[^\d\@\.]//g;
1957     if ( $conf->config('echeck-country') eq 'CA' ) {
1958       $payinfo =~ /^(\d+)\@(\d{5})\.(\d{3})$/
1959         or return 'invalid echeck account@branch.bank';
1960       $payinfo = "$1\@$2.$3";
1961     } elsif ( $conf->config('echeck-country') eq 'US' ) {
1962       $payinfo =~ /^(\d+)\@(\d{9})$/ or return 'invalid echeck account@aba';
1963       $payinfo = "$1\@$2";
1964     } else {
1965       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@routing';
1966       $payinfo = "$1\@$2";
1967     }
1968     $self->payinfo($payinfo);
1969     $self->paycvv('');
1970
1971     unless ( $ignore_banned_card ) {
1972       my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
1973       if ( $ban ) {
1974         if ( $ban->bantype eq 'warn' ) {
1975           #or others depending on value of $ban->reason ?
1976           return '_duplicate_ach' unless $self->override_ban_warn;
1977         } else {
1978           return 'Banned ACH account: banned on '.
1979                  time2str('%a %h %o at %r', $ban->_date).
1980                  ' by '. $ban->otaker.
1981                  ' (ban# '. $ban->bannum. ')';
1982         }
1983       }
1984     }
1985
1986   } elsif ( $self->payby eq 'LECB' ) {
1987
1988     my $payinfo = $self->payinfo;
1989     $payinfo =~ s/\D//g;
1990     $payinfo =~ /^1?(\d{10})$/ or return 'invalid btn billing telephone number';
1991     $payinfo = $1;
1992     $self->payinfo($payinfo);
1993     $self->paycvv('');
1994
1995   } elsif ( $self->payby eq 'BILL' ) {
1996
1997     $error = $self->ut_textn('payinfo');
1998     return "Illegal P.O. number: ". $self->payinfo if $error;
1999     $self->paycvv('');
2000
2001   } elsif ( $self->payby eq 'COMP' ) {
2002
2003     my $curuser = $FS::CurrentUser::CurrentUser;
2004     if (    ! $self->custnum
2005          && ! $curuser->access_right('Complimentary customer')
2006        )
2007     {
2008       return "You are not permitted to create complimentary accounts."
2009     }
2010
2011     $error = $self->ut_textn('payinfo');
2012     return "Illegal comp account issuer: ". $self->payinfo if $error;
2013     $self->paycvv('');
2014
2015   } elsif ( $self->payby eq 'PREPAY' ) {
2016
2017     my $payinfo = $self->payinfo;
2018     $payinfo =~ s/\W//g; #anything else would just confuse things
2019     $self->payinfo($payinfo);
2020     $error = $self->ut_alpha('payinfo');
2021     return "Illegal prepayment identifier: ". $self->payinfo if $error;
2022     return "Unknown prepayment identifier"
2023       unless qsearchs('prepay_credit', { 'identifier' => $self->payinfo } );
2024     $self->paycvv('');
2025
2026   }
2027
2028   if ( $self->paydate eq '' || $self->paydate eq '-' ) {
2029     return "Expiration date required"
2030       unless $self->payby =~ /^(BILL|PREPAY|CHEK|DCHK|LECB|CASH|WEST|MCRD)$/;
2031     $self->paydate('');
2032   } else {
2033     my( $m, $y );
2034     if ( $self->paydate =~ /^(\d{1,2})[\/\-](\d{2}(\d{2})?)$/ ) {
2035       ( $m, $y ) = ( $1, length($2) == 4 ? $2 : "20$2" );
2036     } elsif ( $self->paydate =~ /^19(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
2037       ( $m, $y ) = ( $2, "19$1" );
2038     } elsif ( $self->paydate =~ /^(20)?(\d{2})[\/\-](\d{1,2})[\/\-]\d+$/ ) {
2039       ( $m, $y ) = ( $3, "20$2" );
2040     } else {
2041       return "Illegal expiration date: ". $self->paydate;
2042     }
2043     $m = sprintf('%02d',$m);
2044     $self->paydate("$y-$m-01");
2045     my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
2046     return gettext('expired_card')
2047       if !$import
2048       && !$ignore_expired_card 
2049       && ( $y<$nowy || ( $y==$nowy && $1<$nowm ) );
2050   }
2051
2052   if ( $self->payname eq '' && $self->payby !~ /^(CHEK|DCHK)$/ &&
2053        ( ! $conf->exists('require_cardname')
2054          || $self->payby !~ /^(CARD|DCRD)$/  ) 
2055   ) {
2056     $self->payname( $self->first. " ". $self->getfield('last') );
2057   } else {
2058     $self->payname =~ /^([\w \,\.\-\'\&]+)$/
2059       or return gettext('illegal_name'). " payname: ". $self->payname;
2060     $self->payname($1);
2061   }
2062
2063   return "Please select an invoicing locale"
2064     if ! $self->locale
2065     && ! $self->custnum
2066     && $conf->exists('cust_main-require_locale');
2067
2068   foreach my $flag (qw( tax spool_cdr squelch_cdr archived email_csv_cdr )) {
2069     $self->$flag() =~ /^(Y?)$/ or return "Illegal $flag: ". $self->$flag();
2070     $self->$flag($1);
2071   }
2072
2073   $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
2074
2075   warn "$me check AFTER: \n". $self->_dump
2076     if $DEBUG > 2;
2077
2078   $self->SUPER::check;
2079 }
2080
2081 =item addr_fields 
2082
2083 Returns a list of fields which have ship_ duplicates.
2084
2085 =cut
2086
2087 sub addr_fields {
2088   qw( last first company
2089       address1 address2 city county state zip country
2090       latitude longitude
2091       daytime night fax mobile
2092     );
2093 }
2094
2095 =item has_ship_address
2096
2097 Returns true if this customer record has a separate shipping address.
2098
2099 =cut
2100
2101 sub has_ship_address {
2102   my $self = shift;
2103   $self->bill_locationnum != $self->ship_locationnum;
2104 }
2105
2106 =item location_hash
2107
2108 Returns a list of key/value pairs, with the following keys: address1, 
2109 adddress2, city, county, state, zip, country, district, and geocode.  The 
2110 shipping address is used if present.
2111
2112 =cut
2113
2114 sub location_hash {
2115   my $self = shift;
2116   $self->ship_location->location_hash;
2117 }
2118
2119 =item cust_location
2120
2121 Returns all locations (see L<FS::cust_location>) for this customer.
2122
2123 =cut
2124
2125 sub cust_location {
2126   my $self = shift;
2127   qsearch('cust_location', { 'custnum' => $self->custnum,
2128                              'prospectnum' => '' } );
2129 }
2130
2131 =item cust_contact
2132
2133 Returns all contacts (see L<FS::contact>) for this customer.
2134
2135 =cut
2136
2137 #already used :/ sub contact {
2138 sub cust_contact {
2139   my $self = shift;
2140   qsearch('contact', { 'custnum' => $self->custnum } );
2141 }
2142
2143 =item unsuspend
2144
2145 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
2146 and L<FS::cust_pkg>) for this customer.  Always returns a list: an empty list
2147 on success or a list of errors.
2148
2149 =cut
2150
2151 sub unsuspend {
2152   my $self = shift;
2153   grep { $_->unsuspend } $self->suspended_pkgs;
2154 }
2155
2156 =item suspend
2157
2158 Suspends all unsuspended packages (see L<FS::cust_pkg>) for this customer.
2159
2160 Returns a list: an empty list on success or a list of errors.
2161
2162 =cut
2163
2164 sub suspend {
2165   my $self = shift;
2166   grep { $_->suspend(@_) } $self->unsuspended_pkgs;
2167 }
2168
2169 =item suspend_if_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
2170
2171 Suspends all unsuspended packages (see L<FS::cust_pkg>) matching the listed
2172 PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref instead
2173 of a list of pkgparts; the hashref has the following keys:
2174
2175 =over 4
2176
2177 =item pkgparts - listref of pkgparts
2178
2179 =item (other options are passed to the suspend method)
2180
2181 =back
2182
2183
2184 Returns a list: an empty list on success or a list of errors.
2185
2186 =cut
2187
2188 sub suspend_if_pkgpart {
2189   my $self = shift;
2190   my (@pkgparts, %opt);
2191   if (ref($_[0]) eq 'HASH'){
2192     @pkgparts = @{$_[0]{pkgparts}};
2193     %opt      = %{$_[0]};
2194   }else{
2195     @pkgparts = @_;
2196   }
2197   grep { $_->suspend(%opt) }
2198     grep { my $pkgpart = $_->pkgpart; grep { $pkgpart eq $_ } @pkgparts }
2199       $self->unsuspended_pkgs;
2200 }
2201
2202 =item suspend_unless_pkgpart HASHREF | PKGPART [ , PKGPART ... ]
2203
2204 Suspends all unsuspended packages (see L<FS::cust_pkg>) unless they match the
2205 given PKGPARTs (see L<FS::part_pkg>).  Preferred usage is to pass a hashref
2206 instead of a list of pkgparts; the hashref has the following keys:
2207
2208 =over 4
2209
2210 =item pkgparts - listref of pkgparts
2211
2212 =item (other options are passed to the suspend method)
2213
2214 =back
2215
2216 Returns a list: an empty list on success or a list of errors.
2217
2218 =cut
2219
2220 sub suspend_unless_pkgpart {
2221   my $self = shift;
2222   my (@pkgparts, %opt);
2223   if (ref($_[0]) eq 'HASH'){
2224     @pkgparts = @{$_[0]{pkgparts}};
2225     %opt      = %{$_[0]};
2226   }else{
2227     @pkgparts = @_;
2228   }
2229   grep { $_->suspend(%opt) }
2230     grep { my $pkgpart = $_->pkgpart; ! grep { $pkgpart eq $_ } @pkgparts }
2231       $self->unsuspended_pkgs;
2232 }
2233
2234 =item cancel [ OPTION => VALUE ... ]
2235
2236 Cancels all uncancelled packages (see L<FS::cust_pkg>) for this customer.
2237
2238 Available options are:
2239
2240 =over 4
2241
2242 =item quiet - can be set true to supress email cancellation notices.
2243
2244 =item reason - can be set to a cancellation reason (see L<FS:reason>), either a reasonnum of an existing reason, or passing a hashref will create a new reason.  The hashref should have the following keys: typenum - Reason type (see L<FS::reason_type>, reason - Text of the new reason.
2245
2246 =item ban - can be set true to ban this customer's credit card or ACH information, if present.
2247
2248 =item nobill - can be set true to skip billing if it might otherwise be done.
2249
2250 =back
2251
2252 Always returns a list: an empty list on success or a list of errors.
2253
2254 =cut
2255
2256 # nb that dates are not specified as valid options to this method
2257
2258 sub cancel {
2259   my( $self, %opt ) = @_;
2260
2261   warn "$me cancel called on customer ". $self->custnum. " with options ".
2262        join(', ', map { "$_: $opt{$_}" } keys %opt ). "\n"
2263     if $DEBUG;
2264
2265   return ( 'access denied' )
2266     unless $FS::CurrentUser::CurrentUser->access_right('Cancel customer');
2267
2268   if ( $opt{'ban'} && $self->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
2269
2270     #should try decryption (we might have the private key)
2271     # and if not maybe queue a job for the server that does?
2272     return ( "Can't (yet) ban encrypted credit cards" )
2273       if $self->is_encrypted($self->payinfo);
2274
2275     my $ban = new FS::banned_pay $self->_new_banned_pay_hashref;
2276     my $error = $ban->insert;
2277     return ( $error ) if $error;
2278
2279   }
2280
2281   my @pkgs = $self->ncancelled_pkgs;
2282
2283   if ( !$opt{nobill} && $conf->exists('bill_usage_on_cancel') ) {
2284     $opt{nobill} = 1;
2285     my $error = $self->bill( pkg_list => [ @pkgs ], cancel => 1 );
2286     warn "Error billing during cancel, custnum ". $self->custnum. ": $error"
2287       if $error;
2288   }
2289
2290   warn "$me cancelling ". scalar($self->ncancelled_pkgs). "/".
2291        scalar(@pkgs). " packages for customer ". $self->custnum. "\n"
2292     if $DEBUG;
2293
2294   grep { $_ } map { $_->cancel(%opt) } $self->ncancelled_pkgs;
2295 }
2296
2297 sub _banned_pay_hashref {
2298   my $self = shift;
2299
2300   my %payby2ban = (
2301     'CARD' => 'CARD',
2302     'DCRD' => 'CARD',
2303     'CHEK' => 'CHEK',
2304     'DCHK' => 'CHEK'
2305   );
2306
2307   {
2308     'payby'   => $payby2ban{$self->payby},
2309     'payinfo' => $self->payinfo,
2310     #don't ever *search* on reason! #'reason'  =>
2311   };
2312 }
2313
2314 sub _new_banned_pay_hashref {
2315   my $self = shift;
2316   my $hr = $self->_banned_pay_hashref;
2317   $hr->{payinfo} = md5_base64($hr->{payinfo});
2318   $hr;
2319 }
2320
2321 =item notes
2322
2323 Returns all notes (see L<FS::cust_main_note>) for this customer.
2324
2325 =cut
2326
2327 sub notes {
2328   my($self,$orderby_classnum) = (shift,shift);
2329   my $orderby = "_DATE DESC";
2330   $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum;
2331   qsearch( 'cust_main_note',
2332            { 'custnum' => $self->custnum },
2333            '',
2334            "ORDER BY $orderby",
2335          );
2336 }
2337
2338 =item agent
2339
2340 Returns the agent (see L<FS::agent>) for this customer.
2341
2342 =cut
2343
2344 sub agent {
2345   my $self = shift;
2346   qsearchs( 'agent', { 'agentnum' => $self->agentnum } );
2347 }
2348
2349 =item agent_name
2350
2351 Returns the agent name (see L<FS::agent>) for this customer.
2352
2353 =cut
2354
2355 sub agent_name {
2356   my $self = shift;
2357   $self->agent->agent;
2358 }
2359
2360 =item cust_tag
2361
2362 Returns any tags associated with this customer, as FS::cust_tag objects,
2363 or an empty list if there are no tags.
2364
2365 =cut
2366
2367 sub cust_tag {
2368   my $self = shift;
2369   qsearch('cust_tag', { 'custnum' => $self->custnum } );
2370 }
2371
2372 =item part_tag
2373
2374 Returns any tags associated with this customer, as FS::part_tag objects,
2375 or an empty list if there are no tags.
2376
2377 =cut
2378
2379 sub part_tag {
2380   my $self = shift;
2381   map $_->part_tag, $self->cust_tag; 
2382 }
2383
2384
2385 =item cust_class
2386
2387 Returns the customer class, as an FS::cust_class object, or the empty string
2388 if there is no customer class.
2389
2390 =cut
2391
2392 sub cust_class {
2393   my $self = shift;
2394   if ( $self->classnum ) {
2395     qsearchs('cust_class', { 'classnum' => $self->classnum } );
2396   } else {
2397     return '';
2398   } 
2399 }
2400
2401 =item categoryname 
2402
2403 Returns the customer category name, or the empty string if there is no customer
2404 category.
2405
2406 =cut
2407
2408 sub categoryname {
2409   my $self = shift;
2410   my $cust_class = $self->cust_class;
2411   $cust_class
2412     ? $cust_class->categoryname
2413     : '';
2414 }
2415
2416 =item classname 
2417
2418 Returns the customer class name, or the empty string if there is no customer
2419 class.
2420
2421 =cut
2422
2423 sub classname {
2424   my $self = shift;
2425   my $cust_class = $self->cust_class;
2426   $cust_class
2427     ? $cust_class->classname
2428     : '';
2429 }
2430
2431 =item BILLING METHODS
2432
2433 Documentation on billing methods has been moved to
2434 L<FS::cust_main::Billing>.
2435
2436 =item REALTIME BILLING METHODS
2437
2438 Documentation on realtime billing methods has been moved to
2439 L<FS::cust_main::Billing_Realtime>.
2440
2441 =item remove_cvv
2442
2443 Removes the I<paycvv> field from the database directly.
2444
2445 If there is an error, returns the error, otherwise returns false.
2446
2447 =cut
2448
2449 sub remove_cvv {
2450   my $self = shift;
2451   my $sth = dbh->prepare("UPDATE cust_main SET paycvv = '' WHERE custnum = ?")
2452     or return dbh->errstr;
2453   $sth->execute($self->custnum)
2454     or return $sth->errstr;
2455   $self->paycvv('');
2456   '';
2457 }
2458
2459 =item batch_card OPTION => VALUE...
2460
2461 Adds a payment for this invoice to the pending credit card batch (see
2462 L<FS::cust_pay_batch>), or, if the B<realtime> option is set to a true value,
2463 runs the payment using a realtime gateway.
2464
2465 =cut
2466
2467 sub batch_card {
2468   my ($self, %options) = @_;
2469
2470   my $amount;
2471   if (exists($options{amount})) {
2472     $amount = $options{amount};
2473   }else{
2474     $amount = sprintf("%.2f", $self->balance - $self->in_transit_payments);
2475   }
2476   return '' unless $amount > 0;
2477   
2478   my $invnum = delete $options{invnum};
2479   my $payby = $options{payby} || $self->payby;  #still dubious
2480
2481   if ($options{'realtime'}) {
2482     return $self->realtime_bop( FS::payby->payby2bop($self->payby),
2483                                 $amount,
2484                                 %options,
2485                               );
2486   }
2487
2488   my $oldAutoCommit = $FS::UID::AutoCommit;
2489   local $FS::UID::AutoCommit = 0;
2490   my $dbh = dbh;
2491
2492   #this needs to handle mysql as well as Pg, like svc_acct.pm
2493   #(make it into a common function if folks need to do batching with mysql)
2494   $dbh->do("LOCK TABLE pay_batch IN SHARE ROW EXCLUSIVE MODE")
2495     or return "Cannot lock pay_batch: " . $dbh->errstr;
2496
2497   my %pay_batch = (
2498     'status' => 'O',
2499     'payby'  => FS::payby->payby2payment($payby),
2500   );
2501   $pay_batch{agentnum} = $self->agentnum if $conf->exists('batch-spoolagent');
2502
2503   my $pay_batch = qsearchs( 'pay_batch', \%pay_batch );
2504
2505   unless ( $pay_batch ) {
2506     $pay_batch = new FS::pay_batch \%pay_batch;
2507     my $error = $pay_batch->insert;
2508     if ( $error ) {
2509       $dbh->rollback if $oldAutoCommit;
2510       die "error creating new batch: $error\n";
2511     }
2512   }
2513
2514   my $old_cust_pay_batch = qsearchs('cust_pay_batch', {
2515       'batchnum' => $pay_batch->batchnum,
2516       'custnum'  => $self->custnum,
2517   } );
2518
2519   foreach (qw( address1 address2 city state zip country latitude longitude
2520                payby payinfo paydate payname ))
2521   {
2522     $options{$_} = '' unless exists($options{$_});
2523   }
2524
2525   my $loc = $self->bill_location;
2526
2527   my $cust_pay_batch = new FS::cust_pay_batch ( {
2528     'batchnum' => $pay_batch->batchnum,
2529     'invnum'   => $invnum || 0,                    # is there a better value?
2530                                                    # this field should be
2531                                                    # removed...
2532                                                    # cust_bill_pay_batch now
2533     'custnum'  => $self->custnum,
2534     'last'     => $self->getfield('last'),
2535     'first'    => $self->getfield('first'),
2536     'address1' => $options{address1} || $loc->address1,
2537     'address2' => $options{address2} || $loc->address2,
2538     'city'     => $options{city}     || $loc->city,
2539     'state'    => $options{state}    || $loc->state,
2540     'zip'      => $options{zip}      || $loc->zip,
2541     'country'  => $options{country}  || $loc->country,
2542     'payby'    => $options{payby}    || $loc->payby,
2543     'payinfo'  => $options{payinfo}  || $loc->payinfo,
2544     'exp'      => $options{paydate}  || $loc->paydate,
2545     'payname'  => $options{payname}  || $loc->payname,
2546     'amount'   => $amount,                         # consolidating
2547   } );
2548   
2549   $cust_pay_batch->paybatchnum($old_cust_pay_batch->paybatchnum)
2550     if $old_cust_pay_batch;
2551
2552   my $error;
2553   if ($old_cust_pay_batch) {
2554     $error = $cust_pay_batch->replace($old_cust_pay_batch)
2555   } else {
2556     $error = $cust_pay_batch->insert;
2557   }
2558
2559   if ( $error ) {
2560     $dbh->rollback if $oldAutoCommit;
2561     die $error;
2562   }
2563
2564   my $unapplied =   $self->total_unapplied_credits
2565                   + $self->total_unapplied_payments
2566                   + $self->in_transit_payments;
2567   foreach my $cust_bill ($self->open_cust_bill) {
2568     #$dbh->commit or die $dbh->errstr if $oldAutoCommit;
2569     my $cust_bill_pay_batch = new FS::cust_bill_pay_batch {
2570       'invnum' => $cust_bill->invnum,
2571       'paybatchnum' => $cust_pay_batch->paybatchnum,
2572       'amount' => $cust_bill->owed,
2573       '_date' => time,
2574     };
2575     if ($unapplied >= $cust_bill_pay_batch->amount){
2576       $unapplied -= $cust_bill_pay_batch->amount;
2577       next;
2578     }else{
2579       $cust_bill_pay_batch->amount(sprintf ( "%.2f", 
2580                                    $cust_bill_pay_batch->amount - $unapplied ));      $unapplied = 0;
2581     }
2582     $error = $cust_bill_pay_batch->insert;
2583     if ( $error ) {
2584       $dbh->rollback if $oldAutoCommit;
2585       die $error;
2586     }
2587   }
2588
2589   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
2590   '';
2591 }
2592
2593 =item total_owed
2594
2595 Returns the total owed for this customer on all invoices
2596 (see L<FS::cust_bill/owed>).
2597
2598 =cut
2599
2600 sub total_owed {
2601   my $self = shift;
2602   $self->total_owed_date(2145859200); #12/31/2037
2603 }
2604
2605 =item total_owed_date TIME
2606
2607 Returns the total owed for this customer on all invoices with date earlier than
2608 TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
2609 see L<Time::Local> and L<Date::Parse> for conversion functions.
2610
2611 =cut
2612
2613 sub total_owed_date {
2614   my $self = shift;
2615   my $time = shift;
2616
2617   my $custnum = $self->custnum;
2618
2619   my $owed_sql = FS::cust_bill->owed_sql;
2620
2621   my $sql = "
2622     SELECT SUM($owed_sql) FROM cust_bill
2623       WHERE custnum = $custnum
2624         AND _date <= $time
2625   ";
2626
2627   sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
2628
2629 }
2630
2631 =item total_owed_pkgnum PKGNUM
2632
2633 Returns the total owed on all invoices for this customer's specific package
2634 when using experimental package balances (see L<FS::cust_bill/owed_pkgnum>).
2635
2636 =cut
2637
2638 sub total_owed_pkgnum {
2639   my( $self, $pkgnum ) = @_;
2640   $self->total_owed_date_pkgnum(2145859200, $pkgnum); #12/31/2037
2641 }
2642
2643 =item total_owed_date_pkgnum TIME PKGNUM
2644
2645 Returns the total owed for this customer's specific package when using
2646 experimental package balances on all invoices with date earlier than
2647 TIME.  TIME is specified as a UNIX timestamp; see L<perlfunc/"time">).  Also
2648 see L<Time::Local> and L<Date::Parse> for conversion functions.
2649
2650 =cut
2651
2652 sub total_owed_date_pkgnum {
2653   my( $self, $time, $pkgnum ) = @_;
2654
2655   my $total_bill = 0;
2656   foreach my $cust_bill (
2657     grep { $_->_date <= $time }
2658       qsearch('cust_bill', { 'custnum' => $self->custnum, } )
2659   ) {
2660     $total_bill += $cust_bill->owed_pkgnum($pkgnum);
2661   }
2662   sprintf( "%.2f", $total_bill );
2663
2664 }
2665
2666 =item total_paid
2667
2668 Returns the total amount of all payments.
2669
2670 =cut
2671
2672 sub total_paid {
2673   my $self = shift;
2674   my $total = 0;
2675   $total += $_->paid foreach $self->cust_pay;
2676   sprintf( "%.2f", $total );
2677 }
2678
2679 =item total_unapplied_credits
2680
2681 Returns the total outstanding credit (see L<FS::cust_credit>) for this
2682 customer.  See L<FS::cust_credit/credited>.
2683
2684 =item total_credited
2685
2686 Old name for total_unapplied_credits.  Don't use.
2687
2688 =cut
2689
2690 sub total_credited {
2691   #carp "total_credited deprecated, use total_unapplied_credits";
2692   shift->total_unapplied_credits(@_);
2693 }
2694
2695 sub total_unapplied_credits {
2696   my $self = shift;
2697
2698   my $custnum = $self->custnum;
2699
2700   my $unapplied_sql = FS::cust_credit->unapplied_sql;
2701
2702   my $sql = "
2703     SELECT SUM($unapplied_sql) FROM cust_credit
2704       WHERE custnum = $custnum
2705   ";
2706
2707   sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
2708
2709 }
2710
2711 =item total_unapplied_credits_pkgnum PKGNUM
2712
2713 Returns the total outstanding credit (see L<FS::cust_credit>) for this
2714 customer.  See L<FS::cust_credit/credited>.
2715
2716 =cut
2717
2718 sub total_unapplied_credits_pkgnum {
2719   my( $self, $pkgnum ) = @_;
2720   my $total_credit = 0;
2721   $total_credit += $_->credited foreach $self->cust_credit_pkgnum($pkgnum);
2722   sprintf( "%.2f", $total_credit );
2723 }
2724
2725
2726 =item total_unapplied_payments
2727
2728 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer.
2729 See L<FS::cust_pay/unapplied>.
2730
2731 =cut
2732
2733 sub total_unapplied_payments {
2734   my $self = shift;
2735
2736   my $custnum = $self->custnum;
2737
2738   my $unapplied_sql = FS::cust_pay->unapplied_sql;
2739
2740   my $sql = "
2741     SELECT SUM($unapplied_sql) FROM cust_pay
2742       WHERE custnum = $custnum
2743   ";
2744
2745   sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
2746
2747 }
2748
2749 =item total_unapplied_payments_pkgnum PKGNUM
2750
2751 Returns the total unapplied payments (see L<FS::cust_pay>) for this customer's
2752 specific package when using experimental package balances.  See
2753 L<FS::cust_pay/unapplied>.
2754
2755 =cut
2756
2757 sub total_unapplied_payments_pkgnum {
2758   my( $self, $pkgnum ) = @_;
2759   my $total_unapplied = 0;
2760   $total_unapplied += $_->unapplied foreach $self->cust_pay_pkgnum($pkgnum);
2761   sprintf( "%.2f", $total_unapplied );
2762 }
2763
2764
2765 =item total_unapplied_refunds
2766
2767 Returns the total unrefunded refunds (see L<FS::cust_refund>) for this
2768 customer.  See L<FS::cust_refund/unapplied>.
2769
2770 =cut
2771
2772 sub total_unapplied_refunds {
2773   my $self = shift;
2774   my $custnum = $self->custnum;
2775
2776   my $unapplied_sql = FS::cust_refund->unapplied_sql;
2777
2778   my $sql = "
2779     SELECT SUM($unapplied_sql) FROM cust_refund
2780       WHERE custnum = $custnum
2781   ";
2782
2783   sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
2784
2785 }
2786
2787 =item balance
2788
2789 Returns the balance for this customer (total_owed plus total_unrefunded, minus
2790 total_unapplied_credits minus total_unapplied_payments).
2791
2792 =cut
2793
2794 sub balance {
2795   my $self = shift;
2796   $self->balance_date_range;
2797 }
2798
2799 =item balance_date TIME
2800
2801 Returns the balance for this customer, only considering invoices with date
2802 earlier than TIME (total_owed_date minus total_credited minus
2803 total_unapplied_payments).  TIME is specified as a UNIX timestamp; see
2804 L<perlfunc/"time">).  Also see L<Time::Local> and L<Date::Parse> for conversion
2805 functions.
2806
2807 =cut
2808
2809 sub balance_date {
2810   my $self = shift;
2811   $self->balance_date_range(shift);
2812 }
2813
2814 =item balance_date_range [ START_TIME [ END_TIME [ OPTION => VALUE ... ] ] ]
2815
2816 Returns the balance for this customer, optionally considering invoices with
2817 date earlier than START_TIME, and not later than END_TIME
2818 (total_owed_date minus total_unapplied_credits minus total_unapplied_payments).
2819
2820 Times are specified as SQL fragments or numeric
2821 UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
2822 L<Date::Parse> for conversion functions.  The empty string can be passed
2823 to disable that time constraint completely.
2824
2825 Available options are:
2826
2827 =over 4
2828
2829 =item unapplied_date
2830
2831 set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
2832
2833 =back
2834
2835 =cut
2836
2837 sub balance_date_range {
2838   my $self = shift;
2839   my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
2840             ') FROM cust_main WHERE custnum='. $self->custnum;
2841   sprintf( '%.2f', $self->scalar_sql($sql) || 0 );
2842 }
2843
2844 =item balance_pkgnum PKGNUM
2845
2846 Returns the balance for this customer's specific package when using
2847 experimental package balances (total_owed plus total_unrefunded, minus
2848 total_unapplied_credits minus total_unapplied_payments)
2849
2850 =cut
2851
2852 sub balance_pkgnum {
2853   my( $self, $pkgnum ) = @_;
2854
2855   sprintf( "%.2f",
2856       $self->total_owed_pkgnum($pkgnum)
2857 # n/a - refunds aren't part of pkg-balances since they don't apply to invoices
2858 #    + $self->total_unapplied_refunds_pkgnum($pkgnum)
2859     - $self->total_unapplied_credits_pkgnum($pkgnum)
2860     - $self->total_unapplied_payments_pkgnum($pkgnum)
2861   );
2862 }
2863
2864 =item in_transit_payments
2865
2866 Returns the total of requests for payments for this customer pending in 
2867 batches in transit to the bank.  See L<FS::pay_batch> and L<FS::cust_pay_batch>
2868
2869 =cut
2870
2871 sub in_transit_payments {
2872   my $self = shift;
2873   my $in_transit_payments = 0;
2874   foreach my $pay_batch ( qsearch('pay_batch', {
2875     'status' => 'I',
2876   } ) ) {
2877     foreach my $cust_pay_batch ( qsearch('cust_pay_batch', {
2878       'batchnum' => $pay_batch->batchnum,
2879       'custnum' => $self->custnum,
2880     } ) ) {
2881       $in_transit_payments += $cust_pay_batch->amount;
2882     }
2883   }
2884   sprintf( "%.2f", $in_transit_payments );
2885 }
2886
2887 =item payment_info
2888
2889 Returns a hash of useful information for making a payment.
2890
2891 =over 4
2892
2893 =item balance
2894
2895 Current balance.
2896
2897 =item payby
2898
2899 'CARD' (credit card - automatic), 'DCRD' (credit card - on-demand),
2900 'CHEK' (electronic check - automatic), 'DCHK' (electronic check - on-demand),
2901 'LECB' (Phone bill billing), 'BILL' (billing), or 'COMP' (free).
2902
2903 =back
2904
2905 For credit card transactions:
2906
2907 =over 4
2908
2909 =item card_type 1
2910
2911 =item payname
2912
2913 Exact name on card
2914
2915 =back
2916
2917 For electronic check transactions:
2918
2919 =over 4
2920
2921 =item stateid_state
2922
2923 =back
2924
2925 =cut
2926
2927 sub payment_info {
2928   my $self = shift;
2929
2930   my %return = ();
2931
2932   $return{balance} = $self->balance;
2933
2934   $return{payname} = $self->payname
2935                      || ( $self->first. ' '. $self->get('last') );
2936
2937   $return{$_} = $self->bill_location->$_
2938     for qw(address1 address2 city state zip);
2939
2940   $return{payby} = $self->payby;
2941   $return{stateid_state} = $self->stateid_state;
2942
2943   if ( $self->payby =~ /^(CARD|DCRD)$/ ) {
2944     $return{card_type} = cardtype($self->payinfo);
2945     $return{payinfo} = $self->paymask;
2946
2947     @return{'month', 'year'} = $self->paydate_monthyear;
2948
2949   }
2950
2951   if ( $self->payby =~ /^(CHEK|DCHK)$/ ) {
2952     my ($payinfo1, $payinfo2) = split '@', $self->paymask;
2953     $return{payinfo1} = $payinfo1;
2954     $return{payinfo2} = $payinfo2;
2955     $return{paytype}  = $self->paytype;
2956     $return{paystate} = $self->paystate;
2957
2958   }
2959
2960   #doubleclick protection
2961   my $_date = time;
2962   $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
2963
2964   %return;
2965
2966 }
2967
2968 =item paydate_monthyear
2969
2970 Returns a two-element list consisting of the month and year of this customer's
2971 paydate (credit card expiration date for CARD customers)
2972
2973 =cut
2974
2975 sub paydate_monthyear {
2976   my $self = shift;
2977   if ( $self->paydate  =~ /^(\d{4})-(\d{1,2})-\d{1,2}$/ ) { #Pg date format
2978     ( $2, $1 );
2979   } elsif ( $self->paydate =~ /^(\d{1,2})-(\d{1,2}-)?(\d{4}$)/ ) {
2980     ( $1, $3 );
2981   } else {
2982     ('', '');
2983   }
2984 }
2985
2986 =item paydate_epoch
2987
2988 Returns the exact time in seconds corresponding to the payment method 
2989 expiration date.  For CARD/DCRD customers this is the end of the month;
2990 for others (COMP is the only other payby that uses paydate) it's the start.
2991 Returns 0 if the paydate is empty or set to the far future.
2992
2993 =cut
2994
2995 sub paydate_epoch {
2996   my $self = shift;
2997   my ($month, $year) = $self->paydate_monthyear;
2998   return 0 if !$year or $year >= 2037;
2999   if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
3000     $month++;
3001     if ( $month == 13 ) {
3002       $month = 1;
3003       $year++;
3004     }
3005     return timelocal(0,0,0,1,$month-1,$year) - 1;
3006   }
3007   else {
3008     return timelocal(0,0,0,1,$month-1,$year);
3009   }
3010 }
3011
3012 =item paydate_epoch_sql
3013
3014 Class method.  Returns an SQL expression to obtain the payment expiration date
3015 as a number of seconds.
3016
3017 =cut
3018
3019 # Special expiration date behavior for non-CARD/DCRD customers has been 
3020 # carefully preserved.  Do we really use that?
3021 sub paydate_epoch_sql {
3022   my $class = shift;
3023   my $table = shift || 'cust_main';
3024   my ($case1, $case2);
3025   if ( driver_name eq 'Pg' ) {
3026     $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
3027     $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
3028   }
3029   elsif ( lc(driver_name) eq 'mysql' ) {
3030     $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
3031     $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
3032   }
3033   else { return '' }
3034   return "CASE WHEN $table.payby IN('CARD','DCRD') 
3035   THEN ($case1)
3036   ELSE ($case2)
3037   END"
3038 }
3039
3040 =item tax_exemption TAXNAME
3041
3042 =cut
3043
3044 sub tax_exemption {
3045   my( $self, $taxname ) = @_;
3046
3047   qsearchs( 'cust_main_exemption', { 'custnum' => $self->custnum,
3048                                      'taxname' => $taxname,
3049                                    },
3050           );
3051 }
3052
3053 =item cust_main_exemption
3054
3055 =cut
3056
3057 sub cust_main_exemption {
3058   my $self = shift;
3059   qsearch( 'cust_main_exemption', { 'custnum' => $self->custnum } );
3060 }
3061
3062 =item invoicing_list [ ARRAYREF ]
3063
3064 If an arguement is given, sets these email addresses as invoice recipients
3065 (see L<FS::cust_main_invoice>).  Errors are not fatal and are not reported
3066 (except as warnings), so use check_invoicing_list first.
3067
3068 Returns a list of email addresses (with svcnum entries expanded).
3069
3070 Note: You can clear the invoicing list by passing an empty ARRAYREF.  You can
3071 check it without disturbing anything by passing nothing.
3072
3073 This interface may change in the future.
3074
3075 =cut
3076
3077 sub invoicing_list {
3078   my( $self, $arrayref ) = @_;
3079
3080   if ( $arrayref ) {
3081     my @cust_main_invoice;
3082     if ( $self->custnum ) {
3083       @cust_main_invoice = 
3084         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
3085     } else {
3086       @cust_main_invoice = ();
3087     }
3088     foreach my $cust_main_invoice ( @cust_main_invoice ) {
3089       #warn $cust_main_invoice->destnum;
3090       unless ( grep { $cust_main_invoice->address eq $_ } @{$arrayref} ) {
3091         #warn $cust_main_invoice->destnum;
3092         my $error = $cust_main_invoice->delete;
3093         warn $error if $error;
3094       }
3095     }
3096     if ( $self->custnum ) {
3097       @cust_main_invoice = 
3098         qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
3099     } else {
3100       @cust_main_invoice = ();
3101     }
3102     my %seen = map { $_->address => 1 } @cust_main_invoice;
3103     foreach my $address ( @{$arrayref} ) {
3104       next if exists $seen{$address} && $seen{$address};
3105       $seen{$address} = 1;
3106       my $cust_main_invoice = new FS::cust_main_invoice ( {
3107         'custnum' => $self->custnum,
3108         'dest'    => $address,
3109       } );
3110       my $error = $cust_main_invoice->insert;
3111       warn $error if $error;
3112     }
3113   }
3114   
3115   if ( $self->custnum ) {
3116     map { $_->address }
3117       qsearch( 'cust_main_invoice', { 'custnum' => $self->custnum } );
3118   } else {
3119     ();
3120   }
3121
3122 }
3123
3124 =item check_invoicing_list ARRAYREF
3125
3126 Checks these arguements as valid input for the invoicing_list method.  If there
3127 is an error, returns the error, otherwise returns false.
3128
3129 =cut
3130
3131 sub check_invoicing_list {
3132   my( $self, $arrayref ) = @_;
3133
3134   foreach my $address ( @$arrayref ) {
3135
3136     if ($address eq 'FAX' and $self->getfield('fax') eq '') {
3137       return 'Can\'t add FAX invoice destination with a blank FAX number.';
3138     }
3139
3140     my $cust_main_invoice = new FS::cust_main_invoice ( {
3141       'custnum' => $self->custnum,
3142       'dest'    => $address,
3143     } );
3144     my $error = $self->custnum
3145                 ? $cust_main_invoice->check
3146                 : $cust_main_invoice->checkdest
3147     ;
3148     return $error if $error;
3149
3150   }
3151
3152   return "Email address required"
3153     if $conf->exists('cust_main-require_invoicing_list_email', $self->agentnum)
3154     && ! grep { $_ !~ /^([A-Z]+)$/ } @$arrayref;
3155
3156   '';
3157 }
3158
3159 =item set_default_invoicing_list
3160
3161 Sets the invoicing list to all accounts associated with this customer,
3162 overwriting any previous invoicing list.
3163
3164 =cut
3165
3166 sub set_default_invoicing_list {
3167   my $self = shift;
3168   $self->invoicing_list($self->all_emails);
3169 }
3170
3171 =item all_emails
3172
3173 Returns the email addresses of all accounts provisioned for this customer.
3174
3175 =cut
3176
3177 sub all_emails {
3178   my $self = shift;
3179   my %list;
3180   foreach my $cust_pkg ( $self->all_pkgs ) {
3181     my @cust_svc = qsearch('cust_svc', { 'pkgnum' => $cust_pkg->pkgnum } );
3182     my @svc_acct =
3183       map { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
3184         grep { qsearchs('svc_acct', { 'svcnum' => $_->svcnum } ) }
3185           @cust_svc;
3186     $list{$_}=1 foreach map { $_->email } @svc_acct;
3187   }
3188   keys %list;
3189 }
3190
3191 =item invoicing_list_addpost
3192
3193 Adds postal invoicing to this customer.  If this customer is already configured
3194 to receive postal invoices, does nothing.
3195
3196 =cut
3197
3198 sub invoicing_list_addpost {
3199   my $self = shift;
3200   return if grep { $_ eq 'POST' } $self->invoicing_list;
3201   my @invoicing_list = $self->invoicing_list;
3202   push @invoicing_list, 'POST';
3203   $self->invoicing_list(\@invoicing_list);
3204 }
3205
3206 =item invoicing_list_emailonly
3207
3208 Returns the list of email invoice recipients (invoicing_list without non-email
3209 destinations such as POST and FAX).
3210
3211 =cut
3212
3213 sub invoicing_list_emailonly {
3214   my $self = shift;
3215   warn "$me invoicing_list_emailonly called"
3216     if $DEBUG;
3217   grep { $_ !~ /^([A-Z]+)$/ } $self->invoicing_list;
3218 }
3219
3220 =item invoicing_list_emailonly_scalar
3221
3222 Returns the list of email invoice recipients (invoicing_list without non-email
3223 destinations such as POST and FAX) as a comma-separated scalar.
3224
3225 =cut
3226
3227 sub invoicing_list_emailonly_scalar {
3228   my $self = shift;
3229   warn "$me invoicing_list_emailonly_scalar called"
3230     if $DEBUG;
3231   join(', ', $self->invoicing_list_emailonly);
3232 }
3233
3234 =item referral_custnum_cust_main
3235
3236 Returns the customer who referred this customer (or the empty string, if
3237 this customer was not referred).
3238
3239 Note the difference with referral_cust_main method: This method,
3240 referral_custnum_cust_main returns the single customer (if any) who referred
3241 this customer, while referral_cust_main returns an array of customers referred
3242 BY this customer.
3243
3244 =cut
3245
3246 sub referral_custnum_cust_main {
3247   my $self = shift;
3248   return '' unless $self->referral_custnum;
3249   qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
3250 }
3251
3252 =item referral_cust_main [ DEPTH [ EXCLUDE_HASHREF ] ]
3253
3254 Returns an array of customers referred by this customer (referral_custnum set
3255 to this custnum).  If DEPTH is given, recurses up to the given depth, returning
3256 customers referred by customers referred by this customer and so on, inclusive.
3257 The default behavior is DEPTH 1 (no recursion).
3258
3259 Note the difference with referral_custnum_cust_main method: This method,
3260 referral_cust_main, returns an array of customers referred BY this customer,
3261 while referral_custnum_cust_main returns the single customer (if any) who
3262 referred this customer.
3263
3264 =cut
3265
3266 sub referral_cust_main {
3267   my $self = shift;
3268   my $depth = @_ ? shift : 1;
3269   my $exclude = @_ ? shift : {};
3270
3271   my @cust_main =
3272     map { $exclude->{$_->custnum}++; $_; }
3273       grep { ! $exclude->{ $_->custnum } }
3274         qsearch( 'cust_main', { 'referral_custnum' => $self->custnum } );
3275
3276   if ( $depth > 1 ) {
3277     push @cust_main,
3278       map { $_->referral_cust_main($depth-1, $exclude) }
3279         @cust_main;
3280   }
3281
3282   @cust_main;
3283 }
3284
3285 =item referral_cust_main_ncancelled
3286
3287 Same as referral_cust_main, except only returns customers with uncancelled
3288 packages.
3289
3290 =cut
3291
3292 sub referral_cust_main_ncancelled {
3293   my $self = shift;
3294   grep { scalar($_->ncancelled_pkgs) } $self->referral_cust_main;
3295 }
3296
3297 =item referral_cust_pkg [ DEPTH ]
3298
3299 Like referral_cust_main, except returns a flat list of all unsuspended (and
3300 uncancelled) packages for each customer.  The number of items in this list may
3301 be useful for commission calculations (perhaps after a C<grep { my $pkgpart = $_->pkgpart; grep { $_ == $pkgpart } @commission_worthy_pkgparts> } $cust_main-> ).
3302
3303 =cut
3304
3305 sub referral_cust_pkg {
3306   my $self = shift;
3307   my $depth = @_ ? shift : 1;
3308
3309   map { $_->unsuspended_pkgs }
3310     grep { $_->unsuspended_pkgs }
3311       $self->referral_cust_main($depth);
3312 }
3313
3314 =item referring_cust_main
3315
3316 Returns the single cust_main record for the customer who referred this customer
3317 (referral_custnum), or false.
3318
3319 =cut
3320
3321 sub referring_cust_main {
3322   my $self = shift;
3323   return '' unless $self->referral_custnum;
3324   qsearchs('cust_main', { 'custnum' => $self->referral_custnum } );
3325 }
3326
3327 =item credit AMOUNT, REASON [ , OPTION => VALUE ... ]
3328
3329 Applies a credit to this customer.  If there is an error, returns the error,
3330 otherwise returns false.
3331
3332 REASON can be a text string, an FS::reason object, or a scalar reference to
3333 a reasonnum.  If a text string, it will be automatically inserted as a new
3334 reason, and a 'reason_type' option must be passed to indicate the
3335 FS::reason_type for the new reason.
3336
3337 An I<addlinfo> option may be passed to set the credit's I<addlinfo> field.
3338
3339 Any other options are passed to FS::cust_credit::insert.
3340
3341 =cut
3342
3343 sub credit {
3344   my( $self, $amount, $reason, %options ) = @_;
3345
3346   my $cust_credit = new FS::cust_credit {
3347     'custnum' => $self->custnum,
3348     'amount'  => $amount,
3349   };
3350
3351   if ( ref($reason) ) {
3352
3353     if ( ref($reason) eq 'SCALAR' ) {
3354       $cust_credit->reasonnum( $$reason );
3355     } else {
3356       $cust_credit->reasonnum( $reason->reasonnum );
3357     }
3358
3359   } else {
3360     $cust_credit->set('reason', $reason)
3361   }
3362
3363   for (qw( addlinfo eventnum )) {
3364     $cust_credit->$_( delete $options{$_} )
3365       if exists($options{$_});
3366   }
3367
3368   $cust_credit->insert(%options);
3369
3370 }
3371
3372 =item charge HASHREF || AMOUNT [ PKG [ COMMENT [ TAXCLASS ] ] ]
3373
3374 Creates a one-time charge for this customer.  If there is an error, returns
3375 the error, otherwise returns false.
3376
3377 New-style, with a hashref of options:
3378
3379   my $error = $cust_main->charge(
3380                                   {
3381                                     'amount'     => 54.32,
3382                                     'quantity'   => 1,
3383                                     'start_date' => str2time('7/4/2009'),
3384                                     'pkg'        => 'Description',
3385                                     'comment'    => 'Comment',
3386                                     'additional' => [], #extra invoice detail
3387                                     'classnum'   => 1,  #pkg_class
3388
3389                                     'setuptax'   => '', # or 'Y' for tax exempt
3390
3391                                     #internal taxation
3392                                     'taxclass'   => 'Tax class',
3393
3394                                     #vendor taxation
3395                                     'taxproduct' => 2,  #part_pkg_taxproduct
3396                                     'override'   => {}, #XXX describe
3397
3398                                     #will be filled in with the new object
3399                                     'cust_pkg_ref' => \$cust_pkg,
3400
3401                                     #generate an invoice immediately
3402                                     'bill_now' => 0,
3403                                     'invoice_terms' => '', #with these terms
3404                                   }
3405                                 );
3406
3407 Old-style:
3408
3409   my $error = $cust_main->charge( 54.32, 'Description', 'Comment', 'Tax class' );
3410
3411 =cut
3412
3413 sub charge {
3414   my $self = shift;
3415   my ( $amount, $quantity, $start_date, $classnum );
3416   my ( $pkg, $comment, $additional );
3417   my ( $setuptax, $taxclass );   #internal taxes
3418   my ( $taxproduct, $override ); #vendor (CCH) taxes
3419   my $no_auto = '';
3420   my $cust_pkg_ref = '';
3421   my ( $bill_now, $invoice_terms ) = ( 0, '' );
3422   if ( ref( $_[0] ) ) {
3423     $amount     = $_[0]->{amount};
3424     $quantity   = exists($_[0]->{quantity}) ? $_[0]->{quantity} : 1;
3425     $start_date = exists($_[0]->{start_date}) ? $_[0]->{start_date} : '';
3426     $no_auto    = exists($_[0]->{no_auto}) ? $_[0]->{no_auto} : '';
3427     $pkg        = exists($_[0]->{pkg}) ? $_[0]->{pkg} : 'One-time charge';
3428     $comment    = exists($_[0]->{comment}) ? $_[0]->{comment}
3429                                            : '$'. sprintf("%.2f",$amount);
3430     $setuptax   = exists($_[0]->{setuptax}) ? $_[0]->{setuptax} : '';
3431     $taxclass   = exists($_[0]->{taxclass}) ? $_[0]->{taxclass} : '';
3432     $classnum   = exists($_[0]->{classnum}) ? $_[0]->{classnum} : '';
3433     $additional = $_[0]->{additional} || [];
3434     $taxproduct = $_[0]->{taxproductnum};
3435     $override   = { '' => $_[0]->{tax_override} };
3436     $cust_pkg_ref = exists($_[0]->{cust_pkg_ref}) ? $_[0]->{cust_pkg_ref} : '';
3437     $bill_now = exists($_[0]->{bill_now}) ? $_[0]->{bill_now} : '';
3438     $invoice_terms = exists($_[0]->{invoice_terms}) ? $_[0]->{invoice_terms} : '';
3439   } else {
3440     $amount     = shift;
3441     $quantity   = 1;
3442     $start_date = '';
3443     $pkg        = @_ ? shift : 'One-time charge';
3444     $comment    = @_ ? shift : '$'. sprintf("%.2f",$amount);
3445     $setuptax   = '';
3446     $taxclass   = @_ ? shift : '';
3447     $additional = [];
3448   }
3449
3450   local $SIG{HUP} = 'IGNORE';
3451   local $SIG{INT} = 'IGNORE';
3452   local $SIG{QUIT} = 'IGNORE';
3453   local $SIG{TERM} = 'IGNORE';
3454   local $SIG{TSTP} = 'IGNORE';
3455   local $SIG{PIPE} = 'IGNORE';
3456
3457   my $oldAutoCommit = $FS::UID::AutoCommit;
3458   local $FS::UID::AutoCommit = 0;
3459   my $dbh = dbh;
3460
3461   my $part_pkg = new FS::part_pkg ( {
3462     'pkg'           => $pkg,
3463     'comment'       => $comment,
3464     'plan'          => 'flat',
3465     'freq'          => 0,
3466     'disabled'      => 'Y',
3467     'classnum'      => ( $classnum ? $classnum : '' ),
3468     'setuptax'      => $setuptax,
3469     'taxclass'      => $taxclass,
3470     'taxproductnum' => $taxproduct,
3471   } );
3472
3473   my %options = ( ( map { ("additional_info$_" => $additional->[$_] ) }
3474                         ( 0 .. @$additional - 1 )
3475                   ),
3476                   'additional_count' => scalar(@$additional),
3477                   'setup_fee' => $amount,
3478                 );
3479
3480   my $error = $part_pkg->insert( options       => \%options,
3481                                  tax_overrides => $override,
3482                                );
3483   if ( $error ) {
3484     $dbh->rollback if $oldAutoCommit;
3485     return $error;
3486   }
3487
3488   my $pkgpart = $part_pkg->pkgpart;
3489   my %type_pkgs = ( 'typenum' => $self->agent->typenum, 'pkgpart' => $pkgpart );
3490   unless ( qsearchs('type_pkgs', \%type_pkgs ) ) {
3491     my $type_pkgs = new FS::type_pkgs \%type_pkgs;
3492     $error = $type_pkgs->insert;
3493     if ( $error ) {
3494       $dbh->rollback if $oldAutoCommit;
3495       return $error;
3496     }
3497   }
3498
3499   my $cust_pkg = new FS::cust_pkg ( {
3500     'custnum'    => $self->custnum,
3501     'pkgpart'    => $pkgpart,
3502     'quantity'   => $quantity,
3503     'start_date' => $start_date,
3504     'no_auto'    => $no_auto,
3505   } );
3506
3507   $error = $cust_pkg->insert;
3508   if ( $error ) {
3509     $dbh->rollback if $oldAutoCommit;
3510     return $error;
3511   } elsif ( $cust_pkg_ref ) {
3512     ${$cust_pkg_ref} = $cust_pkg;
3513   }
3514
3515   if ( $bill_now ) {
3516     my $error = $self->bill( 'invoice_terms' => $invoice_terms,
3517                              'pkg_list'      => [ $cust_pkg ],
3518                            );
3519     if ( $error ) {
3520       $dbh->rollback if $oldAutoCommit;
3521       return $error;
3522     }   
3523   }
3524
3525   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
3526   return '';
3527
3528 }
3529
3530 #=item charge_postal_fee
3531 #
3532 #Applies a one time charge this customer.  If there is an error,
3533 #returns the error, returns the cust_pkg charge object or false
3534 #if there was no charge.
3535 #
3536 #=cut
3537 #
3538 # This should be a customer event.  For that to work requires that bill
3539 # also be a customer event.
3540
3541 sub charge_postal_fee {
3542   my $self = shift;
3543
3544   my $pkgpart = $conf->config('postal_invoice-fee_pkgpart', $self->agentnum);
3545   return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
3546
3547   my $cust_pkg = new FS::cust_pkg ( {
3548     'custnum'  => $self->custnum,
3549     'pkgpart'  => $pkgpart,
3550     'quantity' => 1,
3551   } );
3552
3553   my $error = $cust_pkg->insert;
3554   $error ? $error : $cust_pkg;
3555 }
3556
3557 =item cust_bill [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
3558
3559 Returns all the invoices (see L<FS::cust_bill>) for this customer.
3560
3561 Optionally, a list or hashref of additional arguments to the qsearch call can
3562 be passed.
3563
3564 =cut
3565
3566 sub cust_bill {
3567   my $self = shift;
3568   my $opt = ref($_[0]) ? shift : { @_ };
3569
3570   #return $self->num_cust_bill unless wantarray || keys %$opt;
3571
3572   $opt->{'table'} = 'cust_bill';
3573   $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
3574   $opt->{'hashref'}{'custnum'} = $self->custnum;
3575   $opt->{'order_by'} ||= 'ORDER BY _date ASC';
3576
3577   map { $_ } #behavior of sort undefined in scalar context
3578     sort { $a->_date <=> $b->_date }
3579       qsearch($opt);
3580 }
3581
3582 =item open_cust_bill
3583
3584 Returns all the open (owed > 0) invoices (see L<FS::cust_bill>) for this
3585 customer.
3586
3587 =cut
3588
3589 sub open_cust_bill {
3590   my $self = shift;
3591
3592   $self->cust_bill(
3593     'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
3594     #@_
3595   );
3596
3597 }
3598
3599 =item legacy_cust_bill [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
3600
3601 Returns all the legacy invoices (see L<FS::legacy_cust_bill>) for this customer.
3602
3603 =cut
3604
3605 sub legacy_cust_bill {
3606   my $self = shift;
3607
3608   #return $self->num_legacy_cust_bill unless wantarray;
3609
3610   map { $_ } #behavior of sort undefined in scalar context
3611     sort { $a->_date <=> $b->_date }
3612       qsearch({ 'table'    => 'legacy_cust_bill',
3613                 'hashref'  => { 'custnum' => $self->custnum, },
3614                 'order_by' => 'ORDER BY _date ASC',
3615              });
3616 }
3617
3618 =item cust_statement [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
3619
3620 Returns all the statements (see L<FS::cust_statement>) for this customer.
3621
3622 Optionally, a list or hashref of additional arguments to the qsearch call can
3623 be passed.
3624
3625 =cut
3626
3627 sub cust_statement {
3628   my $self = shift;
3629   my $opt = ref($_[0]) ? shift : { @_ };
3630
3631   #return $self->num_cust_statement unless wantarray || keys %$opt;
3632
3633   $opt->{'table'} = 'cust_statement';
3634   $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
3635   $opt->{'hashref'}{'custnum'} = $self->custnum;
3636   $opt->{'order_by'} ||= 'ORDER BY _date ASC';
3637
3638   map { $_ } #behavior of sort undefined in scalar context
3639     sort { $a->_date <=> $b->_date }
3640       qsearch($opt);
3641 }
3642
3643 =item svc_x SVCDB [ OPTION => VALUE | EXTRA_QSEARCH_PARAMS_HASHREF ]
3644
3645 Returns all services of type SVCDB (such as 'svc_acct') for this customer.  
3646
3647 Optionally, a list or hashref of additional arguments to the qsearch call can 
3648 be passed following the SVCDB.
3649
3650 =cut
3651
3652 sub svc_x {
3653   my $self = shift;
3654   my $svcdb = shift;
3655   if ( ! $svcdb =~ /^svc_\w+$/ ) {
3656     warn "$me svc_x requires a svcdb";
3657     return;
3658   }
3659   my $opt = ref($_[0]) ? shift : { @_ };
3660
3661   $opt->{'table'} = $svcdb;
3662   $opt->{'addl_from'} = 
3663     'LEFT JOIN cust_svc USING (svcnum) LEFT JOIN cust_pkg USING (pkgnum) '.
3664     ($opt->{'addl_from'} || '');
3665
3666   my $custnum = $self->custnum;
3667   $custnum =~ /^\d+$/ or die "bad custnum '$custnum'";
3668   my $where = "cust_pkg.custnum = $custnum";
3669
3670   my $extra_sql = $opt->{'extra_sql'} || '';
3671   if ( keys %{ $opt->{'hashref'} } ) {
3672     $extra_sql = " AND $where $extra_sql";
3673   }
3674   else {
3675     if ( $opt->{'extra_sql'} =~ /^\s*where\s(.*)/si ) {
3676       $extra_sql = "WHERE $where AND $1";
3677     }
3678     else {
3679       $extra_sql = "WHERE $where $extra_sql";
3680     }
3681   }
3682   $opt->{'extra_sql'} = $extra_sql;
3683
3684   qsearch($opt);
3685 }
3686
3687 # required for use as an eventtable; 
3688 sub svc_acct {
3689   my $self = shift;
3690   $self->svc_x('svc_acct', @_);
3691 }
3692
3693 =item cust_credit
3694
3695 Returns all the credits (see L<FS::cust_credit>) for this customer.
3696
3697 =cut
3698
3699 sub cust_credit {
3700   my $self = shift;
3701   map { $_ } #return $self->num_cust_credit unless wantarray;
3702   sort { $a->_date <=> $b->_date }
3703     qsearch( 'cust_credit', { 'custnum' => $self->custnum } )
3704 }
3705
3706 =item cust_credit_pkgnum
3707
3708 Returns all the credits (see L<FS::cust_credit>) for this customer's specific
3709 package when using experimental package balances.
3710
3711 =cut
3712
3713 sub cust_credit_pkgnum {
3714   my( $self, $pkgnum ) = @_;
3715   map { $_ } #return $self->num_cust_credit_pkgnum($pkgnum) unless wantarray;
3716   sort { $a->_date <=> $b->_date }
3717     qsearch( 'cust_credit', { 'custnum' => $self->custnum,
3718                               'pkgnum'  => $pkgnum,
3719                             }
3720     );
3721 }
3722
3723 =item cust_pay
3724
3725 Returns all the payments (see L<FS::cust_pay>) for this customer.
3726
3727 =cut
3728
3729 sub cust_pay {
3730   my $self = shift;
3731   return $self->num_cust_pay unless wantarray;
3732   sort { $a->_date <=> $b->_date }
3733     qsearch( 'cust_pay', { 'custnum' => $self->custnum } )
3734 }
3735
3736 =item num_cust_pay
3737
3738 Returns the number of payments (see L<FS::cust_pay>) for this customer.  Also
3739 called automatically when the cust_pay method is used in a scalar context.
3740
3741 =cut
3742
3743 sub num_cust_pay {
3744   my $self = shift;
3745   my $sql = "SELECT COUNT(*) FROM cust_pay WHERE custnum = ?";
3746   my $sth = dbh->prepare($sql) or die dbh->errstr;
3747   $sth->execute($self->custnum) or die $sth->errstr;
3748   $sth->fetchrow_arrayref->[0];
3749 }
3750
3751 =item cust_pay_pkgnum
3752
3753 Returns all the payments (see L<FS::cust_pay>) for this customer's specific
3754 package when using experimental package balances.
3755
3756 =cut
3757
3758 sub cust_pay_pkgnum {
3759   my( $self, $pkgnum ) = @_;
3760   map { $_ } #return $self->num_cust_pay_pkgnum($pkgnum) unless wantarray;
3761   sort { $a->_date <=> $b->_date }
3762     qsearch( 'cust_pay', { 'custnum' => $self->custnum,
3763                            'pkgnum'  => $pkgnum,
3764                          }
3765     );
3766 }
3767
3768 =item cust_pay_void
3769
3770 Returns all voided payments (see L<FS::cust_pay_void>) for this customer.
3771
3772 =cut
3773
3774 sub cust_pay_void {
3775   my $self = shift;
3776   map { $_ } #return $self->num_cust_pay_void unless wantarray;
3777   sort { $a->_date <=> $b->_date }
3778     qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
3779 }
3780
3781 =item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
3782
3783 Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
3784
3785 Optionally, a list or hashref of additional arguments to the qsearch call can
3786 be passed.
3787
3788 =cut
3789
3790 sub cust_pay_batch {
3791   my $self = shift;
3792   my $opt = ref($_[0]) ? shift : { @_ };
3793
3794   #return $self->num_cust_statement unless wantarray || keys %$opt;
3795
3796   $opt->{'table'} = 'cust_pay_batch';
3797   $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
3798   $opt->{'hashref'}{'custnum'} = $self->custnum;
3799   $opt->{'order_by'} ||= 'ORDER BY paybatchnum ASC';
3800
3801   map { $_ } #behavior of sort undefined in scalar context
3802     sort { $a->paybatchnum <=> $b->paybatchnum }
3803       qsearch($opt);
3804 }
3805
3806 =item cust_pay_pending
3807
3808 Returns all pending payments (see L<FS::cust_pay_pending>) for this customer
3809 (without status "done").
3810
3811 =cut
3812
3813 sub cust_pay_pending {
3814   my $self = shift;
3815   return $self->num_cust_pay_pending unless wantarray;
3816   sort { $a->_date <=> $b->_date }
3817     qsearch( 'cust_pay_pending', {
3818                                    'custnum' => $self->custnum,
3819                                    'status'  => { op=>'!=', value=>'done' },
3820                                  },
3821            );
3822 }
3823
3824 =item cust_pay_pending_attempt
3825
3826 Returns all payment attempts / declined payments for this customer, as pending
3827 payments objects (see L<FS::cust_pay_pending>), with status "done" but without
3828 a corresponding payment (see L<FS::cust_pay>).
3829
3830 =cut
3831
3832 sub cust_pay_pending_attempt {
3833   my $self = shift;
3834   return $self->num_cust_pay_pending_attempt unless wantarray;
3835   sort { $a->_date <=> $b->_date }
3836     qsearch( 'cust_pay_pending', {
3837                                    'custnum' => $self->custnum,
3838                                    'status'  => 'done',
3839                                    'paynum'  => '',
3840                                  },
3841            );
3842 }
3843
3844 =item num_cust_pay_pending
3845
3846 Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
3847 customer (without status "done").  Also called automatically when the
3848 cust_pay_pending method is used in a scalar context.
3849
3850 =cut
3851
3852 sub num_cust_pay_pending {
3853   my $self = shift;
3854   $self->scalar_sql(
3855     " SELECT COUNT(*) FROM cust_pay_pending ".
3856       " WHERE custnum = ? AND status != 'done' ",
3857     $self->custnum
3858   );
3859 }
3860
3861 =item num_cust_pay_pending_attempt
3862
3863 Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
3864 customer, with status "done" but without a corresp.  Also called automatically when the
3865 cust_pay_pending method is used in a scalar context.
3866
3867 =cut
3868
3869 sub num_cust_pay_pending_attempt {
3870   my $self = shift;
3871   $self->scalar_sql(
3872     " SELECT COUNT(*) FROM cust_pay_pending ".
3873       " WHERE custnum = ? AND status = 'done' AND paynum IS NULL",
3874     $self->custnum
3875   );
3876 }
3877
3878 =item cust_refund
3879
3880 Returns all the refunds (see L<FS::cust_refund>) for this customer.
3881
3882 =cut
3883
3884 sub cust_refund {
3885   my $self = shift;
3886   map { $_ } #return $self->num_cust_refund unless wantarray;
3887   sort { $a->_date <=> $b->_date }
3888     qsearch( 'cust_refund', { 'custnum' => $self->custnum } )
3889 }
3890
3891 =item display_custnum
3892
3893 Returns the displayed customer number for this customer: agent_custid if
3894 cust_main-default_agent_custid is set and it has a value, custnum otherwise.
3895
3896 =cut
3897
3898 sub display_custnum {
3899   my $self = shift;
3900
3901   my $prefix = $conf->config('cust_main-custnum-display_prefix', $self->agentnum) || '';
3902   if ( my $special = $conf->config('cust_main-custnum-display_special') ) {
3903     if ( $special eq 'CoStAg' ) {
3904       $prefix = uc( join('',
3905         $self->country,
3906         ($self->state =~ /^(..)/),
3907         $prefix || ($self->agent->agent =~ /^(..)/)
3908       ) );
3909     }
3910     elsif ( $special eq 'CoStCl' ) {
3911       $prefix = uc( join('',
3912         $self->country,
3913         ($self->state =~ /^(..)/),
3914         ($self->classnum ? $self->cust_class->classname =~ /^(..)/ : '__')
3915       ) );
3916     }
3917     # add any others here if needed
3918   }
3919
3920   my $length = $conf->config('cust_main-custnum-display_length');
3921   if ( $conf->exists('cust_main-default_agent_custid') && $self->agent_custid ){
3922     return $self->agent_custid;
3923   } elsif ( $prefix ) {
3924     $length = 8 if !defined($length);
3925     return $prefix . 
3926            sprintf('%0'.$length.'d', $self->custnum)
3927   } elsif ( $length ) {
3928     return sprintf('%0'.$length.'d', $self->custnum);
3929   } else {
3930     return $self->custnum;
3931   }
3932 }
3933
3934 =item name
3935
3936 Returns a name string for this customer, either "Company (Last, First)" or
3937 "Last, First".
3938
3939 =cut
3940
3941 sub name {
3942   my $self = shift;
3943   my $name = $self->contact;
3944   $name = $self->company. " ($name)" if $self->company;
3945   $name;
3946 }
3947
3948 =item service_contact
3949
3950 Returns the L<FS::contact> object for this customer that has the 'Service'
3951 contact class, or undef if there is no such contact.  Deprecated; don't use
3952 this in new code.
3953
3954 =cut
3955
3956 sub service_contact {
3957   my $self = shift;
3958   if ( !exists($self->{service_contact}) ) {
3959     my $classnum = $self->scalar_sql(
3960       'SELECT classnum FROM contact_class WHERE classname = \'Service\''
3961     ) || 0; #if it's zero, qsearchs will return nothing
3962     $self->{service_contact} = qsearchs('contact', { 
3963         'classnum' => $classnum, 'custnum' => $self->custnum
3964       }) || undef;
3965   }
3966   $self->{service_contact};
3967 }
3968
3969 =item ship_name
3970
3971 Returns a name string for this (service/shipping) contact, either
3972 "Company (Last, First)" or "Last, First".
3973
3974 =cut
3975
3976 sub ship_name {
3977   my $self = shift;
3978
3979   my $name = $self->ship_contact;
3980   $name = $self->company. " ($name)" if $self->company;
3981   $name;
3982 }
3983
3984 =item name_short
3985
3986 Returns a name string for this customer, either "Company" or "First Last".
3987
3988 =cut
3989
3990 sub name_short {
3991   my $self = shift;
3992   $self->company !~ /^\s*$/ ? $self->company : $self->contact_firstlast;
3993 }
3994
3995 =item ship_name_short
3996
3997 Returns a name string for this (service/shipping) contact, either "Company"
3998 or "First Last".
3999
4000 =cut
4001
4002 sub ship_name_short {
4003   my $self = shift;
4004   $self->service_contact 
4005     ? $self->ship_contact_firstlast 
4006     : $self->name_short
4007 }
4008
4009 =item contact
4010
4011 Returns this customer's full (billing) contact name only, "Last, First"
4012
4013 =cut
4014
4015 sub contact {
4016   my $self = shift;
4017   $self->get('last'). ', '. $self->first;
4018 }
4019
4020 =item ship_contact
4021
4022 Returns this customer's full (shipping) contact name only, "Last, First"
4023
4024 =cut
4025
4026 sub ship_contact {
4027   my $self = shift;
4028   my $contact = $self->service_contact || $self;
4029   $contact->get('last') . ', ' . $contact->get('first');
4030 }
4031
4032 =item contact_firstlast
4033
4034 Returns this customers full (billing) contact name only, "First Last".
4035
4036 =cut
4037
4038 sub contact_firstlast {
4039   my $self = shift;
4040   $self->first. ' '. $self->get('last');
4041 }
4042
4043 =item ship_contact_firstlast
4044
4045 Returns this customer's full (shipping) contact name only, "First Last".
4046
4047 =cut
4048
4049 sub ship_contact_firstlast {
4050   my $self = shift;
4051   my $contact = $self->service_contact || $self;
4052   $contact->get('first') . ' '. $contact->get('last');
4053 }
4054
4055 =item country_full
4056
4057 Returns this customer's full country name
4058
4059 =cut
4060
4061 sub country_full {
4062   my $self = shift;
4063   code2country($self->country);
4064 }
4065
4066 =item geocode DATA_VENDOR
4067
4068 Returns a value for the customer location as encoded by DATA_VENDOR.
4069 Currently this only makes sense for "CCH" as DATA_VENDOR.
4070
4071 =cut
4072
4073 =item cust_status
4074
4075 =item status
4076
4077 Returns a status string for this customer, currently:
4078
4079 =over 4
4080
4081 =item prospect - No packages have ever been ordered
4082
4083 =item ordered - Recurring packages all are new (not yet billed).
4084
4085 =item active - One or more recurring packages is active
4086
4087 =item inactive - No active recurring packages, but otherwise unsuspended/uncancelled (the inactive status is new - previously inactive customers were mis-identified as cancelled)
4088
4089 =item suspended - All non-cancelled recurring packages are suspended
4090
4091 =item cancelled - All recurring packages are cancelled
4092
4093 =back
4094
4095 Behavior of inactive vs. cancelled edge cases can be adjusted with the
4096 cust_main-status_module configuration option.
4097
4098 =cut
4099
4100 sub status { shift->cust_status(@_); }
4101
4102 sub cust_status {
4103   my $self = shift;
4104   for my $status ( FS::cust_main->statuses() ) {
4105     my $method = $status.'_sql';
4106     my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
4107     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
4108     $sth->execute( ($self->custnum) x $numnum )
4109       or die "Error executing 'SELECT $sql': ". $sth->errstr;
4110     return $status if $sth->fetchrow_arrayref->[0];
4111   }
4112 }
4113
4114 =item ucfirst_cust_status
4115
4116 =item ucfirst_status
4117
4118 Returns the status with the first character capitalized.
4119
4120 =cut
4121
4122 sub ucfirst_status { shift->ucfirst_cust_status(@_); }
4123
4124 sub ucfirst_cust_status {
4125   my $self = shift;
4126   ucfirst($self->cust_status);
4127 }
4128
4129 =item statuscolor
4130
4131 Returns a hex triplet color string for this customer's status.
4132
4133 =cut
4134
4135 sub statuscolor { shift->cust_statuscolor(@_); }
4136
4137 sub cust_statuscolor {
4138   my $self = shift;
4139   __PACKAGE__->statuscolors->{$self->cust_status};
4140 }
4141
4142 =item tickets
4143
4144 Returns an array of hashes representing the customer's RT tickets.
4145
4146 =cut
4147
4148 sub tickets {
4149   my $self = shift;
4150
4151   my $num = $conf->config('cust_main-max_tickets') || 10;
4152   my @tickets = ();
4153
4154   if ( $conf->config('ticket_system') ) {
4155     unless ( $conf->config('ticket_system-custom_priority_field') ) {
4156
4157       @tickets = @{ FS::TicketSystem->customer_tickets($self->custnum, $num) };
4158
4159     } else {
4160
4161       foreach my $priority (
4162         $conf->config('ticket_system-custom_priority_field-values'), ''
4163       ) {
4164         last if scalar(@tickets) >= $num;
4165         push @tickets, 
4166           @{ FS::TicketSystem->customer_tickets( $self->custnum,
4167                                                  $num - scalar(@tickets),
4168                                                  $priority,
4169                                                )
4170            };
4171       }
4172     }
4173   }
4174   (@tickets);
4175 }
4176
4177 # Return services representing svc_accts in customer support packages
4178 sub support_services {
4179   my $self = shift;
4180   my %packages = map { $_ => 1 } $conf->config('support_packages');
4181
4182   grep { $_->pkg_svc && $_->pkg_svc->primary_svc eq 'Y' }
4183     grep { $_->part_svc->svcdb eq 'svc_acct' }
4184     map { $_->cust_svc }
4185     grep { exists $packages{ $_->pkgpart } }
4186     $self->ncancelled_pkgs;
4187
4188 }
4189
4190 # Return a list of latitude/longitude for one of the services (if any)
4191 sub service_coordinates {
4192   my $self = shift;
4193
4194   my @svc_X = 
4195     grep { $_->latitude && $_->longitude }
4196     map { $_->svc_x }
4197     map { $_->cust_svc }
4198     $self->ncancelled_pkgs;
4199
4200   scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
4201 }
4202
4203 =item masked FIELD
4204
4205 Returns a masked version of the named field
4206
4207 =cut
4208
4209 sub masked {
4210 my ($self,$field) = @_;
4211
4212 # Show last four
4213
4214 'x'x(length($self->getfield($field))-4).
4215   substr($self->getfield($field), (length($self->getfield($field))-4));
4216
4217 }
4218
4219 =back
4220
4221 =head1 CLASS METHODS
4222
4223 =over 4
4224
4225 =item statuses
4226
4227 Class method that returns the list of possible status strings for customers
4228 (see L<the status method|/status>).  For example:
4229
4230   @statuses = FS::cust_main->statuses();
4231
4232 =cut
4233
4234 sub statuses {
4235   my $self = shift;
4236   keys %{ $self->statuscolors };
4237 }
4238
4239 =item cust_status_sql
4240
4241 Returns an SQL fragment to determine the status of a cust_main record, as a 
4242 string.
4243
4244 =cut
4245
4246 sub cust_status_sql {
4247   my $sql = 'CASE';
4248   for my $status ( FS::cust_main->statuses() ) {
4249     my $method = $status.'_sql';
4250     $sql .= ' WHEN ('.FS::cust_main->$method.") THEN '$status'";
4251   }
4252   $sql .= ' END';
4253   return $sql;
4254 }
4255
4256
4257 =item prospect_sql
4258
4259 Returns an SQL expression identifying prospective cust_main records (customers
4260 with no packages ever ordered)
4261
4262 =cut
4263
4264 use vars qw($select_count_pkgs);
4265 $select_count_pkgs =
4266   "SELECT COUNT(*) FROM cust_pkg
4267     WHERE cust_pkg.custnum = cust_main.custnum";
4268
4269 sub select_count_pkgs_sql {
4270   $select_count_pkgs;
4271 }
4272
4273 sub prospect_sql {
4274   " 0 = ( $select_count_pkgs ) ";
4275 }
4276
4277 =item ordered_sql
4278
4279 Returns an SQL expression identifying ordered cust_main records (customers with
4280 no active packages, but recurring packages not yet setup or one time charges
4281 not yet billed).
4282
4283 =cut
4284
4285 sub ordered_sql {
4286   FS::cust_main->none_active_sql.
4287   " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->not_yet_billed_sql. " ) ";
4288 }
4289
4290 =item active_sql
4291
4292 Returns an SQL expression identifying active cust_main records (customers with
4293 active recurring packages).
4294
4295 =cut
4296
4297 sub active_sql {
4298   " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
4299 }
4300
4301 =item none_active_sql
4302
4303 Returns an SQL expression identifying cust_main records with no active
4304 recurring packages.  This includes customers of status prospect, ordered,
4305 inactive, and suspended.
4306
4307 =cut
4308
4309 sub none_active_sql {
4310   " 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
4311 }
4312
4313 =item inactive_sql
4314
4315 Returns an SQL expression identifying inactive cust_main records (customers with
4316 no active recurring packages, but otherwise unsuspended/uncancelled).
4317
4318 =cut
4319
4320 sub inactive_sql {
4321   FS::cust_main->none_active_sql.
4322   " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) ";
4323 }
4324
4325 =item susp_sql
4326 =item suspended_sql
4327
4328 Returns an SQL expression identifying suspended cust_main records.
4329
4330 =cut
4331
4332
4333 sub suspended_sql { susp_sql(@_); }
4334 sub susp_sql {
4335   FS::cust_main->none_active_sql.
4336   " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " ) ";
4337 }
4338
4339 =item cancel_sql
4340 =item cancelled_sql
4341
4342 Returns an SQL expression identifying cancelled cust_main records.
4343
4344 =cut
4345
4346 sub cancel_sql { shift->cancelled_sql(@_); }
4347
4348 =item uncancel_sql
4349 =item uncancelled_sql
4350
4351 Returns an SQL expression identifying un-cancelled cust_main records.
4352
4353 =cut
4354
4355 sub uncancelled_sql { uncancel_sql(@_); }
4356 sub uncancel_sql { "
4357   ( 0 < ( $select_count_pkgs
4358                    AND ( cust_pkg.cancel IS NULL
4359                          OR cust_pkg.cancel = 0
4360                        )
4361         )
4362     OR 0 = ( $select_count_pkgs )
4363   )
4364 "; }
4365
4366 =item balance_sql
4367
4368 Returns an SQL fragment to retreive the balance.
4369
4370 =cut
4371
4372 sub balance_sql { "
4373     ( SELECT COALESCE( SUM(charged), 0 ) FROM cust_bill
4374         WHERE cust_bill.custnum   = cust_main.custnum     )
4375   - ( SELECT COALESCE( SUM(paid),    0 ) FROM cust_pay
4376         WHERE cust_pay.custnum    = cust_main.custnum     )
4377   - ( SELECT COALESCE( SUM(amount),  0 ) FROM cust_credit
4378         WHERE cust_credit.custnum = cust_main.custnum     )
4379   + ( SELECT COALESCE( SUM(refund),  0 ) FROM cust_refund
4380         WHERE cust_refund.custnum = cust_main.custnum     )
4381 "; }
4382
4383 =item balance_date_sql [ START_TIME [ END_TIME [ OPTION => VALUE ... ] ] ]
4384
4385 Returns an SQL fragment to retreive the balance for this customer, optionally
4386 considering invoices with date earlier than START_TIME, and not
4387 later than END_TIME (total_owed_date minus total_unapplied_credits minus
4388 total_unapplied_payments).
4389
4390 Times are specified as SQL fragments or numeric
4391 UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
4392 L<Date::Parse> for conversion functions.  The empty string can be passed
4393 to disable that time constraint completely.
4394
4395 Available options are:
4396
4397 =over 4
4398
4399 =item unapplied_date
4400
4401 set to true to disregard unapplied credits, payments and refunds outside the specified time period - by default the time period restriction only applies to invoices (useful for reporting, probably a bad idea for event triggering)
4402
4403 =item total
4404
4405 (unused.  obsolete?)
4406 set to true to remove all customer comparison clauses, for totals
4407
4408 =item where
4409
4410 (unused.  obsolete?)
4411 WHERE clause hashref (elements "AND"ed together) (typically used with the total option)
4412
4413 =item join
4414
4415 (unused.  obsolete?)
4416 JOIN clause (typically used with the total option)
4417
4418 =item cutoff
4419
4420 An absolute cutoff time.  Payments, credits, and refunds I<applied> after this 
4421 time will be ignored.  Note that START_TIME and END_TIME only limit the date 
4422 range for invoices and I<unapplied> payments, credits, and refunds.
4423
4424 =back
4425
4426 =cut
4427
4428 sub balance_date_sql {
4429   my( $class, $start, $end, %opt ) = @_;
4430
4431   my $cutoff = $opt{'cutoff'};
4432
4433   my $owed         = FS::cust_bill->owed_sql($cutoff);
4434   my $unapp_refund = FS::cust_refund->unapplied_sql($cutoff);
4435   my $unapp_credit = FS::cust_credit->unapplied_sql($cutoff);
4436   my $unapp_pay    = FS::cust_pay->unapplied_sql($cutoff);
4437
4438   my $j = $opt{'join'} || '';
4439
4440   my $owed_wh   = $class->_money_table_where( 'cust_bill',   $start,$end,%opt );
4441   my $refund_wh = $class->_money_table_where( 'cust_refund', $start,$end,%opt );
4442   my $credit_wh = $class->_money_table_where( 'cust_credit', $start,$end,%opt );
4443   my $pay_wh    = $class->_money_table_where( 'cust_pay',    $start,$end,%opt );
4444
4445   "   ( SELECT COALESCE(SUM($owed),         0) FROM cust_bill   $j $owed_wh   )
4446     + ( SELECT COALESCE(SUM($unapp_refund), 0) FROM cust_refund $j $refund_wh )
4447     - ( SELECT COALESCE(SUM($unapp_credit), 0) FROM cust_credit $j $credit_wh )
4448     - ( SELECT COALESCE(SUM($unapp_pay),    0) FROM cust_pay    $j $pay_wh    )
4449   ";
4450
4451 }
4452
4453 =item unapplied_payments_date_sql START_TIME [ END_TIME ]
4454
4455 Returns an SQL fragment to retreive the total unapplied payments for this
4456 customer, only considering payments with date earlier than START_TIME, and
4457 optionally not later than END_TIME.
4458
4459 Times are specified as SQL fragments or numeric
4460 UNIX timestamps; see L<perlfunc/"time">).  Also see L<Time::Local> and
4461 L<Date::Parse> for conversion functions.  The empty string can be passed
4462 to disable that time constraint completely.
4463
4464 Available options are:
4465
4466 =cut
4467
4468 sub unapplied_payments_date_sql {
4469   my( $class, $start, $end, %opt ) = @_;
4470
4471   my $cutoff = $opt{'cutoff'};
4472
4473   my $unapp_pay    = FS::cust_pay->unapplied_sql($cutoff);
4474
4475   my $pay_where = $class->_money_table_where( 'cust_pay', $start, $end,
4476                                                           'unapplied_date'=>1 );
4477
4478   " ( SELECT COALESCE(SUM($unapp_pay), 0) FROM cust_pay $pay_where ) ";
4479 }
4480
4481 =item _money_table_where TABLE START_TIME [ END_TIME [ OPTION => VALUE ... ] ]
4482
4483 Helper method for balance_date_sql; name (and usage) subject to change
4484 (suggestions welcome).
4485
4486 Returns a WHERE clause for the specified monetary TABLE (cust_bill,
4487 cust_refund, cust_credit or cust_pay).
4488
4489 If TABLE is "cust_bill" or the unapplied_date option is true, only
4490 considers records with date earlier than START_TIME, and optionally not
4491 later than END_TIME .
4492
4493 =cut
4494
4495 sub _money_table_where {
4496   my( $class, $table, $start, $end, %opt ) = @_;
4497
4498   my @where = ();
4499   push @where, "cust_main.custnum = $table.custnum" unless $opt{'total'};
4500   if ( $table eq 'cust_bill' || $opt{'unapplied_date'} ) {
4501     push @where, "$table._date <= $start" if defined($start) && length($start);
4502     push @where, "$table._date >  $end"   if defined($end)   && length($end);
4503   }
4504   push @where, @{$opt{'where'}} if $opt{'where'};
4505   my $where = scalar(@where) ? 'WHERE '. join(' AND ', @where ) : '';
4506
4507   $where;
4508
4509 }
4510
4511 #for dyanmic FS::$table->search in httemplate/misc/email_customers.html
4512 use FS::cust_main::Search;
4513 sub search {
4514   my $class = shift;
4515   FS::cust_main::Search->search(@_);
4516 }
4517
4518 =back
4519
4520 =head1 SUBROUTINES
4521
4522 =over 4
4523
4524 =item batch_charge
4525
4526 =cut
4527
4528 sub batch_charge {
4529   my $param = shift;
4530   #warn join('-',keys %$param);
4531   my $fh = $param->{filehandle};
4532   my $agentnum = $param->{agentnum};
4533   my $format = $param->{format};
4534
4535   my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
4536
4537   my @fields;
4538   if ( $format eq 'simple' ) {
4539     @fields = qw( custnum agent_custid amount pkg );
4540   } else {
4541     die "unknown format $format";
4542   }
4543
4544   eval "use Text::CSV_XS;";
4545   die $@ if $@;
4546
4547   my $csv = new Text::CSV_XS;
4548   #warn $csv;
4549   #warn $fh;
4550
4551   my $imported = 0;
4552   #my $columns;
4553
4554   local $SIG{HUP} = 'IGNORE';
4555   local $SIG{INT} = 'IGNORE';
4556   local $SIG{QUIT} = 'IGNORE';
4557   local $SIG{TERM} = 'IGNORE';
4558   local $SIG{TSTP} = 'IGNORE';
4559   local $SIG{PIPE} = 'IGNORE';
4560
4561   my $oldAutoCommit = $FS::UID::AutoCommit;
4562   local $FS::UID::AutoCommit = 0;
4563   my $dbh = dbh;
4564   
4565   #while ( $columns = $csv->getline($fh) ) {
4566   my $line;
4567   while ( defined($line=<$fh>) ) {
4568
4569     $csv->parse($line) or do {
4570       $dbh->rollback if $oldAutoCommit;
4571       return "can't parse: ". $csv->error_input();
4572     };
4573
4574     my @columns = $csv->fields();
4575     #warn join('-',@columns);
4576
4577     my %row = ();
4578     foreach my $field ( @fields ) {
4579       $row{$field} = shift @columns;
4580     }
4581
4582     if ( $row{custnum} && $row{agent_custid} ) {
4583       dbh->rollback if $oldAutoCommit;
4584       return "can't specify custnum with agent_custid $row{agent_custid}";
4585     }
4586
4587     my %hash = ();
4588     if ( $row{agent_custid} && $agentnum ) {
4589       %hash = ( 'agent_custid' => $row{agent_custid},
4590                 'agentnum'     => $agentnum,
4591               );
4592     }
4593
4594     if ( $row{custnum} ) {
4595       %hash = ( 'custnum' => $row{custnum} );
4596     }
4597
4598     unless ( scalar(keys %hash) ) {
4599       $dbh->rollback if $oldAutoCommit;
4600       return "can't find customer without custnum or agent_custid and agentnum";
4601     }
4602
4603     my $cust_main = qsearchs('cust_main', { %hash } );
4604     unless ( $cust_main ) {
4605       $dbh->rollback if $oldAutoCommit;
4606       my $custnum = $row{custnum} || $row{agent_custid};
4607       return "unknown custnum $custnum";
4608     }
4609
4610     if ( $row{'amount'} > 0 ) {
4611       my $error = $cust_main->charge($row{'amount'}, $row{'pkg'});
4612       if ( $error ) {
4613         $dbh->rollback if $oldAutoCommit;
4614         return $error;
4615       }
4616       $imported++;
4617     } elsif ( $row{'amount'} < 0 ) {
4618       my $error = $cust_main->credit( sprintf( "%.2f", 0-$row{'amount'} ),
4619                                       $row{'pkg'}                         );
4620       if ( $error ) {
4621         $dbh->rollback if $oldAutoCommit;
4622         return $error;
4623       }
4624       $imported++;
4625     } else {
4626       #hmm?
4627     }
4628
4629   }
4630
4631   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
4632
4633   return "Empty file!" unless $imported;
4634
4635   ''; #no error
4636
4637 }
4638
4639 =item notify CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
4640
4641 Deprecated.  Use event notification and message templates 
4642 (L<FS::msg_template>) instead.
4643
4644 Sends a templated email notification to the customer (see L<Text::Template>).
4645
4646 OPTIONS is a hash and may include
4647
4648 I<from> - the email sender (default is invoice_from)
4649
4650 I<to> - comma-separated scalar or arrayref of recipients 
4651    (default is invoicing_list)
4652
4653 I<subject> - The subject line of the sent email notification
4654    (default is "Notice from company_name")
4655
4656 I<extra_fields> - a hashref of name/value pairs which will be substituted
4657    into the template
4658
4659 The following variables are vavailable in the template.
4660
4661 I<$first> - the customer first name
4662 I<$last> - the customer last name
4663 I<$company> - the customer company
4664 I<$payby> - a description of the method of payment for the customer
4665             # would be nice to use FS::payby::shortname
4666 I<$payinfo> - the account information used to collect for this customer
4667 I<$expdate> - the expiration of the customer payment in seconds from epoch
4668
4669 =cut
4670
4671 sub notify {
4672   my ($self, $template, %options) = @_;
4673
4674   return unless $conf->exists($template);
4675
4676   my $from = $conf->config('invoice_from', $self->agentnum)
4677     if $conf->exists('invoice_from', $self->agentnum);
4678   $from = $options{from} if exists($options{from});
4679
4680   my $to = join(',', $self->invoicing_list_emailonly);
4681   $to = $options{to} if exists($options{to});
4682   
4683   my $subject = "Notice from " . $conf->config('company_name', $self->agentnum)
4684     if $conf->exists('company_name', $self->agentnum);
4685   $subject = $options{subject} if exists($options{subject});
4686
4687   my $notify_template = new Text::Template (TYPE => 'ARRAY',
4688                                             SOURCE => [ map "$_\n",
4689                                               $conf->config($template)]
4690                                            )
4691     or die "can't create new Text::Template object: Text::Template::ERROR";
4692   $notify_template->compile()
4693     or die "can't compile template: Text::Template::ERROR";
4694
4695   $FS::notify_template::_template::company_name =
4696     $conf->config('company_name', $self->agentnum);
4697   $FS::notify_template::_template::company_address =
4698     join("\n", $conf->config('company_address', $self->agentnum) ). "\n";
4699
4700   my $paydate = $self->paydate || '2037-12-31';
4701   $FS::notify_template::_template::first = $self->first;
4702   $FS::notify_template::_template::last = $self->last;
4703   $FS::notify_template::_template::company = $self->company;
4704   $FS::notify_template::_template::payinfo = $self->mask_payinfo;
4705   my $payby = $self->payby;
4706   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
4707   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
4708
4709   #credit cards expire at the end of the month/year of their exp date
4710   if ($payby eq 'CARD' || $payby eq 'DCRD') {
4711     $FS::notify_template::_template::payby = 'credit card';
4712     ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
4713     $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
4714     $expire_time--;
4715   }elsif ($payby eq 'COMP') {
4716     $FS::notify_template::_template::payby = 'complimentary account';
4717   }else{
4718     $FS::notify_template::_template::payby = 'current method';
4719   }
4720   $FS::notify_template::_template::expdate = $expire_time;
4721
4722   for (keys %{$options{extra_fields}}){
4723     no strict "refs";
4724     ${"FS::notify_template::_template::$_"} = $options{extra_fields}->{$_};
4725   }
4726
4727   send_email(from => $from,
4728              to => $to,
4729              subject => $subject,
4730              body => $notify_template->fill_in( PACKAGE =>
4731                                                 'FS::notify_template::_template'                                              ),
4732             );
4733
4734 }
4735
4736 =item generate_letter CUSTOMER_OBJECT TEMPLATE_NAME OPTIONS
4737
4738 Generates a templated notification to the customer (see L<Text::Template>).
4739
4740 OPTIONS is a hash and may include
4741
4742 I<extra_fields> - a hashref of name/value pairs which will be substituted
4743    into the template.  These values may override values mentioned below
4744    and those from the customer record.
4745
4746 The following variables are available in the template instead of or in addition
4747 to the fields of the customer record.
4748
4749 I<$payby> - a description of the method of payment for the customer
4750             # would be nice to use FS::payby::shortname
4751 I<$payinfo> - the masked account information used to collect for this customer
4752 I<$expdate> - the expiration of the customer payment method in seconds from epoch
4753 I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or company_address
4754
4755 =cut
4756
4757 # a lot like cust_bill::print_latex
4758 sub generate_letter {
4759   my ($self, $template, %options) = @_;
4760
4761   return unless $conf->exists($template);
4762
4763   my $letter_template = new Text::Template
4764                         ( TYPE       => 'ARRAY',
4765                           SOURCE     => [ map "$_\n", $conf->config($template)],
4766                           DELIMITERS => [ '[@--', '--@]' ],
4767                         )
4768     or die "can't create new Text::Template object: Text::Template::ERROR";
4769
4770   $letter_template->compile()
4771     or die "can't compile template: Text::Template::ERROR";
4772
4773   my %letter_data = map { $_ => $self->$_ } $self->fields;
4774   $letter_data{payinfo} = $self->mask_payinfo;
4775
4776   #my $paydate = $self->paydate || '2037-12-31';
4777   my $paydate = $self->paydate =~ /^\S+$/ ? $self->paydate : '2037-12-31';
4778
4779   my $payby = $self->payby;
4780   my ($payyear,$paymonth,$payday) = split (/-/,$paydate);
4781   my $expire_time = timelocal(0,0,0,$payday,--$paymonth,$payyear);
4782
4783   #credit cards expire at the end of the month/year of their exp date
4784   if ($payby eq 'CARD' || $payby eq 'DCRD') {
4785     $letter_data{payby} = 'credit card';
4786     ($paymonth < 11) ? $paymonth++ : ($paymonth=0, $payyear++);
4787     $expire_time = timelocal(0,0,0,$payday,$paymonth,$payyear);
4788     $expire_time--;
4789   }elsif ($payby eq 'COMP') {
4790     $letter_data{payby} = 'complimentary account';
4791   }else{
4792     $letter_data{payby} = 'current method';
4793   }
4794   $letter_data{expdate} = $expire_time;
4795
4796   for (keys %{$options{extra_fields}}){
4797     $letter_data{$_} = $options{extra_fields}->{$_};
4798   }
4799
4800   unless(exists($letter_data{returnaddress})){
4801     my $retadd = join("\n", $conf->config_orbase( 'invoice_latexreturnaddress',
4802                                                   $self->agent_template)
4803                      );
4804     if ( length($retadd) ) {
4805       $letter_data{returnaddress} = $retadd;
4806     } elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
4807       $letter_data{returnaddress} =
4808         join( "\n", map { s/( {2,})/'~' x length($1)/eg;
4809                           s/$/\\\\\*/;
4810                           $_;
4811                         }
4812                     ( $conf->config('company_name', $self->agentnum),
4813                       $conf->config('company_address', $self->agentnum),
4814                     )
4815         );
4816     } else {
4817       $letter_data{returnaddress} = '~';
4818     }
4819   }
4820
4821   $letter_data{conf_dir} = "$FS::UID::conf_dir/conf.$FS::UID::datasrc";
4822
4823   $letter_data{company_name} = $conf->config('company_name', $self->agentnum);
4824
4825   my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
4826
4827   my $lh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
4828                            DIR      => $dir,
4829                            SUFFIX   => '.eps',
4830                            UNLINK   => 0,
4831                          ) or die "can't open temp file: $!\n";
4832   print $lh $conf->config_binary('logo.eps', $self->agentnum)
4833     or die "can't write temp file: $!\n";
4834   close $lh;
4835   $letter_data{'logo_file'} = $lh->filename;
4836
4837   my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
4838                            DIR      => $dir,
4839                            SUFFIX   => '.tex',
4840                            UNLINK   => 0,
4841                          ) or die "can't open temp file: $!\n";
4842
4843   $letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
4844   close $fh;
4845   $fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
4846   return ($1, $letter_data{'logo_file'});
4847
4848 }
4849
4850 =item print_ps TEMPLATE 
4851
4852 Returns an postscript letter filled in from TEMPLATE, as a scalar.
4853
4854 =cut
4855
4856 sub print_ps {
4857   my $self = shift;
4858   my($file, $lfile) = $self->generate_letter(@_);
4859   my $ps = FS::Misc::generate_ps($file);
4860   unlink($file.'.tex');
4861   unlink($lfile);
4862
4863   $ps;
4864 }
4865
4866 =item print TEMPLATE
4867
4868 Prints the filled in template.
4869
4870 TEMPLATE is the name of a L<Text::Template> to fill in and print.
4871
4872 =cut
4873
4874 sub queueable_print {
4875   my %opt = @_;
4876
4877   my $self = qsearchs('cust_main', { 'custnum' => $opt{custnum} } )
4878     or die "invalid customer number: " . $opt{custvnum};
4879
4880   my $error = $self->print( $opt{template} );
4881   die $error if $error;
4882 }
4883
4884 sub print {
4885   my ($self, $template) = (shift, shift);
4886   do_print [ $self->print_ps($template) ];
4887 }
4888
4889 #these three subs should just go away once agent stuff is all config overrides
4890
4891 sub agent_template {
4892   my $self = shift;
4893   $self->_agent_plandata('agent_templatename');
4894 }
4895
4896 sub agent_invoice_from {
4897   my $self = shift;
4898   $self->_agent_plandata('agent_invoice_from');
4899 }
4900
4901 sub _agent_plandata {
4902   my( $self, $option ) = @_;
4903
4904   #yuck.  this whole thing needs to be reconciled better with 1.9's idea of
4905   #agent-specific Conf
4906
4907   use FS::part_event::Condition;
4908   
4909   my $agentnum = $self->agentnum;
4910
4911   my $regexp = regexp_sql();
4912
4913   my $part_event_option =
4914     qsearchs({
4915       'select'    => 'part_event_option.*',
4916       'table'     => 'part_event_option',
4917       'addl_from' => q{
4918         LEFT JOIN part_event USING ( eventpart )
4919         LEFT JOIN part_event_option AS peo_agentnum
4920           ON ( part_event.eventpart = peo_agentnum.eventpart
4921                AND peo_agentnum.optionname = 'agentnum'
4922                AND peo_agentnum.optionvalue }. $regexp. q{ '(^|,)}. $agentnum. q{(,|$)'
4923              )
4924         LEFT JOIN part_event_condition
4925           ON ( part_event.eventpart = part_event_condition.eventpart
4926                AND part_event_condition.conditionname = 'cust_bill_age'
4927              )
4928         LEFT JOIN part_event_condition_option
4929           ON ( part_event_condition.eventconditionnum = part_event_condition_option.eventconditionnum
4930                AND part_event_condition_option.optionname = 'age'
4931              )
4932       },
4933       #'hashref'   => { 'optionname' => $option },
4934       #'hashref'   => { 'part_event_option.optionname' => $option },
4935       'extra_sql' =>
4936         " WHERE part_event_option.optionname = ". dbh->quote($option).
4937         " AND action = 'cust_bill_send_agent' ".
4938         " AND ( disabled IS NULL OR disabled != 'Y' ) ".
4939         " AND peo_agentnum.optionname = 'agentnum' ".
4940         " AND ( agentnum IS NULL OR agentnum = $agentnum ) ".
4941         " ORDER BY
4942            CASE WHEN part_event_condition_option.optionname IS NULL
4943            THEN -1
4944            ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
4945         " END
4946           , part_event.weight".
4947         " LIMIT 1"
4948     });
4949     
4950   unless ( $part_event_option ) {
4951     return $self->agent->invoice_template || ''
4952       if $option eq 'agent_templatename';
4953     return '';
4954   }
4955
4956   $part_event_option->optionvalue;
4957
4958 }
4959
4960 =item queued_bill 'custnum' => CUSTNUM [ , OPTION => VALUE ... ]
4961
4962 Subroutine (not a method), designed to be called from the queue.
4963
4964 Takes a list of options and values.
4965
4966 Pulls up the customer record via the custnum option and calls bill_and_collect.
4967
4968 =cut
4969
4970 sub queued_bill {
4971   my (%args) = @_; #, ($time, $invoice_time, $check_freq, $resetup) = @_;
4972
4973   my $cust_main = qsearchs( 'cust_main', { custnum => $args{'custnum'} } );
4974   warn 'bill_and_collect custnum#'. $cust_main->custnum. "\n";#log custnum w/pid
4975
4976   $cust_main->bill_and_collect( %args );
4977 }
4978
4979 sub process_bill_and_collect {
4980   my $job = shift;
4981   my $param = thaw(decode_base64(shift));
4982   my $cust_main = qsearchs( 'cust_main', { custnum => $param->{'custnum'} } )
4983       or die "custnum '$param->{custnum}' not found!\n";
4984   $param->{'job'}   = $job;
4985   $param->{'fatal'} = 1; # runs from job queue, will be caught
4986   $param->{'retry'} = 1;
4987
4988   $cust_main->bill_and_collect( %$param );
4989 }
4990
4991 =item process_censustract_update CUSTNUM
4992
4993 Queueable function to update the census tract to the current year (as set in 
4994 the 'census_year' configuration variable) and retrieve the new tract code.
4995
4996 =cut
4997
4998 sub process_censustract_update { 
4999   eval "use FS::Misc::Geo qw(get_censustract)";
5000   die $@ if $@;
5001   my $custnum = shift;
5002   my $cust_main = qsearchs( 'cust_main', { custnum => $custnum })
5003       or die "custnum '$custnum' not found!\n";
5004
5005   my $new_year = $conf->config('census_year') or return;
5006   my $new_tract = get_censustract({ $cust_main->location_hash }, $new_year);
5007   if ( $new_tract =~ /^\d/ ) {
5008     # then it's a tract code
5009         $cust_main->set('censustract', $new_tract);
5010     $cust_main->set('censusyear',  $new_year);
5011
5012     local($ignore_expired_card) = 1;
5013     local($ignore_illegal_zip) = 1;
5014     local($ignore_banned_card) = 1;
5015     local($skip_fuzzyfiles) = 1;
5016     local($import) = 1; #prevent automatic geocoding (need its own variable?)
5017     my $error = $cust_main->replace;
5018     die $error if $error;
5019   }
5020   else {
5021     # it's an error message
5022     die $new_tract;
5023   }
5024   return;
5025 }
5026
5027 #starting to take quite a while for big dbs
5028 # - seq scan of h_cust_main (yuck), but not going to index paycvv, so
5029 # - seq scan of cust_main on signupdate... index signupdate?  will that help?
5030 # - seq scan of cust_main on paydate... index on substrings?  maybe set an
5031 #    upgrade journal flag now that we have that, yyyy-m-dd paydates are ancient
5032 # - seq scan of cust_main on payinfo.. certainly not going toi ndex that...
5033 #    upgrade journal again?  this is also an ancient problem
5034 # - otaker upgrade?  journal and call it good?  (double check to make sure
5035 #    we're not still setting otaker here)
5036 #
5037 #only going to get worse with new location stuff...
5038
5039 sub _upgrade_data { #class method
5040   my ($class, %opts) = @_;
5041
5042   my @statements = (
5043     'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL',
5044     'UPDATE cust_main SET signupdate = (SELECT signupdate FROM h_cust_main WHERE signupdate IS NOT NULL AND h_cust_main.custnum = cust_main.custnum ORDER BY historynum DESC LIMIT 1) WHERE signupdate IS NULL',
5045   );
5046   # fix yyyy-m-dd formatted paydates
5047   if ( driver_name =~ /^mysql/i ) {
5048     push @statements,
5049     "UPDATE cust_main SET paydate = CONCAT( SUBSTRING(paydate FROM 1 FOR 5), '0', SUBSTRING(paydate FROM 6) ) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
5050   }
5051   else { # the SQL standard
5052     push @statements, 
5053     "UPDATE cust_main SET paydate = SUBSTRING(paydate FROM 1 FOR 5) || '0' || SUBSTRING(paydate FROM 6) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
5054   }
5055
5056   push @statements, #fix the weird BILL with a cc# in payinfo problem
5057     #DCRD to be safe
5058     "UPDATE cust_main SET payby = 'DCRD' WHERE payby = 'BILL' and length(payinfo) = 16 and payinfo ". regexp_sql. q( '^[0-9]*$' );
5059
5060   my $t = time;
5061   foreach my $sql ( @statements ) {
5062     my $sth = dbh->prepare($sql) or die dbh->errstr;
5063     $sth->execute or die $sth->errstr;
5064     warn (time - $t). " seconds\n";
5065     $t = time;
5066   }
5067
5068   local($ignore_expired_card) = 1;
5069   local($ignore_banned_card) = 1;
5070   local($skip_fuzzyfiles) = 1;
5071   local($import) = 1; #prevent automatic geocoding (need its own variable?)
5072   $class->_upgrade_otaker(%opts);
5073
5074   FS::cust_main::Location->_upgrade_data(%opts);
5075
5076 }
5077
5078 =back
5079
5080 =head1 BUGS
5081
5082 The delete method.
5083
5084 The delete method should possibly take an FS::cust_main object reference
5085 instead of a scalar customer number.
5086
5087 Bill and collect options should probably be passed as references instead of a
5088 list.
5089
5090 There should probably be a configuration file with a list of allowed credit
5091 card types.
5092
5093 No multiple currency support (probably a larger project than just this module).
5094
5095 payinfo_masked false laziness with cust_pay.pm and cust_refund.pm
5096
5097 Birthdates rely on negative epoch values.
5098
5099 The payby for card/check batches is broken.  With mixed batching, bad
5100 things will happen.
5101
5102 B<collect> I<invoice_time> should be renamed I<time>, like B<bill>.
5103
5104 =head1 SEE ALSO
5105
5106 L<FS::Record>, L<FS::cust_pkg>, L<FS::cust_bill>, L<FS::cust_credit>
5107 L<FS::agent>, L<FS::part_referral>, L<FS::cust_main_county>,
5108 L<FS::cust_main_invoice>, L<FS::UID>, schema.html from the base documentation.
5109
5110 =cut
5111
5112 1;
5113