70d78749c63b121b1b8ed4e414bb5ede8e772c8e
[freeside.git] / FS / FS / cust_credit.pm
1 package FS::cust_credit;
2
3 use strict;
4 use vars qw( @ISA $conf $unsuspendauto $me $DEBUG );
5 use Date::Format;
6 use FS::UID qw( dbh getotaker );
7 use FS::Misc qw(send_email);
8 use FS::Record qw( qsearch qsearchs dbdef );
9 use FS::cust_main_Mixin;
10 use FS::cust_main;
11 use FS::cust_refund;
12 use FS::cust_credit_bill;
13 use FS::part_pkg;
14 use FS::reason_type;
15 use FS::reason;
16
17 @ISA = qw( FS::cust_main_Mixin FS::Record );
18 $me = '[ FS::cust_credit ]';
19 $DEBUG = 0;
20
21 #ask FS::UID to run this stuff for us later
22 $FS::UID::callback{'FS::cust_credit'} = sub { 
23
24   $conf = new FS::Conf;
25   $unsuspendauto = $conf->exists('unsuspendauto');
26
27 };
28
29 our %reasontype_map = ( 'referral_credit_type' => 'Referral Credit',
30                         'cancel_credit_type'   => 'Cancellation Credit',
31                         'signup_credit_type'   => 'Self-Service Credit',
32                       );
33
34 =head1 NAME
35
36 FS::cust_credit - Object methods for cust_credit records
37
38 =head1 SYNOPSIS
39
40   use FS::cust_credit;
41
42   $record = new FS::cust_credit \%hash;
43   $record = new FS::cust_credit { 'column' => 'value' };
44
45   $error = $record->insert;
46
47   $error = $new_record->replace($old_record);
48
49   $error = $record->delete;
50
51   $error = $record->check;
52
53 =head1 DESCRIPTION
54
55 An FS::cust_credit object represents a credit; the equivalent of a negative
56 B<cust_bill> record (see L<FS::cust_bill>).  FS::cust_credit inherits from
57 FS::Record.  The following fields are currently supported:
58
59 =over 4
60
61 =item crednum - primary key (assigned automatically for new credits)
62
63 =item custnum - customer (see L<FS::cust_main>)
64
65 =item amount - amount of the credit
66
67 =item _date - specified as a UNIX timestamp; see L<perlfunc/"time">.  Also see
68 L<Time::Local> and L<Date::Parse> for conversion functions.
69
70 =item otaker - order taker (assigned automatically, see L<FS::UID>)
71
72 =item reason - text ( deprecated )
73
74 =item reasonum - int reason (see L<FS::reason>)
75
76 =item closed - books closed flag, empty or `Y'
77
78 =back
79
80 =head1 METHODS
81
82 =over 4
83
84 =item new HASHREF
85
86 Creates a new credit.  To add the credit to the database, see L<"insert">.
87
88 =cut
89
90 sub table { 'cust_credit'; }
91 sub cust_linked { $_[0]->cust_main_custnum; } 
92 sub cust_unlinked_msg {
93   my $self = shift;
94   "WARNING: can't find cust_main.custnum ". $self->custnum.
95   ' (cust_credit.crednum '. $self->crednum. ')';
96 }
97
98 =item insert
99
100 Adds this credit to the database ("Posts" the credit).  If there is an error,
101 returns the error, otherwise returns false.
102
103 =cut
104
105 sub insert {
106   my ($self, %options) = @_;
107
108   local $SIG{HUP} = 'IGNORE';
109   local $SIG{INT} = 'IGNORE';
110   local $SIG{QUIT} = 'IGNORE';
111   local $SIG{TERM} = 'IGNORE';
112   local $SIG{TSTP} = 'IGNORE';
113   local $SIG{PIPE} = 'IGNORE';
114
115   my $oldAutoCommit = $FS::UID::AutoCommit;
116   local $FS::UID::AutoCommit = 0;
117   my $dbh = dbh;
118
119   my $cust_main = qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
120   my $old_balance = $cust_main->balance;
121
122   unless ($self->reasonnum) {
123     my $result = $self->reason( $self->getfield('reason'),
124                                 exists($options{ 'reason_type' })
125                                   ? ('reason_type' => $options{ 'reason_type' })
126                                   : (),
127                               );
128     unless($result) {
129       $dbh->rollback if $oldAutoCommit;
130       return "failed to set reason for $me: ". $dbh->errstr;
131     }
132   }
133
134   $self->setfield('reason', '');
135
136   my $error = $self->SUPER::insert;
137   if ( $error ) {
138     $dbh->rollback if $oldAutoCommit;
139     return "error inserting $self: $error";
140   }
141
142   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
143
144   #false laziness w/ cust_credit::insert
145   if ( $unsuspendauto && $old_balance && $cust_main->balance <= 0 ) {
146     my @errors = $cust_main->unsuspend;
147     #return 
148     # side-fx with nested transactions?  upstack rolls back?
149     warn "WARNING:Errors unsuspending customer ". $cust_main->custnum. ": ".
150          join(' / ', @errors)
151       if @errors;
152   }
153   #eslaf
154
155   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
156
157   '';
158
159 }
160
161 =item delete
162
163 Unless the closed flag is set, deletes this credit and all associated
164 applications (see L<FS::cust_credit_bill>).  In most cases, you want to use
165 the void method instead to leave a record of the deleted credit.
166
167 =cut
168
169 # very similar to FS::cust_pay::delete
170 sub delete {
171   my $self = shift;
172   return "Can't delete closed credit" if $self->closed =~ /^Y/i;
173
174   local $SIG{HUP} = 'IGNORE';
175   local $SIG{INT} = 'IGNORE';
176   local $SIG{QUIT} = 'IGNORE';
177   local $SIG{TERM} = 'IGNORE';
178   local $SIG{TSTP} = 'IGNORE';
179   local $SIG{PIPE} = 'IGNORE';
180
181   my $oldAutoCommit = $FS::UID::AutoCommit;
182   local $FS::UID::AutoCommit = 0;
183   my $dbh = dbh;
184
185   foreach my $cust_credit_bill ( $self->cust_credit_bill ) {
186     my $error = $cust_credit_bill->delete;
187     if ( $error ) {
188       $dbh->rollback if $oldAutoCommit;
189       return $error;
190     }
191   }
192
193   foreach my $cust_credit_refund ( $self->cust_credit_refund ) {
194     my $error = $cust_credit_refund->delete;
195     if ( $error ) {
196       $dbh->rollback if $oldAutoCommit;
197       return $error;
198     }
199   }
200
201   my $error = $self->SUPER::delete(@_);
202   if ( $error ) {
203     $dbh->rollback if $oldAutoCommit;
204     return $error;
205   }
206
207   if ( $conf->config('deletecredits') ne '' ) {
208
209     my $cust_main = $self->cust_main;
210
211     my $error = send_email(
212       'from'    => $conf->config('invoice_from'), #??? well as good as any
213       'to'      => $conf->config('deletecredits'),
214       'subject' => 'FREESIDE NOTIFICATION: Credit deleted',
215       'body'    => [
216         "This is an automatic message from your Freeside installation\n",
217         "informing you that the following credit has been deleted:\n",
218         "\n",
219         'crednum: '. $self->crednum. "\n",
220         'custnum: '. $self->custnum.
221           " (". $cust_main->last. ", ". $cust_main->first. ")\n",
222         'amount: $'. sprintf("%.2f", $self->amount). "\n",
223         'date: '. time2str("%a %b %e %T %Y", $self->_date). "\n",
224         'reason: '. $self->reason. "\n",
225       ],
226     );
227
228     if ( $error ) {
229       $dbh->rollback if $oldAutoCommit;
230       return "can't send credit deletion notification: $error";
231     }
232
233   }
234
235   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
236
237   '';
238
239 }
240
241 =item replace OLD_RECORD
242
243 You can, but probably shouldn't modify credits... 
244
245 =cut
246
247 sub replace {
248   #return "Can't modify credit!"
249   my $self = shift;
250   return "Can't modify closed credit" if $self->closed =~ /^Y/i;
251   $self->SUPER::replace(@_);
252 }
253
254 =item check
255
256 Checks all fields to make sure this is a valid credit.  If there is an error,
257 returns the error, otherwise returns false.  Called by the insert and replace
258 methods.
259
260 =cut
261
262 sub check {
263   my $self = shift;
264
265   $self->otaker(getotaker) unless ($self->otaker);
266
267   my $error =
268     $self->ut_numbern('crednum')
269     || $self->ut_number('custnum')
270     || $self->ut_numbern('_date')
271     || $self->ut_money('amount')
272     || $self->ut_alpha('otaker')
273     || $self->ut_textn('reason')
274     || $self->ut_foreign_key('reasonnum', 'reason', 'reasonnum')
275     || $self->ut_enum('closed', [ '', 'Y' ])
276   ;
277   return $error if $error;
278
279   return "amount must be > 0 " if $self->amount <= 0;
280
281   return "Unknown customer"
282     unless qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
283
284   $self->_date(time) unless $self->_date;
285
286   $self->SUPER::check;
287 }
288
289 =item cust_refund
290
291 Depreciated.  See the cust_credit_refund method.
292
293 #Returns all refunds (see L<FS::cust_refund>) for this credit.
294
295 =cut
296
297 sub cust_refund {
298   use Carp;
299   croak "FS::cust_credit->cust_pay depreciated; see ".
300         "FS::cust_credit->cust_credit_refund";
301   #my $self = shift;
302   #sort { $a->_date <=> $b->_date }
303   #  qsearch( 'cust_refund', { 'crednum' => $self->crednum } )
304   #;
305 }
306
307 =item cust_credit_refund
308
309 Returns all refund applications (see L<FS::cust_credit_refund>) for this credit.
310
311 =cut
312
313 sub cust_credit_refund {
314   my $self = shift;
315   sort { $a->_date <=> $b->_date }
316     qsearch( 'cust_credit_refund', { 'crednum' => $self->crednum } )
317   ;
318 }
319
320 =item cust_credit_bill
321
322 Returns all application to invoices (see L<FS::cust_credit_bill>) for this
323 credit.
324
325 =cut
326
327 sub cust_credit_bill {
328   my $self = shift;
329   sort { $a->_date <=> $b->_date }
330     qsearch( 'cust_credit_bill', { 'crednum' => $self->crednum } )
331   ;
332 }
333
334 =item unapplied
335
336 Returns the amount of this credit that is still unapplied/outstanding; 
337 amount minus all refund applications (see L<FS::cust_credit_refund>) and
338 applications to invoices (see L<FS::cust_credit_bill>).
339
340 =cut
341
342 sub unapplied {
343   my $self = shift;
344   my $amount = $self->amount;
345   $amount -= $_->amount foreach ( $self->cust_credit_refund );
346   $amount -= $_->amount foreach ( $self->cust_credit_bill );
347   sprintf( "%.2f", $amount );
348 }
349
350 =item credited
351
352 Deprecated name for the unapplied method.
353
354 =cut
355
356 sub credited {
357   my $self = shift;
358   #carp "cust_credit->credited deprecated; use ->unapplied";
359   $self->unapplied(@_);
360 }
361
362 =item cust_main
363
364 Returns the customer (see L<FS::cust_main>) for this credit.
365
366 =cut
367
368 sub cust_main {
369   my $self = shift;
370   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
371 }
372
373
374 =item reason
375
376 Returns the text of the associated reason (see L<FS::reason>) for this credit.
377
378 =cut
379
380 sub reason {
381   my ($self, $value, %options) = @_;
382   my $dbh = dbh;
383   my $reason;
384   my $typenum = $options{'reason_type'};
385
386   my $oldAutoCommit = $FS::UID::AutoCommit;  # this should already be in
387   local $FS::UID::AutoCommit = 0;            # a transaction if it matters
388
389   if ( defined( $value ) ) {
390     my $hashref = { 'reason' => $value };
391     $hashref->{'reason_type'} = $typenum if $typenum;
392     my $addl_from = "LEFT JOIN reason_type ON ( reason_type = typenum ) ";
393     my $extra_sql = " AND reason_type.class='R'"; 
394
395     $reason = qsearchs( { 'table'     => 'reason',
396                           'hashref'   => $hashref,
397                           'addl_from' => $addl_from,
398                           'extra_sql' => $extra_sql,
399                        } );
400
401     if (!$reason && $typenum) {
402       $reason = new FS::reason( { 'reason_type' => $typenum,
403                                   'reason' => $value,
404                                   'disabled' => 'Y', 
405                               } );
406       $reason->insert and $reason = undef;
407     }
408
409     $self->reasonnum($reason ? $reason->reasonnum : '') ;
410     warn "$me reason used in set mode with non-existant reason -- clearing"
411       unless $reason;
412   }
413   $reason = qsearchs( 'reason', { 'reasonnum' => $self->reasonnum } );
414
415   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
416
417   $reason ? $reason->reason : '';
418 }
419
420 # _upgrade_data
421 #
422 # Used by FS::Upgrade to migrate to a new database.
423 #
424 #
425
426 sub _upgrade_data {  # class method
427   my ($class, %opts) = @_;
428
429   warn "$me upgrading $class\n" if $DEBUG;
430
431   if (defined dbdef->table($class->table)->column('reason')) {
432
433     warn "$me Checking for unmigrated reasons\n" if $DEBUG;
434
435     my @cust_credits = qsearch({ 'table'     => $class->table,
436                                  'hashref'   => {},
437                                  'extra_sql' => 'WHERE reason IS NOT NULL',
438                               });
439
440     if (scalar(grep { $_->getfield('reason') =~ /\S/ } @cust_credits)) {
441       warn "$me Found unmigrated reasons\n" if $DEBUG;
442       my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
443       my $reason_type = qsearchs( 'reason_type', $hashref );
444       unless ($reason_type) {
445         $reason_type  = new FS::reason_type( $hashref );
446         my $error   = $reason_type->insert();
447         die "$class had error inserting FS::reason_type into database: $error\n"
448           if $error;
449       }
450
451       $hashref = { 'reason_type' => $reason_type->typenum,
452                    'reason' => '(none)'
453                  };
454       my $noreason = qsearchs( 'reason', $hashref );
455       unless ($noreason) {
456         $hashref->{'disabled'} = 'Y';
457         $noreason = new FS::reason( $hashref );
458         my $error  = $noreason->insert();
459         die "can't insert legacy reason '(none)' into database: $error\n"
460           if $error;
461       }
462
463       foreach my $cust_credit ( @cust_credits ) {
464         my $reason = $cust_credit->getfield('reason');
465         warn "Contemplating reason $reason\n" if $DEBUG > 1;
466         if ($reason =~ /\S/) {
467           $cust_credit->reason($reason, 'reason_type' => $reason_type->typenum)
468             or die "can't insert legacy reason $reason into database\n";
469         }else{
470           $cust_credit->reasonnum($noreason->reasonnum);
471         }
472
473         $cust_credit->setfield('reason', '');
474         my $error = $cust_credit->replace;
475
476         warn "*** WARNING: error replacing reason in $class ".
477              $cust_credit->crednum. ": $error ***\n"
478           if $error;
479       }
480     }
481
482     warn "$me Ensuring existance of auto reasons\n" if $DEBUG;
483
484     foreach ( keys %reasontype_map ) {
485       unless ($conf->config($_)) {       # hmmmm
486 #       warn "$me Found $_ reason type lacking\n" if $DEBUG;
487 #       my $hashref = { 'class' => 'R', 'type' => $reasontype_map{$_} };
488         my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
489         my $reason_type = qsearchs( 'reason_type', $hashref );
490         unless ($reason_type) {
491           $reason_type  = new FS::reason_type( $hashref );
492           my $error   = $reason_type->insert();
493           die "$class had error inserting FS::reason_type into database: $error\n"
494             if $error;
495         }
496         $conf->set($_, $reason_type->typenum);
497       }
498     }
499
500     warn "$me Ensuring commission packages have a reason type\n" if $DEBUG;
501
502     my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
503     my $reason_type = qsearchs( 'reason_type', $hashref );
504     unless ($reason_type) {
505       $reason_type  = new FS::reason_type( $hashref );
506       my $error   = $reason_type->insert();
507       die "$class had error inserting FS::reason_type into database: $error\n"
508         if $error;
509     }
510
511     my @plans = qw( flat_comission flat_comission_cust flat_comission_pkg );
512     foreach my $plan ( @plans ) {
513       foreach my $pkg ( qsearch('part_pkg', { 'plan' => $plan } ) ) {
514         unless ($pkg->option('reason_type', 1) ) { 
515           my $plandata = $pkg->plandata.
516                         "reason_type=". $reason_type->typenum. "\n";
517           $pkg->plandata($plandata);
518           my $error =
519             $pkg->replace( undef,
520                            'pkg_svc' => { map { $_->svcpart => $_->quantity }
521                                           $pkg->pkg_svc
522                                         },
523                            'primary_svc' => $pkg->svcpart,
524                          );
525             die "failed setting reason_type option: $error"
526               if $error;
527         }
528       }
529     }
530   }
531
532   '';
533
534 }
535
536 =back
537
538 =head1 CLASS METHODS
539
540 =over 4
541
542 =item unapplied_sql
543
544 Returns an SQL fragment to retreive the unapplied amount.
545
546 =cut
547
548 sub unapplied_sql {
549   #my $class = shift;
550
551   "amount
552         - COALESCE(
553                     ( SELECT SUM(amount) FROM cust_credit_refund
554                         WHERE cust_credit.crednum = cust_credit_refund.crednum )
555                     ,0
556                   )
557         - COALESCE(
558                     ( SELECT SUM(amount) FROM cust_credit_bill
559                         WHERE cust_credit.crednum = cust_credit_bill.crednum )
560                     ,0
561                   )
562   ";
563
564 }
565
566 =item credited_sql
567
568 Deprecated name for the unapplied_sql method.
569
570 =cut
571
572 sub credited_sql {
573   #my $class = shift;
574
575   #carp "cust_credit->credited_sql deprecated; use ->unapplied_sql";
576
577   #$class->unapplied_sql(@_);
578   unapplied_sql();
579 }
580
581 =back
582
583 =head1 BUGS
584
585 The delete method.  The replace method.
586
587 B<credited> and B<credited_sql> are now called B<unapplied> and
588 B<unapplied_sql>.  The old method names should start to give warnings.
589
590 =head1 SEE ALSO
591
592 L<FS::Record>, L<FS::cust_credit_refund>, L<FS::cust_refund>,
593 L<FS::cust_credit_bill> L<FS::cust_bill>, schema.html from the base
594 documentation.
595
596 =cut
597
598 1;
599