6 use FS::Record qw( qsearch qsearchs );
16 FS::API - Freeside backend API
23 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
26 my $xmlrpc = new Frontier::Client url=>$url;
28 my $result = $xmlrpc->call( 'FS.API.customer_info',
29 'secret' => 'sharingiscaring',
33 print Dumper($result);
37 This module implements a backend API for advanced back-office integration.
39 In contrast to the self-service API, which authenticates an end-user and offers
40 functionality to that end user, the backend API performs a simple shared-secret
41 authentication and offers full, administrator functionality, enabling
42 integration with other back-office systems. Only access this API from a secure
43 network from other backoffice machines. DON'T use this API to create customer
46 If accessing this API remotely with XML-RPC or JSON-RPC, be careful to block
47 the port by default, only allow access from back-office servers with the same
48 security precations as the Freeside server, and encrypt the communication
49 channel (for example, with an SSH tunnel or VPN) rather than accessing it
56 =item insert_payment OPTION => VALUE, ...
58 Adds a new payment to a customers account. Takes a list of keys and values as
59 paramters with the following keys:
81 Option date for payment
91 my $result = FS::API->insert_payment(
92 'secret' => 'sharingiscaring',
98 '_date' => 1397977200, #UNIX timestamp
99 'order_number' => '12345',
102 if ( $result->{'error'} ) {
103 die $result->{'error'};
105 #payment was inserted
106 print "paynum ". $result->{'paynum'};
113 my($class, %opt) = @_;
114 return _shared_secret_error() unless _check_shared_secret($opt{secret});
116 #less "raw" than this? we are the backoffice API, and aren't worried
117 # about version migration ala cust_main/cust_location here
118 my $cust_pay = new FS::cust_pay { %opt };
119 my $error = $cust_pay->insert( 'manual'=>1 );
120 return { 'error' => $error,
121 'paynum' => $cust_pay->paynum,
125 # pass the phone number ( from svc_phone )
126 sub insert_payment_phonenum {
127 my($class, %opt) = @_;
128 $class->_by_phonenum('insert_payment', %opt);
132 my($class, $method, %opt) = @_;
133 return _shared_secret_error() unless _check_shared_secret($opt{secret});
135 my $phonenum = delete $opt{'phonenum'};
137 my $svc_phone = qsearchs('svc_phone', { 'phonenum' => $phonenum } )
138 or return { 'error' => 'Unknown phonenum' };
140 my $cust_pkg = $svc_phone->cust_svc->cust_pkg
141 or return { 'error' => 'Unlinked phonenum' };
143 $opt{'custnum'} = $cust_pkg->custnum;
145 $class->$method(%opt);
148 =item insert_credit OPTION => VALUE, ...
150 Adds a a credit to a customers account. Takes a list of keys and values as
151 parameters with the following keys
169 The date the credit will be posted
175 my $result = FS::API->insert_credit(
176 'secret' => 'sharingiscaring',
181 '_date' => 1397977200, #UNIX timestamp
184 if ( $result->{'error'} ) {
185 die $result->{'error'};
188 print "crednum ". $result->{'crednum'};
195 my($class, %opt) = @_;
196 return _shared_secret_error() unless _check_shared_secret($opt{secret});
198 $opt{'reasonnum'} ||= FS::Conf->new->config('api_credit_reason');
200 #less "raw" than this? we are the backoffice API, and aren't worried
201 # about version migration ala cust_main/cust_location here
202 my $cust_credit = new FS::cust_credit { %opt };
203 my $error = $cust_credit->insert;
204 return { 'error' => $error,
205 'crednum' => $cust_credit->crednum,
209 # pass the phone number ( from svc_phone )
210 sub insert_credit_phonenum {
211 my($class, %opt) = @_;
212 $class->_by_phonenum('insert_credit', %opt);
215 =item apply_payments_and_credits
217 Applies payments and credits for this customer. Takes a list of keys and
218 values as parameter with the following keys:
234 #apply payments and credits
235 sub apply_payments_and_credits {
236 my($class, %opt) = @_;
237 return _shared_secret_error() unless _check_shared_secret($opt{secret});
239 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
240 or return { 'error' => 'Unknown custnum' };
242 my $error = $cust_main->apply_payments_and_credits( 'manual'=>1 );
243 return { 'error' => $error, };
246 =item insert_refund OPTION => VALUE, ...
248 Adds a a credit to a customers account. Takes a list of keys and values as
249 parmeters with the following keys: custnum, payby, refund
253 my $result = FS::API->insert_refund(
254 'secret' => 'sharingiscaring',
260 '_date' => 1397977200, #UNIX timestamp
263 if ( $result->{'error'} ) {
264 die $result->{'error'};
267 print "refundnum ". $result->{'crednum'};
274 my($class, %opt) = @_;
275 return _shared_secret_error() unless _check_shared_secret($opt{secret});
277 # when github pull request #24 is merged,
278 # will have to change over to default reasonnum like credit
279 # but until then, this will do
280 $opt{'reason'} ||= 'API refund';
282 #less "raw" than this? we are the backoffice API, and aren't worried
283 # about version migration ala cust_main/cust_location here
284 my $cust_refund = new FS::cust_refund { %opt };
285 my $error = $cust_refund->insert;
286 return { 'error' => $error,
287 'refundnum' => $cust_refund->refundnum,
291 # pass the phone number ( from svc_phone )
292 sub insert_refund_phonenum {
293 my($class, %opt) = @_;
294 $class->_by_phonenum('insert_refund', %opt);
299 # "2 way syncing" ? start with non-sync pulling info here, then if necessary
300 # figure out how to trigger something when those things change
302 # long-term: package changes?
304 =item new_customer OPTION => VALUE, ...
306 Creates a new customer. Takes a list of keys and values as parameters with the
317 first name (required)
325 (not typically collected; mostly used for ACH transactions)
331 =item address1 (required)
335 =item city (required)
343 =item state (required)
365 Currently used for third party tax vendor lookups
369 Used for determining FCC 477 reporting
373 Used for determining FCC 477 reporting
389 Optional shipping address fields. If sending an optional shipping address,
390 ship_address1, ship_city, ship_state and ship_zip are required.
410 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),
412 Set to 1 to enable postal invoicing
414 =item referral_custnum
416 Referring customer number
428 Agent specific customer number
430 =item referral_custnum
432 Referring customer number
438 #certainly false laziness w/ClientAPI::Signup new_customer/new_customer_minimal
439 # but approaching this from a clean start / back-office perspective
440 # i.e. no package/service, no immediate credit card run, etc.
443 my( $class, %opt ) = @_;
444 return _shared_secret_error() unless _check_shared_secret($opt{secret});
446 #default agentnum like signup_server-default_agentnum?
447 #$opt{agentnum} ||= $conf->config('signup_server-default_agentnum');
449 #same for refnum like signup_server-default_refnum
450 $opt{refnum} ||= FS::Conf->new->config('signup_server-default_refnum');
452 FS::cust_main->API_insert( %opt );
455 =item update_customer
457 Updates an existing customer. Passing an empty value clears that field, while
458 NOT passing that key/value at all leaves it alone. Takes a list of keys and
459 values as parameters with the following keys:
465 API Secret (required)
469 Customer number (required)
525 Comma-separated list of email addresses for email invoices. The special value
526 'POST' is used to designate postal invoicing (it may be specified alone or in
527 addition to email addresses),
529 Set to 1 to enable postal invoicing
531 =item referral_custnum
533 Referring customer number
547 sub update_customer {
548 my( $class, %opt ) = @_;
549 return _shared_secret_error() unless _check_shared_secret($opt{secret});
551 FS::cust_main->API_update( %opt );
554 =item customer_info OPTION => VALUE, ...
556 Returns general customer information. Takes a list of keys and values as
557 parameters with the following keys: custnum, secret
561 use Frontier::Client;
564 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
567 my $xmlrpc = new Frontier::Client url=>$url;
569 my $result = $xmlrpc->call( 'FS.API.customer_info',
570 'secret' => 'sharingiscaring',
574 print Dumper($result);
576 Returns the following fields:
582 Empty, or error message (in which case, none of the other fields will be populated)
584 =item display_custnum
586 Optional customer number display override - if present, use this for all UI instead of the real database custnum
590 Simple string for customer identification (from first, last, company)
630 Employee (initial customer insert)
632 =item referral_custnum
666 Comma-separated list of email addresses
668 =item postal_invoicing
677 my( $class, %opt ) = @_;
678 return _shared_secret_error() unless _check_shared_secret($opt{secret});
680 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
681 or return { 'error' => 'Unknown custnum' };
683 $cust_main->API_getinfo;
686 =item customer_list_svcs OPTION => VALUE, ...
688 Returns customer service information. Takes a list of keys and values as
689 parameters with the following keys: custnum, secret
693 use Frontier::Client;
696 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
699 my $xmlrpc = new Frontier::Client url=>$url;
701 my $result = $xmlrpc->call( 'FS.API.customer_list_svcs',
702 'secret' => 'sharingiscaring',
706 print Dumper($result);
708 foreach my $cust_svc ( @{ $result->{'cust_svc'} } ) {
709 #print $cust_svc->{mac_addr}."\n" if exists $cust_svc->{mac_addr};
710 print $cust_svc->{circuit_id}."\n" if exists $cust_svc->{circuit_id};
715 sub customer_list_svcs {
716 my( $class, %opt ) = @_;
717 return _shared_secret_error() unless _check_shared_secret($opt{secret});
719 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
720 or return { 'error' => 'Unknown custnum' };
722 #$cust_main->API_list_svcs;
724 #false laziness w/ClientAPI/list_svcs
727 #my @cust_pkg_usage = ();
728 #foreach my $cust_pkg ( $p->{'ncancelled'}
729 # ? $cust_main->ncancelled_pkgs
730 # : $cust_main->unsuspended_pkgs ) {
731 foreach my $cust_pkg ( $cust_main->all_pkgs ) {
732 #next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
733 push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
734 #push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
738 'cust_svc' => [ map $_->API_getinfo, @cust_svc ],
745 Returns location specific information for the customer. Takes a list of keys
746 and values as paramters with the following keys: custnum, secret
750 #I also monitor for changes to the additional locations that are applied to
751 # packages, and would like for those to be exportable as well. basically the
752 # location data passed with the custnum.
755 my( $class, %opt ) = @_;
756 return _shared_secret_error() unless _check_shared_secret($opt{secret});
758 my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} });
762 'locations' => [ map $_->hashref, @cust_location ],
768 =item list_customer_packages OPTION => VALUE, ...
770 Lists all customer packages.
786 my $result = FS::API->list_packages(
787 'secret' => 'sharingiscaring',
788 'custnum' => custnum,
791 if ( $result->{'error'} ) {
792 die $result->{'error'};
794 # list packages returns an array of hashes for packages ordered by custnum and pkgnum.
795 print Dumper($result->{'pkgs'});
800 sub list_customer_packages {
801 my( $class, %opt ) = @_;
802 return _shared_secret_error() unless _check_shared_secret($opt{secret});
804 my $sql_query = FS::cust_pkg->search({ 'custnum' => $opt{custnum}, });
806 $sql_query->{order_by} = 'ORDER BY custnum, pkgnum';
808 my @packages = qsearch($sql_query)
809 or return { 'error' => 'No packages' };
812 'packages' => [ map $_->hashref, @packages ],
818 =item package_status OPTION => VALUE, ...
836 my $result = FS::API->package_status(
837 'secret' => 'sharingiscaring',
841 if ( $result->{'error'} ) {
842 die $result->{'error'};
844 # package status returns a hash with the status for a package.
845 print Dumper($result->{'status'});
851 my( $class, %opt ) = @_;
852 return _shared_secret_error() unless _check_shared_secret($opt{secret});
854 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{pkgnum} } )
855 or return { 'error' => 'No packages' };
858 'status' => $cust_pkg->status,
864 =item order_package OPTION => VALUE, ...
866 Orders a new customer package. Takes a list of keys and values as paramaters
867 with the following keys:
901 Including this implements per-customer custom pricing for this package, overriding package definition pricing
905 Including this implements per-customer custom pricing for this package, overriding package definition pricing
907 =item invoice_details
909 A single string for just one detail line, or an array reference of one or more
915 my( $class, %opt ) = @_;
917 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
918 or return { 'error' => 'Unknown custnum' };
920 #some conceptual false laziness w/cust_pkg/Import.pm
922 my $cust_pkg = new FS::cust_pkg {
923 'pkgpart' => $opt{'pkgpart'},
924 'quantity' => $opt{'quantity'} || 1,
927 #start_date and contract_end
928 foreach my $date_field (qw( start_date contract_end )) {
929 if ( $opt{$date_field} =~ /^(\d+)$/ ) {
930 $cust_pkg->$date_field( $opt{$date_field} );
931 } elsif ( $opt{$date_field} ) {
932 $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
936 #especially this part for custom pkg price
937 # (false laziness w/cust_pkg/Import.pm)
938 my $s = $opt{'setup_fee'};
939 my $r = $opt{'recur_fee'};
940 my $part_pkg = $cust_pkg->part_pkg;
941 if ( ( length($s) && $s != $part_pkg->option('setup_fee') )
942 or ( length($r) && $r != $part_pkg->option('recur_fee') )
946 local($FS::part_pkg::skip_pkg_svc_hack) = 1;
948 my $custom_part_pkg = $part_pkg->clone;
949 $custom_part_pkg->disabled('Y');
950 my %options = $part_pkg->options;
951 $options{'setup_fee'} = $s if length($s);
952 $options{'recur_fee'} = $r if length($r);
953 my $error = $custom_part_pkg->insert( options=>\%options );
954 return ( 'error' => "error customizing package: $error" ) if $error;
956 #not ->pkg_svc, we want to ignore links and clone the actual package def
957 foreach my $pkg_svc ( $part_pkg->_pkg_svc ) {
958 my $c_pkg_svc = new FS::pkg_svc { $pkg_svc->hash };
959 $c_pkg_svc->pkgsvcnum('');
960 $c_pkg_svc->pkgpart( $custom_part_pkg->pkgpart );
961 my $error = $c_pkg_svc->insert;
962 return "error customizing package: $error" if $error;
965 $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
969 my %order_pkg = ( 'cust_pkg' => $cust_pkg );
971 my @loc_fields = qw( address1 address2 city county state zip country );
972 if ( grep length($opt{$_}), @loc_fields ) {
973 $order_pkg{'cust_location'} = new FS::cust_location {
974 map { $_ => $opt{$_} } @loc_fields, 'custnum'
978 $order_pkg{'invoice_details'} = $opt{'invoice_details'}
979 if $opt{'invoice_details'};
981 my $error = $cust_main->order_pkg( %order_pkg );
984 return { 'error' => $error,
988 # return { 'error' => '',
989 # #cust_main->order_pkg doesn't actually have a way to return pkgnum
990 # #'pkgnum' => $pkgnum,
996 =item change_package_location
998 Updates package location. Takes a list of keys and values
999 as parameters with the following keys:
1005 locationnum - pass this, or the following keys (don't pass both)
1037 On error, returns a hashref with an 'error' key.
1038 On success, returns a hashref with 'pkgnum' and 'locationnum' keys,
1039 containing the new values.
1043 sub change_package_location {
1046 return _shared_secret_error() unless _check_shared_secret($opt{'secret'});
1048 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} })
1049 or return { 'error' => 'Unknown pkgnum' };
1053 foreach my $field ( qw(
1071 $changeopt{$field} = $opt{$field} if $opt{$field};
1074 $cust_pkg->API_change(%changeopt);
1077 =item bill_now OPTION => VALUE, ...
1079 Bills a single customer now, in the same fashion as the "Bill now" link in the
1082 Returns a hash reference with a single key, 'error'. If there is an error,
1083 the value contains the error, otherwise it is empty. Takes a list of keys and
1084 values as parameters with the following keys:
1090 API Secret (required)
1094 Customer number (required)
1101 my( $class, %opt ) = @_;
1102 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1104 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
1105 or return { 'error' => 'Unknown custnum' };
1107 my $error = $cust_main->bill_and_collect( 'fatal' => 'return',
1109 'check_freq' =>'1d',
1112 return { 'error' => $error,
1118 #next.. Delete Advertising sources?
1120 =item list_advertising_sources OPTION => VALUE, ...
1122 Lists all advertising sources.
1134 my $result = FS::API->list_advertising_sources(
1135 'secret' => 'sharingiscaring',
1138 if ( $result->{'error'} ) {
1139 die $result->{'error'};
1141 # list advertising sources returns an array of hashes for sources.
1142 print Dumper($result->{'sources'});
1147 #list_advertising_sources
1148 sub list_advertising_sources {
1149 my( $class, %opt ) = @_;
1150 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1152 my @sources = qsearch('part_referral', {}, '', "")
1153 or return { 'error' => 'No referrals' };
1156 'sources' => [ map $_->hashref, @sources ],
1162 =item add_advertising_source OPTION => VALUE, ...
1164 Add a new advertising source.
1178 Referral disabled, Y for disabled or nothing for enabled
1186 External referral ID
1192 my $result = FS::API->add_advertising_source(
1193 'secret' => 'sharingiscaring',
1194 'referral' => 'test referral',
1198 'agentnum' => '2', #agent id number
1199 'title' => 'test title',
1202 if ( $result->{'error'} ) {
1203 die $result->{'error'};
1205 # add_advertising_source returns new source upon success.
1206 print Dumper($result);
1211 #add_advertising_source
1212 sub add_advertising_source {
1213 my( $class, %opt ) = @_;
1214 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1216 use FS::part_referral;
1218 my $new_source = $opt{source};
1220 my $source = new FS::part_referral $new_source;
1222 my $error = $source->insert;
1224 my $return = {$source->hash};
1225 $return = { 'error' => $error, } if $error;
1230 =item edit_advertising_source OPTION => VALUE, ...
1232 Edit a advertising source.
1242 Referral number to edit
1246 hash of edited source fields.
1256 Referral disabled, Y for disabled or nothing for enabled
1264 External referral ID
1272 my $result = FS::API->edit_advertising_source(
1273 'secret' => 'sharingiscaring',
1274 'refnum' => '4', # referral number to edit
1277 'referral' => 'test referral',
1279 'agentnum' => '2', #agent id number
1280 'title' => 'test title',
1284 if ( $result->{'error'} ) {
1285 die $result->{'error'};
1287 # edit_advertising_source returns updated source upon success.
1288 print Dumper($result);
1293 #edit_advertising_source
1294 sub edit_advertising_source {
1295 my( $class, %opt ) = @_;
1296 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1298 use FS::part_referral;
1300 my $refnum = $opt{refnum};
1301 my $source = $opt{source};
1303 my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
1304 my $new = new FS::part_referral { $old->hash };
1306 foreach my $key (keys %$source) {
1307 $new->$key($source->{$key});
1310 my $error = $new->replace;
1312 my $return = {$new->hash};
1313 $return = { 'error' => $error, } if $error;
1320 # helper subroutines
1323 sub _check_shared_secret {
1324 shift eq FS::Conf->new->config('api_shared_secret');
1327 sub _shared_secret_error {
1328 return { 'error' => 'Incorrect shared secret' };