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
374 Optional shipping address fields. If sending an optional shipping address,
375 ship_address1, ship_city, ship_state and ship_zip are required.
395 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),
397 Set to 1 to enable postal invoicing
401 CARD, DCRD, CHEK, DCHK, LECB, BILL, COMP or PREPAY
405 Card number for CARD/DCRD, account_number@aba_number for CHEK/DCHK, prepaid "pin" for PREPAY, purchase order number for BILL
409 Credit card CVV2 number (1.5+ or 1.4.2 with CVV schema patch)
413 Expiration date for CARD/DCRD
417 Exact name on credit card for CARD/DCRD, bank name for CHEK/DCHK
419 =item referral_custnum
421 Referring customer number
433 Agent specific customer number
435 =item referral_custnum
437 Referring customer number
443 #certainly false laziness w/ClientAPI::Signup new_customer/new_customer_minimal
444 # but approaching this from a clean start / back-office perspective
445 # i.e. no package/service, no immediate credit card run, etc.
448 my( $class, %opt ) = @_;
449 return _shared_secret_error() unless _check_shared_secret($opt{secret});
451 #default agentnum like signup_server-default_agentnum?
453 #same for refnum like signup_server-default_refnum
455 my $cust_main = new FS::cust_main ( {
456 'refnum' => $opt{refnum}
457 || FS::Conf->new->config('signup_server-default_refnum'),
459 'tagnum' => [ FS::part_tag->default_tags ],
461 map { $_ => $opt{$_} } qw(
462 agentnum salesnum refnum agent_custid referral_custnum
464 daytime night fax mobile
465 payby payinfo paydate paycvv payname
470 my @invoicing_list = $opt{'invoicing_list'}
471 ? split( /\s*\,\s*/, $opt{'invoicing_list'} )
473 push @invoicing_list, 'POST' if $opt{'postal_invoicing'};
475 my ($bill_hash, $ship_hash);
476 foreach my $f (FS::cust_main->location_fields) {
477 # avoid having to change this in front-end code
478 $bill_hash->{$f} = $opt{"bill_$f"} || $opt{$f};
479 $ship_hash->{$f} = $opt{"ship_$f"};
482 my $bill_location = FS::cust_location->new($bill_hash);
484 # we don't have an equivalent of the "same" checkbox in selfservice^Wthis API
485 # so is there a ship address, and if so, is it different from the billing
487 if ( length($ship_hash->{address1}) > 0 and
488 grep { $bill_hash->{$_} ne $ship_hash->{$_} } keys(%$ship_hash)
491 $ship_location = FS::cust_location->new( $ship_hash );
494 $ship_location = $bill_location;
497 $cust_main->set('bill_location' => $bill_location);
498 $cust_main->set('ship_location' => $ship_location);
500 $error = $cust_main->insert( {}, \@invoicing_list );
501 return { 'error' => $error } if $error;
503 return { 'error' => '',
504 'custnum' => $cust_main->custnum,
509 =item update_customer
511 Updates an existing customer. Passing an empty value clears that field, while
512 NOT passing that key/value at all leaves it alone. Takes a list of keys and
513 values as parameters with the following keys:
519 API Secret (required)
523 Customer number (required)
579 Comma-separated list of email addresses for email invoices. The special value
580 'POST' is used to designate postal invoicing (it may be specified alone or in
581 addition to email addresses)
585 CARD, DCRD, CHEK, DCHK, LECB, BILL, COMP or PREPAY
589 Card number for CARD/DCRD, account_number@aba_number for CHEK/DCHK, prepaid
590 +"pin" for PREPAY, purchase order number for BILL
594 Credit card CVV2 number (1.5+ or 1.4.2 with CVV schema patch)
598 Expiration date for CARD/DCRD
602 Exact name on credit card for CARD/DCRD, bank name for CHEK/DCHK
604 =item referral_custnum
606 Referring customer number
620 sub update_customer {
621 my( $class, %opt ) = @_;
622 return _shared_secret_error() unless _check_shared_secret($opt{secret});
624 my $custnum = $opt{'custnum'}
625 or return { 'error' => "no customer record" };
627 my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
628 or return { 'error' => "unknown custnum $custnum" };
630 my $new = new FS::cust_main { $cust_main->hash };
632 $new->set( $_ => $opt{$_} )
633 foreach grep { exists $opt{$_} } qw(
634 agentnum salesnum refnum agent_custid referral_custnum
636 daytime night fax mobile
637 payby payinfo paydate paycvv payname
641 if ( exists $opt{'invoicing_list'} || exists $opt{'postal_invoicing'} ) {
642 @invoicing_list = split( /\s*\,\s*/, $opt{'invoicing_list'} );
643 push @invoicing_list, 'POST' if $opt{'postal_invoicing'};
645 @invoicing_list = $cust_main->invoicing_list;
648 if ( exists( $opt{'address1'} ) ) {
649 my $bill_location = FS::cust_location->new({
650 map { $_ => $opt{$_} } @location_editable_fields
652 $bill_location->set('custnum' => $custnum);
653 my $error = $bill_location->find_or_insert;
654 die $error if $error;
656 # if this is unchanged from before, cust_main::replace will ignore it
657 $new->set('bill_location' => $bill_location);
660 if ( exists($opt{'ship_address1'}) && length($opt{"ship_address1"}) > 0 ) {
661 my $ship_location = FS::cust_location->new({
662 map { $_ => $opt{"ship_$_"} } @location_editable_fields
665 $ship_location->set('custnum' => $custnum);
666 my $error = $ship_location->find_or_insert;
667 die $error if $error;
669 $new->set('ship_location' => $ship_location);
671 } elsif (exists($opt{'ship_address1'} ) && !grep { length($opt{"ship_$_"}) } @location_editable_fields ) {
672 my $ship_location = $new->bill_location;
673 $new->set('ship_location' => $ship_location);
676 my $error = $new->replace( $cust_main, \@invoicing_list );
677 return { 'error' => $error } if $error;
679 return { 'error' => '',
686 Returns general customer information. Takes a list of keys and values as
687 parameters with the following keys: custnum, secret
691 #some false laziness w/ClientAPI::Myaccount customer_info/customer_info_short
693 use vars qw( @cust_main_editable_fields @location_editable_fields );
694 @cust_main_editable_fields = qw(
695 first last company daytime night fax mobile
698 # payby payinfo payname paystart_month paystart_year payissue payip
699 # ss paytype paystate stateid stateid_state
700 @location_editable_fields = qw(
701 address1 address2 city county state zip country
705 my( $class, %opt ) = @_;
706 return _shared_secret_error() unless _check_shared_secret($opt{secret});
708 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
709 or return { 'error' => 'Unknown custnum' };
713 'display_custnum' => $cust_main->display_custnum,
714 'name' => $cust_main->first. ' '. $cust_main->get('last'),
715 'balance' => $cust_main->balance,
716 'status' => $cust_main->status,
717 'statuscolor' => $cust_main->statuscolor,
720 $return{$_} = $cust_main->get($_)
721 foreach @cust_main_editable_fields;
723 for (@location_editable_fields) {
724 $return{$_} = $cust_main->bill_location->get($_)
725 if $cust_main->bill_locationnum;
726 $return{'ship_'.$_} = $cust_main->ship_location->get($_)
727 if $cust_main->ship_locationnum;
730 my @invoicing_list = $cust_main->invoicing_list;
731 $return{'invoicing_list'} =
732 join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
733 $return{'postal_invoicing'} =
734 0 < ( grep { $_ eq 'POST' } @invoicing_list );
736 #generally, the more useful data from the cust_main record the better.
737 # well, tell me what you want
744 =item customer_list_svcs OPTION => VALUE, ...
746 Returns customer service information. Takes a list of keys and values as
747 parameters with the following keys: custnum, secret
751 sub customer_list_svcs {
752 my( $class, %opt ) = @_;
753 return _shared_secret_error() unless _check_shared_secret($opt{secret});
755 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
756 or return { 'error' => 'Unknown custnum' };
758 #$cust_main->API_list_svcs;
760 #false laziness w/ClientAPI/list_svcs
763 #my @cust_pkg_usage = ();
764 #foreach my $cust_pkg ( $p->{'ncancelled'}
765 # ? $cust_main->ncancelled_pkgs
766 # : $cust_main->unsuspended_pkgs ) {
767 foreach my $cust_pkg ( $cust_main->all_pkgs ) {
768 #next if $pkgnum && $cust_pkg->pkgnum != $pkgnum;
769 push @cust_svc, @{[ $cust_pkg->cust_svc ]}; #@{[ ]} to force array context
770 #push @cust_pkg_usage, $cust_pkg->cust_pkg_usage;
774 'cust_svc' => [ map $_->API_getinfo, @cust_svc ],
781 Returns location specific information for the customer. Takes a list of keys
782 and values as paramters with the following keys: custnum, secret
786 #I also monitor for changes to the additional locations that are applied to
787 # packages, and would like for those to be exportable as well. basically the
788 # location data passed with the custnum.
791 my( $class, %opt ) = @_;
792 return _shared_secret_error() unless _check_shared_secret($opt{secret});
794 my @cust_location = qsearch('cust_location', { 'custnum' => $opt{custnum} });
798 'locations' => [ map $_->hashref, @cust_location ],
804 =item list_customer_packages OPTION => VALUE, ...
806 Lists all customer packages.
822 my $result = FS::API->list_packages(
823 'secret' => 'sharingiscaring',
824 'custnum' => custnum,
827 if ( $result->{'error'} ) {
828 die $result->{'error'};
830 # list packages returns an array of hashes for packages ordered by custnum and pkgnum.
831 print Dumper($result->{'pkgs'});
836 sub list_customer_packages {
837 my( $class, %opt ) = @_;
838 return _shared_secret_error() unless _check_shared_secret($opt{secret});
840 my $sql_query = FS::cust_pkg->search({ 'custnum' => $opt{custnum}, });
842 $sql_query->{order_by} = 'ORDER BY custnum, pkgnum';
844 my @packages = qsearch($sql_query)
845 or return { 'error' => 'No packages' };
848 'packages' => [ map $_->hashref, @packages ],
854 =item package_status OPTION => VALUE, ...
872 my $result = FS::API->package_status(
873 'secret' => 'sharingiscaring',
877 if ( $result->{'error'} ) {
878 die $result->{'error'};
880 # package status returns a hash with the status for a package.
881 print Dumper($result->{'status'});
887 my( $class, %opt ) = @_;
888 return _shared_secret_error() unless _check_shared_secret($opt{secret});
890 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{pkgnum} } )
891 or return { 'error' => 'No packages' };
894 'status' => $cust_pkg->status,
900 =item order_package OPTION => VALUE, ...
902 Orders a new customer package. Takes a list of keys and values as paramaters
903 with the following keys:
937 Including this implements per-customer custom pricing for this package, overriding package definition pricing
941 Including this implements per-customer custom pricing for this package, overriding package definition pricing
943 =item invoice_details
945 A single string for just one detail line, or an array reference of one or more
951 my( $class, %opt ) = @_;
953 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
954 or return { 'error' => 'Unknown custnum' };
956 #some conceptual false laziness w/cust_pkg/Import.pm
958 my $cust_pkg = new FS::cust_pkg {
959 'pkgpart' => $opt{'pkgpart'},
960 'quantity' => $opt{'quantity'} || 1,
963 #start_date and contract_end
964 foreach my $date_field (qw( start_date contract_end )) {
965 if ( $opt{$date_field} =~ /^(\d+)$/ ) {
966 $cust_pkg->$date_field( $opt{$date_field} );
967 } elsif ( $opt{$date_field} ) {
968 $cust_pkg->$date_field( str2time( $opt{$date_field} ) );
972 #especially this part for custom pkg price
973 # (false laziness w/cust_pkg/Import.pm)
974 my $s = $opt{'setup_fee'};
975 my $r = $opt{'recur_fee'};
976 my $part_pkg = $cust_pkg->part_pkg;
977 if ( ( length($s) && $s != $part_pkg->option('setup_fee') )
978 or ( length($r) && $r != $part_pkg->option('recur_fee') )
982 local($FS::part_pkg::skip_pkg_svc_hack) = 1;
984 my $custom_part_pkg = $part_pkg->clone;
985 $custom_part_pkg->disabled('Y');
986 my %options = $part_pkg->options;
987 $options{'setup_fee'} = $s if length($s);
988 $options{'recur_fee'} = $r if length($r);
989 my $error = $custom_part_pkg->insert( options=>\%options );
990 return ( 'error' => "error customizing package: $error" ) if $error;
992 #not ->pkg_svc, we want to ignore links and clone the actual package def
993 foreach my $pkg_svc ( $part_pkg->_pkg_svc ) {
994 my $c_pkg_svc = new FS::pkg_svc { $pkg_svc->hash };
995 $c_pkg_svc->pkgsvcnum('');
996 $c_pkg_svc->pkgpart( $custom_part_pkg->pkgpart );
997 my $error = $c_pkg_svc->insert;
998 return "error customizing package: $error" if $error;
1001 $cust_pkg->pkgpart( $custom_part_pkg->pkgpart );
1005 my %order_pkg = ( 'cust_pkg' => $cust_pkg );
1007 my @loc_fields = qw( address1 address2 city county state zip country );
1008 if ( grep length($opt{$_}), @loc_fields ) {
1009 $order_pkg{'cust_location'} = new FS::cust_location {
1010 map { $_ => $opt{$_} } @loc_fields, 'custnum'
1014 $order_pkg{'invoice_details'} = $opt{'invoice_details'}
1015 if $opt{'invoice_details'};
1017 my $error = $cust_main->order_pkg( %order_pkg );
1020 return { 'error' => $error,
1024 # return { 'error' => '',
1025 # #cust_main->order_pkg doesn't actually have a way to return pkgnum
1026 # #'pkgnum' => $pkgnum,
1032 =item change_package_location
1034 Updates package location. Takes a list of keys and values
1035 as paramters with the following keys:
1041 locationnum - pass this, or the following keys (don't pass both)
1073 On error, returns a hashref with an 'error' key.
1074 On success, returns a hashref with 'pkgnum' and 'locationnum' keys,
1075 containing the new values.
1079 sub change_package_location {
1082 return _shared_secret_error() unless _check_shared_secret($opt{'secret'});
1084 my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $opt{'pkgnum'} })
1085 or return { 'error' => 'Unknown pkgnum' };
1089 foreach my $field ( qw(
1107 $changeopt{$field} = $opt{$field} if $opt{$field};
1110 $cust_pkg->API_change(%changeopt);
1113 =item bill_now OPTION => VALUE, ...
1115 Bills a single customer now, in the same fashion as the "Bill now" link in the
1118 Returns a hash reference with a single key, 'error'. If there is an error,
1119 the value contains the error, otherwise it is empty. Takes a list of keys and
1120 values as parameters with the following keys:
1126 API Secret (required)
1130 Customer number (required)
1137 my( $class, %opt ) = @_;
1138 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1140 my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
1141 or return { 'error' => 'Unknown custnum' };
1143 my $error = $cust_main->bill_and_collect( 'fatal' => 'return',
1145 'check_freq' =>'1d',
1148 return { 'error' => $error,
1154 #next.. Delete Advertising sources?
1156 =item list_advertising_sources OPTION => VALUE, ...
1158 Lists all advertising sources.
1170 my $result = FS::API->list_advertising_sources(
1171 'secret' => 'sharingiscaring',
1174 if ( $result->{'error'} ) {
1175 die $result->{'error'};
1177 # list advertising sources returns an array of hashes for sources.
1178 print Dumper($result->{'sources'});
1183 #list_advertising_sources
1184 sub list_advertising_sources {
1185 my( $class, %opt ) = @_;
1186 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1188 my @sources = qsearch('part_referral', {}, '', "")
1189 or return { 'error' => 'No referrals' };
1192 'sources' => [ map $_->hashref, @sources ],
1198 =item add_advertising_source OPTION => VALUE, ...
1200 Add a new advertising source.
1214 Referral disabled, Y for disabled or nothing for enabled
1222 External referral ID
1228 my $result = FS::API->add_advertising_source(
1229 'secret' => 'sharingiscaring',
1230 'referral' => 'test referral',
1234 'agentnum' => '2', #agent id number
1235 'title' => 'test title',
1238 if ( $result->{'error'} ) {
1239 die $result->{'error'};
1241 # add_advertising_source returns new source upon success.
1242 print Dumper($result);
1247 #add_advertising_source
1248 sub add_advertising_source {
1249 my( $class, %opt ) = @_;
1250 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1252 use FS::part_referral;
1254 my $new_source = $opt{source};
1256 my $source = new FS::part_referral $new_source;
1258 my $error = $source->insert;
1260 my $return = {$source->hash};
1261 $return = { 'error' => $error, } if $error;
1266 =item edit_advertising_source OPTION => VALUE, ...
1268 Edit a advertising source.
1278 Referral number to edit
1282 hash of edited source fields.
1292 Referral disabled, Y for disabled or nothing for enabled
1300 External referral ID
1308 my $result = FS::API->edit_advertising_source(
1309 'secret' => 'sharingiscaring',
1310 'refnum' => '4', # referral number to edit
1313 'referral' => 'test referral',
1315 'agentnum' => '2', #agent id number
1316 'title' => 'test title',
1320 if ( $result->{'error'} ) {
1321 die $result->{'error'};
1323 # edit_advertising_source returns updated source upon success.
1324 print Dumper($result);
1329 #edit_advertising_source
1330 sub edit_advertising_source {
1331 my( $class, %opt ) = @_;
1332 return _shared_secret_error() unless _check_shared_secret($opt{secret});
1334 use FS::part_referral;
1336 my $refnum = $opt{refnum};
1337 my $source = $opt{source};
1339 my $old = FS::Record::qsearchs('part_referral', {'refnum' => $refnum,});
1340 my $new = new FS::part_referral { $old->hash };
1342 foreach my $key (keys %$source) {
1343 $new->$key($source->{$key});
1346 my $error = $new->replace;
1348 my $return = {$new->hash};
1349 $return = { 'error' => $error, } if $error;
1356 # helper subroutines
1359 sub _check_shared_secret {
1360 shift eq FS::Conf->new->config('api_shared_secret');
1363 sub _shared_secret_error {
1364 return { 'error' => 'Incorrect shared secret' };