1 package FS::part_export::portaone;
5 use base qw( FS::part_export );
7 use Date::Format 'time2str';
9 use Net::HTTPS::Any qw(https_post);
17 FS::part_export::portaone
21 PortaOne integration for Freeside
25 This export offers basic svc_phone provisioning for PortaOne.
27 This module also provides generic methods for working through the L</PortaOne API>.
33 tie my %options, 'Tie::IxHash',
34 'username' => { label => 'User Name',
36 'password' => { label => 'Password',
38 'port' => { label => 'Port',
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' },
51 'desc' => 'Export customer and service/account to PortaOne',
52 'options' => \%options,
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.
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.
65 Use caution to avoid name/id conflicts when introducing this export to a portaone
66 system with existing customers/accounts.
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....
76 my ($self, $svc_phone) = @_;
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;
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;
88 # initialize api session
90 return $self->api_error if $self->api_error;
92 # check if customer already exists
93 my $customer_info = $self->api_call('Customer','get_customer_info',{
94 'name' => $customer_name,
96 my $i_customer = $customer_info ? $customer_info->{'i_customer'} : undef;
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',{
105 'name' => $customer_name,
106 'iso_4217' => ($conf->config('currency') || 'USD'),
109 return $self->api_error_logout if $self->api_error;
110 unless ($i_customer) {
112 return "Error creating customer";
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',{
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) {
131 return "Account $account_id already exists";
133 $i_account = $account_info->{'i_account'};
135 # normal case--insert account for this service
136 $i_account = $self->api_call('Account','add_account',{
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
146 return $self->api_error_logout if $self->api_error;
148 unless ($i_account) {
150 return "Error creating account";
154 # update customer, including name
155 $self->api_update_customer($i_customer,$cust_main);
156 return $self->api_error_logout if $self->api_error;
159 return $self->api_logout;
162 sub _export_replace {
163 my ($self, $svc_phone, $svc_phone_old) = @_;
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;
170 # initialize api session
172 return $self->api_error if $self->api_error;
174 # if we ever provision DIDs, we should load from DID rather than account
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',{
181 my $i_account = $account_info ? $account_info->{'i_account'} : undef;
183 # if account exists, use account customer
186 $i_account = $account_info->{'i_account'};
187 $i_customer = $account_info->{'i_customer'};
188 # if nothing changed, no need to update account
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
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,
198 $i_customer = $customer_info ? $customer_info->{'i_customer'} : undef;
201 unless ($i_customer) {
203 return "Neither customer nor account found in portaone";
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;
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;
215 return $self->api_logout();
219 my ($self, $svc_phone) = @_;
223 sub _export_suspend {
224 my ($self, $svc_phone) = @_;
228 sub _export_unsuspend {
229 my ($self, $svc_phone) = @_;
235 These methods allow access to the PortaOne API using the credentials
236 set in the export options.
239 die $export->api_error if $export->api_error;
241 my $customer_info = $export->api_call('Customer','get_customer_info',{
242 'name' => $export->portaone_customer_name($cust_main),
244 die $export->api_error_logout if $export->api_error;
246 return $export->api_logout;
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>.
260 Must run L</api_login> first.
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)) : ();
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) ],
277 if (($response eq '200 OK') || ($response =~ /^500/)) {
279 eval { $result = decode_json($page) };
281 $self->{'__portaone_error'} = "Error decoding json: $@";
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";
290 return $result->{$returnfield};
292 if ($result->{'faultcode'}) {
293 $self->{'__portaone_error'} =
294 "Server returned error during $service/$method: ".$result->{'faultstring'};
298 $self->{'__portaone_error'} =
299 "Bad response from server during $service/$method: $response";
305 Returns the error string set by L</PortaOne API> methods,
306 or a blank string if most recent call produced no errors.
312 return $self->{'__portaone_error'} || '';
315 =head2 api_error_logout
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.
323 sub api_error_logout {
325 my $error = $self->api_error;
332 Initializes an api session using the credentials for this export.
333 Always returns empty. Retrieve error messages using L</api_error>.
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'),
344 return unless $result;
345 $self->{'__portaone_auth_info'} = $result;
351 Ends the current api session established by L</api_login>.
353 For convenience, returns L</api_error>.
359 $self->api_call('Session','logout',$self->{'__portaone_auth_info'});
360 return $self->api_error;
363 =head2 api_update_account
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>.
372 sub api_update_account {
373 my ($self,$i_account,$svc_phone) = @_;
374 my $newid = $self->portaone_account_id($svc_phone);
376 $self->{'__portaone_error'} = "Error loading account id during update_account";
379 my $updated_account = $self->api_call('Account','update_account',{
381 'i_account' => $i_account,
383 'i_product' => $self->option('product_id'),
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
392 =head2 api_update_customer
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>.
400 sub api_update_customer {
401 my ($self,$i_customer,$cust_main) = @_;
402 my $location = $cust_main->bill_location;
404 $self->{'__portaone_error'} = "Could not load customer location";
407 my $newname = $self->portaone_customer_name($cust_main);
409 $self->{'__portaone_error'} = "Error loading customer name during update_customer";
412 my $updated_customer = $self->api_call('Customer','update_customer',{
414 'i_customer' => $i_customer,
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
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
435 my ($self, $string, @objects) = @_;
436 return '' unless $string;
437 foreach my $object (@objects) {
439 foreach my $field ($object->fields) {
441 my $value = $object->get($field);
442 $string =~ s/\$$field/$value/g;
445 # strip leading/trailing whitespace
451 =head2 portaone_customer_name
453 Accepts I<$cust_main> and returns customer name with substitutions.
457 sub portaone_customer_name {
458 my ($self, $cust_main) = @_;
459 $self->_substitute($self->option('customer_name'),$cust_main);
462 =head2 portaone_account_id
464 Accepts I<$svc_phone> and returns account id with substitutions.
468 sub portaone_account_id {
469 my ($self, $svc_phone) = @_;
470 $self->_substitute($self->option('account_id'),$svc_phone);
480 jonathan@freeside.biz