caedes has asked for the wisdom of the Perl Monks concerning the following question:

I really hate having to post this problem here because the problem is so specific to my own application, but I really think I need a new set of eyes to look at this correctly. I have a small non-profit group that has dues based on the amount of time the person is in the group. We want to evenly spread out the finanical needs of the group to all the members so that the group just breaks even. My program calculates the dues on an hourly basis because members can enter or leave the group at any time.

So for instance, let's say the group have to pay for $5 of things per day. If we have 20 members on that day then the total due by each person for that day is 25 cents. Now add to the mix the fact that the funds needed can change from month to month.

So this is what my algorithm does. It uses an array ref of date ranges and funding rates to take account for month to month funding changes.

my $rates = [ ['01/01/2002','01/01/2003',100], ['01/01/2003','01/01/2004',200] ];
This means that the group needs $100 a month for 2002 and $200 a month for 2003.

We then calculate each members total debt by iterating over the different funding periods. We figure out what his total hours used in the first period are. We then figure out the total hours used by other people by iterating over all the other people and finding the intersection of their date-ranges and his date-ranges. We then use these two numbers to calculate his share of the groups debt. Then we go to the next funding period and do the same thing.

If this process seems too complex then just wait untill you see the actual code! It started out pretty clean and easy for me to understand when it didn't require the funding to change from month to month, but now it has reached a point where I can no longer effectively debug it. To tell you the truth I'm almost more interested in someone pointing out a better algorythm or a pre-existing program rather than finding the bug in this code.

The main bug right now is that the debt actually gets smaller as time passes, not larger as it should! I've tested this by overloading time(), however that code isn't included in the module right now. To make simpler I'm mainly interested in what is returned by the get_data() routine. Many of the other routines only have to do with the presentation of that data.

So, without further adue here it is:

