Annotation of RT/Invoicing/mk_invoice.pl, Revision 1.1
1.1 ! afresh1 1: #!/usr/bin/perl
! 2: # $AFresh1: rt_invoices.pl,v 1.56 2020/08/02 17:57:28 afresh1 Exp $
! 3: ########################################################################
! 4: # Copyright (c) 2011 Andrew Fresh <andrew@afresh1.com>
! 5: #
! 6: # Permission to use, copy, modify, and distribute this software for any
! 7: # purpose with or without fee is hereby granted, provided that the above
! 8: # copyright notice and this permission notice appear in all copies.
! 9: #
! 10: # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
! 11: # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
! 12: # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
! 13: # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
! 14: # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
! 15: # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
! 16: # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
! 17: ########################################################################
! 18: use strict;
! 19: use warnings;
! 20:
! 21: use 5.010;
! 22:
! 23: use Template;
! 24:
! 25: use File::Path;
! 26: use DateTime;
! 27:
! 28: use List::Util qw/ sum /;
! 29:
! 30: use lib './lib'; # XXX This is fragile, there are better ways
! 31: use RTI::Config;
! 32: use RTI::State;
! 33: use RTI::Util qw/ round ymd_to_DateTime /;
! 34:
! 35: my $config = RTI::Config->new();
! 36: my $state = RTI::State->new( $config->get('state') );
! 37:
! 38: my ($customer, $input, $output) = (@ARGV, '-', '-');
! 39: die "Usage customer [input] [output]\n" unless $output;
! 40:
! 41: my $customers = $config->get('customers');
! 42: my $startdate = set_dates($customers);
! 43:
! 44: my $cust = find_cust($customer, $customers)
! 45: or die "Unable to find cust $customer in config\n";
! 46:
! 47: my @transactions;
! 48: {
! 49: my $fh;
! 50: if ( $input eq '-' ) {
! 51: open $fh, '<&STDIN' or die "Unable to dup STDIN: $!";
! 52: }
! 53: else {
! 54: open $fh, '<', $input or die "Unable to open $input: $!";
! 55: }
! 56:
! 57: while (readline $fh) {
! 58: next if /^\s*#/;
! 59: chomp;
! 60: my ($date, $hours, $description) = split /\s/, $_, 3;
! 61: push @transactions, {
! 62: raw => $_,
! 63: date => $date,
! 64: time_taken => $hours,
! 65: description => $description,
! 66: };
! 67: }
! 68:
! 69: close $fh;
! 70: }
! 71:
! 72: #use YAML;
! 73: #print Dump $config, $state, $customer, $input, $output, $cust, \@transactions; exit;
! 74:
! 75: $cust->{invoice} = make_invoice($cust);
! 76: die "$cust->{id} has no open invoices"
! 77: unless $cust->{invoice};
! 78:
! 79: my $project = make_project($cust, \@transactions, title => "Hourly billing");
! 80:
! 81: foreach my $fee ( @{ $project->{fees} } ) {
! 82: my $hours = hours_for_date( $cust->{invoice}, $fee->{date} );
! 83:
! 84: my $type = 'unknown';
! 85: my $count = $fee->{count};
! 86: while ( $type && $count > 0 && $type ne 'default' ) {
! 87: $type = exists $hours->{ $fee->{type} }
! 88: && $hours->{ $fee->{type} } > 0 ? $fee->{type} : 'default';
! 89:
! 90: next unless exists $hours->{$type} && $hours->{$type} > 0;
! 91:
! 92: my $discount_time = 0;
! 93: if ( $hours->{$type} > $count ) {
! 94: $hours->{$type} -= $count;
! 95: $discount_time = $count;
! 96: }
! 97: else {
! 98: $discount_time = $hours->{$type};
! 99: $hours->{$type} = 0;
! 100: }
! 101:
! 102: if ($discount_time) {
! 103: $cust->{invoice}->{discount}->{amount}
! 104: += round( $discount_time * $fee->{rate} );
! 105: $cust->{invoice}->{discount}->{hours}{$type}
! 106: += $discount_time;
! 107:
! 108: $type = '' if $type eq 'default';
! 109: $count -= $discount_time;
! 110: $fee->{detail} .= " $discount_time $type Hours Discounted";
! 111: }
! 112: }
! 113: }
! 114:
! 115:
! 116: if ( my $unpaid_invoices = $state->unpaid_invoices() ) {
! 117: foreach my $custid ( keys %{$unpaid_invoices} ) {
! 118: my %project
! 119: = ( title => 'Unpaid Invoices', fees => [], no_total => 1 );
! 120: my $past_due = 0;
! 121: my $unpaid = 0;
! 122:
! 123: my $cust;
! 124: foreach ( @{$customers} ) {
! 125: if ( $_->{id} eq $custid ) {
! 126: $cust = $_;
! 127: last;
! 128: }
! 129: }
! 130: $cust ||= fake_customer($custid);
! 131:
! 132: foreach my $id (
! 133: sort { $a <=> $b }
! 134: keys %{ $unpaid_invoices->{$custid} }
! 135: )
! 136: {
! 137: my $unpaid = $state->get_invoice($id);
! 138: my $invdate = ymd_to_DateTime( $unpaid->{invdate} );
! 139:
! 140: my $content
! 141: = sprintf( "Invoice %06d from %s", $id, $invdate->ymd );
! 142: if ( $cust->{duedate} && $invdate < $cust->{duedate}) {
! 143: $content = "PAST DUE: $content";
! 144: $past_due += $unpaid_invoices->{$custid}->{$id};
! 145: }
! 146: else {
! 147: $unpaid += $unpaid_invoices->{$custid}->{$id};
! 148: }
! 149:
! 150: push @{ $project{fees} },
! 151: {
! 152: id => $id,
! 153: contents => $content,
! 154: count => 1,
! 155: rate => $unpaid_invoices->{$custid}->{$id},
! 156: };
! 157: }
! 158:
! 159: if ($past_due) {
! 160: $cust->{invoice} ||= make_invoice($cust);
! 161:
! 162: $cust->{invoice}->{past_due} = $past_due;
! 163: $cust->{invoice}->{unpaid} = $unpaid;
! 164:
! 165: unshift @{ $cust->{invoice}->{projects} }, \%project;
! 166: }
! 167: }
! 168: }
! 169:
! 170: if ( my $credits = $state->credits ) {
! 171: foreach my $custid ( keys %{$credits} ) {
! 172:
! 173: my $cust;
! 174: foreach ( @{$customers} ) {
! 175: if ( $_->{id} eq $custid ) {
! 176: $cust = $_;
! 177: last;
! 178: }
! 179: }
! 180:
! 181: next unless $cust;
! 182: next unless $cust->{invoice};
! 183: next unless $credits->{$custid} < 0;
! 184:
! 185: $cust->{invoice}->{credit} = $credits->{$custid};
! 186:
! 187: unshift @{ $cust->{invoice}->{projects} }, {
! 188: title => 'Credits',
! 189: no_total => 1,
! 190: fees => [
! 191: { contents => 'Available Credit',
! 192: count => 1,
! 193: rate => -$credits->{$custid},
! 194: }
! 195: ],
! 196: };
! 197: }
! 198: }
! 199:
! 200: foreach my $cust ( @{$customers} ) {
! 201: my $invoice = $cust->{invoice} ||= make_invoice($cust);
! 202: next unless $invoice && $invoice->{projects} && @{ $invoice->{projects} };
! 203:
! 204: $invoice->{custid} = $cust->{id};
! 205: $invoice->{transactions} = [];
! 206:
! 207: my %transactions;
! 208: foreach my $project ( @{ $invoice->{projects} } ) {
! 209: if ( $project->{transactions} ) {
! 210: %transactions = ( %transactions, %{ $project->{transactions} } );
! 211: }
! 212: my $subtotal = 0;
! 213: foreach my $fee ( @{ $project->{fees} } ) {
! 214: my $amount = round( $fee->{count} * $fee->{rate} );
! 215: $subtotal += $amount;
! 216: }
! 217: foreach my $expense ( @{ $project->{expenses} } ) {
! 218: $subtotal += round( $expense->{amount} );
! 219: }
! 220: $project->{total} = $subtotal;
! 221:
! 222: next if $project->{no_total};
! 223: $invoice->{total} += $subtotal;
! 224: }
! 225: @{ $invoice->{transactions} } = sort { $a <=> $b } keys %transactions;
! 226:
! 227: if ( $invoice->{discount} ) {
! 228: my $c = "Included Hours\n";
! 229: if ( $invoice->{discount}{hours} ) {
! 230: foreach my $t ( keys %{ $invoice->{discount}{hours} } ) {
! 231: my $type = $t eq 'default' ? '' : $t;
! 232: $c .= "\n$invoice->{discount}{hours}{$t} $type hour";
! 233: $c .= 's' if $invoice->{discount}{hours}{$t} != 1;
! 234: $c .= "\n";
! 235: }
! 236: }
! 237: $invoice->{discount}{contents} = $c;
! 238: $invoice->{total} -= round( $invoice->{discount}{amount} );
! 239: }
! 240:
! 241: if ($invoice->{past_due}) {
! 242: $invoice->{total_due}
! 243: = sum( @{ $invoice }{ qw/ total past_due unpaid / } );
! 244: }
! 245:
! 246: next unless $invoice->{total} > 0 || $invoice->{total_due};
! 247:
! 248: $invoice->{info} = $config->get('info');
! 249: my $from = $config->get('from');
! 250:
! 251: $invoice->{id} = $state->next_invoice_id;
! 252: $invoice->{file} = sprintf 'invoice_%06d.pdf', $invoice->{id};
! 253:
! 254: $invoice->{organization} = $from->{organization} || $from->{name};
! 255: $invoice->{from} = make_address($from);
! 256: $invoice->{to} = make_address( $cust->{address} );
! 257: $invoice->{logo} = $config->get('logo');
! 258:
! 259: $state->add_invoice($invoice);
! 260:
! 261: my $invoice_dir = $config->get('invoice_dir');
! 262: File::Path::make_path($invoice_dir);
! 263: my $file = join '/', $invoice_dir, $invoice->{file};
! 264:
! 265: my $tt = Template->new( INCLUDE_PATH => $config->get('template_dir'), )
! 266: || die $Template::ERROR, "\n";
! 267:
! 268: $tt->process( $config->get('invoice_template'), $invoice, $file )
! 269: or die $tt->error . "\n";
! 270:
! 271: printf "Generated %s for %s: \$%.02f\n", $invoice->{file}, $cust->{id},
! 272: $invoice->{total};
! 273:
! 274: }
! 275:
! 276: $state->save;
! 277:
! 278: sub find_cust {
! 279: my ($id, $customers) = @_;
! 280: foreach my $cust (@{ $customers }) {
! 281: return $cust if $cust->{id} eq $id;
! 282: }
! 283: return fake_customer($id);
! 284: }
! 285:
! 286: sub fake_customer {
! 287: my ($custid) = @_;
! 288: return unless $custid;
! 289:
! 290: my $cust = $config->new_customer;
! 291: push @{$customers}, $cust;
! 292:
! 293: $cust->{id} = $custid;
! 294: $cust->{match} = [
! 295: { type => 'requestors',
! 296: regex => $cust->{id},
! 297: }
! 298: ];
! 299:
! 300: set_cust_dates($cust);
! 301: return $cust;
! 302: }
! 303:
! 304: sub make_invoice {
! 305: my ($cust) = @_;
! 306:
! 307: my %invoice = (
! 308: end => $cust->{billend}->clone->subtract( seconds => 1 ),
! 309: total => 0,
! 310: );
! 311: $invoice{start} = $cust->{startinvoicedate}->clone
! 312: if $cust->{startinvoicedate};
! 313:
! 314: if ( $cust->{base_rate} ) {
! 315: my ( $project, $hours ) = make_base_project($cust);
! 316:
! 317: if ( @{ $project->{fees} } ) {
! 318: $invoice{end} = $project->{end};
! 319: $invoice{hours} = $hours;
! 320: push @{ $invoice{projects} }, $project;
! 321: }
! 322: }
! 323: elsif ( $cust->{hours} ) {
! 324: $invoice{hours} = [
! 325: { end => $invoice{end}->clone,
! 326: hours => $cust->{hours},
! 327: }
! 328: ];
! 329: }
! 330:
! 331: return \%invoice;
! 332: }
! 333:
! 334: sub make_base_project {
! 335: my ($cust) = @_;
! 336:
! 337: my $date = $cust->{billstart}->clone;
! 338: my $billend = $cust->{billend}->clone;
! 339: my ($freq) = get_billing_frequency($cust);
! 340:
! 341: my $title
! 342: = $cust->{frequency} == 1
! 343: ? ucfirst( $cust->{per} . 'ly' )
! 344: : $freq . ' ' . ucfirst( $cust->{per} );
! 345: $title .= ' Retainer';
! 346:
! 347: my %project = ( title => $title, start => $date->clone, fees => [], );
! 348: my @hours;
! 349:
! 350: while ( $date < $billend ) {
! 351: my $start = $date->clone;
! 352:
! 353: $date->add_duration($freq);
! 354:
! 355: my $end = $date > $billend ? $billend->clone : $date->clone;
! 356: $end->subtract( seconds => 1 );
! 357:
! 358: $project{end} = $end->clone;
! 359:
! 360: push @{ $project{fees} },
! 361: {
! 362: count => 1,
! 363: rate => $cust->{base_rate},
! 364: contents => $start->ymd . ' to ' . $end->ymd,
! 365: };
! 366:
! 367: push @hours,
! 368: {
! 369: start => $start->clone,
! 370: end => $end->clone,
! 371: hours => { %{ $cust->{hours} } },
! 372: };
! 373: }
! 374:
! 375: return \%project, \@hours;
! 376: }
! 377:
! 378: sub make_address {
! 379: my ($addr) = @_;
! 380: my @adr;
! 381:
! 382: if ( $addr->{organization} ) {
! 383: push @adr, $addr->{organization};
! 384: }
! 385: elsif ( $addr->{name} && !$addr->{attn} ) {
! 386: push @adr, $addr->{name};
! 387: }
! 388:
! 389: if ( ( $addr->{addr1} || $addr->{addr2} )
! 390: && $addr->{city}
! 391: && $addr->{state}
! 392: && $addr->{zip} )
! 393: {
! 394: push @adr, $addr->{attn} if $addr->{attn};
! 395: push @adr, $addr->{addr1} if $addr->{addr1};
! 396: push @adr, $addr->{addr2} if $addr->{addr2};
! 397: push @adr,
! 398: $addr->{city} . ', ' . $addr->{state} . ' ' . $addr->{zip};
! 399: }
! 400: else {
! 401: push @adr, $addr->{email} if $addr->{email};
! 402: }
! 403:
! 404: return join "\n\n", @adr;
! 405: }
! 406:
! 407: sub make_project {
! 408: my ( $cust, $transactions, %project ) = @_;
! 409:
! 410: $project{fees} = [];
! 411: $project{expenses} = [];
! 412:
! 413: die "Project title required!" unless $project{title};
! 414:
! 415: for my $txn (@{ $transactions }) {
! 416:
! 417: #if ( my $expense = make_expense( $txn, $ticket ) ) {
! 418: # push @{ $project{expenses} }, $expense;
! 419: # $project{transactions}{ $txn->id } = 1;
! 420: #}
! 421:
! 422: next unless $txn->{time_taken};
! 423:
! 424: my $fee = make_fee( $txn, $cust->{rates} );
! 425:
! 426: if ( !( $fee->{rate} && $fee->{count} ) ) {
! 427: warn "Invalid Fee, no rate or count";
! 428: next;
! 429: }
! 430:
! 431: my $invoice = $cust->{invoice};
! 432: if ( $invoice->{start} && $invoice->{start} > $fee->{date} ) {
! 433: warn "Uninvoiced Transaction $txn->{raw}\n";
! 434: next;
! 435: }
! 436: next if $invoice->{end} < $fee->{date};
! 437:
! 438: push @{ $project{fees} }, $fee;
! 439: #$project{transactions}{ $txn->id } = 1;
! 440: }
! 441:
! 442: push @{ $cust->{invoice}->{projects} }, \%project;
! 443:
! 444: return \%project;
! 445: }
! 446:
! 447: sub make_fee {
! 448: my ( $txn, $rates ) = @_;
! 449:
! 450: my $work_time = $txn->{time_taken};
! 451: my $work_type = $txn->{work_type} // '';
! 452:
! 453: if ( $work_type =~ s/\s*Onsite//i ) {
! 454:
! 455: # XXX Do something special for onsite activities
! 456: }
! 457:
! 458: $work_type =~ s/^\s+|\s+$//g;
! 459: $work_type ||= 'Normal';
! 460:
! 461: my %fee = (
! 462: contents => "$txn->{date} - $txn->{description}",
! 463: count => $work_time,
! 464: type => $work_type,
! 465: date => ymd_to_DateTime( $txn->{date} ),
! 466: rate => $rates->{$work_type} || $rates->{default} || 0,
! 467: );
! 468:
! 469: if ( $work_type && $work_type ne 'Normal' ) {
! 470: $fee{detail} = $work_type . ' rate';
! 471: }
! 472:
! 473: return \%fee;
! 474: }
! 475:
! 476: sub make_expense {
! 477: my ( $txn, $ticket ) = @_;
! 478:
! 479: my $amount = $txn->cf('ExpenseAmount') or return;
! 480:
! 481: my %expense = (
! 482: id => $txn->id,
! 483: contents => $txn->created . ' ('
! 484: . $txn->id . ')' . "\n\n"
! 485: . ( $txn->data || $ticket->subject ),
! 486: amount => $amount,
! 487: date => ymd_to_DateTime( $txn->created ),
! 488:
! 489: # detail => ???,
! 490: );
! 491:
! 492: return \%expense;
! 493: }
! 494:
! 495: sub hours_for_date {
! 496: my ( $invoice, $date ) = @_;
! 497:
! 498: my $hours = {};
! 499: if ( $invoice->{hours} ) {
! 500: foreach my $h ( @{ $invoice->{hours} } ) {
! 501: next if $h->{start} && $h->{start} > $date;
! 502: next if $h->{end} < $date;
! 503:
! 504: $hours = $h->{hours};
! 505: last;
! 506: }
! 507: }
! 508: return $hours;
! 509: }
! 510:
! 511: sub get_billing_frequency {
! 512: my ($cust) = @_;
! 513: my $per = $cust->{per} || 'week';
! 514: my $freq = $cust->{frequency} || 1;
! 515:
! 516: my $day_method
! 517: = $per eq 'week' ? 'dow'
! 518: : $per eq 'month' ? 'day'
! 519: : die "Unknown per [$per]\n";
! 520:
! 521: return DateTime::Duration->new( "${per}s" => $freq ), $day_method;
! 522: }
! 523:
! 524: sub set_dates {
! 525: my ($customers) = @_;
! 526:
! 527: my $newest_invoice;
! 528: my $max_duration;
! 529:
! 530: foreach my $cust ( @{$customers} ) {
! 531: set_cust_dates($cust);
! 532:
! 533: my ($freq) = get_billing_frequency($cust);
! 534: $max_duration = $freq
! 535: if !$max_duration
! 536: || DateTime::Duration->compare( $freq, $max_duration ) > 0;
! 537:
! 538: if ( $cust->{startinvoicedate} ) {
! 539: my $date = $cust->{startinvoicedate}->clone;
! 540: $newest_invoice = $date->clone
! 541: if !$newest_invoice || $newest_invoice < $date;
! 542: }
! 543: }
! 544:
! 545: $newest_invoice ||= DateTime->now;
! 546:
! 547: return $newest_invoice->clone->subtract_duration($max_duration)
! 548: ->subtract( days => 1 );
! 549: }
! 550:
! 551: sub set_cust_dates {
! 552: my ($cust) = @_;
! 553:
! 554: my $day = $cust->{day} || 0;
! 555: my ( $freq, $day_method ) = get_billing_frequency($cust);
! 556:
! 557: my $end = DateTime->now( time_zone => 'local' )
! 558: ->set( hour => 0, minute => 0, second => 0 );
! 559:
! 560: my $start = $end->clone->subtract_duration($freq);
! 561:
! 562: # XXX This is helpful, but monthly and billday > 28 == !!!
! 563: $end->subtract( days => 1 ) while $day && $end->$day_method != $day;
! 564:
! 565: my $lastinvoice = $state->last_invoice( $cust->{id} );
! 566: if ( $lastinvoice && $lastinvoice->{end} ) {
! 567: $start = ymd_to_DateTime( $lastinvoice->{end} )->add( days => 1 );
! 568: $cust->{startinvoicedate} = $start->clone;
! 569: }
! 570:
! 571: $cust->{duedate}
! 572: = $cust->{net}
! 573: ? DateTime->now->subtract( days => $cust->{net} )
! 574: : 0;
! 575:
! 576: $cust->{no_invoice} = 1 if $start->clone->add_duration($freq) > $end;
! 577: $cust->{billend} = $end;
! 578: $cust->{billstart} = $start;
! 579: }
FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>