diff --git a/app/models/applied_usage_threshold.rb b/app/models/applied_usage_threshold.rb new file mode 100644 index 00000000000..fecd2e1b827 --- /dev/null +++ b/app/models/applied_usage_threshold.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class AppliedUsageThreshold < ApplicationRecord + belongs_to :usage_threshold, -> { with_discarded } + belongs_to :invoice + + validates :usage_threshold_id, uniqueness: {scope: :invoice_id} + + monetize :lifetime_usage_amount_cents, + with_currency: ->(applied_usage_threshold) { applied_usage_threshold.invoice.currency } +end + +# == Schema Information +# +# Table name: applied_usage_thresholds +# +# id :uuid not null, primary key +# lifetime_usage_amount_cents :bigint default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# usage_threshold_id :uuid not null +# +# Indexes +# +# idx_on_usage_threshold_id_invoice_id_cb82cdf163 (usage_threshold_id,invoice_id) UNIQUE +# index_applied_usage_thresholds_on_invoice_id (invoice_id) +# index_applied_usage_thresholds_on_usage_threshold_id (usage_threshold_id) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (usage_threshold_id => usage_thresholds.id) +# diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 2644aa6c8cd..a628a76803c 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -34,6 +34,9 @@ class Invoice < ApplicationRecord has_many :payment_requests, through: :applied_payment_requests has_many :payments, as: :payable + has_many :applied_usage_thresholds + has_many :usage_thresholds, through: :applied_usage_thresholds + has_one_attached :file monetize :coupons_amount_cents, diff --git a/app/models/lifetime_usage.rb b/app/models/lifetime_usage.rb index bacb1b270d9..44dc38d3f05 100644 --- a/app/models/lifetime_usage.rb +++ b/app/models/lifetime_usage.rb @@ -14,11 +14,16 @@ class LifetimeUsage < ApplicationRecord monetize :current_usage_amount_cents, :invoiced_usage_amount_cents, + :historical_usage_amount_cents, with_currency: ->(lifetime_usage) { lifetime_usage.subscription.plan.amount_currency } default_scope -> { kept } scope :needs_recalculation, -> { where(recalculate_current_usage: true).or(where(recalculate_invoiced_usage: true)) } + + def total_amount_cents + historical_usage_amount_cents + invoiced_usage_amount_cents + current_usage_amount_cents + end end # == Schema Information diff --git a/app/models/usage_threshold.rb b/app/models/usage_threshold.rb index 0db739b7e01..165fad404cf 100644 --- a/app/models/usage_threshold.rb +++ b/app/models/usage_threshold.rb @@ -8,6 +8,9 @@ class UsageThreshold < ApplicationRecord belongs_to :plan + has_many :applied_usage_thresholds + has_many :invoices, through: :applied_usage_thresholds + monetize :amount_cents, with_currency: ->(threshold) { threshold.plan.amount_currency } validates :amount_cents, numericality: {greater_than: 0} diff --git a/app/services/invoices/progressive_billing_service.rb b/app/services/invoices/progressive_billing_service.rb index fd7b5fd17f2..16e59f08d1f 100644 --- a/app/services/invoices/progressive_billing_service.rb +++ b/app/services/invoices/progressive_billing_service.rb @@ -14,6 +14,7 @@ def call ActiveRecord::Base.transaction do create_generating_invoice create_fees + create_applied_usage_thresholds invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents @@ -107,6 +108,16 @@ def boundaries } end + def create_applied_usage_thresholds + usage_thresholds.each do + AppliedUsageThreshold.create!( + invoice:, + usage_threshold: _1, + lifetime_usage_amount_cents: lifetime_usage.total_amount_cents + ) + end + end + def should_deliver_email? License.premium? && subscription.organization.email_settings.include?('invoice.finalized') end diff --git a/app/views/templates/invoices/v4.slim b/app/views/templates/invoices/v4.slim index 55ef494e608..f48a744716a 100644 --- a/app/views/templates/invoices/v4.slim +++ b/app/views/templates/invoices/v4.slim @@ -462,6 +462,11 @@ html == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + - if progressive_billing? + p.body-3.mb-24 + - applied_usage_threshold = applied_usage_thresholds.order(created_at: :asc).last + = I18n.t('invoice.reached_usage_threshold', usage_amount: MoneyHelper. applied_usage_threshold.lifetime_usage_amount, threshold_amount: MoneyHelper.format(applied_usage_threshold.threshold.amount_cents)) + p.body-3.mb-24 = LineBreakHelper.break_lines(organization.invoice_footer) .powered-by diff --git a/config/locales/de/invoice.yml b/config/locales/de/invoice.yml index 1ca5db5ff7a..6f148e46c2e 100644 --- a/config/locales/de/invoice.yml +++ b/config/locales/de/invoice.yml @@ -55,6 +55,7 @@ de: progressive_billing_credit: Nutzung bereits abgerechnet quarter: quartal quarterly: Vierteljährlich + reached_usage_threshold: Diese progressive Rechnung wird erstellt, da Ihre kumulierte Nutzung %{usage_amount} erreicht hat und den Schwellenwert von %{threshold_amount} überschritten hat. see_breakdown: Siehe Aufschlüsselung für Gesamtübersicht sub_total: Zwischensumme sub_total_with_tax: Zwischensumme (inkl. Steuern) diff --git a/config/locales/en/invoice.yml b/config/locales/en/invoice.yml index 56b66c61f9b..a285c5840e4 100644 --- a/config/locales/en/invoice.yml +++ b/config/locales/en/invoice.yml @@ -55,6 +55,7 @@ en: progressive_billing_credit: Usage already billed quarter: quarter quarterly: Quarterly + reached_usage_threshold: This progressive billing is generated because your cumulative usage has reached %{usage_amount}, exceeding the %{threshold_amount} threshold. see_breakdown: See breakdown for total unit sub_total: Subtotal sub_total_with_tax: Subtotal (incl. tax) diff --git a/config/locales/es/invoice.yml b/config/locales/es/invoice.yml index e8501f4542f..f744419031d 100644 --- a/config/locales/es/invoice.yml +++ b/config/locales/es/invoice.yml @@ -54,6 +54,7 @@ es: progressive_billing_credit: Uso ya facturado quarter: trimestre quarterly: Trimestral + reached_usage_threshold: Esta facturación progresiva se genera porque su uso acumulado ha alcanzado los %{usage_amount}, superando el umbral de %{threshold_amount}. see_breakdown: Consulte el desglose a continuación sub_total: Subtotal sub_total_with_tax: Subtotal (impuestos incl.) diff --git a/config/locales/fr/invoice.yml b/config/locales/fr/invoice.yml index 70862d7ea80..693a4dc0920 100644 --- a/config/locales/fr/invoice.yml +++ b/config/locales/fr/invoice.yml @@ -55,6 +55,7 @@ fr: progressive_billing_credit: Usage déjà facturé quarter: trimestre quarterly: trimestriellement + reached_usage_threshold: Cette facturation progressive est générée car votre usage cumulé a atteint %{usage_amount}, dépassant le seuil de %{threshold_amount}. see_breakdown: Consultez le détail ci-après sub_total: Sous total sub_total_with_tax: Sous total (TTC) diff --git a/config/locales/it/invoice.yml b/config/locales/it/invoice.yml index c637d25d2bf..3fa3fde2bd7 100644 --- a/config/locales/it/invoice.yml +++ b/config/locales/it/invoice.yml @@ -52,9 +52,10 @@ it: prepaid_credit_invoice: Fattura anticipata prepaid_credits: Crediti prepagati prepaid_credits_with_value: Crediti prepagati - %{wallet_name} - progressive_billing_credit: Utilizzo già fatturato + progressive_billing_credit: Uso già fatturato quarter: trimestre quarterly: Trimestrale + reached_usage_threshold: Questa fatturazione progressiva è generata poiché il tuo utilizzo cumulato ha raggiunto %{usage_amount}, superando la soglia di %{threshold_amount}. see_breakdown: Vedere la ripartizione per l'unità totale sub_total: Subtotale sub_total_with_tax: Subtotale (incl. tasse) diff --git a/config/locales/nb/invoice.yml b/config/locales/nb/invoice.yml index 2924c4a2626..04013023883 100644 --- a/config/locales/nb/invoice.yml +++ b/config/locales/nb/invoice.yml @@ -55,6 +55,7 @@ nb: progressive_billing_credit: Bruk allerede fakturert quarter: kvartal quarterly: Kvartalsvis + reached_usage_threshold: Denne progressive faktureringen er generert fordi din akkumulerte bruk har nådd %{usage_amount}, og overskredet terskelen på %{threshold_amount}. see_breakdown: Se oversikt for antall enheter sub_total: Sub total sub_total_with_tax: Sub total (inkl. MVA) diff --git a/config/locales/sv/invoice.yml b/config/locales/sv/invoice.yml index 894ac5742e9..f3972d05dca 100644 --- a/config/locales/sv/invoice.yml +++ b/config/locales/sv/invoice.yml @@ -51,9 +51,10 @@ sv: prepaid_credit_invoice: Förskottsfaktura prepaid_credits: Förbetald kontobalans prepaid_credits_with_value: Förbetald kontobalans - %{wallet_name} - progressive_billing_credit: Användning redan fakturerad + progressive_billing_credit: Redan fakturerad användning quarter: kvartal quarterly: Kvartalsvis + reached_usage_threshold: Denna progressiva fakturering skapas eftersom din ackumulerade användning har nått %{usage_amount} och överstiger %{threshold_amount} tröskeln. see_breakdown: Se uppdelning nedan sub_total: Delsumma sub_total_with_tax: Delsumma (inkl. moms) diff --git a/db/migrate/20240823092643_create_applied_usage_thresholds.rb b/db/migrate/20240823092643_create_applied_usage_thresholds.rb new file mode 100644 index 00000000000..e10c85e7aba --- /dev/null +++ b/db/migrate/20240823092643_create_applied_usage_thresholds.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateAppliedUsageThresholds < ActiveRecord::Migration[7.1] + def change + create_table :applied_usage_thresholds, id: :uuid do |t| + t.references :usage_threshold, null: false, foreign_key: true, type: :uuid + t.references :invoice, null: false, foreign_key: true, type: :uuid + t.bigint :lifetime_usage_amount_cents, null: false, default: 0 + + t.timestamps + + t.index %i[usage_threshold_id invoice_id], unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 949755929da..33b5180436d 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.1].define(version: 2024_08_22_142524) do +ActiveRecord::Schema[7.1].define(version: 2024_08_23_092643) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -129,6 +129,17 @@ t.index ["customer_id"], name: "index_applied_coupons_on_customer_id" end + create_table "applied_usage_thresholds", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "usage_threshold_id", null: false + t.uuid "invoice_id", null: false + t.bigint "lifetime_usage_amount_cents", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["invoice_id"], name: "index_applied_usage_thresholds_on_invoice_id" + t.index ["usage_threshold_id", "invoice_id"], name: "idx_on_usage_threshold_id_invoice_id_cb82cdf163", unique: true + t.index ["usage_threshold_id"], name: "index_applied_usage_thresholds_on_usage_threshold_id" + end + create_table "billable_metric_filters", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "billable_metric_id", null: false t.string "key", null: false @@ -1175,6 +1186,8 @@ add_foreign_key "adjusted_fees", "subscriptions" add_foreign_key "applied_add_ons", "add_ons" add_foreign_key "applied_add_ons", "customers" + add_foreign_key "applied_usage_thresholds", "invoices" + add_foreign_key "applied_usage_thresholds", "usage_thresholds" add_foreign_key "billable_metric_filters", "billable_metrics" add_foreign_key "billable_metrics", "organizations" add_foreign_key "cached_aggregations", "groups" diff --git a/spec/factories/applied_usage_thresholds.rb b/spec/factories/applied_usage_thresholds.rb new file mode 100644 index 00000000000..276e4c63108 --- /dev/null +++ b/spec/factories/applied_usage_thresholds.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :applied_usage_threshold do + usage_threshold + invoice + + lifetime_usage_amount_cents { 100 } + end +end diff --git a/spec/models/applied_usage_threshold_spec.rb b/spec/models/applied_usage_threshold_spec.rb new file mode 100644 index 00000000000..d00027cd699 --- /dev/null +++ b/spec/models/applied_usage_threshold_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AppliedUsageThreshold, type: :model do + subject(:applied_usage_threshold) { create(:applied_usage_threshold, invoice:) } + + let(:invoice) { create(:invoice) } + + it { is_expected.to belong_to(:usage_threshold) } +end diff --git a/spec/models/invoice_spec.rb b/spec/models/invoice_spec.rb index a25260d0128..e3afa65b3b2 100644 --- a/spec/models/invoice_spec.rb +++ b/spec/models/invoice_spec.rb @@ -18,6 +18,9 @@ it { is_expected.to have_many(:payment_requests).through(:applied_payment_requests) } it { is_expected.to have_many(:payments) } + it { is_expected.to have_many(:applied_usage_thresholds) } + it { is_expected.to have_many(:usage_thresholds).through(:applied_usage_thresholds) } + it 'has fixed status mapping' do expect(described_class::VISIBLE_STATUS).to match(draft: 0, finalized: 1, voided: 2, failed: 4) expect(described_class::INVISIBLE_STATUS).to match(generating: 3, open: 5) diff --git a/spec/models/lifetime_usage_spec.rb b/spec/models/lifetime_usage_spec.rb index 73a15a3f9c3..97eae27700c 100644 --- a/spec/models/lifetime_usage_spec.rb +++ b/spec/models/lifetime_usage_spec.rb @@ -60,4 +60,14 @@ expect(described_class.needs_recalculation).to match_array([lifetime_usage1, lifetime_usage2]) end end + + describe '#total_amount_cents' do + it 'returns the sum of the historical, invoiced, and current usage' do + lifetime_usage.historical_usage_amount_cents = 100 + lifetime_usage.invoiced_usage_amount_cents = 200 + lifetime_usage.current_usage_amount_cents = 300 + + expect(lifetime_usage.total_amount_cents).to eq(600) + end + end end diff --git a/spec/models/usage_threshold_spec.rb b/spec/models/usage_threshold_spec.rb index 1df267f3981..02736ec1624 100644 --- a/spec/models/usage_threshold_spec.rb +++ b/spec/models/usage_threshold_spec.rb @@ -5,6 +5,9 @@ RSpec.describe UsageThreshold, type: :model do subject(:usage_threshold) { build(:usage_threshold) } + it { is_expected.to have_many(:applied_usage_thresholds) } + it { is_expected.to have_many(:invoices).through(:applied_usage_thresholds) } + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than(0) } describe 'default scope' do diff --git a/spec/services/invoices/progressive_billing_service_spec.rb b/spec/services/invoices/progressive_billing_service_spec.rb index dd7babd731e..f3b4f724ee1 100644 --- a/spec/services/invoices/progressive_billing_service_spec.rb +++ b/spec/services/invoices/progressive_billing_service_spec.rb @@ -62,6 +62,10 @@ expect(invoice.invoice_subscriptions.count).to eq(1) expect(invoice.fees.count).to eq(1) + expect(invoice.applied_usage_thresholds.count).to eq(1) + + expect(invoice.applied_usage_thresholds.first.lifetime_usage_amount_cents) + .to eq(lifetime_usage.total_amount_cents) end context 'with multiple thresholds' do @@ -95,6 +99,7 @@ expect(invoice.invoice_subscriptions.count).to eq(1) expect(invoice.fees.count).to eq(1) + expect(invoice.applied_usage_thresholds.count).to eq(2) end end