RT# 83450 - fixed rateplan export
[freeside.git] / FS / FS / part_export / portaone.pm
1 package FS::part_export::portaone;
2
3 use strict;
4
5 use base qw( FS::part_export );
6
7 use Date::Format 'time2str';
8 use Cpanel::JSON::XS;
9 use Net::HTTPS::Any qw(https_post);
10
11 use FS::Conf;
12
13 =pod
14
15 =head1 NAME
16
17 FS::part_export::portaone
18
19 =head1 SYNOPSIS
20
21 PortaOne integration for Freeside
22
23 =head1 DESCRIPTION
24
25 This export offers basic svc_phone provisioning for PortaOne.
26
27 This module also provides generic methods for working through the L</PortaOne API>.
28
29 =cut
30
31 use vars qw( %info );
32
33 tie my %options, 'Tie::IxHash',
34   'username'         => { label => 'User Name',
35                           default => '' },
36   'password'         => { label => 'Password',
37                           default => '' },
38   'port'             => { label => 'Port',
39                           default => 443 },
40   'customer_name'    => { label => 'Customer Name',
41                           default => 'FREESIDE CUST $custnum' },
42   'account_id'       => { label => 'Account ID',
43                           default => 'SVC$svcnum' },
44   'product_id'       => { label => 'Account Product ID' },
45   'debug'            => { type => 'checkbox',
46                           label => 'Enable debug warnings' },
47 ;
48
49 %info = (
50   'svc'             => 'svc_phone',
51   'desc'            => 'Export customer and service/account to PortaOne',
52   'options'         => \%options,
53   'notes'           => <<'END',
54 During insert, this will add customers to portaone if they do not yet exist,
55 using the "Customer Name" option with substitutions from the customer record 
56 in freeside.  If options "Account ID" and "Account Product ID" are also specified,
57 an account will be created for the service and assigned to the customer, using 
58 substitutions from the phone service record in freeside for the Account ID.
59
60 During replace, if a matching account id for the old service can be found,
61 the existing customer and account will be updated.  Otherwise, if a matching 
62 customer name is found, the info for that customer will be updated.  
63 Otherwise, nothing will be updated during replace.
64
65 Use caution to avoid name/id conflicts when introducing this export to a portaone 
66 system with existing customers/accounts.
67 END
68 );
69
70 ### NOTE:  If we provision DIDs, conflicts with existing data and changes
71 ### to the name/id scheme will be non-issues, as we can load DID by number 
72 ### and then load account/customer from there, but provisioning DIDs has
73 ### not yet been implemented....
74
75 sub _export_insert {
76   my ($self, $svc_phone) = @_;
77
78   # load needed info from our end
79   my $cust_main = $svc_phone->cust_main;
80   return "Could not load service customer" unless $cust_main;
81   my $conf = new FS::Conf;
82
83   # make sure customer name is configured
84   my $customer_name = $self->portaone_customer_name($cust_main);
85   return "No customer name configured, nothing to export"
86     unless $customer_name;
87
88   # initialize api session
89   $self->api_login;
90   return $self->api_error if $self->api_error;
91
92   # check if customer already exists
93   my $customer_info = $self->api_call('Customer','get_customer_info',{
94     'name' => $customer_name,
95   },'customer_info');
96   my $i_customer = $customer_info ? $customer_info->{'i_customer'} : undef;
97
98   # insert customer (using name => custnum) if customer with that name/custnum doesn't exist
99   #   has the possibility of creating duplicates if customer was previously hand-entered,
100   #   could check if customer has existing services on our end, load customer from one of those
101   #   but...not right now
102   unless ($i_customer) {
103     $i_customer = $self->api_call('Customer','add_customer',{
104       'customer_info' => {
105         'name' => $customer_name,
106         'iso_4217' => ($conf->config('currency') || 'USD'),
107       }
108     },'i_customer');
109     return $self->api_error_logout if $self->api_error;
110     unless ($i_customer) {
111       $self->api_logout;
112       return "Error creating customer";
113     }
114   }
115
116   # export account if account id is configured
117   my $account_id = $self->portaone_account_id($svc_phone);
118   my $product_id = $self->option('product_id');
119   if ($account_id && $product_id) {
120     # check if account already exists
121     my $account_info = $self->api_call('Account','get_account_info',{
122       'id' => $account_id,
123     },'account_info');
124
125     my $i_account;
126     if ($account_info) {
127       # there shouldn't be any time account already exists on insert,
128       # but if custnum matches, should be safe to run with it
129       unless ($account_info->{'i_customer'} eq $i_customer) {
130         $self->api_logout;
131         return "Account $account_id already exists";
132       }
133       $i_account = $account_info->{'i_account'};
134     } else {
135       # normal case--insert account for this service
136       $i_account = $self->api_call('Account','add_account',{
137         'account_info' => {
138           'id' => $account_id,
139           'i_customer' => $i_customer,
140           'iso_4217' => ($conf->config('currency') || 'USD'),
141           'i_product' => $product_id,
142           'activation_date' => time2str("%Y-%m-%d",time),
143           'billing_model'   => 1, # '1' for credit, '-1' for debit, could make this an export option
144           'h323_password'   => $svc_phone->sip_password,
145         }
146       },'i_account');
147       return $self->api_error_logout if $self->api_error;
148     }
149     unless ($i_account) {
150       $self->api_logout;
151       return "Error creating account";
152     }
153   }
154
155   # update customer, including name
156   $self->api_update_customer($i_customer,$cust_main);
157   return $self->api_error_logout if $self->api_error;
158
159   # end api session
160   return $self->api_logout;
161 }
162
163 sub _export_replace {
164   my ($self, $svc_phone, $svc_phone_old) = @_;
165
166   # load needed info from our end
167   my $cust_main = $svc_phone->cust_main;
168   return "Could not load service customer" unless $cust_main;
169   my $conf = new FS::Conf;
170
171   # initialize api session
172   $self->api_login;
173   return $self->api_error if $self->api_error;
174
175   # if we ever provision DIDs, we should load from DID rather than account
176
177   # check for existing account
178   my $account_id = $self->portaone_account_id($svc_phone_old);
179   my $account_info = $self->api_call('Account','get_account_info',{
180     'id' => $account_id,
181   },'account_info');
182   my $i_account = $account_info ? $account_info->{'i_account'} : undef;
183
184   # if account exists, use account customer
185   my $i_customer;
186   if ($account_info) {
187     $i_account  = $account_info->{'i_account'};
188     $i_customer = $account_info->{'i_customer'};
189     # if nothing changed, no need to update account
190     $i_account = undef
191       if ($account_info->{'i_product'} eq $self->option('product_id'))
192          && ($account_id eq $self->portaone_account_id($svc_phone));
193   # otherwise, check for existing customer
194   } else {
195     my $customer_name = $self->portaone_customer_name($cust_main);
196     my $customer_info = $self->api_call('Customer','get_customer_info',{
197       'name' => $customer_name,
198     },'customer_info');
199     $i_customer = $customer_info ? $customer_info->{'i_customer'} : undef;
200   }
201
202   unless ($i_customer) {
203     $self->api_logout;
204     return "Neither customer nor account found in portaone";
205   }
206
207   # update customer info
208   $self->api_update_customer($i_customer,$cust_main) if $i_customer;
209   return $self->api_error_logout if $self->api_error;
210
211   # update account info
212   $self->api_update_account($i_account,$svc_phone) if $i_account;
213   return $self->api_error_logout if $self->api_error;
214
215   # end api session
216   return $self->api_logout();
217 }
218
219 sub _export_delete {
220   my ($self, $svc_phone) = @_;
221   return '';
222 }
223
224 sub _export_suspend {
225   my ($self, $svc_phone) = @_;
226   return '';
227 }
228
229 sub _export_unsuspend {
230   my ($self, $svc_phone) = @_;
231   return '';
232 }
233
234 =head1 PortaOne API
235
236 These methods allow access to the PortaOne API using the credentials
237 set in the export options.
238
239         $export->api_login;
240         die $export->api_error if $export->api_error;
241
242         my $customer_info = $export->api_call('Customer','get_customer_info',{
243       'name' => $export->portaone_customer_name($cust_main),
244     },'customer_info');
245         die $export->api_error_logout if $export->api_error;
246
247         return $export->api_logout;
248
249 =cut
250
251 =head2 api_call
252
253 Accepts I<$service>, I<$method>, I<$params> hashref and optional
254 I<$returnfield>.  Places an api call to the specified service
255 and method with the specified params.  Returns the decoded json
256 object returned by the api call.  If I<$returnfield> is specified,
257 returns only that field of the decoded object, and errors out if
258 that field does not exist.  Returns empty on failure;  retrieve
259 error messages using L</api_error>.
260
261 Must run L</api_login> first.
262
263 =cut
264
265 sub api_call {
266   my ($self,$service,$method,$params,$returnfield) = @_;
267   $self->{'__portaone_error'} = '';
268   my $auth_info = $self->{'__portaone_auth_info'};
269   my %auth_info = $auth_info ? ('auth_info' => encode_json($auth_info)) : ();
270   $params ||= {};
271   print "Calling $service/$method\n" if $self->option('debug');
272   my ( $page, $response, %reply_headers ) = https_post(
273     'host'    => $self->machine,
274     'port'    => $self->option('port'),
275     'path'    => '/rest/'.$service.'/'.$method.'/',
276     'args'    => [ %auth_info, 'params' => encode_json($params) ],
277   );
278   if (($response eq '200 OK') || ($response =~ /^500/)) {
279     my $result;
280     eval { $result = decode_json($page) };
281     unless ($result) {
282       $self->{'__portaone_error'} = "Error decoding json: $@";
283       return;
284     }
285     if ($response eq '200 OK') {
286       return $result unless $returnfield;
287       unless (exists $result->{$returnfield}) {
288         $self->{'__portaone_error'} = "Field $returnfield not returned during $service/$method";
289         return;
290       }
291       return $result->{$returnfield};
292     }
293     if ($result->{'faultcode'}) {
294       $self->{'__portaone_error'} = 
295         "Server returned error during $service/$method: ".$result->{'faultstring'};
296       return;
297     }
298   }
299   $self->{'__portaone_error'} = 
300     "Bad response from server during $service/$method: $response";
301   return;
302 }
303
304 =head2 api_error
305
306 Returns the error string set by L</PortaOne API> methods,
307 or a blank string if most recent call produced no errors.
308
309 =cut
310
311 sub api_error {
312   my $self = shift;
313   return $self->{'__portaone_error'} || '';
314 }
315
316 =head2 api_error_logout
317
318 Attempts L</api_logout>, but returns L</api_error> message from
319 before logout was attempted.  Useful for logging out
320 properly after an error.
321
322 =cut
323
324 sub api_error_logout {
325   my $self = shift;
326   my $error = $self->api_error;
327   $self->api_logout;
328   return $error;
329 }
330
331 =head2 api_login
332
333 Initializes an api session using the credentials for this export.
334 Always returns empty.  Retrieve error messages using L</api_error>.
335
336 =cut
337
338 sub api_login {
339   my $self = shift;
340   $self->{'__portaone_auth_info'} = undef;  # needs to be declared undef for api_call
341   my $result = $self->api_call('Session','login',{
342     'login'    => $self->option('username'),
343     'password' => $self->option('password'),
344   });
345   return unless $result;
346   $self->{'__portaone_auth_info'} = $result;
347   return;
348 }
349
350 =head2 api_logout
351
352 Ends the current api session established by L</api_login>.
353
354 For convenience, returns L</api_error>.
355
356 =cut
357
358 sub api_logout {
359   my $self = shift;
360   $self->api_call('Session','logout',$self->{'__portaone_auth_info'});
361   return $self->api_error;
362 }
363
364 =head2 api_update_account
365
366 Accepts I<$i_account> and I<$svc_phone>.  Updates the account
367 specified by I<$i_account> with the current values of I<$svc_phone>
368 (currently only updates account_id.)
369 Always returns empty.  Retrieve error messages using L</api_error>.
370
371 =cut
372
373 sub api_update_account {
374   my ($self,$i_account,$svc_phone) = @_;
375   my $newid = $self->portaone_account_id($svc_phone);
376   unless ($newid) {
377     $self->{'__portaone_error'} = "Error loading account id during update_account";
378     return;
379   }
380   my $updated_account = $self->api_call('Account','update_account',{
381     'account_info' => {
382       'i_account' => $i_account,
383       'id' => $newid,
384       'i_product' => $self->option('product_id'),
385       'h323_password' => $svc_phone->sip_password,
386     },
387   },'i_account');
388   return if $self->api_error;
389   $self->{'__portaone_error'} = "Account updated, but account id mismatch detected"
390     unless $updated_account eq $i_account; # should never happen
391   return;
392 }
393
394 =head2 api_update_customer
395
396 Accepts I<$i_customer> and I<$cust_main>.  Updates the customer
397 specified by I<$i_customer> with the current values of I<$cust_main>.
398 Always returns empty.  Retrieve error messages using L</api_error>.
399
400 =cut
401
402 sub api_update_customer {
403   my ($self,$i_customer,$cust_main) = @_;
404   my $location = $cust_main->bill_location;
405   unless ($location) {
406     $self->{'__portaone_error'} = "Could not load customer location";
407     return;
408   }
409   my $newname = $self->portaone_customer_name($cust_main);
410   unless ($newname) {
411     $self->{'__portaone_error'} = "Error loading customer name during update_customer";
412     return;
413   }
414   my $updated_customer = $self->api_call('Customer','update_customer',{
415     'customer_info' => {
416       'i_customer' => $i_customer,
417       'name' => $newname,
418       'companyname' => $cust_main->company,
419       'firstname' => $cust_main->first,
420       'lastname' => $cust_main->last,
421       'baddr1' => $location->address1,
422       'baddr2' => $location->address2,
423       'city' => $location->city,
424       'state' => $location->state,
425       'zip' => $location->zip,
426       'country' => $location->country,
427       # could also add contact phones & email here
428     },
429   },'i_customer');
430   return if $self->api_error;
431   $self->{'__portaone_error'} = "Customer updated, but custnum mismatch detected"
432     unless $updated_customer eq $i_customer; # should never happen
433   return;
434 }
435
436 sub _substitute {
437   my ($self, $string, @objects) = @_;
438   return '' unless $string;
439   foreach my $object (@objects) {
440     next unless $object;
441     foreach my $field ($object->fields) {
442       next unless $field;
443       my $value = $object->get($field);
444       $string =~ s/\$$field/$value/g;
445     }
446   }
447   # strip leading/trailing whitespace
448   $string =~ s/^\s//g;
449   $string =~ s/\s$//g;
450   return $string;
451 }
452
453 =head2 portaone_customer_name
454
455 Accepts I<$cust_main> and returns customer name with substitutions.
456
457 =cut
458
459 sub portaone_customer_name {
460   my ($self, $cust_main) = @_;
461   $self->_substitute($self->option('customer_name'),$cust_main);
462 }
463
464 =head2 portaone_account_id
465
466 Accepts I<$svc_phone> and returns account id with substitutions.
467
468 =cut
469
470 sub portaone_account_id {
471   my ($self, $svc_phone) = @_;
472   $self->_substitute($self->option('account_id'),$svc_phone);
473 }
474
475 =head1 SEE ALSO
476
477 L<FS::part_export>
478
479 =head1 AUTHOR
480
481 Jonathan Prykop 
482 jonathan@freeside.biz
483
484 =cut
485
486 1;
487
488