DEV: Stop deleting customers on cancel (#207)

Instead of deleting customers on cancel we will now update the
subscription status to canceled. This way we can have some visibility on
which users have canceled.
This commit is contained in:
Blake Erickson 2024-05-02 13:38:30 -06:00 committed by GitHub
parent aaa4baec8a
commit 66e8857c20
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 500 additions and 96 deletions

View File

@ -83,6 +83,7 @@ module DiscourseSubscriptions
expand: ["data.plan.product"],
limit: PAGE_LIMIT,
starting_after: start,
status: "all",
)
end

View File

@ -39,11 +39,7 @@ module DiscourseSubscriptions
user = ::User.find_by_username_or_email(email)
discourse_customer = Customer.find_by(user_id: user.id)
if discourse_customer.nil?
discourse_customer = Customer.create(user_id: user.id, customer_id: customer_id)
end
Subscription.create(
customer_id: discourse_customer.id,
@ -64,48 +60,60 @@ module DiscourseSubscriptions
)
when "customer.subscription.created"
when "customer.subscription.updated"
status = event[:data][:object][:status]
subscription = event[:data][:object]
status = subscription[:status]
return head 200 if !%w[complete active].include?(status)
customer =
Customer.find_by(
customer_id: event[:data][:object][:customer],
product_id: event[:data][:object][:plan][:product],
)
customer = find_active_customer(subscription[:customer], subscription[:plan][:product])
return render_json_error "customer not found" if !customer
update_status(customer.id, subscription[:id], status)
user = ::User.find_by(id: customer.user_id)
return render_json_error "user not found" if !user
if group = plan_group(event[:data][:object][:plan])
if group = plan_group(subscription[:plan])
group.add(user)
end
when "customer.subscription.deleted"
customer =
Customer.find_by(
customer_id: event[:data][:object][:customer],
product_id: event[:data][:object][:plan][:product],
)
subscription = event[:data][:object]
customer = find_active_customer(subscription[:customer], subscription[:plan][:product])
return render_json_error "customer not found" if !customer
Subscription.find_by(
customer_id: customer.id,
external_id: event[:data][:object][:id],
)&.destroy!
update_status(customer.id, subscription[:id], subscription[:status])
user = ::User.find(customer.user_id)
return render_json_error "user not found" if !user
if group = plan_group(event[:data][:object][:plan])
if group = plan_group(subscription[:plan])
group.remove(user)
end
customer.destroy!
end
head 200
end
private
def update_status(customer_id, subscription_id, status)
discourse_subscription =
Subscription.find_by(customer_id: customer_id, external_id: subscription_id)
discourse_subscription.update(status: status) if discourse_subscription
end
def find_active_customer(customer_id, product_id)
Customer
.joins(:subscriptions)
.where(customer_id: customer_id, product_id: product_id)
.where(
Subscription.arel_table[:status].eq(nil).or(
Subscription.arel_table[:status].not_eq("canceled"),
),
)
.first
end
end
end

View File

@ -150,7 +150,11 @@ module DiscourseSubscriptions
)
if transaction[:object] == "subscription"
Subscription.create(customer_id: customer.id, external_id: transaction[:id])
Subscription.create(
customer_id: customer.id,
external_id: transaction[:id],
status: transaction[:status],
)
end
end
@ -169,7 +173,17 @@ module DiscourseSubscriptions
def current_user_products
return [] if current_user.nil?
Customer.select(:product_id).where(user_id: current_user.id).map { |c| c.product_id }.compact
Customer
.joins(:subscriptions)
.where(user_id: current_user.id)
.where(
Subscription.arel_table[:status].eq(nil).or(
Subscription.arel_table[:status].not_eq("canceled"),
),
)
.select(:product_id)
.distinct
.pluck(:product_id)
end
def serialize_plans(plans)

View File

