RT#40056 Export DIDs to portaone switch [activation_date format fix]
[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 => 'FREESIDE 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         }
145       },'i_account');
146       return $self->api_error_logout if $self->api_error;
147     }
148     unless ($i_account) {
149       $self->api_logout;
150       return "Error creating account";
151     }
152   }
153
154   # update customer, including name
155   $self->api_update_customer($i_customer,$cust_main);
156   return $self->api_error_logout if $self->api_error;
157
158   # end api session
159   return $self->api_logout;
160 }
161
162 sub _export_replace {
163   my ($self, $svc_phone, $svc_phone_old) = @_;
164
165   # load needed info from our end
166   my $cust_main = $svc_phone->cust_main;
167   return "Could not load service customer" unless $cust_main;
168   my $conf = new FS::Conf;
169
170   # initialize api session
171   $self->api_login;
172   return $self->api_error if $self->api_error;
173
174   # if we ever provision DIDs, we should load from DID rather than account
175
176   # check for existing account
177   my $account_id = $self->portaone_account_id($svc_phone_old);
178   my $account_info = $self->api_call('Account','get_account_info',{
179     'id' => $account_id,
180   },'account_info');
181   my $i_account = $account_info ? $account_info->{'i_account'} : undef;
182
183   # if account exists, use account customer
184   my $i_customer;
185   if ($account_info) {
186     $i_account  = $account_info->{'i_account'};
187     $i_customer = $account_info->{'i_customer'};
188     # if nothing changed, no need to update account
189     $i_account = undef
190       if ($account_info->{'i_product'} eq $self->option('product_id'))
191          && ($account_id eq $self->portaone_account_id($svc_phone));
192   # otherwise, check for existing customer
193   } else {
194     my $customer_name = $self->portaone_customer_name($cust_main);
195     my $customer_info = $self->api_call('Customer','get_customer_info',{
196       'name' => $customer_name,
197     },'customer_info');
198     $i_customer = $customer_info ? $customer_info->{'i_customer'} : undef;
199   }
200
201   unless ($i_customer) {
202     $self->api_logout;
203     return "Neither customer nor account found in portaone";
204   }
205
206   # update customer info
207   $self->api_update_customer($i_customer,$cust_main) if $i_customer;
208   return $self->api_error_logout if $self->api_error;
209
210   # update account info
211   $self->api_update_account($i_account,$svc_phone) if $i_account;
212   return $self->api_error_logout if $self->api_error;
213
214   # end api session
215   return $self->api_logout();
216 }
217
218 sub _export_delete {
219   my ($self, $svc_phone) = @_;
220   return '';
221 }
222
223 sub _export_suspend {
224   my ($self, $svc_phone) = @_;
225   return '';
226 }
227
228 sub _export_unsuspend {
229   my ($self, $svc_phone) = @_;
230   return '';
231 }
232
233 =head1 PortaOne API
234
235 These methods allow access to the PortaOne API using the credentials
236 set in the export options.
237
238         $export->api_login;
239         die $export->api_error if $export->api_error;
240
241         my $customer_info = $export->api_call('Customer','get_customer_info',{
242       'name' => $export->portaone_customer_name($cust_main),
243     },'customer_info');
244         die $export->api_error_logout if $export->api_error;
245
246         return $export->api_logout;
247
248 =cut
249
250 =head2 api_call
251
252 Accepts I<$service>, I<$method>, I<$params> hashref and optional
253 I<$returnfield>.  Places an api call to the specified service
254 and method with the specified params.  Returns the decoded json
255 object returned by the api call.  If I<$returnfield> is specified,
256 returns only that field of the decoded object, and errors out if
257 that field does not exist.  Returns empty on failure;  retrieve
258 error messages using L</api_error>.
259
260 Must run L</api_login> first.
261
262 =cut
263
264 sub api_call {
265   my ($self,$service,$method,$params,$returnfield) = @_;
266   $self->{'__portaone_error'} = '';
267   my $auth_info = $self->{'__portaone_auth_info'};
268   my %auth_info = $auth_info ? ('auth_info' => encode_json($auth_info)) : ();
269   $params ||= {};
270   print "Calling $service/$method\n" if $self->option('debug');
271   my ( $page, $response, %reply_headers ) = https_post(
272     'host'    => $self->machine,
273     'port'    => $self->option('port'),
274     'path'    => '/rest/'.$service.'/'.$method.'/',
275     'args'    => [ %auth_info, 'params' => encode_json($params) ],
276   );
277   if (($response eq '200 OK') || ($response =~ /^500/)) {
278     my $result;
279     eval { $result = decode_json($page) };
280     unless ($result) {
281       $self->{'__portaone_error'} = "Error decoding json: $@";
282       return;
283     }
284     if ($response eq '200 OK') {
285       return $result unless $returnfield;
286       unless (exists $result->{$returnfield}) {
287         $self->{'__portaone_error'} = "Field $returnfield not returned during $service/$method";
288         return;
289       }
290       return $result->{$returnfield};
291     }
292     if ($result->{'faultcode'}) {
293       $self->{'__portaone_error'} = 
294         "Server returned error during $service/$method: ".$result->{'faultstring'};
295       return;
296     }
297   }
298   $self->{'__portaone_error'} = 
299     "Bad response from server during $service/$method: $response";
300   return;
301 }
302
303 =head2 api_error
304
305 Returns the error string set by L</PortaOne API> methods,
306 or a blank string if most recent call produced no errors.
307
308 =cut
309
310 sub api_error {
311   my $self = shift;
312   return $self->{'__portaone_error'} || '';
313 }
314
315 =head2 api_error_logout
316
317 Attempts L</api_logout>, but returns L</api_error> message from
318 before logout was attempted.  Useful for logging out
319 properly after an error.
320
321 =cut
322
323 sub api_error_logout {
324   my $self = shift;
325   my $error = $self->api_error;
326   $self->api_logout;
327   return $error;
328 }
329
330 =head2 api_login
331
332 Initializes an api session using the credentials for this export.
333 Always returns empty.  Retrieve error messages using L</api_error>.
334
335 =cut
336
337 sub api_login {
338   my $self = shift;
339   $self->{'__portaone_auth_info'} = undef;  # needs to be declared undef for api_call
340   my $result = $self->api_call('Session','login',{
341     'login'    => $self->option('username'),
342     'password' => $self->option('password'),
343   });
344   return unless $result;
345   $self->{'__portaone_auth_info'} = $result;
346   return;
347 }
348
349 =head2 api_logout
350
351 Ends the current api session established by L</api_login>.
352
353 For convenience, returns L</api_error>.
354
355 =cut
356
357 sub api_logout {
358   my $self = shift;
359   $self->api_call('Session','logout',$self->{'__portaone_auth_info'});
360   return $self->api_error;
361 }
362
363 =head2 api_update_account
364
365 Accepts I<$i_account> and I<$svc_phone>.  Updates the account
366 specified by I<$i_account> with the current values of I<$svc_phone>
367 (currently only updates account_id.)
368 Always returns empty.  Retrieve error messages using L</api_error>.
369
370 =cut
371
372 sub api_update_account {
373   my ($self,$i_account,$svc_phone) = @_;
374   my $newid = $self->portaone_account_id($svc_phone);
375   unless ($newid) {
376     $self->{'__portaone_error'} = "Error loading account id during update_account";
377     return;
378   }
379   my $updated_account = $self->api_call('Account','update_account',{
380     'account_info' => {
381       'i_account' => $i_account,
382       'id' => $newid,
383       'i_product' => $self->option('product_id'),
384     },
385   },'i_account');
386   return if $self->api_error;
387   $self->{'__portaone_error'} = "Account updated, but account id mismatch detected"
388     unless $updated_account eq $i_account; # should never happen
389   return;
390 }
391
392 =head2 api_update_customer
393
394 Accepts I<$i_customer> and I<$cust_main>.  Updates the customer
395 specified by I<$i_customer> with the current values of I<$cust_main>.
396 Always returns empty.  Retrieve error messages using L</api_error>.
397
398 =cut
399
400 sub api_update_customer {
401   my ($self,$i_customer,$cust_main) = @_;
402   my $location = $cust_main->bill_location;
403   unless ($location) {
404     $self->{'__portaone_error'} = "Could not load customer location";
405     return;
406   }
407   my $newname = $self->portaone_customer_name($cust_main);
408   unless ($newname) {
409     $self->{'__portaone_error'} = "Error loading customer name during update_customer";
410     return;
411   }
412   my $updated_customer = $self->api_call('Customer','update_customer',{
413     'customer_info' => {
414       'i_customer' => $i_customer,
415       'name' => $newname,
416       'companyname' => $cust_main->company,
417       'firstname' => $cust_main->first,
418       'lastname' => $cust_main->last,
419       'baddr1' => $location->address1,
420       'baddr2' => $location->address2,
421       'city' => $location->city,
422       'state' => $location->state,
423       'zip' => $location->zip,
424       'country' => $location->country,
425       # could also add contact phones & email here
426     },
427   },'i_customer');
428   return if $self->api_error;
429   $self->{'__portaone_error'} = "Customer updated, but custnum mismatch detected"
430     unless $updated_customer eq $i_customer; # should never happen
431   return;
432 }
433
434 sub _substitute {
435   my ($self, $string, @objects) = @_;
436   return '' unless $string;
437   foreach my $object (@objects) {
438     next unless $object;
439     foreach my $field ($object->fields) {
440       next unless $field;
441       my $value = $object->get($field);
442       $string =~ s/\$$field/$value/g;
443     }
444   }
445   # strip leading/trailing whitespace
446   $string =~ s/^\s//g;
447   $string =~ s/\s$//g;
448   return $string;
449 }
450
451 =head2 portaone_customer_name
452
453 Accepts I<$cust_main> and returns customer name with substitutions.
454
455 =cut
456
457 sub portaone_customer_name {
458   my ($self, $cust_main) = @_;
459   $self->_substitute($self->option('customer_name'),$cust_main);
460 }
461
462 =head2 portaone_account_id
463
464 Accepts I<$svc_phone> and returns account id with substitutions.
465
466 =cut
467
468 sub portaone_account_id {
469   my ($self, $svc_phone) = @_;
470   $self->_substitute($self->option('account_id'),$svc_phone);
471 }
472
473 =head1 SEE ALSO
474
475 L<FS::part_export>
476
477 =head1 AUTHOR
478
479 Jonathan Prykop 
480 jonathan@freeside.biz
481
482 =cut
483
484 1;
485
486