From 4d2cc2fd93434602e2b24fffc655c5481a63ade4 Mon Sep 17 00:00:00 2001
From: Jess Robinson
- Swindon Makerspace members pay a monthly fee (concessions available) to access the Makerspace facility 24 hours a day, 7 days a week. Only paid-up members may use the regulated equipment (after a short induction).
+ Swindon Makerspace members pay a monthly fee (concessions available) to access the Makerspace facility 24 hours a day, 7 days a week. Only paid-up members may use the regulated equipment (after a short induction). Donors (Donation Only) are explicitly not members, they do not get a vote or access to the facility.
- Paying Members must be adults (18+), adults may bring supervised children at no further cost.
+ Paying Members/Donors must be adults (18+), adults may bring supervised children at no further cost.
Consult our Privacy Policy for further detail on how your data is used.
Access weekends and Wednesday nights only
Access 24/7 365 days a year
Full access, sponsoring member
Send donations only, not a member of the company',
};
has_field opt_in => field_add_defaults {
@@ -175,7 +203,7 @@ has_field dob => field_add_defaults {
end_date => DateTime->now->clone()->subtract(years => 17)->ymd,
start_date => DateTime->now->clone()->subtract(years => 120)->ymd,
required => 1,
- wrapper_attr => { id => 'field-dob', class => 'payment' },
+ wrapper_attr => { id => 'field-dob', class => 'payment donor_hide' },
tags => { no_errors => 1 },
messages => {
required => 'Please enter your date of birth',
@@ -184,39 +212,19 @@ has_field dob => field_add_defaults {
help_string => 'YYYY-MM, only Year/Month accuracy is required. We use this to ensure that you are old enough to be an adult member, or if you are eligible for senior (65) concessions.',
};
-has_field address => field_add_defaults {
- type => 'TextArea',
- label => 'Address *',
- required => 1,
- rows => 6,
- maxlength => 1024,
- wrapper_attr => { id => 'field-address', },
- tags => { no_errors => 1 },
- messages => {
- required => 'Please enter your full street address',
- },
- help_string => 'As it would appear on an envelope, for the membership register.',
-};
-
-# has_field 'vehicles' => field_add_defaults {
-# type => 'Repeatable',
-# };
-
# has_field 'vehicles.plate_reg' => field_add_defaults {
has_field 'plate_reg' => field_add_defaults {
type => 'Text',
label => 'Car Reg Plate',
required => 0,
maxlength => 7,
- wrapper_attr => { id => 'field-plate-reg', },
+ wrapper_attr => { id => 'field-plate-reg', class => 'donor_hide'},
tags => { no_errors => 1 },
messages => {
required => 'Please enter your car licence plate string (max 7 chars)',
},
- help_string => 'The car park has number plate recognition, enter your plate for us to automatically add you to the system. If not entered you will need to manually check in every visit.',
+ help_string => '(Optional) Car park has no parking rules but this may change (again).',
};
-# has_field 'vehicles.person' => ( type => 'Hidden' );
-# has_field 'vehicles.person_id' => ( type => 'PrimaryKey' );
has_field how_found_us => field_add_defaults {
type => 'Select',
@@ -240,6 +248,7 @@ has_field associated_button => (
type => 'Button',
value => 'Change/Show Associated Accounts',
label => '',
+ wrapper_attr => { class => 'donor_hide' },
element_attr => { style => 'background-color: #eabf83;' },
);
@@ -255,17 +264,17 @@ has_field github_user => field_add_defaults {
help_string => '(Optional) A github username, this will allow us to give you access to our code repositories and wiki.',
};
-has_field telegram_username => field_add_defaults {
- type => 'Text',
- required => 0,
- maxlength => 255,
- wrapper_attr => { id => 'field-telegram-username', class => 'associated associated_hide', style => "display:none"},
- tags => { no_errors => 1 },
- messages => {
- required => 'Please enter a telegram screen name',
- },
- help_string => '(Optional) Your Telegram username, used to tag you by a bot (for inductions etc)',
-};
+# has_field telegram_username => field_add_defaults {
+# type => 'Text',
+# required => 0,
+# maxlength => 255,
+# wrapper_attr => { id => 'field-telegram-username', class => 'associated associated_hide', style => "display:none"},
+# tags => { no_errors => 1 },
+# messages => {
+# required => 'Please enter a telegram screen name',
+# },
+# help_string => '(Optional) Your Telegram username, used to tag you by a bot (for inductions etc)',
+# };
has_field google_id => field_add_defaults {
type => 'Text',
@@ -279,24 +288,11 @@ has_field google_id => field_add_defaults {
help_string => '(Optional) Your google account email address, (even if the same as your usual email address) - this will be used for access to our Google Drive documents.',
};
-# Display 3 tier choices as big buttons?
-has_field tier => field_add_defaults {
- type => 'Select',
- required => 1,
- active_column => 'in_use',
- label_column => 'display_name_and_price',
- widget => 'RadioGroup',
- messages => {
- required => 'Please choose a payment tier',
- },
- wrapper_attr => { id => 'field-tier', class => 'payment' },
- help_string => 'Not resident in Swindon, and a member of a space elsewhere?
Access weekends and Wednesday nights only
Access 24/7 365 days a year
Full access, sponsoring member',
-};
-
has_field payment_button => (
type => 'Button',
value => 'Change/Show More Payment Options',
label => '',
+ wrapper_attr => { class => 'donor_hide' },
element_attr => { style => 'background-color: #eabf83;' },
);
@@ -321,15 +317,6 @@ has_field concessionary_rate_override => field_add_defaults {
help_string => 'Do you qualify for our reduced payment rate? Choose what best matches your situation, please show documentation to a director to prove your status.',
};
-# has_field member_of_other_hackspace => field_add_defaults {
-# type => 'Checkbox',
-# required => 0,
-# wrapper_attr => { id => 'field-member-of-other-hackspace', class => 'payment payment_hide', style => "display:none" },
-# tags => { no_errors => 1},
-# label => 'I am mainly a member of another hackspace/makerspace',
-# help_string => 'Just visiting or only in Swindon part of the year? If you are a member of another hackspace somewhere, you can join us for only £5/month.',
-# };
-
has_field payment_override => field_add_defaults {
type => 'Money',
# currency_symbol => '£',
@@ -348,6 +335,7 @@ has_field door_colour => field_add_defaults {
required => 0,
label => 'Entry Colour',
default => 'green',
+ wrapper_attr => { class => 'donor_hide' },
options => [
{ value => 'green', label => 'Green', attributes => { class => 'door_colour' }, checked => 1 },
{ value => 'white', label => 'White', attributes => { class => 'door_colour' } },
@@ -364,6 +352,7 @@ has_field door_colour => field_add_defaults {
has_field voucher_code => field_add_defaults {
type => 'Text',
required => 0,
+ wrapper_attr => { class => 'donor_hide' },
label => 'Voucher Code',
wrapper_attr => { id => 'field-voucher-code', class => 'payment_hide', style => "display:none" },
help_string => 'If you have a voucher, enter the code, or location you got it, here.',
@@ -374,7 +363,7 @@ has_field membership_guide => field_add_defaults {
type => 'Checkbox',
required => 1,
label => 'I have read and agree to comply with the Membership Guide *',
- wrapper_attr => { id => 'field-membership-guide', },
+ wrapper_attr => { id => 'field-membership-guide', class => 'donor_hide'},
tags => { no_errors => 1 },
messages => {
required => 'Please read and agree to the Membership Guide',
@@ -386,7 +375,7 @@ has_field health_and_safety => field_add_defaults {
type => 'Checkbox',
required => 1,
label => 'I have read and agree to comply with the Health & Safety Policy *',
- wrapper_attr => { id => 'field-health-safety', },
+ wrapper_attr => { id => 'field-health-safety', class => 'donor_hide' },
tags => { no_errors => 1 },
messages => {
required => 'Please read and agree to the Health & Safety Policy',
diff --git a/lib/AccessSystem/Schema/Result/Allowed.pm b/lib/AccessSystem/Schema/Result/Allowed.pm
index fe704dc..7acc781 100644
--- a/lib/AccessSystem/Schema/Result/Allowed.pm
+++ b/lib/AccessSystem/Schema/Result/Allowed.pm
@@ -5,7 +5,7 @@ use warnings;
use base 'DBIx::Class::Core';
-__PACKAGE__->load_components(qw/InflateColumn::DateTime TimeStamp UUIDColumns/);
+__PACKAGE__->load_components(qw/InflateColumn::DateTime TimeStamp UUIDColumns InflateColumn::Boolean/);
__PACKAGE__->table('allowed');
@@ -28,6 +28,7 @@ __PACKAGE__->add_columns(
pending_acceptance => {
data_type => 'boolean',
default_value => 'true',
+ is_boolean => 1,
},
accepted_on => {
data_type => 'datetime',
diff --git a/lib/AccessSystem/Schema/Result/Person.pm b/lib/AccessSystem/Schema/Result/Person.pm
index 9c27c1a..2ee95b7 100644
--- a/lib/AccessSystem/Schema/Result/Person.pm
+++ b/lib/AccessSystem/Schema/Result/Person.pm
@@ -248,6 +248,8 @@ sub update {
sub is_valid {
my ($self, $date) = @_;
+ return 0 if($self->tier->name =~ /donation/i);
+
$date = DateTime->now();
my $dtf = $self->result_source->schema->storage->datetime_parser;
@@ -297,6 +299,10 @@ sub normal_dues {
return 0 if $self->parent;
+ if ($self->tier->name =~ /donation/i) {
+ return 0;
+ }
+
my $dues = $self->tier ? $self->tier->price : 2500;
if($self->tier && $self->tier->concessions_allowed && $self->concessionary_rate) {
@@ -488,13 +494,13 @@ sub create_payment {
# if no valid date yet, but we now make a payment (first ever),
# email member to notify them
+ my $now = DateTime->now;
my $valid_date = $self->valid_until;
- if($valid_date && $valid_date->clone->subtract(days => $OVERLAP_DAYS) > DateTime->now) {
+ if($valid_date && $valid_date->clone->subtract(days => $OVERLAP_DAYS) > $now) {
warn "Member " . $self->bank_ref . " not about to expire.\n";
return 1;
}
- my $now = DateTime->now;
# Figure out what sort of payment this is, if valid_until is
# empty, then its a first payment or renewal payment - use the
# payment date.
@@ -505,15 +511,72 @@ sub create_payment {
# first payment ever
# this is the start of their voucher
if($self->voucher_code) {
- $self->update({ voucher_start => DateTime->now()});
+ $self->update({ voucher_start => $now});
}
}
# work this out after voucher setting cos it changes the dues
- if($self->balance_p < $self->dues) {
+
+ # And so does this bit!
+ # If Donor tier and balance is now enough and only donor-ified after 1 month ago - revert
+ # (one month else they'll keep flipping in/out of donor)
+ if($self->is_donor) {
+ my $old_data = $self->confirmations->find({ token => 'old_tier'});
+ my $when = $dt_parser->parse_datetime($old_data->storage->{changed_on});
+ my $old_tier = $old_data->storage->{tier_id};
+ my $one_month_ago = $now->clone->subtract(months => 1);
+ if($when > $one_month_ago) {
+ if($current_bal >= $schema->resultset('Person')->get_dummy_dues(
+ $old_tier,
+ $self->dob(),
+ $self->concessionary_rate_override(),
+ $old_data->storage->{fees}
+ )) {
+ # Topped up, undo donorification:
+ $self->update({ tier_id => $old_tier });
+ # set payment amount to min tier fee:
+ $self->update({ payment_override => $self->normal_dues });
+ }
+ }
+ }
+
+ if($current_bal < $self->dues) {
+ # Expanded for fees update 2025:
+ # If previously valid, and payment (intention) is less than dues (which have changed!)
+ # and actually expired (mid overlap), convert to donor-tier, fees to 0, store old tier/fees
+ # this should happens before the reminder_email (which is not sent to donors)
+ if($valid_date
+ && $valid_date->clone()->subtract(days => int($OVERLAP_DAYS / 2)) < $now
+ && $current_bal > 0
+ && $self->payment_override < $self->normal_dues
+ && !$self->is_donor) {
+ my $old_tier_id = $self->tier_id;
+ my $old_fees = $self->payment_override;
+ my $min_dues = $self->dues;
+ my $token = 'old_tier'; # findable!
+ $schema->txn_do(sub {
+ my $confirm = $self->confirmations->create({
+ token => $token,
+ storage => {
+ tier_id => $old_tier_id,
+ fees => $old_fees,
+ changed_on => $dt_parser->format_datetime($now),
+ }
+ });
+ $self->update({
+ tier_id => 6,
+ payment_override => 0,
+ });
+ # Send an email (force renew):
+ $self->create_communication('Your Swindon Makerspace membership is now Donor Only', 'move_to_donation_tier', { current_balance => $current_bal, min_dues => $min_dues}, 1);
+ });
+ warn "Member " . $self->bank_ref . " not covered fees, converted to Donor Tier";
+ return;
+ }
+ # Normal fail - intended to pay enough for tier but.. didnt? (Presumably stopped paying)
warn "Member " . $self->bank_ref . " balance not enough for another month.\n";
# Remind when actually expired, 5 days into the overlap
- if($valid_date &&
- $valid_date->clone()->subtract(days => 5) < $now
+ if($valid_date && !$self->is_donor
+ && $valid_date->clone()->subtract(days => 5) < $now
&& $valid_date >= $now->clone()->subtract(months => 1)) {
# has (or is about to) expire
# this will only send once!
@@ -551,6 +614,10 @@ sub create_payment {
my $r_email = $self->communications_rs->find({type => 'reminder_email'});
$r_email->delete if $r_email;
}
+
+ # Donor tier members should not make "payments"
+ return if $self->is_donor;
+
# Only add $OVERLAP extra days if a first or renewal payment - these
# ensure member remains valid if standing order is not an
# exact month due to weekends and bank holidays
@@ -708,6 +775,19 @@ sub create_induction_email {
return ($comm, $confirm);
}
+sub send_membership_fee_warning {
+ my ($self) = @_;
+
+ my $last_payment = $self->last_payment;
+ if ($last_payment && $last_payment->amount_p < $self->dues) {
+ my $last_warning = $self->create_communication('Makerspace Membership Fee changed, please update your payment', 'membership_fees_change', { last_pay => $last_payment });
+ if($last_warning->created_on > DateTime->now->subtract(minutes => 10)) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
sub create_communication {
my ($self, $subject, $type, $tt_vars, $force) = @_;
$force ||= 0;
diff --git a/lib/AccessSystem/Schema/ResultSet/Person.pm b/lib/AccessSystem/Schema/ResultSet/Person.pm
index b3e1e02..d4a10cf 100644
--- a/lib/AccessSystem/Schema/ResultSet/Person.pm
+++ b/lib/AccessSystem/Schema/ResultSet/Person.pm
@@ -148,12 +148,29 @@ sub allowed_to_thing {
}
}
if($deps_ok) {
+ # Jan 2025 - new membrership fees - email if underpaying
+ my $beep = 0;
+ if ($person->send_membership_fee_warning()) {
+ $beep = 1;
+ }
return {
- person => $person,
- thing => $person->allowed->first->tool,
+ person => $person,
+ beep => $beep,
+ message => 'Fees have changed. Check email.',
+ thing => $person->allowed->first->tool,
};
}
} else {
+ if ($person->is_donor) {
+ # Fetch stored old-tier info
+ # force send ?
+ $person->create_communication('Makerspace upgrade Donor Tier to Membership', 'donation_access_denied', {}, 1);
+ return {
+ person => $person,
+ error => "No access for donors",
+ colour => 0x22,
+ };
+ }
return {
error => "Membership expired/unpaid",
colour => 0x22,
diff --git a/root/src/forms/person.tt b/root/src/forms/person.tt
index 848f432..56c4571 100644
--- a/root/src/forms/person.tt
+++ b/root/src/forms/person.tt
@@ -1,19 +1,19 @@
[% WRAPPER wrapper.tt %]
[% IF !parent %]
- Sign up as a Swindon Makerspace member
+ Sign up as a Swindon Makerspace member or donor
[% ELSE %]
Add child for [% parent.name %]
[% END %]
Full details available in the Membership Guide +
Full details of Membership available in the Membership Guide
[% form.render() %] [% END %] diff --git a/root/src/main.js b/root/src/main.js index 5a57361..a07bc12 100644 --- a/root/src/main.js +++ b/root/src/main.js @@ -34,6 +34,11 @@ if (jQuery('#tier\\.3').is(':checked')) { jQuery('.door_colour').prop('disabled', false); } else { + if (jQuery('#tier\\.6').is(':checked')) { + jQuery('.donor_hide').hide(); + } else { + jQuery('.donor_hide').show(); + } jQuery('.door_colour').prop('checked', false); jQuery('#door_colour\\.0').prop('checked', true); jQuery('.door_colour').prop('disabled', true); diff --git a/t/ResultSetPerson.t b/t/ResultSetPerson.t index 069941c..198b5ce 100644 --- a/t/ResultSetPerson.t +++ b/t/ResultSetPerson.t @@ -4,6 +4,7 @@ use strict; use warnings; use Test::More; +use Test::Exception; use DateTime; use AccessSystem::Schema; @@ -11,49 +12,225 @@ use AccessSystem::Schema; use lib 't/lib'; use AccessSystem::Fixtures; +my $overlap_days = 14; +$ENV{CATALYST_HOME} = '.'; + my $testdb="test_db.db"; unlink $testdb if(-e $testdb); my $schema = AccessSystem::Schema->connect("dbi:SQLite:$testdb"); -$schema->deploy(); -# my $fixtures = AccessSystem::Fixtures->new( { schema => $schema } ); -# $fixtures->load('standard_member'); - -# tiers: -AccessSystem::Fixtures::create_tiers($schema); - -# Fobs + acccess -my $test9 = AccessSystem::Fixtures::create_person($schema); - -my $no_token = $schema->resultset('Person')->allowed_to_thing('12345678', 'blahblahblah'); -like($no_token->{error}, qr/not recognised/, 'No such member with that token'); -$test9->create_related('tokens', { id => '12345678', type => 'test token' }); - -my $no_thing = $schema->resultset('Person')->allowed_to_thing('12345678', 'blahblahblah'); -like($no_thing->{error}, qr/not recognised/, 'No such missing thing'); - -my $thing = $schema->resultset('Tool')->create({ name => 'test thing', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); - -my $no_allowed = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); -like($no_allowed->{error}, qr/not inducted/, 'Person cannot use the thing'); - -$test9->create_related('allowed', { tool => $thing, is_admin => 0 }); - -my $no_pay = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); -like($no_pay->{error}, qr/Pay up please/, 'Member hasnt paid'); - -$test9->create_related('payments', { paid_on_date => DateTime->now, expires_on_date => DateTime->now->add(months => 1, days => 14), amount_p => $test9->dues }); - -my $no_confirm = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); -like($no_confirm->{error}, qr/Induction not confirmed/, 'Member hasnt confirmed induction'); - -my $allowed = $test9->allowed->find({tool_id => $thing->id }); -$allowed->update({ pending_acceptance => 'false' }); -my $good = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); -ok(!$good->{error}, 'Allowed to use thing now'); +{ + $| = 1; + + $schema->deploy(); + # my $fixtures = AccessSystem::Fixtures->new( { schema => $schema } ); + # $fixtures->load('standard_member'); + + # tiers: + AccessSystem::Fixtures::create_tiers($schema); + + ## Fobs + acccess, verify normal access with person, token, tool + induction + my $test9 = AccessSystem::Fixtures::create_person($schema); + + my $no_token = $schema->resultset('Person')->allowed_to_thing('12345678', 'blahblahblah'); + like($no_token->{error}, qr/not recognised/, 'No such member with that token'); + $test9->create_related('tokens', { id => '12345678', type => 'test token' }); + + my $no_thing = $schema->resultset('Person')->allowed_to_thing('12345678', 'blahblahblah'); + like($no_thing->{error}, qr/not recognised/, 'No such missing thing'); + + my $thing = $schema->resultset('Tool')->create({ name => 'test thing', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); + + my $no_allowed = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); + like($no_allowed->{error}, qr{accepted/inducted}, 'Person cannot use the thing'); + + $test9->create_related('allowed', { tool => $thing, is_admin => 0 }); + + my $no_pay = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); + like($no_pay->{error}, qr/Pay up please/, 'Member hasnt paid'); + + $test9->create_related('payments', { paid_on_date => DateTime->now, expires_on_date => DateTime->now->add(months => 1, days => 14), amount_p => $test9->dues }); + + my $no_confirm = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); + like($no_confirm->{error}, qr/Induction not confirmed/, 'Member hasnt confirmed induction'); + + my $allowed = $test9->allowed->find({tool_id => $thing->id }); + $allowed->update({ pending_acceptance => 0 }); + + my $good = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); + ok(!$good->{error}, 'Allowed to use thing now'); + + unlink($testdb); +} + +{ + $| = 1; + + ## Test fees - Person, with tier, with matching payment override, create transaction, create payment/fees + + # Deploy fresh DB: + $schema->deploy(); + + # tiers: + AccessSystem::Fixtures::create_tiers($schema); + + ## sends correct emails? + # standard tier (3), cost 2500 + # "thing" to test access against + my $payment_amount = 2500; + my $comms_count = 0; + my $testee = AccessSystem::Fixtures::create_person($schema, payment => $payment_amount); + $testee->create_related('tokens', { id => '12345678', type => 'test token' }); + # The Door so that Result::Person::update_door_access works + my $thing = $schema->resultset('Tool')->create({ name => 'The Door', assigned_ip => '10.0.0.1', requires_induction => 1, team => 'Who knows' }); + my $allowed = $testee->create_related('allowed', { tool => $thing, is_admin => 0}); + $allowed->discard_changes(); + $allowed->update({ pending_acceptance => 0 }); + $allowed->discard_changes(); + + # simulate (under)payment by creating a transaction for payment amount + my $now = DateTime->now(); + $testee->import_transaction( + { + dtposted => $now->clone->subtract(hours => 5), + trnamt => ($payment_amount - 500) / 100, + }); + is($testee->balance_p, $payment_amount - 500, 'Imported underpay transaction'); + # (no emails, does not create a payment) + lives_ok(sub { $testee->create_payment($overlap_days); }, 'Attempted create_payment without dying'); + is($testee->balance_p, $payment_amount - 500, 'Balance remains the same'); + is($testee->communications_rs->count, $comms_count, 'No communications yet'); + + # simulate corrected payment by adding a transaction that makes it up to correct amount + $testee->import_transaction( + { + dtposted => $now->clone->subtract(hours => 4), + trnamt => 500 / 100, + }); + lives_ok( sub { $testee->create_payment($overlap_days); }, '2nd create payment, should actually create one without dying'); + is($testee->balance_p, 0, 'Balance now empty'); + + my $good = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); + ok(!$good->{error}, 'Can access Door'); + + # (has started email, creates a payment) + is($testee->payments_rs->count, 1, 'Has a payment'); + is($testee->communications_rs->count, ++$comms_count, 'Sent a communication'); + my $comms = $testee->communications_rs->find({'type' => 'first_payment'}); + ok($comms, 'Created a first_payment email'); + + # change valid_date so that its over 5 days ago (attempt to + # resolve payments will cause a membership reminder) + my $existing_payment = $testee->payments_rs->first; + $existing_payment->update({ expires_on_date => $now->clone->subtract(days => 6)}); + # attempt to pay - should send reminder_email + # sleep cos the timestamp is part of the primary key (seconds resolution!) + sleep 1; + lives_ok( sub { $testee->create_payment($overlap_days); }, 'Run create payment with an expir(ing) member without dying'); + is($testee->communications_rs->count, ++$comms_count, 'Sent a communication'); + $comms = $testee->communications_rs->find({'type' => 'reminder_email'}); + ok($comms, 'Created a reminder_email email'); + + # simulate full transaction, attempt to pay, should send 'rejoined' email and remove 'reminder_email' + $testee->import_transaction( + { + dtposted => $now->clone->subtract(hours => 3), + trnamt => 2500 / 100, + }); + sleep 1; + lives_ok( sub { $testee->create_payment($overlap_days); }, 'Run create payment with normal payment without dying'); + is($testee->communications_rs->count, $comms_count, 'Same communication count (deleted one)'); + $comms = $testee->communications_rs->find({'type' => 'rejoin_payment'}); + ok($comms, 'Created a rejoin_payment email'); + + ## Tier fees change, test what happens! + # update testee to have a 20% higher fee: + # store current tier id: + my $old_tier_id = $testee->tier_id; + my $tier_fee = $testee->tier->price; + $testee->tier->update({ price => $tier_fee * 1.2 }); + ok($testee->tier->price > $tier_fee, 'Tier is now a higher fee'); + $payment_amount = $testee->payment_override; + diag("Payment Amount: $payment_amount"); + ok($payment_amount < $testee->tier->price, 'Member thinks they are paying less, still'); + ok($testee->dues > $payment_amount, 'System disagrees (lower payment_override ignored)'); + is($testee->balance_p, 0, 'Should start at 0 to do this test'); + + # reset expiry dates + $existing_payment = $testee->last_payment; + # Expires in 10 days, should add new payment if available + # NB If this gets to 2 days it will also send a reminder_email? + # but not change to donor yet as not entirely expired: + $existing_payment->update({ expires_on_date => $now->clone->add(days => 10) }); + # try to access Door - should send email! (last payment was less than dues) + # sleep - comms emails also have timestamps in the PK + sleep 1; + $good = $schema->resultset('Person')->allowed_to_thing('12345678', $thing->id); + ok(!$good->{error}, 'No Door error'); + is($testee->communications_rs->count, ++$comms_count, 'Sent a communication'); + $comms = $testee->communications_rs->find({'type' => 'membership_fees_change'}); + ok($comms, 'Sent a membership_fees_change email'); + + # pay the old amount: + $testee->import_transaction( + { + dtposted => $now->clone->subtract(hours => 2), + trnamt => $payment_amount / 100, + }); + # No emails.. normal "under balance" status until actually expired + # Aka access allowed for whole of final month.. + my $payment_count = $testee->payments->count; + sleep 1; + lives_ok( sub { $testee->create_payment($overlap_days); }, 'Run create payment with old fees without dying'); + is($testee->balance_p, $payment_amount, 'Balance unused'); + is($testee->payments->count, $payment_count, 'Not made a new payment'); + is($testee->communications_rs->count, $comms_count, 'No new communication'); + $testee->discard_changes(); + is($testee->tier_id, $old_tier_id, 'Tier not changed'); + + # Now "expire" the last payment: + $existing_payment->update({ expires_on_date => $now->clone->subtract(days => 1) }); + # and attempt to make a new payment with the lower balance: + sleep 1; + lives_ok( sub { $testee->create_payment($overlap_days); }, 'Run create payment with old payment, expired member, without dying'); + # still no payment: + is($testee->balance_p, $payment_amount, 'Balance unused'); + is($testee->payments->count, $payment_count, 'Not made a new payment'); + + # and a new email: + is($testee->communications_rs->count, ++$comms_count, 'Sent a communication'); + $comms = $testee->communications_rs->find({'type' => 'move_to_donation_tier'}); + ok($comms, 'Sent a move_to_donation_tier email'); + # changed tier: + $testee->discard_changes(); + isnt($testee->tier_id, $old_tier_id, 'Changed tier id'); + # should be in confirmations: + my $conf = $testee->confirmations->find({ token => 'old_tier' }); + ok($conf, 'Stored old tier settings'); + + # top up missing amount + # old tier price: + my $old_tier = $schema->resultset('Tier')->find({ id => $old_tier_id }); + $testee->import_transaction( + { + dtposted => $now->clone->subtract(hours => 1), + trnamt => ($old_tier->price - $payment_amount) / 100, + }); + + # retry payment, should revert tier!? + sleep 1; + lives_ok( sub { $testee->create_payment($overlap_days); }, 'Run create payment with new fees without dying'); + is($testee->balance_p, 0, 'Balance empty'); + is($testee->payments->count, $payment_count+1, 'Made a new fees payment'); + is($testee->tier_id, $old_tier_id, 'Reset tier id'); + ok($testee->payment_override >= $testee->tier->price, 'Corrected payment_override'); + + # Bob's my uncle? + + unlink($testdb); +} -unlink($testdb); done_testing; From fa7e5327ccafa412b079f526ea94703a3a7ca2ce Mon Sep 17 00:00:00 2001 From: Jess Robinson~;I`TkTR)o)CpNZj`1 zLBAw;N)YI%%eWDdN%{s7XKQjS=_i2GCJZovwQG*`QItvgZ`vO8A9gUtCZ*t>7Sz26 zMGTn#!zlcN9fL}U(`Jz%s1IOg`5Q|n9hV3a4>pg#O z2mUmPg5p}BBZ#~kb8#lkp`fl>pV0n5piOlN F=fEy(l~$>RkF^SQg!tDd&%VK5)sz;~j`X=_an zUwue(P5xYn^ro7hd7()0DmCkO%oFTfKm{6p4F%@8+0#b|!yauRmAtu@3f=KhQKc&N zN)7))ML6m23yO{c%>ZW=IEA;_C(3;UDm|U*4Nz9_gWpi02x4`VWGK;ztd$mJZgMco z0n?K!u m=Qa;6r5I0;PYuaNjyNIh63q)yP=@@ znWEWoK0^Pu&bvkiwT}xGrZ>*sfYs?LkqXWN8RUT7jz__lw+#xR!`Zx%B9V{K2}6iZ z7{)3^WWm}q; 2O-%D28C>rA0 z?uEd19!P|vM}#0q%~hI_X#k`{f8RNv6#7#hTVg!0>N5Qf)2dD6amQb+ICc!~(3M pyp^gh5k!KJ=7|>I>??Xo{&bI1TE@d)?BJgZb$X0Un`6B<^ zuFFD%Byh8;;{ew4TvDamI)(Dc>0(LEV~KVY4MRjld$&0zJFs;dGbj+`rHs3;=9K5} z3G$k>c>X%e`>+N+6A6v~f>aI0m0R24s_J+m+A8JzAlfw08}wDCr(v o1JGM2qoZI~cJuR67S|oS}E|FPzV;jNW=@E&gdG zIS=dU0G)xd(cwffTRJh6g-7R;&jF95gv5Ly1#~;eay5uQQUyb9##ZT2HwH_qF2kF_ zuQ=W*lw}?G`j(PWeGducXY5@iUi*7NNB-8Mo7>~sUVPqOov$H*>o1uPB+83}tCU9z z)>Y?g$U#? 2xf5=?s2n z$ceAK)LBl5y)UmfMHHrLuCOZ~PL~W`r{oGoK&G6x8GIw?ct5;krJwd0iS0Qr8I}&G zH0e~E@)^U#x4xmU6&A@OP72i`eSW=R?Sc=#HKlFK3-s#|9l|;^-hAH@5z@>CH|yP` zAw6AfpUutxG23W7)q#Ge#OKMaX2ATeKM>^ZIGNYiKCB)4(*W{PF?6TEU4lXFfjQgm zO;FrF!nt3@4wFOw0}P{@M=pL(&+pRYns3P`u19K*#iFfGqg>rQOVIm@>sOqDJ0^#E zEmB_4y$(OccQJqWmt2b;sC9^HO^rT`?fRtir2U@bwD KW z1Wowifo@SkW#E^#v@-Jv1E~>l*9;<`f;ey@zCl_i<>tU*;|GuTVu+XLH%-=IdC~#% z@ ddV)T9w+$8Nd z70fp|K$}Dk_QDt*=hyyU2yjIYw2CMA=<{3m|84N4EM&WRhY|vd`4`_sli%#sej$Gm za;XUrbIW9|)m`1e9d2BScI6nT{`>?LTg)Xaw@X5%aYw RhiZ}Pr z-m2nC*6Ao!yiq$zKcQuYXbX&Is?d1*m3XaW9SbeCXbbXIBal 6|y5>W5?d;#xG4+(m z#C}ZD*2T (_;`zh_;Mj6*q_yDv zlfCF%16DXk;nU&xJ1)C=99h{ymlRaqPn7<+@1<77p-4C{t$-T@vWWj!S^cRMq%z>$ zhi*rR&lb8Q0C!E1pU00kWgS@6wwEWl>(fE0+wT3C5z#zPg1eK7lPX}Z1gV!9>dY|y zG&kIR9dHUI%kDOfm4wTpP6yD=AT0Ujo)t=z*Ak08X}$}BTWkQ|36_nz(~x(tJ|8m5 zX@5KFM<`W%r?CHgqx`#e<8^g)*_c^E!zcWRh!+Wn*iimbyv-SkZ401t3mbOyOMfS6 z?b;@2x;~9_0nc5?Q{I9^hG}2TQC}Hzlnvie`=vuQu7_(xPYx_YqXp3lkpn5h)+B}3 z{r#dN1^VOHpb}}jP8U*bHy%XwBt>R U7YDD=_LP|{=SeiD zznzGaRk!<4Z4PaoaLmc>_+Gf{>Lhxs27ScDFF-XF#x!OncLONIts8Uf?q(%8f5@yE z>kzl>`t!tNvNgxAy=t;Xjnh|fu4mJV0gpaC;oG9i@4HPLwq>Y{*h=;ceYr^7NFCnC zwQ$sO9A_I-lldO!JUrLAZ+nNFANgr%8^A5CG*&X$$33V8#eaO%_B!e Qz&m+h^Y{T3@??x=bJ7v 0I__#m79YqfWl z cQpLflOLXfYZb??%a#nRjIZM)ArF9dVxtPVgequVODCpO7 z8{CGei`&C+ddfC0qIqx{c0INN??>jV&aT#ekORk3R#R=w;vDp c2noM{`K}nN% z9%3mV6K?2rh1 37%L`d9KQr0lE5O^s|T%u6X3Bi;!UW@X*T?N$)30F~0d zb|qeHG&(}1>;`qi*EX#iL&095gfgJ1J?0bHH5s$$Z#JiKw4P!UaZLq)bK4CC77_cC z!%$l-&_B_Gkg&d>yXW0*6(UR7HH`IB_7qo}H+jptwZ|LlrrX=n&z_MjlP{6S#q)g{ zyn&6@S?P|Pv;LDS^@??FFZeHD!~G7-)MLXX*8tbNV}X-|zp$TN^XwZ>wGj8%2V)HP z^($}YjqxL? XRk%f*ujT}w8R|no*=)sW^{>-sc9`;J3%Rk$TUB#?k z8GAqQ9aoO`P4m?Ud<9?LsaX?jl*u?E#?LJF5LcX@D;C}79E#t7g4;LlN|%ja=&zt& zg=^YtYZ3b@)Q>8MgF}}qReXpufgByPPkZL{kp6QScPz(9*B~|u8-uq+#{1Dd20u7r zp6+WRxN775%Vqh&vx7oez#A_^5;l=3cjVBMA`F0g#x;)*ufe7x{8qiax2S0p*=V&x z41PyZ%gNJb0a*ogj0jffGV>G`6HV^m{BO*Xk7A*OX)tw-WQ$4RD>XA!X|>dOXXTN& z?(j|)%kS?M_6CGD!4AC*$CU~GOJnduzyhIRXhAbYn3yH_O+^2ar1MSC zn|H3^wgpEpDxR@_dWQbxNSxMG^j1!- 9WnJ);Z!_ z9LTq~&2V!#h57;f{+eK!0&O~an8waBIDf}u!%Bx}JKpX3rgR<=rQCivSDQZO;tox* zu!2pNS5x2}Dnc_{_Nf6VPs~?RvY^f}4-mblsI@xPJ0!8&T9anOTO1u*l!{J}^b)A2 z6$E?de;wNdhoo}rEs<`jEjI54m-rqQ@aN^qaSA2)@84hhI0eB-WYNB%_!mE|S~tg0 zZ6r# z#(@fL=hIu5(P2&7FM*OHGF92v%-&5C1c87ztzZ~N 3=wkS7F=-9lc?3`UETRpQ(ey*(fji|8m=6M+>n301#YloU%h^) z{xVifqu5GYWUMV#uu3J`g&B(a6JBu(y)o(rBGm#JU>Nve4!7mrs076Bj)1KjkCa;H zM>LqhB4HQm4@3gNJMar`MomsP&^w{@!-82#hgy^#7w_O?@&aP=QU%L6f}9gm725k# ziaiQ%H+ds0yGYcl>jGdEnnZjLyLxBqoW~RBG&y-eN`nx=q)2&jIe8(1#87NBz2iI> z90^saq1UMm${~8UX880QQ?je4E}ZXY*TEOC6D> z`iN5xF`4K{yS1?oMBNngp2NmOVj-r+It!M46fiu$>aY8AL-4+^<@J`3fjP*o=_6vu z)%~|kkUg-6`A5jvOmVzND-TbrzJcP;U|d=xuTJEip8ZaF^a&m8RxN|rhQXc8p$n() z4E=RiyF9c=Wg)9?AObO--vy411@3#BOjI!W)BB2V5LtmA C$J$bk>Pa6U`nh$ECKF>?NK8rE$>U?Na z53CH_@_(ZE?(MduI?(s@5D<2Ec_*8wdLu8v=D)-BCJ_pAuX3dFQM#i{!WgBP3GVLF zuWNAi5^$k6)%Deaq0g5{$|&g8T2UUIj<1uU!iOQcsJz1$t8_pYri`O96mh1aI)I%_ z4Es%WHI1H%=EtBqj5O*q0B5(3F}#c)29@lqgyrdr$a2}RggiP=m4J}<>zx+^+1KLW zT6(&e#NIBB7zRy^j>I@wr&NEo3b9RaL<1hOSV)-07{{G6!8C6G%i+5tCrlFcBYg1U zE-VtVOIkRh*jYW!39U7(SR{Pq$Q!9yvXCSU85W!ZbS!>hpE?sa(XpJCwCu|1pQIF` z38g4zIrLWHS|rGdf-cOWU5rA|a4sSYF=nAj^xL^(#V} w)9 xI4r_tF&e}%Yc z`Qa`j4bRN{gVGlQ+E3NbM@*ZK{ppJfXmaR$UR6Qj6O%h6IFhv`%wfFo2xObDQREyl z=8TB#Jvl>a=W?d8cE^{eL6*Pe(0O_-9OG*kKEGMu%9vL^VJIr*_ErCLf !^K}*u^K#?CJNye5o;RRLw}qDKeD-GG-}-heCl5K znbme;E{Hoy;`@gT+~9Rr (H 728Gxwt=KXZdt9mR;UO10rUnm0CAM7mMdbTYcDDMCs z9m=c-*yJs!GT_M<)8#P@^w9S {y;v+hI{fvmkK)oJlyt?W)~lx0 zlVY#a{(UD&WM`v?C>AAp7d=~{UPxP>f+*AEEl&Vnbp~^8lVp)OYD%9o4ruwKo@!eM z{Tn&82kGm1$uGxyEg=`IGp!^-H=`Cjkx$V0nFi)ejUFUvG{E7B-L${0Tn3JY95VlS zEREAlG!~MxNc6S!3fn+&S-T(AcEJ*c8z KEW^f>qfsg=0t_NA!Ea{eR2eB 6Dtd54pN6xD+vd0${8vD_1N>GZJE-ytiGE2r|hB6oNDsH#mz8J z@>+Sagg(v2Q <(W0?Dn-{hTF^s@x_e3iZWn#%gJ&96eJ)z>6%_>|B6GQh#j)JWu2eN^ ztS`)K8@DiOJF2`U6^N#-2=}2CH%X_eW8)vbk>(0`c@Tg43!4`+jAsUq{JcNtO?@RI z4B~(4F2;&<_2&D2Jb{IzGY)i^q}XCb3kC9x))&No(lI)Dfzz#@UI086)*`f$2QIhh zMO+;2r>TKtopgI{7nlQDXWQpTVUUjC%6D$%) i)? at^!{YMQ~A2KiKixv z%wRZDOMCk{L9AL!7hy$MUPD?HmQuW^O6Q?LVa1`;FXp#5Z|F#q?wNA-N;^C+cdBHQ zLrfnqkG7|k+Qp>fC6OB4!dYF3tXA5G8Zn%jQnW3*vbwZCbXoH&Aj=s6pO~p=25kBa zFDJ||rs{Hmr <#T0{-3%c+G{pu;LdA zs%joab!lxsd^rBJwz!2ERjrHwx270$Ua)V<>{i6;+pi6PS>-MUnhV?mlB`;CG7f6( z#CZe!H6E$>Haz=sLyYLQpA5u7O~#^3S~k_(hCa*^yAO0ji&h=P5gf9(cqc)0^jj}Y zpq>r~#3OLTt_|jHN};2-Xp|jKh%{g~-SxKFqkSK{_Uc5eiSisQL8Q`S;h1a9^Xtnq zu8~G(@yi}({ablR_MJG WfM(3C%XOMmH@ z&qK` sTu|lzE{9RN>|cNdWseG+?m-~2ww&Z zTG6LUcGWle^a$M&Cn>G*CM 1V+ya}K z4d63 Mc5<^(++UZ|aXx#afAfUlvJZ|2VSMOWAVboxX=l zC>-}@61$^2beq{Z#h8=OR{yD)ruc!p)_<^bfmP^Dt)m+F6a$EE&%{cfI_$w%clsCc zHukYx^ysBo`p6h@$~>6F=)9dP6D3RDBvlRvzjW@gSpV1QH}gxNO=%#z2uF6-DPKG9 zfyze>p;;_` LT+@ z2RRpJq%Qu!H@3MzGM1be6A1*oQGoM%I{LoL{nUNw43@=??ju$MTz{DB0b_T1VgUnm z0RvP4qqAsp5eHbqkU(fAcU^CfjzK*b-%|w|O@x1cY`4Ip&LL5&gRj(U0X{p*?xNBF zt*Ul-yxHp`YekHgD>g-{I|i^6fs56nEImpdwL5TDEkF!83HOUH3e_%|payGkz72i8 zmDeVo1UL^3YeMmFFm2rcsa`9>ZvSj^6#>4E_pjp} NVl=y)T2d~b zAZR_yYS0|KT 0) then (check name) + if (Name contains sm) then (find member) + if (Numerical id is a Person id) then (import) + if (Already imported - \nmatch on date/amount/person?) then (no) + :Insert new transaction into DB; + else (yes) + stop + endif + else (no) + stop + endif + else (no) + stop + endif + else (no) + stop + endif +endwhile +stop +@enduml diff --git a/docs/diagrams/extend_membership_activity.png b/docs/diagrams/extend_membership_activity.png new file mode 100644 index 0000000000000000000000000000000000000000..a55fe4434b16f5ebaf8285451b9086a307cf30d8 GIT binary patch literal 48392 zcma&NWl&trqArZP1r5R7T@&0L65J)Y1$PD~xVsKc0>K@EI|LitgAPt`hdbomd)Geq z)K~ZWL9y2K>h9@PJ>8FW#7AWrbd+}}P*70la ^`yu7KYsjaQ8ySsaMczAAZZgq8acX#*n^z`iP?B(SJenXE1 z(ifwvl(ws>gQJ(7xrHl~jQJOHXA@U*GxE<~ GgURkSayZ-xgC}>DKp4lmypInysvHWhT7EJDqD`_O0m1uc9G7ua=hoYIt)+fbW zBqnUyT6*WQoP96Pn)tK>qD$w$yU$B!n}F^j`-QxU8I%e_Pr*^y45{6+k?BONQuLb{ z3~C|NJ1|03J>G{Q)A9aM2qo!bpSs0|(FS@e5uv)#2{ 7o=o2Xcw;VRf(Dei4u;7G=JTG<_eAD&UajItw1X#P+K50cAhn2k%p9?_W zmBareu1`g{707-<@QeQYEP{^ta``PGvUnvTn>KYQK3i3LxhzVrR3J|Y^1{nI?K7L0 zU1oDiN-4L5K OzZK0u$>1?_2OwaKJVAI#VU#&q*dB+xEJ=0 zF&16pU9K+Va4UPK#ZJ>EWX@S;Fo!Qkg>$fKCw#%CE2vTjCZIIAeUhOqF}Fa}-xjkS z&&;A *!~jht=w3;T3#oL zSN1eqREr_0;bv6L%ILaMF6n5HWWZYc5_QMIowWQOd5PB3Tq{uI_hmHcQxc}kHlQoK zSnAzVbXSIHUfieK*?OXAjQP%ZJwh2X6ub%=jYX8KQe(HiqpJDb53E*Fs-c|cB%3*0 z-VAFzY*ei1G9kGq@)A ++wsA?x---16Gi2lF%@Mc~yuPL)I@-f)si;NT4-OqGIRscRAz=2U zS-uSbT#%I-q=#OShkWi&!EN+upHNXl3{nC8a<*pt)Bms*Po!)3hQ%|}duwbM2c`Au zBRt}ttFsc1eWi45d^ lrM-#eKqmb zNLaFDa3I9MOEGc)7E%!=%zm)wJ$bw^k|Z7qes|}_ogY$Sr3X@~6dqg-{);#}>P4CN zLigr3(T;A;TjEjcL^4%2$>+Rd7c8IM6brj(nxM-?vZ d0$GY^U09+3odv`%8krhomH(nT5>>rq9_^vgGCQl)j+o7(0 zjvxnMY{tb-_s0tlO8A`rgrX~_1jL?nls?@URcT{beV~F2@ZV$o_Y1Hl++>kKDw@ds zb8&@F$_f&@Cq^^1yO_9pYhWk~>>ZW8cO>+|=N}{7cYz}{-Yhwi6M3VNH|mOMrOFu? zs_2->H77inH{ow>zzk`H!?-U2rU(-RZdT=lQpXuZ*>@pEfO?}TEDJ1*u@vnk+`oX| zB4i>vYIgY*Co4fAEJ(PIQ>3z7&3~XU8$-q4bkvY7GC%Lh607&c1 Gp{3*Fi|Z~j2vM)H5Y@9 w $uHqmS(YQ ^C YLVf15ByJT`Z}Kyp9e%ttx9rNCW48ofX4+BwdxP_3XMBSO*7QU zzh)1RrBHq@)9C3We5JL$jziMl5p$06xzIEr%S+P~y%@yH3exhB+nHHvwcY#-G}2o4 zyvm*>u%D#C!VXMLw~&ttf@x=X_;N8>@VyYL>@e^rjQrwnhhH!SWQj zUp5vj zO0Fk;yBHFlE;+13YWNy0- *-s`GCrQw-kC(aGSPH0^^xgDWq-&Z@bw3AkOC3Ch+Yp0%m zS;P|70IfWf`D&?d?ygn8gy1)3!#xY@7E@NWll${W%Y|Laj>i4Zmb)s;mI6krX0vtY zbml5;3K#b!BftcanUkiUzEzI-$`c=GQ+W6(r}B=?y<$e;5G@N;4g;f--~8X}m5Wzi z2|NglDyoE9G(ixMi7yMRN`7ObOAhFOnyFuahy1~Qvkq*S(E3}(2F1e?FKom>GgB{m z?+Xnnxx7m}i~d{f?S%H7{-;5q^TMQEkK~emy2atIk(b|@^o?efkBlc(+N#8emz>G= z)nq0!I0U^)=+^KgQe*ZC%kz)b1p4a4sN6+Aed(bAmAbpd@mP&0xXr+WN-fgSGcS}g zH^~79<~&47-ZWH^3Bqnc>pybVy;(t}(&6uTv&91_0XFJ)+!bs;1Z08lW`&iAhEzWn zVQ$J#eZFr-Gh}B73r(upeP=gp;#0|7B5<7`Jl1Drz1&EplaRM*% zh9|-`?jA5bQjXw~CGXUaU#UvDcUaKu1q8)ecixPAs$yxvE}!@YS!O?lyAC<(ctNo? z3f%8f>@Shd;#td#-pPfx>g>cb>YajX3)JmZC`ak2X6v9jf2(gF-@sbw3Tq?0hTSe} zcs~)UH~B(j=7cUR@C(O8*FhQ4CVNG0myJ>NHVK-4r2@F)oux^qETWVO?lln&dlA0V z&@fwNN`$;obb;cy0Xxiy(~4io)P0;&)K8NXdM)E-sUUx+%Un$y?x?IqZpvu1=N^(c z>IChg$%rb+0Y)us)?P`rQU(!OcMY?4dB_ClJl?A4kkqC3z@us}rZ57Gz5_8c7jfej zE^<2@`80xWp_?e1Lq zHV{%=wBTugKC|p>UqEVL=}W-?0xz$Fm-SDa7uTM|2YpzqXZBc1m*y@wgT4w?uZ!K! zAUZGn0I#Y !5MJc^3P zJuXw4rJ=qD@83jGkXLqJzXFzj)`EWkOOeV}fNHZWkPWKXF+tcUfCInY=_3lkzKbMb z0z 6D$ns%-LHl8bG?sRnp^AS*#C?9vQAjc77oL36srx7D{5FqX z`tyY-k=AK!u&s|RS=zDiZ&y`yGn!vYgLq>%gT%+|S!BYUhQp?8LuJdc;$Q5Kx8%z9 zIL<7huQ%g5h!76|ztI5`R85S((C6@8{-j)H0exnO$AgBSdZ2Soqed`2Te;<4s6xc( z+Wkf`$rYeF2p&e-sn7YpEqu5Ae5_`4OMXC1JAx}=NeFPoKHN=VPJ;U8=D@55o7(Rf zoc4fKaC_H}e!tZ2AR-iI=N`xZdtVltA2eErx$ou#r2~@}wZY^`8P}U9FFYsQAQg|K zmR2h*o;tiLd)Qop_I1!uTg%)3i+)KgY#@dtY_UKb4lUbnIgFt%rToVGAni;k#STg# z5iDd-wpqN@&;>tMCRv#*(3>){wj^-H9O$*xBZ4gZ75rx^EkvtxZLoqDzo7=M zterQYoybAON;)e5w2W3&_jtM?M(o_d4GCzI=mr(^gy#Mkl>4kFNkbO3b^y@`4id1I z%>Woq!u28Q(>=~XEG(#wwD&Cdg0=rgq4_y%ZY<$~DbGF@1M$rrv>9@34SB-g20KaJ ze&M&F` V5_e=>vpUbCNBH@b >jS+o9 zrjk4k!9w4O8?o`piJZ!7hQ`Zf^4jcfPh5`+;~Jubbgjck_ZvyE8&-{Yn7e`ViooZ} zB^g$hy6;gPH5Z-t$0&YCg?i0(HmCKrVhm5x*RHa4sBNK*m>gOd!~3fv&a8LxYHIU( z&$hFv)nO6>C&d7^`Fwem3=i$W7gKU%Ejzv^a$T7MvQ6I0!g5%bX?cr{yq8~T2M0 =FZ)cIWWoOjA9H^dpO_cQV?fD{o>1 +| za}9^*$#w`e7KXBgfh_~a<7FR aC=R_NcVXNX@`@b$o$+y{ z-qPsum4}7MYb}y7xzO34aa+^S=1-JYPPUUD>f@~`4-sd76lTybN}2xkX(Zyd)-flg z)_(oqPrmR*WXhg{Y<=lUe&e3_=_%Hp05wR+Qg)b(DZ9SB6xo_c^Gm&3N8E *Hqt*W z$uWNOo$D7pw!Omc_7C^6$TQ%Il& rc0?*F zDjrdwhDecA0qcv>h+g-hjYf}3mqGmHGc)D!5eHgg%T;zRkAzf}&_Z>|)-0VFPihr4 z1>}eCV=GTA2P7wv0F$sLf5nWPgEzKY(bmH;hS!7ddgPdLY0va(4Ld?vr(qN}Cf5pX zv{7{1TD@q($4FX^0&7Jy`K?=)XTM*_=di3lY8*7*;IBsF)L#}ZdN)5sHu9`>bdGPT zoD`YI`jqk<{?u((toCmW%{K-(SzG;(Fu~tooVGCjNOoV3(<}w^5AU43w&p4Ubnv>F z1Un@uO1w3E;@!}GAU@aAgJHfjzB|Wmt$e)Sga#qjyod&%D jG> @d{R|vcVO(+G<$J7_%se59YY44mqsph< z^Ucd72Z50U*?Gh)BW;4LK9J2C(YeB?1=$2azHKP0o_qdpb_n;Q|C90c4G3}HMW4YM zsw~1h^zuE1L^7w;y>Gv#G>=%(gjTfW58vvo=l4G*8lxk@ZI$tmu|Y?z(P2!f%)srJ zBG;U?Vqg2Vs_f9&1QoSOE`tviU4H&y*k_Fwo*&O;;}&J`r+=k=TX%Jm_r{}FDffu` z!7K!gMzTPzCVj0Hzl~; SdtTapN7ZnLME*l=gAt%;BBiEO6}oP9vtc}oF)%XN!b5r@_&!p?u+`4?7YKn*4!`H8(C58SC z;AWd%bNzirMBFZ~E)g)XW%jp y*ydx8B~c=Q z@WRE2N92pq{i$E^gP$7<0k5IJGTTseF4~$; 4>T{t< z>%c$v+vmUQN}i&Ggdp(3KepGcQC}7~{Rb?s$67QYck5EYLg2nq{&=VOe8?q6s}Byn zMMVENfl9>okD3BeMC_4PhO|f{b^`pOiXrqPQ1-_%+3)-7QHA !{$^>uTXmaoU z%>?tEWi~Vj{Gld{e4ftRkZ`Zo#|?G0<}B#RJ>`WCqEOh^11b}QR&i3qEh8>l#ILi< z#wc=Vq#)DMjoZJn;;Gg9KKsW`>&Uf2tM_$wW0R}-@qEfOIGJUhr_pF$30Sa`8Fq9! zxU25z@HnAZGJe%)1ahk@{pO>c^2Sq-w(z-)q2SVcxyqK*jkQ64wTU9o9J31|931*c zRCD+0CI+IZ8F`WGH;&%?B+a_$?P;U5D1pqXtIx+;nETW2+Qq_Y1uY~8k5KDgfoH19 zS+yUpSILH#V_q2J^fN}?<>r`}SV`BAB^4`A8%hpR8jWOz>7OL;xsjgC<&Y~K+>S11 zrlA#8uqcF54TTI4B2X49D=Tp~bQD7meJZ|tlQ0}!l?R%8_BUegr@3$P6ofbM@2ql- z$TuZYXFMrx1*Kju%F6DaM+#|iRa})Rfpgn8RVE5_YkZ`8)Z1Lij43JA&DtcJ!x=8y z8Pn9RFDnNw{AO`@WgTYy-2AyH#HPLIF7Yj>C AsSS>ohW 2|S8XFktMhXH{}R#nZ6k zU4?O#PG~=nU&}ZU+T`_@Fev!V08py0#gvJmgx*$v4|8z7gz&bL83?GazWb~^<>OxT zcR9xh;ycA*hA)~h3BPC 1o6e4?s5`$Q zbN@H#04eh>Jz`kN1;=Eu8JY{B221tzdlX-kpV5UO5czIjLi`_LPd #RWxns^&f>2w(Mcs$NZZWcsN(SpOp N7-8sE?~=0|BQC#_;X6wZZ;{N3-_M}Og3?JFOFlrLF-0s9 zH`QeL=OUGg-@G=yC18lK04GnxlaT`@bwBg{+ <*H^=Dp;-ndVG*Joba3#))*(?K1EvB8sDd+8`G-H(T*aKYBhYH5tE zos(nrDyKG_eSsMW!w3RH%G^9O!7`w?f}DMC P>Ykljw>Ud>+tS(I`y>x7R|dkVIIT;kTOS0+J2jgg(ju6HnG4Hc(N0^#sW)u z8o^3_J%}4+l5jx{D`DCqf~hU+G&vR~_0j>U4jyxyv%vrJ2NjV%czk+QPU)K4M+D}m znjtMXW?x5I4rZYr0GqhMILzL?A<~tg=XDhhgK7by0Kk?!U*5|&@4W>bBfPiFHb?jn zY`Eb9CeugnJSIlh{fZ1w -GxcnmuJ$T=-^hDDKEoc{2u_W0I(IiZN`z K3~b zr!jXAp6%U+zVCsX<*fm0_`D77L_xl%y?n9hM*p<-MVqRSMiRQ>MhpE^Z1!b@cGlZJ zd7ERoRc0$8iVaXCewj{7;z_jxWY*3~GB^9?Z+6YGMR0FzoRHX$#YaCyhssXpkvE}R zHxF~yNkZWFWVijGor1iV(N%N%?RK+VE5J_wdv>v$!uPsfLDWRV)a~S9U0$L)d~Gyg zwzCU=Oudul3yP)oUIYgJ_~6PeUAS5*`~Bc#(|D2z5NrL|^``<0AqQ$IfDK%o39Pdr z;=WqxZX~zOJ*mBC)?90B+v2x=wb8F08+tNiI@TW{RwD|i)NoJIPmPoZDXU}J0aYgF z!AYV7C}6Tx+_t<|zgKpFVD~~gHe Y z;w$4#$WHz*B=N#&OjHzo$10sl;SUd|0hcYrU)j&P$N+^{H?W<(Z!;S&5I~lgu40*8 zNZY4s0+ZAq0y$(6K&7GVch8LL@88Ey&=$mG`GsFDBJyFuP_Hw1Ou}bAhh19Jn1w1~ zJ&xCIj-|3v0CbV(8hssR*@yJli&ThWz^C||b49y^ O<1r9o&AutBB!@s!S zea7$gc{1M@`6z=~!?AfI2omqUsj6Q%JDvf&7M*HtW2cC&{w=VR>hLM +S6omdDs zee}!;bm;skIoj9y1brgv6AtV2HVjG(t^0h3#x+4W9NiNkp6rOt)wdB!yoB JP!Jp-enoCSa9IG>Acqczs$byVDQD35 z0l)~b?)(&7 DpT`eFKs91trSHg@*O`(G+qlGi3HJ{$=AO;r`z?B50#E)&F)I~(8 #`i@# *Fi;epVAmV+@h{- z`)IVPt8I0K@J~`Ee%lw3=nJRnr=lIElJ@h4fk^BpaBmQGh!ar5UC}hPlySNkEf>=i z+2%rMfCEYBB67{ udl{p@|A?g%jN-?T2h!!=9Ci z^CZc20B0Tfn>IC26FE9ksr4~8I^9M5)9B}rEy9Iq%Fo`M(D!^Hgtlq5!OXEkGzx>p z9u&T%uDE9J<5<#TgKw40uK393qAl)r-&gXb?z{X^SY(lO*RgZzyc`aaOVy*nceVck zP+mba6`6qx@N`7I_lJeOB{tJ)h30AEE+80Icxj ymB3F}!?_>`)x+sBk^F@r zNt~dN%GE}wLH@e;W_aPOCz6u}><}#qU9C%C;j~5)m&*c!&lSp=Lx)(SSWeP}Gt$5o z>Yo|7RpW(A;fRC^O_3a|E^|qDqUFq>NI2Eh5^p>CqbO3qUPW}+5h;)@H#(eJ0Lw{| zRUv^D9T~S# _dXaF$@^^F zOtR5!WKeG8_&gf;k>Q@dcs#blRD&ah7@WcX!<-&tBtmN+ tjb#M>Ybb^0@ ~_MT+#Wj2V2d}LW^$-=5#ME;!q(BHi7R?3!A`uPz54PMlJlks0O;9EItg7~&- zQBv~9S!qVeIC&bd;I$FYuO_ET3-!R} JtlOE&H36UDgssR#zt{$HJD**=JW4hSeF`Z$3Nb z)~=n8ol;9s<=}^?3y%bb*hQoRH7F}Xd*<}-4V#6)EO-8&OYFS9v0$o=#ps;)b?&Z9 zO6*Y?*?7&Hx^_3kk|~5l9$HE9^<7V~%?4_)>WHTB*c+07^>~Y}KfyLTPijlw6C0t1 zXepD;!xc?T?Jdrac*M?{#ZSBMm_f@LTYbut8x5dW#InjrdI?>jjBwf8T1q zGJ u~G#zd8XaNYYqYu6Zj__@w)kyHi*)gzq*X_BM_s3TuZg_lN@BBlO#l@F~5OL zNlt*!hg=4RHXL=;HK-H9W|c#!Jvdspl;j!+fmr!ep7)Lh!okrNG~fP97W!_z6a3m1 z6^G<&!w@}4T~+#39vT<{FA1C&f%+ojR3HjOJ=ACw{l1@=Tn4MRJ)TK>!GP`J-ALLk zJcS0x00smuTfCr&KY?szM|UW%iqK;4@w^5I+;e1Y|3E|GQ~d `_p@bJf~7XICKP<*!`1KO6@3WBE7@Q8Pbi&HKsC_vaRuwM<`5BRGCw z;0|xU{8b 1hIBIm7P^)K(OztlgTv9Hu3V5Et zaEA^AKH5vO1;!(<4fSHq?R}Ic)1l2IBOG`f0Km}TkiUikY{1@-guwDFH94*AHaa01 z+eRP>?bTquPM?`~;j5{kqEgOPh1iOQjYk)0$oTodHxQ`nm Kww$-bPQHuA5kCK65QPx!=ENn@LJBMhHtdw1w zM(pcD9LP4yFE8**aVpkWn$uPDV`FN1iF)MQ$@*(u%*(A4)AZdcj$_`*=}tf4iXYCD z3)2o+;HP)Ac0UftSbi(2wq9J0K91d9qS$2X4i7x2*>r9jy19%*8%%qpZK|%zXNcZX zSHbsaD`}tP^wU2vl*l2CHPrP-7+I(sMmAD7JC+^*#*$Ss<8Jah)- xFEf z )E zw!qx;S1jh~emaO34&yQU<>NLHTd&28UIc;o7YvtcKUAvuC$Hb5h30CRUDj~N3nNm_ zUpQL~krqvCm{VW=d}KKAqIfkPr!UTBUS+j^8ILm;5aj?qiNz%C-@YUE3cq|fL?W(D zOid2_D;C=oa5WnIO$@z0?b{7m7A%RD4lWA5xNZV
Y*+~V;~fc(Y<4Uz^PK@@XT3~6HiDefnp}}8lD=07`q&nw6niZ z0S 0valQuv8xl_@7Sjavfb(E1Yw>&D*?dgo4OJ+kER#X zi%mx3rKcIQms{p7+QO%( Md z?_@L237e~dzPf8J 8DBVG&FM)}yr8qWnr!O1&QPGQMP~a8^rD5zWjgR*1b$ znPa$C_eQDA?YOaj@lcFK(-`Rdtq#f$z`M`Tau79vbS0BP;J%XAm<74CH^=O`0IOY= z^;L7&vV*!-N-$2(V3ub~V&Ta P7Px!4ZJro1%06{xNVq6&DhCtMcSIGtHZ3n7M;VUJrV`)MqXcf6VnL^gXs%8y zOqSgd7L>prQg1=Va#|iqlC@tdD!jU+_!UqRc%cZ|jmSwxvtiWEyXYS@2)ouq4yk34 zjf)UyXUJ$5tZcdvwrvcc`l1&*RY_u~>+oPD3~!{23$s7}4bDzH%<3m4k`ztB9daMO zt3hd-JozxPQsL4oLfKjIT* i|wb*-MI2S&Vu(sn=@$Pq)bC M z`uUAaBeA_S&GN;f7uf<6TRk8X{R$r op(JIhW4(PjLzc;G)!{Ib$gHv2HPIjN<~?!2g68fra|i%MqzQ^#*FKt*Ulgwk3e zT$L2Z$o4g$8DaTfnaclSm@yJRcaR_i7$-Eb7Hn(+ag(I-iv&;pUa?Z;7aip%a)Q8b zS2$?Q9im@RfvGWede_@mSTUI2s~N#aj}&(K9z{YHWGKMMKm~|pqQMj8t@#1TFPK1y z)|^8Uiw=T?uWfj45vC+J<+CJdbm|dAWoycL`8 od XjyWkx0Owku}_tMIaK?`q8~r}=nf#f`r2wWL7O zanDQw8pPE?ZZ7MQgw !Ue54=z%yQPp6Rt||B}z|CEKpr7qm`_j}{KisXhog zpTFtpGWK6Miq>J@@0rmeMVjDINH^U3)Gf6h^;r2cp45!q+#YXOexh*XYxkBYX}Tp{ zE}puh(htBm8d*rX(%@H7h0aQPKR+i}KF=0R2v*4L-Q;5->^S`?IH=J2wj(HwMDK>s zN778j+w1y{41aF^q+5lmjZ!NiUJv9|QS%efP^7gO>^vvMNe`i0BvJfQ#;G};nge8; zCdV#@a2bv0u7iNKEI%H34^ld(JlH8v kF}gGR&L9?%(d4Ax(<&j4WZjDem3p#$N0&z8TLongWt{4 zevo#Dix*AYVLdR}=JV~$MlbV_L$MqOh33(e=nSPzV#>`=8;jU_jtu@J-AH(9et7GB zgvp{eLpiU4_;qA4#fn (cPrNBQm7DQ0gAQ3k*%&%^av!IRn=O*KCA&UE85Gjq(Tzr<+oJa7V}$MBIf^tZ z2kUew*x*|VwPE