diff --git a/Gemfile b/Gemfile
index cd802296..915bd8a0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -55,6 +55,11 @@ gem 'hexapdf', '~> 1.4.0'
# Money management [https://github.com/RubyMoney/money-rails]
gem 'money-rails', '~> 2.0.0'
+# Accounting management
+gem 'chartkick', '~> 5.2'
+gem 'csv', '~> 3.3'
+gem 'scenic', '~> 1.8'
+
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem 'debug', platforms: [:mri, :windows], require: 'debug/prelude'
diff --git a/Gemfile.lock b/Gemfile.lock
index 89afea97..c04e5890 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -98,6 +98,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
+ chartkick (5.2.1)
childprocess (5.1.0)
logger (~> 1.5)
cmdparse (3.0.7)
@@ -108,6 +109,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
+ csv (3.3.5)
date (3.4.1)
debug (1.11.0)
irb (~> 1.10)
@@ -381,6 +383,9 @@ GEM
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (1.13.0)
rubyzip (2.4.1)
+ scenic (1.9.0)
+ activerecord (>= 4.0.0)
+ railties (>= 4.0.0)
securerandom (0.4.1)
selenium-webdriver (4.34.0)
base64 (~> 0.2)
@@ -460,6 +465,8 @@ DEPENDENCIES
brakeman (~> 7.0)
cancancan (~> 3.6)
capybara (~> 3.40)
+ chartkick (~> 5.2)
+ csv (~> 3.3)
debug
guard (~> 2.19)
guard-minitest (~> 2.4)
@@ -485,6 +492,7 @@ DEPENDENCIES
rubocop-minitest (~> 0.38)
rubocop-performance (~> 1.25)
rubocop-rails (~> 2.32)
+ scenic (~> 1.8)
selenium-webdriver
simplecov (~> 0.22.0)
simplecov-cobertura (~> 3.0)
@@ -522,6 +530,7 @@ CHECKSUMS
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
cancancan (3.6.1) sha256=975c1d5cbf58d5df48a9452a7f61ae3d254608cd87570402f5925a8864c56b62
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
+ chartkick (5.2.1) sha256=2848d7de87189f30f28d077eb0bbdebc8a1f0f6f81de1ded95008fe564369949
childprocess (5.1.0) sha256=9a8d484be2fd4096a0e90a0cd3e449a05bc3aa33f8ac9e4d6dcef6ac1455b6ec
cmdparse (3.0.7) sha256=f7c5cace10bec6abf853370ae095e4b97a84ed9d847b3fb38f41cc4fbc950739
coderay (1.1.3) sha256=dc530018a4684512f8f38143cd2a096c9f02a1fc2459edcfe534787a7fc77d4b
@@ -529,6 +538,7 @@ CHECKSUMS
connection_pool (2.5.3) sha256=cfd74a82b9b094d1ce30c4f1a346da23ee19dc8a062a16a85f58eab1ced4305b
crack (1.0.0) sha256=c83aefdb428cdc7b66c7f287e488c796f055c0839e6e545fec2c7047743c4a49
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
+ csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
date (3.4.1) sha256=bf268e14ef7158009bfeaec40b5fa3c7271906e88b196d958a89d4b408abe64f
debug (1.11.0) sha256=1425db64cfa0130c952684e3dc974985be201dd62899bf4bbe3f8b5d6cf1aef2
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
@@ -631,6 +641,7 @@ CHECKSUMS
rubocop-rails (2.32.0) sha256=9fcc623c8722fe71e835e99c4a18b740b5b0d3fb69915d7f0777f00794b30490
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
+ scenic (1.9.0) sha256=6eec3aa049ba2a137e7eb0b4c4f0200b77f7765fc82e08ad44c01da7905563d9
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.34.0) sha256=ec7bb718cbe66fe2b247d8ca5e6ba26caed0976d76579d7cb2fadd8dae8b271e
shellany (0.0.1) sha256=0e127a9132698766d7e752e82cdac8250b6adbd09e6c0a7fbbb6f61964fedee7
@@ -663,4 +674,4 @@ CHECKSUMS
zeitwerk (2.7.3) sha256=b2e86b4a9b57d26ba68a15230dcc7fe6f040f06831ce64417b0621ad96ba3e85
BUNDLED WITH
- 4.0.3
+ 4.0.3
diff --git a/app/assets/stylesheets/admin/accounting.css b/app/assets/stylesheets/admin/accounting.css
new file mode 100644
index 00000000..bdb3d950
--- /dev/null
+++ b/app/assets/stylesheets/admin/accounting.css
@@ -0,0 +1,323 @@
+/* Accounting Dashboard Styles */
+
+.accounting-dashboard {
+ padding: 2rem 0;
+ width: 100%;
+}
+
+.accounting-container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 0 1rem;
+}
+
+.dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+}
+
+.dashboard-header h1 {
+ margin: 0;
+ font-size: 2rem;
+}
+
+.period-selector {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.period-form {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.period-select {
+ padding: 0.5rem 1rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.custom-date-inputs {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+}
+
+.date-input {
+ padding: 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+.date-range {
+ color: #666;
+ margin-bottom: 2rem;
+ font-size: 0.95rem;
+}
+
+/* KPI Cards */
+.kpi-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 3rem;
+}
+
+.kpi-card {
+ background: white;
+ border-radius: 8px;
+ padding: 1.5rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ display: flex;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+.kpi-icon {
+ font-size: 2.5rem;
+ line-height: 1;
+}
+
+.kpi-content {
+ flex: 1;
+}
+
+.kpi-label {
+ font-size: 0.875rem;
+ color: #666;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.kpi-value {
+ font-size: 1.75rem;
+ font-weight: bold;
+ color: #333;
+ margin-bottom: 0.25rem;
+}
+
+.kpi-change {
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.kpi-change.positive {
+ color: #10b981;
+}
+
+.kpi-change.negative {
+ color: #ef4444;
+}
+
+.kpi-meta {
+ font-size: 0.875rem;
+ color: #888;
+}
+
+/* Dashboard Sections */
+.dashboard-section {
+ background: white;
+ border-radius: 8px;
+ padding: 2rem;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ margin-bottom: 2rem;
+}
+
+.dashboard-section.full-width {
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.dashboard-section h2 {
+ margin-top: 0;
+ margin-bottom: 1.5rem;
+ font-size: 1.5rem;
+ color: #333;
+}
+
+.dashboard-section h3 {
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+ font-size: 1.2rem;
+ color: #333;
+}
+
+/* Two Column Layout */
+.two-column-layout {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ gap: 2rem;
+ margin-bottom: 2rem;
+}
+
+@media (max-width: 900px) {
+ .two-column-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Charts */
+.chart-container {
+ margin-bottom: 2rem;
+ min-height: 300px;
+ max-height: 400px;
+ position: relative;
+}
+
+.chart-container canvas {
+ max-height: 400px !important;
+}
+
+/* Tables */
+.table-wrapper {
+ overflow-x: auto;
+ width: 100%;
+ max-width: 100%;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 1rem;
+ table-layout: auto;
+ min-width: 600px;
+}
+
+.dashboard-section table {
+ max-width: 100%;
+}
+
+.data-table thead {
+ background-color: #f8f9fa;
+}
+
+.data-table th,
+.data-table td {
+ padding: 0.75rem 1rem;
+ text-align: left;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.data-table th {
+ font-weight: 600;
+ color: #374151;
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.data-table td {
+ color: #1f2937;
+}
+
+.data-table tbody tr:hover {
+ background-color: #f9fafb;
+}
+
+.data-table.compact th,
+.data-table.compact td {
+ padding: 0.5rem 0.75rem;
+}
+
+/* Badge */
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: white;
+ background-color: #3b82f6;
+ border-radius: 4px;
+ margin-left: 0.5rem;
+}
+
+/* Metrics Grid */
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.metric-item {
+ text-align: center;
+ padding: 1rem;
+ background-color: #f8f9fa;
+ border-radius: 6px;
+}
+
+.metric-label {
+ font-size: 0.875rem;
+ color: #666;
+ margin-bottom: 0.5rem;
+}
+
+.metric-value {
+ font-size: 1.75rem;
+ font-weight: bold;
+ color: #333;
+}
+
+/* No Data Message */
+.no-data {
+ text-align: center;
+ color: #888;
+ padding: 2rem;
+ font-style: italic;
+}
+
+/* Buttons */
+.btn {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ border-radius: 4px;
+ font-size: 1rem;
+ text-decoration: none;
+ cursor: pointer;
+ border: none;
+ transition: all 0.2s;
+}
+
+.btn-primary {
+ background-color: #3b82f6;
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: #2563eb;
+}
+
+.btn-secondary {
+ background-color: #6b7280;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #4b5563;
+}
+
+/* Responsive Adjustments */
+@media (max-width: 768px) {
+ .kpi-cards {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .period-selector {
+ width: 100%;
+ }
+}
diff --git a/app/controllers/admin/accounting_controller.rb b/app/controllers/admin/accounting_controller.rb
new file mode 100644
index 00000000..daa0c37e
--- /dev/null
+++ b/app/controllers/admin/accounting_controller.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+module Admin
+ class AccountingController < ApplicationController
+ before_action :set_date_range
+ before_action :init_query
+
+ def index
+ authorize! :manage, :all
+
+ @kpis = @query.kpis
+ @revenue_data = @query.revenue_by_date
+ @payment_methods_data = @query.payment_methods
+ @top_items_data = @query.top_items
+ @customer_metrics = @query.customer_metrics
+ @sales_by_seller = @query.sales_by_seller
+ @recent_sales = recent_sales_list
+ end
+
+ def export_csv
+ authorize! :manage, :all
+
+ send_data generate_csv_data,
+ filename: "rezoleo_accounting_export_#{@start_date.to_date}_#{@end_date.to_date}.csv",
+ type: 'text/csv'
+ end
+
+ private
+
+ def init_query
+ @query = AccountingQuery.new(
+ start_date: @start_date,
+ end_date: @end_date
+ )
+ end
+
+ def recent_sales_list
+ Sale.where(created_at: @start_date..@end_date)
+ .where.not(verified_at: nil)
+ .includes(:client, :seller, :payment_method,
+ :articles_sales, :sales_subscription_offers,
+ articles: [], subscription_offers: [])
+ .order(created_at: :desc)
+ .limit(5)
+ .map do |sale|
+ {
+ id: sale.id,
+ date: sale.created_at,
+ client: sale.client.display_name,
+ seller: sale.seller&.display_name || 'N/A',
+ payment_method: sale.payment_method.name,
+ total: sale.total_price
+ }
+ end
+ end
+
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
+ def set_date_range
+ @period = params[:period] || 'current_month'
+
+ @start_date, @end_date =
+ case @period
+ when 'last_month'
+ [1.month.ago.beginning_of_month, 1.month.ago.end_of_month]
+ when 'last_30_days'
+ [30.days.ago.beginning_of_day, Time.zone.now.end_of_day]
+ when 'current_year'
+ [Time.zone.now.beginning_of_year, Time.zone.now.end_of_year]
+ when 'last_year'
+ [1.year.ago.beginning_of_year, 1.year.ago.end_of_year]
+ when 'all_time'
+ [Sale.minimum(:created_at) || Time.zone.now, Time.zone.now]
+ when 'custom'
+ [
+ params[:start_date].present? ? Time.zone.parse(params[:start_date]) : Time.zone.now.beginning_of_month,
+ params[:end_date].present? ? Time.zone.parse(params[:end_date]) : Time.zone.now.end_of_month
+ ]
+ else
+ [Time.zone.now.beginning_of_month, Time.zone.now.end_of_month]
+ end
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
+
+ # rubocop:disable Metrics/MethodLength
+ def generate_csv_data
+ CSV.generate(headers: true) do |csv|
+ csv << csv_headers
+
+ sanitized_query = ActiveRecord::Base.sanitize_sql_array([
+ Rails.root.join('app/queries/csv_export_query.sql').read,
+ { start_date: @start_date, end_date: @end_date }
+ ])
+
+ results = ActiveRecord::Base.connection.execute(sanitized_query)
+
+ results.each do |row|
+ csv << [
+ row['date'].to_datetime.strftime('%Y-%m-%d %H:%M'),
+ row['sale_id'],
+ row['client'],
+ row['seller'],
+ row['payment_method'],
+ row['item_type'],
+ row['item_name'],
+ row['quantity'],
+ format_cents(row['unit_price_cents']),
+ format_cents(row['line_total_cents']),
+ format_cents(row['sale_total_cents'])
+ ]
+ end
+ end
+ end
+ # rubocop:enable Metrics/MethodLength
+
+ def csv_headers
+ ['Date', 'Sale ID', 'Client', 'Seller', 'Payment Method',
+ 'Item Type', 'Item Name', 'Quantity', 'Unit Price', 'Line Total', 'Sale Total']
+ end
+
+ def format_cents(cents)
+ format('%.2f', cents.to_i / 100.0)
+ end
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 68be8f29..af92954a 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -4,7 +4,7 @@ class UsersController < ApplicationController
protect_from_forgery unless: -> { request.format.json? }
def index
- @users = User.accessible_by(current_ability)
+ @users = User.accessible_by(current_ability).includes(:valid_subscriptions_by_date, :free_accesses_by_date)
end
def show
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 0d7b4940..0f73d0f4 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,3 +1,5 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
+import "chartkick"
+import "Chart.bundle"
diff --git a/app/models/sales_with_total.rb b/app/models/sales_with_total.rb
new file mode 100644
index 00000000..fad1c6ea
--- /dev/null
+++ b/app/models/sales_with_total.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class SalesWithTotal < ApplicationRecord
+ self.primary_key = :id
+
+ belongs_to :client, class_name: 'User'
+ belongs_to :seller, class_name: 'User', optional: true
+ belongs_to :payment_method
+ attribute :total_cents
+
+ def readonly?
+ true
+ end
+
+ def total_price
+ Money.new(total_cents)
+ end
+
+ def articles_total
+ Money.new(articles_total_cents)
+ end
+
+ def subscriptions_total
+ Money.new(subscriptions_total_cents)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 0f0b2ec5..19957183 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -8,6 +8,7 @@ class User < ApplicationRecord
}, dependent: :destroy, class_name: 'FreeAccess', inverse_of: :user
has_many :sales_as_client, class_name: 'Sale', foreign_key: 'client_id', dependent: :destroy, inverse_of: :client
has_many :sales_as_seller, class_name: 'Sale', foreign_key: 'seller_id', dependent: :nullify, inverse_of: :seller
+ has_many :sales, class_name: 'Sale', foreign_key: 'client_id', dependent: :destroy, inverse_of: :client
has_many :refunds, foreign_key: 'refunder_id', dependent: :destroy, inverse_of: :refunder
has_many :subscriptions, through: :sales_as_client, dependent: :destroy
has_many :valid_subscriptions_by_date, lambda {
diff --git a/app/queries/accounting_query.rb b/app/queries/accounting_query.rb
new file mode 100644
index 00000000..8ea46172
--- /dev/null
+++ b/app/queries/accounting_query.rb
@@ -0,0 +1,238 @@
+# frozen_string_literal: true
+
+# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Style/MultilineBlockChain
+class AccountingQuery
+ def initialize(start_date:, end_date:)
+ @start_date = start_date
+ @end_date = end_date
+ end
+
+ def verified_sales
+ SalesWithTotal
+ .where(created_at: @start_date..@end_date)
+ .where.not(verified_at: nil)
+ end
+
+ def kpis
+ total_revenue_cents = verified_sales.sum(:total_cents)
+
+ transaction_count = verified_sales.count
+
+ avg_transaction = transaction_count.positive? ? total_revenue_cents / transaction_count : 0
+
+ subscription_stats = subscription_stats_sql
+ previous_revenue_cents = previous_period_revenue
+
+ growth_rate =
+ if previous_revenue_cents.positive?
+ ((total_revenue_cents - previous_revenue_cents).to_f / previous_revenue_cents * 100).round(2)
+ else
+ 0
+ end
+
+ avg_price_per_month =
+ subscription_stats[:months].positive? ? subscription_stats[:revenue] / subscription_stats[:months] : 0
+
+ {
+ total_revenue: Money.new(total_revenue_cents),
+ transaction_count: transaction_count,
+ avg_transaction_value: Money.new(avg_transaction),
+ growth_rate: growth_rate,
+ total_months_sold: subscription_stats[:months],
+ avg_price_per_month: Money.new(avg_price_per_month)
+ }
+ end
+
+ def revenue_by_date
+ raw = verified_sales
+ .group('DATE(sales_with_totals.created_at)')
+ .sum(:total_cents)
+
+ (@start_date.to_date..@end_date.to_date).index_with do |date|
+ raw[date] ? raw[date] / 100.0 : 0.0
+ end
+ end
+
+ def payment_methods
+ verified_sales
+ .joins(:payment_method)
+ .group('payment_methods.id', 'payment_methods.name', 'payment_methods.auto_verify')
+ .select(
+ 'payment_methods.name AS name',
+ 'payment_methods.auto_verify AS auto_verified',
+ 'COUNT(sales_with_totals.id) AS count',
+ 'SUM(sales_with_totals.total_cents) AS amount'
+ )
+ .order('amount DESC')
+ .map do |r|
+ {
+ name: r.name,
+ count: r.count.to_i,
+ amount: Money.new(r.amount),
+ amount_chart: r.amount.to_f / 100.0,
+ avg_value: r.count.to_i.positive? ? Money.new(r.amount) / r.count.to_i : Money.zero,
+ auto_verified: r.auto_verified
+ }
+ end
+ end
+
+ def top_items
+ items = article_items.merge(subscription_items) do |_k, a, b|
+ {
+ name: a[:name],
+ type: a[:type],
+ quantity: a[:quantity] + b[:quantity],
+ revenue: a[:revenue] + b[:revenue]
+ }
+ end
+
+ total_revenue = items.values.sum { |i| i[:revenue].cents }
+
+ items.values
+ .map do |i|
+ i[:percentage] =
+ total_revenue.positive? ? (i[:revenue].cents.to_f / total_revenue * 100).round(2) : 0
+ i
+ end
+ .sort_by { |i| -i[:revenue].cents }
+ .first(10)
+ end
+
+ def customer_metrics
+ total_customers = verified_sales.select(:client_id).distinct.count
+
+ new_customers = User
+ .joins('INNER JOIN sales_with_totals ON sales_with_totals.client_id = users.id')
+ .where.not(sales_with_totals: { verified_at: nil })
+ .group('users.id')
+ .having('MIN(sales_with_totals.created_at) >= ?', @start_date)
+ .count
+ .size
+
+ revenues = verified_sales
+ .group(:client_id)
+ .sum(:total_cents)
+
+ avg_ltv = revenues.any? ? Money.new(revenues.values.sum / revenues.size) : Money.zero
+
+ top_customer_ids = revenues.sort_by { |_client_id, total| -total }.first(10).map(&:first)
+ clients = User.where(id: top_customer_ids).index_by(&:id)
+
+ transaction_counts = verified_sales
+ .where(client_id: top_customer_ids)
+ .group(:client_id)
+ .count
+
+ top_customers = top_customer_ids.map do |client_id|
+ {
+ name: clients[client_id].display_name,
+ revenue: Money.new(revenues[client_id]),
+ transaction_count: transaction_counts[client_id] || 0
+ }
+ end
+
+ {
+ new_customers: new_customers,
+ total_customers: total_customers,
+ avg_lifetime_value: avg_ltv,
+ top_customers: top_customers
+ }
+ end
+
+ def sales_by_seller
+ results = verified_sales
+ .joins(:seller)
+ .group(:seller_id)
+ .select(
+ 'sales_with_totals.seller_id AS seller_id',
+ 'COUNT(sales_with_totals.id) AS count',
+ 'SUM(sales_with_totals.total_cents) AS revenue'
+ )
+ .order('revenue DESC')
+ .first(8)
+
+ seller_ids = results.map(&:seller_id)
+ sellers = User.where(id: seller_ids).index_by(&:id)
+
+ results.map do |r|
+ {
+ name: sellers[r.seller_id].display_name,
+ count: r.count.to_i,
+ revenue: Money.new(r.revenue)
+ }
+ end
+ end
+
+ private
+
+ def subscription_stats_sql
+ row = SalesSubscriptionOffer
+ .joins(:sale, :subscription_offer)
+ .where(sales: { created_at: @start_date..@end_date })
+ .where.not(sales: { verified_at: nil })
+ .select(
+ 'SUM(sales_subscription_offers.quantity * subscription_offers.duration) AS months',
+ 'SUM(sales_subscription_offers.quantity * subscription_offers.price_cents) AS revenue'
+ )
+ .take
+
+ {
+ months: row&.months.to_i,
+ revenue: row&.revenue.to_i
+ }
+ end
+
+ def previous_period_revenue
+ period_length = @end_date - @start_date
+
+ SalesWithTotal
+ .where(created_at: (@start_date - period_length)...@start_date)
+ .where.not(verified_at: nil)
+ .sum(:total_cents)
+ end
+
+ def article_items
+ Article
+ .joins(articles_sales: :sale)
+ .where(sales: { created_at: @start_date..@end_date })
+ .where.not(sales: { verified_at: nil })
+ .group('articles.id', 'articles.name')
+ .select(
+ 'articles.id',
+ 'articles.name AS name',
+ 'SUM(articles_sales.quantity) AS quantity',
+ 'SUM(articles_sales.quantity * articles.price_cents) AS revenue'
+ )
+ .each_with_object({}) do |r, h|
+ h["article_#{r.id}"] = {
+ name: r.name,
+ type: 'Article',
+ quantity: r.quantity.to_i,
+ revenue: Money.new(r.revenue)
+ }
+ end
+ end
+
+ def subscription_items
+ SubscriptionOffer
+ .joins(sales_subscription_offers: :sale)
+ .where(sales: { created_at: @start_date..@end_date })
+ .where.not(sales: { verified_at: nil })
+ .group('subscription_offers.id', 'subscription_offers.duration')
+ .select(
+ 'subscription_offers.id',
+ 'subscription_offers.duration AS duration',
+ 'SUM(sales_subscription_offers.quantity) AS quantity',
+ 'SUM(sales_subscription_offers.quantity * subscription_offers.price_cents) AS revenue'
+ )
+ .each_with_object({}) do |r, h|
+ h["subscription_#{r.id}"] = {
+ name: "#{r.duration} mois",
+ type: 'Subscription',
+ quantity: r.quantity.to_i,
+ revenue: Money.new(r.revenue)
+ }
+ end
+ end
+end
+# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Style/MultilineBlockChain
diff --git a/app/queries/csv_export_query.sql b/app/queries/csv_export_query.sql
new file mode 100644
index 00000000..0d479ce0
--- /dev/null
+++ b/app/queries/csv_export_query.sql
@@ -0,0 +1,141 @@
+WITH
+-- Sales totals (articles + subscriptions)
+sale_totals AS (SELECT s.id AS sale_id,
+ COALESCE(a.total, 0) + COALESCE(sub.total, 0) AS sale_total_cents
+ FROM sales s
+ LEFT JOIN (SELECT asu.sale_id,
+ SUM(asu.quantity * a.price_cents) AS total
+ FROM articles_sales asu
+ JOIN articles a ON a.id = asu.article_id
+ GROUP BY asu.sale_id) a ON a.sale_id = s.id
+ LEFT JOIN (SELECT sso.sale_id,
+ SUM(sso.quantity * so.price_cents) AS total
+ FROM sales_subscription_offers sso
+ JOIN subscription_offers so ON so.id = sso.subscription_offer_id
+ GROUP BY sso.sale_id) sub ON sub.sale_id = s.id
+ WHERE s.verified_at IS NOT NULL
+ AND s.created_at BETWEEN :start_date AND :end_date),
+
+-- Sale lines (articles + subscriptions)
+sale_lines AS (SELECT s.created_at AS date,
+ s.id AS sale_id,
+ c.username AS client,
+ COALESCE(sel.username, 'N/A') AS seller,
+ pm.name AS payment_method,
+ 'Article' AS item_type,
+ a.name AS item_name,
+ asu.quantity AS quantity,
+ a.price_cents AS unit_price_cents,
+ (asu.quantity * a.price_cents) AS line_total_cents,
+ st.sale_total_cents AS sale_total_cents
+ FROM sales s
+ JOIN sale_totals st ON st.sale_id = s.id
+ JOIN users c ON c.id = s.client_id
+ LEFT JOIN users sel ON sel.id = s.seller_id
+ JOIN payment_methods pm ON pm.id = s.payment_method_id
+ JOIN articles_sales asu ON asu.sale_id = s.id
+ JOIN articles a ON a.id = asu.article_id
+ WHERE s.verified_at IS NOT NULL
+ AND s.created_at BETWEEN :start_date AND :end_date
+
+ UNION ALL
+
+ SELECT s.created_at,
+ s.id,
+ c.username,
+ COALESCE(sel.username, 'N/A'),
+ pm.name,
+ 'Subscription',
+ CONCAT(so.duration, ' months'),
+ sso.quantity,
+ so.price_cents,
+ (sso.quantity * so.price_cents),
+ st.sale_total_cents
+ FROM sales s
+ JOIN sale_totals st ON st.sale_id = s.id
+ JOIN users c ON c.id = s.client_id
+ LEFT JOIN users sel ON sel.id = s.seller_id
+ JOIN payment_methods pm ON pm.id = s.payment_method_id
+ JOIN sales_subscription_offers sso ON sso.sale_id = s.id
+ JOIN subscription_offers so ON so.id = sso.subscription_offer_id
+ WHERE s.verified_at IS NOT NULL
+ AND s.created_at BETWEEN :start_date AND :end_date),
+
+-- Refund totals (articles + subscriptions)
+refund_totals AS (SELECT r.id AS refund_id,
+ COALESCE(a.total, 0) + COALESCE(sub.total, 0) AS refund_total_cents
+ FROM refunds r
+ LEFT JOIN (SELECT ar.refund_id,
+ SUM(ar.quantity * a.price_cents) AS total
+ FROM articles_refunds ar
+ JOIN articles a ON a.id = ar.article_id
+ GROUP BY ar.refund_id) a ON a.refund_id = r.id
+ LEFT JOIN (SELECT rso.refund_id,
+ SUM(rso.quantity * so.price_cents) AS total
+ FROM refunds_subscription_offers rso
+ JOIN subscription_offers so ON so.id = rso.subscription_offer_id
+ GROUP BY rso.refund_id) sub ON sub.refund_id = r.id),
+
+-- Refund lines (articles + subscriptions)
+refund_lines AS (SELECT r.created_at AS date,
+ s.id AS sale_id,
+ c.username AS client,
+ COALESCE(ref.username, 'N/A') AS seller,
+ rm.name AS payment_method,
+ 'Article (Refund)' AS item_type,
+ a.name AS item_name,
+ -ar.quantity AS quantity,
+ a.price_cents AS unit_price_cents,
+ -(ar.quantity * a.price_cents) AS line_total_cents,
+ -rt.refund_total_cents AS sale_total_cents
+ FROM refunds r
+ JOIN refund_totals rt ON rt.refund_id = r.id
+ JOIN sales s ON s.id = r.sale_id
+ JOIN users c ON c.id = s.client_id
+ LEFT JOIN users ref ON ref.id = r.refunder_id
+ JOIN payment_methods rm ON rm.id = r.refund_method_id
+ JOIN articles_refunds ar ON ar.refund_id = r.id
+ JOIN articles a ON a.id = ar.article_id
+ WHERE r.created_at BETWEEN :start_date AND :end_date
+
+ UNION ALL
+
+ SELECT r.created_at,
+ s.id,
+ c.username,
+ COALESCE(ref.username, 'N/A'),
+ rm.name,
+ 'Subscription (Refund)',
+ CONCAT(so.duration, ' months'),
+ -rso.quantity,
+ so.price_cents,
+ -(rso.quantity * so.price_cents),
+ -rt.refund_total_cents
+ FROM refunds r
+ JOIN refund_totals rt ON rt.refund_id = r.id
+ JOIN sales s ON s.id = r.sale_id
+ JOIN users c ON c.id = s.client_id
+ LEFT JOIN users ref ON ref.id = r.refunder_id
+ JOIN payment_methods rm ON rm.id = r.refund_method_id
+ JOIN refunds_subscription_offers rso ON rso.refund_id = r.id
+ JOIN subscription_offers so ON so.id = rso.subscription_offer_id
+ WHERE r.created_at BETWEEN :start_date AND :end_date)
+
+-- Final selection: combine sale lines and refund lines
+SELECT date,
+ sale_id,
+ client,
+ seller,
+ payment_method,
+ item_type,
+ item_name,
+ quantity,
+ unit_price_cents,
+ line_total_cents,
+ sale_total_cents
+FROM (SELECT *
+ FROM sale_lines
+ UNION ALL
+ SELECT *
+ FROM refund_lines) all_lines
+ORDER BY date, sale_id;
diff --git a/app/views/admin/accounting/index.html.erb b/app/views/admin/accounting/index.html.erb
new file mode 100644
index 00000000..2f2f3d45
--- /dev/null
+++ b/app/views/admin/accounting/index.html.erb
@@ -0,0 +1,300 @@
+<%# locals: () -%>
+
+<% content_for :title, "Accounting Dashboard" %>
+
+
+
+
+
+
+ Period:
+ <%= @start_date.strftime('%B %d, %Y') %> - <%= @end_date.strftime('%B %d, %Y') %>
+
+
+
+
+
๐ฐ
+
+
Total Revenue
+
<%= @kpis[:total_revenue].format %>
+
+ <%= @kpis[:growth_rate] >= 0 ? 'โ' : 'โ' %>
+ <%= number_to_percentage(@kpis[:growth_rate].abs, precision: 2) %>
+
+
+
+
+
+
๐งพ
+
+
Transactions
+
<%= number_with_delimiter(@kpis[:transaction_count]) %>
+
Verified sales
+
+
+
+
+
๐
+
+
Avg Transaction
+
<%= @kpis[:avg_transaction_value].format %>
+
+
+
+
+
๐
+
+
Subscription Time Sold
+
+ <%= number_with_delimiter(@kpis[:total_months_sold]) %> months
+
+
+
+
+
+
๐
+
+
Avg Month Value
+
<%= @kpis[:avg_price_per_month].format %>
+
+
+
+
+
+ Revenue Over Time
+
+ <%= column_chart @revenue_data, suffix: ' โฌ', height: '350px' %>
+
+
+
+
+
+ Payment Methods
+
+ <% if @payment_methods_data.any? %>
+
+ <%= pie_chart(@payment_methods_data.map { |pm| [pm[:name], pm[:amount_chart]] }, donut: true, suffix: ' โฌ', height: '300px') %>
+
+
+
+
+
+ | Payment Method |
+ Count |
+ Amount |
+ Avg |
+
+
+
+ <% @payment_methods_data.each do |pm| %>
+
+ |
+ <%= pm[:name] %>
+ <% if pm[:auto_verified] %>
+ Auto
+ <% end %>
+ |
+ <%= number_with_delimiter(pm[:count]) %> |
+ <%= pm[:amount].format %> |
+ <%= pm[:avg_value].format %> |
+
+ <% end %>
+
+
+ <% else %>
+ No payment data for this period.
+ <% end %>
+
+
+
+ Top Items
+
+ <% if @top_items_data.any? %>
+
+ <%= column_chart(@top_items_data.first(10).map { |i| [i[:name], i[:revenue].to_f] }, suffix: ' โฌ', height: '300px') %>
+
+
+
+
+
+ | Item |
+ Type |
+ Qty |
+ Revenue |
+ % of Total |
+
+
+
+ <% @top_items_data.each do |item| %>
+
+ | <%= item[:name] %> |
+ <%= item[:type] %> |
+ <%= number_with_delimiter(item[:quantity]) %> |
+ <%= item[:revenue].format %> |
+ <%= number_to_percentage(item[:percentage], precision: 1) %> |
+
+ <% end %>
+
+
+ <% else %>
+ No items sold in this period.
+ <% end %>
+
+
+
+
+ Recent Sales
+
+ <% if @recent_sales.any? %>
+
+
+
+
+ | Sale ID |
+ Date |
+ Client |
+ Seller |
+ Payment Method |
+ Items |
+ Total |
+ Invoice |
+
+
+
+ <% @recent_sales.each do |sale| %>
+
+ | #<%= sale[:id] %> |
+ <%= sale[:date].strftime('%Y-%m-%d %H:%M') %> |
+ <%= sale[:client] %> |
+ <%= sale[:seller] %> |
+ <%= sale[:payment_method] %> |
+ <%= number_with_delimiter(sale[:items_count]) %> |
+ <%= sale[:total].format %> |
+
+ ๐ PDF
+ |
+
+ <% end %>
+
+
+
+ <% else %>
+ No sales in this period.
+ <% end %>
+
+
+
+
+ Customer Metrics
+
+
+
+
New Customers
+
+ <%= number_with_delimiter(@customer_metrics[:new_customers]) %>
+
+
+
+
+
Total Customers
+
+ <%= number_with_delimiter(@customer_metrics[:total_customers]) %>
+
+
+
+
+
Avg Customer LTV
+
+ <%= @customer_metrics[:avg_lifetime_value].format %>
+
+
+
+
+ <% if @customer_metrics[:top_customers].any? %>
+ Top Customers
+
+
+
+ | Customer |
+ Revenue |
+ Orders |
+
+
+
+ <% @customer_metrics[:top_customers].first(5).each do |customer| %>
+
+ | <%= customer[:name] %> |
+ <%= customer[:revenue].format %> |
+ <%= number_with_delimiter(customer[:transaction_count]) %> |
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+ Sales by Seller
+
+ <% if @sales_by_seller.any? %>
+
+
+
+ | Seller |
+ Sales Count |
+ Revenue |
+
+
+
+ <% @sales_by_seller.first(8).each do |seller| %>
+
+ | <%= seller[:name] %> |
+ <%= number_with_delimiter(seller[:count]) %> |
+ <%= seller[:revenue].format %> |
+
+ <% end %>
+
+
+ <% else %>
+ No seller data available for this period.
+ <% end %>
+
+
+
+
diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb
index b0a3e8ec..ef77024b 100644
--- a/app/views/admin/dashboard/index.html.erb
+++ b/app/views/admin/dashboard/index.html.erb
@@ -2,6 +2,10 @@
Admin dashboard
+
+ <%= link_to '๐ Accounting Dashboard', admin_accounting_path, class: 'button-primary', style: 'display: inline-block; padding: 1rem 1.5rem; text-decoration: none;' %>
+
+
<% if can?(:read, Article) %>
Articles
<% if can?(:create, Article) %>
diff --git a/config/importmap.rb b/config/importmap.rb
index b57e7beb..0ae4b431 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -7,3 +7,8 @@
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
pin_all_from 'app/javascript/controllers', under: 'controllers'
+# pin 'chartkick', to: 'https://ga.jspm.io/npm:chartkick@5.0.1/dist/chartkick.esm.js'
+# pin 'chart.js', to: 'https://ga.jspm.io/npm:chart.js@4.5.1/dist/chart.js'
+# pin '@kurkle/color', to: 'https://ga.jspm.io/npm:@kurkle/color@0.3.4/dist/color.esm.js'
+pin 'chartkick', to: 'chartkick.js'
+pin 'Chart.bundle', to: 'Chart.bundle.js'
diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb
new file mode 100644
index 00000000..b7a86645
--- /dev/null
+++ b/config/initializers/chartkick.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Chartkick.options = {
+ adapter: 'chartjs',
+ thousands: ' ',
+ decimal: ','
+}
diff --git a/config/routes.rb b/config/routes.rb
index 5b96854c..2fcb9a2e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -19,6 +19,8 @@
scope module: :admin do
get '/admin', as: 'admin', to: 'dashboard#index'
+ get '/admin/accounting', as: 'admin_accounting', to: 'accounting#index'
+ get '/admin/accounting/export_csv', as: 'export_csv_admin_accounting', to: 'accounting#export_csv'
resources :articles, only: [:new, :create, :destroy]
resources :subscription_offers, only: [:new, :create, :destroy]
resources :payment_methods, only: [:new, :create, :destroy]
diff --git a/db/migrate/20260103170240_create_sales_with_totals.rb b/db/migrate/20260103170240_create_sales_with_totals.rb
new file mode 100644
index 00000000..4f80adbe
--- /dev/null
+++ b/db/migrate/20260103170240_create_sales_with_totals.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class CreateSalesWithTotals < ActiveRecord::Migration[7.2]
+ def change
+ create_view :sales_with_totals
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8a776707..77fd56fa 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_12_14_175208) do
+ActiveRecord::Schema[7.2].define(version: 2026_01_03_170240) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -230,4 +230,28 @@
add_foreign_key "sales_subscription_offers", "sales"
add_foreign_key "sales_subscription_offers", "subscription_offers"
add_foreign_key "subscriptions", "sales"
+
+ create_view "sales_with_totals", sql_definition: <<-SQL
+ SELECT sales.id,
+ sales.created_at,
+ sales.updated_at,
+ sales.verified_at,
+ sales.client_id,
+ sales.seller_id,
+ sales.payment_method_id,
+ COALESCE(articles_totals.total, (0)::bigint) AS articles_total_cents,
+ COALESCE(subscriptions_totals.total, (0)::bigint) AS subscriptions_total_cents,
+ (COALESCE(articles_totals.total, (0)::bigint) + COALESCE(subscriptions_totals.total, (0)::bigint)) AS total_cents
+ FROM ((sales
+ LEFT JOIN ( SELECT articles_sales.sale_id,
+ sum((articles.price_cents * articles_sales.quantity)) AS total
+ FROM (articles_sales
+ JOIN articles ON ((articles.id = articles_sales.article_id)))
+ GROUP BY articles_sales.sale_id) articles_totals ON ((articles_totals.sale_id = sales.id)))
+ LEFT JOIN ( SELECT sales_subscription_offers.sale_id,
+ sum((subscription_offers.price_cents * sales_subscription_offers.quantity)) AS total
+ FROM (sales_subscription_offers
+ JOIN subscription_offers ON ((subscription_offers.id = sales_subscription_offers.subscription_offer_id)))
+ GROUP BY sales_subscription_offers.sale_id) subscriptions_totals ON ((subscriptions_totals.sale_id = sales.id)));
+ SQL
end
diff --git a/db/seeds/perfs.rb b/db/seeds/perfs.rb
new file mode 100644
index 00000000..7ea49ad6
--- /dev/null
+++ b/db/seeds/perfs.rb
@@ -0,0 +1,203 @@
+# frozen_string_literal: true
+
+# rubocop:disable Rails/SkipsModelValidations
+Rails.logger.info 'Seeding heavy dataset...'
+
+NOW = Time.current
+
+CLIENT_COUNT = 3_000
+SELLER_COUNT = 30
+PAYMENT_METHODS = 4
+ARTICLES_COUNT = 3
+SALES_COUNT = 10_000
+
+def random_date(range = 12.months)
+ NOW - rand(range)
+end
+
+def rand_quantity
+ rand(1..3)
+end
+
+# -------------------------------------------------
+# Users
+# -------------------------------------------------
+
+Rails.logger.info 'Creating users...'
+
+clients = Array.new(CLIENT_COUNT) do |i|
+ {
+ firstname: "Client#{i}",
+ lastname: 'Test',
+ email: "client#{i}@test.local",
+ username: "client#{i}",
+ wifi_password: SecureRandom.hex(6),
+ created_at: NOW,
+ updated_at: NOW
+ }
+end
+
+User.insert_all!(clients)
+client_ids = User.order(:id).limit(CLIENT_COUNT).pluck(:id)
+
+sellers = Array.new(SELLER_COUNT) do |i|
+ {
+ firstname: "Seller#{i}",
+ lastname: 'Test',
+ email: "seller#{i}@test.local",
+ username: "seller#{i}",
+ wifi_password: SecureRandom.hex(6),
+ created_at: NOW,
+ updated_at: NOW
+ }
+end
+
+User.insert_all!(sellers)
+seller_ids = User.order(:id).offset(CLIENT_COUNT).limit(SELLER_COUNT).pluck(:id)
+
+# -------------------------------------------------
+# Payment methods
+# -------------------------------------------------
+
+Rails.logger.info 'Creating payment methods...'
+
+payment_methods = Array.new(PAYMENT_METHODS) do |i|
+ {
+ name: "Payment #{i}",
+ auto_verify: i.even?,
+ created_at: NOW,
+ updated_at: NOW
+ }
+end
+
+PaymentMethod.insert_all!(payment_methods)
+payment_method_ids = PaymentMethod.pluck(:id)
+
+# -------------------------------------------------
+# Articles
+# -------------------------------------------------
+
+Rails.logger.info 'Creating articles...'
+
+articles = Array.new(ARTICLES_COUNT) do |i|
+ {
+ name: "Article #{i}",
+ price_cents: rand(100..5_000),
+ created_at: NOW,
+ updated_at: NOW
+ }
+end
+
+Article.insert_all!(articles)
+article_ids = Article.pluck(:id)
+
+# -------------------------------------------------
+# Subscription offers
+# -------------------------------------------------
+
+Rails.logger.info 'Creating subscription offers...'
+
+subscription_offers = [
+ {
+ duration: 1,
+ price_cents: 500,
+ created_at: NOW,
+ updated_at: NOW
+ },
+ {
+ duration: 12,
+ price_cents: 5_000,
+ created_at: NOW,
+ updated_at: NOW
+ }
+]
+
+SubscriptionOffer.insert_all!(subscription_offers)
+subscription_offer_ids = SubscriptionOffer.pluck(:id)
+
+# -------------------------------------------------
+# Invoices
+# -------------------------------------------------
+
+def next_invoice_id
+ @invoice_seq ||= Invoice.maximum(:id).to_i
+ @invoice_seq += 1
+end
+
+Rails.logger.info 'Creating invoices...'
+
+invoices = Array.new(SALES_COUNT) do
+ {
+ id: next_invoice_id,
+ generation_json: { generated: true },
+ created_at: NOW,
+ updated_at: NOW
+ }
+end
+
+Invoice.insert_all!(invoices)
+invoice_ids = Invoice.pluck(:id)
+
+# -------------------------------------------------
+# Sales
+# -------------------------------------------------
+
+Rails.logger.info 'Creating sales...'
+
+sales = Array.new(SALES_COUNT) do
+ created_at = random_date
+
+ {
+ client_id: client_ids.sample,
+ seller_id: seller_ids.sample,
+ payment_method_id: payment_method_ids.sample,
+ invoice_id: invoice_ids.sample,
+ verified_at: rand < 0.8 ? created_at + rand(1..48).hours : nil,
+ created_at: created_at,
+ updated_at: created_at
+ }
+end
+
+Sale.insert_all!(sales)
+sale_ids = Sale.pluck(:id)
+
+# -------------------------------------------------
+# Articles sales
+# -------------------------------------------------
+
+Rails.logger.info 'Linking articles to sales...'
+
+articles_sales = []
+
+sale_ids.each do |sale_id|
+ article_ids
+ .sample(rand(1..ARTICLES_COUNT))
+ .each do |article_id|
+ articles_sales << {
+ sale_id: sale_id,
+ article_id: article_id,
+ quantity: rand_quantity
+ }
+ end
+end
+
+ArticlesSale.insert_all!(articles_sales)
+
+# -------------------------------------------------
+# Subscription sales
+# -------------------------------------------------
+
+Rails.logger.info 'Linking subscriptions to sales...'
+
+subscriptions_sales = sale_ids.sample(SALES_COUNT / 2).map do |sale_id|
+ {
+ sale_id: sale_id,
+ subscription_offer_id: subscription_offer_ids.sample,
+ quantity: rand_quantity
+ }
+end
+
+SalesSubscriptionOffer.insert_all!(subscriptions_sales)
+
+Rails.logger.info 'Heavy seed done!'
+# rubocop:enable Rails/SkipsModelValidations
diff --git a/db/views/sales_with_totals_v01.sql b/db/views/sales_with_totals_v01.sql
new file mode 100644
index 00000000..afb98c8e
--- /dev/null
+++ b/db/views/sales_with_totals_v01.sql
@@ -0,0 +1,28 @@
+SELECT
+ sales.id,
+ sales.created_at,
+ sales.updated_at,
+ sales.verified_at,
+ sales.client_id,
+ sales.seller_id,
+ sales.payment_method_id,
+ COALESCE(articles_totals.total, 0) AS articles_total_cents,
+ COALESCE(subscriptions_totals.total, 0) AS subscriptions_total_cents,
+ COALESCE(articles_totals.total, 0) + COALESCE(subscriptions_totals.total, 0) AS total_cents
+FROM sales
+LEFT JOIN (
+ SELECT
+ articles_sales.sale_id,
+ SUM(articles.price_cents * articles_sales.quantity) AS total
+ FROM articles_sales
+ JOIN articles ON articles.id = articles_sales.article_id
+ GROUP BY articles_sales.sale_id
+) articles_totals ON articles_totals.sale_id = sales.id
+LEFT JOIN (
+ SELECT
+ sales_subscription_offers.sale_id,
+ SUM(subscription_offers.price_cents * sales_subscription_offers.quantity) AS total
+ FROM sales_subscription_offers
+ JOIN subscription_offers ON subscription_offers.id = sales_subscription_offers.subscription_offer_id
+ GROUP BY sales_subscription_offers.sale_id
+) subscriptions_totals ON subscriptions_totals.sale_id = sales.id
diff --git a/test/controllers/admin/accounting_controller_test.rb b/test/controllers/admin/accounting_controller_test.rb
new file mode 100644
index 00000000..c935267f
--- /dev/null
+++ b/test/controllers/admin/accounting_controller_test.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Admin
+ class AccountingControllerTest < ActionDispatch::IntegrationTest
+ def setup
+ super
+ @user = users(:ironman)
+ sign_in_as @user, ['rezoleo']
+ end
+
+ test 'should show accounting dashboard' do
+ get admin_accounting_path
+ assert_response :success
+ assert_template 'admin/accounting/index'
+ end
+
+ test 'should handle different period parameters' do
+ ['current_month', 'last_month', 'last_30_days', 'current_year', 'last_year', 'all_time'].each do |period|
+ get admin_accounting_path, params: { period: period }
+ assert_response :success
+ end
+ end
+
+ test 'should handle custom date range' do
+ get admin_accounting_path, params: {
+ period: 'custom',
+ start_date: '2024-01-01',
+ end_date: '2024-12-31'
+ }
+ assert_response :success
+ end
+
+ test 'should export csv' do
+ get export_csv_admin_accounting_path
+ assert_response :success
+ assert_equal 'text/csv', response.content_type
+ end
+ end
+end
diff --git a/test/controllers/admin/accounting_controller_user_right_test.rb b/test/controllers/admin/accounting_controller_user_right_test.rb
new file mode 100644
index 00000000..b8097e7d
--- /dev/null
+++ b/test/controllers/admin/accounting_controller_user_right_test.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'test_helper'
+
+module Admin
+ class AccountingControllerUserRightTest < ActionDispatch::IntegrationTest
+ def setup
+ super
+ @user = users(:pepper)
+ sign_in_as @user
+ end
+
+ test 'non-admin user should not see the accounting dashboard' do
+ assert_raises CanCan::AccessDenied do
+ get admin_accounting_path
+ end
+ end
+ end
+end