'Make appointment',
'View package definition costs', #NEWNEW
'Change package start date',
- 'Add/remove package contract end date',
+ 'Change package contract end date',
],
###
# 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)
+ );
+ }
+
if ( $svcdb eq 'svc_acct' ) {
foreach (qw(username email finger seconds)) {
$hash{$_} = $svc_x->$_;
'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s)' =>
'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s)',
- 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Payment Type' =>
- 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type',
-
- 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Payment Type | Current Balance' =>
- 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type | Current Balance',
+ 'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Mobile phone | Fax number | Invoicing email(s) | Current Balance' =>
+ 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Current Balance',
'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s)' =>
'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s)',
- 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type' =>
- 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type',
-
- 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type | Current Balance' =>
- 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
+ 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
+ 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance',
- 'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Payment Type | Current Balance' =>
- 'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
+ 'Cust# | Agent Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | Invoicing email(s) | Current Balance' =>
+ 'custnum | Agent Cust# | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Current Balance',
- 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Payment Type | Current Balance' =>
- 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Payment Type | Current Balance',
+ 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance' =>
+ 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance',
- 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Payment Type | Current Balance | Advertising Source' =>
- 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Payment Type | Current Balance | Advertising Source',
+ 'Cust# | Cust. Status | Name | Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Latitude | (bill) Longitude | Day phone | Night phone | Mobile phone | Fax number | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Latitude | (service) Longitude | Invoicing email(s) | Current Balance | Advertising Source' =>
+ 'custnum | Status | Last, First | Company | (address+coord) | (all phones) | (service address+coord) | Invoicing email(s) | Current Balance | Advertising Source',
'Invoicing email(s)' => 'Invoicing email(s)',
'Cust# | Invoicing email(s)' => 'custnum | Invoicing email(s)',
FS::Record::qsearchs(@_);
}
+=item new_customer FIRSTNAME
+
+Returns an L<FS::cust_main> object full of default test data, ready to be inserted.
+This doesn't insert the customer, because you might want to change some things first.
+FIRSTNAME is recommended so you know which test the customer was used for.
+
+=cut
+
+sub new_customer {
+ my $self = shift;
+ my $first = shift || 'No Name';
+ my $location = FS::cust_location->new({
+ address1 => '123 Example Street',
+ city => 'Sacramento',
+ state => 'CA',
+ country => 'US',
+ zip => '94901',
+ });
+ my $cust = FS::cust_main->new({
+ agentnum => 1,
+ refnum => 1,
+ last => 'Customer',
+ first => $first,
+ invoice_email => 'newcustomer@fake.freeside.biz',
+ bill_location => $location,
+ ship_location => $location,
+ });
+ $cust;
+}
+
1; # End of FS::Test
'(service) Latitude' => 'ship_latitude',
'(service) Longitude' => 'ship_longitude',
'Invoicing email(s)' => 'invoicing_list_emailonly_scalar',
- 'Payment Type' => 'cust_payby',
+# FS::Upgrade::upgrade_config removes this from existing cust-fields settings
+# 'Payment Type' => 'cust_payby',
'Current Balance' => 'current_balance',
'Agent Cust#' => 'agent_custid',
'Advertising Source' => 'referral',
foreach my $field (qw(daytime night mobile fax )) {
push @fields, $field if (grep { $_ eq $field } @cust_fields);
}
- push @fields, "payby AS cust_payby"
- if grep { $_ eq 'cust_payby' } @cust_fields;
push @fields, 'agent_custid';
my @extra_fields = ();
$conf->delete('unsuspendauto');
}
+ if ($conf->config('cust-fields') =~ / \| Payment Type/) {
+ my $cust_fields = $conf->config('cust-fields');
+ # so we can potentially use 'Payment Types' or somesuch in the future
+ $cust_fields =~ s/ \| Payment Type( \|)/$1/;
+ $cust_fields =~ s/ \| Payment Type$//;
+ $conf->set('cust-fields',$cust_fields);
+ }
+
enable_banned_pay_pad() unless length($conf->config('banned_pay-pad'));
}
'cust_bill_pkg_detail' => [],
#add necessary columns to RT schema
'TicketSystem' => [],
-
+ #remove possible dangling records
+ 'password_history' => [],
+ 'cust_pay_pending' => [],
;
\%hash;
# by default, set the included minutes for this region/time to
# what's in the rate_detail
- $included_min->{$regionnum}{$ratetimenum} = $rate_detail->min_included
- unless exists $included_min->{$regionnum}{$ratetimenum};
+ if (!exists( $included_min->{$regionnum}{$ratetimenum} )) {
+ $included_min->{$regionnum}{$ratetimenum} =
+ ($rate_detail->min_included * $cust_pkg->quantity || 1);
+ }
if ( $included_min->{$regionnum}{$ratetimenum} >= $minutes ) {
$charge_sec = 0;
'name' => 'Number of calls, one line per service',
'invoice_header' => 'Caller,Rate,Messages,Price',
},
+ 'sum_duration' => {
+ 'name' => 'Summary, one line per service',
+ 'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
+ },
'sum_duration_prefix' => {
'name' => 'Summary, one line per destination prefix',
'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
'name' => 'Summary, one line per usage class',
'invoice_header' => 'Caller,Class,Calls,Price',
},
+ 'sum_duration_accountcode' => {
+ 'name' => 'Summary, one line per accountcode',
+ 'invoice_header' => 'Caller,Rate,Calls,Minutes,Price',
+ },
);
my %export_formats = ();
If there is an error, returns the error, otherwise returns false.
+DEPRECATED. Use L</remove_cvv_from_cust_payby> instead.
+
=cut
sub remove_cvv {
}
+=item remove_cvv_from_cust_payby PAYINFO
+
+Removes paycvv from associated cust_payby with matching PAYINFO.
+
+=cut
+
+sub remove_cvv_from_cust_payby {
+ my ($self,$payinfo) = @_;
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ foreach my $cust_payby ( qsearch('cust_payby',{ custnum => $self->custnum }) ) {
+ next unless $cust_payby->payinfo eq $payinfo; # can't qsearch on payinfo
+ $cust_payby->paycvv('');
+ my $error = $cust_payby->replace;
+ if ($error) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
+}
+
=back
=head1 CLASS METHODS
$paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
$content{expiration} = "$2/$1";
- my $paycvv = exists($options{'paycvv'})
- ? $options{'paycvv'}
- : $self->paycvv;
- $content{cvv2} = $paycvv
- if length($paycvv);
+ $content{cvv2} = $options{'paycvv'}
+ if length($options{'paycvv'});
my $paystart_month = exists($options{'paystart_month'})
? $options{'paystart_month'}
###
# compare to FS::cust_main::save_cust_payby - check both to make sure working correctly
- if ( length($self->paycvv)
+ if ( length($options{'paycvv'})
&& ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
) {
- my $error = $self->remove_cvv;
+ my $error = $self->remove_cvv_from_cust_payby($options{payinfo});
if ( $error ) {
warn "WARNING: error removing cvv: $error\n";
}
$paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
$content{expiration} = "$2/$1";
- my $paycvv = exists($options{'paycvv'})
- ? $options{'paycvv'}
- : $self->paycvv;
- $content{cvv2} = $paycvv
- if length($paycvv);
+ $content{cvv2} = $options{'paycvv'}
+ if length($options{'paycvv'});
my $paystart_month = exists($options{'paystart_month'})
? $options{'paystart_month'}
}
}
+ my $log = FS::Log->new('FS::cust_main::Billing_Realtime::realtime_verify_bop');
+
if ( $transaction->is_success() ) {
$cust_pay_pending->status('authorized');
my $e = "Authorization successful but reversal failed, custnum #".
$self->custnum. ': '. $reverse->result_code.
": ". $reverse->error_message;
+ $log->warning($e);
warn $e;
return $e;
}
+ ### Address Verification ###
+ #
+ # Single-letter codes vary by cardtype.
+ #
+ # Erring on the side of accepting cards if avs is not available,
+ # only rejecting if avs occurred and there's been an explicit mismatch
+ #
+ # Charts below taken from vSecure documentation,
+ # shows codes for Amex/Dscv/MC/Visa
+ #
+ # ACCEPTABLE AVS RESPONSES:
+ # Both Address and 5-digit postal code match Y A Y Y
+ # Both address and 9-digit postal code match Y A X Y
+ # United Kingdom – Address and postal code match _ _ _ F
+ # International transaction – Address and postal code match _ _ _ D/M
+ #
+ # ACCEPTABLE, BUT ISSUE A WARNING:
+ # Ineligible transaction; or message contains a content error _ _ _ E
+ # System unavailable; retry R U R R
+ # Information unavailable U W U U
+ # Issuer does not support AVS S U S S
+ # AVS is not applicable _ _ _ S
+ # Incompatible formats – Not verified _ _ _ C
+ # Incompatible formats – Address not verified; postal code matches _ _ _ P
+ # International transaction – address not verified _ G _ G/I
+ #
+ # UNACCEPTABLE AVS RESPONSES:
+ # Only Address matches A Y A A
+ # Only 5-digit postal code matches Z Z Z Z
+ # Only 9-digit postal code matches Z Z W W
+ # Neither address nor postal code matches N N N N
+
+ if (my $avscode = uc($transaction->avs_code)) {
+
+ # map codes to accept/warn/reject
+ my $avs = {
+ 'American Express card' => {
+ 'A' => 'r',
+ 'N' => 'r',
+ 'R' => 'w',
+ 'S' => 'w',
+ 'U' => 'w',
+ 'Y' => 'a',
+ 'Z' => 'r',
+ },
+ 'Discover card' => {
+ 'A' => 'a',
+ 'G' => 'w',
+ 'N' => 'r',
+ 'U' => 'w',
+ 'W' => 'w',
+ 'Y' => 'r',
+ 'Z' => 'r',
+ },
+ 'MasterCard' => {
+ 'A' => 'r',
+ 'N' => 'r',
+ 'R' => 'w',
+ 'S' => 'w',
+ 'U' => 'w',
+ 'W' => 'r',
+ 'X' => 'a',
+ 'Y' => 'a',
+ 'Z' => 'r',
+ },
+ 'VISA card' => {
+ 'A' => 'r',
+ 'C' => 'w',
+ 'D' => 'a',
+ 'E' => 'w',
+ 'F' => 'a',
+ 'G' => 'w',
+ 'I' => 'w',
+ 'M' => 'a',
+ 'N' => 'r',
+ 'P' => 'w',
+ 'R' => 'w',
+ 'S' => 'w',
+ 'U' => 'w',
+ 'W' => 'r',
+ 'Y' => 'a',
+ 'Z' => 'r',
+ },
+ };
+ my $cardtype = cardtype($content{card_number});
+ if ($avs->{$cardtype}) {
+ my $avsact = $avs->{$cardtype}->{$avscode};
+ my $warning = '';
+ if ($avsact eq 'r') {
+ return "AVS code verification failed, cardtype $cardtype, code $avscode";
+ } elsif ($avsact eq 'w') {
+ $warning = "AVS did not occur, cardtype $cardtype, code $avscode";
+ } elsif (!$avsact) {
+ $warning = "AVS code unknown, cardtype $cardtype, code $avscode";
+ } # else $avsact eq 'a'
+ if ($warning) {
+ $log->warning($warning);
+ warn $warning;
+ }
+ } # else $cardtype avs handling not implemented
+ } # else !$transaction->avs_code
+
} else { # is not success
# status is 'done' not 'declined', as in _realtime_bop_result
$self->payinfo($transaction->card_token);
my $error = $self->replace;
if ( $error ) {
- warn "WARNING: error storing token: $error, but proceeding anyway\n";
+ my $warning = "WARNING: error storing token: $error, but proceeding anyway\n";
+ $log->warning($warning);
+ warn $warning;
}
}
}
+sub _upgrade_schema {
+ my ($class, %opts) = @_;
+
+ # fix records where jobnum points to a nonexistent queue job
+ my $sql = 'UPDATE cust_pay_pending SET jobnum = NULL
+ WHERE NOT EXISTS (
+ SELECT 1 FROM queue WHERE queue.jobnum = cust_pay_pending.jobnum
+ )';
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute or die $sth->errstr;
+ '';
+}
+
=back
=head1 BUGS
$self->payinfo($new_account.'@'.$new_aba);
}
- # don't preserve paycvv if it was passed blank and payinfo changed
- unless ( $self->payby =~ /^(CARD|DCRD)$/
- && $old->payinfo ne $self->payinfo
- && $old->paymask ne $self->paymask
- && $self->paycvv =~ /^\s*$/ )
- {
- if ( length($old->paycvv) && $self->paycvv =~ /^\s*[\*x]*\s*$/ ) {
+ # only unmask paycvv if payinfo stayed the same
+ if ( $self->payby =~ /^(CARD|DCRD)$/ and $self->paycvv =~ /^\s*[\*x]+\s*$/ ) {
+ if ( $old->payinfo eq $self->payinfo
+ && $old->paymask eq $self->paymask
+ ) {
$self->paycvv($old->paycvv);
+ } else {
+ $self->paycvv('');
}
}
return "start_date $date is in the past";
}
+ # If the user entered a new location, set it up now.
+ if ( $opt->{'cust_location'} ) {
+ $error = $opt->{'cust_location'}->find_or_insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "creating location record: $error";
+ }
+ $opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
+ }
+
if ( $self->change_to_pkgnum ) {
my $change_to = FS::cust_pkg->by_key($self->change_to_pkgnum);
my $new_pkgpart = $opt->{'pkgpart'}
=item seconds_since_sqlradacct TIMESTAMP_START TIMESTAMP_END
-See L<FS::svc_acct/seconds_since_sqlradacct>. Equivalent to
-$cust_svc->svc_x->seconds_since_sqlradacct, but more efficient. Meaningless
-for records where B<svcdb> is not "svc_acct".
+Equivalent to $cust_svc->svc_x->seconds_since_sqlradacct, but
+more efficient. Meaningless for records where B<svcdb> is not
+svc_acct or svc_broadband.
=cut
-#note: implementation here, POD in FS::svc_acct
sub seconds_since_sqlradacct {
my($self, $start, $end) = @_;
=item attribute_since_sqlradacct TIMESTAMP_START TIMESTAMP_END ATTRIBUTE
See L<FS::svc_acct/attribute_since_sqlradacct>. Equivalent to
-$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient. Meaningless
-for records where B<svcdb> is not "svc_acct".
+$cust_svc->svc_x->attribute_since_sqlradacct, but more efficient.
+Meaningless for records where B<svcdb> is not svc_acct or svc_broadband.
=cut
-#note: implementation here, POD in FS::svc_acct
#(false laziness w/seconds_since_sqlradacct above)
sub attribute_since_sqlradacct {
my($self, $start, $end, $attrib) = @_;
--- /dev/null
+package FS::detail_format::sum_duration_accountcode;
+
+use strict;
+use vars qw( $DEBUG );
+use base qw(FS::detail_format);
+
+$DEBUG = 0;
+
+my $me = '[sum_duration_accountcode]';
+
+sub name { 'Summary, one line per accountcode' };
+
+sub header_detail {
+ 'Account code,Calls,Duration,Price';
+}
+
+sub append {
+ my $self = shift;
+ my $codes = ($self->{codes} ||= {});
+ my $acctids = ($self->{acctids} ||= []);
+ foreach my $cdr (@_) {
+ my $accountcode = $cdr->accountcode || 'other';
+
+ my $object = $self->{inbound} ? $cdr->cdr_termination(1) : $cdr;
+ my $subtotal = $codes->{$accountcode}
+ ||= { count => 0, duration => 0, amount => 0.0 };
+ $subtotal->{count}++;
+ $subtotal->{duration} += $object->rated_seconds;
+ $subtotal->{amount} += $object->rated_price
+ if $object->freesidestatus ne 'no-charge';
+
+ push @$acctids, $cdr->acctid;
+ }
+}
+
+sub finish {
+ my $self = shift;
+ my $codes = $self->{codes};
+ foreach my $accountcode (sort { $a cmp $b } keys %$codes) {
+
+ warn "processing $accountcode\n" if $DEBUG;
+
+ my $subtotal = $codes->{$accountcode};
+
+ $self->csv->combine(
+ $accountcode,
+ $subtotal->{count},
+ sprintf('%.01f min', $subtotal->{duration}/60),
+ $self->money_char . sprintf('%.02f', $subtotal->{amount})
+ );
+
+ warn "adding detail: ".$self->csv->string."\n" if $DEBUG;
+
+ push @{ $self->{buffer} }, FS::cust_bill_pkg_detail->new({
+ amount => $subtotal->{amount},
+ format => 'C',
+ classnum => '', #ignored in this format
+ duration => $subtotal->{duration},
+ phonenum => '', # not divided up per service
+ accountcode => $accountcode,
+ startdate => '',
+ regionname => '',
+ detail => $self->csv->string,
+ acctid => $self->{acctids},
+ });
+ } #foreach $accountcode
+}
+
+1;
bill_and_collect
FS::cust_main::Billing::bill_and_collect
FS::cust_main::Billing::bill
+ FS::cust_main::Billing_Realtime::realtime_verify_bop
FS::pay_batch::import_from_gateway
FS::Misc::Geo::standardize_uscensus
Cron::bill
my $self = shift;
my ($cust_pkg, $sdate) = @_;
my @cutoff_days = $self->cutoff_day($cust_pkg);
- if ( ! $cust_pkg->bill
- and $self->option('prorate_defer_bill',1)
- and @cutoff_days
- ) {
- my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days);
- # If today is the cutoff day, set the next bill and setup both to
- # midnight today, so that the customer will be billed normally for a
- # month starting today.
- if ( $mnow - $mstart < 86400 ) {
- $cust_pkg->setup($mstart);
- $cust_pkg->bill($mstart);
+ if ( @cutoff_days and $self->option('prorate_defer_bill', 1) ) {
+ if ( $cust_pkg->setup ) {
+ # Setup date is already set. Then we're being called indirectly via calc_prorate
+ # to calculate the deferred setup fee. Allow that to happen normally.
+ return 0;
+ } else {
+ # We're going to set the setup date (so that the deferred billing knows when
+ # the package started) and suppress charging the setup fee.
+ if ( $cust_pkg->bill ) {
+ # For some reason (probably user override), the bill date has been set even
+ # though the package isn't billing yet. Start billing as though that was the
+ # start date.
+ $sdate = $cust_pkg->bill;
+ $cust_pkg->setup($cust_pkg->bill);
+ }
+ # Now figure the start and end of the period that contains the start date.
+ my ($mnow, $mend, $mstart) = $self->_endpoints($sdate, @cutoff_days);
+ # If today is the cutoff day, set the next bill and setup both to
+ # midnight today, so that the customer will be billed normally for a
+ # month starting today.
+ if ( $mnow - $mstart < 86400 ) {
+ $cust_pkg->setup($mstart);
+ $cust_pkg->bill($mstart);
+ }
+ else {
+ $cust_pkg->bill($mend);
+ }
+ return 1;
}
- else {
- $cust_pkg->bill($mend);
- }
- return 1;
}
return 0;
}
my $included_min = $self->option('min_included', 1) || 0;
#single price rating
#or region group
+ $included_min *= ($cust_pkg->quantity || 1);
my $included_calls = $self->option('calls_included', 1) || 0;
+ $included_calls *= ($cust_pkg->quantity || 1);
my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum';
my $rating_method = $self->option('rating_method') || 'prefix';
FS::cust_pkg_usage->new({
'pkgnum' => $cust_pkg->pkgnum,
'pkgusagepart' => $part,
- 'minutes' => $part_pkg_usage->minutes,
+ 'minutes' => $part_pkg_usage->minutes *
+ ($cust_pkg->quantity || 1),
});
foreach my $cdr_usage (
qsearch('cdr_cust_pkg_usage', {'cdrusagenum' => $usage->cdrusagenum})
# my $downstream_cdr = '';
my $included_min = $self->option('min_included', 1) || 0;
+ $included_min *= ($cust_pkg->quantity || 1);
my $use_duration = $self->option('use_duration');
my $output_format = $self->option('output_format', 1) || 'default';
# find the price and add detail to the invoice
###
- $included_min{$regionnum} = $rate_detail->min_included
+ $included_min{$regionnum} =
+ ($rate_detail->min_included * $cust_pkg->quantity || 1)
unless exists $included_min{$regionnum};
my $granularity = $rate_detail->sec_granularity;
&& ( $last_bill eq '' || $last_bill == 0 );
my $included_min = $self->option('min_included', 1) || 0;
+ $included_min *= ($cust_pkg->quantity || 1);
my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum';
my $cdr_inout = ($cdr_svc_method eq 'svc_phone.phonenum')
&& $self->option('cdr_inout',1)
}
+sub _upgrade_schema {
+ # clean up history records where linked_acct has gone away
+ my @where;
+ for my $fk ( grep /__/, __PACKAGE__->dbdef_table->columns ) {
+ my ($table, $key) = split(/__/, $fk);
+ push @where, "
+ ( $fk IS NOT NULL AND NOT EXISTS(SELECT 1 FROM $table WHERE $table.$key = $fk) )";
+ }
+ my @recs = qsearch({
+ 'table' => 'password_history',
+ 'extra_sql' => ' WHERE ' . join(' AND ', @where),
+ });
+ my $error;
+ if (@recs) {
+ warn "Removing unattached password_history records (".scalar(@recs).").\n";
+ foreach my $password_history (@recs) {
+ $error = $password_history->delete;
+ die $error if $error;
+ }
+ }
+ '';
+}
+
=back
=head1 BUGS
use Text::CSV;
my %opt;
-getopts('vqa:P:C:e:', \%opt);
+getopts('vqNa:P:C:e:', \%opt);
# Product codes that are subject to flat rate E911 charges. For these
# products, the'quantity' field represents the number of lines.
freeside-ipifony-download
[ -v ]
[ -q ]
+ [ -N ]
[ -a archivedir ]
[ -P port ]
[ -C category ]
if ( $next_bill_date ) {
my ($bill_month, $bill_year) = (localtime($next_bill_date))[4, 5];
my ($this_month, $this_year) = (localtime(time))[4, 5];
- if ( $this_month == $bill_month and $this_year == $bill_year ) {
+ if ( $opt{N} or
+ $this_month == $bill_month and $this_year == $bill_year ) {
$cust_main->set('charge_date', $next_bill_date);
}
}
freeside-ipifony-download
[ -v ]
[ -q ]
+ [ -N ]
[ -a archivedir ]
[ -P port ]
[ -C category ]
I<hostname>: the SFTP server.
+I<path>: the path on the server to the working directory. The working
+directory is the one containing the "ready/" and "done/" subdirectories.
+
=head1 OPTIONAL PARAMETERS
-v: Be verbose.
-q: Include the quantity and unit price in the charge description.
+-N: Always bill the charges on the customer's next bill date, if they have
+one. Otherwise, charges will be billed on the next bill date only if it's
+within the current calendar month.
+
-a I<archivedir>: Save a copy of the downloaded file to I<archivedir>.
-P I<port>: Connect to that TCP port.
--- /dev/null
+#!/usr/bin/perl
+
+=head2 DESCRIPTION
+
+Tests the prorate_defer_bill behavior when a package is started on the cutoff day,
+and when it's started later in the month.
+
+Correct: The package started on the cutoff day should be charged a setup fee and a
+full period. The package started later in the month should be charged a setup fee,
+a full period, and the partial period.
+
+=cut
+
+use strict;
+use Test::More tests => 11;
+use FS::Test;
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use Test::MockTime qw(set_fixed_time);
+use FS::cust_main;
+use FS::cust_pkg;
+use FS::Conf;
+my $FS= FS::Test->new;
+
+my $error;
+
+my $old_part_pkg = $FS->qsearchs('part_pkg', { pkgpart => 2 });
+my $part_pkg = $old_part_pkg->clone;
+BAIL_OUT("existing pkgpart 2 is not a prorated monthly package")
+ unless $part_pkg->freq eq '1' and $part_pkg->plan eq 'prorate';
+$error = $part_pkg->insert(
+ options => { $old_part_pkg->options,
+ 'prorate_defer_bill' => 1,
+ 'cutoff_day' => 1,
+ 'setup_fee' => 100,
+ 'recur_fee' => 30,
+ }
+);
+BAIL_OUT("can't configure package: $error") if $error;
+
+my $cust = $FS->new_customer('Prorate defer');
+$error = $cust->insert;
+BAIL_OUT("can't create test customer: $error") if $error;
+
+my @pkgs;
+foreach my $start_day (1, 11) {
+ diag("prorate package starting on day $start_day");
+ # Create and bill the first package.
+ my $date = str2time("2016-04-$start_day");
+ set_fixed_time($date);
+ my $pkg = FS::cust_pkg->new({ pkgpart => $part_pkg->pkgpart });
+ $error = $cust->order_pkg({ 'cust_pkg' => $pkg });
+ BAIL_OUT("can't order package: $error") if $error;
+
+ # bill the customer on the order date
+ $error = $cust->bill_and_collect;
+ $pkg = $pkg->replace_old;
+ push @pkgs, $pkg;
+ my ($cust_bill_pkg) = $pkg->cust_bill_pkg;
+ if ( $start_day == 1 ) {
+ # then it should bill immediately
+ ok($cust_bill_pkg, "package was billed") or next;
+ ok($cust_bill_pkg->setup == 100, "setup fee was charged");
+ ok($cust_bill_pkg->recur == 30, "one month was charged");
+ } elsif ( $start_day == 11 ) {
+ # then not
+ ok(!$cust_bill_pkg, "package billing was deferred");
+ ok($pkg->setup == $date, "package setup date was set");
+ }
+}
+diag("first of month billing...");
+my $date = str2time('2016-05-01');
+set_fixed_time($date);
+my @bill;
+$error = $cust->bill_and_collect(return_bill => \@bill);
+# examine the invoice...
+my $cust_bill = $bill[0] or BAIL_OUT("neither package was billed");
+for my $pkg ($pkgs[0]) {
+ diag("package started day 1:");
+ my ($cust_bill_pkg) = grep {$_->pkgnum == $pkg->pkgnum} $cust_bill->cust_bill_pkg;
+ ok($cust_bill_pkg, "was billed") or next;
+ ok($cust_bill_pkg->setup == 0, "no setup fee was charged");
+ ok($cust_bill_pkg->recur == 30, "one month was charged");
+}
+for my $pkg ($pkgs[1]) {
+ diag("package started day 11:");
+ my ($cust_bill_pkg) = grep {$_->pkgnum == $pkg->pkgnum} $cust_bill->cust_bill_pkg;
+ ok($cust_bill_pkg, "was billed") or next;
+ ok($cust_bill_pkg->setup == 100, "setup fee was charged");
+ ok($cust_bill_pkg->recur == 50, "twenty days + one month was charged");
+}
+
--- /dev/null
+#!/usr/bin/perl
+
+=head2 DESCRIPTION
+
+Test scheduling a package location change through the UI, then billing
+on the day of the scheduled change.
+
+=cut
+
+use Test::More tests => 6;
+use FS::Test;
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use Test::MockTime qw(set_fixed_time);
+use FS::cust_pkg;
+my $FS = FS::Test->new;
+my $error;
+
+# set up a customer with an active package
+my $cust = $FS->new_customer('Future location change');
+$error = $cust->insert;
+my $pkg = FS::cust_pkg->new({pkgpart => 2});
+$error ||= $cust->order_pkg({ cust_pkg => $pkg });
+my $date = str2time('2016-04-01');
+set_fixed_time($date);
+$error ||= $cust->bill_and_collect;
+BAIL_OUT($error) if $error;
+
+# get the form
+my %args = ( pkgnum => $pkg->pkgnum,
+ pkgpart => $pkg->pkgpart,
+ locationnum => -1);
+$FS->post('/misc/change_pkg.cgi', %args);
+my $form = $FS->form('OrderPkgForm');
+
+# Schedule the package change two days from now.
+$date += 86400*2;
+my $date_str = time2str('%x', $date);
+
+my %params = (
+ start_date => $date_str,
+ delay => 1,
+ address1 => int(rand(1000)) . ' Changed Street',
+ city => 'New City',
+ state => 'CA',
+ zip => '90001',
+ country => 'US',
+);
+
+diag "requesting location change to $params{address1}";
+
+foreach (keys %params) {
+ $form->value($_, $params{$_});
+}
+$FS->post($form);
+ok( $FS->error eq '' , 'form posted' );
+if ( ok( $FS->page =~ m[location.reload], 'location change accepted' )) {
+ #nothing
+} else {
+ $FS->post($FS->redirect);
+ BAIL_OUT( $FS->error);
+}
+# check that the package change is set
+$pkg = $pkg->replace_old;
+my $new_pkgnum = $pkg->change_to_pkgnum;
+ok( $new_pkgnum, 'package change is scheduled' );
+
+# run it and check that the package change happened
+diag("billing customer on $date_str");
+set_fixed_time($date);
+my $error = $cust->bill_and_collect;
+BAIL_OUT($error) if $error;
+
+$pkg = $pkg->replace_old;
+ok($pkg->get('cancel'), "old package is canceled");
+my $new_pkg = $FS->qsearchs('cust_pkg', { pkgnum => $new_pkgnum });
+ok($new_pkg->setup, "new package is active");
+ok($new_pkg->cust_location->address1 eq $params{'address1'}, "new location is correct")
+ or diag $new_pkg->cust_location->address1;
+
+1;
+
sub view_usage {
my $res = list_svcs(
'session_id' => $session_id,
- 'svcdb' => [ 'svc_acct', 'svc_phone', 'svc_port', 'svc_pbx' ],
+ 'svcdb' => [ 'svc_acct', 'svc_broadband', 'svc_phone', 'svc_port', 'svc_pbx' ],
'ncancelled' => 1,
);
if ($res->{hide_usage}) {
<%= $url = "$selfurl?action=";
%by_pkg_label = (); # not used yet, but I'm sure it will be...
- @svc_acct = ();
@svc_phone = ();
@svc_port = ();
@svc_pbx = ();
-
- foreach (@svcs) {
- $by_pkg_label{ $_->{pkg_label} } ||= [];
- push @{ $by_pkg_label{ $_->{pkg_label} } }, $_;
- if ( $_->{svcdb} eq 'svc_acct' ) {
- push @svc_acct, $_;
- } elsif ( $_->{svcdb} eq 'svc_phone' ) {
- push @svc_phone, $_;
- } elsif ( $_->{svcdb} eq 'svc_port' ) {
- push @svc_port, $_;
- } elsif ( $_->{svcdb} eq 'svc_pbx' ) {
- push @svc_pbx, $_;
+ @bytes_svcs = (); # contains svc_acct and svc_broadband
+ @bytes_cols = qw(seconds_used seconds upbytes_used upbytes downbytes_used downbytes totalbytes_used totalbytes);
+ %bytes_show = map { $_ => 0 } @bytes_cols;
+
+ foreach my $svc (@svcs) {
+ $by_pkg_label{ $svc->{pkg_label} } ||= [];
+ push @{ $by_pkg_label{ $svc->{pkg_label} } }, $svc;
+
+ if (( $svc->{svcdb} eq 'svc_acct' ) || ( $svc->{svcdb} eq 'svc_broadband' )) {
+ foreach my $field (@bytes_cols) {
+ $bytes_show{$field} = 1 if length($svc->{$field}) or (($field !~ /_used$/) && $svc->{'recharge_'.$field});
+ }
+ push @bytes_svcs, $svc;
+ } elsif ( $svc->{svcdb} eq 'svc_phone' ) {
+ push @svc_phone, $svc;
+ } elsif ( $svc->{svcdb} eq 'svc_port' ) {
+ push @svc_port, $svc;
+ } elsif ( $svc->{svcdb} eq 'svc_pbx' ) {
+ push @svc_pbx, $svc;
}
}
'';
$OUT .= qq!<FONT SIZE="+1" COLOR="#ff0000">$error</FONT><BR><BR>!;
} ''; %>
-<%= if ( @svc_acct ) {
- $OUT.= '<TABLE BGCOLOR="#cccccc">
- <TR>
- <TH ALIGN="left">Account</TH>
- <TH ALIGN="right">Time remaining</TH>
- <TH ALIGN="right">Upload remaining</TH>
- <TH ALIGN="right">Download remaining</TH>
- <TH ALIGN="right">Total remaining</TH>
- </TR>';
- } else {
- $OUT .= '';
+<%= if ( @bytes_svcs ) {
+ $OUT .= '<TABLE BGCOLOR="#cccccc"><TR>';
+ $OUT .= '<TH ALIGN="left">Account</TH>';
+ $OUT .= '<TH ALIGN="right">Time used</TH>' if $bytes_show{'seconds_used'};
+ $OUT .= '<TH ALIGN="right">Time remaining</TH>' if $bytes_show{'seconds'};
+ $OUT .= '<TH ALIGN="right">Upload used</TH>' if $bytes_show{'upbytes_used'};
+ $OUT .= '<TH ALIGN="right">Upload remaining</TH>' if $bytes_show{'upbytes'};
+ $OUT .= '<TH ALIGN="right">Download used</TH>' if $bytes_show{'downbytes_used'};
+ $OUT .= '<TH ALIGN="right">Download remaining</TH>' if $bytes_show{'downbytes'};
+ $OUT .= '<TH ALIGN="right">Total used</TH>' if $bytes_show{'totalbytes_used'};
+ $OUT .= '<TH ALIGN="right">Total remaining</TH>' if $bytes_show{'totalbytes'};
+ $OUT .= '</TR>';
}
%>
-<%= foreach my $svc ( @svc_acct ) {
- my $link = "${url}view_usage_details;".
- "svcnum=$svc->{'svcnum'};beginning=0;ending=0";
+<%= foreach my $svc ( @bytes_svcs ) {
+ my $link = "${url}view_usage_details;".
+ "svcnum=$svc->{'svcnum'};beginning=0;ending=0";
my $username = $svc->{'value'};
$username =~ s/@.*?$//g if $view_usage_nodomain;
- $OUT .= '<TR><TD>';
- $OUT .= qq!<A HREF="$link">!. $svc->{'label'}. ': '. $username .'</A>';
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'seconds'};
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'upbytes'};
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'downbytes'};
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'totalbytes'};
- $OUT .= '</TD></TR>';
+ $OUT .= '<TR>';
+ $OUT .= '<TD>' . qq!<A HREF="$link">!. $svc->{'label'}. ': '. $username .'</A></TD>';
+ foreach my $field (@bytes_cols) {
+ $OUT .= '<TD ALIGN="right">' . $svc->{$field} . '</TD>' if $bytes_show{$field};
+ }
if ( $svc->{'recharge_amount'} ) {
my $link = "${url}process_order_recharge;".
"svcnum=$svc->{'svcnum'}";
- $OUT .= '<TR><TD ALIGN="right">';
+ $OUT .= '<TR><TD ALIGN="right">';
$OUT .= qq!<A HREF="$link">!.'Recharge for $';
$OUT .= $svc->{'recharge_amount'} . '</A> with';
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'recharge_seconds'} if $svc->{'recharge_seconds'};
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'recharge_upbytes'} if $svc->{'recharge_upbytes'};
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'recharge_downbytes'} if $svc->{'recharge_downbytes'};
- $OUT .= '</TD><TD ALIGN="right">';
- $OUT .= $svc->{'recharge_totalbytes'} if $svc->{'recharge_totalbytes'};
- $OUT .= '</TD></TR>';
+ $OUT .= '</TD>';
+ foreach my $field ( qw(seconds upbytes downbytes totalbytes) ) {
+ $OUT .= '<TD></TD>' if $bytes_show{$field.'_used'};
+ if ($bytes_show{$field}) {
+ $OUT .= '<TD ALIGN="right">';
+ $OUT .= $svc->{'recharge_'.$field} if $svc->{'recharge_'.$field};
+ $OUT .= '</TD>';
+ }
+ }
+ $OUT .= '</TR>';
}
}
%>
-<%= scalar(@svc_acct) ? '</TABLE><BR><BR>' : '' %>
+<%= scalar(@bytes_svcs) ? '</TABLE><BR><BR>' : '' %>
<%= if ( @svc_phone or @svc_pbx ) {
%any = ();
'LEFT JOIN cust_main USING ( custnum )',
'hashref' => { 'quotationpkgnum' => $pkgnum },
'extra_sql' => ' AND '. $curuser->agentnums_sql,
+})
+|| qsearchs({
+ 'table' => 'quotation_pkg',
+ 'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'.
+ 'LEFT JOIN prospect_main USING ( prospectnum )',
+ 'hashref' => { 'quotationpkgnum' => $pkgnum },
+ 'extra_sql' => ' AND '. $curuser->agentnums_sql,
});
+
my @orig_details = $quotation_pkg->details();
my $action = 'Quotation details'.
'LEFT JOIN cust_main USING ( custnum )',
'hashref' => { 'quotationpkgnum' => $pkgnum },
'extra_sql' => ' AND '. $curuser->agentnums_sql,
+})
+|| qsearchs({
+ 'table' => 'quotation_pkg',
+ 'addl_from' => 'LEFT JOIN quotation USING ( quotationnum )'.
+ 'LEFT JOIN prospect_main USING ( prospectnum )',
+ 'hashref' => { 'quotationpkgnum' => $pkgnum },
+ 'extra_sql' => ' AND '. $curuser->agentnums_sql,
});
my $part_pkg = $quotation_pkg->part_pkg;
ID = "<%$id%>_paycvv"
SIZE = 2
MAXLENGTH = 4
- VALUE = "<% scalar($cgi->param($name.'_paycvv')) %>"
+ VALUE = "<% scalar($cgi->param($name.'_paycvv')) || ('*' x length($cust_payby->paycvv)) %>"
onChange = "<% $onchange %>"
>
<BR><FONT SIZE="-1"><% mt('CVV2') |h %> (<A HREF="javascript:void(0);" onClick="overlib( OLiframeContent('<%$p%>docs/cvv2.html', 480, 275, 'cvv2_popup' ), CAPTION, 'CVV2 Help', STICKY, AUTOSTATUSCAP, CLOSECLICK, DRAGGABLE ); return false;"><% mt('help') |h %></A>)</FONT>
<td align=left BGCOLOR="#ffffff"> <!-- valign="top" -->
<font size=6><% $company_name || 'ExampleCo' %></font>
</td>
+ <td align="right" BGCOLOR="#ffffff">
+ <& notify-tickets.html &>
+ </td>
<td align=right valign=top BGCOLOR="#ffffff"><FONT SIZE="-1">Logged in as <b><% $FS::CurrentUser::CurrentUser->username |h %> </b> <FONT SIZE="-2"><a href="<%$fsurl%>loginout/logout.html">logout</a></FONT><br></FONT><FONT SIZE="-2"><a href="<%$fsurl%>pref/pref.html" STYLE="color: #000000">Preferences</a>
% if ( $conf->config("ticket_system")
% && FS::TicketSystem->access_right(\%session, 'ModifySelf') ) {
--- /dev/null
+% if ($enabled) {
+<style>
+.dot {
+ border-radius: 50%;
+ border: 1px solid black;
+ width: 1ex;
+ height: 1ex;
+ display: inline-block;
+}
+</style>
+<div style="font-weight: bold; vertical-align: bottom; text-align: left">
+% if ( $UnrepliedTickets->Count > 0 ) {
+ <a href="<% $fsurl %>rt/Search/UnrepliedTickets.html">
+ <div class="dot" style="background-color: green"></div>
+ <% emt('New activity on [quant,_1,ticket]', $UnrepliedTickets->Count) %>
+ </a>
+% } else {
+ <% emt('No new activity on tickets') %>
+% }
+</div>
+% }
+<%init>
+use Class::Load 'load_class';
+
+my $enabled = $FS::TicketSystem::system eq 'RT_Internal';
+my $UnrepliedTickets;
+if ($enabled) {
+ my $class = 'RT::Search::UnrepliedTickets';
+ load_class($class);
+ my $session = FS::TicketSystem->session;
+ my $CurrentUser = $session->{CurrentUser};
+ $UnrepliedTickets = RT::Tickets->new($CurrentUser);
+ my $search = $class->new(TicketsObj => $UnrepliedTickets);
+ $search->Prepare;
+}
+</%init>
</TR>
% } else { # so that the rest of the page works correctly
- <INPUT TYPE="hidden" ID="classnum" NAME="classnum" VALUE="-1`">
+ <INPUT TYPE="hidden" ID="classnum" NAME="classnum" VALUE="-1">
% }
<TR>
$payinfo = $cust_payby->payinfo;
$paymask = $cust_payby->paymask;
- $paycvv = '';
+ $paycvv = $cust_payby->paycvv; # pass it if we got it, running a transaction will clear it
( $month, $year ) = $cust_payby->paydate_mon_year;
$payname = $cust_payby->payname;
{ Name => 'Open Tickets', # loc
Description => 'Open tickets on correspondence', # loc
ExecModule => 'AutoOpen' },
- { Name => 'Open Inactive Tickets', # loc
- Description => 'Open inactive tickets', # loc
- ExecModule => 'AutoOpenInactive' },
{ Name => 'Extract Subject Tag', # loc
Description => 'Extract tags from a Transaction\'s subject and add them to the Ticket\'s subject.', # loc
ExecModule => 'ExtractSubjectTag' },
# ScripCondition => 'On Correspond',
# ScripAction => 'Notify Requestors And Ccs',
# Template => 'Correspondence in HTML' },
- { Description => 'On Correspond Open Inactive Tickets',
+ { Description => 'On Correspond Open Tickets',
ScripCondition => 'On Correspond',
- ScripAction => 'Open Inactive Tickets',
+ ScripAction => 'Open Tickets',
Template => 'Blank' },
{ Description => 'On Create Autoreply To Requestors',
ScripCondition => 'On Create',
'on correspond' => {
'notify requestors and ccs' => { 'correspondence' => 1 },
'notify other recipients' => { 'correspondence' => 1 },
- }
+ # RT 4.2
+ # superseded by "notify owner and adminccs"
+ 'notify adminccs' => { 'admin correspondence' => 1 },
+ # the new way, but doesn't work right vs. "open tickets"
+ 'open inactive tickets' => { 'blank' => 1 },
+ },
+ 'on create' => {
+ # RT 4.2
+ # superseded by "notify owner and adminccs"
+ 'notify adminccs' => { 'transaction' => 1 },
+ },
);
# -*- perl -*-
--- /dev/null
+=head1 NAME
+
+ RT::Search::UnrepliedTickets
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+Find all unresolved tickets owned by the current user where the last correspondence
+from a requestor (or ticket creation) is more recent than the last
+correspondence from a non-requestor (if there is any).
+
+=head1 METHODS
+
+=cut
+
+package RT::Search::UnrepliedTickets;
+
+use strict;
+use warnings;
+use base qw(RT::Search);
+
+
+sub Describe {
+ my $self = shift;
+ return ($self->loc("Tickets awaiting a reply"));
+}
+
+sub Prepare {
+ my $self = shift;
+
+ my $TicketsObj = $self->TicketsObj;
+ $TicketsObj->Limit(
+ FIELD => 'Owner',
+ VALUE => $TicketsObj->CurrentUser->id
+ );
+ $TicketsObj->Limit(
+ FIELD => 'Status',
+ OPERATOR => '!=',
+ VALUE => 'resolved'
+ );
+ my $txn_alias = $TicketsObj->JoinTransactions;
+ $TicketsObj->Limit(
+ ALIAS => $txn_alias,
+ FIELD => 'Created',
+ OPERATOR => '>',
+ VALUE => 'COALESCE(main.Told,\'1970-01-01\')',
+ QUOTEVALUE => 0,
+ );
+ $TicketsObj->Limit(
+ ALIAS => $txn_alias,
+ FIELD => 'Type',
+ OPERATOR => 'IN',
+ VALUE => [ 'Correspond', 'Create' ],
+ );
+
+ return(1);
+}
+
+RT::Base->_ImportOverlays();
+
+1;
--- /dev/null
+%# false laziness with Results.html; basically this is the same thing but with
+%# a hardcoded RT::Tickets object instead of a Query param
+
+<& /Elements/Header, Title => $title,
+ Refresh => $refresh,
+ LinkRel => \%link_rel &>
+
+% $m->callback( ARGSRef => \%ARGS, Format => \$Format, CallbackName => 'BeforeResults' );
+
+<& /Elements/CollectionList,
+ Class => 'RT::Tickets',
+ Collection => $session{tickets},
+ TotalFound => $ticketcount,
+ AllowSorting => 1,
+ OrderBy => $OrderBy,
+ Order => $Order,
+ Rows => $Rows,
+ Page => $Page,
+ Format => $Format,
+ BaseURL => $BaseURL,
+ SavedSearchId => $ARGS{'SavedSearchId'},
+ SavedChartSearchId => $ARGS{'SavedChartSearchId'},
+ PassArguments => [qw(Format Rows Page Order OrderBy SavedSearchId SavedChartSearchId)],
+&>
+% $m->callback( ARGSRef => \%ARGS, CallbackName => 'AfterResults' );
+
+% my %hiddens = (Format => $Format, Rows => $Rows, OrderBy => $OrderBy, Order => $Order, HideResults => $HideResults, Page => $Page, SavedChartSearchId => $SavedChartSearchId );
+<div align="right" class="refresh">
+<form method="get" action="<%RT->Config->Get('WebPath')%>/Search/UnrepliedTickets.html">
+% foreach my $key (keys(%hiddens)) {
+<input type="hidden" class="hidden" name="<%$key%>" value="<% defined($hiddens{$key})?$hiddens{$key}:'' %>" />
+% }
+<& /Elements/Refresh, Name => 'TicketsRefreshInterval', Default => $session{'tickets_refresh_interval'}||RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'}) &>
+<input type="submit" class="button" value="<&|/l&>Change</&>" />
+</form>
+</div>
+<%INIT>
+$m->callback( ARGSRef => \%ARGS, CallbackName => 'Initial' );
+
+# Read from user preferences
+my $prefs = $session{'CurrentUser'}->UserObj->Preferences("SearchDisplay") || {};
+
+# These variables are what define a search_hash; this is also
+# where we give sane defaults.
+$Format ||= $prefs->{'Format'} || RT->Config->Get('DefaultSearchResultFormat');
+$Order ||= $prefs->{'Order'} || RT->Config->Get('DefaultSearchResultOrder');
+$OrderBy ||= $prefs->{'OrderBy'} || RT->Config->Get('DefaultSearchResultOrderBy');
+
+# In this case the search UI isn't available, so trust the defaults.
+
+# Some forms pass in "RowsPerPage" rather than "Rows"
+# We call it RowsPerPage everywhere else.
+
+if ( defined $prefs->{'RowsPerPage'} ) {
+ $Rows = $prefs->{'RowsPerPage'};
+} else {
+ $Rows = 50;
+}
+$Page = 1 unless $Page && $Page > 0;
+
+use RT::Search::UnrepliedTickets;
+
+$session{'i'}++;
+$session{'tickets'} = RT::Tickets->new($session{'CurrentUser'}) ;
+my $search = RT::Search::UnrepliedTickets->new( TicketsObj => $session{'tickets'} );
+$search->Prepare;
+
+if ($OrderBy =~ /\|/) {
+ # Multiple Sorts
+ my @OrderBy = split /\|/,$OrderBy;
+ my @Order = split /\|/,$Order;
+ $session{'tickets'}->OrderByCols(
+ map { { FIELD => $OrderBy[$_], ORDER => $Order[$_] } } ( 0
+ .. $#OrderBy ) );;
+} else {
+ $session{'tickets'}->OrderBy(FIELD => $OrderBy, ORDER => $Order);
+}
+$session{'tickets'}->RowsPerPage( $Rows ) if $Rows;
+$session{'tickets'}->GotoPage( $Page - 1 );
+
+# use this to set a CSRF token applying to the search, so that the user can come
+# back to this page without triggering a referrer check
+$session{'CurrentSearchHash'} = {
+ Format => $Format,
+ Page => $Page,
+ Order => $Order,
+ OrderBy => $OrderBy,
+ RowsPerPage => $Rows
+};
+
+
+my $ticketcount = $session{tickets}->CountAll();
+my $title = loc('New activity on [quant,_1,ticket,tickets]', $ticketcount);
+
+# pass this through on pagination links
+my $QueryString = "?".$m->comp('/Elements/QueryString',
+ Format => $Format,
+ Rows => $Rows,
+ OrderBy => $OrderBy,
+ Order => $Order,
+ Page => $Page);
+
+if ($ARGS{'TicketsRefreshInterval'}) {
+ $session{'tickets_refresh_interval'} = $ARGS{'TicketsRefreshInterval'};
+}
+
+my $refresh = $session{'tickets_refresh_interval'}
+ || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} );
+
+# Check $m->request_args, not $DECODED_ARGS, to avoid creating a new CSRF token on each refresh
+if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{CSRF_Token}) {
+ my $token = RT::Interface::Web::StoreRequestToken( $session{'CurrentSearchHash'} );
+ $m->notes->{RefreshURL} = RT->Config->Get('WebURL')
+ . "Search/UnrepliedTickets.html?CSRF_Token="
+ . $token;
+}
+
+my %link_rel;
+my $genpage = sub {
+ return $m->comp(
+ '/Elements/QueryString',
+ Format => $Format,
+ Rows => $Rows,
+ OrderBy => $OrderBy,
+ Order => $Order,
+ Page => shift(@_),
+ );
+};
+
+if ( RT->Config->Get('SearchResultsAutoRedirect') && $ticketcount == 1 &&
+ $session{tickets}->First ) {
+# $ticketcount is not always precise unless $UseSQLForACLChecks is set to true,
+# check $session{tickets}->First here is to make sure the ticket is there.
+ RT::Interface::Web::Redirect( RT->Config->Get('WebURL')
+ ."Ticket/Display.html?id=". $session{tickets}->First->id );
+}
+
+my $BaseURL = RT->Config->Get('WebPath')."/Search/UnrepliedTickets.html?";
+$link_rel{first} = $BaseURL . $genpage->(1) if $Page > 1;
+$link_rel{prev} = $BaseURL . $genpage->($Page - 1) if $Page > 1;
+$link_rel{next} = $BaseURL . $genpage->($Page + 1) if ($Page * $Rows) < $ticketcount;
+$link_rel{last} = $BaseURL . $genpage->(POSIX::ceil($ticketcount/$Rows)) if $Rows and ($Page * $Rows) < $ticketcount;
+</%INIT>
+<%CLEANUP>
+$session{'tickets'}->PrepForSerialization();
+</%CLEANUP>
+<%ARGS>
+$HideResults => 0
+$Rows => undef
+$Page => 1
+$OrderBy => undef
+$Order => undef
+$SavedSearchId => undef
+$SavedChartSearchId => undef
+$Format => undef
+</%ARGS>