Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/jobs/clock/expire_incomplete_subscriptions_job.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions app/jobs/subscriptions/activation_rules/expire_incomplete_job.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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:)
Expand Down
51 changes: 51 additions & 0 deletions app/services/subscriptions/activation_rules/expire_service.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions clock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 * * *"})
Expand Down
52 changes: 52 additions & 0 deletions spec/jobs/clock/expire_incomplete_subscriptions_job_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions spec/scenarios/subscriptions/payment_gated_activation_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions spec/services/subscriptions/activation_rules/expire_service_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading