RT#34078: Payment History Report / Statement
authorJonathan Prykop <jonathan@freeside.biz>
Mon, 22 Jun 2015 23:34:27 +0000 (18:34 -0500)
committerJonathan Prykop <jonathan@freeside.biz>
Sat, 4 Jul 2015 02:10:49 +0000 (21:10 -0500)
FS/FS/ClientAPI/MyAccount.pm
FS/FS/Conf.pm
FS/FS/cust_main.pm
FS/FS/cust_main_Mixin.pm
FS/FS/msg_template.pm
FS/FS/msg_template/InitialData.pm
httemplate/edit/msg_template.html
httemplate/misc/email-customers-history.html [new file with mode: 0644]
httemplate/misc/email-customers.html

index 4097ff8..804c851 100644 (file)
@@ -662,73 +662,15 @@ sub billing_history {
     $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;
index c5c0e46..c17eb4a 100644 (file)
@@ -2688,6 +2688,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'payment_history_msgnum',
+    'section'     => 'notification',
+    'description' => 'Template to use for sending payment history to customer',
+    %msg_template_options,
+  },
+
+  {
     'key'         => 'payby',
     'section'     => 'billing',
     'description' => 'Available payment types.',
index 6aaeac6..d17a636 100644 (file)
@@ -4437,6 +4437,204 @@ my ($self,$field) = @_;
 
 }
 
+=item payment_history
+
+Returns an array of hashrefs standardizing information from cust_bill, cust_pay,
+cust_credit and cust_refund objects.  Each hashref has the following fields:
+
+I<type> - one of 'Line item', 'Invoice', 'Payment', 'Credit', 'Refund' or 'Previous'
+
+I<date> - value of _date field, unix timestamp
+
+I<date_pretty> - user-friendly date
+
+I<description> - user-friendly description of item
+
+I<amount> - impact of item on user's balance 
+(positive for Invoice/Refund/Line item, negative for Payment/Credit.)
+Not to be confused with the native 'amount' field in cust_credit, see below.
+
+I<amount_pretty> - includes money char
+
+I<balance> - customer balance, chronologically as of this item
+
+I<balance_pretty> - includes money char
+
+I<charged> - amount charged for cust_bill (Invoice or Line item) records, undef for other types
+
+I<paid> - amount paid for cust_pay records, undef for other types
+
+I<credit> - amount credited for cust_credit records, undef for other types.
+Literally the 'amount' field from cust_credit, renamed here to avoid confusion.
+
+I<refund> - amount refunded for cust_refund records, undef for other types
+
+The four table-specific keys always have positive values, whether they reflect charges or payments.
+
+The following options may be passed to this method:
+
+I<line_items> - if true, returns charges ('Line item') rather than invoices
+
+I<start_date> - unix timestamp, only include records on or after.
+If specified, an item of type 'Previous' will also be included.
+It does not have table-specific fields.
+
+I<end_date> - unix timestamp, only include records before
+
+I<reverse_sort> - order from newest to oldest (default is oldest to newest)
+
+I<conf> - optional already-loaded FS::Conf object.
+
+=cut
+
+# Caution: this gets used by FS::ClientAPI::MyAccount::billing_history,
+# and also payment_history_text, which should both be kept customer-friendly.
+# If you add anything that shouldn't be passed on through the API or exposed 
+# to customers, add a new option to include it, don't include it by default
+sub payment_history {
+  my $self = shift;
+  my $opt = ref($_[0]) ? $_[0] : { @_ };
+
+  my $conf = $$opt{'conf'} || new FS::Conf;
+  my $money_char = $conf->config("money_char") || '$',
+
+  #first load entire history, 
+  #need previous to calculate previous balance
+  #loading after end_date shouldn't hurt too much?
+  my @history = ();
+  if ( $$opt{'line_items'} ) {
+
+    foreach my $cust_bill ( $self->cust_bill ) {
+
+      push @history, {
+        'type'        => 'Line item',
+        'description' => $_->desc( $self->locale ).
+                           ( $_->sdate && $_->edate
+                               ? ' '. time2str('%d-%b-%Y', $_->sdate).
+                                 ' To '. time2str('%d-%b-%Y', $_->edate)
+                               : ''
+                           ),
+        'amount'      => sprintf('%.2f', $_->setup + $_->recur ),
+        'charged'     => sprintf('%.2f', $_->setup + $_->recur ),
+        'date'        => $cust_bill->_date,
+        'date_pretty' =>  time2str('%m/%d/%Y', $cust_bill->_date ),
+      }
+        foreach $cust_bill->cust_bill_pkg;
+
+    }
+
+  } else {
+
+    push @history, {
+                     'type'        => 'Invoice',
+                     'description' => 'Invoice #'. $_->display_invnum,
+                     'amount'      => sprintf('%.2f', $_->charged ),
+                     'charged'     => sprintf('%.2f', $_->charged ),
+                     'date'        => $_->_date,
+                     'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                   }
+      foreach $self->cust_bill;
+
+  }
+
+  push @history, {
+                   'type'        => 'Payment',
+                   'description' => 'Payment', #XXX type
+                   'amount'      => sprintf('%.2f', 0 - $_->paid ),
+                   'paid'        => sprintf('%.2f', $_->paid ),
+                   'date'        => $_->_date,
+                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                 }
+    foreach $self->cust_pay;
+
+  push @history, {
+                   'type'        => 'Credit',
+                   'description' => 'Credit', #more info?
+                   'amount'      => sprintf('%.2f', 0 -$_->amount ),
+                   'credit'      => sprintf('%.2f', $_->amount ),
+                   'date'        => $_->_date,
+                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                 }
+    foreach $self->cust_credit;
+
+  push @history, {
+                   'type'        => 'Refund',
+                   'description' => 'Refund', #more info?  type, like payment?
+                   'amount'      => $_->refund,
+                   'refund'      => $_->refund,
+                   'date'        => $_->_date,
+                   'date_pretty' =>  time2str('%m/%d/%Y', $_->_date ),
+                 }
+    foreach $self->cust_refund;
+
+  #put it all in chronological order
+  @history = sort { $a->{'date'} <=> $b->{'date'} } @history;
+
+  #calculate balance, filter items outside date range
+  my $previous = 0;
+  my $balance = 0;
+  my @out = ();
+  foreach my $item (@history) {
+    last if $$opt{'end_date'} && ($$item{'date'} >= $$opt{'end_date'});
+    $balance += $$item{'amount'};
+    if ($$opt{'start_date'} && ($$item{'date'} < $$opt{'start_date'})) {
+      $previous += $$item{'amount'};
+      next;
+    }
+    $$item{'balance'} = sprintf("%.2f",$balance);
+    foreach my $key ( qw(amount balance) ) {
+      $$item{$key.'_pretty'} = $$item{$key};
+      $$item{$key.'_pretty'} =~ s/^(-?)/$1$money_char/;
+    }
+    push(@out,$item);
+  }
+
+  # start with previous balance, if there was one
+  if ($previous) {
+    my $item = {
+      'type'        => 'Previous',
+      'description' => 'Previous balance',
+      'amount'      => sprintf("%.2f",$previous),
+      'balance'     => sprintf("%.2f",$previous),
+    };
+    #false laziness with above
+    foreach my $key ( qw(amount balance) ) {
+      $$item{$key.'_pretty'} = $$item{$key};
+      $$item{$key.'_pretty'} =~ s/^(-?)/$1$money_char/;
+    }
+    unshift(@out,$item);
+  }
+
+  @out = reverse @history if $$opt{'reverse_sort'};
+
+  return @out;
+}
+
+=item payment_history_text
+
+Accepts the same options as L</payment_history> and returns those
+results as a string table with fixed-width columns, max width 80 char.
+
+=cut
+
+sub payment_history_text {
+  my $self = shift;
+  my $opt = ref($_[0]) ? $_[0] : { @_ };
+  my $out = sprintf("%-12s",'Date');
+  $out .= sprintf("%11s",'Amount') . '  ';
+  $out .= sprintf("%11s",'Balance') . '  ';
+  $out .= 'Description'; #don't need to pad with spaces
+  $out .= "\n";
+  foreach my $item ($self->payment_history($opt)) {
+    $out .= sprintf("%-10.10s",$$item{'date_pretty'}) . '  ';   #12 width
+    $out .= sprintf("%11.11s",$$item{'amount_pretty'}) . '  ';  #13 width
+    $out .= sprintf("%11.11s",$$item{'balance_pretty'}) . '  '; #13 width
+    $out .= sprintf("%.42s",$$item{'description'});             #max 42 width
+    $out .= "\n";
+  }
+  return $out;
+}
+
 =back
 
 =head1 CLASS METHODS
index 40c0ae9..211dc32 100644 (file)
@@ -380,6 +380,11 @@ HTML body
 
 Text body
 
+=item sub_param
+
+Optional list of parameter hashrefs to be passed
+along to L<FS::msg_template/prepare>.
+
 =back
 
 Returns an error message, or false for success.
@@ -456,6 +461,8 @@ sub email_search_result {
         'cust_main' => $cust_main,
         'object'    => $obj,
       );
+      $message{'sub_param'} = $param->{'sub_param'}
+        if $param->{'sub_param'};
     }
     else {
       my @to = $cust_main->invoicing_list_emailonly;
@@ -533,7 +540,9 @@ sub process_email_search_result {
 
   $param->{'search'} = thaw(decode_base64($param->{'search'}))
     or die "process_email_search_result requires search params.\n";
-
+  $param->{'sub_param'} = thaw(decode_base64($param->{'sub_param'}))
+    or die "process_email_search_result error decoding sub_param\n"
+      if $param->{'sub_param'};
 #  $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
 #    unless ref($param->{'payby'});
 
index cb13696..644663e 100644 (file)
@@ -268,7 +268,19 @@ invoicing_list addresses.  Multiple addresses may be comma-separated.
 
 =item substitutions
 
-A hash reference of additional substitutions
+A hash reference of additional string substitutions
+
+=item sub_param
+
+A hash reference, keys are the names of existing substitutions,
+values are an addition parameter object to pass to the subroutine
+for that substitution, e.g.
+
+       'sub_param' => {
+         'payment_history' => {
+           'start_date' => 1434764295,
+         },
+       },
 
 =back
 
@@ -324,7 +336,10 @@ sub prepare {
       }
       elsif( ref($name) eq 'ARRAY' ) {
         # [ foo => sub { ... } ]
-        $hash{$prefix.($name->[0])} = $name->[1]->($obj);
+        my @subparam = ();
+        push(@subparam, $opt{'sub_param'}->{$name->[0]})
+          if $opt{'sub_param'} && $opt{'sub_param'}->{$name->[0]};
+        $hash{$prefix.($name->[0])} = $name->[1]->($obj,@subparam);
       }
       else {
         warn "bad msg_template substitution: '$name'\n";
@@ -337,7 +352,10 @@ sub prepare {
     $hash{$_} = $opt{substitutions}->{$_} foreach keys %{$opt{substitutions}};
   }
 
-  $_ = encode_entities($_ || '') foreach values(%hash);
+  foreach my $key (keys %hash) {
+    next if $self->no_encode($key);
+    $hash{$key} = encode_entities($_ || '');
+  };
 
   ###
   # clean up template
@@ -504,6 +522,13 @@ my $usage_warning = sub {
 
 #my $conf = new FS::Conf;
 
+# for substitutions that handle their own encoding
+sub no_encode {
+  my $self = shift;
+  my $field = shift;
+  return ($field eq 'payment_history');
+}
+
 #return contexts and fill-in values
 # If you add anything, be sure to add a description in 
 # httemplate/edit/msg_template.html.
@@ -562,6 +587,12 @@ sub substitutions {
       [ selfservice_server_base_url => sub { 
           $conf->config('selfservice_server-base_url') #, shift->agentnum) 
         } ],
+      [ payment_history => sub {
+          my $cust_main = shift;
+          my $param = shift || {};
+          #html works, see no_encode method
+          return '<PRE>' . encode_entities($cust_main->payment_history_text($param)) . '</PRE>';
+        } ],
     ],
     # next_bill_date
     'cust_pkg'  => [qw( 
index a4e27fd..87c407c 100644 (file)
@@ -21,6 +21,15 @@ If you did not request this password reset, you may safely ignore and delete thi
 END
                       ],
     },
+    { msgname   => 'payment_history_template',
+      mime_type => 'text/html',
+      _conf        => 'payment_history_msgnum',
+      _insert_args => [ subject => '{ $company_name } payment history',
+                        body    => <<'END',
+{ $payment_history }
+END
+                      ],
+    },
   ];
 }
 
index c6b2a7d..a1026fe 100644 (file)
@@ -210,6 +210,7 @@ my %substitutions = (
     '$company_address'=> 'Our company address',
     '$company_phonenum' => 'Our phone number',
     '$selfservice_server_base_url' => 'Base URL of customer self-service',
+    '$payment_history' => 'List of invoices/payments/credits/refunds',
   ],
   'contact' => [ # duplicate this for shipping
     '$name'           => 'Company and contact name',
@@ -322,7 +323,7 @@ my $widget = new HTML::Widgets::SelectLayers(
     my @hints = @{ $substitutions{$section} };
     while(@hints) {
       my $key = shift @hints;
-      $html .= qq!\n<TR><TD><A href="javascript:insertHtml('{$key}')">$key</A></TD>!;
+      $html .= qq!\n<TR><TD STYLE="padding-right: .25em;"><A href="javascript:insertHtml('{$key}')">$key</A></TD>!;
       $html .= "\n<TD>".shift(@hints).'</TD></TR>';
     }
     $html .= "\n</TABLE>";
diff --git a/httemplate/misc/email-customers-history.html b/httemplate/misc/email-customers-history.html
new file mode 100644 (file)
index 0000000..2f9a38d
--- /dev/null
@@ -0,0 +1,51 @@
+
+ <% include('email-customers.html',
+      'form_action'       => 'email-customers-history.html',
+      'sub_param_process' => $sub_param_process,
+      'alternate_form'    => $alternate_form,
+      'title'             => 'Send payment history',
+    )
+ %>
+
+<%init>
+
+my $sub_param_process = sub {
+  my $conf = shift;
+  my %sub_param;
+  foreach my $field ( qw( start_date end_date ) ) {
+    $sub_param{'payment_history'}->{$field} = parse_datetime($cgi->param($field));
+    $cgi->delete($field);
+  }
+  $cgi->param('msgnum',$conf->config('payment_history_msgnum'));
+  return %sub_param;
+};
+
+my $alternate_form = sub {
+  my %sub_param = @_;
+  # this could maaaybe be a separate element, for cleanliness
+  # but it's really only for use by this page, and it's not overly complicated
+  my $noinit = 0;
+  return join("\n",
+    '<TABLE BORDER="0">',
+    (
+      map {
+        my $label = ucfirst($_);
+        $label =~ s/_/ /;
+        include('/elements/tr-input-date-field.html',{
+          'name' => $_,
+          'value' => $sub_param{'payment_history'}->{$_} || '',
+          'label' => $label,
+          'noinit' => $noinit++
+        });
+      }
+      qw( start_date end_date )
+    ),
+    '</TABLE>',
+    '<INPUT TYPE="hidden" NAME="msgnum" VALUE="' . $cgi->param('msgnum') . '">',
+    '<INPUT TYPE="hidden" NAME="action" VALUE="preview">',
+    '<INPUT TYPE="submit" VALUE="Preview notice">',
+  );
+};
+
+</%init>
+
index 83e8615..d1d5ac7 100644 (file)
@@ -1,3 +1,26 @@
+<%doc>
+
+Allows emailing one or more customers, based on a search for customers.  Search can
+be specified either through cust_main fields as cgi params, or through a base64 encoded
+frozen hash in the 'search' cgi param.  Form allows selecting an existing msg_template,
+or creating a custom message, and shows a preview of the message before sending.
+If linked to as a popup, include the cgi parameter 'popup' for proper header handling.
+
+This may also be used as an element in other pages, enabling you to pass along
+additional substitution parameters to a message template, with the following options:
+
+form_action - the URL to submit the form to
+
+sub_param_process - subroutine to override cgi param values (such as msgnum) 
+and parse/delete additional form fields from the cgi;  should return a %sub_param 
+hash to be passed along for message substitution
+
+alternate_form - an alternate form for template selection/message creation
+
+title - the title of the page
+
+</%doc>
+
 % if ($popup) {
 <% include('/elements/header-popup.html', $title) %>
 % } else {
 % }
 
 
-<FORM NAME="OneTrueForm" ACTION="email-customers.html" METHOD="POST">
+<FORM NAME="OneTrueForm" ACTION="<% $form_action %>" METHOD="POST">
 <INPUT TYPE="hidden" NAME="table" VALUE="<% $table %>">
 %# Mixing search params with from address, subject, etc. required special-case
 %# handling of those, risked name conflicts, and caused massive problems with 
 %# multi-valued search params.  We are no longer in search context, so we 
 %# pack the search into a Storable string for later use.
 <INPUT TYPE="hidden" NAME="search" VALUE="<% encode_base64(nfreeze(\%search)) %>">
+% if (%sub_param) {
+<INPUT TYPE="hidden" NAME="sub_param" VALUE="<% encode_base64(nfreeze(\%sub_param)) %>">
+% }
 <INPUT TYPE="hidden" NAME="popup" VALUE="<% $popup %>">
 <INPUT TYPE="hidden" NAME="url" VALUE="<% $url | h %>">
 
@@ -21,7 +47,7 @@
 
     <% include('/elements/progress-init.html',
                  'OneTrueForm',
-                 [ qw( search table from subject html_body text_body msgnum ) ],
+                 [ qw( search table from subject html_body text_body msgnum sub_param ) ],
                  'process/email-customers.html',
                  $pdest,
               )
     
 %   }
 
+% } elsif ($alternate_form) {
+
+<% $alternate_form %>
+
 % } else {
 
 <SCRIPT TYPE="text/javascript">
@@ -144,7 +174,7 @@ Template:
     <INPUT TYPE="hidden" NAME="action" VALUE="preview">
     <INPUT TYPE="submit" VALUE="Preview notice">
 
-% }
+% } #end not preview or alternate form
 
 </FORM>
 
@@ -158,11 +188,18 @@ Template:
 
 <%init>
 
+my %opt = @_;
+
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Bulk send customer notices');
 
 my $conf = FS::Conf->new;
 
+my $form_action = $opt{'form_action'} || 'email-customers.html';
+my %sub_param = $opt{'sub_param_process'} ? &{$opt{'sub_param_process'}}($conf) : ();
+my $alternate_form = $opt{'alternate_form'} ? &{$opt{'alternate_form'}}(%sub_param) : ();
+my $title = $opt{'title'} || 'Send customer notices';
+
 my $table = $cgi->param('table') or die "'table' required";
 my $agent_virt_agentnum = $cgi->param('agent_virt_agentnum') || '';
 
@@ -177,7 +214,7 @@ if ( $cgi->param('search') ) {
 }
 else {
   %search = $cgi->Vars;
-  delete $search{$_} for qw( action table from subject html_body text_body popup url );
+  delete $search{$_} for qw( action table from subject html_body text_body popup url sub_param );
   # FS::$table->search is expected to know which parameters might be 
   # multi-valued, and to accept scalar values for them also.  No good 
   # solution to this since CGI can't tell whether a parameter _might_
@@ -185,8 +222,6 @@ else {
   @search{keys %search} = map { /\0/ ? [ split /\0/, $_ ] : $_ } values %search;
 } 
 
-my $title = 'Send customer notices';
-
 my $num_cust;
 my $from = '';
 if ( $cgi->param('from') ) {
@@ -221,10 +256,12 @@ if ( $cgi->param('action') eq 'preview' ) {
     $sql_query->{'order_by'} = '';
     my $object = qsearchs($sql_query);
     my $cust = $object->cust_main;
-    my %message = $msg_template->prepare(
+    my %msgopts = (
       'cust_main' => $cust,
-      'object' => $object
+      'object' => $object,
     );
+    $msgopts{'sub_param'} = \%sub_param if %sub_param; 
+    my %message = $msg_template->prepare(%msgopts);
     ($from, $subject, $html_body) = @message{'from', 'subject', 'html_body'};
   }
 }