X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2FClientAPI%2FMyAccount.pm;h=7e1720da5ca4fd79ea20bca860de6a24f9eec728;hp=2aeecc1b2b16ff7f9645fff5a01a093f55bd44f9;hb=f32ac83068c6211f829f1688a1a9cdec71bc6ec7;hpb=9c533839580e7914f6e64170ffe7aa76fc945275 diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm index 2aeecc1b2..7e1720da5 100644 --- a/FS/FS/ClientAPI/MyAccount.pm +++ b/FS/FS/ClientAPI/MyAccount.pm @@ -7,6 +7,7 @@ use subs qw( _cache _provision ); use IO::Scalar; use Data::Dumper; use Digest::MD5 qw(md5_hex); +use Digest::SHA qw(sha512_hex); use Date::Format; use Time::Duration; use Time::Local qw(timelocal_nocheck); @@ -22,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; @@ -44,16 +45,23 @@ use FS::cust_pkg; use FS::payby; use FS::acct_rt_transaction; use FS::msg_template; +use FS::contact; +use FS::cust_contact; +use FS::cust_location; +use FS::cust_payby; -$DEBUG = 1; +# for code organization +use FS::ClientAPI::MyAccount::contact; +use FS::ClientAPI::MyAccount::quotation; + +$DEBUG = 0; $me = '[FS::ClientAPI::MyAccount]'; 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 @@ -80,7 +88,7 @@ sub skin_info { #return { 'error' => $session } if $context eq 'error'; my $agentnum = ''; - if ( $context eq 'customer' ) { + if ( $context eq 'customer' && $custnum ) { my $sth = dbh->prepare('SELECT agentnum FROM cust_main WHERE custnum = ?') or die dbh->errstr; @@ -94,6 +102,7 @@ sub skin_info { } elsif ( defined($p->{'agentnum'}) and $p->{'agentnum'} =~ /^(\d+)$/ ) { $agentnum = $1; } + $p->{'agentnum'} = $agentnum; my $conf = new FS::Conf; @@ -123,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 @@ -133,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 @@ -209,6 +219,7 @@ sub login { my $conf = new FS::Conf; my $svc_x = ''; + my $session = {}; if ( $p->{'domain'} eq 'svc_phone' && $conf->exists('selfservice_server-phone_login') ) { @@ -226,21 +237,66 @@ sub login { $svc_x = $svc_phone; + } elsif ( $p->{email} + && (my $contact = FS::contact->by_selfservice_email($p->{email})) + ) + { + 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; + } elsif ( scalar(@cust_contact) ) { + $session->{'customers'} = { map { $_->custnum => $_->cust_main->name } + @cust_contact + }; + } else { + return { error => 'No customer self-service access for contact' }; #?? + } + } else { + ( $p->{username}, $p->{domain} ) = split('@', $p->{email}) if $p->{email}; + 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.' } @@ -250,35 +306,37 @@ sub login { } - my $session = { - 'svcnum' => $svc_x->svcnum, - }; + if ( $svc_x ) { - my $cust_svc = $svc_x->cust_svc; - my $cust_pkg = $cust_svc->cust_pkg; - if ( $cust_pkg ) { - my $cust_main = $cust_pkg->cust_main; - $session->{'custnum'} = $cust_main->custnum; - if ( $conf->exists('pkg-balances') ) { - my @cust_pkg = grep { $_->part_pkg->freq !~ /^(0|$)/ } - $cust_main->ncancelled_pkgs; - $session->{'pkgnum'} = $cust_pkg->pkgnum - if scalar(@cust_pkg) > 1; + $session->{'svcnum'} = $svc_x->svcnum; + + my $cust_svc = $svc_x->cust_svc; + my $cust_pkg = $cust_svc->cust_pkg; + if ( $cust_pkg ) { + my $cust_main = $cust_pkg->cust_main; + $session->{'custnum'} = $cust_main->custnum; + if ( $conf->exists('pkg-balances') ) { + my @cust_pkg = grep { $_->part_pkg->freq !~ /^(0|$)/ } + $cust_main->ncancelled_pkgs; + $session->{'pkgnum'} = $cust_pkg->pkgnum + if scalar(@cust_pkg) > 1; + } } - } - #my $pkg_svc = $svc_acct->cust_svc->pkg_svc; - #return { error => 'Only primary user may log in.' } - # if $conf->exists('selfservice_server-primary_only') - # && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' ); - my $part_pkg = $cust_pkg->part_pkg; - return { error => 'Only primary user may log in.' } - if $conf->exists('selfservice_server-primary_only') - && $cust_svc->svcpart != $part_pkg->svcpart([qw( svc_acct svc_phone )]); + #my $pkg_svc = $svc_acct->cust_svc->pkg_svc; + #return { error => 'Only primary user may log in.' } + # if $conf->exists('selfservice_server-primary_only') + # && ( ! $pkg_svc || $pkg_svc->primary_svc ne 'Y' ); + my $part_pkg = $cust_pkg->part_pkg; + return { error => 'Only primary user may log in.' } + if $conf->exists('selfservice_server-primary_only') + && $cust_svc->svcpart != $part_pkg->svcpart([qw( svc_acct svc_phone )]); + + } my $session_id; do { - $session_id = md5_hex(md5_hex(time(). {}. rand(). $$)) + $session_id = sha512_hex(time(). {}. rand(). $$) } until ( ! defined _cache->get($session_id) ); #just in case my $timeout = $conf->config('selfservice-session_timeout') || '1 hour'; @@ -286,16 +344,18 @@ sub login { return { 'error' => '', 'session_id' => $session_id, + %$session, }; } sub logout { my $p = shift; + my $skin_info = skin_info($p); if ( $p->{'session_id'} ) { _cache->remove($p->{'session_id'}); - return { %{ skin_info($p) }, 'error' => '' }; + return { %$skin_info, 'error' => '' }; } else { - return { %{ skin_info($p) }, 'error' => "Can't resume session" }; #better error message + return { %$skin_info, 'error' => "Can't resume session" }; #better error message } } @@ -318,6 +378,23 @@ sub switch_acct { } +sub switch_cust { + my $p = shift; + my($context, $session, $custnum) = _custoragent_session_custnum($p); + return { 'error' => $session } if $context eq 'error'; + + $session->{'custnum'} = $p->{'custnum'} + if exists $session->{'customers'}{ $p->{'custnum'} }; + + my $conf = new FS::Conf; + my $timeout = $conf->config('selfservice-session_timeout') || '1 hour'; + _cache->set( $p->{'session_id'}, $session, $timeout ); + + return { 'error' => '', + %{ customer_info( { session_id=>$p->{'session_id'} } ) }, + }; +} + sub payment_gateway { # internal use only # takes a cust_main and a cust_payby entry, returns the payment_gateway @@ -362,25 +439,28 @@ sub access_info { 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" }; + my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } ); $info->{'hide_payment_fields'} = [ map { - my $pg = payment_gateway($cust_main, $_); + my $pg = $cust_main && payment_gateway($cust_main, $_); $pg && $pg->gateway_namespace eq 'Business::OnlineThirdPartyPayment'; } @{ $info->{cust_paybys} } ]; $info->{'self_suspend_reason'} = - $conf->config('selfservice-self_suspend_reason', $cust_main->agentnum); + $conf->config('selfservice-self_suspend_reason', + $cust_main ? $cust_main->agentnum : '' + ); $info->{'edit_ticket_subject'} = $conf->exists('ticket_system-selfservice_edit_subject') && - $cust_main->edit_subject; + $cust_main && $cust_main->edit_subject; $info->{'timeout'} = $conf->config('selfservice-timeout') || 3600; + $info->{'hide_usage'} = $conf->exists('selfservice_hide-usage'); + return { %$info, 'custnum' => $custnum, 'access_pkgnum' => $session->{'pkgnum'}, @@ -412,7 +492,7 @@ sub customer_info { my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) - or return { 'error' => "unknown custnum $custnum" }; + or return { 'error' => "customer_info: unknown custnum $custnum" }; my $list_tickets = list_tickets($p); $return{'tickets'} = $list_tickets->{'tickets'}; @@ -420,11 +500,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; @@ -445,8 +527,9 @@ sub customer_info { ); $return{has_ship_address} = $cust_main->has_ship_address; - $return{status} = $cust_main->status; + $return{status} = $cust_main->status_label; #$cust_main->status; #better to break anyone obscurely testing for strings in self-service than to have to upgrade every front-end to get the new status to display $return{statuscolor} = $cust_main->statuscolor; + $return{status_label} = $cust_main->status_label; # compatibility: some places in selfservice use this to determine # if there's a ship address @@ -515,7 +598,7 @@ sub customer_info_short { my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) - or return { 'error' => "unknown custnum $custnum" }; + or return { 'error' => "customer_info_short: unknown custnum $custnum" }; $return{display_custnum} = $cust_main->display_custnum; @@ -529,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')); @@ -538,9 +622,9 @@ sub customer_info_short { 1, ##nobalance ); - $return{name} = $cust_main->first. ' '. $cust_main->get('last'); - - $return{payby} = $cust_main->payby; + $return{first} = $cust_main->first; + $return{'last'} = $cust_main->get('last'); + $return{name} = $cust_main->first. ' '. $cust_main->get('last'); #none of these are terribly expensive if we want 'em... for (@cust_main_editable_fields) { @@ -548,15 +632,12 @@ sub customer_info_short { } #maybe a little more expensive, but it should be cached by now for (@location_editable_fields) { - $return{$_} = $cust_main->bill_location->get($_); - $return{'ship_'.$_} = $cust_main->ship_location->get($_); + $return{$_} = $cust_main->bill_location->get($_) + if $cust_main->bill_locationnum; + $return{'ship_'.$_} = $cust_main->ship_location->get($_) + 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 ); @@ -580,6 +661,11 @@ 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, @@ -608,78 +694,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; @@ -723,48 +753,7 @@ 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; - } - - 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' ); - - } 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 $conf = new FS::Conf; my @invoicing_list; if ( exists $p->{'invoicing_list'} || exists $p->{'postal_invoicing'} ) { @@ -818,7 +807,11 @@ sub payment_info { 'card_types' => card_types(), - 'paytypes' => [ @FS::cust_main::paytypes ], + 'withcvv' => $conf->exists('selfservice-require_cvv'), #or enable optional cvv? + 'require_cvv' => $conf->exists('selfservice-require_cvv'), + 'onfile_require_cvv' => $conf->exists('selfservice-onfile_require_cvv'), + + 'paytypes' => [ FS::cust_payby::paytypes ], 'paybys' => [ $conf->config('signup_server-payby') ], 'cust_paybys' => \@cust_paybys, @@ -857,31 +850,30 @@ 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; - - if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) { - $return{card_type} = cardtype($cust_main->payinfo); - $return{payinfo} = $cust_main->paymask; - - @return{'month', 'year'} = $cust_main->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) - } + #XXX look for stored cust_payby info + # + # $return{payname} = $cust_main->payname + # || ( $cust_main->first. ' '. $cust_main->get('last') ); + # + #if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) { + # $return{card_type} = cardtype($cust_main->payinfo); + # $return{payinfo} = $cust_main->paymask; + # + # @return{'month', 'year'} = $cust_main->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 ( $conf->config('prepayment_discounts-credit_type') ) { #need to eval? @@ -890,7 +882,8 @@ sub payment_info { #doubleclick protection my $_date = time; - $return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32; + $return{payunique} = "webui-MyAccount-$_date-$$-". rand() * 2**32; #new + $return{paybatch} = $return{payunique}; #back compat return { 'error' => '', %return, @@ -940,10 +933,16 @@ sub validate_payment { or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} }; my $payname = $1; + $p->{'payunique'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/ + or return { 'error' => gettext('illegal_text'). " payunique: ". $p->{'payunique'} }; + my $payunique = $1; + $p->{'paybatch'} =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=]*)$/ or return { 'error' => gettext('illegal_text'). " paybatch: ". $p->{'paybatch'} }; my $paybatch = $1; + $payunique = $paybatch if ! length($payunique) && length($paybatch); + $p->{'payby'} ||= 'CARD'; $p->{'payby'} =~ /^([A-Z]{4})$/ or return { 'error' => "illegal_payby " . $p->{'payby'} }; @@ -969,10 +968,14 @@ sub validate_payment { $payinfo = $p->{'payinfo'}; + my $onfile = 0; + #more intelligent matching will be needed here if you change #card_masking_method and don't remove existing paymasks - $payinfo = $cust_main->payinfo - if $cust_main->paymask eq $payinfo; + if ( $cust_main->paymask eq $payinfo ) { + $payinfo = $cust_main->payinfo; + $onfile = 1; + } $payinfo =~ s/\D//g; $payinfo =~ /^(\d{13,16}|\d{8,9})$/ @@ -994,6 +997,10 @@ sub validate_payment { or return { 'error' => "CVV2 (CVC2/CID) is three digits." }; $paycvv = $1; } + } elsif ( $conf->exists('selfservice-onfile_require_cvv') ) { + return { 'error' => 'CVV2 is required' }; + } elsif ( !$onfile && $conf->exists('selfservice-require_cvv') ) { + return { 'error' => 'CVV2 is required' }; } } else { @@ -1021,7 +1028,8 @@ sub validate_payment { 'month' => $p->{'month'}, 'year' => $p->{'year'}, 'payname' => $payname, - 'paybatch' => $paybatch, #this doesn't actually do anything + 'payunique' => $payunique, + 'paybatch' => $paybatch, 'paycvv' => $paycvv, 'payname' => $payname, 'discount_term' => $discount_term, @@ -1083,36 +1091,6 @@ sub do_process_payment { my $payby = delete $validate->{'payby'}; - my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount, - 'quiet' => 1, - 'selfservice' => 1, - 'paynum_ref' => \$paynum, - %$validate, - ); - return { 'error' => $error } if $error; - - #no error, so order the fee package if applicable... - my $conf = new FS::Conf; - my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum); - my $fee_skip_first = $conf->exists('selfservice_process-skip_first'); - - if ( $fee_pkgpart and ! $fee_skip_first || scalar($cust_main->cust_pay) ) { - - my $cust_pkg = new FS::cust_pkg { 'pkgpart' => $fee_pkgpart }; - - $error = $cust_main->order_pkg( 'cust_pkg' => $cust_pkg ); - return { 'error' => "payment processed successfully, but error ordering fee: $error" } - if $error; - - #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" } - 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') { @@ -1133,7 +1111,7 @@ sub do_process_payment { stateid stateid_state ); $new->set( 'payby' => $validate->{'auto'} ? 'CHEK' : 'DCHK' ); } - $new->set( 'payinfo' => $cust_main->card_token || $validate->{'payinfo'} ); + $new->payinfo( $validate->{'payinfo'} ); #to properly set paymask $new->set( 'paydate' => $validate->{'paydate'} ); my $error = $new->replace($cust_main); if ( $error ) { @@ -1141,18 +1119,48 @@ sub do_process_payment { #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?? + #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". "NEW: ". Dumper($new)."\n". "OLD: ". Dumper($cust_main)."\n". "PACKET: ". Dumper($validate)."\n"; - #} else { - #not needed... - #$cust_main = $new; + } else { + $cust_main = $new; } } + my $error = $cust_main->realtime_bop( $FS::payby::payby2bop{$payby}, $amount, + 'quiet' => 1, + 'manual' => 1, + 'selfservice' => 1, + 'paynum_ref' => \$paynum, + %$validate, + ); + return { 'error' => $error } if $error; + + #no error, so order the fee package if applicable... + my $conf = new FS::Conf; + my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum); + my $fee_skip_first = $conf->exists('selfservice_process-skip_first'); + + if ( $fee_pkgpart and ! $fee_skip_first || scalar($cust_main->cust_pay) ) { + + my $cust_pkg = new FS::cust_pkg { 'pkgpart' => $fee_pkgpart }; + + $error = $cust_main->order_pkg( 'cust_pkg' => $cust_pkg ); + return { 'error' => "payment processed successfully, but error ordering fee: $error" } + if $error; + + #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" } + if $error; + + } + + $cust_main->apply_payments; + my $cust_pay = ''; my $receipt_html = ''; if ($paynum) { @@ -1195,16 +1203,14 @@ sub do_process_payment { if ( $cust_pay ) { - my($gw, $auth, $order) = split(':', $cust_pay->paybatch); - return { 'error' => '', 'amount' => sprintf('%.2f', $cust_pay->paid), 'date' => $cust_pay->_date, 'date_pretty' => time2str('%Y-%m-%d', $cust_pay->_date), 'time_pretty' => time2str('%T', $cust_pay->_date), - 'auth_num' => $auth, - 'order_num' => $order, + 'auth_num' => $cust_pay->auth, + 'order_num' => $cust_pay->order_number, 'receipt_html' => $receipt_html, }; @@ -1488,7 +1494,6 @@ sub invoice_logo { }; } - sub list_invoices { my $p = shift; my $session = _cache->get($p->{'session_id'}) @@ -1506,25 +1511,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, @@ -1542,6 +1553,79 @@ sub list_invoices { }; } +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? + # 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; + + 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 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'} }; + + return { 'error' => $cust_payby->delete }; + +} + sub cancel { my $p = shift; my $session = _cache->get($p->{'session_id'}) @@ -1572,6 +1656,7 @@ sub list_pkgs { or return { 'error' => "unknown custnum $custnum" }; my $conf = new FS::Conf; + my $immutable = $conf->exists('selfservice_immutable-package'); # the duplication below is necessary: # 1. to maintain the current buggy behaviour wrt the cust_pkg and part_pkg @@ -1584,6 +1669,7 @@ sub list_pkgs { 'custnum' => $custnum, 'cust_pkg' => [ map { { $_->hash, + immutable => $immutable, part_pkg => [ map $_->hashref, $_->part_pkg ], part_svc => [ map $_->hashref, $_->available_part_svc ], @@ -1616,6 +1702,7 @@ sub list_pkgs { my $primary_cust_svc = $_->primary_cust_svc; +{ $_->hash, $_->part_pkg->hash, + immutable => $immutable, pkg_label => $_->pkg_locale, status => $_->status, statuscolor => $_->statuscolor, @@ -1665,6 +1752,9 @@ sub list_svcs { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; + my $conf = new FS::Conf; + + my $hide_usage = $conf->exists('selfservice_hide-usage') ? 1 : 0; my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) @@ -1678,7 +1768,6 @@ sub list_svcs { my @cust_svc = (); my @cust_pkg_usage = (); - #foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) { foreach my $cust_pkg ( $p->{'ncancelled'} ? $cust_main->ncancelled_pkgs : $cust_main->unsuspended_pkgs ) { @@ -1689,14 +1778,16 @@ sub list_svcs { @cust_svc = grep { $_->part_svc->selfservice_access ne 'hidden' } @cust_svc; my %usage_pools; - foreach (@cust_pkg_usage) { - my $part = $_->part_pkg_usage; - my $tag = $part->description . ($part->shared ? 1 : 0); - my $row = $usage_pools{$tag} - ||= [ $part->description, 0, 0, $part->shared ? 1 : 0 ]; - $row->[1] += $_->minutes; # minutes remaining - $row->[2] += $part->minutes; # minutes total - } + if (!$hide_usage) { + foreach (@cust_pkg_usage) { + my $part = $_->part_pkg_usage; + my $tag = $part->description . ($part->shared ? 1 : 0); + my $row = $usage_pools{$tag} + ||= [ $part->description, 0, 0, $part->shared ? 1 : 0 ]; + $row->[1] += sprintf('%.1f', $_->minutes); # minutes remaining + $row->[2] += $part->minutes; # minutes total + } + } # otherwise just leave them empty if ( $p->{'svcdb'} ) { my $svcdb = ref($p->{'svcdb'}) eq 'HASH' @@ -1710,107 +1801,112 @@ sub list_svcs { #@svc_x = sort { $a->domain cmp $b->domain || $a->username cmp $b->username } # @svc_x; - my $conf = new FS::Conf; + my @svcs; # stuff to return to the client + foreach my $cust_svc (@cust_svc) { + my $svc_x = $cust_svc->svc_x; + my($label, $value) = $cust_svc->label; + my $part_svc = $cust_svc->part_svc; + my $svcdb = $part_svc->svcdb; + my $cust_pkg = $cust_svc->cust_pkg; + my $part_pkg = $cust_pkg->part_pkg; + + my %hash = ( + 'svcnum' => $cust_svc->svcnum, + 'display_svcnum' => $cust_svc->display_svcnum, + 'svcdb' => $svcdb, + 'label' => $label, + 'value' => $value, + 'pkg_label' => $cust_pkg->pkg_locale, + 'pkg_status' => $cust_pkg->status, + 'readonly' => ($part_svc->selfservice_access eq 'readonly'), + ); - { + # would it make sense to put this in a svc_* method? + + if ( $svcdb eq 'svc_acct' ) { + foreach (qw(username email finger seconds)) { + $hash{$_} = $svc_x->$_; + } + + if (!$hide_usage) { + %hash = ( + %hash, + 'upbytes' => display_bytecount($svc_x->upbytes), + 'downbytes' => display_bytecount($svc_x->downbytes), + 'totalbytes' => display_bytecount($svc_x->totalbytes), + + 'recharge_amount' => $part_pkg->option('recharge_amount',1), + 'recharge_seconds' => $part_pkg->option('recharge_seconds',1), + 'recharge_upbytes' => + display_bytecount($part_pkg->option('recharge_upbytes',1)), + 'recharge_downbytes' => + display_bytecount($part_pkg->option('recharge_downbytes',1)), + 'recharge_totalbytes' => + display_bytecount($part_pkg->option('recharge_totalbytes',1)), + # more... + ); + } + + } elsif ( $svcdb eq 'svc_dsl' ) { + + $hash{'phonenum'} = $svc_x->phonenum; + if ( $svc_x->first || $svc_x->get('last') || $svc_x->company ) { + $hash{'name'} = $svc_x->first. ' '. $svc_x->get('last'); + $hash{'name'} = $svc_x->company. ' ('. $hash{'name'}. ')' + if $svc_x->company; + } else { + $hash{'name'} = $cust_main->name; + } + # no usage to hide here + + } elsif ( $svcdb eq 'svc_phone' or $svcdb eq 'svc_pbx' ) { + if (!$hide_usage) { + # could potentially show lots of things... + $hash{'outbound'} = 1; + $hash{'inbound'} = 0; + if ( $svcdb eq 'svc_phone' ) { + if ( $part_pkg->plan eq 'voip_inbound' ) { + $hash{'outbound'} = 0; + $hash{'inbound'} = 1; + } elsif ( $part_pkg->option('selfservice_inbound_format') + or $conf->config('selfservice-default_inbound_cdr_format') + ) { + $hash{'inbound'} = 1; + } + } + foreach (qw(inbound outbound)) { + # hmm...we can't filter by status here, because there might + # not be cdr_terminations at all. have to go by date. + # find all since the last bill date. + # XXX cdr types? we are going to need them. + if ( $hash{$_} ) { + my $sum_cdr = $svc_x->sum_cdrs( + 'inbound' => ( $_ eq 'inbound' ? 1 : 0 ), + 'begin' => ($cust_pkg->last_bill || 0), + 'nonzero' => 1, + 'disable_charged_party' => 1, + ); + $hash{$_} = $sum_cdr->hashref; + } + } + } # not hiding usage + } # svcdb + + push @svcs, \%hash; + } # foreach $cust_svc + + 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' => [ - map { - my $svc_x = $_->svc_x; - my($label, $value) = $_->label; - my $part_svc = $_->part_svc; - my $svcdb = $part_svc->svcdb; - my $cust_pkg = $_->cust_pkg; - my $part_pkg = $cust_pkg->part_pkg; - - my %hash = ( - 'svcnum' => $_->svcnum, - 'display_svcnum' => $_->display_svcnum, - 'svcdb' => $svcdb, - 'label' => $label, - 'value' => $value, - 'pkg_label' => $cust_pkg->pkg_locale, - 'pkg_status' => $cust_pkg->status, - 'readonly' => ($part_svc->selfservice_access eq 'readonly'), - ); - - if ( $svcdb eq 'svc_acct' ) { - %hash = ( - %hash, - 'username' => $svc_x->username, - 'email' => $svc_x->email, - 'finger' => $svc_x->finger, - 'seconds' => $svc_x->seconds, - 'upbytes' => display_bytecount($svc_x->upbytes), - 'downbytes' => display_bytecount($svc_x->downbytes), - 'totalbytes' => display_bytecount($svc_x->totalbytes), - - 'recharge_amount' => $part_pkg->option('recharge_amount',1), - 'recharge_seconds' => $part_pkg->option('recharge_seconds',1), - 'recharge_upbytes' => - display_bytecount($part_pkg->option('recharge_upbytes',1)), - 'recharge_downbytes' => - display_bytecount($part_pkg->option('recharge_downbytes',1)), - 'recharge_totalbytes' => - display_bytecount($part_pkg->option('recharge_totalbytes',1)), - # more... - ); - - } elsif ( $svcdb eq 'svc_dsl' ) { - $hash{'phonenum'} = $svc_x->phonenum; - if ( $svc_x->first || $svc_x->get('last') || $svc_x->company ) { - $hash{'name'} = $svc_x->first. ' '. $svc_x->get('last'); - $hash{'name'} = $svc_x->company. ' ('. $hash{'name'}. ')' - if $svc_x->company; - } else { - $hash{'name'} = $cust_main->name; - } - } elsif ( $svcdb eq 'svc_phone' ) { - # could potentially show lots of things... - $hash{'outbound'} = 1; - $hash{'inbound'} = 0; - if ( $part_pkg->plan eq 'voip_inbound' ) { - $hash{'outbound'} = 0; - $hash{'inbound'} = 1; - } elsif ( $part_pkg->option('selfservice_inbound_format') - or $conf->config('selfservice-default_inbound_cdr_format') - ) { - $hash{'inbound'} = 1; - } - foreach (qw(inbound outbound)) { - # hmm...we can't filter by status here, because there might - # not be cdr_terminations at all. have to go by date. - # find all since the last bill date. - # XXX cdr types? we are going to need them. - if ( $hash{$_} ) { - my $sum_cdr = $svc_x->sum_cdrs( - 'inbound' => ( $_ eq 'inbound' ? 1 : 0 ), - 'begin' => ($cust_pkg->last_bill || 0), - 'nonzero' => 1, - ); - $hash{$_} = $sum_cdr->hashref; - } - } - } - - # elsif ( $svcdb eq 'svc_phone' || $svcdb eq 'svc_port' ) { - # %hash = ( - # %hash, - # ); - #} - - \%hash; - } - @cust_svc - ], + 'svcs' => \@svcs, 'usage_pools' => [ map { $usage_pools{$_} } sort { $a cmp $b } keys %usage_pools ], + 'hide_usage' => $hide_usage, }; } @@ -2071,20 +2167,28 @@ sub _list_cdr_usage { # XXX CDR type support... # XXX any way to do a paged search on this? # we have to return the results all at once... - my($svc_phone, $begin, $end, %opt) = @_; + my($svc_x, $begin, $end, %opt) = @_; map [ $_->downstream_csv(%opt, 'keeparray' => 1) ], - $svc_phone->get_cdrs( 'begin'=>$begin, 'end'=>$end, %opt ); + $svc_x->get_cdrs( + 'begin' => $begin, + 'end' => $end, + 'disable_charged_party' => 1, + %opt + ); } sub list_cdr_usage { my $p = shift; - _usage_details( \&_list_cdr_usage, $p, - 'svcdb' => 'svc_phone', - ); + _usage_details( \&_list_cdr_usage, $p ); } sub _usage_details { my($callback, $p, %opt) = @_; + my $conf = FS::Conf->new; + + if ( $conf->exists('selfservice_hide-usage') ) { + return { 'error' => 'Viewing usage is not allowed.' }; + } my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; @@ -2092,18 +2196,17 @@ sub _usage_details { my $search = { 'svcnum' => $p->{'svcnum'} }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; - my $svcdb = $opt{'svcdb'} || 'svc_acct'; - - my $svc_x = qsearchs( $svcdb, $search ); + my $cust_svc = qsearchs( 'cust_svc', $search ); return { 'error' => 'No service selected in list_svc_usage' } - unless $svc_x; + unless $cust_svc; - my $cust_pkg = $svc_x->cust_svc->cust_pkg; + my $svc_x = $cust_svc->svc_x; + my $svcdb = $svc_x->table; + my $cust_pkg = $cust_svc->cust_pkg; my $freq = $cust_pkg->part_pkg->freq; my %callback_opt; my $header = []; - if ( $svcdb eq 'svc_phone' ) { - my $conf = FS::Conf->new; + if ( $svcdb eq 'svc_phone' or $svcdb eq 'svc_pbx' ) { my $format = ''; if ( $p->{inbound} ) { $format = $cust_pkg->part_pkg->option('selfservice_inbound_format') @@ -2137,6 +2240,14 @@ sub _usage_details { %callback_opt ); + if ( $conf->exists('selfservice-hide_cdr_price') ) { + # ugly kludge, I know + my ($delete_col) = grep { $header->[$_] eq 'Price' } (0..scalar(@$header)); + if (defined $delete_col) { + delete($_->[$delete_col]) foreach ($header, @usage); + } + } + #kinda false laziness with FS::cust_main::bill, but perhaps #we should really change this bit to DateTime and DateTime::Duration # @@ -2196,11 +2307,23 @@ sub order_pkg { or return { 'error' => "unknown custnum $custnum" }; my $status = $cust_main->status; + + my %order_pkg_options = (); + if ( $p->{locationnum} > 0 ) { + $order_pkg_options{locationnum} = delete($p->{locationnum}); + } elsif ( $p->{address1} ) { + $order_pkg_options{'cust_location'} = new FS::cust_location { + map { $_ => $p->{$_} } + qw( address1 address2 city county state zip country ) + }; + } + #false laziness w/ClientAPI/Signup.pm my $cust_pkg = new FS::cust_pkg ( { - 'custnum' => $custnum, - 'pkgpart' => $p->{'pkgpart'}, + 'custnum' => $custnum, + 'pkgpart' => $p->{'pkgpart'}, + 'quantity' => $p->{'quantity'} || 1, } ); my $error = $cust_pkg->check; return { 'error' => $error } if $error; @@ -2259,11 +2382,12 @@ sub order_pkg { } - use Tie::RefHash; - tie my %hash, 'Tie::RefHash'; - %hash = ( $cust_pkg => \@svc ); - #msgcat - $error = $cust_main->order_pkgs( \%hash, 'noexport' => 1 ); + $error = $cust_main->order_pkg( + 'cust_pkg' => $cust_pkg, + 'svcs' => \@svc, + 'noexport' => 1, + %order_pkg_options, + ); return { 'error' => $error } if $error; my $conf = new FS::Conf; @@ -2294,6 +2418,10 @@ sub change_pkg { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; + my $conf = new FS::Conf; + my $immutable = $conf->exists('selfservice_immutable-package'); + return { 'error' => "Package modification disabled" } if $immutable; + my $search = { 'custnum' => $custnum }; $search->{'agentnum'} = $session->{'agentnum'} if $context eq 'agent'; my $cust_main = qsearchs('cust_main', $search ) @@ -2303,27 +2431,31 @@ sub change_pkg { my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $p->{pkgnum} } ) or return { 'error' => "unknown package $p->{pkgnum}" }; - my @newpkg; - my $error = FS::cust_pkg::order( $custnum, - [$p->{pkgpart}], - [$p->{pkgnum}], - \@newpkg, - ); + #if someone does need self-service package change of suspended packages, + # figure out how to be more discriminating + return { error=>"Can't change a suspended package", pkgnum=>$cust_pkg->pkgnum} + if $cust_pkg->status eq 'suspended'; + + my $err_or_cust_pkg = $cust_pkg->change( 'pkgpart' => $p->{'pkgpart'}, + 'quantity' => $p->{'quantity'} || 1, + ); + + return { error=>$err_or_cust_pkg, pkgnum=>$cust_pkg->pkgnum } + unless ref($err_or_cust_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, 'no_invoice_void'=>1 ); if ($bill_error) { - $newpkg[0]->suspend; + $err_or_cust_pkg->suspend; return $bill_error; } else { - $newpkg[0]->reexport; + $err_or_cust_pkg->reexport; } } else { - $newpkg[0]->reexport; + $err_or_cust_pkg->reexport; } return { error => '', pkgnum => $cust_pkg->pkgnum }; @@ -2385,30 +2517,38 @@ sub order_recharge { } sub _do_bop_realtime { - my ($cust_main, $status) = (shift, shift); - - my $old_balance = $cust_main->balance; - - my $bill_error = $cust_main->bill - || $cust_main->apply_payments_and_credits - || $cust_main->realtime_collect('selfservice' => 1); - - if ( $cust_main->balance > $old_balance - && $cust_main->balance > 0 - && ( $cust_main->payby !~ /^(BILL|DCRD|DCHK)$/ ? - 1 : $status eq 'suspended' ) ) { - #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' ); - - return { 'error' => '_decline', 'bill_error' => $bill_error }; + my ($cust_main, $status, %opt) = @_; + + 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 }; + } + + ''; } sub renew_info { @@ -2530,11 +2670,12 @@ sub cancel_pkg { } sub provision_phone { - my $p = shift; - my @bulkdid; - @bulkdid = @{$p->{'bulkdid'}} if $p->{'bulkdid'}; + my $p = shift; + my @bulkdid; + @bulkdid = @{$p->{'bulkdid'}} if $p->{'bulkdid'}; - if($p->{'svcnum'} && $p->{'svcnum'} =~ /^\d+$/){ + #editing an existing phone number + if ( $p->{'svcnum'} && $p->{'svcnum'} =~ /^\d+$/ ) { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; @@ -2551,8 +2692,8 @@ sub provision_phone { return { 'error' => $svc_phone->replace }; } -# single DID LNP - unless($p->{'lnp'}) { + # single DID LNP + unless ( $p->{'lnp'} ) { $p->{'lnp_desired_due_date'} = parse_datetime($p->{'lnp_desired_due_date'}); $p->{'lnp_status'} = "portingin"; return _provision( 'FS::svc_phone', @@ -2562,19 +2703,19 @@ sub provision_phone { $p, @_ ); - } + } -# single DID order - unless (scalar(@bulkdid)) { + # single DID order (the usual case) + unless (scalar(@bulkdid)) { return _provision( 'FS::svc_phone', [qw(phonenum countrycode)], [qw(phonenum countrycode)], $p, @_ ); - } + } -# bulk DID order case + # bulk DID order case my $error; foreach my $did ( @bulkdid ) { $did =~ s/[^0-9]//g; @@ -2594,6 +2735,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" @@ -2632,6 +2788,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" @@ -2659,6 +2824,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( { @@ -2666,9 +2834,21 @@ sub _provision { 'svcpart' => $p->{'svcpart'}, map { $_ => $p->{$_} } @$fields } ); + + my %insert_args = (); + #i shouldn't be a special case here (pass an option or something) + if ( $class eq 'FS::svc_phone' + && grep length($p->{$_}), @location_editable_fields + ) + { + $insert_args{'cust_location'} = new FS::cust_location { + map { $_ => $p->{$_} } @location_editable_fields + }; + } + warn "inserting $class record\n" if $DEBUG; - my $error = $svc_x->insert; + my $error = $svc_x->insert(%insert_args); unless ( $error ) { warn "finding inserted record for svcnum ". $svc_x->svcnum. "\n" @@ -2742,6 +2922,10 @@ sub part_svc_info { } } + if ($ret->{'svcdb'} eq 'svc_forward') { + $ret->{'forward_emails'} = {$cust_pkg->forward_emails()}; + } + $ret; } @@ -2804,13 +2988,37 @@ sub myaccount_passwd { my $error = ''; my $conf = new FS::Conf; + + return { 'error' => 'Incorrect current password.' } + if ( exists($p->{'old_password'}) + || $conf->exists('selfservice-password_change_oldpass') + ) + && ! $svc_acct->check_password($p->{'old_password'}); + + # should move password length checks into is_password_allowed $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. + #need to support the "ISP provides email that's used as a contact email" case + #as well as we can. + my $contact = FS::contact->by_selfservice_email($svc_acct->email); + if ( $contact && qsearchs('cust_contact', { contactnum=> $contact->contactnum, + custnum => $custnum, + selfservice_access => 'Y', + } + ) + ) { + #svc_acct was successful but this one returns an error? "shouldn't happen" + $error ||= $contact->change_password($p->{'new_password'}); + } my($label, $value) = $svc_acct->cust_svc->label; @@ -2824,26 +3032,69 @@ sub myaccount_passwd { sub reset_passwd { my $p = shift; + my $info = skin_info($p); + my $conf = new FS::Conf; my $verification = $conf->config('selfservice-password_reset_verification') - or return { 'error' => 'Password resets disabled' }; + or return { %$info, 'error' => 'Password resets disabled' }; + + my $contact = ''; + my $svc_acct = ''; + my $cust_main = ''; + if ( $p->{'email'} ) { #new-style, changes contact and svc_acct + + $contact = FS::contact->by_selfservice_email($p->{'email'}); + + if ( $contact ) { + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $cust_main = $cust_contact[0]->cust_main if scalar(@cust_contact) == 1; + } + + #also look for an svc_acct, otherwise it would be super confusing + + my($username, $domain) = split('@', $p->{'email'}); + my $svc_domain = qsearchs('svc_domain', { 'domain' => $domain } ); + if ( $svc_domain ) { + $svc_acct = qsearchs('svc_acct', { 'username' => $username, + 'domsvc' => $svc_domain->svcnum } + ); + if ( $svc_acct ) { + my $cust_pkg = $svc_acct->cust_svc->cust_pkg; + $cust_main ||= $cust_pkg->cust_main if $cust_pkg; + + #precaution: don't change svc_acct password not part of the same + # customer as contact + $svc_acct = '' if ! $cust_pkg + || $cust_pkg->custnum != $cust_main->custnum; + } + + } + + return { %$info, 'error' => 'Email address not found' } + unless $contact || $svc_acct; + + } elsif ( $p->{'username'} ) { #old style, looks in svc_acct only - my $username = $p->{'username'}; + my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } ) + or return { %$info, 'error' => 'Account not found' }; + + $svc_acct = qsearchs('svc_acct', { 'username' => $p->{'username'}, + 'domsvc' => $svc_domain->svcnum } + ) + or return { %$info, 'error' => 'Account not found' }; - my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } ) - or return { 'error' => 'Account not found' }; + my $cust_pkg = $svc_acct->cust_svc->cust_pkg + or return { %$info, 'error' => 'Account not found' }; - my $svc_acct = qsearchs('svc_acct', { 'username' => $p->{'username'}, - 'domsvc' => $svc_domain->svcnum } - ) - or return { 'error' => 'Account not found' }; + $cust_main = $cust_pkg->cust_main; - my $cust_pkg = $svc_acct->cust_svc->cust_pkg - or return { 'error' => 'Account not found' }; + } - my $cust_main = $cust_pkg->cust_main; + return { %$info, 'error' => 'Multi-customer contacts incompatible with customer-based verification' } + if ! $cust_main && $verification ne 'email'; my %verify = ( + 'email' => sub { 1; }, 'paymask' => sub { my( $p, $cust_main ) = @_; $cust_main->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ @@ -2870,42 +3121,62 @@ sub reset_passwd { foreach my $verify ( split(',', $verification) ) { &{ $verify{$verify} }( $p, $cust_main ) - or return { 'error' => 'Account not found' }; + or return { %$info, 'error' => 'Account not found' }; } - #okay, we're verified, now create a unique session + #okay, we're verified - my $reset_session = { - 'svcnum' => $svc_acct->svcnum, - }; + if ( $contact ) { - my $timeout = '1 hour'; #? + my $error = $contact->send_reset_email( + 'svcnum' => ($svc_acct ? $svc_acct->svcnum : ''), + ); + + if ( $error ) { + return { %$info, 'error' => $error }; #???? + } + + } elsif ( $svc_acct ) { + + #create a unique session + + my $reset_session = { + 'svcnum' => $svc_acct->svcnum, + 'agentnum' => $svc_acct->cust_main->agentnum, + }; + + my $timeout = '1 hour'; #? + + my $reset_session_id; + do { + $reset_session_id = sha512_hex(time(). {}. rand(). $$) + } until ( ! defined _cache->get("reset_passwd_$reset_session_id") ); + #just in case + + _cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout ); + + #email it + + my $msgnum = $conf->config('selfservice-password_reset_msgnum', + $cust_main->agentnum); + #die "selfservice-password_reset_msgnum unset" unless $msgnum; + return { %$info, 'error' => "selfservice-password_reset_msgnum unset" } + unless $msgnum; + my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } ); + my $error = $msg_template->send( 'cust_main' => $cust_main, + 'object' => $svc_acct, + 'substitutions' => { + 'session_id' => $reset_session_id, + } + ); + if ( $error ) { + return { %$info, 'error' => $error }; #???? + } - my $reset_session_id; - do { - $reset_session_id = md5_hex(md5_hex(time(). {}. rand(). $$)) - } until ( ! defined _cache->get("reset_passwd_$reset_session_id") ); #just in case - - _cache->set( "reset_passwd_$reset_session_id", $reset_session, $timeout ); - - #email it - - my $msgnum = $conf->config('selfservice-password_reset_msgnum', $cust_main->agentnum); - #die "selfservice-password_reset_msgnum unset" unless $msgnum; - return { 'error' => "selfservice-password_reset_msgnum unset" } unless $msgnum; - my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } ); - my $error = $msg_template->send( 'cust_main' => $cust_main, - 'object' => $svc_acct, - 'substitutions' => { - 'session_id' => $reset_session_id, - } - ); - if ( $error ) { - return { 'error' => $error }; #???? } - return { 'error' => '' }; + return { %$info, 'error' => '' }; } sub check_reset_passwd { @@ -2918,14 +3189,48 @@ sub check_reset_passwd { my $reset_session = _cache->get('reset_passwd_'. $p->{'session_id'}) or return { 'error' => "Can't resume session" }; #better error message - my $svcnum = $reset_session->{'svcnum'}; + if ( $reset_session->{'svcnum'} ) { - my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } ) - or return { 'error' => "Service not found" }; + my $svcnum = $reset_session->{'svcnum'}; - return { 'error' => '', - 'username' => $svc_acct->username, - }; + my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } ) + or return { 'error' => "Service not found" }; + + $p->{'agentnum'} = $svc_acct->cust_svc->cust_pkg->cust_main->agentnum; + my $info = skin_info($p); + + return { %$info, + 'error' => '', + 'session_id' => $p->{'session_id'}, + 'username' => $svc_acct->username, + }; + + } elsif ( $reset_session->{'contactnum'} ) { + + my $contactnum = $reset_session->{'contactnum'}; + + my $contact = qsearchs('contact', { 'contactnum' => $contactnum } ) + or return { 'error' => "Contact not found" }; + + my @contact_email = $contact->contact_email; + return { 'error' => 'No contact email' } unless @contact_email; + + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum + if scalar(@cust_contact) == 1; + my $info = skin_info($p); + + return { %$info, + 'error' => '', + 'session_id' => $p->{'session_id'}, + 'email' => $contact_email[0]->email, #the first? + }; + + } else { + + return { 'error' => 'No svcnum or contactnum in session' }; #?? + + } } @@ -2936,29 +3241,73 @@ sub process_reset_passwd { my $verification = $conf->config('selfservice-password_reset_verification') or return { 'error' => 'Password resets disabled' }; - return { 'error' => "New passwords don't match." } + my $reset_session = _cache->get('reset_passwd_'. $p->{'session_id'}) + or return { 'error' => "Can't resume session" }; #better error message + + my $info = ''; + + my $svc_acct = ''; + if ( $reset_session->{'svcnum'} ) { + + my $svcnum = $reset_session->{'svcnum'}; + + $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } ) + or return { 'error' => "Service not found" }; + + $p->{'agentnum'} ||= $svc_acct->cust_svc->cust_pkg->cust_main->agentnum; + $info ||= skin_info($p); + + } + + my $contact = ''; + if ( $reset_session->{'contactnum'} ) { + + my $contactnum = $reset_session->{'contactnum'}; + + $contact = qsearchs('contact', { 'contactnum' => $contactnum } ) + or return { 'error' => "Contact not found" }; + + my @cust_contact = grep $_->selfservice_access, $contact->cust_contact; + $p->{'agentnum'} = $cust_contact[0]->cust_main->agentnum + if scalar(@cust_contact) == 1; + $info ||= skin_info($p); + + } + + return { %$info, 'error' => "New passwords don't match." } if $p->{'new_password'} ne $p->{'new_password2'}; - return { 'error' => 'Enter new password' } + return { %$info, 'error' => 'Enter new password' } unless length($p->{'new_password'}); - my $reset_session = _cache->get('reset_passwd_'. $p->{'session_id'}) - or return { 'error' => "Can't resume session" }; #better error message + if ( $svc_acct ) { - my $svcnum = $reset_session->{'svcnum'}; + my $error ||= $svc_acct->is_password_allowed($p->{'new_password'}) + || $svc_acct->set_password($p->{'new_password'}) + || $svc_acct->replace(); - my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $svcnum } ) - or return { 'error' => "Service not found" }; + return { %$info, 'error' => $error } if $error; - $svc_acct->set_password($p->{'new_password'}); - my $error = $svc_acct->replace(); + #my($label, $value) = $svc_acct->cust_svc->label; + #return { 'error' => $error, + # #'label' => $label, + # #'value' => $value, + # }; - my($label, $value) = $svc_acct->cust_svc->label; + } - return { 'error' => $error, - #'label' => $label, - #'value' => $value, - }; + if ( $contact ) { + + my $error = $contact->change_password($p->{'new_password'}); + + return { %$info, 'error' => $error }; # if $error; + + } + + #password changed ,so remove session, don't want it reused + _cache->remove($p->{'session_id'}); + + return { %$info, 'error' => '' }; } @@ -3013,8 +3362,8 @@ sub create_ticket { my($context, $session, $custnum) = _custoragent_session_custnum($p); return { 'error' => $session } if $context eq 'error'; -# warn "$me create_ticket: initializing ticket system\n" if $DEBUG; -# FS::TicketSystem->init(); + warn "$me create_ticket: initializing ticket system\n" if $DEBUG; + FS::TicketSystem->init(); my $conf = new FS::Conf; my $queue = $p->{'queue'}