MT#14301 deleting prof packages instead of terminating

+profile package details view to overlook associations
+voucher count is properly checked before deleting a package
+package_id filters for /api/customers and /api/vouchers

+Fake::Time replaced by Time::Warp for balance_interval tests
+cleanup

Change-Id: Idc34220a9d29c115453eeee2907e70a71cf9f0cc
changes/36/2336/3
Rene Krenn 11 years ago
parent 5b44417421
commit bef06dce91

4
debian/control vendored

@ -23,7 +23,7 @@ Build-Depends: debhelper (>= 8),
libdatetime-format-iso8601-perl,
libdatetime-format-mysql-perl,
libdatetime-format-rfc3339-perl,
libtime-fake-perl,
libtime-warp-perl,
libtext-table-perl,
libdbix-class-resultset-recursiveupdate-perl (>= 0.30~),
libdevel-cover-perl,
@ -97,7 +97,7 @@ Depends: gettext,
libdatetime-format-mysql-perl,
libdatetime-format-rfc3339-perl,
libdatetime-perl,
libtime-fake-perl,
libtime-warp-perl,
libtext-table-perl,
libdbix-class-resultset-recursiveupdate-perl (>= 0.30~),
libemail-mime-perl,

@ -98,6 +98,17 @@ class_has 'query_params' => (
second => sub { },
},
},
{
param => 'package_id',
description => 'Filter for customers with specific profile package id',
query => {
first => sub {
my $q = shift;
{ 'me.profile_package_id' => $q };
},
second => sub { },
},
},
]},
);

