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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:31:49 +02:00
parent b073ded6c1
commit 964e3f472c
13 changed files with 341 additions and 54 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,31 +1,92 @@
<h2>Pagamenti e fatture</h2>
<h2>Pagamenti da fatturare</h2>
<p style="color:#666;margin-bottom:16px">
Elenco società: pagamenti da Stripe e fatture PDF da emettere o inviare al cliente.
Pagamenti Stripe <strong>pagati</strong> senza PDF fattura. Genera il PDF nei tuoi sistemi, poi caricalo qui: viene associato al pagamento e inviato via email al cliente.
</p>
<table class="admin-table">
<% if @clubs.many? %>
<p style="margin-bottom:16px">
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 %>
</p>
<% end %>
<% if @pending_payments.any? %>
<div class="billing-pending-list">
<% @pending_payments.each do |payment| %>
<% club = payment.club %>
<article id="payment-<%= payment.id %>" class="billing-pending-card">
<header class="billing-pending-card__head">
<div>
<strong><%= club.name %></strong>
· <%= (payment.paid_at || payment.created_at).to_date.strftime("%d/%m/%Y") %>
· <%= payment.display_description %>
· <strong><%= payment.formatted_amount %></strong>
</div>
<% unless club.billing_profile_complete? %>
<span class="billing-pending-card__warn">Dati fatturazione incompleti</span>
<% end %>
</header>
<div class="billing-pending-card__body">
<% if club.billing_profile_invoice_lines.any? %>
<dl class="billing-profile-dl">
<% club.billing_profile_invoice_lines.each do |label, value| %>
<dt><%= label %></dt>
<dd><%= value %></dd>
<% end %>
</dl>
<% else %>
<p class="billing-pending-card__warn">Nessun dato di fatturazione — il cliente deve completare il profilo.</p>
<% end %>
<%= form_with url: admin_billing_payment_attach_pdf_path(payment),
method: :post, multipart: true, local: true, class: "billing-upload-form" do %>
<label class="billing-upload-form__label">
PDF fattura
<%= file_field_tag :pdf, accept: "application/pdf", required: true %>
</label>
<%= submit_tag "Carica PDF e invia al cliente", class: "admin-btn admin-btn--primary" %>
<% end %>
</div>
</article>
<% end %>
</div>
<% else %>
<p class="admin-flash" style="background:#1b3d1b;border-color:#2e7d32">
Nessun pagamento in attesa di fattura<%= @filter_club ? " per #{@filter_club.name}" : "" %>.
</p>
<% end %>
<% if @completed_payments.any? %>
<h2 style="margin-top:40px;font-size:1.15rem">Fatture già caricate</h2>
<table class="admin-table">
<thead>
<tr>
<th>Data</th>
<th>Società</th>
<th>Pagamenti</th>
<th>Fatture</th>
<th></th>
<th>Descrizione</th>
<th>Importo</th>
<th>Fattura</th>
<th>Stato</th>
</tr>
</thead>
<tbody>
<% @clubs.each do |club| %>
<% @completed_payments.each do |payment| %>
<% inv = payment.invoice %>
<tr>
<td><strong><%= club.name %></strong></td>
<td><%= club.billing_payments.size %></td>
<td><%= club.billing_invoices.size %></td>
<td><%= link_to "Apri", admin_club_billing_invoices_path(club), class: "admin-btn admin-btn--sm" %></td>
<td><%= payment.paid_at&.to_date || payment.created_at.to_date %></td>
<td><%= payment.club.name %></td>
<td><%= payment.display_description %></td>
<td><%= payment.formatted_amount %></td>
<td><%= inv&.display_number %></td>
<td><%= inv&.display_status %></td>
</tr>
<% end %>
</tbody>
</table>
<% if @clubs.empty? %>
<p>Nessuna società registrata.</p>
</table>
<% end %>
<p style="margin-top:20px"><%= link_to "← Dashboard", admin_root_path %></p>
<p style="margin-top:24px"><%= link_to "← Dashboard", admin_root_path %></p>

View File

@@ -10,7 +10,7 @@
<td><%= t.youtube_credential.present? ? "✓" : "—" %></td>
<td>
<% 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 %>

View File

@@ -1,7 +1,7 @@
<h2><%= @team.name %></h2>
<p>Sport: <%= @team.sport %></p>
<% if @team.club %>
<p>Società: <%= @team.club.name %> · <%= link_to "Fatture società", admin_club_billing_invoices_path(@team.club) %></p>
<p>Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p>
<% end %>
<p>YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %></p>

View File

@@ -4,7 +4,7 @@
<title>Match Live TV Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=1">
<link rel="stylesheet" href="/admin.css?v=2">
<% if controller_name == "dashboard" %>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-dashboard.js?v=1" defer></script>

View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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