REST API, RT#28181
authorIvan Kohler <ivan@freeside.biz>
Wed, 16 Jul 2014 13:17:05 +0000 (06:17 -0700)
committerIvan Kohler <ivan@freeside.biz>
Wed, 16 Jul 2014 13:17:05 +0000 (06:17 -0700)
28 files changed:
FS/FS/API.pm
FS/FS/Mason.pm
FS/FS/Record.pm
FS/FS/UI/REST.pm [new file with mode: 0644]
FS/FS/cust_bill.pm
FS/FS/cust_main.pm
FS/FS/cust_main/API.pm [new file with mode: 0644]
FS/FS/cust_pay.pm
FS/FS/cust_pkg.pm
FS/FS/cust_pkg/API.pm [new file with mode: 0644]
FS/FS/cust_svc.pm
FS/FS/part_export/internal_diddb.pm
FS/FS/part_pkg.pm
FS/FS/part_pkg/API.pm [new file with mode: 0644]
htetc/freeside-base2.4.conf
htetc/freeside-base2.conf
httemplate/REST/1.0/.cust_bill.swp [new file with mode: 0644]
httemplate/REST/1.0/.cust_main.swp [new file with mode: 0644]
httemplate/REST/1.0/.cust_pkg.swp [new file with mode: 0644]
httemplate/REST/1.0/.part_pkg.swp [new file with mode: 0644]
httemplate/REST/1.0/.phone_avail.swp [new file with mode: 0644]
httemplate/REST/1.0/.rate_detail.swp [new file with mode: 0644]
httemplate/REST/1.0/cust_bill [new file with mode: 0644]
httemplate/REST/1.0/cust_main [new file with mode: 0644]
httemplate/REST/1.0/cust_pkg [new file with mode: 0644]
httemplate/REST/1.0/part_pkg [new file with mode: 0644]
httemplate/REST/1.0/phone_avail [new file with mode: 0644]
httemplate/REST/1.0/rate_detail [new file with mode: 0644]

index 2105409..a0f1dba 100644 (file)
@@ -476,19 +476,6 @@ Returns general customer information. Takes a hash reference as parameter with t
 
 =cut
 
