package FS::part_pkg;
+use base qw( FS::m2m_Common FS::o2m_Common FS::option_Common );
use strict;
-use vars qw( @ISA %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
+use vars qw( %plans $DEBUG $setup_hack $skip_pkg_svc_hack );
use Carp qw(carp cluck confess);
use Scalar::Util qw( blessed );
-use Time::Local qw( timelocal_nocheck );
+use DateTime;
+use Time::Local qw( timelocal timelocal_nocheck ); # eventually replace with DateTime
use Tie::IxHash;
use FS::Conf;
use FS::Record qw( qsearch qsearchs dbh dbdef );
use FS::agent_type;
use FS::type_pkgs;
use FS::part_pkg_option;
-use FS::pkg_class;
-use FS::agent;
+use FS::part_pkg_msgcat;
use FS::part_pkg_taxrate;
use FS::part_pkg_taxoverride;
use FS::part_pkg_taxproduct;
use FS::part_pkg_link;
use FS::part_pkg_discount;
use FS::part_pkg_vendor;
+use FS::part_pkg_currency;
-@ISA = qw( FS::m2m_Common FS::option_Common );
$DEBUG = 0;
$setup_hack = 0;
$skip_pkg_svc_hack = 0;
ancestor of this record. If this record is not a successor to another
part_pkg, will be equal to pkgpart.
+=item delay_start - Number of days to delay package start, by default
+
=back
=head1 METHODS
If I<options> is set to a hashref of options, appropriate FS::part_pkg_option
records will be inserted.
+If I<part_pkg_currency> is set to a hashref of options (with the keys as
+option_CURRENCY), appropriate FS::part_pkg::currency records will be inserted.
+
=cut
sub insert {
}
}
+ warn " inserting part_pkg_currency records" if $DEBUG;
+ my %part_pkg_currency = %{ $options{'part_pkg_currency'} || {} };
+ foreach my $key ( keys %part_pkg_currency ) {
+ $key =~ /^(.+)_([A-Z]{3})$/ or next;
+ my( $optionname, $currency ) = ( $1, $2 );
+ if ( $part_pkg_currency{$key} =~ /^\s*$/ ) {
+ if ( $self->option($optionname) == 0 ) {
+ $part_pkg_currency{$key} = '0';
+ } else {
+ $dbh->rollback if $oldAutoCommit;
+ ( my $thing = $optionname ) =~ s/_/ /g;
+ return ucfirst($thing). " $currency is required";
+ }
+ }
+ my $part_pkg_currency = new FS::part_pkg_currency {
+ 'pkgpart' => $self->pkgpart,
+ 'optionname' => $optionname,
+ 'currency' => $currency,
+ 'optionvalue' => $part_pkg_currency{$key},
+ };
+ my $error = $part_pkg_currency->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
unless ( $skip_pkg_svc_hack ) {
warn " inserting pkg_svc records" if $DEBUG;
If I<pkg_svc> is set to a hashref with svcparts as keys and quantities as
values, the appropriate FS::pkg_svc records will be replaced. I<hidden_svc>
can be set to a hashref of svcparts and flag values ('Y' or '') to set the
-'hidden' field in these records.
+'hidden' field in these records. I<bulk_skip> can be set to a hashref of
+svcparts and flag values ('Y' or '') to set the 'bulk_skip' field in those
+records.
If I<primary_svc> is set to the svcpart of the primary service, the appropriate
FS::pkg_svc record will be updated.
If I<options> is set to a hashref, the appropriate FS::part_pkg_option records
will be replaced.
+If I<part_pkg_currency> is set to a hashref of options (with the keys as
+option_CURRENCY), appropriate FS::part_pkg::currency records will be replaced.
+
=cut
sub replace {
}
}
+ #trivial nit: not the most efficient to delete and reinsert
+ warn " deleting old part_pkg_currency records" if $DEBUG;
+ foreach my $part_pkg_currency ( $old->part_pkg_currency ) {
+ my $error = $part_pkg_currency->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error deleting part_pkg_currency record: $error";
+ }
+ }
+
+ warn " inserting new part_pkg_currency records" if $DEBUG;
+ my %part_pkg_currency = %{ $options->{'part_pkg_currency'} || {} };
+ foreach my $key ( keys %part_pkg_currency ) {
+ $key =~ /^(.+)_([A-Z]{3})$/ or next;
+ my $part_pkg_currency = new FS::part_pkg_currency {
+ 'pkgpart' => $new->pkgpart,
+ 'optionname' => $1,
+ 'currency' => $2,
+ 'optionvalue' => $part_pkg_currency{$key},
+ };
+ my $error = $part_pkg_currency->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error inserting part_pkg_currency record: $error";
+ }
+ }
+
+
warn " replacing pkg_svc records" if $DEBUG;
my $pkg_svc = $options->{'pkg_svc'};
my $hidden_svc = $options->{'hidden_svc'} || {};
+ my $bulk_skip = $options->{'bulk_skip'} || {};
if ( $pkg_svc ) { # if it wasn't passed, don't change existing pkg_svcs
foreach my $part_svc ( qsearch('part_svc', {} ) ) {
- my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
- my $hidden = $hidden_svc->{$part_svc->svcpart} || '';
+ my $quantity = $pkg_svc->{$part_svc->svcpart} || 0;
+ my $hidden = $hidden_svc->{$part_svc->svcpart} || '';
+ my $bulk_skip = $bulk_skip->{$part_svc->svcpart} || '';
my $primary_svc =
( defined($options->{'primary_svc'}) && $options->{'primary_svc'}
&& $options->{'primary_svc'} == $part_svc->svcpart
my $old_quantity = 0;
my $old_primary_svc = '';
my $old_hidden = '';
+ my $old_bulk_skip = '';
if ( $old_pkg_svc ) {
$old_quantity = $old_pkg_svc->quantity;
$old_primary_svc = $old_pkg_svc->primary_svc
if $old_pkg_svc->dbdef_table->column('primary_svc'); # is this needed?
$old_hidden = $old_pkg_svc->hidden;
+ $old_bulk_skip = $old_pkg_svc->old_bulk_skip;
}
- next unless $old_quantity != $quantity ||
- $old_primary_svc ne $primary_svc ||
- $old_hidden ne $hidden;
+ next unless $old_quantity != $quantity
+ || $old_primary_svc ne $primary_svc
+ || $old_hidden ne $hidden
+ || $old_bulk_skip ne $bulk_skip;
my $new_pkg_svc = new FS::pkg_svc( {
'pkgsvcnum' => ( $old_pkg_svc ? $old_pkg_svc->pkgsvcnum : '' ),
'quantity' => $quantity,
'primary_svc' => $primary_svc,
'hidden' => $hidden,
+ 'bulk_skip' => $bulk_skip,
} );
my $error = $old_pkg_svc
? $new_pkg_svc->replace($old_pkg_svc)
my $error = $self->ut_numbern('pkgpart')
|| $self->ut_text('pkg')
- || $self->ut_text('comment')
+ || $self->ut_textn('comment')
|| $self->ut_textn('promo_code')
|| $self->ut_alphan('plan')
|| $self->ut_enum('setuptax', [ '', 'Y' ] )
)
|| $self->ut_numbern('fcc_ds0s')
|| $self->ut_numbern('fcc_voip_class')
+ || $self->ut_numbern('delay_start')
|| $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart')
|| $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart')
+ || $self->ut_alphan('agent_pkgpartid')
|| $self->SUPER::check
;
return $error if $error;
join("\n", @error);
}
+=item pkg_locale LOCALE
+
+Returns a customer-viewable string representing this package for the given
+locale, from the part_pkg_msgcat table. If the given locale is empty or no
+localized string is found, returns the base pkg field.
+
+=cut
+
+sub pkg_locale {
+ my( $self, $locale ) = @_;
+ return $self->pkg unless $locale;
+ my $part_pkg_msgcat = $self->part_pkg_msgcat($locale) or return $self->pkg;
+ $part_pkg_msgcat->pkg;
+}
+
+=item part_pkg_msgcat LOCALE
+
+Like pkg_locale, but returns the FS::part_pkg_msgcat object itself.
+
+=cut
+
+sub part_pkg_msgcat {
+ my( $self, $locale ) = @_;
+ qsearchs( 'part_pkg_msgcat', {
+ pkgpart => $self->pkgpart,
+ locale => $locale,
+ });
+}
+
=item pkg_comment [ OPTION => VALUE... ]
Returns an (internal) string representing this package. Currently,
#$self->pkg. ' - '. $self->comment;
#$self->pkg. ' ('. $self->comment. ')';
my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
- $pre. $self->pkg. ' - '. $self->custom_comment;
+ my $custom_comment = $self->custom_comment(%opt);
+ $pre. $self->pkg. ( $custom_comment ? " - $custom_comment" : '' );
+}
+
+#without price info (so without hitting the DB again)
+sub pkg_comment_only {
+ my $self = shift;
+ my %opt = @_;
+
+ my $pre = $opt{nopkgpart} ? '' : $self->pkgpart. ': ';
+ my $comment = $self->comment;
+ $pre. $self->pkg. ( $comment ? " - $comment" : '' );
}
sub price_info { # safety, in case a part_pkg hasn't defined price_info
sub custom_comment {
my $self = shift;
- ( $self->custom ? '(CUSTOM) ' : '' ). $self->comment . ' ' . $self->price_info;
+ my $price_info = $self->price_info(@_);
+ ( $self->custom ? '(CUSTOM) ' : '' ).
+ $self->comment.
+ ( ($self->custom || $self->comment) ? ' - ' : '' ).
+ ($price_info || 'No charge');
+}
+
+sub pkg_price_info {
+ my $self = shift;
+ $self->pkg. ' - '. ($self->price_info || 'No charge');
}
=item pkg_class
Returns the package class, as an FS::pkg_class object, or the empty string
if there is no package class.
-=cut
-
-sub pkg_class {
- my $self = shift;
- if ( $self->classnum ) {
- qsearchs('pkg_class', { 'classnum' => $self->classnum } );
- } else {
- return '';
- }
-}
-
=item addon_pkg_class
Returns the add-on package class, as an FS::pkg_class object, or the empty
Returns the associated agent for this event, if any, as an FS::agent object.
-=cut
-
-sub agent {
- my $self = shift;
- qsearchs('agent', { 'agentnum' => $self->agentnum } );
-}
-
=item pkg_svc [ HASHREF | OPTION => VALUE ]
Returns all FS::pkg_svc objects (see L<FS::pkg_svc>) for this package
=cut
-sub type_pkgs {
- my $self = shift;
- qsearch('type_pkgs', { 'pkgpart' => $self->pkgpart } );
-}
-
sub pkg_svc {
my $self = shift;
}
}
+# whether the plan allows discounts to be applied to this package
sub can_discount { 0; }
-
+
+# whether the plan allows changing the start date
sub can_start_date { 1; }
+# whether the plan supports part_pkg_usageprice add-ons (a specific kind of
+# pre-selectable usage pricing, there's others this doesn't refer to)
+sub can_usageprice { 0; }
+
+# the delay start date if present
+sub delay_start_date {
+ my $self = shift;
+
+ my $delay = $self->delay_start or return '';
+
+ # avoid timelocal silliness
+ my $dt = DateTime->today(time_zone => 'local');
+ $dt->add(days => $delay);
+ $dt->epoch;
+}
+
+sub can_currency_exchange { 0; }
+
sub freqs_href {
# moved to FS::Misc to make this accessible to other packages
# at initialization
if ( $freq =~ /^\d+$/ ) {
$mon += $freq;
until ( $mon < 12 ) { $mon -= 12; $year++; }
+
+ $mday = 28 if $mday > 28 && FS::Conf->new->exists('anniversary-rollback');
+
} elsif ( $freq =~ /^(\d+)w$/ ) {
my $weeks = $1;
$mday += $weeks * 7;
Returns all vendor/external package ids as FS::part_pkg_vendor objects (see
L<FS::part_pkg_vendor>).
-=cut
-
-sub part_pkg_vendor {
- my $self = shift;
- qsearch('part_pkg_vendor', { 'pkgpart' => $self->pkgpart } );
-}
-
=item vendor_pkg_ids
Returns a list of vendor/external package ids by exportnum
Returns all options as FS::part_pkg_option objects (see
L<FS::part_pkg_option>).
-=cut
-
-sub part_pkg_option {
- my $self = shift;
- qsearch('part_pkg_option', { 'pkgpart' => $self->pkgpart } );
-}
-
=item options
Returns a list of option names and values suitable for assigning to a hash.
sub option {
my( $self, $opt, $ornull ) = @_;
+ cluck "$self -> option: searching for $opt"
+ if $DEBUG;
my $part_pkg_option =
qsearchs('part_pkg_option', {
pkgpart => $self->pkgpart,
'';
}
+=item part_pkg_currency [ CURRENCY ]
+
+Returns all currency options as FS::part_pkg_currency objects (see
+L<FS::part_pkg_currency>), or, if a currency is specified, only return the
+objects for that currency.
+
+=cut
+
+sub part_pkg_currency {
+ my $self = shift;
+ my %hash = ( 'pkgpart' => $self->pkgpart );
+ $hash{'currency'} = shift if @_;
+ qsearch('part_pkg_currency', \%hash );
+}
+
+=item part_pkg_currency_options CURRENCY
+
+Returns a list of option names and values from FS::part_pkg_currency for the
+specified currency.
+
+=cut
+
+sub part_pkg_currency_options {
+ my $self = shift;
+ map { $_->optionname => $_->optionvalue } $self->part_pkg_currency(shift);
+}
+
+=item part_pkg_currency_option CURRENCY OPTIONNAME
+
+Returns the option value for the given name and currency.
+
+=cut
+
+sub part_pkg_currency_option {
+ my( $self, $currency, $optionname ) = @_;
+ my $part_pkg_currency =
+ qsearchs('part_pkg_currency', { 'pkgpart' => $self->pkgpart,
+ 'currency' => $currency,
+ 'optionname' => $optionname,
+ }
+ )#;
+ #fatal if not found? that works for our use cases from
+ #part_pkg/currency_fixed, but isn't how we would typically/expect the method
+ #to behave. have to catch it there if we change it here...
+ or die "Unknown price for ". $self->pkg_comment. " in $currency\n";
+
+ $part_pkg_currency->optionvalue;
+}
+
=item bill_part_pkg_link
Returns the associated part_pkg_link records (see L<FS::part_pkg_link>).
$part_pkg_taxproduct ? $part_pkg_taxproduct->description : '';
}
-=item part_pkg_taxrate DATA_PROVIDER, GEOCODE, [ CLASS ]
-Returns the package to taxrate m2m records for this package in the location
-specified by GEOCODE (see L<FS::part_pkg_taxrate>) and usage class CLASS.
-CLASS may be one of 'setup', 'recur', or one of the usage classes numbers
-(see L<FS::usage_class>).
+=item tax_rates DATA_PROVIDER, GEOCODE, [ CLASS ]
-=cut
+Returns the tax table entries (L<FS::tax_rate> objects) that apply to this
+package in the location specified by GEOCODE, for usage class CLASS (one of
+'setup', 'recur', null, or a C<usage_class> number).
-sub _expand_cch_taxproductnum {
- my $self = shift;
- my $class = shift;
- my $part_pkg_taxproduct = $self->taxproduct($class);
-
- my ($a,$b,$c,$d) = ( $part_pkg_taxproduct
- ? ( split ':', $part_pkg_taxproduct->taxproduct )
- : ()
- );
- $a = '' unless $a; $b = '' unless $b; $c = '' unless $c; $d = '' unless $d;
- my $extra_sql = "AND ( taxproduct = '$a:$b:$c:$d'
- OR taxproduct = '$a:$b:$c:'
- OR taxproduct = '$a:$b:".":$d'
- OR taxproduct = '$a:$b:".":' )";
- map { $_->taxproductnum } qsearch( { 'table' => 'part_pkg_taxproduct',
- 'hashref' => { 'data_vendor'=>'cch' },
- 'extra_sql' => $extra_sql,
- } );
-
-}
+=cut
-sub part_pkg_taxrate {
+sub tax_rates {
my $self = shift;
- my ($data_vendor, $geocode, $class) = @_;
-
- my $dbh = dbh;
- my $extra_sql = 'WHERE part_pkg_taxproduct.data_vendor = '.
- dbh->quote($data_vendor);
-
- # CCH oddness in m2m
- $extra_sql .= ' AND ('.
- join(' OR ', map{ 'geocode = '. $dbh->quote(substr($geocode, 0, $_)) }
- qw(10 5 2)
- ).
- ')';
- # much more CCH oddness in m2m -- this is kludgy
- my @tpnums = $self->_expand_cch_taxproductnum($class);
- if (scalar(@tpnums)) {
- $extra_sql .= ' AND ('.
- join(' OR ', map{ "taxproductnum = $_" } @tpnums ).
- ')';
- } else {
- $extra_sql .= ' AND ( 0 = 1 )';
+ my ($vendor, $geocode, $class) = @_;
+ my @taxclassnums = map { $_->taxclassnum }
+ $self->part_pkg_taxoverride($class);
+ if (!@taxclassnums) {
+ my $part_pkg_taxproduct = $self->taxproduct($class);
+ @taxclassnums = map { $_->taxclassnum }
+ grep { $_->taxable eq 'Y' } # why do we need this?
+ $part_pkg_taxproduct->part_pkg_taxrate($geocode);
}
+ return unless @taxclassnums;
- my $addl_from = 'LEFT JOIN part_pkg_taxproduct USING ( taxproductnum )';
- my $order_by = 'ORDER BY taxclassnum, length(geocode) desc, length(taxproduct) desc';
- my $select = 'DISTINCT ON(taxclassnum) *, taxproduct';
-
- # should qsearch preface columns with the table to facilitate joins?
- qsearch( { 'table' => 'part_pkg_taxrate',
- 'select' => $select,
- 'hashref' => { # 'data_vendor' => $data_vendor,
- # 'taxproductnum' => $self->taxproductnum,
- },
- 'addl_from' => $addl_from,
- 'extra_sql' => $extra_sql,
- 'order_by' => $order_by,
- } );
+ warn "Found taxclassnum values of ". join(',', @taxclassnums) ."\n"
+ if $DEBUG;
+ my $extra_sql = "AND taxclassnum IN (". join(',', @taxclassnums) . ")";
+ my @taxes = qsearch({ 'table' => 'tax_rate',
+ 'hashref' => { 'geocode' => $geocode,
+ 'data_vendor' => $vendor },
+ 'extra_sql' => $extra_sql,
+ });
+ warn "Found taxes ". join(',', map {$_->taxnum} @taxes) ."\n"
+ if $DEBUG;
+
+ return @taxes;
}
=item part_pkg_discount
Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
for this package.
-=cut
+=item part_pkg_usage
-sub part_pkg_discount {
- my $self = shift;
- qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
-}
+Returns the voice usage pools (see L<FS::part_pkg_usage>) defined for
+this package.
=item _rebless
$cust_bill_pkg->recur;
}
+=item unit_setup CUST_PKG
+
+Returns the setup fee for one unit of the package.
+
+=cut
+
+sub unit_setup {
+ my ($self, $cust_pkg) = @_;
+ $self->option('setup_fee') || 0;
+}
+
=item format OPTION DATA
Returns data formatted according to the function 'format' described