package Zephir::Dues; use strict; use Zephir; ## vars for testing## my $setup = 161.29; ############### my $rate = [ ['01/01/1990', '09/01/2003', 105], ['08/01/2003', '10/01/2003', 190], ['10/01/2003', '01/01/2007', 140], ]; sub notify { if(not Zephir::authorize('Administrator')){ return Zephir::boxit("Not Authorized","Error"); } my @users = split(',', $Z::r{usernames}); for(@users){ my %hash = Zephir::userdata($_); $hash{username} = $_; $_ = \%hash; } my $out = Zephir::tmpl( template => 'Dues_notify', users => \@users, ); return Zephir::boxit($out,"Results"); } sub notify_email { if(not Zephir::authorize('Administrator')){ return Zephir::boxit("Not Authorized","Error"); } my @users = split(',',$Z::r{emails}); my $email = Zephir::tmpl(template => 'Dues_email'); my ($subject,$body) = $email =~ /^(.*?)[\n\r\f](.*)$/s; for(@users){ Zephir::email( to=>$_, from=>'myemail@alsdkfj.com', body=>$body, subject=>$subject, ); } print "Location: http://$Z::var{HOSTNAME}$Z::var{Z_URL}?lib=Dues&a +ction=admin\n\n"; return; } sub admin { if(not Zephir::authorize('Administrator')){ return Zephir::boxit("Not Authorized","Error"); } add_membership() if(defined $Z::r{usernames}); add_payment() if(defined $Z::r{payment}); my $info = $Z::var{DB}->selectcol_arrayref('SELECT name FROM dues' +); my %temp; @temp{@$info} = (); delete $temp{''}; @$info = keys %temp; my ($total_payments, $total_debt, $balance, @users); $Z::r{show} ||= 'all'; $Z::r{show_other} = 'all'; $Z::r{show_other} = 'current' if($Z::r{show} eq 'all'); for(@$info){ my $temp = get_data($_); next unless defined $temp; $total_payments += $temp->{total_payments}; $total_debt += $temp->{total_debt}; $balance += $temp->{balance}; next if($Z::r{show} eq 'current' and not defined $temp->{curre +nt}); push @users, ($temp); } @users = sort { $a->{balance} <=> $b->{balance} } @users; my $hash_ref = load_edit_jid() if(defined $Z::r{jid}); $hash_ref = load_edit_pid() if(defined $Z::r{pid}); $hash_ref->{tjoin} = dates($hash_ref->{tjoin}, 'tjoin'); $hash_ref->{tquit} = dates($hash_ref->{tquit}, 'tquit'); my $select_names = Zephir::select_box( name => 'name', 'values' => [keys %temp], selected => $hash_ref->{name} || '', ); my $out = Zephir::tmpl( template => 'Dues_admin', rows => \@users, total_payments => cash($total_payments), total_debt => cash($total_debt), balance => cash($balance), select_names => $select_names, %Z::r, %$hash_ref, ); return $out; return Zephir::template( title => 'WHA dues administration', body_loop => $out, ); } sub dates { my ($date, $name) = @_; my ($mm,$dd,$yy) = split('/', $date || ''); my @m_labels = ('Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','S +ep','Oct','Nov','Dec'); my $month = Zephir::select_box( name => "month_$name", 'values' => [(1..12)], labels => \@m_labels, selected => $mm||'', ); my $day = Zephir::select_box( name => "day_$name", 'values' => [(1..31)], selected => $dd||'', ); my $year = Zephir::select_box( name => "year_$name", 'values' => [(1995..2010)], selected => $yy||'', ); return "$month$day$year"; } sub load_edit_jid { return $Z::var{DB}->selectrow_hashref( 'SELECT * FROM dues WHERE id=?', undef, $Z::r{jid}); } sub load_edit_pid { return $Z::var{DB}->selectrow_hashref( 'SELECT * FROM dues WHERE id=?', undef, $Z::r{pid}); } sub add_membership { if(not Zephir::authorize('Administrator')){ return Zephir::boxit("Not Authorized","Error"); } $Z::r{tjoin} = "$Z::r{month_tjoin}/$Z::r{day_tjoin}/$Z::r{year_tjo +in}"; $Z::r{tquit} = "$Z::r{month_tquit}/$Z::r{day_tquit}/$Z::r{year_tqu +it}"; if(defined $Z::r{delete}){ $Z::var{DB}->do('DELETE FROM dues WHERE id=?', undef, $Z::r{id +}); } elsif($Z::r{id} ne ''){ $Z::var{DB}->do('UPDATE dues SET usernames=?,tjoin=?,tquit=?,s +hares=? WHERE id=? and name=?' ,undef, $Z::r{usernames}, $Z::r{tjoin}, $Z::r{tquit}, $Z:: +r{shares}, $Z::r{id}, $Z::r{name} ) if($Z::r{usernames} ne '' and $Z::r{name} ne '' and $Z:: +r{shares} ne ''); } else{ $Z::var{DB}->do('INSERT INTO dues (usernames,name,tjoin,tquit, +shares) VALUES(?,?,?,?,?)' ,undef, $Z::r{usernames}, $Z::r{name}, $Z::r{tjoin}, $Z::r +{tquit}, $Z::r{shares}, ) if($Z::r{usernames} ne '' and $Z::r{name} ne '' and $Z:: +r{shares} ne ''); } } sub add_payment { if(not Zephir::authorize('Administrator')){ return Zephir::boxit("Not Authorized","Error"); } $Z::r{tjoin} = "$Z::r{month_tjoin}/$Z::r{day_tjoin}/$Z::r{year_tjo +in}"; if(defined $Z::r{delete}){ $Z::var{DB}->do('DELETE FROM dues WHERE id=?', undef, $Z::r{id +}); } elsif($Z::r{id} ne ''){ $Z::var{DB}->do('UPDATE dues SET tjoin=?,payment=? WHERE id=? +and name=?' ,undef, $Z::r{tjoin}, $Z::r{payment}, $Z::r{id}, $Z::r{nam +e} ) if($Z::r{tjoin} ne '' and $Z::r{payment} ne ''); } else{ $Z::var{DB}->do('INSERT INTO dues (name,tjoin,payment) VALUES( +?,?,?)' ,undef, $Z::r{name}, $Z::r{tjoin}, $Z::r{payment}, ) if($Z::r{name} ne '' and $Z::r{tjoin} ne '' and $Z::r{pa +yment} ne ''); } } sub get_data { my ($name,$tmpl) = @_; # usernames is a list like ',username,' or ',user1,user2,user3,' my $info = $Z::var{DB}->selectall_arrayref( 'SELECT usernames,tjoin,tquit,shares,id FROM dues WHERE name=? + AND length(usernames)' ,undef, $name); my $usernames = $info->[0]->[0]; #join is time in this context my $payments = $Z::var{DB}->selectall_arrayref( 'SELECT tjoin,payment,id FROM dues WHERE name=? AND payment>0' ,undef, $name); my $total_debt = 0; my (@values, $total_members, $current); for(@$info){ my (undef, $join, $quit, $shares, $id) = @$_; my ($m1,$d1,$y1) = split('/', $join); my ($m2,$d2,$y2) = split('/', $quit); my $t1 = Date_to_Time($m1,$d1,$y1); my $t2 = Date_to_Time($m2,$d2,$y2); my $ttime = $t2-$t1; if($ttime < 0){ $t2 = time(); $ttime = $t2 - $t1; $quit = $current = "current"; } for(@$rate){ my ($d1, $d2, $rate) = @$_; my $rt1 = Date_to_Time(split('/', $d1)); my $rt2 = Date_to_Time(split('/', $d2)); (my $other_share_hours, $total_members) = total_share_hour +s($t1, $t2); my $hours = intersect($rt1, $rt2, $t1, $t2) / 3600; my $debt = ($hours**2) / $other_share_hours * $shares * $r +ate * 12 / 365.24 / 24; $total_debt += $debt; } $_ = "$shares shares: $join-$quit"; push @values, $id; } $total_debt += $setup / ($total_members || 0.001); $info = Zephir::select_box( name=>'jid','values' => \@values, labe +ls => $info ); my $total_payments = 0; undef @values; for(@$payments){ my ($time, $payment, $id) = @$_; $total_payments += $payment; $payment = cash($payment); $_ = "\$$payment ($time)"; push @values, $id; } $payments = Zephir::select_box( name => 'pid', 'values' => \@value +s, labels => $payments ); my $balance = $total_payments - $total_debt; $name =~ s/ /&nbsp;/gs; my %out = ( usernames => $usernames, name => $name, info => $info, payments => $payments, total_payments => cash($total_payments), total_debt => cash($total_debt), balance => cash($balance), current => $current, ); return \%out; } sub cash { my $cash = shift; $cash = int($cash*100)/100; $cash = sprintf("%1.2f",$cash); return $cash; } sub gtime { my $time = shift; $time = Zephir::ztime($time); $time =~ s/^(.*?) .*$/$1/; return $time } sub main { my ($name); my $un = $Z::a{username} || ')&^#$)*#$^'; # usernames is a list like 'username' or 'user1,user2,user3' my $info = $Z::var{DB}->selectall_arrayref( 'SELECT usernames,name FROM dues WHERE LENGTH(usernames)' ); for(@$info){ if($_->[0] =~ /[,\W]*\Q$un\E[,\W]*/){ $name = $_->[1]; } } if(not defined $name){ return Zephir::boxit("You don't appear to be registered as a W +HA member under this username.","Error"); } $info = get_data($name,"Dues_user"); ($info->{rate}) = total_share_hours(time - 3600*24*30,time); $info->{rate} = $rate->[-1]->[2] * 24 * 30 / $info->{rate}; $info->{rate} = cash($info->{rate}); # use total_share_hours to find rate for last month my $out = Zephir::tmpl(%$info); return Zephir::template( title => 'WHA dues', body_loop => $out, ); } sub intersect { my ($a,$b,$c,$d) = @_; my ($n1,$n2) = ([$a,$b],[$c,$d]); if($a > $c){ my $t = $n1; $n1 = $n2; $n2 = $t; } return (($n1->[1]-$n2->[0])*($n1->[1]>$n2->[0])) - (($n1->[1]-$n2- +>[1])*($n1->[1]>$n2->[1])); } my $tsh; sub total_share_hours { my ($join, $quit) = @_; if(not defined $tsh){ $tsh = $Z::var{DB}->selectall_arrayref( 'SELECT tjoin,tquit,shares,name FROM dues'); for(@$tsh){ my ($j,$q,$s,$n) = @$_; next unless $s; my ($m1,$d1,$y1) = split('/', $j); my ($m2,$d2,$y2) = split('/', $q); $j = Date_to_Time($m1,$d1,$y1); $q = Date_to_Time($m2,$d2,$y2); $q = time if($q < $j); $_ = [$j,$q,$s,$n]; } } my $total = 0; my (%members,$tm); for(@$tsh){ my ($j,$q,$s,$n) = @$_; next unless $s; $tm++ if(not defined $members{$n}); $members{$n} = 1; my $hours = intersect($join,$quit,$j,$q); $total += $s * $hours / 3600; } return ($total, $tm); } sub Date_to_Time { my ($MM, $DD, $YYYY) = @_; my $maxtime = time + 3600*24*365; --$MM; my ($mm,$dd,$yy); my $guess = ($YYYY - 1969 - 1)*3600*24*365*($YYYY > 1969); while($guess < $maxtime){ $guess += 3600*24; ($mm,$dd,$yy) = (localtime($guess))[4,3,5]; $guess += ($MM - $mm - 1)*30*24*3600*($MM > $mm); next unless $mm == $MM; next unless $dd == $DD; $yy += 1900; next unless $yy == $YYYY; last; } return $guess; } 1;

