add cust_main.mobile and ship_mobile to support uniserve portal, RT#13656
[freeside.git] / FS / FS / cust_main.pm
index 895f54d..765987e 100644 (file)
@@ -67,6 +67,8 @@ use FS::agent_payment_gateway;
 use FS::banned_pay;
 use FS::cust_main_note;
 use FS::cust_attachment;
+use FS::contact;
+use FS::Locales;
 
 # 1 is mostly method/subroutine entry and options
 # 2 traces progress of some operations
@@ -209,6 +211,10 @@ phone (optional)
 
 phone (optional)
 
+=item mobile
+
+phone (optional)
+
 =item ship_first
 
 Shipping first name
@@ -255,6 +261,10 @@ phone (optional)
 
 phone (optional)
 
+=item ship_mobile
+
+phone (optional)
+
 =item payby
 
 Payment Type (See L<FS::payinfo_Mixin> for valid payby values)
@@ -323,6 +333,10 @@ A suggestion to events (see L<FS::part_bill_event">) to delay until this unix ti
 
 Discourage individual CDR printing, empty or `Y'
 
+=item edit_subject
+
+Allow self-service editing of ticket subjects, empty or 'Y'
+
 =back
 
 =head1 METHODS
@@ -368,7 +382,8 @@ invoicing_list destination to the newly-created svc_acct.  Here's an example:
 
   $cust_main->insert( {}, [ $email, 'POST' ] );
 
-Currently available options are: I<depend_jobnum>, I<noexport> and I<tax_exemption>.
+Currently available options are: I<depend_jobnum>, I<noexport>,
+I<tax_exemption> and I<prospectnum>.
 
 If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
 on the supplied jobnum (they will not run until the specific job completes).
@@ -382,6 +397,8 @@ the B<reexport> method.)
 The I<tax_exemption> option can be set to an arrayref of tax names.
 FS::cust_main_exemption records will be created and inserted.
 
+If I<prospectnum> is set, moves contacts and locations from that prospect.
+
 =cut
 
 sub insert {
@@ -480,16 +497,41 @@ sub insert {
     }
   }
 
-  if ( $invoicing_list ) {
-    $error = $self->check_invoicing_list( $invoicing_list );
+  my $prospectnum = delete $options{'prospectnum'};
+  if ( $prospectnum ) {
+
+    warn "  moving contacts and locations from prospect $prospectnum\n"
+      if $DEBUG > 1;
+
+    my $prospect_main =
+      qsearchs('prospect_main', { 'prospectnum' => $prospectnum } );
+    unless ( $prospect_main ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "Unknown prospectnum $prospectnum";
+    }
+    $prospect_main->custnum($self->custnum);
+    $prospect_main->disabled('Y');
+    my $error = $prospect_main->replace;
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
-      #return "checking invoicing_list (transaction rolled back): $error";
       return $error;
     }
-    $self->invoicing_list( $invoicing_list );
-  }
 
+    my @contact = $prospect_main->contact;
+    my @cust_location = $prospect_main->cust_location;
+    my @qual = $prospect_main->qual;
+
+    foreach my $r ( @contact, @cust_location, @qual ) {
+      $r->prospectnum('');
+      $r->custnum($self->custnum);
+      my $error = $r->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
 
   warn "  setting cust_main_exemption\n"
     if $DEBUG > 1;
@@ -743,7 +785,7 @@ sub get_prepay {
 
     $prepay_credit = qsearchs(
       'prepay_credit',
-      { 'identifier' => $prepay_credit },
+      { 'identifier' => $identifier },
       '',
       'FOR UPDATE'
     );
@@ -1440,6 +1482,11 @@ sub replace {
     && $self->payby =~ /^(CARD|DCRD)$/
     && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
 
+  local($ignore_banned_card) = 1
+    if (    $old->payby  =~ /^(CARD|DCRD)$/ && $self->payby =~ /^(CARD|DCRD)$/
+         || $old->payby  =~ /^(CHEK|DCHK)$/ && $self->payby =~ /^(CHEK|DCHK)$/ )
+    && ( $old->payinfo eq $self->payinfo || $old->paymask eq $self->paymask );
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -1652,6 +1699,9 @@ sub check {
     || $self->ut_alphan('geocode')
     || $self->ut_floatn('cdr_termination_percentage')
     || $self->ut_floatn('credit_limit')
+    || $self->ut_numbern('billday')
+    || $self->ut_enum('edit_subject', [ '', 'Y' ] )
+    || $self->ut_enum('locale', [ '', FS::Locales->locales ])
   ;
 
   #barf.  need message catalogs.  i18n.  etc.
@@ -1705,9 +1755,10 @@ sub check {
   }
 
   $error =
-    $self->ut_phonen('daytime', $self->country)
-    || $self->ut_phonen('night', $self->country)
-    || $self->ut_phonen('fax', $self->country)
+       $self->ut_phonen('daytime', $self->country)
+    || $self->ut_phonen('night',   $self->country)
+    || $self->ut_phonen('fax',     $self->country)
+    || $self->ut_phonen('mobile',  $self->country)
   ;
   return $error if $error;
 
@@ -1717,7 +1768,7 @@ sub check {
   }
 
   if ( $conf->exists('cust_main-require_phone')
-       && ! length($self->daytime) && ! length($self->night)
+       && ! length($self->daytime) && ! length($self->night) && ! length($self->mobile)
      ) {
 
     my $daytime_label = FS::Msgcat::_gettext('daytime') =~ /^(daytime)?$/
@@ -1726,8 +1777,12 @@ sub check {
     my $night_label = FS::Msgcat::_gettext('night') =~ /^(night)?$/
                         ? 'Night Phone'
                         : FS::Msgcat::_gettext('night');
-  
-    return "$daytime_label or $night_label is required"
+
+    my $mobile_label = FS::Msgcat::_gettext('mobile') =~ /^(mobile)?$/
+                        ? 'Mobile Phone'
+                        : FS::Msgcat::_gettext('mobile');
+
+    return "$daytime_label, $night_label or $mobile_label is required"
   
   }
 
@@ -1765,9 +1820,10 @@ sub check {
     #eofalse
 
     $error =
-      $self->ut_phonen('ship_daytime', $self->ship_country)
-      || $self->ut_phonen('ship_night', $self->ship_country)
-      || $self->ut_phonen('ship_fax', $self->ship_country)
+         $self->ut_phonen('ship_daytime', $self->ship_country)
+      || $self->ut_phonen('ship_night',   $self->ship_country)
+      || $self->ut_phonen('ship_fax',     $self->ship_country)
+      || $self->ut_phonen('ship_mobile',  $self->ship_country)
     ;
     return $error if $error;
 
@@ -1818,7 +1874,7 @@ sub check {
 
     my $payinfo = $self->payinfo;
     $payinfo =~ s/\D//g;
-    $payinfo =~ /^(\d{13,16})$/
+    $payinfo =~ /^(\d{13,16}|\d{8,9})$/
       or return gettext('invalid_card'); # . ": ". $self->payinfo;
     $payinfo = $1;
     $self->payinfo($payinfo);
@@ -1830,12 +1886,21 @@ sub check {
       && cardtype($self->payinfo) eq "Unknown";
 
     unless ( $ignore_banned_card ) {
-      my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
+      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
       if ( $ban ) {
-        return 'Banned credit card: banned on '.
-               time2str('%a %h %o at %r', $ban->_date).
-               ' by '. $ban->otaker.
-               ' (ban# '. $ban->bannum. ')';
+        if ( $ban->bantype eq 'warn' ) {
+          #or others depending on value of $ban->reason ?
+          return '_duplicate_card'.
+                 ': disabled from'. time2str('%a %h %o at %r', $ban->_date).
+                 ' until '.         time2str('%a %h %o at %r', $ban->_end_date).
+                 ' (ban# '. $ban->bannum. ')'
+            unless $self->override_ban_warn;
+        } else {
+          return 'Banned credit card: banned on '.
+                 time2str('%a %h %o at %r', $ban->_date).
+                 ' by '. $ban->otaker.
+                 ' (ban# '. $ban->bannum. ')';
+        }
       }
     }
 
@@ -1880,8 +1945,14 @@ sub check {
   } elsif ( $check_payinfo && $self->payby =~ /^(CHEK|DCHK)$/ ) {
 
     my $payinfo = $self->payinfo;
-    $payinfo =~ s/[^\d\@]//g;
-    if ( $conf->exists('echeck-nonus') ) {
+    $payinfo =~ s/[^\d\@\.]//g;
+    if ( $conf->exists('cust_main-require-bank-branch') ) {
+      $payinfo =~ /^(\d+)\@(\d+)\.(\d+)$/ or return 'invalid echeck account@branch.bank';
+      $payinfo = "$1\@$2.$3";
+    } elsif ( $conf->exists('echeck-no_routing') ) {
+      $payinfo =~ /^(\d+)\@(\d*)$/ or return 'invalid echeck account';
+      $payinfo = "$1\@$2";
+    } elsif ( $conf->exists('echeck-nonus') ) {
       $payinfo =~ /^(\d+)\@(\d+)$/ or return 'invalid echeck account@aba';
       $payinfo = "$1\@$2";
     } else {
@@ -1892,12 +1963,17 @@ sub check {
     $self->paycvv('');
 
     unless ( $ignore_banned_card ) {
-      my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
+      my $ban = FS::banned_pay->ban_search( %{ $self->_banned_pay_hashref } );
       if ( $ban ) {
-        return 'Banned ACH account: banned on '.
-               time2str('%a %h %o at %r', $ban->_date).
-               ' by '. $ban->otaker.
-               ' (ban# '. $ban->bannum. ')';
+        if ( $ban->bantype eq 'warn' ) {
+          #or others depending on value of $ban->reason ?
+          return '_duplicate_ach' unless $self->override_ban_warn;
+        } else {
+          return 'Banned ACH account: banned on '.
+                 time2str('%a %h %o at %r', $ban->_date).
+                 ' by '. $ban->otaker.
+                 ' (ban# '. $ban->bannum. ')';
+        }
       }
     }
 
@@ -1973,7 +2049,7 @@ sub check {
   ) {
     $self->payname( $self->first. " ". $self->getfield('last') );
   } else {
-    $self->payname =~ /^([µ_0123456789aAáÁàÀâÂåÅäÄãêæÆbBcCçÇdDðÐeEéÉèÈêÊëËfFgGhHiIíÍìÌîÎïÏjJkKlLmMnNñÑoOóÓòÒôÔöÖõÕøغpPqQrRsSßtTuUúÚùÙûÛüÜvVwWxXyYýÝÿzZþÞ \,\.\-\'\&]+)$/
+    $self->payname =~ /^([\w \,\.\-\'\&]+)$/
       or return gettext('illegal_name'). " payname: ". $self->payname;
     $self->payname($1);
   }
@@ -2000,7 +2076,7 @@ Returns a list of fields which have ship_ duplicates.
 sub addr_fields {
   qw( last first company
       address1 address2 city county state zip country
-      daytime night fax
+      daytime night fax mobile
     );
 }
 
@@ -2033,6 +2109,18 @@ sub cust_location {
   qsearch('cust_location', { 'custnum' => $self->custnum } );
 }
 
+=item cust_contact
+
+Returns all contacts (see L<FS::contact>) for this customer.
+
+=cut
+
+#already used :/ sub contact {
+sub cust_contact {
+  my $self = shift;
+  qsearch('contact', { 'custnum' => $self->custnum } );
+}
+
 =item unsuspend
 
 Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
@@ -2165,7 +2253,7 @@ sub cancel {
     return ( "Can't (yet) ban encrypted credit cards" )
       if $self->is_encrypted($self->payinfo);
 
-    my $ban = new FS::banned_pay $self->_banned_pay_hashref;
+    my $ban = new FS::banned_pay $self->_new_banned_pay_hashref;
     my $error = $ban->insert;
     return ( $error ) if $error;
 
@@ -2199,11 +2287,18 @@ sub _banned_pay_hashref {
 
   {
     'payby'   => $payby2ban{$self->payby},
-    'payinfo' => md5_base64($self->payinfo),
+    'payinfo' => $self->payinfo,
     #don't ever *search* on reason! #'reason'  =>
   };
 }
 
+sub _new_banned_pay_hashref {
+  my $self = shift;
+  my $hr = $self->_banned_pay_hashref;
+  $hr->{payinfo} = md5_base64($hr->{payinfo});
+  $hr;
+}
+
 =item notes
 
 Returns all notes (see L<FS::cust_main_note>) for this customer.
@@ -2216,9 +2311,9 @@ sub notes {
   $orderby = "CLASSNUM ASC, $orderby" if $orderby_classnum;
   qsearch( 'cust_main_note',
            { 'custnum' => $self->custnum },
-          '',
-          "ORDER BY $orderby",
-        );
+           '',
+           "ORDER BY $orderby",
+         );
 }
 
 =item agent
@@ -2864,6 +2959,60 @@ sub paydate_monthyear {
   }
 }
 
+=item paydate_epoch
+
+Returns the exact time in seconds corresponding to the payment method 
+expiration date.  For CARD/DCRD customers this is the end of the month;
+for others (COMP is the only other payby that uses paydate) it's the start.
+Returns 0 if the paydate is empty or set to the far future.
+
+=cut
+
+sub paydate_epoch {
+  my $self = shift;
+  my ($month, $year) = $self->paydate_monthyear;
+  return 0 if !$year or $year >= 2037;
+  if ( $self->payby eq 'CARD' or $self->payby eq 'DCRD' ) {
+    $month++;
+    if ( $month == 13 ) {
+      $month = 1;
+      $year++;
+    }
+    return timelocal(0,0,0,1,$month-1,$year) - 1;
+  }
+  else {
+    return timelocal(0,0,0,1,$month-1,$year);
+  }
+}
+
+=item paydate_epoch_sql
+
+Class method.  Returns an SQL expression to obtain the payment expiration date
+as a number of seconds.
+
+=cut
+
+# Special expiration date behavior for non-CARD/DCRD customers has been 
+# carefully preserved.  Do we really use that?
+sub paydate_epoch_sql {
+  my $class = shift;
+  my $table = shift || 'cust_main';
+  my ($case1, $case2);
+  if ( driver_name eq 'Pg' ) {
+    $case1 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) + INTERVAL '1 month') - 1";
+    $case2 = "EXTRACT( EPOCH FROM CAST( $table.paydate AS TIMESTAMP ) )";
+  }
+  elsif ( lc(driver_name) eq 'mysql' ) {
+    $case1 = "UNIX_TIMESTAMP( DATE_ADD( CAST( $table.paydate AS DATETIME ), INTERVAL 1 month ) ) - 1";
+    $case2 = "UNIX_TIMESTAMP( CAST( $table.paydate AS DATETIME ) )";
+  }
+  else { return '' }
+  return "CASE WHEN $table.payby IN('CARD','DCRD') 
+  THEN ($case1)
+  ELSE ($case2)
+  END"
+}
+
 =item tax_exemption TAXNAME
 
 =cut
@@ -3368,7 +3517,7 @@ sub charge {
 sub charge_postal_fee {
   my $self = shift;
 
-  my $pkgpart = $conf->config('postal_invoice-fee_pkgpart');
+  my $pkgpart = $conf->config('postal_invoice-fee_pkgpart', $self->agentnum);
   return '' unless ($pkgpart && grep { $_ eq 'POST' } $self->invoicing_list);
 
   my $cust_pkg = new FS::cust_pkg ( {
@@ -3448,6 +3597,56 @@ sub cust_statement {
       qsearch($opt);
 }
 
+=item svc_x SVCDB [ OPTION => VALUE | EXTRA_QSEARCH_PARAMS_HASHREF ]
+
+Returns all services of type SVCDB (such as 'svc_acct') for this customer.  
+
+Optionally, a list or hashref of additional arguments to the qsearch call can 
+be passed following the SVCDB.
+
+=cut
+
+sub svc_x {
+  my $self = shift;
+  my $svcdb = shift;
+  if ( ! $svcdb =~ /^svc_\w+$/ ) {
+    warn "$me svc_x requires a svcdb";
+    return;
+  }
+  my $opt = ref($_[0]) ? shift : { @_ };
+
+  $opt->{'table'} = $svcdb;
+  $opt->{'addl_from'} = 
+    'LEFT JOIN cust_svc USING (svcnum) LEFT JOIN cust_pkg USING (pkgnum) '.
+    ($opt->{'addl_from'} || '');
+
+  my $custnum = $self->custnum;
+  $custnum =~ /^\d+$/ or die "bad custnum '$custnum'";
+  my $where = "cust_pkg.custnum = $custnum";
+
+  my $extra_sql = $opt->{'extra_sql'} || '';
+  if ( keys %{ $opt->{'hashref'} } ) {
+    $extra_sql = " AND $where $extra_sql";
+  }
+  else {
+    if ( $opt->{'extra_sql'} =~ /^\s*where\s(.*)/si ) {
+      $extra_sql = "WHERE $where AND $1";
+    }
+    else {
+      $extra_sql = "WHERE $where $extra_sql";
+    }
+  }
+  $opt->{'extra_sql'} = $extra_sql;
+
+  qsearch($opt);
+}
+
+# required for use as an eventtable; 
+sub svc_acct {
+  my $self = shift;
+  $self->svc_x('svc_acct', @_);
+}
+
 =item cust_credit
 
 Returns all the credits (see L<FS::cust_credit>) for this customer.
@@ -3855,7 +4054,7 @@ sub statuscolor { shift->cust_statuscolor(@_); }
 
 sub cust_statuscolor {
   my $self = shift;
-  $self->statuscolors->{$self->cust_status};
+  __PACKAGE__->statuscolors->{$self->cust_status};
 }
 
 =item tickets
@@ -4172,7 +4371,7 @@ sub balance_date_sql {
 =item unapplied_payments_date_sql START_TIME [ END_TIME ]
 
 Returns an SQL fragment to retreive the total unapplied payments for this
-customer, only considering invoices with date earlier than START_TIME, and
+customer, only considering payments with date earlier than START_TIME, and
 optionally not later than END_TIME.
 
 Times are specified as SQL fragments or numeric
@@ -4696,7 +4895,7 @@ sub _agent_plandata {
         " ORDER BY
            CASE WHEN part_event_condition_option.optionname IS NULL
            THEN -1
-          ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
+           ELSE ". FS::part_event::Condition->age2seconds_sql('part_event_condition_option.optionvalue').
         " END
           , part_event.weight".
         " LIMIT 1"