diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb index fdbbaab8b..de75bbe0c 100644 --- a/app/models/family/subscribeable.rb +++ b/app/models/family/subscribeable.rb @@ -53,6 +53,10 @@ module Family::Subscribeable subscription&.current_period_ends_at end + def subscription_pending_cancellation? + subscription&.pending_cancellation? + end + def start_subscription!(stripe_subscription_id) if subscription.present? subscription.update!(status: "active", stripe_id: stripe_subscription_id) diff --git a/app/models/provider/stripe/subscription_event_processor.rb b/app/models/provider/stripe/subscription_event_processor.rb index 360a7f74a..891b87aea 100644 --- a/app/models/provider/stripe/subscription_event_processor.rb +++ b/app/models/provider/stripe/subscription_event_processor.rb @@ -10,7 +10,8 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc interval: subscription_details.plan.interval, amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars currency: subscription_details.plan.currency.upcase, - current_period_ends_at: Time.at(subscription_details.current_period_end) + current_period_ends_at: Time.at(subscription_details.current_period_end), + cancel_at_period_end: subscription.cancel_at_period_end ) end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 0cdad97fb..83dc609f0 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -35,4 +35,8 @@ class Subscription < ApplicationRecord "Open demo" end end + + def pending_cancellation? + active? && cancel_at_period_end? + end end diff --git a/app/views/settings/payments/show.html.erb b/app/views/settings/payments/show.html.erb index ca56148ec..b395545a3 100644 --- a/app/views/settings/payments/show.html.erb +++ b/app/views/settings/payments/show.html.erb @@ -16,7 +16,11 @@ Currently on the <%= @family.subscription.name %>.
<% if @family.next_payment_date %> - <%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %> + <% if @family.subscription_pending_cancellation? %> + <%= t("views.settings.payments.cancellation", date: l(@family.next_payment_date, format: :long)) %> + <% else %> + <%= t("views.settings.payments.renewal", date: l(@family.next_payment_date, format: :long)) %> + <% end %> <% end %>

<% elsif @family.trialing? %> diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 10e2acc52..88990073f 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -4,6 +4,7 @@ en: settings: payments: renewal: "Your contribution continues on %{date}." + cancellation: "Your contribution ends on %{date}." settings: ai_prompts: show: diff --git a/db/migrate/20260123000000_add_cancel_at_period_end_to_subscriptions.rb b/db/migrate/20260123000000_add_cancel_at_period_end_to_subscriptions.rb new file mode 100644 index 000000000..93637944a --- /dev/null +++ b/db/migrate/20260123000000_add_cancel_at_period_end_to_subscriptions.rb @@ -0,0 +1,5 @@ +class AddCancelAtPeriodEndToSubscriptions < ActiveRecord::Migration[7.2] + def change + add_column :subscriptions, :cancel_at_period_end, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index a2f020b1d..7da95888c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do +ActiveRecord::Schema[7.2].define(version: 2026_01_23_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1259,6 +1259,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do t.datetime "trial_ends_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "cancel_at_period_end", default: false, null: false t.index ["family_id"], name: "index_subscriptions_on_family_id", unique: true end diff --git a/test/models/provider/stripe/subscription_event_processor_test.rb b/test/models/provider/stripe/subscription_event_processor_test.rb index e6f608ae0..26b7fb641 100644 --- a/test/models/provider/stripe/subscription_event_processor_test.rb +++ b/test/models/provider/stripe/subscription_event_processor_test.rb @@ -13,6 +13,7 @@ class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase id: test_subscription_id, status: "active", customer: test_customer_id, + cancel_at_period_end: false, items: { data: [ { @@ -53,5 +54,52 @@ class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase assert_equal 9, family.subscription.amount assert_equal "USD", family.subscription.currency assert family.subscription.current_period_ends_at > 20.days.from_now + assert_equal false, family.subscription.cancel_at_period_end + end + + test "handles subscription cancellation at period end" do + test_customer_id = "test-customer-id-cancel" + test_subscription_id = "test-subscription-id-cancel" + + mock_event = JSON.parse({ + type: "customer.subscription.updated", + data: { + object: { + id: test_subscription_id, + status: "active", + customer: test_customer_id, + cancel_at_period_end: true, + items: { + data: [ + { + current_period_end: 1.month.from_now.to_i, + plan: { + interval: "month", + amount: 900, + currency: "usd" + } + } + ] + } + } + } + }.to_json, object_class: OpenStruct) + + family = Family.create!( + name: "Test Cancelling Family", + stripe_customer_id: test_customer_id + ) + + family.start_subscription!(test_subscription_id) + + processor = Provider::Stripe::SubscriptionEventProcessor.new(mock_event) + processor.process + + family.reload + + assert_equal "active", family.subscription.status + assert_equal true, family.subscription.cancel_at_period_end + assert family.subscription.pending_cancellation? + assert family.subscription_pending_cancellation? end end