-caedes

Update: I learned about the readmore tag.

Replies are listed 'Best First'.
Re: Membership Dues Application bugs
by Roger (Parson) on Dec 08, 2003 at 07:35 UTC
    I would first calculate the daily/monthly rate for the month that falls in a particular time period, then find out how many users logged on during that month, then distribute the cost evenly to the users. The following is a sample I quickly pulled together to show how to calculate daily rate. To calculate the monthly rate, say, you just need to accumulate the totals for the month.

    use strict; use Date::Calc qw(:all); use Data::Dumper; my $rates = [ ['01/01/1990', '09/01/2003', 105], ['08/01/2003', '10/01/2003', 190], ['10/01/2003', '01/01/2007', 140], ]; my $new_rates = initrates($rates); # print Dumper($new_rates); my %date = ( '07/01/2003', 20, # date, no. user '09/01/2003', 10, '10/01/2003', 20, '12/12/2003', 12); foreach (sort keys %date) { my $rate = getdailyrate($_); my $per_user = sprintf "%.2f", $rate / $date{$_}; print "Daily rate for $_ is $rate, $date{$_} users, every user pay + \$$per_user\n"; } # date in dd/mm/yy sub getdailyrate { my $datestr = shift; my ($y, $m, $d) = getymd($datestr); my $date = Mktime($y, $m, $d, 0, 0, 0); foreach(@$new_rates) { return $_->[3] if $date >= $_->[0] && $date < $_->[1]; } return(0); } sub initrates { my $rates = shift; my @rates; foreach (@$rates) { my ($y1, $m1, $d1) = getymd($_->[0]); my ($y2, $m2, $d2) = getymd($_->[1]); my $total = $_->[2]; my $dd = Delta_Days($y1,$m1,$d1,$y2,$m2,$d2); my $rate = sprintf "%.02f", $total / $dd; # print "$dd days - wanted $total - rate \$$rate per day\n"; my $t1 = Mktime($y1, $m1, $d1, 0, 0, 0); my $t2 = Mktime($y2, $m2, $d2, 0, 0, 0); push @rates, [ $t1, $t2, $total, $rate ]; } return \@rates; } sub getymd { my $datestr = shift; my ($day, $mon, $year) = $datestr =~ m[(\d+)/(\d+)/(\d+)]; # or Pa +rse_Date return ($year, $mon, $day); }
    And the output is -
    Daily rate for 07/01/2003 is 0.02, 20 users, every user pay $0.00 Daily rate for 09/01/2003 is 95.00, 10 users, every user pay $9.50 Daily rate for 10/01/2003 is 0.10, 20 users, every user pay $0.01 Daily rate for 12/12/2003 is 0.10, 12 users, every user pay $0.01
    Note that I have $0.00 in the output, that's due to the rounding with sprintf (for printing only). Remove the sprintf and you will get more accurate daily per person payment amount.

      This just might be the different viewpoint that I was looking for. I think your idea is conceptually simpler. I'm going to study your code snippet and get back to you if I implement it. Thanks.

      -caedes

Re: Membership Dues Application bugs
by ysth (Canon) on Dec 08, 2003 at 01:19 UTC
    Well, that's certainly an argument for commenting your code as you write it.

    DateTime::Set may help some.