Mostra in admin anagrafica e dati fiscali delle società.

Così Ops può emettere fattura dalla scheda club senza cercare il profilo solo tra i pagamenti pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 16:17:20 +02:00
co-authored by Cursor
parent adfea01cdb
commit d7dd744c32
15 changed files with 293 additions and 8 deletions
@@ -3,7 +3,7 @@ module Admin
before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote] before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
def index 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) .order(:name)
end end
@@ -63,7 +63,7 @@ module Admin
private private
def set_club def set_club
@club = Club.find(params[:id]) @club = Club.includes(club_memberships: :user).find(params[:id])
end end
def redirect_back_or_club(notice: nil, alert: nil) def redirect_back_or_club(notice: nil, alert: nil)
+8 -1
View File
@@ -21,7 +21,14 @@ class Club < ApplicationRecord
validates :secondary_color, presence: true validates :secondary_color, presence: true
def owner 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 end
def owned_by?(user) def owned_by?(user)
@@ -59,18 +59,43 @@ module ClubBillingProfile
def billing_profile_invoice_lines def billing_profile_invoice_lines
lines = [] lines = []
lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present? 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 << ["P.IVA", billing_vat_number] if billing_vat_number.present?
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.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? lines << ["Telefono", billing_phone] if billing_phone.present?
addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ") core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank
lines << ["Indirizzo", addr] if addr.present? 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 << ["SDI", billing_recipient_code] if billing_recipient_code.present?
lines << ["PEC", billing_pec] if billing_pec.present? lines << ["PEC", billing_pec] if billing_pec.present?
lines lines
end 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 private
def billing_profile_for_invoicing def billing_profile_for_invoicing
@@ -0,0 +1,45 @@
<% owner = club.owner %>
<% status = club.billing_profile_admin_status %>
<% lines = club.billing_profile_invoice_lines.select { |_label, value| value.present? } %>
<section class="panel admin-club-profile" style="margin:16px 0 24px">
<h3 style="font-size:1rem;margin:0 0 12px"><%= t("admin.clubs.show.profile_title") %></h3>
<h4 style="font-size:0.9rem;margin:0 0 8px;color:var(--muted)"><%= t("admin.clubs.show.owner_title") %></h4>
<% if owner %>
<dl class="billing-profile-dl" style="margin-bottom:16px">
<dt><%= t("admin.clubs.show.owner_name") %></dt>
<dd><%= owner.name %></dd>
<dt><%= t("admin.clubs.show.owner_email") %></dt>
<dd><%= mail_to owner.email %></dd>
</dl>
<% else %>
<p class="muted" style="margin:0 0 16px"><%= t("admin.clubs.show.owner_none") %></p>
<% end %>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
<h4 style="font-size:0.9rem;margin:0;color:var(--muted)"><%= t("admin.clubs.show.billing_title") %></h4>
<span class="admin-billing-status admin-billing-status--<%= status %>">
<%= t("admin.clubs.billing_status.#{status}") %>
</span>
</div>
<% if status == :absent || lines.empty? %>
<p class="muted" style="margin:0"><%= t("admin.clubs.show.billing_none") %></p>
<% else %>
<dl class="billing-profile-dl">
<% lines.each do |label, value| %>
<dt><%= label %></dt>
<dd><%= value %></dd>
<% end %>
</dl>
<% end %>
<% if status == :incomplete %>
<ul class="muted" style="margin:12px 0 0;padding-left:1.2rem;font-size:0.88rem">
<% club.billing_profile_errors.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
</section>
@@ -9,6 +9,7 @@
<th><%= t("admin.clubs.index.table.club") %></th> <th><%= t("admin.clubs.index.table.club") %></th>
<th><%= t("admin.clubs.index.table.plan") %></th> <th><%= t("admin.clubs.index.table.plan") %></th>
<th><%= t("admin.clubs.index.table.teams") %></th> <th><%= t("admin.clubs.index.table.teams") %></th>
<th><%= t("admin.clubs.index.table.billing_profile") %></th>
<th><%= t("admin.clubs.index.table.comped") %></th> <th><%= t("admin.clubs.index.table.comped") %></th>
<th><%= t("admin.clubs.index.table.stripe") %></th> <th><%= t("admin.clubs.index.table.stripe") %></th>
<th><%= t("admin.clubs.index.table.quote") %></th> <th><%= t("admin.clubs.index.table.quote") %></th>
@@ -18,10 +19,16 @@
<tbody> <tbody>
<% @clubs.each do |club| %> <% @clubs.each do |club| %>
<% sub = club.subscription %> <% sub = club.subscription %>
<% billing_status = club.billing_profile_admin_status %>
<tr> <tr>
<td><strong><%= club.name %></strong></td> <td><strong><%= club.name %></strong></td>
<td><%= sub&.plan&.name || t("admin.common.free_plan") %></td> <td><%= sub&.plan&.name || t("admin.common.free_plan") %></td>
<td><%= club.teams.size %></td> <td><%= club.teams.size %></td>
<td>
<span class="admin-billing-status admin-billing-status--<%= billing_status %>">
<%= t("admin.clubs.billing_status.#{billing_status}") %>
</span>
</td>
<td> <td>
<% if sub&.admin_comped? %> <% if sub&.admin_comped? %>
<span style="color:#ffb74d"><%= t("admin.common.yes") %></span> <span style="color:#ffb74d"><%= t("admin.common.yes") %></span>
@@ -8,6 +8,8 @@
· <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %> · <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %>
</p> </p>
<%= 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/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) %> <%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=19"> <link rel="stylesheet" href="/admin.css?v=20">
<%= yield :head %> <%= yield :head %>
<% if content_for?(:replay_archive_styles) %> <% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42"> <link rel="stylesheet" href="/marketing.css?v=42">
+13
View File
@@ -239,8 +239,10 @@ de:
club: Verein club: Verein
plan: Plan plan: Plan
teams: Teams teams: Teams
billing_profile: Rechnungsdaten
comped: Kostenlos comped: Kostenlos
stripe: Stripe stripe: Stripe
quote: Vereinbart
manage: Verwalten manage: Verwalten
invoices: Rechnungen invoices: Rechnungen
show: show:
@@ -254,6 +256,13 @@ de:
youtube_premium_full_not_connected: Premium Full — Vereinskanal nicht verbunden (nutzt Match Live TV) 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}." no_channel_hint_html: "Ohne verbundenen Kanal nutzt die App den Match-Live-TV-Kanal. %{link}."
link_channel: Kanal verbinden (Vereinsseite) 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 teams_title: Teams
no_teams: Keine Teams registriert. no_teams: Keine Teams registriert.
table: table:
@@ -265,6 +274,10 @@ de:
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief. 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_none: Keine Versuche für diesen Verein erfasst.
concurrency_all: Alle Konto-Missbräuche ansehen concurrency_all: Alle Konto-Missbräuche ansehen
billing_status:
complete: Vollständig
incomplete: Unvollständig
absent: Fehlend
comped: comped:
title: Kostenloses Abonnement title: Kostenloses Abonnement
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar." description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
+13
View File
@@ -239,8 +239,10 @@ en:
club: Club club: Club
plan: Plan plan: Plan
teams: Teams teams: Teams
billing_profile: Billing data
comped: Comp comped: Comp
stripe: Stripe stripe: Stripe
quote: Quoted
manage: Manage manage: Manage
invoices: Invoices invoices: Invoices
show: show:
@@ -254,6 +256,13 @@ en:
youtube_premium_full_not_connected: Premium Full — club channel not connected (using Match Live TV) 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}." no_channel_hint_html: "Without a connected channel, the app uses the Match Live TV channel. %{link}."
link_channel: Connect channel (club page) 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 teams_title: Teams
no_teams: No teams registered. no_teams: No teams registered.
table: table:
@@ -265,6 +274,10 @@ en:
concurrency_lead: Same account tried to start another live while one was already running. concurrency_lead: Same account tried to start another live while one was already running.
concurrency_none: No attempts recorded for this club. concurrency_none: No attempts recorded for this club.
concurrency_all: View all account abuse concurrency_all: View all account abuse
billing_status:
complete: Complete
incomplete: Incomplete
absent: Missing
comped: comped:
title: Complimentary subscription title: Complimentary subscription
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time." description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
+13
View File
@@ -239,8 +239,10 @@ es:
club: Club club: Club
plan: Plan plan: Plan
teams: Equipos teams: Equipos
billing_profile: Datos fiscales
comped: Cortesía comped: Cortesía
stripe: Stripe stripe: Stripe
quote: Acordado
manage: Gestionar manage: Gestionar
invoices: Facturas invoices: Facturas
show: show:
@@ -254,6 +256,13 @@ es:
youtube_premium_full_not_connected: Premium Full — canal del club no conectado (usa Match Live TV) 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}." 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) 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 teams_title: Equipos
no_teams: No hay equipos registrados. no_teams: No hay equipos registrados.
table: table:
@@ -265,6 +274,10 @@ es:
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso. 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_none: No hay intentos registrados para este club.
concurrency_all: Ver todos los abusos de cuenta concurrency_all: Ver todos los abusos de cuenta
billing_status:
complete: Completo
incomplete: Incompleto
absent: Ausentes
comped: comped:
title: Suscripción de cortesía title: Suscripción de cortesía
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento." description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
+13
View File
@@ -239,8 +239,10 @@ fr:
club: Club club: Club
plan: Forfait plan: Forfait
teams: Équipes teams: Équipes
billing_profile: Données fiscales
comped: Offert comped: Offert
stripe: Stripe stripe: Stripe
quote: Convenu
manage: Gérer manage: Gérer
invoices: Factures invoices: Factures
show: show:
@@ -254,6 +256,13 @@ fr:
youtube_premium_full_not_connected: Premium Full — chaîne du club non connectée (utilise Match Live TV) 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}." 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) 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 teams_title: Équipes
no_teams: Aucune équipe enregistrée. no_teams: Aucune équipe enregistrée.
table: table:
@@ -265,6 +274,10 @@ fr:
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours. concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours.
concurrency_none: Aucune tentative enregistrée pour ce club. concurrency_none: Aucune tentative enregistrée pour ce club.
concurrency_all: Voir tous les abus de compte concurrency_all: Voir tous les abus de compte
billing_status:
complete: Complet
incomplete: Incomplet
absent: Absentes
comped: comped:
title: Abonnement offert title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment." description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
+12
View File
@@ -243,6 +243,7 @@ it:
club: Società club: Società
plan: Piano plan: Piano
teams: Squadre teams: Squadre
billing_profile: Dati fiscali
comped: Omaggio comped: Omaggio
stripe: Stripe stripe: Stripe
quote: Concordato quote: Concordato
@@ -259,6 +260,13 @@ it:
youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV) youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV)
no_channel_hint_html: "Senza canale collegato, lapp usa il canale Match Live TV. %{link}." no_channel_hint_html: "Senza canale collegato, lapp usa il canale Match Live TV. %{link}."
link_channel: Collega canale (pagina società) 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 teams_title: Squadre
no_teams: Nessuna squadra registrata. no_teams: Nessuna squadra registrata.
table: table:
@@ -270,6 +278,10 @@ it:
concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una. concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una.
concurrency_none: Nessun tentativo registrato per questa società. concurrency_none: Nessun tentativo registrato per questa società.
concurrency_all: Vedi tutti gli abusi account concurrency_all: Vedi tutti gli abusi account
billing_status:
complete: Completo
incomplete: Incompleto
absent: Assenti
comped: comped:
title: Abbonamento omaggio title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento." description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
+29
View File
@@ -622,6 +622,35 @@ body.admin-body {
word-break: break-word; 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 { .billing-upload-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -41,4 +41,15 @@ RSpec.describe ClubBillingProfile do
club.billing_fiscal_code = "RSSMRA80A01H501U" club.billing_fiscal_code = "RSSMRA80A01H501U"
expect(club.billing_profile_complete?).to be true expect(club.billing_profile_complete?).to be true
end 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 end
@@ -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