agent-virtualize credit card surcharge percentage, RT#72961
[freeside.git] / FS / FS / part_export / a2billing.pm
1 package FS::part_export::a2billing;
2
3 use strict;
4 use vars qw(@ISA @EXPORT_OK $DEBUG %info %options);
5 use Exporter;
6 use Tie::IxHash;
7 use FS::Record qw( qsearch qsearchs str2time_sql );
8 use FS::part_export;
9 use FS::svc_acct;
10 use FS::svc_phone;
11 use Locale::Country qw(country_code2code);
12 use Date::Format qw(time2str);
13 use Carp qw( cluck );
14
15 @ISA = qw(FS::part_export);
16
17 $DEBUG = 0;
18
19 tie %options, 'Tie::IxHash',
20   'datasrc'     => { label=>'DBI data source ' },
21   'username'    => { label=>'Database username' },
22   'password'    => { label=>'Database password' },
23   'didgroup'    => { label=>'DID group ID', default=>1 },
24   'credit'      => { label=>'Default credit limit' },
25   'billtype'    => {label=>'Billing type',
26                     type => 'select',
27                     options => ['Dial Out Rate', 'Free']
28                   },
29   'debug'       => { label=>'Enable debugging', type=>'checkbox' }
30 ;
31
32 my $notes = <<'END';
33 <p>Real-time export to the backend database of an <a
34 href="http://www.asterisk2billing.org">Asterisk2Billing</a> billing server.
35 This is both a svc_acct and a svc_phone export, and needs to be attached 
36 to both a svc_acct and svc_phone definition within the same package.</p>
37 <ul>
38 <li>When you set up this export, it will create 'svcnum' fields in the 
39 cc_card and cc_did tables in the A2Billing database to store the 
40 service numbers of svc_acct and svc_phone records.  The database username 
41 must have ALTER TABLE privileges.</li>
42 <li><i>DBI data source</i> should look like<br>
43 <b>dbi:mysql:host=</b><i>hostname</i><b>;database=</b><i>dbname</i>
44 </li>
45 END
46
47 %info = (
48   'svc'      => ['svc_acct', 'svc_phone'],
49   'desc'     => 'Export to Asterisk2Billing database',
50   'options'  => \%options,
51   'nodomain' => 'Y',
52   'no_machine' => 1,
53   'notes'    => $notes
54 );
55
56 sub dbh {
57   my $self = shift;
58   $self->{dbh} ||= DBI->connect(
59                       $self->option('datasrc'),
60                       $self->option('username'),
61                       $self->option('password')
62                       ) or die $DBI::errstr;
63
64   $self->{dbh}->trace(1, '%%%FREESIDE_LOG%%%/a2b_exportlog.'.$self->exportnum)
65     if $DEBUG;
66
67   $self->{dbh};
68 }
69
70 # hook insert/replace, because we need to make some changes to the
71 # database when the export is created
72 sub insert {
73   my $self = shift;
74   my $error = $self->SUPER::insert(@_);
75   return $error if $error;
76   if ( $self->option('datasrc') ) {
77     my $error;
78     foreach (qw(cc_card cc_did)) {
79       $self->dbh->do("ALTER TABLE $_ ADD COLUMN svcnum int")
80         or $error = $self->dbh->errstr;
81       $error = '' if $error =~ /Duplicate column name/; # harmless
82       return "Error preparing a2billing database: $error\n" if $error;
83     }
84   }
85   '';
86 }
87
88 sub replace {
89   my $new = shift;
90   my $old = shift || $new->replace_old;
91   my $old_datasrc = $old->option('datasrc');
92   my $error = $new->SUPER::replace($old, @_);
93   return $error if $error;
94
95   if ($new->option('datasrc') and $new->option('datasrc') ne $old_datasrc) {
96     my $dbh = $new->a2b_connect;
97     my $error;
98     foreach (qw(cc_card cc_did)) {
99       $new->dbh->do("ALTER TABLE $_ ADD COLUMN svcnum int")
100         or $error = $new->dbh->errstr;
101       $error = '' if $error =~ /Duplicate column name/; # harmless
102       return "Error preparing a2billing database: $error\n" if $error;
103     }
104   }
105   '';
106 }
107
108 sub export_insert {
109   my $self = shift;
110   my $svc = shift;
111   my $cust_pkg = $svc->cust_svc->cust_pkg;
112   my $cust_main = $cust_pkg->cust_main;
113   my $location = $cust_pkg->cust_location;
114   my $part_pkg = $cust_pkg->part_pkg;
115
116   my $error;
117   $DEBUG ||= $self->option('debug');
118
119   # 3-letter UN country code
120   my $country3 = uc(country_code2code($location->country, 'alpha2' => 'alpha3'));
121   
122   my $dbh = $self->a2b_connect;
123
124   if ( $svc->isa('FS::svc_acct') ) {
125     # export to cc_card (customer identity) and cc_sip_buddies (SIP extension)
126
127     my $username = $svc->username;
128
129     my %cc_card = (
130       svcnum    => $svc->svcnum,
131       username  => $username,
132       useralias => $username,
133       uipass    => $svc->_password,
134       creditlimit    => $cust_main->credit_limit || $self->option('credit') || 0,
135       tariff    => $part_pkg->option('a2billing_tariff'),
136       status    => 1,
137       lastname  => $cust_main->last, # $svc->finger?
138       firstname => $cust_main->first,
139       address   => $location->address1 .
140                   ($location->address2 ? ', '.$location->address2 : ''),
141       city      => $location->city,
142       state     => $location->state,
143       country   => $country3,
144       zipcode   => $location->zip,
145       simultaccess  => $part_pkg->option('a2billing_simultaccess'),
146       typepaid  => $part_pkg->option('a2billing_type'),
147       email_notification => $cust_main->invoicing_list_emailonly_scalar,
148       notify_email => ($cust_main->invoicing_list_emailonly_scalar ? 1 : 0),
149       credit_notification => $cust_main->credit_limit || $self->option('credit') || 0,
150       sip_buddy => 1,
151       company_name => $cust_main->company,
152       activated => 't',
153     );
154     warn "creating A2B cc_card record for $username\n" if $DEBUG;
155     $error = $self->a2b_insert_or_replace('cc_card', 'svcnum', \%cc_card);
156     return "Error creating A2Billing customer identity: $error" if $error;
157     
158     my $fullcontact = '';
159     if ( $svc->ip_addr ) {
160       $fullcontact = "sip:$username\@".$svc->ip_addr;
161     }
162
163     my $cc_card_id = $self->a2b_find('cc_card', 'svcnum', $svc->svcnum);
164     # these are the fields we know about; some of them might need to be 
165     # export options eventually, and there are a lot more fields in the table
166     my %cc_sip_buddy = (
167       id_cc_card      => $cc_card_id,
168       name            => $username,
169       accountcode     => $username,
170       regexten        => $username,
171       amaflags        => 'billing',
172       context         => 'a2billing',
173       host            => 'dynamic',
174       port            => 5060,
175       secret          => $svc->_password,
176       username        => $username,
177       allow           => 'ulaw,alaw,gsm,g729',
178       ipaddr          => ($svc->slipip || ''),
179       fullcontact     => $fullcontact,
180     );
181     warn "creating A2B cc_sip_buddies record for $username\n" if $DEBUG;
182     $error = $self->a2b_insert_or_replace('cc_sip_buddies', 'id_cc_card',
183                                           \%cc_sip_buddy);
184     return "Error creating A2Billing SIP extension: $error" if $error;
185
186     # then, if there are any DIDs on the package, set them up
187     foreach ( $self->_linked_svcs($svc, 'svc_phone') ) {
188       warn "triggering export of svc_phone #".$_->svcnum."\n" if $DEBUG;
189       $error = $self->export_insert($_->svc_x);
190       return $error if $error;
191     }
192     return '';
193
194   } elsif ( $svc->isa('FS::svc_phone') ) {
195     # find the linked svc_acct
196     my $svc_acct;
197     foreach ($self->_linked_svcs($svc, 'svc_acct')) {
198       $svc_acct = $_->svc_x;
199       last;
200     }
201     if ( !$svc_acct ) {
202       # it hasn't been created yet, so just exit.
203       # this service will be exported later.
204       warn "no linked svc_acct; deferring phone number export\n" if $DEBUG;
205       return '';
206     }
207     # find the card and sip_buddies records
208     my $cc_card_id = $self->a2b_find('cc_card', 'svcnum', $svc_acct->svcnum);
209     my $cc_sip_buddies_id = $self->a2b_find('cc_sip_buddies', 'id_cc_card', $cc_card_id);
210     if (!$cc_card_id or !$cc_sip_buddies_id) {
211       warn "When exporting svc_phone #".$svc->svcnum.", svc_acct #".$svc_acct->svcnum." was not found in A2Billing.\n";
212       if ( $FS::svc_Common::noexport_hack ) {
213         # recursion protection
214         return "During export of linked DID#".$svc->phonenum.", svc_acct #".$svc_acct->svcnum." was not found in A2Billing.";
215       }
216       return $svc_acct->export_insert; # which will call back to here when 
217                                        # it's done
218     }
219
220     # Create the DID.
221     my $cc_country_id = $self->a2b_find('cc_country', 'countrycode', $country3);
222     my %cc_did = (
223       svcnum          => $svc->svcnum,
224       id_cc_didgroup  => $self->option('didgroup'),
225       id_cc_country   => $cc_country_id,
226       iduser          => $cc_card_id,
227       did             => $svc->countrycode. $svc->phonenum,
228       billingtype     => ($self->option('billtype') eq 'Dial Out Rate' ? 2 : 3),
229       activated       => 1,
230       aleg_carrier_cost_min_offp  => $part_pkg->option('a2billing_carrier_cost_min'),
231       aleg_carrier_initblock_offp => $part_pkg->option('a2billing_carrier_initblock_offp'),
232       aleg_carrier_increment_offp => $part_pkg->option('a2billing_carrier_increment_offp'),
233       aleg_retail_cost_min_offp   => $part_pkg->option('a2billing_retail_cost_min_offp'),
234       aleg_retail_initblock_offp  => $part_pkg->option('a2billing_retail_initblock_offp'),
235       aleg_retail_increment_offp  => $part_pkg->option('a2billing_retail_increment_offp'),
236     );
237
238     # use 'did' as the key here so that if the DID already exists, we 
239     # link it to this customer.
240     $error = $self->a2b_insert_or_replace('cc_did', 'did', \%cc_did);
241     return "Error creating A2Billing DID record: $error" if $error;
242
243     my $cc_did_id = $self->a2b_find('cc_did', 'svcnum', $svc->svcnum);
244     
245     my $destination = 'SIP/user-'. $svc_acct->username. '@'. $svc->sip_server. "!". $svc->countrycode. $svc->phonenum;
246     my %cc_did_destination = (
247       destination     => $destination,
248       priority        => 1,
249       id_cc_card      => $cc_card_id,
250       id_cc_did       => $cc_did_id,
251       validated       => 1,
252       voip_call       => 1,
253     );
254
255     # and if there's already a destination, change it to point to
256     # this customer's SIP extension
257     $error = $self->a2b_insert_or_replace('cc_did_destination', 'id_cc_did',
258                                           \%cc_did_destination);
259     return "Error linking A2Billing DID record to customer: $error" if $error;
260
261     my %cc_did_use = (
262       id_cc_card      => $cc_card_id,
263       id_did          => $cc_did_id,
264       activated       => 1,
265       month_payed     => 1, # it's the default in the A2Billing code, I think
266     );
267     # and change the in-use record, too
268     my $id_use = $self->a2b_find('cc_did_use',
269       id_did          => $cc_did_id,
270       activated       => 1,
271     );
272     if ( $id_use ) {
273       $error = $self->a2b_insert_or_replace('cc_did_use', 'id',
274         { id          => $id_use,
275           releasedate => time2str('%Y-%m-%d %H:%M:%S', time),
276           activated   => 0
277         }
278       );
279       return "Error closing existing A2Billing DID assignment record: $error"
280         if $error;
281
282       # and do an update instead of an insert
283       $cc_did_use{id} = $id_use;
284     }
285
286     $error = $self->a2b_insert_or_replace('cc_did_use', 'id', \%cc_did_use);
287     return "Error creating A2Billing DID use record: $error" if $error;
288
289   } # if $svc->isa(...)
290   '';
291 }
292
293 sub export_delete {
294   my $self = shift;
295   my $svc = shift;
296
297   my $error;
298   $DEBUG ||= $self->option('debug');
299
300   if ( $svc->isa('FS::svc_acct') ) {
301
302     # first remove the DID links
303     foreach ($self->_linked_svcs($svc, 'svc_phone')) {
304       warn "triggering export of svc_phone #".$_->svcnum."\n" if $DEBUG;
305       $error = $self->export_delete($_->svc_x);
306       return $error if $error;
307     }
308
309     # a2billing never deletes a card, just sets status = 0.
310     # though we also need to remove the svcnum, since that svcnum is no 
311     # longer valid.
312     my $cc_card_id = $self->a2b_find('cc_card', 'svcnum', $svc->svcnum);
313     if (!$cc_card_id) {
314       warn "tried to remove svc_acct #".$svc->svcnum." from A2Billing, but couldn't find it.\n";
315       # which is not really a problem.
316       return '';
317     }
318     warn "deactivating A2B cc_card record #$cc_card_id\n" if $DEBUG;
319     $error = $self->a2b_insert_or_replace('cc_card', 'id', {
320         id        => $cc_card_id,
321         status    => 0,
322         activated => 0,
323         svcnum    => 0,
324     });
325     return $error if $error;
326
327   } elsif ( $svc->isa('FS::svc_phone') ) {
328
329     my $cc_did_id = $self->a2b_find('cc_did', 'svcnum', $svc->svcnum);
330     if ( $cc_did_id ) {
331       warn "deactivating DID ".$svc->phonenum."\n" if $DEBUG;
332       $error = $self->a2b_insert_or_replace('cc_did', 'id',
333         { id        => $cc_did_id,
334           activated => 0,
335           iduser    => 0,
336           svcnum    => 0,
337         }
338       );
339       return $error if $error;
340     } else {
341       warn "tried to remove svc_phone #".$svc->svcnum." from A2Billing, but couldn't find it.\n";
342       return '';
343     }
344
345     my $cc_did_destination_id = $self->a2b_find('cc_did_destination',
346       'id_cc_did', $cc_did_id,
347       'activated', 1
348     );
349     if ( $cc_did_destination_id ) {
350       warn "unlinking DID ".$svc->phonenum." from customer\n" if $DEBUG;
351       $error = $self->a2b_delete('cc_did_destination', $cc_did_destination_id);
352       return $error if $error;
353     } else {
354       warn "no cc_did_destination found for cc_did #$cc_did_id\n";
355     }
356     
357     my $cc_did_use_id = $self->a2b_find('cc_did_use',
358       'id_did', $cc_did_id,
359       'activated', 1
360     );
361     if ( $cc_did_use_id ) {
362       warn "closing DID assignment\n" if $DEBUG;
363       $error = $self->a2b_insert_or_replace('cc_did_use', 'id',
364         { id          => $cc_did_use_id,
365           releasedate => time2str('%Y-%m-%d %H:%M:%S', time),
366           activated   => 0
367         }
368       );
369       return "Error closing existing A2Billing DID assignment record: $error"
370         if $error;
371     } else {
372       warn "no cc_did_use found for cc_did #$cc_did_id\n";
373     }
374
375   }
376   '';
377 }
378
379 sub export_replace {
380   my $self = shift;
381   my $new = shift;
382   my $old = shift || $self->replace_old;
383
384   my $error;
385   $DEBUG ||= $self->option('debug');
386
387   if ( $new->isa('FS::svc_acct') ) {
388
389     my $cc_card_id = $self->a2b_find('cc_card', 'svcnum', $new->svcnum);
390     if ( $cc_card_id and $new->username ne $old->username ) {
391       # If the username is changing and any DIDs are provisioned, we need to 
392       # change their destinations.  To do this, we unlink them.  This will 
393       # close their did_use records, delete their cc_did_destinations, and 
394       # set their cc_dids to inactive.
395       foreach ($self->_linked_svcs($new, 'svc_phone')) {
396         warn "triggering export of svc_phone #".$_->svcnum."\n" if $DEBUG;
397         $error = $self->export_delete($_->svc_x);
398         return $error if $error;
399       }
400     }
401
402     # export_insert will replace the record with the same svcnum, if there 
403     # is one, and then re-export all existing DIDs (which is convenient since
404     # we just unlinked them).
405     $error = $self->export_insert($new);
406     return $error if $error;
407
408   } elsif ( $new->isa('FS::svc_phone') ) {
409
410     # if the phone number has changed, need to create a new DID.
411     if ( $new->phonenum ne $old->phonenum || $new->countrycode ne $old->countrycode ) {
412       # deactivate/unlink/close the old DID
413       # and create/link the new one
414       $error = $self->export_delete($old)
415             || $self->export_insert($new);
416       return $error if $error;
417     }
418     # otherwise we don't care
419   }
420
421   '';
422 }
423
424 sub export_suspend {
425   my $self = shift;
426   my $svc = shift;
427
428   my $error;
429   $DEBUG ||= $self->option('debug');
430
431   if ( $svc->isa('FS::svc_acct') ) {
432     $error = $self->a2b_insert_or_replace('cc_card', 'svcnum',
433       { svcnum    => $svc->svcnum,
434         status    => 6, # "SUSPENDED FOR UNDERPAYMENT"
435         activated => 0, # still used in some places, grrr
436       }
437     );
438   } elsif ( $svc->isa('FS::svc_phone') ) {
439     # deactivate the DID
440     $error = $self->a2b_insert_or_replace('cc_did', 'svcnum',
441       { svcnum    => $svc->svcnum,
442         activated => 0,
443       }
444     );
445   }
446   $error || '';
447 }
448
449 sub export_unsuspend {
450   my $self = shift;
451   my $svc = shift;
452
453   my $error;
454   $DEBUG ||= $self->option('debug');
455
456   if ( $svc->isa('FS::svc_acct') ) {
457     $error = $self->a2b_insert_or_replace('cc_card', 'svcnum',
458       { svcnum    => $svc->svcnum,
459         status    => 1, #"ACTIVE"
460         activated => 1,
461       }
462     );
463   } elsif ( $svc->isa('FS::svc_phone') ) {
464     $error = $self->a2b_insert_or_replace('cc_did', 'svcnum',
465       { svcnum    => $svc->svcnum,
466         activated => 1,
467       }
468     );
469   }
470   $error || '';
471 }
472
473 =item a2b_insert_or_replace TABLE KEY HASHREF
474
475 Create a record in TABLE with the values in HASHREF.  If there's already one 
476 that matches on the KEY field, update the existing record instead of creating
477 a new one.  Pass an empty KEY to just insert the record without checking.
478
479 =cut
480
481 sub a2b_insert_or_replace {
482   my $self = shift;
483   my $table = shift;
484   my $key = shift;
485   my $hashref = shift;
486
487   if ( $key ) {
488     my $id = $self->a2b_find($table, $key, $hashref->{$key});
489     if ( $id ) {
490       my $sql = "UPDATE $table SET " .
491                 join(', ', map { "$_ = ?" } keys(%$hashref)) .
492                 " WHERE id = ?";
493       $self->dbh->do($sql, {}, values(%$hashref), $id)
494         or return $self->dbh->errstr;
495       return '';
496     }
497   }
498   # no key, or no existing record
499   my $sql = "INSERT INTO $table (".  join(', ', keys(%$hashref)) . ")" .
500             " VALUES (" . join(', ', map { '?' } keys(%$hashref)) . ")";
501   $self->dbh->do($sql, {}, values(%$hashref))
502     or return $self->dbh->errstr;
503   return '';
504 }
505
506 =item a2b_delete TABLE ID
507
508 Remove the record with id ID from TABLE.
509
510 =cut
511
512 sub a2b_delete {
513   my $self = shift;
514   my ($table, $id) = @_;
515   my $sql = "DELETE FROM $table WHERE id = ?";
516   $self->dbh->do($sql, {}, $id)
517     or return $self->dbh->errstr;
518   return '';
519 }
520
521 =item a2b_find TABLE KEY VALUE [ KEY VALUE ... ]
522
523 Search TABLE for a row where KEY equals VALUE, and return its "id" field.
524
525 =cut
526
527 sub a2b_find {
528   my $self = shift;
529   my ($table, %params) = @_;
530   my $sql = "SELECT id FROM $table WHERE " .
531     join(' AND ', map { "$_ = ?" } keys(%params));
532   my ($id) = $self->dbh->selectrow_array($sql, {}, values(%params));
533   die $self->dbh->errstr if $self->dbh->errstr;
534   $id || '';
535 }
536
537 # find services on the same package that are exportable with this export
538 # and are of a specified svcdb
539 #
540 # just to avoid repeating myself
541 sub _linked_svcs {
542   my ($self, $svc, $svcdb) = @_;
543   # index the svcparts that belong to the a2billing export
544   my $export_svcparts = $self->{export_svcparts} ||= 
545     { map { $_->svcpart => $_->part_svc->svcdb }
546       $self->export_svc
547     };
548
549   my $pkgnum = $svc->cust_svc->pkgnum;
550   my @svcs = qsearch('cust_svc', { pkgnum => $pkgnum });
551   grep { $export_svcparts->{$_->svcpart} eq $svcdb } @svcs;
552 }
553
554 1;