diff --git a/backend/app/controllers/admin/clubs_controller.rb b/backend/app/controllers/admin/clubs_controller.rb index b39e06d..2e564ea 100644 --- a/backend/app/controllers/admin/clubs_controller.rb +++ b/backend/app/controllers/admin/clubs_controller.rb @@ -3,7 +3,7 @@ module Admin before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote] def index - @clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by]) + @clubs = Club.includes(:teams, :billing_quote, { club_memberships: :user }, subscription: %i[plan admin_comped_by]) .order(:name) end @@ -63,7 +63,7 @@ module Admin private def set_club - @club = Club.find(params[:id]) + @club = Club.includes(club_memberships: :user).find(params[:id]) end def redirect_back_or_club(notice: nil, alert: nil) diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb index bcb4f0f..af092ac 100644 --- a/backend/app/models/club.rb +++ b/backend/app/models/club.rb @@ -21,7 +21,14 @@ class Club < ApplicationRecord validates :secondary_color, presence: true def owner - club_memberships.find_by(role: "owner")&.user + memberships = club_memberships + membership = + if memberships.loaded? + memberships.detect { |m| m.role == "owner" } + else + memberships.find_by(role: "owner") + end + membership&.user end def owned_by?(user) diff --git a/backend/app/models/concerns/club_billing_profile.rb b/backend/app/models/concerns/club_billing_profile.rb index 3c558ca..12a78f7 100644 --- a/backend/app/models/concerns/club_billing_profile.rb +++ b/backend/app/models/concerns/club_billing_profile.rb @@ -59,18 +59,43 @@ module ClubBillingProfile def billing_profile_invoice_lines lines = [] lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present? - lines << ["Intestatario", billing_legal_name] + lines << ["Intestatario", billing_legal_name] if billing_legal_name.present? lines << ["P.IVA", billing_vat_number] if billing_vat_number.present? lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present? - lines << ["Email fatturazione", billing_email] + lines << ["Email fatturazione", billing_email] if billing_email.present? lines << ["Telefono", billing_phone] if billing_phone.present? - addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ") - lines << ["Indirizzo", addr] if addr.present? + core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank + if core_address.any? + core_address << billing_country if billing_country.present? + lines << ["Indirizzo", core_address.join(", ")] + end lines << ["SDI", billing_recipient_code] if billing_recipient_code.present? lines << ["PEC", billing_pec] if billing_pec.present? lines end + # Stato profilo fiscale per badge admin: :complete | :incomplete | :absent + def billing_profile_admin_status + return :complete if billing_profile_complete? + return :absent unless billing_profile_started? + + :incomplete + end + + def billing_profile_started? + billing_legal_name.present? || + billing_vat_number.present? || + billing_fiscal_code.present? || + billing_email.present? || + billing_phone.present? || + billing_address_line.present? || + billing_city.present? || + billing_postal_code.present? || + billing_province.present? || + billing_recipient_code.present? || + billing_pec.present? + end + private def billing_profile_for_invoicing diff --git a/backend/app/views/admin/clubs/_billing_profile.html.erb b/backend/app/views/admin/clubs/_billing_profile.html.erb new file mode 100644 index 0000000..67db45a --- /dev/null +++ b/backend/app/views/admin/clubs/_billing_profile.html.erb @@ -0,0 +1,45 @@ +<% owner = club.owner %> +<% status = club.billing_profile_admin_status %> +<% lines = club.billing_profile_invoice_lines.select { |_label, value| value.present? } %> + +
+

<%= t("admin.clubs.show.profile_title") %>

+ +

<%= t("admin.clubs.show.owner_title") %>

+ <% if owner %> +
+
<%= t("admin.clubs.show.owner_name") %>
+
<%= owner.name %>
+
<%= t("admin.clubs.show.owner_email") %>
+
<%= mail_to owner.email %>
+
+ <% else %> +

<%= t("admin.clubs.show.owner_none") %>

+ <% end %> + +
+

<%= t("admin.clubs.show.billing_title") %>

+ + <%= t("admin.clubs.billing_status.#{status}") %> + +
+ + <% if status == :absent || lines.empty? %> +

<%= t("admin.clubs.show.billing_none") %>

