diff --git a/app/graphql/types/dunning_campaign_thresholds/create_input.rb b/app/graphql/types/dunning_campaign_thresholds/create_input.rb new file mode 100644 index 00000000000..dc55c6a375b --- /dev/null +++ b/app/graphql/types/dunning_campaign_thresholds/create_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module DunningCampaignThresholds + class CreateInput < Types::BaseInputObject + graphql_name "CreateDunningCampaignThresholdInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :currency, Types::CurrencyEnum, required: true + end + end +end diff --git a/app/graphql/types/dunning_campaign_thresholds/object.rb b/app/graphql/types/dunning_campaign_thresholds/object.rb new file mode 100644 index 00000000000..c96df003e2a --- /dev/null +++ b/app/graphql/types/dunning_campaign_thresholds/object.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module DunningCampaignThresholds + class Object < Types::BaseObject + graphql_name "DunningCampaignThreshold" + + field :amount_cents, Integer, null: false + field :currency, String, null: false + end + end +end diff --git a/app/graphql/types/dunning_campaigns/create_input.rb b/app/graphql/types/dunning_campaigns/create_input.rb index f28da434bea..c23cbced3d9 100644 --- a/app/graphql/types/dunning_campaigns/create_input.rb +++ b/app/graphql/types/dunning_campaigns/create_input.rb @@ -10,6 +10,7 @@ class CreateInput < Types::BaseInputObject argument :days_between_attempts, Integer, required: true argument :max_attempts, Integer, required: true argument :name, String, required: true + argument :thresholds, [Types::DunningCampaignThresholds::CreateInput], required: true argument :description, String, required: false end diff --git a/app/graphql/types/dunning_campaigns/object.rb b/app/graphql/types/dunning_campaigns/object.rb index 920c4d3ae31..138f4eb9024 100644 --- a/app/graphql/types/dunning_campaigns/object.rb +++ b/app/graphql/types/dunning_campaigns/object.rb @@ -12,6 +12,7 @@ class Object < Types::BaseObject field :days_between_attempts, Integer, null: false field :max_attempts, Integer, null: false field :name, String, null: false + field :thresholds, [Types::DunningCampaignThresholds::Object], null: false field :description, String, null: true diff --git a/app/models/dunning_campaign.rb b/app/models/dunning_campaign.rb index 100de2b34c5..da419538434 100644 --- a/app/models/dunning_campaign.rb +++ b/app/models/dunning_campaign.rb @@ -5,6 +5,7 @@ class DunningCampaign < ApplicationRecord belongs_to :organization has_many :thresholds, class_name: "DunningCampaignThreshold", dependent: :destroy + accepts_nested_attributes_for :thresholds validates :name, presence: true validates :days_between_attempts, numericality: {greater_than: 0} diff --git a/app/services/dunning_campaigns/create_service.rb b/app/services/dunning_campaigns/create_service.rb index c67100ac74e..c2c220631b6 100644 --- a/app/services/dunning_campaigns/create_service.rb +++ b/app/services/dunning_campaigns/create_service.rb @@ -10,18 +10,24 @@ def initialize(organization:, params:) end def call - dunning_campaign = organization.dunning_campaigns.new( - applied_to_organization: params[:applied_to_organization], - code: params[:code], - days_between_attempts: params[:days_between_attempts], - max_attempts: params[:max_attempts], - name: params[:name], - description: params[:description] - ) - - dunning_campaign.save! - - result.dunning_campaign = dunning_campaign + # TODO: Restrict to dunning premium add-on + + ActiveRecord::Base.transaction do + dunning_campaign = organization.dunning_campaigns.create!( + applied_to_organization: params[:applied_to_organization], + code: params[:code], + days_between_attempts: params[:days_between_attempts], + max_attempts: params[:max_attempts], + name: params[:name], + description: params[:description], + thresholds_attributes: params[:thresholds].map(&:to_h) + ) + + # TODO: If the dunning campaign is applied to the organization, we need to remove the flag from all other dunning campaigns. + + result.dunning_campaign = dunning_campaign + end + result rescue ActiveRecord::RecordInvalid => e result.record_validation_failure!(record: e.record) diff --git a/schema.graphql b/schema.graphql index 87f1af51e4c..6a171857af3 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2011,6 +2011,12 @@ input CreateDunningCampaignInput { description: String maxAttempts: Int! name: String! + thresholds: [CreateDunningCampaignThresholdInput!]! +} + +input CreateDunningCampaignThresholdInput { + amountCents: BigInt! + currency: CurrencyEnum! } """ @@ -3708,7 +3714,7 @@ input DownloadInvoiceInput { } type DunningCampaign { - appliedToOrganization: String! + appliedToOrganization: Boolean! code: String! createdAt: ISO8601DateTime! daysBetweenAttempts: Int! @@ -3716,9 +3722,15 @@ type DunningCampaign { id: ID! maxAttempts: Int! name: String! + thresholds: [DunningCampaignThreshold!]! updatedAt: ISO8601DateTime! } +type DunningCampaignThreshold { + amountCents: Int! + currency: String! +} + """ Organization Email Settings Values """ diff --git a/schema.json b/schema.json index 2e3b5e23cb7..63d81471d5f 100644 --- a/schema.json +++ b/schema.json @@ -7940,6 +7940,30 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateDunningCampaignThresholdInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "description", "description": null, @@ -7967,6 +7991,49 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "CreateDunningCampaignThresholdInput", + "description": null, + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "CreateHubspotIntegrationInput", @@ -16332,7 +16399,7 @@ "name": null, "ofType": { "kind": "SCALAR", - "name": "String", + "name": "Boolean", "ofType": null } }, @@ -16464,6 +16531,32 @@ ] }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DunningCampaignThreshold", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "updatedAt", "description": null, @@ -16486,6 +16579,55 @@ "inputFields": null, "enumValues": null }, + { + "kind": "OBJECT", + "name": "DunningCampaignThreshold", + "description": null, + "interfaces": [ + + ], + "possibleTypes": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + } + ], + "inputFields": null, + "enumValues": null + }, { "kind": "ENUM", "name": "EmailSettingsEnum", diff --git a/spec/graphql/mutations/dunning_campaigns/create_spec.rb b/spec/graphql/mutations/dunning_campaigns/create_spec.rb index b08df35dafd..c18ac56cd90 100644 --- a/spec/graphql/mutations/dunning_campaigns/create_spec.rb +++ b/spec/graphql/mutations/dunning_campaigns/create_spec.rb @@ -12,7 +12,13 @@ description: "Dunning campaign description", maxAttempts: 3, daysBetweenAttempts: 1, - appliedToOrganization: false + appliedToOrganization: false, + thresholds: [ + { + amountCents: 10000, + currency: "EUR" + } + ] } end @@ -20,7 +26,17 @@ <<-GQL mutation($input: CreateDunningCampaignInput!) { createDunningCampaign(input: $input) { - id name code description maxAttempts daysBetweenAttempts appliedToOrganization + id + name + code + description + maxAttempts + daysBetweenAttempts + appliedToOrganization + thresholds { + amountCents + currency + } } } GQL @@ -46,7 +62,13 @@ "description" => "Dunning campaign description", "maxAttempts" => 3, "daysBetweenAttempts" => 1, - "appliedToOrganization" => false + "appliedToOrganization" => false, + "thresholds" => [ + { + "amountCents" => 10000, + "currency" => "EUR" + } + ] ) end end diff --git a/spec/graphql/types/dunning_campaign_thresholds/create_input_spec.rb b/spec/graphql/types/dunning_campaign_thresholds/create_input_spec.rb new file mode 100644 index 00000000000..86d87030cbf --- /dev/null +++ b/spec/graphql/types/dunning_campaign_thresholds/create_input_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaignThresholds::CreateInput do + subject { described_class } + + it { is_expected.to accept_argument(:amount_cents).of_type("BigInt!") } + it { is_expected.to accept_argument(:currency).of_type("CurrencyEnum!") } +end diff --git a/spec/graphql/types/dunning_campaign_thresholds/object_spec.rb b/spec/graphql/types/dunning_campaign_thresholds/object_spec.rb new file mode 100644 index 00000000000..f9a1d5fd74c --- /dev/null +++ b/spec/graphql/types/dunning_campaign_thresholds/object_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaignThresholds::Object do + subject { described_class } + + it { is_expected.to have_field(:amount_cents).of_type("Int!") } + it { is_expected.to have_field(:currency).of_type("String!") } +end diff --git a/spec/graphql/types/dunning_campaigns/create_input_spec.rb b/spec/graphql/types/dunning_campaigns/create_input_spec.rb index b5c2ad90c97..a5dd6d6b083 100644 --- a/spec/graphql/types/dunning_campaigns/create_input_spec.rb +++ b/spec/graphql/types/dunning_campaigns/create_input_spec.rb @@ -10,6 +10,7 @@ it { is_expected.to accept_argument(:days_between_attempts).of_type('Int!') } it { is_expected.to accept_argument(:max_attempts).of_type('Int!') } it { is_expected.to accept_argument(:name).of_type('String!') } + it { is_expected.to accept_argument(:thresholds).of_type('[CreateDunningCampaignThresholdInput!]!') } it { is_expected.to accept_argument(:description).of_type('String') } end diff --git a/spec/graphql/types/dunning_campaigns/object_spec.rb b/spec/graphql/types/dunning_campaigns/object_spec.rb index e278bd8d434..cd6bfe55fce 100644 --- a/spec/graphql/types/dunning_campaigns/object_spec.rb +++ b/spec/graphql/types/dunning_campaigns/object_spec.rb @@ -12,6 +12,7 @@ it { is_expected.to have_field(:days_between_attempts).of_type("Int!") } it { is_expected.to have_field(:max_attempts).of_type("Int!") } it { is_expected.to have_field(:name).of_type("String!") } + it { is_expected.to have_field(:thresholds).of_type("[Types::DunningCampaignThresholds::Object!]!") } it { is_expected.to have_field(:description).of_type("String") } diff --git a/spec/services/dunning_campaigns/create_service_spec.rb b/spec/services/dunning_campaigns/create_service_spec.rb new file mode 100644 index 00000000000..41c526eeb9b --- /dev/null +++ b/spec/services/dunning_campaigns/create_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::CreateService, type: :service do + subject(:create_service) { described_class.new(organization:, params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:params) do + { + name: "Dunning Campaign", + code: "dunning-campaign", + days_between_attempts: 1, + max_attempts: 3, + description: "Dunning Campaign Description", + applied_to_organization: true, + thresholds: + } + end + + let(:thresholds) do + [ + { amount_cents: 10000, currency: "USD" }, + { amount_cents: 20000, currency: "EUR" } + ] + end + + describe "#call" do + it "creates a dunning campaign" do + expect { create_service.call }.to change(DunningCampaign, :count).by(1) + .and change(DunningCampaignThreshold, :count).by(2) + end + + it "returns dunning campaign in the result" do + result = create_service.call + expect(result.dunning_campaign).to be_a(DunningCampaign) + expect(result.dunning_campaign.thresholds.first).to be_a(DunningCampaignThreshold) + end + + context "with validation error" do + before { create(:dunning_campaign, organization: organization, code: "dunning-campaign") } + + it "returns an error" do + result = create_service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + end + end +end