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..dc2cd5fd56b --- /dev/null +++ b/app/controllers/api/v1/order_forms_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Api + module V1 + class OrderFormsController < Api::BaseController + before_action :ensure_feature_flag! + + 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 ensure_feature_flag! + forbidden_error(code: "feature_not_available") unless current_organization.feature_flag_enabled?(:order_forms) + end + + 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/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/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 new file mode 100644 index 00000000000..b343ebdf793 --- /dev/null +++ b/app/models/order_form.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class OrderForm < ApplicationRecord + include Sequenced + + STATUSES = { + generated: "generated", + signed: "signed", + expired: "expired", + voided: "voided" + }.freeze + + VOID_REASONS = { + manual: "manual", + expired: "expired", + invalid: "invalid" + }.freeze + + before_save :ensure_number + + 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, + default: :generated, + validate: true + enum :void_reason, VOID_REASONS, + instance_methods: false, + validate: {allow_nil: true} + + validates :billing_snapshot, 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] + end + + 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 +# expires_at :datetime +# 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_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 +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (quote_version_id => quote_versions.id) +# fk_rails_... (signed_by_user_id => users.id) +# diff --git a/app/models/organization.rb b/app/models/organization.rb index 7f48b16fe0d..d8ac34e3e1b 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/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 new file mode 100644 index 00000000000..1a091da3d55 --- /dev/null +++ b/app/queries/order_forms_query.rb @@ -0,0 +1,110 @@ +# 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, :quote_version).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_version: :quote).where(quotes: {number: filters.quote_number}) + end + + def with_owner_id(scope) + scope.where( + quote_version_id: QuoteVersion.where( + quote_id: QuoteOwner.where(user_id: filters.owner_id).select(:quote_id) + ).select(: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..e496c92c568 --- /dev/null +++ b/app/serializers/v1/order_form_serializer.rb @@ -0,0 +1,25 @@ +# 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_version.quote_id, + lago_quote_version_id: model.quote_version_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/db/migrate/20260518152858_create_order_forms.rb b/db/migrate/20260518152858_create_order_forms.rb new file mode 100644 index 00000000000..d000c0f5d1b --- /dev/null +++ b/db/migrate/20260518152858_create_order_forms.rb @@ -0,0 +1,55 @@ +# 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_version, + null: false, + foreign_key: true, + index: {unique: 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.datetime :expires_at + t.datetime :signed_at + t.datetime :voided_at + + 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], + unique: true, + name: "index_unique_order_forms_on_organization_number" + end + end +end 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 diff --git a/db/structure.sql b/db/structure.sql index 4065bcab6a1..ae6d05dae92 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; @@ -182,6 +183,7 @@ 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.fees DROP CONSTRAINT IF EXISTS fk_rails_6023b3f2dd; ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS fk_rails_5efea6fe31; @@ -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_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; @@ -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_version_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: - -- @@ -4591,6 +4626,30 @@ 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_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, + 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, + CONSTRAINT order_forms_constraint_sequential_id_positive CHECK ((sequential_id > 0)) +); + + -- -- Name: password_resets; Type: TABLE; Schema: public; Owner: - -- @@ -5818,6 +5877,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: - -- @@ -8660,6 +8727,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_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); + + -- -- Name: index_organizations_on_api_key; Type: INDEX; Schema: public; Owner: - -- @@ -9402,6 +9483,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: - -- @@ -10007,6 +10102,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: - -- @@ -10623,6 +10726,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: - -- @@ -10823,6 +10934,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: - -- @@ -12135,6 +12254,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: - -- @@ -12215,6 +12342,7 @@ SET search_path TO "$user", public; INSERT INTO "schema_migrations" (version) VALUES ('20260520075420'), +('20260518152858'), ('20260517101105'), ('20260513105210'), ('20260513105209'), @@ -13217,3 +13345,4 @@ INSERT INTO "schema_migrations" (version) VALUES ('20220530091046'), ('20220526101535'), ('20220525122759'); + diff --git a/spec/factories/order_forms.rb b/spec/factories/order_forms.rb new file mode 100644 index 00000000000..c01dd85fb08 --- /dev/null +++ b/spec/factories/order_forms.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :order_form do + customer + organization { customer&.organization || association(:organization) } + quote_version do + association(:quote_version, + organization:, + quote: association(:quote, organization:, customer:)) + end + billing_snapshot { {items: []} } + status { :generated } + + trait :signed do + status { :signed } + signed_at { Time.current } + signed_by_user { association(:user) } + 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 + end +end 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) } diff --git a/spec/models/order_form_spec.rb b/spec/models/order_form_spec.rb new file mode 100644 index 00000000000..46f271c6451 --- /dev/null +++ b/spec/models/order_form_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrderForm do + subject(:order_form) { build(:order_form, quote_version: nil) } + + 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) + .backed_by_column_of_type(:enum) + .validating(allowing_nil: true) + .with_values(manual: "manual", expired: "expired", invalid: "invalid") + .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_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 + + describe "validations" do + it do + expect(order_form).to validate_presence_of(:billing_snapshot) + end + end + + 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 + + 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 + + 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 + + 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 diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index b5c75193264..38933c1a8f2 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 new file mode 100644 index 00000000000..ec347abb183 --- /dev/null +++ b/spec/queries/order_forms_query_spec.rb @@ -0,0 +1,170 @@ +# 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(: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 + 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:) } + 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(: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 + 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(: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 + 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(: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:) } + + 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:, 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 + 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:, 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 + 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) } + 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_version: other_quote_version) + 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..e89270dcf5c --- /dev/null +++ b/spec/requests/api/v1/order_forms_controller_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::OrderFormsController do + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:customer) { create(:customer, organization:) } + let(:quote) { create(:quote, organization:, customer:) } + 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_version:) } + + before { create(:order_form, :signed, organization:, customer:) } + + 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 + + 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 + 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 + + 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 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..59fa5b7d5f4 --- /dev/null +++ b/spec/serializers/v1/order_form_serializer_spec.rb @@ -0,0 +1,31 @@ +# 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_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 + ) + end +end