RT# 81961 Repair broken links in POD documentation
[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   my $cust_main = $self->cust_main;
214   return $self->cust_unlinked_msg unless $cust_main;
215   return $cust_main->cust_status;
216 }
217
218 =item ucfirst_cust_status
219
220 Given an object that contains fields from cust_main (say, from a JOINed
221 search; see httemplate/search/ for examples), returns the equivalent of the
222 FS::cust_main I<ucfirst_status> method, or "(unlinked)" if this object is not
223 linked to a customer.
224
225 =cut
226
227 sub ucfirst_cust_status {
228   carp "ucfirst_cust_status deprecated, use cust_status_label";
229   local($FS::cust_main::ucfirst_nowarn) = 1;
230   my $self = shift;
231   $self->cust_linked
232     ? ucfirst( $self->cust_status(@_) ) 
233     : $self->cust_unlinked_msg;
234 }
235
236 =item cust_status_label
237
238 =cut
239
240 sub cust_status_label {
241   my $self = shift;
242
243   $self->cust_linked
244     ? FS::cust_main::cust_status_label($self)
245     : $self->cust_unlinked_msg;
246 }
247
248 =item cust_statuscolor
249
250 Given an object that contains fields from cust_main (say, from a JOINed
251 search; see httemplate/search/ for examples), returns the equivalent of the
252 FS::cust_main I<statuscol> method, or "000000" if this object is not linked to
253 a customer.
254
255 =cut
256
257 sub cust_statuscolor {
258   my $self = shift;
259
260   $self->cust_linked
261     ? FS::cust_main::cust_statuscolor($self)
262     : '000000';
263 }
264
265 =item agent_name
266
267 =cut
268
269 sub agent_name {
270   my $self = shift;
271   $self->cust_linked
272     ? $self->cust_main->agent_name
273     : $self->cust_unlinked_msg;
274 }
275
276 =item prospect_sql
277
278 =item active_sql
279
280 =item inactive_sql
281
282 =item suspended_sql
283
284 =item cancelled_sql
285
286 Class methods that return SQL framents, equivalent to the corresponding
287 FS::cust_main method.
288
289 =cut
290
291 #      my \$self = shift;
292 #      \$self->cust_linked
293 #        ? FS::cust_main::${sub}_sql(\$self)
294 #        : '0';
295
296 foreach my $sub (qw( prospect active inactive suspended cancelled )) {
297   eval "
298     sub ${sub}_sql {
299       confess 'cust_main_Mixin ${sub}_sql called with object' if ref(\$_[0]);
300       'cust_main.custnum IS NOT NULL AND '. FS::cust_main->${sub}_sql();
301     }
302   ";
303   die $@ if $@;
304 }
305
306 =item cust_search_sql
307
308 Returns a list of SQL WHERE fragments to search for parameters specified
309 in HASHREF.  Valid parameters are:
310
311 =over 4
312
313 =item agentnum
314
315 =item status
316
317 =back
318
319 =cut
320
321 sub cust_search_sql {
322   my($class, $param) = @_;
323
324   if ( $DEBUG ) {
325     warn "$me cust_search_sql called with params: \n".
326          join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
327   }
328
329   my @search = ();
330
331   if ( $param->{'agentnum'} && $param->{'agentnum'} =~ /^(\d+)$/ ) {
332     push @search, "cust_main.agentnum = $1";
333   }
334
335   #status (prospect active inactive suspended cancelled)
336   if ( grep { $param->{'status'} eq $_ } FS::cust_main->statuses() ) {
337     my $method = $param->{'status'}. '_sql';
338     push @search, $class->$method();
339   }
340
341   #here is the agent virtualization
342   push @search,
343     $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
344   
345   return @search;
346
347 }
348
349 =item email_search_result HASHREF
350
351 Emails a notice to the specified customer's contact_email addresses.
352
353
354 If the user has specified "Invoice recipients" on the send e-mail screen,
355 contact_email rows containing the invoice_dest flag will be included.
356 This option is default, if neither 'invoice' nor 'message' are present.
357
358 If the user has specified "Message recipients" on the send e-mail screen,
359 contact_email rows containing the message_dest flag will be included.
360
361 The selection is indicated by the presence of the text 'message' or
362 'invoice' within the to_contact_classnum argument.
363
364
365 Parameters:
366
367 =over 4
368
369 =item job
370
371 Queue job for status updates.  Required.
372
373 =item search
374
375 Hashref of params to the L<FS::Record/search> method.  Required.
376
377 =item msgnum
378
379 Message template number (see L<FS::msg_template>).  Overrides all 
380 of the following options.
381
382 =item from
383
384 From: address
385
386 =item subject
387
388 Email Subject:
389
390 =item html_body
391
392 HTML body
393
394 =item text_body
395
396 Text body
397
398 =item to_contact_classnum
399
400 This field contains a comma-separated list.  This list may contain:
401
402 - the text "invoice" indicating contacts with invoice_dest flag should
403   be included
404 - the text "message" indicating contacts with message_dest flag should
405   be included
406 - numbers representing classnum id values for email contact classes.
407   If any classnum are present, emails should only be sent to contact_email
408   addresses where contact_email.classnum contains one of these classes.
409   The classnum 0 also includes where contact_email.classnum IS NULL
410
411 If neither 'invoice' nor 'message' has been specified, this method will
412 behave as if 'invoice' had been selected
413
414 =back
415
416 Returns an error message, or false for success.
417
418 If any messages fail to send, they will be queued as individual 
419 jobs which can be manually retried.  If the first ten messages 
420 in the job fail, the entire job will abort and return an error.
421
422 =cut
423
424 use Storable qw(thaw);
425 use MIME::Base64;
426 use Data::Dumper qw(Dumper);
427 use Digest::SHA qw(sha1); # for duplicate checking
428
429 sub email_search_result {
430   my($class, $param) = @_;
431
432   my $conf = FS::Conf->new;
433   my $send_to_domain = $conf->config('email-to-voice_domain');
434
435   my $msgnum = $param->{msgnum};
436   my $from = delete $param->{from};
437   my $subject = delete $param->{subject};
438   my $html_body = delete $param->{html_body};
439   my $text_body = delete $param->{text_body};
440   my $to_contact_classnum = delete $param->{to_contact_classnum};
441   my $emailtovoice_name = delete $param->{emailtovoice_contact};
442
443   my $error = '';
444
445   my $to = $emailtovoice_name . '@' . $send_to_domain unless !$emailtovoice_name;
446
447   my $job = delete $param->{'job'}
448     or die "email_search_result must run from the job queue.\n";
449   
450   my $msg_template;
451   if ( $msgnum ) {
452     $msg_template = qsearchs('msg_template', { msgnum => $msgnum } )
453       or die "msgnum $msgnum not found\n";
454   } else {
455     $msg_template = FS::msg_template->new({
456         from_addr => $from,
457         msgname   => $subject, # maybe a timestamp also?
458         disabled  => 'D', # 'D'raft
459         # msgclass, maybe
460     });
461     $error = $msg_template->insert(
462       subject => $subject,
463       body    => $html_body,
464     );
465     return "$error (when creating draft template)" if $error;
466   }
467
468   my $sql_query = $class->search($param->{'search'});
469   $sql_query->{'select'} = $sql_query->{'table'} . '.*';
470
471   my $count_query   = delete($sql_query->{'count_query'});
472   my $count_sth = dbh->prepare($count_query)
473     or die "Error preparing $count_query: ". dbh->errstr;
474   $count_sth->execute
475     or die "Error executing $count_query: ". $count_sth->errstr;
476   my $count_arrayref = $count_sth->fetchrow_arrayref;
477   my $num_cust = $count_arrayref->[0];
478
479   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
480   my @retry_jobs = ();
481   my $dups = 0;
482   my $success = 0;
483   my %sent_to = ();
484
485   if ( !$msg_template ) {
486     die "email_search_result now requires a msg_template";
487   }
488
489   #eventually order+limit magic to reduce memory use?
490   foreach my $obj ( qsearch($sql_query) ) {
491
492     #progressbar first, so that the count is right
493     $num++;
494     if ( time - $min_sec > $last ) {
495       my $error = $job->update_statustext(
496         int( 100 * $num / $num_cust )
497       );
498       die $error if $error;
499       $last = time;
500     }
501
502     my $cust_main = $obj->cust_main;
503     if ( !$cust_main ) { 
504       next; # unlinked object; nothing else we can do
505     }
506
507     my %to = ();
508     if ($to) { $to{'to'} = $to; }
509
510     my $cust_msg = $msg_template->prepare(
511       'cust_main' => $cust_main,
512       'object'    => $obj,
513       'to_contact_classnum' => $to_contact_classnum,
514       %to,
515     );
516
517     # For non-cust_main searches, we avoid duplicates based on message
518     # body text.
519     my $unique = $cust_main->custnum;
520     $unique .= sha1($cust_msg->text_body) if $class ne 'FS::cust_main';
521     if( $sent_to{$unique} ) {
522       # avoid duplicates
523       $dups++;
524       next;
525     }
526
527     $sent_to{$unique} = 1;
528     
529     $error = $cust_msg->send;
530
531     if($error) {
532       # queue the sending of this message so that the user can see what we
533       # tried to do, and retry if desired
534       # (note the cust_msg itself also now has a status of 'failed'; that's 
535       # fine, as it will get its status reset if we retry the job)
536       my $queue = new FS::queue {
537         'job'        => 'FS::cust_msg::process_send',
538         'custnum'    => $cust_main->custnum,
539         'status'     => 'failed',
540         'statustext' => $error,
541       };
542       $queue->insert($cust_msg->custmsgnum);
543       push @retry_jobs, $queue;
544     }
545     else {
546       $success++;
547     }
548
549     if($success == 0 and
550         (scalar(@retry_jobs) > 10 or $num == $num_cust)
551       ) {
552       # 10 is arbitrary, but if we have enough failures, that's
553       # probably a configuration or network problem, and we
554       # abort the batch and run away screaming.
555       # We NEVER do this if anything was successfully sent.
556       $_->delete foreach (@retry_jobs);
557       return "multiple failures: '$error'\n";
558     }
559   } # foreach $obj
560
561   # if the message template was created as "draft", change its status to
562   # "completed"
563   if ($msg_template->disabled eq 'D') {
564     $msg_template->set('disabled' => 'C');
565     my $error = $msg_template->replace;
566     warn "$error (setting draft message template status)" if $error;
567   }
568
569   if(@retry_jobs) {
570     # fail the job, but with a status message that makes it clear
571     # something was sent.
572     return "Sent $success, skipped $dups duplicate(s), failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
573   }
574
575   return '';
576 }
577
578 sub process_email_search_result {
579   my $job = shift;
580   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
581
582   my $param = shift;
583   warn Dumper($param) if $DEBUG;
584
585   $param->{'job'} = $job;
586
587   $param->{'search'} = thaw(decode_base64($param->{'search'}))
588     or die "process_email_search_result requires search params.\n";
589
590   my $table = $param->{'table'} 
591     or die "process_email_search_result requires table.\n";
592
593   eval "use FS::$table;";
594   die "error loading FS::$table: $@\n" if $@;
595
596   my $error = "FS::$table"->email_search_result( $param );
597   dbh->commit; # save failed jobs before rethrowing the error
598   die $error if $error;
599
600 }
601
602 =item conf
603
604 Returns a configuration handle (L<FS::Conf>) set to the customer's locale, 
605 if they have one.  If not, returns an FS::Conf with no locale.
606
607 =cut
608
609 sub conf {
610   my $self = shift;
611   return $self->{_conf} if (ref $self and $self->{_conf});
612   my $cust_main = $self->cust_main;
613   my $conf = new FS::Conf { 
614     'locale' => ($cust_main ? $cust_main->locale : '')
615   };
616   $self->{_conf} = $conf if ref $self;
617   return $conf;
618 }
619
620 =item mt TEXT [, ARGS ]
621
622 Localizes a text string (see L<Locale::Maketext>) for the customer's locale,
623 if they have one.
624
625 =cut
626
627 sub mt {
628   my $self = shift;
629   return $self->{_lh}->maketext(@_) if (ref $self and $self->{_lh});
630   my $cust_main = $self->cust_main;
631   my $locale = $cust_main ? $cust_main->locale : '';
632   my $lh = FS::L10N->get_handle($locale);
633   $self->{_lh} = $lh if ref $self;
634   return $lh->maketext(@_);
635 }
636
637 =item time2str_local FORMAT, TIME[, ESCAPE]
638
639 Localizes a date (see L<Date::Language>) for the customer's locale.
640
641 FORMAT can be a L<Date::Format> string, or one of these special words:
642
643 - "short": the value of the "date_format" config setting for the customer's 
644   locale, defaulting to "%x".
645 - "rdate": the same as "short" except that the default has a four-digit year.
646 - "long": the value of the "date_format_long" config setting for the 
647   customer's locale, defaulting to "%b %o, %Y".
648
649 ESCAPE, if specified, is one of "latex" or "html", and will escape non-ASCII
650 characters and convert spaces to nonbreaking spaces.
651
652 =cut
653
654 sub time2str_local {
655   # renamed so that we don't have to change every single reference to 
656   # time2str everywhere
657   my $self = shift;
658   my ($format, $time, $escape) = @_;
659   return '' unless $time > 0; # work around time2str's traditional stupidity
660
661   $self->{_date_format} ||= {};
662   if (!exists($self->{_dh})) {
663     my $locale = $self->cust_main->locale if $self->cust_main;
664     $locale ||= FS::Conf->new->config('locale') || 'en_US';
665
666     my %info = FS::Locales->locale_info($locale);
667
668     $self->{_dh} = eval { Date::Language->new($info{'name'}) }
669       || Date::Language->new(); # fall back to English
670   }
671
672   if ($format eq 'short') {
673     $format = $self->{_date_format}->{short}
674             ||= $self->conf->config('date_format') || '%x';
675   } elsif ($format eq 'rdate') {
676     $format = $self->{_date_format}->{rdate}
677             ||= $self->conf->config('date_format') || '%m/%d/%Y';
678   } elsif ($format eq 'long') {
679     $format = $self->{_date_format}->{long}
680             ||= $self->conf->config('date_format_long') || '%b %o, %Y';
681   }
682
683   # actually render the date
684   my $string = $self->{_dh}->time2str($format, $time);
685
686   if ($escape) {
687     if ($escape eq 'html') {
688       $string = encode_entities($string);
689       $string =~ s/ +/&nbsp;/g;
690     } elsif ($escape eq 'latex') { # just do nbsp's here
691       $string =~ s/ +/~/g;
692     }
693   }
694   
695   $string;
696 }
697
698 =item unsuspend_balance
699
700 If conf I<unsuspend_balance> is set and customer's current balance is
701 beneath the set threshold, unsuspends customer packages.
702
703 =cut
704
705 sub unsuspend_balance {
706   my $self = shift;
707   my $cust_main = $self->cust_main;
708   my $conf = $self->conf;
709   my $setting = $conf->config('unsuspend_balance') or return;
710   my $maxbalance;
711   if ($setting eq 'Zero') {
712     $maxbalance = 0;
713
714   # kind of a pain to load/check all cust_bill instead of just open ones,
715   # but if for some reason payment gets applied to later bills before
716   # earlier ones, we still want to consider the later ones as allowable balance
717   } elsif ($setting eq 'Latest invoice charges') {
718     my @cust_bill = $cust_main->cust_bill();
719     my $cust_bill = $cust_bill[-1]; #always want the most recent one
720     if ($cust_bill) {
721       $maxbalance = $cust_bill->charged || 0;
722     } else {
723       $maxbalance = 0;
724     }
725   } elsif ($setting eq 'Charges not past due') {
726     my $now = time;
727     $maxbalance = 0;
728     foreach my $cust_bill ($cust_main->cust_bill()) {
729       next unless $now <= ($cust_bill->due_date || $cust_bill->_date);
730       $maxbalance += $cust_bill->charged || 0;
731     }
732   } elsif (length($setting)) {
733     warn "Unrecognized unsuspend_balance setting $setting";
734     return;
735   } else {
736     return;
737   }
738   my $balance = $cust_main->balance || 0;
739   if ($balance <= $maxbalance) {
740     my @errors = $cust_main->unsuspend(
741                    'reason_type' => $conf->config('unsuspend_reason_type')
742                  );
743     # side-fx with nested transactions?  upstack rolls back?
744     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
745          join(' / ', @errors)
746       if @errors;
747   }
748   return;
749 }
750
751 =back
752
753 =head1 BUGS
754
755 =head1 SEE ALSO
756
757 L<FS::cust_main>, L<FS::Record>
758
759 =cut
760
761 1;