+ <% else %> +
+ <% lines.each do |label, value| %> +
<%= label %>
+
<%= value %>
+ <% end %> +
+ <% end %> + + <% if status == :incomplete %> + + <% end %> +
diff --git a/backend/app/views/admin/clubs/index.html.erb b/backend/app/views/admin/clubs/index.html.erb index 61e2d95..a18bd1d 100644 --- a/backend/app/views/admin/clubs/index.html.erb +++ b/backend/app/views/admin/clubs/index.html.erb @@ -9,6 +9,7 @@ <%= t("admin.clubs.index.table.club") %> <%= t("admin.clubs.index.table.plan") %> <%= t("admin.clubs.index.table.teams") %> + <%= t("admin.clubs.index.table.billing_profile") %> <%= t("admin.clubs.index.table.comped") %> <%= t("admin.clubs.index.table.stripe") %> <%= t("admin.clubs.index.table.quote") %> @@ -18,10 +19,16 @@ <% @clubs.each do |club| %> <% sub = club.subscription %> + <% billing_status = club.billing_profile_admin_status %> <%= club.name %> <%= sub&.plan&.name || t("admin.common.free_plan") %> <%= club.teams.size %> + + + <%= t("admin.clubs.billing_status.#{billing_status}") %> + + <% if sub&.admin_comped? %> <%= t("admin.common.yes") %> diff --git a/backend/app/views/admin/clubs/show.html.erb b/backend/app/views/admin/clubs/show.html.erb index d048d8e..47280e9 100644 --- a/backend/app/views/admin/clubs/show.html.erb +++ b/backend/app/views/admin/clubs/show.html.erb @@ -8,6 +8,8 @@ · <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %>

