From 7833944b793ced251a20dcfca8e7f699761a82a1 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 15:30:21 +0100 Subject: [PATCH 1/7] feat(payment-gated-subs): add ExpireService for incomplete subscription timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Gated subscriptions stuck in `incomplete` past their activation rule's `expires_at` need to be canceled so authorized PSP funds are released and the customer can be retried without conflicting state. M1 left the foundation (`expires_at` column, `expirable` scope, payment evaluator that accepts `:expired`) but no actor to run the transition. ## Description Add `Subscriptions::ActivationRules::ExpireService` — the core timeout-driven cancel: 1. Acquires `subscription.with_lock`. Race protection against a payment webhook landing concurrently. 2. Re-checks `subscription.incomplete?` after the lock; if it resolved between the clock-job pickup and the lock acquisition (success webhook won the race), bails cleanly. 3. Transitions the payment activation rule to `:expired` via `Payment::EvaluateService`. 4. Closes the open invoice (`invoice.closed!`). 5. Calls `ResolveSubscriptionStatusService` — the existing M1 service handles the actual `mark_as_canceled!` transition, webhook (`subscription.canceled`), and activity log. 6. Sets `cancelation_reason: :timeout` after the resolution. Matches M1's `Payment::ResolveService#handle_failure` pattern of caller- sets-reason: rule status alone doesn't disambiguate which actor triggered the rejection. 7. Best-effort: enqueues `PaymentProviders::CancelPaymentJob` for the most recent pending/processing payment on the invoice. The PSP-side cancel runs after the transaction commits. Spec covers three contexts: happy path, race where the subscription already resolved before lock acquisition, and the no-eligible-payment case (rule expires, sub cancels, no PSP cancel job is enqueued). `PaymentProviders::CancelPaymentJob` is still in an open PR (the dispatcher); the spec defines a minimal stub inline so it works against current main. --- .../activation_rules/expire_service.rb | 51 ++++++++ .../activation_rules/expire_service_spec.rb | 119 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 app/services/subscriptions/activation_rules/expire_service.rb create mode 100644 spec/services/subscriptions/activation_rules/expire_service_spec.rb 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/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..6449e032887 --- /dev/null +++ b/spec/services/subscriptions/activation_rules/expire_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::ExpireService do + # TODO: remove after the dispatcher PR adding PaymentProviders::CancelPaymentJob is merged + class PaymentProviders::CancelPaymentJob < ApplicationJob; end + + 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 From 4435db02d8d85b82320b6adc6e3f833e5789ce37 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 15:32:45 +0100 Subject: [PATCH 2/7] feat(payment-gated-subs): add ExpireIncompleteJob async wrapper Thin async wrapper around ExpireService. Each clock-job tick enqueues one ExpireIncompleteJob per expirable subscription; the job runs independently so a slow expiration does not block others. Queue routing follows the sibling subscription-billing convention: :billing when SIDEKIQ_BILLING is enabled, :default otherwise. Matches Subscriptions::TerminateJob. unique :until_executed prevents the same subscription from being enqueued twice if a clock tick runs before the previous tick's jobs have drained. Inner state checks live in ExpireService where they can see the post-lock state. --- .../activation_rules/expire_incomplete_job.rb | 21 +++++++++++++++++++ .../expire_incomplete_job_spec.rb | 19 +++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 app/jobs/subscriptions/activation_rules/expire_incomplete_job.rb create mode 100644 spec/jobs/subscriptions/activation_rules/expire_incomplete_job_spec.rb 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/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 From fb80f7b3e2704d3b4aa9266aa8ef77ff0775f412 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 15:35:18 +0100 Subject: [PATCH 3/7] refactor(payment-gated-subs): align Payment ResolveJob queue with sibling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExpireIncompleteJob uses the conditional :billing / :default queue pattern so an operator can route subscription-billing jobs onto a dedicated worker pool when SIDEKIQ_BILLING is enabled. The sibling Payment ResolveJob — which handles the success/failure resolution side of the same activation-rule machinery — was still on the plain "default" queue. Update ResolveJob to the same pattern so both jobs scale together. No behavioral change when SIDEKIQ_BILLING is unset (still :default). --- .../subscriptions/activation_rules/payment/resolve_job.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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:) From 5fe4b696f84e9635e3b8789b1c409e0f3343f68f Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 15:43:49 +0100 Subject: [PATCH 4/7] feat(payment-gated-subs): add clock job to expire incomplete subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Periodic batch worker that scans for gated subscriptions whose payment activation rule has timed out and enqueues per-subscription ExpireIncompleteJob workers. Inherits from ClockJob, which already configures the :clock_worker / :clock queue routing. The query relies on the M1-era Subscription.expirable scope, which joins activation_rules where status is pending and expires_at is in the past — no new database access patterns are introduced here. Spec covers three populations: expirable (incomplete + pending + past), non-expirable pending (rule still within window), and active with a satisfied rule. Only the first should be picked up. --- .../expire_incomplete_subscriptions_job.rb | 11 ++++ ...xpire_incomplete_subscriptions_job_spec.rb | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 app/jobs/clock/expire_incomplete_subscriptions_job.rb create mode 100644 spec/jobs/clock/expire_incomplete_subscriptions_job_spec.rb 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/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 From a61a4240e7beb4e229aad582f8cf3fca93de4d50 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 15:52:05 +0100 Subject: [PATCH 5/7] chore(payment-gated-subs): schedule ExpireIncompleteSubscriptionsJob hourly Run Clock::ExpireIncompleteSubscriptionsJob every hour at *:20, staggered between the existing *:15 api_keys_track_usage and *:30 retry_generating_subscription_invoices schedules to spread load. Sentry cron monitor registered under slug lago_expire_incomplete_subscriptions so missed runs surface as alerts. Hourly granularity follows the same cadence as the existing terminate_ended_subscriptions schedule. timeout_hours values of 1+ are served with at-most-one-hour late tolerance, which matches the M2 "best-effort" semantics. --- clock.rb | 6 ++++++ 1 file changed, 6 insertions(+) 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 * * *"}) From e4ba39b4d4a840f4211bfc83a30e4c19ad814b01 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Fri, 15 May 2026 16:01:15 +0100 Subject: [PATCH 6/7] test(payment-gated-subs): add E2E timeout scenarios Two scenarios exercise the full timeout chain end-to-end against the test environment: 1. Gated subscription whose activation rule has aged past its expires_at: clock job picks it up, enqueues the expire job, the expire service runs, and the subscription ends up canceled with cancelation_reason: timeout, the rule expired, and the open invoice closed. 2. Gated subscription still within the timeout window: clock job runs but the subscription remains incomplete, demonstrating that the Subscription.expirable scope correctly excludes future-expiry rules. The dispatcher PR is still open, so PaymentProviders::CancelPaymentJob is stubbed via stub_const for the duration of the new describe block. When that PR merges, the stub becomes a no-op (real class wins) and the scenarios continue to pass. --- .../payment_gated_activation_spec.rb | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb index 85382918910..f511ec01259 100644 --- a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb +++ b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb @@ -219,6 +219,57 @@ def simulate_stripe_webhook(status:) end end + describe "timeout: subscription cancels on activation rule expiry" do + # TODO: remove after the dispatcher PR adding PaymentProviders::CancelPaymentJob is merged + before do + stub_const("PaymentProviders::CancelPaymentJob", Class.new(ApplicationJob) do + def self.perform_after_commit(*); end + end) + end + + 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 From b48878a5b85d999a53bc95a2727c1d302f598c93 Mon Sep 17 00:00:00 2001 From: Ancor Cruz Date: Tue, 26 May 2026 15:01:13 +0100 Subject: [PATCH 7/7] remove mocks --- .../subscriptions/payment_gated_activation_spec.rb | 7 ------- .../subscriptions/activation_rules/expire_service_spec.rb | 3 --- 2 files changed, 10 deletions(-) diff --git a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb index f511ec01259..075025ec9dc 100644 --- a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb +++ b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb @@ -220,13 +220,6 @@ def simulate_stripe_webhook(status:) end describe "timeout: subscription cancels on activation rule expiry" do - # TODO: remove after the dispatcher PR adding PaymentProviders::CancelPaymentJob is merged - before do - stub_const("PaymentProviders::CancelPaymentJob", Class.new(ApplicationJob) do - def self.perform_after_commit(*); end - end) - end - it "expires the gated subscription with cancelation_reason: timeout" do # Stage 1: Create gated subscription create_subscription(subscription_params) diff --git a/spec/services/subscriptions/activation_rules/expire_service_spec.rb b/spec/services/subscriptions/activation_rules/expire_service_spec.rb index 6449e032887..db392947f4f 100644 --- a/spec/services/subscriptions/activation_rules/expire_service_spec.rb +++ b/spec/services/subscriptions/activation_rules/expire_service_spec.rb @@ -3,9 +3,6 @@ require "rails_helper" RSpec.describe Subscriptions::ActivationRules::ExpireService do - # TODO: remove after the dispatcher PR adding PaymentProviders::CancelPaymentJob is merged - class PaymentProviders::CancelPaymentJob < ApplicationJob; end - subject(:result) { described_class.call(subscription:) } let(:organization) { create(:organization) }