Skip to content
This repository was archived by the owner on Dec 16, 2025. It is now read-only.

Commit 1f74e7c

Browse files
committed
Refresh the subscription instead of relying on event payloads
Events can land out of order. See #23 (comment)
1 parent 7df55bc commit 1f74e7c

2 files changed

Lines changed: 37 additions & 51 deletions

File tree

app/controllers/stripe/webhooks_controller.rb

Lines changed: 21 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,10 @@ def create
1515
private
1616
def dispatch_stripe_event(event)
1717
case event.type
18-
when "checkout.session.completed"
19-
handle_checkout_completed(event.data.object)
20-
when "customer.subscription.updated"
21-
handle_subscription_updated(event.data.object)
22-
when "customer.subscription.deleted"
23-
handle_subscription_deleted(event.data.object)
24-
when "invoice.payment_failed"
25-
handle_payment_failed(event.data.object)
18+
when "checkout.session.completed"
19+
sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription"
20+
when "customer.subscription.updated", "customer.subscription.deleted"
21+
sync_subscription(event.data.object.id)
2622
end
2723
end
2824

@@ -36,47 +32,37 @@ def verify_webhook_signature
3632
nil
3733
end
3834

39-
def handle_checkout_completed(session)
40-
return unless session.mode == "subscription"
41-
42-
subscription = find_subscription_by_customer(session.customer)
43-
return unless subscription
44-
45-
stripe_subscription = Stripe::Subscription.retrieve(session.subscription)
46-
47-
subscription.update! \
48-
stripe_subscription_id: stripe_subscription.id,
49-
plan_key: session.metadata["plan_key"],
50-
status: stripe_subscription.status,
51-
current_period_end: extract_current_period_end(stripe_subscription)
35+
def sync_new_subscription(stripe_subscription_id, plan_key:)
36+
sync_subscription(stripe_subscription_id) do |subscription_properties|
37+
subscription_properties[:plan_key] = plan_key if plan_key
38+
end
5239
end
5340

54-
def handle_subscription_updated(stripe_subscription)
41+
# Always fetch fresh subscription data from Stripe to handle out-of-order
42+
# event delivery. Not relying on payload data.
43+
def sync_subscription(stripe_subscription_id)
44+
stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id)
45+
5546
if subscription = find_subscription_by_customer(stripe_subscription.customer)
56-
subscription.update! \
47+
subscription_properties = {
48+
stripe_subscription_id: stripe_subscription.id,
5749
status: stripe_subscription.status,
58-
current_period_end: extract_current_period_end(stripe_subscription),
50+
current_period_end: current_period_end_for(stripe_subscription),
5951
cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil
60-
end
61-
end
52+
}
6253

63-
def handle_subscription_deleted(stripe_subscription)
64-
if subscription = find_subscription_by_customer(stripe_subscription.customer)
65-
subscription.update!(status: "canceled", stripe_subscription_id: nil)
66-
end
67-
end
54+
yield subscription_properties if block_given?
55+
subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled"
6856

69-
def handle_payment_failed(invoice)
70-
if subscription = find_subscription_by_customer(invoice.customer)
71-
subscription.update!(status: "past_due")
57+
subscription.update!(subscription_properties)
7258
end
7359
end
7460

7561
def find_subscription_by_customer(customer_id)
7662
Account::Subscription.find_by(stripe_customer_id: customer_id)
7763
end
7864

79-
def extract_current_period_end(stripe_subscription)
65+
def current_period_end_for(stripe_subscription)
8066
timestamp = stripe_subscription.items.data.first&.current_period_end
8167
Time.at(timestamp) if timestamp
8268
end

test/controllers/stripe/webhooks_controller_test.rb

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
1818
end
1919

2020
test "checkout session completed activates subscription" do
21-
stripe_sub = OpenStruct.new(id: "sub_123", status: "active", items: stub_items(1.month.from_now.to_i))
21+
stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i))
2222

2323
event = stripe_event("checkout.session.completed",
2424
mode: "subscription",
@@ -41,14 +41,18 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
4141
test "subscription updated changes status" do
4242
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
4343

44-
event = stripe_event("customer.subscription.updated",
44+
stripe_sub = OpenStruct.new(
45+
id: "sub_123",
4546
customer: "cus_test123",
4647
status: "past_due",
4748
cancel_at: nil,
4849
items: stub_items(1.month.from_now.to_i)
4950
)
5051

52+
event = stripe_event("customer.subscription.updated", id: "sub_123")
53+
5154
Stripe::Webhook.stubs(:construct_event).returns(event)
55+
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
5256

5357
post stripe_webhooks_path
5458

@@ -59,9 +63,18 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
5963
test "subscription deleted cancels subscription" do
6064
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
6165

62-
event = stripe_event("customer.subscription.deleted", customer: "cus_test123")
66+
stripe_sub = OpenStruct.new(
67+
id: "sub_123",
68+
customer: "cus_test123",
69+
status: "canceled",
70+
cancel_at: nil,
71+
items: stub_items(1.month.from_now.to_i)
72+
)
73+
74+
event = stripe_event("customer.subscription.deleted", id: "sub_123")
6375

6476
Stripe::Webhook.stubs(:construct_event).returns(event)
77+
Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
6578

6679
post stripe_webhooks_path
6780

@@ -71,19 +84,6 @@ class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
7184
assert_nil @subscription.stripe_subscription_id
7285
end
7386

74-
test "payment failed marks subscription as past due" do
75-
@subscription.update!(stripe_subscription_id: "sub_123", status: "active")
76-
77-
event = stripe_event("invoice.payment_failed", customer: "cus_test123")
78-
79-
Stripe::Webhook.stubs(:construct_event).returns(event)
80-
81-
post stripe_webhooks_path
82-
83-
assert_response :ok
84-
assert_equal "past_due", @subscription.reload.status
85-
end
86-
8787
private
8888
def stripe_event(type, **attributes)
8989
OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes)))

0 commit comments

Comments
 (0)