diff --git a/backend/app/controllers/admin/billing_controller.rb b/backend/app/controllers/admin/billing_controller.rb index d81a666..727c0a0 100644 --- a/backend/app/controllers/admin/billing_controller.rb +++ b/backend/app/controllers/admin/billing_controller.rb @@ -1,7 +1,33 @@ module Admin class BillingController < BaseController def index - @clubs = Club.order(:name).includes(:billing_payments, :billing_invoices) + @pending_payments = pending_scope.recent + @completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40) + @filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present? + @clubs = Club.order(:name) + end + + def attach_pdf + payment = Billing::Payment.find(params[:payment_id]) + Billing::AttachPaymentInvoice.call(payment: payment, pdf: params[:pdf]) + redirect_to admin_billing_path(billing_redirect_params(payment)), + notice: "Fattura caricata e inviata a #{payment.club.billing_email}." + rescue Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e + payment ||= Billing::Payment.find_by(id: params[:payment_id]) + redirect_to admin_billing_path(payment ? billing_redirect_params(payment) : {}), + alert: e.message + end + + private + + def pending_scope + scope = Billing::Payment.awaiting_invoice_pdf.includes(:club, invoice: { pdf_attachment: :blob }) + scope = scope.where(club_id: params[:club_id]) if params[:club_id].present? + scope + end + + def billing_redirect_params(payment) + { club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact end end end diff --git a/backend/app/controllers/admin/billing_invoices_controller.rb b/backend/app/controllers/admin/billing_invoices_controller.rb index ebefc07..185fca1 100644 --- a/backend/app/controllers/admin/billing_invoices_controller.rb +++ b/backend/app/controllers/admin/billing_invoices_controller.rb @@ -4,26 +4,11 @@ module Admin before_action :set_invoice, only: %i[edit update] def index - @payments = @club.billing_payments.recent.includes(:invoice) - @invoices = @club.billing_invoices.recent.includes(:billing_payment, pdf_attachment: :blob) + redirect_to admin_billing_path(club_id: @club.id) end def new - @payment = @club.billing_payments.find_by(id: params[:billing_payment_id]) if params[:billing_payment_id].present? - if @payment&.invoice.present? - redirect_to edit_admin_club_billing_invoice_path(@club, @payment.invoice), alert: "Esiste già una fattura per questo pagamento." - return - end - - @invoice = @club.billing_invoices.build( - issued_on: Date.current, - currency: "eur", - source: "manual", - status: "draft", - billing_payment: @payment, - amount_cents: @payment&.amount_cents, - number: suggested_invoice_number - ) + redirect_to admin_billing_path(club_id: @club.id), alert: "Carica il PDF dalla lista «Pagamenti da fatturare»." end def create @@ -33,7 +18,7 @@ module Admin if @invoice.save redirect_to edit_admin_club_billing_invoice_path(@club, @invoice), - notice: "Bozza fattura #{@invoice.number} creata. Carica il PDF e invia al cliente." + notice: "Bozza creata. Carica il PDF dalla pagina Fatturazione o qui sotto." else @payment = @invoice.billing_payment flash.now[:alert] = @invoice.errors.full_messages.join(", ") @@ -50,10 +35,10 @@ module Admin if issuing? Billing::IssueInvoice.call(invoice: @invoice, pdf: params.dig(:billing_invoice, :pdf)) - redirect_to admin_club_billing_invoices_path(@club), + redirect_to admin_billing_path(club_id: @club.id), notice: "Fattura #{@invoice.number} emessa e inviata a #{@club.billing_email}." elsif @invoice.save - redirect_to admin_club_billing_invoices_path(@club), notice: "Fattura #{@invoice.number} aggiornata." + redirect_to admin_billing_path(club_id: @club.id), notice: "Fattura #{@invoice.number} aggiornata." else @payment = @invoice.billing_payment flash.now[:alert] = @invoice.errors.full_messages.join(", ") @@ -79,12 +64,6 @@ module Admin params[:commit].to_s == "Emetti e invia via email" end - def suggested_invoice_number - year = Date.current.year - count = @club.billing_invoices.where("number LIKE ?", "%#{year}%").count + 1 - "#{year}/#{count}" - end - def invoice_params params.require(:billing_invoice).permit( :number, diff --git a/backend/app/models/billing/payment.rb b/backend/app/models/billing/payment.rb index d6abb05..51f89e5 100644 --- a/backend/app/models/billing/payment.rb +++ b/backend/app/models/billing/payment.rb @@ -14,6 +14,41 @@ module Billing scope :recent, -> { order(paid_at: :desc, created_at: :desc) } + # Pagamenti pagati senza PDF fattura caricato (da gestire in admin). + scope :awaiting_invoice_pdf, lambda { + where(status: "paid").where( + <<~SQL.squish, + NOT EXISTS ( + SELECT 1 FROM billing_invoices bi + INNER JOIN active_storage_attachments asa + ON asa.record_type = 'Billing::Invoice' + AND asa.record_id = bi.id + AND asa.name = 'pdf' + WHERE bi.billing_payment_id = billing_payments.id + ) + SQL + ) + } + + scope :with_invoice_pdf, lambda { + where(status: "paid").where( + <<~SQL.squish, + EXISTS ( + SELECT 1 FROM billing_invoices bi + INNER JOIN active_storage_attachments asa + ON asa.record_type = 'Billing::Invoice' + AND asa.record_id = bi.id + AND asa.name = 'pdf' + WHERE bi.billing_payment_id = billing_payments.id + ) + SQL + ) + } + + def awaiting_invoice_pdf? + status == "paid" && !invoice&.pdf&.attached? + end + def amount_euros amount_cents / 100.0 end diff --git a/backend/app/models/concerns/club_billing_profile.rb b/backend/app/models/concerns/club_billing_profile.rb index a6fa7c7..bee7527 100644 --- a/backend/app/models/concerns/club_billing_profile.rb +++ b/backend/app/models/concerns/club_billing_profile.rb @@ -53,6 +53,22 @@ module ClubBillingProfile parts.compact.join(" · ") end + # Righe per intestazione fattura in admin (etichetta, valore). + def billing_profile_invoice_lines + lines = [] + lines << ["Tipo", BILLING_ENTITY_TYPES[billing_entity_type]] if billing_entity_type.present? + lines << ["Intestatario", billing_legal_name] + 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 << ["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? + lines << ["SDI", billing_recipient_code] if billing_recipient_code.present? + lines << ["PEC", billing_pec] if billing_pec.present? + lines + end + private def billing_profile_for_invoicing diff --git a/backend/app/services/billing/attach_payment_invoice.rb b/backend/app/services/billing/attach_payment_invoice.rb new file mode 100644 index 0000000..6e5defe --- /dev/null +++ b/backend/app/services/billing/attach_payment_invoice.rb @@ -0,0 +1,37 @@ +module Billing + class AttachPaymentInvoice + class Error < StandardError; end + + def self.call(payment:, pdf:) + new(payment: payment, pdf: pdf).call + end + + def initialize(payment:, pdf:) + @payment = payment + @pdf = pdf + end + + def call + raise Error, "Seleziona un file PDF" if @pdf.blank? + raise Error, "Pagamento non valido" unless @payment.status == "paid" + + club = @payment.club + invoice = @payment.invoice || build_invoice!(club) + IssueInvoice.call(invoice: invoice, pdf: @pdf) + end + + private + + def build_invoice!(club) + club.billing_invoices.create!( + number: InvoiceNumber.next_for(club), + issued_on: @payment.paid_at&.to_date || Date.current, + amount_cents: @payment.amount_cents, + currency: @payment.currency.presence || "eur", + status: "draft", + source: "manual", + billing_payment: @payment + ) + end + end +end diff --git a/backend/app/services/billing/invoice_number.rb b/backend/app/services/billing/invoice_number.rb new file mode 100644 index 0000000..a11272a --- /dev/null +++ b/backend/app/services/billing/invoice_number.rb @@ -0,0 +1,9 @@ +module Billing + class InvoiceNumber + def self.next_for(club) + year = Date.current.year + count = club.billing_invoices.where("number LIKE ?", "%#{year}%").count + 1 + "#{year}/#{count}" + end + end +end diff --git a/backend/app/views/admin/billing/index.html.erb b/backend/app/views/admin/billing/index.html.erb index 6ba831a..e547685 100644 --- a/backend/app/views/admin/billing/index.html.erb +++ b/backend/app/views/admin/billing/index.html.erb @@ -1,31 +1,92 @@ -

Pagamenti e fatture

+

Pagamenti da fatturare

- Elenco società: pagamenti da Stripe e fatture PDF da emettere o inviare al cliente. + Pagamenti Stripe pagati senza PDF fattura. Genera il PDF nei tuoi sistemi, poi caricalo qui: viene associato al pagamento e inviato via email al cliente.

- - - - - - - - - - +<% if @clubs.many? %> +

+ Filtra società: + <%= link_to "Tutte", admin_billing_path, class: (@filter_club ? nil : "admin-nav-active") %> <% @clubs.each do |club| %> -

- - - - - + · <%= link_to club.name, admin_billing_path(club_id: club.id), class: (@filter_club&.id == club.id ? "admin-nav-active" : nil) %> <% end %> - -
SocietàPagamentiFatture
<%= club.name %><%= club.billing_payments.size %><%= club.billing_invoices.size %><%= link_to "Apri", admin_club_billing_invoices_path(club), class: "admin-btn admin-btn--sm" %>
- -<% if @clubs.empty? %> -

Nessuna società registrata.

+

<% end %> -

<%= link_to "← Dashboard", admin_root_path %>

+<% if @pending_payments.any? %> +
+ <% @pending_payments.each do |payment| %> + <% club = payment.club %> +
+
+
+ <%= club.name %> + · <%= (payment.paid_at || payment.created_at).to_date.strftime("%d/%m/%Y") %> + · <%= payment.display_description %> + · <%= payment.formatted_amount %> +
+ <% unless club.billing_profile_complete? %> + Dati fatturazione incompleti + <% end %> +
+ +
+ <% if club.billing_profile_invoice_lines.any? %> +
+ <% club.billing_profile_invoice_lines.each do |label, value| %> +
<%= label %>
+
<%= value %>
+ <% end %> +
+ <% else %> +

Nessun dato di fatturazione — il cliente deve completare il profilo.

+ <% end %> + + <%= form_with url: admin_billing_payment_attach_pdf_path(payment), + method: :post, multipart: true, local: true, class: "billing-upload-form" do %> + + <%= submit_tag "Carica PDF e invia al cliente", class: "admin-btn admin-btn--primary" %> + <% end %> +
+
+ <% end %> +
+<% else %> +

+ Nessun pagamento in attesa di fattura<%= @filter_club ? " per #{@filter_club.name}" : "" %>. +

+<% end %> + +<% if @completed_payments.any? %> +

Fatture già caricate

+ + + + + + + + + + + + + <% @completed_payments.each do |payment| %> + <% inv = payment.invoice %> + + + + + + + + + <% end %> + +
DataSocietàDescrizioneImportoFatturaStato
<%= payment.paid_at&.to_date || payment.created_at.to_date %><%= payment.club.name %><%= payment.display_description %><%= payment.formatted_amount %><%= inv&.display_number %><%= inv&.display_status %>
+<% end %> + +

<%= link_to "← Dashboard", admin_root_path %>

diff --git a/backend/app/views/admin/teams/index.html.erb b/backend/app/views/admin/teams/index.html.erb index 68e15bd..767e11f 100644 --- a/backend/app/views/admin/teams/index.html.erb +++ b/backend/app/views/admin/teams/index.html.erb @@ -10,7 +10,7 @@ <%= t.youtube_credential.present? ? "✓" : "—" %> <% if t.club %> - <%= link_to "Pagamenti", admin_club_billing_invoices_path(t.club) %> + <%= link_to "Fatturazione", admin_billing_path(club_id: t.club.id) %> <% else %> — <% end %> diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb index 102c1c0..99e2b9b 100644 --- a/backend/app/views/admin/teams/show.html.erb +++ b/backend/app/views/admin/teams/show.html.erb @@ -1,7 +1,7 @@

<%= @team.name %>

Sport: <%= @team.sport %>

<% if @team.club %> -

Società: <%= @team.club.name %> · <%= link_to "Fatture società", admin_club_billing_invoices_path(@team.club) %>

+

Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %>

<% end %>

YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %>

diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index b45b46a..170ada4 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -4,7 +4,7 @@ Match Live TV Admin - + <% if controller_name == "dashboard" %> diff --git a/backend/config/routes.rb b/backend/config/routes.rb index baae948..5390985 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -63,6 +63,7 @@ Rails.application.routes.draw do root to: "dashboard#index" get "metrics", to: "dashboard#metrics" get "billing", to: "billing#index", as: :billing + post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf resources :teams, only: %i[index show] resources :clubs, only: [] do resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices" diff --git a/backend/public/admin.css b/backend/public/admin.css index 33a599c..b0ea784 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -285,3 +285,86 @@ body.admin-body { font-size: 0.85rem; margin-bottom: 0.25rem; } + +.billing-pending-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.billing-pending-card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 12px; + overflow: hidden; +} + +.billing-pending-card__head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.5rem 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--card-border); + font-size: 0.95rem; +} + +.billing-pending-card__warn { + color: #ffb74d; + font-size: 0.85rem; +} + +.billing-pending-card__body { + padding: 1rem 1.25rem 1.25rem; + display: grid; + gap: 1.25rem; +} + +@media (min-width: 900px) { + .billing-pending-card__body { + grid-template-columns: 1fr minmax(220px, 280px); + align-items: start; + } +} + +.billing-profile-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.35rem 1rem; + margin: 0; + font-size: 0.88rem; +} + +.billing-profile-dl dt { + color: var(--muted); + margin: 0; +} + +.billing-profile-dl dd { + margin: 0; + word-break: break-word; +} + +.billing-upload-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.billing-upload-form__label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.88rem; + color: var(--muted); +} + +.billing-upload-form input[type="file"] { + font-size: 0.85rem; + color: var(--text); +} + +.admin-nav-active { + color: var(--red) !important; + font-weight: 600; +} diff --git a/backend/spec/services/billing/attach_payment_invoice_spec.rb b/backend/spec/services/billing/attach_payment_invoice_spec.rb new file mode 100644 index 0000000..3c27630 --- /dev/null +++ b/backend/spec/services/billing/attach_payment_invoice_spec.rb @@ -0,0 +1,40 @@ +require "rails_helper" + +RSpec.describe Billing::AttachPaymentInvoice do + let!(:club) do + Club.create!( + name: "Attach Club", sport: "volleyball", + primary_color: "#e53935", secondary_color: "#ffffff", + billing_entity_type: "company", billing_legal_name: "ASD Attach", + billing_email: "fatture@attach.test", billing_address_line: "Via 1", + billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100", + billing_country: "IT", billing_vat_number: "12345678901", + billing_recipient_code: "ABCDEFG" + ) + end + let!(:payment) do + club.billing_payments.create!( + amount_cents: 500, currency: "eur", status: "paid", paid_at: Time.current, + description: "Premium Light — €5/mese" + ) + end + let(:pdf) do + { io: StringIO.new("%PDF-1.4 test"), filename: "fattura.pdf", content_type: "application/pdf" } + end + + it "crea fattura, allega PDF e invia email" do + mail = instance_double(ActionMailer::MessageDelivery, deliver_now: true) + allow(Billing::InvoiceMailer).to receive(:with).and_return( + instance_double(Billing::InvoiceMailer, invoice_pdf: mail) + ) + + expect { + described_class.call(payment: payment, pdf: pdf) + }.to change { club.billing_invoices.count }.by(1) + + inv = payment.reload.invoice + expect(inv.pdf).to be_attached + expect(inv.status).to eq("sent") + expect(Billing::InvoiceMailer).to have_received(:with).with(invoice: inv) + end +end