diff --git a/app/graphql/mutations/dunning_campaigns/destroy.rb b/app/graphql/mutations/dunning_campaigns/destroy.rb new file mode 100644 index 00000000000..5dec32f36d0 --- /dev/null +++ b/app/graphql/mutations/dunning_campaigns/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module DunningCampaigns + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "dunning_campaigns:delete" + + graphql_name "DestroyDunningCampaign" + description "Deletes a dunning campaign" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + dunning_campaign = current_organization.dunning_campaigns.find_by(id:) + result = ::DunningCampaigns::DestroyService.call(dunning_campaign:) + + result.success? ? result.dunning_campaign : result_error(result) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 3aa5b611a09..ba977d24edb 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -149,6 +149,7 @@ class MutationType < Types::BaseObject field :okta_login, mutation: Mutations::Auth::Okta::Login field :create_dunning_campaign, mutation: Mutations::DunningCampaigns::Create + field :destroy_dunning_campaign, mutation: Mutations::DunningCampaigns::Destroy field :update_dunning_campaign, mutation: Mutations::DunningCampaigns::Update field :create_api_key, mutation: Mutations::ApiKeys::Create diff --git a/app/services/dunning_campaigns/destroy_service.rb b/app/services/dunning_campaigns/destroy_service.rb new file mode 100644 index 00000000000..743f6026bc5 --- /dev/null +++ b/app/services/dunning_campaigns/destroy_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module DunningCampaigns + class DestroyService < BaseService + def initialize(dunning_campaign:) + @dunning_campaign = dunning_campaign + + super + end + + def call + return result.not_found_failure!(resource: "dunning_campaign") unless dunning_campaign + return result.forbidden_failure! unless dunning_campaign.organization.auto_dunning_enabled? + + ActiveRecord::Base.transaction do + dunning_campaign.discard! + dunning_campaign.thresholds.discard_all + + # TODO: Reset counters for customers that were in the dunning campaign + end + + result.dunning_campaign = dunning_campaign + result + end + + private + + attr_reader :dunning_campaign + end +end diff --git a/schema.graphql b/schema.graphql index 04ba87c0353..39be5b22e88 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3565,6 +3565,28 @@ type DestroyCustomerPayload { id: ID } +""" +Autogenerated input type of DestroyDunningCampaign +""" +input DestroyDunningCampaignInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyDunningCampaign. +""" +type DestroyDunningCampaignPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + """ Autogenerated input type of DestroyIntegrationCollectionMapping """ @@ -5049,6 +5071,16 @@ type Mutation { input: DestroyCustomerInput! ): DestroyCustomerPayload + """ + Deletes a dunning campaign + """ + destroyDunningCampaign( + """ + Parameters for DestroyDunningCampaign + """ + input: DestroyDunningCampaignInput! + ): DestroyDunningCampaignPayload + """ Destroy an integration """ @@ -5997,6 +6029,7 @@ type Permissions { developersManage: Boolean! draftInvoicesUpdate: Boolean! dunningCampaignsCreate: Boolean! + dunningCampaignsDelete: Boolean! dunningCampaignsUpdate: Boolean! dunningCampaignsView: Boolean! invoicesCreate: Boolean! diff --git a/schema.json b/schema.json index a59d95988f7..c77ca40de91 100644 --- a/schema.json +++ b/schema.json @@ -16027,6 +16027,86 @@ "inputFields": null, "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyDunningCampaignInput", + "description": "Autogenerated input type of DestroyDunningCampaign", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, + { + "kind": "OBJECT", + "name": "DestroyDunningCampaignPayload", + "description": "Autogenerated return type of DestroyDunningCampaign.", + "interfaces": [ + + ], + "possibleTypes": null, + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + } + ], + "inputFields": null, + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "DestroyIntegrationCollectionMappingInput", @@ -25229,6 +25309,35 @@ } ] }, + { + "name": "destroyDunningCampaign", + "description": "Deletes a dunning campaign", + "type": { + "kind": "OBJECT", + "name": "DestroyDunningCampaignPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for DestroyDunningCampaign", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyDunningCampaignInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "destroyIntegration", "description": "Destroy an integration", @@ -29419,6 +29528,24 @@ ] }, + { + "name": "dunningCampaignsDelete", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + + ] + }, { "name": "dunningCampaignsUpdate", "description": null, diff --git a/spec/graphql/mutations/dunning_campaigns/destroy_spec.rb b/spec/graphql/mutations/dunning_campaigns/destroy_spec.rb new file mode 100644 index 00000000000..4934a441a5a --- /dev/null +++ b/spec/graphql/mutations/dunning_campaigns/destroy_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::DunningCampaigns::Destroy, type: :graphql do + let(:required_permissions) { "dunning_campaigns:delete" } + let(:membership) { create(:membership, organization:) } + let(:organization) { create(:organization, premium_integrations: ["auto_dunning"]) } + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyDunningCampaignInput!) { + destroyDunningCampaign(input: $input) { + id + } + } + GQL + end + + around { |test| lago_premium!(&test) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "dunning_campaigns:delete" + + it "deletes a dunning campaign" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: {id: dunning_campaign.id} + } + ) + + data = result["data"]["destroyDunningCampaign"] + expect(data["id"]).to eq(dunning_campaign.id) + end + + context "when dunnign campaign is not found" do + let(:dunning_campaign) { create(:dunning_campaign) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: {id: dunning_campaign.id} + } + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/services/dunning_campaigns/destroy_service_spec.rb b/spec/services/dunning_campaigns/destroy_service_spec.rb new file mode 100644 index 00000000000..bda5148420d --- /dev/null +++ b/spec/services/dunning_campaigns/destroy_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DunningCampaigns::DestroyService, type: :service do + subject(:destroy_service) { described_class.new(dunning_campaign:) } + + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + let(:dunning_campaign_threshold) { create(:dunning_campaign_threshold, dunning_campaign:) } + + before { dunning_campaign_threshold } + + describe "#call" do + subject(:result) { destroy_service.call } + + context "when dunning campaign is not found" do + let(:dunning_campaign) { nil } + let(:dunning_campaign_threshold) { nil } + + it "returns an error", :aggregate_failures do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("dunning_campaign_not_found") + end + end + + context "when lago freemium" do + it 'returns an error', :aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not delete the dunning campaign" do + expect { result }.not_to change(dunning_campaign, :deleted_at) + end + end + + context "when lago premium" do + around { |test| lago_premium!(&test) } + + context "when no auto_dunning premium integration" do + it 'returns an error', :aggregate_failures do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not delete the dunning campaign" do + expect { result }.not_to change(dunning_campaign, :deleted_at) + end + end + + context "when auto_dunning premium integration" do + let(:organization) do + create(:organization, premium_integrations: ["auto_dunning"]) + end + + it "soft deletes the dunning campaign" do + freeze_time do + expect { destroy_service.call }.to change(DunningCampaign, :count).by(-1) + .and change { dunning_campaign.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes the dunning campaign threshold" do + freeze_time do + expect { destroy_service.call }.to change(DunningCampaignThreshold, :count).by(-1) + .and change { dunning_campaign_threshold.reload.deleted_at }.from(nil).to(Time.current) + end + end + end + end + end +end