diff --git a/Gemfile b/Gemfile
index a57bf50..120ac9d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -11,9 +11,11 @@ gem "puma", "~> 5.0"
gem "rails", "~> 7.0.3"
gem "dotenv-rails"
gem "pundit"
+gem "ransack"
group :development, :test do
gem "debug", platforms: %i[ mri mingw x64_mingw ]
+ gem "timecop"
end
group :development do
diff --git a/Gemfile.lock b/Gemfile.lock
index 83e438f..8f490b1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -159,10 +159,15 @@ GEM
thor (~> 1.0)
zeitwerk (~> 2.5)
rake (13.0.6)
+ ransack (3.2.1)
+ activerecord (>= 6.1.5)
+ activesupport (>= 6.1.5)
+ i18n
reline (0.3.1)
io-console (~> 0.5)
strscan (3.0.3)
thor (1.2.1)
+ timecop (0.9.5)
timeout (0.3.0)
tzinfo (2.0.4)
concurrent-ruby (~> 1.0)
@@ -189,6 +194,8 @@ DEPENDENCIES
puma (~> 5.0)
pundit
rails (~> 7.0.3)
+ ransack
+ timecop
web-console
RUBY VERSION
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index dcd7273..b735090 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1 +1,7 @@
/* Application styles */
+
+table { border: 1px solid black; }
+
+td, th { padding: 10px; }
+
+.new_contribution_form { max-width: 600px; }
diff --git a/app/controllers/contributions_controller.rb b/app/controllers/contributions_controller.rb
new file mode 100644
index 0000000..08820f5
--- /dev/null
+++ b/app/controllers/contributions_controller.rb
@@ -0,0 +1,54 @@
+class ContributionsController < ApplicationController
+ before_action :set_member, only: %i[ new create ]
+ before_action :set_contribution, only: %i[ edit ]
+
+ # GET /members/new
+ def new
+ @contribution = Contribution.new
+ end
+
+ # GET /members/1/edit
+ def edit
+ end
+
+ # POST /members
+ def create
+ @contribution = @member.contributions.build(contribution_params)
+
+ Contribution.transaction do
+ if @contribution.save
+ @member.handle_new_contribution(@contribution, params.dig(:contribution, :overriden_expires_on))
+ @member.reset_status!
+
+ redirect_to @member, notice: "Contribution was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+ end
+
+ ## PATCH/PUT /members/1
+ #def update
+ # if @member.update(member_params)
+ # redirect_to @member, notice: "Member was successfully updated."
+ # else
+ # render :edit, status: :unprocessable_entity
+ # end
+ #end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_member
+ @member = Member.find(params[:member_id])
+ end
+
+ def set_contribution
+ @contribution = Contribution.find(params[:id])
+ end
+
+ # Only allow a list of trusted parameters through.
+ def contribution_params
+ params.fetch(:contribution, {}).permit(:eurocents, :payment_method, :payment_on, :payment_reference)
+ end
+end
+
diff --git a/app/controllers/members_controller.rb b/app/controllers/members_controller.rb
new file mode 100644
index 0000000..11bccae
--- /dev/null
+++ b/app/controllers/members_controller.rb
@@ -0,0 +1,72 @@
+class MembersController < ApplicationController
+ before_action :set_member, only: %i[ show edit update destroy ]
+ helper_method :sort_params
+
+ # GET /members
+ def index
+ params.delete(:status) if params[:status] == 'any'
+ params.delete(:category) if params[:category] == 'any'
+
+ @members = Member.all.order(sort_params.merge(number: :asc))
+ @members = @members.ransack(display_name_or_email_or_identification_number_i_cont: params[:q], status_cont: params[:status], category_cont: params[:category]).result
+ end
+
+ # GET /members/1
+ def show
+ end
+
+ # GET /members/new
+ def new
+ @member = Member.new
+ end
+
+ # GET /members/1/edit
+ def edit
+ end
+
+ # POST /members
+ def create
+ @member = Member.new(member_params)
+
+ if @member.save
+ @member.reset_status!
+ redirect_to @member, notice: "Member was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ # PATCH/PUT /members/1
+ def update
+ if @member.update(member_params)
+ @member.reload.reset_status!
+ redirect_to @member, notice: "Member was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ private
+ # Use callbacks to share common setup or constraints between actions.
+ def set_member
+ @member = Member.find(params[:id])
+ end
+
+ # Only allow a list of trusted parameters through.
+ def member_params
+ params.fetch(:member, {}).permit(:display_name, :email, :identification_number, :category, :address, :joined_on, :expires_on)
+ end
+
+ def sort_params
+ field, direction = params.fetch(:sort, "").split(".")
+
+ directions = %w[ asc desc ]
+ fields = %w[ number expires_on joined_on email status display_name ]
+
+ if directions.include?(direction) && fields.include?(field)
+ { field => direction }
+ else
+ { number: :asc }
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be79..5da3d9b 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,5 @@
module ApplicationHelper
+ def member_status(status)
+ t("members.status.#{status}")
+ end
end
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
new file mode 100644
index 0000000..eaf9de3
--- /dev/null
+++ b/app/helpers/members_helper.rb
@@ -0,0 +1,24 @@
+module MembersHelper
+ def link_to_current_with_sort text, default_sort
+ current_sort = stringify(sort_params)
+
+ pp [default_sort, current_sort]
+
+ if default_sort == current_sort
+ link_to text, members_path(sort: invert_sort_order(current_sort))
+ else
+ link_to text, members_path(sort: default_sort)
+ end
+
+ end
+
+ private
+
+ def stringify(sort)
+ "#{sort.keys.first.to_s}.#{sort.values.first.to_s}"
+ end
+
+ def invert_sort_order(sort)
+ sort.sub(/\.(asc|desc)$/) { |x| x == '.asc' ? '.desc' : '.asc' }
+ end
+end
diff --git a/app/lib/if_then_pay.rb b/app/lib/if_then_pay.rb
new file mode 100644
index 0000000..4f8dd42
--- /dev/null
+++ b/app/lib/if_then_pay.rb
@@ -0,0 +1,19 @@
+require 'net/http'
+
+module IfThenPay
+ def self.generate_gateway_link(id:, amount:, description:)
+ response = Net::HTTP.post(
+ URI("https://ifthenpay.com/api/gateway/paybylink/#{ENV['IFTHENPAY_KEY']}"),
+ JSON.generate({
+ id: id,
+ amount: amount.to_s,
+ description: description.to_s,
+ "lang": "pt",
+ "expiredate": "",
+ "accounts": ENV['IFTHENPAY_ACCOUNTS'],
+ })
+ )
+
+ JSON.parse(response.body)
+ end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index 3c34c81..8e855f5 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
- default from: "from@example.com"
+ default from: email_address_with_name(ENV['SMTP_FROM_ADDRESS'], ENV['SMTP_FROM_NAME'])
layout "mailer"
end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
new file mode 100644
index 0000000..f39ae39
--- /dev/null
+++ b/app/mailers/notification_mailer.rb
@@ -0,0 +1,68 @@
+class NotificationMailer < ApplicationMailer
+
+ # Subject can be set in your I18n file at config/locales/en.yml
+ # with the following lookup:
+ #
+ # en.notification_mailer.expiration_in_60d.subject
+ #
+ def expiration_in_60d
+ @notification = params[:notification]
+
+ mail to: params[:notification].member.email
+ end
+
+ # Subject can be set in your I18n file at config/locales/en.yml
+ # with the following lookup:
+ #
+ # en.notification_mailer.expiration_in_30d.subject
+ #
+ def expiration_in_30d
+ @notification = params[:notification]
+
+ mail to: params[:notification].member.email
+ end
+
+ # Subject can be set in your I18n file at config/locales/en.yml
+ # with the following lookup:
+ #
+ # en.notification_mailer.expired.subject
+ #
+ def expired
+ @notification = params[:notification]
+
+ mail to: params[:notification].member.email
+ end
+
+ # Subject can be set in your I18n file at config/locales/en.yml
+ # with the following lookup:
+ #
+ # en.notification_mailer.expired_30d_ago.subject
+ #
+ def expired_30d_ago
+ @notification = params[:notification]
+
+ mail to: params[:notification].member.email
+ end
+
+ # Subject can be set in your I18n file at config/locales/en.yml
+ # with the following lookup:
+ #
+ # en.notification_mailer.expired_60d_ago.subject
+ #
+ def expired_60d_ago
+ @notification = params[:notification]
+
+ mail to: params[:notification].member.email
+ end
+
+ # Subject can be set in your I18n file at config/locales/en.yml
+ # with the following lookup:
+ #
+ # en.notification_mailer.cancelled.subject
+ #
+ def cancelled
+ @notification = params[:notification]
+
+ mail to: params[:notification].member.email
+ end
+end
diff --git a/app/models/contribution.rb b/app/models/contribution.rb
new file mode 100644
index 0000000..8596bab
--- /dev/null
+++ b/app/models/contribution.rb
@@ -0,0 +1,3 @@
+class Contribution < ApplicationRecord
+ belongs_to :member
+end
diff --git a/app/models/member.rb b/app/models/member.rb
new file mode 100644
index 0000000..a036845
--- /dev/null
+++ b/app/models/member.rb
@@ -0,0 +1,88 @@
+class Member < ApplicationRecord
+ has_many :contributions
+ has_many :notifications
+
+ def cancelled_on
+ expires_on + 90.days
+ end
+
+ def reset_status!
+ update(status: expected_status)
+ end
+
+ def expected_status
+ if joined_on.nil?
+ :pending
+ elsif (joined_on + 6.months).future?
+ :passive
+ elsif expires_on.future?
+ :active
+ elsif cancelled_on.future?
+ :expired
+ else
+ :cancelled
+ end
+ end
+
+ def handle_new_contribution(contribution, overriden_expires_on)
+ if joined_on.nil?
+ self.joined_on = contribution.payment_on
+ self.expires_on = overriden_expires_on.presence || (joined_on + 1.year)
+ else
+ self.expires_on = overriden_expires_on.presence || expires_on + 1.year
+ end
+
+ save!
+ end
+
+ def regenerate_notifications
+ notifications.where(status: 'scheduled').delete_all
+
+ return if expires_on.nil?
+
+ [
+ { to_be_sent_on: expires_on - 90.days, template: "expiration_in_60d" },
+ { to_be_sent_on: expires_on - 30.days, template: "expiration_in_30d" },
+ { to_be_sent_on: expires_on + 0.days, template: "expired" },
+ { to_be_sent_on: expires_on + 30.days, template: "expired_30d_ago" },
+ { to_be_sent_on: expires_on + 60.days, template: "expired_60d_ago" },
+ { to_be_sent_on: expires_on + 90.days, template: "cancelled" },
+ ].reject { |n| n[:to_be_sent_on].past? }.each do |n|
+ notifications.create(n.merge(status: "scheduled"))
+ end
+ end
+
+ def generate_missing_ifthenpay_links!
+ self.regular_ifthenpay_link = IfThenPay.generate_gateway_link(
+ id: number,
+ amount: "30.00",
+ description: "Quotas ANSOL",
+ ) unless self.regular_ifthenpay_link.present?
+
+ self.reduced_ifthenpay_link = IfThenPay.generate_gateway_link(
+ id: number,
+ amount: "6.00",
+ description: "Quotas ANSOL",
+ ) unless self.reduced_ifthenpay_link.present?
+
+ save!
+ end
+
+ def self.reset_all_status!
+ Member.all.each do |member|
+ member.reset_status!
+ end
+ end
+
+ def self.generate_all_missing_ifthenpay_links!
+ Member.all.each do |member|
+ member.generate_missing_ifthenpay_links!
+ end
+ end
+
+ def self.regenerate_all_notifications
+ Member.all.each do |member|
+ member.regenerate_notifications
+ end
+ end
+end
diff --git a/app/models/notification.rb b/app/models/notification.rb
new file mode 100644
index 0000000..4e2f651
--- /dev/null
+++ b/app/models/notification.rb
@@ -0,0 +1,20 @@
+class Notification < ApplicationRecord
+ belongs_to :member
+
+ scope :scheduled_for_today, ->() { where(status: 'scheduled', to_be_sent_on: Date.today) }
+
+ def self.send_scheduled_for_today
+ scheduled_for_today.each do |n|
+ n.deliver!
+ end
+ end
+
+ def deliver!
+ # actually send the email.
+ NotificationMailer.with(notification: self).send(template).deliver_now!
+
+ update(status: 'sent', sent_at: Time.current)
+ rescue
+ # TODO: do something about failures
+ end
+end
diff --git a/app/views/contributions/edit.html.erb b/app/views/contributions/edit.html.erb
new file mode 100644
index 0000000..91b1ebf
--- /dev/null
+++ b/app/views/contributions/edit.html.erb
@@ -0,0 +1,22 @@
+editando contriboot
+
+<%= form_with(model: @contribution) do |form| %>
+
+
+ Amount
+ €<%= @contribution.eurocents %>
+
+
+ Payment date
+ €<%= @contribution.payment_on %>
+
+
+ Payment method
+ <%= @contribution.payment_method %>
+
+
+ Payment reference
+ <%= @contribution.payment_reference %>
+
+
+<% end %>
diff --git a/app/views/contributions/new.html.erb b/app/views/contributions/new.html.erb
new file mode 100644
index 0000000..4eb3f92
--- /dev/null
+++ b/app/views/contributions/new.html.erb
@@ -0,0 +1,48 @@
+Registering contribution for <%= @member.display_name %>
+
+
+ Member number <%= @member.number %>
+ Joined on <%= @member.joined_on %>
+ Expires on <%= @member.expires_on %>
+
+
+
+<%= form_with(model: [@member, @contribution]) do |form| %>
+
+
+
+ <%= form.submit %>
+
+<% end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 5f22252..ae94ca1 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -11,6 +11,10 @@
+
+ <%= link_to t('navigation.members'), members_path %>
+
+
<%= yield %>