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
390 Optional shipping address fields. If sending an optional shipping address,
391 ship_address1, ship_city, ship_state and ship_zip are required.
411 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),
413 Set to 1 to enable postal invoicing
415 =item referral_custnum
417 Referring customer number
429 Agent specific customer number
431 =item referral_custnum
433 Referring customer number
439 #certainly false laziness w/ClientAPI::Signup new_customer/new_customer_minimal
440 # but approaching this from a clean start / back-office perspective
441 # i.e. no package/service, no immediate credit card run, etc.
444 my( $class, %opt ) = @_;
445 return _shared_secret_error() unless _check_shared_secret($opt{secret});
447 #default agentnum like signup_server-default_agentnum?
448 #$opt{agentnum} ||= $conf->config('signup_server-default_agentnum');
450 #same for refnum like signup_server-default_refnum
451 $opt{refnum} ||= FS::Conf->new->config('signup_server-default_refnum');
453 FS::cust_main->API_insert( %opt );
456 =item update_customer
458 Updates an existing customer. Passing an empty value clears that field, while
459 NOT passing that key/value at all leaves it alone. Takes a list of keys and
460 values as parameters with the following keys:
466 API Secret (required)
470 Customer number (required)
526 Comma-separated list of email addresses for email invoices. The special value
527 'POST' is used to designate postal invoicing (it may be specified alone or in
528 addition to email addresses),
530 Set to 1 to enable postal invoicing
532 =item referral_custnum
534 Referring customer number
548 sub update_customer {
549 my( $class, %opt ) = @_;
550 return _shared_secret_error() unless _check_shared_secret($opt{secret});
552 FS::cust_main->API_update( %opt );
555 =item customer_info OPTION => VALUE, ...
557 Returns general customer information. Takes a list of keys and values as
558 parameters with the following keys: custnum, secret
562 use Frontier::Client;
565 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
568 my $xmlrpc = new Frontier::Client url=>$url;
570 my $result = $xmlrpc->call( 'FS.API.customer_info',
571 'secret' => 'sharingiscaring',
575 print Dumper($result);
577 Returns the following fields:
583 Empty, or error message (in which case, none of the other fields will be populated)
585 =item display_custnum
587 Optional customer number display override - if present, use this for all UI instead of the real database custnum
591 Simple string for customer identification (from first, last, company)
631 Employee (initial customer insert)
633 =item referral_custnum
667 Comma-separated list of email addresses
669 =item postal_invoicing
678 my( $class, %opt ) = @_;
679 return _shared_secret_error() unless _check_shared_secret($opt{secret});
681 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
682 or return { 'error' => 'Unknown custnum' };
684 $cust_main->API_getinfo;
687 =item customer_list_svcs OPTION => VALUE, ...
689 Returns customer service information. Takes a list of keys and values as
690 parameters with the following keys: custnum, secret
694 use Frontier::Client;
697 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
700 my $xmlrpc = new Frontier::Client url=>$url;
702 my $result = $xmlrpc->call( 'FS.API.customer_list_svcs',
703 'secret' => 'sharingiscaring',
707 print Dumper($result);
709 foreach my $cust_svc ( @{ $result->{'cust_svc'} } ) {
710 #print $cust_svc->{mac_addr}."\n" if exists $cust_svc->{mac_addr};
711 print $cust_svc->{circuit_id}."\n" if exists $cust_svc->{circuit_id};
716 sub customer_list_svcs {
717 my( $class, %opt ) = @_;
718 return _shared_secret_error() unless _check_shared_secret($opt{secret});
720 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
721 or return { 'error' => 'Unknown custnum' };
723 #$cust_main->API_list_svcs;
725 #false laziness w/ClientAPI/list_svcs
728 #my @cust_pkg_usage = ();
729 #foreach my $cust_pkg ( $p->{'ncancelled'}
730 # ? $cust_main->ncancelled_pkgs
731 # : $cust_main->unsuspended_pkgs ) {
732 foreach my $cust_pkg ( $cust_main->all_pkgs ) {
733 #next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
734 push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
735 #push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
739 'cust_svc' => [ map $_->API_getinfo, @cust_svc ],
746 Returns location specific information for the customer. Takes a list of keys
747 and values as paramters with the following keys: custnum, secret
751 #I also monitor for changes to the additional locations that are applied to
752 # packages, and would like for those to be exportable as well. basically the
753 # location data passed with the custnum.
756 my( $class, %opt ) = @_;
757 return _shared_secret_error() unless _check_shared_secret($opt{secret});
759 my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} });
763 'locations' => [ map $_->hashref, @cust_location ],
769 =item list_customer_packages OPTION => VALUE, ...
771 Lists all customer packages.
787 my $result = FS::API->list_packages(
788 'secret' => 'sharingiscaring',
789 'custnum' => custnum,
792 if ( $result->{'error'} ) {
793 die $result->{'error'};
795 # list packages returns an array of hashes for packages ordered by custnum and pkgnum.
796 print Dumper($result->{'pkgs'});
801 sub list_customer_packages {
802 my( $class, %opt ) = @_;
803 return _shared_secret_error() unless _check_shared_secret($opt{secret});
805 my $sql_query = FS::cust_pkg->search({ 'custnum' => $opt{custnum}, });
807 $sql_query->{order_by} = 'ORDER BY custnum, pkgnum';
809 my @packages = qsearch($sql_query)
810 or return { 'error' => 'No packages' };
813 'packages' => [ map $_->hashref, @packages ],
819 =item package_status OPTION => VALUE, ...
837 my $result = FS::API->package_status(
838 'secret' => 'sharingiscaring',
842 if ( $result->{'error'} ) {
843 die $result->{'error'};
845 # package status returns a hash with the status for a package.
846 print Dumper($result->{'status'});
852 my( $class, %opt ) = @_;
853 return _shared_secret_error() unless _check_shared_secret($opt{secret});
855 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{pkgnum} } )
856 or return { 'error' => 'No packages' };
859 'status' => $cust_pkg->status,
865 =item order_package OPTION => VALUE, ...
867 Orders a new customer package. Takes a list of keys and values as paramaters
868 with the following keys:
902 Including this implements per-customer custom pricing for this package, overriding package definition pricing
906 Including this implements per-customer custom pricing for this package, overriding package definition pricing
908 =item invoice_details
910 A single string for just one detail line, or an array reference of one or more
918 my( $class, %opt ) = @_;
920 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
921 or return { 'error' => 'Unknown custnum' };
923 #some conceptual false laziness w/cust_pkg/Import.pm
925 my $cust_pkg = new FS::cust_pkg {
926 'pkgpart' => $opt{'pkgpart'},
927 'quantity' => $opt{'quantity'} || 1,
930 #start_date and contract_end
931 foreach my $date_field (qw( start_date contract_end )) {
932 if ( $opt{$date_field} =~ /^(\d+)$/ ) {
933 $cust_pkg->$date_field( $opt{$date_field} );
934 } elsif ( $opt{$date_field} ) {
935 $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
939 #especially this part for custom pkg price
940 # (false laziness w/cust_pkg/Import.pm)
941 my $s = $opt{'setup_fee'};
942 my $r = $opt{'recur_fee'};
943 my $part_pkg = $cust_pkg->part_pkg;
944 if ( ( length($s) && $s != $part_pkg->option('setup_fee') )
945 or ( length($r) && $r != $part_pkg->option('recur_fee') )
949 local($FS::part_pkg::skip_pkg_svc_hack) = 1;
951 my $custom_part_pkg = $part_pkg->clone;
952 $custom_part_pkg->disabled('Y');
953 my %options = $part_pkg->options;
954 $options{'setup_fee'} = $s if length($s);
955 $options{'recur_fee'} = $r if length($r);
956 my $error = $custom_part_pkg->insert( options=>\%options );
957 return ( 'error' => "error customizing package: $error" ) if $error;
959 #not ->pkg_svc, we want to ignore links and clone the actual package def
960 foreach my $pkg_svc ( $part_pkg->_pkg_svc ) {
961 my $c_pkg_svc = new FS::pkg_svc { $pkg_svc->hash };
962 $c_pkg_svc->pkgsvcnum('');
963 $c_pkg_svc->pkgpart( $custom_part_pkg->pkgpart );
964 my $error = $c_pkg_svc->insert;
965 return "error customizing package: $error" if $error;
968 $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
972 my %order_pkg = ( 'cust_pkg' => $cust_pkg );
974 my @loc_fields = qw( address1 address2 city county state zip country );
975 if ( grep length($opt{$_}), @loc_fields ) {
976 $order_pkg{'cust_location'} = new FS::cust_location {
977 map { $_ => $opt{$_} } @loc_fields, 'custnum'
981 $order_pkg{'invoice_details'} = $opt{'invoice_details'}
982 if $opt{'invoice_details'};
984 my $error = $cust_main->order_pkg( %order_pkg );
987 return { 'error' => $error,
991 # return { 'error' => '',
992 # #cust_main->order_pkg doesn't actually have a way to return pkgnum
993 # #'pkgnum' => $pkgnum,
999 =item change_package_location
1001 Updates package location. Takes a list of keys and values
1002 as parameters with the following keys:
1008 locationnum - pass this, or the following keys (don't pass both)
1040 On error, returns a hashref with an 'error' key.
1041 On success, returns a hashref with 'pkgnum' and 'locationnum' keys,
1042 containing the new values.
1046 sub change_package_location {
1049 return _shared_secret_error() unless _check_shared_secret($opt{'secret'});
1051 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} })
1052 or return { 'error' => 'Unknown pkgnum' };
1056 foreach my $field ( qw(
1074 $changeopt{$field} = $opt{$field} if $opt{$field};
1077 $cust_pkg->API_change(%changeopt);
1080 =item bill_now OPTION => VALUE, ...
1082 Bills a single customer now, in the same fashion as the "Bill now" link in the
1085 Returns a hash reference with a single key, 'error'. If there is an error,
1086 the value contains the error, otherwise it is empty. Takes a list of keys and
1087 values as parameters with the following keys:
1093 API Secret (required)
1097 Customer number (required)
1104 my( $class, %opt ) = @_;
1105 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1107 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
1108 or return { 'error' => 'Unknown custnum' };
1110 my $error = $cust_main->bill_and_collect( 'fatal' => 'return',
1112 'check_freq' =>'1d',
1115 return { 'error' => $error,
1121 #next.. Delete Advertising sources?
1123 =item list_advertising_sources OPTION => VALUE, ...
1125 Lists all advertising sources.
1137 my $result = FS::API->list_advertising_sources(
1138 'secret' => 'sharingiscaring',
1141 if ( $result->{'error'} ) {
1142 die $result->{'error'};
1144 # list advertising sources returns an array of hashes for sources.
1145 print Dumper($result->{'sources'});
1150 #list_advertising_sources
1151 sub list_advertising_sources {
1152 my( $class, %opt ) = @_;
1153 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1155 my @sources = qsearch('part_referral', {}, '', "")
1156 or return { 'error' => 'No referrals' };
1159 'sources' => [ map $_->hashref, @sources ],
1165 =item add_advertising_source OPTION => VALUE, ...
1167 Add a new advertising source.
1181 Referral disabled, Y for disabled or nothing for enabled
1189 External referral ID
1195 my $result = FS::API->add_advertising_source(
1196 'secret' => 'sharingiscaring',
1197 'referral' => 'test referral',
1201 'agentnum' => '2', #agent id number
1202 'title' => 'test title',
1205 if ( $result->{'error'} ) {
1206 die $result->{'error'};
1208 # add_advertising_source returns new source upon success.
1209 print Dumper($result);
1214 #add_advertising_source
1215 sub add_advertising_source {
1216 my( $class, %opt ) = @_;
1217 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1219 use FS::part_referral;
1221 my $new_source = $opt{source};
1223 my $source = new FS::part_referral $new_source;
1225 my $error = $source->insert;
1227 my $return = {$source->hash};
1228 $return = { 'error' => $error, } if $error;
1233 =item edit_advertising_source OPTION => VALUE, ...
1235 Edit a advertising source.
1245 Referral number to edit
1249 hash of edited source fields.
1259 Referral disabled, Y for disabled or nothing for enabled
1267 External referral ID
1275 my $result = FS::API->edit_advertising_source(
1276 'secret' => 'sharingiscaring',
1277 'refnum' => '4', # referral number to edit
1280 'referral' => 'test referral',
1282 'agentnum' => '2', #agent id number
1283 'title' => 'test title',
1287 if ( $result->{'error'} ) {
1288 die $result->{'error'};
1290 # edit_advertising_source returns updated source upon success.
1291 print Dumper($result);
1296 #edit_advertising_source
1297 sub edit_advertising_source {
1298 my( $class, %opt ) = @_;
1299 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1301 use FS::part_referral;
1303 my $refnum = $opt{refnum};
1304 my $source = $opt{source};
1306 my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
1307 my $new = new FS::part_referral { $old->hash };
1309 foreach my $key (keys %$source) {
1310 $new->$key($source->{$key});
1313 my $error = $new->replace;
1315 my $return = {$new->hash};
1316 $return = { 'error' => $error, } if $error;
1322 =item email_optout OPTION => VALUE, ...
1324 Each e-mail address, or L<FS::cust_contact> record, has two opt-in flags:
1325 message_dest: recieve non-invoicing messages, and invoice_dest: recieve
1328 Use this API call to remove opt-in flags for an e-mail address
1336 =item disable_message_dest
1339 Set this parameter as 0 in your API call to leave the message_dest flag as is
1341 =item disable_invoice_dest
1344 Set this parameter as 0 in your API call to leave the invoice_dest flag as is
1351 my ($class, %opt) = @_;
1353 return _shared_secret_error()
1354 unless _check_shared_secret($opt{secret});
1356 return {error => 'No e-mail address specified'}
1357 unless $opt{address} && $opt{address} =~ /\@/;
1359 $opt{disable_message_dest} ||= 1;
1360 $opt{disable_invoice_dest} ||= 1;
1362 my $address = FS::Record::dbh->quote($opt{address});
1364 for my $cust_contact (
1365 FS::Record::qsearch({
1366 table => 'cust_contact',
1367 select => 'cust_contact.*',
1368 addl_from => 'LEFT JOIN contact_email USING (contactnum)',
1369 extra_sql => "WHERE contact_email.emailaddress = $address",
1372 $cust_contact->set(invoice_dest => '') if $opt{disable_invoice_dest};
1373 $cust_contact->set(message_dest => '') if $opt{disable_message_dest};
1375 my $error = $cust_contact->replace();
1376 return {error => $error} if $error;
1383 # helper subroutines
1386 sub _check_shared_secret {
1387 shift eq FS::Conf->new->config('api_shared_secret');
1390 sub _shared_secret_error {
1391 return { 'error' => 'Incorrect shared secret' };