9fc66e0596ea21bd4a92b77e0c77bb1c45cfd399
[freeside.git] / FS / FS / cust_main_Mixin.pm
1 package FS::cust_main_Mixin;
2
3 use strict;
4 use vars qw( $DEBUG $me );
5 use Carp qw( confess carp cluck );
6 use FS::UID qw(dbh);
7 use FS::cust_main;
8 use FS::Record qw( qsearch qsearchs );
9 use FS::Misc qw( send_email generate_email );
10 use HTML::Entities;
11
12 $DEBUG = 0;
13 $me = '[FS::cust_main_Mixin]';
14
15 =head1 NAME
16
17 FS::cust_main_Mixin - Mixin class for records that contain fields from cust_main
18
19 =head1 SYNOPSIS
20
21 package FS::some_table;
22 use vars qw(@ISA);
23 @ISA = qw( FS::cust_main_Mixin FS::Record );
24
25 =head1 DESCRIPTION
26
27 This is a mixin class for records that contain fields from the cust_main table,
28 for example, from a JOINed search.  See httemplate/search/ for examples.
29
30 =head1 METHODS
31
32 =over 4
33
34 =cut
35
36 sub cust_unlinked_msg { '(unlinked)'; }
37 sub cust_linked { $_[0]->custnum; }
38
39 sub cust_main { 
40   my $self = shift;
41   cluck ref($self). '->cust_main called' if $DEBUG;
42   $self->cust_linked ? qsearchs('cust_main', {custnum => $self->custnum}) : '';
43 }
44
45 =item display_custnum
46
47 Given an object that contains fields from cust_main (say, from a JOINed
48 search; see httemplate/search/ for examples), returns the equivalent of the
49 FS::cust_main I<name> method, or "(unlinked)" if this object is not linked to
50 a customer.
51
52 =cut
53
54 sub display_custnum {
55   my $self = shift;
56   $self->cust_linked
57     ? FS::cust_main::display_custnum($self)
58     : $self->cust_unlinked_msg;
59 }
60
61 =item name
62
63 Given an object that contains fields from cust_main (say, from a JOINed
64 search; see httemplate/search/ for examples), returns the equivalent of the
65 FS::cust_main I<name> method, or "(unlinked)" if this object is not linked to
66 a customer.
67
68 =cut
69
70 sub name {
71   my $self = shift;
72   $self->cust_linked
73     ? FS::cust_main::name($self)
74     : $self->cust_unlinked_msg;
75 }
76
77 =item ship_name
78
79 Given an object that contains fields from cust_main (say, from a JOINed
80 search; see httemplate/search/ for examples), returns the equivalent of the
81 FS::cust_main I<ship_name> method, or "(unlinked)" if this object is not
82 linked to a customer.
83
84 =cut
85
86 sub ship_name {
87   my $self = shift;
88   $self->cust_linked
89     ? FS::cust_main::ship_name($self)
90     : $self->cust_unlinked_msg;
91 }
92
93 =item contact
94
95 Given an object that contains fields from cust_main (say, from a JOINed
96 search; see httemplate/search/ for examples), returns the equivalent of the
97 FS::cust_main I<contact> method, or "(unlinked)" if this object is not linked
98 to a customer.
99
100 =cut
101
102 sub contact {
103   my $self = shift;
104   $self->cust_linked
105     ? FS::cust_main::contact($self)
106     : $self->cust_unlinked_msg;
107 }
108
109 =item ship_contact
110
111 Given an object that contains fields from cust_main (say, from a JOINed
112 search; see httemplate/search/ for examples), returns the equivalent of the
113 FS::cust_main I<ship_contact> method, or "(unlinked)" if this object is not
114 linked to a customer.
115
116 =cut
117
118 sub ship_contact {
119   my $self = shift;
120   $self->cust_linked
121     ? FS::cust_main::ship_contact($self)
122     : $self->cust_unlinked_msg;
123 }
124
125 =item country_full
126
127 Given an object that contains fields from cust_main (say, from a JOINed
128 search; see httemplate/search/ for examples), returns the equivalent of the
129 FS::cust_main I<country_full> method, or "(unlinked)" if this object is not
130 linked to a customer.
131
132 =cut
133
134 sub country_full {
135   my $self = shift;
136   if ( $self->locationnum ) {  # cust_pkg has this
137     my $location = FS::cust_location->by_key($self->locationnum);
138     $location ? $location->country_full : '';
139   } elsif ( $self->cust_linked ) {
140     $self->cust_main->bill_country_full;
141   }
142 }
143
144 =item invoicing_list_emailonly
145
146 Given an object that contains fields from cust_main (say, from a JOINed
147 search; see httemplate/search/ for examples), returns the equivalent of the
148 FS::cust_main I<invoicing_list_emailonly> method, or "(unlinked)" if this
149 object is not linked to a customer.
150
151 =cut
152
153 sub invoicing_list_emailonly {
154   my $self = shift;
155   warn "invoicing_list_email only called on $self, ".
156        "custnum ". $self->custnum. "\n"
157     if $DEBUG;
158   $self->cust_linked
159     ? FS::cust_main::invoicing_list_emailonly($self)
160     : $self->cust_unlinked_msg;
161 }
162
163 =item invoicing_list_emailonly_scalar
164
165 Given an object that contains fields from cust_main (say, from a JOINed
166 search; see httemplate/search/ for examples), returns the equivalent of the
167 FS::cust_main I<invoicing_list_emailonly_scalar> method, or "(unlinked)" if
168 this object is not linked to a customer.
169
170 =cut
171
172 sub invoicing_list_emailonly_scalar {
173   my $self = shift;
174   warn "invoicing_list_emailonly called on $self, ".
175        "custnum ". $self->custnum. "\n"
176     if $DEBUG;
177   $self->cust_linked
178     ? FS::cust_main::invoicing_list_emailonly_scalar($self)
179     : $self->cust_unlinked_msg;
180 }
181
182 =item invoicing_list
183
184 Given an object that contains fields from cust_main (say, from a JOINed
185 search; see httemplate/search/ for examples), returns the equivalent of the
186 FS::cust_main I<invoicing_list> method, or "(unlinked)" if this object is not
187 linked to a customer.
188
189 Note: this method is read-only.
190
191 =cut
192
193 #read-only
194 sub invoicing_list {
195   my $self = shift;
196   $self->cust_linked
197     ? FS::cust_main::invoicing_list($self)
198     : ();
199 }
200
201 =item status
202
203 Given an object that contains fields from cust_main (say, from a JOINed
204 search; see httemplate/search/ for examples), returns the equivalent of the
205 FS::cust_main I<status> method, or "(unlinked)" if this object is not linked to
206 a customer.
207
208 =cut
209
210 sub cust_status {
211   my $self = shift;
212   return $self->cust_unlinked_msg unless $self->cust_linked;
213
214   #FS::cust_main::status($self)
215   #false laziness w/actual cust_main::status
216   # (make sure FS::cust_main methods are called)
217   for my $status (qw( prospect active inactive suspended cancelled )) {
218     my $method = $status.'_sql';
219     my $sql = FS::cust_main->$method();;
220     my $numnum = ( $sql =~ s/cust_main\.custnum/?/g );
221     my $sth = dbh->prepare("SELECT $sql") or die dbh->errstr;
222     $sth->execute( ($self->custnum) x $numnum )
223       or die "Error executing 'SELECT $sql': ". $sth->errstr;
224     return $status if $sth->fetchrow_arrayref->[0];
225   }
226 }
227
228 =item ucfirst_cust_status
229
230 Given an object that contains fields from cust_main (say, from a JOINed
231 search; see httemplate/search/ for examples), returns the equivalent of the
232 FS::cust_main I<ucfirst_status> method, or "(unlinked)" if this object is not
233 linked to a customer.
234
235 =cut
236
237 sub ucfirst_cust_status {
238   carp "ucfirst_cust_status deprecated, use cust_status_label";
239   local($FS::cust_main::ucfirst_nowarn) = 1;
240   my $self = shift;
241   $self->cust_linked
242     ? ucfirst( $self->cust_status(@_) ) 
243     : $self->cust_unlinked_msg;
244 }
245
246 =item cust_status_label
247
248 =cut
249
250 sub cust_status_label {
251   my $self = shift;
252
253   $self->cust_linked
254     ? FS::cust_main::cust_status_label($self)
255     : $self->cust_unlinked_msg;
256 }
257
258 =item cust_statuscolor
259
260 Given an object that contains fields from cust_main (say, from a JOINed
261 search; see httemplate/search/ for examples), returns the equivalent of the
262 FS::cust_main I<statuscol> method, or "000000" if this object is not linked to
263 a customer.
264
265 =cut
266
267 sub cust_statuscolor {
268   my $self = shift;
269
270   $self->cust_linked
271     ? FS::cust_main::cust_statuscolor($self)
272     : '000000';
273 }
274
275 =item prospect_sql
276
277 =item active_sql
278
279 =item inactive_sql
280
281 =item suspended_sql
282
283 =item cancelled_sql
284
285 Class methods that return SQL framents, equivalent to the corresponding
286 FS::cust_main method.
287
288 =cut
289
290 #      my \$self = shift;
291 #      \$self->cust_linked
292 #        ? FS::cust_main::${sub}_sql(\$self)
293 #        : '0';
294
295 foreach my $sub (qw( prospect active inactive suspended cancelled )) {
296   eval "
297     sub ${sub}_sql {
298       confess 'cust_main_Mixin ${sub}_sql called with object' if ref(\$_[0]);
299       'cust_main.custnum IS NOT NULL AND '. FS::cust_main->${sub}_sql();
300     }
301   ";
302   die $@ if $@;
303 }
304
305 =item cust_search_sql
306
307 Returns a list of SQL WHERE fragments to search for parameters specified
308 in HASHREF.  Valid parameters are:
309
310 =over 4
311
312 =item agentnum
313
314 =item status
315
316 =back
317
318 =cut
319
320 sub cust_search_sql {
321   my($class, $param) = @_;
322
323   if ( $DEBUG ) {
324     warn "$me cust_search_sql called with params: \n".
325          join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
326   }
327
328   my @search = ();
329
330   if ( $param->{'agentnum'} && $param->{'agentnum'} =~ /^(\d+)$/ ) {
331     push @search, "cust_main.agentnum = $1";
332   }
333
334   #status (prospect active inactive suspended cancelled)
335   if ( grep { $param->{'status'} eq $_ } FS::cust_main->statuses() ) {
336     my $method = $param->{'status'}. '_sql';
337     push @search, $class->$method();
338   }
339
340   #here is the agent virtualization
341   push @search,
342     $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
343   
344   return @search;
345
346 }
347
348 =item email_search_result HASHREF
349
350 Emails a notice to the specified customers.  Customers without 
351 invoice email destinations will be skipped.
352
353 Parameters: 
354
355 =over 4
356
357 =item job
358
359 Queue job for status updates.  Required.
360
361 =item search
362
363 Hashref of params to the L<search()> method.  Required.
364
365 =item msgnum
366
367 Message template number (see L<FS::msg_template>).  Overrides all 
368 of the following options.
369
370 =item from
371
372 From: address
373
374 =item subject
375
376 Email Subject:
377
378 =item html_body
379
380 HTML body
381
382 =item text_body
383
384 Text body
385
386 =item to_contact_classnum
387
388 The customer contact class (or classes, as a comma-separated list) to send
389 the message to. If unspecified, will be sent to any contacts that are marked
390 as invoice destinations (the equivalent of specifying 'invoice').
391
392 =back
393
394 Returns an error message, or false for success.
395
396 If any messages fail to send, they will be queued as individual 
397 jobs which can be manually retried.  If the first ten messages 
398 in the job fail, the entire job will abort and return an error.
399
400 =cut
401
402 use Storable qw(thaw);
403 use MIME::Base64;
404 use Data::Dumper qw(Dumper);
405 use Digest::SHA qw(sha1); # for duplicate checking
406
407 sub email_search_result {
408   my($class, $param) = @_;
409
410   my $msgnum = $param->{msgnum};
411   my $from = delete $param->{from};
412   my $subject = delete $param->{subject};
413   my $html_body = delete $param->{html_body};
414   my $text_body = delete $param->{text_body};
415   my $to_contact_classnum = delete $param->{to_contact_classnum};
416   my $error = '';
417
418   my $job = delete $param->{'job'}
419     or die "email_search_result must run from the job queue.\n";
420   
421   my $msg_template;
422   if ( $msgnum ) {
423     $msg_template = qsearchs('msg_template', { msgnum => $msgnum } )
424       or die "msgnum $msgnum not found\n";
425   } else {
426     $msg_template = FS::msg_template->new({
427         from_addr => $from,
428         msgname   => $subject, # maybe a timestamp also?
429         disabled  => 'D', # 'D'raft
430         # msgclass, maybe
431     });
432     $error = $msg_template->insert(
433       subject => $subject,
434       body    => $html_body,
435     );
436     return "$error (when creating draft template)" if $error;
437   }
438
439   my $sql_query = $class->search($param->{'search'});
440   $sql_query->{'select'} = $sql_query->{'table'} . '.*';
441
442   my $count_query   = delete($sql_query->{'count_query'});
443   my $count_sth = dbh->prepare($count_query)
444     or die "Error preparing $count_query: ". dbh->errstr;
445   $count_sth->execute
446     or die "Error executing $count_query: ". $count_sth->errstr;
447   my $count_arrayref = $count_sth->fetchrow_arrayref;
448   my $num_cust = $count_arrayref->[0];
449
450   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
451   my @retry_jobs = ();
452   my $dups = 0;
453   my $success = 0;
454   my %sent_to = ();
455
456   if ( !$msg_template ) {
457     die "email_search_result now requires a msg_template";
458   }
459
460   #eventually order+limit magic to reduce memory use?
461   foreach my $obj ( qsearch($sql_query) ) {
462
463     #progressbar first, so that the count is right
464     $num++;
465     if ( time - $min_sec > $last ) {
466       my $error = $job->update_statustext(
467         int( 100 * $num / $num_cust )
468       );
469       die $error if $error;
470       $last = time;
471     }
472
473     my $cust_main = $obj->cust_main;
474     if ( !$cust_main ) { 
475       next; # unlinked object; nothing else we can do
476     }
477
478     my $cust_msg = $msg_template->prepare(
479       'cust_main' => $cust_main,
480       'object'    => $obj,
481       'to_contact_classnum' => $to_contact_classnum,
482     );
483
484     # For non-cust_main searches, we avoid duplicates based on message
485     # body text.
486     my $unique = $cust_main->custnum;
487     $unique .= sha1($cust_msg->text_body) if $class ne 'FS::cust_main';
488     if( $sent_to{$unique} ) {
489       # avoid duplicates
490       $dups++;
491       next;
492     }
493
494     $sent_to{$unique} = 1;
495     
496     $error = $cust_msg->send;
497
498     if($error) {
499       # queue the sending of this message so that the user can see what we
500       # tried to do, and retry if desired
501       # (note the cust_msg itself also now has a status of 'failed'; that's 
502       # fine, as it will get its status reset if we retry the job)
503       my $queue = new FS::queue {
504         'job'        => 'FS::cust_msg::process_send',
505         'custnum'    => $cust_main->custnum,
506         'status'     => 'failed',
507         'statustext' => $error,
508       };
509       $queue->insert($cust_msg->custmsgnum);
510       push @retry_jobs, $queue;
511     }
512     else {
513       $success++;
514     }
515
516     if($success == 0 and
517         (scalar(@retry_jobs) > 10 or $num == $num_cust)
518       ) {
519       # 10 is arbitrary, but if we have enough failures, that's
520       # probably a configuration or network problem, and we
521       # abort the batch and run away screaming.
522       # We NEVER do this if anything was successfully sent.
523       $_->delete foreach (@retry_jobs);
524       return "multiple failures: '$error'\n";
525     }
526   } # foreach $obj
527
528   # if the message template was created as "draft", change its status to
529   # "completed"
530   if ($msg_template->disabled eq 'D') {
531     $msg_template->set('disabled' => 'C');
532     my $error = $msg_template->replace;
533     warn "$error (setting draft message template status)" if $error;
534   }
535
536   if(@retry_jobs) {
537     # fail the job, but with a status message that makes it clear
538     # something was sent.
539     return "Sent $success, skipped $dups duplicate(s), failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
540   }
541
542   return '';
543 }
544
545 sub process_email_search_result {
546   my $job = shift;
547   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
548
549   my $param = shift;
550   warn Dumper($param) if $DEBUG;
551
552   $param->{'job'} = $job;
553
554   $param->{'search'} = thaw(decode_base64($param->{'search'}))
555     or die "process_email_search_result requires search params.\n";
556
557   my $table = $param->{'table'} 
558     or die "process_email_search_result requires table.\n";
559
560   eval "use FS::$table;";
561   die "error loading FS::$table: $@\n" if $@;
562
563   my $error = "FS::$table"->email_search_result( $param );
564   dbh->commit; # save failed jobs before rethrowing the error
565   die $error if $error;
566
567 }
568
569 =item conf
570
571 Returns a configuration handle (L<FS::Conf>) set to the customer's locale, 
572 if they have one.  If not, returns an FS::Conf with no locale.
573
574 =cut
575
576 sub conf {
577   my $self = shift;
578   return $self->{_conf} if (ref $self and $self->{_conf});
579   my $cust_main = $self->cust_main;
580   my $conf = new FS::Conf { 
581     'locale' => ($cust_main ? $cust_main->locale : '')
582   };
583   $self->{_conf} = $conf if ref $self;
584   return $conf;
585 }
586
587 =item mt TEXT [, ARGS ]
588
589 Localizes a text string (see L<Locale::Maketext>) for the customer's locale,
590 if they have one.
591
592 =cut
593
594 sub mt {
595   my $self = shift;
596   return $self->{_lh}->maketext(@_) if (ref $self and $self->{_lh});
597   my $cust_main = $self->cust_main;
598   my $locale = $cust_main ? $cust_main->locale : '';
599   my $lh = FS::L10N->get_handle($locale);
600   $self->{_lh} = $lh if ref $self;
601   return $lh->maketext(@_);
602 }
603
604 =item time2str_local FORMAT, TIME[, ESCAPE]
605
606 Localizes a date (see L<Date::Language>) for the customer's locale.
607
608 FORMAT can be a L<Date::Format> string, or one of these special words:
609
610 - "short": the value of the "date_format" config setting for the customer's 
611   locale, defaulting to "%x".
612 - "rdate": the same as "short" except that the default has a four-digit year.
613 - "long": the value of the "date_format_long" config setting for the 
614   customer's locale, defaulting to "%b %o, %Y".
615
616 ESCAPE, if specified, is one of "latex" or "html", and will escape non-ASCII
617 characters and convert spaces to nonbreaking spaces.
618
619 =cut
620
621 sub time2str_local {
622   # renamed so that we don't have to change every single reference to 
623   # time2str everywhere
624   my $self = shift;
625   my ($format, $time, $escape) = @_;
626   return '' unless $time > 0; # work around time2str's traditional stupidity
627
628   $self->{_date_format} ||= {};
629   if (!exists($self->{_dh})) {
630     my $cust_main = $self->cust_main;
631     my $locale = $cust_main->locale  if $cust_main;
632     $locale ||= 'en_US';
633     my %info = FS::Locales->locale_info($locale);
634     my $dh = eval { Date::Language->new($info{'name'}) } ||
635              Date::Language->new(); # fall back to English
636     $self->{_dh} = $dh;
637   }
638
639   if ($format eq 'short') {
640     $format = $self->{_date_format}->{short}
641             ||= $self->conf->config('date_format') || '%x';
642   } elsif ($format eq 'rdate') {
643     $format = $self->{_date_format}->{rdate}
644             ||= $self->conf->config('date_format') || '%m/%d/%Y';
645   } elsif ($format eq 'long') {
646     $format = $self->{_date_format}->{long}
647             ||= $self->conf->config('date_format_long') || '%b %o, %Y';
648   }
649
650   # actually render the date
651   my $string = $self->{_dh}->time2str($format, $time);
652
653   if ($escape) {
654     if ($escape eq 'html') {
655       $string = encode_entities($string);
656       $string =~ s/ +/&nbsp;/g;
657     } elsif ($escape eq 'latex') { # just do nbsp's here
658       $string =~ s/ +/~/g;
659     }
660   }
661   
662   $string;
663 }
664
665 =item unsuspend_balance
666
667 If conf I<unsuspend_balance> is set and customer's current balance is
668 beneath the set threshold, unsuspends customer packages.
669
670 =cut
671
672 sub unsuspend_balance {
673   my $self = shift;
674   my $cust_main = $self->cust_main;
675   my $conf = $self->conf;
676   my $setting = $conf->config('unsuspend_balance');
677   my $maxbalance;
678   if ($setting eq 'Zero') {
679     $maxbalance = 0;
680
681   # kind of a pain to load/check all cust_bill instead of just open ones,
682   # but if for some reason payment gets applied to later bills before
683   # earlier ones, we still want to consider the later ones as allowable balance
684   } elsif ($setting eq 'Latest invoice charges') {
685     my @cust_bill = $cust_main->cust_bill();
686     my $cust_bill = $cust_bill[-1]; #always want the most recent one
687     if ($cust_bill) {
688       $maxbalance = $cust_bill->charged || 0;
689     } else {
690       $maxbalance = 0;
691     }
692   } elsif ($setting eq 'Charges not past due') {
693     my $now = time;
694     $maxbalance = 0;
695     foreach my $cust_bill ($cust_main->cust_bill()) {
696       next unless $now <= ($cust_bill->due_date || $cust_bill->_date);
697       $maxbalance += $cust_bill->charged || 0;
698     }
699   } elsif (length($setting)) {
700     warn "Unrecognized unsuspend_balance setting $setting";
701     return;
702   } else {
703     return;
704   }
705   my $balance = $cust_main->balance || 0;
706   if ($balance <= $maxbalance) {
707     my @errors = $cust_main->unsuspend;
708     # side-fx with nested transactions?  upstack rolls back?
709     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
710          join(' / ', @errors)
711       if @errors;
712   }
713   return;
714 }
715
716 =back
717
718 =head1 BUGS
719
720 =head1 SEE ALSO
721
722 L<FS::cust_main>, L<FS::Record>
723
724 =cut
725
726 1;
727