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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
37
backend/app/services/billing/attach_payment_invoice.rb
Normal file
37
backend/app/services/billing/attach_payment_invoice.rb
Normal 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
|
||||
9
backend/app/services/billing/invoice_number.rb
Normal file
9
backend/app/services/billing/invoice_number.rb
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
40
backend/spec/services/billing/attach_payment_invoice_spec.rb
Normal file
40
backend/spec/services/billing/attach_payment_invoice_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user