From c488274e5a64a11d95468b9718a1ad9215ca057f Mon Sep 17 00:00:00 2001 From: David Marembert Date: Sat, 27 Dec 2025 18:06:33 +0100 Subject: [PATCH 1/7] wip wip2 --- Gemfile | 6 + Gemfile.lock | 10 + app/assets/stylesheets/admin/accounting.css | 323 ++++++++++++++ .../admin/accounting_controller.rb | 412 ++++++++++++++++++ app/javascript/application.js | 4 + app/models/user.rb | 1 + app/views/admin/accounting/index.html.erb | 300 +++++++++++++ app/views/admin/dashboard/index.html.erb | 4 + config/application.rb | 1 + config/importmap.rb | 5 + config/initializers/chartkick.rb | 6 + config/routes.rb | 2 + .../admin/accounting_controller_test.rb | 41 ++ .../accounting_controller_user_right_test.rb | 19 + 14 files changed, 1134 insertions(+) create mode 100644 app/assets/stylesheets/admin/accounting.css create mode 100644 app/controllers/admin/accounting_controller.rb create mode 100644 app/views/admin/accounting/index.html.erb create mode 100644 config/initializers/chartkick.rb create mode 100644 test/controllers/admin/accounting_controller_test.rb create mode 100644 test/controllers/admin/accounting_controller_user_right_test.rb diff --git a/Gemfile b/Gemfile index cd802296..7c3858ea 100644 --- a/Gemfile +++ b/Gemfile @@ -92,3 +92,9 @@ group :test do gem 'simplecov-cobertura', '~> 3.0', require: false gem 'webmock', '~> 3.23' end + +gem 'chartkick', '~> 5.2' + +gem 'groupdate', '~> 6.7' + +gem 'csv', '~> 3.3' diff --git a/Gemfile.lock b/Gemfile.lock index 89afea97..aacd3905 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) @@ -131,6 +133,8 @@ GEM geom2d (0.4.1) globalid (1.2.1) activesupport (>= 6.1) + groupdate (6.7.0) + activesupport (>= 7.1) guard (2.19.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -460,7 +464,10 @@ DEPENDENCIES brakeman (~> 7.0) cancancan (~> 3.6) capybara (~> 3.40) + chartkick (~> 5.2) + csv (~> 3.3) debug + groupdate (~> 6.7) guard (~> 2.19) guard-minitest (~> 2.4) hexapdf (~> 1.4.0) @@ -522,6 +529,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 +537,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 @@ -543,6 +552,7 @@ CHECKSUMS formatador (1.1.0) sha256=54e23e2af4d60bb9327c7fac62b29968e4cf28cee0111f726d0bdeadc85e06d0 geom2d (0.4.1) sha256=ea0998ea90c4f2752e24fe13d85a4f89bee689d151316140ebcc6369bf634ed9 globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 + groupdate (6.7.0) sha256=beaa8d5bf3856814681914a1d4a20e77436a2214b85d0017dc2ea5c355fb6777 guard (2.19.1) sha256=b8bc52694be3d8b26730280de7dcec7fe92ea1cff3414246fe96af3f23580f3d guard-compat (1.2.1) sha256=3ad21ab0070107f92edfd82610b5cdc2fb8e368851e72362ada9703443d646fe guard-minitest (2.4.6) sha256=d89e83d029447c13b191599085d24b6e2fe61e402d275e46491cd3e82f561572 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..4f3ecba8 --- /dev/null +++ b/app/controllers/admin/accounting_controller.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +module Admin + # rubocop:disable Metrics/ClassLength + class AccountingController < ApplicationController + before_action :set_date_range + + def index + authorize! :manage, :all + + @kpis = calculate_kpis + @revenue_data = revenue_by_date + @payment_methods_data = payment_methods_breakdown + @top_items_data = top_items_performance + @recent_sales = recent_sales_list + @customer_metrics = customer_metrics + @sales_by_seller = sales_by_seller + end + + def export_csv + authorize! :manage, :all + + csv_data = generate_csv_data + send_data csv_data, + filename: "accounting_export_#{@start_date.to_date}_#{@end_date.to_date}.csv", + type: 'text/csv' + end + + private + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + def set_date_range + @period = params[:period] || 'current_month' + + case @period + when 'last_month' + @start_date = 1.month.ago.beginning_of_month + @end_date = 1.month.ago.end_of_month + when 'last_30_days' + @start_date = 30.days.ago.beginning_of_day + @end_date = Time.zone.now.end_of_day + when 'current_year' + @start_date = Time.zone.now.beginning_of_year + @end_date = Time.zone.now.end_of_year + when 'last_year' + @start_date = 1.year.ago.beginning_of_year + @end_date = 1.year.ago.end_of_year + when 'all_time' + @start_date = Sale.minimum(:created_at) || Time.zone.now + @end_date = Time.zone.now + when 'custom' + @start_date = params[:start_date].present? ? Time.zone.parse(params[:start_date]) : default_start_date + @end_date = params[:end_date].present? ? Time.zone.parse(params[:end_date]) : default_end_date + else + @start_date = Time.zone.now.beginning_of_month + @end_date = Time.zone.now.end_of_month + end + end + + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength + + def sales_scope + Sale.where(created_at: @start_date..@end_date) + end + + def verified_sales_scope + sales_scope.where.not(verified_at: nil) + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def calculate_kpis + sales = verified_sales_scope.includes(:sales_subscription_offers, subscription_offers: []) + + total_revenue = sales.sum(Money.zero, &:total_price) + transaction_count = sales.count + avg_transaction = transaction_count.positive? ? total_revenue / transaction_count : Money.zero + + total_months_sold = 0 + subscription_revenue = Money.zero + + sales.each do |sale| + sale.sales_subscription_offers.each do |sso| + total_months_sold += sso.quantity * sso.subscription_offer.duration + subscription_revenue += sso.subscription_offer.price * sso.quantity + end + end + + period_length = @end_date - @start_date + previous_sales = Sale.where(created_at: (@start_date - period_length)..@start_date) + .where.not(verified_at: nil) + previous_revenue = previous_sales.sum(Money.zero, &:total_price) + + growth_rate = + if previous_revenue.positive? + ((total_revenue - previous_revenue) / previous_revenue * 100).round(2) + else + 0 + end + + avg_price_per_month = + total_months_sold.positive? ? subscription_revenue / total_months_sold : Money.zero + + { + total_revenue: total_revenue, + transaction_count: transaction_count, + avg_transaction_value: avg_transaction, + growth_rate: growth_rate, + total_months_sold: total_months_sold, + avg_price_per_month: avg_price_per_month + } + end + + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + + def revenue_by_date + range = @start_date.to_date..@end_date.to_date + data = range.index_with { 0.0 } + + verified_sales_scope.find_each do |sale| + date = sale.created_at.to_date + data[date] += sale.total_price.to_f if data.key?(date) + end + + data + end + + def payment_methods_breakdown + breakdown = verified_sales_scope.includes(:payment_method).group_by(&:payment_method) + results = breakdown.map do |payment_method, sales| + amount = sales.sum(Money.zero, &:total_price) + + { + name: payment_method.name, + count: sales.size, + amount: amount, + amount_chart: amount.to_f, + avg_value: sales.any? ? amount / sales.size : Money.zero, + auto_verified: payment_method.auto_verify + } + end + results.sort_by { |pm| -pm[:amount].cents } + end + + def top_items_performance + items = build_items_data + total_revenue = items.values.sum { |i| i[:revenue].cents } + + results = items.values.map do |item| + item[:percentage] = total_revenue.positive? ? (item[:revenue].cents.to_f / total_revenue * 100).round(2) : 0 + item + end + + results.sort_by { |i| -i[:revenue].cents }.first(10) + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def build_items_data + items = {} + + # Add articles + verified_sales_scope.includes(articles_sales: :article).find_each do |sale| + sale.articles_sales.each do |as| + article = as.article + key = "article_#{article.id}" + items[key] ||= { + name: article.name, + type: 'Article', + quantity: 0, + revenue: Money.zero + } + + items[key][:quantity] += as.quantity + items[key][:revenue] += article.price * as.quantity + end + end + + # Add subscription offers + verified_sales_scope.includes(sales_subscription_offers: :subscription_offer).find_each do |sale| + sale.sales_subscription_offers.each do |sso| + offer = sso.subscription_offer + key = "subscription_#{offer.id}" + items[key] ||= { + name: "#{offer.duration} mois", + type: 'Subscription', + quantity: 0, + revenue: Money.zero + } + + items[key][:quantity] += sso.quantity + items[key][:revenue] += offer.price * sso.quantity + end + end + + items + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + def recent_sales_list + verified_sales_scope.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, + items_count: sale.articles_sales.count + sale.sales_subscription_offers.count + } + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + def customer_metrics + sales = verified_sales_scope.includes(:client) + clients = sales.map(&:client).uniq + + new_customers = clients.count do |client| + first_sale = client.sales.where.not(verified_at: nil).minimum(:created_at) + first_sale && first_sale >= @start_date + end + + customer_revenues = sales.group_by(&:client) + .transform_values { |s| s.sum(Money.zero, &:total_price) } + + avg_ltv = + customer_revenues.any? ? customer_revenues.values.sum / customer_revenues.size : Money.zero + + { + new_customers: new_customers, + total_customers: clients.size, + avg_lifetime_value: avg_ltv, + top_customers: customer_revenues.sort_by { |_, r| -r.cents }.first(10).map do |customer, revenue| + { + name: customer.display_name, + revenue: revenue, + transaction_count: sales.count { |s| s.client == customer } + } + end + } + end + + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity + + def sales_by_seller + seller_sales = verified_sales_scope.includes(:seller).select(&:seller).group_by(&:seller) + results = seller_sales.map do |seller, sales| + { + name: seller.display_name, + count: sales.size, + revenue: sales.sum(Money.zero, &:total_price) + } + end + results.sort_by { |s| -s[:revenue].cents } + end + + def generate_csv_data + CSV.generate(headers: true) do |csv| + csv << csv_headers + export_sales_to_csv(csv) + export_refunds_to_csv(csv) + end + end + + def csv_headers + ['Date', 'Sale ID', 'Client', 'Seller', 'Payment Method', + 'Item Type', 'Item Name', 'Quantity', 'Unit Price', 'Line Total', 'Sale Total'] + end + + def export_sales_to_csv(csv) + sales_for_export.find_each do |sale| + export_sale_articles(csv, sale) + export_sale_subscriptions(csv, sale) + end + end + + def export_refunds_to_csv(csv) + refunds_for_export.find_each do |refund| + export_refund_articles(csv, refund) + export_refund_subscriptions(csv, refund) + end + end + + def sales_for_export + verified_sales_scope.includes(:articles_sales, :sales_subscription_offers, + :client, :seller, :payment_method, + articles: [], subscription_offers: []) + end + + def refunds_for_export + Refund.where(sale_id: sales_for_export.select(:id)) + .where(created_at: @start_date..@end_date) + .includes(:articles_refunds, :refunds_subscription_offers, + :refunder, :refund_method, :sale, + articles: [], subscription_offers: []) + end + + def export_sale_articles(csv, sale) + sale.articles_sales.each do |as| + csv << sale_row(sale, 'Article', as.article.name, as.quantity, + as.article.price, as.article.price * as.quantity) + end + end + + def export_sale_subscriptions(csv, sale) + sale.sales_subscription_offers.each do |sso| + name = "#{sso.subscription_offer.duration} months" + csv << sale_row(sale, 'Subscription', name, sso.quantity, + sso.subscription_offer.price, sso.subscription_offer.price * sso.quantity) + end + end + + def export_refund_articles(csv, refund) + refund.articles_refunds.each do |ar| + csv << refund_row(refund, 'Article (Refund)', ar.article.name, -ar.quantity, + ar.article.price, -(ar.article.price * ar.quantity)) + end + end + + def export_refund_subscriptions(csv, refund) + refund.refunds_subscription_offers.each do |rso| + name = "#{rso.subscription_offer.duration} months" + csv << refund_row(refund, 'Subscription (Refund)', name, -rso.quantity, + rso.subscription_offer.price, -(rso.subscription_offer.price * rso.quantity)) + end + end + + # rubocop:disable Metrics/ParameterLists + def sale_row(sale, item_type, item_name, quantity, unit_price, line_total) + [ + sale.created_at.strftime('%Y-%m-%d %H:%M'), + sale.id, + sale.client.display_name, + sale.seller&.display_name || 'N/A', + sale.payment_method.name, + item_type, + item_name, + quantity, + format_money(unit_price), + format_money(line_total), + format_money(sale.total_price) + ] + end + + # rubocop:enable Metrics/ParameterLists + + # rubocop:disable Metrics/ParameterLists + def refund_row(refund, item_type, item_name, quantity, unit_price, line_total) + [ + refund.created_at.strftime('%Y-%m-%d %H:%M'), + refund.sale.id, + refund.sale.client.display_name, + refund.refunder&.display_name || 'N/A', + refund.refund_method.name, + item_type, + item_name, + quantity, + format_money(unit_price), + format_money(line_total), + format_money(-refund.total_price) + ] + end + + # rubocop:enable Metrics/ParameterLists + + def format_money(money) + format('%.2f', money.cents / 100.0) + end + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + def date_range_for_period(period) + 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' + custom_date_range + else + [Time.zone.now.beginning_of_month, Time.zone.now.end_of_month] + end + end + + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + + def custom_date_range + start_date = params[:start_date].present? ? Time.zone.parse(params[:start_date]) : default_start_date + end_date = params[:end_date].present? ? Time.zone.parse(params[:end_date]) : default_end_date + [start_date, end_date] + end + + def default_start_date + Time.zone.now.beginning_of_month + end + + def default_end_date + Time.zone.now.end_of_month + end + end + + # rubocop:enable Metrics/ClassLength +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 0d7b4940..bf9c8452 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,3 +1,7 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" import "controllers" +//import "chart.js" +//import "chartkick" +import "chartkick" +import "Chart.bundle" \ No newline at end of file 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/views/admin/accounting/index.html.erb b/app/views/admin/accounting/index.html.erb new file mode 100644 index 00000000..c12384ac --- /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: ' โ‚ฌ', thousands: ' ', 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.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/application.rb b/config/application.rb index e6742a90..dde51638 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,6 +15,7 @@ require 'action_view/railtie' # require "action_cable/engine" require 'rails/test_unit/railtie' +require 'csv' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. 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..e685d676 --- /dev/null +++ b/config/initializers/chartkick.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Configure Chartkick to use Chart.js +Chartkick.options = { + adapter: 'chartjs' +} 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/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 From 1f77c88fe302bec7df09823650fb73b2939c5747 Mon Sep 17 00:00:00 2001 From: David Marembert Date: Tue, 30 Dec 2025 16:49:27 +0100 Subject: [PATCH 2/7] wip3 --- .../admin/accounting_controller.rb | 420 ++---------------- app/queries/accounting_query.rb | 269 +++++++++++ app/views/admin/accounting/index.html.erb | 2 +- db/seeds/perfs.rb | 199 +++++++++ 4 files changed, 509 insertions(+), 381 deletions(-) create mode 100644 app/queries/accounting_query.rb create mode 100644 db/seeds/perfs.rb diff --git a/app/controllers/admin/accounting_controller.rb b/app/controllers/admin/accounting_controller.rb index 4f3ecba8..7126cb92 100644 --- a/app/controllers/admin/accounting_controller.rb +++ b/app/controllers/admin/accounting_controller.rb @@ -1,412 +1,72 @@ # frozen_string_literal: true module Admin - # rubocop:disable Metrics/ClassLength class AccountingController < ApplicationController before_action :set_date_range + before_action :init_query def index authorize! :manage, :all - @kpis = calculate_kpis - @revenue_data = revenue_by_date - @payment_methods_data = payment_methods_breakdown - @top_items_data = top_items_performance + @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 - @customer_metrics = customer_metrics - @sales_by_seller = sales_by_seller - end - - def export_csv - authorize! :manage, :all - - csv_data = generate_csv_data - send_data csv_data, - filename: "accounting_export_#{@start_date.to_date}_#{@end_date.to_date}.csv", - type: 'text/csv' end private - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength - def set_date_range - @period = params[:period] || 'current_month' - - case @period - when 'last_month' - @start_date = 1.month.ago.beginning_of_month - @end_date = 1.month.ago.end_of_month - when 'last_30_days' - @start_date = 30.days.ago.beginning_of_day - @end_date = Time.zone.now.end_of_day - when 'current_year' - @start_date = Time.zone.now.beginning_of_year - @end_date = Time.zone.now.end_of_year - when 'last_year' - @start_date = 1.year.ago.beginning_of_year - @end_date = 1.year.ago.end_of_year - when 'all_time' - @start_date = Sale.minimum(:created_at) || Time.zone.now - @end_date = Time.zone.now - when 'custom' - @start_date = params[:start_date].present? ? Time.zone.parse(params[:start_date]) : default_start_date - @end_date = params[:end_date].present? ? Time.zone.parse(params[:end_date]) : default_end_date - else - @start_date = Time.zone.now.beginning_of_month - @end_date = Time.zone.now.end_of_month - end - end - - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength - - def sales_scope - Sale.where(created_at: @start_date..@end_date) - end - - def verified_sales_scope - sales_scope.where.not(verified_at: nil) - end - - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def calculate_kpis - sales = verified_sales_scope.includes(:sales_subscription_offers, subscription_offers: []) - - total_revenue = sales.sum(Money.zero, &:total_price) - transaction_count = sales.count - avg_transaction = transaction_count.positive? ? total_revenue / transaction_count : Money.zero - - total_months_sold = 0 - subscription_revenue = Money.zero - - sales.each do |sale| - sale.sales_subscription_offers.each do |sso| - total_months_sold += sso.quantity * sso.subscription_offer.duration - subscription_revenue += sso.subscription_offer.price * sso.quantity - end - end - - period_length = @end_date - @start_date - previous_sales = Sale.where(created_at: (@start_date - period_length)..@start_date) - .where.not(verified_at: nil) - previous_revenue = previous_sales.sum(Money.zero, &:total_price) - - growth_rate = - if previous_revenue.positive? - ((total_revenue - previous_revenue) / previous_revenue * 100).round(2) - else - 0 - end - - avg_price_per_month = - total_months_sold.positive? ? subscription_revenue / total_months_sold : Money.zero - - { - total_revenue: total_revenue, - transaction_count: transaction_count, - avg_transaction_value: avg_transaction, - growth_rate: growth_rate, - total_months_sold: total_months_sold, - avg_price_per_month: avg_price_per_month - } - end - - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - - def revenue_by_date - range = @start_date.to_date..@end_date.to_date - data = range.index_with { 0.0 } - - verified_sales_scope.find_each do |sale| - date = sale.created_at.to_date - data[date] += sale.total_price.to_f if data.key?(date) - end - - data - end - - def payment_methods_breakdown - breakdown = verified_sales_scope.includes(:payment_method).group_by(&:payment_method) - results = breakdown.map do |payment_method, sales| - amount = sales.sum(Money.zero, &:total_price) - - { - name: payment_method.name, - count: sales.size, - amount: amount, - amount_chart: amount.to_f, - avg_value: sales.any? ? amount / sales.size : Money.zero, - auto_verified: payment_method.auto_verify - } - end - results.sort_by { |pm| -pm[:amount].cents } - end - - def top_items_performance - items = build_items_data - total_revenue = items.values.sum { |i| i[:revenue].cents } - - results = items.values.map do |item| - item[:percentage] = total_revenue.positive? ? (item[:revenue].cents.to_f / total_revenue * 100).round(2) : 0 - item - end - - results.sort_by { |i| -i[:revenue].cents }.first(10) + def init_query + @query = AccountingQuery.new( + start_date: @start_date, + end_date: @end_date + ) end - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength - def build_items_data - items = {} - - # Add articles - verified_sales_scope.includes(articles_sales: :article).find_each do |sale| - sale.articles_sales.each do |as| - article = as.article - key = "article_#{article.id}" - items[key] ||= { - name: article.name, - type: 'Article', - quantity: 0, - revenue: Money.zero - } - - items[key][:quantity] += as.quantity - items[key][:revenue] += article.price * as.quantity - end - end - - # Add subscription offers - verified_sales_scope.includes(sales_subscription_offers: :subscription_offer).find_each do |sale| - sale.sales_subscription_offers.each do |sso| - offer = sso.subscription_offer - key = "subscription_#{offer.id}" - items[key] ||= { - name: "#{offer.duration} mois", - type: 'Subscription', - quantity: 0, - revenue: Money.zero - } - - items[key][:quantity] += sso.quantity - items[key][:revenue] += offer.price * sso.quantity - end - end - - items - end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - def recent_sales_list - verified_sales_scope.includes(:client, :seller, :payment_method, - :articles_sales, :sales_subscription_offers, - articles: [], subscription_offers: []) - .order(created_at: :desc) - .limit(5) - .map do |sale| + Sale.where(created_at: @start_date..@end_date) + .where.not(verified_at: nil) + .includes(:client, :seller, :payment_method) + .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, - items_count: sale.articles_sales.count + sale.sales_subscription_offers.count + total: sale.total_price } end end - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity - def customer_metrics - sales = verified_sales_scope.includes(:client) - clients = sales.map(&:client).uniq - - new_customers = clients.count do |client| - first_sale = client.sales.where.not(verified_at: nil).minimum(:created_at) - first_sale && first_sale >= @start_date - end - - customer_revenues = sales.group_by(&:client) - .transform_values { |s| s.sum(Money.zero, &:total_price) } - - avg_ltv = - customer_revenues.any? ? customer_revenues.values.sum / customer_revenues.size : Money.zero + def set_date_range + @period = params[:period] || 'current_month' - { - new_customers: new_customers, - total_customers: clients.size, - avg_lifetime_value: avg_ltv, - top_customers: customer_revenues.sort_by { |_, r| -r.cents }.first(10).map do |customer, revenue| - { - name: customer.display_name, - revenue: revenue, - transaction_count: sales.count { |s| s.client == customer } - } + @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, Metrics/PerceivedComplexity - - def sales_by_seller - seller_sales = verified_sales_scope.includes(:seller).select(&:seller).group_by(&:seller) - results = seller_sales.map do |seller, sales| - { - name: seller.display_name, - count: sales.size, - revenue: sales.sum(Money.zero, &:total_price) - } - end - results.sort_by { |s| -s[:revenue].cents } - end - - def generate_csv_data - CSV.generate(headers: true) do |csv| - csv << csv_headers - export_sales_to_csv(csv) - export_refunds_to_csv(csv) - end - end - - def csv_headers - ['Date', 'Sale ID', 'Client', 'Seller', 'Payment Method', - 'Item Type', 'Item Name', 'Quantity', 'Unit Price', 'Line Total', 'Sale Total'] - end - - def export_sales_to_csv(csv) - sales_for_export.find_each do |sale| - export_sale_articles(csv, sale) - export_sale_subscriptions(csv, sale) - end - end - - def export_refunds_to_csv(csv) - refunds_for_export.find_each do |refund| - export_refund_articles(csv, refund) - export_refund_subscriptions(csv, refund) - end - end - - def sales_for_export - verified_sales_scope.includes(:articles_sales, :sales_subscription_offers, - :client, :seller, :payment_method, - articles: [], subscription_offers: []) - end - - def refunds_for_export - Refund.where(sale_id: sales_for_export.select(:id)) - .where(created_at: @start_date..@end_date) - .includes(:articles_refunds, :refunds_subscription_offers, - :refunder, :refund_method, :sale, - articles: [], subscription_offers: []) - end - - def export_sale_articles(csv, sale) - sale.articles_sales.each do |as| - csv << sale_row(sale, 'Article', as.article.name, as.quantity, - as.article.price, as.article.price * as.quantity) - end - end - - def export_sale_subscriptions(csv, sale) - sale.sales_subscription_offers.each do |sso| - name = "#{sso.subscription_offer.duration} months" - csv << sale_row(sale, 'Subscription', name, sso.quantity, - sso.subscription_offer.price, sso.subscription_offer.price * sso.quantity) - end - end - - def export_refund_articles(csv, refund) - refund.articles_refunds.each do |ar| - csv << refund_row(refund, 'Article (Refund)', ar.article.name, -ar.quantity, - ar.article.price, -(ar.article.price * ar.quantity)) - end - end - - def export_refund_subscriptions(csv, refund) - refund.refunds_subscription_offers.each do |rso| - name = "#{rso.subscription_offer.duration} months" - csv << refund_row(refund, 'Subscription (Refund)', name, -rso.quantity, - rso.subscription_offer.price, -(rso.subscription_offer.price * rso.quantity)) - end - end - - # rubocop:disable Metrics/ParameterLists - def sale_row(sale, item_type, item_name, quantity, unit_price, line_total) - [ - sale.created_at.strftime('%Y-%m-%d %H:%M'), - sale.id, - sale.client.display_name, - sale.seller&.display_name || 'N/A', - sale.payment_method.name, - item_type, - item_name, - quantity, - format_money(unit_price), - format_money(line_total), - format_money(sale.total_price) - ] - end - - # rubocop:enable Metrics/ParameterLists - - # rubocop:disable Metrics/ParameterLists - def refund_row(refund, item_type, item_name, quantity, unit_price, line_total) - [ - refund.created_at.strftime('%Y-%m-%d %H:%M'), - refund.sale.id, - refund.sale.client.display_name, - refund.refunder&.display_name || 'N/A', - refund.refund_method.name, - item_type, - item_name, - quantity, - format_money(unit_price), - format_money(line_total), - format_money(-refund.total_price) - ] - end - - # rubocop:enable Metrics/ParameterLists - - def format_money(money) - format('%.2f', money.cents / 100.0) - end - - # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity - def date_range_for_period(period) - 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' - custom_date_range - else - [Time.zone.now.beginning_of_month, Time.zone.now.end_of_month] - end - end - - # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity - - def custom_date_range - start_date = params[:start_date].present? ? Time.zone.parse(params[:start_date]) : default_start_date - end_date = params[:end_date].present? ? Time.zone.parse(params[:end_date]) : default_end_date - [start_date, end_date] - end - - def default_start_date - Time.zone.now.beginning_of_month - end - - def default_end_date - Time.zone.now.end_of_month end end - - # rubocop:enable Metrics/ClassLength end diff --git a/app/queries/accounting_query.rb b/app/queries/accounting_query.rb new file mode 100644 index 00000000..5d276ecd --- /dev/null +++ b/app/queries/accounting_query.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +class AccountingQuery + def initialize(start_date:, end_date:) + @start_date = start_date + @end_date = end_date + end + + def verified_sales + Sale + .where(created_at: @start_date..@end_date) + .where.not(verified_at: nil) + end + + def kpis + total_revenue_cents = verified_sales + .joins(articles_total_join) + .joins(subscriptions_total_join) + .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + + 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 + .joins(articles_total_join) + .joins(subscriptions_total_join) + .group('DATE(sales.created_at)') + .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + + (@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) + .joins(articles_total_join) + .joins(subscriptions_total_join) + .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.id) AS count', + 'SUM(COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)) 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(:sales) + .where.not(sales: { verified_at: nil }) + .group('users.id') + .having('MIN(sales.created_at) >= ?', @start_date) + .count + .size + + revenues = verified_sales + .joins(articles_total_join) + .joins(subscriptions_total_join) + .group(:client_id) + .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + + avg_ltv = revenues.any? ? Money.new(revenues.values.sum / revenues.size) : Money.zero + + top_customers = revenues + .sort_by { |_client_id, total| -total } + .first(10) + .map do |client_id, total| + client = User.find(client_id) + { + name: client.display_name, + revenue: Money.new(total), + transaction_count: verified_sales.where(client_id: client_id).count + } + end + + { + new_customers: new_customers, + total_customers: total_customers, + avg_lifetime_value: avg_ltv, + top_customers: top_customers + } + end + + def sales_by_seller + verified_sales + .joins(:seller) + .joins(articles_total_join) + .joins(subscriptions_total_join) + .group(:seller_id) + .select( + 'sales.seller_id AS seller_id', + 'COUNT(sales.id) AS count', + 'SUM(COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)) AS revenue' + ) + .order('revenue DESC') + .first(8) + .map do |r| + user = User.find(r.seller_id) + { + name: user.display_name, + count: r.count.to_i, + revenue: Money.new(r.revenue) + } + end + end + + private + + def articles_total_join + <<~SQL.squish + 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 + SQL + end + + def subscriptions_total_join + <<~SQL.squish + 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 + + 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 + + Sale + .where(created_at: (@start_date - period_length)..@start_date) + .where.not(verified_at: nil) + .joins(articles_total_join) + .joins(subscriptions_total_join) + .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + 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 diff --git a/app/views/admin/accounting/index.html.erb b/app/views/admin/accounting/index.html.erb index c12384ac..7bdfb72c 100644 --- a/app/views/admin/accounting/index.html.erb +++ b/app/views/admin/accounting/index.html.erb @@ -282,7 +282,7 @@ - <% @sales_by_seller.each do |seller| %> + <% @sales_by_seller.first(8).each do |seller| %> <%= seller[:name] %> <%= number_with_delimiter(seller[:count]) %> diff --git a/db/seeds/perfs.rb b/db/seeds/perfs.rb new file mode 100644 index 00000000..bd56914c --- /dev/null +++ b/db/seeds/perfs.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +puts '๐Ÿ”ฅ Seeding heavy dataset...' + +NOW = Time.current + +CLIENT_COUNT = 10_000 +SELLER_COUNT = 30 +PAYMENT_METHODS = 4 +ARTICLES_COUNT = 3 +SALES_COUNT = 60_000 + +# ------------------------------------------------- +# Helpers +# ------------------------------------------------- + +def random_date(range = 12.months) + NOW - rand(range) +end + +def rand_quantity + rand(1..3) +end + +# ------------------------------------------------- +# Users +# ------------------------------------------------- + +puts '๐Ÿ‘ค 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 +# ------------------------------------------------- + +puts '๐Ÿ’ณ 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 +# ------------------------------------------------- + +puts '๐Ÿ“ฆ Creating articles...' + +articles = Array.new(ARTICLES_COUNT) do |i| + { + name: "Article #{i}", + price_cents: rand(500..10_000), + created_at: NOW, + updated_at: NOW + } +end + +Article.insert_all!(articles) +article_ids = Article.pluck(:id) + +# ------------------------------------------------- +# Subscription offers +# ------------------------------------------------- + +puts '๐Ÿ“† Creating subscription offers...' + +subscription_offers = [1, 3, 6, 12, 18, 24].map do |months| + { + duration: months, + price_cents: months * rand(800..1_500), + created_at: NOW, + updated_at: NOW + } +end + +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 + +puts '๐Ÿงพ 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 +# ------------------------------------------------- + +puts '๐Ÿ›’ 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 (FIXED) +# ------------------------------------------------- + +puts '๐Ÿ“ฆ Linking articles to sales...' + +articles_sales = [] + +sale_ids.each do |sale_id| + article_ids + .sample(rand(1..ARTICLES_COUNT)) # ๐Ÿ”‘ UNIQUES + .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 +# ------------------------------------------------- + +puts '๐Ÿ“† 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: 1 + } +end + +SalesSubscriptionOffer.insert_all!(subscriptions_sales) + +puts 'โœ… Heavy seed done!' From 630fb90fda167fd52e34e4e6e3f62e93172fef99 Mon Sep 17 00:00:00 2001 From: David Marembert Date: Fri, 2 Jan 2026 14:53:38 +0100 Subject: [PATCH 3/7] feat: export csv --- .../admin/accounting_controller.rb | 47 ++++++ app/queries/accounting_query.rb | 2 +- app/queries/csv_export_query.sql | 141 ++++++++++++++++++ app/views/admin/accounting/index.html.erb | 2 +- config/initializers/chartkick.rb | 5 +- db/seeds/perfs.rb | 50 ++++--- 6 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 app/queries/csv_export_query.sql diff --git a/app/controllers/admin/accounting_controller.rb b/app/controllers/admin/accounting_controller.rb index 7126cb92..b3f01194 100644 --- a/app/controllers/admin/accounting_controller.rb +++ b/app/controllers/admin/accounting_controller.rb @@ -17,6 +17,14 @@ def index @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 @@ -68,5 +76,44 @@ def set_date_range [Time.zone.now.beginning_of_month, Time.zone.now.end_of_month] end end + + def generate_csv_data + CSV.generate(headers: true) do |csv| + csv << csv_headers + + sql_query = Rails.root.join('app/queries/csv_export_query.sql').read + sanitized_query = ActiveRecord::Base.sanitize_sql_array([ + sql_query, + { 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 + + 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/queries/accounting_query.rb b/app/queries/accounting_query.rb index 5d276ecd..a03a5aa3 100644 --- a/app/queries/accounting_query.rb +++ b/app/queries/accounting_query.rb @@ -216,7 +216,7 @@ def previous_period_revenue period_length = @end_date - @start_date Sale - .where(created_at: (@start_date - period_length)..@start_date) + .where(created_at: (@start_date - period_length)...@start_date) .where.not(verified_at: nil) .joins(articles_total_join) .joins(subscriptions_total_join) 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 index 7bdfb72c..2f2f3d45 100644 --- a/app/views/admin/accounting/index.html.erb +++ b/app/views/admin/accounting/index.html.erb @@ -99,7 +99,7 @@

Revenue Over Time

- <%= column_chart @revenue_data, suffix: ' โ‚ฌ', thousands: ' ', height: '350px' %> + <%= column_chart @revenue_data, suffix: ' โ‚ฌ', height: '350px' %>
diff --git a/config/initializers/chartkick.rb b/config/initializers/chartkick.rb index e685d676..b7a86645 100644 --- a/config/initializers/chartkick.rb +++ b/config/initializers/chartkick.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -# Configure Chartkick to use Chart.js Chartkick.options = { - adapter: 'chartjs' + adapter: 'chartjs', + thousands: ' ', + decimal: ',' } diff --git a/db/seeds/perfs.rb b/db/seeds/perfs.rb index bd56914c..f762856f 100644 --- a/db/seeds/perfs.rb +++ b/db/seeds/perfs.rb @@ -1,18 +1,14 @@ # frozen_string_literal: true -puts '๐Ÿ”ฅ Seeding heavy dataset...' +Rails.logger.info 'Seeding heavy dataset...' NOW = Time.current -CLIENT_COUNT = 10_000 +CLIENT_COUNT = 3_000 SELLER_COUNT = 30 PAYMENT_METHODS = 4 ARTICLES_COUNT = 3 -SALES_COUNT = 60_000 - -# ------------------------------------------------- -# Helpers -# ------------------------------------------------- +SALES_COUNT = 10_000 def random_date(range = 12.months) NOW - rand(range) @@ -26,7 +22,7 @@ def rand_quantity # Users # ------------------------------------------------- -puts '๐Ÿ‘ค Creating users...' +Rails.logger.info 'Creating users...' clients = Array.new(CLIENT_COUNT) do |i| { @@ -62,7 +58,7 @@ def rand_quantity # Payment methods # ------------------------------------------------- -puts '๐Ÿ’ณ Creating payment methods...' +Rails.logger.info 'Creating payment methods...' payment_methods = Array.new(PAYMENT_METHODS) do |i| { @@ -80,12 +76,12 @@ def rand_quantity # Articles # ------------------------------------------------- -puts '๐Ÿ“ฆ Creating articles...' +Rails.logger.info 'Creating articles...' articles = Array.new(ARTICLES_COUNT) do |i| { name: "Article #{i}", - price_cents: rand(500..10_000), + price_cents: rand(100..5_000), created_at: NOW, updated_at: NOW } @@ -98,16 +94,22 @@ def rand_quantity # Subscription offers # ------------------------------------------------- -puts '๐Ÿ“† Creating subscription offers...' +Rails.logger.info 'Creating subscription offers...' -subscription_offers = [1, 3, 6, 12, 18, 24].map do |months| +subscription_offers = [ { - duration: months, - price_cents: months * rand(800..1_500), + duration: 1, + price_cents: 500, + created_at: NOW, + updated_at: NOW + }, + { + duration: 12, + price_cents: 5_000, created_at: NOW, updated_at: NOW } -end +] SubscriptionOffer.insert_all!(subscription_offers) subscription_offer_ids = SubscriptionOffer.pluck(:id) @@ -121,7 +123,7 @@ def next_invoice_id @invoice_seq += 1 end -puts '๐Ÿงพ Creating invoices...' +Rails.logger.info 'Creating invoices...' invoices = Array.new(SALES_COUNT) do { @@ -139,7 +141,7 @@ def next_invoice_id # Sales # ------------------------------------------------- -puts '๐Ÿ›’ Creating sales...' +Rails.logger.info 'Creating sales...' sales = Array.new(SALES_COUNT) do created_at = random_date @@ -159,16 +161,16 @@ def next_invoice_id sale_ids = Sale.pluck(:id) # ------------------------------------------------- -# Articles sales (FIXED) +# Articles sales # ------------------------------------------------- -puts '๐Ÿ“ฆ Linking articles to sales...' +Rails.logger.info 'Linking articles to sales...' articles_sales = [] sale_ids.each do |sale_id| article_ids - .sample(rand(1..ARTICLES_COUNT)) # ๐Ÿ”‘ UNIQUES + .sample(rand(1..ARTICLES_COUNT)) .each do |article_id| articles_sales << { sale_id: sale_id, @@ -184,16 +186,16 @@ def next_invoice_id # Subscription sales # ------------------------------------------------- -puts '๐Ÿ“† Linking subscriptions to 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: 1 + quantity: rand_quantity } end SalesSubscriptionOffer.insert_all!(subscriptions_sales) -puts 'โœ… Heavy seed done!' +Rails.logger.info 'Heavy seed done!' From dbe7ef7c9f009665db84813ce7c97bf9fa49040e Mon Sep 17 00:00:00 2001 From: David Marembert Date: Fri, 2 Jan 2026 19:23:22 +0100 Subject: [PATCH 4/7] fix: n+1 queries --- .../admin/accounting_controller.rb | 4 +- app/controllers/users_controller.rb | 2 +- app/queries/accounting_query.rb | 53 +++++++++++-------- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/app/controllers/admin/accounting_controller.rb b/app/controllers/admin/accounting_controller.rb index b3f01194..6b308f46 100644 --- a/app/controllers/admin/accounting_controller.rb +++ b/app/controllers/admin/accounting_controller.rb @@ -37,7 +37,9 @@ def init_query def recent_sales_list Sale.where(created_at: @start_date..@end_date) .where.not(verified_at: nil) - .includes(:client, :seller, :payment_method) + .includes(:client, :seller, :payment_method, + :articles_sales, :sales_subscription_offers, + articles: [], subscription_offers: []) .order(created_at: :desc) .limit(5) .map do |sale| 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/queries/accounting_query.rb b/app/queries/accounting_query.rb index a03a5aa3..658654ed 100644 --- a/app/queries/accounting_query.rb +++ b/app/queries/accounting_query.rb @@ -123,15 +123,19 @@ def customer_metrics avg_ltv = revenues.any? ? Money.new(revenues.values.sum / revenues.size) : Money.zero - top_customers = revenues - .sort_by { |_client_id, total| -total } - .first(10) - .map do |client_id, total| - client = User.find(client_id) + 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: client.display_name, - revenue: Money.new(total), - transaction_count: verified_sales.where(client_id: client_id).count + name: clients[client_id].display_name, + revenue: Money.new(revenues[client_id]), + transaction_count: transaction_counts[client_id] || 0 } end @@ -144,22 +148,25 @@ def customer_metrics end def sales_by_seller - verified_sales - .joins(:seller) - .joins(articles_total_join) - .joins(subscriptions_total_join) - .group(:seller_id) - .select( - 'sales.seller_id AS seller_id', - 'COUNT(sales.id) AS count', - 'SUM(COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)) AS revenue' - ) - .order('revenue DESC') - .first(8) - .map do |r| - user = User.find(r.seller_id) + results = verified_sales + .joins(:seller) + .joins(articles_total_join) + .joins(subscriptions_total_join) + .group(:seller_id) + .select( + 'sales.seller_id AS seller_id', + 'COUNT(sales.id) AS count', + 'SUM(COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)) 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: user.display_name, + name: sellers[r.seller_id].display_name, count: r.count.to_i, revenue: Money.new(r.revenue) } From 35537cf1a574530b6a5aa91c615b2a7087f53538 Mon Sep 17 00:00:00 2001 From: David Marembert Date: Sat, 3 Jan 2026 09:01:06 +0100 Subject: [PATCH 5/7] fix: clean imports --- Gemfile | 10 ++++------ Gemfile.lock | 4 ---- app/javascript/application.js | 4 +--- config/application.rb | 1 - 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index 7c3858ea..58596c22 100644 --- a/Gemfile +++ b/Gemfile @@ -55,6 +55,10 @@ 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' + 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' @@ -92,9 +96,3 @@ group :test do gem 'simplecov-cobertura', '~> 3.0', require: false gem 'webmock', '~> 3.23' end - -gem 'chartkick', '~> 5.2' - -gem 'groupdate', '~> 6.7' - -gem 'csv', '~> 3.3' diff --git a/Gemfile.lock b/Gemfile.lock index aacd3905..819fdc7c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -133,8 +133,6 @@ GEM geom2d (0.4.1) globalid (1.2.1) activesupport (>= 6.1) - groupdate (6.7.0) - activesupport (>= 7.1) guard (2.19.1) formatador (>= 0.2.4) listen (>= 2.7, < 4.0) @@ -467,7 +465,6 @@ DEPENDENCIES chartkick (~> 5.2) csv (~> 3.3) debug - groupdate (~> 6.7) guard (~> 2.19) guard-minitest (~> 2.4) hexapdf (~> 1.4.0) @@ -552,7 +549,6 @@ CHECKSUMS formatador (1.1.0) sha256=54e23e2af4d60bb9327c7fac62b29968e4cf28cee0111f726d0bdeadc85e06d0 geom2d (0.4.1) sha256=ea0998ea90c4f2752e24fe13d85a4f89bee689d151316140ebcc6369bf634ed9 globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 - groupdate (6.7.0) sha256=beaa8d5bf3856814681914a1d4a20e77436a2214b85d0017dc2ea5c355fb6777 guard (2.19.1) sha256=b8bc52694be3d8b26730280de7dcec7fe92ea1cff3414246fe96af3f23580f3d guard-compat (1.2.1) sha256=3ad21ab0070107f92edfd82610b5cdc2fb8e368851e72362ada9703443d646fe guard-minitest (2.4.6) sha256=d89e83d029447c13b191599085d24b6e2fe61e402d275e46491cd3e82f561572 diff --git a/app/javascript/application.js b/app/javascript/application.js index bf9c8452..0f73d0f4 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,7 +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 "chart.js" -//import "chartkick" import "chartkick" -import "Chart.bundle" \ No newline at end of file +import "Chart.bundle" diff --git a/config/application.rb b/config/application.rb index dde51638..e6742a90 100644 --- a/config/application.rb +++ b/config/application.rb @@ -15,7 +15,6 @@ require 'action_view/railtie' # require "action_cable/engine" require 'rails/test_unit/railtie' -require 'csv' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. From 2eaa02b1bf53387b47f714b86f967c589cb4b451 Mon Sep 17 00:00:00 2001 From: David Marembert Date: Sat, 17 Jan 2026 09:17:32 +0100 Subject: [PATCH 6/7] wip: database view --- Gemfile | 1 + Gemfile.lock | 7 +- app/models/sales_with_total.rb | 27 +++++++ app/queries/accounting_query.rb | 70 ++++--------------- ...20260103170240_create_sales_with_totals.rb | 5 ++ db/schema.rb | 26 ++++++- db/views/sales_with_totals_v01.sql | 28 ++++++++ 7 files changed, 107 insertions(+), 57 deletions(-) create mode 100644 app/models/sales_with_total.rb create mode 100644 db/migrate/20260103170240_create_sales_with_totals.rb create mode 100644 db/views/sales_with_totals_v01.sql diff --git a/Gemfile b/Gemfile index 58596c22..915bd8a0 100644 --- a/Gemfile +++ b/Gemfile @@ -58,6 +58,7 @@ 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 diff --git a/Gemfile.lock b/Gemfile.lock index 819fdc7c..c04e5890 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -383,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) @@ -489,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) @@ -637,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 @@ -669,4 +674,4 @@ CHECKSUMS zeitwerk (2.7.3) sha256=b2e86b4a9b57d26ba68a15230dcc7fe6f040f06831ce64417b0621ad96ba3e85 BUNDLED WITH - 4.0.3 + 4.0.3 diff --git a/app/models/sales_with_total.rb b/app/models/sales_with_total.rb new file mode 100644 index 00000000..8d7debd6 --- /dev/null +++ b/app/models/sales_with_total.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class SalesWithTotal < ApplicationRecord + self.primary_key = :id + + + belongs_to :client, class_name: 'User', foreign_key: :client_id + belongs_to :seller, class_name: 'User', foreign_key: :seller_id, 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/queries/accounting_query.rb b/app/queries/accounting_query.rb index 658654ed..2ee40735 100644 --- a/app/queries/accounting_query.rb +++ b/app/queries/accounting_query.rb @@ -7,16 +7,13 @@ def initialize(start_date:, end_date:) end def verified_sales - Sale + SalesWithTotal .where(created_at: @start_date..@end_date) .where.not(verified_at: nil) end def kpis - total_revenue_cents = verified_sales - .joins(articles_total_join) - .joins(subscriptions_total_join) - .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + total_revenue_cents = verified_sales.sum(:total_cents) transaction_count = verified_sales.count @@ -47,10 +44,8 @@ def kpis def revenue_by_date raw = verified_sales - .joins(articles_total_join) - .joins(subscriptions_total_join) - .group('DATE(sales.created_at)') - .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + .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 @@ -60,14 +55,12 @@ def revenue_by_date def payment_methods verified_sales .joins(:payment_method) - .joins(articles_total_join) - .joins(subscriptions_total_join) .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.id) AS count', - 'SUM(COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)) AS amount' + 'COUNT(sales_with_totals.id) AS count', + 'SUM(sales_with_totals.total_cents) AS amount' ) .order('amount DESC') .map do |r| @@ -108,18 +101,16 @@ def customer_metrics total_customers = verified_sales.select(:client_id).distinct.count new_customers = User - .joins(:sales) - .where.not(sales: { verified_at: nil }) + .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.created_at) >= ?', @start_date) + .having('MIN(sales_with_totals.created_at) >= ?', @start_date) .count .size revenues = verified_sales - .joins(articles_total_join) - .joins(subscriptions_total_join) .group(:client_id) - .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + .sum(:total_cents) avg_ltv = revenues.any? ? Money.new(revenues.values.sum / revenues.size) : Money.zero @@ -150,13 +141,11 @@ def customer_metrics def sales_by_seller results = verified_sales .joins(:seller) - .joins(articles_total_join) - .joins(subscriptions_total_join) .group(:seller_id) .select( - 'sales.seller_id AS seller_id', - 'COUNT(sales.id) AS count', - 'SUM(COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)) AS revenue' + '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) @@ -175,33 +164,6 @@ def sales_by_seller private - def articles_total_join - <<~SQL.squish - 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 - SQL - end - - def subscriptions_total_join - <<~SQL.squish - 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 - def subscription_stats_sql row = SalesSubscriptionOffer .joins(:sale, :subscription_offer) @@ -222,12 +184,10 @@ def subscription_stats_sql def previous_period_revenue period_length = @end_date - @start_date - Sale + SalesWithTotal .where(created_at: (@start_date - period_length)...@start_date) .where.not(verified_at: nil) - .joins(articles_total_join) - .joins(subscriptions_total_join) - .sum('COALESCE(articles_totals.total,0) + COALESCE(subscriptions_totals.total,0)') + .sum(:total_cents) end def article_items 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..bbcc2ef1 --- /dev/null +++ b/db/migrate/20260103170240_create_sales_with_totals.rb @@ -0,0 +1,5 @@ +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/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 From d5b611d2132c9f63a975d4ac251d41bbd19c66f6 Mon Sep 17 00:00:00 2001 From: David Marembert Date: Wed, 18 Feb 2026 14:02:31 +0100 Subject: [PATCH 7/7] fix: rubocop --- app/controllers/admin/accounting_controller.rb | 7 +++++-- app/models/sales_with_total.rb | 5 ++--- app/queries/accounting_query.rb | 2 ++ db/migrate/20260103170240_create_sales_with_totals.rb | 2 ++ db/seeds/perfs.rb | 2 ++ 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/controllers/admin/accounting_controller.rb b/app/controllers/admin/accounting_controller.rb index 6b308f46..daa0c37e 100644 --- a/app/controllers/admin/accounting_controller.rb +++ b/app/controllers/admin/accounting_controller.rb @@ -54,6 +54,7 @@ def recent_sales_list end end + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength def set_date_range @period = params[:period] || 'current_month' @@ -78,14 +79,15 @@ def set_date_range [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 - sql_query = Rails.root.join('app/queries/csv_export_query.sql').read sanitized_query = ActiveRecord::Base.sanitize_sql_array([ - sql_query, + Rails.root.join('app/queries/csv_export_query.sql').read, { start_date: @start_date, end_date: @end_date } ]) @@ -108,6 +110,7 @@ def generate_csv_data end end end + # rubocop:enable Metrics/MethodLength def csv_headers ['Date', 'Sale ID', 'Client', 'Seller', 'Payment Method', diff --git a/app/models/sales_with_total.rb b/app/models/sales_with_total.rb index 8d7debd6..fad1c6ea 100644 --- a/app/models/sales_with_total.rb +++ b/app/models/sales_with_total.rb @@ -3,9 +3,8 @@ class SalesWithTotal < ApplicationRecord self.primary_key = :id - - belongs_to :client, class_name: 'User', foreign_key: :client_id - belongs_to :seller, class_name: 'User', foreign_key: :seller_id, optional: true + belongs_to :client, class_name: 'User' + belongs_to :seller, class_name: 'User', optional: true belongs_to :payment_method attribute :total_cents diff --git a/app/queries/accounting_query.rb b/app/queries/accounting_query.rb index 2ee40735..8ea46172 100644 --- a/app/queries/accounting_query.rb +++ b/app/queries/accounting_query.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Style/MultilineBlockChain class AccountingQuery def initialize(start_date:, end_date:) @start_date = start_date @@ -234,3 +235,4 @@ def subscription_items end end end +# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Style/MultilineBlockChain diff --git a/db/migrate/20260103170240_create_sales_with_totals.rb b/db/migrate/20260103170240_create_sales_with_totals.rb index bbcc2ef1..4f80adbe 100644 --- a/db/migrate/20260103170240_create_sales_with_totals.rb +++ b/db/migrate/20260103170240_create_sales_with_totals.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class CreateSalesWithTotals < ActiveRecord::Migration[7.2] def change create_view :sales_with_totals diff --git a/db/seeds/perfs.rb b/db/seeds/perfs.rb index f762856f..7ea49ad6 100644 --- a/db/seeds/perfs.rb +++ b/db/seeds/perfs.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Rails/SkipsModelValidations Rails.logger.info 'Seeding heavy dataset...' NOW = Time.current @@ -199,3 +200,4 @@ def next_invoice_id SalesSubscriptionOffer.insert_all!(subscriptions_sales) Rails.logger.info 'Heavy seed done!' +# rubocop:enable Rails/SkipsModelValidations