underscoring the important
[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 credited
335
336 Returns the amount of this credit that is still outstanding; which is
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 credited {
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 cust_main
351
352 Returns the customer (see L<FS::cust_main>) for this credit.
353
354 =cut
355
356 sub cust_main {
357   my $self = shift;
358   qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
359 }
360
361
362 =item reason
363
364 Returns the text of the associated reason (see L<FS::reason>) for this credit.
365
366 =cut
367
368 sub reason {
369   my ($self, $value, %options) = @_;
370   my $dbh = dbh;
371   my $reason;
372   my $typenum = $options{'reason_type'};
373
374   my $oldAutoCommit = $FS::UID::AutoCommit;  # this should already be in
375   local $FS::UID::AutoCommit = 0;            # a transaction if it matters
376
377   if ( defined( $value ) ) {
378     my $hashref = { 'reason' => $value };
379     $hashref->{'reason_type'} = $typenum if $typenum;
380     my $addl_from = "LEFT JOIN reason_type ON ( reason_type = typenum ) ";
381     my $extra_sql = " AND reason_type.class='R'"; 
382
383     $reason = qsearchs( { 'table'     => 'reason',
384                           'hashref'   => $hashref,
385                           'addl_from' => $addl_from,
386                           'extra_sql' => $extra_sql,
387                        } );
388
389     if (!$reason && $typenum) {
390       $reason = new FS::reason( { 'reason_type' => $typenum,
391                                   'reason' => $value,
392                                   'disabled' => 'Y', 
393                               } );
394       $reason->insert and $reason = undef;
395     }
396
397     $self->reasonnum($reason ? $reason->reasonnum : '') ;
398     warn "$me reason used in set mode with non-existant reason -- clearing"
399       unless $reason;
400   }
401   $reason = qsearchs( 'reason', { 'reasonnum' => $self->reasonnum } );
402
403   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
404
405   $reason ? $reason->reason : '';
406 }
407
408 # _upgrade_data
409 #
410 # Used by FS::Upgrade to migrate to a new database.
411 #
412 #
413
414 sub _upgrade_data {  # class method
415   my ($class, %opts) = @_;
416
417   warn "$me upgrading $class\n" if $DEBUG;
418
419   if (defined dbdef->table($class->table)->column('reason')) {
420
421     warn "$me Checking for unmigrated reasons\n" if $DEBUG;
422
423     my @cust_credits = qsearch({ 'table'     => $class->table,
424                                  'hashref'   => {},
425                                  'extra_sql' => 'WHERE reason IS NOT NULL',
426                               });
427
428     if (scalar(grep { $_->getfield('reason') =~ /\S/ } @cust_credits)) {
429       warn "$me Found unmigrated reasons\n" if $DEBUG;
430       my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
431       my $reason_type = qsearchs( 'reason_type', $hashref );
432       unless ($reason_type) {
433         $reason_type  = new FS::reason_type( $hashref );
434         my $error   = $reason_type->insert();
435         die "$class had error inserting FS::reason_type into database: $error\n"
436           if $error;
437       }
438
439       $hashref = { 'reason_type' => $reason_type->typenum,
440                    'reason' => '(none)'
441                  };
442       my $noreason = qsearchs( 'reason', $hashref );
443       unless ($noreason) {
444         $hashref->{'disabled'} = 'Y';
445         $noreason = new FS::reason( $hashref );
446         my $error  = $noreason->insert();
447         die "can't insert legacy reason '(none)' into database: $error\n"
448           if $error;
449       }
450
451       foreach my $cust_credit ( @cust_credits ) {
452         my $reason = $cust_credit->getfield('reason');
453         warn "Contemplating reason $reason\n" if $DEBUG > 1;
454         if ($reason =~ /\S/) {
455           $cust_credit->reason($reason, 'reason_type' => $reason_type->typenum)
456             or die "can't insert legacy reason $reason into database\n";
457         }else{
458           $cust_credit->reasonnum($noreason->reasonnum);
459         }
460
461         $cust_credit->setfield('reason', '');
462         my $error = $cust_credit->replace;
463
464         warn "*** WARNING: error replacing reason in $class ".
465              $cust_credit->crednum. ": $error ***\n"
466           if $error;
467       }
468     }
469
470     warn "$me Ensuring existance of auto reasons\n" if $DEBUG;
471
472     foreach ( keys %reasontype_map ) {
473       unless ($conf->config($_)) {       # hmmmm
474 #       warn "$me Found $_ reason type lacking\n" if $DEBUG;
475 #       my $hashref = { 'class' => 'R', 'type' => $reasontype_map{$_} };
476         my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
477         my $reason_type = qsearchs( 'reason_type', $hashref );
478         unless ($reason_type) {
479           $reason_type  = new FS::reason_type( $hashref );
480           my $error   = $reason_type->insert();
481           die "$class had error inserting FS::reason_type into database: $error\n"
482             if $error;
483         }
484         $conf->set($_, $reason_type->typenum);
485       }
486     }
487
488     warn "$me Ensuring commission packages have a reason type\n" if $DEBUG;
489
490     my $hashref = { 'class' => 'R', 'type' => 'Legacy' };
491     my $reason_type = qsearchs( 'reason_type', $hashref );
492     unless ($reason_type) {
493       $reason_type  = new FS::reason_type( $hashref );
494       my $error   = $reason_type->insert();
495       die "$class had error inserting FS::reason_type into database: $error\n"
496         if $error;
497     }
498
499     my @plans = qw( flat_comission flat_comission_cust flat_comission_pkg );
500     foreach my $plan ( @plans ) {
501       foreach my $pkg ( qsearch('part_pkg', { 'plan' => $plan } ) ) {
502         unless ($pkg->option('reason_type', 1) ) { 
503           my $plandata = $pkg->plandata.
504                         "reason_type=". $reason_type->typenum. "\n";
505           $pkg->plandata($plandata);
506           my $error =
507             $pkg->replace( undef,
508                            'pkg_svc' => { map { $_->svcpart => $_->quantity }
509                                           $pkg->pkg_svc
510                                         },
511                            'primary_svc' => $pkg->svcpart,
512                          );
513             die "failed setting reason_type option: $error"
514               if $error;
515         }
516       }
517     }
518   }
519
520   '';
521
522 }
523
524 =back
525
526 =head1 CLASS METHODS
527
528 =over 4
529
530 =item credited_sql
531
532 Returns an SQL fragment to retreive the unapplied amount.
533
534 =cut
535
536 sub credited_sql {
537   #my $class = shift;
538
539   "amount
540         - COALESCE(
541                     ( SELECT SUM(amount) FROM cust_credit_refund
542                         WHERE cust_credit.crednum = cust_credit_refund.crednum )
543                     ,0
544                   )
545         - COALESCE(
546                     ( SELECT SUM(amount) FROM cust_credit_bill
547                         WHERE cust_credit.crednum = cust_credit_bill.crednum )
548                     ,0
549                   )
550   ";
551
552 }
553
554 =back
555
556 =head1 BUGS
557
558 The delete method.  The replace method.
559
560 B<credited> and B<credited_sql> should probably be called B<unapplied> and
561 B<unapplied_sql>.
562
563 =head1 SEE ALSO
564
565 L<FS::Record>, L<FS::cust_credit_refund>, L<FS::cust_refund>,
566 L<FS::cust_credit_bill> L<FS::cust_bill>, schema.html from the base
567 documentation.
568
569 =cut
570
571 1;
572