@ -15,6 +15,7 @@ module DiscourseSubscriptions
begin
customer = Customer.where(user_id: current_user.id)
customer_ids = customer.map { |c| c.id } if customer
stripe_customer_ids = customer.map { |c| c.customer_id } if customer
subscription_ids =
Subscription.where("customer_id in (?)", customer_ids).pluck(
:external_id,
@ -24,15 +25,14 @@ module DiscourseSubscriptions
if subscription_ids
plans = ::Stripe::Price.list(expand: ["data.product"], limit: 100)
all_subscriptions = []
customers =
::Stripe::Customer.list(email: current_user.email, expand: ["data.subscriptions"])
subscriptions =
customers[:data].map { |sub_customer| sub_customer[:subscriptions][:data] }.flatten(1)
subscriptions = subscriptions.select { |sub| subscription_ids.include?(sub[:id]) }
stripe_customer_ids.each do |stripe_customer_id|
customer_subscriptions =
::Stripe::Subscription.list(customer: stripe_customer_id, status: "all")
all_subscriptions.concat(customer_subscriptions[:data])
end
subscriptions = all_subscriptions.select { |sub| subscription_ids.include?(sub[:id]) }
subscriptions.map! do |subscription|
plan = plans[:data].find { |p| p[:id] == subscription[:items][:data][0][:price][:id] }
subscription.to_h.except!(:plan)

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddStatusToSubscriptions < ActiveRecord::Migration[7.0]
def change
add_column :discourse_subscriptions_subscriptions, :status, :string
end
end

View File

@ -0,0 +1,67 @@
{
"object": "list",
"data": [
{
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1709840311,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {
"group_name": "subscribers",
"trial_period_days": "0"
},
"nickname": "EA1",
"product": {
"id": "prod_PhB6IpGhEX14Hi",
"object": "product",
"active": true,
"attributes": [
],
"created": 1709840195,
"default_price": null,
"description": null,
"images": [
],
"livemode": false,
"marketing_features": [
],
"metadata": {
"description": "Sign up and get access to an exclusive group of enthusiasts just like you!"
},
"name": "Exclusive Access",
"package_dimensions": null,
"shippable": null,
"statement_descriptor": "TESTING",
"tax_code": null,
"type": "service",
"unit_label": null,
"updated": 1709840195,
"url": null
},
"recurring": {
"aggregate_usage": null,
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 1000,
"unit_amount_decimal": "1000"
}
],
"has_more": false,
"url": "/v1/prices"
}

View File

@ -0,0 +1,149 @@
{
"has_more": false,
"data": [
{
"id": "sub_10z",
"object": "subscription",
"created": 1714594277,
"current_period_end": 1717272677,
"current_period_start": 1714594277,
"customer": "cus_Q1n43We0YFjnlc",
"items": {
"object": "list",
"data": [
{
"id": "si_Q1n45g1Ifcluuu",
"object": "subscription_item",
"billing_thresholds": null,
"created": 1714594277,
"discounts": [],
"metadata": {},
"plan": {
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "plan",
"active": true,
"created": 1709840311,
"metadata": {
"group_name": "subscribers",
"trial_period_days": "0"
},
"nickname": "EA1",
"product": "prod_PhB6IpGhEX14Hi"
},
"price": {
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "price",
"metadata": {
"group_name": "subscribers",
"trial_period_days": "0"
},
"nickname": "EA1",
"product": "prod_PhB6IpGhEX14Hi"
},
"quantity": 1,
"subscription": "sub_1PBjUnEYXaQnncShE7USquGd",
"tax_rates": []
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1PBjUnEYXaQnncShE7USquGd"
},
"latest_invoice": "in_1PBjUnEYXaQnncSh5c7HZ2jG",
"metadata": { "user_id": "108", "username": "f79fc8fde" },
"plan": {
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "plan",
"active": true,
"metadata": { "group_name": "subscribers", "trial_period_days": "0" },
"meter": null,
"nickname": "EA1",
"product": {
"id": "prod_PhB6IpGhEX14Hi",
"object": "product",
"metadata": {
"description": "Sign up and get access to an exclusive group of enthusiasts just like you!"
},
"name": "Exclusive Access"
}
},
"quantity": 1,
"schedule": null,
"start_date": 1714594277,
"status": "active"
},
{
"id": "sub_32b",
"object": "subscription",
"created": 1714594277,
"current_period_end": 1717272677,
"current_period_start": 1714594277,
"customer": "cus_Q1n43We0YFjnlc",
"items": {
"object": "list",
"data": [
{
"id": "si_Q1n45g1Ifcluuu",
"object": "subscription_item",
"billing_thresholds": null,
"created": 1714594277,
"discounts": [],
"metadata": {},
"plan": {
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "plan",
"active": true,
"created": 1709840311,
"metadata": {
"group_name": "subscribers",
"trial_period_days": "0"
},
"nickname": "EA1",
"product": "prod_PhB6IpGhEX14Hi"
},
"price": {
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "price",
"metadata": {
"group_name": "subscribers",
"trial_period_days": "0"
},
"nickname": "EA1",
"product": "prod_PhB6IpGhEX14Hi"
},
"quantity": 1,
"subscription": "sub_1PBjUnEYXaQnncShE7USquGd",
"tax_rates": []
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1PBjUnEYXaQnncShE7USquGd"
},
"latest_invoice": "in_1PBjUnEYXaQnncSh5c7HZ2jG",
"metadata": { "user_id": "108", "username": "f79fc8fde" },
"plan": {
"id": "price_1OrmlvEYXaQnncShNahrpKvA",
"object": "plan",
"active": true,
"metadata": { "group_name": "subscribers", "trial_period_days": "0" },
"meter": null,
"nickname": "EA1",
"product": {
"id": "prod_PhB6IpGhEX14Hi",
"object": "product",
"metadata": {
"description": "Sign up and get access to an exclusive group of enthusiasts just like you!"
},
"name": "Exclusive Access"
}
},
"quantity": 1,
"schedule": null,
"start_date": 1714594277,
"status": "canceled"
}
],
"length": 2,
"last_record": "sub_1P9aohEYXaQnncSh4wf1wzuL"
}

View File

@ -48,7 +48,7 @@ RSpec.describe DiscourseSubscriptions::Admin::SubscriptionsController do
it "gets the subscriptions and products" do
::Stripe::Subscription
.expects(:list)
.with(expand: ["data.plan.product"], limit: 10, starting_after: nil)
.with(expand: ["data.plan.product"], limit: 10, starting_after: nil, status: "all")
.returns(has_more: false, data: [{ id: "sub_12345" }, { id: "sub_nope" }])
get "/s/admin/subscriptions.json"
subscriptions = response.parsed_body["data"][0]["id"]
@ -60,7 +60,7 @@ RSpec.describe DiscourseSubscriptions::Admin::SubscriptionsController do
it "handles starting at a different point in the set" do
::Stripe::Subscription
.expects(:list)
.with(expand: ["data.plan.product"], limit: 10, starting_after: "sub_nope")
.with(expand: ["data.plan.product"], limit: 10, starting_after: "sub_nope", status: "all")
.returns(has_more: false, data: [{ id: "sub_77777" }, { id: "sub_yepnoep" }])
get "/s/admin/subscriptions.json", params: { last_record: "sub_nope" }
subscriptions = response.parsed_body["data"][0]["id"]

View File

@ -27,6 +27,9 @@ RSpec.describe DiscourseSubscriptions::HooksController do
let(:customer) do
Fabricate(:customer, customer_id: "c_575768", product_id: "p_8654", user_id: user.id)
end
let!(:subscription) do
Fabricate(:subscription, external_id: "sub_12345", customer_id: customer.id, status: nil)
end
let(:group) { Fabricate(:group, name: "subscribers-group") }
let(:event_data) do
@ -43,6 +46,22 @@ RSpec.describe DiscourseSubscriptions::HooksController do
}
end
let(:customer_subscription_deleted_data) do
{
object: {
id: subscription.external_id,
customer: customer.customer_id,
plan: {
product: customer.product_id,
metadata: {
group_name: group.name,
},
},
status: "canceled",
},
}
end
let(:checkout_session_completed_data) do
{
object: {
@ -217,7 +236,7 @@ RSpec.describe DiscourseSubscriptions::HooksController do
describe "customer.subscription.deleted" do
before do
event = { type: "customer.subscription.deleted", data: event_data }
event = { type: "customer.subscription.deleted", data: customer_subscription_deleted_data }
::Stripe::Webhook.stubs(:construct_event).returns(event)
@ -225,7 +244,9 @@ RSpec.describe DiscourseSubscriptions::HooksController do
end
it "deletes the customer" do
expect { post "/s/hooks.json" }.to change { DiscourseSubscriptions::Customer.count }.by(-1)
expect { post "/s/hooks.json" }.to change {
DiscourseSubscriptions::Subscription.where(status: "canceled").count
}.by(+1)
expect(response.status).to eq 200
end

View File

@ -69,6 +69,10 @@ RSpec.describe DiscourseSubscriptions::SubscribeController do
end
describe "#index" do
let(:customer) do
Fabricate(:customer, product_id: product[:id], user_id: user.id, customer_id: "x")
end
it "gets products" do
::Stripe::Product
.expects(:list)
@ -94,7 +98,8 @@ RSpec.describe DiscourseSubscriptions::SubscribeController do
end
it "is subscribed" do
Fabricate(:customer, product_id: product[:id], user_id: user.id, customer_id: "x")
Fabricate(:subscription, external_id: "sub_12345", customer_id: customer.id, status: nil)
::Stripe::Product
.expects(:list)
.with({ ids: product_ids, active: true })

View File

@ -37,62 +37,48 @@ RSpec.describe DiscourseSubscriptions::User::SubscriptionsController do
before do
sign_in(user)
Fabricate(:subscription, customer_id: customer.id, external_id: "sub_1234")
Fabricate(:subscription, customer_id: customer.id, external_id: "sub_10z")
end
describe "index" do
let(:plans) do
{
data: [
{ id: "plan_1", product: { name: "ACME Subscriptions" } },
{ id: "plan_2", product: { name: "ACME Other Subscriptions" } },
],
}
end
let(:customers) do
{
data: [
{
id: "cus_23456",
subscriptions: {
data: [
{ id: "sub_1234", items: { data: [price: { id: "plan_1" }] } },
{ id: "sub_4567", items: { data: [price: { id: "plan_2" }] } },
],
},
},
],
}
end
plans_json =
File.read(
Rails.root.join(
"plugins",
"discourse-subscriptions",
"spec",
"fixtures",
"json",
"stripe-price-list.json",
),
)
it "gets subscriptions" do
::Stripe::Price.expects(:list).with(expand: ["data.product"], limit: 100).returns(plans)
::Stripe::Price.stubs(:list).returns(JSON.parse(plans_json, symbolize_names: true))
::Stripe::Customer
.expects(:list)
.with(email: user.email, expand: ["data.subscriptions"])
.returns(customers)
subscriptions_json =
File.read(
Rails.root.join(
"plugins",
"discourse-subscriptions",
"spec",
"fixtures",
"json",
"stripe-subscription-list.json",
),
)
::Stripe::Subscription.stubs(:list).returns(
JSON.parse(subscriptions_json, symbolize_names: true),
)
get "/s/user/subscriptions.json"
subscription = response.parsed_body.first
subscription = JSON.parse(response.body, symbolize_names: true).first
expect(subscription).to eq(
"id" => "sub_1234",
"items" => {
"data" => [{ "price" => { "id" => "plan_1" } }],
},
"plan" => {
"id" => "plan_1",
"product" => {
"name" => "ACME Subscriptions",
},
},
"product" => {
"name" => "ACME Subscriptions",
},
)
expect(subscription[:id]).to eq("sub_10z")
expect(subscription[:items][:data][0][:plan][:id]).to eq("price_1OrmlvEYXaQnncShNahrpKvA")
expect(subscription[:product][:name]).to eq("Exclusive Access")
end
end
@ -100,7 +86,7 @@ RSpec.describe DiscourseSubscriptions::User::SubscriptionsController do
it "updates the payment method for subscription" do
::Stripe::Subscription.expects(:update).once
::Stripe::PaymentMethod.expects(:attach).once
put "/s/user/subscriptions/sub_1234.json", params: { payment_method: "pm_abc123abc" }
put "/s/user/subscriptions/sub_10z.json", params: { payment_method: "pm_abc123abc" }
end
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminSubscriptionSubscription < PageObjects::Pages::Base
SUBSCRIPTIONS_TABLE_SELECTOR = "table.discourse-patrons-table"
def visit_subscriptions
visit("/admin/plugins/discourse-subscriptions/subscriptions")
self
end
def has_subscription?(id)
has_css?("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr", text: id)
self
end
def subscription_row(id)
find("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr", text: id)
end
def has_number_of_subscriptions?(count)
has_css?("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr", count:)
self
end
def click_cancel_nth_row(row)
find("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr:nth-child(#{row}) button.btn-danger").click()
end
end
end
end

View File

@ -0,0 +1,32 @@
# frozen_string_literal: true
module PageObjects
module Pages
class UserBillingSubscription < PageObjects::Pages::Base
SUBSCRIPTIONS_TABLE_SELECTOR = "table.discourse-subscriptions-user-table"
def visit_subscriptions
visit("/my/billing/subscriptions")
self
end
def has_subscription?(id)
has_css?("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr", text: id)
self
end
def subscription_row(id)
find("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr", text: id)
end
def has_number_of_subscriptions?(count)
has_css?("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr", count:)
self
end
def click_cancel_nth_row(row)
find("#{SUBSCRIPTIONS_TABLE_SELECTOR} tr:nth-child(#{row}) button.btn-danger").click()
end
end
end
end

View File

@ -67,14 +67,4 @@ RSpec.describe "Pricing Table", type: :system, js: true do
text: "There are currently no products available.",
)
end
# Commenting out for now, not sure how to stub network reqeusts made in the browser to stripe
# it "Shows a pricing table when setup" do
# SiteSetting.discourse_subscriptions_pricing_table = '{"insert-pricing-table-embed-code"}'
# visit("/")
# find("li.nav-item_subscribe a").click
# expect(page).to have_selector('stripe-pricing-table')
# end
end

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
describe "Subscription products", type: :system do
fab!(:admin)
fab!(:user)
fab!(:product) { Fabricate(:product, external_id: "prod_OiK") }
fab!(:customer) do
Fabricate(:customer, customer_id: "cus_Q1n", product_id: product.external_id, user_id: user.id)
end
fab!(:subscription) do
Fabricate(:subscription, customer_id: customer.id, external_id: "sub_10z", status: "active")
end
fab!(:subscription) do
Fabricate(:subscription, customer_id: customer.id, external_id: "sub_32b", status: "canceled")
end
let(:dialog) { PageObjects::Components::Dialog.new }
let(:product_subscriptions_page) { PageObjects::Pages::AdminSubscriptionProduct.new }
let(:admin_subscriptions_page) { PageObjects::Pages::AdminSubscriptionSubscription.new }
let(:user_billing_subscriptions_page) { PageObjects::Pages::UserBillingSubscription.new }
before do
SiteSetting.discourse_subscriptions_enabled = true
SiteSetting.discourse_subscriptions_secret_key = "sk_test_51xuu"
SiteSetting.discourse_subscriptions_public_key = "pk_test_51xuu"
# # this needs to be stubbed or it will try to make a request to stripe
one_product = {
id: "prod_OiK",
active: true,
name: "Tomtom",
metadata: {
description: "Photos of tomtom",
repurchaseable: true,
},
}
plans_json =
File.read(
Rails.root.join(
"plugins",
"discourse-subscriptions",
"spec",
"fixtures",
"json",
"stripe-price-list.json",
),
)
subscriptions_json =
File.read(
Rails.root.join(
"plugins",
"discourse-subscriptions",
"spec",
"fixtures",
"json",
"stripe-subscription-list.json",
),
)
::Stripe::Product.stubs(:list).returns({ data: [one_product] })
::Stripe::Product.stubs(:delete).returns({ id: "prod_OiK" })
::Stripe::Product.stubs(:retrieve).returns(one_product)
::Stripe::Price.stubs(:list).returns(JSON.parse(plans_json, symbolize_names: true))
::Stripe::Subscription.stubs(:list).returns(
JSON.parse(subscriptions_json, symbolize_names: true),
)
end
it "shows active and canceled subscriptions for admins" do
sign_in(admin)
active_subscription_row =
admin_subscriptions_page.visit_subscriptions.subscription_row("sub_10z")
expect(active_subscription_row).to have_text("active")
canceled_subscription_row =
admin_subscriptions_page.visit_subscriptions.subscription_row("sub_32b")
expect(canceled_subscription_row).to have_text("canceled")
end
it "shows active and canceled subscriptions for users" do
sign_in(user)
active_subscription_row =
user_billing_subscriptions_page.visit_subscriptions.subscription_row("sub_10z")
expect(active_subscription_row).to have_text("active")
canceled_subscription_row =
user_billing_subscriptions_page.visit_subscriptions.subscription_row("sub_32b")
expect(canceled_subscription_row).to have_text("canceled")
end
end