-#some false laziness w/ClientAPI::Myaccount customer_info/customer_info_short
-
-use vars qw( @cust_main_editable_fields @location_editable_fields );
-@cust_main_editable_fields = qw(
-  first last company daytime night fax mobile
-);
-#  locale
-#  payby payinfo payname paystart_month paystart_year payissue payip
-#  ss paytype paystate stateid stateid_state
-@location_editable_fields = qw(
-  address1 address2 city county state zip country
-);
-
 sub customer_info {
   my( $class, %opt ) = @_;
   my $conf = new FS::Conf;
@@ -498,39 +485,10 @@ sub customer_info {
   my $cust_main = qsearchs('cust_main', { 'custnum' => $opt{custnum} })
     or return { 'error' => 'Unknown custnum' };
 
-  my %return = (
-    'error'           => '',
-    'display_custnum' => $cust_main->display_custnum,
-    'name'            => $cust_main->first. ' '. $cust_main->get('last'),
-    'balance'         => $cust_main->balance,
-    'status'          => $cust_main->status,
-    'statuscolor'     => $cust_main->statuscolor,
-  );
-
-  $return{$_} = $cust_main->get($_)
-    foreach @cust_main_editable_fields;
-
-  for (@location_editable_fields) {
-    $return{$_} = $cust_main->bill_location->get($_)
-      if $cust_main->bill_locationnum;
-    $return{'ship_'.$_} = $cust_main->ship_location->get($_)
-      if $cust_main->ship_locationnum;
-  }
-
-  my @invoicing_list = $cust_main->invoicing_list;
-  $return{'invoicing_list'} =
-    join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
-  $return{'postal_invoicing'} =
-    0 < ( grep { $_ eq 'POST' } @invoicing_list );
-
-  #generally, the more useful data from the cust_main record the better.
-  # well, tell me what you want
-
-  return \%return;
+  $cust_main->API_getinfo;
 
 }
 
-
 =item location_info
 
 Returns location specific information for the customer. Takes a hash reference as parameter with the following keys: custnum,secret
index 5476fd8..b7aa355 100644 (file)
@@ -138,6 +138,7 @@ if ( -e $addl_handler_use_file ) {
   use FS::UI::Web qw(svc_url);
   use FS::UI::Web::small_custview qw(small_custview);
   use FS::UI::bytecount;
+  use FS::UI::REST qw( rest_auth rest_uri_remain encode_rest );
   use FS::Msgcat qw(gettext geterror);
   use FS::Misc qw( send_email send_fax ocr_image
                    states_hash counties cities state_label
index a684425..b226e17 100644 (file)
@@ -367,6 +367,9 @@ sub qsearch {
   my @bind_type = ();
   my $dbh = dbh;
   foreach my $stable ( @stable ) {
+
+    carp '->qsearch on cust_main called' if $stable eq 'cust_main' && $DEBUG;
+
     #stop altering the caller's hashref
     my $record      = { %{ shift(@record) || {} } };#and be liberal in receipt
     my $select      = shift @select;
@@ -994,6 +997,8 @@ sub AUTOLOAD {
     eval "use FS::$table";
     die $@ if $@;
 
+    carp '->cust_main called' if $table eq 'cust_main' && $DEBUG;
+
     my $pkey_value = $self->$column();
     my %search = ( $foreign_column => $pkey_value );
 
@@ -1122,6 +1127,13 @@ sub hashref {
   $self->{'Hash'};
 }
 
+#fallback
+sub API_getinfo {
+  my $self = shift;
+  +{ ( map { $_=>$self->$_ } $self->fields ),
+   };
+}
+
 =item modified
 
 Returns true if any of this object's values have been modified with set (or via
diff --git a/FS/FS/UI/REST.pm b/FS/FS/UI/REST.pm
new file mode 100644 (file)
index 0000000..b6503ba
--- /dev/null
@@ -0,0 +1,38 @@
+package FS::UI::REST;
+use base qw( Exporter );
+
+use strict;
+use vars qw( @EXPORT_OK );
+use JSON::XS;
+use FS::UID qw( adminsuidsetup );
+use FS::Conf;
+
+@EXPORT_OK = qw( rest_auth rest_uri_remain encode_rest );
+
+sub rest_auth {
+  my $cgi = shift;
+  adminsuidsetup('fs_api');
+  my $conf = new FS::Conf;
+  die 'Incorrect shared secret'
+    unless $cgi->param('secret') eq $conf->config('api_shared_secret');
+}
+
+sub rest_uri_remain {
+  my($r, $m) = @_;
+
+  #wacky way to get this... surely there must be a better way
+
+  my $path = $m->request_comp->path;
+
+  $r->uri =~ /\Q$path\E\/?(.*)$/ or die "$path not in ". $r->uri;
+
+  $1;
+
+}
+
+sub encode_rest {
+  #XXX HTTP Accept header to send other formats besides JSON
+  encode_json(shift);
+}
+
+1;
index 4fb4a7d..7cee5d7 100644 (file)
@@ -3243,6 +3243,14 @@ sub re_X {
 
 }
 
+sub API_getinfo {
+  my $self = shift;
+  +{ ( map { $_=>$self->$_ } $self->fields ),
+     'owed' => $self->owed,
+     #XXX last payment applied date
+   };
+}
+
 =back
 
 =head1 CLASS METHODS
index 9f382ac..f0a4799 100644 (file)
@@ -8,6 +8,7 @@ use base qw( FS::cust_main::Packages
              FS::cust_main::Billing_ThirdParty
              FS::cust_main::Location
              FS::cust_main::Credit_Limit
+             FS::cust_main::API
              FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
              FS::geocode_Mixin FS::Quotable_Mixin FS::Sales_Mixin
              FS::o2m_Common
diff --git a/FS/FS/cust_main/API.pm b/FS/FS/cust_main/API.pm
new file mode 100644 (file)
index 0000000..2637c7e
--- /dev/null
@@ -0,0 +1,63 @@
+package FS::cust_main::API;
+
+use strict;
+
+#some false laziness w/ClientAPI::Myaccount customer_info/customer_info_short
+
+use vars qw(
+  @cust_main_addl_fields @cust_main_editable_fields @location_editable_fields
+);
+@cust_main_addl_fields = qw(
+  agentnum salesnum refnum classnum usernum referral_custnum
+);
+@cust_main_editable_fields = qw(
+  first last company daytime night fax mobile
+);
+#  locale
+#  payby payinfo payname paystart_month paystart_year payissue payip
+#  ss paytype paystate stateid stateid_state
+@location_editable_fields = qw(
+  address1 address2 city county state zip country
+);
+
+sub API_getinfo {
+  my( $self, %opt ) = @_;
+
+  my %return = (
+    'error'           => '',
+    'display_custnum' => $self->display_custnum,
+    'name'            => $self->first. ' '. $self->get('last'),
+    'balance'         => $self->balance,
+    'status'          => $self->status,
+    'statuscolor'     => $self->statuscolor,
+  );
+
+  $return{$_} = $self->get($_)
+    foreach @cust_main_editable_fields;
+
+  unless ( $opt{'selfservice'} ) {
+    $return{$_} = $self->get($_)
+      foreach @cust_main_addl_fields;
+  }
+
+  for (@location_editable_fields) {
+    $return{$_} = $self->bill_location->get($_)
+      if $self->bill_locationnum;
+    $return{'ship_'.$_} = $self->ship_location->get($_)
+      if $self->ship_locationnum;
+  }
+
+  my @invoicing_list = $self->invoicing_list;
+  $return{'invoicing_list'} =
+    join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list );
+  $return{'postal_invoicing'} =
+    0 < ( grep { $_ eq 'POST' } @invoicing_list );
+
+  #generally, the more useful data from the cust_main record the better.
+  # well, tell me what you want
+
+  return \%return;
+
+}
+
+1;
index 775c353..1044e43 100644 (file)
@@ -892,6 +892,13 @@ sub unapplied_sql {
 
 }
 
+sub API_getinfo {
+ my $self = shift;
+ my @fields = grep { $_ ne 'payinfo' } $self->fields;
+ +{ ( map { $_=>$self->$_ } @fields ),
+  };
+}
+
 # _upgrade_data
 #
 # Used by FS::Upgrade to migrate to a new database.
index af1bd83..915f229 100644 (file)
@@ -1,5 +1,5 @@
 package FS::cust_pkg;
-use base qw( FS::cust_pkg::Search
+use base qw( FS::cust_pkg::Search FS::cust_pkg::API
              FS::otaker_Mixin FS::cust_main_Mixin FS::Sales_Mixin
              FS::contact_Mixin FS::location_Mixin
              FS::m2m_Common FS::option_Common
diff --git a/FS/FS/cust_pkg/API.pm b/FS/FS/cust_pkg/API.pm
new file mode 100644 (file)
index 0000000..f87eed3
--- /dev/null
@@ -0,0 +1,13 @@
+package FS::cust_pkg::API;
+
+use strict;
+
+sub API_getinfo {
+  my $self = shift;
+
+  +{ ( map { $_=>$self->$_ } $self->fields ),
+   };
+
+}
+
+1;
index 8fc929f..df179f5 100644 (file)
@@ -904,6 +904,13 @@ sub tickets {
   (@tickets);
 }
 
+sub API_getinfo {
+  my $self = shift;
+  my $svc_x = $self->svc_x;
+ +{ ( map { $_=>$self->$_ } $self->fields ),
+    ( map { $svc_x=>$svc_x->$_ } $svc_x->fields ),
+  };
+}
 
 =back
 
index bb97433..8771ae8 100644 (file)
@@ -81,7 +81,7 @@ sub get_dids {
                         })
            ];
 
-  } elsif ( $opt{'state'} ) { #return aracodes
+  } elsif ( $opt{'state'} ) { #return areacodes
 
     $hash{state} = $opt{state};
 
@@ -94,7 +94,15 @@ sub get_dids {
            ];
 
   } else { 
-    die "FS::part_export::internal_diddb::get_dids called without options\n";
+
+    #die "FS::part_export::internal_diddb::get_dids called without options\n";
+    return [ map { $_->npa. '-'. $_->nxx. '-'. $_->station }
+                 qsearch({ 'table'    => 'phone_avail',
+                           'hashref'  => \%hash,
+                           'order_by' => 'ORDER BY station',
+                        })
+           ];
+
   }
 
 }
index 2f06467..2ad7859 100644 (file)
@@ -1,5 +1,7 @@
 package FS::part_pkg;
-use base qw( FS::m2m_Common FS::o2m_Common FS::option_Common );
+use base qw( FS::part_pkg::API
+             FS::m2m_Common FS::o2m_Common FS::option_Common
+           );
 
 use strict;
 use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
@@ -2017,8 +2019,8 @@ sub _pkgs_sql {
 #false laziness w/part_export & cdr
 my %info;
 foreach my $INC ( @INC ) {
-  warn "globbing $INC/FS/part_pkg/*.pm\n" if $DEBUG;
-  foreach my $file ( glob("$INC/FS/part_pkg/*.pm") ) {
+  warn "globbing $INC/FS/part_pkg/[a-z]*.pm\n" if $DEBUG;
+  foreach my $file ( glob("$INC/FS/part_pkg/[a-z]*.pm") ) {
     warn "attempting to load plan info from $file\n" if $DEBUG;
     $file =~ /\/(\w+)\.pm$/ or do {
       warn "unrecognized file in $INC/FS/part_pkg/: $file\n";
diff --git a/FS/FS/part_pkg/API.pm b/FS/FS/part_pkg/API.pm
new file mode 100644 (file)
index 0000000..3210aa0
--- /dev/null
@@ -0,0 +1,17 @@
+package FS::part_pkg::API;
+
+use strict;
+
+sub API_getinfo {
+  my $self = shift;
+  #my( $self, %opt ) = @_;
+
+  +{ ( map { $_=>$self->$_ } $self->fields ),
+     ( map { $_=>$self->option($_) }
+         qw(setup_fee recur_fee)
+     ),
+   };
+
+}
+
+1;
index c4e93f8..36ce3a5 100644 (file)
@@ -1,5 +1,6 @@
 PerlModule Apache2::compat
 
+PerlModule DBIx::Profile
 #PerlModule Apache::DBI
 
 PerlModule HTML::Mason
@@ -72,3 +73,9 @@ PerlSetVar FreesideHttpOnly 1
 <Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/REST/1.0/NoAuth/>
     Satisfy any
 </Directory>
+
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/REST/>
+    Satisfy any
+    SetHandler perl-script
+    PerlHandler HTML::Mason
+</Directory>
index 1bbe90a..e2d507a 100644 (file)
@@ -59,3 +59,9 @@ PerlSetVar FreesideHttpOnly 1
     Satisfy any
 </Directory>
 
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/REST/1.0/>
+    Satisfy any
+    SetHandler perl-script
+    PerlHandler HTML::Mason
+</Directory>
+
diff --git a/httemplate/REST/1.0/.cust_bill.swp b/httemplate/REST/1.0/.cust_bill.swp
new file mode 100644 (file)
index 0000000..44d4de7
Binary files /dev/null and b/httemplate/REST/1.0/.cust_bill.swp differ
diff --git a/httemplate/REST/1.0/.cust_main.swp b/httemplate/REST/1.0/.cust_main.swp
new file mode 100644 (file)
index 0000000..d785784
Binary files /dev/null and b/httemplate/REST/1.0/.cust_main.swp differ
diff --git a/httemplate/REST/1.0/.cust_pkg.swp b/httemplate/REST/1.0/.cust_pkg.swp
new file mode 100644 (file)
index 0000000..451a985
Binary files /dev/null and b/httemplate/REST/1.0/.cust_pkg.swp differ
diff --git a/httemplate/REST/1.0/.part_pkg.swp b/httemplate/REST/1.0/.part_pkg.swp
new file mode 100644 (file)
index 0000000..547a79e
Binary files /dev/null and b/httemplate/REST/1.0/.part_pkg.swp differ
diff --git a/httemplate/REST/1.0/.phone_avail.swp b/httemplate/REST/1.0/.phone_avail.swp
new file mode 100644 (file)
index 0000000..a65bb27
Binary files /dev/null and b/httemplate/REST/1.0/.phone_avail.swp differ
diff --git a/httemplate/REST/1.0/.rate_detail.swp b/httemplate/REST/1.0/.rate_detail.swp
new file mode 100644 (file)
index 0000000..8d46a28
Binary files /dev/null and b/httemplate/REST/1.0/.rate_detail.swp differ
diff --git a/httemplate/REST/1.0/cust_bill b/httemplate/REST/1.0/cust_bill
new file mode 100644 (file)
index 0000000..926cf3b
--- /dev/null
@@ -0,0 +1,28 @@
+<% encode_rest($return) %>\
+<%init>
+
+rest_auth($cgi);
+
+my( $invnum, $command ) = split('/', rest_uri_remain($r, $m) );
+
+my $cust_bill = qsearchs('cust_bill', { 'invnum'=>$invnum } )
+  or die "unknown invnum $invnum";
+
+my $return = [];
+
+if ( $command eq '' ) {
+
+  my @fields = fields('cust_bill');
+  $return = +{ map { $_=>$cust_bill->$_ } @fields };
+
+} elsif ( $command eq 'cust_bill_pkg' ) {
+
+  my @fields = fields('cust_bill_pkg');
+  $return = [ map { my $cust_bill_pkg = $_;
+                    +{ map { $_=>$cust_bill_pkg->$_ } @fields };
+                  }
+                $cust_bill->cust_bill_pkg
+            ];
+}
+
+</%init>
diff --git a/httemplate/REST/1.0/cust_main b/httemplate/REST/1.0/cust_main
new file mode 100644 (file)
index 0000000..89c558c
--- /dev/null
@@ -0,0 +1,81 @@
+<% encode_rest($return) %>\
+<%init>
+
+rest_auth($cgi);
+
+my( $custnum, $command ) = split('/', rest_uri_remain($r, $m), 2 );
+
+if ( $r->method eq 'GET' ) {
+
+  my $return = [];
+
+  if ( $custnum ) {
+
+    my $cust_main = qsearchs('cust_main', { 'custnum'=>$custnum } )
+      or die "unknown custnum $custnum";
+
+    if ( $command eq '' ) {
+
+      $return = $cust_main->API_getinfo;
+
+    } elsif ( $command =~ /^(cust_(pkg|attachment|bill|pay))$/ ) {
+
+      my $method = $1;
+
+      $return = [ map $_->API_getinfo, $cust_main->$method ];
+
+    } elsif ( $command eq 'part_pkg' ) {
+
+      my %pkgpart = map { $_->pkgpart => 1 } $cust_main->cust_pkg;
+
+      $return = [ map $_->API_getinfo,
+                    map qsearchs('part_pkg', { 'pkgpart'=>$_ }),
+                      keys %pkgpart;
+                ];
+
+    }
+
+  } else { #list
+
+    my %hash = ( map { $_ => scalar($cgi->param($_)) }
+                   qw( agentnum salesnum refnum classnum usernum
+                       referral_custnum
+                     )
+               );
+    my $extra_sql = '';
+    if ( $cgi->param('cust_main_invoice_dest') ) {
+      my $dest = dbh->quote(scalar($cgi->param('cust_main_invoice_dest')));
+      $extra_sql = "
+        WHERE EXISTS ( SELECT 1 FROM cust_main_invoice
+                         WHERE cust_main.custnum = cust_main_invoice.custnum
+                           AND dest = $dest
+                     )
+      ";
+    } elsif ( $cgi->param('cust_main_invoice_dest_substring') ) {
+      my $dest = dbh->quote('%'. scalar($cgi->param('cust_main_invoice_dest_substring')). '%');
+      $extra_sql = "
+        WHERE EXISTS ( SELECT 1 FROM cust_main_invoice
+                         WHERE cust_main.custnum = cust_main_invoice.custnum
+                           AND dest ILIKE $dest
+                     )
+      ";
+    }
+
+    my @cust_main = qsearch({
+      'table'     => 'cust_main',
+      'hashref'   =>  \%hash,
+      'extra_sql' => $extra_sql;
+    });
+
+    $return = [ map $_->API_getinfo, @cust_main ];
+
+  }
+
+} elsif ( $r->method eq 'POST' ) { #create new
+
+} elsif ( $r->method eq 'PUT' ) { #modify
+
+}
+
+</%init>
diff --git a/httemplate/REST/1.0/cust_pkg b/httemplate/REST/1.0/cust_pkg
new file mode 100644 (file)
index 0000000..3c58bcf
--- /dev/null
@@ -0,0 +1,39 @@
+<% encode_rest($return) %>\
+<%init>
+
+rest_auth($cgi);
+
+my( $pkgnum, $command ) = split('/', rest_uri_remain($r, $m), 2 );
+
+if ( $r->method eq 'GET' ) {
+
+  my $return = [];
+
+  if ( $pkgnum ) {
+
+    my $cust_pkg = qsearchs('cust_main', { 'pkgnum'=>$pkgnum } )
+      or die "unknown pkgnum $pkgnum";
+
+    if ( $command eq '' ) {
+
+      $return = $cust_pkg->API_getinfo;
+
+    } elsif ( $command eq 'cust_svc' ) {
+
+      $return = [ map $_->API_getinfo, $cust_pkg->cust_svc ];
+
+    }
+
+
+
+  #} else { #list
+
+  }
+
+} elsif ( $r->method eq 'POST' ) { #create new
+
+} elsif ( $r->method eq 'PUT' ) { #modify
+
+}
+
+</%init>
diff --git a/httemplate/REST/1.0/part_pkg b/httemplate/REST/1.0/part_pkg
new file mode 100644 (file)
index 0000000..c81b7b8
--- /dev/null
@@ -0,0 +1,40 @@
+<% encode_rest($return) %>\
+<%init>
+
+rest_auth($cgi);
+
+my( $pkgpart, $command ) = split('/', rest_uri_remain($r, $m) );
+
+my @fields = fields('part_pkg');
+
+my $return = [];
+
+if ( $pkgpart ) {
+
+  my $part_pkg = qsearchs('part_pkg', { 'pkgpart'=>$pkgpart } )
+    or die "unknown pkgpart $pkgpart";
+
+  if ( $command eq '' ) {
+
+    $return = $part_pkg->API_getinfo;
+
+  } elsif ( $command eq 'customers' ) {
+     die 'XXX not yet implemented';
+     #XXX redirect to a cust_main search?
+  }
+
+} else {
+
+  my %hash = ( map { $_ => scalar($cgi->param($_)) }
+                 qw( disabled classnum )
+             );
+
+  my @part_pkg = qsearch('part_pkg', \%hash);
+
+  $return = [ map $part_pkg->API_getinfo, @part_pkg ];
+
+}
+
+
+
+</%init>
diff --git a/httemplate/REST/1.0/phone_avail b/httemplate/REST/1.0/phone_avail
new file mode 100644 (file)
index 0000000..ef9d3e7
--- /dev/null
@@ -0,0 +1,25 @@
+<% encode_rest($phonenums) %>\
+<%init>
+
+rest_auth($cgi);
+
+#i'm basically a simpler misc/phonenums.cgi
+
+my $svcpart = $cgi->param('svcpart');
+
+my $part_svc = qsearchs('part_svc', { 'svcpart'=>$svcpart } );
+die "unknown svcpart $svcpart" unless $part_svc;
+
+my @exports = $part_svc->part_export_did;
+if ( scalar(@exports) > 1 ) {
+  die "more than one DID-providing export attached to svcpart $svcpart";
+} elsif ( ! @exports ) {
+  die "no DID providing export attached to svcpart $svcpart";
+}
+my $export = $exports[0];
+    
+my $phonenums = $export->get_dids( map { $_ => scalar($cgi->param($_)) }
+                                     qw( ratecenter state areacode exchange )
+                                 );
+
+</%init>
diff --git a/httemplate/REST/1.0/rate_detail b/httemplate/REST/1.0/rate_detail
new file mode 100644 (file)
index 0000000..54e55de
--- /dev/null
@@ -0,0 +1,35 @@
+<% encode_rest( \@rate_detail ) %>\
+<%init>
+
+rest_auth($cgi);
+
+my $extra_sql = '';
+if ( $cgi->param('countrycode') =~ /^\+?(\d+)$/ ) {
+  my $countrycode = $1;
+  $extra_sql = "
+    WHERE EXISTS ( SELECT 1 rate_region
+                     WHERE rate_detail.dest_regionnum = rate_region.regionnum
+                       AND countrycode = '$countrycode'
+  ";
+}
+
+my @detail_fields = fields('rate_detail');
+my @region_fields = fields('rate_region');
+
+my @rate_detail =
+  map {
+    my $rate_detail = $_;
+    my $rate_region = $rate_detail->dest_region;
+
+    +{
+      ( map { $_ => $rate_detail->$_ } @detail_fields ),
+      ( map { $_ => $rate_region->$_ } @region_fields ),
+     };
+
+  } qsearch({
+      'table'   => 'rate_detail',
+      'hashref' => {},
+      extra_sql => $extra_sql,
+    });
+
+</%init>