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" %> + +
+
+
+

Accounting Dashboard

+ +
+ <%= form_with url: admin_accounting_path, method: :get, class: "period-form" do |f| %> + <%= f.select :period, + options_for_select([ + ['Current Month', 'current_month'], + ['Last Month', 'last_month'], + ['Last 30 Days', 'last_30_days'], + ['Current Year', 'current_year'], + ['Last Year', 'last_year'], + ['All Time', 'all_time'], + ['Custom Range', 'custom'] + ], @period), + {}, + class: 'period-select', + onchange: 'this.form.submit()' %> + + <% if @period == 'custom' %> +
+ <%= f.date_field :start_date, value: params[:start_date] || @start_date.to_date, class: 'date-input' %> + <%= f.date_field :end_date, value: params[:end_date] || @end_date.to_date, class: 'date-input' %> + <%= f.submit 'Apply', class: 'btn btn-primary' %> +
+ <% end %> + <% end %> + + <%= link_to 'Export CSV', + export_csv_admin_accounting_path( + period: @period, + start_date: @start_date.to_date, + end_date: @end_date.to_date + ), + class: 'btn btn-secondary' %> +
+
+ +

+ 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_methods_data.each do |pm| %> + + + + + + + <% end %> + +
Payment MethodCountAmountAvg
+ <%= pm[:name] %> + <% if pm[:auto_verified] %> + Auto + <% end %> + <%= number_with_delimiter(pm[:count]) %><%= pm[:amount].format %><%= pm[:avg_value].format %>
+ <% 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') %> +
+ + + + + + + + + + + + + <% @top_items_data.each do |item| %> + + + + + + + + <% end %> + +
ItemTypeQtyRevenue% of Total
<%= item[:name] %><%= item[:type] %><%= number_with_delimiter(item[:quantity]) %><%= item[:revenue].format %><%= number_to_percentage(item[:percentage], precision: 1) %>
+ <% else %> +

No items sold in this period.

+ <% end %> +
+
+ +
+

Recent Sales

+ + <% if @recent_sales.any? %> +
+ + + + + + + + + + + + + + + <% @recent_sales.each do |sale| %> + + + + + + + + + + + <% end %> + +
Sale IDDateClientSellerPayment MethodItemsTotalInvoice
#<%= 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 +
+
+ <% 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_metrics[:top_customers].first(5).each do |customer| %> + + + + + + <% end %> + +
CustomerRevenueOrders
<%= customer[:name] %><%= customer[:revenue].format %><%= number_with_delimiter(customer[:transaction_count]) %>
+ <% end %> +
+ +
+

Sales by Seller

+ + <% if @sales_by_seller.any? %> + + + + + + + + + + <% @sales_by_seller.first(8).each do |seller| %> + + + + + + <% end %> + +
SellerSales CountRevenue
<%= seller[:name] %><%= number_with_delimiter(seller[:count]) %><%= seller[:revenue].format %>
+ <% 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