add conditions for customer cancelled packages, RT#42043
[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 =back
387
388 Returns an error message, or false for success.
389
390 If any messages fail to send, they will be queued as individual 
391 jobs which can be manually retried.  If the first ten messages 
392 in the job fail, the entire job will abort and return an error.
393
394 =cut
395
396 use Storable qw(thaw);
397 use MIME::Base64;
398 use Data::Dumper qw(Dumper);
399 use Digest::SHA qw(sha1); # for duplicate checking
400
401 sub email_search_result {
402   my($class, $param) = @_;
403
404   my $msgnum = $param->{msgnum};
405   my $from = delete $param->{from};
406   my $subject = delete $param->{subject};
407   my $html_body = delete $param->{html_body};
408   my $text_body = delete $param->{text_body};
409   my $error = '';
410
411   my $job = delete $param->{'job'}
412     or die "email_search_result must run from the job queue.\n";
413   
414   my $msg_template;
415   if ( $msgnum ) {
416     $msg_template = qsearchs('msg_template', { msgnum => $msgnum } )
417       or die "msgnum $msgnum not found\n";
418   } else {
419     $msg_template = FS::msg_template->new({
420         from_addr => $from,
421         msgname   => $subject, # maybe a timestamp also?
422         disabled  => 'D', # 'D'raft
423         # msgclass, maybe
424     });
425     $error = $msg_template->insert(
426       subject => $subject,
427       body    => $html_body,
428     );
429     return "$error (when creating draft template)" if $error;
430   }
431
432   my $sql_query = $class->search($param->{'search'});
433   $sql_query->{'select'} = $sql_query->{'table'} . '.*';
434
435   my $count_query   = delete($sql_query->{'count_query'});
436   my $count_sth = dbh->prepare($count_query)
437     or die "Error preparing $count_query: ". dbh->errstr;
438   $count_sth->execute
439     or die "Error executing $count_query: ". $count_sth->errstr;
440   my $count_arrayref = $count_sth->fetchrow_arrayref;
441   my $num_cust = $count_arrayref->[0];
442
443   my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
444   my @retry_jobs = ();
445   my $dups = 0;
446   my $success = 0;
447   my %sent_to = ();
448
449   if ( !$msg_template ) {
450     die "email_search_result now requires a msg_template";
451   }
452
453   #eventually order+limit magic to reduce memory use?
454   foreach my $obj ( qsearch($sql_query) ) {
455
456     #progressbar first, so that the count is right
457     $num++;
458     if ( time - $min_sec > $last ) {
459       my $error = $job->update_statustext(
460         int( 100 * $num / $num_cust )
461       );
462       die $error if $error;
463       $last = time;
464     }
465
466     my $cust_main = $obj->cust_main;
467     if ( !$cust_main ) { 
468       next; # unlinked object; nothing else we can do
469     }
470
471     my $cust_msg = $msg_template->prepare(
472       'cust_main' => $cust_main,
473       'object'    => $obj,
474     );
475
476     # For non-cust_main searches, we avoid duplicates based on message
477     # body text.
478     my $unique = $cust_main->custnum;
479     $unique .= sha1($cust_msg->text_body) if $class ne 'FS::cust_main';
480     if( $sent_to{$unique} ) {
481       # avoid duplicates
482       $dups++;
483       next;
484     }
485
486     $sent_to{$unique} = 1;
487     
488     $error = $cust_msg->send;
489
490     if($error) {
491       # queue the sending of this message so that the user can see what we
492       # tried to do, and retry if desired
493       # (note the cust_msg itself also now has a status of 'failed'; that's 
494       # fine, as it will get its status reset if we retry the job)
495       my $queue = new FS::queue {
496         'job'        => 'FS::cust_msg::process_send',
497         'custnum'    => $cust_main->custnum,
498         'status'     => 'failed',
499         'statustext' => $error,
500       };
501       $queue->insert($cust_msg->custmsgnum);
502       push @retry_jobs, $queue;
503     }
504     else {
505       $success++;
506     }
507
508     if($success == 0 and
509         (scalar(@retry_jobs) > 10 or $num == $num_cust)
510       ) {
511       # 10 is arbitrary, but if we have enough failures, that's
512       # probably a configuration or network problem, and we
513       # abort the batch and run away screaming.
514       # We NEVER do this if anything was successfully sent.
515       $_->delete foreach (@retry_jobs);
516       return "multiple failures: '$error'\n";
517     }
518   } # foreach $obj
519
520   # if the message template was created as "draft", change its status to
521   # "completed"
522   if ($msg_template->disabled eq 'D') {
523     $msg_template->set('disabled' => 'C');
524     my $error = $msg_template->replace;
525     warn "$error (setting draft message template status)" if $error;
526   }
527
528   if(@retry_jobs) {
529     # fail the job, but with a status message that makes it clear
530     # something was sent.
531     return "Sent $success, skipped $dups duplicate(s), failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
532   }
533
534   return '';
535 }
536
537 sub process_email_search_result {
538   my $job = shift;
539   #warn "$me process_re_X $method for job $job\n" if $DEBUG;
540
541   my $param = shift;
542   warn Dumper($param) if $DEBUG;
543
544   $param->{'job'} = $job;
545
546   $param->{'search'} = thaw(decode_base64($param->{'search'}))
547     or die "process_email_search_result requires search params.\n";
548
549   my $table = $param->{'table'} 
550     or die "process_email_search_result requires table.\n";
551
552   eval "use FS::$table;";
553   die "error loading FS::$table: $@\n" if $@;
554
555   my $error = "FS::$table"->email_search_result( $param );
556   dbh->commit; # save failed jobs before rethrowing the error
557   die $error if $error;
558
559 }
560
561 =item conf
562
563 Returns a configuration handle (L<FS::Conf>) set to the customer's locale, 
564 if they have one.  If not, returns an FS::Conf with no locale.
565
566 =cut
567
568 sub conf {
569   my $self = shift;
570   return $self->{_conf} if (ref $self and $self->{_conf});
571   my $cust_main = $self->cust_main;
572   my $conf = new FS::Conf { 
573     'locale' => ($cust_main ? $cust_main->locale : '')
574   };
575   $self->{_conf} = $conf if ref $self;
576   return $conf;
577 }
578
579 =item mt TEXT [, ARGS ]
580
581 Localizes a text string (see L<Locale::Maketext>) for the customer's locale,
582 if they have one.
583
584 =cut
585
586 sub mt {
587   my $self = shift;
588   return $self->{_lh}->maketext(@_) if (ref $self and $self->{_lh});
589   my $cust_main = $self->cust_main;
590   my $locale = $cust_main ? $cust_main->locale : '';
591   my $lh = FS::L10N->get_handle($locale);
592   $self->{_lh} = $lh if ref $self;
593   return $lh->maketext(@_);
594 }
595
596 =item time2str_local FORMAT, TIME[, ESCAPE]
597
598 Localizes a date (see L<Date::Language>) for the customer's locale.
599
600 FORMAT can be a L<Date::Format> string, or one of these special words:
601
602 - "short": the value of the "date_format" config setting for the customer's 
603   locale, defaulting to "%x".
604 - "rdate": the same as "short" except that the default has a four-digit year.
605 - "long": the value of the "date_format_long" config setting for the 
606   customer's locale, defaulting to "%b %o, %Y".
607
608 ESCAPE, if specified, is one of "latex" or "html", and will escape non-ASCII
609 characters and convert spaces to nonbreaking spaces.
610
611 =cut
612
613 sub time2str_local {
614   # renamed so that we don't have to change every single reference to 
615   # time2str everywhere
616   my $self = shift;
617   my ($format, $time, $escape) = @_;
618   return '' unless $time > 0; # work around time2str's traditional stupidity
619
620   $self->{_date_format} ||= {};
621   if (!exists($self->{_dh})) {
622     my $cust_main = $self->cust_main;
623     my $locale = $cust_main->locale  if $cust_main;
624     $locale ||= 'en_US';
625     my %info = FS::Locales->locale_info($locale);
626     my $dh = eval { Date::Language->new($info{'name'}) } ||
627              Date::Language->new(); # fall back to English
628     $self->{_dh} = $dh;
629   }
630
631   if ($format eq 'short') {
632     $format = $self->{_date_format}->{short}
633             ||= $self->conf->config('date_format') || '%x';
634   } elsif ($format eq 'rdate') {
635     $format = $self->{_date_format}->{rdate}
636             ||= $self->conf->config('date_format') || '%m/%d/%Y';
637   } elsif ($format eq 'long') {
638     $format = $self->{_date_format}->{long}
639             ||= $self->conf->config('date_format_long') || '%b %o, %Y';
640   }
641
642   # actually render the date
643   my $string = $self->{_dh}->time2str($format, $time);
644
645   if ($escape) {
646     if ($escape eq 'html') {
647       $string = encode_entities($string);
648       $string =~ s/ +/&nbsp;/g;
649     } elsif ($escape eq 'latex') { # just do nbsp's here
650       $string =~ s/ +/~/g;
651     }
652   }
653   
654   $string;
655 }
656
657 =item unsuspend_balance
658
659 If conf I<unsuspend_balance> is set and customer's current balance is
660 beneath the set threshold, unsuspends customer packages.
661
662 =cut
663
664 sub unsuspend_balance {
665   my $self = shift;
666   my $cust_main = $self->cust_main;
667   my $conf = $self->conf;
668   my $setting = $conf->config('unsuspend_balance');
669   my $maxbalance;
670   if ($setting eq 'Zero') {
671     $maxbalance = 0;
672   } elsif ($setting eq 'Latest invoice charges') {
673     my @cust_bill = $cust_main->cust_bill();
674     my $cust_bill = $cust_bill[-1]; #always want the most recent one
675     return unless $cust_bill;
676     $maxbalance = $cust_bill->charged || 0;
677   } elsif (length($setting)) {
678     warn "Unrecognized unsuspend_balance setting $setting";
679     return;
680   } else {
681     return;
682   }
683   my $balance = $cust_main->balance || 0;
684   if ($balance <= $maxbalance) {
685     my @errors = $cust_main->unsuspend;
686     # side-fx with nested transactions?  upstack rolls back?
687     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
688          join(' / ', @errors)
689       if @errors;
690   }
691   return;
692 }
693
694 =back
695
696 =head1 BUGS
697
698 =head1 SEE ALSO
699
700 L<FS::cust_main>, L<FS::Record>
701
702 =cut
703
704 1;
705