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 $class->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);
579 my( $class, %opt ) = @_;
580 return _shared_secret_error() unless _check_shared_secret($opt{secret});
582 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
583 or return { 'error' => 'Unknown custnum' };
585 $cust_main->API_getinfo;
588 =item customer_list_svcs OPTION => VALUE, ...
590 Returns customer service information. Takes a list of keys and values as
591 parameters with the following keys: custnum, secret
595 use Frontier::Client;
598 my $url = new URI 'http://localhost:8008/'; #or if accessing remotely, secure
601 my $xmlrpc = new Frontier::Client url=>$url;
603 my $result = $xmlrpc->call( 'FS.API.customer_list_svcs',
604 'secret' => 'sharingiscaring',
608 print Dumper($result);
610 foreach my $cust_svc ( @{ $result->{'cust_svc'} } ) {
611 #print $cust_svc->{mac_addr}."\n" if exists $cust_svc->{mac_addr};
612 print $cust_svc->{circuit_id}."\n" if exists $cust_svc->{circuit_id};
617 sub customer_list_svcs {
618 my( $class, %opt ) = @_;
619 return _shared_secret_error() unless _check_shared_secret($opt{secret});
621 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
622 or return { 'error' => 'Unknown custnum' };
624 #$cust_main->API_list_svcs;
626 #false laziness w/ClientAPI/list_svcs
629 #my @cust_pkg_usage = ();
630 #foreach my $cust_pkg ( $p->{'ncancelled'}
631 # ? $cust_main->ncancelled_pkgs
632 # : $cust_main->unsuspended_pkgs ) {
633 foreach my $cust_pkg ( $cust_main->all_pkgs ) {
634 #next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
635 push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
636 #push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
640 'cust_svc' => [ map $_->API_getinfo, @cust_svc ],
647 Returns location specific information for the customer. Takes a list of keys
648 and values as paramters with the following keys: custnum, secret
652 #I also monitor for changes to the additional locations that are applied to
653 # packages, and would like for those to be exportable as well. basically the
654 # location data passed with the custnum.
657 my( $class, %opt ) = @_;
658 return _shared_secret_error() unless _check_shared_secret($opt{secret});
660 my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} });
664 'locations' => [ map $_->hashref, @cust_location ],
670 =item list_customer_packages OPTION => VALUE, ...
672 Lists all customer packages.
688 my $result = FS::API->list_packages(
689 'secret' => 'sharingiscaring',
690 'custnum' => custnum,
693 if ( $result->{'error'} ) {
694 die $result->{'error'};
696 # list packages returns an array of hashes for packages ordered by custnum and pkgnum.
697 print Dumper($result->{'pkgs'});
702 sub list_customer_packages {
703 my( $class, %opt ) = @_;
704 return _shared_secret_error() unless _check_shared_secret($opt{secret});
706 my $sql_query = FS::cust_pkg->search({ 'custnum' => $opt{custnum}, });
708 $sql_query->{order_by} = 'ORDER BY custnum, pkgnum';
710 my @packages = qsearch($sql_query)
711 or return { 'error' => 'No packages' };
714 'packages' => [ map $_->hashref, @packages ],
720 =item package_status OPTION => VALUE, ...
738 my $result = FS::API->package_status(
739 'secret' => 'sharingiscaring',
743 if ( $result->{'error'} ) {
744 die $result->{'error'};
746 # package status returns a hash with the status for a package.
747 print Dumper($result->{'status'});
753 my( $class, %opt ) = @_;
754 return _shared_secret_error() unless _check_shared_secret($opt{secret});
756 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{pkgnum} } )
757 or return { 'error' => 'No packages' };
760 'status' => $cust_pkg->status,
766 =item order_package OPTION => VALUE, ...
768 Orders a new customer package. Takes a list of keys and values as paramaters
769 with the following keys:
803 Including this implements per-customer custom pricing for this package, overriding package definition pricing
807 Including this implements per-customer custom pricing for this package, overriding package definition pricing
809 =item invoice_details
811 A single string for just one detail line, or an array reference of one or more
817 my( $class, %opt ) = @_;
819 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
820 or return { 'error' => 'Unknown custnum' };
822 #some conceptual false laziness w/cust_pkg/Import.pm
824 my $cust_pkg = new FS::cust_pkg {
825 'pkgpart' => $opt{'pkgpart'},
826 'quantity' => $opt{'quantity'} || 1,
829 #start_date and contract_end
830 foreach my $date_field (qw( start_date contract_end )) {
831 if ( $opt{$date_field} =~ /^(\d+)$/ ) {
832 $cust_pkg->$date_field( $opt{$date_field} );
833 } elsif ( $opt{$date_field} ) {
834 $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
838 #especially this part for custom pkg price
839 # (false laziness w/cust_pkg/Import.pm)
840 my $s = $opt{'setup_fee'};
841 my $r = $opt{'recur_fee'};
842 my $part_pkg = $cust_pkg->part_pkg;
843 if ( ( length($s) && $s != $part_pkg->option('setup_fee') )
844 or ( length($r) && $r != $part_pkg->option('recur_fee') )
848 local($FS::part_pkg::skip_pkg_svc_hack) = 1;
850 my $custom_part_pkg = $part_pkg->clone;
851 $custom_part_pkg->disabled('Y');
852 my %options = $part_pkg->options;
853 $options{'setup_fee'} = $s if length($s);
854 $options{'recur_fee'} = $r if length($r);
855 my $error = $custom_part_pkg->insert( options=>\%options );
856 return ( 'error' => "error customizing package: $error" ) if $error;
858 #not ->pkg_svc, we want to ignore links and clone the actual package def
859 foreach my $pkg_svc ( $part_pkg->_pkg_svc ) {
860 my $c_pkg_svc = new FS::pkg_svc { $pkg_svc->hash };
861 $c_pkg_svc->pkgsvcnum('');
862 $c_pkg_svc->pkgpart( $custom_part_pkg->pkgpart );
863 my $error = $c_pkg_svc->insert;
864 return "error customizing package: $error" if $error;
867 $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
871 my %order_pkg = ( 'cust_pkg' => $cust_pkg );
873 my @loc_fields = qw( address1 address2 city county state zip country );
874 if ( grep length($opt{$_}), @loc_fields ) {
875 $order_pkg{'cust_location'} = new FS::cust_location {
876 map { $_ => $opt{$_} } @loc_fields, 'custnum'
880 $order_pkg{'invoice_details'} = $opt{'invoice_details'}
881 if $opt{'invoice_details'};
883 my $error = $cust_main->order_pkg( %order_pkg );
886 return { 'error' => $error,
890 # return { 'error' => '',
891 # #cust_main->order_pkg doesn't actually have a way to return pkgnum
892 # #'pkgnum' => $pkgnum,
898 =item change_package_location
900 Updates package location. Takes a list of keys and values
901 as parameters with the following keys:
907 locationnum - pass this, or the following keys (don't pass both)
939 On error, returns a hashref with an 'error' key.
940 On success, returns a hashref with 'pkgnum' and 'locationnum' keys,
941 containing the new values.
945 sub change_package_location {
948 return _shared_secret_error() unless _check_shared_secret($opt{'secret'});
950 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} })
951 or return { 'error' => 'Unknown pkgnum' };
955 foreach my $field ( qw(
973 $changeopt{$field} = $opt{$field} if $opt{$field};
976 $cust_pkg->API_change(%changeopt);
979 =item bill_now OPTION => VALUE, ...
981 Bills a single customer now, in the same fashion as the "Bill now" link in the
984 Returns a hash reference with a single key, 'error'. If there is an error,
985 the value contains the error, otherwise it is empty. Takes a list of keys and
986 values as parameters with the following keys:
992 API Secret (required)
996 Customer number (required)
1003 my( $class, %opt ) = @_;
1004 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1006 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
1007 or return { 'error' => 'Unknown custnum' };
1009 my $error = $cust_main->bill_and_collect( 'fatal' => 'return',
1011 'check_freq' =>'1d',
1014 return { 'error' => $error,
1020 #next.. Delete Advertising sources?
1022 =item list_advertising_sources OPTION => VALUE, ...
1024 Lists all advertising sources.
1036 my $result = FS::API->list_advertising_sources(
1037 'secret' => 'sharingiscaring',
1040 if ( $result->{'error'} ) {
1041 die $result->{'error'};
1043 # list advertising sources returns an array of hashes for sources.
1044 print Dumper($result->{'sources'});
1049 #list_advertising_sources
1050 sub list_advertising_sources {
1051 my( $class, %opt ) = @_;
1052 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1054 my @sources = qsearch('part_referral', {}, '', "")
1055 or return { 'error' => 'No referrals' };
1058 'sources' => [ map $_->hashref, @sources ],
1064 =item add_advertising_source OPTION => VALUE, ...
1066 Add a new advertising source.
1080 Referral disabled, Y for disabled or nothing for enabled
1088 External referral ID
1094 my $result = FS::API->add_advertising_source(
1095 'secret' => 'sharingiscaring',
1096 'referral' => 'test referral',
1100 'agentnum' => '2', #agent id number
1101 'title' => 'test title',
1104 if ( $result->{'error'} ) {
1105 die $result->{'error'};
1107 # add_advertising_source returns new source upon success.
1108 print Dumper($result);
1113 #add_advertising_source
1114 sub add_advertising_source {
1115 my( $class, %opt ) = @_;
1116 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1118 use FS::part_referral;
1120 my $new_source = $opt{source};
1122 my $source = new FS::part_referral $new_source;
1124 my $error = $source->insert;
1126 my $return = {$source->hash};
1127 $return = { 'error' => $error, } if $error;
1132 =item edit_advertising_source OPTION => VALUE, ...
1134 Edit a advertising source.
1144 Referral number to edit
1148 hash of edited source fields.
1158 Referral disabled, Y for disabled or nothing for enabled
1166 External referral ID
1174 my $result = FS::API->edit_advertising_source(
1175 'secret' => 'sharingiscaring',
1176 'refnum' => '4', # referral number to edit
1179 'referral' => 'test referral',
1181 'agentnum' => '2', #agent id number
1182 'title' => 'test title',
1186 if ( $result->{'error'} ) {
1187 die $result->{'error'};
1189 # edit_advertising_source returns updated source upon success.
1190 print Dumper($result);
1195 #edit_advertising_source
1196 sub edit_advertising_source {
1197 my( $class, %opt ) = @_;
1198 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1200 use FS::part_referral;
1202 my $refnum = $opt{refnum};
1203 my $source = $opt{source};
1205 my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
1206 my $new = new FS::part_referral { $old->hash };
1208 foreach my $key (keys %$source) {
1209 $new->$key($source->{$key});
1212 my $error = $new->replace;
1214 my $return = {$new->hash};
1215 $return = { 'error' => $error, } if $error;
1222 # helper subroutines
1225 sub _check_shared_secret {
1226 shift eq FS::Conf->new->config('api_shared_secret');
1229 sub _shared_secret_error {
1230 return { 'error' => 'Incorrect shared secret' };