@ -178,7 +178,37 @@ sub PUT :Allow {
return;
}
# we don't allow to DELETE a profile package
sub DELETE :Allow {
my ($self, $c, $id) = @_;
my $guard = $c->model('DB')->txn_scope_guard;
{
last unless $self->valid_id($c, $id);
my $package = $self->item_by_id($c, $id);
last unless $self->resource_exists($c, profilepackage => $package);
unless($package->get_column('contract_cnt') == 0) {
$self->error($c, HTTP_LOCKED, "Cannnot delete profile package that is still assigned to contracts");
last;
}
unless($package->get_column('voucher_cnt') == 0) {
$self->error($c, HTTP_LOCKED, "Cannnot delete profile package that is assigned to vouchers");
last;
}
last unless $self->add_delete_journal_item_hal($c,sub {
my $self = shift;
my ($c) = @_;
#my $_form = $self->get_form($c);
return $self->hal_from_item($c, $package, "profilepackages"); });
$package->delete;
$guard->commit;
$c->response->status(HTTP_NO_CONTENT);
$c->response->body(q());
}
return;
}
sub item_base_journal :Journal {
my $self = shift @_;

@ -110,6 +110,29 @@ sub ajax_reseller_filter :Chained('list_customer') :PathPart('ajax/reseller') :A
$c->detach( $c->view("JSON") );
}
sub ajax_package_filter :Chained('list_customer') :PathPart('ajax/package') :Args(1) {
my ($self, $c, $package_id) = @_;
unless($package_id && $package_id->is_int) {
NGCP::Panel::Utils::Message->error(
c => $c,
log => 'Invalid profile package id detected',
desc => $c->loc('Invalid profile package id detected'),
);
$c->response->redirect($c->uri_for());
return;
}
my $rs = $c->stash->{contract_select_rs}->search_rs({
'profile_package_id' => $package_id,
},undef);
my $package_customer_columns = NGCP::Panel::Utils::Datatables::set_columns($c, [
NGCP::Panel::Utils::ProfilePackages::get_customer_datatable_cols($c)
]);
NGCP::Panel::Utils::Datatables::process($c, $rs, $package_customer_columns);
$c->detach( $c->view("JSON") );
}
sub create :Chained('list_customer') :PathPart('create') :Args(0) {
my ($self, $c) = @_;

@ -8,6 +8,7 @@ use NGCP::Panel::Form::ProfilePackage::Reseller;
use NGCP::Panel::Utils::Message;
use NGCP::Panel::Utils::Navigation;
use NGCP::Panel::Utils::ProfilePackages qw();
use NGCP::Panel::Utils::Voucher qw();
sub auto :Does(ACL) :ACLDetachTo('/denied_page') :AllowedRole(admin) :AllowedRole(reseller) {
my ($self, $c) = @_;
@ -38,11 +39,11 @@ sub _package_resultset_admin {
return $c->model('DB')->resultset('profile_packages')->search_rs(
undef,
{ group_by => 'me.id',
})->search_rs({
'me.status' => { '!=' => 'terminated' },
},
{ '+select' => { '' => \[ NGCP::Panel::Utils::ProfilePackages::get_contract_count_stmt() ] , -as => 'contract_cnt' },
#{ '' => \[ NGCP::Panel::Utils::ProfilePackages::get_package_count_stmt() ] , -as => 'package_cnt' },
})->search_rs(
undef,
{ '+select' => [ { '' => \[ NGCP::Panel::Utils::ProfilePackages::get_contract_count_stmt() ] , -as => 'contract_cnt' },
{ '' => \[ NGCP::Panel::Utils::ProfilePackages::get_voucher_count_stmt() ] , -as => 'voucher_cnt' },
],
});
}
@ -55,11 +56,11 @@ sub _package_resultset_reseller {
->search_related('profile_packages')->search_rs(
undef,
{ group_by => 'me.id',
})->search_rs({
'me.status' => { '!=' => 'terminated' },
},
{ '+select' => { '' => \[ NGCP::Panel::Utils::ProfilePackages::get_contract_count_stmt() ] , -as => 'contract_cnt' },
#{ '' => \[ NGCP::Panel::Utils::ProfilePackages::get_package_count_stmt() ] , -as => 'package_cnt' },
})->search_rs(
undef,
{ '+select' => [ { '' => \[ NGCP::Panel::Utils::ProfilePackages::get_contract_count_stmt() ] , -as => 'contract_cnt' },
{ '' => \[ NGCP::Panel::Utils::ProfilePackages::get_voucher_count_stmt() ] , -as => 'voucher_cnt' },
],
});
}
@ -210,7 +211,7 @@ sub edit :Chained('base') :PathPart('edit') :Args(0) {
$c->model('DB')->schema->txn_do( sub {
unless($c->stash->{'package_result'}->get_column('contract_cnt') == 0) {
die('Cannnot modify profile package that is still assigned to contracts');
}
}
my $profile_package = $c->stash->{'package_result'}->update($form->values);
$profile_package->profiles->delete;
foreach my $mapping (@mappings_to_create) {
@ -240,30 +241,31 @@ sub edit :Chained('base') :PathPart('edit') :Args(0) {
);
}
sub terminate :Chained('base') :PathPart('terminate') :Args(0) {
sub delete :Chained('base') :PathPart('delete') :Args(0) {
my ($self, $c) = @_;
my $package = $c->stash->{package_result};
try {
#todo: putting the package fetch into a transaction wouldn't help since the count columns a prone to phantom reads...
unless($package->get_column('contract_cnt') == 0) {
die('Cannnot terminate profile package that is still assigned to contracts');
die('Cannnot delete profile package that is still assigned to contracts');
}
unless($package->get_column('voucher_cnt') == 0) {
die('Cannnot delete profile package that is assigned to vouchers');
}
$package->update({
status => 'terminated',
#terminate_timestamp => NGCP::Panel::Utils::DateTime::current_local,
});
$package->delete;
NGCP::Panel::Utils::Message->info(
c => $c,
data => $c->stash->{package},
desc => $c->loc('Profile package successfully terminated'),
desc => $c->loc('Profile package successfully deleted'),
);
} catch ($e) {
NGCP::Panel::Utils::Message->error(
c => $c,
error => $e,
data => $c->stash->{package},
desc => $c->loc('Failed to terminate profile package'),
desc => $c->loc('Failed to delete profile package'),
);
};
NGCP::Panel::Utils::Navigation::back_or($c, $c->uri_for('/package'));
@ -287,6 +289,85 @@ sub ajax_filter_reseller :Chained('package_list') :PathPart('ajax/filter_reselle
$c->detach( $c->view("JSON") );
}
sub details_base :Chained('/') :PathPart('package') :CaptureArgs(1) {
my ($self, $c, $package_id) = @_;
my $dispatch_to = '_package_resultset_' . $c->user->roles;
my $package_rs = $self->$dispatch_to($c);
unless($package_id && $package_id->is_integer) {
$package_id //= '';
NGCP::Panel::Utils::Message->error(
c => $c,
data => { id => $package_id },
desc => $c->loc('Invalid package id detected'),
);
$c->response->redirect($c->uri_for());
$c->detach;
return;
}
my $res = $package_rs->find($package_id);
unless(defined($res)) {
NGCP::Panel::Utils::Message->error(
c => $c,
desc => $c->loc('Profile package does not exist'),
);
$c->response->redirect($c->uri_for());
$c->detach;
return;
}
$c->stash->{profile_set_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [
#{ name => 'id', search => 1, title => $c->loc('#') },
{ name => 'billing_profile.name', search => 1, title => $c->loc('Billing Profile') },
{ name => 'billing_network.name', search => 1, title => $c->loc('Billing Network') },
]);
$c->stash->{customer_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [
NGCP::Panel::Utils::ProfilePackages::get_customer_datatable_cols($c)
]);
$c->stash->{voucher_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [
NGCP::Panel::Utils::Voucher::get_datatable_cols($c,1)
]);
$c->stash(package_result => $res);
}
sub details :Chained('details_base') :PathPart('details') :Args(0) {
my ($self, $c) = @_;
$c->stash(template => 'package/details.tt');
}
sub details_ajax :Chained('details_base') :PathPart('ajax') :CaptureArgs(0) {
my ($self, $c) = @_;
}
sub ajax_initial_profiles :Chained('details_ajax') :PathPart('initial_profiles') :Args(0) {
my ($self, $c) = @_;
my $resultset = $c->stash->{package_result}->initial_profiles;
NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{profile_set_dt_columns});
$c->detach( $c->view("JSON") );
}
sub ajax_topup_profiles :Chained('details_ajax') :PathPart('topup_profiles') :Args(0) {
my ($self, $c) = @_;
my $resultset = $c->stash->{package_result}->topup_profiles;
NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{profile_set_dt_columns});
$c->detach( $c->view("JSON") );
}
sub ajax_underrun_profiles :Chained('details_ajax') :PathPart('underrun_profiles') :Args(0) {
my ($self, $c) = @_;
my $resultset = $c->stash->{package_result}->underrun_profiles;
NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{profile_set_dt_columns});
$c->detach( $c->view("JSON") );
}
__PACKAGE__->meta->make_immutable;
1;

@ -29,7 +29,14 @@ sub auto :Does(ACL) :ACLDetachTo('/denied_page') :AllowedRole(admin) :AllowedRol
sub voucher_list :Chained('/') :PathPart('voucher') :CaptureArgs(0) {
my ( $self, $c ) = @_;
my $voucher_rs = $c->model('DB')->resultset('vouchers');
my $voucher_rs = $c->model('DB')->resultset('vouchers'); #->search_rs(undef, {
#'join' => { 'customer' => 'contact'},
#'+select' => [
# 'contact.email',
#],
#'+as' => [
# 'customer_contact_email',
#],});
if($c->user->roles eq "reseller") {
$voucher_rs = $voucher_rs->search({
reseller_id => $c->user->reseller_id,
@ -38,14 +45,7 @@ sub voucher_list :Chained('/') :PathPart('voucher') :CaptureArgs(0) {
$c->stash(voucher_rs => $voucher_rs);
$c->stash->{voucher_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [
{ name => "id", "search" => 1, "title" => $c->loc("#") },
$c->user->billing_data ? { name => "code", "search" => 1, "title" => $c->loc("Code") } : (),
{ name => "amount", "search" => 1, "title" => $c->loc("Amount") },
{ name => "reseller.name", "search" => 1, "title" => $c->loc("Reseller") },
{ name => "profile_package.name", "search" => 1, "title" => $c->loc("Profile Package") },
{ name => "valid_until", "search" => 1, "title" => $c->loc("Valid Until") },
{ name => "used_at", "search" => 1, "title" => $c->loc("Used At") },
{ name => "used_by_subscriber.id", "search" => 1, "title" => $c->loc("Used By Subscriber #") },
NGCP::Panel::Utils::Voucher::get_datatable_cols($c)
]);
$c->stash(template => 'voucher/list.tt');
@ -55,24 +55,41 @@ sub root :Chained('voucher_list') :PathPart('') :Args(0) {
my ($self, $c) = @_;
}
sub _process_dt_voucher_rows :Private {
my ($c,$row) = @_;
my $v = { $row->get_inflated_columns };
if($c->user->billing_data) {
$v->{code} = NGCP::Panel::Utils::Voucher::decrypt_code($c, $row->code);
} else {
$v->{code} = "<retracted>";
}
foreach my $k(keys %{ $v }) {
if(blessed($v->{$k}) && $v->{$k}->isa('DateTime')) {
$v->{$k} = NGCP::Panel::Utils::DateTime::to_string($v->{$k});
}
}
return %{ $v };
}
sub ajax :Chained('voucher_list') :PathPart('ajax') :Args(0) {
my ($self, $c) = @_;
my $resultset = $c->stash->{voucher_rs};
NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{voucher_dt_columns}, sub {
my $row = shift;
my $v = { $row->get_inflated_columns };
if($c->user->billing_data) {
$v->{code} = NGCP::Panel::Utils::Voucher::decrypt_code($c, $row->code);
} else {
$v->{code} = "<retracted>";
}
foreach my $k(keys %{ $v }) {
if(blessed($v->{$k}) && $v->{$k}->isa('DateTime')) {
$v->{$k} = NGCP::Panel::Utils::DateTime::to_string($v->{$k});
}
}
return %{ $v };
return _process_dt_voucher_rows($c,$row);
});
$c->detach( $c->view("JSON") );
}
sub ajax_package_filter :Chained('voucher_list') :PathPart('ajax/package') :Args(1) {
my ($self, $c, $package_id) = @_;
my $resultset = $c->stash->{voucher_rs}->search_rs({ package_id => $package_id },undef);
NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{voucher_dt_columns}, sub {
my $row = shift;
return _process_dt_voucher_rows($c,$row);
});
$c->detach( $c->view("JSON") );

@ -37,19 +37,6 @@ has_field 'description' => (
},
);
has_field 'status' => (
type => 'Hidden',
options => [
{ value => 'active', label => 'active' },
{ value => 'terminated', label => 'terminated' },
],
element_attr => {
rel => ['tooltip'],
title => ['The status of this package. Only active profile packages can be assigned to customers/profile packages.']
},
);
has_field 'initial_balance' => (
type => 'Money',
element_attr => {

@ -91,10 +91,10 @@ sub hal_from_item {
sub item_rs {
my ($self, $c) = @_;
my $item_rs = $c->model('DB')->resultset('profile_packages')->search_rs({ 'me.status' => { '!=' => 'terminated' } });
my $item_rs = $c->model('DB')->resultset('profile_packages')->search_rs(); #{ 'me.status' => { '!=' => 'terminated' } });
my $search_xtra = {
'+select' => [ { '' => \[ NGCP::Panel::Utils::ProfilePackages::get_contract_count_stmt() ] , -as => 'contract_cnt' },
#{ '' => \[ NGCP::Panel::Utils::ProfilePackages::get_package_count_stmt() ] , -as => 'package_cnt' },
{ '' => \[ NGCP::Panel::Utils::ProfilePackages::get_voucher_count_stmt() ] , -as => 'voucher_cnt' },
],
};
if($c->user->roles eq "admin") {
@ -117,11 +117,6 @@ sub item_by_id {
sub update_item {
my ($self, $c, $item, $old_resource, $resource, $form) = @_;
if ($item->status eq 'terminated') {
$self->error($c, HTTP_UNPROCESSABLE_ENTITY, 'Profile package is already terminated and cannot be changed.');
return;
}
delete $resource->{id};
my $schema = $c->model('DB');
@ -145,18 +140,11 @@ sub update_item {
$self->error($c, HTTP_UNPROCESSABLE_ENTITY, $err);
});
#if(exists $resource->{status} && $resource->{status} eq 'terminated') {
unless($item->get_column('contract_cnt') == 0) {
$self->error($c, HTTP_UNPROCESSABLE_ENTITY,
"Cannnot modify or terminate profile_package that is still assigned to contracts");
return;
}
#unless($item->get_column('contract_cnt') == 0) {
# $self->error($c, HTTP_UNPROCESSABLE_ENTITY,
# "Cannnot terminate billing_network that is still used in profile sets of profile packages");
# return;
#}
#}
unless($item->get_column('contract_cnt') == 0) {
$self->error($c, HTTP_UNPROCESSABLE_ENTITY,
"Cannnot modify or terminate profile_package that is still assigned to contracts");
return;
}
my $mappings_to_create = [];
return unless NGCP::Panel::Utils::ProfilePackages::prepare_profile_package(

@ -96,7 +96,7 @@ sub get_contract_count_stmt {
return "select count(distinct c.id) from `billing`.`billing_mappings` bm join `billing`.`contracts` c on c.id = bm.contract_id where bm.`billing_profile_id` = `me`.`id` and c.status != 'terminated' and (bm.end_date is null or bm.end_date >= now())";
}
sub get_package_count_stmt {
return "select count(distinct pp.id) from `billing`.`package_profile_sets` pps join `billing`.`profile_packages` pp on pp.id = pps.package_id where pps.`profile_id` = `me`.`id` and pp.status != 'terminated'";
return "select count(distinct pp.id) from `billing`.`package_profile_sets` pps join `billing`.`profile_packages` pp on pp.id = pps.package_id where pps.`profile_id` = `me`.`id`"; # and pp.status != 'terminated'";
}
sub get_datatable_cols {

@ -131,7 +131,7 @@ sub get_contract_count_stmt {
return "select count(distinct c.id) from `billing`.`billing_mappings` bm join `billing`.`contracts` c on c.id = bm.contract_id where bm.`network_id` = `me`.`id` and c.status != 'terminated' and (bm.end_date is null or bm.end_date >= now())";
}
sub get_package_count_stmt {
return "select count(distinct pp.id) from `billing`.`package_profile_sets` pps join `billing`.`profile_packages` pp on pp.id = pps.package_id where pps.`network_id` = `me`.`id` and pp.status != 'terminated'";
return "select count(distinct pp.id) from `billing`.`package_profile_sets` pps join `billing`.`profile_packages` pp on pp.id = pps.package_id where pps.`network_id` = `me`.`id`"; # and pp.status != 'terminated'";
}
sub get_datatable_cols {

@ -765,9 +765,7 @@ sub _check_profile_package {
unless($package) {
return 0 unless &{$err_code}("Invalid 'profile_package_id'.",$field);
}
if ($package->status eq 'terminated') {
return 0 unless &{$err_code}("Invalid 'profile_package_id', already terminated.",$field);
}
if (defined $reseller_id && defined $package->reseller_id && $reseller_id != $package->reseller_id) {
return 0 unless &{$err_code}("The reseller of the contact doesn't match the reseller of the profile package (" . $package->name . ").",$field);
}

@ -3,18 +3,27 @@ package NGCP::Panel::Utils::DateTime;
#use Sipwise::Base; seg fault when creating threads in test scripts
use strict;
use warnings;
use Time::Fake; #load this before any use DateTime
use Time::HiRes; #prevent warning from Time::Warp
use Time::Warp;
#use Time::Fake; #load this before any use DateTime
use DateTime;
#use DateTime::Infinite;
use DateTime::Format::ISO8601;
use DateTime::Format::Strptime;
use constant RFC_1123_FORMAT_PATTERN => '%a, %d %b %Y %T %Z';
my $is_fake_time = 0;
sub current_local {
return DateTime->now(
time_zone => DateTime::TimeZone->new(name => 'local')
);
if ($is_fake_time) {
return DateTime->from_epoch(epoch => Time::Warp::time,
time_zone => DateTime::TimeZone->new(name => 'local')
);
} else {
return DateTime->now(
time_zone => DateTime::TimeZone->new(name => 'local')
);
}
}
sub infinite_past {
@ -53,20 +62,36 @@ sub is_infinite {
}
sub set_fake_time {
my ($o) = @_;
my ($o) = @_;
$is_fake_time = 1;
if (defined $o) {
Time::Fake->offset(ref $o eq 'DateTime' ? $o->epoch : $o);
if (ref $o eq 'DateTime') {
$o = $o->epoch;
} else {
my %mult = (
s => 1,
m => 60,
h => 60*60,
d => 60*60*24,
M => 60*60*24*30,
y => 60*60*24*365,
);
if (!$o) {
$o = time;
} elsif ($o =~ m/^([+-]\d+)([smhdMy]?)$/) {
$o = time + $1 * $mult{ $2 || "s" };
} elsif ($o !~ m/\D/) {
} else {
die("Invalid time offset: '$o'");
}
}
Time::Warp::to($o);
} else {
Time::Fake->reset();
Time::Warp::reset();
}
}
#sub infinite_past {
# DateTime::Infinite::Past->new();
#}
#sub infinite_future {
# return DateTime::Infinite::Future->new();
#}
sub last_day_of_month {
my $dt = shift;

@ -3,7 +3,6 @@ package NGCP::Panel::Utils::Message;
use Catalyst;
use Sipwise::Base;
use Data::Dumper;
use Time::Fake; #load this before any use DateTime
use DateTime qw();
use DateTime::Format::RFC3339 qw();
use Time::HiRes qw();

@ -782,6 +782,10 @@ sub get_contract_count_stmt {
return "select count(distinct c.id) from `billing`.`contracts` c where c.`profile_package_id` = `me`.`id` and c.status != 'terminated'";
}
sub get_voucher_count_stmt {
return "select count(distinct v.id) from `billing`.`vouchers` v where v.`package_id` = `me`.`id`"; # and v.`used_by_subscriber_id` is null";
}
sub _get_profile_set_group_stmt {
my ($discriminator) = @_;
my $grp_stmt = "group_concat(if(bn.`name` is null,bp.`name`,concat(bp.`name`,'/',bn.`name`)) separator ', ')";
@ -793,7 +797,8 @@ sub get_datatable_cols {
my ($c) = @_;
return (
{ name => "contract_cnt", "search" => 0, "title" => $c->loc("Used (contracts)"), },
{ name => "contract_cnt", "search" => 0, "title" => $c->loc("Contracts"), },
{ name => "voucher_cnt", "search" => 0, "title" => $c->loc("Vouchers"), },
{ name => 'initial_profiles_grp', accessor => "initial_profiles_grp", search => 0, title => $c->loc('Initial Profiles'),
literal_sql => _get_profile_set_group_stmt(INITIAL_PROFILE_DISCRIMINATOR) },
{ name => 'underrun_profiles_grp', accessor => "underrun_profiles_grp", search => 0, title => $c->loc('Underrun Profiles'),
@ -808,5 +813,17 @@ sub get_datatable_cols {
);
}
sub get_customer_datatable_cols {
my ($c) = @_;
return (
{ name => "id", search => 1, title => $c->loc("#") },
{ name => "external_id", search => 1, title => $c->loc("External #") },
#{ name => "billing_mappings_actual.billing_mappings.product.name", search => 1, title => $c->loc("Product") },
{ name => "contact.email", search => 1, title => $c->loc("Contact Email") },
{ name => "status", search => 1, title => $c->loc("Status") },
);
}
1;

@ -41,4 +41,21 @@ sub decrypt_code {
return $plain;
}
sub get_datatable_cols {
my ($c,$hide_package) = @_;
return (
{ name => "id", "search" => 1, "title" => $c->loc("#") },
$c->user->billing_data ? { name => "code", "search" => 1, "title" => $c->loc("Code") } : (),
{ name => "amount", "search" => 1, "title" => $c->loc("Amount") },
{ name => "reseller.name", "search" => 1, "title" => $c->loc("Reseller") },
$hide_package ? () : { name => "profile_package.name", "search" => 1, "title" => $c->loc("Profile Package") },
#{ name => "customer_contact_email", "search" => 1, "title" => $c->loc("Reserved for Customer") },
{ name => "customer_id", "search" => 1, "title" => $c->loc("For Contract #") },
{ name => "valid_until", "search" => 1, "title" => $c->loc("Valid Until") },
{ name => "used_at", "search" => 1, "title" => $c->loc("Used At") },
{ name => "used_by_subscriber.id", "search" => 1, "title" => $c->loc("Used By Subscriber #") },
);
}
1;

@ -0,0 +1,156 @@
[% site_config.title = c.loc('Profile Package Details for [_1]', package_result.name) -%]
<div class="row">
<span>
<a class="btn btn-primary btn-large" href="[% c.uri_for('/back') %]"><i class="icon-arrow-left"></i> [% c.loc('Back') %]</a>
</span>
</div>
[% back_created = 1 -%]
<div class="ngcp-separator"></div>
<div class="row">
[% FOREACH m IN messages -%]
<div class="alert alert-[% m.type %]">[% m.text %]</div>
[% END -%]
[% messages = [] -%]
</div>
<div class="accordion" id="package_details">
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#package_details" href="#collapse_initial_profile_set">[% c.loc('Initial Billing Profiles/Networks') %]</a>
</div>
<div class="accordion-body collapse" id="collapse_initial_profile_set">
<div class="accordion-inner">
[%
helper.name = c.loc('Billing Profile/Network');
helper.identifier = 'initial_profile_set';
helper.messages = messages;
helper.dt_columns = profile_set_dt_columns;
helper.paginate = 'true';
helper.filter = 'true';
helper.close_target = close_target;
helper.create_flag = create_flag;
helper.edit_flag = edit_flag;
helper.form_object = form;
helper.ajax_uri = c.uri_for('/package/' _ package_result.id _ '/ajax/initial_profiles');
PROCESS 'helpers/datatables.tt';
-%]
</div>
</div>
</div>
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#package_details" href="#collapse_topup_profile_set">[% c.loc('Top-up Billing Profiles/Networks') %]</a>
</div>
<div class="accordion-body collapse" id="collapse_topup_profile_set">
<div class="accordion-inner">
[%
helper.name = c.loc('Billing Profile/Network');
helper.identifier = 'topup_profile_set';
helper.messages = messages;
helper.dt_columns = profile_set_dt_columns;
helper.paginate = 'true';
helper.filter = 'true';
helper.close_target = close_target;
helper.create_flag = create_flag;
helper.edit_flag = edit_flag;
helper.form_object = form;
helper.ajax_uri = c.uri_for('/package/' _ package_result.id _ '/ajax/topup_profiles');
PROCESS 'helpers/datatables.tt';
-%]
</div>
</div>
</div>
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#package_details" href="#collapse_underrun_profile_set">[% c.loc('Underrun Billing Profiles/Networks') %]</a>
</div>
<div class="accordion-body collapse" id="collapse_underrun_profile_set">
<div class="accordion-inner">
[%
helper.name = c.loc('Billing Profile/Network');
helper.identifier = 'underrun_profile_set';
helper.messages = messages;
helper.dt_columns = profile_set_dt_columns;
helper.paginate = 'true';
helper.filter = 'true';
helper.close_target = close_target;
helper.create_flag = create_flag;
helper.edit_flag = edit_flag;
helper.form_object = form;
helper.ajax_uri = c.uri_for('/package/' _ package_result.id _ '/ajax/underrun_profiles');
PROCESS 'helpers/datatables.tt';
-%]
</div>
</div>
</div>
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#package_details" href="#collapse_customers">[% c.loc('Customer Contracts') %]</a>
</div>
<div class="accordion-body collapse" id="collapse_customers">
<div class="accordion-inner">
[%
helper.name = c.loc('Contract');
helper.identifier = 'customers';
helper.messages = messages;
helper.dt_columns = customer_dt_columns;
helper.paginate = 'true';
helper.filter = 'true';
helper.close_target = close_target;
helper.create_flag = create_flag;
helper.edit_flag = edit_flag;
helper.form_object = form;
helper.ajax_uri = c.uri_for_action('/customer/ajax_package_filter', package_result.id );
PROCESS 'helpers/datatables.tt';
-%]
</div>
</div>
</div>
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse" data-parent="#package_details" href="#collapse_vouchers">[% c.loc('Vouchers') %]</a>
</div>
<div class="accordion-body collapse" id="collapse_vouchers">
<div class="accordion-inner">
[%
helper.name = c.loc('Voucher');
helper.identifier = 'vouchers';
helper.messages = messages;
helper.dt_columns = voucher_dt_columns;
helper.paginate = 'true';
helper.filter = 'true';
helper.close_target = close_target;
helper.create_flag = create_flag;
helper.edit_flag = edit_flag;
helper.form_object = form;
helper.ajax_uri = c.uri_for_action('/voucher/ajax_package_filter', package_result.id );
PROCESS 'helpers/datatables.tt';
-%]
</div>
</div>
</div>
</div>
[% IF edit_flag || create_flag -%]
IF form.has_for_js;
form.render_repeatable_js;
END;
[%PROCESS 'helpers/modal.tt' -%]
[% END -%]
[% # vim: set tabstop=4 syntax=html expandtab: -%]

@ -16,11 +16,16 @@
UNLESS c.user.read_only;
helper.dt_buttons = [
{ name = c.loc('Edit'), uri = "/package/'+full.id+'/edit", class = 'btn-small btn-primary', icon = 'icon-edit' },
{ name = c.loc('Terminate'), uri = "/package/'+full[\"id\"]+'/terminate", class = 'btn-small btn-secondary', icon = 'icon-remove', condition => 'full.contract_cnt == "0"' },
{ name = c.loc('Delete'), uri = "/package/'+full[\"id\"]+'/delete", class = 'btn-small btn-secondary', icon = 'icon-remove', condition => 'full.contract_cnt == "0" && full.voucher_cnt == "0"' },
{ name = c.loc('Details'), uri = "/package/'+full.id+'/details", class = 'btn-small btn-tertiary', icon = 'icon-list' },
];
helper.top_buttons = [
{ name = c.loc('Create Profile Package'), uri = c.uri_for('/package/create'), icon = 'icon-star' },
];
ELSE;
helper.dt_buttons = [
{ name = c.loc('Details'), uri = "/package/'+full.id+'/details", class = 'btn-small btn-tertiary', icon = 'icon-list' },
];
END;
PROCESS 'helpers/datatables.tt';

@ -270,7 +270,7 @@
UNLESS c.user.read_only;
helper.dt_buttons = [
{ name = c.loc('Edit'), uri = "/package/'+full.id+'/edit", class = 'btn-small btn-primary', icon = 'icon-edit' },
{ name = c.loc('Terminate'), uri = "/package/'+full[\"id\"]+'/terminate", class = 'btn-small btn-secondary', icon = 'icon-remove', condition => 'full.contract_cnt == "0"' },
{ name = c.loc('Delete'), uri = "/package/'+full[\"id\"]+'/delete", class = 'btn-small btn-secondary', icon = 'icon-remove', condition => 'full.contract_cnt == "0" && full.voucher_cnt == "0"' },
];
END;

@ -9,8 +9,8 @@ use Net::Domain qw(hostfqdn);
use LWP::UserAgent;
use JSON qw();
use Test::More;
#use Storable qw();
use Time::Fake;
use Time::HiRes; #prevent warning from Time::Warp
use Time::Warp qw();
use DateTime::Format::Strptime;
use DateTime::Format::ISO8601;
use Data::Dumper;
@ -118,9 +118,6 @@ my $infinite_future;
ok($past->epoch < $fake_now->epoch,$offset_label . 'past is smaller than now (epoch)');
ok(!($past->epoch > $fake_now->epoch),$offset_label . 'past is not greater than now (epoch)');
}
#use DateTime::Infinite;
#$past = DateTime::Infinite::Past->new();
#$future = DateTime::Infinite::Future->new();
_set_time();
}
@ -327,7 +324,7 @@ if (_get_allow_fake_client_time() && $enable_profile_packages) {
$req_identifier = $cnt . '. switch customer ' . $customer->{id} . ' to package ' . $prof_package_create30d->{name}; diag($req_identifier); $cnt++;
$customer = _switch_package($customer,$prof_package_create30d);
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); $cnt++;
_check_interval_history($customer,[
{ start => '2014-01-01 00:00:00', stop => '2014-01-31 23:59:59'},
{ start => '2014-02-01 00:00:00', stop => '2014-02-28 23:59:59'},
@ -459,26 +456,26 @@ if (_get_allow_fake_client_time() && $enable_profile_packages) {
{ start => '~2014-10-04 13:00:00', stop => $infinite_future},
],NGCP::Panel::Utils::DateTime::from_string($t1));
$req_identifier = $cnt . '. create topup_start_mode_test1 voucher'; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. create topup_start_mode_test1 voucher'; diag($req_identifier); $cnt++;
my $voucher1 = _create_voucher(10,'topup_start_mode_test1'.$t,$customer);
$req_identifier = $cnt . '. create topup_start_mode_test2 voucher'; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. create topup_start_mode_test2 voucher'; diag($req_identifier); $cnt++;
my $voucher2 = _create_voucher(10,'topup_start_mode_test2'.$t,$customer,$prof_package_create1m);
$req_identifier = $cnt . '. create subscriber for customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. create subscriber for customer ' . $customer->{id}; diag($req_identifier); $cnt++;
my $subscriber = _create_subscriber($customer);
$t1 = $ts;
$ts = '2014-10-23 13:00:00';
_set_time(NGCP::Panel::Utils::DateTime::from_string($ts));
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); $cnt++;
_check_interval_history($customer,[
{ start => '~2014-10-04 13:00:00', stop => $infinite_future},
],NGCP::Panel::Utils::DateTime::from_string($t1));
$req_identifier = $cnt . '. perform topup with voucher ' . $voucher1->{code}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. perform topup with voucher ' . $voucher1->{code}; diag($req_identifier); $cnt++;
_perform_topup_voucher($subscriber,$voucher1);
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); $cnt++;
_check_interval_history($customer,[
{ start => '~2014-10-04 13:00:00', stop => '~2014-10-23 13:00:00'},
{ start => '~2014-10-23 13:00:00', stop => $infinite_future},
@ -488,23 +485,23 @@ if (_get_allow_fake_client_time() && $enable_profile_packages) {
$ts = '2014-11-29 13:00:00';
_set_time(NGCP::Panel::Utils::DateTime::from_string($ts));
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); $cnt++;
_check_interval_history($customer,[
{ start => '~2014-10-23 13:00:00', stop => $infinite_future},
],NGCP::Panel::Utils::DateTime::from_string($t1));
$req_identifier = $cnt . '. perform topup with voucher ' . $voucher2->{code}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. perform topup with voucher ' . $voucher2->{code}; diag($req_identifier); $cnt++;
_perform_topup_voucher($subscriber,$voucher2);
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); $cnt++;
_check_interval_history($customer,[
{ start => '~2014-10-23 13:00:00', stop => '2014-12-06 23:59:59'},
],NGCP::Panel::Utils::DateTime::from_string($t1));
$req_identifier = $cnt . '. switch customer ' . $customer->{id} . ' to no package '; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. switch customer ' . $customer->{id} . ' to no package '; diag($req_identifier); $cnt++;
$customer = _switch_package($customer);
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); diag($req_identifier); $cnt++;
$req_identifier = $cnt . '. get balance history of customer ' . $customer->{id}; diag($req_identifier); $cnt++;
_check_interval_history($customer,[
{ start => '~2014-10-23 13:00:00', stop => '2014-11-30 23:59:59', cash => 20},
],NGCP::Panel::Utils::DateTime::from_string($t1));
@ -546,8 +543,6 @@ if (_get_allow_fake_client_time() && $enable_profile_packages) {
_set_time();
my $t1 = time;
#_fetch_intervals_worker(0,'asc');
#_fetch_intervals_worker(0,'desc');
my $delay = 2;
my $t_a = threads->create(\&_fetch_intervals_worker,$delay,'id','asc');
@ -870,12 +865,11 @@ sub _set_time {
pattern => '%F %T',
);
if (defined $o) {
$o = $o->epoch if ref $o eq 'DateTime';
Time::Fake->offset($o);
NGCP::Panel::Utils::DateTime::set_fake_time($o);
my $now = NGCP::Panel::Utils::DateTime::current_local;
diag("applying fake time offset '$o' - current time: " . $dtf->format_datetime($now));
} else {
Time::Fake->reset();
NGCP::Panel::Utils::DateTime::set_fake_time();
my $now = NGCP::Panel::Utils::DateTime::current_local;
diag("resetting fake time - current time: " . $dtf->format_datetime($now));
}
@ -975,9 +969,6 @@ sub _create_base_profile_package {
$req = HTTP::Request->new('POST', $uri.'/api/profilepackages/');
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
#$req->header('X-Request-Identifier' => $req_identifier) if $req_identifier;
#my $name = $start_mode . ($interval_unit ? '/' . $interval_value . ' ' . $interval_unit : '');
my $req_data = {
name => "base profile package " . $t,
description => "base prof package descr " . $t,
@ -998,8 +989,6 @@ sub _create_base_profile_package {
my $profilepackage_uri = $uri.'/'.$res->header('Location');
my $request = $req;
$req = HTTP::Request->new('GET', $profilepackage_uri);
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
#$req->header('X-Request-Identifier' => $req_identifier) if $req_identifier;
$res = $ua->request($req);
is($res->code, 200, "fetch POSTed base profilepackage");
my $package = JSON::from_json($res->decoded_content);
@ -1015,9 +1004,6 @@ sub _create_silver_profile_package {
$req = HTTP::Request->new('POST', $uri.'/api/profilepackages/');
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
#$req->header('X-Request-Identifier' => $req_identifier) if $req_identifier;
#my $name = $start_mode . ($interval_unit ? '/' . $interval_value . ' ' . $interval_unit : '');
my $req_data = {
name => "silver profile package " . $t,
description => "silver prof package descr " . $t,
@ -1041,7 +1027,6 @@ sub _create_silver_profile_package {
my $profilepackage_uri = $uri.'/'.$res->header('Location');
my $request = $req;
$req = HTTP::Request->new('GET', $profilepackage_uri);
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
$res = $ua->request($req);
is($res->code, 200, "fetch POSTed silver profilepackage");
my $package = JSON::from_json($res->decoded_content);
@ -1057,8 +1042,6 @@ sub _create_extension_profile_package {
$req = HTTP::Request->new('POST', $uri.'/api/profilepackages/');
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
#my $name = $start_mode . ($interval_unit ? '/' . $interval_value . ' ' . $interval_unit : '');
my $req_data = {
name => "extension profile package " . $t,
description => "extension prof package descr " . $t,
@ -1082,7 +1065,6 @@ sub _create_extension_profile_package {
my $profilepackage_uri = $uri.'/'.$res->header('Location');
my $request = $req;
$req = HTTP::Request->new('GET', $profilepackage_uri);
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
$res = $ua->request($req);
is($res->code, 200, "fetch POSTed extension profilepackage");
my $package = JSON::from_json($res->decoded_content);
@ -1098,8 +1080,6 @@ sub _create_gold_profile_package {
$req = HTTP::Request->new('POST', $uri.'/api/profilepackages/');
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
#my $name = $start_mode . ($interval_unit ? '/' . $interval_value . ' ' . $interval_unit : '');
my $req_data = {
name => "gold profile package " . $t,
description => "gold prof package descr " . $t,
@ -1123,7 +1103,6 @@ sub _create_gold_profile_package {
my $profilepackage_uri = $uri.'/'.$res->header('Location');
my $request = $req;
$req = HTTP::Request->new('GET', $profilepackage_uri);
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
$res = $ua->request($req);
is($res->code, 200, "fetch POSTed gold profilepackage");
my $package = JSON::from_json($res->decoded_content);
@ -1141,7 +1120,6 @@ sub _create_voucher {
);
$req = HTTP::Request->new('POST', $uri.'/api/vouchers/');
$req->header('Content-Type' => 'application/json');
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
my $req_data = {
amount => $amount * 100.0,
code => $code,
@ -1156,7 +1134,6 @@ sub _create_voucher {
is($res->code, 201, "create " . $label);
my $request = $req;
$req = HTTP::Request->new('GET', $uri.'/'.$res->header('Location'));
#$req->header('X-Fake-Clienttime' => _get_rfc_1123_now());
$res = $ua->request($req);
is($res->code, 200, "fetch " . $label);
my $voucher = JSON::from_json($res->decoded_content);

@ -955,23 +955,13 @@ if ($enable_profile_packages) {
$res = $ua->request($req);
is($res->code, 200, "prof-package: terminate customer");
$req = HTTP::Request->new('PATCH', $profile_package_uri);
$req->header('Prefer' => 'return=representation');
$req->header('Content-Type' => 'application/json-patch+json');
$req->content(JSON::to_json(
[ { op => 'replace', path => '/status', value => 'terminated' } ]
));
$req = HTTP::Request->new('DELETE', $profile_package_uri);
$res = $ua->request($req);
is($res->code, 200, "prof-package: terminate profile package");
is($res->code, 204, "prof-package: delete profile package");
$req = HTTP::Request->new('PATCH', $second_profile_package_uri);
$req->header('Prefer' => 'return=representation');
$req->header('Content-Type' => 'application/json-patch+json');
$req->content(JSON::to_json(
[ { op => 'replace', path => '/status', value => 'terminated' } ]
));
$req = HTTP::Request->new('DELETE', $second_profile_package_uri);
$res = $ua->request($req);
is($res->code, 200, "prof-package: terminate second profile package");
is($res->code, 204, "prof-package: delete second profile package");
$req = HTTP::Request->new('PATCH', $uri.'/api/billingprofiles/'.$third_billing_profile_id);
$req->header('Prefer' => 'return=representation');

@ -175,8 +175,8 @@ sub test_profilepackage {
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
$req->content(JSON::to_json({
name => "test profile package " . $t,
description => "test profile package description " . $t,
name => "test profile package " . ($t-1),
description => "test profile package description " . ($t-1),
reseller_id => $reseller->{id},
initial_profiles => [{ profile_id => $profile->{id}, }, ]
}));
@ -198,8 +198,8 @@ sub test_profilepackage {
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
$req->content(JSON::to_json({
name => "test profile package ".$t." PUT",
description => "test profile package description ".$t." PUT",
name => "test profile package ".($t-1)." PUT",
description => "test profile package description ".($t-1)." PUT",
#reseller_id => $reseller_id,
initial_profiles => [{ profile_id => $profile->{id}, }, ],
}));
@ -217,7 +217,7 @@ sub test_profilepackage {
$req->header('Content-Type' => 'application/json-patch+json');
$req->header('Prefer' => 'return=representation');
$req->content(JSON::to_json(
[ { op => 'replace', path => '/name', value => "test profile package ".$t." PATCH" } ]
[ { op => 'replace', path => '/name', value => "test profile package ".($t-1)." PATCH" } ]
));
$res = $ua->request($req);
is($res->code, 200, "PATCH test profilepackage");
@ -228,9 +228,32 @@ sub test_profilepackage {
_test_item_journal_link('profilepackages',$profilepackage,$profilepackage->{id});
$journal = _test_journal_top_journalitem('profilepackages',$profilepackage->{id},$profilepackage,'update',$journals,$journal);
$req = HTTP::Request->new('DELETE', $profilepackage_uri);
$res = $ua->request($req);
is($res->code, 204, "delete POSTed test profilepackage");
$journal = _test_journal_top_journalitem('profilepackages',$profilepackage->{id},$profilepackage,'delete',$journals,$journal);
_test_journal_collection('profilepackages',$profilepackage->{id},$journals);
$req = HTTP::Request->new('POST', $uri.'/api/profilepackages/');
$req->header('Content-Type' => 'application/json');
$req->header('Prefer' => 'return=representation');
$req->content(JSON::to_json({
name => "test profile package " . $t,
description => "test profile package description " . $t,
reseller_id => $reseller->{id},
initial_profiles => [{ profile_id => $profile->{id}, }, ]
}));
$res = $ua->request($req);
is($res->code, 201, "POST another test profilepackage");
$profilepackage_uri = $uri.'/'.$res->header('Location');
$req = HTTP::Request->new('GET', $profilepackage_uri);
$res = $ua->request($req);
is($res->code, 200, "fetch POSTed profilepackage");
$profilepackage = JSON::from_json($res->decoded_content);
return $profilepackage;
}

@ -144,17 +144,13 @@ if ($enable_profile_packages) {
is($res->code, 200, "fetch PATCHed test profilepackage");
$profilepackage = JSON::from_json($res->decoded_content);
$req = HTTP::Request->new('PATCH', $profilepackage_uri);
$req->header('Content-Type' => 'application/json-patch+json');
$req->header('Prefer' => 'return=representation');
$req->content(JSON::to_json(
[ { op => 'replace', path => '/status', value => "terminated" } ]
));
$req = HTTP::Request->new('DELETE', $profilepackage_uri);
$res = $ua->request($req);
is($res->code, 200, "terminate test profilepackage");
is($res->code, 204, "delete profilepackage");
$req = HTTP::Request->new('GET', $profilepackage_uri);
$res = $ua->request($req);
is($res->code, 404, "try to fetch terminated test profilepackage");
is($res->code, 404, "try to fetch deleted test profilepackage");
}
@ -184,7 +180,7 @@ sub _post_profile_package {
name => "test profile package ".$i . ' ' . $t,
description => "test profile package description ".$i . $t,
reseller_id => $default_reseller_id,
status => 'active',
#status => 'active',
initial_profiles => [{ profile_id => $billingprofile->{id}, network_id => undef },
{ profile_id => $billingprofile->{id}, network_id => $billingnetwork->{id}}],
initial_balance => 0.0,

Loading…
Cancel
Save