6 use FS::Record qw( qsearch qsearchs );
17 FS::API - Freeside backend API
24 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
27 my $xmlrpc = new Frontier::Client url=>$url;
29 my $result = $xmlrpc->call( 'FS.API.customer_info',
30 'secret' => 'sharingiscaring',
34 print Dumper($result);
38 This module implements a backend API for advanced back-office integration.
40 In contrast to the self-service API, which authenticates an end-user and offers
41 functionality to that end user, the backend API performs a simple shared-secret
42 authentication and offers full, administrator functionality, enabling
43 integration with other back-office systems. Only access this API from a secure
44 network from other backoffice machines. DON'T use this API to create customer
47 If accessing this API remotely with XML-RPC or JSON-RPC, be careful to block
48 the port by default, only allow access from back-office servers with the same
49 security precations as the Freeside server, and encrypt the communication
50 channel (for example, with an SSH tunnel or VPN) rather than accessing it
57 =item insert_payment OPTION => VALUE, ...
59 Adds a new payment to a customers account. Takes a list of keys and values as
60 paramters with the following keys:
82 Option date for payment
92 my $result = FS::API->insert_payment(
93 'secret' => 'sharingiscaring',
99 '_date' => 1397977200, #UNIX timestamp
100 'order_number' => '12345',
103 if ( $result->{'error'} ) {
104 die $result->{'error'};
106 #payment was inserted
107 print "paynum ". $result->{'paynum'};
114 my($class, %opt) = @_;
115 return _shared_secret_error() unless _check_shared_secret($opt{secret});
117 #less "raw" than this? we are the backoffice API, and aren't worried
118 # about version migration ala cust_main/cust_location here
119 my $cust_pay = new FS::cust_pay { %opt };
120 my $error = $cust_pay->insert( 'manual'=>1 );
121 return { 'error' => $error,
122 'paynum' => $cust_pay->paynum,
126 # pass the phone number ( from svc_phone )
127 sub insert_payment_phonenum {
128 my($class, %opt) = @_;
129 $class->_by_phonenum('insert_payment', %opt);
133 my($class, $method, %opt) = @_;
134 return _shared_secret_error() unless _check_shared_secret($opt{secret});
136 my $phonenum = delete $opt{'phonenum'};
138 my $svc_phone = qsearchs('svc_phone', { 'phonenum' => $phonenum } )
139 or return { 'error' => 'Unknown phonenum' };
141 my $cust_pkg = $svc_phone->cust_svc->cust_pkg
142 or return { 'error' => 'Unlinked phonenum' };
144 $opt{'custnum'} = $cust_pkg->custnum;
146 $class->$method(%opt);
149 =item insert_credit OPTION => VALUE, ...
151 Adds a a credit to a customers account. Takes a list of keys and values as
152 parameters with the following keys
170 The date the credit will be posted
176 my $result = FS::API->insert_credit(
177 'secret' => 'sharingiscaring',
182 '_date' => 1397977200, #UNIX timestamp
185 if ( $result->{'error'} ) {
186 die $result->{'error'};
189 print "crednum ". $result->{'crednum'};
196 my($class, %opt) = @_;
197 return _shared_secret_error() unless _check_shared_secret($opt{secret});
199 $opt{'reasonnum'} ||= FS::Conf->new->config('api_credit_reason');
201 #less "raw" than this? we are the backoffice API, and aren't worried
202 # about version migration ala cust_main/cust_location here
203 my $cust_credit = new FS::cust_credit { %opt };
204 my $error = $cust_credit->insert;
205 return { 'error' => $error,
206 'crednum' => $cust_credit->crednum,
210 # pass the phone number ( from svc_phone )
211 sub insert_credit_phonenum {
212 my($class, %opt) = @_;
213 $class->_by_phonenum('insert_credit', %opt);
216 =item apply_payments_and_credits
218 Applies payments and credits for this customer. Takes a list of keys and
219 values as parameter with the following keys:
235 #apply payments and credits
236 sub apply_payments_and_credits {
237 my($class, %opt) = @_;
238 return _shared_secret_error() unless _check_shared_secret($opt{secret});
240 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
241 or return { 'error' => 'Unknown custnum' };
243 my $error = $cust_main->apply_payments_and_credits( 'manual'=>1 );
244 return { 'error' => $error, };
247 =item insert_refund OPTION => VALUE, ...
249 Adds a a credit to a customers account. Takes a list of keys and values as
250 parmeters with the following keys: custnum, payby, refund
254 my $result = FS::API->insert_refund(
255 'secret' => 'sharingiscaring',
261 '_date' => 1397977200, #UNIX timestamp
264 if ( $result->{'error'} ) {
265 die $result->{'error'};
268 print "refundnum ". $result->{'crednum'};
275 my($class, %opt) = @_;
276 return _shared_secret_error() unless _check_shared_secret($opt{secret});
278 # when github pull request #24 is merged,
279 # will have to change over to default reasonnum like credit
280 # but until then, this will do
281 $opt{'reason'} ||= 'API refund';
283 #less "raw" than this? we are the backoffice API, and aren't worried
284 # about version migration ala cust_main/cust_location here
285 my $cust_refund = new FS::cust_refund { %opt };
286 my $error = $cust_refund->insert;
287 return { 'error' => $error,
288 'refundnum' => $cust_refund->refundnum,
292 # pass the phone number ( from svc_phone )
293 sub insert_refund_phonenum {
294 my($class, %opt) = @_;
295 $class->_by_phonenum('insert_refund', %opt);
300 # "2 way syncing" ? start with non-sync pulling info here, then if necessary
301 # figure out how to trigger something when those things change
303 # long-term: package changes?
305 =item new_customer OPTION => VALUE, ...
307 Creates a new customer. Takes a list of keys and values as parameters with the
318 first name (required)
326 (not typically collected; mostly used for ACH transactions)
332 =item address1 (required)
336 =item city (required)
344 =item state (required)
366 Currently used for third party tax vendor lookups
370 Used for determining FCC 477 reporting
374 Used for determining FCC 477 reporting
394 comma-separated list of email addresses for email invoices. The special value 'POST' is used to designate postal invoicing (it may be specified alone or in addition to email addresses),
396 Set to 1 to enable postal invoicing
398 =item referral_custnum
400 Referring customer number
412 Agent specific customer number
414 =item referral_custnum
416 Referring customer number
422 #certainly false laziness w/ClientAPI::Signup new_customer/new_customer_minimal
423 # but approaching this from a clean start / back-office perspective
424 # i.e. no package/service, no immediate credit card run, etc.
427 my( $class, %opt ) = @_;
428 return _shared_secret_error() unless _check_shared_secret($opt{secret});
430 #default agentnum like signup_server-default_agentnum?
431 #$opt{agentnum} ||= $conf->config('signup_server-default_agentnum');
433 #same for refnum like signup_server-default_refnum
434 $opt{refnum} ||= FS::Conf->new->config('signup_server-default_refnum');
436 $class->API_insert( %opt );
439 =item update_customer
441 Updates an existing customer. Passing an empty value clears that field, while
442 NOT passing that key/value at all leaves it alone. Takes a list of keys and
443 values as parameters with the following keys:
449 API Secret (required)
453 Customer number (required)
509 Comma-separated list of email addresses for email invoices. The special value
510 'POST' is used to designate postal invoicing (it may be specified alone or in
511 addition to email addresses),
513 Set to 1 to enable postal invoicing
515 =item referral_custnum
517 Referring customer number
531 sub update_customer {
532 my( $class, %opt ) = @_;
533 return _shared_secret_error() unless _check_shared_secret($opt{secret});
535 FS::cust_main->API_update( %opt );
538 =item customer_info OPTION => VALUE, ...
540 Returns general customer information. Takes a list of keys and values as
541 parameters with the following keys: custnum, secret
545 use Frontier::Client;
548 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
551 my $xmlrpc = new Frontier::Client url=>$url;
553 my $result = $xmlrpc->call( 'FS.API.customer_info',
554 'secret' => 'sharingiscaring',
558 print Dumper($result);
563 my( $class, %opt ) = @_;
564 return _shared_secret_error() unless _check_shared_secret($opt{secret});
566 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
567 or return { 'error' => 'Unknown custnum' };
569 $cust_main->API_getinfo;
572 =item customer_list_svcs OPTION => VALUE, ...
574 Returns customer service information. Takes a list of keys and values as
575 parameters with the following keys: custnum, secret
579 use Frontier::Client;
582 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
585 my $xmlrpc = new Frontier::Client url=>$url;
587 my $result = $xmlrpc->call( 'FS.API.customer_list_svcs',
588 'secret' => 'sharingiscaring',
592 print Dumper($result);
594 foreach my $cust_svc ( @{ $result->{'cust_svc'} } ) {
595 #print $cust_svc->{mac_addr}."\n" if exists $cust_svc->{mac_addr};
596 print $cust_svc->{circuit_id}."\n" if exists $cust_svc->{circuit_id};
601 sub customer_list_svcs {
602 my( $class, %opt ) = @_;
603 return _shared_secret_error() unless _check_shared_secret($opt{secret});
605 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
606 or return { 'error' => 'Unknown custnum' };
608 #$cust_main->API_list_svcs;
610 #false laziness w/ClientAPI/list_svcs
613 #my @cust_pkg_usage = ();
614 #foreach my $cust_pkg ( $p->{'ncancelled'}
615 # ? $cust_main->ncancelled_pkgs
616 # : $cust_main->unsuspended_pkgs ) {
617 foreach my $cust_pkg ( $cust_main->all_pkgs ) {
618 #next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
619 push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
620 #push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
624 'cust_svc' => [ map $_->API_getinfo, @cust_svc ],
631 Returns location specific information for the customer. Takes a list of keys
632 and values as paramters with the following keys: custnum, secret
636 #I also monitor for changes to the additional locations that are applied to
637 # packages, and would like for those to be exportable as well. basically the
638 # location data passed with the custnum.
641 my( $class, %opt ) = @_;
642 return _shared_secret_error() unless _check_shared_secret($opt{secret});
644 my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} });
648 'locations' => [ map $_->hashref, @cust_location ],
654 =item list_customer_packages OPTION => VALUE, ...
656 Lists all customer packages.
672 my $result = FS::API->list_packages(
673 'secret' => 'sharingiscaring',
674 'custnum' => custnum,
677 if ( $result->{'error'} ) {
678 die $result->{'error'};
680 # list packages returns an array of hashes for packages ordered by custnum and pkgnum.
681 print Dumper($result->{'pkgs'});
686 sub list_customer_packages {
687 my( $class, %opt ) = @_;
688 return _shared_secret_error() unless _check_shared_secret($opt{secret});
690 my $sql_query = FS::cust_pkg->search({ 'custnum' => $opt{custnum}, });
692 $sql_query->{order_by} = 'ORDER BY custnum, pkgnum';
694 my @packages = qsearch($sql_query)
695 or return { 'error' => 'No packages' };
698 'packages' => [ map $_->hashref, @packages ],
704 =item package_status OPTION => VALUE, ...
722 my $result = FS::API->package_status(
723 'secret' => 'sharingiscaring',
727 if ( $result->{'error'} ) {
728 die $result->{'error'};
730 # package status returns a hash with the status for a package.
731 print Dumper($result->{'status'});
737 my( $class, %opt ) = @_;
738 return _shared_secret_error() unless _check_shared_secret($opt{secret});
740 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{pkgnum} } )
741 or return { 'error' => 'No packages' };
744 'status' => $cust_pkg->status,
750 =item order_package OPTION => VALUE, ...
752 Orders a new customer package. Takes a list of keys and values as paramaters
753 with the following keys:
787 Including this implements per-customer custom pricing for this package, overriding package definition pricing
791 Including this implements per-customer custom pricing for this package, overriding package definition pricing
793 =item invoice_details
795 A single string for just one detail line, or an array reference of one or more
803 my( $class, %opt ) = @_;
805 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
806 or return { 'error' => 'Unknown custnum' };
808 #some conceptual false laziness w/cust_pkg/Import.pm
810 my $cust_pkg = new FS::cust_pkg {
811 'pkgpart' => $opt{'pkgpart'},
812 'quantity' => $opt{'quantity'} || 1,
815 #start_date and contract_end
816 foreach my $date_field (qw( start_date contract_end )) {
817 if ( $opt{$date_field} =~ /^(\d+)$/ ) {
818 $cust_pkg->$date_field( $opt{$date_field} );
819 } elsif ( $opt{$date_field} ) {
820 $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
824 #especially this part for custom pkg price
825 # (false laziness w/cust_pkg/Import.pm)
826 my $s = $opt{'setup_fee'};
827 my $r = $opt{'recur_fee'};
828 my $part_pkg = $cust_pkg->part_pkg;
829 if ( ( length($s) && $s != $part_pkg->option('setup_fee') )
830 or ( length($r) && $r != $part_pkg->option('recur_fee') )
834 local($FS::part_pkg::skip_pkg_svc_hack) = 1;
836 my $custom_part_pkg = $part_pkg->clone;
837 $custom_part_pkg->disabled('Y');
838 my %options = $part_pkg->options;
839 $options{'setup_fee'} = $s if length($s);
840 $options{'recur_fee'} = $r if length($r);
841 my $error = $custom_part_pkg->insert( options=>\%options );
842 return ( 'error' => "error customizing package: $error" ) if $error;
844 #not ->pkg_svc, we want to ignore links and clone the actual package def
845 foreach my $pkg_svc ( $part_pkg->_pkg_svc ) {
846 my $c_pkg_svc = new FS::pkg_svc { $pkg_svc->hash };
847 $c_pkg_svc->pkgsvcnum('');
848 $c_pkg_svc->pkgpart( $custom_part_pkg->pkgpart );
849 my $error = $c_pkg_svc->insert;
850 return "error customizing package: $error" if $error;
853 $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
857 my %order_pkg = ( 'cust_pkg' => $cust_pkg );
859 my @loc_fields = qw( address1 address2 city county state zip country );
860 if ( grep length($opt{$_}), @loc_fields ) {
861 $order_pkg{'cust_location'} = new FS::cust_location {
862 map { $_ => $opt{$_} } @loc_fields, 'custnum'
866 $order_pkg{'invoice_details'} = $opt{'invoice_details'}
867 if $opt{'invoice_details'};
869 my $error = $cust_main->order_pkg( %order_pkg );
872 return { 'error' => $error,
876 # return { 'error' => '',
877 # #cust_main->order_pkg doesn't actually have a way to return pkgnum
878 # #'pkgnum' => $pkgnum,
884 =item change_package_location
886 Updates package location. Takes a list of keys and values
887 as parameters with the following keys:
893 locationnum - pass this, or the following keys (don't pass both)
925 On error, returns a hashref with an 'error' key.
926 On success, returns a hashref with 'pkgnum' and 'locationnum' keys,
927 containing the new values.
931 sub change_package_location {
934 return _shared_secret_error() unless _check_shared_secret($opt{'secret'});
936 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} })
937 or return { 'error' => 'Unknown pkgnum' };
941 foreach my $field ( qw(
959 $changeopt{$field} = $opt{$field} if $opt{$field};
962 $cust_pkg->API_change(%changeopt);
965 =item bill_now OPTION => VALUE, ...
967 Bills a single customer now, in the same fashion as the "Bill now" link in the
970 Returns a hash reference with a single key, 'error'. If there is an error,
971 the value contains the error, otherwise it is empty. Takes a list of keys and
972 values as parameters with the following keys:
978 API Secret (required)
982 Customer number (required)
989 my( $class, %opt ) = @_;
990 return _shared_secret_error() unless _check_shared_secret($opt{secret});
992 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
993 or return { 'error' => 'Unknown custnum' };
995 my $error = $cust_main->bill_and_collect( 'fatal' => 'return',
1000 return { 'error' => $error,
1006 #next.. Delete Advertising sources?
1008 =item list_advertising_sources OPTION => VALUE, ...
1010 Lists all advertising sources.
1022 my $result = FS::API->list_advertising_sources(
1023 'secret' => 'sharingiscaring',
1026 if ( $result->{'error'} ) {
1027 die $result->{'error'};
1029 # list advertising sources returns an array of hashes for sources.
1030 print Dumper($result->{'sources'});
1035 #list_advertising_sources
1036 sub list_advertising_sources {
1037 my( $class, %opt ) = @_;
1038 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1040 my @sources = qsearch('part_referral', {}, '', "")
1041 or return { 'error' => 'No referrals' };
1044 'sources' => [ map $_->hashref, @sources ],
1050 =item add_advertising_source OPTION => VALUE, ...
1052 Add a new advertising source.
1066 Referral disabled, Y for disabled or nothing for enabled
1074 External referral ID
1080 my $result = FS::API->add_advertising_source(
1081 'secret' => 'sharingiscaring',
1082 'referral' => 'test referral',
1086 'agentnum' => '2', #agent id number
1087 'title' => 'test title',
1090 if ( $result->{'error'} ) {
1091 die $result->{'error'};
1093 # add_advertising_source returns new source upon success.
1094 print Dumper($result);
1099 #add_advertising_source
1100 sub add_advertising_source {
1101 my( $class, %opt ) = @_;
1102 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1104 use FS::part_referral;
1106 my $new_source = $opt{source};
1108 my $source = new FS::part_referral $new_source;
1110 my $error = $source->insert;
1112 my $return = {$source->hash};
1113 $return = { 'error' => $error, } if $error;
1118 =item edit_advertising_source OPTION => VALUE, ...
1120 Edit a advertising source.
1130 Referral number to edit
1134 hash of edited source fields.
1144 Referral disabled, Y for disabled or nothing for enabled
1152 External referral ID
1160 my $result = FS::API->edit_advertising_source(
1161 'secret' => 'sharingiscaring',
1162 'refnum' => '4', # referral number to edit
1165 'referral' => 'test referral',
1167 'agentnum' => '2', #agent id number
1168 'title' => 'test title',
1172 if ( $result->{'error'} ) {
1173 die $result->{'error'};
1175 # edit_advertising_source returns updated source upon success.
1176 print Dumper($result);
1181 #edit_advertising_source
1182 sub edit_advertising_source {
1183 my( $class, %opt ) = @_;
1184 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1186 use FS::part_referral;
1188 my $refnum = $opt{refnum};
1189 my $source = $opt{source};
1191 my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
1192 my $new = new FS::part_referral { $old->hash };
1194 foreach my $key (keys %$source) {
1195 $new->$key($source->{$key});
1198 my $error = $new->replace;
1200 my $return = {$new->hash};
1201 $return = { 'error' => $error, } if $error;
1207 =item email_optout OPTION => VALUE, ...
1209 Each e-mail address, or L<FS::cust_contact> record, has two opt-in flags:
1210 message_dest: recieve non-invoicing messages, and invoice_dest: recieve
1213 Use this API call to remove opt-in flags for an e-mail address
1221 =item disable_message_dest
1224 Set this parameter as 0 in your API call to leave the message_dest flag as is
1226 =item disable_invoice_dest
1229 Set this parameter as 0 in your API call to leave the invoice_dest flag as is
1236 my ($class, %opt) = @_;
1238 return _shared_secret_error()
1239 unless _check_shared_secret($opt{secret});
1241 return {error => 'No e-mail address specified'}
1242 unless $opt{address} && $opt{address} =~ /\@/;
1244 $opt{disable_message_dest} ||= 1;
1245 $opt{disable_invoice_dest} ||= 1;
1247 my $address = FS::Record::dbh->quote($opt{address});
1249 for my $cust_contact (
1250 FS::Record::qsearch({
1251 table => 'cust_contact',
1252 select => 'cust_contact.*',
1253 addl_from => 'LEFT JOIN contact_email USING (contactnum)',
1254 extra_sql => "WHERE contact_email.emailaddress = $address",
1257 $cust_contact->set(invoice_dest => '') if $opt{disable_invoice_dest};
1258 $cust_contact->set(message_dest => '') if $opt{disable_message_dest};
1260 my $error = $cust_contact->replace();
1261 return {error => $error} if $error;
1268 # helper subroutines
1271 sub _check_shared_secret {
1272 shift eq FS::Conf->new->config('api_shared_secret');
1275 sub _shared_secret_error {
1276 return { 'error' => 'Incorrect shared secret' };