From 7274a87e35c4e4dc82fc3ad288cbaf8c210f139a Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Fri, 17 Apr 2026 15:58:44 +0200 Subject: [PATCH 01/13] feat(order-forms): add OrderForm REST API OrderForm read-only REST API for listing and showing order forms, with filters (status, customer_id, number, quote_number, owner_id, and created_at / expires_at ranges). Split from the combined read-order-form branch to unblock REST QA independently of frontend. Add OrderForm model extensions, query object with filters, REST controller (index and show) with inlined index action, serializer, and routes. Register order_form in ApiKey::RESOURCES. --- .../order_forms_query_filters_contract.rb | 20 +++ .../api/v1/order_forms_controller.rb | 65 +++++++ app/models/api_key.rb | 2 +- app/models/order_form.rb | 94 ++++++++++ app/queries/order_forms_query.rb | 108 ++++++++++++ app/serializers/v1/order_form_serializer.rb | 24 +++ config/routes.rb | 1 + spec/factories/order_forms.rb | 38 ++++ spec/factories/quotes.rb | 4 + spec/models/order_form_spec.rb | 37 ++++ spec/queries/order_forms_query_spec.rb | 165 ++++++++++++++++++ .../api/v1/order_forms_controller_spec.rb | 66 +++++++ .../v1/order_form_serializer_spec.rb | 30 ++++ 13 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 app/contracts/queries/order_forms_query_filters_contract.rb create mode 100644 app/controllers/api/v1/order_forms_controller.rb create mode 100644 app/models/order_form.rb create mode 100644 app/queries/order_forms_query.rb create mode 100644 app/serializers/v1/order_form_serializer.rb create mode 100644 spec/factories/order_forms.rb create mode 100644 spec/models/order_form_spec.rb create mode 100644 spec/queries/order_forms_query_spec.rb create mode 100644 spec/requests/api/v1/order_forms_controller_spec.rb create mode 100644 spec/serializers/v1/order_form_serializer_spec.rb diff --git a/app/contracts/queries/order_forms_query_filters_contract.rb b/app/contracts/queries/order_forms_query_filters_contract.rb new file mode 100644 index 00000000000..f5ebfcc1e08 --- /dev/null +++ b/app/contracts/queries/order_forms_query_filters_contract.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Queries + class OrderFormsQueryFiltersContract < Dry::Validation::Contract + params do + optional(:status).maybe do + value(:string, included_in?: OrderForm::STATUSES.keys.map(&:to_s)) | + array(:string, included_in?: OrderForm::STATUSES.keys.map(&:to_s)) + end + optional(:customer_id).maybe { value(:string, format?: Regex::UUID) | array(:string, format?: Regex::UUID) } + optional(:number).maybe { value(:string) | array(:string) } + optional(:quote_number).maybe { value(:string) | array(:string) } + optional(:owner_id).maybe { value(:string, format?: Regex::UUID) | array(:string, format?: Regex::UUID) } + optional(:created_at_from).maybe(:time) + optional(:created_at_to).maybe(:time) + optional(:expires_at_from).maybe(:time) + optional(:expires_at_to).maybe(:time) + end + end +end diff --git a/app/controllers/api/v1/order_forms_controller.rb b/app/controllers/api/v1/order_forms_controller.rb new file mode 100644 index 00000000000..a94888932a6 --- /dev/null +++ b/app/controllers/api/v1/order_forms_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Api + module V1 + class OrderFormsController < Api::BaseController + def index + result = OrderFormsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters, + search_term: params[:search_term] + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.order_forms, + ::V1::OrderFormSerializer, + collection_name: "order_forms", + meta: pagination_metadata(result.order_forms) + ) + ) + else + render_error_response(result) + end + end + + def show + order_form = current_organization.order_forms.find_by(id: params[:id]) + return not_found_error(resource: "order_form") unless order_form + + render_order_form(order_form) + end + + private + + def index_filters + { + status: params[:status], + customer_id: params[:customer_id], + number: params[:number], + quote_number: params[:quote_number], + owner_id: params[:owner_id], + created_at_from: params[:created_at_from], + created_at_to: params[:created_at_to], + expires_at_from: params[:expires_at_from], + expires_at_to: params[:expires_at_to] + } + end + + def render_order_form(order_form) + render( + json: ::V1::OrderFormSerializer.new(order_form, root_name: "order_form") + ) + end + + def resource_name + "order_form" + end + end + end +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb index 24b7a8f2278..f18e716f9ff 100644 --- a/app/models/api_key.rb +++ b/app/models/api_key.rb @@ -5,7 +5,7 @@ class ApiKey < ApplicationRecord RESOURCES = %w[ activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage - customer event fee invoice organization payment payment_receipt payment_request payment_method plan subscription lifetime_usage + customer event fee invoice organization order_form payment payment_receipt payment_request payment_method plan subscription lifetime_usage tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section billing_entity alert feature security_log quote ].freeze diff --git a/app/models/order_form.rb b/app/models/order_form.rb new file mode 100644 index 00000000000..f23f8f9f529 --- /dev/null +++ b/app/models/order_form.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +class OrderForm < ApplicationRecord + include Sequenced + + STATUSES = { + generated: "generated", + signed: "signed", + expired: "expired", + voided: "voided" + }.freeze + + VOID_REASONS = { + manual: 0, + expired: 1, + invalid: 2 + }.freeze + + before_save :ensure_number + + belongs_to :organization + belongs_to :customer + belongs_to :quote + has_one :order + + enum :status, STATUSES, + default: :generated, + validate: true + enum :void_reason, VOID_REASONS, + instance_methods: false, + validate: {allow_nil: true} + + validates :billing_snapshot, presence: true + + def self.ransackable_attributes(_ = nil) + %w[number] + end + + sequenced( + scope: ->(order_form) { order_form.organization.order_forms }, + lock_key: ->(order_form) { order_form.organization_id } + ) + + private + + def ensure_number + return if number.present? + return if sequential_id.blank? + + time = created_at || Time.current + formatted_sequential_id = format("%04d", sequential_id) + self.number = "OF-#{time.strftime("%Y")}-#{formatted_sequential_id}" + end +end + +# == Schema Information +# +# Table name: order_forms +# Database name: primary +# +# id :uuid not null, primary key +# billing_snapshot :jsonb not null +# content :text +# contract_uploaded_at :datetime +# contract_uploaded_by_user :uuid +# expires_at :datetime +# legal_text :text +# number :string not null +# signed_at :datetime +# status :enum default("generated"), not null +# void_reason(Rails enum) :integer +# voided_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# quote_id :uuid not null +# sequential_id :integer not null +# signed_by_user_id :uuid +# +# Indexes +# +# index_order_forms_on_customer_id (customer_id) +# index_order_forms_on_quote_id (quote_id) +# index_unique_order_forms_on_organization_number (organization_id,number) UNIQUE +# index_unique_order_forms_on_organization_sequentialid (organization_id,sequential_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (quote_id => quotes.id) +# fk_rails_... (signed_by_user_id => users.id) +# diff --git a/app/queries/order_forms_query.rb b/app/queries/order_forms_query.rb new file mode 100644 index 00000000000..c209bac374b --- /dev/null +++ b/app/queries/order_forms_query.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class OrderFormsQuery < BaseQuery + Result = BaseResult[:order_forms] + Filters = BaseFilters[ + :status, + :customer_id, + :number, + :quote_number, + :owner_id, + :created_at_from, + :created_at_to, + :expires_at_from, + :expires_at_to + ] + + def call + return result unless validate_filters.success? + + order_forms = base_scope.result + order_forms = with_status(order_forms) if filters.status.present? + order_forms = with_customer_id(order_forms) if filters.customer_id.present? + order_forms = with_number(order_forms) if filters.number.present? + order_forms = with_quote_number(order_forms) if filters.quote_number.present? + order_forms = with_owner_id(order_forms) if filters.owner_id.present? + order_forms = with_created_at_range(order_forms) if created_at_range? + order_forms = with_expires_at_range(order_forms) if expires_at_range? + order_forms = paginate(order_forms) + order_forms = apply_consistent_ordering(order_forms) + + result.order_forms = order_forms + result + end + + private + + def filters_contract + @filters_contract ||= Queries::OrderFormsQueryFiltersContract.new + end + + def base_scope + organization.order_forms.includes(:customer).ransack(search_params) + end + + def search_params + return if search_term.blank? + + {number_cont: search_term} + end + + def with_status(scope) + scope.where(status: filters.status) + end + + def with_customer_id(scope) + scope.where(customer_id: filters.customer_id) + end + + def with_number(scope) + scope.where(number: filters.number) + end + + def with_quote_number(scope) + scope.joins(:quote).where(quotes: {number: filters.quote_number}) + end + + def with_owner_id(scope) + scope.where( + quote_id: QuoteOwner.where(user_id: filters.owner_id).select(:quote_id) + ) + end + + def with_created_at_range(scope) + scope = scope.where(created_at: created_at_from..) if filters.created_at_from + scope = scope.where(created_at: ..created_at_to) if filters.created_at_to + scope + end + + def with_expires_at_range(scope) + scope = scope.where(expires_at: expires_at_from..) if filters.expires_at_from + scope = scope.where(expires_at: ..expires_at_to) if filters.expires_at_to + scope + end + + def created_at_range? + filters.created_at_from.present? || filters.created_at_to.present? + end + + def expires_at_range? + filters.expires_at_from.present? || filters.expires_at_to.present? + end + + def created_at_from + @created_at_from ||= parse_datetime_filter(:created_at_from) + end + + def created_at_to + @created_at_to ||= parse_datetime_filter(:created_at_to) + end + + def expires_at_from + @expires_at_from ||= parse_datetime_filter(:expires_at_from) + end + + def expires_at_to + @expires_at_to ||= parse_datetime_filter(:expires_at_to) + end +end diff --git a/app/serializers/v1/order_form_serializer.rb b/app/serializers/v1/order_form_serializer.rb new file mode 100644 index 00000000000..9c7ae1b6bc2 --- /dev/null +++ b/app/serializers/v1/order_form_serializer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module V1 + class OrderFormSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + number: model.number, + status: model.status, + void_reason: model.void_reason, + billing_snapshot: model.billing_snapshot, + expires_at: model.expires_at&.iso8601, + signed_at: model.signed_at&.iso8601, + voided_at: model.voided_at&.iso8601, + lago_signed_by_user_id: model.signed_by_user_id, + lago_organization_id: model.organization_id, + lago_customer_id: model.customer_id, + lago_quote_id: model.quote_id, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601 + } + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 53b361b0f87..c993d9f920b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -148,6 +148,7 @@ post :resend_email, on: :member end resources :payment_requests, only: %i[create index show] + resources :order_forms, only: %i[show index] resources :payments, only: %i[create index show] resources :plans, param: :code, code: /.*/ do resources :charges, only: %i[index show create update destroy], param: :code, code: /.*/, controller: "plans/charges" do diff --git a/spec/factories/order_forms.rb b/spec/factories/order_forms.rb new file mode 100644 index 00000000000..8875fb672da --- /dev/null +++ b/spec/factories/order_forms.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :order_form do + customer + organization { customer&.organization || association(:organization) } + quote { association(:quote, customer:, organization:) } + billing_snapshot { {items: []} } + status { :generated } + + trait :signed do + status { :signed } + signed_at { Time.current } + signed_by_user_id { association(:user).id } + end + + trait :expired do + status { :expired } + expires_at { 1.day.ago } + voided_at { Time.current } + void_reason { :expired } + end + + trait :voided do + status { :voided } + voided_at { Time.current } + void_reason { :manual } + end + + trait :expiring_tomorrow do + expires_at { 1.day.from_now } + end + + trait :expired_yesterday do + expires_at { 1.day.ago } + end + end +end diff --git a/spec/factories/quotes.rb b/spec/factories/quotes.rb index e77f9ae4568..bc27c2233ec 100644 --- a/spec/factories/quotes.rb +++ b/spec/factories/quotes.rb @@ -22,5 +22,9 @@ ) end end + + trait :auto_execute do + auto_execute { true } + end end end diff --git a/spec/models/order_form_spec.rb b/spec/models/order_form_spec.rb new file mode 100644 index 00000000000..82f503fe559 --- /dev/null +++ b/spec/models/order_form_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrderForm do + subject(:order_form) { build(:order_form) } + + describe "enums" do + it do + expect(order_form).to define_enum_for(:status) + .backed_by_column_of_type(:enum) + .validating + .with_values(generated: "generated", signed: "signed", expired: "expired", voided: "voided") + .with_default(:generated) + + expect(order_form).to define_enum_for(:void_reason) + .validating(allowing_nil: true) + .with_values(manual: 0, expired: 1, invalid: 2) + .without_instance_methods + end + end + + describe "associations" do + it do + expect(order_form).to belong_to(:organization) + expect(order_form).to belong_to(:customer) + expect(order_form).to belong_to(:quote) + expect(order_form).to have_one(:order) + end + end + + describe "validations" do + it do + expect(order_form).to validate_presence_of(:billing_snapshot) + end + end +end diff --git a/spec/queries/order_forms_query_spec.rb b/spec/queries/order_forms_query_spec.rb new file mode 100644 index 00000000000..3e51ea58ac9 --- /dev/null +++ b/spec/queries/order_forms_query_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrderFormsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:, search_term:) + end + + let(:returned_ids) { result.order_forms.pluck(:id) } + let(:pagination) { nil } + let(:filters) { nil } + let(:search_term) { nil } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:quote) { create(:quote, organization:, customer:) } + let(:order_form_one) { create(:order_form, organization:, customer:, quote:) } + let(:order_form_two) { create(:order_form, organization:, customer:, quote:) } + + before do + order_form_one + order_form_two + end + + it "returns all order forms for the organization" do + expect(result).to be_success + expect(returned_ids).to match_array([order_form_one.id, order_form_two.id]) + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 1} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.order_forms.count).to eq(1) + expect(result.order_forms.current_page).to eq(2) + expect(result.order_forms.total_pages).to eq(2) + expect(result.order_forms.total_count).to eq(2) + end + end + + context "when filtering by status" do + let(:order_form_two) { create(:order_form, :signed, organization:, customer:, quote:) } + let(:filters) { {status: "generated"} } + + it "returns only order forms with the specified status" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "when filtering by customer_id" do + let(:other_customer) { create(:customer, organization:) } + let(:other_quote) { create(:quote, organization:, customer: other_customer) } + let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote: other_quote) } + let(:filters) { {customer_id: [customer.id]} } + + it "returns only order forms for the specified customer" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "when filtering by number" do + let(:filters) { {number: [order_form_one.number]} } + + it "returns only order forms with the specified numbers" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "when filtering by quote_number" do + let(:other_customer) { create(:customer, organization:) } + let(:other_quote) { create(:quote, organization:, customer: other_customer) } + let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote: other_quote) } + let(:filters) { {quote_number: [quote.number]} } + + it "returns only order forms linked to the specified quote" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "when filtering by owner_id" do + let(:user) { membership.user } + let(:other_customer) { create(:customer, organization:) } + let(:other_quote) { create(:quote, organization:, customer: other_customer) } + let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote: other_quote) } + let(:filters) { {owner_id: [user.id]} } + + before { QuoteOwner.create!(organization:, quote:, user:) } + + it "returns only order forms whose quote has the specified owner" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "when filtering by created_at range" do + let(:order_form_one) { create(:order_form, organization:, customer:, quote:, created_at: 3.days.ago) } + let(:order_form_two) { create(:order_form, organization:, customer:, quote:, created_at: 1.day.ago) } + let(:filters) { {created_at_from: 2.days.ago.iso8601, created_at_to: Time.current.iso8601} } + + it "returns only order forms within the date range" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_two.id]) + end + end + + context "when filtering by expires_at range" do + let(:order_form_one) { create(:order_form, organization:, customer:, quote:, expires_at: 5.days.from_now) } + let(:order_form_two) { create(:order_form, organization:, customer:, quote:, expires_at: 15.days.from_now) } + let(:filters) { {expires_at_from: 3.days.from_now.iso8601, expires_at_to: 10.days.from_now.iso8601} } + + it "returns only order forms expiring within the date range" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "with search_term on number" do + let(:search_term) { order_form_one.number } + + it "returns matching order forms" do + expect(result).to be_success + expect(returned_ids).to eq([order_form_one.id]) + end + end + + context "when no order forms exist" do + before { OrderForm.delete_all } + + it "returns an empty result set" do + expect(result).to be_success + expect(returned_ids).to be_empty + end + end + + context "when filters are invalid" do + let(:filters) { {status: "invalid_status"} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:status]).to be_present + end + end + + context "when order forms belong to another organization" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_quote) { create(:quote, organization: other_organization, customer: other_customer) } + + before do + create(:order_form, organization: other_organization, customer: other_customer, quote: other_quote) + end + + it "does not return order forms from other organizations" do + expect(result).to be_success + expect(returned_ids).to match_array([order_form_one.id, order_form_two.id]) + end + end +end diff --git a/spec/requests/api/v1/order_forms_controller_spec.rb b/spec/requests/api/v1/order_forms_controller_spec.rb new file mode 100644 index 00000000000..20c57058c2b --- /dev/null +++ b/spec/requests/api/v1/order_forms_controller_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::OrderFormsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:quote) { create(:quote, organization:, customer:) } + let(:order_form) { create(:order_form, organization:, customer:, quote:) } + + describe "GET /api/v1/order_forms" do + subject { get_with_token(organization, "/api/v1/order_forms") } + + let!(:order_form) { create(:order_form, organization:, customer:, quote:) } + + before { create(:order_form, :signed, organization:, customer:, quote:) } + + include_examples "requires API permission", "order_form", "read" + + it "returns a list of order forms" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:order_forms].count).to eq(2) + end + + context "when filtering by status" do + subject { get_with_token(organization, "/api/v1/order_forms", {status: "generated"}) } + + it "returns only matching order forms" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:order_forms].count).to eq(1) + expect(json[:order_forms].first[:lago_id]).to eq(order_form.id) + end + end + end + + describe "GET /api/v1/order_forms/:id" do + subject { get_with_token(organization, "/api/v1/order_forms/#{order_form.id}") } + + before { order_form } + + include_examples "requires API permission", "order_form", "read" + + it "returns the order form" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:order_form][:lago_id]).to eq(order_form.id) + expect(json[:order_form][:number]).to eq(order_form.number) + expect(json[:order_form][:status]).to eq("generated") + end + + context "when order form does not exist" do + subject { get_with_token(organization, "/api/v1/order_forms/#{SecureRandom.uuid}") } + + it "returns not found" do + subject + + expect(response).to be_not_found_error("order_form") + end + end + end +end diff --git a/spec/serializers/v1/order_form_serializer_spec.rb b/spec/serializers/v1/order_form_serializer_spec.rb new file mode 100644 index 00000000000..7836b9b1ce4 --- /dev/null +++ b/spec/serializers/v1/order_form_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::OrderFormSerializer do + subject(:serializer) { described_class.new(order_form, root_name: "order_form") } + + let(:order_form) { create(:order_form) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["order_form"]).to include( + "lago_id" => order_form.id, + "number" => order_form.number, + "status" => "generated", + "void_reason" => nil, + "billing_snapshot" => order_form.billing_snapshot, + "expires_at" => nil, + "signed_at" => nil, + "voided_at" => nil, + "lago_signed_by_user_id" => nil, + "lago_organization_id" => order_form.organization_id, + "lago_customer_id" => order_form.customer_id, + "lago_quote_id" => order_form.quote_id, + "created_at" => order_form.created_at.iso8601, + "updated_at" => order_form.updated_at.iso8601 + ) + end +end From c15b8d43fafbb36b6c77e533d873d94dcfeb4fd7 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 20 Apr 2026 11:22:50 +0200 Subject: [PATCH 02/13] feat: check for the order-forms feature flag in REST endpoints --- .../api/v1/order_forms_controller.rb | 4 ++++ .../api/v1/order_forms_controller_spec.rb | 24 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/v1/order_forms_controller.rb b/app/controllers/api/v1/order_forms_controller.rb index a94888932a6..6823bc4dcaf 100644 --- a/app/controllers/api/v1/order_forms_controller.rb +++ b/app/controllers/api/v1/order_forms_controller.rb @@ -4,6 +4,8 @@ module Api module V1 class OrderFormsController < Api::BaseController def index + return forbidden_error(code: "feature_not_available") unless current_organization.feature_flag_enabled?(:order_forms) + result = OrderFormsQuery.call( organization: current_organization, pagination: { @@ -29,6 +31,8 @@ def index end def show + return forbidden_error(code: "feature_not_available") unless current_organization.feature_flag_enabled?(:order_forms) + order_form = current_organization.order_forms.find_by(id: params[:id]) return not_found_error(resource: "order_form") unless order_form diff --git a/spec/requests/api/v1/order_forms_controller_spec.rb b/spec/requests/api/v1/order_forms_controller_spec.rb index 20c57058c2b..1ef494d47a7 100644 --- a/spec/requests/api/v1/order_forms_controller_spec.rb +++ b/spec/requests/api/v1/order_forms_controller_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe Api::V1::OrderFormsController do - let(:organization) { create(:organization) } + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } let(:customer) { create(:customer, organization:) } let(:quote) { create(:quote, organization:, customer:) } let(:order_form) { create(:order_form, organization:, customer:, quote:) } @@ -35,6 +35,17 @@ expect(json[:order_forms].first[:lago_id]).to eq(order_form.id) end end + + context "when the order_forms feature flag is disabled" do + let(:organization) { create(:organization) } + + it "returns forbidden" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:code]).to eq("feature_not_available") + end + end end describe "GET /api/v1/order_forms/:id" do @@ -62,5 +73,16 @@ expect(response).to be_not_found_error("order_form") end end + + context "when the order_forms feature flag is disabled" do + let(:organization) { create(:organization) } + + it "returns forbidden" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:code]).to eq("feature_not_available") + end + end end end From 92318788c20a529d61e59684b86d71ef3c34dedd Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 20 Apr 2026 16:06:53 +0200 Subject: [PATCH 03/13] dry the feature flag check with a before_action --- app/controllers/api/v1/order_forms_controller.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/v1/order_forms_controller.rb b/app/controllers/api/v1/order_forms_controller.rb index 6823bc4dcaf..dc2cd5fd56b 100644 --- a/app/controllers/api/v1/order_forms_controller.rb +++ b/app/controllers/api/v1/order_forms_controller.rb @@ -3,9 +3,9 @@ module Api module V1 class OrderFormsController < Api::BaseController - def index - return forbidden_error(code: "feature_not_available") unless current_organization.feature_flag_enabled?(:order_forms) + before_action :ensure_feature_flag! + def index result = OrderFormsQuery.call( organization: current_organization, pagination: { @@ -31,8 +31,6 @@ def index end def show - return forbidden_error(code: "feature_not_available") unless current_organization.feature_flag_enabled?(:order_forms) - order_form = current_organization.order_forms.find_by(id: params[:id]) return not_found_error(resource: "order_form") unless order_form @@ -41,6 +39,10 @@ def show private + def ensure_feature_flag! + forbidden_error(code: "feature_not_available") unless current_organization.feature_flag_enabled?(:order_forms) + end + def index_filters { status: params[:status], From f914182e60a8f15e1eb8d71da555becdadf396dd Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 18 May 2026 15:47:07 +0200 Subject: [PATCH 04/13] adding missing migration and model after rebase --- app/models/metadata/item_metadata.rb | 14 +- app/models/order_form.rb | 43 +++--- app/models/organization.rb | 1 + .../20260518152858_create_order_forms.rb | 54 ++++++++ db/structure.sql | 130 ++++++++++++++++++ spec/models/order_form_spec.rb | 4 +- 6 files changed, 214 insertions(+), 32 deletions(-) create mode 100644 db/migrate/20260518152858_create_order_forms.rb diff --git a/app/models/metadata/item_metadata.rb b/app/models/metadata/item_metadata.rb index 42e72a75e40..dce79e7505e 100644 --- a/app/models/metadata/item_metadata.rb +++ b/app/models/metadata/item_metadata.rb @@ -44,13 +44,13 @@ def value_correctness # Table name: item_metadata # Database name: primary # -# id :uuid not null, primary key -# owner_type :string not null -# value :jsonb not null -# created_at :datetime not null -# updated_at :datetime not null -# organization_id :uuid not null -# owner_id :uuid not null +# id :uuid not null, primary key +# owner_type(Polymorphic owner type) :string not null +# value(item_metadata key-value pairs) :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id(Reference to the organization) :uuid not null +# owner_id(Polymorphic owner id) :uuid not null # # Indexes # diff --git a/app/models/order_form.rb b/app/models/order_form.rb index f23f8f9f529..39ddf064fe9 100644 --- a/app/models/order_form.rb +++ b/app/models/order_form.rb @@ -11,9 +11,9 @@ class OrderForm < ApplicationRecord }.freeze VOID_REASONS = { - manual: 0, - expired: 1, - invalid: 2 + manual: "manual", + expired: "expired", + invalid: "invalid" }.freeze before_save :ensure_number @@ -21,7 +21,6 @@ class OrderForm < ApplicationRecord belongs_to :organization belongs_to :customer belongs_to :quote - has_one :order enum :status, STATUSES, default: :generated, @@ -58,25 +57,23 @@ def ensure_number # Table name: order_forms # Database name: primary # -# id :uuid not null, primary key -# billing_snapshot :jsonb not null -# content :text -# contract_uploaded_at :datetime -# contract_uploaded_by_user :uuid -# expires_at :datetime -# legal_text :text -# number :string not null -# signed_at :datetime -# status :enum default("generated"), not null -# void_reason(Rails enum) :integer -# voided_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# customer_id :uuid not null -# organization_id :uuid not null -# quote_id :uuid not null -# sequential_id :integer not null -# signed_by_user_id :uuid +# id :uuid not null, primary key +# billing_snapshot :jsonb not null +# content :text +# expires_at :datetime +# legal_text :text +# number :string not null +# signed_at :datetime +# status :enum default("generated"), not null +# void_reason :enum +# voided_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# quote_id :uuid not null +# sequential_id :integer not null +# signed_by_user_id :uuid # # Indexes # diff --git a/app/models/organization.rb b/app/models/organization.rb index b79c0ea56c4..131af0237db 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -77,6 +77,7 @@ class Organization < ApplicationRecord has_many :roles has_many :quotes has_many :quote_versions + has_many :order_forms has_many :activity_logs, class_name: "Clickhouse::ActivityLog" has_many :features, class_name: "Entitlement::Feature" has_many :privileges, class_name: "Entitlement::Privilege" diff --git a/db/migrate/20260518152858_create_order_forms.rb b/db/migrate/20260518152858_create_order_forms.rb new file mode 100644 index 00000000000..6d3b82b7b9e --- /dev/null +++ b/db/migrate/20260518152858_create_order_forms.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class CreateOrderForms < ActiveRecord::Migration[8.0] + def change + create_enum :order_form_status, %w[generated signed expired voided] + create_enum :order_form_void_reason, %w[manual expired invalid] + + create_table :order_forms, id: :uuid do |t| + t.references :organization, + null: false, + foreign_key: true, + index: false, # covered by the composite unique indexes below + type: :uuid + t.references :customer, + null: false, + foreign_key: true, + type: :uuid + t.references :quote, + null: false, + foreign_key: true, + type: :uuid + t.references :signed_by_user, + foreign_key: {to_table: :users}, + index: false, + type: :uuid + + t.string :number, null: false + t.integer :sequential_id, null: false + + t.enum :status, + enum_type: :order_form_status, + null: false, + default: "generated" + t.enum :void_reason, enum_type: :order_form_void_reason + + t.jsonb :billing_snapshot, null: false + t.text :content + t.text :legal_text + + t.datetime :expires_at + t.datetime :signed_at + t.datetime :voided_at + + t.timestamps + + t.index [:organization_id, :number], + unique: true, + name: "index_unique_order_forms_on_organization_number" + t.index [:organization_id, :sequential_id], + unique: true, + name: "index_unique_order_forms_on_organization_sequentialid" + end + end +end diff --git a/db/structure.sql b/db/structure.sql index ae942503d9c..68465d47dc4 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -182,7 +182,9 @@ ALTER TABLE IF EXISTS ONLY public.pricing_unit_usages DROP CONSTRAINT IF EXISTS ALTER TABLE IF EXISTS ONLY public.applied_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_63ac282e70; ALTER TABLE IF EXISTS ONLY public.invoice_metadata DROP CONSTRAINT IF EXISTS fk_rails_63683837a2; ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS fk_rails_62d18ea517; +ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_6298debfc7; ALTER TABLE IF EXISTS ONLY public.credit_notes_taxes DROP CONSTRAINT IF EXISTS fk_rails_626209b8d2; +ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_60bc1d491f; ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_6023b3f2dd; ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS fk_rails_5efea6fe31; ALTER TABLE IF EXISTS ONLY public.fixed_charges DROP CONSTRAINT IF EXISTS fk_rails_5e06da3c18; @@ -207,6 +209,7 @@ ALTER TABLE IF EXISTS ONLY public.commitments DROP CONSTRAINT IF EXISTS fk_rails ALTER TABLE IF EXISTS ONLY public.billable_metric_filters DROP CONSTRAINT IF EXISTS fk_rails_51077e7c0e; ALTER TABLE IF EXISTS ONLY public.payment_provider_customers DROP CONSTRAINT IF EXISTS fk_rails_50d46d3679; ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_4ff087c52e; +ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_4ed54bfec0; ALTER TABLE IF EXISTS ONLY public.billing_entities DROP CONSTRAINT IF EXISTS fk_rails_4aa58496c3; ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_49fcc221b0; ALTER TABLE IF EXISTS ONLY public.charges DROP CONSTRAINT IF EXISTS fk_rails_4934f27a06; @@ -284,6 +287,7 @@ ALTER TABLE IF EXISTS ONLY public.applied_invoice_custom_sections DROP CONSTRAIN ALTER TABLE IF EXISTS ONLY public.fees_taxes DROP CONSTRAINT IF EXISTS fk_rails_103e187859; ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS fk_rails_0f807322b1; ALTER TABLE IF EXISTS ONLY public.integration_mappings DROP CONSTRAINT IF EXISTS fk_rails_0f762162b0; +ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_0f6233ccbc; ALTER TABLE IF EXISTS ONLY public.integration_customers DROP CONSTRAINT IF EXISTS fk_rails_0e464363cb; ALTER TABLE IF EXISTS ONLY public.ai_conversations DROP CONSTRAINT IF EXISTS fk_rails_0da056ac92; ALTER TABLE IF EXISTS ONLY public.invoices DROP CONSTRAINT IF EXISTS fk_rails_0d349e632f; @@ -381,6 +385,8 @@ DROP INDEX IF EXISTS public.index_unique_quote_versions_on_share_token; DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_sequential_id; DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_active_status; DROP INDEX IF EXISTS public.index_unique_quote_owners_on_quote_user; +DROP INDEX IF EXISTS public.index_unique_order_forms_on_organization_sequentialid; +DROP INDEX IF EXISTS public.index_unique_order_forms_on_organization_number; DROP INDEX IF EXISTS public.index_unique_applied_to_organization_per_organization; DROP INDEX IF EXISTS public.index_uniq_wallet_code_per_customer; DROP INDEX IF EXISTS public.index_uniq_invoice_subscriptions_on_fixed_charges_boundaries; @@ -487,6 +493,8 @@ DROP INDEX IF EXISTS public.index_password_resets_on_token; DROP INDEX IF EXISTS public.index_organizations_on_slug; DROP INDEX IF EXISTS public.index_organizations_on_hmac_key; DROP INDEX IF EXISTS public.index_organizations_on_api_key; +DROP INDEX IF EXISTS public.index_order_forms_on_quote_id; +DROP INDEX IF EXISTS public.index_order_forms_on_customer_id; DROP INDEX IF EXISTS public.index_memberships_on_user_id_and_organization_id; DROP INDEX IF EXISTS public.index_memberships_on_user_id; DROP INDEX IF EXISTS public.index_memberships_on_organization_id; @@ -882,6 +890,7 @@ ALTER TABLE IF EXISTS ONLY public.payment_methods DROP CONSTRAINT IF EXISTS paym ALTER TABLE IF EXISTS ONLY public.payment_intents DROP CONSTRAINT IF EXISTS payment_intents_pkey; ALTER TABLE IF EXISTS ONLY public.password_resets DROP CONSTRAINT IF EXISTS password_resets_pkey; ALTER TABLE IF EXISTS ONLY public.organizations DROP CONSTRAINT IF EXISTS organizations_pkey; +ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS order_forms_pkey; ALTER TABLE IF EXISTS ONLY public.memberships DROP CONSTRAINT IF EXISTS memberships_pkey; ALTER TABLE IF EXISTS ONLY public.membership_roles DROP CONSTRAINT IF EXISTS membership_roles_pkey; ALTER TABLE IF EXISTS ONLY public.lifetime_usages DROP CONSTRAINT IF EXISTS lifetime_usages_pkey; @@ -995,6 +1004,7 @@ DROP TABLE IF EXISTS public.payment_providers; DROP TABLE IF EXISTS public.payment_methods; DROP TABLE IF EXISTS public.payment_intents; DROP TABLE IF EXISTS public.password_resets; +DROP TABLE IF EXISTS public.order_forms; DROP TABLE IF EXISTS public.memberships; DROP TABLE IF EXISTS public.membership_roles; DROP TABLE IF EXISTS public.lifetime_usages; @@ -1142,6 +1152,8 @@ DROP TYPE IF EXISTS public.quote_order_type; DROP TYPE IF EXISTS public.payment_type; DROP TYPE IF EXISTS public.payment_payable_payment_status; DROP TYPE IF EXISTS public.payment_method_types; +DROP TYPE IF EXISTS public.order_form_void_reason; +DROP TYPE IF EXISTS public.order_form_status; DROP TYPE IF EXISTS public.invoice_settlement_settlement_type; DROP TYPE IF EXISTS public.invoice_custom_section_type; DROP TYPE IF EXISTS public.inbound_webhook_status; @@ -1322,6 +1334,29 @@ CREATE TYPE public.invoice_settlement_settlement_type AS ENUM ( ); +-- +-- Name: order_form_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.order_form_status AS ENUM ( + 'generated', + 'signed', + 'expired', + 'voided' +); + + +-- +-- Name: order_form_void_reason; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.order_form_void_reason AS ENUM ( + 'manual', + 'expired', + 'invalid' +); + + -- -- Name: payment_method_types; Type: TYPE; Schema: public; Owner: - -- @@ -4592,6 +4627,31 @@ CREATE TABLE public.memberships ( ); +-- +-- Name: order_forms; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.order_forms ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid NOT NULL, + quote_id uuid NOT NULL, + signed_by_user_id uuid, + number character varying NOT NULL, + sequential_id integer NOT NULL, + status public.order_form_status DEFAULT 'generated'::public.order_form_status NOT NULL, + void_reason public.order_form_void_reason, + billing_snapshot jsonb NOT NULL, + content text, + legal_text text, + expires_at timestamp(6) without time zone, + signed_at timestamp(6) without time zone, + voided_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + -- -- Name: password_resets; Type: TABLE; Schema: public; Owner: - -- @@ -5819,6 +5879,14 @@ ALTER TABLE ONLY public.memberships ADD CONSTRAINT memberships_pkey PRIMARY KEY (id); +-- +-- Name: order_forms order_forms_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.order_forms + ADD CONSTRAINT order_forms_pkey PRIMARY KEY (id); + + -- -- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -8661,6 +8729,20 @@ CREATE INDEX index_memberships_on_user_id ON public.memberships USING btree (use CREATE UNIQUE INDEX index_memberships_on_user_id_and_organization_id ON public.memberships USING btree (user_id, organization_id) WHERE (revoked_at IS NULL); +-- +-- Name: index_order_forms_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_order_forms_on_customer_id ON public.order_forms USING btree (customer_id); + + +-- +-- Name: index_order_forms_on_quote_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_order_forms_on_quote_id ON public.order_forms USING btree (quote_id); + + -- -- Name: index_organizations_on_api_key; Type: INDEX; Schema: public; Owner: - -- @@ -9403,6 +9485,20 @@ CREATE UNIQUE INDEX index_uniq_wallet_code_per_customer ON public.wallets USING CREATE UNIQUE INDEX index_unique_applied_to_organization_per_organization ON public.dunning_campaigns USING btree (organization_id) WHERE ((applied_to_organization = true) AND (deleted_at IS NULL)); +-- +-- Name: index_unique_order_forms_on_organization_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_order_forms_on_organization_number ON public.order_forms USING btree (organization_id, number); + + +-- +-- Name: index_unique_order_forms_on_organization_sequentialid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_order_forms_on_organization_sequentialid ON public.order_forms USING btree (organization_id, sequential_id); + + -- -- Name: index_unique_quote_owners_on_quote_user; Type: INDEX; Schema: public; Owner: - -- @@ -10008,6 +10104,14 @@ ALTER TABLE ONLY public.integration_customers ADD CONSTRAINT fk_rails_0e464363cb FOREIGN KEY (customer_id) REFERENCES public.customers(id); +-- +-- Name: order_forms fk_rails_0f6233ccbc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.order_forms + ADD CONSTRAINT fk_rails_0f6233ccbc FOREIGN KEY (signed_by_user_id) REFERENCES public.users(id); + + -- -- Name: integration_mappings fk_rails_0f762162b0; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10624,6 +10728,14 @@ ALTER TABLE ONLY public.billing_entities ADD CONSTRAINT fk_rails_4aa58496c3 FOREIGN KEY (applied_dunning_campaign_id) REFERENCES public.dunning_campaigns(id) ON DELETE SET NULL; +-- +-- Name: order_forms fk_rails_4ed54bfec0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.order_forms + ADD CONSTRAINT fk_rails_4ed54bfec0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + -- -- Name: wallets fk_rails_4ff087c52e; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10816,6 +10928,14 @@ ALTER TABLE ONLY public.fees ADD CONSTRAINT fk_rails_6023b3f2dd FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); +-- +-- Name: order_forms fk_rails_60bc1d491f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.order_forms + ADD CONSTRAINT fk_rails_60bc1d491f FOREIGN KEY (quote_id) REFERENCES public.quotes(id); + + -- -- Name: credit_notes_taxes fk_rails_626209b8d2; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -10824,6 +10944,14 @@ ALTER TABLE ONLY public.credit_notes_taxes ADD CONSTRAINT fk_rails_626209b8d2 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); +-- +-- Name: order_forms fk_rails_6298debfc7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.order_forms + ADD CONSTRAINT fk_rails_6298debfc7 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + -- -- Name: payments fk_rails_62d18ea517; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -12216,6 +12344,7 @@ SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES ('20260520075420'), +('20260518152858'), ('20260517101105'), ('20260513181544'), ('20260513105210'), @@ -13219,3 +13348,4 @@ INSERT INTO "schema_migrations" (version) VALUES ('20220530091046'), ('20220526101535'), ('20220525122759'); + diff --git a/spec/models/order_form_spec.rb b/spec/models/order_form_spec.rb index 82f503fe559..572df60437f 100644 --- a/spec/models/order_form_spec.rb +++ b/spec/models/order_form_spec.rb @@ -14,8 +14,9 @@ .with_default(:generated) expect(order_form).to define_enum_for(:void_reason) + .backed_by_column_of_type(:enum) .validating(allowing_nil: true) - .with_values(manual: 0, expired: 1, invalid: 2) + .with_values(manual: "manual", expired: "expired", invalid: "invalid") .without_instance_methods end end @@ -25,7 +26,6 @@ expect(order_form).to belong_to(:organization) expect(order_form).to belong_to(:customer) expect(order_form).to belong_to(:quote) - expect(order_form).to have_one(:order) end end From 4c2abe822a384dacf3279f4f5365a5b3f1004048 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Mon, 18 May 2026 16:36:15 +0200 Subject: [PATCH 05/13] link order forms to quote versions, not quotes --- app/models/order_form.rb | 32 ++++------ app/models/quote.rb | 1 + app/models/quote_version.rb | 1 + app/queries/order_forms_query.rb | 8 ++- app/serializers/v1/order_form_serializer.rb | 3 +- .../20260518152858_create_order_forms.rb | 11 +--- db/structure.sql | 59 +++++++++---------- spec/factories/order_forms.rb | 14 ++--- spec/factories/quotes.rb | 4 -- spec/models/order_form_spec.rb | 34 ++++++++++- spec/models/organization_spec.rb | 1 + spec/models/quote_spec.rb | 1 + spec/models/quote_version_spec.rb | 1 + spec/queries/order_forms_query_spec.rb | 27 +++++---- .../api/v1/order_forms_controller_spec.rb | 7 ++- .../v1/order_form_serializer_spec.rb | 3 +- 16 files changed, 115 insertions(+), 92 deletions(-) diff --git a/app/models/order_form.rb b/app/models/order_form.rb index 39ddf064fe9..b90bc6d1ab2 100644 --- a/app/models/order_form.rb +++ b/app/models/order_form.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class OrderForm < ApplicationRecord - include Sequenced - STATUSES = { generated: "generated", signed: "signed", @@ -16,11 +14,12 @@ class OrderForm < ApplicationRecord invalid: "invalid" }.freeze - before_save :ensure_number + before_validation :ensure_number belongs_to :organization belongs_to :customer - belongs_to :quote + belongs_to :quote_version + has_one :quote, through: :quote_version enum :status, STATUSES, default: :generated, @@ -30,25 +29,19 @@ class OrderForm < ApplicationRecord validate: {allow_nil: true} validates :billing_snapshot, presence: true + validates :number, presence: true def self.ransackable_attributes(_ = nil) %w[number] end - sequenced( - scope: ->(order_form) { order_form.organization.order_forms }, - lock_key: ->(order_form) { order_form.organization_id } - ) - private def ensure_number return if number.present? - return if sequential_id.blank? + return if quote_version.blank? - time = created_at || Time.current - formatted_sequential_id = format("%04d", sequential_id) - self.number = "OF-#{time.strftime("%Y")}-#{formatted_sequential_id}" + self.number = quote_version.quote.number.sub(/\AQT-/, "OF-") end end @@ -71,21 +64,20 @@ def ensure_number # updated_at :datetime not null # customer_id :uuid not null # organization_id :uuid not null -# quote_id :uuid not null -# sequential_id :integer not null +# quote_version_id :uuid not null # signed_by_user_id :uuid # # Indexes # -# index_order_forms_on_customer_id (customer_id) -# index_order_forms_on_quote_id (quote_id) -# index_unique_order_forms_on_organization_number (organization_id,number) UNIQUE -# index_unique_order_forms_on_organization_sequentialid (organization_id,sequential_id) UNIQUE +# index_order_forms_on_customer_id (customer_id) +# index_order_forms_on_organization_id (organization_id) +# index_order_forms_on_organization_id_and_number (organization_id,number) +# index_order_forms_on_quote_version_id (quote_version_id) UNIQUE # # Foreign Keys # # fk_rails_... (customer_id => customers.id) # fk_rails_... (organization_id => organizations.id) -# fk_rails_... (quote_id => quotes.id) +# fk_rails_... (quote_version_id => quote_versions.id) # fk_rails_... (signed_by_user_id => users.id) # diff --git a/app/models/quote.rb b/app/models/quote.rb index 16ea9603500..fa1fdb0c877 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -22,6 +22,7 @@ class Quote < ApplicationRecord has_many :versions, -> { order(sequential_id: :desc) }, class_name: "QuoteVersion" has_one :current_version, -> { order(sequential_id: :desc) }, class_name: "QuoteVersion" + has_many :order_forms, through: :versions enum :order_type, ORDER_TYPES, instance_methods: false, diff --git a/app/models/quote_version.rb b/app/models/quote_version.rb index f4605c81177..0bacd6c167d 100644 --- a/app/models/quote_version.rb +++ b/app/models/quote_version.rb @@ -20,6 +20,7 @@ class QuoteVersion < ApplicationRecord belongs_to :organization belongs_to :quote + has_one :order_form enum :status, STATUSES, default: :draft, diff --git a/app/queries/order_forms_query.rb b/app/queries/order_forms_query.rb index c209bac374b..1a091da3d55 100644 --- a/app/queries/order_forms_query.rb +++ b/app/queries/order_forms_query.rb @@ -39,7 +39,7 @@ def filters_contract end def base_scope - organization.order_forms.includes(:customer).ransack(search_params) + organization.order_forms.includes(:customer, :quote_version).ransack(search_params) end def search_params @@ -61,12 +61,14 @@ def with_number(scope) end def with_quote_number(scope) - scope.joins(:quote).where(quotes: {number: filters.quote_number}) + scope.joins(quote_version: :quote).where(quotes: {number: filters.quote_number}) end def with_owner_id(scope) scope.where( - quote_id: QuoteOwner.where(user_id: filters.owner_id).select(:quote_id) + quote_version_id: QuoteVersion.where( + quote_id: QuoteOwner.where(user_id: filters.owner_id).select(:quote_id) + ).select(:id) ) end diff --git a/app/serializers/v1/order_form_serializer.rb b/app/serializers/v1/order_form_serializer.rb index 9c7ae1b6bc2..e496c92c568 100644 --- a/app/serializers/v1/order_form_serializer.rb +++ b/app/serializers/v1/order_form_serializer.rb @@ -15,7 +15,8 @@ def serialize lago_signed_by_user_id: model.signed_by_user_id, lago_organization_id: model.organization_id, lago_customer_id: model.customer_id, - lago_quote_id: model.quote_id, + lago_quote_id: model.quote_version.quote_id, + lago_quote_version_id: model.quote_version_id, created_at: model.created_at.iso8601, updated_at: model.updated_at.iso8601 } diff --git a/db/migrate/20260518152858_create_order_forms.rb b/db/migrate/20260518152858_create_order_forms.rb index 6d3b82b7b9e..132096b61bd 100644 --- a/db/migrate/20260518152858_create_order_forms.rb +++ b/db/migrate/20260518152858_create_order_forms.rb @@ -9,15 +9,15 @@ def change t.references :organization, null: false, foreign_key: true, - index: false, # covered by the composite unique indexes below type: :uuid t.references :customer, null: false, foreign_key: true, type: :uuid - t.references :quote, + t.references :quote_version, null: false, foreign_key: true, + index: {unique: true}, type: :uuid t.references :signed_by_user, foreign_key: {to_table: :users}, @@ -25,7 +25,6 @@ def change type: :uuid t.string :number, null: false - t.integer :sequential_id, null: false t.enum :status, enum_type: :order_form_status, @@ -44,11 +43,7 @@ def change t.timestamps t.index [:organization_id, :number], - unique: true, - name: "index_unique_order_forms_on_organization_number" - t.index [:organization_id, :sequential_id], - unique: true, - name: "index_unique_order_forms_on_organization_sequentialid" + name: "index_order_forms_on_organization_id_and_number" end end end diff --git a/db/structure.sql b/db/structure.sql index 68465d47dc4..71ed56bc419 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18,6 +18,7 @@ ALTER TABLE IF EXISTS ONLY public.subscription_activation_rules DROP CONSTRAINT ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_fd399a23d3; ALTER TABLE IF EXISTS ONLY public.wallet_targets DROP CONSTRAINT IF EXISTS fk_rails_fbd2b9fccb; ALTER TABLE IF EXISTS ONLY public.fees_taxes DROP CONSTRAINT IF EXISTS fk_rails_f98413d404; +ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_f94f882198; ALTER TABLE IF EXISTS ONLY public.billing_entities DROP CONSTRAINT IF EXISTS fk_rails_f66617edcb; ALTER TABLE IF EXISTS ONLY public.payment_receipts DROP CONSTRAINT IF EXISTS fk_rails_f53ff93138; ALTER TABLE IF EXISTS ONLY public.quantified_events DROP CONSTRAINT IF EXISTS fk_rails_f510acb495; @@ -184,7 +185,6 @@ ALTER TABLE IF EXISTS ONLY public.invoice_metadata DROP CONSTRAINT IF EXISTS fk_ ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS fk_rails_62d18ea517; ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_6298debfc7; ALTER TABLE IF EXISTS ONLY public.credit_notes_taxes DROP CONSTRAINT IF EXISTS fk_rails_626209b8d2; -ALTER TABLE IF EXISTS ONLY public.order_forms DROP CONSTRAINT IF EXISTS fk_rails_60bc1d491f; ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_6023b3f2dd; ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS fk_rails_5efea6fe31; ALTER TABLE IF EXISTS ONLY public.fixed_charges DROP CONSTRAINT IF EXISTS fk_rails_5e06da3c18; @@ -385,8 +385,6 @@ DROP INDEX IF EXISTS public.index_unique_quote_versions_on_share_token; DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_sequential_id; DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_active_status; DROP INDEX IF EXISTS public.index_unique_quote_owners_on_quote_user; -DROP INDEX IF EXISTS public.index_unique_order_forms_on_organization_sequentialid; -DROP INDEX IF EXISTS public.index_unique_order_forms_on_organization_number; DROP INDEX IF EXISTS public.index_unique_applied_to_organization_per_organization; DROP INDEX IF EXISTS public.index_uniq_wallet_code_per_customer; DROP INDEX IF EXISTS public.index_uniq_invoice_subscriptions_on_fixed_charges_boundaries; @@ -493,7 +491,9 @@ DROP INDEX IF EXISTS public.index_password_resets_on_token; DROP INDEX IF EXISTS public.index_organizations_on_slug; DROP INDEX IF EXISTS public.index_organizations_on_hmac_key; DROP INDEX IF EXISTS public.index_organizations_on_api_key; -DROP INDEX IF EXISTS public.index_order_forms_on_quote_id; +DROP INDEX IF EXISTS public.index_order_forms_on_quote_version_id; +DROP INDEX IF EXISTS public.index_order_forms_on_organization_id_and_number; +DROP INDEX IF EXISTS public.index_order_forms_on_organization_id; DROP INDEX IF EXISTS public.index_order_forms_on_customer_id; DROP INDEX IF EXISTS public.index_memberships_on_user_id_and_organization_id; DROP INDEX IF EXISTS public.index_memberships_on_user_id; @@ -4635,10 +4635,9 @@ CREATE TABLE public.order_forms ( id uuid DEFAULT gen_random_uuid() NOT NULL, organization_id uuid NOT NULL, customer_id uuid NOT NULL, - quote_id uuid NOT NULL, + quote_version_id uuid NOT NULL, signed_by_user_id uuid, number character varying NOT NULL, - sequential_id integer NOT NULL, status public.order_form_status DEFAULT 'generated'::public.order_form_status NOT NULL, void_reason public.order_form_void_reason, billing_snapshot jsonb NOT NULL, @@ -8737,10 +8736,24 @@ CREATE INDEX index_order_forms_on_customer_id ON public.order_forms USING btree -- --- Name: index_order_forms_on_quote_id; Type: INDEX; Schema: public; Owner: - +-- Name: index_order_forms_on_organization_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX index_order_forms_on_quote_id ON public.order_forms USING btree (quote_id); +CREATE INDEX index_order_forms_on_organization_id ON public.order_forms USING btree (organization_id); + + +-- +-- Name: index_order_forms_on_organization_id_and_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_order_forms_on_organization_id_and_number ON public.order_forms USING btree (organization_id, number); + + +-- +-- Name: index_order_forms_on_quote_version_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_order_forms_on_quote_version_id ON public.order_forms USING btree (quote_version_id); -- @@ -9485,20 +9498,6 @@ CREATE UNIQUE INDEX index_uniq_wallet_code_per_customer ON public.wallets USING CREATE UNIQUE INDEX index_unique_applied_to_organization_per_organization ON public.dunning_campaigns USING btree (organization_id) WHERE ((applied_to_organization = true) AND (deleted_at IS NULL)); --- --- Name: index_unique_order_forms_on_organization_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_unique_order_forms_on_organization_number ON public.order_forms USING btree (organization_id, number); - - --- --- Name: index_unique_order_forms_on_organization_sequentialid; Type: INDEX; Schema: public; Owner: - --- - -CREATE UNIQUE INDEX index_unique_order_forms_on_organization_sequentialid ON public.order_forms USING btree (organization_id, sequential_id); - - -- -- Name: index_unique_quote_owners_on_quote_user; Type: INDEX; Schema: public; Owner: - -- @@ -10928,14 +10927,6 @@ ALTER TABLE ONLY public.fees ADD CONSTRAINT fk_rails_6023b3f2dd FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); --- --- Name: order_forms fk_rails_60bc1d491f; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.order_forms - ADD CONSTRAINT fk_rails_60bc1d491f FOREIGN KEY (quote_id) REFERENCES public.quotes(id); - - -- -- Name: credit_notes_taxes fk_rails_626209b8d2; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -12264,6 +12255,14 @@ ALTER TABLE ONLY public.billing_entities ADD CONSTRAINT fk_rails_f66617edcb FOREIGN KEY (organization_id) REFERENCES public.organizations(id); +-- +-- Name: order_forms fk_rails_f94f882198; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.order_forms + ADD CONSTRAINT fk_rails_f94f882198 FOREIGN KEY (quote_version_id) REFERENCES public.quote_versions(id); + + -- -- Name: fees_taxes fk_rails_f98413d404; Type: FK CONSTRAINT; Schema: public; Owner: - -- diff --git a/spec/factories/order_forms.rb b/spec/factories/order_forms.rb index 8875fb672da..f6b5e2077c6 100644 --- a/spec/factories/order_forms.rb +++ b/spec/factories/order_forms.rb @@ -4,7 +4,11 @@ factory :order_form do customer organization { customer&.organization || association(:organization) } - quote { association(:quote, customer:, organization:) } + quote_version do + association(:quote_version, + organization:, + quote: association(:quote, organization:, customer:)) + end billing_snapshot { {items: []} } status { :generated } @@ -26,13 +30,5 @@ voided_at { Time.current } void_reason { :manual } end - - trait :expiring_tomorrow do - expires_at { 1.day.from_now } - end - - trait :expired_yesterday do - expires_at { 1.day.ago } - end end end diff --git a/spec/factories/quotes.rb b/spec/factories/quotes.rb index bc27c2233ec..e77f9ae4568 100644 --- a/spec/factories/quotes.rb +++ b/spec/factories/quotes.rb @@ -22,9 +22,5 @@ ) end end - - trait :auto_execute do - auto_execute { true } - end end end diff --git a/spec/models/order_form_spec.rb b/spec/models/order_form_spec.rb index 572df60437f..93fc85e6d7c 100644 --- a/spec/models/order_form_spec.rb +++ b/spec/models/order_form_spec.rb @@ -3,7 +3,7 @@ require "rails_helper" RSpec.describe OrderForm do - subject(:order_form) { build(:order_form) } + subject(:order_form) { build(:order_form, quote_version: nil) } describe "enums" do it do @@ -25,7 +25,8 @@ it do expect(order_form).to belong_to(:organization) expect(order_form).to belong_to(:customer) - expect(order_form).to belong_to(:quote) + expect(order_form).to belong_to(:quote_version) + expect(order_form).to have_one(:quote).through(:quote_version) end end @@ -33,5 +34,34 @@ it do expect(order_form).to validate_presence_of(:billing_snapshot) end + + describe "number presence" do + it "is required when the callback cannot derive it" do + expect(order_form).not_to be_valid + expect(order_form.errors[:number]).to be_present + end + end + end + + describe "#ensure_number" do + let(:quote) { create(:quote) } + let(:quote_version) { create(:quote_version, quote:, organization: quote.organization) } + + it "derives the number from the parent quote on save" do + order_form = build(:order_form, quote_version:, number: nil) + + expect { order_form.save! } + .to change(order_form, :number) + .from(nil) + .to(quote.number.sub("QT", "OF")) + end + + context "when the number is already set" do + it "does not overwrite it" do + order_form = build(:order_form, quote_version:, number: "OF-CUSTOM") + + expect { order_form.save! }.not_to change(order_form, :number).from("OF-CUSTOM") + end + end end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 5d9d7734bc0..b99800d770e 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -56,6 +56,7 @@ expect(subject).to have_one(:applied_dunning_campaign).conditions(applied_to_organization: true) expect(subject).to have_one(:enriched_store_migration) expect(subject).to have_many(:pending_vies_checks) + expect(subject).to have_many(:order_forms) end end diff --git a/spec/models/quote_spec.rb b/spec/models/quote_spec.rb index 32e272a99ea..e09d3a24f21 100644 --- a/spec/models/quote_spec.rb +++ b/spec/models/quote_spec.rb @@ -28,6 +28,7 @@ expect(subject).to have_many(:owners).through(:quote_owners) expect(subject).to have_many(:versions).class_name("QuoteVersion").order(sequential_id: :desc) expect(subject).to have_one(:current_version).class_name("QuoteVersion").order(sequential_id: :desc) + expect(subject).to have_many(:order_forms).through(:versions) end end diff --git a/spec/models/quote_version_spec.rb b/spec/models/quote_version_spec.rb index 390883d4532..fea28473f0b 100644 --- a/spec/models/quote_version_spec.rb +++ b/spec/models/quote_version_spec.rb @@ -30,6 +30,7 @@ it do expect(subject).to belong_to(:organization) expect(subject).to belong_to(:quote) + expect(subject).to have_one(:order_form) end end diff --git a/spec/queries/order_forms_query_spec.rb b/spec/queries/order_forms_query_spec.rb index 3e51ea58ac9..ec347abb183 100644 --- a/spec/queries/order_forms_query_spec.rb +++ b/spec/queries/order_forms_query_spec.rb @@ -15,8 +15,9 @@ let(:organization) { membership.organization } let(:customer) { create(:customer, organization:) } let(:quote) { create(:quote, organization:, customer:) } - let(:order_form_one) { create(:order_form, organization:, customer:, quote:) } - let(:order_form_two) { create(:order_form, organization:, customer:, quote:) } + let(:quote_version) { create(:quote_version, quote:, organization:) } + let(:order_form_one) { create(:order_form, organization:, customer:, quote_version:) } + let(:order_form_two) { create(:order_form, organization:, customer:) } before do order_form_one @@ -41,7 +42,7 @@ end context "when filtering by status" do - let(:order_form_two) { create(:order_form, :signed, organization:, customer:, quote:) } + let(:order_form_two) { create(:order_form, :signed, organization:, customer:) } let(:filters) { {status: "generated"} } it "returns only order forms with the specified status" do @@ -53,7 +54,8 @@ context "when filtering by customer_id" do let(:other_customer) { create(:customer, organization:) } let(:other_quote) { create(:quote, organization:, customer: other_customer) } - let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote: other_quote) } + let(:other_quote_version) { create(:quote_version, quote: other_quote, organization:) } + let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote_version: other_quote_version) } let(:filters) { {customer_id: [customer.id]} } it "returns only order forms for the specified customer" do @@ -74,7 +76,8 @@ context "when filtering by quote_number" do let(:other_customer) { create(:customer, organization:) } let(:other_quote) { create(:quote, organization:, customer: other_customer) } - let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote: other_quote) } + let(:other_quote_version) { create(:quote_version, quote: other_quote, organization:) } + let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote_version: other_quote_version) } let(:filters) { {quote_number: [quote.number]} } it "returns only order forms linked to the specified quote" do @@ -87,7 +90,8 @@ let(:user) { membership.user } let(:other_customer) { create(:customer, organization:) } let(:other_quote) { create(:quote, organization:, customer: other_customer) } - let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote: other_quote) } + let(:other_quote_version) { create(:quote_version, quote: other_quote, organization:) } + let(:order_form_two) { create(:order_form, organization:, customer: other_customer, quote_version: other_quote_version) } let(:filters) { {owner_id: [user.id]} } before { QuoteOwner.create!(organization:, quote:, user:) } @@ -99,8 +103,8 @@ end context "when filtering by created_at range" do - let(:order_form_one) { create(:order_form, organization:, customer:, quote:, created_at: 3.days.ago) } - let(:order_form_two) { create(:order_form, organization:, customer:, quote:, created_at: 1.day.ago) } + let(:order_form_one) { create(:order_form, organization:, customer:, created_at: 3.days.ago) } + let(:order_form_two) { create(:order_form, organization:, customer:, created_at: 1.day.ago) } let(:filters) { {created_at_from: 2.days.ago.iso8601, created_at_to: Time.current.iso8601} } it "returns only order forms within the date range" do @@ -110,8 +114,8 @@ end context "when filtering by expires_at range" do - let(:order_form_one) { create(:order_form, organization:, customer:, quote:, expires_at: 5.days.from_now) } - let(:order_form_two) { create(:order_form, organization:, customer:, quote:, expires_at: 15.days.from_now) } + let(:order_form_one) { create(:order_form, organization:, customer:, expires_at: 5.days.from_now) } + let(:order_form_two) { create(:order_form, organization:, customer:, expires_at: 15.days.from_now) } let(:filters) { {expires_at_from: 3.days.from_now.iso8601, expires_at_to: 10.days.from_now.iso8601} } it "returns only order forms expiring within the date range" do @@ -152,9 +156,10 @@ let(:other_organization) { create(:organization) } let(:other_customer) { create(:customer, organization: other_organization) } let(:other_quote) { create(:quote, organization: other_organization, customer: other_customer) } + let(:other_quote_version) { create(:quote_version, quote: other_quote, organization: other_organization) } before do - create(:order_form, organization: other_organization, customer: other_customer, quote: other_quote) + create(:order_form, organization: other_organization, customer: other_customer, quote_version: other_quote_version) end it "does not return order forms from other organizations" do diff --git a/spec/requests/api/v1/order_forms_controller_spec.rb b/spec/requests/api/v1/order_forms_controller_spec.rb index 1ef494d47a7..e89270dcf5c 100644 --- a/spec/requests/api/v1/order_forms_controller_spec.rb +++ b/spec/requests/api/v1/order_forms_controller_spec.rb @@ -6,14 +6,15 @@ let(:organization) { create(:organization, feature_flags: ["order_forms"]) } let(:customer) { create(:customer, organization:) } let(:quote) { create(:quote, organization:, customer:) } - let(:order_form) { create(:order_form, organization:, customer:, quote:) } + let(:quote_version) { create(:quote_version, quote:, organization:) } + let(:order_form) { create(:order_form, organization:, customer:, quote_version:) } describe "GET /api/v1/order_forms" do subject { get_with_token(organization, "/api/v1/order_forms") } - let!(:order_form) { create(:order_form, organization:, customer:, quote:) } + let!(:order_form) { create(:order_form, organization:, customer:, quote_version:) } - before { create(:order_form, :signed, organization:, customer:, quote:) } + before { create(:order_form, :signed, organization:, customer:) } include_examples "requires API permission", "order_form", "read" diff --git a/spec/serializers/v1/order_form_serializer_spec.rb b/spec/serializers/v1/order_form_serializer_spec.rb index 7836b9b1ce4..59fa5b7d5f4 100644 --- a/spec/serializers/v1/order_form_serializer_spec.rb +++ b/spec/serializers/v1/order_form_serializer_spec.rb @@ -22,7 +22,8 @@ "lago_signed_by_user_id" => nil, "lago_organization_id" => order_form.organization_id, "lago_customer_id" => order_form.customer_id, - "lago_quote_id" => order_form.quote_id, + "lago_quote_id" => order_form.quote_version.quote_id, + "lago_quote_version_id" => order_form.quote_version_id, "created_at" => order_form.created_at.iso8601, "updated_at" => order_form.updated_at.iso8601 ) From a6b830b41bee3efdedc1804be72e248727c6dc68 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Tue, 19 May 2026 09:45:29 +0200 Subject: [PATCH 06/13] feat(order-forms): add signed_by_user association on OrderForm ## Context The `order_forms` table has a nullable `signed_by_user_id` foreign key to `users`, but the `OrderForm` model was missing the corresponding Active Record association. The `:signed` factory trait worked around the gap by setting the foreign key directly via `association(:user).id`. ## Description Declare `belongs_to :signed_by_user, class_name: "User", optional: true` on `OrderForm`, update the `:signed` factory trait to use the association, and assert it in the model spec. --- app/models/order_form.rb | 1 + spec/factories/order_forms.rb | 2 +- spec/models/order_form_spec.rb | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/order_form.rb b/app/models/order_form.rb index b90bc6d1ab2..d7e3fa96186 100644 --- a/app/models/order_form.rb +++ b/app/models/order_form.rb @@ -19,6 +19,7 @@ class OrderForm < ApplicationRecord belongs_to :organization belongs_to :customer belongs_to :quote_version + belongs_to :signed_by_user, class_name: "User", optional: true has_one :quote, through: :quote_version enum :status, STATUSES, diff --git a/spec/factories/order_forms.rb b/spec/factories/order_forms.rb index f6b5e2077c6..c01dd85fb08 100644 --- a/spec/factories/order_forms.rb +++ b/spec/factories/order_forms.rb @@ -15,7 +15,7 @@ trait :signed do status { :signed } signed_at { Time.current } - signed_by_user_id { association(:user).id } + signed_by_user { association(:user) } end trait :expired do diff --git a/spec/models/order_form_spec.rb b/spec/models/order_form_spec.rb index 93fc85e6d7c..c12e6f9d10f 100644 --- a/spec/models/order_form_spec.rb +++ b/spec/models/order_form_spec.rb @@ -26,6 +26,7 @@ expect(order_form).to belong_to(:organization) expect(order_form).to belong_to(:customer) expect(order_form).to belong_to(:quote_version) + expect(order_form).to belong_to(:signed_by_user).class_name("User").optional expect(order_form).to have_one(:quote).through(:quote_version) end end From 8d00098bf7186fd1a238fab4365c92285bacc19e Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Tue, 19 May 2026 09:46:24 +0200 Subject: [PATCH 07/13] feat(order-forms): add Customer#order_forms inverse association ## Context `OrderForm` declares `belongs_to :customer`, but the `Customer` model was missing the inverse `has_many :order_forms`. This left the association one-sided and prevented querying a customer's order forms through the usual Active Record idiom (mirroring `has_many :quotes`, which is already in place). ## Description Declare `has_many :order_forms` on `Customer` and assert it in the model spec. --- app/models/customer.rb | 1 + spec/models/customer_spec.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/app/models/customer.rb b/app/models/customer.rb index 172d76187b4..238c47a85a8 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -64,6 +64,7 @@ class Customer < ApplicationRecord has_many :add_ons, through: :applied_add_ons has_many :daily_usages has_many :quotes + has_many :order_forms has_many :wallets has_many :wallet_transactions, through: :wallets has_many :payment_provider_customers, diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb index 35a6d866076..4657c9d4fc4 100644 --- a/spec/models/customer_spec.rb +++ b/spec/models/customer_spec.rb @@ -18,6 +18,7 @@ it { is_expected.to have_many(:payment_methods) } it { is_expected.to have_many(:payment_requests) } it { is_expected.to have_many(:error_details).dependent(:destroy) } + it { is_expected.to have_many(:order_forms) } it { is_expected.to have_one(:netsuite_customer) } it { is_expected.to have_one(:anrok_customer) } From a90747b38d5242a55113fffb11c51c2619b64e1d Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Tue, 19 May 2026 13:33:21 +0200 Subject: [PATCH 08/13] chore(order-forms): seed OrderForms with varied statuses --- db/seeds/70_order_forms.rb | 69 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/db/seeds/70_order_forms.rb b/db/seeds/70_order_forms.rb index 5b90ca7c889..6c9505a1d3c 100644 --- a/db/seeds/70_order_forms.rb +++ b/db/seeds/70_order_forms.rb @@ -4,6 +4,8 @@ @organization = Organization.find_by!(name: "Hooli") @customer = Customer.find_by!(external_id: "cust_john-doe") +@organization.enable_feature_flag!(:order_forms) + def create_quote(organization:, customer:, **params) quote = ::Quote.new( organization: organization, @@ -24,6 +26,32 @@ def create_quote_version(quote:, **params) quote_version end +def create_order_form(quote_version:, status: :generated, signed_by: nil, expires_at: nil) + attrs = { + organization: quote_version.organization, + customer: quote_version.quote.customer, + quote_version: quote_version, + billing_snapshot: {items: [{name: "Seeded item", amount_cents: 10_000, currency: "EUR"}]}, + status: status, + expires_at: expires_at + } + + case status + when :signed + attrs[:signed_at] = 1.day.ago + attrs[:signed_by_user] = signed_by + when :expired + attrs[:expires_at] = 2.days.ago + attrs[:voided_at] = 1.day.ago + attrs[:void_reason] = :expired + when :voided + attrs[:voided_at] = 1.day.ago + attrs[:void_reason] = :manual + end + + OrderForm.create!(attrs) +end + # Create a chain of quotes def create_quote_chain(organization:, customer:, versions_count: 3) quote = create_quote( @@ -62,12 +90,47 @@ def create_draft_quote_for_each_customer(organization:) customer: customer, order_type: :one_off ) - quote_version = create_quote_version( - quote: quote - ) + quote_version = create_quote_version(quote: quote) quote.update!(current_version: quote_version) end end +# Create a standalone approved quote with an order form. Order forms only exist +# on approved quote_versions, so we build a fresh quote/version dedicated to +# them — without touching the draft quotes seeded above, which may be relied on +# by other QA flows. +def create_approved_quote_with_order_form(organization:, customer:, status: :generated, signed_by: nil, expires_at: nil) + quote = create_quote( + organization: organization, + customer: customer, + order_type: :one_off + ) + quote_version = create_quote_version( + quote: quote, + status: :approved, + approved_at: Time.current + ) + quote.update!(current_version: quote_version) + create_order_form( + quote_version: quote_version, + status: status, + signed_by: signed_by, + expires_at: expires_at + ) +end + create_quote_chain(organization: @organization, customer: @customer) create_draft_quote_for_each_customer(organization: @organization) + +gavin = User.find_by(email: "gavin@hooli.com") +[ + {customer_external_id: "cust_john-doe", status: :generated}, + {customer_external_id: "cust_1", status: :generated}, + {customer_external_id: "cust_2", status: :signed, signed_by: gavin}, + {customer_external_id: "cust_3", status: :expired}, + {customer_external_id: "cust_4", status: :voided}, + {customer_external_id: "cust_5", status: :generated, expires_at: 7.days.from_now} +].each do |params| + customer = Customer.find_by!(external_id: params.delete(:customer_external_id)) + create_approved_quote_with_order_form(organization: @organization, customer: customer, **params) +end From c837a8dab8050056e9f95f86ec145909d06c75e7 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Thu, 21 May 2026 12:09:47 +0200 Subject: [PATCH 09/13] feat(order-forms): sequence number and drop unused text fields --- db/migrate/20260518152858_create_order_forms.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/db/migrate/20260518152858_create_order_forms.rb b/db/migrate/20260518152858_create_order_forms.rb index 132096b61bd..d000c0f5d1b 100644 --- a/db/migrate/20260518152858_create_order_forms.rb +++ b/db/migrate/20260518152858_create_order_forms.rb @@ -9,6 +9,7 @@ def change t.references :organization, null: false, foreign_key: true, + index: false, # covered by the composite unique indexes below type: :uuid t.references :customer, null: false, @@ -25,6 +26,7 @@ def change type: :uuid t.string :number, null: false + t.integer :sequential_id, null: false t.enum :status, enum_type: :order_form_status, @@ -33,8 +35,6 @@ def change t.enum :void_reason, enum_type: :order_form_void_reason t.jsonb :billing_snapshot, null: false - t.text :content - t.text :legal_text t.datetime :expires_at t.datetime :signed_at @@ -42,8 +42,14 @@ def change t.timestamps + t.check_constraint "sequential_id > 0", + name: "order_forms_constraint_sequential_id_positive" + t.index [:organization_id, :sequential_id], + unique: true, + name: "index_unique_order_forms_on_organization_sequential_id" t.index [:organization_id, :number], - name: "index_order_forms_on_organization_id_and_number" + unique: true, + name: "index_unique_order_forms_on_organization_number" end end end From b399ace4ccbdf4dfdf9d315726a128a11392f272 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Thu, 21 May 2026 12:13:12 +0200 Subject: [PATCH 10/13] feat(order-forms): use Sequenced concern for OrderForm number --- app/models/order_form.rb | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/app/models/order_form.rb b/app/models/order_form.rb index d7e3fa96186..a70a530d4cd 100644 --- a/app/models/order_form.rb +++ b/app/models/order_form.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class OrderForm < ApplicationRecord + include Sequenced + STATUSES = { generated: "generated", signed: "signed", @@ -14,7 +16,7 @@ class OrderForm < ApplicationRecord invalid: "invalid" }.freeze - before_validation :ensure_number + before_save :ensure_number belongs_to :organization belongs_to :customer @@ -30,7 +32,11 @@ class OrderForm < ApplicationRecord validate: {allow_nil: true} validates :billing_snapshot, presence: true - validates :number, presence: true + + sequenced( + scope: ->(order_form) { order_form.organization.order_forms }, + lock_key: ->(order_form) { order_form.organization_id } + ) def self.ransackable_attributes(_ = nil) %w[number] @@ -40,9 +46,11 @@ def self.ransackable_attributes(_ = nil) def ensure_number return if number.present? - return if quote_version.blank? + return if sequential_id.blank? - self.number = quote_version.quote.number.sub(/\AQT-/, "OF-") + time = created_at || Time.current + formatted_sequential_id = format("%04d", sequential_id) + self.number = "OF-#{time.strftime("%Y")}-#{formatted_sequential_id}" end end @@ -53,10 +61,9 @@ def ensure_number # # id :uuid not null, primary key # billing_snapshot :jsonb not null -# content :text # expires_at :datetime -# legal_text :text # number :string not null +# sequential_id :integer not null # signed_at :datetime # status :enum default("generated"), not null # void_reason :enum @@ -70,10 +77,10 @@ def ensure_number # # Indexes # -# index_order_forms_on_customer_id (customer_id) -# index_order_forms_on_organization_id (organization_id) -# index_order_forms_on_organization_id_and_number (organization_id,number) -# index_order_forms_on_quote_version_id (quote_version_id) UNIQUE +# index_order_forms_on_customer_id (customer_id) +# index_order_forms_on_quote_version_id (quote_version_id) UNIQUE +# index_unique_order_forms_on_organization_number (organization_id,number) UNIQUE +# index_unique_order_forms_on_organization_sequential_id (organization_id,sequential_id) UNIQUE # # Foreign Keys # From c79673f0102df08bb368387d8b90c442636e350b Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Thu, 21 May 2026 12:17:04 +0200 Subject: [PATCH 11/13] test(order-forms): cover Sequenced number generation --- spec/models/order_form_spec.rb | 50 ++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/spec/models/order_form_spec.rb b/spec/models/order_form_spec.rb index c12e6f9d10f..46f271c6451 100644 --- a/spec/models/order_form_spec.rb +++ b/spec/models/order_form_spec.rb @@ -35,34 +35,42 @@ it do expect(order_form).to validate_presence_of(:billing_snapshot) end - - describe "number presence" do - it "is required when the callback cannot derive it" do - expect(order_form).not_to be_valid - expect(order_form.errors[:number]).to be_present - end - end end - describe "#ensure_number" do - let(:quote) { create(:quote) } - let(:quote_version) { create(:quote_version, quote:, organization: quote.organization) } - - it "derives the number from the parent quote on save" do - order_form = build(:order_form, quote_version:, number: nil) + describe "sequencing" do + it "assigns sequential ids per organization" do + organization = create(:organization) + customer = create(:customer, organization:) + first = create(:order_form, organization:, customer:) + second = create(:order_form, organization:, customer:) + expect([first.sequential_id, second.sequential_id]).to eq([1, 2]) + end - expect { order_form.save! } - .to change(order_form, :number) - .from(nil) - .to(quote.number.sub("QT", "OF")) + it "scopes the sequence per organization" do + org_a = create(:organization) + org_b = create(:organization) + a1 = create(:order_form, organization: org_a, customer: create(:customer, organization: org_a)) + b1 = create(:order_form, organization: org_b, customer: create(:customer, organization: org_b)) + expect([a1.sequential_id, b1.sequential_id]).to eq([1, 1]) end + end - context "when the number is already set" do - it "does not overwrite it" do - order_form = build(:order_form, quote_version:, number: "OF-CUSTOM") + describe "ensure_number callback" do + it "assigns a formatted number when sequential_id and created_at are present" do + order_form = create(:order_form, created_at: Time.zone.local(2020, 1, 2)) + expect(order_form.number).to eq("OF-2020-#{format("%04d", order_form.sequential_id)}") + end - expect { order_form.save! }.not_to change(order_form, :number).from("OF-CUSTOM") + it "uses the current year when created_at is blank on save" do + travel_to(Time.zone.local(2026, 6, 1)) do + order_form = create(:order_form, created_at: nil) + expect(order_form.number).to eq("OF-2026-#{format("%04d", order_form.sequential_id)}") end end + + it "preserves an explicitly assigned number" do + order_form = create(:order_form, number: "OF-CUSTOM-0001") + expect(order_form.number).to eq("OF-CUSTOM-0001") + end end end From 197e9fd8ee0e6d93d0b1dbf488b9a92bf1b9d13a Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Thu, 21 May 2026 12:18:02 +0200 Subject: [PATCH 12/13] chore(order-forms): refresh annotation after migration --- app/models/order_form.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/models/order_form.rb b/app/models/order_form.rb index a70a530d4cd..b343ebdf793 100644 --- a/app/models/order_form.rb +++ b/app/models/order_form.rb @@ -63,7 +63,6 @@ def ensure_number # billing_snapshot :jsonb not null # expires_at :datetime # number :string not null -# sequential_id :integer not null # signed_at :datetime # status :enum default("generated"), not null # void_reason :enum @@ -73,14 +72,15 @@ def ensure_number # customer_id :uuid not null # organization_id :uuid not null # quote_version_id :uuid not null +# sequential_id :integer not null # signed_by_user_id :uuid # # Indexes # -# index_order_forms_on_customer_id (customer_id) -# index_order_forms_on_quote_version_id (quote_version_id) UNIQUE -# index_unique_order_forms_on_organization_number (organization_id,number) UNIQUE -# index_unique_order_forms_on_organization_sequential_id (organization_id,sequential_id) UNIQUE +# index_order_forms_on_customer_id (customer_id) +# index_order_forms_on_quote_version_id (quote_version_id) UNIQUE +# index_unique_order_forms_on_organization_number (organization_id,number) UNIQUE +# index_unique_order_forms_on_organization_sequential_id (organization_id,sequential_id) UNIQUE # # Foreign Keys # From 3087d6cff1e02e21cbf899882353b5d32fa108d9 Mon Sep 17 00:00:00 2001 From: Thomas Battiston Date: Thu, 21 May 2026 12:19:07 +0200 Subject: [PATCH 13/13] chore(order-forms): update structure.sql for OrderForm schema --- db/structure.sql | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/db/structure.sql b/db/structure.sql index 71ed56bc419..8edb4be8930 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -385,6 +385,8 @@ DROP INDEX IF EXISTS public.index_unique_quote_versions_on_share_token; DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_sequential_id; DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_active_status; DROP INDEX IF EXISTS public.index_unique_quote_owners_on_quote_user; +DROP INDEX IF EXISTS public.index_unique_order_forms_on_organization_sequential_id; +DROP INDEX IF EXISTS public.index_unique_order_forms_on_organization_number; DROP INDEX IF EXISTS public.index_unique_applied_to_organization_per_organization; DROP INDEX IF EXISTS public.index_uniq_wallet_code_per_customer; DROP INDEX IF EXISTS public.index_uniq_invoice_subscriptions_on_fixed_charges_boundaries; @@ -492,8 +494,6 @@ DROP INDEX IF EXISTS public.index_organizations_on_slug; DROP INDEX IF EXISTS public.index_organizations_on_hmac_key; DROP INDEX IF EXISTS public.index_organizations_on_api_key; DROP INDEX IF EXISTS public.index_order_forms_on_quote_version_id; -DROP INDEX IF EXISTS public.index_order_forms_on_organization_id_and_number; -DROP INDEX IF EXISTS public.index_order_forms_on_organization_id; DROP INDEX IF EXISTS public.index_order_forms_on_customer_id; DROP INDEX IF EXISTS public.index_memberships_on_user_id_and_organization_id; DROP INDEX IF EXISTS public.index_memberships_on_user_id; @@ -4638,16 +4638,16 @@ CREATE TABLE public.order_forms ( quote_version_id uuid NOT NULL, signed_by_user_id uuid, number character varying NOT NULL, + sequential_id integer NOT NULL, status public.order_form_status DEFAULT 'generated'::public.order_form_status NOT NULL, void_reason public.order_form_void_reason, billing_snapshot jsonb NOT NULL, - content text, - legal_text text, expires_at timestamp(6) without time zone, signed_at timestamp(6) without time zone, voided_at timestamp(6) without time zone, created_at timestamp(6) without time zone NOT NULL, - updated_at timestamp(6) without time zone NOT NULL + updated_at timestamp(6) without time zone NOT NULL, + CONSTRAINT order_forms_constraint_sequential_id_positive CHECK ((sequential_id > 0)) ); @@ -8735,20 +8735,6 @@ CREATE UNIQUE INDEX index_memberships_on_user_id_and_organization_id ON public.m CREATE INDEX index_order_forms_on_customer_id ON public.order_forms USING btree (customer_id); --- --- Name: index_order_forms_on_organization_id; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_order_forms_on_organization_id ON public.order_forms USING btree (organization_id); - - --- --- Name: index_order_forms_on_organization_id_and_number; Type: INDEX; Schema: public; Owner: - --- - -CREATE INDEX index_order_forms_on_organization_id_and_number ON public.order_forms USING btree (organization_id, number); - - -- -- Name: index_order_forms_on_quote_version_id; Type: INDEX; Schema: public; Owner: - -- @@ -9498,6 +9484,20 @@ CREATE UNIQUE INDEX index_uniq_wallet_code_per_customer ON public.wallets USING CREATE UNIQUE INDEX index_unique_applied_to_organization_per_organization ON public.dunning_campaigns USING btree (organization_id) WHERE ((applied_to_organization = true) AND (deleted_at IS NULL)); +-- +-- Name: index_unique_order_forms_on_organization_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_order_forms_on_organization_number ON public.order_forms USING btree (organization_id, number); + + +-- +-- Name: index_unique_order_forms_on_organization_sequential_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_order_forms_on_organization_sequential_id ON public.order_forms USING btree (organization_id, sequential_id); + + -- -- Name: index_unique_quote_owners_on_quote_user; Type: INDEX; Schema: public; Owner: - --