diff --git a/app/jobs/clock/expire_incomplete_subscriptions_job.rb b/app/jobs/clock/expire_incomplete_subscriptions_job.rb new file mode 100644 index 00000000000..aae5c77f8ee --- /dev/null +++ b/app/jobs/clock/expire_incomplete_subscriptions_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class ExpireIncompleteSubscriptionsJob < ClockJob + def perform + Subscription.expirable.find_each do |subscription| + Subscriptions::ActivationRules::ExpireIncompleteJob.perform_later(subscription) + end + end + end +end diff --git a/app/jobs/subscriptions/activation_rules/expire_incomplete_job.rb b/app/jobs/subscriptions/activation_rules/expire_incomplete_job.rb new file mode 100644 index 00000000000..e628edf4bf7 --- /dev/null +++ b/app/jobs/subscriptions/activation_rules/expire_incomplete_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + class ExpireIncompleteJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end + + unique :until_executed, on_conflict: :log + + def perform(subscription) + Subscriptions::ActivationRules::ExpireService.call!(subscription:) + end + end + end +end diff --git a/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb b/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb index 4be3cb2e841..14e5163eb26 100644 --- a/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb +++ b/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb @@ -4,7 +4,13 @@ module Subscriptions module ActivationRules module Payment class ResolveJob < ApplicationJob - queue_as "default" + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end def perform(subscription, invoice, payment_status) Payment::ResolveService.call!(subscription:, invoice:, payment_status:) diff --git a/app/services/subscriptions/activation_rules/expire_service.rb b/app/services/subscriptions/activation_rules/expire_service.rb new file mode 100644 index 00000000000..a6f019b467c --- /dev/null +++ b/app/services/subscriptions/activation_rules/expire_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + class ExpireService < BaseService + Result = BaseResult[:subscription] + + def initialize(subscription:) + @subscription = subscription + super + end + + def call + subscription.with_lock do + # Race protection: a payment webhook may resolve the subscription + # concurrently. If it already did so by the time we acquired the + # lock, bail. + next unless subscription.incomplete? + + payment_rule = subscription.activation_rules.payment.sole + Payment::EvaluateService.call!(rule: payment_rule, status: :expired) + + invoice = subscription.invoices.open.subscription.sole + invoice.closed! + + ResolveSubscriptionStatusService.call!(subscription:) + subscription.update!(cancelation_reason: :timeout) if subscription.canceled? + + enqueue_psp_cancel(invoice) + end + + result.subscription = subscription + result + end + + private + + attr_reader :subscription + + def enqueue_psp_cancel(invoice) + payment = invoice.payments + .where(payable_payment_status: %w[pending processing]) + .order(created_at: :desc) + .first + return unless payment + + PaymentProviders::CancelPaymentJob.perform_after_commit(payment) + end + end + end +end diff --git a/clock.rb b/clock.rb index c4ae17ac5b6..c34e6cec0ed 100644 --- a/clock.rb +++ b/clock.rb @@ -86,6 +86,12 @@ module Clockwork .perform_later end + every(1.hour, "schedule:expire_incomplete_subscriptions", at: "*:20") do + Clock::ExpireIncompleteSubscriptionsJob + .set(sentry: {"slug" => "lago_expire_incomplete_subscriptions", "cron" => "20 */1 * * *"}) + .perform_later + end + every(1.hour, "schedule:retry_generating_subscription_invoices", at: "*:30") do Clock::RetryGeneratingSubscriptionInvoicesJob .set(sentry: {"slug" => "lago_retry_invoices", "cron" => "30 */1 * * *"}) diff --git a/spec/jobs/clock/expire_incomplete_subscriptions_job_spec.rb b/spec/jobs/clock/expire_incomplete_subscriptions_job_spec.rb new file mode 100644 index 00000000000..29b99d1d4a4 --- /dev/null +++ b/spec/jobs/clock/expire_incomplete_subscriptions_job_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::ExpireIncompleteSubscriptionsJob, job: true do + subject { described_class } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + + describe ".perform" do + let(:expirable_subscription) { create(:subscription, :incomplete, customer:, organization:, plan:) } + let(:non_expirable_pending_subscription) { create(:subscription, :incomplete, customer:, organization:, plan:) } + let(:active_subscription) { create(:subscription, :active, customer:, organization:, plan:) } + + before do + # Expirable: incomplete + pending payment rule + expires_at in the past + create(:subscription_activation_rule, subscription: expirable_subscription, organization:, + status: "pending", timeout_hours: 48, expires_at: 1.hour.ago) + + # Incomplete sub but rule is still in the future window — not yet expirable + create(:subscription_activation_rule, subscription: non_expirable_pending_subscription, organization:, + status: "pending", timeout_hours: 48, expires_at: 12.hours.from_now) + + # Active sub with a satisfied rule — should never be picked up + create(:subscription_activation_rule, subscription: active_subscription, organization:, + status: "satisfied", timeout_hours: 48, expires_at: 1.hour.ago) + end + + it "enqueues an ExpireIncompleteJob for each expirable subscription" do + described_class.perform_now + + expect(Subscriptions::ActivationRules::ExpireIncompleteJob) + .to have_been_enqueued.with(expirable_subscription) + end + + it "does not enqueue for subscriptions whose rule has not expired yet" do + described_class.perform_now + + expect(Subscriptions::ActivationRules::ExpireIncompleteJob) + .not_to have_been_enqueued.with(non_expirable_pending_subscription) + end + + it "does not enqueue for non-incomplete subscriptions" do + described_class.perform_now + + expect(Subscriptions::ActivationRules::ExpireIncompleteJob) + .not_to have_been_enqueued.with(active_subscription) + end + end +end diff --git a/spec/jobs/subscriptions/activation_rules/expire_incomplete_job_spec.rb b/spec/jobs/subscriptions/activation_rules/expire_incomplete_job_spec.rb new file mode 100644 index 00000000000..0d78bbe9a73 --- /dev/null +++ b/spec/jobs/subscriptions/activation_rules/expire_incomplete_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::ExpireIncompleteJob do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, :incomplete, customer:, organization:) } + + before do + allow(Subscriptions::ActivationRules::ExpireService).to receive(:call!) + end + + it "forwards the subscription to the ExpireService" do + described_class.perform_now(subscription) + + expect(Subscriptions::ActivationRules::ExpireService).to have_received(:call!).with(subscription:) + end +end diff --git a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb index 85382918910..075025ec9dc 100644 --- a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb +++ b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb @@ -219,6 +219,50 @@ def simulate_stripe_webhook(status:) end end + describe "timeout: subscription cancels on activation rule expiry" do + it "expires the gated subscription with cancelation_reason: timeout" do + # Stage 1: Create gated subscription + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + + invoice = subscription.invoices.sole + expect(invoice).to be_open + + # Stage 2: Simulate timeout — push the rule's expires_at into the past + subscription.activation_rules.sole.update!(expires_at: 1.hour.ago) + + # Stage 3: Clock job runs — picks up the expired rule, enqueues ExpireIncompleteJob + Clock::ExpireIncompleteSubscriptionsJob.perform_now + perform_all_enqueued_jobs + + subscription.reload + expect(subscription).to be_canceled + expect(subscription.cancelation_reason).to eq("timeout") + expect(subscription.activation_rules.sole).to be_expired + + expect(invoice.reload).to be_closed + end + + it "does not act on subscriptions whose rule has not yet expired" do + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + + # Rule's expires_at is still in the future (48 hours from creation) + Clock::ExpireIncompleteSubscriptionsJob.perform_now + perform_all_enqueued_jobs + + expect(subscription.reload).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + end + end + describe "gated subscription with pending VIES check" do let(:vat_number) { "IT12345678901" } let(:organization) do diff --git a/spec/services/subscriptions/activation_rules/expire_service_spec.rb b/spec/services/subscriptions/activation_rules/expire_service_spec.rb new file mode 100644 index 00000000000..db392947f4f --- /dev/null +++ b/spec/services/subscriptions/activation_rules/expire_service_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::ExpireService do + subject(:result) { described_class.call(subscription:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) { create(:subscription, :incomplete, customer:, organization:, plan:) } + let(:invoice) do + create(:invoice, :open, customer:, organization:, invoice_type: :subscription) + end + + before do + create(:invoice_subscription, invoice:, subscription:) + end + + context "when the subscription is incomplete with a pending payment rule" do + let(:payment_rule) do + create(:subscription_activation_rule, subscription:, organization:, + status: "pending", timeout_hours: 48, expires_at: 1.hour.ago) + end + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:payment) do + create(:payment, payable: invoice, payment_provider:, organization:, customer:, + provider_payment_id: "pi_test", payable_payment_status: :pending) + end + + before do + payment_rule + payment + end + + it "marks the payment activation rule as expired" do + result + + expect(payment_rule.reload).to be_expired + end + + it "closes the open invoice" do + result + + expect(invoice.reload).to be_closed + end + + it "cancels the subscription with cancelation_reason: timeout" do + result + + expect(subscription.reload).to be_canceled + expect(subscription.cancelation_reason).to eq("timeout") + end + + it "enqueues a PSP cancel job for the pending payment" do + result + + expect(PaymentProviders::CancelPaymentJob).to have_been_enqueued.with(payment) + end + + it "returns a successful result with the subscription" do + expect(result).to be_success + expect(result.subscription).to eq(subscription) + end + end + + context "when the subscription is no longer incomplete (resolved concurrently)" do + let(:payment_rule) do + create(:subscription_activation_rule, subscription:, organization:, + status: "satisfied", timeout_hours: 48, expires_at: 1.hour.ago) + end + + before do + payment_rule + subscription.update!(status: :active) + end + + it "returns a successful result without mutating state" do + result + + expect(subscription.reload).to be_active + expect(payment_rule.reload).to be_satisfied + expect(invoice.reload).to be_open + end + + it "does not enqueue a PSP cancel job" do + result + + expect(PaymentProviders::CancelPaymentJob).not_to have_been_enqueued + end + end + + context "when the open invoice has no pending or processing payments" do + let(:payment_rule) do + create(:subscription_activation_rule, subscription:, organization:, + status: "pending", timeout_hours: 48, expires_at: 1.hour.ago) + end + + before { payment_rule } + + it "still expires the rule and cancels the subscription" do + result + + expect(payment_rule.reload).to be_expired + expect(subscription.reload).to be_canceled + expect(subscription.cancelation_reason).to eq("timeout") + expect(invoice.reload).to be_closed + end + + it "does not enqueue a PSP cancel job (nothing to cancel)" do + result + + expect(PaymentProviders::CancelPaymentJob).not_to have_been_enqueued + end + end +end