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