+<%= render "admin/clubs/billing_profile", club: @club %> + <%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> <%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %> diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index 81f05c4..865e86b 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -5,7 +5,7 @@ <%= csrf_meta_tags %> - + <%= yield :head %> <% if content_for?(:replay_archive_styles) %> diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml index 157a101..ad6ec88 100644 --- a/backend/config/locales/admin.de.yml +++ b/backend/config/locales/admin.de.yml @@ -239,8 +239,10 @@ de: club: Verein plan: Plan teams: Teams + billing_profile: Rechnungsdaten comped: Kostenlos stripe: Stripe + quote: Vereinbart manage: Verwalten invoices: Rechnungen show: @@ -254,6 +256,13 @@ de: youtube_premium_full_not_connected: Premium Full — Vereinskanal nicht verbunden (nutzt Match Live TV) no_channel_hint_html: "Ohne verbundenen Kanal nutzt die App den Match-Live-TV-Kanal. %{link}." link_channel: Kanal verbinden (Vereinsseite) + profile_title: Kontaktdaten & Rechnungsdaten + owner_title: Kontoinhaber + owner_name: Name + owner_email: E-Mail + owner_none: Kein Inhaber zugeordnet. + billing_title: Rechnungsdaten + billing_none: Keine Rechnungsdaten hinterlegt. teams_title: Teams no_teams: Keine Teams registriert. table: @@ -265,6 +274,10 @@ de: concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief. concurrency_none: Keine Versuche für diesen Verein erfasst. concurrency_all: Alle Konto-Missbräuche ansehen + billing_status: + complete: Vollständig + incomplete: Unvollständig + absent: Fehlend comped: title: Kostenloses Abonnement description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar." diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml index 040891d..2794675 100644 --- a/backend/config/locales/admin.en.yml +++ b/backend/config/locales/admin.en.yml @@ -239,8 +239,10 @@ en: club: Club plan: Plan teams: Teams + billing_profile: Billing data comped: Comp stripe: Stripe + quote: Quoted manage: Manage invoices: Invoices show: @@ -254,6 +256,13 @@ en: youtube_premium_full_not_connected: Premium Full — club channel not connected (using Match Live TV) no_channel_hint_html: "Without a connected channel, the app uses the Match Live TV channel. %{link}." link_channel: Connect channel (club page) + profile_title: Contact & billing details + owner_title: Account owner + owner_name: Name + owner_email: Email + owner_none: No owner associated. + billing_title: Billing details + billing_none: No billing details provided. teams_title: Teams no_teams: No teams registered. table: @@ -265,6 +274,10 @@ en: concurrency_lead: Same account tried to start another live while one was already running. concurrency_none: No attempts recorded for this club. concurrency_all: View all account abuse + billing_status: + complete: Complete + incomplete: Incomplete + absent: Missing comped: title: Complimentary subscription description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time." diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml index 2c9fad1..52d578f 100644 --- a/backend/config/locales/admin.es.yml +++ b/backend/config/locales/admin.es.yml @@ -239,8 +239,10 @@ es: club: Club plan: Plan teams: Equipos + billing_profile: Datos fiscales comped: Cortesía stripe: Stripe + quote: Acordado manage: Gestionar invoices: Facturas show: @@ -254,6 +256,13 @@ es: youtube_premium_full_not_connected: Premium Full — canal del club no conectado (usa Match Live TV) no_channel_hint_html: "Sin un canal conectado, la app usa el canal de Match Live TV. %{link}." link_channel: Conectar canal (página del club) + profile_title: Contacto y datos fiscales + owner_title: Titular de la cuenta + owner_name: Nombre + owner_email: Correo + owner_none: No hay titular asociado. + billing_title: Datos de facturación + billing_none: No hay datos de facturación. teams_title: Equipos no_teams: No hay equipos registrados. table: @@ -265,6 +274,10 @@ es: concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso. concurrency_none: No hay intentos registrados para este club. concurrency_all: Ver todos los abusos de cuenta + billing_status: + complete: Completo + incomplete: Incompleto + absent: Ausentes comped: title: Suscripción de cortesía description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento." diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml index 5ca6df1..87a798b 100644 --- a/backend/config/locales/admin.fr.yml +++ b/backend/config/locales/admin.fr.yml @@ -239,8 +239,10 @@ fr: club: Club plan: Forfait teams: Équipes + billing_profile: Données fiscales comped: Offert stripe: Stripe + quote: Convenu manage: Gérer invoices: Factures show: @@ -254,6 +256,13 @@ fr: youtube_premium_full_not_connected: Premium Full — chaîne du club non connectée (utilise Match Live TV) no_channel_hint_html: "Sans chaîne connectée, l'application utilise la chaîne Match Live TV. %{link}." link_channel: Connecter la chaîne (page du club) + profile_title: Coordonnées et données fiscales + owner_title: Titulaire du compte + owner_name: Nom + owner_email: E-mail + owner_none: Aucun titulaire associé. + billing_title: Données de facturation + billing_none: Aucune donnée de facturation renseignée. teams_title: Équipes no_teams: Aucune équipe enregistrée. table: @@ -265,6 +274,10 @@ fr: concurrency_lead: Le même compte a tenté de démarrer un autre direct alors qu’un était déjà en cours. concurrency_none: Aucune tentative enregistrée pour ce club. concurrency_all: Voir tous les abus de compte + billing_status: + complete: Complet + incomplete: Incomplet + absent: Absentes comped: title: Abonnement offert description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment." diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml index cb7a3f4..507afe8 100644 --- a/backend/config/locales/admin.it.yml +++ b/backend/config/locales/admin.it.yml @@ -243,6 +243,7 @@ it: club: Società plan: Piano teams: Squadre + billing_profile: Dati fiscali comped: Omaggio stripe: Stripe quote: Concordato @@ -259,6 +260,13 @@ it: youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV) no_channel_hint_html: "Senza canale collegato, l’app usa il canale Match Live TV. %{link}." link_channel: Collega canale (pagina società) + profile_title: Anagrafica e dati fiscali + owner_title: Titolare account + owner_name: Nome + owner_email: Email + owner_none: Nessun titolare associato. + billing_title: Dati di fatturazione + billing_none: Nessun dato di fatturazione compilato. teams_title: Squadre no_teams: Nessuna squadra registrata. table: @@ -270,6 +278,10 @@ it: concurrency_lead: Stesso account che ha provato ad avviare un’altra diretta mentre ne era già in corso una. concurrency_none: Nessun tentativo registrato per questa società. concurrency_all: Vedi tutti gli abusi account + billing_status: + complete: Completo + incomplete: Incompleto + absent: Assenti comped: title: Abbonamento omaggio description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento." diff --git a/backend/public/admin.css b/backend/public/admin.css index 81242ff..a826d77 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -622,6 +622,35 @@ body.admin-body { word-break: break-word; } +.admin-billing-status { + display: inline-block; + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0.15rem 0.5rem; + border-radius: 999px; + border: 1px solid #3a3a45; + color: #ccc; +} + +.admin-billing-status--complete { + border-color: #2e7d32; + color: #a5f0b8; + background: #1b3d1b; +} + +.admin-billing-status--incomplete { + border-color: #f9a825; + color: #ffe082; + background: #3d3210; +} + +.admin-billing-status--absent { + border-color: #555; + color: #999; + background: #1a1a22; +} + .billing-upload-form { display: flex; flex-direction: column; diff --git a/backend/spec/models/club_billing_profile_spec.rb b/backend/spec/models/club_billing_profile_spec.rb index 5caf949..3859009 100644 --- a/backend/spec/models/club_billing_profile_spec.rb +++ b/backend/spec/models/club_billing_profile_spec.rb @@ -41,4 +41,15 @@ RSpec.describe ClubBillingProfile do club.billing_fiscal_code = "RSSMRA80A01H501U" expect(club.billing_profile_complete?).to be true end + + it "classifies admin billing status" do + expect(club.billing_profile_admin_status).to eq(:complete) + + club.billing_recipient_code = nil + club.billing_pec = nil + expect(club.billing_profile_admin_status).to eq(:incomplete) + + empty = Club.create!(name: "Empty", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") + expect(empty.billing_profile_admin_status).to eq(:absent) + end end diff --git a/backend/spec/requests/admin/clubs_billing_profile_spec.rb b/backend/spec/requests/admin/clubs_billing_profile_spec.rb new file mode 100644 index 0000000..686a2ec --- /dev/null +++ b/backend/spec/requests/admin/clubs_billing_profile_spec.rb @@ -0,0 +1,95 @@ +require "rails_helper" + +RSpec.describe "Admin clubs billing profile", type: :request do + let!(:admin) { AdminAccount.create!(username: "ops-clubs-billing", password: "Password123") } + + let!(:complete_club) do + Club.create!( + name: "Club Completo", + sport: "volleyball", + primary_color: "#e53935", + secondary_color: "#ffffff", + billing_entity_type: "company", + billing_legal_name: "ASD Completo", + billing_email: "fatture@completo.it", + billing_address_line: "Via Roma 1", + billing_city: "Milano", + billing_province: "MI", + billing_postal_code: "20100", + billing_country: "IT", + billing_vat_number: "12345678901", + billing_recipient_code: "ABCDEFG" + ) + end + + let!(:incomplete_club) do + Club.create!( + name: "Club Incompleto", + sport: "volleyball", + primary_color: "#e53935", + secondary_color: "#ffffff", + billing_entity_type: "company", + billing_legal_name: "ASD Incompleto", + billing_vat_number: "10987654321" + ) + end + + let!(:absent_club) do + Club.create!( + name: "Club Assente", + sport: "volleyball", + primary_color: "#e53935", + secondary_color: "#ffffff" + ) + end + + let!(:owner) do + user = User.create!(email: "owner-completo@test.it", name: "Mario Rossi", password: "Password123", role: "coach") + ClubMembership.create!(user: user, club: complete_club, role: "owner") + user + end + + before do + post admin_login_path, params: { username: "ops-clubs-billing", password: "Password123" } + end + + it "mostra i dati fiscali e il titolare nella scheda società" do + get admin_club_path(complete_club) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Anagrafica e dati fiscali") + expect(response.body).to include("Mario Rossi") + expect(response.body).to include("owner-completo@test.it") + expect(response.body).to include("ASD Completo") + expect(response.body).to include("12345678901") + expect(response.body).to include("ABCDEFG") + expect(response.body).to include("Completo") + end + + it "mostra lo stato incompleto con errori mancanti" do + get admin_club_path(incomplete_club) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Incompleto") + expect(response.body).to include("ASD Incompleto") + expect(response.body).to include("10987654321") + end + + it "mostra lo stato assente quando non ci sono dati fiscali" do + get admin_club_path(absent_club) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Assenti") + expect(response.body).to include("Nessun dato di fatturazione compilato.") + end + + it "elenca lo stato del profilo fiscale nella lista società" do + get admin_clubs_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Dati fiscali") + expect(response.body).to include("Completo") + expect(response.body).to include("Incompleto") + expect(response.body).to include("Assenti") + end +end