From 964e3f472caf6ecf9ab3e60edc651d237aa6b6a6 Mon Sep 17 00:00:00 2001
From: Emiliano Frascaro
Date: Tue, 2 Jun 2026 17:31:49 +0200
Subject: [PATCH] Admin fatturazione: coda pagamenti senza PDF e upload
diretto.
Lista globale dei pagamenti da fatturare con dati intestazione, caricamento
PDF per riga che crea la fattura e invia email; rimuove il flusso bozza manuale.
Co-authored-by: Cursor
---
.../controllers/admin/billing_controller.rb | 28 ++++-
.../admin/billing_invoices_controller.rb | 31 +----
backend/app/models/billing/payment.rb | 35 ++++++
.../models/concerns/club_billing_profile.rb | 16 +++
.../billing/attach_payment_invoice.rb | 37 ++++++
.../app/services/billing/invoice_number.rb | 9 ++
.../app/views/admin/billing/index.html.erb | 109 ++++++++++++++----
backend/app/views/admin/teams/index.html.erb | 2 +-
backend/app/views/admin/teams/show.html.erb | 2 +-
backend/app/views/layouts/admin.html.erb | 2 +-
backend/config/routes.rb | 1 +
backend/public/admin.css | 83 +++++++++++++
.../billing/attach_payment_invoice_spec.rb | 40 +++++++
13 files changed, 341 insertions(+), 54 deletions(-)
create mode 100644 backend/app/services/billing/attach_payment_invoice.rb
create mode 100644 backend/app/services/billing/invoice_number.rb
create mode 100644 backend/spec/services/billing/attach_payment_invoice_spec.rb
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.