X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2FClientAPI%2FMyAccount.pm;h=66697efb5318b3a10f35c6be75a4c94abd34a255;hp=c89b7bb3f837f2a7d0f648d779979994bfe26bce;hb=de549dfea65bf78508b508f03e4f9637122166a6;hpb=7898193bb13ad215d1cc95983dbd092fbd2ba799 diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index c89b7bb3f..66697efb5 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -11,7 +11,7 @@ use Digest::SHA qw(sha512_hex); use Date::Format; use Time::Duration; use Time::Local qw(timelocal_nocheck); -use Business::CreditCard; +use Business::CreditCard 0.35; use HTML::Entities; use Text::CSV_XS; use Spreadsheet::WriteExcel; @@ -23,7 +23,7 @@ use FS::Conf; #use FS::UID qw(dbh); use FS::Record qw(qsearch qsearchs dbh); use FS::Msgcat qw(gettext); -use FS::Misc qw(card_types); +use FS::Misc qw(card_types money_pretty); use FS::Misc::DateTime qw(parse_datetime); use FS::TicketSystem; use FS::ClientAPI_SessionCache; @@ -48,8 +48,11 @@ use FS::msg_template; use FS::contact; use FS::cust_contact; use FS::cust_location; +use FS::cust_payby; -use FS::ClientAPI::MyAccount::quotation; # just for code organization +# for code organization +use FS::ClientAPI::MyAccount::contact; +use FS::ClientAPI::MyAccount::quotation; $DEBUG = 0; $me = '[FS::ClientAPI::MyAccount]'; @@ -58,8 +61,7 @@ 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 + ss stateid stateid_state ); @location_editable_fields = qw( address1 address2 city county state zip country @@ -130,7 +132,7 @@ sub skin_info { ), 'menu_disable' => [ $conf->config('selfservice-menu_disable',$agentnum) ], ( map { $_ => $conf->exists("selfservice-$_", $agentnum ) } - qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo ) + qw( menu_skipblanks menu_skipheadings menu_nounderline no_logo enable_payment_without_balance ) ), ( map { $_ => scalar($conf->config_binary("selfservice-$_", $agentnum)) } qw( title_left_image title_right_image @@ -140,6 +142,7 @@ sub skin_info { 'logo' => scalar($conf->config_binary('logo.png', $agentnum )), ( map { $_ => join("\n", $conf->config("selfservice-$_", $agentnum ) ) } qw( head body_header body_footer company_address ) ), + 'money_char' => $conf->config("money_char") || '$', 'menu' => join("\n", $conf->config("ng_selfservice-menu", $agentnum ) ) || 'main.php Home @@ -234,6 +237,13 @@ sub login { $svc_x = $svc_phone; + } elsif ( $p->{'domain'} eq 'ip_mac' ) { + + my $svc_broadband = qsearchs( 'svc_broadband', { 'mac_addr' => $p->{'username'} } ); + return { error => 'IP address not found' } + unless $svc_broadband; + $svc_x = $svc_broadband; + } elsif ( $p->{email} && (my $contact = FS::contact->by_selfservice_email($p->{email})) ) @@ -241,6 +251,8 @@ sub login { return { error => 'Incorrect contact password.' } unless $contact->authenticate_password($p->{'password'}); + $session->{'contactnum'} = $contact->contactnum; + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; if ( scalar(@cust_contact) == 1 ) { $session->{'custnum'} = $cust_contact[0]->custnum; @@ -259,16 +271,39 @@ sub login { my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } ) or return { error => 'Domain '. $p->{'domain'}. ' not found' }; - my $svc_acct = qsearchs( 'svc_acct', { 'username' => $p->{'username'}, - 'domsvc' => $svc_domain->svcnum, } - ); - return { error => 'User not found.' } unless $svc_acct; + my @svc_acct = qsearch( 'svc_acct', { 'username' => $p->{'username'}, + 'domsvc' => $svc_domain->svcnum, } + ); - if($conf->exists('selfservice_server-login_svcpart')) { - my @svcpart = $conf->config('selfservice_server-login_svcpart'); - my $svcpart = $svc_acct->cust_svc->svcpart; - return { error => 'Invalid user.' } - unless grep($_ eq $svcpart, @svcpart); + if ( $conf->exists('selfservice_server-login_svcpart') ) { + my @svcpart = $conf->config('selfservice_server-login_svcpart'); + @svc_acct = grep { my $svcpart = $_->cust_svc->svcpart; + scalar( grep( $_ eq $svcpart, @svcpart ) ); + } + @svc_acct; + } + + if ( $conf->exists('selfservice_server-primary_only') ) { + @svc_acct = + grep { + my $cust_svc = $_->cust_svc; + $cust_svc->cust_pkg->part_pkg->svcpart([qw( svc_acct svc_phone )]) + == $cust_svc->svcpart + } + @svc_acct; + } + + return { error => 'User not found.' } unless @svc_acct; + + return { error => 'Multiple users.' } if scalar(@svc_acct) > 1; + + my $svc_acct = $svc_acct[0]; + + if ( $conf->exists('selfservice_server-login_svcpart') ) { + my @svcpart = $conf->config('selfservice_server-login_svcpart'); + my $svcpart = $svc_acct->cust_svc->svcpart; + return { error => 'Invalid user.' } + unless grep($_ eq $svcpart, @svcpart); } return { error => 'Incorrect password.' } @@ -373,20 +408,12 @@ sub payment_gateway { my $conf = new FS::Conf; my $cust_main = shift; my $cust_payby = shift; - my $gatewaynum = $conf->config('selfservice-payment_gateway'); - if ( $gatewaynum ) { - my $pg = qsearchs('payment_gateway', { gatewaynum => $gatewaynum }); - die "configured gatewaynum $gatewaynum not found!" if !$pg; - return $pg; - } - else { - return '' if ! FS::payby->realtime($cust_payby); - my $pg = $cust_main->agent->payment_gateway( - 'method' => FS::payby->payby2bop($cust_payby), - 'nofatal' => 1 - ); - return $pg; - } + return '' if ! FS::payby->realtime($cust_payby); + my $pg = $cust_main->agent->payment_gateway( + 'method' => FS::payby->payby2bop($cust_payby), + 'nofatal' => 1 + ); + return $pg; } sub access_info { @@ -472,11 +499,13 @@ sub customer_info { if ( $session->{'pkgnum'} ) { #XXX open invoices in the pkg-balances case } else { + $return{'money_char'} = $conf->config("money_char") || '$'; my @open = map { { - invnum => $_->invnum, - date => time2str("%b %o, %Y", $_->_date), - owed => $_->owed, + invnum => $_->invnum, + date => time2str("%b %o, %Y", $_->_date), + owed => $_->owed, + charged => $_->charged, }; } $cust_main->open_cust_bill; $return{open_invoices} = \@open; @@ -571,6 +600,7 @@ sub customer_info_short { or return { 'error' => "customer_info_short: unknown custnum $custnum" }; $return{display_custnum} = $cust_main->display_custnum; + $return{max_invnum} = $cust_main->max_invnum; if ( $session->{'pkgnum'} ) { $return{balance} = $cust_main->balance_pkgnum( $session->{'pkgnum'} ); @@ -582,6 +612,7 @@ sub customer_info_short { $return{next_bill_date} ? time2str('%m/%d/%Y', $return{next_bill_date} ) : '(none)'; } + $return{balance_pretty} = money_pretty($return{balance}); $return{countrydefault} = scalar($conf->config('countrydefault')); @@ -595,8 +626,6 @@ sub customer_info_short { $return{'last'} = $cust_main->get('last'); $return{name} = $cust_main->first. ' '. $cust_main->get('last'); - $return{payby} = $cust_main->payby; - #none of these are terribly expensive if we want 'em... for (@cust_main_editable_fields) { $return{$_} = $cust_main->get($_); @@ -609,11 +638,6 @@ sub customer_info_short { if $cust_main->ship_locationnum; } - if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) { - $return{payinfo} = $cust_main->paymask; - @return{'month', 'year'} = $cust_main->paydate_monthyear; - } - my @invoicing_list = $cust_main->invoicing_list; $return{'invoicing_list'} = join(', ', grep { $_ !~ /^(POST|FAX)$/ } @invoicing_list ); @@ -637,6 +661,34 @@ sub customer_info_short { } + # this is here because this routine is called by both fs_ and ng_ main pages, where it appears + # it is not customer-specific, though it is only shown to authenticated customers + # it is not currently agent-specific, though at some point it might be + $return{'announcement'} = join(' ',$conf->config('selfservice-announcement')) || ''; + + return { 'error' => '', + 'custnum' => $custnum, + %return, + }; +} + +sub customer_recurring { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + my %return; + + my $conf = new FS::Conf; + + my $search = { 'custnum' => $custnum }; + $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; + my $cust_main = qsearchs('cust_main', $search ) + or return { 'error' => "customer_info_short: unknown custnum $custnum" }; + + $return{'display_recurring'} = [ $cust_main->display_recurring ]; + return { 'error' => '', 'custnum' => $custnum, %return, @@ -665,78 +717,22 @@ sub billing_history { } $return{balance} = $cust_main->balance; + $return{balance_pretty} = money_pretty($return{balance}); $return{next_bill_date} = $cust_main->next_bill_date; $return{next_bill_date_pretty} = $return{next_bill_date} ? time2str('%m/%d/%Y', $return{next_bill_date} ) : '(none)'; - my @history = (); - my $conf = new FS::Conf; - if ( $conf->exists('selfservice-billing_history-line_items') ) { - - foreach my $cust_bill ( $cust_main->cust_bill ) { - - push @history, { - 'type' => 'Line item', - 'description' => $_->desc( $cust_main->locale ). - ( $_->sdate && $_->edate - ? ' '. time2str('%d-%b-%Y', $_->sdate). - ' To '. time2str('%d-%b-%Y', $_->edate) - : '' - ), - 'amount' => sprintf('%.2f', $_->setup + $_->recur ), - 'date' => $cust_bill->_date, - 'date_pretty' => time2str('%m/%d/%Y', $cust_bill->_date ), - } - foreach $cust_bill->cust_bill_pkg; - - } - - } else { + $return{'history'} = [ + $cust_main->payment_history( + 'line_items' => $conf->exists('selfservice-billing_history-line_items'), + 'reverse_sort' => 1, + ) + ]; - push @history, { - 'type' => 'Invoice', - 'description' => 'Invoice #'. $_->display_invnum, - 'amount' => sprintf('%.2f', $_->charged ), - 'date' => $_->_date, - 'date_pretty' => time2str('%m/%d/%Y', $_->_date ), - } - foreach $cust_main->cust_bill; - - } - - push @history, { - 'type' => 'Payment', - 'description' => 'Payment', #XXX type - 'amount' => sprintf('%.2f', 0 - $_->paid ), - 'date' => $_->_date, - 'date_pretty' => time2str('%m/%d/%Y', $_->_date ), - } - foreach $cust_main->cust_pay; - - push @history, { - 'type' => 'Credit', - 'description' => 'Credit', #more info? - 'amount' => sprintf('%.2f', 0 -$_->amount ), - 'date' => $_->_date, - 'date_pretty' => time2str('%m/%d/%Y', $_->_date ), - } - foreach $cust_main->cust_credit; - - push @history, { - 'type' => 'Refund', - 'description' => 'Refund', #more info? type, like payment? - 'amount' => $_->refund, - 'date' => $_->_date, - 'date_pretty' => time2str('%m/%d/%Y', $_->_date ), - } - foreach $cust_main->cust_refund; - - @history = sort { $b->{'date'} <=> $a->{'date'} } @history; - - $return{'history'} = \@history; + $return{'money_char'} = $conf->config("money_char") || '$', return \%return; @@ -753,6 +749,11 @@ sub edit_info { my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) or return { 'error' => "unknown custnum $custnum" }; + my $conf = new FS::Conf; + if (($p->{payby} eq "CHEK" || $p->{payby} eq "DCHEK") && $conf->exists('selfservice-ACH_info_readonly')) { + return { 'error' => "You do not have authority to add a bank account" }; + } + my $new = new FS::cust_main { $cust_main->hash }; $new->set( $_ => $p->{$_} ) @@ -780,55 +781,6 @@ sub edit_info { # but if it hasn't been passed in at all, leave ship_location alone-- # DON'T change it to match bill_location. - my $payby = ''; - if (exists($p->{'payby'})) { - $p->{'payby'} =~ /^([A-Z]{4})$/ - or return { 'error' => "illegal_payby " . $p->{'payby'} }; - $payby = $1; - } - - my $conf = new FS::Conf; - - if ( $payby =~ /^(CARD|DCRD)$/ ) { - - $new->paydate($p->{'year'}. '-'. $p->{'month'}. '-01'); - - if ( $new->payinfo eq $cust_main->paymask ) { - $new->payinfo($cust_main->payinfo); - } else { - $new->payinfo($p->{'payinfo'}); - } - - $new->set( 'payby' => $p->{'auto'} ? 'CARD' : 'DCRD' ); - - if ( $conf->exists('selfservice-onfile_require_cvv') ){ - return { 'error' => 'CVV2 is required' } unless $p->{'paycvv'}; - } - - } elsif ( $payby =~ /^(CHEK|DCHK)$/ ) { - - my $payinfo; - $p->{'payinfo1'} =~ /^([\dx]+)$/ - or return { 'error' => "illegal account number ". $p->{'payinfo1'} }; - my $payinfo1 = $1; - $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ? - or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} }; - my $payinfo2 = $1; - $payinfo = $payinfo1. '@'. $payinfo2; - - $new->payinfo( ($payinfo eq $cust_main->paymask) - ? $cust_main->payinfo - : $payinfo - ); - - $new->set( 'payby' => $p->{'auto'} ? 'CHEK' : 'DCHK' ); - - } elsif ( $payby =~ /^(BILL)$/ ) { - #no-op - } elsif ( $payby ) { #notyet ready - return { 'error' => "unknown payby $payby" }; - } - my @invoicing_list; if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) { #false laziness with httemplate/edit/process/cust_main.cgi @@ -885,7 +837,7 @@ sub payment_info { 'require_cvv' => $conf->exists('selfservice-require_cvv'), 'onfile_require_cvv' => $conf->exists('selfservice-onfile_require_cvv'), - 'paytypes' => [ @FS::cust_main::paytypes ], + 'paytypes' => [ FS::cust_payby::paytypes ], 'paybys' => [ $conf->config('signup_server-payby') ], 'cust_paybys' => \@cust_paybys, @@ -898,8 +850,8 @@ sub payment_info { 'show_paystate' => $conf->exists('show_bankstate'), 'save_unchecked' => $conf->exists('selfservice-save_unchecked'), + 'ach_read_only' => $conf->exists('selfservice-ACH_info_readonly'), - 'credit_card_surcharge_percentage' => scalar($conf->config('credit-card-surcharge-percentage')), }; } @@ -910,6 +862,8 @@ sub payment_info { my %return = %$payment_info; + delete $return{'cust_main_county'} if $p->{'omit_cust_main_county'}; + my $custnum = $session->{'custnum'}; my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) @@ -924,30 +878,36 @@ sub payment_info { $return{balance} = $cust_main->balance; #XXX pkg-balances? - $return{payname} = $cust_main->payname - || ( $cust_main->first. ' '. $cust_main->get('last') ); - $return{$_} = $cust_main->bill_location->get($_) for qw(address1 address2 city state zip); - $return{payby} = $cust_main->payby; - $return{stateid_state} = $cust_main->stateid_state; + # look for stored cust_payby info + # only if we've been given a clear payment_payby (to avoid payname conflicts) + if ($p->{'payment_payby'} =~ /^(CARD|CHEK)$/) { + my @search_payby = ($p->{'payment_payby'} eq 'CARD') ? ('CARD','DCRD') : ('CHEK','DCHK'); + my ($cust_payby) = $cust_main->cust_payby(@search_payby); + if ($cust_payby) { + $return{payname} = $cust_payby->payname + || ( $cust_main->first. ' '. $cust_main->get('last') ); + $return{custpaybynum} = $cust_payby->custpaybynum; - if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) { - $return{card_type} = cardtype($cust_main->payinfo); - $return{payinfo} = $cust_main->paymask; + if ( $cust_payby->payby =~ /^(CARD|DCRD)$/ ) { + $return{card_type} = cardtype($cust_payby->payinfo); + $return{payinfo} = $cust_payby->paymask; - @return{'month', 'year'} = $cust_main->paydate_monthyear; + @return{'month', 'year'} = $cust_payby->paydate_monthyear; - } + } - if ( $cust_main->payby =~ /^(CHEK|DCHK)$/ ) { - my ($payinfo1, $payinfo2) = split '@', $cust_main->paymask; - $return{payinfo1} = $payinfo1; - $return{payinfo2} = $payinfo2; - $return{paytype} = $cust_main->paytype; - $return{paystate} = $cust_main->paystate; - $return{payname} = $cust_main->payname; # override 'first/last name' default from above, if any. Is instution-name here. (#15819) + if ( $cust_payby->payby =~ /^(CHEK|DCHK)$/ ) { + my ($payinfo1, $payinfo2) = split '@', $cust_payby->paymask; + $return{payinfo1} = $payinfo1; + $return{payinfo2} = $payinfo2; + $return{paytype} = $cust_payby->paytype; + $return{paystate} = $cust_payby->paystate; + $return{payname} = $cust_payby->payname; # override 'first/last name' default from above, if any. Is instution-name here. (#15819) + } + } } if ( $conf->config('prepayment_discounts-credit_type') ) { @@ -960,6 +920,8 @@ sub payment_info { $return{payunique} = "webui-MyAccount-$_date-$$-". rand() * 2**32; #new $return{paybatch} = $return{payunique}; #back compat + $return{credit_card_surcharge_percentage} = $conf->config('credit-card-surcharge-percentage', $cust_main->agentnum); + return { 'error' => '', %return, }; @@ -1026,6 +988,7 @@ sub validate_payment { #false laziness w/process/payment.cgi my $payinfo; my $paycvv = ''; + my $replace_cust_payby; if ( $payby eq 'CHEK' || $payby eq 'DCHK' ) { $p->{'payinfo1'} =~ /^([\dx]+)$/ @@ -1036,8 +999,19 @@ sub validate_payment { my $payinfo2 = $1; $payinfo = $payinfo1. '@'. $payinfo2; - $payinfo = $cust_main->payinfo - if $cust_main->paymask eq $payinfo; + my $achonfile = 0; + foreach my $cust_payby ($cust_main->cust_payby('CHEK','DCHK')) { + if ( $cust_payby->paymask eq $payinfo ) { + $payinfo = $cust_payby->payinfo; + $replace_cust_payby = $cust_payby; + $achonfile = 1; + last; + } + } + + if ($conf->exists('selfservice-ACH_info_readonly') && !$achonfile) { + return { 'error' => "You are not allowed to change your payment information." }; + } } elsif ( $payby eq 'CARD' || $payby eq 'DCRD' ) { @@ -1047,20 +1021,24 @@ sub validate_payment { #more intelligent matching will be needed here if you change #card_masking_method and don't remove existing paymasks - if ( $cust_main->paymask eq $payinfo ) { - $payinfo = $cust_main->payinfo; - $onfile = 1; + foreach my $cust_payby ($cust_main->cust_payby('CARD','DCRD')) { + if ( $cust_payby->paymask eq $payinfo ) { + $payinfo = $cust_payby->payinfo; + $replace_cust_payby = $cust_payby; + $onfile = 1; + last; + } } $payinfo =~ s/\D//g; - $payinfo =~ /^(\d{13,16}|\d{8,9})$/ + $payinfo =~ /^(\d{13,19}|\d{8,9})$/ or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo $payinfo = $1; validate($payinfo) or return { 'error' => gettext('invalid_card') }; # . ": ". $self->payinfo return { 'error' => gettext('unknown_card_type') } - if $payinfo !~ /^99\d{14}$/ && cardtype($payinfo) eq "Unknown"; + if !$cust_main->tokenized($payinfo) && cardtype($payinfo) eq "Unknown"; if ( length($p->{'paycvv'}) && $p->{'paycvv'} !~ /^\s*$/ ) { if ( cardtype($payinfo) eq 'American Express card' ) { @@ -1088,6 +1066,8 @@ sub validate_payment { 'CHEK' => [ qw( ss paytype paystate stateid stateid_state payip ) ], ); + my %replace = ( 'replace' => $replace_cust_payby, ); + my $card_type = ''; $card_type = cardtype($payinfo) if $payby eq 'CARD'; @@ -1096,7 +1076,7 @@ sub validate_payment { 'amount' => sprintf('%.2f', $amount), 'payby' => $payby, 'payinfo' => $payinfo, - 'paymask' => $cust_main->mask_payinfo( $payby, $payinfo ), + 'paymask' => FS::payinfo_Mixin->mask_payinfo( $payby, $payinfo ), 'card_type' => $card_type, 'paydate' => $p->{'year'}. '-'. $p->{'month'}. '-01', 'paydate_pretty' => $p->{'month'}. ' / '. $p->{'year'}, @@ -1109,6 +1089,7 @@ sub validate_payment { 'payname' => $payname, 'discount_term' => $discount_term, 'pkgnum' => $session->{'pkgnum'}, + %replace, map { $_ => $p->{$_} } ( @{ $payby2fields{$payby} }, qw( save auto ), ) @@ -1166,6 +1147,50 @@ sub do_process_payment { my $payby = delete $validate->{'payby'}; + if ( $validate->{'save'} ) { + + my %saveopt; + foreach my $field ( qw( auto payinfo paymask payname payip ) ) { + $saveopt{$field} = $validate->{$field}; + } + + if ( $payby eq 'CARD' ) { + my $bill_location = FS::cust_location->new({ + map { $_ => $validate->{$_} } + qw(address1 address2 city state country zip) + }); + $saveopt{'bill_location'} = $bill_location; + foreach my $field ( qw( paydate paystart_month paystart_year payissue ) ) { + $saveopt{$field} = $validate->{$field}; + } + } else { + # stateid/stateid_state won't be saved, might be broken as of 4.x + foreach my $field ( qw( paytype paystate ) ) { + $saveopt{$field} = $validate->{$field}; + } + } + + my $error = $cust_main->save_cust_payby( + 'payment_payby' => $payby, + 'replace' => $validate->{'replace'}, # cust_payby object to replace + %saveopt + ); + + if ( $error ) { + #no, this causes customers to process their payments again + #return { 'error' => $error }; + #XXX just warn verosely for now so i can figure out how these happen in + # the first place, eventually should redirect them to the "change + #address" page but indicate if the payment processed? + delete($validate->{'payinfo'}); #don't want to log this! + warn "WARNING: error changing customer info when processing payment (not returning to customer as a processing error): $error\n". + "PAYBY: $payby\n". + "SAVEOPT: ".Dumper(\%saveopt)."\n". + "CUST_MAIN: ". Dumper($cust_main)."\n". + "PACKET: ". Dumper($validate)."\n"; + } + } + my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount, 'quiet' => 1, 'manual' => 1, @@ -1190,53 +1215,13 @@ sub do_process_payment { #and generate an invoice for it now too $error = $cust_main->bill( 'pkg_list' => [ $cust_pkg ] ); - return { 'error' => "payment processed and fee ordered sucessfully, but error billing fee: $error" } + return { 'error' => "payment processed and fee ordered successfully, but error billing fee: $error" } if $error; } $cust_main->apply_payments; - if ( $validate->{'save'} ) { - my $new = new FS::cust_main { $cust_main->hash }; - if ($payby eq 'CARD' || $payby eq 'DCRD') { - $new->set( $_ => $validate->{$_} ) - foreach qw( payname paystart_month paystart_year payissue payip ); - $new->set( 'payby' => $validate->{'auto'} ? 'CARD' : 'DCRD' ); - - my $bill_location = FS::cust_location->new({ - map { $_ => $validate->{$_} } - qw(address1 address2 city state country zip) - }); # county? - $new->set('bill_location' => $bill_location); - # but don't allow the service address to change this way. - - } elsif ($payby eq 'CHEK' || $payby eq 'DCHK') { - $new->set( $_ => $validate->{$_} ) - foreach qw( payname payip paytype paystate - stateid stateid_state ); - $new->set( 'payby' => $validate->{'auto'} ? 'CHEK' : 'DCHK' ); - } - $new->set( 'payinfo' => $cust_main->card_token || $validate->{'payinfo'} ); - $new->set( 'paydate' => $validate->{'paydate'} ); - my $error = $new->replace($cust_main); - if ( $error ) { - #no, this causes customers to process their payments again - #return { 'error' => $error }; - #XXX just warn verosely for now so i can figure out how these happen in - # the first place, eventually should redirect them to the "change - #address" page but indicate the payment did process?? - delete($validate->{'payinfo'}); #don't want to log this! - warn "WARNING: error changing customer info when processing payment (not returning to customer as a processing error): $error\n". - "NEW: ". Dumper($new)."\n". - "OLD: ". Dumper($cust_main)."\n". - "PACKET: ". Dumper($validate)."\n"; - #} else { - #not needed... - #$cust_main = $new; - } - } - my $cust_pay = ''; my $receipt_html = ''; if ($paynum) { @@ -1570,7 +1555,6 @@ sub invoice_logo { }; } - sub list_invoices { my $p = shift; my $session = _cache->get($p->{'session_id'}) @@ -1588,25 +1572,31 @@ sub list_invoices { my @cust_bill = grep ! $_->hide, $cust_main->cust_bill; my $balance = 0; + my $invoices = [ + map { + #not super efficient, we also run cust_bill_pay/cust_credited inside owed + my @payments_and_credits = sort {$b->_date <=> $a->_date} ($_->cust_bill_pay,$_->cust_credited); + my $owed = $_->owed; + $balance += $owed; + +{ 'invnum' => $_->invnum, + '_date' => $_->_date, + 'date' => time2str("%b %o, %Y", $_->_date), + 'date_short' => time2str("%m-%d-%Y", $_->_date), + 'previous' => sprintf('%.2f', ($_->previous)[0]), + 'charged' => sprintf('%.2f', $_->charged), + 'owed' => sprintf('%.2f', $owed), + 'balance' => sprintf('%.2f', $balance), + 'lastpay' => @payments_and_credits + ? time2str("%b %o, %Y", $payments_and_credits[0]->_date) + : '', + } + } @cust_bill + ]; return { 'error' => '', 'balance' => $cust_main->balance, - 'invoices' => [ - map { - my $owed = $_->owed; - $balance += $owed; - +{ 'invnum' => $_->invnum, - '_date' => $_->_date, - 'date' => time2str("%b %o, %Y", $_->_date), - 'date_short' => time2str("%m-%d-%Y", $_->_date), - 'previous' => sprintf('%.2f', ($_->previous)[0]), - 'charged' => sprintf('%.2f', $_->charged), - 'owed' => sprintf('%.2f', $owed), - 'balance' => sprintf('%.2f', $balance), - } - } - @cust_bill - ], + 'money_char' => $conf->config("money_char") || '$', + 'invoices' => $invoices, 'legacy_invoices' => [ map { +{ 'legacyinvnum' => $_->legacyinvnum, @@ -1624,6 +1614,175 @@ sub list_invoices { }; } +sub list_payments { + my $p = shift; + my $session = _cache->get($p->{'session_id'}) + or return { 'error' => "Can't resume session" }; #better error message + + my $custnum = $session->{'custnum'}; + + my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) + or return { 'error' => "unknown custnum $custnum" }; + + return { 'error' => '', + 'balance' => $cust_main->balance, + 'money_char' => FS::Conf->new->config("money_char") || '$', + 'payments' => [ map $_->SSAPI_getinfo, $cust_main->cust_pay ], + }; +} + +sub payment_receipt { + my $p = shift; + my $session = _cache->get($p->{'session_id'}) + or return { 'error' => "Can't resume session" }; #better error message + + my $custnum = $session->{'custnum'}; + + my $cust_pay = qsearchs('cust_pay', { 'custnum' => $custnum, + 'paynum' => $p->{'paynum'}, + } + ) + or return { 'error' => "unknown payment ". $p->{'paynum'} }; + + return { + 'error' => '', + %{ $cust_pay->SSAPI_getinfo }, + }; +} + +sub list_payby { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) + or return { 'error' => "unknown custnum $custnum" }; + + return { + 'payby' => [ map { + my $cust_payby = $_; + +{ + map { $_ => $cust_payby->$_ } + qw( custpaybynum weight payby paymask paydate + payname paystate paytype + ) + }; + } + $cust_main->cust_payby + ], + }; +} + +sub insert_payby { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + #XXX payinfo1 + payinfo2 for CHEK? + #or take the opportunity to use separate, more well- named fields? + if ($p->{'payby'} eq 'CHEK') { + $p->{'payinfo1'} =~ /^([\dx]+)$/ + or return { 'error' => "illegal account number ". $p->{'payinfo1'} }; + my $payinfo1 = $1; + $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ? + or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} }; + my $payinfo2 = $1; + $p->{'payinfo'} = $payinfo1. '@'. $payinfo2; + } + + my $cust_payby = new FS::cust_payby { + 'custnum' => $custnum, + map { $_ => $p->{$_} } qw( weight payby payinfo paycvv paydate payname + paystate paytype payip + ), + }; + + my $error = $cust_payby->insert; + if ( $error ) { + return { 'error' => $error }; + } else { + return { 'custpaybynum' => $cust_payby->custpaybynum }; + } + +} + +sub update_payby { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + if ($p->{'payby'} eq 'CHEK') { + $p->{'payinfo1'} =~ /^([\dx]+)$/ + or return { 'error' => "illegal account number ". $p->{'payinfo1'} }; + my $payinfo1 = $1; + $p->{'payinfo2'} =~ /^([\dx\.]+)$/ # . turned on by echeck-country CA ? + or return { 'error' => "illegal ABA/routing number ". $p->{'payinfo2'} }; + my $payinfo2 = $1; + $p->{'payinfo'} = $payinfo1. '@'. $payinfo2; + } + + my $cust_payby = qsearchs('cust_payby', { + 'custnum' => $custnum, + 'custpaybynum' => $p->{'custpaybynum'}, + }) + or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} }; + + foreach my $field ( + qw( weight payby payinfo paycvv paydate payname paystate paytype payip ) + ) { + next unless exists($p->{$field}); + $cust_payby->set($field,$p->{$field}); + } + + my $error = $cust_payby->replace; + if ( $error ) { + return { 'error' => $error }; + } else { + return { 'custpaybynum' => $cust_payby->custpaybynum }; + } + +} + +sub verify_payby { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + my $cust_payby = qsearchs('cust_payby', { + 'custnum' => $custnum, + 'custpaybynum' => $p->{'custpaybynum'}, + }) + or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} }; + + return { 'error' => $cust_payby->verify }; + +} + +sub delete_payby { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + my $cust_payby = qsearchs('cust_payby', { + 'custnum' => $custnum, + 'custpaybynum' => $p->{'custpaybynum'}, + }) + or return { 'error' => 'unknown custpaybynum '. $p->{'custpaybynum'} }; + + my $conf = new FS::Conf; + if (($cust_payby->payby eq "DCHK" || $cust_payby->payby eq "CHEK") && $conf->exists('selfservice-ACH_info_readonly')) { + return { 'error' => "Sorry you do not have permission to delete bank information." }; + } + else { + return { 'error' => $cust_payby->delete }; + } +} + sub cancel { my $p = shift; my $session = _cache->get($p->{'session_id'}) @@ -1642,6 +1801,30 @@ sub cancel { } +sub pkg_info { + my $p = shift; + + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + my $pkg = qsearchs({ + 'table' => 'cust_pkg', + 'addl_from' => 'LEFT JOIN part_pkg USING ( pkgpart )', + 'hashref' => { + 'custnum' => $custnum, + 'pkgnum' => $p->{'pkgnum'}, + }, + }) + or return {'error' => 'unknown pkg num $pkgnum'}; + + return { + pkg_label => $pkg->pkg, + pkgpart => $pkg->pkgpart, + classnum => $pkg->classnum, + }; + +} + sub list_pkgs { my $p = shift; @@ -1800,6 +1983,7 @@ sub list_svcs { # @svc_x; my @svcs; # stuff to return to the client + my %bytes_used_total; # for _used columns only foreach my $cust_svc (@cust_svc) { my $svc_x = $cust_svc->svc_x; my($label, $value) = $cust_svc->label; @@ -1821,6 +2005,24 @@ sub list_svcs { # would it make sense to put this in a svc_* method? + if (!$hide_usage and grep(/^$svcdb$/, qw(svc_acct svc_broadband)) and $part_svc->part_export_usage) { + my $last_bill = $cust_pkg->last_bill || 0; + my $now = time; + my $up_used = $cust_svc->attribute_since_sqlradacct($last_bill,$now,'AcctInputOctets'); + my $down_used = $cust_svc->attribute_since_sqlradacct($last_bill,$now,'AcctOutputOctets'); + %hash = ( + %hash, + 'seconds_used' => $cust_svc->seconds_since_sqlradacct($last_bill,$now), + 'upbytes_used' => display_bytecount($up_used), + 'downbytes_used' => display_bytecount($down_used), + 'totalbytes_used' => display_bytecount($up_used + $down_used) + ); + $bytes_used_total{'seconds_used'} += $hash{'seconds_used'}; + $bytes_used_total{'upbytes_used'} += $up_used; + $bytes_used_total{'downbytes_used'} += $down_used; + $bytes_used_total{'totalbytes_used'} += $up_used + $down_used; + } + if ( $svcdb eq 'svc_acct' ) { foreach (qw(username email finger seconds)) { $hash{$_} = $svc_x->$_; @@ -1893,12 +2095,19 @@ sub list_svcs { push @svcs, \%hash; } # foreach $cust_svc + foreach my $field (keys %bytes_used_total) { + if ($field =~ /bytes/) { + $bytes_used_total{$field} = display_bytecount($bytes_used_total{$field}); + } + } + return { 'svcnum' => $session->{'svcnum'}, 'custnum' => $custnum, 'date_format' => $conf->config('date_format') || '%m/%d/%Y', 'view_usage_nodomain' => $conf->exists('selfservice-view_usage_nodomain'), 'svcs' => \@svcs, + 'bytes_used_total' => \%bytes_used_total, 'usage_pools' => [ map { $usage_pools{$_} } sort { $a cmp $b } @@ -2391,7 +2600,7 @@ sub order_pkg { my $conf = new FS::Conf; if ( $conf->exists('signup_server-realtime') ) { - my $bill_error = _do_bop_realtime( $cust_main, $status ); + my $bill_error = _do_bop_realtime( $cust_main, $status, 'collect'=>$p->{run_bill_events} ); if ($bill_error) { $cust_pkg->cancel('quiet'=>1); @@ -2437,13 +2646,17 @@ sub change_pkg { my $err_or_cust_pkg = $cust_pkg->change( 'pkgpart' => $p->{'pkgpart'}, 'quantity' => $p->{'quantity'} || 1, ); + + my $new_pkg = qsearchs('part_pkg', { 'pkgpart' => $p->{pkgpart} } ) + or return { 'error' => "unknown package $p->{pkgpart}" }; return { error=>$err_or_cust_pkg, pkgnum=>$cust_pkg->pkgnum } unless ref($err_or_cust_pkg); + if ( $conf->exists('signup_server-realtime') ) { - my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_credit'=>1 ); + my $bill_error = _do_bop_realtime( $cust_main, $status, 'no_invoice_void'=>1 ); if ($bill_error) { $err_or_cust_pkg->suspend; @@ -2456,7 +2669,7 @@ sub change_pkg { $err_or_cust_pkg->reexport; } - return { error => '', pkgnum => $cust_pkg->pkgnum }; + return { error => '', pkg => $new_pkg->pkg, pkgnum => $err_or_cust_pkg->pkgnum }; } @@ -2517,35 +2730,42 @@ sub order_recharge { sub _do_bop_realtime { my ($cust_main, $status, %opt) = @_; - my $old_balance = $cust_main->balance; - - my $bill_error = $cust_main->bill - || $cust_main->apply_payments_and_credits; - - $bill_error ||= $cust_main->realtime_collect('selfservice' => 1) - if $cust_main->payby =~ /^(CARD|CHEK)$/; - - if ( $cust_main->balance > $old_balance - && $cust_main->balance > 0 - && ( $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/ - || $status eq 'suspended' - ) - ) - { - unless ( $opt{'no_credit'} ) { - #this makes sense. credit is "un-doing" the invoice - my $conf = new FS::Conf; - $cust_main->credit( sprintf("%.2f", $cust_main->balance-$old_balance ), - 'self-service decline', - reason_type=>$conf->config('signup_credit_type'), - ); - $cust_main->apply_credits( 'order' => 'newest' ); + my $old_balance = $cust_main->balance; + + my @cust_bill; + my $bill_error = $cust_main->bill( + 'return_bill' => \@cust_bill, + ); + + $bill_error ||= $cust_main->apply_payments_and_credits; + + $bill_error ||= $cust_main->realtime_collect('selfservice' => 1); + + if ( $cust_main->balance > $old_balance + && $cust_main->balance > 0 + && ( $cust_main->has_cust_payby_auto || $status eq 'suspended' ) + ) + { + unless ( $opt{'no_invoice_void'} ) { + + #this used to apply a credit, but now we can void invoices... + foreach my $cust_bill (@cust_bill) { + my $voiderror = $cust_bill->void('automatic payment failed'); + warn "Error voiding cust bill after decline: $voiderror" if $voiderror; } - return { 'error' => '_decline', 'bill_error' => $bill_error }; } - ''; + return { 'error' => '_decline', 'bill_error' => $bill_error }; + } + + if ( $opt{'collect'} ) { + my $collect_error = $cust_main->collect(); + return { 'error' => '_decline', 'bill_error' => $collect_error } + if $collect_error; #? + } + + ''; } sub renew_info { @@ -2651,19 +2871,18 @@ sub cancel_pkg { or return { 'error' => "Can't resume session" }; #better error message my $custnum = $session->{'custnum'}; - my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ) or return { 'error' => "unknown custnum $custnum" }; my $pkgnum = $p->{'pkgnum'}; - my $cust_pkg = qsearchs('cust_pkg', { 'custnum' => $custnum, 'pkgnum' => $pkgnum, } ) or return { 'error' => "unknown pkgnum $pkgnum" }; - my $error = $cust_pkg->cancel('quiet' => 1); + my $error = $cust_pkg->cancel( 'quiet' => 1, + 'date' => $p->{'date'}, + ); return { 'error' => $error }; - } sub provision_phone { @@ -2732,6 +2951,21 @@ sub provision_phone { { 'bulkdid' => [ @bulkdid ], 'svc' => $error->{'svc'} } } +sub provision_pbx { + my $p = shift; + warn "provision_pbx called\n" + if $DEBUG; + + warn "provision_pbx calling _provision\n" + if $DEBUG; + _provision( 'FS::svc_pbx', + [qw(id title max_extensions max_simultaneous ip_addr)], + [qw(id title max_extensions max_simultaneous ip_addr)], + $p, + @_ + ); +} + sub provision_acct { my $p = shift; warn "provision_acct called\n" @@ -2770,6 +3004,15 @@ sub provision_external { ); } +sub provision_forward { + my $p = shift; + _provision( 'FS::svc_forward', + ['srcsvc','src','dstsvc','dst'], + [], + $p, + ); +} + sub _provision { my( $class, $fields, $return_fields, $p ) = splice(@_, 0, 4); warn "_provision called for $class\n" @@ -2797,6 +3040,9 @@ sub _provision { my $part_svc = qsearchs('part_svc', { 'svcpart' => $p->{'svcpart'} } ) or return { 'error' => "unknown svcpart $p->{'svcpart'}" }; + return { error=> 'svcpart '. $p->{'svcpart'}. " is not a $class definition" } + if $class ne 'FS::'. $part_svc->svcdb; + warn "creating $class record\n" if $DEBUG; my $svc_x = $class->new( { @@ -2892,6 +3138,10 @@ sub part_svc_info { } } + if ($ret->{'svcdb'} eq 'svc_forward') { + $ret->{'forward_emails'} = {$cust_pkg->forward_emails()}; + } + $ret; } @@ -2961,13 +3211,9 @@ sub myaccount_passwd { ) && ! $svc_acct->check_password($p->{'old_password'}); - $error = 'Password too short.' - if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6); - $error = 'Password too long.' - if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8); - - $svc_acct->set_password($p->{'new_password'}); - $error ||= $svc_acct->replace(); + $error ||= $svc_acct->is_password_allowed($p->{'new_password'}) + || $svc_acct->set_password($p->{'new_password'}) + || $svc_acct->replace(); #regular pw change in self-service should change contact pw too, otherwise its #way too confusing. hell its confusing they're separate at all, but alas. @@ -2981,6 +3227,8 @@ sub myaccount_passwd { ) ) { #svc_acct was successful but this one returns an error? "shouldn't happen" + #don't recheck is_password_allowed here; if the svc_acct password was + #legal, that's good enough $error ||= $contact->change_password($p->{'new_password'}); } @@ -2993,53 +3241,6 @@ sub myaccount_passwd { } -# sub contact_passwd { -# my $p = shift; -# my($context, $session, $custnum) = _custoragent_session_custnum($p); -# return { 'error' => $session } if $context eq 'error'; -# -# return { 'error' => 'Not logged in as a contact.' } -# unless $session->{'contactnum'}; -# -# return { 'error' => "New passwords don't match." } -# if $p->{'new_password'} ne $p->{'new_password2'}; -# -# return { 'error' => 'Enter new password' } -# unless length($p->{'new_password'}); -# -# #my $search = { 'custnum' => $custnum }; -# #$search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; -# $custnum =~ /^(\d+)$/ or die "illegal custnum"; -# my $search = " AND selfservice_access IS NOT NULL ". -# " AND selfservice_access = 'Y' ". -# " AND ( disabled IS NULL OR disabled = '' )". -# " AND custnum IS NOT NULL AND custnum = $1"; -# $search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent'; -# -# my $contact = qsearchs( { -# 'table' => 'contact', -# 'addl_from' => 'LEFT JOIN cust_main USING ( custnum ) ', -# 'hashref' => { 'contactnum' => $session->{'contactnum'}, }, -# 'extra_sql' => $search, #important -# } ) -# or return { 'error' => "Email not found" }; #? how did we get logged in? -# # deleted since then? -# -# my $error = ''; -# -# # use these svc_acct length restrictions?? -# my $conf = new FS::Conf; -# $error = 'Password too short.' -# if length($p->{'new_password'}) < ($conf->config('passwordmin') || 6); -# $error = 'Password too long.' -# if length($p->{'new_password'}) > ($conf->config('passwordmax') || 8); -# -# $error ||= $contact->change_password($p->{'new_password'}); -# -# return { 'error' => $error, }; -# -# } - sub reset_passwd { my $p = shift; @@ -3066,7 +3267,7 @@ sub reset_passwd { my($username, $domain) = split('@', $p->{'email'}); my $svc_domain = qsearchs('svc_domain', { 'domain' => $domain } ); if ( $svc_domain ) { - $svc_acct = qsearchs('svc_acct', { 'username' => $p->{'username'}, + $svc_acct = qsearchs('svc_acct', { 'username' => $username, 'domsvc' => $svc_domain->svcnum } ); if ( $svc_acct ) { @@ -3154,7 +3355,7 @@ sub reset_passwd { my $reset_session = { 'svcnum' => $svc_acct->svcnum, - 'agentnum' => + 'agentnum' => $svc_acct->cust_main->agentnum, }; my $timeout = '1 hour'; #? @@ -3293,8 +3494,9 @@ sub process_reset_passwd { if ( $svc_acct ) { - $svc_acct->set_password($p->{'new_password'}); - my $error = $svc_acct->replace(); + my $error ||= $svc_acct->is_password_allowed($p->{'new_password'}) + || $svc_acct->set_password($p->{'new_password'}) + || $svc_acct->replace(); return { %$info, 'error' => $error } if $error; @@ -3308,7 +3510,8 @@ sub process_reset_passwd { if ( $contact ) { - my $error = $contact->change_password($p->{'new_password'}); + my $error = $contact->is_password_allowed($p->{'new_password'}) + || $contact->change_password($p->{'new_password'}); return { %$info, 'error' => $error }; # if $error; @@ -3321,6 +3524,45 @@ sub process_reset_passwd { } +sub validate_passwd { + my $p = shift; + + my %result; + %result = ( 'fieldid' => $p->{'fieldid'} ) + if $p->{'fieldid'} =~ /^\w+$/; + + return { %result, 'password_invalid' => 'Enter new password' } + unless length($p->{'check_password'}); + + my $svc_acct; + if ($p->{'svcnum'}) { + # false laziness with myaccount_passwd + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { %result, 'error' => $session } if $context eq 'error'; + + $custnum =~ /^(\d+)$/ or die "illegal custnum"; + my $search = " AND custnum = $1"; + $search .= " AND agentnum = ". $session->{'agentnum'} if $context eq 'agent'; + + $svc_acct = qsearchs( { + 'table' => 'svc_acct', + 'addl_from' => 'LEFT JOIN cust_svc USING ( svcnum ) '. + 'LEFT JOIN cust_pkg USING ( pkgnum ) '. + 'LEFT JOIN cust_main USING ( custnum ) ', + 'hashref' => { 'svcnum' => $p->{'svcnum'}, }, + 'extra_sql' => $search, #important + } ) + or return { %result, 'error' => "Service not found" }; + # end false laziness + } + + $svc_acct ||= new FS::svc_acct {}; + + my $error = $svc_acct->is_password_allowed($p->{'check_password'}); + return { %result, 'password_invalid' => $error } if $error; + return { %result, 'password_valid' => 1 }; +} + sub list_tickets { my $p = shift; my($context, $session, $custnum) = _custoragent_session_custnum($p); @@ -3347,6 +3589,11 @@ sub list_tickets { # unavoidable false laziness w/ httemplate/view/cust_main/tickets.html if ( $FS::TicketSystem::system && FS::TicketSystem->selfservice_priority ) { + + @tickets = grep { $_->{'_selfservice_priority'} + !~ /^\s*(closed?|resolved?|done)\s*/i } + @tickets; + my $conf = new FS::Conf; my $dir = $conf->exists('ticket_system-priority_reverse') ? -1 : 1; +{ tickets => [