diff --git a/.github/workflows/ci-auto-lint.yml b/.github/workflows/ci-auto-lint.yml
index a54a0904..f99f9f5d 100644
--- a/.github/workflows/ci-auto-lint.yml
+++ b/.github/workflows/ci-auto-lint.yml
@@ -24,4 +24,4 @@ jobs:
ruby-version: "4.0.1"
bundler-cache: true
- name: Run RuboCop
- run: bundle exec rubocop --format github --force-exclusion
+ run: bin/rubocop --format github --force-exclusion
diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml
new file mode 100644
index 00000000..06aa8060
--- /dev/null
+++ b/.github/workflows/ci-dev.yml
@@ -0,0 +1,81 @@
+# CI unique pour la branche dev : lint + sécurité + tests en parallèle (un seul run GitHub Actions).
+# Permissions minimales (contents: read) — pas d’écriture dépôt ; artefact tests seulement.
+name: ci-dev
+
+on:
+ push:
+ branches: [dev]
+ pull_request:
+ branches: [dev]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-dev-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "4.0.1"
+ bundler-cache: true
+ - uses: actions/setup-node@v6
+ with:
+ node-version: "22"
+ - name: RuboCop
+ run: bin/rubocop --format github --force-exclusion
+
+ security:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "4.0.1"
+ bundler-cache: true
+ - name: Brakeman
+ run: bin/brakeman --no-pager
+ - name: Bundler Audit
+ run: bundle exec bundle-audit check --update
+
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "4.0.1"
+ bundler-cache: true
+ - uses: actions/setup-node@v6
+ with:
+ node-version: "22"
+ - name: Build Tailwind CSS
+ run: bundle exec rails tailwindcss:build
+ - name: Prepare test databases
+ run: RAILS_ENV=test bin/rails db:prepare
+ - name: Run full test suite with coverage
+ env:
+ CI: true
+ run: |
+ mkdir -p tmp
+ bin/rspec --format progress --format json --out tmp/rspec.json
+ - name: Verify coverage threshold
+ run: |
+ COVERAGE=$(ruby -rjson -e 'f="coverage/.last_run.json"; puts(File.exist?(f) ? JSON.parse(File.read(f)).dig("result","line").to_f : 0)')
+ echo "Coverage: ${COVERAGE}%"
+ awk "BEGIN {exit !(${COVERAGE} >= 58)}"
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-results
+ path: |
+ tmp/rspec.json
+ coverage
+ if-no-files-found: warn
+ retention-days: 7
diff --git a/.github/workflows/ci-docker-build.yml b/.github/workflows/ci-docker-build.yml
index ebe5c32d..e710ea5e 100644
--- a/.github/workflows/ci-docker-build.yml
+++ b/.github/workflows/ci-docker-build.yml
@@ -16,12 +16,10 @@ concurrency:
jobs:
docker-build-check:
runs-on: ubuntu-latest
- env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6
- - uses: docker/setup-buildx-action@v3
- - uses: docker/build-push-action@v6
+ - uses: docker/setup-buildx-action@v4
+ - uses: docker/build-push-action@v7
with:
context: .
push: false
diff --git a/.github/workflows/ci-lint-audit.yml b/.github/workflows/ci-lint-audit.yml
deleted file mode 100644
index 09a0817e..00000000
--- a/.github/workflows/ci-lint-audit.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-name: ci-lint-audit
-
-on:
- push:
- branches: [dev]
- pull_request:
- branches: [dev]
-
-permissions:
- contents: read
-
-concurrency:
- group: ci-lint-audit-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: ruby/setup-ruby@v1
- with:
- ruby-version: "4.0.1"
- bundler-cache: true
- - uses: actions/setup-node@v6
- with:
- node-version: "22"
- - name: Run RuboCop
- run: bundle exec rubocop --format github --force-exclusion
-
- security:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: ruby/setup-ruby@v1
- with:
- ruby-version: "4.0.1"
- bundler-cache: true
- - name: Run Brakeman
- run: bundle exec brakeman --no-pager
- - name: Run Bundler Audit
- run: bundle exec bundle-audit check --update
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
deleted file mode 100644
index 1fc86ade..00000000
--- a/.github/workflows/ci-tests.yml
+++ /dev/null
@@ -1,46 +0,0 @@
-name: ci-tests
-
-on:
- push:
- branches: [dev]
- pull_request:
- branches: [dev]
-
-permissions:
- contents: read
-
-concurrency:
- group: ci-tests-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- - uses: ruby/setup-ruby@v1
- with:
- ruby-version: "4.0.1"
- bundler-cache: true
- - uses: actions/setup-node@v6
- with:
- node-version: "22"
- - name: Build Tailwind CSS
- run: bundle exec rails tailwindcss:build
- - name: Run full test suite with coverage
- run: bundle exec rspec --format json --out tmp/rspec.json
- - name: Verify coverage threshold
- run: |
- COVERAGE=$(ruby -rjson -e 'f="coverage/.last_run.json"; puts(File.exist?(f) ? JSON.parse(File.read(f)).dig("result","line").to_f : 0)')
- echo "Coverage: ${COVERAGE}%"
- awk "BEGIN {exit !(${COVERAGE} >= 52)}"
- - name: Upload test results
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: test-results
- path: |
- tmp/rspec.json
- coverage
- if-no-files-found: warn
- retention-days: 7
diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml
index 3ae0b278..0fe58b35 100644
--- a/.github/workflows/deploy-production.yml
+++ b/.github/workflows/deploy-production.yml
@@ -15,17 +15,15 @@ concurrency:
jobs:
build-push:
runs-on: ubuntu-latest
- env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ - uses: docker/setup-buildx-action@v4
+ - uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - uses: docker/build-push-action@v6
+ - uses: docker/build-push-action@v7
with:
context: .
push: true
@@ -45,7 +43,7 @@ jobs:
with:
ruby-version: "4.0.1"
bundler-cache: true
- - uses: webfactory/ssh-agent@v0.9.0
+ - uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
- name: Deploy production with Kamal
@@ -63,7 +61,7 @@ jobs:
with:
ruby-version: "4.0.1"
bundler-cache: true
- - uses: webfactory/ssh-agent@v0.9.0
+ - uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
- name: Check production health
diff --git a/.github/workflows/deploy-promote-staging.yml b/.github/workflows/deploy-promote-staging.yml
index 9a184cbc..ce33d920 100644
--- a/.github/workflows/deploy-promote-staging.yml
+++ b/.github/workflows/deploy-promote-staging.yml
@@ -28,7 +28,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
- for required in "ci-lint-audit.yml" "ci-tests.yml" "ci-docker-build.yml"; do
+ for required in "ci-dev.yml" "ci-docker-build.yml"; do
status=$(gh run list \
--workflow="$required" \
--branch=dev \
diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml
index 07010c6b..2e5454a8 100644
--- a/.github/workflows/deploy-staging.yml
+++ b/.github/workflows/deploy-staging.yml
@@ -15,17 +15,15 @@ concurrency:
jobs:
build-push:
runs-on: ubuntu-latest
- env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
steps:
- uses: actions/checkout@v6
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ - uses: docker/setup-buildx-action@v4
+ - uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - uses: docker/build-push-action@v6
+ - uses: docker/build-push-action@v7
with:
context: .
push: true
@@ -45,7 +43,7 @@ jobs:
with:
ruby-version: "4.0.1"
bundler-cache: true
- - uses: webfactory/ssh-agent@v0.9.0
+ - uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
- name: Deploy staging with Kamal
@@ -63,7 +61,7 @@ jobs:
with:
ruby-version: "4.0.1"
bundler-cache: true
- - uses: webfactory/ssh-agent@v0.9.0
+ - uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
- name: Check staging health
diff --git a/.gitignore b/.gitignore
index c3810244..21a7753d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,6 +74,7 @@ db/schema_clean.rb
# CI / linter outputs (never commit these)
rspec.xml
+spec/examples.txt
rubocop.json
identifier.sqlite
@@ -95,3 +96,4 @@ SCRATCH.md
# Watchman (not used - we use Tailwind watcher instead)
/watchman
+docs/internal/ux_audit_2025_01.md
diff --git a/.rubocop.yml b/.rubocop.yml
index 0b50b51a..0ad9c398 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -69,4 +69,51 @@ Performance/MapMethodChain:
Performance/Sum:
Enabled: true
Performance/Detect:
- Enabled: true
\ No newline at end of file
+ Enabled: true
+
+# Phase audit metrics (conservative thresholds to avoid CI churn)
+Metrics/ClassLength:
+ Enabled: true
+ Max: 320
+ Include:
+ - 'app/models/person.rb'
+ - 'app/controllers/admin/users_controller.rb'
+ - 'app/controllers/admin/payments_controller.rb'
+ - 'app/services/member_management_service.rb'
+ Exclude:
+ - 'app/views/**/*'
+Metrics/ModuleLength:
+ Enabled: true
+ Max: 220
+ Include:
+ - 'app/models/person.rb'
+ - 'app/controllers/admin/users_controller.rb'
+ - 'app/controllers/admin/payments_controller.rb'
+ - 'app/services/member_management_service.rb'
+Metrics/MethodLength:
+ Enabled: true
+ Max: 45
+ Include:
+ - 'app/models/person.rb'
+ - 'app/controllers/admin/users_controller.rb'
+ - 'app/controllers/admin/payments_controller.rb'
+ - 'app/services/member_management_service.rb'
+Metrics/BlockLength:
+ Enabled: true
+ Max: 60
+ Include:
+ - 'app/models/person.rb'
+ - 'app/controllers/admin/users_controller.rb'
+ - 'app/controllers/admin/payments_controller.rb'
+ - 'app/services/member_management_service.rb'
+ Exclude:
+ - 'config/routes.rb'
+ - 'spec/**/*'
+Metrics/AbcSize:
+ Enabled: true
+ Max: 60
+ Include:
+ - 'app/models/person.rb'
+ - 'app/controllers/admin/users_controller.rb'
+ - 'app/controllers/admin/payments_controller.rb'
+ - 'app/services/member_management_service.rb'
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index 823d1e65..c74883bc 100644
--- a/Gemfile
+++ b/Gemfile
@@ -57,6 +57,8 @@ group :development, :test do
# RSpec testing framework - ESSENTIELS
gem "factory_bot_rails"
+ gem "guard", require: false
+ gem "guard-rspec", require: false
gem "rspec-rails", "~> 8.0.2"
gem "shoulda-matchers" # Pour tester les validations Rails
# gem "database_cleaner-active_record" # Nettoyage de DB entre tests
diff --git a/Gemfile.lock b/Gemfile.lock
index b86563c7..47f38fac 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -102,6 +102,7 @@ GEM
xpath (~> 3.2)
childprocess (5.1.0)
logger (~> 1.5)
+ coderay (1.1.3)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
@@ -141,11 +142,28 @@ GEM
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
+ formatador (1.2.3)
+ reline
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
+ guard (2.20.1)
+ formatador (>= 0.2.4)
+ listen (>= 2.7, < 4.0)
+ logger (~> 1.6)
+ lumberjack (>= 1.0.12, < 2.0)
+ nenv (~> 0.1)
+ notiffany (~> 0.0)
+ pry (>= 0.13.0)
+ shellany (~> 0.0)
+ thor (>= 0.18.1)
+ guard-compat (1.2.1)
+ guard-rspec (4.7.3)
+ guard (~> 2.1)
+ guard-compat (~> 1.1)
+ rspec (>= 2.99.0, < 4.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@@ -189,10 +207,15 @@ GEM
railties (>= 6.1)
rexml
lint_roller (1.1.0)
+ listen (3.10.0)
+ logger
+ rb-fsevent (~> 0.10, >= 0.10.3)
+ rb-inotify (~> 0.9, >= 0.9.10)
logger (1.7.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
+ lumberjack (1.4.2)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
@@ -206,6 +229,7 @@ GEM
yajl-ruby
marcel (1.1.0)
matrix (0.4.3)
+ method_source (1.1.0)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
@@ -213,6 +237,7 @@ GEM
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
+ nenv (0.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.3)
@@ -242,6 +267,9 @@ GEM
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
+ notiffany (0.1.3)
+ nenv (~> 0.1)
+ shellany (~> 0.0)
ostruct (0.6.3)
pagy (6.5.0)
parallel (2.0.1)
@@ -256,6 +284,10 @@ GEM
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
rack
+ pry (0.16.0)
+ coderay (~> 1.1)
+ method_source (~> 1.0)
+ reline (>= 0.6.0)
psych (5.3.1)
date
stringio
@@ -314,6 +346,9 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.4.2)
+ rb-fsevent (0.11.2)
+ rb-inotify (0.11.1)
+ ffi (~> 1.0)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
@@ -322,6 +357,10 @@ GEM
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
+ rspec (3.13.2)
+ rspec-core (~> 3.13.0)
+ rspec-expectations (~> 3.13.0)
+ rspec-mocks (~> 3.13.0)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
@@ -384,6 +423,7 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
+ shellany (0.0.1)
shoulda-matchers (7.0.1)
activesupport (>= 7.1)
simplecov (0.22.0)
@@ -489,6 +529,8 @@ DEPENDENCIES
dotenv-rails
factory_bot_rails
faker
+ guard
+ guard-rspec
image_processing (~> 1.2)
importmap-rails
jbuilder
@@ -551,6 +593,7 @@ CHECKSUMS
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec
+ coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
@@ -577,8 +620,12 @@ CHECKSUMS
ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e
+ formatador (1.2.3) sha256=19fa898133c2c26cdbb5d09f6998c1e137ad9427a046663e55adfe18b950d894
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
+ guard (2.20.1) sha256=ab9cd7873854e6b76080c0589f781ff3e390e441bdda20165804df54f977015a
+ guard-compat (1.2.1) sha256=3ad21ab0070107f92edfd82610b5cdc2fb8e368851e72362ada9703443d646fe
+ guard-rspec (4.7.3) sha256=a47ba03cbd1e3c71e6ae8645cea97e203098a248aede507461a43e906e2f75ca
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
@@ -592,16 +639,20 @@ CHECKSUMS
letter_opener (1.10.0) sha256=2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2
letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
+ listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04
+ lumberjack (1.4.2) sha256=40de5ae46321380c835031bcc1370f13bba304d29f2b5f5bb152061a5a191b95
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
mailjet (1.8.3) sha256=50980a5664e38f379d191887834a4834af3c3cf7a2a177864c052482346db77a
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
+ method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
minitest (6.0.5) sha256=f007d7246bf4feea549502842cd7c6aba8851cdc9c90ba06de9c476c0d01155c
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
+ nenv (0.3.0) sha256=d9de6d8fb7072228463bf61843159419c969edb34b3cef51832b516ae7972765
net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
@@ -617,6 +668,7 @@ CHECKSUMS
nokogiri (1.19.2-arm-linux-musl) sha256=61114d44f6742ff72194a1b3020967201e2eb982814778d130f6471c11f9828c
nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8
+ notiffany (0.1.3) sha256=d37669605b7f8dcb04e004e6373e2a780b98c776f8eb503ac9578557d7808738
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
pagy (6.5.0) sha256=3b2418e79bd67abbac820dddaf6fd76665bac4170b2fef0f4a601a6400ef1a07
parallel (2.0.1) sha256=337782d3e39f4121e67563bf91dd8ece67f48923d90698614773a0ec9a5b2c7d
@@ -625,6 +677,7 @@ CHECKSUMS
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e
+ pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
puma (8.0.0) sha256=1681050b8b60fab1d3033255ab58b6aec64cd063e43fc6f8204bcb8bf9364b88
@@ -643,10 +696,13 @@ CHECKSUMS
railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+ rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
@@ -664,6 +720,7 @@ CHECKSUMS
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.43.0) sha256=a634377b964b701c6ac0a009ce3a08fa34ec1e1e7fe9a6d57e3088d14529a65c
+ shellany (0.0.1) sha256=0e127a9132698766d7e752e82cdac8250b6adbd09e6c0a7fbbb6f61964fedee7
shoulda-matchers (7.0.1) sha256=b4bfd8744c10e0a36c8ac1a687f921ee7e25ed529e50488d61b79a8688749c77
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
diff --git a/Guardfile b/Guardfile
new file mode 100644
index 00000000..8b7fad7a
--- /dev/null
+++ b/Guardfile
@@ -0,0 +1,70 @@
+# A sample Guardfile
+# More info at https://github.com/guard/guard#readme
+
+## Uncomment and set this to only include directories you want to watch
+# directories %w(app lib config test spec features) \
+# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")}
+
+## Note: if you are using the `directories` clause above and you are not
+## watching the project directory ('.'), then you will want to move
+## the Guardfile to a watched dir and symlink it back, e.g.
+#
+# $ mkdir config
+# $ mv Guardfile config/
+# $ ln -s config/Guardfile .
+#
+# and, you'll have to watch "config/Guardfile" instead of "Guardfile"
+
+# Note: The cmd option is now required due to the increasing number of ways
+# rspec may be run, below are examples of the most common uses.
+# * bundler: 'bundle exec rspec'
+# * bundler binstubs: 'bin/rspec'
+# * spring: 'bin/rspec' (This will use spring if running and you have
+# installed the spring binstubs per the docs)
+# * zeus: 'zeus rspec' (requires the server to be started separately)
+# * 'just' rspec: 'rspec'
+
+guard :rspec, cmd: "bin/rspec" do
+ require "guard/rspec/dsl"
+ dsl = Guard::RSpec::Dsl.new(self)
+
+ # Feel free to open issues for suggestions and improvements
+
+ # RSpec files
+ rspec = dsl.rspec
+ watch(rspec.spec_helper) { rspec.spec_dir }
+ watch(rspec.spec_support) { rspec.spec_dir }
+ watch(rspec.spec_files)
+
+ # Ruby files
+ ruby = dsl.ruby
+ dsl.watch_spec_files_for(ruby.lib_files)
+
+ # Rails files
+ rails = dsl.rails(view_extensions: %w[erb haml slim])
+ dsl.watch_spec_files_for(rails.app_files)
+ dsl.watch_spec_files_for(rails.views)
+
+ watch(rails.controllers) do |m|
+ [
+ rspec.spec.call("routing/#{m[1]}_routing"),
+ rspec.spec.call("controllers/#{m[1]}_controller"),
+ rspec.spec.call("acceptance/#{m[1]}")
+ ]
+ end
+
+ # Rails config changes
+ watch(rails.spec_helper) { rspec.spec_dir }
+ watch(rails.routes) { "#{rspec.spec_dir}/routing" }
+ watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" }
+
+ # Capybara features specs
+ watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") }
+ watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") }
+
+ # Turnip features and steps
+ watch(%r{^spec/acceptance/(.+)\.feature$})
+ watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m|
+ Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance"
+ end
+end
diff --git a/README.md b/README.md
index d7301549..fd37aacf 100644
--- a/README.md
+++ b/README.md
@@ -41,13 +41,17 @@ Le projet utilise **RSpec uniquement**. Le legacy Minitest (`test/`) a ete retir
La stack d'authentification reste **native Rails 8** (pas Devise).
```bash
+bin/rspec spec/services # RSpec sérialisé pour SQLite
bin/test # suite complète + couverture
bin/test_fast # models + services (rapide)
bin/test --no-coverage # sans SimpleCov
-bundle exec rspec # commande RSpec canonique
+bin/test_watch # watch mode via Guard
bundle exec rubocop --force-exclusion
```
+Avec SQLite, n'exécutez pas plusieurs processus RSpec en parallèle sur la même base `tmp/test.sqlite3`.
+Utilisez `bin/rspec`, `bin/test`, `bin/test_fast` et `bin/test_watch` : ils sérialisent l'accès via un lockfile.
+
---
## Déploiement
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 59fa3663..b7739add 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1 +1,5 @@
-/* (legacy placeholder file intentionally left minimal) */
\ No newline at end of file
+/*
+ *= require opening_hours_editor
+ */
+
+/* Legacy stylesheet kept intentionally minimal. */
\ No newline at end of file
diff --git a/app/assets/stylesheets/opening_hours_editor.css b/app/assets/stylesheets/opening_hours_editor.css
new file mode 100644
index 00000000..b89dec67
--- /dev/null
+++ b/app/assets/stylesheets/opening_hours_editor.css
@@ -0,0 +1,23 @@
+/* Opening hours editor native select styling */
+.hour-select,
+.minute-select {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
+ background-position: right 8px center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+ padding-right: 32px;
+}
+
+.hour-select:focus,
+.minute-select:focus {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%231F5C55' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
+}
+
+.hour-select:hover,
+.minute-select:hover {
+ border-color: #1f5c55;
+ box-shadow: 0 0 0 1px rgba(31, 92, 85, 0.1);
+}
diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css
index 62d6cf85..8ba4e1e2 100644
--- a/app/assets/tailwind/application.css
+++ b/app/assets/tailwind/application.css
@@ -4,6 +4,29 @@
@import "tailwindcss";
+/*
+ * Leaflet CSS is loaded via in layouts/application.html.erb (before tailwind.css).
+ * Embedding @import url(leaflet) here breaks Tailwind v4 output: "@import must precede @layer".
+ *
+ * Preflight img { max-width: 100% } breaks tiles — reset inside maps only.
+ */
+.leaflet-container img {
+ max-width: none !important;
+}
+
+.leaflet-map-shell {
+ position: relative;
+}
+
+/* Force full bleed inside the rounded shell (Leaflet defaults + % heights are fragile). */
+.leaflet-map-shell-inner.leaflet-container {
+ position: absolute !important;
+ inset: 0 !important;
+ width: 100% !important;
+ height: 100% !important;
+ outline: none;
+}
+
@import "./components/buttons.css";
@import "./components/forms.css";
@import "./components/hero.css";
@@ -76,6 +99,8 @@
html {
@apply h-full;
overflow-y: auto;
+ /* Réserve l’espace scrollbar + évite le collage au bord droit (nav, etc.) */
+ scrollbar-gutter: stable;
}
html::-webkit-scrollbar {
@@ -117,8 +142,11 @@
.text-brand-accent { color: #5836A5; }
.bg-brand-accent { background-color: #5836A5; }
.bg-brand-accent\/10 { background-color: rgba(88, 54, 165, 0.1); }
+ /* Opacity variants used in markup; not always emitted by Tailwind v4 scan */
+ .bg-brand-accent\/72 { background-color: rgba(88, 54, 165, 0.72); }
.border-brand-accent { border-color: #5836A5; }
.border-brand-accent\/20 { border-color: rgba(88, 54, 165, 0.2); }
+ .border-brand-accent\/45 { border-color: rgba(88, 54, 165, 0.45); }
.focus-visible\:outline-brand-primary:focus-visible { outline-color: #1F5C55; }
.focus-visible\:outline-brand-accent:focus-visible { outline-color: #5836A5; }
diff --git a/app/assets/tailwind/components/buttons.css b/app/assets/tailwind/components/buttons.css
index 550b0f39..f86e9a26 100644
--- a/app/assets/tailwind/components/buttons.css
+++ b/app/assets/tailwind/components/buttons.css
@@ -116,5 +116,71 @@
color: var(--brand-primary);
border-color: var(--brand-primary);
}
-}
+ .admin-btn-primary {
+ @apply inline-flex items-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2;
+ background-color: var(--brand-primary);
+ }
+
+ .admin-btn-primary:hover {
+ background-color: #194A45;
+ }
+
+ .admin-btn-primary:focus-visible {
+ --tw-ring-color: var(--brand-primary);
+ }
+
+ .admin-btn-accent {
+ @apply inline-flex items-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2;
+ background-color: var(--brand-accent);
+ }
+
+ .admin-btn-accent:hover {
+ background-color: #4c2d8a;
+ }
+
+ .admin-btn-accent:focus-visible {
+ --tw-ring-color: var(--brand-accent);
+ }
+
+ .public-cta {
+ @apply inline-flex items-center justify-center rounded-lg border-2 px-8 py-4 text-lg font-bold tracking-wide shadow-lg transition-all duration-300;
+ background-color: var(--brand-primary);
+ border-color: var(--brand-primary);
+ color: #fff;
+ }
+
+ .public-cta:hover {
+ background-color: #f3f4f6;
+ color: var(--brand-primary);
+ border-color: var(--brand-primary);
+ }
+
+ .public-cta:hover,
+ .public-cta:focus-visible {
+ transform: translateY(-0.25rem);
+ }
+
+ .public-cta-pill {
+ @apply inline-flex items-center justify-center rounded-full border px-6 py-3 text-sm font-semibold shadow-md transition-colors duration-300;
+ background-color: var(--brand-primary);
+ border-color: var(--brand-primary);
+ color: #fff;
+ }
+
+ .public-cta-pill:hover {
+ background-color: #fff;
+ color: var(--brand-primary);
+ }
+
+ .public-cta-ghost {
+ @apply inline-flex items-center justify-center rounded-full border px-5 py-2.5 text-sm font-semibold transition-colors duration-300;
+ border-color: rgba(31, 92, 85, 0.4);
+ color: var(--brand-primary);
+ background-color: #fff;
+ }
+
+ .public-cta-ghost:hover {
+ background-color: rgba(31, 92, 85, 0.1);
+ }
+}
diff --git a/app/assets/tailwind/components/forms.css b/app/assets/tailwind/components/forms.css
index 778348b4..6a1f6cc8 100644
--- a/app/assets/tailwind/components/forms.css
+++ b/app/assets/tailwind/components/forms.css
@@ -10,5 +10,8 @@
.form-control {
@apply block w-full px-4 py-2 text-gray-800 placeholder-gray-400 bg-white border border-gray-200 rounded-lg transition focus:outline-none focus:ring-1 focus:ring-[#5836A5] focus:border-[#5836A5];
}
-}
+ .auth-field {
+ @apply block w-full px-4 py-2 text-gray-800 placeholder-gray-400 bg-white border border-gray-200 rounded-lg transition focus:outline-none focus:ring-1 focus:ring-[#5836A5] focus:border-[#5836A5];
+ }
+}
diff --git a/app/assets/tailwind/components/layout.css b/app/assets/tailwind/components/layout.css
index 940b7cc3..bb77a6ea 100644
--- a/app/assets/tailwind/components/layout.css
+++ b/app/assets/tailwind/components/layout.css
@@ -4,6 +4,46 @@
margin-bottom: 2rem !important;
}
+ .page-container {
+ @apply mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8;
+ }
+
+ .page-container-wide {
+ @apply mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8;
+ }
+
+ .surface-card {
+ @apply overflow-hidden rounded-lg bg-white shadow-lg;
+ }
+
+ .admin-page-header {
+ @apply mb-6 rounded-lg bg-gradient-to-r from-[#1F5C55] to-[#194A45] shadow-lg;
+ }
+
+ .admin-metric-card {
+ @apply rounded-lg border border-gray-200 bg-white p-4 shadow-lg transition-shadow duration-200 hover:shadow-xl;
+ }
+
+ .admin-filter-panel {
+ @apply mb-6 rounded-lg bg-gray-50 p-4;
+ }
+
+ .public-hero-shell {
+ @apply relative overflow-hidden rounded-[32px] shadow-2xl;
+ }
+
+ .public-surface {
+ @apply rounded-[28px] bg-white/95 shadow-lg;
+ }
+
+ .public-surface-bordered {
+ @apply rounded-[28px] border border-white/80 bg-white/95 shadow-lg;
+ }
+
+ .public-soft-card {
+ @apply rounded-2xl border border-gray-100 bg-white shadow-md;
+ }
+
.outline-contrast-primary {
outline-color: rgba(31, 92, 85, 0.85);
}
@@ -13,4 +53,3 @@
font-weight: 600;
}
}
-
diff --git a/app/assets/tailwind/components/swiper_overrides.css b/app/assets/tailwind/components/swiper_overrides.css
index 06bb8f2e..b50f36f4 100644
--- a/app/assets/tailwind/components/swiper_overrides.css
+++ b/app/assets/tailwind/components/swiper_overrides.css
@@ -23,10 +23,6 @@
display: block;
}
- .swiper-horizontal {
- touch-action: pan-y;
- }
-
.swiper-button-next,
.swiper-button-prev {
position: absolute;
diff --git a/app/components/admin/payments/payment_display_component.rb b/app/components/admin/payments/payment_display_component.rb
index 35929731..09fc3ba0 100644
--- a/app/components/admin/payments/payment_display_component.rb
+++ b/app/components/admin/payments/payment_display_component.rb
@@ -46,7 +46,7 @@ def display_payment_lines
if payment.payment_lines.any?
payment.payment_lines.map do |line|
content_tag :div, class: "text-xs" do
- "#{line.description || line.item_type}: #{number_to_currency(line.amount_cents / 100.0, unit: '€', separator: ',', delimiter: ' ')}"
+ "#{line.history_description}: #{number_to_currency(line.amount_cents / 100.0, unit: '€', separator: ',', delimiter: ' ')}"
end
end.join.html_safe
else
diff --git a/app/components/admin/users/action_buttons_component.rb b/app/components/admin/users/action_buttons_component.rb
deleted file mode 100644
index fd164a67..00000000
--- a/app/components/admin/users/action_buttons_component.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- module Users
- class ActionButtonsComponent < ViewComponent::Base
- # LEGACY: kept for reference; not currently used in admin user views.
- def initialize(person:)
- @person = person
- end
-
- private
-
- attr_reader :person
-
- def membership_action_button
- current_membership = person.current_membership
-
- if current_membership.nil?
- # Pas d'adhésion active
- link_to "Ajouter une adhésion",
- new_admin_membership_path(person_id: person.id),
- class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
- elsif current_membership.membership_type.basic?
- # Adhésion basique -> peut upgrader vers cirque
- link_to "Upgrader vers Cirque",
- new_admin_membership_path(person_id: person.id, upgrade: true),
- class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
- elsif current_membership.membership_type.circus?
- # Adhésion cirque -> peut renouveler
- if current_membership.ended_at < 30.days.from_now
- link_to "Renouveler adhésion",
- new_admin_membership_path(person_id: person.id, renew: true),
- class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
- else
- # Adhésion cirque valide -> pas de bouton
- content_tag :span, "Adhésion Cirque active",
- class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100"
- end
- else
- # Autres cas -> renouveler
- link_to "Renouveler adhésion",
- new_admin_membership_path(person_id: person.id, renew: true),
- class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
- end
- end
-
- def subscription_action_button
- current_membership = person.current_membership
-
- if current_membership.nil?
- # Pas d'adhésion -> pas de cotisation possible
- content_tag :span, "Adhésion requise",
- class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100"
- elsif current_membership.membership_type.basic?
- # Adhésion basique -> pas de cotisation
- content_tag :span, "Adhésion Basique",
- class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100"
- elsif current_membership.membership_type.circus?
- # Adhésion cirque -> peut ajouter des cotisations
- active_contribution = person.contributions.active.first
-
- buttons = []
- if active_contribution
- label = "Voir cotisation"
- if active_contribution.contribution_formula.duration == "pack10"
- remaining_entries = active_contribution.remaining_entries.to_i
- label = "Voir cotisation (#{remaining_entries} restantes)" if remaining_entries.positive?
- end
- buttons << link_to(
- label,
- admin_user_path(person.user ? person.user.id : "person_#{person.id}"),
- class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] mr-2"
- )
- end
-
- buttons << link_to(
- "Ajouter une cotisation",
- new_admin_subscription_plan_path(person_id: person.id),
- class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
- )
-
- safe_join(buttons)
- else
- # Autres cas
- content_tag :span, "Non applicable",
- class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100"
- end
- end
- end
- end
-end
diff --git a/app/components/admin/users/editable_member_number_component.html.erb b/app/components/admin/users/editable_member_number_component.html.erb
index 113740fd..bcd99cec 100644
--- a/app/components/admin/users/editable_member_number_component.html.erb
+++ b/app/components/admin/users/editable_member_number_component.html.erb
@@ -3,8 +3,8 @@
#
- <%= form_with model: person, url: admin_user_path("person_#{person.id}"), method: :patch,
- local: false, id: form_id, class: "inline-flex items-center",
+ <%= form_with model: person, url: admin_user_path(Admin::Users::PersonRouteKey.call(person)), method: :patch,
+ local: false, id: form_id, class: "inline-flex items-center",
data: { "editable-member-number-target": "form" } do |form| %>
<%= form.text_field :member_number,
value: member_number,
diff --git a/app/components/admin/users/member_number_change_component.html.erb b/app/components/admin/users/member_number_change_component.html.erb
index aad8b6f8..4641474e 100644
--- a/app/components/admin/users/member_number_change_component.html.erb
+++ b/app/components/admin/users/member_number_change_component.html.erb
@@ -21,8 +21,8 @@
- <%= form_with model: person, url: admin_user_path("person_#{person.id}"), method: :patch,
- local: false, id: "member-number-change-form",
+ <%= form_with model: person, url: admin_user_path(Admin::Users::PersonRouteKey.call(person)), method: :patch,
+ local: false, id: "member-number-change-form",
data: { "member-number-change-target": "form" } do |form| %>
diff --git a/app/components/admin/users/membership_display_component.html.erb b/app/components/admin/users/membership_display_component.html.erb
index 3892b830..66693c75 100644
--- a/app/components/admin/users/membership_display_component.html.erb
+++ b/app/components/admin/users/membership_display_component.html.erb
@@ -52,11 +52,11 @@
<% end %>
- <% elsif can_purchase_subscriptions? %>
+ <% elsif can_purchase_contributions? %>
Aucune cotisation active
- <%= link_to "Ajouter une cotisation", new_admin_subscription_plan_path(person_id: person.id),
+ <%= link_to "Ajouter une cotisation", new_admin_contribution_formula_path(person_id: person.id),
class: "inline-flex items-center px-3 py-1 text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] rounded-md" %>
diff --git a/app/components/admin/users/membership_display_component.rb b/app/components/admin/users/membership_display_component.rb
index 82a119df..76d74635 100644
--- a/app/components/admin/users/membership_display_component.rb
+++ b/app/components/admin/users/membership_display_component.rb
@@ -39,7 +39,7 @@ def is_circus_member?
current_membership&.membership_type&.circus?
end
- def can_purchase_subscriptions?
+ def can_purchase_contributions?
is_circus_member?
end
diff --git a/app/components/admin/users/user_actions_component.rb b/app/components/admin/users/user_actions_component.rb
index 87f6d01d..44e702ff 100644
--- a/app/components/admin/users/user_actions_component.rb
+++ b/app/components/admin/users/user_actions_component.rb
@@ -23,8 +23,8 @@ def primary_actions
# Membership action
actions << membership_action
- # Subscription action
- actions << subscription_action
+ # Contribution action
+ actions << contribution_action
# Create web account action - moved to header
# if is_person_without_user
@@ -52,27 +52,27 @@ def membership_action
new_admin_membership_path(person_id: person.id),
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
elsif current_membership.membership_type.basic?
- link_to "Upgrader vers Cirque",
+ link_to "Passer en adhésion Cirque",
new_admin_membership_path(person_id: person.id, upgrade: true),
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
elsif current_membership.membership_type.circus?
if current_membership.ended_at < 30.days.from_now
- link_to "Renouveler adhésion",
+ link_to "Renouveler l'adhésion",
new_admin_membership_path(person_id: person.id, renew: true),
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
else
link_to "Voir adhésion",
- "#{admin_user_path(user ? user.id : "person_#{person.id}")}#membership",
+ "#{admin_user_path(user || person_route_key)}#membership",
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
end
else
- link_to "Renouveler adhésion",
+ link_to "Renouveler l'adhésion",
new_admin_membership_path(person_id: person.id, renew: true),
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
end
end
- def subscription_action
+ def contribution_action
current_membership = person.current_membership
if current_membership.nil?
@@ -83,7 +83,6 @@ def subscription_action
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100"
elsif current_membership.membership_type.circus?
active_contribution = person.contributions.active.first
-
links = []
if active_contribution
label = "Voir cotisation"
@@ -93,18 +92,16 @@ def subscription_action
end
links << link_to(
label,
- "#{admin_user_path(user ? user.id : "person_#{person.id}")}#payments",
+ "#{admin_user_path(person_route_key)}#payments",
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] mr-2"
)
end
-
links << link_to(
- "Ajouter une cotisation",
- new_admin_subscription_plan_path(person_id: person.id),
+ contribution_action_label,
+ new_admin_contribution_formula_path(person_id: person.id),
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]"
)
-
- safe_join(links)
+ helpers.safe_join(links)
else
content_tag :span, "Non applicable",
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-500 bg-gray-100"
@@ -119,18 +116,18 @@ def create_web_account_action
def edit_information_action
link_to "Modifier les informations",
- edit_person_admin_user_path("person_#{person.id}"),
+ edit_person_admin_user_path(person_route_key),
class: "text-[#1F5C55] hover:text-[#194A45] hover:underline"
end
def payment_history_action
- link_to "Historique des paiements",
+ link_to "Voir les paiements",
admin_payments_path(person_id: person.id),
class: "text-[#1F5C55] hover:text-[#194A45] hover:underline"
end
def make_donation_action
- link_to "Faire un don",
+ link_to "Enregistrer un don",
new_admin_donation_path(person_id: person.id),
class: "text-[#1F5C55] hover:text-[#194A45] hover:underline"
end
@@ -161,11 +158,27 @@ def delete_action
end
button_to "Supprimer",
- admin_user_path(user || "person_#{person.id}"),
+ admin_user_path(user || person_route_key),
method: :delete,
form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer cet utilisateur/cette personne ?" } },
class: "text-red-600 hover:text-red-800 hover:underline bg-transparent border-none cursor-pointer"
end
+
+ def person_route_key
+ Admin::Users::PersonRouteKey.call(person)
+ end
+
+ def contribution_action_label
+ active_contribution = person.contributions.active.first
+ return "Acheter une cotisation" unless active_contribution
+
+ return "Gérer les cotisations" unless active_contribution.contribution_formula.duration == "pack10"
+
+ remaining_entries = active_contribution.remaining_entries.to_i
+ return "Gérer les cotisations" unless remaining_entries.positive?
+
+ "Gérer les cotisations (#{remaining_entries} restantes)"
+ end
end
end
end
diff --git a/app/components/contextual_actions_component.html.erb b/app/components/contextual_actions_component.html.erb
index 06137642..2078c20b 100644
--- a/app/components/contextual_actions_component.html.erb
+++ b/app/components/contextual_actions_component.html.erb
@@ -4,7 +4,7 @@
# Construire l'URL selon le type d'action
url = case action[:type]
when :view
- admin_user_path("person_#{person.id}")
+ admin_user_path(Admin::Users::PersonRouteKey.call(person))
when :create_user
new_admin_user_path(person_id: person.id)
when :add_membership
diff --git a/app/components/contextual_actions_component.rb b/app/components/contextual_actions_component.rb
index 47516fca..8f03d702 100644
--- a/app/components/contextual_actions_component.rb
+++ b/app/components/contextual_actions_component.rb
@@ -17,7 +17,7 @@ def build_actions
actions = []
# Action "Voir" (toujours disponible)
- actions << { type: :view, icon: :view_icon, url: "person_#{person.id}",
+ actions << { type: :view, icon: :view_icon, url: Admin::Users::PersonRouteKey.call(person),
class: "action-icon text-gray-600 hover:text-[#1F5C55] mr-2",
title: "Voir la fiche", data: { turbo: false } }
@@ -33,7 +33,7 @@ def build_actions
# Actions de cotisation (si adhésion Cirque)
if person.has_active_membership? && person.current_membership.membership_type.name.downcase.include?("cirque")
- actions << { type: :subscription, icon: :subscription_icon, url: "#",
+ actions << { type: :contribution, icon: :contribution_icon, url: "#",
class: "action-icon text-indigo-600 hover:text-indigo-800 mr-2",
title: "Ajouter une cotisation" }
end
@@ -138,7 +138,7 @@ def add_icon
viewBox: "0 0 24 24")
end
- def subscription_icon
+ def contribution_icon
content_tag(:svg,
content_tag(:path, "",
stroke_linecap: "round",
diff --git a/app/controllers/admin/attendances_controller.rb b/app/controllers/admin/attendances_controller.rb
index 17e3ecfc..add22839 100644
--- a/app/controllers/admin/attendances_controller.rb
+++ b/app/controllers/admin/attendances_controller.rb
@@ -79,10 +79,7 @@ def set_attendance
end
def attendance_params
- params.expect(attendance: %i[person_id event_id date contribution_id attendance_list_id notes]).tap do |permitted|
- legacy = params[:attendance][:book_of_entry_id]
- permitted[:contribution_id] ||= legacy if legacy.present?
- end
+ params.expect(attendance: %i[person_id event_id date contribution_id attendance_list_id notes])
end
end
end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 8b1c3d12..aecff1ee 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -2,14 +2,44 @@
module Admin
class BaseController < ApplicationController
- before_action :require_admin_or_super_admin
+ # On évite le before_action global `require_authentication` ici : il ne distingue pas
+ # « pas connecté » et « connecté sans rôle staff », ce qui renvoie parfois vers /session/new
+ # alors qu’une session cookie existe déjà (compte public). On centralise la logique admin.
+ skip_before_action :require_authentication
+ before_action :require_admin_zone_access
private
- def require_admin_or_super_admin
+ def admin_person_path(person)
+ admin_user_path(Admin::Users::PersonRouteKey.call(person))
+ end
+
+ def add_person_context_breadcrumbs(person, current_label = nil)
+ add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
+ add_breadcrumb person.full_name, admin_person_path(person)
+ add_breadcrumb current_label, nil if current_label.present?
+ end
+
+ def require_admin_zone_access
+ resume_session
+
+ unless Current.session
+ store_return_location_for_admin_request
+ redirect_to new_session_path, alert: I18n.t("admin.base.sign_in_required_alert")
+ return
+ end
+
return if Current.user&.has_privileges?
- redirect_to root_path, alert: I18n.t("admin.base.unauthorized_alert")
+ redirect_to root_path, alert: I18n.t("admin.base.staff_only_alert")
+ end
+
+ def store_return_location_for_admin_request
+ uri = URI.parse(request.url)
+ path = uri.path.to_s.presence || "/"
+ session[:return_to_after_authenticating] = request.url unless non_gettable_redirect_path?(path)
+ rescue URI::InvalidURIError
+ session[:return_to_after_authenticating] = request.url
end
end
end
diff --git a/app/controllers/admin/subscription_plans_controller.rb b/app/controllers/admin/contribution_formulas_controller.rb
similarity index 56%
rename from app/controllers/admin/subscription_plans_controller.rb
rename to app/controllers/admin/contribution_formulas_controller.rb
index 0c13477c..0aa0d6eb 100644
--- a/app/controllers/admin/subscription_plans_controller.rb
+++ b/app/controllers/admin/contribution_formulas_controller.rb
@@ -1,7 +1,19 @@
# frozen_string_literal: true
module Admin
- class SubscriptionPlansController < BaseController
+ class ContributionFormulasController < BaseController
+ FORMULA_ATTRS = %i[name duration rate_kind price_cents description membership_type_id sessions_count validity_days].freeze
+ PURCHASE_ATTRS = %i[
+ person_id
+ contribution_formula_id
+ payment_method
+ record_attendance
+ attendance_date
+ custom_amount_cents
+ offer_reason
+ donation_amount
+ ].freeze
+
before_action :set_contribution_formula, only: %i[show edit update destroy]
before_action :set_person, only: %i[new create]
before_action :set_breadcrumbs
@@ -9,12 +21,12 @@ class SubscriptionPlansController < BaseController
def index
@contribution_formulas = ContributionFormula.includes(:membership_type).order(:duration, :price_cents)
- add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plans"), nil
+ add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.catalog"), nil
end
def show
@contributions = @contribution_formula.contributions.includes(:person)
- add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plan_named", name: @contribution_formula.name), nil
+ add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.formula_named", name: @contribution_formula.name), nil
end
def new
@@ -22,17 +34,17 @@ def new
unless @person&.can_buy_contribution_formulas?
flash[:alert] = t(".needs_circus_membership_alert")
- redirect_to admin_users_path
+ redirect_to admin_person_path(@person)
return
end
@contribution_formulas = ContributionFormula.available_for(@person)
- add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.new_contribution"), nil
+ add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.new_contribution"), nil
end
def edit
- add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.edit_named", name: @contribution_formula.name), nil
+ add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.edit_named", name: @contribution_formula.name), nil
end
def create
@@ -43,7 +55,7 @@ def create
result = People::ContributionCreator.new(
person: @person,
- contribution_formula_id: contribution_purchase_params[:contribution_formula_id] || contribution_purchase_params[:subscription_plan_id],
+ contribution_formula_id: contribution_formula_id_from_purchase_params,
payment_method: contribution_purchase_params[:payment_method].presence || "cash",
recorded_by_id: Current.user&.id,
record_attendance: false,
@@ -53,19 +65,19 @@ def create
).call
if result.success?
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".purchased")
+ redirect_to admin_user_path(Admin::Users::PersonRouteKey.call(@person)), notice: t(".purchased")
else
- redirect_to new_admin_subscription_plan_path(person_id: @person.id),
+ redirect_to new_admin_contribution_formula_path(person_id: @person.id),
alert: t(".purchase_failed_alert", message: result.message)
end
rescue StandardError => e
flash[:alert] = t(".purchase_failed_alert", message: e.message)
- redirect_to new_admin_subscription_plan_path(person_id: @person.id)
+ redirect_to new_admin_contribution_formula_path(person_id: @person.id)
end
def update
if @contribution_formula.update(contribution_formula_params)
- redirect_to admin_subscription_plans_path, notice: t(".updated")
+ redirect_to admin_contribution_formulas_path, notice: t(".updated")
else
flash.now[:alert] = @contribution_formula.errors.full_messages.to_sentence
render :edit, status: :unprocessable_content
@@ -74,18 +86,22 @@ def update
def destroy
if @contribution_formula.destroy
- redirect_to admin_subscription_plans_path, notice: t(".destroyed")
+ redirect_to admin_contribution_formulas_path, notice: t(".destroyed")
else
- redirect_to admin_subscription_plans_path, alert: @contribution_formula.errors.full_messages.to_sentence
+ redirect_to admin_contribution_formulas_path, alert: @contribution_formula.errors.full_messages.to_sentence
end
end
private
+ def contribution_formula_id_from_purchase_params
+ contribution_purchase_params[:contribution_formula_id]
+ end
+
def require_super_admin
return if Current.user&.super_admin?
- redirect_to admin_subscription_plans_path, alert: I18n.t("admin.subscription_plans.require_super_admin.forbidden")
+ redirect_to admin_contribution_formulas_path, alert: I18n.t("admin.contribution_formulas.require_super_admin.forbidden")
end
def set_contribution_formula
@@ -98,23 +114,19 @@ def set_person
def set_breadcrumbs
add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path
- add_breadcrumb I18n.t("breadcrumbs.admin.subscription_plans.plans"), admin_subscription_plans_path
+ if @person.present?
+ add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.contribution_formulas.new_contribution"))
+ else
+ add_breadcrumb I18n.t("breadcrumbs.admin.contribution_formulas.catalog"), admin_contribution_formulas_path
+ end
end
def contribution_formula_params
- params.expect(contribution_formula: %i[name duration price_cents description membership_type_id sessions_count validity_days]).tap do |permitted|
- legacy = params[:subscription_plan]
- permitted.merge!(legacy.permit(:name, :duration, :price_cents, :description, :membership_type_id, :sessions_count, :validity_days)) if legacy.respond_to?(:permit)
- end
- rescue ActionController::ParameterMissing
- params.expect(subscription_plan: %i[name duration price_cents description membership_type_id sessions_count validity_days])
+ params.expect(contribution_formula: FORMULA_ATTRS)
end
def contribution_purchase_params
- key = params.key?(:contribution_formula) ? :contribution_formula : :subscription_plan
- params.expect(key => %i[person_id contribution_formula_id subscription_plan_id payment_method record_attendance attendance_date custom_amount_cents offer_reason donation_amount]).merge(
- recorded_by_id: Current.user.id
- )
+ params.expect(contribution_formula: PURCHASE_ATTRS).merge(recorded_by_id: Current.user.id)
end
def donation_cents_from(params_hash)
diff --git a/app/controllers/admin/contributions_controller.rb b/app/controllers/admin/contributions_controller.rb
new file mode 100644
index 00000000..77872cb4
--- /dev/null
+++ b/app/controllers/admin/contributions_controller.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Admin
+ class ContributionsController < BaseController
+ before_action :set_person
+
+ def upgrade
+ result = build_contribution_upgrader.call
+
+ if result.success?
+ redirect_to admin_person_path(@person), notice: upgrade_notice_for(result)
+ else
+ redirect_to admin_person_path(@person), alert: upgrade_failure_message(result.message)
+ end
+ rescue StandardError => e
+ redirect_to admin_person_path(@person), alert: upgrade_failure_message(e.message)
+ end
+
+ private
+
+ def set_person
+ @person = Person.find(params[:person_id])
+ end
+
+ def admin_person_path(person)
+ admin_user_path(Admin::Users::PersonRouteKey.call(person))
+ end
+
+ def build_contribution_upgrader
+ People::ContributionUpgrader.new(
+ person: @person,
+ from_contribution_id: source_contribution_id,
+ to_formula_id: target_formula_id,
+ payment_method: params[:payment_method].presence || "cash",
+ recorded_by_id: Current.user.id,
+ offer_reason: params[:offer_reason]
+ )
+ end
+
+ def source_contribution_id
+ params[:from_contribution_id].presence || params[:from_book_id]
+ end
+
+ def target_formula_id
+ params[:to_formula_id].presence || params[:to_plan_id]
+ end
+
+ def upgrade_notice_for(result)
+ return t(".success_notice") unless result.credit_applied.positive?
+
+ t(".success_notice") + t(".credit_applied_suffix", amount: (result.credit_applied / 100.0).round(2))
+ end
+
+ def upgrade_failure_message(message)
+ t(".failure_alert", message: message)
+ end
+ end
+end
diff --git a/app/controllers/admin/donations_controller.rb b/app/controllers/admin/donations_controller.rb
index b2716b82..dfe8819c 100644
--- a/app/controllers/admin/donations_controller.rb
+++ b/app/controllers/admin/donations_controller.rb
@@ -2,49 +2,31 @@
module Admin
class DonationsController < BaseController
- before_action :set_breadcrumbs
-
def new
return unless assign_person_context!
- add_breadcrumb I18n.t("admin.donations.new.title"), nil
+ set_breadcrumbs
end
def create
return unless assign_person_context!
- begin
- amount_cents = (payment_params[:payment_amount].to_f * 100).to_i
-
- result = People::PaymentCreator.new(
- person: @person,
- amount_cents: amount_cents,
- payment_method: "cash",
- recorded_by_id: Current.user&.id,
- item_type: "Donation",
- item_id: @person.id,
- description: "Donation",
- notes: "Donation"
- ).call
+ result = build_payment_recorder.call
- if result.success?
- redirect_to admin_payment_path(result.payment), notice: t(".recorded")
- else
- flash[:alert] = "Erreur lors de la création de la donation: #{result.message}"
- add_breadcrumb I18n.t("admin.donations.new.title"), nil
- render :new, status: :unprocessable_content
- end
- rescue StandardError => e
- flash[:alert] = "Erreur lors de la création de la donation: #{e.message}"
- add_breadcrumb I18n.t("admin.donations.new.title"), nil
- render :new, status: :unprocessable_content
+ if result.success?
+ redirect_to admin_payments_path(person_id: @person.id), notice: t(".recorded")
+ else
+ render_creation_error(result.message)
end
+ rescue StandardError => e
+ render_creation_error(e.message)
end
private
def set_breadcrumbs
add_breadcrumb I18n.t("breadcrumbs.admin.common.administration"), admin_dashboard_index_path
+ add_person_context_breadcrumbs(@person, I18n.t("admin.donations.new.title"))
end
def assign_person_context!
@@ -65,5 +47,33 @@ def assign_person_context!
def payment_params
params.expect(payment: %i[payment_amount payment_date payment_type status donation total_payment user_id person_id])
end
+
+ def build_payment_recorder
+ People::PaymentRecorder.new(
+ person: @person,
+ payment_method: "cash",
+ recorded_by: Current.user,
+ status: "success",
+ notes: "Donation",
+ total_cents: payment_amount_cents,
+ payment_lines: [
+ {
+ item_type: "Donation",
+ amount_cents: payment_amount_cents,
+ description: "Donation"
+ }
+ ]
+ )
+ end
+
+ def payment_amount_cents
+ (payment_params[:payment_amount].to_f * 100).to_i
+ end
+
+ def render_creation_error(message)
+ flash[:alert] = "Erreur lors de la création de la donation: #{message}"
+ set_breadcrumbs
+ render :new, status: :unprocessable_content
+ end
end
end
diff --git a/app/controllers/admin/exports_controller.rb b/app/controllers/admin/exports_controller.rb
index 2ef6f953..791bd89b 100644
--- a/app/controllers/admin/exports_controller.rb
+++ b/app/controllers/admin/exports_controller.rb
@@ -9,44 +9,50 @@ def index
end
def newsletter_subscribed
- users = User.where(newsletter_subscribed: true).select(:first_name, :last_name, :email_address)
- csv_data = users_to_csv_newsletter(users)
+ subscribers = NewsletterSubscriber.subscribed.includes(:person).order(:email)
+ csv_data = users_to_csv_newsletter(subscribers)
send_data csv_data, filename: "utilisateur_newsletter.csv", type: "text/csv", disposition: "attachment"
end
def all_users
- users = User.where(deleted: false).select(
- :id, :first_name, :last_name, :email_address, :birthdate,
- :address, :zip_code, :town, :phone_number, :occupation,
- :specialty, :newsletter_subscribed, :created_at
- )
- csv_data = users_to_csv(users)
+ people = Person.active.includes(:newsletter_subscriber, :user).order(:last_name, :first_name)
+ csv_data = users_to_csv(people)
send_data csv_data, filename: "tous_les_utilisateurs.csv", type: "text/csv", disposition: "attachment"
end
- def users_to_csv(users)
- return "" if users.empty?
+ def users_to_csv(people)
+ return "" if people.empty?
CSV.generate(headers: true) do |csv|
- # Utiliser les colonnes sélectionnées dans la requête
- selected_columns = %i[id first_name last_name email_address birthdate
- address zip_code town phone_number occupation
- specialty newsletter_subscribed created_at]
-
- csv << selected_columns
- users.each do |user|
- csv << selected_columns.map { |col| user.send(col) }
+ csv << %i[id first_name last_name email email_address birth_date address zip_code town phone occupation specialty newsletter_subscribed created_at]
+ people.each do |person|
+ csv << [
+ person.id,
+ person.first_name,
+ person.last_name,
+ person.email,
+ person.user&.email_address,
+ person.birth_date,
+ person.address,
+ person.zip_code,
+ person.town,
+ person.phone,
+ person.occupation,
+ person.specialty,
+ person.newsletter_subscribed?,
+ person.created_at
+ ]
end
end
end
- def users_to_csv_newsletter(users)
- return "" if users.empty?
+ def users_to_csv_newsletter(subscribers)
+ return "" if subscribers.empty?
CSV.generate(headers: true) do |csv|
- csv << %i[first_name last_name email_address]
- users.each do |user|
- csv << [ user.first_name, user.last_name, user.email_address ]
+ csv << %i[first_name last_name email]
+ subscribers.each do |subscriber|
+ csv << [ subscriber.person&.first_name, subscriber.person&.last_name, subscriber.email ]
end
end
end
diff --git a/app/controllers/admin/health_reports_controller.rb b/app/controllers/admin/health_reports_controller.rb
index b174d57c..c820381a 100644
--- a/app/controllers/admin/health_reports_controller.rb
+++ b/app/controllers/admin/health_reports_controller.rb
@@ -13,6 +13,14 @@ def index
@people_without_user_count = report.people_without_user_count
@payments_without_person = report.payments_without_person
@payments_without_person_count = report.payments_without_person_count
+ @payments_without_lines = report.payments_without_lines
+ @payments_without_lines_count = report.payments_without_lines_count
+ @payments_with_mismatched_totals = report.payments_with_mismatched_totals
+ @payments_with_mismatched_totals_count = report.payments_with_mismatched_totals_count
+ @legacy_donation_lines = report.legacy_donation_lines
+ @legacy_donation_lines_count = report.legacy_donation_lines_count
+ @contribution_invariant_issues = report.contribution_invariant_issues
+ @contribution_invariant_issues_count = report.contribution_invariant_issues_count
@duplicate_people_by_email_count = report.duplicate_people_by_email_count
@duplicate_people_by_phone_count = report.duplicate_people_by_phone_count
@duplicate_people_by_email_groups = report.duplicate_people_by_email.group_by { |person| person.email.to_s.downcase }
diff --git a/app/controllers/admin/membership_types_controller.rb b/app/controllers/admin/membership_types_controller.rb
index 7078a115..f4bc6f7d 100644
--- a/app/controllers/admin/membership_types_controller.rb
+++ b/app/controllers/admin/membership_types_controller.rb
@@ -18,6 +18,7 @@ def show
def new
@membership_type = MembershipType.new(
effective_from: Date.current,
+ rate_kind: "standard",
version: 1,
created_by_user: Current.user
)
@@ -74,7 +75,7 @@ def set_breadcrumbs
end
def membership_type_params
- params.expect(membership_type: %i[name category price_cents description effective_from version created_by_user_id])
+ params.expect(membership_type: %i[name category rate_kind price_cents description effective_from version created_by_user_id])
end
end
end
diff --git a/app/controllers/admin/memberships_controller.rb b/app/controllers/admin/memberships_controller.rb
index d16b06b6..a67bc188 100644
--- a/app/controllers/admin/memberships_controller.rb
+++ b/app/controllers/admin/memberships_controller.rb
@@ -16,43 +16,39 @@ def show
@membership_types = MembershipType.all
@contribution_formulas = ContributionFormula.all
- add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}")
- add_breadcrumb I18n.t("breadcrumbs.admin.memberships.membership"), nil
+ add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.membership"))
end
def new
@person = Person.find(params[:person_id]) if params[:person_id]
- # Gérer l'upgrade d'adhésion
if params[:upgrade] == "true" && @person&.current_membership&.basic?
- # Pour l'upgrade, on ne propose que les types Circus
- @membership_types = MembershipType.circus_types.current_versions.order(:price_cents)
+ @membership_types = MembershipType.circus_types.available_for(@person).order(:price_cents)
@is_upgrade = true
@current_membership = @person.current_membership
- add_breadcrumb I18n.t("breadcrumbs.admin.memberships.upgrade_to_circus"), nil
+ add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.upgrade_to_circus"))
else
- # Pour une nouvelle adhésion, on propose tous les types
@membership_types = MembershipType.current_versions.order(:price_cents)
@is_upgrade = false
- add_breadcrumb I18n.t("breadcrumbs.admin.memberships.new_membership"), nil
+ add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.new_membership")) if @person.present?
+ add_breadcrumb I18n.t("breadcrumbs.admin.memberships.new_membership"), nil unless @person.present?
end
end
def edit
@membership = @person.current_membership
@membership_types = MembershipType.all
- add_breadcrumb I18n.t("breadcrumbs.admin.memberships.edit_membership"), nil
+ add_person_context_breadcrumbs(@person, I18n.t("breadcrumbs.admin.memberships.edit_membership"))
end
def create
- @person = Person.find(membership_purchase_params[:person_id])
- membership_type = MembershipType.find(membership_purchase_params[:membership_type_id])
+ params_hash = membership_purchase_params
+ membership_type = MembershipType.find(params_hash[:membership_type_id])
- if membership_purchase_params[:upgrade] == "true" && @person.current_membership&.basic?
- handle_upgrade_flow(@person, membership_type)
+ if upgrade_request_for?(@person, params_hash)
+ handle_upgrade_flow(@person, membership_type, params_hash)
else
- handle_creation_flow(@person, membership_type)
+ handle_creation_flow(@person, membership_type, params_hash)
end
end
@@ -68,7 +64,7 @@ def update
).call
if result.success?
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success")
+ redirect_to admin_person_path(@person), notice: t(".success")
else
flash[:alert] = result.message
redirect_to edit_admin_membership_path(@membership)
@@ -130,8 +126,7 @@ def donation_cents_from(params_hash)
cents.positive? ? cents : nil
end
- def handle_upgrade_flow(person, membership_type)
- params_hash = membership_purchase_params
+ def handle_upgrade_flow(person, membership_type, params_hash)
result = People::MembershipUpgrader.new(
person: person,
new_membership_type_id: membership_type.id,
@@ -143,15 +138,14 @@ def handle_upgrade_flow(person, membership_type)
).call
if result.success?
- redirect_to admin_user_path("person_#{person.id}"), notice: build_upgrade_notice(membership_type, result, payment_method_from(params_hash))
+ redirect_to admin_person_path(person), notice: build_upgrade_notice(membership_type, result, payment_method_from(params_hash))
else
redirect_to new_admin_membership_path(person_id: person.id, upgrade: params_hash[:upgrade]),
alert: t("admin.memberships.create.upgrade_failed_alert", message: result.message)
end
end
- def handle_creation_flow(person, membership_type)
- params_hash = membership_purchase_params
+ def handle_creation_flow(person, membership_type, params_hash)
result = People::MembershipCreator.new(
person: person,
membership_type_id: membership_type.id,
@@ -167,7 +161,7 @@ def handle_creation_flow(person, membership_type)
redirect_to new_admin_membership_path(person_id: person.id),
alert: t(".duplicate_active")
else
- redirect_to admin_user_path("person_#{person.id}"),
+ redirect_to admin_person_path(person),
notice: t(".success_with_contribution_hint")
end
else
@@ -176,6 +170,10 @@ def handle_creation_flow(person, membership_type)
end
end
+ def upgrade_request_for?(person, params_hash)
+ params_hash[:upgrade] == "true" && person.current_membership&.basic?
+ end
+
def build_upgrade_notice(membership_type, result, payment_method)
message =
if payment_method == "offered"
diff --git a/app/controllers/admin/payments_controller.rb b/app/controllers/admin/payments_controller.rb
index 6eac58f2..6fc3bf94 100644
--- a/app/controllers/admin/payments_controller.rb
+++ b/app/controllers/admin/payments_controller.rb
@@ -50,66 +50,13 @@ def edit
end
def create
- # Convertir le montant en centimes si fourni en euros
- total_cents = payment_params[:total_cents]
- total_cents = (total_cents.to_f * 100).to_i if total_cents.present?
-
- person = Person.find(payment_params[:person_id])
-
- result = People::PaymentCreator.new(
- person: person,
- amount_cents: total_cents,
- payment_method: payment_params[:payment_method] || "cash",
- recorded_by_id: Current.user&.id,
- item_type: "Donation",
- item_id: person.id,
- description: "Paiement direct",
- notes: payment_params[:notes]
- ).call
-
- respond_to do |format|
- if result.success?
- created_msg = t(".created_notice")
- format.html { redirect_to admin_payments_path, notice: created_msg }
- format.turbo_stream do
- filter_locals = payments_index_filter_params.to_unsafe_h
- fresh = payment_for_ui_row(result.payment)
- render turbo_stream: [
- turbo_stream.append("payments", partial: "payment_row",
- locals: { payment: fresh, list_filter_params: filter_locals }),
- turbo_stream.replace("payment-summary", partial: "payment_summary",
- locals: payment_summary_locals(payments_index_filter_params)),
- turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: created_msg })
- ]
- end
- else
- fail_msg = t(".failure_alert", message: result.message)
- err_detail = I18n.t("flash.generic.error_detail", message: result.message)
- format.html { redirect_to admin_payments_path, alert: fail_msg }
- format.turbo_stream do
- render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail })
- end
- end
- end
+ result = build_payment_create_result
+ result.success? ? respond_to_created_payment(result) : respond_to_failed_payment_create(result.message)
rescue ActiveRecord::RecordNotFound => e
- respond_to do |format|
- fail_msg = t("admin.payments.create.failure_alert", message: e.message)
- err_detail = I18n.t("flash.generic.error_detail", message: e.message)
- format.html { redirect_to admin_payments_path, alert: fail_msg }
- format.turbo_stream do
- render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail })
- end
- end
+ respond_to_failed_payment_create(e.message)
rescue StandardError => e
Rails.logger.error("[Admin::PaymentsController#create] #{e.class}: #{e.message}")
- respond_to do |format|
- fail_msg = t("admin.payments.create.failure_alert", message: e.message)
- err_detail = I18n.t("flash.generic.error_detail", message: e.message)
- format.html { redirect_to admin_payments_path, alert: fail_msg }
- format.turbo_stream do
- render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail })
- end
- end
+ respond_to_failed_payment_create(e.message)
end
# OLD: logique directe (commentée pour rollback)
@@ -138,6 +85,7 @@ def update
payment_method: payment_params[:payment_method],
status: payment_params[:status],
notes: payment_params[:notes],
+ offer_reason: payment_params[:offer_reason],
updated_by_id: Current.user.id
).call
@@ -155,7 +103,7 @@ def update
locals: { payment: fresh, list_filter_params: filter_locals }),
turbo_stream.replace("payment-summary", partial: "payment_summary",
locals: payment_summary_locals(payments_index_filter_params)),
- turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: updated_msg })
+ turbo_flash_replace(:notice, updated_msg)
]
end
else
@@ -163,7 +111,7 @@ def update
err_detail = I18n.t("flash.generic.error_detail", message: result.message)
format.html { redirect_to admin_payment_path(params[:id]), alert: fail_msg }
format.turbo_stream do
- render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail })
+ render turbo_stream: turbo_flash_replace(:alert, err_detail)
end
end
end
@@ -194,7 +142,7 @@ def destroy
turbo_stream.remove("payment_row_#{result.payment.id}"),
turbo_stream.replace("payment-summary", partial: "payment_summary",
locals: payment_summary_locals(payments_index_filter_params)),
- turbo_stream.replace("flash", partial: "shared/flash", locals: { notice: cancelled_msg })
+ turbo_flash_replace(:notice, cancelled_msg)
]
end
else
@@ -202,7 +150,7 @@ def destroy
err_detail = I18n.t("flash.generic.error_detail", message: result.message)
format.html { redirect_to admin_payments_path, alert: fail_msg }
format.turbo_stream do
- render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash", locals: { alert: err_detail })
+ render turbo_stream: turbo_flash_replace(:alert, err_detail)
end
end
end
@@ -239,6 +187,71 @@ def restore
FILTER_PARAM_KEYS = Admin::PaymentsHelper::PAYMENTS_INDEX_QUERY_KEYS
+ def build_payment_create_result
+ person = Person.find(payment_create_params[:person_id])
+ total_cents = normalized_total_cents(payment_create_params[:total_cents])
+
+ People::PaymentRecorder.new(
+ person: person,
+ payment_method: payment_create_params[:payment_method] || "cash",
+ recorded_by: Current.user,
+ status: "success",
+ notes: payment_create_params[:notes],
+ offer_reason: payment_create_params[:offer_reason],
+ total_cents: total_cents,
+ payment_lines: direct_payment_lines(total_cents)
+ ).call
+ end
+
+ def direct_payment_lines(total_cents)
+ [
+ {
+ item_type: "Donation",
+ amount_cents: total_cents,
+ description: "Paiement direct"
+ }
+ ]
+ end
+
+ def respond_to_created_payment(result)
+ created_msg = t(".created_notice")
+
+ respond_to do |format|
+ format.html { redirect_to admin_payments_path, notice: created_msg }
+ format.turbo_stream do
+ filter_locals = payments_index_filter_params.to_unsafe_h
+ fresh = payment_for_ui_row(result.payment)
+ render turbo_stream: [
+ turbo_stream.append("payments", partial: "payment_row",
+ locals: { payment: fresh, list_filter_params: filter_locals }),
+ turbo_stream.replace("payment-summary", partial: "payment_summary",
+ locals: payment_summary_locals(payments_index_filter_params)),
+ turbo_flash_replace(:notice, created_msg)
+ ]
+ end
+ end
+ end
+
+ def respond_to_failed_payment_create(message)
+ fail_msg = t("admin.payments.create.failure_alert", message: message)
+ err_detail = I18n.t("flash.generic.error_detail", message: message)
+
+ respond_to do |format|
+ format.html { redirect_to admin_payments_path, alert: fail_msg }
+ format.turbo_stream { render turbo_stream: turbo_flash_replace(:alert, err_detail) }
+ end
+ end
+
+ def payment_create_params
+ @payment_create_params ||= payment_params.to_h.symbolize_keys
+ end
+
+ def normalized_total_cents(total_cents)
+ return total_cents unless total_cents.present?
+
+ (total_cents.to_f * 100).to_i
+ end
+
# Filtres liste : uniquement la query string (pas le corps form PATCH), pour éviter les logs
# Strong Parameters « Unpermitted » sur :payment, :authenticity_token, :controller, etc.
# Ordre : query courante → GET index → Referer vers la liste.
@@ -302,7 +315,7 @@ def payment_summary_locals(filter_params)
def payment_params
params.expect(
- payment: [ :person_id, :recorded_by_id, :total_cents, :payment_method, :status, :notes,
+ payment: [ :person_id, :recorded_by_id, :total_cents, :payment_method, :status, :notes, :offer_reason,
# Compatibilité avec l'ancien modèle
:payment_id, :payment_date, :payment_amount, :payment_type, :order_id, :donation, :total_payment ]
)
@@ -313,18 +326,7 @@ def set_payments_breadcrumbs
person = Person.find_by(id: params[:person_id])
if person
add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb person.full_name, admin_user_path("person_#{person.id}")
- add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil
- return
- end
- end
-
- if params[:user_id].present?
- user = User.find_by(id: params[:user_id])
- if user
- add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- label = user.full_name.presence || "Utilisateur ##{user.id}"
- add_breadcrumb label, admin_user_path(user)
+ add_breadcrumb person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(person))
add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil
return
end
@@ -332,5 +334,9 @@ def set_payments_breadcrumbs
add_breadcrumb I18n.t("breadcrumbs.admin.payments.history"), nil
end
+
+ def turbo_flash_replace(type, message)
+ turbo_stream.replace("flash", partial: "shared/flash", locals: { type => message })
+ end
end
end
diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb
index 64aa28f4..1d48c776 100644
--- a/app/controllers/admin/sessions_controller.rb
+++ b/app/controllers/admin/sessions_controller.rb
@@ -2,22 +2,24 @@
module Admin
class SessionsController < BaseController
- allow_unauthenticated_access only: %i[new create]
- rate_limit to: 10, within: 3.minutes, only: :create,
- with: -> { redirect_to new_session_url, alert: I18n.t("admin.sessions.rate_limited_alert") }
+ skip_before_action :require_admin_zone_access, only: %i[new create]
+
+ rate_limit to: 10, within: 3.minutes, only: :create,
+ with: -> { redirect_to new_session_url, alert: I18n.t("admin.sessions.rate_limited_alert") }
def new
- return unless authenticated?
+ return redirect_to(root_path, status: :see_other) if authenticated?
- redirect_to root_path
+ # Pas de vue dédiée : même formulaire que l’espace public.
+ redirect_to new_session_path, status: :see_other
end
def create
if (user = User.authenticate_by(params.permit(:email_address, :password)))
- start_new_session_for user
- redirect_to after_authentication_url, notice: t(".success")
+ start_new_session_for(user)
+ redirect_to after_authentication_url, notice: t(".success"), status: :see_other
else
- redirect_to new_session_path, alert: t(".invalid_credentials")
+ redirect_to new_session_path, alert: t(".invalid_credentials"), status: :see_other
end
end
diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb
deleted file mode 100644
index f273f80f..00000000
--- a/app/controllers/admin/subscriptions_controller.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
- class SubscriptionsController < BaseController
- before_action :set_person
-
- def upgrade
- result = People::ContributionUpgrader.new(
- person: @person,
- from_contribution_id: params[:from_contribution_id] || params[:from_book_id],
- to_formula_id: params[:to_formula_id] || params[:to_plan_id],
- payment_method: params[:payment_method] || "cash",
- recorded_by_id: Current.user.id
- ).call
-
- if result.success?
- notice = t(".success_notice")
- if result.credit_applied.positive?
- notice += t(".credit_applied_suffix", amount: (result.credit_applied / 100.0).round(2))
- end
- redirect_to admin_user_path("person_#{@person.id}"), notice: notice
- else
- redirect_to admin_user_path("person_#{@person.id}"),
- alert: t(".failure_alert", message: result.message)
- end
- rescue StandardError => e
- redirect_to admin_user_path("person_#{@person.id}"),
- alert: t(".failure_alert", message: e.message)
- end
-
- private
-
- def set_person
- @person = Person.find(params[:person_id])
- end
- end
-end
diff --git a/app/controllers/admin/users/payments_controller.rb b/app/controllers/admin/users/payments_controller.rb
index 8035ed8f..84c30de8 100644
--- a/app/controllers/admin/users/payments_controller.rb
+++ b/app/controllers/admin/users/payments_controller.rb
@@ -9,71 +9,45 @@ module Users
# - Managing payment-related operations
class PaymentsController < BaseController
before_action :set_person
- before_action :set_breadcrumbs
# GET /admin/users/person_1/payments
def index
- @payments = @person.payments.includes(:payment_lines, :recorded_by)
- .order(created_at: :desc)
- .page(params[:page])
+ redirect_to filtered_payments_path
end
# GET /admin/users/person_1/payments/1
def show
- @payment = @person.payments.find(params[:id])
+ redirect_to filtered_payments_path
end
# GET /admin/users/person_1/payments/new
def new
- @payment = @person.payments.build
- @payment.recorded_by = Current.user
- @membership_types = MembershipType.all
- @contribution_formulas = ContributionFormula.all
+ redirect_to filtered_payments_path
end
# POST /admin/users/person_1/payments
def create
normalized_lines = normalize_payment_lines(params[:payment_lines])
+ lines = normalized_lines.presence || direct_payment_lines
+ total_cents = lines.sum { |line| line[:amount_cents].to_i }
- total_cents = if normalized_lines.any?
- normalized_lines.sum { |line| line[:amount_cents].to_i }
- else
- payment_params[:total_cents].to_i
- end
-
- service_params = {
+ result = People::PaymentRecorder.new(
person: @person,
payment_method: payment_params[:payment_method] || "cash",
- recorded_by_id: Current.user&.id,
- notes: payment_params[:notes]
- }
-
- if normalized_lines.any?
- service_params[:payment_lines] = normalized_lines
- service_params[:total_cents] = total_cents
- else
- first_line = normalized_lines.first
- service_params[:amount_cents] = total_cents
- service_params[:item_type] = first_line ? first_line[:item_type] : "Donation"
- service_params[:item_id] = first_line ? first_line[:item_id] : @person.id
- service_params[:description] = first_line ? first_line[:description] : "Paiement"
- end
-
- result = People::PaymentCreator.new(service_params).call
+ recorded_by: Current.user,
+ status: "success",
+ notes: payment_params[:notes],
+ total_cents: total_cents,
+ payment_lines: lines
+ ).call
if result.success?
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success")
+ redirect_to filtered_payments_path, notice: t(".success")
else
- @membership_types = MembershipType.all
- @contribution_formulas = ContributionFormula.all
- flash.now[:alert] = t(".failure_alert", message: result.message)
- render :new, status: :unprocessable_content
+ redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message)
end
rescue StandardError => e
- @membership_types = MembershipType.all
- @contribution_formulas = ContributionFormula.all
- flash.now[:alert] = t(".failure_alert", message: e.message)
- render :new, status: :unprocessable_content
+ redirect_to filtered_payments_path, alert: t(".failure_alert", message: e.message)
end
# PATCH /admin/users/person_1/payments/1
@@ -93,9 +67,9 @@ def update
).call
if result.success?
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".success")
+ redirect_to filtered_payments_path, notice: t(".success")
else
- redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message)
+ redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message)
end
end
@@ -110,9 +84,9 @@ def destroy
).call
if result.success?
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".destroyed")
+ redirect_to filtered_payments_path, notice: t(".destroyed")
else
- redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message)
+ redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message)
end
end
@@ -129,15 +103,15 @@ def process_payment
).call
if result.success?
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".processed")
+ redirect_to filtered_payments_path, notice: t(".processed")
else
- redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: result.message)
+ redirect_to filtered_payments_path, alert: t(".failure_alert", message: result.message)
end
else
- redirect_to admin_user_path("person_#{@person.id}"), notice: t(".already_processed")
+ redirect_to filtered_payments_path, notice: t(".already_processed")
end
rescue StandardError => e
- redirect_to admin_user_path("person_#{@person.id}"), alert: t(".failure_alert", message: e.message)
+ redirect_to filtered_payments_path, alert: t(".failure_alert", message: e.message)
end
end
@@ -147,8 +121,8 @@ def set_person
identifier = params[:person_id].presence || params[:user_id].presence
raise ActiveRecord::RecordNotFound, "person identifier missing" if identifier.blank?
- if identifier.to_s.start_with?("person_")
- person_id = identifier.to_s.delete_prefix("person_")
+ if Admin::Users::PersonRouteKey.person_identifier?(identifier)
+ person_id = Admin::Users::PersonRouteKey.extract(identifier)
@person = Person.find(person_id)
else
user = User.find(identifier)
@@ -156,12 +130,6 @@ def set_person
end
end
- def set_breadcrumbs
- add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}")
- add_breadcrumb I18n.t("breadcrumbs.admin.payments.management"), nil
- end
-
def payment_params
params.expect(
payment: %i[total_cents
@@ -178,6 +146,20 @@ def normalize_payment_lines(lines_param)
line.symbolize_keys
end
end
+
+ def direct_payment_lines
+ [
+ {
+ item_type: "Donation",
+ amount_cents: payment_params[:total_cents].to_i,
+ description: "Paiement direct"
+ }
+ ]
+ end
+
+ def filtered_payments_path
+ admin_payments_path(person_id: @person.id)
+ end
end
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 5f2086af..9089d5ed 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -9,6 +9,9 @@ module Admin
# The public UsersController in contrast only handles self-service actions
# for individual users managing their own profiles.
class UsersController < BaseController
+ include NewsletterParamParser
+ include Admin::Users::ParameterHandling
+ include Admin::Users::UpdateHandling
before_action :set_user, only: %i[edit update destroy]
before_action :set_breadcrumbs, except: %i[index new]
before_action :check_deletion_permissions, only: [ :destroy ]
@@ -16,28 +19,14 @@ class UsersController < BaseController
# GET /admin/users or /admin/users.json
def index
- # Base query avec eager loading optimisé - ne montrer que les Person principales
- @people = PersonQuery.active.main_people.includes(
- :user,
- memberships: :membership_type,
- contributions: :contribution_formula
- )
-
- # Filtres
- apply_person_filters
-
- # Recherche
- apply_person_search
-
- # Tri
- @people = @people.order(:last_name, :first_name)
+ people_scope = Admin::Users::IndexQuery.new(params).call
# Pagination - Réduire à 15 éléments pour une meilleure lisibilité (ou paramètre items)
items_per_page = params[:items]&.to_i || 15
- @pagy, @people = pagy(@people, items: items_per_page)
+ @pagy, @people = pagy(people_scope, items: items_per_page)
# Statistiques pour le dashboard (basées sur les Person principales)
- statistics_service = Admin::DashboardStatisticsService.new(base_people: @people)
+ statistics_service = Admin::DashboardStatisticsService.new(base_people: people_scope)
statistics = statistics_service.call
@total_people = statistics[:total_people]
@people_with_user = statistics[:people_with_user]
@@ -53,65 +42,10 @@ def index
# GET /admin/users/1 or /admin/users/1.json
def show
- # Adapter pour accepter les ID de Person ET de User
- if params[:id].to_s.start_with?("person_")
- # ID de Person (format: person_123)
- person_id = params[:id].gsub("person_", "")
-
- # Chercher d'abord dans les Person actives
- @person = PersonQuery.active.includes(:user, memberships: :membership_type, contributions: :contribution_formula, payments: %i[payment_lines recorded_by])
- .find_by(id: person_id)
-
- # Si Person archivée (fusion), rediriger vers la liste
- if @person.nil?
- archived_person = Person.find_by(id: person_id)
- raise ActiveRecord::RecordNotFound if archived_person&.deleted_at.blank?
-
- redirect_to admin_users_path, notice: t(".merged_person_notice")
- return
-
- end
- @user = @person.user # Peut être nil
-
- # Si pas de User, créer un User temporaire pour la vue
- if @user.nil?
- @user = User.new(
- id: "temp_#{@person.id}",
- email_address: @person.email,
- system_role: nil # Pas de rôle pour une Person sans User
- )
- # Établir la relation person manuellement
- @user.association(:person).target = @person
- @user.association(:person).loaded!
- @is_person_without_user = true
- else
- @is_person_without_user = false
- end
-
- # Données pour les formulaires
- @membership_types = MembershipType.all
- @contribution_formulas = ContributionFormula.all
- @users = User.where(person: nil) # Users non liés
- @recent_payments = @person.payments.includes(:payment_lines, :recorded_by).order(created_at: :desc).limit(10)
-
- add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb @person.full_name, nil
-
+ if person_identifier?(params[:id])
+ return unless load_show_context_for_person
else
- # ID de User (format classique)
- @user = User.unscoped.includes(
- :person,
- :memberships,
- payments: { payment_lines: :item }
- ).find_by(id: params[:id])
-
- @person = @user.person
- @array_right = available_roles_for_user(@user)
- @is_person_without_user = false
- @recent_payments = @person&.payments&.includes(:payment_lines, :recorded_by)&.order(created_at: :desc)&.limit(10) || []
-
- add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb @user&.person&.full_name.present? ? @user.person.full_name : "Utilisateur ##{@user.id}", nil
+ return unless load_show_context_for_user
end
respond_to do |format|
@@ -132,7 +66,7 @@ def new
@user.email_address = @person.email
@user.system_role = "web_visitor" # Rôle par défaut
add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}")
+ add_breadcrumb @person.full_name, admin_user_path(person_route_key(@person))
add_breadcrumb I18n.t("breadcrumbs.admin.users.create_web_account"), nil
else
add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
@@ -142,17 +76,17 @@ def new
# GET /admin/users/person_1/edit_person
def edit_person
- person_id = params[:id].to_s.gsub("person_", "")
+ person_id = extracted_person_id(params[:id])
@person = PersonQuery.active.find(person_id)
add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
- add_breadcrumb @person.full_name, admin_user_path("person_#{@person.id}")
+ add_breadcrumb @person.full_name, admin_user_path(person_route_key(@person))
add_breadcrumb I18n.t("breadcrumbs.admin.common.edit"), nil
end
# GET /admin/users/1/edit
def edit
# Adapter pour gérer les Person
- if params[:id].to_s.start_with?("person_")
+ if person_identifier?(params[:id])
# Rediriger vers l'édition Person
redirect_to edit_person_admin_user_path(params[:id])
return
@@ -191,7 +125,7 @@ def create
result = form.call
if result.success?
- redirect_to admin_user_path("person_#{result.person.id}"), notice: result.message
+ redirect_to admin_user_path(person_route_key(result.person)), notice: result.message
else
@user = User.new
@user.person = result.person if result.person
@@ -202,116 +136,16 @@ def create
# PATCH/PUT /admin/users/1 or /admin/users/1.json
def update
- # Adapter pour gérer les Person
- if params[:id].to_s.start_with?("person_")
- person_id = params[:id].to_s.gsub("person_", "")
- @person = PersonQuery.active.find(person_id)
-
- person_attributes = person_params.to_h.deep_symbolize_keys
- newsletter_flag = ActiveModel::Type::Boolean.new.cast(person_attributes.delete(:newsletter_subscribed))
-
- result = People::Register.new(
- person_params: person_attributes.merge(allow_blank_attributes: true),
- existing_person: @person,
- newsletter_subscribed: newsletter_flag,
- newsletter_source: "admin",
- create_user_account: false,
- create_membership: false
- ).call
-
- if result.success?
- updated_person = result.person || @person
- # Handle AJAX requests for inline editing
- if request.xhr?
- render json: {
- success: true,
- member_number: updated_person.reload.member_number,
- message: t(".ajax_success_json_message")
- }
- else
- redirect_to admin_user_path("person_#{updated_person.id}"), notice: t(".person_saved_notice")
- end
- elsif request.xhr?
- render json: {
- success: false,
- errors: result.errors
- }, status: :unprocessable_content
- else
- flash.now[:alert] = result.message
- render :edit_person, status: :unprocessable_content
- end
- return
- end
+ return handle_person_update if person_identifier?(params[:id])
- respond_to do |format|
- # Séparer les paramètres User des paramètres Person
- user_only_params = user_params.slice(:email_address, :system_role, :created_by_admin, :create_web_account)
- person_params_flat = user_params.except(:email_address, :system_role, :created_by_admin, :create_web_account, :person)
- newsletter_flag = person_params_flat.delete(:newsletter_subscribed)
-
- # Utiliser le service UserManagement::UserUpdater
- updater = UserManagement::UserUpdater.new(
- user_id: @user.id,
- email_address: user_only_params[:email_address],
- system_role: user_only_params[:system_role],
- person_attributes: person_params_flat,
- newsletter_subscribed: [ "1", true, 1 ].include?(newsletter_flag),
- updated_by_id: Current.user.id
- )
-
- result = updater.call
-
- if result.success?
- format.html { redirect_to admin_user_path(@user), notice: t(".html_updated") }
- format.json { render json: @user }
- format.turbo_stream do
- flash.now[:notice] = t(".turbo_notice")
- render turbo_stream: [
- turbo_stream.replace(@user),
- turbo_stream.replace("flash", partial: "shared/flash")
- ]
- end
- else
- format.html { render :show, status: :unprocessable_content, alert: result.message }
- format.json { render json: { errors: result.errors }, status: :unprocessable_content }
- format.turbo_stream do
- render turbo_stream: turbo_stream.replace(
- "error_explanation",
- partial: "shared/error_messages",
- locals: { resource: @user, errors: result.errors }
- )
- end
- end
- end
+ handle_user_update
end
# DELETE /admin/users/1 or /admin/users/1.json
def destroy
# Adapter pour gérer les Person
- if params[:id].to_s.start_with?("person_")
- # Supprimer la Person (déjà chargée dans set_user)
- person = @person
-
- # Debug: vérifier si @person est défini
- if person.nil?
- Rails.logger.error "DEBUG: @person is nil for params[:id] = #{params[:id]}"
- redirect_to admin_users_path, alert: t(".person_not_found_alert") and return
- end
-
- # Utiliser le service UserManagement::UserDeleter
- deleter = UserManagement::UserDeleter.new(
- person_id: person.id,
- deleted_by_id: current_user.id,
- reason: "Suppression via interface admin"
- )
-
- result = deleter.call
-
- if result.success?
- redirect_to admin_users_path, status: :see_other, notice: t(".person_deleted_notice")
- else
- redirect_to admin_users_path, alert: t(".destruction_failed_alert_html", message: result.message)
- end
+ if person_identifier?(params[:id])
+ destroy_person_entity
return
end
@@ -342,9 +176,9 @@ def restore
# Use callbacks to share common setup or constraints between actions.
def set_user
# Adapter pour gérer les IDs de Person (format: person_123)
- if params[:id].to_s.start_with?("person_")
+ if person_identifier?(params[:id])
# Pour les Person, charger la Person
- person_id = params[:id].gsub("person_", "")
+ person_id = extracted_person_id(params[:id])
@person = Person.find_by(id: person_id)
# If person not found, redirect to index with alert
@@ -374,147 +208,6 @@ def check_deletion_permissions
redirect_to admin_users_path, alert: I18n.t("admin.users.check_deletion_permissions.higher_privileges")
end
- # Méthodes privées pour les filtres et la recherche
- def apply_person_filters
- case params[:filter]
- when "with_active_membership"
- @people = @people.with_active_membership
- when "with_expiring_membership"
- @people = @people.with_expiring_membership
- when "with_expired_membership"
- @people = @people.with_expired_membership
- when "without_membership"
- @people = @people.without_membership
- when "with_user_account"
- @people = @people.with_user_account
- when "without_user_account"
- @people = @people.without_user_account
- end
- end
-
- def apply_person_search
- @people = @people.search_by_contact(params[:search]) if params[:search].present?
- end
-
- # Only allow a list of trusted parameters through.
- def user_params
- params.expect(
- user: [ :email_address,
- :system_role,
- :created_by_admin,
- :create_web_account,
- # Attributs délégués à Person (paramètres plats)
- :first_name,
- :last_name,
- :email,
- :phone,
- :birth_date,
- :address,
- :emergency_contact_name,
- :emergency_contact_phone,
- :notes,
- :specialty,
- :is_minor,
- :image_rights,
- :get_involved,
- :newsletter_subscribed,
- :dyslexic_font,
- :zip_code,
- :town,
- :country,
- :reduced_rate_eligible,
- :reduced_rate_reason,
- :reduced_rate_proof,
- # Paramètres imbriqués (pour compatibilité)
- {
- person: %i[
- id
- first_name
- last_name
- email
- phone
- birth_date
- address
- emergency_contact_name
- emergency_contact_phone
- notes
- specialty
- is_minor
- image_rights
- get_involved
- newsletter_subscribed
- dyslexic_font
- zip_code
- town
- country
- reduced_rate_eligible
- reduced_rate_reason
- reduced_rate_proof
- ]
- } ]
- )
- end
-
- def user_creation_params
- # Extraire les paramètres de person et les aplatir pour le formulaire
- person_params = params.dig(:user, :person) || {}
-
- {
- first_name: person_params[:first_name],
- last_name: person_params[:last_name],
- email: person_params[:email],
- phone: person_params[:phone],
- address: person_params[:address],
- zip_code: person_params[:zip_code],
- town: person_params[:town],
- country: person_params[:country],
- birth_date: person_params[:birth_date],
- emergency_contact_name: person_params[:emergency_contact_name],
- emergency_contact_phone: person_params[:emergency_contact_phone],
- notes: person_params[:notes],
- specialty: person_params[:specialty],
- is_minor: person_params[:is_minor],
- image_rights: person_params[:image_rights],
- get_involved: person_params[:get_involved],
- newsletter_subscribed: person_params[:newsletter_subscribed],
- dyslexic_font: person_params[:dyslexic_font],
- reduced_rate_eligible: person_params[:reduced_rate_eligible],
- reduced_rate_reason: person_params[:reduced_rate_reason],
- reduced_rate_proof: person_params[:reduced_rate_proof],
- create_web_account: params.dig(:user, :create_web_account),
- email_address: params.dig(:user, :email_address) || person_params[:email],
- system_role: params.dig(:user, :system_role),
- create_membership: params.dig(:user, :create_membership),
- membership_type_id: params.dig(:user, :membership_type_id),
- payment_method: params.dig(:user, :payment_method),
- person_id: params.dig(:user, :person_id)
- }.compact
- end
-
- def person_params
- params.expect(
- person: %i[first_name
- last_name
- email
- phone
- address
- zip_code
- town
- country
- birth_date
- emergency_contact_name
- emergency_contact_phone
- notes
- newsletter_subscribed
- get_involved
- image_rights
- is_minor
- reduced_rate_eligible
- reduced_rate_reason
- reduced_rate_proof]
- )
- end
-
def available_roles_for_user(user)
return [] if user.nil?
@@ -534,5 +227,106 @@ def require_super_admin
redirect_to admin_users_path, alert: I18n.t("admin.users.require_super_admin.restore_denied_alert")
end
+
+ def person_identifier?(raw_id)
+ Admin::Users::PersonRouteKey.person_identifier?(raw_id)
+ end
+
+ def extracted_person_id(raw_id)
+ Admin::Users::PersonRouteKey.extract(raw_id)
+ end
+
+ def person_route_key(person_or_id)
+ Admin::Users::PersonRouteKey.call(person_or_id)
+ end
+
+ def load_recent_payments(person)
+ return [] unless person
+
+ PaymentQuery.with_person_and_recorded_by
+ .where(person_id: person.id)
+ .order(created_at: :desc)
+ .limit(10)
+ end
+
+ def add_admin_user_breadcrumbs(label)
+ add_breadcrumb I18n.t("breadcrumbs.admin.users.members_list"), admin_users_path
+ add_breadcrumb label, nil
+ end
+
+ def user_label(user)
+ user&.person&.full_name.presence || "Utilisateur ##{user.id}"
+ end
+
+ def load_show_context_for_person
+ person_id = extracted_person_id(params[:id])
+ @person = PersonQuery.active.includes(:user, memberships: :membership_type, contributions: :contribution_formula, payments: %i[payment_lines recorded_by])
+ .find_by(id: person_id)
+
+ return handle_missing_person_in_show(person_id) if @person.nil?
+
+ @user = @person.user
+ @is_person_without_user = @user.nil?
+ @user = Admin::Users::ViewUserAdapter.from_person(@person) if @is_person_without_user
+ @membership_types = MembershipType.all
+ @contribution_formulas = ContributionFormula.all
+ @users = User.where(person: nil)
+ @recent_payments = load_recent_payments(@person)
+ add_admin_user_breadcrumbs(@person.full_name)
+ true
+ end
+
+ def handle_missing_person_in_show(person_id)
+ archived_person = Person.find_by(id: person_id)
+ raise ActiveRecord::RecordNotFound if archived_person&.deleted_at.blank?
+
+ redirect_to admin_users_path, notice: t(".merged_person_notice")
+ false
+ end
+
+ def load_show_context_for_user
+ @user = User.unscoped.includes(
+ :person,
+ :memberships,
+ payments: { payment_lines: :item }
+ ).find_by(id: params[:id])
+
+ if @user.nil?
+ respond_to do |format|
+ format.html do
+ redirect_to admin_users_path, alert: I18n.t("admin.users.set_user.person_or_user_missing_alert")
+ end
+ format.json { head :not_found }
+ end
+ return false
+ end
+
+ @person = @user.person
+ @array_right = available_roles_for_user(@user)
+ @is_person_without_user = false
+ @recent_payments = load_recent_payments(@person)
+ add_admin_user_breadcrumbs(user_label(@user))
+ true
+ end
+
+ def destroy_person_entity
+ person = @person
+ if person.nil?
+ redirect_to admin_users_path, alert: t(".person_not_found_alert")
+ return
+ end
+
+ deleter = UserManagement::UserDeleter.new(
+ person_id: person.id,
+ deleted_by_id: current_user.id,
+ reason: "Suppression via interface admin"
+ )
+ result = deleter.call
+ if result.success?
+ redirect_to admin_users_path, status: :see_other, notice: t(".person_deleted_notice")
+ else
+ redirect_to admin_users_path, alert: t(".destruction_failed_alert_html", message: result.message)
+ end
+ end
end
end
diff --git a/app/controllers/blogs_controller.rb b/app/controllers/blogs_controller.rb
index 64021e97..0c7a4c7d 100644
--- a/app/controllers/blogs_controller.rb
+++ b/app/controllers/blogs_controller.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class BlogsController < ApplicationController
+ skip_before_action :require_authentication, only: %i[latest]
before_action :set_blog, only: %i[show]
# def article
diff --git a/app/controllers/concerns/admin/users/parameter_handling.rb b/app/controllers/concerns/admin/users/parameter_handling.rb
new file mode 100644
index 00000000..5106cfe2
--- /dev/null
+++ b/app/controllers/concerns/admin/users/parameter_handling.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module Admin
+ module Users
+ module ParameterHandling
+ extend ActiveSupport::Concern
+
+ PERSON_FORM_KEYS = %i[
+ first_name
+ last_name
+ email
+ phone
+ birth_date
+ address
+ emergency_contact_name
+ emergency_contact_phone
+ notes
+ specialty
+ is_minor
+ image_rights
+ get_involved
+ newsletter_subscribed
+ dyslexic_font
+ zip_code
+ town
+ country
+ reduced_rate_eligible
+ reduced_rate_reason
+ reduced_rate_proof
+ ].freeze
+
+ USER_CONTROL_KEYS = %i[
+ email_address
+ system_role
+ created_by_admin
+ create_web_account
+ ].freeze
+
+ PERSON_EDIT_KEYS = %i[
+ first_name
+ last_name
+ email
+ phone
+ address
+ zip_code
+ town
+ country
+ birth_date
+ emergency_contact_name
+ emergency_contact_phone
+ notes
+ newsletter_subscribed
+ get_involved
+ image_rights
+ is_minor
+ reduced_rate_eligible
+ reduced_rate_reason
+ reduced_rate_proof
+ ].freeze
+
+ USER_EXPECTED_KEYS = [
+ *USER_CONTROL_KEYS,
+ *PERSON_FORM_KEYS,
+ { person: %i[id] + PERSON_FORM_KEYS }
+ ].freeze
+
+ private
+
+ def user_params
+ params.expect(user: USER_EXPECTED_KEYS)
+ end
+
+ def user_creation_params
+ person_attributes = nested_user_person_params
+
+ person_attributes.slice(*PERSON_FORM_KEYS).merge(
+ create_web_account: params.dig(:user, :create_web_account),
+ email_address: params.dig(:user, :email_address) || person_attributes[:email],
+ system_role: params.dig(:user, :system_role),
+ create_membership: params.dig(:user, :create_membership),
+ membership_type_id: params.dig(:user, :membership_type_id),
+ payment_method: params.dig(:user, :payment_method),
+ person_id: params.dig(:user, :person_id)
+ ).compact
+ end
+
+ def person_params
+ params.expect(person: PERSON_EDIT_KEYS)
+ end
+
+ def nested_user_person_params
+ raw_person_params = params.dig(:user, :person)
+ return {} unless raw_person_params.respond_to?(:permit)
+
+ raw_person_params.permit(*PERSON_FORM_KEYS).to_h.symbolize_keys
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/admin/users/update_handling.rb b/app/controllers/concerns/admin/users/update_handling.rb
new file mode 100644
index 00000000..cf6ac900
--- /dev/null
+++ b/app/controllers/concerns/admin/users/update_handling.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+module Admin
+ module Users
+ module UpdateHandling
+ extend ActiveSupport::Concern
+
+ USER_UPDATE_CONTROL_KEYS = %i[email_address system_role created_by_admin create_web_account].freeze
+
+ private
+
+ def handle_person_update
+ @person = PersonQuery.active.find(extracted_person_id(params[:id]))
+ result = build_person_update_result(@person)
+
+ return respond_to_successful_person_update(result.person || @person) if result.success?
+ return respond_to_failed_person_update(result) unless request.xhr?
+
+ render json: { success: false, errors: result.errors }, status: :unprocessable_content
+ end
+
+ def handle_user_update
+ respond_to do |format|
+ result = build_user_update_result
+ result.success? ? respond_to_successful_user_update(format) : respond_to_failed_user_update(format, result)
+ end
+ end
+
+ def build_person_update_result(person)
+ person_attributes = person_params.to_h.deep_symbolize_keys
+ newsletter_flag = ActiveModel::Type::Boolean.new.cast(person_attributes.delete(:newsletter_subscribed))
+
+ People::Register.new(
+ person_params: person_attributes.merge(allow_blank_attributes: true),
+ existing_person: person,
+ newsletter_subscribed: newsletter_flag,
+ newsletter_source: "admin",
+ create_user_account: false,
+ create_membership: false
+ ).call
+ end
+
+ def respond_to_successful_person_update(person)
+ if request.xhr?
+ render json: {
+ success: true,
+ member_number: person.reload.member_number,
+ message: t(".ajax_success_json_message")
+ }
+ else
+ redirect_to admin_user_path(person_route_key(person)), notice: t(".person_saved_notice")
+ end
+ end
+
+ def respond_to_failed_person_update(result)
+ flash.now[:alert] = result.message
+ render :edit_person, status: :unprocessable_content
+ end
+
+ def build_user_update_result
+ user_only_params, person_params_flat, newsletter_subscribed_value = extracted_user_update_attributes
+
+ UserManagement::UserUpdater.new(
+ user_id: @user.id,
+ email_address: user_only_params[:email_address],
+ system_role: user_only_params[:system_role],
+ person_attributes: person_params_flat,
+ newsletter_subscribed: newsletter_subscribed_value,
+ updated_by_id: Current.user.id
+ ).call
+ end
+
+ def extracted_user_update_attributes
+ permitted_params = user_params
+ user_only_params = permitted_params.slice(*USER_UPDATE_CONTROL_KEYS)
+ person_params_flat = permitted_params.except(*USER_UPDATE_CONTROL_KEYS, :person)
+ newsletter_subscribed_value = extract_newsletter_subscribed!(
+ source_params: permitted_params,
+ person_params: person_params_flat
+ )
+
+ [ user_only_params, person_params_flat, newsletter_subscribed_value ]
+ end
+
+ def respond_to_successful_user_update(format)
+ format.html { redirect_to admin_user_path(@user), notice: t(".html_updated") }
+ format.json { render json: @user }
+ format.turbo_stream do
+ flash.now[:notice] = t(".turbo_notice")
+ render turbo_stream: [
+ turbo_stream.replace(@user),
+ turbo_stream.replace("flash", partial: "shared/flash")
+ ]
+ end
+ end
+
+ def respond_to_failed_user_update(format, result)
+ format.html { render :show, status: :unprocessable_content, alert: result.message }
+ format.json { render json: { errors: result.errors }, status: :unprocessable_content }
+ format.turbo_stream do
+ render turbo_stream: turbo_stream.replace(
+ "error_explanation",
+ partial: "shared/error_messages",
+ locals: { resource: @user, errors: result.errors }
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index 2b3604ec..339ac88a 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -33,27 +33,90 @@ def resume_session
end
def find_session_by_cookie
- Session.find_by(id: cookies.signed[:session_id])
+ session_id = cookies.signed[:session_id]
+ return nil if session_id.blank?
+
+ found = Session.find_by(id: session_id)
+ if found.nil?
+ cookies.delete(:session_id, **SESSION_COOKIE_OPTS)
+ Rails.logger.warn(
+ "[auth.audit] stale_session_cookie cookie_session_id=#{session_id} path=#{request.path} method=#{request.request_method}"
+ )
+ return nil
+ end
+
+ user = User.find_by(id: found.user_id)
+ if user.nil? || user.deleted?
+ reason = user.nil? ? "missing_user" : "user_deleted"
+ db_session_id = found.id
+ db_user_id = found.user_id
+ found.destroy
+ cookies.delete(:session_id, **SESSION_COOKIE_OPTS)
+ Rails.logger.warn(
+ "[auth.audit] invalid_session_cookie reason=#{reason} cookie_session_id=#{session_id} " \
+ "db_session_id=#{db_session_id} user_id=#{db_user_id} path=#{request.path} method=#{request.request_method}"
+ )
+ return nil
+ end
+
+ found
end
def request_authentication
- session[:return_to_after_authenticating] = request.url
+ begin
+ uri = URI.parse(request.url)
+ path = uri.path.to_s.presence || "/"
+ session[:return_to_after_authenticating] = request.url unless non_gettable_redirect_path?(path)
+ rescue URI::InvalidURIError
+ session[:return_to_after_authenticating] = request.url
+ end
+
redirect_to new_session_path
end
+ # Cible après login : ne jamais rediriger vers une URL en GET qui n'existe pas
+ # (ex. /session et /registration sont POST-only → 303 après POST créait GET /session → RoutingError).
def after_authentication_url
- session.delete(:return_to_after_authenticating) || root_url
+ stored = session.delete(:return_to_after_authenticating)
+ safe_url_after_authentication(stored)
end
+ def safe_url_after_authentication(stored)
+ return root_path if stored.blank?
+
+ uri = URI.parse(stored.to_s.strip)
+
+ if uri.host.present?
+ return root_path unless uri.host == request.host && uri.port == request.port
+ end
+
+ path = uri.path.to_s
+ path = "/" if path.blank?
+ return root_path if non_gettable_redirect_path?(path)
+
+ stored.to_s
+ rescue URI::InvalidURIError
+ root_path
+ end
+
+ def non_gettable_redirect_path?(path)
+ path == "/session" || path == "/registration"
+ end
+
+ # Même jeu d’options à la pose et à la suppression du cookie (évite un cookie « mort »).
+ SESSION_COOKIE_OPTS = { httponly: true, same_site: :lax, path: "/" }.freeze
+
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
- cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
+ cookies.signed.permanent[:session_id] = { value: session.id, **SESSION_COOKIE_OPTS }
end
end
def terminate_session
- Current.session.destroy
- cookies.delete(:session_id)
+ Current.session&.destroy
+ ensure
+ Current.session = nil
+ cookies.delete(:session_id, **SESSION_COOKIE_OPTS)
end
end
diff --git a/app/controllers/concerns/newsletter_param_parser.rb b/app/controllers/concerns/newsletter_param_parser.rb
new file mode 100644
index 00000000..e49fdc38
--- /dev/null
+++ b/app/controllers/concerns/newsletter_param_parser.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module NewsletterParamParser
+ private
+
+ # Returns nil when newsletter key is absent, to avoid implicit unsubscribe.
+ def extract_newsletter_subscribed!(source_params:, person_params:)
+ newsletter_explicit = source_params.key?(:newsletter_subscribed)
+ newsletter_flag = person_params.delete(:newsletter_subscribed)
+ return nil unless newsletter_explicit
+
+ [ "1", true, 1 ].include?(newsletter_flag)
+ end
+end
diff --git a/app/controllers/concerns/profile_section_turbo_streams.rb b/app/controllers/concerns/profile_section_turbo_streams.rb
new file mode 100644
index 00000000..e75e0061
--- /dev/null
+++ b/app/controllers/concerns/profile_section_turbo_streams.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+# Builds Turbo Stream responses for embedded profile sections (contact + account).
+module ProfileSectionTurboStreams
+ extend ActiveSupport::Concern
+
+ private
+
+ def profile_contact_section_update_streams(user)
+ [
+ profile_contact_section_replace_action(user),
+ profile_flash_replace_action
+ ]
+ end
+
+ def profile_account_section_update_streams(user, embedded:, compact:)
+ [
+ profile_account_section_replace_action(user, embedded: embedded, compact: compact),
+ profile_flash_replace_action
+ ]
+ end
+
+ def profile_contact_section_replace_action(user)
+ turbo_stream.replace(
+ ProfileSectionDomIds::CONTACT_SECTION,
+ render_to_string(
+ partial: "users/contact_section",
+ locals: {
+ user: user,
+ frame_id: ProfileSectionDomIds::CONTACT_SECTION
+ }
+ )
+ )
+ end
+
+ def profile_account_section_replace_action(user, embedded:, compact:)
+ turbo_stream.replace(
+ ProfileSectionDomIds::ACCOUNT_SECTION,
+ render_to_string(
+ partial: "settings/account_section",
+ locals: {
+ user: user,
+ frame_id: ProfileSectionDomIds::ACCOUNT_SECTION,
+ embedded: embedded,
+ compact: compact
+ }
+ )
+ )
+ end
+
+ def profile_flash_replace_action
+ turbo_stream.replace(
+ ProfileSectionDomIds::FLASH_FRAME,
+ render_to_string(partial: "shared/flash")
+ )
+ end
+end
diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb
index 0d46473f..6cf53629 100644
--- a/app/controllers/contacts_controller.rb
+++ b/app/controllers/contacts_controller.rb
@@ -4,6 +4,9 @@ class ContactsController < ApplicationController
allow_unauthenticated_access only: :create
CONTACT_SUBMISSION_KEYS = %i[name email message category].freeze
+ LEGACY_CONTACT_CATEGORY_TO_CANONICAL = {
+ "residence" => "creative_hosting"
+ }.freeze
def create
@contact = contact_submission_params
@@ -13,23 +16,25 @@ def create
return
end
- recipient_email = case @contact[:category]
- when "technical"
- ENV.fetch("CONTACT_EMAIL_TECHNICAL", nil)
- when "residence"
- ENV.fetch("CONTACT_EMAIL_RESIDENCE", nil)
- when "partnership"
- ENV.fetch("CONTACT_EMAIL_PARTNERSHIP", nil)
- else
- ENV.fetch("CONTACT_EMAIL_GENERAL", nil)
- end
+ category = canonical_contact_category(@contact[:category])
+ recipient_email =
+ case category
+ when "technical"
+ ENV.fetch("CONTACT_EMAIL_TECHNICAL", nil)
+ when "creative_hosting"
+ ENV.fetch("CONTACT_EMAIL_CREATIVE_HOSTING", nil) || ENV.fetch("CONTACT_EMAIL_RESIDENCE", nil)
+ when "partnership"
+ ENV.fetch("CONTACT_EMAIL_PARTNERSHIP", nil)
+ else
+ ENV.fetch("CONTACT_EMAIL_GENERAL", nil)
+ end
begin
UserMailer.contact_email(
@contact[:name],
@contact[:email],
@contact[:message],
- @contact[:category],
+ category,
recipient_email
).deliver_later
@@ -50,6 +55,11 @@ def create
private
+ def canonical_contact_category(raw)
+ key = raw.to_s
+ LEGACY_CONTACT_CATEGORY_TO_CANONICAL.fetch(key, key)
+ end
+
# Formulaire public : champs à la racine (`name`, `email`, …), pas `contact[...]`.
# Toujours inclure les clés requises : `permit` seul omet les paramètres absents, ce qui faisait
# passer des requêtes incomplètes jusqu’au mailer.
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 89d38411..3c2ed544 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class EventsController < ApplicationController
- skip_before_action :require_authentication, only: %i[index show upcoming]
+ skip_before_action :require_authentication, only: %i[index show upcoming past]
def index
redirect_to page_path("news", anchor: "evenements")
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index 3124e615..4495f79f 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -35,7 +35,6 @@ def show
if params[:id] == "contact_us"
@contact = {}
- @faqs = contact_faq_entries
end
@adhesion_faqs = adhesion_faq_entries if params[:id] == "become_member"
@@ -60,29 +59,78 @@ def show
private
def contact_faq_entries
+ cf = page_path("contact_us", anchor: "contact-form")
+ lc = "text-[#5836A5] underline hover:text-[#412886]"
[
- { question: "Comment adhérer au Circographe ?", answer: "Passe sur un créneau d'ouverture : on remplit la fiche ensemble et on t'explique le fonctionnement." },
- { question: "Puis-je réserver un créneau de résidence ?", answer: "Oui, écris-nous via la catégorie 'Résidence'. Nous te recontacterons avec les disponibilités et modalités." },
- { question: "Le lieu est-il accessible aux débutant·es ?", answer: "Les entraînements libres sont destinés aux personnes autonomes. Pour débuter, on recommande une école partenaire : contacte-nous pour des conseils." },
- { question: "Proposez-vous des prestations ou des partenariats ?", answer: "Oui, nous travaillons avec des structures culturelles, établissements scolaires et entreprises. Sélectionne la catégorie 'Partenariat' pour en discuter." }
+ {
+ question: "Demander un temps d’accueil en création",
+ answer_html: helpers.safe_join([
+ "Écris-nous avec le ",
+ helpers.link_to("formulaire Contact", cf, class: lc),
+ ", catégorie « Temps d’accueil en création » — on te répond sur les dispo et le cadre."
+ ])
+ },
+ {
+ question: "Partenariat, atelier, événement ou projet avec le lieu",
+ answer_html: helpers.safe_join([
+ "Le plus simple : passer lors d’un créneau d’ouverture avec ton idée. Tu peux aussi utiliser le ",
+ helpers.link_to("formulaire Contact", cf, class: lc),
+ " (Partenariat ou Question générale). Les bénévoles t’orientent."
+ ])
+ }
]
end
def adhesion_faq_entries
+ cf = page_path("contact_us", anchor: "contact-form")
+ lc = "text-[#5836A5] underline hover:text-[#412886]"
[
- { question: "Puis-je adhérer en ligne ?", answer: "L'inscription se fait uniquement sur place afin de te présenter le lieu et les règles d'autogestion." },
- { question: "Quels moyens de paiement acceptez-vous ?", answer: "Carte bancaire et espèces. Une adhésion de soutien peut également être effectuée par virement sur demande." },
- { question: "Faut-il être autonome pour les entraînements libres ?", answer: "Oui, les créneaux libres s'adressent aux pratiquant·es autonomes. Pour débuter, on peut te recommander des écoles partenaires." },
- { question: "Puis-je proposer un atelier ou un événement ?", answer: "Tout est possible ! Passe nous voir avec ton idée, on regardera ensemble comment l'inscrire dans la programmation." }
+ {
+ question: "Comment adhérer ?",
+ answer: "Sur place, lors d’un créneau d’accueil : visite du lieu, fiche d’adhésion et explication de l’autogestion. Pas d’inscription en ligne — on fait ça ensemble au lieu pour que chacun·e parte avec les mêmes repères."
+ },
+ {
+ question: "Paiement adhésion ou cotisation",
+ answer: "Carte bancaire ou espèces à l’accueil, avec les bénévoles."
+ },
+ {
+ question: "Je débute : les entraînements libres me concernent ?",
+ answer_html: helpers.safe_join([
+ "Les créneaux libres cirque sont pour des pratiquant·es autonomes en sécurité. Pour apprendre les bases, une école partenaire ; pour une orientation, ",
+ helpers.link_to("écris-nous", cf, class: lc),
+ " en Question générale."
+ ])
+ }
]
end
def general_faq_entries
+ cf = page_path("contact_us", anchor: "contact-form")
+ bm = page_path("become_member", anchor: "tarifs")
+ lc = "text-[#5836A5] underline hover:text-[#412886]"
[
- { question: "Où se situe le Circographe ?", answer: "Au 27 bis allée Maurice Sarraut, Toulouse — dans le quartier de la Cartoucherie. Consulte la page Contact pour la carte et l'accès." },
- { question: "Quels sont les horaires d'ouverture ?", answer: "Les créneaux publics évoluent chaque saison ; on les met à jour sur les pages Accueil, Adhérer et Contact. Pense à vérifier avant de te déplacer." },
- { question: "Comment soutenir financièrement le projet ?", answer: "En adhérant, en souscrivant à l'adhésion soutien ou en faisant un don ponctuel. Écris-nous si tu souhaites devenir partenaire." },
- { question: "J'ai une question administrative, qui contacter ?", answer: "Utilise le formulaire de contact (catégorie 'Question générale') ou écris à contact@circographe.fr ; l'équipe bénévole te répondra rapidement." }
+ {
+ question: "Adresse et accès",
+ answer_html: helpers.safe_join([
+ "97 bis boulevard de Suisse, 31200 Toulouse. ",
+ helpers.link_to("Plan et bus (ligne 15)", page_path("contact_us", anchor: "map"), class: lc),
+ "."
+ ])
+ },
+ {
+ question: "Horaires",
+ answer: "Les créneaux publics bougent selon la saison et les bénévoles. À jour sur l’accueil, la page Adhérer et la page Contact — jette un œil avant de venir."
+ },
+ {
+ question: "Soutenir le lieu ou une question administrative",
+ answer_html: helpers.safe_join([
+ "Adhérer, cotisation, don : ",
+ helpers.link_to("page Adhérer", bm, class: lc),
+ ". Pour un partenariat, un don hors cadre ou une demande administrative : ",
+ helpers.link_to("formulaire Contact", cf, class: lc),
+ " (Question générale ou Partenariat)."
+ ])
+ }
]
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 5027d65f..63af7a18 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -6,24 +6,28 @@ class SessionsController < ApplicationController
with: -> { redirect_to new_session_url, alert: I18n.t("sessions.rate_limited_alert") }
def new
- return unless authenticated?
-
- redirect_to root_path
+ # Déjà connecté·e : ne pas afficher le formulaire (évite la confusion après un POST échoué avec cookie valide).
+ redirect_to(root_path, status: :see_other) if authenticated?
end
def create
- respond_to do |format|
- if (user = User.authenticate_by(params.permit(:email_address, :password)))
- start_new_session_for user
- flash[:notice] = t(".login_success_notice")
- format.turbo_stream { redirect_to after_authentication_url }
- format.html { redirect_to after_authentication_url, notice: t(".login_success_notice") }
- else
+ # Même logique que #new : si une session cookie est valide, inutile de traiter un « second » login ici.
+ return redirect_to(root_path, status: :see_other, notice: t("sessions.already_signed_in_notice")) if authenticated?
+
+ email_address, password = params.expect(:email_address, :password)
+
+ if (user = User.authenticate_by(email_address:, password:))
+ start_new_session_for(user)
+ # 303 + Turbo : évite les redirections POST ignorées ou sans cookie ; même comportement qu’un POST HTML classique.
+ redirect_to after_authentication_url, notice: t(".login_success_notice"), status: :see_other
+ else
+ log_system_account_login_incident(email_address)
+ respond_to do |format|
format.turbo_stream do
flash.now[:alert] = t(".invalid_credentials_flash")
render turbo_stream: turbo_stream.replace("flash", render_to_string(partial: "shared/flash")), status: :unprocessable_content
end
- format.html { redirect_to new_session_path, alert: t(".invalid_credentials_redirect") }
+ format.html { redirect_to new_session_path, alert: t(".invalid_credentials_redirect"), status: :see_other }
end
end
end
@@ -36,4 +40,23 @@ def destroy
format.html { redirect_to root_path, notice: t(".signed_out_notice") }
end
end
+
+ private
+
+ def log_system_account_login_incident(email_address)
+ return unless Rails.env.development?
+
+ tracked_accounts = %w[super-admin@rails.com admin@rails.com volunteer@rails.com]
+ normalized_email = email_address.to_s.strip.downcase
+ return unless tracked_accounts.include?(normalized_email)
+
+ DevIncidentLogger.log!(
+ type: "system_account_login_failed",
+ details: {
+ email_address: normalized_email,
+ user_exists: User.exists?(email_address: normalized_email),
+ users_count: User.count
+ }
+ )
+ end
end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index e8dc446b..2ad5a708 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -1,37 +1,43 @@
# frozen_string_literal: true
class SettingsController < ApplicationController
+ include ProfileSectionTurboStreams
+ include NewsletterParamParser
+
before_action :require_authentication
+
def show
@user = current_user
end
def update
@user = current_user
+ profile_context = params[:ui_context] == "profile"
+
+ if email_change_flow?
+ return handle_email_change(profile_context)
+ end
- # Séparer les paramètres User des paramètres Person
user_only_params = user_params.slice(:email_address)
person_params = user_params.except(:email_address)
- newsletter_flag = person_params.delete(:newsletter_subscribed)
+ newsletter_subscribed_value = extract_newsletter_subscribed!(
+ source_params: user_params,
+ person_params: person_params
+ )
- # Utiliser le service UserManagement::UserUpdater
updater = UserManagement::UserUpdater.new(
user_id: @user.id,
email_address: user_only_params[:email_address],
person_attributes: person_params,
- newsletter_subscribed: [ "1", true, 1 ].include?(newsletter_flag),
+ newsletter_subscribed: newsletter_subscribed_value,
updated_by_id: @user.id
)
result = updater.call
- if result.success?
- flash[:notice] = t(".saved_notice")
- redirect_to user_path(@user), status: :see_other
- else
- flash.now[:alert] = result.message
- render :show, status: :unprocessable_content
- end
+ return render_profile_section_success(t(".saved_notice"), profile_context) if result.success?
+
+ render_profile_section_error(result.message, profile_context)
end
private
@@ -43,4 +49,98 @@ def user_params
dyslexic_font]
)
end
+
+ def email_change_flow?
+ params.key?(:email_confirm) || params.key?(:email_verification_code)
+ end
+
+ def handle_email_change(profile_context)
+ new_email = params.dig(:user, :email_address).to_s.strip.downcase
+ email_confirm = params[:email_confirm].to_s.strip.downcase
+ verification_code = params[:email_verification_code].to_s.strip
+
+ if verification_code.present?
+ confirm_email_change!(new_email:, email_confirm:, verification_code:, profile_context:)
+ else
+ request_email_change_code!(new_email:, email_confirm:, profile_context:)
+ end
+ end
+
+ def request_email_change_code!(new_email:, email_confirm:, profile_context:)
+ return render_email_change_error(t("settings.update.email_change_error_blank"), profile_context) if new_email.blank? || email_confirm.blank?
+ return render_email_change_error(t("settings.update.email_change_error_mismatch"), profile_context) if new_email != email_confirm
+ return render_email_change_error(t("settings.update.email_change_error_unchanged"), profile_context) if new_email == @user.email_address.to_s.downcase
+ return render_email_change_error(t("settings.update.email_change_error_taken"), profile_context) if User.where.not(id: @user.id).exists?(email_address: new_email)
+
+ code = format("%06d", SecureRandom.random_number(1_000_000))
+ @user.store_email_change_request!(new_email:, code:)
+ UserMailer.email_change_verification(@user, new_email, code).deliver_now
+
+ render_email_change_success(
+ t("settings.update.email_change_code_sent_notice", email: new_email),
+ profile_context
+ )
+ rescue StandardError => e
+ Rails.logger.error("[SettingsController] email change code request failed: #{e.message}")
+ render_email_change_error(t("settings.update.email_change_error_send_failed"), profile_context)
+ end
+
+ def confirm_email_change!(new_email:, email_confirm:, verification_code:, profile_context:)
+ return render_email_change_error(t("settings.update.email_change_error_no_request"), profile_context) if @user.pending_email_address.blank?
+ return render_email_change_error(t("settings.update.email_change_error_expired"), profile_context) if @user.email_change_code_expired?
+ return render_email_change_error(t("settings.update.email_change_error_mismatch"), profile_context) if new_email.blank? || email_confirm.blank? || new_email != email_confirm
+ return render_email_change_error(t("settings.update.email_change_error_pending_mismatch"), profile_context) if @user.pending_email_address != new_email
+ return render_email_change_error(t("settings.update.email_change_error_invalid_code"), profile_context) unless @user.email_change_code_valid?(verification_code)
+
+ @user.update!(email_address: @user.pending_email_address)
+ @user.clear_email_change_request!
+
+ render_email_change_success(t("settings.update.email_change_success_notice"), profile_context)
+ rescue ActiveRecord::RecordInvalid => e
+ render_email_change_error(e.record.errors.full_messages.to_sentence, profile_context)
+ rescue StandardError => e
+ Rails.logger.error("[SettingsController] email change confirmation failed: #{e.message}")
+ render_email_change_error(t("settings.update.email_change_error_confirm_failed"), profile_context)
+ end
+
+ def render_email_change_success(message, profile_context)
+ render_profile_section_success(message, profile_context, reload_user: true)
+ end
+
+ def render_email_change_error(message, profile_context)
+ render_profile_section_error(message, profile_context)
+ end
+
+ def render_profile_section_success(message, profile_context, reload_user: false)
+ account_user = reload_user ? @user.reload : @user
+
+ respond_to do |format|
+ format.turbo_stream do
+ flash.now[:notice] = message
+ render turbo_stream: profile_account_section_update_streams(
+ account_user,
+ embedded: profile_context,
+ compact: profile_context
+ )
+ end
+ format.html { redirect_to settings_path, notice: message, status: :see_other }
+ end
+ end
+
+ def render_profile_section_error(message, profile_context)
+ respond_to do |format|
+ format.turbo_stream do
+ flash.now[:alert] = message
+ render turbo_stream: profile_account_section_update_streams(
+ @user,
+ embedded: profile_context,
+ compact: profile_context
+ ), status: :unprocessable_content
+ end
+ format.html do
+ flash.now[:alert] = message
+ render :show, status: :unprocessable_content
+ end
+ end
+ end
end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 47ef9d2d..00b6d475 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class UsersController < ApplicationController
+ include ProfileSectionTurboStreams
+
# This controller handles user profile management for authenticated users.
# It allows users to view and edit their profile information and
# delete their account (GDPR compliance).
@@ -16,25 +18,41 @@ class UsersController < ApplicationController
# User profile view
def show; end
- # User profile edit form
- def edit; end
+ # User profile edit form — unified on users#show (#contact)
+ def edit
+ redirect_to user_path(@user, anchor: "contact"), status: :see_other
+ end
- # User profile update
+ # User profile update (postal / phone coordinates only; account email & prefs → Settings)
def update
- # Utiliser le service UserManagement::UserUpdater
updater = UserManagement::UserUpdater.new(
user_id: @user.id,
- person_attributes: person_params,
+ person_attributes: coordinate_params,
updated_by_id: @user.id
)
result = updater.call
if result.success?
- redirect_to @user, notice: t(".profile_updated")
+ respond_to do |format|
+ format.turbo_stream do
+ flash.now[:notice] = t(".coordinates_updated")
+ render turbo_stream: profile_contact_section_update_streams(@user)
+ end
+ format.html { redirect_to user_path(@user, anchor: "contact"), notice: t(".coordinates_updated") }
+ end
else
- flash.now[:alert] = result.message
- render :edit, status: :unprocessable_content
+ respond_to do |format|
+ format.turbo_stream do
+ flash.now[:alert] = result.message
+ render turbo_stream: profile_contact_section_update_streams(@user),
+ status: :unprocessable_content
+ end
+ format.html do
+ flash.now[:alert] = result.message
+ render :show, status: :unprocessable_content
+ end
+ end
end
end
@@ -46,13 +64,13 @@ def destroy
reset_session
redirect_to root_path, notice: t(".deleted_notice")
else
- redirect_to edit_user_path(@user), alert: t(".destroy_failed_alert")
+ redirect_to user_path(@user, anchor: "account"), alert: t(".destroy_failed_alert")
end
end
# Newsletter subscription management (legacy, redirect to settings)
def change_newsletter_status
- redirect_to settings_path, alert: t(".redirect_manage_in_settings_alert")
+ redirect_to user_path(Current.user, anchor: "account"), alert: t(".redirect_manage_in_settings_alert")
end
# Handle newsletter signup from footer
@@ -75,7 +93,7 @@ def newsletter_signup
# If authenticated, redirect to settings (no form for connected users)
if authenticated? && Current.user.present?
- redirect_to settings_path, notice: t(".manage_newsletter_notice")
+ redirect_to user_path(Current.user, anchor: "account"), notice: t(".manage_newsletter_notice")
return
end
@@ -110,18 +128,10 @@ def set_user
@user = Current.user
end
- # Permitted parameters for person data (migrated from user)
- def person_params
+ # Postal / phone fields only (email & preference toggles use SettingsController)
+ def coordinate_params
params.expect(
- user: %i[phone
- address
- zip_code
- town
- country
- image_rights
- get_involved
- newsletter_subscribed
- dyslexic_font]
+ user: %i[phone address zip_code town country]
)
end
end
diff --git a/app/helpers/admin/memberships_helper.rb b/app/helpers/admin/memberships_helper.rb
index 1bf33f5d..bbb7cbcb 100644
--- a/app/helpers/admin/memberships_helper.rb
+++ b/app/helpers/admin/memberships_helper.rb
@@ -2,33 +2,12 @@
module Admin
module MembershipsHelper
- # Calculer et formater la différence de prix pour l'upgrade
- def upgrade_price_difference(new_membership_type, current_membership:)
- return "0€" unless current_membership&.membership_type
-
- old_price = current_membership.membership_type.price_cents
- new_price = new_membership_type.price_cents
- difference = new_price - old_price
-
- if difference.positive?
- "+#{difference / 100.0}€"
- elsif difference.negative?
- "#{difference / 100.0}€"
- else
- "0€"
- end
+ def upgrade_membership_options_for(membership_types)
+ membership_types.map { |membership_type| [ membership_type.name_with_price, membership_type.id ] }
end
- # Formater le nom avec la différence de prix pour l'upgrade
- def upgrade_name_with_price(membership_type, current_membership:)
- base_name = membership_type.name
- price_diff = upgrade_price_difference(membership_type, current_membership: current_membership)
-
- if price_diff == "0€"
- "#{base_name} - Gratuit"
- else
- "#{base_name} - #{price_diff}"
- end
+ def upgrade_membership_rule_hint
+ "Le montant facture correspond a l'adhesion Cirque choisie. Aucun prorata de l'adhesion actuelle n'est deduit."
end
end
end
diff --git a/app/helpers/admin/payments_helper.rb b/app/helpers/admin/payments_helper.rb
index 6295253d..8c78b2b1 100644
--- a/app/helpers/admin/payments_helper.rb
+++ b/app/helpers/admin/payments_helper.rb
@@ -47,7 +47,7 @@ def payment_lines_display(payment)
if payment.payment_lines.any?
payment.payment_lines.map do |line|
content_tag :div, class: "text-xs" do
- "#{line.description || line.item_type}: #{number_to_currency(line.price_euros, unit: '€', separator: ',', delimiter: ' ')}"
+ "#{line.history_description}: #{number_to_currency(line.price_euros, unit: '€', separator: ',', delimiter: ' ')}"
end
end.join.html_safe
else
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index af35a816..b138d6cd 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -2,6 +2,7 @@
module ApplicationHelper
include Pagy::Frontend
+ include MembershipCardHelper
def public_registration_enabled?
Rails.application.config.x.public_registration_enabled
@@ -90,6 +91,13 @@ def available_hero_images(except: [])
fallback && exclusions.exclude?(fallback.to_s) ? [ fallback ] : []
end
+ # Filenames under app/assets/images/ (same pool as hero_image). Sorted for stable order.
+ # Swiper receives several slides but only the first image uses eager loading.
+ def news_carousel_image_sources(limit: 12)
+ max_slides = limit.to_i.clamp(1, 24)
+ hero_image_pool.uniq.sort.take(max_slides)
+ end
+
def asset_available?(logical_path)
return false if logical_path.blank?
diff --git a/app/helpers/membership_card_helper.rb b/app/helpers/membership_card_helper.rb
new file mode 100644
index 00000000..853fb4ca
--- /dev/null
+++ b/app/helpers/membership_card_helper.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+# Shared presentation for `shared/_membershipcard` (role badge, avatar).
+module MembershipCardHelper
+ MEMBERSHIP_CARD_ROLE_BADGE_CLASSES = {
+ "super_admin" => "bg-[#1F5C55]",
+ "admin" => "bg-[#1F5C55]/90",
+ "volunteer" => "bg-[#1F5C55]/80",
+ "web_visitor" => "bg-[#1F5C55]/70"
+ }.freeze
+
+ MEMBERSHIP_CARD_AVATARS = {
+ "super_admin" => [ "super_admin.webp", "Avatar Super administrateur" ],
+ "admin" => [ "admin.webp", "Avatar Administrateur" ],
+ "volunteer" => [ "volunteer.webp", "Avatar Bénévole" ],
+ "web_visitor" => [ "users.png", "Avatar Visiteur web" ]
+ }.freeze
+
+ DEFAULT_AVATAR = [ "users.png", "Avatar" ].freeze
+
+ def membership_card_role_badge_classes(system_role)
+ MEMBERSHIP_CARD_ROLE_BADGE_CLASSES[system_role.to_s] || "bg-[#1F5C55]/70"
+ end
+
+ # @return [Array(String, String)] image filename under app/assets/images, alt text
+ def membership_card_avatar_source_and_alt(user)
+ MEMBERSHIP_CARD_AVATARS[user.system_role.to_s] || DEFAULT_AVATAR
+ end
+
+ def membership_card_display_name(user)
+ user.full_name.presence || "Membre #{user.id}"
+ end
+end
diff --git a/app/javascript/confirm_modal.js b/app/javascript/confirm_modal.js
index 84eae7b7..da0f39d5 100644
--- a/app/javascript/confirm_modal.js
+++ b/app/javascript/confirm_modal.js
@@ -2,11 +2,14 @@ import { Turbo } from "@hotwired/turbo-rails"
const HIDDEN_CLASS = "hidden"
const DEFAULT_CONFIRM_MESSAGE = "Es-tu sûr·e de vouloir continuer ?"
+const DEFAULT_CONFIRM_BODY_FALLBACK = "Êtes-vous sûr de vouloir poursuivre cette action ?"
+const DEFAULT_CONFIRM_TITLE = "Confirmation"
let modalElement
let backdropElement
let dialogElement
let messageElement
+let titleElement
let confirmButton
let cancelButton
let activeResolve
@@ -22,12 +25,28 @@ function cacheElements() {
backdropElement = document.getElementById("confirm-modal-backdrop")
dialogElement = modalElement.querySelector("[data-confirm-dialog='true']") || modalElement
messageElement = document.getElementById("confirm-modal-message")
+ titleElement = document.getElementById("confirm-modal-title")
confirmButton = document.getElementById("confirm-modal-confirm")
cancelButton = document.getElementById("confirm-modal-cancel")
return Boolean(messageElement && confirmButton && cancelButton)
}
+function resetConfirmModalChrome() {
+ if (titleElement) {
+ titleElement.textContent = DEFAULT_CONFIRM_TITLE
+ }
+ if (messageElement) {
+ messageElement.replaceChildren()
+ const p = document.createElement("p")
+ p.className = "text-base leading-relaxed"
+ p.textContent = DEFAULT_CONFIRM_BODY_FALLBACK
+ messageElement.appendChild(p)
+ }
+ if (confirmButton) confirmButton.textContent = "Confirmer"
+ if (cancelButton) cancelButton.textContent = "Annuler"
+}
+
function closeModal(result) {
if (!modalElement) return
@@ -37,6 +56,8 @@ function closeModal(result) {
document.removeEventListener("keydown", keydownHandler)
keydownHandler = null
+ resetConfirmModalChrome()
+
if (previousFocus && typeof previousFocus.focus === "function") {
previousFocus.focus()
}
@@ -54,7 +75,97 @@ function showModal(message, element) {
return Promise.resolve(window.confirm(message))
}
- messageElement.textContent = message || DEFAULT_CONFIRM_MESSAGE
+ resetConfirmModalChrome()
+ messageElement.replaceChildren()
+ const p = document.createElement("p")
+ p.className = "text-base leading-relaxed"
+ p.textContent = message || DEFAULT_CONFIRM_MESSAGE
+ messageElement.appendChild(p)
+
+ return new Promise((resolve) => {
+ activeResolve = resolve
+ previousFocus = document.activeElement
+
+ modalElement.classList.remove(HIDDEN_CLASS)
+ modalElement.removeAttribute("aria-hidden")
+
+ requestAnimationFrame(() => {
+ if (dialogElement) {
+ dialogElement.focus()
+ } else {
+ confirmButton.focus()
+ }
+ })
+
+ const finish = (result) => {
+ confirmButton.removeEventListener("click", confirmHandler)
+ cancelButton.removeEventListener("click", cancelHandler)
+ if (backdropElement) {
+ backdropElement.removeEventListener("click", backdropHandler)
+ }
+ closeModal(result)
+ }
+
+ const confirmHandler = () => finish(true)
+ const cancelHandler = () => finish(false)
+
+ const backdropHandler = (event) => {
+ if (event.target === backdropElement) {
+ finish(false)
+ }
+ }
+
+ confirmButton.addEventListener("click", confirmHandler)
+ cancelButton.addEventListener("click", cancelHandler)
+ if (backdropElement) {
+ backdropElement.addEventListener("click", backdropHandler)
+ }
+
+ keydownHandler = (event) => {
+ if (event.key === "Escape") {
+ event.preventDefault()
+ finish(false)
+ } else if (event.key === "Enter" && document.activeElement === confirmButton) {
+ event.preventDefault()
+ finish(true)
+ }
+ }
+
+ document.addEventListener("keydown", keydownHandler)
+ })
+}
+
+export function openRichConfirmModal(options = {}) {
+ const {
+ title = DEFAULT_CONFIRM_TITLE,
+ introText = "",
+ htmlBody = "",
+ confirmText = "Confirmer",
+ cancelText = "Annuler"
+ } = options
+
+ if (!modalElement && !cacheElements()) {
+ return Promise.resolve(window.confirm(introText || DEFAULT_CONFIRM_MESSAGE))
+ }
+
+ if (titleElement) titleElement.textContent = title
+ confirmButton.textContent = confirmText
+ cancelButton.textContent = cancelText
+
+ messageElement.replaceChildren()
+ if (introText) {
+ const intro = document.createElement("p")
+ intro.className = "text-base leading-relaxed text-gray-700"
+ intro.textContent = introText
+ messageElement.appendChild(intro)
+ }
+ if (htmlBody) {
+ const wrap = document.createElement("div")
+ wrap.className =
+ "mt-2 max-h-52 overflow-y-auto rounded-lg bg-gray-50 px-3 py-2 text-sm text-gray-900 border border-gray-100"
+ wrap.innerHTML = htmlBody
+ messageElement.appendChild(wrap)
+ }
return new Promise((resolve) => {
activeResolve = resolve
diff --git a/app/javascript/controllers/admin_users_index_controller.js b/app/javascript/controllers/admin_users_index_controller.js
new file mode 100644
index 00000000..81b16554
--- /dev/null
+++ b/app/javascript/controllers/admin_users_index_controller.js
@@ -0,0 +1,61 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["searchInput", "hiddenSearch", "itemsPerPage"]
+ static values = { debounceMs: { type: Number, default: 500 } }
+
+ connect() {
+ this.boundHandleGlobalKeydown = this.handleGlobalKeydown.bind(this)
+ document.addEventListener("keydown", this.boundHandleGlobalKeydown)
+ }
+
+ disconnect() {
+ document.removeEventListener("keydown", this.boundHandleGlobalKeydown)
+ if (this.searchTimeout) clearTimeout(this.searchTimeout)
+ }
+
+ debouncedSearch(event) {
+ if (this.searchTimeout) clearTimeout(this.searchTimeout)
+
+ this.searchTimeout = setTimeout(() => {
+ this.syncSearchValue()
+ const valueLength = event.target.value.length
+ if (valueLength >= 2 || valueLength === 0) {
+ event.target.form.requestSubmit()
+ }
+ }, this.debounceMsValue)
+ }
+
+ clearSearch(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ if (this.searchTimeout) clearTimeout(this.searchTimeout)
+
+ const url = new URL(window.location.href)
+ url.searchParams.delete("search")
+ url.searchParams.delete("page")
+ window.location.href = url.toString()
+ }
+
+ changeItemsPerPage(event) {
+ const items = event.target.value
+ const url = new URL(window.location.href)
+ url.searchParams.set("items", items)
+ url.searchParams.delete("page")
+ window.location.href = url.toString()
+ }
+
+ handleGlobalKeydown(event) {
+ if ((event.ctrlKey || event.metaKey) && event.key === "k") {
+ event.preventDefault()
+ if (this.hasSearchInputTarget) this.searchInputTarget.focus()
+ }
+ }
+
+ syncSearchValue() {
+ if (!this.hasSearchInputTarget || !this.hasHiddenSearchTarget) return
+
+ this.hiddenSearchTarget.value = this.searchInputTarget.value
+ }
+}
diff --git a/app/javascript/controllers/contribution_purchase_controller.js b/app/javascript/controllers/contribution_purchase_controller.js
new file mode 100644
index 00000000..9156e132
--- /dev/null
+++ b/app/javascript/controllers/contribution_purchase_controller.js
@@ -0,0 +1,24 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["formula", "summaryName", "summaryPrice", "summaryConstraint", "summaryAlert"]
+
+ connect() {
+ this.refresh()
+ }
+
+ refresh() {
+ const selectedFormula = this.formulaTargets.find((input) => input.checked)
+ if (!selectedFormula) return
+
+ this.summaryNameTarget.textContent = selectedFormula.dataset.formulaName || ""
+ this.summaryPriceTarget.textContent = selectedFormula.dataset.formulaPrice || ""
+ this.summaryConstraintTarget.textContent = selectedFormula.dataset.formulaConstraint || ""
+
+ if (this.hasSummaryAlertTarget) {
+ const alert = selectedFormula.dataset.formulaAlert || ""
+ this.summaryAlertTarget.textContent = alert
+ this.summaryAlertTarget.classList.toggle("hidden", alert.length === 0)
+ }
+ }
+}
diff --git a/app/javascript/controllers/email_change_modal_controller.js b/app/javascript/controllers/email_change_modal_controller.js
new file mode 100644
index 00000000..c565b419
--- /dev/null
+++ b/app/javascript/controllers/email_change_modal_controller.js
@@ -0,0 +1,145 @@
+import { Controller } from "@hotwired/stimulus"
+import { openRichConfirmModal } from "confirm_modal"
+
+export default class extends Controller {
+ static targets = ["panel", "form", "email", "emailConfirm", "code", "error"]
+ static values = {
+ currentEmail: String,
+ blankMessage: String,
+ mismatchMessage: String,
+ unchangedMessage: String,
+ previewTitle: String,
+ previewIntro: String,
+ previewConfirmLabel: String,
+ previewBackLabel: String,
+ invalidCodeMessage: String
+ }
+
+ connect() {
+ this.onSubmitEnd = this.handleSubmitEnd.bind(this)
+ if (this.hasFormTarget) {
+ this.formTarget.addEventListener("turbo:submit-end", this.onSubmitEnd)
+ }
+ }
+
+ disconnect() {
+ if (this.hasFormTarget) {
+ this.formTarget.removeEventListener("turbo:submit-end", this.onSubmitEnd)
+ }
+ document.body.style.overflow = ""
+ }
+
+ open(event) {
+ event?.preventDefault()
+ if (!this.hasPanelTarget) return
+ this.clearError()
+ this.panelTarget.classList.remove("hidden")
+ document.body.style.overflow = "hidden"
+ requestAnimationFrame(() => {
+ if (this.hasEmailTarget) this.emailTarget.focus()
+ })
+ }
+
+ close(event) {
+ event?.preventDefault()
+ if (!this.hasPanelTarget) return
+ this.panelTarget.classList.add("hidden")
+ document.body.style.overflow = ""
+ this.clearError()
+ if (this.hasFormTarget) this.formTarget.reset()
+ }
+
+ stopPropagation(event) {
+ event.stopPropagation()
+ }
+
+ async requestCode(event) {
+ event.preventDefault()
+ this.clearError()
+
+ const payload = this.validatedPayload()
+ if (!payload) return
+
+ const escaped = this.escapeHtml(payload.nextRaw)
+ const confirmed = await openRichConfirmModal({
+ title: this.previewTitleValue,
+ introText: this.previewIntroValue,
+ htmlBody: `${escaped}
`,
+ confirmText: this.previewConfirmLabelValue,
+ cancelText: this.previewBackLabelValue
+ })
+
+ if (!confirmed) return
+
+ if (this.hasCodeTarget) this.codeTarget.value = ""
+ this.formTarget.requestSubmit()
+ }
+
+ // Backward-compatible action name used by previous modal button wiring.
+ async submitWithConfirmation(event) {
+ await this.requestCode(event)
+ }
+
+ confirmCode(event) {
+ event.preventDefault()
+ this.clearError()
+
+ const payload = this.validatedPayload()
+ if (!payload) return
+
+ const code = this.hasCodeTarget ? this.codeTarget.value.trim() : ""
+ if (!/^\d{6}$/.test(code)) {
+ this.showError(this.invalidCodeMessageValue || "Invalid code format.")
+ return
+ }
+
+ this.formTarget.requestSubmit()
+ }
+
+ handleSubmitEnd(event) {
+ if (event.detail.success) this.close()
+ }
+
+ showError(message) {
+ if (!this.hasErrorTarget) return
+ this.errorTarget.textContent = message
+ this.errorTarget.classList.remove("hidden")
+ }
+
+ clearError() {
+ if (!this.hasErrorTarget) return
+ this.errorTarget.textContent = ""
+ this.errorTarget.classList.add("hidden")
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement("div")
+ div.textContent = text
+ return div.innerHTML
+ }
+
+ validatedPayload() {
+ const nextRaw = this.emailTarget.value.trim()
+ const confirmRaw = this.emailConfirmTarget.value.trim()
+ const next = nextRaw.toLowerCase()
+ const confirm = confirmRaw.toLowerCase()
+ const current = (this.currentEmailValue || "").trim().toLowerCase()
+
+ if (!next || !confirm) {
+ this.showError(this.blankMessageValue || this.mismatchMessageValue)
+ return null
+ }
+
+ if (next !== confirm) {
+ this.showError(this.mismatchMessageValue)
+ return null
+ }
+
+ if (next === current) {
+ this.showError(this.unchangedMessageValue)
+ return null
+ }
+
+ return { nextRaw }
+ }
+}
diff --git a/app/javascript/controllers/form_toggle_controller.js b/app/javascript/controllers/form_toggle_controller.js
index 14ecef10..a5dd5750 100644
--- a/app/javascript/controllers/form_toggle_controller.js
+++ b/app/javascript/controllers/form_toggle_controller.js
@@ -5,59 +5,47 @@ export default class extends Controller {
static targets = ["webAccountFields", "emailField", "newsletterNote"]
connect() {
- console.log('Form toggle controller connected!')
- this.toggleWebAccountFields()
- this.toggleNewsletterNote()
+ this.refresh()
}
toggleWebAccountFields() {
- console.log('toggleWebAccountFields called')
- const checkbox = this.element.querySelector('input[name="user[create_web_account]"]')
- console.log('Checkbox found:', checkbox)
- console.log('Checkbox checked:', checkbox?.checked)
-
- if (this.hasWebAccountFieldsTarget && checkbox) {
- if (checkbox.checked) {
- this.webAccountFieldsTarget.style.display = 'block'
- console.log('Showing web account fields')
- if (this.hasEmailFieldTarget) {
- this.emailFieldTarget.required = true
- }
- } else {
- this.webAccountFieldsTarget.style.display = 'none'
- console.log('Hiding web account fields')
- if (this.hasEmailFieldTarget) {
- this.emailFieldTarget.required = false
- }
- }
- }
+ this.refresh()
}
toggleNewsletterNote() {
- const checkbox = this.element.querySelector('input[name="user[person][newsletter_subscribed]"]')
- if (this.hasNewsletterNoteTarget && checkbox) {
- if (checkbox.checked) {
- this.newsletterNoteTarget.style.display = 'block'
- if (this.hasEmailFieldTarget) {
- this.emailFieldTarget.required = true
- }
- } else {
- this.newsletterNoteTarget.style.display = 'none'
- if (this.hasEmailFieldTarget) {
- this.emailFieldTarget.required = false
- }
- }
- }
+ this.refresh()
}
- // Actions
createWebAccountChanged() {
- console.log('createWebAccountChanged called')
- this.toggleWebAccountFields()
+ this.refresh()
}
newsletterSubscribedChanged() {
- console.log('newsletterSubscribedChanged called')
- this.toggleNewsletterNote()
+ this.refresh()
+ }
+
+ refresh() {
+ const webAccountChecked = this.webAccountCheckbox?.checked || false
+ const newsletterChecked = this.newsletterCheckbox?.checked || false
+
+ if (this.hasWebAccountFieldsTarget) {
+ this.webAccountFieldsTarget.classList.toggle("hidden", !webAccountChecked)
+ }
+
+ if (this.hasNewsletterNoteTarget) {
+ this.newsletterNoteTarget.classList.toggle("hidden", !newsletterChecked)
+ }
+
+ if (this.hasEmailFieldTarget) {
+ this.emailFieldTarget.required = webAccountChecked || newsletterChecked
+ }
+ }
+
+ get webAccountCheckbox() {
+ return this.element.querySelector('input[name="user[create_web_account]"]')
+ }
+
+ get newsletterCheckbox() {
+ return this.element.querySelector('input[name="user[person][newsletter_subscribed]"]')
}
}
diff --git a/app/javascript/controllers/map_controller.js b/app/javascript/controllers/map_controller.js
new file mode 100644
index 00000000..95a7eda3
--- /dev/null
+++ b/app/javascript/controllers/map_controller.js
@@ -0,0 +1,113 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ lat: Number,
+ lng: Number,
+ zoom: { type: Number, default: 17 }
+ }
+
+ async connect () {
+ this._disconnected = false
+ this._lastSize = { w: 0, h: 0 }
+ this._resizeFrame = null
+ this.L = await this._loadLeaflet()
+
+ if (this._disconnected || !this.element.isConnected) return
+
+ // Attendre 2 frames : la grille / le turbo-frame ont souvent une hauteur encore à 0 au premier paint.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (this._disconnected || !this.element.isConnected) return
+ this._initMap()
+ })
+ })
+ }
+
+ _initMap () {
+ if (this._disconnected || !this.element.isConnected) return
+
+ this._resizeTimeouts = []
+
+ const scheduleInvalidate = (force = false) => {
+ if (!this.map) return
+ if (this._resizeFrame != null) cancelAnimationFrame(this._resizeFrame)
+ this._resizeFrame = requestAnimationFrame(() => {
+ this._resizeFrame = null
+ const el = this.element
+ const w = el.clientWidth
+ const h = el.clientHeight
+ if (w <= 0 || h <= 0) return
+ if (!force && w === this._lastSize.w && h === this._lastSize.h) return
+ this._lastSize = { w, h }
+ this.map.invalidateSize({ animate: false })
+ })
+ }
+
+ this.boundTurboFrameLoad = (e) => {
+ if (e.target.contains(this.element)) scheduleInvalidate(true)
+ }
+ document.addEventListener("turbo:frame-load", this.boundTurboFrameLoad)
+
+ this.boundWindowResize = () => scheduleInvalidate(true)
+ window.addEventListener("resize", this.boundWindowResize)
+
+ this.map = this.L.map(this.element).setView([this.latValue, this.lngValue], this.zoomValue)
+
+ // Tuiles raster via CARTO (cartocdn.com) ; rendu 100 % Leaflet côté navigateur (pas d’iframe ni SDK tiers).
+ this.L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", {
+ attribution:
+ '© Sources des données · CARTO ',
+ subdomains: "abcd",
+ maxZoom: 19
+ }).addTo(this.map)
+
+ // Pas d’images CDN (évite icône cassée si cdnjs / tracking bloqués) — cercle aux couleurs du site.
+ this.L.circleMarker([this.latValue, this.lngValue], {
+ radius: 10,
+ color: "#1F5C55",
+ fillColor: "#5836A5",
+ fillOpacity: 0.9,
+ weight: 3
+ }).addTo(this.map).bindPopup("Le Circographe 97 bis bd de Suisse, Toulouse")
+
+ this.map.whenReady(() => {
+ scheduleInvalidate(true)
+ ;[120, 350, 700].forEach(ms => {
+ const id = setTimeout(() => scheduleInvalidate(true), ms)
+ this._resizeTimeouts.push(id)
+ })
+ })
+
+ this.resizeObserver = new ResizeObserver(() => scheduleInvalidate(false))
+ this.resizeObserver.observe(this.element)
+ }
+
+ disconnect () {
+ this._disconnected = true
+ this._resizeTimeouts?.forEach(id => clearTimeout(id))
+ this._resizeTimeouts = null
+ if (this.boundTurboFrameLoad) {
+ document.removeEventListener("turbo:frame-load", this.boundTurboFrameLoad)
+ this.boundTurboFrameLoad = null
+ }
+ if (this.boundWindowResize) {
+ window.removeEventListener("resize", this.boundWindowResize)
+ this.boundWindowResize = null
+ }
+ if (this._resizeFrame != null) cancelAnimationFrame(this._resizeFrame)
+ this._resizeFrame = null
+ this.resizeObserver?.disconnect()
+ this.resizeObserver = null
+ this.map?.remove()
+ this.map = null
+ }
+
+ async _loadLeaflet () {
+ if (this.constructor.leafletModule) return this.constructor.leafletModule
+
+ const module = await import("leaflet")
+ this.constructor.leafletModule = module
+ return module
+ }
+}
diff --git a/app/javascript/controllers/offer_fields_controller.js b/app/javascript/controllers/offer_fields_controller.js
new file mode 100644
index 00000000..fd25d64b
--- /dev/null
+++ b/app/javascript/controllers/offer_fields_controller.js
@@ -0,0 +1,25 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["paymentMethod", "customAmount", "offerReason", "offerReasonInput"]
+
+ connect() {
+ this.refresh()
+ }
+
+ refresh() {
+ const offered = this.paymentMethodTarget.value === "offered"
+
+ if (this.hasCustomAmountTarget) {
+ this.customAmountTarget.classList.toggle("hidden", !offered)
+ }
+
+ if (this.hasOfferReasonTarget) {
+ this.offerReasonTarget.classList.toggle("hidden", !offered)
+ }
+
+ if (this.hasOfferReasonInputTarget) {
+ this.offerReasonInputTarget.required = offered
+ }
+ }
+}
diff --git a/app/javascript/controllers/opening_hours_editor_controller.js b/app/javascript/controllers/opening_hours_editor_controller.js
new file mode 100644
index 00000000..5b30745e
--- /dev/null
+++ b/app/javascript/controllers/opening_hours_editor_controller.js
@@ -0,0 +1,130 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["closedCheckbox", "timeInput", "finalInput", "previewContainer"]
+
+ connect() {
+ this.initializeFromInputs()
+ }
+
+ toggleClosed(event) {
+ const checkbox = event.currentTarget
+ const day = checkbox.dataset.day
+ const timeInput = this.timeInputFor(day)
+ const finalInput = this.finalInputFor(day)
+
+ if (!timeInput || !finalInput) return
+
+ if (checkbox.checked) {
+ timeInput.style.display = "none"
+ finalInput.value = "Fermé"
+ this.updatePreview(day, "Fermé")
+ return
+ }
+
+ timeInput.style.display = "block"
+ this.resetDaySelectors(day)
+ this.updateFromSelectors(day)
+ }
+
+ updateFromSelectors(eventOrDay) {
+ const day = typeof eventOrDay === "string" ? eventOrDay : eventOrDay.currentTarget.dataset.day
+ const openHour = this.inputValue(`open_hour_${day}`)
+ const openMinute = this.inputValue(`open_minute_${day}`)
+ const closeHour = this.inputValue(`close_hour_${day}`)
+ const closeMinute = this.inputValue(`close_minute_${day}`)
+
+ if ([openHour, openMinute, closeHour, closeMinute].some((value) => value === null)) return
+
+ const timeValue = `${openHour.padStart(2, "0")}:${openMinute.padStart(2, "0")} - ${closeHour.padStart(2, "0")}:${closeMinute.padStart(2, "0")}`
+ const finalInput = this.finalInputFor(day)
+ if (finalInput) finalInput.value = timeValue
+ this.updatePreview(day, timeValue)
+ }
+
+ initializeFromInputs() {
+ this.closedCheckboxTargets.forEach((checkbox) => {
+ const day = checkbox.dataset.day
+ const finalInput = this.finalInputFor(day)
+ const timeInput = this.timeInputFor(day)
+ if (!finalInput || !timeInput) return
+
+ this.updatePreview(day, finalInput.value)
+ if (checkbox.checked) {
+ timeInput.style.display = "none"
+ } else {
+ timeInput.style.display = "block"
+ this.initializeSelectors(day, finalInput.value)
+ }
+ })
+ }
+
+ updatePreview(day, value) {
+ if (!this.hasPreviewContainerTarget) return
+
+ const rows = this.previewContainerTarget.querySelectorAll("tr")
+ rows.forEach((row) => {
+ const rowText = row.textContent.toLowerCase()
+ if (!rowText.includes(day.toLowerCase())) return
+
+ const hourCell = row.querySelector("td:nth-child(2)")
+ if (!hourCell) return
+
+ if (value.toLowerCase() === "fermé") {
+ hourCell.innerHTML = `
+
+
+ Fermé
+
+ `
+ } else {
+ hourCell.innerHTML = `
+
+
+ ${value}
+
+ `
+ }
+ })
+ }
+
+ initializeSelectors(day, timeValue) {
+ if (!timeValue || timeValue.toLowerCase() === "fermé") return
+
+ const [openTime, closeTime] = timeValue.split(" - ")
+ if (!openTime || !closeTime) return
+
+ const [openHour, openMinute] = openTime.split(":")
+ const [closeHour, closeMinute] = closeTime.split(":")
+
+ this.setInputValue(`open_hour_${day}`, openHour)
+ this.setInputValue(`open_minute_${day}`, openMinute)
+ this.setInputValue(`close_hour_${day}`, closeHour)
+ this.setInputValue(`close_minute_${day}`, closeMinute)
+ }
+
+ resetDaySelectors(day) {
+ this.setInputValue(`open_hour_${day}`, "14")
+ this.setInputValue(`open_minute_${day}`, "0")
+ this.setInputValue(`close_hour_${day}`, "22")
+ this.setInputValue(`close_minute_${day}`, "0")
+ }
+
+ inputValue(name) {
+ const input = this.element.querySelector(`select[name="${name}"]`)
+ return input ? input.value : null
+ }
+
+ setInputValue(name, value) {
+ const input = this.element.querySelector(`select[name="${name}"]`)
+ if (input) input.value = value
+ }
+
+ timeInputFor(day) {
+ return this.timeInputTargets.find((element) => element.dataset.day === day)
+ }
+
+ finalInputFor(day) {
+ return this.finalInputTargets.find((element) => element.dataset.day === day)
+ }
+}
diff --git a/app/javascript/controllers/payment_modal_controller.js b/app/javascript/controllers/payment_modal_controller.js
index 143d181b..ba6dd363 100644
--- a/app/javascript/controllers/payment_modal_controller.js
+++ b/app/javascript/controllers/payment_modal_controller.js
@@ -3,13 +3,6 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container"]
- connect() {
- // Auto-open if person_id is in params
- if (new URLSearchParams(window.location.search).get("person_id")) {
- this.open()
- }
- }
-
open() {
if (this.hasContainerTarget) {
this.containerTarget.classList.remove("hidden")
diff --git a/app/javascript/controllers/reduced_rate_controller.js b/app/javascript/controllers/reduced_rate_controller.js
index ebebc60e..dd1a508e 100644
--- a/app/javascript/controllers/reduced_rate_controller.js
+++ b/app/javascript/controllers/reduced_rate_controller.js
@@ -2,10 +2,15 @@ import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="reduced-rate"
export default class extends Controller {
- static targets = ["details", "checkbox"]
+ static targets = ["details", "checkbox", "reason", "proof"]
connect() {
+ this.refresh()
+ }
+
+ refresh() {
this.toggleDetails()
+ this.toggleProof()
}
toggleDetails() {
@@ -26,7 +31,17 @@ export default class extends Controller {
// Action when checkbox changes
checkboxChanged() {
- this.toggleDetails()
+ this.refresh()
}
-}
+ reasonChanged() {
+ this.toggleProof()
+ }
+
+ toggleProof() {
+ if (!this.hasProofTarget || !this.hasReasonTarget) return
+
+ const shouldShowProof = this.reasonTarget.value === "Autre"
+ this.proofTarget.classList.toggle("hidden", !shouldShowProof)
+ }
+}
diff --git a/app/javascript/controllers/section_edit_controller.js b/app/javascript/controllers/section_edit_controller.js
new file mode 100644
index 00000000..34c1d90d
--- /dev/null
+++ b/app/javascript/controllers/section_edit_controller.js
@@ -0,0 +1,209 @@
+import { Controller } from "@hotwired/stimulus"
+import { openRichConfirmModal } from "confirm_modal"
+
+// data-controller="section-edit"
+export default class extends Controller {
+ static targets = ["read", "edit", "form", "save", "modifyWrap", "cancelWrap", "acceptWrap", "modifyButton", "acceptButton", "cancelButton", "field", "protectedAction"]
+ static values = {
+ editing: Boolean,
+ warningMessage: String,
+ modifyLabel: String,
+ acceptLabel: String,
+ previewTitle: String,
+ previewIntro: String,
+ previewConfirmLabel: String,
+ previewBackLabel: String,
+ yesLabel: String,
+ noLabel: String
+ }
+
+ connect() {
+ this.allowNativeSubmit = false
+ this.initialValues = this.fieldTargets.map((field) => this.fieldValue(field))
+ this.beforeUnloadHandler = (event) => {
+ if (!this.hasUnsavedChanges()) return
+ event.preventDefault()
+ event.returnValue = this.warningMessageValue || "Vous avez des modifications non enregistrées."
+ }
+ window.addEventListener("beforeunload", this.beforeUnloadHandler)
+ this.render()
+ }
+
+ disconnect() {
+ window.removeEventListener("beforeunload", this.beforeUnloadHandler)
+ }
+
+ handleModifyClick(event) {
+ event.preventDefault()
+ if (this.editingValue) return
+
+ this.initialValues = this.fieldTargets.map((field) => this.fieldValue(field))
+ this.editingValue = true
+ this.render()
+ this.updateSaveState()
+ }
+
+ async handleAcceptClick(event) {
+ event.preventDefault()
+ if (!this.editingValue || !this.hasUnsavedChanges()) return
+
+ const summaryHtml = this.buildRecapFromFields()
+ const confirmed = await openRichConfirmModal({
+ title: this.previewTitleValue,
+ introText: this.previewIntroValue,
+ htmlBody: summaryHtml,
+ confirmText: this.previewConfirmLabelValue,
+ cancelText: this.previewBackLabelValue
+ })
+
+ if (!confirmed) return
+
+ this.allowNativeSubmit = true
+ this.formTarget.requestSubmit()
+ }
+
+ cancelEdit(event) {
+ event.preventDefault()
+ this.resetFields()
+ this.editingValue = false
+ this.render()
+ }
+
+ handleInput() {
+ this.updateSaveState()
+ }
+
+ handleFormSubmit(event) {
+ if (this.allowNativeSubmit) {
+ this.allowNativeSubmit = false
+ this.applySavingStateToSubmit()
+ return
+ }
+
+ event.preventDefault()
+ event.stopPropagation()
+ }
+
+ applySavingStateToSubmit() {
+ if (this.hasSaveTarget) {
+ this.saveTarget.disabled = true
+ }
+
+ if (this.hasAcceptWrapTarget && !this.acceptWrapTarget.classList.contains("hidden")) {
+ this.acceptButtonTarget.disabled = true
+ const loading = this.acceptButtonTarget.dataset.loadingText
+ if (loading) this.acceptButtonTarget.textContent = loading
+ }
+ }
+
+ render() {
+ const editing = this.editingValue
+ const dirty = this.hasUnsavedChanges()
+
+ this.readTargets.forEach((element) => element.classList.toggle("hidden", editing))
+ this.editTargets.forEach((element) => element.classList.toggle("hidden", !editing))
+
+ // Visibility on wrappers only — never combine Tailwind `hidden` with `inline-flex`
+ // on the same node (display utilities fight; buttons stayed visible).
+ if (this.hasModifyWrapTarget) {
+ const showModify = !editing || (editing && !dirty)
+ this.modifyWrapTarget.classList.toggle("hidden", !showModify)
+ }
+
+ if (this.hasCancelWrapTarget) {
+ this.cancelWrapTarget.classList.toggle("hidden", !editing)
+ }
+
+ if (this.hasAcceptWrapTarget) {
+ this.acceptWrapTarget.classList.toggle("hidden", !(editing && dirty))
+ }
+
+ if (this.hasModifyButtonTarget) {
+ if (!editing) {
+ this.modifyButtonTarget.disabled = false
+ this.modifyButtonTarget.textContent = this.modifyLabelValue
+ } else if (!dirty) {
+ this.modifyButtonTarget.disabled = true
+ this.modifyButtonTarget.textContent = this.modifyLabelValue
+ } else {
+ this.modifyButtonTarget.disabled = false
+ }
+ }
+
+ if (this.hasAcceptButtonTarget && editing && dirty) {
+ this.acceptButtonTarget.disabled = false
+ this.acceptButtonTarget.textContent = this.acceptLabelValue
+ }
+
+ this.protectedActionTargets.forEach((element) => {
+ element.classList.toggle("opacity-50", editing)
+ element.classList.toggle("pointer-events-none", editing)
+ element.setAttribute("aria-disabled", editing ? "true" : "false")
+ })
+ }
+
+ updateSaveState() {
+ this.render()
+ }
+
+ resetFields() {
+ this.fieldTargets.forEach((field, index) => {
+ const initial = this.initialValues[index]
+ if (field.type === "checkbox") {
+ field.checked = initial === "true"
+ } else {
+ field.value = initial
+ }
+ })
+ }
+
+ fieldValue(field) {
+ return field.type === "checkbox" ? String(field.checked) : (field.value || "")
+ }
+
+ hasUnsavedChanges() {
+ if (!this.editingValue) return false
+ return this.fieldTargets.some((field, index) => this.fieldValue(field) !== this.initialValues[index])
+ }
+
+ escapeHtml(text) {
+ return String(text)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ }
+
+ fieldLabelText(field) {
+ if (field.labels && field.labels.length > 0) {
+ const lab = field.labels[0]
+ const span = lab.querySelector(":scope > span")
+ if (span) return span.textContent.replace(/\s+/g, " ").trim()
+
+ const clone = lab.cloneNode(true)
+ clone.querySelectorAll("input, textarea, select, button").forEach((el) => el.remove())
+ return clone.textContent.replace(/\s+/g, " ").trim()
+ }
+ return field.name || ""
+ }
+
+ displayValueForField(field) {
+ if (field.type === "checkbox") {
+ return field.checked ? this.yesLabelValue : this.noLabelValue
+ }
+ const v = (field.value || "").trim()
+ return v.length ? v : "—"
+ }
+
+ buildRecapFromFields() {
+ const items = this.fieldTargets.map((field) => {
+ const label = this.escapeHtml(this.fieldLabelText(field))
+ const value = this.escapeHtml(this.displayValueForField(field))
+ return `${label} ${value} `
+ })
+
+ if (!items.length) return ""
+
+ return ``
+ }
+}
diff --git a/app/javascript/controllers/slider_controller.js b/app/javascript/controllers/slider_controller.js
index ee64448a..377b3ee0 100644
--- a/app/javascript/controllers/slider_controller.js
+++ b/app/javascript/controllers/slider_controller.js
@@ -33,6 +33,14 @@ export default class extends Controller {
}
const options = Object.assign({}, defaultOptions, this.optionsValue || {})
+
+ if (options.pagination?.el && typeof options.pagination.el === "string") {
+ const paginationEl = this.element.querySelector(options.pagination.el)
+ if (paginationEl) {
+ options.pagination = { ...options.pagination, el: paginationEl }
+ }
+ }
+
const slideCount = this.element.querySelectorAll(".swiper-slide").length
const breakpointValues = Object.values(options.breakpoints || {}).map(cfg => cfg.slidesPerView || options.slidesPerView)
const maxSlidesPerView = Math.max(options.slidesPerView || 1, ...breakpointValues, 1)
diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js
index f62ee4d7..d8cc2499 100644
--- a/app/javascript/controllers/tabs_controller.js
+++ b/app/javascript/controllers/tabs_controller.js
@@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["trigger", "panel"]
static values = { initial: String, useHash: Boolean }
- static classes = ["active"]
+ static classes = ["active", "inactive"]
connect() {
const initialName = this.initialValue || this.defaultName
@@ -24,7 +24,8 @@ export default class extends Controller {
show(name) {
this.triggerTargets.forEach(trigger => {
const selected = trigger.dataset.tabsNameValue === name
- trigger.classList.toggle(this.activeClass, selected)
+ this.activeClassesList.forEach(className => trigger.classList.toggle(className, selected))
+ this.inactiveClassesList.forEach(className => trigger.classList.toggle(className, !selected))
trigger.setAttribute("aria-selected", selected)
trigger.setAttribute("tabindex", selected ? "0" : "-1")
})
@@ -46,4 +47,12 @@ export default class extends Controller {
const names = this.triggerTargets.map(trigger => trigger.dataset.tabsNameValue)
return names.includes(hash) ? hash : null
}
+
+ get activeClassesList() {
+ return this.hasActiveClass ? this.activeClass.split(" ").filter(Boolean) : []
+ }
+
+ get inactiveClassesList() {
+ return this.hasInactiveClass ? this.inactiveClass.split(" ").filter(Boolean) : []
+ }
}
diff --git a/app/javascript/global_animations.js b/app/javascript/global_animations.js
index 4e700fe7..0b80bfea 100644
--- a/app/javascript/global_animations.js
+++ b/app/javascript/global_animations.js
@@ -1,21 +1,26 @@
+let fadeInObserver = null
+
document.addEventListener("turbo:load", function () {
- initFadeInAnimations();
-});
+ if (fadeInObserver) {
+ fadeInObserver.disconnect()
+ fadeInObserver = null
+ }
+ initFadeInAnimations()
+})
-function initFadeInAnimations() {
- const fadeElements = document.querySelectorAll(".fade-in");
+function initFadeInAnimations () {
+ const fadeElements = document.querySelectorAll(".fade-in")
- if (fadeElements.length === 0) return; // Évite d'exécuter le script s'il n'y a aucun élément à animer
+ if (fadeElements.length === 0) return
- const observer = new IntersectionObserver((entries, observer) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- entry.target.classList.add("visible");
- observer.unobserve(entry.target); // Arrête d'observer une fois l'animation déclenchée
- }
- });
- }, { threshold: 0.1 });
+ fadeInObserver = new IntersectionObserver((entries, observer) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ entry.target.classList.add("visible")
+ observer.unobserve(entry.target)
+ }
+ })
+ }, { threshold: 0.1 })
- fadeElements.forEach(el => observer.observe(el));
+ fadeElements.forEach(el => fadeInObserver.observe(el))
}
-
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 197ec2c5..70315e3b 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -25,9 +25,36 @@ def membership_expiration_reminder(user_membership)
def contact_email(name, email, message, category, recipient_email)
@name = name
+ @email = email
@message = message
@category = category
+ @category_label = contact_category_label(category)
@submitted_at = Time.zone.now
- mail(to: recipient_email, subject: "Nouveau message : #{category.capitalize}", reply_to: email)
+ mail(
+ to: recipient_email,
+ subject: I18n.t("mailers.user_mailer.contact_email.subject", category_label: @category_label),
+ reply_to: email
+ )
+ end
+
+ def email_change_verification(user, new_email, code)
+ @user = user
+ @new_email = new_email
+ @code = code
+ @ttl_minutes = (User::EMAIL_CHANGE_CODE_TTL / 60).to_i
+
+ mail(
+ to: @new_email,
+ subject: I18n.t("mailers.user_mailer.email_change_verification.subject")
+ )
+ end
+
+ private
+
+ def contact_category_label(category)
+ I18n.t(
+ "mailers.user_mailer.contact_email.category_labels.#{category}",
+ default: category.to_s.tr("_", " ").capitalize
+ )
end
end
diff --git a/app/models/concerns/person_payment_reporting.rb b/app/models/concerns/person_payment_reporting.rb
new file mode 100644
index 00000000..2e4fd63d
--- /dev/null
+++ b/app/models/concerns/person_payment_reporting.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module PersonPaymentReporting
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def total_offered_payments
+ joins(:payments).where(payments: { payment_method: "offered" }).count
+ end
+
+ def total_free_offers
+ joins(:payments).where(payments: { payment_method: "offered", total_cents: 0 }).count
+ end
+
+ def total_paid_offers
+ joins(:payments).where(payments: { payment_method: "offered" }).where("payments.total_cents > 0").count
+ end
+
+ def offered_payments_by_reason
+ joins(:payments)
+ .where(payments: { payment_method: "offered" })
+ .group("payments.offer_reason")
+ .count
+ end
+
+ def upgrades_today
+ joins(:payments)
+ .joins("JOIN payment_lines ON payments.id = payment_lines.payment_id")
+ .where(payment_lines: { item_type: "MembershipUpgrade" })
+ .where(payments: { created_at: Date.current.all_day })
+ .count
+ end
+ end
+
+ def offered_payments_count
+ payments.where(payment_method: "offered").count
+ end
+
+ def offered_payments_total
+ payments.where(payment_method: "offered").sum(:total_cents)
+ end
+
+ def free_offers_count
+ payments.where(payment_method: "offered", total_cents: 0).count
+ end
+
+ def paid_offers_count
+ payments.where(payment_method: "offered").where("total_cents > 0").count
+ end
+
+ def membership_upgrades_count
+ payments.joins(:payment_lines)
+ .where(payment_lines: { item_type: "MembershipUpgrade" })
+ .count
+ end
+
+ def contribution_purchases_count
+ payments.joins(:payment_lines)
+ .where(payment_lines: { item_type: "Contribution" })
+ .count
+ end
+
+ def newsletter_subscribed?
+ return false if email.blank?
+
+ subscriber = NewsletterSubscriber.find_by(email: email)
+ subscriber&.subscribed? || false
+ end
+
+ def newsletter_subscribed
+ newsletter_subscribed?
+ end
+
+ def newsletter_subscribed=(_value)
+ # Compatibility writer for legacy forms/factories. Newsletter persistence is handled by NewsletterSubscriber.
+ end
+end
diff --git a/app/models/concerns/priceable.rb b/app/models/concerns/priceable.rb
index f4e3e292..2e81e284 100644
--- a/app/models/concerns/priceable.rb
+++ b/app/models/concerns/priceable.rb
@@ -18,6 +18,10 @@ def price_euros
cents / 100.0
end
+ def total_euros
+ price_euros
+ end
+
def price_euros=(value)
# Support pour différents noms de colonnes
if respond_to?(:price_cents=)
diff --git a/app/models/concerns/rate_kindable.rb b/app/models/concerns/rate_kindable.rb
new file mode 100644
index 00000000..a551f748
--- /dev/null
+++ b/app/models/concerns/rate_kindable.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module RateKindable
+ extend ActiveSupport::Concern
+
+ RATE_KINDS = %w[standard reduced].freeze
+
+ included do
+ validates :rate_kind, presence: true, inclusion: { in: RATE_KINDS }
+ scope :for_rate_kinds, ->(rate_kinds) { where(rate_kind: Array(rate_kinds)) }
+ end
+
+ def standard_rate?
+ rate_kind == "standard"
+ end
+
+ def reduced_rate?
+ rate_kind == "reduced"
+ end
+
+ def available_for?(person)
+ person&.allows_rate_kind?(rate_kind) || false
+ end
+
+ def rate_kind_humanized
+ self.class.humanize_rate_kind(rate_kind)
+ end
+
+ def rate_kind_badge_class
+ reduced_rate? ? "bg-amber-100 text-amber-800" : "bg-slate-100 text-slate-800"
+ end
+
+ class_methods do
+ def rate_kind_options
+ RATE_KINDS.map { |kind| [ humanize_rate_kind(kind), kind ] }
+ end
+
+ def humanize_rate_kind(kind)
+ case kind.to_s
+ when "standard"
+ "Tarif standard"
+ when "reduced"
+ "Tarif réduit"
+ else
+ kind.to_s.humanize
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/roleable.rb b/app/models/concerns/roleable.rb
index 4884f0ba..81e3180c 100644
--- a/app/models/concerns/roleable.rb
+++ b/app/models/concerns/roleable.rb
@@ -6,10 +6,10 @@ module Roleable
# Humanization des rôles
def role_humanized
case system_role
- when "super_admin" then "Super Admin"
- when "admin" then "Admin"
+ when "super_admin" then "Super administrateur"
+ when "admin" then "Administrateur"
when "volunteer" then "Bénévole"
- when "web_visitor" then "Visiteur Web"
+ when "web_visitor" then "Visiteur web"
else system_role.humanize
end
end
@@ -75,10 +75,10 @@ def can_manage_notepad?
class_methods do
def humanize_role(role)
case role.to_s
- when "super_admin" then "Super Admin"
- when "admin" then "Admin"
+ when "super_admin" then "Super administrateur"
+ when "admin" then "Administrateur"
when "volunteer" then "Bénévole"
- when "web_visitor" then "Visiteur Web"
+ when "web_visitor" then "Visiteur web"
else role.to_s.humanize
end
end
diff --git a/app/models/contribution.rb b/app/models/contribution.rb
index 88d54794..64119384 100644
--- a/app/models/contribution.rb
+++ b/app/models/contribution.rb
@@ -13,6 +13,8 @@ class Contribution < ApplicationRecord
validates :expires_at, presence: true, unless: :is_pack10?
validate :sessions_remaining_validation
+ validate :day_pass_single_use_validation
+ validate :day_pass_expiration_validation
enum :status, {
inactive: 0,
@@ -128,6 +130,23 @@ def sessions_remaining_validation
end
end
+ def day_pass_single_use_validation
+ return unless contribution_formula&.duration == "day"
+ return if sessions_remaining.in?([ 0, 1 ])
+
+ errors.add(:sessions_remaining, "doit être 1 avant utilisation puis 0 après utilisation pour une cotisation journée")
+ end
+
+ def day_pass_expiration_validation
+ return unless contribution_formula&.duration == "day"
+ return if expires_at.blank? || purchased_at.blank?
+
+ expected_expiration = purchased_at.end_of_day
+ return if expires_at.to_i == expected_expiration.to_i
+
+ errors.add(:expires_at, "doit être la fin du jour d'achat pour une cotisation journée")
+ end
+
def set_initial_values
self.purchased_at ||= Time.current
self.status ||= :active
diff --git a/app/models/contribution_formula.rb b/app/models/contribution_formula.rb
index 6bb70859..9b3378cc 100644
--- a/app/models/contribution_formula.rb
+++ b/app/models/contribution_formula.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ContributionFormula < ApplicationRecord
+ include RateKindable
include Priceable
include Humanizable
include Versionable
@@ -110,7 +111,10 @@ def price_change_percentage(from_date, to_date)
def self.available_for(person)
return none unless person&.current_membership&.membership_type&.circus?
- for_circus_members.current_versions.order(:duration, :price_cents)
+ for_circus_members
+ .current_versions
+ .for_rate_kinds(person.allowed_rate_kinds)
+ .order(:duration, :price_cents)
end
def self.create_default_plans!
@@ -120,6 +124,7 @@ def self.create_default_plans!
find_or_create_by(name: "Journée - #{membership_type.name}", version: 1) do |sp|
sp.membership_type = membership_type
sp.duration = :day
+ sp.rate_kind = membership_type.rate_kind
sp.price_cents = 800
sp.description = "Accès aux cours pour une journée"
sp.version = 1
@@ -129,6 +134,7 @@ def self.create_default_plans!
find_or_create_by(name: "Trimestre - #{membership_type.name}", version: 1) do |sp|
sp.membership_type = membership_type
sp.duration = :trimester
+ sp.rate_kind = membership_type.rate_kind
sp.price_cents = 6000
sp.description = "Accès aux cours pendant 3 mois"
sp.version = 1
@@ -138,6 +144,7 @@ def self.create_default_plans!
find_or_create_by(name: "Annuel - #{membership_type.name}", version: 1) do |sp|
sp.membership_type = membership_type
sp.duration = :annual
+ sp.rate_kind = membership_type.rate_kind
sp.price_cents = 20_000
sp.description = "Accès aux cours pendant 1 an"
sp.version = 1
@@ -147,6 +154,7 @@ def self.create_default_plans!
find_or_create_by(name: "Pack 10 séances - #{membership_type.name}", version: 1) do |sp|
sp.membership_type = membership_type
sp.duration = :pack10
+ sp.rate_kind = membership_type.rate_kind
sp.price_cents = 7000
sp.sessions_count = 10
sp.validity_days = 365
diff --git a/app/models/membership_type.rb b/app/models/membership_type.rb
index 28bf1c2a..9ef7081e 100644
--- a/app/models/membership_type.rb
+++ b/app/models/membership_type.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class MembershipType < ApplicationRecord
+ include RateKindable
include Priceable
include Humanizable
include Versionable
@@ -77,11 +78,16 @@ def price_change_percentage(from_date, to_date)
scope :effective_on, ->(date) { where("effective_from <= ? AND (effective_until IS NULL OR effective_until >= ?)", date, date) }
scope :price_history, -> { order(:effective_from, :version) }
+ def self.available_for(person)
+ current_versions.for_rate_kinds(person.allowed_rate_kinds)
+ end
+
# Méthodes de classe pour créer les types par défaut
def self.create_default_types!
find_or_create_by(name: "Adhésion Basique", version: 1) do |mt|
mt.category = :basic
mt.price_cents = 1500 # 15€
+ mt.rate_kind = "standard"
mt.description = "Adhésion de base à l'association"
mt.effective_from = Date.current
end
@@ -89,6 +95,7 @@ def self.create_default_types!
find_or_create_by(name: "Adhésion Cirque Complète", version: 1) do |mt|
mt.category = :circus
mt.price_cents = 2500 # 25€
+ mt.rate_kind = "standard"
mt.description = "Adhésion complète avec accès aux cours de cirque"
mt.effective_from = Date.current
end
@@ -96,6 +103,7 @@ def self.create_default_types!
find_or_create_by(name: "Adhésion Cirque Réduite", version: 1) do |mt|
mt.category = :circus
mt.price_cents = 2000 # 20€
+ mt.rate_kind = "reduced"
mt.description = "Adhésion cirque à tarif réduit (étudiants, chômeurs, etc.)"
mt.effective_from = Date.current
end
diff --git a/app/models/payment.rb b/app/models/payment.rb
index fc3d4b07..01d88b23 100644
--- a/app/models/payment.rb
+++ b/app/models/payment.rb
@@ -15,6 +15,8 @@ class Payment < ApplicationRecord
enum :status, { success: 0, pending: 1, cancel: 2 }, default: :pending
enum :payment_method, { cash: 0, card: 1, cheque: 2, transfer: 3, offered: 4 }, default: :cash
+ validates :offer_reason, presence: true, if: :is_offered?
+
before_create :generate_uuid
after_create :create_audit_log
# Callbacks legacy supprimés : la création/mise à jour cascade passe désormais par les services People::*.
@@ -51,7 +53,7 @@ def payment_type
# Déterminer le type de paiement basé sur les payment_lines
if payment_lines.memberships.any?
"Adhésion"
- elsif payment_lines.contribution_formulas.any?
+ elsif payment_lines.contributions.any? || payment_lines.contribution_formulas.any?
"Cotisation"
elsif payment_lines.any?
# Utiliser la description de la première ligne
@@ -82,8 +84,22 @@ def membership_related?
end
def carnet_related?
- payment_lines.joins("JOIN contribution_formulas ON payment_lines.item_type = 'ContributionFormula' AND payment_lines.item_id = contribution_formulas.id")
- .exists?(contribution_formulas: { duration: ContributionFormula.durations[:pack10] })
+ payment_lines.joins(<<~SQL.squish)
+ LEFT JOIN contribution_formulas direct_formulas
+ ON payment_lines.item_type = 'ContributionFormula'
+ AND payment_lines.item_id = direct_formulas.id
+ LEFT JOIN contributions
+ ON payment_lines.item_type = 'Contribution'
+ AND payment_lines.item_id = contributions.id
+ LEFT JOIN contribution_formulas contribution_formulas
+ ON contributions.contribution_formula_id = contribution_formulas.id
+ SQL
+ .where(
+ "direct_formulas.duration = ? OR contribution_formulas.duration = ?",
+ ContributionFormula.durations[:pack10],
+ ContributionFormula.durations[:pack10]
+ )
+ .exists?
end
# Generate a UUID for the payment
@@ -160,12 +176,16 @@ def destroy
# Anonymization for GDPR compliance
def anonymize!
- return if anonymized_at.present?
-
- self.original_person_identifier = "ANON_#{Digest::SHA256.hexdigest("#{person_id}_#{id}_#{created_at}")}"
- self.person_id = nil
- self.anonymized_at = Time.current
- save!
+ with_lock do
+ return if anonymized_at.present?
+
+ # payments.person_id is NOT NULL: anonymization is a traceability mark,
+ # not a destructive unlink from the owning Person.
+ update!(
+ original_person_identifier: "ANON_#{Digest::SHA256.hexdigest("#{person_id}_#{id}_#{created_at}")}",
+ anonymized_at: Time.current
+ )
+ end
end
scope :anonymized, -> { where.not(anonymized_at: nil) }
diff --git a/app/models/payment_line.rb b/app/models/payment_line.rb
index 955026d7..c93ca46a 100644
--- a/app/models/payment_line.rb
+++ b/app/models/payment_line.rb
@@ -24,7 +24,7 @@ class PaymentLine < ApplicationRecord
def item_description
case item_type
when "Membership"
- item&.membership_type&.name || I18n.t("payment_line.item_description.membership_fallback")
+ description.presence || item&.membership_type&.name || I18n.t("payment_line.item_description.membership_fallback")
when "MembershipType"
item&.name || I18n.t("payment_line.item_description.membership_type_fallback")
when "Contribution"
@@ -42,10 +42,26 @@ def item_description
end
end
+ def history_description
+ case item_type
+ when "Membership"
+ membership_history_description
+ when "Contribution"
+ "Cotisation #{item_description}"
+ when "ContributionFormula"
+ "Cotisation #{item_description}"
+ when "Donation"
+ description.presence || I18n.t("payment_line.item_description.donation_fallback")
+ else
+ item_description
+ end
+ end
+
# (amount_euros maintenant dans le module Priceable)
# Scopes
scope :memberships, -> { where(item_type: "Membership") }
+ scope :contributions, -> { where(item_type: "Contribution") }
scope :contribution_formulas, -> { where(item_type: "ContributionFormula") }
scope :membership_types, -> { where(item_type: "MembershipType") }
scope :donations, -> { where(item_type: "Donation") }
@@ -57,7 +73,7 @@ def self.create_for_membership!(payment, membership, amount_cents)
payment: payment,
item: membership,
amount_cents: amount_cents,
- description: I18n.t("payment_line.descriptions.membership", name: membership.membership_type.name)
+ description: normalize_membership_name(membership.membership_type.name)
)
end
@@ -77,7 +93,44 @@ def self.create_for_membership_type!(payment, membership_type, amount_cents)
payment: payment,
item: membership_type,
amount_cents: amount_cents,
- description: I18n.t("payment_line.descriptions.membership_type", name: membership_type.name)
+ description: normalize_membership_name(membership_type.name)
)
end
+
+ def self.normalize_membership_name(raw_name)
+ name = raw_name.to_s.strip
+ return "Adhésion" if name.blank?
+
+ if name.match?(/\A(?:adhesion|adhésion)\b/i)
+ normalized = name.gsub(/\A(?:adhesion|adhésion)(?:\s+(?:adhesion|adhésion))+\s+/i, "Adhésion ")
+ normalized.gsub(/^adhesion\s+/i, "Adhésion ").gsub(/^adhésion\s+/i, "Adhésion ")
+ else
+ "Adhésion #{name}"
+ end
+ end
+
+ private
+
+ def membership_history_description
+ return normalized_upgrade_description if upgrade_description?
+
+ self.class.normalize_membership_name(description.presence || item&.membership_type&.name)
+ end
+
+ def upgrade_description?
+ description.to_s.start_with?("Upgrade d'adhésion", "Passage d'adhésion")
+ end
+
+ def normalized_upgrade_description
+ text = description.to_s.strip
+
+ if text.start_with?("Passage d'adhésion")
+ text
+ elsif text.match(/\AUpgrade d'adhésion de (?.+) vers (?.+?)(?:\s+\(plein tarif\))?\z/)
+ match = Regexp.last_match
+ "Passage d'adhésion : #{match[:from]} -> #{match[:to]}"
+ else
+ text
+ end
+ end
end
diff --git a/app/models/person.rb b/app/models/person.rb
index 19f3e14b..9d5dd1e4 100644
--- a/app/models/person.rb
+++ b/app/models/person.rb
@@ -1,22 +1,18 @@
# frozen_string_literal: true
class Person < ApplicationRecord
- # ===================================================================
- # ⚠️ DEPRECATED: newsletter_subscribed column
- # ===================================================================
- # This column is deprecated in favor of NewsletterSubscriber model.
- # The new table provides better tracking with source ('web', 'admin', 'import')
- # and supports orphaned emails (without Person).
- #
- # Migration plan:
- # 1. Phase 1 (current): Mark as deprecated, stop writing to it
- # 2. Phase 2: Migrate data from Person.newsletter_subscribed → NewsletterSubscriber
- # 3. Phase 3: Remove Person.newsletter_subscribed column
- #
- # TODO: Replace all newsletter_subscribed references with NewsletterSubscriber
- # ===================================================================
+ include PersonPaymentReporting
+
+ REDUCED_RATE_REASONS = [
+ "RSA",
+ "Mineur",
+ "Situation Handicap",
+ "Étudiant",
+ "Autre"
+ ].freeze
has_one :user, dependent: :restrict_with_error
+ has_many :account_claims, dependent: :restrict_with_error
has_many :memberships, dependent: :restrict_with_error
has_many :payments, dependent: :restrict_with_error
has_many :attendances, dependent: :destroy
@@ -31,6 +27,8 @@ class Person < ApplicationRecord
validates :email, uniqueness: true, allow_blank: true
validates :phone, uniqueness: true, allow_blank: true
validates :member_number, uniqueness: true, allow_blank: true
+ validate :email_not_used_by_other_user_account
+ validate :reduced_rate_consistency
def full_name
"#{first_name} #{last_name}"
@@ -102,6 +100,11 @@ def current_membership
memberships.active.current.first
end
+ # Dernière adhésion la plus récente par date de fin (profil : distinguer « jamais adhéré » / « adhésion terminée »).
+ def most_recent_membership
+ memberships.order(ended_at: :desc, started_at: :desc).first
+ end
+
def has_active_membership?
current_membership.present?
end
@@ -120,6 +123,16 @@ def adult?
!is_minor
end
+ def allows_rate_kind?(rate_kind)
+ allowed_rate_kinds.include?(rate_kind.to_s.presence || "standard")
+ end
+
+ def allowed_rate_kinds
+ kinds = [ "standard" ]
+ kinds << "reduced" if reduced_rate_eligible?
+ kinds
+ end
+
scope :with_user_account, -> { joins(:user) }
scope :without_user_account, -> { where.missing(:user) }
scope :by_name, lambda { |query|
@@ -192,240 +205,6 @@ def can_be_claimed_by?(email_to_check)
true
end
- def create_membership!(membership_type, recorded_by:, payment_method: :cash, custom_amount_cents: nil, offer_reason: nil, donation_cents: nil)
- ActiveRecord::Base.transaction do
- validate_offer_permissions!(recorded_by, "membership", offer_reason) if payment_method.to_s == "offered"
-
- raise "Cette personne a déjà une adhésion active" if memberships.active.current.exists?
-
- membership = memberships.create!(
- membership_type: membership_type,
- started_at: Date.current,
- ended_at: Date.current + 1.year,
- status: :active
- )
-
- if member_number.blank?
- normalized_category = case membership_type.category
- when "circus"
- "CIRQUE"
- when "basic"
- "BASIQUE"
- else
- "BASIQUE"
- end
-
- MemberManagementService.assign_member_number(self, normalized_category) unless Rails.env.test?
- end
-
- amount_cents = calculate_amount_cents(payment_method, membership_type.price_cents, custom_amount_cents)
- donation_cents = donation_cents.to_i if donation_cents.present?
- donation_cents = nil if donation_cents.to_i <= 0
- total_cents = amount_cents + (donation_cents || 0)
-
- description = generate_payment_description(payment_method, membership_type.name, "Membership")
- payment = payments.create!(
- total_cents: total_cents,
- payment_method: payment_method,
- status: :success,
- recorded_by: recorded_by,
- notes: "Paiement pour #{description}"
- )
-
- payment.payment_lines.create!(
- item_type: "Membership",
- item_id: membership.id,
- amount_cents: amount_cents,
- description: description
- )
-
- if donation_cents.present?
- payment.payment_lines.create!(
- item_type: "Donation",
- item_id: payment.id,
- amount_cents: donation_cents,
- description: "Donation"
- )
- end
-
- { membership: membership, payment: payment }
- end
- end
-
- def create_contribution!(contribution_formula, recorded_by:, payment_method: :cash, record_attendance: false, custom_amount_cents: nil, offer_reason: nil, donation_cents: nil)
- ActiveRecord::Base.transaction do
- validate_offer_permissions!(recorded_by, "contribution", offer_reason, contribution_formula) if payment_method.to_s == "offered"
-
- raise "Cette personne doit avoir une adhésion Cirque active pour acheter une cotisation" unless can_buy_contribution_formulas?
-
- sessions_remaining = case contribution_formula.duration
- when "pack10"
- contribution_formula.sessions_count || 10
- when "day"
- 1
- when "trimester", "annual"
- nil
- else
- contribution_formula.sessions_count || 1
- end
-
- expires_at = case contribution_formula.duration
- when "pack10"
- nil
- when "day"
- Date.current.end_of_day
- when "trimester"
- Date.current + 90.days
- when "annual"
- Date.current + 1.year
- else
- contribution_formula.validity_days ? Date.current + contribution_formula.validity_days.days : nil
- end
-
- contribution = contributions.create!(
- contribution_formula: contribution_formula,
- sessions_remaining: sessions_remaining,
- status: :active,
- purchased_at: Time.current,
- expires_at: expires_at
- )
-
- amount_cents = calculate_amount_cents(payment_method, contribution_formula.price_cents, custom_amount_cents)
- donation_cents = donation_cents.to_i if donation_cents.present?
- donation_cents = nil if donation_cents.to_i <= 0
- total_cents = amount_cents + (donation_cents || 0)
-
- description = generate_payment_description(payment_method, contribution_formula.name, "Contribution")
- payment = payments.create!(
- total_cents: total_cents,
- payment_method: payment_method,
- status: :success,
- recorded_by: recorded_by,
- notes: "Paiement pour #{description}"
- )
-
- payment.payment_lines.create!(
- item_type: "Contribution",
- item_id: contribution.id,
- amount_cents: amount_cents,
- description: description
- )
-
- if donation_cents.present?
- payment.payment_lines.create!(
- item_type: "Donation",
- item_id: payment.id,
- amount_cents: donation_cents,
- description: "Donation"
- )
- end
-
- if record_attendance
- # Logique de présence — implémentation à compléter selon les besoins.
- end
-
- { contribution: contribution, payment: payment }
- end
- end
-
- def upgrade_contribution!(from_contribution_id:, to_formula_id:, recorded_by:, payment_method: :cash)
- ActiveRecord::Base.transaction do
- raise "Adhésion Cirque active requise" unless can_buy_contribution_formulas?
-
- from_contribution = contributions.find(from_contribution_id)
- to_formula = ContributionFormula.find(to_formula_id)
-
- validate_contribution_upgrade!(from_contribution, to_formula)
-
- credit_cents = calculate_contribution_credit(from_contribution)
-
- from_contribution.suspend!(reason: "Upgrade vers #{to_formula.name}")
-
- new_result = create_contribution!(to_formula, payment_method: payment_method, recorded_by: recorded_by)
-
- amount_to_pay = [ to_formula.price_cents - credit_cents, 0 ].max
-
- payment = payments.create!(
- total_cents: amount_to_pay,
- payment_method: payment_method,
- status: :success,
- recorded_by: recorded_by,
- notes: "Upgrade cotisation: #{from_contribution.contribution_formula.name} → #{to_formula.name}. Crédit: #{credit_cents / 100.0}€"
- )
-
- payment.payment_lines.create!(
- item_type: "Contribution",
- item_id: new_result[:contribution].id,
- amount_cents: amount_to_pay,
- description: "Upgrade avec crédit prorata"
- )
-
- {
- old_contribution: from_contribution,
- new_contribution: new_result[:contribution],
- payment: payment,
- credit_applied: credit_cents
- }
- end
- end
-
- def create_donation!(amount_cents, recorded_by:, payment_method: :cash, notes: "Donation")
- ActiveRecord::Base.transaction do
- payment = payments.create!(
- total_cents: amount_cents,
- payment_method: payment_method,
- status: :success,
- recorded_by: recorded_by,
- notes: notes
- )
-
- payment.payment_lines.create!(
- item_type: "Donation",
- item_id: payment.id,
- amount_cents: amount_cents,
- description: notes
- )
-
- payment
- end
- end
-
- def upgrade_membership!(new_membership_type, recorded_by:, payment_method: :cash, custom_amount_cents: nil, offer_reason: nil, donation_cents: nil)
- ActiveRecord::Base.transaction do
- current_membership = self.current_membership
- raise "Aucune adhésion active à upgrader" unless current_membership
-
- validate_offer_permissions!(recorded_by, "membership_upgrade", offer_reason) if payment_method.to_s == "offered"
-
- old_membership_type = current_membership.membership_type
-
- amount_to_pay = new_membership_type.price_cents
-
- new_membership = current_membership.upgrade_to!(new_membership_type)
-
- old_member_number = member_number
- new_member_number = handle_member_number_change!(old_membership_type, new_membership_type, recorded_by)
-
- payment = create_payment_with_line!(
- amount_cents: amount_to_pay,
- payment_method: payment_method,
- recorded_by: recorded_by,
- item_type: "Membership",
- item_id: new_membership.id,
- description: "Upgrade d'adhésion de #{old_membership_type.name} vers #{new_membership_type.name} (plein tarif)",
- donation_cents: donation_cents
- )
-
- {
- membership: new_membership,
- payment: payment,
- member_number_changed: old_member_number != new_member_number,
- old_member_number: old_member_number,
- new_member_number: new_member_number
- }
- end
- end
-
def renew_membership!(membership_type, recorded_by:, payment_method: :cash, custom_amount_cents: nil, offer_reason: nil)
ActiveRecord::Base.transaction do
current = current_membership
@@ -433,7 +212,17 @@ def renew_membership!(membership_type, recorded_by:, payment_method: :cash, cust
current&.update!(status: :expired)
- result = create_membership!(membership_type, payment_method: payment_method, recorded_by: recorded_by, custom_amount_cents: custom_amount_cents, offer_reason: offer_reason)
+ creation_result = People::MembershipCreator.new(
+ person: self,
+ membership_type_id: membership_type.id,
+ payment_method: payment_method.to_s,
+ recorded_by_id: recorded_by.id,
+ custom_amount_cents: custom_amount_cents,
+ offer_reason: offer_reason
+ ).call
+
+ raise "Cette personne a déjà une adhésion active" if creation_result.success? && creation_result.already_existed
+ raise creation_result.message unless creation_result.success?
old_number = member_number
new_number = MemberManagementService.generate_member_number(get_membership_type_code(membership_type))
@@ -448,37 +237,18 @@ def renew_membership!(membership_type, recorded_by:, payment_method: :cash, cust
update!(member_number: new_number)
- result.merge(
+ {
+ membership: creation_result.membership,
+ payment: creation_result.payment,
renewed: true,
old_member_number: old_number,
new_member_number: new_number
- )
+ }
end
end
private
- def handle_member_number_change!(old_membership_type, new_membership_type, recorded_by)
- old_type_code = get_membership_type_code(old_membership_type)
- new_type_code = get_membership_type_code(new_membership_type)
-
- if old_type_code == new_type_code
- member_number
- else
- new_member_number = MemberManagementService.generate_member_number(new_type_code)
-
- create_member_number_change_history!(old_member_number: member_number,
- new_member_number: new_member_number,
- old_type: old_type_code,
- new_type: new_type_code,
- recorded_by: recorded_by)
-
- update!(member_number: new_member_number)
-
- new_member_number
- end
- end
-
def get_membership_type_code(membership_type)
case membership_type.category
when "circus"
@@ -490,17 +260,6 @@ def get_membership_type_code(membership_type)
end
end
- def get_membership_type_name(membership_type)
- case membership_type.category
- when "circus"
- "Cirque"
- when "basic"
- "Basique"
- else
- "Basique"
- end
- end
-
def create_member_number_change_history!(old_member_number:, new_member_number:, old_type:, new_type:, recorded_by:)
if old_member_number.present?
old_history = member_number_histories.where(member_number: old_member_number, replaced_at: nil).first
@@ -519,183 +278,37 @@ def create_member_number_change_history!(old_member_number:, new_member_number:,
)
end
- def calculate_amount_cents(payment_method, base_price_cents, custom_amount_cents = nil)
- case payment_method.to_s
- when "offered"
- custom_amount_cents || 0
- else
- base_price_cents
- end
- end
+ def must_have_active_membership
+ return if new_record?
+ return if memberships.active.any?
+ return if skip_membership_validation
- def generate_payment_description(payment_method, item_name, item_type)
- case payment_method.to_s
- when "offered"
- case item_type
- when "Membership" then "Adhésion offerte #{item_name}"
- when "Contribution" then "Cotisation offerte #{item_name}"
- when "MembershipUpgrade" then "Upgrade offert d'adhésion vers #{item_name}"
- else "#{item_type} offert #{item_name}"
- end
- else
- case item_type
- when "Membership" then "Adhésion #{item_name}"
- when "Contribution" then "Plan d'abonnement #{item_name}"
- when "MembershipUpgrade" then "Upgrade d'adhésion vers #{item_name}"
- else "#{item_type} #{item_name}"
- end
- end
+ errors.add(:base, "Une adhésion active est obligatoire")
end
- def validate_offer_permissions!(recorded_by, offer_type, offer_reason, contribution_formula = nil)
- raise "Seuls les bénévoles, admins et super-admins peuvent offrir des #{offer_type}s" unless recorded_by.super_admin? || recorded_by.admin? || recorded_by.volunteer?
-
- raise "Une raison doit être fournie pour offrir une #{offer_type}" if offer_reason.blank?
-
- raise "Les bénévoles ne peuvent offrir que des cotisations 'journée'" if recorded_by.volunteer? && offer_type == "contribution" && contribution_formula&.duration != "day"
+ private
- create_offer_audit_log!(recorded_by, offer_type, offer_reason, contribution_formula)
- end
+ def email_not_used_by_other_user_account
+ return if email.blank?
+ return unless Identity::EmailPolicy.person_email_conflicts_with_other_user?(email: email, current_person_id: id)
- def create_offer_audit_log!(recorded_by, offer_type, offer_reason, _contribution_formula = nil)
- Rails.logger.info "OFFER AUDIT: #{recorded_by.email} offered #{offer_type} to #{full_name} (#{id}) - Reason: #{offer_reason}"
+ errors.add(:email, "est deja utilisee par un autre compte utilisateur")
end
- def create_payment_with_line!(amount_cents:, payment_method:, recorded_by:, item_type:, item_id:, description:, donation_cents: nil)
- donation_cents = donation_cents.to_i if donation_cents.present?
- donation_cents = nil if donation_cents.to_i <= 0
- total_cents = amount_cents + (donation_cents || 0)
-
- payment = payments.create!(
- total_cents: total_cents,
- payment_method: payment_method,
- status: :success,
- recorded_by: recorded_by,
- notes: description
- )
+ def reduced_rate_consistency
+ return unless reduced_rate_eligible?
- payment.payment_lines.create!(
- item_type: item_type,
- item_id: item_id,
- amount_cents: amount_cents,
- description: description
- )
-
- if donation_cents.present?
- payment.payment_lines.create!(
- item_type: "Donation",
- item_id: payment.id,
- amount_cents: donation_cents,
- description: "Donation"
- )
+ if reduced_rate_reason.blank?
+ errors.add(:reduced_rate_reason, "doit être renseigné pour un tarif réduit")
+ return
end
- payment
- end
-
- def validate_contribution_upgrade!(from_contribution, to_formula)
- from_duration = from_contribution.contribution_formula.duration
- to_duration = to_formula.duration
-
- valid_upgrades = {
- "pack10" => %w[trimester annual],
- "trimester" => [ "annual" ]
- }
-
- allowed = valid_upgrades[from_duration]
- raise "Upgrade #{from_duration} → #{to_duration} non autorisé" unless allowed&.include?(to_duration)
- end
-
- def calculate_contribution_credit(contribution)
- formula = contribution.contribution_formula
-
- case formula.duration
- when "pack10"
- 0
- when "trimester"
- total_days = 90
- days_remaining = (contribution.expires_at.to_date - Date.current).to_i
- (formula.price_cents * days_remaining / total_days.to_f).round
- when "annual"
- total_days = 365
- days_remaining = (contribution.expires_at.to_date - Date.current).to_i
- (formula.price_cents * days_remaining / total_days.to_f).round
- else
- 0
+ unless REDUCED_RATE_REASONS.include?(reduced_rate_reason)
+ errors.add(:reduced_rate_reason, "n'est pas une raison de tarif réduit valide")
end
- end
-
- public
- def offered_payments_count
- payments.where(payment_method: "offered").count
- end
-
- def offered_payments_total
- payments.where(payment_method: "offered").sum(:total_cents)
- end
-
- def free_offers_count
- payments.where(payment_method: "offered", total_cents: 0).count
- end
+ return unless reduced_rate_reason == "Autre" && reduced_rate_proof.blank?
- def paid_offers_count
- payments.where(payment_method: "offered").where("total_cents > 0").count
+ errors.add(:reduced_rate_proof, "doit être renseigné quand la raison de tarif réduit est Autre")
end
-
- def membership_upgrades_count
- payments.joins(:payment_lines)
- .where(payment_lines: { item_type: "MembershipUpgrade" })
- .count
- end
-
- def contribution_purchases_count
- payments.joins(:payment_lines)
- .where(payment_lines: { item_type: "Contribution" })
- .count
- end
-
- def self.total_offered_payments
- joins(:payments).where(payments: { payment_method: "offered" }).count
- end
-
- def self.total_free_offers
- joins(:payments).where(payments: { payment_method: "offered", total_cents: 0 }).count
- end
-
- def self.total_paid_offers
- joins(:payments).where(payments: { payment_method: "offered" }).where("payments.total_cents > 0").count
- end
-
- def self.offered_payments_by_reason
- joins(:payments)
- .where(payments: { payment_method: "offered" })
- .group("payments.notes")
- .count
- end
-
- def self.upgrades_today
- joins(:payments)
- .joins("JOIN payment_lines ON payments.id = payment_lines.payment_id")
- .where(payment_lines: { item_type: "MembershipUpgrade" })
- .where(payments: { created_at: Date.current.all_day })
- .count
- end
-
- def must_have_active_membership
- return if new_record?
- return if memberships.active.any?
- return if skip_membership_validation
-
- errors.add(:base, "Une adhésion active est obligatoire")
- end
-
- def newsletter_subscribed?
- return false if email.blank?
-
- subscriber = NewsletterSubscriber.find_by(email: email)
- subscriber&.subscribed? || false
- end
-
- public :newsletter_subscribed?
end
diff --git a/app/models/user.rb b/app/models/user.rb
index db732c85..281a51c2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -4,11 +4,12 @@ class User < ApplicationRecord
include Roleable
include Dateable
+ EMAIL_CHANGE_CODE_TTL = 15.minutes
+
attr_accessor :cgu, :privacy_policy
before_validation :ensure_person_for_new_record, on: :create
after_create :generate_password_reset_token
- after_create :welcome_send
# Configuration du token de réinitialisation de mot de passe
generates_token_for :password_reset, expires_in: 15.minutes do
@@ -54,7 +55,7 @@ def newsletter_subscribed
validates :email_address, presence: true
validates :person, presence: true
- validate :email_uniqueness_unless_person_email
+ validate :email_uniqueness_and_identity_consistency
validates :cgu, acceptance: { message: :must_accept }, unless: :created_by_admin?
validates :privacy_policy, acceptance: { message: :must_accept }, unless: :created_by_admin?
@@ -209,6 +210,7 @@ def deleted?
def archive!
return false if deleted?
+ sessions.destroy_all
update!(deleted: true, deleted_at: Time.current)
anonymize_personal_data
true
@@ -224,16 +226,12 @@ def restore!
# Standard destroy method - no soft deletion
- def email_uniqueness_unless_person_email
+ def email_uniqueness_and_identity_consistency
return if email_address.blank?
+ errors.add(:email_address, "est deja utilise") if User.where(email_address: email_address).where.not(id: id).exists?
+ return unless Identity::EmailPolicy.user_email_conflicts_with_other_person?(email: email_address, current_person_id: person_id)
- # Si on a une Person associée avec le même email, c'est OK
- return if person&.email == email_address
-
- # Sinon, vérifier l'unicité normale
- return unless User.where(email_address: email_address).where.not(id: id).exists?
-
- errors.add(:email_address, "est déjà utilisé")
+ errors.add(:email_address, "entre en conflit avec l'email d'une autre personne")
end
# Check if user is interested in an event (Person-Based Architecture)
@@ -243,6 +241,36 @@ def is_interested_in?(event_id)
person.attendances.exists?(event_id: event_id)
end
+ def store_email_change_request!(new_email:, code:)
+ update!(
+ pending_email_address: new_email,
+ email_change_code_digest: BCrypt::Password.create(code),
+ email_change_code_sent_at: Time.current
+ )
+ end
+
+ def email_change_code_valid?(candidate_code)
+ return false if email_change_code_digest.blank?
+
+ BCrypt::Password.new(email_change_code_digest).is_password?(candidate_code.to_s)
+ rescue BCrypt::Errors::InvalidHash
+ false
+ end
+
+ def email_change_code_expired?
+ return true if email_change_code_sent_at.blank?
+
+ email_change_code_sent_at < EMAIL_CHANGE_CODE_TTL.ago
+ end
+
+ def clear_email_change_request!
+ update!(
+ pending_email_address: nil,
+ email_change_code_digest: nil,
+ email_change_code_sent_at: nil
+ )
+ end
+
private
def ensure_person_for_new_record
diff --git a/app/queries/admin/users/index_query.rb b/app/queries/admin/users/index_query.rb
new file mode 100644
index 00000000..c15614ff
--- /dev/null
+++ b/app/queries/admin/users/index_query.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Admin
+ module Users
+ class IndexQuery
+ def initialize(params)
+ @params = params
+ end
+
+ def call
+ apply_sort(apply_search(apply_filter(base_query)))
+ end
+
+ private
+
+ attr_reader :params
+
+ def base_query
+ PersonQuery.active.main_people.includes(
+ :user,
+ memberships: :membership_type,
+ contributions: :contribution_formula
+ )
+ end
+
+ def apply_filter(relation)
+ case params[:filter]
+ when "with_active_membership"
+ relation.with_active_membership
+ when "with_expiring_membership"
+ relation.with_expiring_membership
+ when "with_expired_membership"
+ relation.with_expired_membership
+ when "without_membership"
+ relation.without_membership
+ when "with_user_account"
+ relation.with_user_account
+ when "without_user_account"
+ relation.without_user_account
+ else
+ relation
+ end
+ end
+
+ def apply_search(relation)
+ return relation if params[:search].blank?
+
+ relation.search_by_contact(params[:search])
+ end
+
+ def apply_sort(relation)
+ relation.order(:last_name, :first_name)
+ end
+ end
+ end
+end
diff --git a/app/services/admin/health_report.rb b/app/services/admin/health_report.rb
index edae0a65..97cc2dbf 100644
--- a/app/services/admin/health_report.rb
+++ b/app/services/admin/health_report.rb
@@ -4,6 +4,7 @@ module Admin
class HealthReport
MAX_LIST = 50
DUPLICATE_FIELDS = %i[email phone].freeze
+ ContributionIssue = Struct.new(:contribution, :message, keyword_init: true)
Result = Struct.new(
:users_without_person,
@@ -16,6 +17,14 @@ class HealthReport
:duplicate_people_by_phone_count,
:payments_without_person,
:payments_without_person_count,
+ :payments_without_lines,
+ :payments_without_lines_count,
+ :payments_with_mismatched_totals,
+ :payments_with_mismatched_totals_count,
+ :legacy_donation_lines,
+ :legacy_donation_lines_count,
+ :contribution_invariant_issues,
+ :contribution_invariant_issues_count,
keyword_init: true
)
@@ -23,6 +32,10 @@ def call
users_without_person = User.left_joins(:person).where(people: { id: nil }).order(:created_at)
people_without_user = PersonQuery.active.where.missing(:user).order(:created_at)
payments_without_person = Payment.where(person_id: nil).order(:created_at)
+ payments_without_lines = Payment.includes(:person, :recorded_by).where.missing(:payment_lines).order(:created_at)
+ payments_with_mismatched_totals = payment_total_mismatches
+ legacy_donation_lines = PaymentLine.where(item_type: "Payment").includes(payment: :person).order(:created_at)
+ contribution_invariant_issues = build_contribution_invariant_issues
email_keys = duplicate_keys(PersonQuery.active, :email, normalize: true)
phone_keys = duplicate_keys(PersonQuery.active, :phone, normalize: false)
@@ -37,12 +50,90 @@ def call
duplicate_people_by_phone: people_by_keys(PersonQuery.active, :phone, phone_keys, normalize: false).limit(MAX_LIST),
duplicate_people_by_phone_count: phone_keys.size,
payments_without_person: payments_without_person.limit(MAX_LIST),
- payments_without_person_count: payments_without_person.count
+ payments_without_person_count: payments_without_person.count,
+ payments_without_lines: payments_without_lines.limit(MAX_LIST),
+ payments_without_lines_count: payments_without_lines.count,
+ payments_with_mismatched_totals: payments_with_mismatched_totals.limit(MAX_LIST),
+ payments_with_mismatched_totals_count: grouped_count(payments_with_mismatched_totals),
+ legacy_donation_lines: legacy_donation_lines.limit(MAX_LIST),
+ legacy_donation_lines_count: legacy_donation_lines.count,
+ contribution_invariant_issues: contribution_invariant_issues.first(MAX_LIST),
+ contribution_invariant_issues_count: contribution_invariant_issues.size
)
end
private
+ def payment_total_mismatches
+ Payment.includes(:person, :recorded_by)
+ .left_joins(:payment_lines)
+ .group("payments.id")
+ .having("COUNT(payment_lines.id) > 0")
+ .having("COALESCE(SUM(payment_lines.amount_cents), 0) != payments.total_cents")
+ .select("payments.*, COALESCE(SUM(payment_lines.amount_cents), 0) AS lines_total_cents, COUNT(payment_lines.id) AS payment_lines_count")
+ .order(:created_at)
+ end
+
+ def grouped_count(relation)
+ relation.except(:select, :order).count.size
+ end
+
+ def build_contribution_invariant_issues
+ contributions = Contribution.includes(:person, :contribution_formula)
+ .joins(:contribution_formula)
+ .where(contribution_formulas: { duration: %w[day trimester annual] })
+ .order(:created_at)
+
+ contributions.flat_map do |contribution|
+ contribution_issues_for(contribution)
+ end
+ end
+
+ def contribution_issues_for(contribution)
+ duration = contribution.contribution_formula.duration
+
+ case duration
+ when "day"
+ day_contribution_issues(contribution)
+ when "trimester", "annual"
+ unlimited_contribution_issues(contribution)
+ else
+ []
+ end
+ end
+
+ def day_contribution_issues(contribution)
+ issues = []
+
+ unless contribution.sessions_remaining.in?([ 0, 1 ])
+ issues << ContributionIssue.new(
+ contribution: contribution,
+ message: "Cotisation journee avec sessions_remaining invalide (attendu: 1 puis 0)"
+ )
+ end
+
+ if contribution.purchased_at.present? && contribution.expires_at.present? &&
+ contribution.expires_at.to_i != contribution.purchased_at.end_of_day.to_i
+ issues << ContributionIssue.new(
+ contribution: contribution,
+ message: "Cotisation journee avec expiration differente de la fin du jour d'achat"
+ )
+ end
+
+ issues
+ end
+
+ def unlimited_contribution_issues(contribution)
+ return [] if contribution.sessions_remaining.nil?
+
+ [
+ ContributionIssue.new(
+ contribution: contribution,
+ message: "Cotisation #{contribution.contribution_formula.duration} avec sessions_remaining non nil"
+ )
+ ]
+ end
+
def duplicate_keys(scope, field, normalize: true)
raise ArgumentError, "unsupported field" unless DUPLICATE_FIELDS.include?(field)
diff --git a/app/services/admin/users/person_route_key.rb b/app/services/admin/users/person_route_key.rb
new file mode 100644
index 00000000..d8957961
--- /dev/null
+++ b/app/services/admin/users/person_route_key.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Admin
+ module Users
+ class PersonRouteKey
+ PREFIX = "person_"
+
+ def self.call(person_or_id)
+ id = person_or_id.respond_to?(:id) ? person_or_id.id : person_or_id
+ "#{PREFIX}#{id}"
+ end
+
+ def self.person_identifier?(raw_id)
+ raw_id.to_s.start_with?(PREFIX)
+ end
+
+ def self.extract(raw_id)
+ raw_id.to_s.delete_prefix(PREFIX)
+ end
+ end
+ end
+end
diff --git a/app/services/admin/users/view_user_adapter.rb b/app/services/admin/users/view_user_adapter.rb
new file mode 100644
index 00000000..d919b6d6
--- /dev/null
+++ b/app/services/admin/users/view_user_adapter.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Admin
+ module Users
+ class ViewUserAdapter
+ def self.from_person(person)
+ user = User.new(
+ id: "temp_#{person.id}",
+ email_address: person.email,
+ system_role: nil
+ )
+
+ user.association(:person).target = person
+ user.association(:person).loaded!
+ user
+ end
+ end
+ end
+end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index 6dd76c94..ea09aaa7 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -15,17 +15,18 @@ class BaseService
def success(**data)
OpenStruct.new(
success?: true,
- message: data.delete(:message) || "Operation completed successfully",
+ message: data.delete(:message) || I18n.t("services.base.success_default"),
**data
)
end
# Méthode générique pour créer une réponse d'échec
- def failure(message, errors: nil)
+ def failure(message = nil, errors: nil)
+ resolved_message = message || I18n.t("services.base.failure_default")
OpenStruct.new(
success?: false,
- errors: errors || [ message ],
- message: message
+ errors: errors || [ resolved_message ],
+ message: resolved_message
)
end
end
diff --git a/app/services/dev_incident_logger.rb b/app/services/dev_incident_logger.rb
new file mode 100644
index 00000000..d704caa0
--- /dev/null
+++ b/app/services/dev_incident_logger.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require "digest"
+
+class DevIncidentLogger
+ LOG_PATH = Rails.root.join("log", "dev_incidents.log")
+
+ def self.log!(type:, details: {})
+ normalized_details = details.to_h.deep_stringify_keys
+
+ return unless Rails.env.development?
+
+ incident = {
+ at: Time.current.iso8601,
+ type: type,
+ pid: Process.pid,
+ fingerprint: fingerprint_for(type: type, details: normalized_details),
+ details: normalized_details
+ }
+
+ Rails.logger.warn("[dev.incident] #{incident.to_json}")
+ File.open(LOG_PATH, "a") { |f| f.puts(incident.to_json) }
+ rescue StandardError => e
+ Rails.logger.warn("[dev.incident] failed_to_persist type=#{type} error=#{e.class}: #{e.message}")
+ end
+
+ def self.fingerprint_for(type:, details:)
+ keys = %w[table email_address user_exists users_count where_clause db]
+ compact_details = details.slice(*keys)
+ payload = { type: type, details: compact_details }
+ Digest::SHA256.hexdigest(payload.to_json).first(12)
+ end
+end
diff --git a/app/services/identity/email_policy.rb b/app/services/identity/email_policy.rb
new file mode 100644
index 00000000..bbe4630f
--- /dev/null
+++ b/app/services/identity/email_policy.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Identity
+ class EmailPolicy
+ def self.person_email_conflicts_with_other_user?(email:, current_person_id:)
+ normalized_email = normalize(email)
+ return false if normalized_email.blank?
+
+ User.where(email_address: normalized_email)
+ .where.not(person_id: current_person_id)
+ .exists?
+ end
+
+ def self.user_email_conflicts_with_other_person?(email:, current_person_id:)
+ normalized_email = normalize(email)
+ return false if normalized_email.blank?
+
+ Person.where(email: normalized_email)
+ .where.not(id: current_person_id)
+ .exists?
+ end
+
+ def self.normalize(email)
+ email.to_s.strip.downcase
+ end
+ private_class_method :normalize
+ end
+end
diff --git a/app/services/member_management_service.rb b/app/services/member_management_service.rb
index aee310cf..21bf3a68 100644
--- a/app/services/member_management_service.rb
+++ b/app/services/member_management_service.rb
@@ -6,7 +6,7 @@ def self.generate_member_number(membership_type = "U")
loop do
# Format: YYTNNN (ex: 25C001, 25U400)
year = Date.current.year.to_s.last(2) # 2025 -> 25
- type_code = %w[CIRQUE C].include?(membership_type.upcase) ? "C" : "U"
+ type_code = MemberNumberManagement::Policy.type_code_for(membership_type)
# Chercher le dernier numéro pour cette année et ce type
# Utiliser l'historique ET les numéros actuels pour éviter les conflits
@@ -72,14 +72,7 @@ def self.assign_member_number(person, membership_type = nil)
person.update!(member_number: new_number)
# Normaliser le type d'adhésion pour l'historique
- normalized_type = case membership_type.to_s.upcase
- when "CIRQUE", "C"
- "Cirque"
- when "BASIQUE", "U", "BASIC"
- "Basique"
- else
- "Basique"
- end
+ normalized_type = MemberNumberManagement::Policy.type_label_for(membership_type)
# Créer l'historique
person.member_number_histories.create!(
@@ -202,26 +195,12 @@ def self.assign_missing_member_numbers
# Analyse un numéro d'adhérent existant
def self.parse_member_number(member_number)
- return nil if member_number.blank?
-
- # Format: YYTNNN
- match = member_number.match(/^(\d{2})([CU])(\d+)$/)
- return nil unless match
-
- {
- year: "20#{match[1]}", # 25 -> 2025
- type: match[2] == "C" ? "Cirque" : "Basique",
- number: match[3].to_i,
- full_year: match[1],
- type_code: match[2]
- }
+ MemberNumberManagement::Policy.parse(member_number)
end
# Valide le format d'un numéro d'adhérent
def self.valid_member_number_format?(member_number)
- return false if member_number.blank?
-
- member_number.match?(/^\d{2}[CU]\d{3,5}$/)
+ MemberNumberManagement::Policy.valid_format?(member_number)
end
# Génère des numéros d'adhérent pour les tests
diff --git a/app/services/member_number_management/policy.rb b/app/services/member_number_management/policy.rb
new file mode 100644
index 00000000..5a7d8973
--- /dev/null
+++ b/app/services/member_number_management/policy.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module MemberNumberManagement
+ class Policy
+ TYPE_CODE_BY_INPUT = {
+ "CIRQUE" => "C",
+ "C" => "C",
+ "BASIQUE" => "U",
+ "BASIC" => "U",
+ "U" => "U"
+ }.freeze
+
+ TYPE_LABEL_BY_CODE = {
+ "C" => "Cirque",
+ "U" => "Basique"
+ }.freeze
+
+ def self.type_code_for(membership_type)
+ TYPE_CODE_BY_INPUT.fetch(membership_type.to_s.upcase, "U")
+ end
+
+ def self.type_label_for(membership_type)
+ TYPE_LABEL_BY_CODE.fetch(type_code_for(membership_type), "Basique")
+ end
+
+ def self.parse(member_number)
+ return nil if member_number.blank?
+
+ match = member_number.match(/^(\d{2})([CU])(\d+)$/)
+ return nil unless match
+
+ {
+ year: "20#{match[1]}",
+ type: type_label_for(match[2]),
+ number: match[3].to_i,
+ full_year: match[1],
+ type_code: match[2]
+ }
+ end
+
+ def self.valid_format?(member_number)
+ return false if member_number.blank?
+
+ member_number.match?(/^\d{2}[CU]\d{3,5}$/)
+ end
+ end
+end
diff --git a/app/services/partners_catalog.rb b/app/services/partners_catalog.rb
index adae2fd2..fb3f0662 100644
--- a/app/services/partners_catalog.rb
+++ b/app/services/partners_catalog.rb
@@ -8,7 +8,7 @@ def self.public_partners
Partner.new(name: "Ville de Toulouse", description: "Soutien institutionnel & logistique", link: "https://www.toulouse.fr"),
Partner.new(name: "L'Usine", description: "Compagnonnage cirque contemporain", link: "https://www.usine-c.com"),
Partner.new(name: "École de cirque Pop", description: "Partage de savoir-faire pédagogique", link: "https://www.ecoledecirquepop.fr"),
- Partner.new(name: "Atelier Graphein", description: "Résidences graphiques & expositions", link: "https://ateliergraphein.fr")
+ Partner.new(name: "Atelier Graphein", description: "Temps d’accueil en création graphiques & expositions", link: "https://ateliergraphein.fr")
]
end
end
diff --git a/app/services/people/account_merger.rb b/app/services/people/account_merger.rb
index 30cbb2d1..790e0460 100644
--- a/app/services/people/account_merger.rb
+++ b/app/services/people/account_merger.rb
@@ -34,6 +34,8 @@ def call
merge_contributions(source, target)
merge_attendances(source, target)
merge_newsletter(source, target)
+ merge_account_claims(source, target)
+ merge_member_number_histories(source, target)
merge_attributes(source, target)
if destroy_source
@@ -92,6 +94,14 @@ def merge_newsletter(source, target)
subscriber.update!(person: target)
end
+ def merge_account_claims(source, target)
+ transfer_relation(source.account_claims, target.id)
+ end
+
+ def merge_member_number_histories(source, target)
+ transfer_relation(source.member_number_histories, target.id)
+ end
+
def merge_attributes(source, target)
attrs = {}
if source.email.present? && (target.email.blank? || target_email_is_auth_placeholder?(target))
diff --git a/app/services/people/contribution_creator.rb b/app/services/people/contribution_creator.rb
index b4b5101b..b27961be 100644
--- a/app/services/people/contribution_creator.rb
+++ b/app/services/people/contribution_creator.rb
@@ -32,30 +32,52 @@ def call
contribution_formula = ContributionFormula.find(contribution_formula_id)
recorded_by = resolve_recorded_by
- result = target_person.create_contribution!(
- contribution_formula,
- payment_method: payment_method.to_sym,
- recorded_by: recorded_by,
- record_attendance: record_attendance,
- custom_amount_cents: custom_amount_cents,
- offer_reason: offer_reason,
- donation_cents: donation_cents
- )
+ result = nil
+ ActiveRecord::Base.transaction do
+ People::OfferPolicy.validate!(
+ recorded_by: recorded_by,
+ person: target_person,
+ offer_type: "contribution",
+ offer_reason: offer_reason,
+ contribution_formula: contribution_formula
+ ) if payment_method == "offered"
+
+ raise "Cette personne doit avoir une adhésion Cirque active pour acheter une cotisation" unless target_person.can_buy_contribution_formulas?
+
+ contribution = target_person.contributions.create!(
+ People::ContributionPayloadBuilder.call(contribution_formula)
+ .merge(contribution_formula: contribution_formula, status: :active, purchased_at: Time.current)
+ )
+
+ amount_cents = amount_for(contribution_formula.price_cents)
+ description = payment_description(contribution_formula.name)
+ payment = record_payment!(
+ person: target_person,
+ recorded_by: recorded_by,
+ item_type: "Contribution",
+ item_id: contribution.id,
+ amount_cents: amount_cents,
+ description: description,
+ notes: "Paiement pour #{description}"
+ )
+
+ result = { contribution: contribution, payment: payment }
+ end
instrument_contribution_created(target_person, contribution_formula, recorded_by, result[:contribution], result[:payment])
success(
contribution: result[:contribution],
payment: result[:payment],
- message: "Contribution created successfully"
+ message: I18n.t("services.success.contribution_created")
)
rescue ActiveRecord::RecordNotFound => e
ActiveSupport::Notifications.instrument("contribution.failed", error: e.message, reason: "record_not_found")
- failure("Record not found: #{e.message}")
+ failure(I18n.t("services.errors.record_not_found", message: e.message))
rescue StandardError => e
Rails.logger.error("[People::ContributionCreator] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
ActiveSupport::Notifications.instrument("contribution.failed", error: e.message, reason: "exception")
- failure("Error creating contribution: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "contribution creation", message: e.message))
end
private
@@ -75,6 +97,55 @@ def resolve_recorded_by
Current.user
end
+ def amount_for(base_price_cents)
+ return custom_amount_cents || 0 if payment_method == "offered"
+
+ base_price_cents
+ end
+
+ def payment_description(name)
+ payment_method == "offered" ? "Cotisation offerte #{name}" : "Cotisation #{name}"
+ end
+
+ def record_payment!(person:, recorded_by:, item_type:, item_id:, amount_cents:, description:, notes:)
+ lines = [
+ {
+ item_type: item_type,
+ item_id: item_id,
+ amount_cents: amount_cents,
+ description: description
+ }
+ ]
+
+ if normalized_donation_cents.present?
+ lines << {
+ item_type: "Donation",
+ amount_cents: normalized_donation_cents,
+ description: "Donation"
+ }
+ end
+
+ payment_result = People::PaymentRecorder.new(
+ person: person,
+ recorded_by: recorded_by,
+ payment_method: payment_method,
+ status: "success",
+ notes: notes,
+ offer_reason: offer_reason,
+ total_cents: lines.sum { |line| line[:amount_cents].to_i },
+ payment_lines: lines
+ ).call
+
+ raise payment_result.message unless payment_result.success?
+
+ payment_result.payment
+ end
+
+ def normalized_donation_cents
+ cents = donation_cents.to_i
+ cents.positive? ? cents : nil
+ end
+
def instrument_contribution_created(person, contribution_formula, recorded_by, contribution, payment)
ActiveSupport::Notifications.instrument(
"contribution.created",
diff --git a/app/services/people/contribution_payload_builder.rb b/app/services/people/contribution_payload_builder.rb
new file mode 100644
index 00000000..04fcc601
--- /dev/null
+++ b/app/services/people/contribution_payload_builder.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module People
+ class ContributionPayloadBuilder
+ def self.call(contribution_formula, reference_date: Date.current)
+ sessions_remaining = case contribution_formula.duration
+ when "pack10"
+ contribution_formula.sessions_count || 10
+ when "day"
+ 1
+ when "trimester", "annual"
+ nil
+ else
+ contribution_formula.sessions_count || 1
+ end
+
+ expires_at = case contribution_formula.duration
+ when "pack10"
+ nil
+ when "day"
+ reference_date.end_of_day
+ when "trimester"
+ reference_date + 90.days
+ when "annual"
+ reference_date + 1.year
+ else
+ contribution_formula.validity_days ? reference_date + contribution_formula.validity_days.days : nil
+ end
+
+ { sessions_remaining: sessions_remaining, expires_at: expires_at }
+ end
+ end
+end
diff --git a/app/services/people/contribution_upgrader.rb b/app/services/people/contribution_upgrader.rb
index 90c15d4e..dd42fcec 100644
--- a/app/services/people/contribution_upgrader.rb
+++ b/app/services/people/contribution_upgrader.rb
@@ -16,6 +16,7 @@ class ContributionUpgrader
attribute :to_formula_id, :integer
attribute :payment_method, :string, default: "cash"
attribute :recorded_by_id, :integer
+ attribute :offer_reason, :string
validates :from_contribution_id, presence: true
validates :to_formula_id, presence: true
@@ -29,12 +30,47 @@ def call
target_person = resolve_person
recorded_by = resolve_user
- result = target_person.upgrade_contribution!(
- from_contribution_id: from_contribution_id,
- to_formula_id: to_formula_id,
- payment_method: payment_method.to_sym,
- recorded_by: recorded_by
- )
+ result = nil
+ ActiveRecord::Base.transaction do
+ raise "Adhésion Cirque active requise" unless target_person.can_buy_contribution_formulas?
+
+ from_contribution = target_person.contributions.find(from_contribution_id)
+ to_formula = ContributionFormula.find(to_formula_id)
+
+ validate_contribution_upgrade!(from_contribution, to_formula)
+
+ People::OfferPolicy.validate!(
+ recorded_by: recorded_by,
+ person: target_person,
+ offer_type: "contribution_upgrade",
+ offer_reason: offer_reason,
+ contribution_formula: to_formula
+ ) if payment_method == "offered"
+
+ credit_cents = calculate_contribution_credit(from_contribution)
+ from_contribution.suspend!(reason: "Upgrade vers #{to_formula.name}")
+
+ amount_to_pay = [ to_formula.price_cents - credit_cents, 0 ].max
+ new_contribution = target_person.contributions.create!(
+ People::ContributionPayloadBuilder.call(to_formula)
+ .merge(contribution_formula: to_formula, status: :active, purchased_at: Time.current)
+ )
+
+ payment = record_payment!(
+ person: target_person,
+ recorded_by: recorded_by,
+ contribution: new_contribution,
+ amount_cents: amount_to_pay,
+ notes: "Upgrade cotisation: #{from_contribution.contribution_formula.name} → #{to_formula.name}. Crédit: #{credit_cents / 100.0}€"
+ )
+
+ result = {
+ old_contribution: from_contribution,
+ new_contribution: new_contribution,
+ payment: payment,
+ credit_applied: credit_cents
+ }
+ end
instrument_upgrade(target_person, recorded_by, result)
@@ -43,13 +79,13 @@ def call
new_contribution: result[:new_contribution],
payment: result[:payment],
credit_applied: result[:credit_applied] || 0,
- message: "Contribution upgraded successfully"
+ message: I18n.t("services.success.contribution_upgraded")
)
rescue ActiveRecord::RecordNotFound => e
- failure("Record not found: #{e.message}")
+ failure(I18n.t("services.errors.record_not_found", message: e.message))
rescue StandardError => e
Rails.logger.error("[People::ContributionUpgrader] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
- failure("Error upgrading contribution: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "contribution upgrade", message: e.message))
end
private
@@ -77,6 +113,61 @@ def instrument_upgrade(person, recorded_by, result)
)
end
+ def record_payment!(person:, recorded_by:, contribution:, amount_cents:, notes:)
+ payment_result = People::PaymentRecorder.new(
+ person: person,
+ recorded_by: recorded_by,
+ payment_method: payment_method,
+ status: "success",
+ notes: notes,
+ offer_reason: offer_reason,
+ total_cents: amount_cents,
+ payment_lines: [
+ {
+ item_type: "Contribution",
+ item_id: contribution.id,
+ amount_cents: amount_cents,
+ description: "Upgrade avec crédit prorata"
+ }
+ ]
+ ).call
+
+ raise payment_result.message unless payment_result.success?
+
+ payment_result.payment
+ end
+
+ def validate_contribution_upgrade!(from_contribution, to_formula)
+ valid_upgrades = {
+ "pack10" => %w[trimester annual],
+ "trimester" => [ "annual" ]
+ }
+
+ from_duration = from_contribution.contribution_formula.duration
+ to_duration = to_formula.duration
+ allowed = valid_upgrades[from_duration]
+ raise "Upgrade #{from_duration} → #{to_duration} non autorisé" unless allowed&.include?(to_duration)
+ end
+
+ def calculate_contribution_credit(contribution)
+ formula = contribution.contribution_formula
+
+ case formula.duration
+ when "pack10"
+ 0
+ when "trimester"
+ total_days = 90
+ days_remaining = (contribution.expires_at.to_date - Date.current).to_i
+ (formula.price_cents * days_remaining / total_days.to_f).round
+ when "annual"
+ total_days = 365
+ days_remaining = (contribution.expires_at.to_date - Date.current).to_i
+ (formula.price_cents * days_remaining / total_days.to_f).round
+ else
+ 0
+ end
+ end
+
def person_identifier_present
errors.add(:person_id, "must be provided") if person.blank? && person_id.blank?
end
diff --git a/app/services/people/membership_creator.rb b/app/services/people/membership_creator.rb
index d8b04f96..560e9033 100644
--- a/app/services/people/membership_creator.rb
+++ b/app/services/people/membership_creator.rb
@@ -29,20 +29,41 @@ def call
"membership.skipped",
person_id: person.id, reason: "already_active", membership_type_id: membership_type_id
)
- return Result.new(success?: true, membership: person.memberships.active.current.first, payment: nil, errors: [], message: "Person already has an active membership", already_existed: true)
+ return Result.new(success?: true, membership: person.memberships.active.current.first, payment: nil, errors: [], message: I18n.t("services.success.membership_already_active"), already_existed: true)
end
membership_type = MembershipType.find(membership_type_id)
recorded_by = find_recorded_by
- membership_data = person.create_membership!(
- membership_type,
- payment_method: payment_method.to_sym,
- recorded_by: recorded_by,
- custom_amount_cents: custom_amount_cents,
- offer_reason: offer_reason,
- donation_cents: donation_cents
- )
+ membership_data = nil
+ ActiveRecord::Base.transaction do
+ People::OfferPolicy.validate!(
+ recorded_by: recorded_by,
+ person: person,
+ offer_type: "membership",
+ offer_reason: offer_reason
+ ) if payment_method == "offered"
+
+ membership = person.memberships.create!(
+ membership_type: membership_type,
+ started_at: Date.current,
+ ended_at: Date.current + 1.year,
+ status: :active
+ )
+
+ assign_member_number_if_needed!(membership_type)
+
+ amount_cents = amount_for(membership_type.price_cents)
+ payment = record_payment!(
+ item_type: "Membership",
+ item_id: membership.id,
+ amount_cents: amount_cents,
+ description: payment_description(membership_type.name),
+ notes: "Paiement pour #{payment_description(membership_type.name)}"
+ )
+
+ membership_data = { membership: membership, payment: payment }
+ end
ActiveSupport::Notifications.instrument(
"membership.created",
@@ -55,20 +76,20 @@ def call
membership: membership_data[:membership],
payment: membership_data[:payment],
errors: [],
- message: "Membership created successfully",
+ message: I18n.t("services.success.membership_created"),
already_existed: false
)
rescue ActiveRecord::RecordNotFound => e
ActiveSupport::Notifications.instrument("membership.failed", error: e.message, reason: "record_not_found")
- failure("Record not found: #{e.message}")
+ failure(I18n.t("services.errors.record_not_found", message: e.message))
rescue ActiveRecord::RecordInvalid => e
ActiveSupport::Notifications.instrument("membership.failed", error: e.message, reason: "validation")
- failure("Validation error: #{e.message}")
+ failure(I18n.t("services.errors.validation_error", message: e.message))
rescue StandardError => e
db_path = ActiveRecord::Base.connection_db_config&.database
Rails.logger.error("[People::MembershipCreator] db=#{db_path} #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
ActiveSupport::Notifications.instrument("membership.failed", error: e.message, reason: "exception")
- failure("Error creating membership: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "membership creation", message: e.message))
end
private
@@ -85,6 +106,62 @@ def find_recorded_by
end
end
+ def assign_member_number_if_needed!(membership_type)
+ return if person.member_number.present?
+
+ category = membership_type.circus? ? "CIRQUE" : "BASIQUE"
+ MemberManagementService.assign_member_number(person, category) unless Rails.env.test?
+ end
+
+ def amount_for(base_price_cents)
+ return custom_amount_cents || 0 if payment_method == "offered"
+
+ base_price_cents
+ end
+
+ def payment_description(name)
+ PaymentLine.normalize_membership_name(name)
+ end
+
+ def record_payment!(item_type:, item_id:, amount_cents:, description:, notes:)
+ lines = [
+ {
+ item_type: item_type,
+ item_id: item_id,
+ amount_cents: amount_cents,
+ description: description
+ }
+ ]
+
+ if normalized_donation_cents.present?
+ lines << {
+ item_type: "Donation",
+ amount_cents: normalized_donation_cents,
+ description: "Donation"
+ }
+ end
+
+ result = People::PaymentRecorder.new(
+ person: person,
+ recorded_by: find_recorded_by,
+ payment_method: payment_method,
+ status: "success",
+ notes: notes,
+ offer_reason: offer_reason,
+ total_cents: lines.sum { |line| line[:amount_cents].to_i },
+ payment_lines: lines
+ ).call
+
+ raise result.message unless result.success?
+
+ result.payment
+ end
+
+ def normalized_donation_cents
+ cents = donation_cents.to_i
+ cents.positive? ? cents : nil
+ end
+
def failure(message, error_list = nil)
Result.new(
success?: false,
diff --git a/app/services/people/membership_deactivator.rb b/app/services/people/membership_deactivator.rb
index bd14d2c7..ca3cf468 100644
--- a/app/services/people/membership_deactivator.rb
+++ b/app/services/people/membership_deactivator.rb
@@ -24,7 +24,7 @@ def call
target_membership = membership || Membership.find(membership_id)
user = resolve_user
- return failure("Insufficient permissions to deactivate this membership") unless can_deactivate?(user)
+ return failure(I18n.t("services.errors.insufficient_permissions.membership_deactivate")) unless can_deactivate?(user)
ActiveRecord::Base.transaction do
target_membership.update!(status: :inactive)
@@ -33,16 +33,16 @@ def call
success?: true,
membership: target_membership,
errors: [],
- message: "Membership deactivated successfully"
+ message: I18n.t("services.success.membership_deactivated")
)
end
rescue ActiveRecord::RecordNotFound => e
- failure("Record not found: #{e.message}")
+ failure(I18n.t("services.errors.record_not_found", message: e.message))
rescue ActiveRecord::RecordInvalid => e
- failure("Validation error: #{e.message}")
+ failure(I18n.t("services.errors.validation_error", message: e.message))
rescue StandardError => e
Rails.logger.error("[People::MembershipDeactivator] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
- failure("Error deactivating membership: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "membership deactivation", message: e.message))
end
private
diff --git a/app/services/people/membership_updater.rb b/app/services/people/membership_updater.rb
index 57e7cb68..a44a0929 100644
--- a/app/services/people/membership_updater.rb
+++ b/app/services/people/membership_updater.rb
@@ -27,7 +27,7 @@ def call
membership_type = MembershipType.find(membership_type_id)
user = resolve_user
- return failure("Insufficient permissions to update this membership") unless can_update?(user)
+ return failure(I18n.t("services.errors.insufficient_permissions.membership_update")) unless can_update?(user)
ActiveRecord::Base.transaction do
target_membership.update!(
@@ -40,16 +40,16 @@ def call
success?: true,
membership: target_membership,
errors: [],
- message: "Membership updated successfully"
+ message: I18n.t("services.success.membership_updated")
)
end
rescue ActiveRecord::RecordNotFound => e
- failure("Record not found: #{e.message}")
+ failure(I18n.t("services.errors.record_not_found", message: e.message))
rescue ActiveRecord::RecordInvalid => e
- failure("Validation error: #{e.message}")
+ failure(I18n.t("services.errors.validation_error", message: e.message))
rescue StandardError => e
Rails.logger.error("[People::MembershipUpdater] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
- failure("Error updating membership: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "membership update", message: e.message))
end
private
diff --git a/app/services/people/membership_upgrader.rb b/app/services/people/membership_upgrader.rb
index 2037a31a..b5690e96 100644
--- a/app/services/people/membership_upgrader.rb
+++ b/app/services/people/membership_upgrader.rb
@@ -27,14 +27,39 @@ def call
new_membership_type = MembershipType.find(new_membership_type_id)
recorded_by = resolve_recorded_by
- result = person.upgrade_membership!(
- new_membership_type,
- payment_method: payment_method.to_sym,
- recorded_by: recorded_by,
- custom_amount_cents: custom_amount_cents,
- offer_reason: offer_reason,
- donation_cents: donation_cents
- )
+ result = nil
+ ActiveRecord::Base.transaction do
+ current_membership = person.current_membership
+ raise "Aucune adhésion active à upgrader" unless current_membership
+
+ People::OfferPolicy.validate!(
+ recorded_by: recorded_by,
+ person: person,
+ offer_type: "membership_upgrade",
+ offer_reason: offer_reason
+ ) if payment_method == "offered"
+
+ old_membership_type = current_membership.membership_type
+ new_membership = current_membership.upgrade_to!(new_membership_type)
+ old_member_number = person.member_number
+ new_member_number = handle_member_number_change!(old_membership_type, new_membership_type, recorded_by)
+ amount_cents = amount_for(new_membership_type.price_cents)
+
+ payment = record_payment!(
+ item_type: "Membership",
+ item_id: new_membership.id,
+ amount_cents: amount_cents,
+ description: upgrade_payment_description(old_membership_type, new_membership_type)
+ )
+
+ result = {
+ membership: new_membership,
+ payment: payment,
+ member_number_changed: old_member_number != new_member_number,
+ old_member_number: old_member_number,
+ new_member_number: new_member_number
+ }
+ end
ActiveSupport::Notifications.instrument(
"membership.upgraded",
@@ -51,15 +76,15 @@ def call
old_member_number: result[:old_member_number],
new_member_number: result[:new_member_number],
errors: [],
- message: "Membership upgraded successfully"
+ message: I18n.t("services.success.membership_upgraded")
)
rescue ActiveRecord::RecordNotFound => e
ActiveSupport::Notifications.instrument("membership.upgrade_failed", error: e.message, reason: "record_not_found")
- failure("Record not found: #{e.message}")
+ failure(I18n.t("services.errors.record_not_found", message: e.message))
rescue StandardError => e
Rails.logger.error("[People::MembershipUpgrader] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
ActiveSupport::Notifications.instrument("membership.upgrade_failed", error: e.message, reason: "exception")
- failure("Error upgrading membership: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "membership upgrade", message: e.message))
end
private
@@ -76,6 +101,94 @@ def resolve_recorded_by
end
end
+ def amount_for(base_price_cents)
+ return custom_amount_cents || 0 if payment_method == "offered"
+
+ base_price_cents
+ end
+
+ def record_payment!(item_type:, item_id:, amount_cents:, description:)
+ lines = [
+ {
+ item_type: item_type,
+ item_id: item_id,
+ amount_cents: amount_cents,
+ description: description
+ }
+ ]
+
+ if normalized_donation_cents.present?
+ lines << {
+ item_type: "Donation",
+ amount_cents: normalized_donation_cents,
+ description: "Donation"
+ }
+ end
+
+ payment_result = People::PaymentRecorder.new(
+ person: person,
+ recorded_by: resolve_recorded_by,
+ payment_method: payment_method,
+ status: "success",
+ notes: description,
+ offer_reason: offer_reason,
+ total_cents: lines.sum { |line| line[:amount_cents].to_i },
+ payment_lines: lines
+ ).call
+
+ raise payment_result.message unless payment_result.success?
+
+ payment_result.payment
+ end
+
+ def normalized_donation_cents
+ cents = donation_cents.to_i
+ cents.positive? ? cents : nil
+ end
+
+ def upgrade_payment_description(old_membership_type, new_membership_type)
+ "Passage d'adhésion : #{old_membership_type.name} -> #{new_membership_type.name}"
+ end
+
+ def handle_member_number_change!(old_membership_type, new_membership_type, recorded_by)
+ old_type_code = membership_type_code(old_membership_type)
+ new_type_code = membership_type_code(new_membership_type)
+ return person.member_number if old_type_code == new_type_code
+
+ new_member_number = MemberManagementService.generate_member_number(new_type_code)
+ create_member_number_change_history!(
+ old_member_number: person.member_number,
+ new_member_number: new_member_number,
+ old_type: old_type_code,
+ new_type: new_type_code,
+ recorded_by: recorded_by
+ )
+ person.update!(member_number: new_member_number)
+ new_member_number
+ end
+
+ def membership_type_code(membership_type)
+ membership_type.circus? ? "CIRQUE" : "BASIQUE"
+ end
+
+ def create_member_number_change_history!(old_member_number:, new_member_number:, old_type:, new_type:, recorded_by:)
+ if old_member_number.present?
+ old_history = person.member_number_histories.where(member_number: old_member_number, replaced_at: nil).first
+ old_history&.mark_as_replaced!
+ end
+
+ old_type_name = old_type == "CIRQUE" ? "Cirque" : "Basique"
+ new_type_name = new_type == "CIRQUE" ? "Cirque" : "Basique"
+
+ person.member_number_histories.create!(
+ member_number: new_member_number,
+ membership_type: new_type_name,
+ year: Date.current.year,
+ notes: "Changement automatique lors de l'upgrade d'adhésion (#{old_type_name} → #{new_type_name}) - Enregistré par #{recorded_by.email}",
+ assigned_at: Time.current
+ )
+ end
+
def failure(message, error_list = nil)
Result.new(
success?: false,
diff --git a/app/services/people/offer_policy.rb b/app/services/people/offer_policy.rb
new file mode 100644
index 00000000..34c737af
--- /dev/null
+++ b/app/services/people/offer_policy.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module People
+ class OfferPolicy
+ def self.validate!(recorded_by:, person:, offer_type:, offer_reason:, contribution_formula: nil)
+ new(
+ recorded_by: recorded_by,
+ person: person,
+ offer_type: offer_type,
+ offer_reason: offer_reason,
+ contribution_formula: contribution_formula
+ ).validate!
+ end
+
+ def initialize(recorded_by:, person:, offer_type:, offer_reason:, contribution_formula: nil)
+ @recorded_by = recorded_by
+ @person = person
+ @offer_type = offer_type
+ @offer_reason = offer_reason
+ @contribution_formula = contribution_formula
+ end
+
+ def validate!
+ raise "Seuls les bénévoles, admins et super-admins peuvent offrir des #{offer_type}s" unless allowed_actor?
+ raise "Une raison doit être fournie pour offrir une #{offer_type}" if offer_reason.blank?
+ raise "Les bénévoles ne peuvent offrir que des cotisations 'journée'" if volunteer_limited_contribution?
+
+ audit!
+ true
+ end
+
+ private
+
+ attr_reader :recorded_by, :person, :offer_type, :offer_reason, :contribution_formula
+
+ def allowed_actor?
+ recorded_by.super_admin? || recorded_by.admin? || recorded_by.volunteer?
+ end
+
+ def volunteer_limited_contribution?
+ recorded_by.volunteer? && offer_type == "contribution" && contribution_formula&.duration != "day"
+ end
+
+ def audit!
+ Rails.logger.info "OFFER AUDIT: #{recorded_by.email} offered #{offer_type} to #{person.full_name} (#{person.id}) - Reason: #{offer_reason}"
+ end
+ end
+end
diff --git a/app/services/people/payment_creator.rb b/app/services/people/payment_creator.rb
index fc01b28b..e56eaf9e 100644
--- a/app/services/people/payment_creator.rb
+++ b/app/services/people/payment_creator.rb
@@ -21,6 +21,7 @@ class PaymentCreator
attribute :item_id, :integer
attribute :description, :string
attribute :notes, :string
+ attribute :offer_reason, :string
attribute :status, :string, default: "success"
validates :payment_method, presence: true, inclusion: { in: %w[cash card cheque transfer offered pending] }
@@ -32,32 +33,21 @@ class PaymentCreator
def call
return failure("Invalid payment data: #{errors.full_messages.join(', ')}") unless valid?
- target_person = resolve_person
- recorded_by = resolve_recorded_by
normalized_lines = normalize_payment_lines
total = determine_total(normalized_lines)
- payment = nil
-
- ActiveRecord::Base.transaction do
- payment = target_person.payments.create!(
- total_cents: total,
- payment_method: payment_method,
- status: status,
- recorded_by: recorded_by,
- notes: notes
- )
-
- created_lines = if normalized_lines.any?
- create_multiple_lines(payment, normalized_lines)
- else
- [ create_single_line(payment) ]
- end
-
- instrument_payment_created(payment, created_lines.length)
-
- return success(payment: payment, payment_lines: created_lines)
- end
+ lines = normalized_lines.any? ? normalized_lines : [ single_line_attributes ]
+ People::PaymentRecorder.new(
+ person: person,
+ person_id: person_id,
+ recorded_by_id: recorded_by_id,
+ total_cents: total,
+ payment_method: payment_method,
+ status: status,
+ notes: notes,
+ offer_reason: offer_reason,
+ payment_lines: lines
+ ).call
rescue ActiveRecord::RecordNotFound => e
failure("Record not found: #{e.message}")
rescue ActiveRecord::RecordInvalid => e
@@ -69,49 +59,24 @@ def call
private
- def resolve_person
- return person if person.present?
- raise ActiveRecord::RecordNotFound, "Person not found" if person_id.blank?
-
- Person.find(person_id)
- end
-
- def resolve_recorded_by
- return User.find(recorded_by_id) if recorded_by_id.present?
-
- raise ActiveRecord::RecordNotFound, "Recorded_by user not provided" unless Current.respond_to?(:user) && Current.user.present?
-
- Current.user
- end
-
- def create_single_line(payment)
- line_amount = amount_cents.to_i
+ def single_line_attributes
line_item_type = item_type.presence || "Donation"
line_description = description.presence || default_description_for(line_item_type)
- payment.payment_lines.create!(
+ {
item_type: line_item_type,
- item_id: donation_line?(line_item_type) ? payment.id : item_id,
- amount_cents: line_amount,
+ item_id: item_id,
+ amount_cents: amount_cents.to_i,
description: line_description
- )
- end
-
- def create_multiple_lines(payment, lines)
- lines.map do |line|
- payment.payment_lines.create!(
- item_type: line[:item_type],
- item_id: line[:item_id],
- amount_cents: line[:amount_cents].to_i,
- description: line[:description].presence || default_description_for(line[:item_type])
- )
- end
+ }
end
def normalize_payment_lines
Array(payment_lines).map do |line|
line = line.to_unsafe_h if line.respond_to?(:to_unsafe_h)
- line.symbolize_keys
+ line.symbolize_keys.tap do |attrs|
+ attrs[:description] = attrs[:description].presence || default_description_for(attrs[:item_type])
+ end
end
end
@@ -136,18 +101,6 @@ def donation_line?(item_type)
item_type.to_s.casecmp("donation").zero?
end
- def instrument_payment_created(payment, lines_count)
- ActiveSupport::Notifications.instrument(
- "payment.created",
- payment_id: payment.id,
- person_id: payment.person_id,
- total_cents: payment.total_cents,
- payment_method: payment.payment_method,
- recorded_by_id: payment.recorded_by_id,
- lines_count: lines_count
- )
- end
-
def success(payment:, payment_lines: [])
Result.new(success?: true, payment: payment, payment_lines: payment_lines, errors: [], message: "Payment created successfully")
end
@@ -170,15 +123,19 @@ def recorded_by_present
def validate_amount_requirements
if Array(payment_lines).any?
+ return if payment_method == "offered" && total_cents.to_i >= 0
+
errors.add(:total_cents, "must be greater than zero when payment lines are provided") if total_cents.blank? || total_cents.to_i <= 0
else
- errors.add(:amount_cents, "must be greater than zero") if amount_cents.blank? || amount_cents.to_i <= 0
+ unless payment_method == "offered" && amount_cents.to_i >= 0
+ errors.add(:amount_cents, "must be greater than zero") if amount_cents.blank? || amount_cents.to_i <= 0
+ end
errors.add(:item_type, "cannot be blank when no payment lines provided") if item_type.blank? && description.blank?
end
end
def validate_payment_lines_sum
- lines = Array(payment_lines)
+ lines = normalize_payment_lines
return if lines.empty?
lines_sum = lines.sum { |line| line[:amount_cents].to_i }
diff --git a/app/services/people/payment_recorder.rb b/app/services/people/payment_recorder.rb
new file mode 100644
index 00000000..422faa26
--- /dev/null
+++ b/app/services/people/payment_recorder.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+module People
+ class PaymentRecorder
+ include ActiveModel::Model
+ include ActiveModel::Attributes
+
+ Result = Struct.new(:success?, :payment, :payment_lines, :errors, :message, keyword_init: true)
+
+ attr_accessor :person, :recorded_by
+
+ attribute :person_id, :integer
+ attribute :recorded_by_id, :integer
+ attribute :total_cents, :integer
+ attribute :payment_method, :string, default: "cash"
+ attribute :status, :string, default: "success"
+ attribute :notes, :string
+ attribute :offer_reason, :string
+ attribute :payment_lines, default: []
+
+ validates :payment_method, presence: true, inclusion: { in: %w[cash card cheque transfer offered] }
+ validates :status, presence: true, inclusion: { in: %w[pending success cancel] }
+ validate :person_present
+ validate :recorded_by_present
+ validate :payment_lines_present
+ validate :total_matches_lines
+ validate :total_allowed_for_payment_method
+ validate :offer_reason_required_for_offered
+
+ def call
+ return failure("Invalid payment data: #{errors.full_messages.join(', ')}") unless valid?
+
+ target_person = resolve_person
+ recorder = resolve_recorded_by
+ normalized_lines = normalize_payment_lines
+
+ payment = nil
+ created_lines = []
+
+ ActiveRecord::Base.transaction do
+ payment = target_person.payments.create!(
+ total_cents: total_cents.to_i,
+ payment_method: payment_method,
+ status: status,
+ recorded_by: recorder,
+ notes: notes,
+ offer_reason: normalized_offer_reason
+ )
+
+ created_lines = normalized_lines.map do |line|
+ payment.payment_lines.create!(
+ item_type: line[:item_type],
+ item_id: item_id_for(line, payment),
+ amount_cents: line[:amount_cents].to_i,
+ description: line[:description]
+ )
+ end
+
+ instrument_payment_created(payment, created_lines.length)
+ end
+
+ success(payment: payment, payment_lines: created_lines)
+ rescue ActiveRecord::RecordNotFound => e
+ failure("Record not found: #{e.message}")
+ rescue ActiveRecord::RecordInvalid => e
+ failure("Validation error: #{e.message}")
+ rescue StandardError => e
+ Rails.logger.error("[People::PaymentRecorder] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
+ failure("Error creating payment: #{e.message}")
+ end
+
+ private
+
+ def resolve_person
+ return person if person.present?
+ raise ActiveRecord::RecordNotFound, "Person not found" if person_id.blank?
+
+ Person.find(person_id)
+ end
+
+ def resolve_recorded_by
+ return recorded_by if recorded_by.present?
+ return User.find(recorded_by_id) if recorded_by_id.present?
+
+ raise ActiveRecord::RecordNotFound, "Recorded_by user not provided" unless Current.respond_to?(:user) && Current.user.present?
+
+ Current.user
+ end
+
+ def normalize_payment_lines
+ Array(payment_lines).map do |line|
+ line = line.to_unsafe_h if line.respond_to?(:to_unsafe_h)
+ line.symbolize_keys
+ end
+ end
+
+ def item_id_for(line, payment)
+ return payment.id if donation_line?(line[:item_type])
+
+ line[:item_id]
+ end
+
+ def donation_line?(item_type)
+ item_type.to_s.casecmp("donation").zero?
+ end
+
+ def normalized_offer_reason
+ return if payment_method != "offered"
+
+ offer_reason.to_s.strip.presence
+ end
+
+ def instrument_payment_created(payment, lines_count)
+ ActiveSupport::Notifications.instrument(
+ "payment.created",
+ payment_id: payment.id,
+ person_id: payment.person_id,
+ total_cents: payment.total_cents,
+ payment_method: payment.payment_method,
+ recorded_by_id: payment.recorded_by_id,
+ lines_count: lines_count
+ )
+ end
+
+ def person_present
+ return if person.present? || person_id.present?
+
+ errors.add(:person, "must be provided")
+ end
+
+ def recorded_by_present
+ return if recorded_by.present? || recorded_by_id.present? || (Current.respond_to?(:user) && Current.user.present?)
+
+ errors.add(:recorded_by_id, "must be provided")
+ end
+
+ def payment_lines_present
+ errors.add(:payment_lines, "must include at least one line") if normalize_payment_lines.empty?
+ end
+
+ def total_matches_lines
+ lines = normalize_payment_lines
+ return if lines.empty?
+
+ lines_sum = lines.sum { |line| line[:amount_cents].to_i }
+ return if total_cents.to_i == lines_sum
+
+ errors.add(:payment_lines, "La somme des lignes (#{lines_sum} cents) ne correspond pas au total (#{total_cents.to_i} cents)")
+ end
+
+ def total_allowed_for_payment_method
+ return if total_cents.present? && total_cents.to_i.positive?
+ return if payment_method == "offered" && total_cents.to_i >= 0
+
+ errors.add(:total_cents, "must be greater than zero")
+ end
+
+ def offer_reason_required_for_offered
+ return unless payment_method == "offered"
+ return if normalized_offer_reason.present?
+
+ errors.add(:offer_reason, "must be provided for an offered payment")
+ end
+
+ def success(payment:, payment_lines:)
+ Result.new(success?: true, payment: payment, payment_lines: payment_lines, errors: [], message: "Payment created successfully")
+ end
+
+ def failure(message)
+ Result.new(success?: false, payment: nil, payment_lines: [], errors: [ message ], message: message)
+ end
+ end
+end
diff --git a/app/services/people/payment_updater.rb b/app/services/people/payment_updater.rb
index 369f9ad7..f1176e67 100644
--- a/app/services/people/payment_updater.rb
+++ b/app/services/people/payment_updater.rb
@@ -16,6 +16,7 @@ class PaymentUpdater
attribute :payment_method, :string
attribute :status, :string
attribute :notes, :string
+ attribute :offer_reason, :string
attribute :updated_by_id, :integer
validates :updated_by_id, presence: true
@@ -23,6 +24,7 @@ class PaymentUpdater
validates :total_cents, numericality: { greater_than: 0 }, allow_nil: true
validates :payment_method, inclusion: { in: %w[cash card cheque transfer offered] }, allow_nil: true
validates :status, inclusion: { in: %w[pending success cancel] }, allow_nil: true
+ validate :offer_reason_required_for_offered_update
def call
return failure("Invalid payment data: #{errors.full_messages.join(', ')}") unless valid?
@@ -74,6 +76,8 @@ def build_update_attributes
attrs[:payment_method] = payment_method if payment_method.present?
attrs[:status] = status if status.present?
attrs[:notes] = notes if notes.present?
+ attrs[:offer_reason] = normalized_offer_reason if should_update_offer_reason?
+ attrs[:offer_reason] = nil if clears_offer_reason?
end
end
@@ -81,6 +85,31 @@ def payment_identifier_present
errors.add(:payment_id, "must be provided") if payment.blank? && payment_id.blank?
end
+ def normalized_offer_reason
+ offer_reason.to_s.strip.presence
+ end
+
+ def should_update_offer_reason?
+ offer_reason.present? || payment_method == "offered"
+ end
+
+ def clears_offer_reason?
+ payment_method.present? && payment_method != "offered"
+ end
+
+ def resulting_payment_method
+ payment_method.presence || resolve_payment.payment_method
+ end
+
+ def offer_reason_required_for_offered_update
+ return unless resulting_payment_method == "offered"
+ return if normalized_offer_reason.present? || resolve_payment.offer_reason.present?
+
+ errors.add(:offer_reason, "must be provided for an offered payment")
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
+
def success(payment:, message:)
Result.new(success?: true, payment: payment, errors: [], message: message)
end
diff --git a/app/services/people/register.rb b/app/services/people/register.rb
index b84d1295..911f2dba 100644
--- a/app/services/people/register.rb
+++ b/app/services/people/register.rb
@@ -35,7 +35,7 @@ def call
person = person_result.person
- return failure("Cette personne a déjà un compte web.") if create_user_account && person.user.present?
+ return failure(I18n.t("services.errors.register_existing_web_account")) if create_user_account && person.user.present?
user = nil
if create_user_account
@@ -72,7 +72,7 @@ def call
end
rescue StandardError => e
Rails.logger.error("[People::Register] #{e.class}: #{e.message}\n#{e.backtrace.take(5).join("\n")}")
- failure("Registration failed: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "registration", message: e.message))
end
private
@@ -103,10 +103,10 @@ def failure_from(service_result)
def build_success_message(person_created, user_created, membership_created)
parts = []
- parts << (person_created ? "Person created" : "Person updated")
- parts << "user account" if user_created
- parts << "membership" if membership_created
- "Successfully processed #{parts.join(' and ')}".strip
+ parts << I18n.t(person_created ? "services.success.register_person_created" : "services.success.register_person_updated")
+ parts << I18n.t("services.success.register_user_account") if user_created
+ parts << I18n.t("services.success.register_membership") if membership_created
+ I18n.t("services.success.register_processed", parts: parts.join(I18n.t("services.success.register_parts_joiner")))
end
def failure(message, errors = [])
diff --git a/app/services/people/user_account_creator.rb b/app/services/people/user_account_creator.rb
index 78a64aef..3b6a461b 100644
--- a/app/services/people/user_account_creator.rb
+++ b/app/services/people/user_account_creator.rb
@@ -77,6 +77,7 @@ def create_user(target_person)
user.privacy_policy = privacy_policy
user.save!
+ user.welcome_send
success(
user: user,
diff --git a/app/services/profile_section_dom_ids.rb b/app/services/profile_section_dom_ids.rb
new file mode 100644
index 00000000..efd4cce3
--- /dev/null
+++ b/app/services/profile_section_dom_ids.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# Single source of truth for Turbo Stream targets on the profile/settings UI.
+# Keep controllers, layouts, and partial defaults aligned with these IDs.
+module ProfileSectionDomIds
+ CONTACT_SECTION = "profile_contact_section"
+ ACCOUNT_SECTION = "profile_account_section"
+ FLASH_FRAME = "flash"
+end
diff --git a/app/services/user_management/user_updater.rb b/app/services/user_management/user_updater.rb
index 4dff15ff..0056a14f 100644
--- a/app/services/user_management/user_updater.rb
+++ b/app/services/user_management/user_updater.rb
@@ -20,7 +20,7 @@ def call
updated_by = User.find(updated_by_id)
# Vérifier les permissions
- return failure("Insufficient permissions to update this user") unless can_update_user?(user, updated_by)
+ return failure(I18n.t("services.errors.insufficient_permissions.user_update")) unless can_update_user?(user, updated_by)
ActiveRecord::Base.transaction do
# Mettre à jour User (seulement si des attributs sont fournis)
@@ -30,17 +30,17 @@ def call
user_updated = user_attrs.empty? || user.update(user_attrs)
- # Mettre à jour Person si présente
+ # Mettre à jour Person si présente (tester person_attributes en premier pour éviter un load Person inutile)
person_updated = true
- if user.person.present? && person_attributes.present?
+ if person_attributes.present? && user.person.present?
user.person.skip_membership_validation = true
person_updated = user.person.update(person_attributes.except(:newsletter_subscribed))
end
- # Gérer newsletter si nécessaire
- if person_updated && user.person.present? && !newsletter_subscribed.nil?
+ # Gérer newsletter si nécessaire (nil d'abord pour ne pas charger Person quand la clé était absente de la requête)
+ if person_updated && !newsletter_subscribed.nil? && user.person.present?
newsletter_result = update_newsletter(user.person)
- return failure("Error updating newsletter: #{newsletter_result.message}") unless newsletter_result.success?
+ return failure(I18n.t("services.errors.newsletter_update_failed", message: newsletter_result.message)) unless newsletter_result.success?
end
if user_updated && person_updated
@@ -53,19 +53,19 @@ def call
changes: user.previous_changes.merge(user.person&.previous_changes || {})
)
- success(user: user, person: user.person, message: "User updated successfully")
+ success(user: user, person: user.person, message: I18n.t("services.success.user_updated"))
else
errors_array = []
errors_array.concat(user.errors.full_messages) if user.errors.any?
errors_array.concat(user.person.errors.full_messages) if user.person&.errors&.any?
- failure("Validation errors: #{errors_array.join(', ')}")
+ failure(I18n.t("services.validation.invalid_data_with_details", details: errors_array.join(", ")))
end
end
rescue ActiveRecord::RecordNotFound => e
- failure("User not found: #{e.message}")
+ failure(I18n.t("services.errors.user_not_found", message: e.message))
rescue StandardError => e
Rails.logger.error "[UserUpdater] Error: #{e.message}"
- failure("Error updating user: #{e.message}")
+ failure(I18n.t("services.errors.unexpected_error", action: "user update", message: e.message))
end
end
@@ -80,7 +80,7 @@ def can_update_user?(user, updated_by)
end
def update_newsletter(person)
- return success(message: "Newsletter update skipped (no email)") if person.email.blank?
+ return success(message: I18n.t("services.success.newsletter_update_skipped_no_email")) if person.email.blank?
updater = NewsletterManagement::NewsletterUpdater.new(
person_id: person.id,
diff --git a/app/views/admin/attendance_lists/edit.html.erb b/app/views/admin/attendance_lists/edit.html.erb
index 34e67cb0..f72743d5 100644
--- a/app/views/admin/attendance_lists/edit.html.erb
+++ b/app/views/admin/attendance_lists/edit.html.erb
@@ -1,7 +1,7 @@
<% content_for :title, "Modifier la liste de présence" %>
-
+
<%= render 'shared/breadcrumbs' %>
@@ -12,7 +12,7 @@
-
+
Informations de la liste
@@ -70,7 +70,7 @@
-
+
Zone de danger
@@ -88,4 +88,3 @@
-
diff --git a/app/views/admin/attendance_lists/index.html.erb b/app/views/admin/attendance_lists/index.html.erb
index acbbac64..08f24d9c 100644
--- a/app/views/admin/attendance_lists/index.html.erb
+++ b/app/views/admin/attendance_lists/index.html.erb
@@ -1,24 +1,24 @@
-<% content_for :title, "Gestion des listes de présence" %>
+<% content_for :title, "Registre de présence" %>
-
+
<%= render 'shared/breadcrumbs' %>
-
Listes de présence
+ Registre de présence
<%= link_to new_admin_attendance_list_path, class: "px-4 py-2 bg-[#1F5C55] hover:bg-[#194A45] text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] flex items-center" do %>
- Créer une nouvelle liste
+ Créer un registre
<% end %>
-
Gérez les listes de présence pour les formations, événements et réunions
+
Gérez les registres de présence du jour pour les formations, événements et réunions
-
+
@@ -117,7 +117,7 @@
<% if @attendance_list.present? %>
<% @attendance_list.each do |attendance_list| %>
-
+
@@ -188,7 +188,7 @@
<% end %>
<% else %>
-
+
Aucune liste de présence disponible
Créez une nouvelle liste pour commencer
@@ -196,4 +196,4 @@
<% end %>
-
\ No newline at end of file
+
diff --git a/app/views/admin/attendance_lists/new.html.erb b/app/views/admin/attendance_lists/new.html.erb
index c64bd22f..c44e3b31 100644
--- a/app/views/admin/attendance_lists/new.html.erb
+++ b/app/views/admin/attendance_lists/new.html.erb
@@ -1,25 +1,25 @@
-<% content_for :title, "Créer une nouvelle liste de présence" %>
+<% content_for :title, "Créer un registre de présence" %>
-
+
<%= render 'shared/breadcrumbs' %>
-
Nouvelle liste
-
Créez une liste pour gérer les présences
+
Nouveau registre de présence
+
Créez le registre de présence du jour
-
+
Informations
<%= form_with model: AttendanceList.new, url: admin_attendance_lists_path, method: :post, class: "space-y-4" do |form| %>
- <%= form.label :name, "Nom de la liste", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.label :name, "Nom du registre", class: "block text-sm font-medium text-gray-700" %>
-
\ No newline at end of file
+
diff --git a/app/views/admin/attendance_lists/show.html.erb b/app/views/admin/attendance_lists/show.html.erb
index c1132519..36730ac9 100644
--- a/app/views/admin/attendance_lists/show.html.erb
+++ b/app/views/admin/attendance_lists/show.html.erb
@@ -1,7 +1,7 @@
-<% content_for :title, "Détails de la liste de présence" %>
+<% content_for :title, "Registre de présence" %>
-
+
<%= render 'shared/breadcrumbs' %>
@@ -11,18 +11,18 @@
<%= @attendance_list.name %>
<%= link_to edit_admin_attendance_list_path(@attendance_list), class: "px-4 py-2 bg-[#1F5C55] hover:bg-[#194A45] text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55] flex items-center" do %>
- Modifier la liste
+ Modifier le registre
<% end %>
<%= link_to new_admin_attendance_list_attendance_path(@attendance_list), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 flex items-center" do %>
Ajouter un participant
<% end %>
-
Consultez les détails et les participants de cette liste de présence
+
Consultez les détails et les présences enregistrées pour ce jour
-
+
Informations
@@ -105,7 +105,7 @@
-
+
Participants
<%= link_to new_admin_attendance_list_attendance_path(@attendance_list), class: "text-[#1F5C55] hover:text-[#194A45] flex items-center text-sm" do %>
@@ -181,4 +181,4 @@
-
\ No newline at end of file
+
diff --git a/app/views/admin/attendances/index.html.erb b/app/views/admin/attendances/index.html.erb
index a8c53d94..a76dacd6 100644
--- a/app/views/admin/attendances/index.html.erb
+++ b/app/views/admin/attendances/index.html.erb
@@ -1,7 +1,7 @@
<% content_for :title, "Participants à la liste de présence" %>
-
+
<%= render 'shared/breadcrumbs' %>
@@ -24,7 +24,7 @@
-
+
<% if @users.present? %>
<% @users.each do |user| %>
@@ -65,4 +65,4 @@
<% end %>
-
\ No newline at end of file
+
diff --git a/app/views/admin/attendances/new.html.erb b/app/views/admin/attendances/new.html.erb
index 0e9809d4..4f83a24b 100644
--- a/app/views/admin/attendances/new.html.erb
+++ b/app/views/admin/attendances/new.html.erb
@@ -1,7 +1,7 @@
<% content_for :title, "Ajouter des participants" %>
-
+
<%= render 'shared/breadcrumbs' %>
@@ -31,7 +31,7 @@
-
+
Utilisateurs disponibles
<%= pluralize(@users_not_in_list.count, "utilisateur", "utilisateurs") %> disponible(s)
diff --git a/app/views/admin/subscription_plans/edit.html.erb b/app/views/admin/contribution_formulas/edit.html.erb
similarity index 86%
rename from app/views/admin/subscription_plans/edit.html.erb
rename to app/views/admin/contribution_formulas/edit.html.erb
index f2c36081..a7a7e8bd 100644
--- a/app/views/admin/subscription_plans/edit.html.erb
+++ b/app/views/admin/contribution_formulas/edit.html.erb
@@ -1,17 +1,17 @@
-
+
-
+
-
+
-
Informations du plan de cotisation
+ Informations de la formule
@@ -72,6 +72,15 @@
Seuls les adhérents Cirque peuvent acheter des cotisations
+
+ <%= form.label :rate_kind, "Type tarifaire", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.select :rate_kind,
+ options_for_select(ContributionFormula.rate_kind_options, @contribution_formula.rate_kind),
+ {},
+ { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-[#1F5C55] focus:ring-[#1F5C55] sm:text-sm" } %>
+
Doit rester cohérent avec le type d'adhésion requis pour éviter les écarts de catalogue.
+
+
<%= form.label :price_cents, "Prix (en centimes)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :price_cents,
@@ -101,7 +110,7 @@
- <%= link_to "Annuler", admin_subscription_plan_path(@contribution_formula),
+ <%= link_to "Annuler", admin_contribution_formula_path(@contribution_formula),
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %>
<%= form.submit "Mettre à jour",
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-[#1F5C55] hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %>
diff --git a/app/views/admin/subscription_plans/index.html.erb b/app/views/admin/contribution_formulas/index.html.erb
similarity index 90%
rename from app/views/admin/subscription_plans/index.html.erb
rename to app/views/admin/contribution_formulas/index.html.erb
index a53af1b9..9e4b81c1 100644
--- a/app/views/admin/subscription_plans/index.html.erb
+++ b/app/views/admin/contribution_formulas/index.html.erb
@@ -1,21 +1,21 @@
-
+
-
+
-
+
@@ -36,8 +36,11 @@
end %>">
<%= plan.duration.humanize %>
+
+ <%= plan.rate_kind_humanized %>
+
- <%= pluralize(plan.contributions.count, 'carnet') %>
+ <%= pluralize(plan.contributions.count, 'cotisation') %>
@@ -87,7 +90,7 @@
<% if Current.user&.system_role == 'super_admin' %>
- <%= link_to edit_admin_subscription_plan_path(plan),
+ <%= link_to edit_admin_contribution_formula_path(plan),
class: "inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors duration-200",
title: "Modifier" do %>
@@ -99,9 +102,9 @@
<% if plan.contributions.empty? %>
<% if Current.user&.system_role == 'super_admin' %>
- <%= button_to admin_subscription_plan_path(plan),
+ <%= button_to admin_contribution_formula_path(plan),
method: :delete,
- form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer ce plan de cotisation ?" } },
+ form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer cette formule de cotisation ?" } },
class: "inline-flex items-center px-3 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors duration-200",
title: "Supprimer" do %>
@@ -157,8 +160,11 @@
end %>">
<%= plan.duration.humanize %>
+
+ <%= plan.rate_kind_humanized %>
+
- <%= pluralize(plan.contributions.count, 'carnet') %>
+ <%= pluralize(plan.contributions.count, 'cotisation') %>
@@ -208,7 +214,7 @@
<% if Current.user&.system_role == 'super_admin' %>
- <%= link_to edit_admin_subscription_plan_path(plan),
+ <%= link_to edit_admin_contribution_formula_path(plan),
class: "inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors duration-200",
title: "Modifier" do %>
@@ -220,9 +226,9 @@
<% if plan.contributions.empty? %>
<% if Current.user&.system_role == 'super_admin' %>
- <%= button_to admin_subscription_plan_path(plan),
+ <%= button_to admin_contribution_formula_path(plan),
method: :delete,
- form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer ce plan de cotisation ?" } },
+ form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer cette formule de cotisation ?" } },
class: "inline-flex items-center px-3 py-2 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors duration-200",
title: "Supprimer" do %>
diff --git a/app/views/admin/contribution_formulas/new.html.erb b/app/views/admin/contribution_formulas/new.html.erb
new file mode 100644
index 00000000..acfaf706
--- /dev/null
+++ b/app/views/admin/contribution_formulas/new.html.erb
@@ -0,0 +1,248 @@
+<% content_for :title, "Nouvelle cotisation" %>
+
+
+
+ <%= render "shared/breadcrumbs" %>
+
+
+
+
Nouvelle cotisation
+
+ <% if @person %>
+ Pour <%= @person.full_name %>
+ <% else %>
+ Sélectionnez une formule de cotisation
+ <% end %>
+
+
+
+
+
+
+ <% if @person&.can_buy_contribution_formulas? %>
+ <% selected_formula = @contribution_formulas.first %>
+ <% current_membership = @person.current_membership %>
+ <% active_contributions = @person.contributions.active.includes(:contribution_formula).order(expires_at: :asc, created_at: :desc) %>
+ <% formula_label = lambda do |formula|
+ case formula.duration
+ when "day" then "Journée"
+ when "trimester" then "Trimestriel"
+ when "annual" then "Annuel"
+ when "pack10" then "Carnet 10"
+ else formula.name
+ end
+ end %>
+ <% formula_constraint = lambda do |formula|
+ if formula.duration == "day"
+ "Usage unique, valable jusqu'à la fin de la journée"
+ elsif formula.sessions_count.present? && formula.validity_days.present?
+ "#{formula.sessions_count} séances à utiliser sous #{formula.validity_days} jours"
+ elsif formula.duration == "trimester"
+ "Séances illimitées pendant 3 mois"
+ elsif formula.duration == "annual"
+ "Séances illimitées pendant 1 an"
+ else
+ formula.duration_humanized
+ end
+ end %>
+ <% formula_alert = lambda do |formula|
+ matching_active_contribution = active_contributions.find { |contribution| contribution.contribution_formula.duration == formula.duration }
+ next nil unless matching_active_contribution
+
+ case formula.duration
+ when "day"
+ "Déjà une journée active aujourd'hui"
+ when "pack10"
+ "Un carnet 10 est déjà disponible"
+ when "trimester"
+ "Un trimestriel est déjà actif"
+ when "annual"
+ "Un annuel est déjà actif"
+ end
+ end %>
+ <% contribution_alert = lambda do |contribution|
+ formula = contribution.contribution_formula
+
+ if formula.duration == "day"
+ "Journée active jusqu'au #{l(contribution.expires_at.to_date)}"
+ elsif contribution.is_pack10?
+ "Carnet 10 disponible : #{contribution.sessions_remaining} séances restantes"
+ elsif formula.duration == "trimester"
+ "Trimestriel actif jusqu'au #{l(contribution.expires_at.to_date)}"
+ elsif formula.duration == "annual"
+ "Annuel actif jusqu'au #{l(contribution.expires_at.to_date)}"
+ elsif contribution.expires_at.present?
+ "#{formula_label.call(formula)} actif jusqu'au #{l(contribution.expires_at.to_date)}"
+ else
+ "#{formula_label.call(formula)} actif"
+ end
+ end %>
+
+ <% if selected_formula.present? %>
+
+
+
+
+
Adhésion
+ <% if current_membership.present? %>
+
<%= current_membership.membership_type.name %>
+
Jusqu'au <%= l(current_membership.ended_at) %>
+ <% else %>
+
Aucune adhésion active
+ <% end %>
+
+
+ <% if @person.member_number.present? %>
+
+
N° membre
+
<%= @person.formatted_member_number %>
+
+ <% end %>
+
+
+
+
Cotisations en cours
+ <% if active_contributions.any? %>
+
+ <% active_contributions.each do |contribution| %>
+
+ <%= contribution_alert.call(contribution) %>
+
+ <% end %>
+
+ <% else %>
+
Aucune cotisation active
+ <% end %>
+
+
+
+
+ <%= form_with url: admin_contribution_formulas_path,
+ method: :post,
+ local: true,
+ scope: :contribution_formula,
+ class: "space-y-5",
+ data: { controller: "contribution-purchase offer-fields" } do |form| %>
+ <%= form.hidden_field :person_id, value: @person.id %>
+
+
+ Cotisation
+
+
+ <% @contribution_formulas.each_with_index do |contribution_formula, index| %>
+
+ <%= form.radio_button :contribution_formula_id,
+ contribution_formula.id,
+ checked: index.zero?,
+ class: "sr-only",
+ data: {
+ contribution_purchase_target: "formula",
+ action: "change->contribution-purchase#refresh",
+ formula_name: formula_label.call(contribution_formula),
+ formula_price: "#{contribution_formula.price_euros} €",
+ formula_constraint: formula_constraint.call(contribution_formula),
+ formula_alert: formula_alert.call(contribution_formula)
+ } %>
+
+
+
+
<%= formula_label.call(contribution_formula) %>
+
<%= formula_constraint.call(contribution_formula) %>
+ <% if formula_alert.call(contribution_formula).present? %>
+
<%= formula_alert.call(contribution_formula) %>
+ <% end %>
+
+
+
+
<%= contribution_formula.price_euros %> €
+
+
+
+ <% end %>
+
+
+
+
+
+
+
Paiement
+
+ <%= formula_label.call(selected_formula) %>
+ ·
+ <%= formula_constraint.call(selected_formula) %>
+
+
+
<%= selected_formula.price_euros %> €
+
+
+
+
+ <%= form.label :payment_method, "Mode de paiement", class: "mb-1 block text-sm font-medium text-gray-700" %>
+ <%= form.select :payment_method,
+ options_for_select(payment_method_options),
+ { selected: "cash" },
+ { class: "w-full rounded-md border-gray-300 text-sm focus:border-[#1F5C55] focus:ring-[#1F5C55]",
+ data: { offer_fields_target: "paymentMethod", action: "change->offer-fields#refresh" } } %>
+
+
+
+ <%= form.label :donation_amount, "Don additionnel (€)", class: "mb-1 block text-sm font-medium text-gray-700" %>
+ <%= form.number_field :donation_amount,
+ step: 0.01,
+ min: 0,
+ placeholder: "0.00",
+ class: "w-full rounded-md border-gray-300 text-sm focus:border-[#1F5C55] focus:ring-[#1F5C55]" %>
+
+
+
+
+ <%= form.label :custom_amount_cents, "Montant offert (€)", class: "mb-1 block text-sm font-medium text-gray-700" %>
+ <%= form.number_field :custom_amount_cents,
+ step: 0.01,
+ min: 0,
+ placeholder: "0.00",
+ class: "w-full rounded-md border-gray-300 text-sm focus:border-[#1F5C55] focus:ring-[#1F5C55]" %>
+
+
+
+ <%= form.label :offer_reason, "Raison de l'offre", class: "mb-1 block text-sm font-medium text-gray-700" %>
+ <%= form.text_area :offer_reason,
+ rows: 2,
+ placeholder: "Ex : bénévolat, geste commercial, partenariat...",
+ class: "w-full rounded-md border-gray-300 text-sm focus:border-[#1F5C55] focus:ring-[#1F5C55]",
+ data: { offer_fields_target: "offerReasonInput" } %>
+
+
+
+ <%= formula_alert.call(selected_formula) %>
+
+
+
+
Retour ensuite sur la fiche personne.
+ <%= form.submit "Acheter la cotisation",
+ class: "inline-flex items-center justify-center rounded-md border border-transparent bg-[#1F5C55] px-4 py-2 text-sm font-medium text-white hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-[#1F5C55] focus:ring-offset-2" %>
+
+
+ <% end %>
+ <% else %>
+
+
Aucune formule de cotisation active n'est disponible pour cette adhésion Cirque.
+
+ <% end %>
+ <% else %>
+
+
Cette personne doit avoir une adhésion Cirque pour acheter une formule de cotisation.
+
+ <% end %>
+
+ <% if @person.present? %>
+
+ <%= link_to "← Retour à la fiche",
+ admin_user_path(Admin::Users::PersonRouteKey.call(@person)),
+ class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50" %>
+
+ <% end %>
+
+
+
+
diff --git a/app/views/admin/subscription_plans/show.html.erb b/app/views/admin/contribution_formulas/show.html.erb
similarity index 84%
rename from app/views/admin/subscription_plans/show.html.erb
rename to app/views/admin/contribution_formulas/show.html.erb
index 877b2367..0ddcf739 100644
--- a/app/views/admin/subscription_plans/show.html.erb
+++ b/app/views/admin/contribution_formulas/show.html.erb
@@ -1,27 +1,27 @@
-
+
<%= render 'shared/breadcrumbs' %>
-
+
-
-
-
Informations du plan
+
+
+
Informations de la formule
@@ -30,8 +30,8 @@
-
Type de plan
-
+ Durée / type
+
<%= @contribution_formula.duration.humanize %>
+
+ <%= @contribution_formula.rate_kind_humanized %>
+
@@ -89,7 +92,7 @@
-
+
Carnets d'entrées associés
@@ -111,7 +114,7 @@
<% @contributions.each do |book| %>
- <%= link_to book.person.full_name, admin_user_path("person_#{book.person.id}"),
+ <%= link_to book.person.full_name, admin_user_path(Admin::Users::PersonRouteKey.call(book.person)),
class: "text-[#1F5C55] hover:underline" %>
@@ -133,22 +136,21 @@
<% else %>
-
Aucun carnet d'entrée n'a été créé avec ce plan.
+
Aucune cotisation n'a été créée avec cette formule.
<% end %>
- <%= link_to "Retour à la liste", admin_subscription_plans_path,
+ <%= link_to "Retour à la liste", admin_contribution_formulas_path,
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1F5C55]" %>
<% if Current.user&.system_role == 'super_admin' && @contribution_formula.contributions.empty? %>
- <%= button_to "Supprimer ce plan", admin_subscription_plan_path(@contribution_formula),
+ <%= button_to "Supprimer cette formule", admin_contribution_formula_path(@contribution_formula),
method: :delete,
- form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer ce plan de cotisation ?" } },
+ form: { data: { turbo_confirm: "Êtes-vous sûr de vouloir supprimer cette formule de cotisation ?" } },
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
-
diff --git a/app/views/admin/dashboard/_admin_lateral_navbar.html.erb b/app/views/admin/dashboard/_admin_lateral_navbar.html.erb
index e7a1e900..9dae3a53 100644
--- a/app/views/admin/dashboard/_admin_lateral_navbar.html.erb
+++ b/app/views/admin/dashboard/_admin_lateral_navbar.html.erb
@@ -42,7 +42,7 @@
<% end %>
<% if current_user.can_manage_attendance_lists? %>
<%= link_to admin_attendance_lists_path, class: "sidebar-link flex items-center" do %>
-
+
<% end %>
<% end %>
@@ -57,8 +57,8 @@
<% end %>
-
<%= link_to admin_subscription_plans_path, class: "sidebar-link flex items-center" do %>
-
+ <%= link_to admin_contribution_formulas_path, class: "sidebar-link flex items-center" do %>
+
<% end %>
<% end %>
@@ -93,4 +93,4 @@
-
\ No newline at end of file
+
diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb
index bed18d70..3ad54f95 100644
--- a/app/views/admin/dashboard/index.html.erb
+++ b/app/views/admin/dashboard/index.html.erb
@@ -1,12 +1,12 @@
<% content_for :title, "Tableau de bord administrateur" %>
-
+
<%= render 'shared/breadcrumbs' %>
-
+
Tableau de bord administrateur
@@ -37,7 +37,7 @@
- <%= link_to admin_users_path, class: "group bg-white rounded-lg shadow-lg p-4 border border-gray-200 hover:shadow-xl hover:border-[#1F5C55] transition-all duration-200 cursor-pointer" do %>
+ <%= link_to admin_users_path, class: "admin-metric-card group cursor-pointer hover:border-[#1F5C55] transition-all duration-200" do %>
Personnes
@@ -58,7 +58,7 @@
<% end %>
-
+
Utilisateurs
@@ -73,7 +73,7 @@
-
+
Adhésions Basic
@@ -87,7 +87,7 @@
-
+
Adhésions Circus
@@ -101,7 +101,7 @@
-
+
Adhésions actives
@@ -120,7 +120,7 @@
-
+
@@ -136,7 +136,7 @@
-
+
@@ -153,7 +153,7 @@
-
+
diff --git a/app/views/admin/donations/new.html.erb b/app/views/admin/donations/new.html.erb
index 85cd7fdc..f848386e 100644
--- a/app/views/admin/donations/new.html.erb
+++ b/app/views/admin/donations/new.html.erb
@@ -1,10 +1,10 @@
<% content_for :title, t("admin.donations.new.title") %>
-
+
<%= render "shared/breadcrumbs" %>
-
+
<%= t("admin.donations.new.heading") %>
@@ -32,7 +32,7 @@
<%= f.submit t("admin.donations.new.submit"),
class: "inline-flex justify-center rounded-md border border-transparent bg-[#1F5C55] px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-[#194A45] focus:outline-none focus:ring-2 focus:ring-[#1F5C55] focus:ring-offset-2" %>
<%= link_to t("admin.donations.new.cancel"),
- (@user.present? ? admin_user_path(@user) : admin_user_path("person_#{@person.id}")),
+ (@user.present? ? admin_user_path(@user) : admin_user_path(Admin::Users::PersonRouteKey.call(@person))),
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50" %>
<% end %>
diff --git a/app/views/admin/events/edit.html.erb b/app/views/admin/events/edit.html.erb
index 50d5331d..127681ff 100644
--- a/app/views/admin/events/edit.html.erb
+++ b/app/views/admin/events/edit.html.erb
@@ -1,8 +1,8 @@
-
+
<%= render 'shared/breadcrumbs' %>
-