diff --git a/cpanfile b/cpanfile index af672bb..33484bc 100644 --- a/cpanfile +++ b/cpanfile @@ -29,7 +29,7 @@ requires 'JSON'; requires 'MIME::Base64'; requires 'CatalystX::AuthenCookie'; requires 'Catalyst::Plugin::StackTrace'; -requires 'DBD::Pg'; +requires 'DBD::Pg', 3.18.0; requires 'DateTime::Format::Pg'; requires 'List::Util'; requires 'Mojo::Base'; @@ -44,3 +44,6 @@ requires 'Email::Sender::Transport::SMTP'; requires 'DBIx::Class::EasyFixture'; requires 'Time::HiRes'; requires 'Date::Holidays::GB'; +requires 'Test::More'; +requires 'Test::Exception'; +requires 'DBIx::Class::InflateColumn::Boolean'; diff --git a/docs/diagrams/bank_import_activity.png b/docs/diagrams/bank_import_activity.png new file mode 100644 index 0000000..91595f0 Binary files /dev/null and b/docs/diagrams/bank_import_activity.png differ diff --git a/docs/diagrams/bank_import_activity.txt b/docs/diagrams/bank_import_activity.txt new file mode 100644 index 0000000..690b728 --- /dev/null +++ b/docs/diagrams/bank_import_activity.txt @@ -0,0 +1,30 @@ +@startuml + +:Download latest OFX file; +:Rename OFX file to today's date; +:Copy OFX file to Access Server; +:Find date of most recent DB Transaction; +:Subtract 2 days just in case; +:Find all OFX files newer than date picked; +:Load each file; +while(Get payment from file) + if (Amount is > 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 0000000..a55fe44 Binary files /dev/null and b/docs/diagrams/extend_membership_activity.png differ diff --git a/docs/diagrams/extend_membership_activity.txt b/docs/diagrams/extend_membership_activity.txt new file mode 100644 index 0000000..c65f6d8 --- /dev/null +++ b/docs/diagrams/extend_membership_activity.txt @@ -0,0 +1,63 @@ +@startuml +skinparam ConditionEndStyle hline + +start +while (Get Member from DB) + :Find current expiry date if any; + if (Expiry date exists and\nIs in the next $OVERLAP days) then (yes) + if (Expiry date) then (no) + if (Voucher code) then (yes) + :Set voucher start-date to now; + endif + ->continue; + endif + ->continue; + :Get current balance (sum of all Transactions); + if ((NEW) Member is donor) then (yes) + :Load previous tier information; + if (Made Donor less than 1 month ago and\nCurrent balance enough for old tier) then (yes) + :Reset tier to old tier; + endif + endif + if (Current balance < fees) then (yes) + if ((NEW) Has previously paid anything and\nActually expired and\nBalance is > 0 and\nNot donor tier and\nChosen amount less than default tier fee) then (yes) + :Store current tier and payment amount; + :Switch tier to donor and payment amount to 0; + :Send email announcing donor switch; + break + else (no) + :Log "balance not enough"; + break + endif + if (Expiry date and\nExpired between 5 days ago and 1 month ago) then (yes) + :Send "reminder_email" email; + endif + endif + if (Expiry date) then (no) + :Send email "first_payment"; + endif + ->continue; + if (Expiry date and\nExpiry date is before now) then (yes) + :Force send email "rejoin_payment"; + :Delete any previous "reminder_email" communication; + endif + if (No Expiry date or\n Expiry date is before now) then (yes) + :Set Expiry date to today + :Extra days is $overlap days; + endif + ->continue; + :Enable Door access (in case of returning pre-covid member); + :Transaction Amount = membership fees + :Calculate new Expiry date (old one + 1 month + overlap days); + if (Current balance > fee * 12 * 0.9) then (yes) + :Recalulcate new Expiry date (old one + 12 months + overlap days); + :Transaction Amount = membership fees * 12 * 0.9; + endif + ->no; + :Create negative Transaction for Transaction Amount; + :Create Dues entry (Amount, paid on, expiry date); + endif +endwhile +:Repeat if more members; +stop +@enduml diff --git a/lib/AccessSystem/API/Controller/Root.pm b/lib/AccessSystem/API/Controller/Root.pm index 8c34c9b..3814df8 100644 --- a/lib/AccessSystem/API/Controller/Root.pm +++ b/lib/AccessSystem/API/Controller/Root.pm @@ -291,11 +291,13 @@ sub verify: Chained('base') :PathPart('verify') :Args(0) { person => { name => $result->{person}->name }, inductor => $result->{person}->allowed->first->is_admin, access => 1, + beep => $result->{beep} || 0, cache => $result->{person}->tier->restrictions->{'times'} ? 0 : 1, colour => $result->{person}->door_colour_to_code || 0x01, } ); } elsif($result) { + # Found Tool and Person but not allowed in some way: $c->stash( json => { access => 0, @@ -943,14 +945,15 @@ sub get_dues: Chained('base'): PathPart('get_dues'): Args(0) { my $tier = $c->req->params->{tier} || 3; $c->log->debug(Data::Dumper::Dumper($c->req->params)); + my $dummy_dues = $c->model('AccessDB::Person')->get_dummy_dues($tier, $dob, $concession); # $c->log->debug("Vals: $dob $concession $other_hackspace Result: ", $new_person->dues); - my $new_person = $c->model('AccessDB::Person')->new_result({}); - $new_person->tier_id($tier); - $new_person->dob($dob) if $dob; - $new_person->concessionary_rate_override($concession); + # my $new_person = $c->model('AccessDB::Person')->new_result({}); + # $new_person->tier_id($tier); + # $new_person->dob($dob) if $dob; + # $new_person->concessionary_rate_override($concession); - $c->log->debug("Vals: $dob $concession Result: ", $new_person->dues); - $c->response->body($new_person->dues / 100); + $c->log->debug("Vals: $dob $concession Result: ", $dummy_dues); + $c->response->body($dummy_dues / 100); } sub register: Chained('base'): PathPart('register'): Args(0) { diff --git a/lib/AccessSystem/Form/Person.pm b/lib/AccessSystem/Form/Person.pm index bd88a0c..12bfe92 100644 --- a/lib/AccessSystem/Form/Person.pm +++ b/lib/AccessSystem/Form/Person.pm @@ -146,7 +146,35 @@ has_field email => field_add_defaults { required => 'Please enter an email we can use to contact you', }, unique_message => "That email address is already registered, did you do submit twice?", - help_string => 'An email address we can contact you on. Your membership payment details will be sent to it.', + help_string => 'An email address we can contact you on. Your membership/donor payment details will be sent to it.', +}; + +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, required by the gov for the membership register.', +}; + +# 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
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..80a0783 100644 --- a/lib/AccessSystem/Schema/Result/Person.pm +++ b/lib/AccessSystem/Schema/Result/Person.pm @@ -245,9 +245,19 @@ sub update { return $self->next::method(@_); } +sub is_donor { + my ($self) = @_; + + if ($self->tier && $self->tier->name =~ /donation/i) { + return 1; + } +} + sub is_valid { my ($self, $date) = @_; + return 0 if($self->is_donor); + $date = DateTime->now(); my $dtf = $self->result_source->schema->storage->datetime_parser; @@ -297,6 +307,10 @@ sub normal_dues { return 0 if $self->parent; + if ($self->is_donor) { + return 0; + } + my $dues = $self->tier ? $self->tier->price : 2500; if($self->tier && $self->tier->concessions_allowed && $self->concessionary_rate) { @@ -465,6 +479,13 @@ sub import_transaction { =head2 create_payment +OVERLAP_DAYS is set to 14: That is any new-member or returning member +is given one month +14 days as the length of their initial +access. They are "valid" as members/door + tool users, for this +time. Subsequent payments made while still valid add a further months +worth of access. The intention of this is to keep access over bank +holidays or payment issues. + Check if we are nearing the end of this member's paid membership, return true if not. @@ -474,11 +495,38 @@ can't find one. If the balance is >= 12*$monthly*0.1, then make a year payment. +Fees change 2025! + +If the member is nearing the end of their overlap period (2 days or +fewer to go), and was at some point a paidup member (previously made a +payment), and does not have enough balance to pay their dues (max +value between payment_override + tier minimum amount), they are +converted to a "donor only" tier member. + +If the balance is enough to pay their dues (as above) and was made a +donor member less than a month ago, they've presumably topped up/fixed +their amount, we convert them back to their old tier. + +Emails are sent for: + +=over + +=item New member payment received + +=item Returning member payment received + +=item Converted to donor member + +=item Reminder when within 5 days of expiry and no payment yet received. + +=back + =cut sub create_payment { my ($self, $OVERLAP_DAYS) = @_; my $schema = $self->result_source->schema; + my $dt_parser = $schema->storage->datetime_parser; ## minor(?) side effects: @@ -488,13 +536,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 +553,73 @@ 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}); } } + my $current_bal = $self->balance_p; # 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! @@ -544,13 +650,17 @@ sub create_payment { } if($valid_date && $valid_date < $now) { - # renewed payments - $self->create_communication('Your Swindon Makerspace membership has restarted', 'rejoin_payment'); + # renewed payments - force this one, should happen everytime + $self->create_communication('Your Swindon Makerspace membership has restarted', 'rejoin_payment', {}, 1); # rejoined, so remove any "reminder" email, so that if they # subsequently stop paying again, they get a new reminder (!) 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 @@ -563,7 +673,7 @@ sub create_payment { my $payment_size = $self->dues; $self->update_door_access(); my $expires_on = $valid_date->clone->add(months => 1, %extra_days); - if($self->balance_p >= $self->dues * 12 * 0.9) { + if($current_bal >= $self->dues * 12 * 0.9) { # Special case, they paid for a year in advance (we assume!) $expires_on = $valid_date->clone->add(years => 1, %extra_days); $payment_size = $self->dues * 12 * 0.9; @@ -708,6 +818,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..c679545 100644 --- a/lib/AccessSystem/Schema/ResultSet/Person.pm +++ b/lib/AccessSystem/Schema/ResultSet/Person.pm @@ -81,6 +81,13 @@ or { error => 'Membership expired/unpaid', colour => 0x22 } +Fees change 2025! + +If member is being allowed access, check whether their most recent +payment matches their dues (maximum between the payment_override and +current tier price), if not beep, email and output a message on +screen. + =cut sub allowed_to_thing { @@ -129,6 +136,7 @@ sub allowed_to_thing { } if (!$r_allow) { return { + person => $person, error => 'No access for Weekend Member', colour => 0x21, }; @@ -148,13 +156,31 @@ 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 { + person => $person, error => "Membership expired/unpaid", colour => 0x22, }; @@ -194,7 +220,7 @@ sub allowed_to_thing { } } else { return { - error => sprintf("%s not accepted. See email", $thing_rs->first->name), + error => sprintf("%s not accepted/inducted. See email", $thing_rs->first->name), person => $person, thing => $thing_rs->first, colour => 0x24, @@ -279,6 +305,18 @@ sub update_member_register { } } +sub get_dummy_dues { + my ($self, $tier_id, $dob, $conc, $fee_override) = @_; + + my $new_person = $self->new_result({}); + $new_person->tier_id($tier_id); + $new_person->dob($dob) if $dob; + $new_person->concessionary_rate_override($conc); + $new_person->payment_override($fee_override) if $fee_override; + + return $new_person->dues; +} + sub get_person_from_hash { my ($self, $hash) = @_; diff --git a/root/src/emails/donation_access_denied/donation_access_denied.html b/root/src/emails/donation_access_denied/donation_access_denied.html new file mode 100644 index 0000000..58c23da --- /dev/null +++ b/root/src/emails/donation_access_denied/donation_access_denied.html @@ -0,0 +1,23 @@ +[% WRAPPER layout.html.tt %] + +

Dear [% member.name %],

+ +

You recently tried to access the Makerspace however your membership is currently set to donation which does not have access

+

This is likely due to our membership fees changing and you have not updated your standing order to reflect this

+ +

To activate your membership again and gain access to the Makerspace please make sure your payments are as follows

+

+

+

+ +

Any issues please contact us either on Telegram or email us at info@swindon-makerspace.org

+

Regards,

+ +

Swindon Makerspace

\ No newline at end of file diff --git a/root/src/emails/donation_access_denied/donation_access_denied.txt b/root/src/emails/donation_access_denied/donation_access_denied.txt new file mode 100644 index 0000000..0211a93 --- /dev/null +++ b/root/src/emails/donation_access_denied/donation_access_denied.txt @@ -0,0 +1,19 @@ +Dear [% member.name %], + +You recently tried to access the Makerspace however your membership is currently set to donation which does not have access +This is likely due to our membership fees changing and you have not updated your standing order to reflect this + +To activate your membership again and gain access to the Makerspace please make sure your payments are as follows + + Monthly fee: £[% member.oldtier.dues / 100 | format('%.2f') %]/month + To: Swindon Makerspace CIC + Bank: Barclays + Sort Code: 20-84-58 + Account: 83789160 + Ref: [% member.bank_ref %] + + +Any issues please contact us either on Telegram or email us at info@swindon-makerspace.org +Regards, + +Swindon Makerspace \ No newline at end of file diff --git a/root/src/emails/membership_fees_change/membership_fees_change.html b/root/src/emails/membership_fees_change/membership_fees_change.html new file mode 100644 index 0000000..1a8b66a --- /dev/null +++ b/root/src/emails/membership_fees_change/membership_fees_change.html @@ -0,0 +1,27 @@ +[% WRAPPER layout.html.tt %] + +

Dear [% member.name %],

+ +

Our membership rates have recently changed. Hopefully you have seen this in recent emails, this is due to increase in rent and similar bills

+

While you have access to the end of your current months membership, you will need to update your standing order to the new rate for your membership to continue

+

Payment details are the same below just incase along with the new monthly fees rate

+ +

+

+

+ +

Please update your membership fees, failure to do so will move you to a donor tier which does not have access to the Makerspace

+

If you have already paid your old amount this month, you can pay the difference to top up, this will restore your account to your chosen tier if done within a month

+ +

Any issues please contact us either on Telegram or email us at info@swindon-makerspace.org

+

Regards,

+ +

Swindon Makerspace

+[% END %] diff --git a/root/src/emails/membership_fees_change/membership_fees_change.txt b/root/src/emails/membership_fees_change/membership_fees_change.txt new file mode 100644 index 0000000..9ccf4f0 --- /dev/null +++ b/root/src/emails/membership_fees_change/membership_fees_change.txt @@ -0,0 +1,23 @@ +Dear [% member.name %], + +Our membership rates have recently changed. Hopefully you have seen this in recent emails, this is due to increase in rent and similar bills +While you have access to the end of your current months membership, you will need to update your standing order to the new rate for your membership to continue +Payment details are the same below just incase along with the new monthly fees rate + + + + Monthly fee: £[% member.dues / 100 | format('%.2f') %]/month + To: Swindon Makerspace CIC + Bank: Barclays + Sort Code: 20-84-58 + Account: 83789160 + Ref: [% member.bank_ref %] + + +Please update your membership fees, failure to do so will move you to a donor tier which does not have access to the Makerspace +If you have already paid your old amount this month, you can pay the difference to top up, this will restore your account to your chosen tier if done within a month. + +Any issues please contact us either on Telegram or email us at info@swindon-makerspace.org +Regards, + +Swindon Makerspace diff --git a/root/src/emails/move_to_donation_tier/move_to_donation_tier.html b/root/src/emails/move_to_donation_tier/move_to_donation_tier.html new file mode 100644 index 0000000..b64bcdf --- /dev/null +++ b/root/src/emails/move_to_donation_tier/move_to_donation_tier.html @@ -0,0 +1,28 @@ +[% WRAPPER layout.html.tt %] +

Dear [% member.name %],

+ +

Your membership has been moved to the donation tier.\n +This is because our current membership fees have changed and you have not updated your payments to reflect this.
+This donation tier does not have access to the space

+ +

To resolve this and return to your active membership please send the difference of the new membership against your previous payments and update your standing order

+ +

Difference to pay: [% (current_balance - min_dues ) / 100 | format('%.2f') %]

+ +

Incase you have fogotten our the payment details are +

+

+ +

Any issues please contact us either on Telegram or email us at info@swindon-makerspace.org

+

Regards,

+ +

Swindon Makerspace

+ +[% END %] diff --git a/root/src/emails/move_to_donation_tier/move_to_donation_tier.txt b/root/src/emails/move_to_donation_tier/move_to_donation_tier.txt new file mode 100644 index 0000000..afd6a8c --- /dev/null +++ b/root/src/emails/move_to_donation_tier/move_to_donation_tier.txt @@ -0,0 +1,22 @@ +Dear [% member.name %], + +Your membership has been moved to the donation tier. +This is because our current membership fees have changed and you have not updated your payments to reflect this. +This donation tier does not have access to the space + +To resolve this and return to your active membership please send the difference of the new membership against your previous payments and update your standing order + +Difference to pay: [% (current_balance - min_dues ) / 100 | format('%.2f') %] + +Incase you have fogotten our the payment details are + Monthly fee: £[% min_dues / 100 | format('%.2f') %]/month + To: Swindon Makerspace CIC + Bank: Barclays + Sort Code: 20-84-58 + Account: 83789160 + Ref: [% member.bank_ref %] + +Any issues please contact us either on Telegram or email us at info@swindon-makerspace.org +Regards, + +Swindon Makerspace 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 %]

- 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.

-

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;