Annotation of RT/Invoicing/rt_invoices.pl, Revision 1.13
1.1 andrew 1: #!/usr/bin/perl
1.11 andrew 2: # $AFresh1$
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: ########################################################################
1.1 andrew 18: use strict;
19: use warnings;
20:
21: use 5.010;
22:
23: use YAML::Any;
24: use Template;
25: use RT::Client::REST;
26: use RT::Client::REST::Ticket;
27: use RT::Client::REST::User;
28:
29: use DateTime;
30:
31: my $config = RTI::Config->new();
1.6 andrew 32: my $state = RTI::State->new( $config->get('state') );
1.1 andrew 33:
1.6 andrew 34: my $rt_conf = $config->get('rt');
35: my $rt = RT::Client::REST->new(
36: server => $rt_conf->{server},
37: timeout => $rt_conf->{timeout},
38: );
39: my $tickets = RT::Client::REST::Ticket->new( rt => $rt );
1.1 andrew 40:
1.6 andrew 41: $rt->login( username => $rt_conf->{user}, password => $rt_conf->{pass} );
42:
43: #print Dump $config, $state; exit;
1.1 andrew 44:
45: my $startdate;
1.3 andrew 46:
1.6 andrew 47: my $customers = $config->get('customers');
48: CUSTOMER: while ( my ( $custid, $cust ) = each %{$customers} ) {
49:
1.13 ! andrew 50: my $invoice = make_invoice();
1.1 andrew 51:
52: if ( $cust->{base_rate} ) {
1.3 andrew 53: my $day = $cust->{day} || 1;
1.1 andrew 54: my $freq = $cust->{frequency} || 1;
55:
1.7 andrew 56: my $per;
1.3 andrew 57: my $day_method;
1.1 andrew 58: given ( $cust->{per} ) {
1.3 andrew 59: when ('week') { $per = 'weeks'; $day_method = 'dow' }
60: when ('month') { $per = 'months'; $day_method = 'day' }
1.1 andrew 61: default { die "Unknown per [$cust->{per}]\n" }
62: }
63:
1.13 ! andrew 64: my $billends = $invoice->{end}->clone;
1.7 andrew 65: $billends->subtract( days => 1 ) while $billends->$day_method != $day;
1.3 andrew 66:
1.6 andrew 67: my $date = $billends->clone->subtract( $per => $freq );
68:
69: my $lastinvoice = $state->last_invoice($custid);
70: if ( $lastinvoice->{date} ) {
71: my $last_invoice_date = ymd_to_DateTime( $lastinvoice->{date} );
72: $date = $last_invoice_date->clone->add( days => 1 );
73: }
1.1 andrew 74:
1.7 andrew 75: next CUSTOMER if $billends <= $date;
76:
1.1 andrew 77: my $title
78: = $freq == 1
79: ? ucfirst( $cust->{per} . 'ly' )
80: : $freq . ' ' . ucfirst( $cust->{per} );
81: $title .= ' Retainer';
82:
83: my %project = ( title => $title, fees => [], );
84:
1.6 andrew 85: while ( $date < $billends ) {
1.3 andrew 86: my $start = $date->clone;
87:
88: $date->add( $per => $freq );
1.6 andrew 89: $date = $billends->clone if $date > $billends;
1.7 andrew 90:
91: # XXX This is helpful, but monthly and billday > 28 == !!!
92: $date->subtract( days => 1 ) while $date->$day_method != $day;
1.3 andrew 93:
1.6 andrew 94: my $end = $date->clone->subtract( seconds => 1 );
1.3 andrew 95:
1.7 andrew 96: next if $end < $start;
97:
1.4 andrew 98: $startdate = $start->clone if !$startdate || $startdate > $start;
1.13 ! andrew 99: $invoice->{start} ||= $start->clone;
! 100: $invoice->{end} = $end->clone;
1.1 andrew 101: my %hours = (
1.3 andrew 102: start => $start->clone,
103: end => $end->clone,
1.1 andrew 104: hours => { %{ $cust->{hours} } },
105: );
106:
1.13 ! andrew 107: push @{ $invoice->{hours} }, \%hours;
1.3 andrew 108: push @{ $project{fees} },
1.1 andrew 109: {
110: count => 1,
111: rate => $cust->{base_rate},
1.3 andrew 112: contents => $start->ymd . ' to ' . $end->ymd,
1.1 andrew 113: };
114:
115: }
116:
1.3 andrew 117: if ( @{ $project{fees} } ) {
1.13 ! andrew 118: push @{ $invoice->{projects} }, \%project;
1.2 andrew 119: }
1.1 andrew 120: }
121:
1.12 andrew 122: $cust->{address} ||= get_user($custid);
1.13 ! andrew 123: $cust->{invoice} = $invoice;
1.1 andrew 124: }
125:
126: my @limits = map +{
127: attribute => 'status',
128: operator => '=',
129: value => $_,
130: aggregator => 'or',
131: },
132:
133: # XXX This should be a config option
134: qw/ open stalled resolved /;
135:
136: if ($startdate) {
137: push @limits,
138: {
139: attribute => 'last_updated',
140: operator => '>=',
141: value => $startdate->ymd,
142: };
143: }
144:
145: my $results = $tickets->search(
146: limits => \@limits,
147: orderby => 'id',
148: );
149:
150: my $count = $results->count;
151: print "There are $count results that matched your query\n";
152:
153: my $iterator = $results->get_iterator;
154: while ( my $ticket = &$iterator ) {
1.6 andrew 155: my $cust = find_customer_for_ticket( $customers, $ticket );
156: if ( !$cust ) {
1.10 andrew 157: warn "No customer found for ticket " . $ticket->id;
1.7 andrew 158: next;
159: }
160: if ( !$cust->{invoice} ) {
1.12 andrew 161:
1.10 andrew 162: #say "Customer has no open invoices for ticket " . $ticket->id;
1.4 andrew 163: next;
164: }
1.6 andrew 165: my $invoice = $cust->{invoice};
1.1 andrew 166:
1.7 andrew 167: my $project = make_project( $ticket, $cust );
1.6 andrew 168: next unless @{ $project->{fees} } || @{ $project->{expenses} };
1.1 andrew 169:
1.6 andrew 170: foreach my $fee ( @{ $project->{fees} } ) {
171: my $hours = hours_for_date( $invoice, $fee->{date} );
1.1 andrew 172:
1.3 andrew 173: my $h_type
1.4 andrew 174: = exists $hours->{ $fee->{type} }
175: ? $fee->{type}
1.1 andrew 176: : 'default';
177:
1.4 andrew 178: next unless exists $hours->{$h_type} && $hours->{$h_type} > 0;
1.1 andrew 179:
180: my $discount_time = 0;
1.4 andrew 181: if ( $hours->{$h_type} > $fee->{count} ) {
182: $hours->{$h_type} -= $fee->{count};
183: $discount_time = $fee->{count};
1.1 andrew 184: }
185: else {
1.3 andrew 186: $discount_time = $hours->{$h_type};
187: $hours->{$h_type} = 0;
1.1 andrew 188: }
189:
190: if ($discount_time) {
1.4 andrew 191: $invoice->{discount}{amount} += $discount_time * $fee->{rate};
192: $invoice->{discount}{hours}{$h_type} += $discount_time;
193:
194: $h_type = '' if $h_type eq 'default';
195: $fee->{detail} = "$discount_time $h_type Hours Discounted";
1.1 andrew 196: }
197: }
198:
1.6 andrew 199: push @{ $invoice->{projects} }, $project;
1.1 andrew 200: }
201:
1.6 andrew 202: while ( my ( $custid, $cust ) = each %{$customers} ) {
203: my $invoice = $cust->{invoice};
1.1 andrew 204: next unless $invoice->{projects} && @{ $invoice->{projects} };
205:
1.12 andrew 206: my %li = ( custid => $custid, invdate => DateTime->now->ymd, );
1.7 andrew 207:
1.1 andrew 208: foreach my $project ( @{ $invoice->{projects} } ) {
1.6 andrew 209: if ( $project->{transactions} ) {
1.7 andrew 210: push @{ $li{transactions} }, @{ $project->{transactions} };
1.6 andrew 211: }
1.1 andrew 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: $invoice->{total} += $subtotal;
222: }
223:
224: if ( $invoice->{discount} ) {
225: my $c = "Included Hours\n";
226: if ( $invoice->{discount}{hours} ) {
227: foreach my $t ( keys %{ $invoice->{discount}{hours} } ) {
228: $c .= "\n$invoice->{discount}{hours}{$t} $t hour";
229: $c .= 's' if $invoice->{discount}{hours}{$t} != 1;
230: $c .= "\n";
231: }
232: }
233: $invoice->{discount}{contents} = $c;
234: $invoice->{total} -= round( $invoice->{discount}{amount} );
235: }
236:
237: if ( $invoice->{past_due} ) {
238: $invoice->{total_due} = $invoice->{total} + $invoice->{past_due};
1.3 andrew 239: }
240:
1.9 andrew 241: next unless $invoice->{total} > 0 || $invoice->{total_due};
242:
1.6 andrew 243: # XXX Here we need to "make_address"
244: $invoice->{info} = $config->get('info');
1.12 andrew 245: $invoice->{from} = make_address( $config->get('from') );
246: $invoice->{to} = make_address( $cust->{address} );
1.6 andrew 247:
248: $state->{lastinvoice}++;
249: $invoice->{id} = $state->{lastinvoice};
250: $invoice->{file} = 'invoice_' . $state->{lastinvoice} . '.pdf';
251:
1.10 andrew 252: foreach my $k (qw/ file transactions start end total past_due total_due /)
253: {
254: my $v;
255: if ( $invoice->{$k} ) { $v = $invoice->{$k} }
256: elsif ( $cust->{$k} ) { $v = $cust->{$k} }
257:
1.12 andrew 258: if ( defined $v && length $v ) {
1.10 andrew 259: if ( ref $v eq 'DateTime' ) {
260: $li{$k} = $v->ymd;
261: }
262: else {
263: $li{$k} = $v;
264: }
265: }
266: }
267: $state->{invoice}->{ $li{end} }{ $invoice->{id} } = \%li;
268:
1.3 andrew 269: foreach my $key (qw/ start end /) {
270: if ( exists $invoice->{$key} ) {
271: $invoice->{$key} = $invoice->{$key}->strftime('%B %d, %Y');
272: }
1.1 andrew 273: }
274: my $tt = Template->new;
275: $tt->process( 'invoice.tex.tt', $invoice, $invoice->{file} )
276: or die $tt->error . "\n";
277:
1.9 andrew 278: printf "Generated %s for %s: \$%.02f\n", $invoice->{file}, $custid,
279: $invoice->{total};
1.1 andrew 280: }
281:
1.6 andrew 282: $state->save;
1.1 andrew 283:
284: sub round {
285: my ($amount) = @_;
286:
287: #$amount =~ s/\.\d\d\K.*$//;
288: #return $amount;
289: return sprintf "%.02f", $amount;
1.4 andrew 290: }
291:
1.6 andrew 292: sub find_customer_for_ticket {
293: my ( $customers, $ticket ) = @_;
1.4 andrew 294:
1.6 andrew 295: INVOICE: foreach my $cust ( values %{$customers} ) {
296: next INVOICE unless $cust->{match};
297: foreach my $m ( @{ $cust->{match} } ) {
1.4 andrew 298: my $type = $m->{type};
299: my $thing = join ' ', $ticket->$type;
300:
301: if ( $m->{$type} ) {
302: my $match = lc $m->{$type};
303: next INVOICE unless lc($thing) ~~ $match;
304: }
305: elsif ( $m->{regex} ) {
306: next INVOICE unless $thing ~~ /\Q$m->{regex}\E/;
307: }
308: else {
309: warn "Invalid match!";
310: next INVOICE;
311: }
312: }
1.6 andrew 313: return $cust;
314: }
315:
1.8 andrew 316: return fake_customer( $customers, $ticket );
1.6 andrew 317: }
318:
319: sub fake_customer {
1.8 andrew 320: my ( $customers, $ticket ) = @_;
321:
322: # make the custid the first requestor
323: my ($custid) = $ticket->requestors;
324: return unless $custid;
325:
326: my $cust = $config->get('default') || {};
1.12 andrew 327: $cust->{address} = get_user($custid);
1.8 andrew 328:
1.13 ! andrew 329: $cust->{match} = [{
! 330: type => 'requestors',
! 331: regex => $custid,
! 332: }];
! 333:
! 334: my $invoice = make_invoice();
1.8 andrew 335:
336: my $lastinvoice = $state->last_invoice($custid);
337: if ( $lastinvoice->{date} ) {
338: my $last_invoice_date = ymd_to_DateTime( $lastinvoice->{date} );
1.13 ! andrew 339: $invoice->{start} = $last_invoice_date->clone->add( days => 1 );
1.8 andrew 340: }
341:
1.13 ! andrew 342: if ( !( $invoice->{start} && $invoice->{start} < $invoice->{end} ) ) {
! 343: $cust->{invoice} = $invoice;
1.8 andrew 344: }
345:
346: $customers->{$custid} = $cust;
347: return $cust;
348: }
349:
1.9 andrew 350: sub get_user {
1.8 andrew 351: my ($id) = @_;
352:
1.9 andrew 353: state %users;
354: return $users{$id} if $users{$id};
1.8 andrew 355:
356: my %map = (
357: address_one => 'addr1',
358: address_two => 'addr2',
359: email_address => 'email',
1.9 andrew 360: real_name => 'name',
361: name => 'username',
1.8 andrew 362: );
363:
1.12 andrew 364: my $users = RT::Client::REST::User->new( rt => $rt, id => $id );
1.8 andrew 365: $users->retrieve;
366:
1.9 andrew 367: my %user;
1.8 andrew 368: foreach my $m ( keys %{ $users->_attributes } ) {
369: next unless $users->can($m);
370:
371: my $v = $users->$m;
372: next unless $v;
373:
374: $m = $map{$m} if exists $map{$m};
375:
1.9 andrew 376: $user{$m} = $v;
1.8 andrew 377: }
1.6 andrew 378:
1.9 andrew 379: $users{$id} = \%user;
380: return \%user;
1.13 ! andrew 381: }
! 382:
! 383: sub make_invoice {
! 384:
! 385: my $billends
! 386: = DateTime->now->set( hour => 0, minute => 0, second => 0 );
! 387: my %invoice = ( end => $billends->clone->subtract( days => 1, seconds => 1 ) );
! 388:
! 389: return \%invoice;
1.12 andrew 390: }
391:
392: sub make_address {
393: my ($addr) = @_;
394: my @adr;
395:
396: if ( $addr->{organization} ) {
397: push @adr, $addr->{organization};
398: }
399: elsif ( $addr->{name} && !$addr->{attn} ) {
400: push @adr, $addr->{name};
401: }
402:
403: if ( ( $addr->{addr1} || $addr->{addr2} )
404: && $addr->{city}
405: && $addr->{state}
406: && $addr->{zip} )
407: {
408: push @adr, $addr->{attn} if $addr->{attn};
409: push @adr, $addr->{addr1} if $addr->{addr1};
410: push @adr, $addr->{addr2} if $addr->{addr2};
411: push @adr,
412: $addr->{city} . ', ' . $addr->{state} . ' ' . $addr->{zip};
413: }
414: else {
415: push @adr, $addr->{email} if $addr->{email};
416: }
417:
418: return join "\n\n", @adr;
1.6 andrew 419: }
420:
421: sub make_project {
1.7 andrew 422: my ( $ticket, $cust ) = @_;
1.6 andrew 423:
424: my %project = (
425: id => $ticket->id,
426: queue => $ticket->queue,
427: owner => $ticket->owner,
428: title => $ticket->subject,
429: detail => 'Ticket: '
430: . $ticket->id
431: . ' Queue: '
432: . $ticket->queue
433: . ' Requestors: '
434: . join( ', ', $ticket->requestors ),
435: fees => [],
436: expenses => [],
437: );
438:
439: my $txns = $ticket->transactions( type => [qw(Comment Correspond)] );
440: my $txn_i = $txns->get_iterator;
441: while ( my $txn = $txn_i->() ) {
442: next unless $txn->time_taken;
443: next if $state->txn_is_invoiced( $txn->id );
444:
1.7 andrew 445: my $fee = make_fee( $txn, $cust->{rates}, $ticket );
446:
1.6 andrew 447: if ( !( $fee->{rate} && $fee->{count} ) ) {
448: warn "Invalid Fee, no rate or count";
449: next;
450: }
451:
1.7 andrew 452: my $invoice = $cust->{invoice};
453: next
454: if $invoice->{start}
455: && $invoice->{start} > $fee->{date};
1.6 andrew 456: next if $invoice->{end} < $fee->{date};
457:
458: push @{ $project{fees} }, $fee;
459: push @{ $project{transactions} }, $txn->id;
1.4 andrew 460: }
1.6 andrew 461:
462: return \%project;
1.4 andrew 463: }
464:
465: sub make_fee {
1.7 andrew 466: my ( $txn, $rates, $ticket ) = @_;
467:
468: # XXX Only need $ticket for the alternate subject
1.4 andrew 469:
470: my $work_time = sprintf "%.03f", $txn->time_taken / 60;
471: my $work_type = $txn->cf('WorkType');
472:
473: my %fee = (
474: id => $txn->id,
475: contents => $txn->created . ' ('
476: . $txn->id . ')' . "\n\n"
477: . ( $txn->data || $ticket->subject ),
478: count => $work_time,
479: type => $work_type,
1.6 andrew 480: date => ymd_to_DateTime( $txn->created ),
1.7 andrew 481: rate => $rates->{$work_type} || $rates->{default} || 0,
1.4 andrew 482: );
483:
484: if ( $work_type && $work_type ne 'Normal' ) {
485: $fee{detail} = $work_type . ' rate';
486: }
487:
488: return \%fee;
489: }
490:
491: sub hours_for_date {
492: my ( $invoice, $date ) = @_;
493:
494: my $hours = {};
495: if ( $invoice->{hours} ) {
496: foreach my $h ( @{ $invoice->{hours} } ) {
497: next if $h->{start} && $h->{start} > $date;
498: next if $h->{end} < $date;
499:
500: $hours = $h->{hours};
501: last;
502: }
503: }
504: return $hours;
1.1 andrew 505: }
506:
1.6 andrew 507: sub ymd_to_DateTime {
508: my ($ymd) = @_;
1.10 andrew 509: my ( $date, $time ) = split /[\sT]/, $ymd;
1.6 andrew 510: my ( $year, $month, $day ) = split '-', $date;
511: my ( $hour, $minute, $second ) = split ':', $time if $time;
512:
513: return DateTime->new(
514: year => $year,
515: month => $month,
516: day => $day,
517: hour => $hour || 0,
518: minute => $minute || 0,
519: second => $second || 0,
520: );
521: }
522:
1.1 andrew 523: package RTI::Config;
524: use strict;
525: use warnings;
526:
527: use 5.010;
528:
1.5 andrew 529: use YAML::Any qw/ LoadFile Dump Load /;
1.6 andrew 530: use File::Basename;
1.1 andrew 531:
532: sub new {
533: my ( $class, $args ) = @_;
534:
535: my $self = { file => '', };
536: bless $self, $class;
1.5 andrew 537:
1.1 andrew 538: my $file = $args->{file} || $self->_find_config;
539: $self->read_config($file);
540:
541: return $self;
542: }
543:
544: sub _find_config {
545: my ($self) = @_;
546:
547: # XXX This needs to be better
548: foreach my $file (qw/ rt_invoice.conf rt_invoice.cfg .rt_invoicerc /) {
1.6 andrew 549: foreach my $dir ( '.', $ENV{HOME} . '/.rt_invoicing', $ENV{HOME} ) {
1.1 andrew 550: my $path = join '/', $dir, $file;
551: return $path if -e $path;
552: }
553: }
554: return;
555: }
556:
557: sub read_config {
558: my ( $self, $file ) = @_;
559:
560: $file ||= $self->{file};
561: die "$file: no such file\n" unless -e $file;
562:
563: my $c = LoadFile($file) or die "Unable to load $file\n";
564:
1.6 andrew 565: $c->{customers} ||= {};
1.1 andrew 566: if ( $c->{default} ) {
1.6 andrew 567: foreach my $cust ( values %{ $c->{customers} } ) {
1.1 andrew 568: foreach my $k ( keys %{ $c->{default} } ) {
1.5 andrew 569: $cust->{$k} //= Load( Dump( $c->{default}->{$k} ) );
1.1 andrew 570: }
571: }
572: }
573:
574: $self->{_config} = $c;
575: $self->{file} = $file;
576: }
577:
578: sub get {
579: my ( $self, $key ) = @_;
1.6 andrew 580: my $value = Load( Dump( $self->{_config}->{$key} ) );
581: if ( !$value ) {
582: given ($key) {
583: when ('state') {
584: $value = dirname( $self->{file} ) . '/rt_invoice.state'
585: }
586: }
587: }
588: return $value;
1.1 andrew 589: }
590:
591: package RTI::State;
592: use strict;
593: use warnings;
594:
595: use 5.010;
596:
597: use YAML::Any qw/ LoadFile DumpFile /;
598:
1.6 andrew 599: my $file = '';
600:
1.1 andrew 601: sub new {
1.6 andrew 602: my $class;
603: ( $class, $file ) = @_;
604:
605: my $self = { lastinvoice => 0, };
606: if ( -e $file ) {
607: $self = LoadFile($file) or die "Unable to load state: $!";
608: }
1.1 andrew 609:
610: bless $self, $class;
1.6 andrew 611:
612: die "Need to pass filename to new: $!" unless $file;
1.1 andrew 613:
614: return $self;
1.6 andrew 615: }
616:
617: sub last_invoice {
618: my ( $self, $custid ) = @_;
619: state %table;
620: if ( !%table ) {
621: foreach my $date ( sort keys %{ $self->{invoice} } ) {
622: while ( my ( $id, $inv ) = each %{ $self->{invoice}->{$date} } ) {
623: next unless $inv->{custid};
624: $table{ $inv->{custid} } = {
625: id => $id,
626: date => $date,
627: %{$inv},
628: };
629: }
630: }
631: }
632: return $table{$custid} || {};
633: }
634:
635: sub txn_is_invoiced {
636: my ( $self, $txn ) = @_;
637: state %table;
638: if ( !%table ) {
639: foreach my $date ( sort keys %{ $self->{invoice} } ) {
640: foreach my $inv ( values %{ $self->{invoice}->{$date} } ) {
641: foreach my $t ( @{ $inv->{transactions} } ) {
642: $table{$t} = 1;
643: }
644: }
645: }
646: }
647: return $table{$txn};
648: }
649:
650: sub save {
651: my ($self) = @_;
652: DumpFile( $file, {%$self} ) or die "Unable to save state: $!";
1.1 andrew 653: }
FreeBSD-CVSweb <freebsd-cvsweb@FreeBSD.org>