4 use FS::Record qw( qsearch qsearchs );
14 FS::API - Freeside backend API
22 This module implements a backend API for advanced back-office integration.
24 In contrast to the self-service API, which authenticates an end-user and offers
25 functionality to that end user, the backend API performs a simple shared-secret
26 authentication and offers full, administrator functionality, enabling
27 integration with other back-office systems. Only access this API from a secure
28 network from other backoffice machines. DON'T use this API to create customer
31 If accessing this API remotely with XML-RPC or JSON-RPC, be careful to block
32 the port by default, only allow access from back-office servers with the same
33 security precations as the Freeside server, and encrypt the communication
34 channel (for example, with an SSH tunnel or VPN) rather than accessing it
41 =item insert_payment OPTION => VALUE, ...
43 Adds a new payment to a customers account. Takes a list of keys and values as
44 paramters with the following keys:
66 Option date for payment
76 my $result = FS::API->insert_payment(
77 'secret' => 'sharingiscaring',
83 '_date' => 1397977200, #UNIX timestamp
84 'order_number' => '12345',
87 if ( $result->{'error'} ) {
88 die $result->{'error'};
91 print "paynum ". $result->{'paynum'};
98 my($class, %opt) = @_;
99 return _shared_secret_error() unless _check_shared_secret($opt{secret});
101 #less "raw" than this? we are the backoffice API, and aren't worried
102 # about version migration ala cust_main/cust_location here
103 my $cust_pay = new FS::cust_pay { %opt };
104 my $error = $cust_pay->insert( 'manual'=>1 );
105 return { 'error' => $error,
106 'paynum' => $cust_pay->paynum,
110 # pass the phone number ( from svc_phone )
111 sub insert_payment_phonenum {
112 my($class, %opt) = @_;
113 $class->_by_phonenum('insert_payment', %opt);
117 my($class, $method, %opt) = @_;
118 return _shared_secret_error() unless _check_shared_secret($opt{secret});
120 my $phonenum = delete $opt{'phonenum'};
122 my $svc_phone = qsearchs('svc_phone', { 'phonenum' => $phonenum } )
123 or return { 'error' => 'Unknown phonenum' };
125 my $cust_pkg = $svc_phone->cust_svc->cust_pkg
126 or return { 'error' => 'Unlinked phonenum' };
128 $opt{'custnum'} = $cust_pkg->custnum;
130 $class->$method(%opt);
133 =item insert_credit OPTION => VALUE, ...
135 Adds a a credit to a customers account. Takes a list of keys and values as
136 parameters with the following keys
154 The date the credit will be posted
160 my $result = FS::API->insert_credit(
161 'secret' => 'sharingiscaring',
166 '_date' => 1397977200, #UNIX timestamp
169 if ( $result->{'error'} ) {
170 die $result->{'error'};
173 print "crednum ". $result->{'crednum'};
180 my($class, %opt) = @_;
181 return _shared_secret_error() unless _check_shared_secret($opt{secret});
183 $opt{'reasonnum'} ||= FS::Conf->new->config('api_credit_reason');
185 #less "raw" than this? we are the backoffice API, and aren't worried
186 # about version migration ala cust_main/cust_location here
187 my $cust_credit = new FS::cust_credit { %opt };
188 my $error = $cust_credit->insert;
189 return { 'error' => $error,
190 'crednum' => $cust_credit->crednum,
194 # pass the phone number ( from svc_phone )
195 sub insert_credit_phonenum {
196 my($class, %opt) = @_;
197 $class->_by_phonenum('insert_credit', %opt);
200 =item apply_payments_and_credits
202 Applies payments and credits for this customer. Takes a list of keys and
203 values as parameter with the following keys:
219 #apply payments and credits
220 sub apply_payments_and_credits {
221 my($class, %opt) = @_;
222 return _shared_secret_error() unless _check_shared_secret($opt{secret});
224 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
225 or return { 'error' => 'Unknown custnum' };
227 my $error = $cust_main->apply_payments_and_credits( 'manual'=>1 );
228 return { 'error' => $error, };
231 =item insert_refund OPTION => VALUE, ...
233 Adds a a credit to a customers account. Takes a list of keys and values as
234 parmeters with the following keys: custnum, payby, refund
238 my $result = FS::API->insert_refund(
239 'secret' => 'sharingiscaring',
245 '_date' => 1397977200, #UNIX timestamp
248 if ( $result->{'error'} ) {
249 die $result->{'error'};
252 print "refundnum ". $result->{'crednum'};
259 my($class, %opt) = @_;
260 return _shared_secret_error() unless _check_shared_secret($opt{secret});
262 # when github pull request #24 is merged,
263 # will have to change over to default reasonnum like credit
264 # but until then, this will do
265 $opt{'reason'} ||= 'API refund';
267 #less "raw" than this? we are the backoffice API, and aren't worried
268 # about version migration ala cust_main/cust_location here
269 my $cust_refund = new FS::cust_refund { %opt };
270 my $error = $cust_refund->insert;
271 return { 'error' => $error,
272 'refundnum' => $cust_refund->refundnum,
276 # pass the phone number ( from svc_phone )
277 sub insert_refund_phonenum {
278 my($class, %opt) = @_;
279 $class->_by_phonenum('insert_refund', %opt);
284 # "2 way syncing" ? start with non-sync pulling info here, then if necessary
285 # figure out how to trigger something when those things change
287 # long-term: package changes?
289 =item new_customer OPTION => VALUE, ...
291 Creates a new customer. Takes a list of keys and values as parameters with the
302 first name (required)
310 (not typically collected; mostly used for ACH transactions)
316 =item address1 (required)
320 =item city (required)
328 =item state (required)
350 Currently used for third party tax vendor lookups
354 Used for determining FCC 477 reporting
358 Used for determining FCC 477 reporting
378 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),
380 Set to 1 to enable postal invoicing
384 CARD, DCRD, CHEK, DCHK, LECB, BILL, COMP or PREPAY
388 Card number for CARD/DCRD, account_number@aba_number for CHEK/DCHK, prepaid "pin" for PREPAY, purchase order number for BILL
392 Credit card CVV2 number (1.5+ or 1.4.2 with CVV schema patch)
396 Expiration date for CARD/DCRD
400 Exact name on credit card for CARD/DCRD, bank name for CHEK/DCHK
402 =item referral_custnum
404 Referring customer number
416 Agent specific customer number
418 =item referral_custnum
420 Referring customer number
426 #certainly false laziness w/ClientAPI::Signup new_customer/new_customer_minimal
427 # but approaching this from a clean start / back-office perspective
428 # i.e. no package/service, no immediate credit card run, etc.
431 my( $class, %opt ) = @_;
432 return _shared_secret_error() unless _check_shared_secret($opt{secret});
434 #default agentnum like signup_server-default_agentnum?
436 #same for refnum like signup_server-default_refnum
438 my $cust_main = new FS::cust_main ( {
439 'refnum' => $opt{refnum}
440 || FS::Conf->new->config('signup_server-default_refnum'),
442 'tagnum' => [ FS::part_tag->default_tags ],
444 map { $_ => $opt{$_} } qw(
445 agentnum salesnum refnum agent_custid referral_custnum
447 daytime night fax mobile
448 payby payinfo paydate paycvv payname
453 my @invoicing_list = $opt{'invoicing_list'}
454 ? split( /\s*\,\s*/, $opt{'invoicing_list'} )
456 push @invoicing_list, 'POST' if $opt{'postal_invoicing'};
458 my ($bill_hash, $ship_hash);
459 foreach my $f (FS::cust_main->location_fields) {
460 # avoid having to change this in front-end code
461 $bill_hash->{$f} = $opt{"bill_$f"} || $opt{$f};
462 $ship_hash->{$f} = $opt{"ship_$f"};
465 my $bill_location = FS::cust_location->new($bill_hash);
467 # we don't have an equivalent of the "same" checkbox in selfservice^Wthis API
468 # so is there a ship address, and if so, is it different from the billing
470 if ( length($ship_hash->{address1}) > 0 and
471 grep { $bill_hash->{$_} ne $ship_hash->{$_} } keys(%$ship_hash)
474 $ship_location = FS::cust_location->new( $ship_hash );
477 $ship_location = $bill_location;
480 $cust_main->set('bill_location' => $bill_location);
481 $cust_main->set('ship_location' => $ship_location);
483 $error = $cust_main->insert( {}, \@invoicing_list );
484 return { 'error' => $error } if $error;
486 return { 'error' => '',
487 'custnum' => $cust_main->custnum,
492 =item update_customer
494 Updates an existing customer. Passing an empty value clears that field, while
495 NOT passing that key/value at all leaves it alone. Takes a list of keys and
496 values as parameters with the following keys:
502 API Secret (required)
506 Customer number (required)
562 Comma-separated list of email addresses for email invoices. The special value
563 'POST' is used to designate postal invoicing (it may be specified alone or in
564 addition to email addresses)
568 CARD, DCRD, CHEK, DCHK, LECB, BILL, COMP or PREPAY
572 Card number for CARD/DCRD, account_number@aba_number for CHEK/DCHK, prepaid
573 +"pin" for PREPAY, purchase order number for BILL
577 Credit card CVV2 number (1.5+ or 1.4.2 with CVV schema patch)
581 Expiration date for CARD/DCRD
585 Exact name on credit card for CARD/DCRD, bank name for CHEK/DCHK
587 =item referral_custnum
589 Referring customer number
603 sub update_customer {
604 my( $class, %opt ) = @_;
605 return _shared_secret_error() unless _check_shared_secret($opt{secret});
607 my $custnum = $opt{'custnum'}
608 or return { 'error' => "no customer record" };
610 my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
611 or return { 'error' => "unknown custnum $custnum" };
613 my $new = new FS::cust_main { $cust_main->hash };
615 $new->set( $_ => $opt{$_} )
616 foreach grep { exists $opt{$_} } qw(
617 agentnum salesnum refnum agent_custid referral_custnum
619 daytime night fax mobile
620 payby payinfo paydate paycvv payname
624 if ( exists $opt{'invoicing_list'} || exists $opt{'postal_invoicing'} ) {
625 @invoicing_list = split( /\s*\,\s*/, $opt{'invoicing_list'} );
626 push @invoicing_list, 'POST' if $opt{'postal_invoicing'};
628 @invoicing_list = $cust_main->invoicing_list;
631 if ( exists( $opt{'address1'} ) ) {
632 my $bill_location = FS::cust_location->new({
633 map { $_ => $opt{$_} } @location_editable_fields
635 $bill_location->set('custnum' => $custnum);
636 my $error = $bill_location->find_or_insert;
637 die $error if $error;
639 # if this is unchanged from before, cust_main::replace will ignore it
640 $new->set('bill_location' => $bill_location);
643 if ( exists($opt{'ship_address1'}) && length($opt{"ship_address1"}) > 0 ) {
644 my $ship_location = FS::cust_location->new({
645 map { $_ => $opt{"ship_$_"} } @location_editable_fields
648 $ship_location->set('custnum' => $custnum);
649 my $error = $ship_location->find_or_insert;
650 die $error if $error;
652 $new->set('ship_location' => $ship_location);
654 } elsif (exists($opt{'ship_address1'} ) && !grep { length($opt{"ship_$_"}) } @location_editable_fields ) {
655 my $ship_location = $new->bill_location;
656 $new->set('ship_location' => $ship_location);
659 my $error = $new->replace( $cust_main, \@invoicing_list );
660 return { 'error' => $error } if $error;
662 return { 'error' => '',
669 Returns general customer information. Takes a list of keys and values as
670 parameters with the following keys: custnum, secret
674 #some false laziness w/ClientAPI::Myaccount customer_info/customer_info_short
676 use vars qw( @cust_main_editable_fields @location_editable_fields );
677 @cust_main_editable_fields = qw(
678 first last company daytime night fax mobile
681 # payby payinfo payname paystart_month paystart_year payissue payip
682 # ss paytype paystate stateid stateid_state
683 @location_editable_fields = qw(
684 address1 address2 city county state zip country
688 my( $class, %opt ) = @_;
689 return _shared_secret_error() unless _check_shared_secret($opt{secret});
691 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
692 or return { 'error' => 'Unknown custnum' };
696 'display_custnum' => $cust_main->display_custnum,
697 'name' => $cust_main->first. ' '. $cust_main->get('last'),
698 'balance' => $cust_main->balance,
699 'status' => $cust_main->status,
700 'statuscolor' => $cust_main->statuscolor,
703 $return{$_} = $cust_main->get($_)
704 foreach @cust_main_editable_fields;
706 for (@location_editable_fields) {
707 $return{$_} = $cust_main->bill_location->get($_)
708 if $cust_main->bill_locationnum;
709 $return{'ship_'.$_} = $cust_main->ship_location->get($_)
710 if $cust_main->ship_locationnum;
713 my @invoicing_list = $cust_main->invoicing_list;
714 $return{'invoicing_list'} =
715 join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
716 $return{'postal_invoicing'} =
717 0 < ( grep { $_ eq 'POST' } @invoicing_list );
719 #generally, the more useful data from the cust_main record the better.
720 # well, tell me what you want
727 =item customer_list_svcs OPTION => VALUE, ...
729 Returns customer service information. Takes a list of keys and values as
730 parameters with the following keys: custnum, secret
734 sub customer_list_svcs {
735 my( $class, %opt ) = @_;
736 return _shared_secret_error() unless _check_shared_secret($opt{secret});
738 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
739 or return { 'error' => 'Unknown custnum' };
741 #$cust_main->API_list_svcs;
743 #false laziness w/ClientAPI/list_svcs
746 #my @cust_pkg_usage = ();
747 #foreach my $cust_pkg ( $p->{'ncancelled'}
748 # ? $cust_main->ncancelled_pkgs
749 # : $cust_main->unsuspended_pkgs ) {
750 foreach my $cust_pkg ( $cust_main->all_pkgs ) {
751 #next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
752 push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
753 #push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
757 'cust_svc' => [ map $_->API_getinfo, @cust_svc ],
764 Returns location specific information for the customer. Takes a list of keys
765 and values as paramters with the following keys: custnum, secret
769 #I also monitor for changes to the additional locations that are applied to
770 # packages, and would like for those to be exportable as well. basically the
771 # location data passed with the custnum.
774 my( $class, %opt ) = @_;
775 return _shared_secret_error() unless _check_shared_secret($opt{secret});
777 my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} });
781 'locations' => [ map $_->hashref, @cust_location ],
787 =item list_customer_packages OPTION => VALUE, ...
789 Lists all customer packages.
805 my $result = FS::API->list_packages(
806 'secret' => 'sharingiscaring',
807 'custnum' => custnum,
810 if ( $result->{'error'} ) {
811 die $result->{'error'};
813 # list packages returns an array of hashes for packages ordered by custnum and pkgnum.
814 print Dumper($result->{'pkgs'});
819 sub list_customer_packages {
820 my( $class, %opt ) = @_;
821 return _shared_secret_error() unless _check_shared_secret($opt{secret});
823 my $sql_query = FS::cust_pkg->search({ 'custnum' => $opt{custnum}, });
825 $sql_query->{order_by} = 'ORDER BY custnum, pkgnum';
827 my @packages = qsearch($sql_query)
828 or return { 'error' => 'No packages' };
831 'packages' => [ map $_->hashref, @packages ],
837 =item package_status OPTION => VALUE, ...
855 my $result = FS::API->package_status(
856 'secret' => 'sharingiscaring',
860 if ( $result->{'error'} ) {
861 die $result->{'error'};
863 # package status returns a hash with the status for a package.
864 print Dumper($result->{'status'});
870 my( $class, %opt ) = @_;
871 return _shared_secret_error() unless _check_shared_secret($opt{secret});
873 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{pkgnum} } )
874 or return { 'error' => 'No packages' };
877 'status' => $cust_pkg->status,
883 =item order_package OPTION => VALUE, ...
885 Orders a new customer package. Takes a list of keys and values as paramaters
886 with the following keys:
920 Including this implements per-customer custom pricing for this package, overriding package definition pricing
924 Including this implements per-customer custom pricing for this package, overriding package definition pricing
926 =item invoice_details
928 A single string for just one detail line, or an array reference of one or more
934 my( $class, %opt ) = @_;
936 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
937 or return { 'error' => 'Unknown custnum' };
939 #some conceptual false laziness w/cust_pkg/Import.pm
941 my $cust_pkg = new FS::cust_pkg {
942 'pkgpart' => $opt{'pkgpart'},
943 'quantity' => $opt{'quantity'} || 1,
946 #start_date and contract_end
947 foreach my $date_field (qw( start_date contract_end )) {
948 if ( $opt{$date_field} =~ /^(\d+)$/ ) {
949 $cust_pkg->$date_field( $opt{$date_field} );
950 } elsif ( $opt{$date_field} ) {
951 $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
955 #especially this part for custom pkg price
956 # (false laziness w/cust_pkg/Import.pm)
957 my $s = $opt{'setup_fee'};
958 my $r = $opt{'recur_fee'};
959 my $part_pkg = $cust_pkg->part_pkg;
960 if ( ( length($s) && $s != $part_pkg->option('setup_fee') )
961 or ( length($r) && $r != $part_pkg->option('recur_fee') )
965 local($FS::part_pkg::skip_pkg_svc_hack) = 1;
967 my $custom_part_pkg = $part_pkg->clone;
968 $custom_part_pkg->disabled('Y');
969 my %options = $part_pkg->options;
970 $options{'setup_fee'} = $s if length($s);
971 $options{'recur_fee'} = $r if length($r);
972 my $error = $custom_part_pkg->insert( options=>\%options );
973 return ( 'error' => "error customizing package: $error" ) if $error;
975 #not ->pkg_svc, we want to ignore links and clone the actual package def
976 foreach my $pkg_svc ( $part_pkg->_pkg_svc ) {
977 my $c_pkg_svc = new FS::pkg_svc { $pkg_svc->hash };
978 $c_pkg_svc->pkgsvcnum('');
979 $c_pkg_svc->pkgpart( $custom_part_pkg->pkgpart );
980 my $error = $c_pkg_svc->insert;
981 return "error customizing package: $error" if $error;
984 $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
988 my %order_pkg = ( 'cust_pkg' => $cust_pkg );
990 my @loc_fields = qw( address1 address2 city county state zip country );
991 if ( grep length($opt{$_}), @loc_fields ) {
992 $order_pkg{'cust_location'} = new FS::cust_location {
993 map { $_ => $opt{$_} } @loc_fields, 'custnum'
997 $order_pkg{'invoice_details'} = $opt{'invoice_details'}
998 if $opt{'invoice_details'};
1000 my $error = $cust_main->order_pkg( %order_pkg );
1003 return { 'error' => $error,
1007 # return { 'error' => '',
1008 # #cust_main->order_pkg doesn't actually have a way to return pkgnum
1009 # #'pkgnum' => $pkgnum,
1015 =item change_package_location
1017 Updates package location. Takes a list of keys and values
1018 as paramters with the following keys:
1024 locationnum - pass this, or the following keys (don't pass both)
1056 On error, returns a hashref with an 'error' key.
1057 On success, returns a hashref with 'pkgnum' and 'locationnum' keys,
1058 containing the new values.
1062 sub change_package_location {
1065 return _shared_secret_error() unless _check_shared_secret($opt{'secret'});
1067 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} })
1068 or return { 'error' => 'Unknown pkgnum' };
1072 foreach my $field ( qw(
1090 $changeopt{$field} = $opt{$field} if $opt{$field};
1093 $cust_pkg->API_change(%changeopt);
1096 =item bill_now OPTION => VALUE, ...
1098 Bills a single customer now, in the same fashion as the "Bill now" link in the
1101 Returns a hash reference with a single key, 'error'. If there is an error,
1102 the value contains the error, otherwise it is empty. Takes a list of keys and
1103 values as parameters with the following keys:
1109 API Secret (required)
1113 Customer number (required)
1120 my( $class, %opt ) = @_;
1121 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1123 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
1124 or return { 'error' => 'Unknown custnum' };
1126 my $error = $cust_main->bill_and_collect( 'fatal' => 'return',
1128 'check_freq' =>'1d',
1131 return { 'error' => $error,
1137 #next.. Delete Advertising sources?
1139 =item list_advertising_sources OPTION => VALUE, ...
1141 Lists all advertising sources.
1153 my $result = FS::API->list_advertising_sources(
1154 'secret' => 'sharingiscaring',
1157 if ( $result->{'error'} ) {
1158 die $result->{'error'};
1160 # list advertising sources returns an array of hashes for sources.
1161 print Dumper($result->{'sources'});
1166 #list_advertising_sources
1167 sub list_advertising_sources {
1168 my( $class, %opt ) = @_;
1169 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1171 my @sources = qsearch('part_referral', {}, '', "")
1172 or return { 'error' => 'No referrals' };
1175 'sources' => [ map $_->hashref, @sources ],
1181 =item add_advertising_source OPTION => VALUE, ...
1183 Add a new advertising source.
1197 Referral disabled, Y for disabled or nothing for enabled
1205 External referral ID
1211 my $result = FS::API->add_advertising_source(
1212 'secret' => 'sharingiscaring',
1213 'referral' => 'test referral',
1217 'agentnum' => '2', #agent id number
1218 'title' => 'test title',
1221 if ( $result->{'error'} ) {
1222 die $result->{'error'};
1224 # add_advertising_source returns new source upon success.
1225 print Dumper($result);
1230 #add_advertising_source
1231 sub add_advertising_source {
1232 my( $class, %opt ) = @_;
1233 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1235 use FS::part_referral;
1237 my $new_source = $opt{source};
1239 my $source = new FS::part_referral $new_source;
1241 my $error = $source->insert;
1243 my $return = {$source->hash};
1244 $return = { 'error' => $error, } if $error;
1249 =item edit_advertising_source OPTION => VALUE, ...
1251 Edit a advertising source.
1261 Referral number to edit
1265 hash of edited source fields.
1275 Referral disabled, Y for disabled or nothing for enabled
1283 External referral ID
1291 my $result = FS::API->edit_advertising_source(
1292 'secret' => 'sharingiscaring',
1293 'refnum' => '4', # referral number to edit
1296 'referral' => 'test referral',
1298 'agentnum' => '2', #agent id number
1299 'title' => 'test title',
1303 if ( $result->{'error'} ) {
1304 die $result->{'error'};
1306 # edit_advertising_source returns updated source upon success.
1307 print Dumper($result);
1312 #edit_advertising_source
1313 sub edit_advertising_source {
1314 my( $class, %opt ) = @_;
1315 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1317 use FS::part_referral;
1319 my $refnum = $opt{refnum};
1320 my $source = $opt{source};
1322 my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
1323 my $new = new FS::part_referral { $old->hash };
1325 foreach my $key (keys %$source) {
1326 $new->$key($source->{$key});
1329 my $error = $new->replace;
1331 my $return = {$new->hash};
1332 $return = { 'error' => $error, } if $error;
1339 # helper subroutines
1342 sub _check_shared_secret {
1343 shift eq FS::Conf->new->config('api_shared_secret');
1346 sub _shared_secret_error {
1347 return { 'error' => 'Incorrect shared secret' };