diff --git a/CLAUDE.md b/CLAUDE.md
index f37e6cd..afba8c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -33,7 +33,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
### Admin Tasks
- `bin/rails "invitations:cleanup"` - Clean up expired invitations
-- `bin/rails "reminders:process"` - Send daily payment reminders
+- `bin/rails "reminders:process"` - Send daily payment reminders (runs automatically via SolidQueue)
+- `bin/rails "reminders:stats"` - Show reminder system statistics
+- `bin/rails "reminders:test[project_id]"` - Test reminder system for a specific project
### Telegram Bot
- `bin/rails telegram:setup_webhook` - Configure webhook for development/production
diff --git a/Gemfile b/Gemfile
index e83cafa..d21d31b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -56,7 +56,7 @@ group :development do
end
-gem "inertia_rails", "~> 3.9"
+gem "inertia_rails"
gem "vite_rails", "~> 3.0", ">= 3.0.19"
diff --git a/Gemfile.lock b/Gemfile.lock
index 4d9df0b..8e9b29f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,29 +1,29 @@
GEM
remote: https://rubygems.org/
specs:
- actioncable (8.0.2)
- actionpack (= 8.0.2)
- activesupport (= 8.0.2)
+ actioncable (8.0.2.1)
+ actionpack (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.0.2)
- actionpack (= 8.0.2)
- activejob (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ actionmailbox (8.0.2.1)
+ actionpack (= 8.0.2.1)
+ activejob (= 8.0.2.1)
+ activerecord (= 8.0.2.1)
+ activestorage (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
mail (>= 2.8.0)
- actionmailer (8.0.2)
- actionpack (= 8.0.2)
- actionview (= 8.0.2)
- activejob (= 8.0.2)
- activesupport (= 8.0.2)
+ actionmailer (8.0.2.1)
+ actionpack (= 8.0.2.1)
+ actionview (= 8.0.2.1)
+ activejob (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.0.2)
- actionview (= 8.0.2)
- activesupport (= 8.0.2)
+ actionpack (8.0.2.1)
+ actionview (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -31,35 +31,35 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.0.2)
- actionpack (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ actiontext (8.0.2.1)
+ actionpack (= 8.0.2.1)
+ activerecord (= 8.0.2.1)
+ activestorage (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.0.2)
- activesupport (= 8.0.2)
+ actionview (8.0.2.1)
+ activesupport (= 8.0.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (8.0.2)
- activesupport (= 8.0.2)
+ activejob (8.0.2.1)
+ activesupport (= 8.0.2.1)
globalid (>= 0.3.6)
- activemodel (8.0.2)
- activesupport (= 8.0.2)
- activerecord (8.0.2)
- activemodel (= 8.0.2)
- activesupport (= 8.0.2)
+ activemodel (8.0.2.1)
+ activesupport (= 8.0.2.1)
+ activerecord (8.0.2.1)
+ activemodel (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
timeout (>= 0.4.0)
- activestorage (8.0.2)
- actionpack (= 8.0.2)
- activejob (= 8.0.2)
- activerecord (= 8.0.2)
- activesupport (= 8.0.2)
+ activestorage (8.0.2.1)
+ actionpack (= 8.0.2.1)
+ activejob (= 8.0.2.1)
+ activerecord (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
marcel (~> 1.0)
- activesupport (8.0.2)
+ activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -101,7 +101,7 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
- dry-cli (1.2.0)
+ dry-cli (1.3.0)
dry-core (1.1.0)
concurrent-ruby (~> 1.0)
logger
@@ -127,9 +127,9 @@ GEM
ed25519 (1.4.0)
erb (5.0.2)
erubi (1.13.1)
- et-orbi (1.2.11)
+ et-orbi (1.3.0)
tzinfo
- faraday (2.13.3)
+ faraday (2.13.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -145,7 +145,7 @@ GEM
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
- fugit (1.11.1)
+ fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
@@ -160,20 +160,20 @@ GEM
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
- inertia_rails (3.9.0)
+ inertia_rails (3.11.0)
railties (>= 6)
io-console (0.8.1)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- jbuilder (2.13.0)
- actionview (>= 5.0.0)
- activesupport (>= 5.0.0)
+ jbuilder (2.14.1)
+ actionview (>= 7.0.0)
+ activesupport (>= 7.0.0)
js-routes (2.3.5)
railties (>= 5)
sorbet-runtime
- json (2.13.0)
+ json (2.13.2)
kamal (2.7.0)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -220,7 +220,7 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
- mini_magick (5.3.0)
+ mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (5.25.5)
@@ -231,7 +231,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
- net-imap (0.5.9)
+ net-imap (0.5.10)
date
net-protocol
net-pop (0.1.2)
@@ -264,7 +264,7 @@ GEM
racc (~> 1.4)
ostruct (0.6.3)
parallel (1.27.0)
- parser (3.3.8.0)
+ parser (3.3.9.0)
ast (~> 2.4.1)
racc
pp (0.6.2)
@@ -275,11 +275,11 @@ GEM
date
stringio
public_suffix (6.0.2)
- puma (6.6.0)
+ puma (6.6.1)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.1.16)
+ rack (3.2.0)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-proxy (0.7.7)
@@ -291,20 +291,20 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
- rails (8.0.2)
- actioncable (= 8.0.2)
- actionmailbox (= 8.0.2)
- actionmailer (= 8.0.2)
- actionpack (= 8.0.2)
- actiontext (= 8.0.2)
- actionview (= 8.0.2)
- activejob (= 8.0.2)
- activemodel (= 8.0.2)
- activerecord (= 8.0.2)
- activestorage (= 8.0.2)
- activesupport (= 8.0.2)
+ rails (8.0.2.1)
+ actioncable (= 8.0.2.1)
+ actionmailbox (= 8.0.2.1)
+ actionmailer (= 8.0.2.1)
+ actionpack (= 8.0.2.1)
+ actiontext (= 8.0.2.1)
+ actionview (= 8.0.2.1)
+ activejob (= 8.0.2.1)
+ activemodel (= 8.0.2.1)
+ activerecord (= 8.0.2.1)
+ activestorage (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
bundler (>= 1.15.0)
- railties (= 8.0.2)
+ railties (= 8.0.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -312,9 +312,9 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (8.0.2)
- actionpack (= 8.0.2)
- activesupport (= 8.0.2)
+ railties (8.0.2.1)
+ actionpack (= 8.0.2.1)
+ activesupport (= 8.0.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -325,13 +325,13 @@ GEM
rdoc (6.14.2)
erb
psych (>= 4.0.0)
- regexp_parser (2.10.0)
+ regexp_parser (2.11.2)
reline (0.6.2)
io-console (~> 0.5)
- resend (0.22.0)
+ resend (0.24.0)
httparty (>= 0.21.0)
- rexml (3.4.1)
- rubocop (1.78.0)
+ rexml (3.4.2)
+ rubocop (1.80.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -339,7 +339,7 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.45.1, < 2.0)
+ rubocop-ast (>= 1.46.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.46.0)
@@ -349,7 +349,7 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
- rubocop-rails (2.32.0)
+ rubocop-rails (2.33.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -360,11 +360,11 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
- ruby-vips (2.2.4)
+ ruby-vips (2.2.5)
ffi (~> 1.12)
logger
securerandom (0.4.1)
- solid_cable (3.0.11)
+ solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
@@ -379,14 +379,14 @@ GEM
activerecord (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
- solid_queue (1.2.0)
+ solid_queue (1.2.1)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11.0)
railties (>= 7.1)
- thor (~> 1.3.1)
- sorbet-runtime (0.5.12356)
+ thor (>= 1.3.1)
+ sorbet-runtime (0.6.12473)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu)
@@ -408,16 +408,16 @@ GEM
faraday (~> 2.0)
faraday-multipart (~> 1.0)
zeitwerk (~> 2.6)
- thor (1.3.2)
- thruster (0.1.14)
- thruster (0.1.14-aarch64-linux)
- thruster (0.1.14-arm64-darwin)
- thruster (0.1.14-x86_64-darwin)
- thruster (0.1.14-x86_64-linux)
+ thor (1.4.0)
+ thruster (0.1.15)
+ thruster (0.1.15-aarch64-linux)
+ thruster (0.1.15-arm64-darwin)
+ thruster (0.1.15-x86_64-darwin)
+ thruster (0.1.15-x86_64-linux)
timeout (0.4.3)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
- unicode-display_width (3.1.4)
+ unicode-display_width (3.1.5)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
@@ -462,7 +462,7 @@ DEPENDENCIES
debug
dotenv-rails
image_processing (~> 1.2)
- inertia_rails (~> 3.9)
+ inertia_rails
jbuilder
js-routes (~> 2.3)
kamal
diff --git a/app/controllers/billing_cycles_controller.rb b/app/controllers/billing_cycles_controller.rb
index 6dd5d51..a389ede 100644
--- a/app/controllers/billing_cycles_controller.rb
+++ b/app/controllers/billing_cycles_controller.rb
@@ -20,7 +20,7 @@ def index
@billing_cycles = @billing_cycles.upcoming if params[:filter] == "upcoming"
@billing_cycles = @billing_cycles.overdue if params[:filter] == "overdue"
if params[:filter] == "due_soon"
- days = params[:days]&.to_i || 7
+ days = params[:days]&.to_i || BillingConfig.current.due_soon_days
@billing_cycles = @billing_cycles.where(id: BillingCycle.due_soon(days).pluck(:id))
end
@@ -80,7 +80,8 @@ def show
user_permissions: {
is_owner: @project.is_owner?(Current.user),
is_member: @project.is_member?(Current.user),
- can_manage: @project.is_owner?(Current.user)
+ can_manage: @project.is_owner?(Current.user),
+ can_pay: @billing_cycle.user_payment_pending?(Current.user)
}
}
end
@@ -245,11 +246,14 @@ def billing_cycle_props(billing_cycle)
created_at: billing_cycle.created_at,
updated_at: billing_cycle.updated_at,
total_paid: billing_cycle.total_paid,
+ fully_paid: billing_cycle.fully_paid?,
+ total_pending: billing_cycle.total_pending,
amount_remaining: billing_cycle.amount_remaining,
expected_payment_per_member: billing_cycle.expected_payment_per_member,
payment_status: billing_cycle.payment_status,
overdue: billing_cycle.overdue?,
- days_until_due: billing_cycle.days_until_due
+ days_until_due: billing_cycle.days_until_due,
+ can_pay: billing_cycle.user_payment_pending?(Current.user)
}
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 379f4a0..9d87641 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -148,7 +148,7 @@ def calculate_dashboard_stats
total_amount_paid: Current.user.payments.confirmed.sum(:amount),
upcoming_due_soon: BillingCycle.joins(:project)
.where(project_id: all_project_ids)
- .due_soon(7)
+ .due_soon
.count
}
end
diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb
index f8bc1b4..920c940 100644
--- a/app/controllers/invitations_controller.rb
+++ b/app/controllers/invitations_controller.rb
@@ -44,7 +44,7 @@ def update
format.json do
render json: {
errors: @invitation.errors.full_messages
- }, status: :unprocessable_entity
+ }, status: :unprocessable_content
end
format.html do
redirect_back(fallback_location: project_path(@project),
@@ -78,7 +78,7 @@ def send_email
format.json do
render json: {
error: "Cannot send email: no email address provided"
- }, status: :unprocessable_entity
+ }, status: :unprocessable_content
end
format.html do
redirect_back(fallback_location: project_path(@project),
@@ -118,7 +118,7 @@ def create
format.json do
render json: {
errors: @invitation.errors.full_messages
- }, status: :unprocessable_entity
+ }, status: :unprocessable_content
end
format.html do
redirect_back(fallback_location: project_path(@project),
@@ -255,7 +255,7 @@ def confirm
user_email: @invitation.email,
errors: { email: [ "An account with this email already exists. Please sign in instead." ] }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
return
end
@@ -270,7 +270,7 @@ def confirm
user_email: @invitation.email,
errors: { email: [ "An account with this email already exists. Please sign in instead." ] }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
return
end
@@ -313,7 +313,7 @@ def confirm
user_email: @invitation.email,
errors: { message: "Unable to accept invitation. Please try again." }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
end
end
else
@@ -326,7 +326,7 @@ def confirm
user_email: @invitation.email,
errors: user.errors.as_json
},
- status: :unprocessable_entity
+ status: :unprocessable_content
end
rescue ActiveRecord::RecordNotUnique => e
Rails.logger.error "RecordNotUnique error: #{e.message}"
@@ -337,7 +337,7 @@ def confirm
user_email: @invitation.email,
errors: { email: [ "An account with this email already exists. Please contact the project owner." ] }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
rescue ActiveRecord::StatementInvalid => e
Rails.logger.error "StatementInvalid error: #{e.message}"
if e.message.include?("UNIQUE constraint failed") || e.message.include?("duplicate key")
@@ -348,7 +348,7 @@ def confirm
user_email: @invitation.email,
errors: { email: [ "An account with this email already exists. Please contact the project owner." ] }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
else
render inertia: "invitations/confirm",
props: {
@@ -357,7 +357,7 @@ def confirm
user_email: @invitation.email,
errors: { message: "Something went wrong while creating your account. Please try again or contact support." }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
end
rescue StandardError => e
Rails.logger.error "Error in invitation confirmation: #{e.class.name} - #{e.message}"
@@ -369,7 +369,7 @@ def confirm
user_email: @invitation.email,
errors: { message: "Something went wrong while creating your account. Please try again or contact support." }
},
- status: :unprocessable_entity
+ status: :unprocessable_content
end
end
diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb
index 445b699..fa8b3d9 100644
--- a/app/controllers/payments_controller.rb
+++ b/app/controllers/payments_controller.rb
@@ -1,6 +1,7 @@
class PaymentsController < ApplicationController
before_action :set_payment, only: [ :show, :update, :destroy ]
- before_action :set_billing_cycle, only: [ :new, :create ]
+ before_action :set_billing_cycle, only: [ :new, :create, :mark_as_paid ]
+ before_action :authorize_billing_cycle_management, only: [ :mark_as_paid ]
before_action :authorize_payment_access, only: [ :show ]
before_action :authorize_payment_modification, only: [ :update, :destroy ]
@@ -69,6 +70,25 @@ def create
end
end
+ def mark_as_paid
+ user = User.find_by(id: params[:user_id])
+ return redirect_back_or_to @billing_cycle, alert: "User not found." unless user
+ return redirect_back_or_to @billing_cycle, alert: "User has already paid." if @billing_cycle.user_successfully_paid?(user)
+
+ @payment = @billing_cycle.payments.build(
+ user:,
+ status: "confirmed",
+ notes: "Marked as Paid by #{Current.user.full_name}",
+ confirmed_by: Current.user,
+ confirmation_date: Date.today).tap { |p| p.amount = p.expected_amount }
+
+ if @payment.save
+ redirect_back_or_to @billing_cycle, notice: "Marked as Paid for #{user.full_name}"
+ else
+ redirect_back_or_to @billing_cycle, alert: "Couldn't mark as paid."
+ end
+ end
+
def update
if @payment.update(payment_params)
redirect_to @payment, notice: "Payment updated successfully!"
@@ -108,6 +128,12 @@ def authorize_payment_modification
authorize!(:destroy, @payment) if action_name == "destroy"
end
+ def authorize_billing_cycle_management
+ return true if @billing_cycle.project&.is_owner?(Current.user)
+
+ redirect_back_or_to @billing_cycle, alert: "You are not authorized to update this resource"
+ end
+
def payment_params
params.require(:payment).permit(:amount, :transaction_id, :notes, :evidence)
end
diff --git a/app/frontend/pages/billing_cycles/Index.svelte b/app/frontend/pages/billing_cycles/Index.svelte
index 9c48b67..4392b0a 100644
--- a/app/frontend/pages/billing_cycles/Index.svelte
+++ b/app/frontend/pages/billing_cycles/Index.svelte
@@ -13,7 +13,6 @@
Plus,
Calendar,
DollarSign,
- Users,
AlertCircle,
CheckCircle,
Clock,
@@ -234,15 +233,15 @@
- Total Amount
+ Collected Amount
- {formatCurrency(safeStats.total_amount, project.currency)}
+ {formatCurrency(safeStats.total_paid, project.currency)}
- {formatCurrency(safeStats.total_paid, project.currency)} collected
+ of total {formatCurrency(safeStats.total_amount, project.currency)} to date
@@ -276,7 +275,7 @@
{formatCurrency(safeStats.total_remaining, project.currency)}
- {safeStats.due_soon} due soon
+ {safeStats.due_soon} due soon, {safeStats.overdue} overdue
@@ -432,31 +431,44 @@
{#if cycle.days_until_due !== undefined}
- {#if cycle.overdue}
-
- Overdue by {Math.abs(cycle.days_until_due)} day{Math.abs(
- cycle.days_until_due,
- ) === 1
- ? ""
- : "s"}
-
- {:else if cycle.days_until_due === 0}
- Due today
+ {#if cycle.fully_paid}
+
+ Settled - no payment due 🎉
+
{:else}
-
- Due in {cycle.days_until_due} day{cycle.days_until_due ===
- 1
- ? ""
- : "s"}
-
+ {#if cycle.overdue}
+
+ Overdue by {Math.abs(cycle.days_until_due)} day{Math.abs(
+ cycle.days_until_due,
+ ) === 1
+ ? ""
+ : "s"}
+
+ {:else if cycle.days_until_due === 0}
+ Due today
+ {:else}
+
+ Due in {cycle.days_until_due} day{cycle.days_until_due ===
+ 1
+ ? ""
+ : "s"}
+
+ {/if}
{/if}
{/if}
-
+
+ {#if cycle.can_pay}
+
+ {/if}
{/each}
@@ -312,11 +324,20 @@
-
+
Payment Details ({payments.length})
+
+ {#if user_permissions.can_pay}
+
+ Submit Proof
+
+ {/if}
{#if payments.length === 0}
diff --git a/app/frontend/pages/payment_confirmations/Index.svelte b/app/frontend/pages/payment_confirmations/Index.svelte
index 2a35340..f8be8b4 100644
--- a/app/frontend/pages/payment_confirmations/Index.svelte
+++ b/app/frontend/pages/payment_confirmations/Index.svelte
@@ -329,7 +329,7 @@