diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb
index 505700b3..17892479 100644
--- a/app/controllers/accountable_sparklines_controller.rb
+++ b/app/controllers/accountable_sparklines_controller.rb
@@ -7,7 +7,6 @@ class AccountableSparklinesController < ApplicationController
.where(accountable_type: @accountable.name)
.balance_series(
currency: family.currency,
- timezone: family.timezone,
favorable_direction: @accountable.favorable_direction
)
end
diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb
index 9e7dd144..9d1f9ef6 100644
--- a/app/controllers/concerns/onboardable.rb
+++ b/app/controllers/concerns/onboardable.rb
@@ -3,24 +3,19 @@ module Onboardable
included do
before_action :require_onboarding_and_upgrade
- helper_method :subscription_pending?
end
private
- # A subscription goes into "pending" mode immediately after checkout, but before webhooks are processed.
- def subscription_pending?
- subscribed_at = Current.session.subscribed_at
- subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
- end
-
# First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
def require_onboarding_and_upgrade
return unless Current.user
return unless redirectable_path?(request.path)
- if !Current.user.onboarded?
+ if Current.user.needs_onboarding?
redirect_to onboarding_path
- elsif !Current.family.subscribed? && !Current.family.trialing? && !self_hosted?
+ elsif Current.family.needs_subscription?
+ redirect_to trial_onboarding_path
+ elsif Current.family.upgrade_required?
redirect_to upgrade_subscription_path
end
end
diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb
index 7bb8c200..5d69d632 100644
--- a/app/controllers/settings/billings_controller.rb
+++ b/app/controllers/settings/billings_controller.rb
@@ -2,6 +2,6 @@ class Settings::BillingsController < ApplicationController
layout "settings"
def show
- @user = Current.user
+ @family = Current.family
end
end
diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb
index 45f4548a..541d3dd9 100644
--- a/app/controllers/subscriptions_controller.rb
+++ b/app/controllers/subscriptions_controller.rb
@@ -4,49 +4,40 @@ class SubscriptionsController < ApplicationController
# Upgrade page for unsubscribed users
def upgrade
- render layout: "onboardings"
- end
-
- def start_trial
- unless Current.family.trialing?
- ActiveRecord::Base.transaction do
- Current.user.update!(onboarded_at: Time.current)
- Current.family.update!(trial_started_at: Time.current)
- end
+ if Current.family.subscription&.active?
+ redirect_to root_path, notice: "You are already subscribed."
+ else
+ @plan = params[:plan] || "annual"
+ render layout: "onboardings"
end
-
- redirect_to root_path, notice: "Welcome to Maybe!"
end
def new
- price_map = {
- monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
- annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
- }
-
- price_id = price_map[(params[:plan] || :monthly).to_sym]
-
- unless Current.family.existing_customer?
- customer = stripe.create_customer(
- email: Current.family.primary_user.email,
- metadata: { family_id: Current.family.id }
- )
-
- Current.family.update(stripe_customer_id: customer.id)
- end
-
- checkout_session_url = stripe.get_checkout_session_url(
- price_id: price_id,
- customer_id: Current.family.stripe_customer_id,
+ checkout_session = stripe.create_checkout_session(
+ plan: params[:plan],
+ family_id: Current.family.id,
+ family_email: Current.family.billing_email,
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
- cancel_url: upgrade_subscription_url(plan: params[:plan])
+ cancel_url: upgrade_subscription_url
)
- redirect_to checkout_session_url, allow_other_host: true, status: :see_other
+ Current.family.update!(stripe_customer_id: checkout_session.customer_id)
+
+ redirect_to checkout_session.url, allow_other_host: true, status: :see_other
+ end
+
+ # Only used for managing our "offline" trials. Paid subscriptions are handled in success callback of checkout session
+ def create
+ if Current.family.can_start_trial?
+ Current.family.start_trial_subscription!
+ redirect_to root_path, notice: "Welcome to Maybe!"
+ else
+ redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue."
+ end
end
def show
- portal_session_url = stripe.get_billing_portal_session_url(
+ portal_session_url = stripe.create_billing_portal_session_url(
customer_id: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
@@ -54,12 +45,16 @@ class SubscriptionsController < ApplicationController
redirect_to portal_session_url, allow_other_host: true, status: :see_other
end
+ # Stripe redirects here after a successful checkout session and passes the session ID in the URL
def success
- checkout_session = stripe.retrieve_checkout_session(params[:session_id])
- Current.session.update(subscribed_at: Time.at(checkout_session.created))
- redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
- rescue Stripe::InvalidRequestError
- redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
+ checkout_result = stripe.get_checkout_result(params[:session_id])
+
+ if checkout_result.success?
+ Current.family.start_subscription!(checkout_result.subscription_id)
+ redirect_to root_path, notice: "Welcome to Maybe! Your subscription has been created."
+ else
+ redirect_to root_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
+ end
end
private
diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb
index d138e64f..ff1ae08c 100644
--- a/app/controllers/webhooks_controller.rb
+++ b/app/controllers/webhooks_controller.rb
@@ -44,9 +44,11 @@ class WebhooksController < ApplicationController
head :ok
rescue JSON::ParserError => error
Sentry.capture_exception(error)
+ Rails.logger.error "JSON parser error: #{error.message}"
head :bad_request
rescue Stripe::SignatureVerificationError => error
Sentry.capture_exception(error)
+ Rails.logger.error "Stripe signature verification error: #{error.message}"
head :bad_request
end
end
diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb
index 8a34c25d..cf16ac78 100644
--- a/app/models/account/chartable.rb
+++ b/app/models/account/chartable.rb
@@ -2,7 +2,7 @@ module Account::Chartable
extend ActiveSupport::Concern
class_methods do
- def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil, timezone: nil)
+ def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
series_interval = interval || period.interval
@@ -132,8 +132,7 @@ module Account::Chartable
period: period,
view: view,
interval: interval,
- favorable_direction: favorable_direction,
- timezone: family.timezone
+ favorable_direction: favorable_direction
)
end
diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb
index 38db5758..b912d81d 100644
--- a/app/models/assistant/function/get_accounts.rb
+++ b/app/models/assistant/function/get_accounts.rb
@@ -33,7 +33,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
def historical_balances(account)
start_date = [ account.start_date, 5.years.ago.to_date ].max
period = Period.custom(start_date: start_date, end_date: Date.current)
- balance_series = account.balance_series(period: period, interval: "1 month", timezone: family.timezone)
+ balance_series = account.balance_series(period: period, interval: "1 month")
to_ai_time_series(balance_series)
end
diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb
index 930eda6d..71992831 100644
--- a/app/models/assistant/function/get_balance_sheet.rb
+++ b/app/models/assistant/function/get_balance_sheet.rb
@@ -54,8 +54,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
currency: family.currency,
period: period,
interval: "1 month",
- favorable_direction: "up",
- timezone: family.timezone
+ favorable_direction: "up"
)
to_ai_time_series(balance_series)
diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb
index fce184b2..c289f86f 100644
--- a/app/models/balance_sheet.rb
+++ b/app/models/balance_sheet.rb
@@ -69,7 +69,7 @@ class BalanceSheet
end
def net_worth_series(period: Period.last_30_days)
- active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up", timezone: family.timezone)
+ active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up")
end
def currency
diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb
index a7ce8940..bbcc292c 100644
--- a/app/models/demo/generator.rb
+++ b/app/models/demo/generator.rb
@@ -160,13 +160,14 @@ class Demo::Generator
id: id,
name: family_name,
currency: currency,
- stripe_subscription_status: require_onboarding ? nil : "active",
locale: "en",
country: "US",
timezone: "America/New_York",
date_format: "%m-%d-%Y"
)
+ family.start_subscription!("sub_1234567890")
+
family.users.create! \
email: user_email,
first_name: "Demo",
diff --git a/app/models/family.rb b/app/models/family.rb
index 0adb1521..ed919b11 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -1,5 +1,5 @@
class Family < ApplicationRecord
- include Syncable, AutoTransferMatchable
+ include Syncable, AutoTransferMatchable, Subscribeable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@@ -68,6 +68,9 @@ class Family < ApplicationRecord
def sync_data(sync, start_date: nil)
update!(last_synced_at: Time.current)
+ # We don't rely on this value to guard the app, but keep it eventually consistent
+ sync_trial_status!
+
Rails.logger.info("Syncing accounts for family #{id}")
accounts.manual.each do |account|
account.sync_later(start_date: start_date, parent_sync: sync)
@@ -127,22 +130,6 @@ class Family < ApplicationRecord
).link_token
end
- def subscribed?
- stripe_subscription_status == "active"
- end
-
- def trialing?
- !subscribed? && trial_started_at.present? && trial_started_at <= 14.days.from_now
- end
-
- def trial_remaining_days
- (14 - (Time.current - trial_started_at).to_i / 86400).to_i
- end
-
- def existing_customer?
- stripe_customer_id.present?
- end
-
def requires_data_provider?
# If family has any trades, they need a provider for historical prices
return true if trades.any?
@@ -162,18 +149,10 @@ class Family < ApplicationRecord
requires_data_provider? && Provider::Registry.get_provider(:synth).nil?
end
- def primary_user
- users.order(:created_at).first
- end
-
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
- def active_accounts_count
- accounts.active.count
- end
-
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
def build_cache_key(key)
[
diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb
new file mode 100644
index 00000000..1cc3a5f1
--- /dev/null
+++ b/app/models/family/subscribeable.rb
@@ -0,0 +1,81 @@
+module Family::Subscribeable
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :subscription, dependent: :destroy
+ end
+
+ def billing_email
+ primary_admin = users.admin.order(:created_at).first
+
+ unless primary_admin.present?
+ raise "No primary admin found for family #{id}. This is an invalid data state and should never occur."
+ end
+
+ primary_admin.email
+ end
+
+ def upgrade_required?
+ return false if self_hoster?
+ return false if subscription&.active? || subscription&.trialing?
+
+ true
+ end
+
+ def can_start_trial?
+ subscription&.trial_ends_at.blank?
+ end
+
+ def start_trial_subscription!
+ create_subscription!(
+ status: "trialing",
+ trial_ends_at: Subscription.new_trial_ends_at
+ )
+ end
+
+ def trialing?
+ subscription&.trialing? && days_left_in_trial.positive?
+ end
+
+ def has_active_subscription?
+ subscription&.active?
+ end
+
+ def needs_subscription?
+ subscription.nil? && !self_hoster?
+ end
+
+ def next_billing_date
+ subscription&.current_period_ends_at
+ end
+
+ def start_subscription!(stripe_subscription_id)
+ if subscription.present?
+ subscription.update!(status: "active", stripe_id: stripe_subscription_id)
+ else
+ create_subscription!(status: "active", stripe_id: stripe_subscription_id)
+ end
+ end
+
+ def days_left_in_trial
+ return -1 unless subscription.present?
+ ((subscription.trial_ends_at - Time.current).to_i / 86400) + 1
+ end
+
+ def percentage_of_trial_remaining
+ return 0 unless subscription.present?
+ (days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
+ end
+
+ def percentage_of_trial_completed
+ return 0 unless subscription.present?
+ (1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
+ end
+
+ private
+ def sync_trial_status!
+ if subscription&.status == "trialing" && days_left_in_trial < 0
+ subscription.update!(status: "paused")
+ end
+ end
+end
diff --git a/app/models/provider/stripe.rb b/app/models/provider/stripe.rb
index d5f5242c..81fc3f96 100644
--- a/app/models/provider/stripe.rb
+++ b/app/models/provider/stripe.rb
@@ -1,9 +1,8 @@
class Provider::Stripe
+ Error = Class.new(StandardError)
+
def initialize(secret_key:, webhook_secret:)
- @client = Stripe::StripeClient.new(
- secret_key,
- stripe_version: "2025-04-30.basil"
- )
+ @client = Stripe::StripeClient.new(secret_key)
@webhook_secret = webhook_secret
end
@@ -12,56 +11,77 @@ class Provider::Stripe
case event.type
when /^customer\.subscription\./
- SubscriptionEventProcessor.new(event: event, client: client).process
- when /^customer\./
- CustomerEventProcessor.new(event: event, client: client).process
+ SubscriptionEventProcessor.new(event).process
else
- Rails.logger.info "Unhandled event type: #{event.type}"
+ Rails.logger.warn "Unhandled event type: #{event.type}"
end
end
def process_webhook_later(webhook_body, sig_header)
thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)
-
- if thin_event.type.start_with?("customer.")
- StripeEventHandlerJob.perform_later(thin_event.id)
- else
- Rails.logger.info "Unhandled event type: #{thin_event.type}"
- end
+ StripeEventHandlerJob.perform_later(thin_event.id)
end
- def create_customer(email:, metadata: {})
- client.v1.customers.create(
- email: email,
- metadata: metadata
+ def create_checkout_session(plan:, family_id:, family_email:, success_url:, cancel_url:)
+ customer = client.v1.customers.create(
+ email: family_email,
+ metadata: {
+ family_id: family_id
+ }
)
- end
- def get_checkout_session_url(price_id:, customer_id: nil, success_url: nil, cancel_url: nil)
- client.v1.checkout.sessions.create(
- customer: customer_id,
- line_items: [ { price: price_id, quantity: 1 } ],
+ session = client.v1.checkout.sessions.create(
+ customer: customer.id,
+ line_items: [ { price: price_id_for(plan), quantity: 1 } ],
mode: "subscription",
allow_promotion_codes: true,
success_url: success_url,
cancel_url: cancel_url
- ).url
+ )
+
+ NewCheckoutSession.new(url: session.url, customer_id: customer.id)
end
- def get_billing_portal_session_url(customer_id:, return_url: nil)
+ def get_checkout_result(session_id)
+ session = client.v1.checkout.sessions.retrieve(session_id)
+
+ unless session.status == "complete" && session.payment_status == "paid"
+ raise Error, "Checkout session not complete"
+ end
+
+ CheckoutSessionResult.new(success?: true, subscription_id: session.subscription)
+ rescue StandardError => e
+ Sentry.capture_exception(e)
+ Rails.logger.error "Error fetching checkout result for session #{session_id}: #{e.message}"
+ CheckoutSessionResult.new(success?: false, subscription_id: nil)
+ end
+
+ def create_billing_portal_session_url(customer_id:, return_url:)
client.v1.billing_portal.sessions.create(
customer: customer_id,
return_url: return_url
).url
end
- def retrieve_checkout_session(session_id)
- client.v1.checkout.sessions.retrieve(session_id)
+ def update_customer_metadata(customer_id:, metadata:)
+ client.v1.customers.update(customer_id, metadata: metadata)
end
private
attr_reader :client, :webhook_secret
+ NewCheckoutSession = Data.define(:url, :customer_id)
+ CheckoutSessionResult = Data.define(:success?, :subscription_id)
+
+ def price_id_for(plan)
+ prices = {
+ monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
+ annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
+ }
+
+ prices[plan.to_sym || :monthly]
+ end
+
def retrieve_event(event_id)
client.v1.events.retrieve(event_id)
end
diff --git a/app/models/provider/stripe/customer_event_processor.rb b/app/models/provider/stripe/customer_event_processor.rb
deleted file mode 100644
index 6efce0b2..00000000
--- a/app/models/provider/stripe/customer_event_processor.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-class Provider::Stripe::CustomerEventProcessor < Provider::Stripe::EventProcessor
- Error = Class.new(StandardError)
-
- def process
- raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
-
- family.update(
- stripe_customer_id: customer_id
- )
- end
-
- private
- def family
- Family.find_by(stripe_customer_id: customer_id)
- end
-
- def customer_id
- event_data.id
- end
-end
diff --git a/app/models/provider/stripe/event_processor.rb b/app/models/provider/stripe/event_processor.rb
index 03806d1d..f19c419f 100644
--- a/app/models/provider/stripe/event_processor.rb
+++ b/app/models/provider/stripe/event_processor.rb
@@ -1,7 +1,6 @@
class Provider::Stripe::EventProcessor
- def initialize(event:, client:)
+ def initialize(event)
@event = event
- @client = client
end
def process
@@ -9,7 +8,7 @@ class Provider::Stripe::EventProcessor
end
private
- attr_reader :event, :client
+ attr_reader :event
def event_data
event.data.object
diff --git a/app/models/provider/stripe/subscription_event_processor.rb b/app/models/provider/stripe/subscription_event_processor.rb
index 773b91d1..360a7f74 100644
--- a/app/models/provider/stripe/subscription_event_processor.rb
+++ b/app/models/provider/stripe/subscription_event_processor.rb
@@ -2,28 +2,28 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc
Error = Class.new(StandardError)
def process
- raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
+ raise Error, "Family not found for Stripe customer ID: #{subscription.customer}" unless family
- family.update(
- stripe_plan_id: plan_id,
- stripe_subscription_status: subscription_status
+ family.subscription.update(
+ stripe_id: subscription.id,
+ status: subscription.status,
+ 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)
)
end
private
def family
- Family.find_by(stripe_customer_id: customer_id)
+ Family.find_by(stripe_customer_id: subscription.customer)
end
- def customer_id
- event_data.customer
+ def subscription_details
+ event_data.items.data.first
end
- def plan_id
- event_data.plan.id
- end
-
- def subscription_status
- event_data.status
+ def subscription
+ event_data
end
end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 00000000..5f96361e
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,37 @@
+class Subscription < ApplicationRecord
+ TRIAL_DAYS = 14
+
+ belongs_to :family
+
+ # https://docs.stripe.com/api/subscriptions/object
+ enum :status, {
+ incomplete: "incomplete",
+ incomplete_expired: "incomplete_expired",
+ trialing: "trialing", # We use this, but "offline" (no through Stripe's interface)
+ active: "active",
+ past_due: "past_due",
+ canceled: "canceled",
+ unpaid: "unpaid",
+ paused: "paused"
+ }
+
+ validates :stripe_id, presence: true, if: :active?
+ validates :trial_ends_at, presence: true, if: :trialing?
+
+ class << self
+ def new_trial_ends_at
+ TRIAL_DAYS.days.from_now
+ end
+ end
+
+ def name
+ case interval
+ when "month"
+ "Monthly Plan"
+ when "year"
+ "Annual Plan"
+ else
+ "Free trial"
+ end
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index d211878a..19a17c87 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -156,6 +156,10 @@ class User < ApplicationRecord
onboarded_at.present?
end
+ def needs_onboarding?
+ !onboarded?
+ end
+
private
def ensure_valid_profile_image
return unless profile_image.attached?
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 25432ff0..53e7f666 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -89,7 +89,7 @@
to
<%= form.text_field :value, placeholder: "Enter a value" %>
diff --git a/app/views/settings/billings/show.html.erb b/app/views/settings/billings/show.html.erb
index 221cdaad..f94189be 100644
--- a/app/views/settings/billings/show.html.erb
+++ b/app/views/settings/billings/show.html.erb
@@ -11,19 +11,21 @@
) %>
- <% if subscription_pending? %>
+ <% if @family.has_active_subscription? %>
- Your subscription is pending. You can still use Maybe+ while we process your subscription.
+ You are currently subscribed to the <%= @family.subscription.name %>.
+
+ <% if @family.next_billing_date %>
+ Your plan renews on <%= @family.next_billing_date.strftime("%B %d, %Y") %>.
+ <% end %>
- <% elsif @user.family.trialing? %>
+ <% elsif @family.trialing? %>
- You are currently trialing Maybe+
+ You are currently trialing Maybe
- (<%= @user.family.trial_remaining_days %> days remaining)
+ (<%= @family.days_left_in_trial %> days remaining)
- <% elsif @user.family.subscribed? %>
-
You are currently subscribed to Maybe+
<% else %>
You are currently not subscribed
Once you subscribe to Maybe+, you'll see your billing settings here.
@@ -31,7 +33,7 @@
- <% if @user.family.subscribed? %>
+ <% if @family.has_active_subscription? %>
<%= render LinkComponent.new(
text: "Manage",
icon: "external-link",
@@ -40,7 +42,7 @@
href: subscription_path,
rel: "noopener"
) %>
- <% elsif @user.family.trialing? && !subscription_pending? %>
+ <% else %>
<%= render LinkComponent.new(
text: "Choose plan",
variant: "primary",
diff --git a/app/views/subscriptions/upgrade.html.erb b/app/views/subscriptions/upgrade.html.erb
index b66e927b..e1edb17f 100644
--- a/app/views/subscriptions/upgrade.html.erb
+++ b/app/views/subscriptions/upgrade.html.erb
@@ -18,7 +18,7 @@
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
<% if Current.family.trialing? %>
- Your trial has <%= Current.family.trial_remaining_days %> days remaining
+ Your trial has <%= Current.family.days_left_in_trial %> days remaining
<% else %>
Your trial is over
<% end %>
@@ -33,8 +33,8 @@
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
- <%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: params[:plan] == "annual" || params[:plan].blank? %>
- <%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: params[:plan] == "monthly" %>
+ <%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: @plan == "annual" %>
+ <%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: @plan == "monthly" %>
diff --git a/config/routes.rb b/config/routes.rb
index da7790fb..8384d116 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -54,11 +54,10 @@ Rails.application.routes.draw do
resource :security, only: :show
end
- resource :subscription, only: %i[new show] do
+ resource :subscription, only: %i[new show create] do
collection do
get :upgrade
get :success
- post :start_trial
end
end
diff --git a/db/migrate/20250502164951_create_subscriptions.rb b/db/migrate/20250502164951_create_subscriptions.rb
new file mode 100644
index 00000000..3dfd3cdc
--- /dev/null
+++ b/db/migrate/20250502164951_create_subscriptions.rb
@@ -0,0 +1,46 @@
+class CreateSubscriptions < ActiveRecord::Migration[7.2]
+ def change
+ create_table :subscriptions, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+
+ t.string :status, null: false
+
+ t.string :stripe_id
+ t.decimal :amount, precision: 19, scale: 4
+ t.string :currency
+ t.string :interval
+
+ t.datetime :current_period_ends_at
+ t.datetime :trial_ends_at
+
+ t.timestamps
+ end
+
+ reversible do |dir|
+ dir.up do
+ if Rails.application.config.app_mode.managed?
+ execute <<~SQL
+ INSERT INTO subscriptions (family_id, status, trial_ends_at, created_at, updated_at)
+ SELECT
+ f.id,
+ CASE
+ WHEN f.trial_started_at IS NOT NULL THEN 'trialing'
+ ELSE COALESCE(f.stripe_subscription_status, 'incomplete')
+ END,
+ CASE
+ WHEN f.trial_started_at IS NOT NULL THEN f.trial_started_at + INTERVAL '14 days'
+ ELSE NULL
+ END,
+ now(),
+ now()
+ FROM families f
+ SQL
+ end
+ end
+ end
+
+ remove_column :families, :stripe_subscription_status, :string
+ remove_column :families, :trial_started_at, :datetime
+ remove_column :families, :stripe_plan_id, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c5db79e9..9a7e5fcf 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: 2025_05_01_172430) do
+ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -222,15 +222,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
t.datetime "updated_at", null: false
t.string "currency", default: "USD"
t.string "locale", default: "en"
- t.string "stripe_plan_id"
t.string "stripe_customer_id"
- t.string "stripe_subscription_status", default: "incomplete"
t.string "date_format", default: "%m-%d-%Y"
t.string "country", default: "US"
t.datetime "last_synced_at"
t.string "timezone"
t.boolean "data_enrichment_enabled", default: false
- t.datetime "trial_started_at"
t.boolean "early_access", default: false
end
@@ -575,6 +572,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code"
end
+ create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.string "status", null: false
+ t.string "stripe_id"
+ t.decimal "amount", precision: 19, scale: 4
+ t.string "currency"
+ t.string "interval"
+ t.datetime "current_period_ends_at"
+ t.datetime "trial_ends_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["family_id"], name: "index_subscriptions_on_family_id"
+ end
+
create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "syncable_type", null: false
t.uuid "syncable_id", null: false
@@ -742,6 +753,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
add_foreign_key "security_prices", "securities"
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
add_foreign_key "sessions", "users"
+ add_foreign_key "subscriptions", "families"
add_foreign_key "syncs", "syncs", column: "parent_id"
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families"
diff --git a/test/controllers/concerns/onboardable_test.rb b/test/controllers/concerns/onboardable_test.rb
index c4d313ae..0d04d077 100644
--- a/test/controllers/concerns/onboardable_test.rb
+++ b/test/controllers/concerns/onboardable_test.rb
@@ -3,6 +3,7 @@ require "test_helper"
class OnboardableTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:empty)
+ @user.family.subscription.destroy
end
test "must complete onboarding before any other action" do
@@ -10,32 +11,18 @@ class OnboardableTest < ActionDispatch::IntegrationTest
get root_path
assert_redirected_to onboarding_path
-
- @user.family.update!(trial_started_at: 1.day.ago, stripe_subscription_status: "active")
-
- get root_path
- assert_redirected_to onboarding_path
end
- test "must subscribe if onboarding complete and no trial or subscription is active" do
+ test "must have subscription to visit dashboard" do
@user.update!(onboarded_at: 1.day.ago)
- @user.family.update!(trial_started_at: nil, stripe_subscription_status: "incomplete")
get root_path
- assert_redirected_to upgrade_subscription_path
- end
-
- test "onboarded trial user can visit dashboard" do
- @user.update!(onboarded_at: 1.day.ago)
- @user.family.update!(trial_started_at: 1.day.ago, stripe_subscription_status: "incomplete")
-
- get root_path
- assert_response :success
+ assert_redirected_to trial_onboarding_path
end
test "onboarded subscribed user can visit dashboard" do
@user.update!(onboarded_at: 1.day.ago)
- @user.family.update!(stripe_subscription_status: "active")
+ @user.family.start_trial_subscription!
get root_path
assert_response :success
diff --git a/test/controllers/subscriptions_controller_test.rb b/test/controllers/subscriptions_controller_test.rb
index abd7473f..1e791632 100644
--- a/test/controllers/subscriptions_controller_test.rb
+++ b/test/controllers/subscriptions_controller_test.rb
@@ -1,41 +1,78 @@
require "test_helper"
+require "ostruct"
class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
setup do
- sign_in @user = users(:family_admin)
+ sign_in @user = users(:empty)
+ @family = @user.family
+
+ @mock_stripe = mock
+ Provider::Registry.stubs(:get_provider).with(:stripe).returns(@mock_stripe)
end
- test "can start trial" do
- @user.update!(onboarded_at: nil)
- @user.family.update!(trial_started_at: nil, stripe_subscription_status: "incomplete")
-
- assert_nil @user.onboarded_at
- assert_nil @user.family.trial_started_at
-
- post start_trial_subscription_path
- assert_redirected_to root_path
- assert_equal "Welcome to Maybe!", flash[:notice]
-
- assert @user.reload.onboarded?
- assert @user.family.reload.trial_started_at.present?
- end
-
- test "if user re-enters onboarding, don't restart trial" do
- onboard_time = 1.day.ago
- trial_start_time = 1.day.ago
-
- @user.update!(onboarded_at: onboard_time)
- @user.family.update!(trial_started_at: trial_start_time, stripe_subscription_status: "incomplete")
-
- post start_trial_subscription_path
- assert_redirected_to root_path
-
- assert @user.reload.family.trial_started_at < Date.current
- end
-
- test "redirects to settings if self hosting" do
+ test "disabled for self hosted users" do
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
- get subscription_path
+ post subscription_path
assert_response :forbidden
end
+
+ # Trial subscriptions are managed internally and do NOT go through Stripe
+ test "can create trial subscription" do
+ @family.subscription.destroy
+ @family.reload
+
+ assert_difference "Subscription.count", 1 do
+ post subscription_path
+ end
+
+ assert_redirected_to root_path
+ assert_equal "Welcome to Maybe!", flash[:notice]
+ assert_equal "trialing", @family.subscription.status
+ assert_in_delta Subscription::TRIAL_DAYS.days.from_now, @family.subscription.trial_ends_at, 1.minute
+ end
+
+ test "users who have already trialed cannot create a new subscription" do
+ @family.start_trial_subscription!
+
+ assert_no_difference "Subscription.count" do
+ post subscription_path
+ end
+
+ assert_redirected_to root_path
+ assert_equal "You have already started or completed a trial. Please upgrade to continue.", flash[:alert]
+ end
+
+ test "creates new checkout session" do
+ @mock_stripe.expects(:create_checkout_session).with(
+ plan: "monthly",
+ family_id: @family.id,
+ family_email: @family.billing_email,
+ success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
+ cancel_url: upgrade_subscription_url
+ ).returns(
+ OpenStruct.new(
+ url: "https://checkout.stripe.com/c/pay/test-session-id",
+ customer_id: "test-customer-id"
+ )
+ )
+
+ get new_subscription_path(plan: "monthly")
+
+ assert_redirected_to "https://checkout.stripe.com/c/pay/test-session-id"
+ assert_equal "test-customer-id", @family.reload.stripe_customer_id
+ end
+
+ test "creates active subscription on checkout success" do
+ @mock_stripe.expects(:get_checkout_result).with("test-session-id").returns(
+ OpenStruct.new(
+ success?: true,
+ subscription_id: "test-subscription-id"
+ )
+ )
+
+ get success_subscription_url(session_id: "test-session-id")
+
+ assert @family.subscription.active?
+ assert_equal "Welcome to Maybe! Your subscription has been created.", flash[:notice]
+ end
end
diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml
index 375bb175..9c6790b9 100644
--- a/test/fixtures/families.yml
+++ b/test/fixtures/families.yml
@@ -1,10 +1,8 @@
empty:
name: Family
- stripe_subscription_status: active
last_synced_at: <%= Time.now %>
dylan_family:
name: The Dylan Family
- stripe_subscription_status: active
last_synced_at: <%= Time.now %>
diff --git a/test/fixtures/subscriptions.yml b/test/fixtures/subscriptions.yml
new file mode 100644
index 00000000..333ba7fe
--- /dev/null
+++ b/test/fixtures/subscriptions.yml
@@ -0,0 +1,9 @@
+active:
+ family: dylan_family
+ status: active
+ stripe_id: "test_1234567890"
+
+trialing:
+ family: empty
+ status: trialing
+ trial_ends_at: <%= 12.days.from_now %>
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index e7088ec9..1973ba37 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -5,6 +5,7 @@ empty:
email: user1@email.com
password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
onboarded_at: <%= 3.days.ago %>
+ role: admin
ai_enabled: true
maybe_support_staff:
diff --git a/test/models/family/subscribeable_test.rb b/test/models/family/subscribeable_test.rb
new file mode 100644
index 00000000..597f41e7
--- /dev/null
+++ b/test/models/family/subscribeable_test.rb
@@ -0,0 +1,13 @@
+require "test_helper"
+
+class Family::SubscribeableTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ end
+
+ # We keep the status eventually consistent, but don't rely on it for guarding the app
+ test "trial respects end date even if status is not yet updated" do
+ @family.subscription.update!(trial_ends_at: 1.day.ago, status: "trialing")
+ assert_not @family.trialing?
+ end
+end
diff --git a/test/models/provider/stripe/customer_event_processor_test.rb b/test/models/provider/stripe/customer_event_processor_test.rb
deleted file mode 100644
index c3ab10e9..00000000
--- a/test/models/provider/stripe/customer_event_processor_test.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-require "test_helper"
-
-class Provider::Stripe::CustomerEventProcessorTest < ActiveSupport::TestCase
- # test "process" do
-
- # end
-end
diff --git a/test/models/provider/stripe/subscription_event_processor_test.rb b/test/models/provider/stripe/subscription_event_processor_test.rb
index 19c6b384..e6f608ae 100644
--- a/test/models/provider/stripe/subscription_event_processor_test.rb
+++ b/test/models/provider/stripe/subscription_event_processor_test.rb
@@ -1,6 +1,57 @@
require "test_helper"
+require "ostruct"
class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase
- # test "process" do
- # end
+ test "handles subscription event" do
+ test_customer_id = "test-customer-id"
+ test_subscription_id = "test-subscription-id"
+
+ mock_event = JSON.parse({
+ type: "customer.subscription.created",
+ data: {
+ object: {
+ id: test_subscription_id,
+ status: "active",
+ customer: test_customer_id,
+ 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 Subscribed Family",
+ stripe_customer_id: test_customer_id
+ )
+
+ family.start_subscription!(test_subscription_id)
+
+ processor = Provider::Stripe::SubscriptionEventProcessor.new(mock_event)
+
+ assert_equal "active", family.subscription.status
+ assert_equal test_subscription_id, family.subscription.stripe_id
+ assert_nil family.subscription.amount
+ assert_nil family.subscription.currency
+ assert_nil family.subscription.current_period_ends_at
+
+ processor.process
+
+ family.reload
+
+ assert_equal "active", family.subscription.status
+ assert_equal test_subscription_id, family.subscription.stripe_id
+ assert_equal 9, family.subscription.amount
+ assert_equal "USD", family.subscription.currency
+ assert family.subscription.current_period_ends_at > 20.days.from_now
+ end
end
diff --git a/test/models/provider/stripe_test.rb b/test/models/provider/stripe_test.rb
new file mode 100644
index 00000000..68fb8ad8
--- /dev/null
+++ b/test/models/provider/stripe_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class Provider::StripeTest < ActiveSupport::TestCase
+ setup do
+ @stripe = Provider::Stripe.new(
+ secret_key: ENV["STRIPE_SECRET_KEY"] || "foo",
+ webhook_secret: ENV["STRIPE_WEBHOOK_SECRET"] || "bar"
+ )
+ end
+
+ test "creates checkout session" do
+ test_email = "test@example.com"
+
+ test_success_url = "http://localhost:3000/subscription/success?session_id={CHECKOUT_SESSION_ID}"
+ test_cancel_url = "http://localhost:3000/subscription/upgrade"
+
+ VCR.use_cassette("stripe/create_checkout_session") do
+ session = @stripe.create_checkout_session(
+ plan: "monthly",
+ family_id: 1,
+ family_email: test_email,
+ success_url: test_success_url,
+ cancel_url: test_cancel_url
+ )
+
+ assert_match /https:\/\/checkout.stripe.com\/c\/pay\/cs_test_.*/, session.url
+ assert_match /cus_.*/, session.customer_id
+ end
+ end
+
+ # To re-run VCR for this test:
+ # 1. Complete a checkout session locally in the UI
+ # 2. Find the session ID, replace below
+ # 3. Re-run VCR, make sure ENV vars are in test environment
+ test "validates checkout session and returns subscription ID" do
+ test_session_id = "cs_test_b1RD8r6DAkSA8vrQ3grBC2QVgR5zUJ7QQFuVHZkcKoSYaEOQgCMPMOCOM5" # must exist in test Dashboard
+
+ VCR.use_cassette("stripe/checkout_session") do
+ result = @stripe.get_checkout_result(test_session_id)
+
+ assert result.success?
+ assert_match /sub_.*/, result.subscription_id
+ end
+ end
+end
diff --git a/test/models/subscription_test.rb b/test/models/subscription_test.rb
new file mode 100644
index 00000000..3986335c
--- /dev/null
+++ b/test/models/subscription_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class SubscriptionTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:empty)
+ end
+
+ test "can create subscription without stripe details if trial" do
+ subscription = Subscription.new(
+ family: @family,
+ status: :trialing,
+ )
+
+ assert_not subscription.valid?
+
+ subscription.trial_ends_at = 14.days.from_now
+
+ assert subscription.valid?
+ end
+
+ test "stripe details required for all statuses except trial" do
+ subscription = Subscription.new(
+ family: @family,
+ status: :active,
+ )
+
+ assert_not subscription.valid?
+
+ subscription.stripe_id = "test-stripe-id"
+
+ assert subscription.valid?
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 40bf14c9..23f98faa 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -26,6 +26,8 @@ VCR.configure do |config|
config.filter_sensitive_data("") { ENV["SYNTH_API_KEY"] }
config.filter_sensitive_data("") { ENV["OPENAI_ACCESS_TOKEN"] }
config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] }
+ config.filter_sensitive_data("") { ENV["STRIPE_SECRET_KEY"] }
+ config.filter_sensitive_data("") { ENV["STRIPE_WEBHOOK_SECRET"] }
end
module ActiveSupport
diff --git a/test/vcr_cassettes/stripe/checkout_session.yml b/test/vcr_cassettes/stripe/checkout_session.yml
new file mode 100644
index 00000000..7d032cbd
--- /dev/null
+++ b/test/vcr_cassettes/stripe/checkout_session.yml
@@ -0,0 +1,181 @@
+---
+http_interactions:
+- request:
+ method: get
+ uri: https://api.stripe.com/v1/checkout/sessions/cs_test_b1RD8r6DAkSA8vrQ3grBC2QVgR5zUJ7QQFuVHZkcKoSYaEOQgCMPMOCOM5
+ body:
+ encoding: US-ASCII
+ string: ''
+ headers:
+ User-Agent:
+ - Stripe/v1 RubyBindings/15.1.0
+ Authorization:
+ - Bearer
+ Stripe-Version:
+ - 2025-04-30.basil
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version":"15.1.0","lang":"ruby","lang_version":"3.4.1 p0 (2024-12-25)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin
+ Zachs-MacBook-Pro.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan 2 20:24:16
+ PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64","hostname":"Zachs-MacBook-Pro.local"}'
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 05 May 2025 16:09:23 GMT
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '2667'
+ Connection:
+ - keep-alive
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, HEAD, PUT, PATCH, POST, DELETE
+ Access-Control-Allow-Origin:
+ - "*"
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Content-Security-Policy:
+ - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';
+ img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src
+ 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=7L6NHIm4wk05H5wi0PfH951BH62utb5j2ZImtzEXvcfJgdc1v5juGoNb0oSAXIHhGQtWiGOiCmz3UG1W
+ Request-Id:
+ - req_c2n4M98HkgTk63
+ Stripe-Version:
+ - 2025-04-30.basil
+ Vary:
+ - Origin
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ X-Wc:
+ - ABGHI
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ body:
+ encoding: UTF-8
+ string: |-
+ {
+ "id": "cs_test_b1RD8r6DAkSA8vrQ3grBC2QVgR5zUJ7QQFuVHZkcKoSYaEOQgCMPMOCOM5",
+ "object": "checkout.session",
+ "adaptive_pricing": null,
+ "after_expiration": null,
+ "allow_promotion_codes": true,
+ "amount_subtotal": 900,
+ "amount_total": 900,
+ "automatic_tax": {
+ "enabled": false,
+ "liability": null,
+ "provider": null,
+ "status": null
+ },
+ "billing_address_collection": null,
+ "cancel_url": "http://localhost:3000/subscription/upgrade?plan=monthly",
+ "client_reference_id": null,
+ "client_secret": null,
+ "collected_information": {
+ "shipping_details": null
+ },
+ "consent": null,
+ "consent_collection": null,
+ "created": 1746281950,
+ "currency": "usd",
+ "currency_conversion": null,
+ "custom_fields": [],
+ "custom_text": {
+ "after_submit": null,
+ "shipping_address": null,
+ "submit": null,
+ "terms_of_service_acceptance": null
+ },
+ "customer": "cus_SFBH32Bf5lsggB",
+ "customer_creation": "always",
+ "customer_details": {
+ "address": {
+ "city": null,
+ "country": "US",
+ "line1": null,
+ "line2": null,
+ "postal_code": "12345",
+ "state": null
+ },
+ "email": "user@maybe.local",
+ "name": "Test Checkout User",
+ "phone": null,
+ "tax_exempt": "none",
+ "tax_ids": []
+ },
+ "customer_email": "user@maybe.local",
+ "discounts": [],
+ "expires_at": 1746368350,
+ "invoice": "in_1RKguoQT2jbOS8G0PuBVklxw",
+ "invoice_creation": null,
+ "livemode": false,
+ "locale": null,
+ "metadata": {},
+ "mode": "subscription",
+ "payment_intent": null,
+ "payment_link": null,
+ "payment_method_collection": "always",
+ "payment_method_configuration_details": {
+ "id": "pmc_1RJyv5QT2jbOS8G0PDwTVBar",
+ "parent": null
+ },
+ "payment_method_options": {
+ "card": {
+ "request_three_d_secure": "automatic"
+ }
+ },
+ "payment_method_types": [
+ "card",
+ "link",
+ "cashapp",
+ "amazon_pay"
+ ],
+ "payment_status": "paid",
+ "permissions": null,
+ "phone_number_collection": {
+ "enabled": false
+ },
+ "recovered_from": null,
+ "saved_payment_method_options": {
+ "allow_redisplay_filters": [
+ "always"
+ ],
+ "payment_method_remove": null,
+ "payment_method_save": null
+ },
+ "setup_intent": null,
+ "shipping_address_collection": null,
+ "shipping_cost": null,
+ "shipping_options": [],
+ "status": "complete",
+ "submit_type": null,
+ "subscription": "sub_1RKguoQT2jbOS8G0Zih79ix9",
+ "success_url": "http://localhost:3000/subscription/success?session_id={CHECKOUT_SESSION_ID}",
+ "total_details": {
+ "amount_discount": 0,
+ "amount_shipping": 0,
+ "amount_tax": 0
+ },
+ "ui_mode": "hosted",
+ "url": null,
+ "wallet_options": null
+ }
+ recorded_at: Mon, 05 May 2025 16:09:23 GMT
+recorded_with: VCR 6.3.1
diff --git a/test/vcr_cassettes/stripe/create_checkout_session.yml b/test/vcr_cassettes/stripe/create_checkout_session.yml
new file mode 100644
index 00000000..773ff2d3
--- /dev/null
+++ b/test/vcr_cassettes/stripe/create_checkout_session.yml
@@ -0,0 +1,298 @@
+---
+http_interactions:
+- request:
+ method: post
+ uri: https://api.stripe.com/v1/customers
+ body:
+ encoding: UTF-8
+ string: email=test%40example.com&metadata[family_id]=1
+ headers:
+ User-Agent:
+ - Stripe/v1 RubyBindings/15.1.0
+ Authorization:
+ - Bearer
+ Idempotency-Key:
+ - 7e129de1-324e-456a-8bd7-44382f6b4fa7
+ Stripe-Version:
+ - 2025-04-30.basil
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version":"15.1.0","lang":"ruby","lang_version":"3.4.1 p0 (2024-12-25)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin
+ Zachs-MacBook-Pro.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan 2 20:24:16
+ PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64","hostname":"Zachs-MacBook-Pro.local"}'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 05 May 2025 16:09:23 GMT
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '652'
+ Connection:
+ - keep-alive
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, HEAD, PUT, PATCH, POST, DELETE
+ Access-Control-Allow-Origin:
+ - "*"
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Content-Security-Policy:
+ - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';
+ img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src
+ 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=o27rhODPrC4hNe4D-N6JT0tcY2dQoSKNy5hNsSJNdH_SL3lHJ8eS2959Oc-3UnIGMeyex9H3Qo_613Zp
+ Idempotency-Key:
+ - 7e129de1-324e-456a-8bd7-44382f6b4fa7
+ Original-Request:
+ - req_LIU5SlUlOEkoOg
+ Request-Id:
+ - req_LIU5SlUlOEkoOg
+ Stripe-Should-Retry:
+ - 'false'
+ Stripe-Version:
+ - 2025-04-30.basil
+ Vary:
+ - Origin
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ X-Wc:
+ - ABGHI
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ body:
+ encoding: UTF-8
+ string: |-
+ {
+ "id": "cus_SFxVsSZ9enVBNC",
+ "object": "customer",
+ "address": null,
+ "balance": 0,
+ "created": 1746461363,
+ "currency": null,
+ "default_source": null,
+ "delinquent": false,
+ "description": null,
+ "discount": null,
+ "email": "test@example.com",
+ "invoice_prefix": "JPHCOASK",
+ "invoice_settings": {
+ "custom_fields": null,
+ "default_payment_method": null,
+ "footer": null,
+ "rendering_options": null
+ },
+ "livemode": false,
+ "metadata": {
+ "family_id": "1"
+ },
+ "name": null,
+ "next_invoice_sequence": 1,
+ "phone": null,
+ "preferred_locales": [],
+ "shipping": null,
+ "tax_exempt": "none",
+ "test_clock": null
+ }
+ recorded_at: Mon, 05 May 2025 16:09:23 GMT
+- request:
+ method: post
+ uri: https://api.stripe.com/v1/checkout/sessions
+ body:
+ encoding: UTF-8
+ string: customer=cus_SFxVsSZ9enVBNC&line_items[0][price]=price_1RJz6KQT2jbOS8G0Otv3qD01&line_items[0][quantity]=1&mode=subscription&allow_promotion_codes=true&success_url=http%3A%2F%2Flocalhost%3A3000%2Fsubscription%2Fsuccess%3Fsession_id%3D%7BCHECKOUT_SESSION_ID%7D&cancel_url=http%3A%2F%2Flocalhost%3A3000%2Fsubscription%2Fupgrade
+ headers:
+ User-Agent:
+ - Stripe/v1 RubyBindings/15.1.0
+ Authorization:
+ - Bearer
+ X-Stripe-Client-Telemetry:
+ - '{"last_request_metrics":{"request_id":"req_LIU5SlUlOEkoOg","request_duration_ms":307}}'
+ Idempotency-Key:
+ - d62353a3-199a-468d-9f78-a4fb10fa28bd
+ Stripe-Version:
+ - 2025-04-30.basil
+ X-Stripe-Client-User-Agent:
+ - '{"bindings_version":"15.1.0","lang":"ruby","lang_version":"3.4.1 p0 (2024-12-25)","platform":"arm64-darwin24","engine":"ruby","publisher":"stripe","uname":"Darwin
+ Zachs-MacBook-Pro.local 24.3.0 Darwin Kernel Version 24.3.0: Thu Jan 2 20:24:16
+ PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6000 arm64","hostname":"Zachs-MacBook-Pro.local"}'
+ Content-Type:
+ - application/x-www-form-urlencoded
+ Accept-Encoding:
+ - gzip;q=1.0,deflate;q=0.6,identity;q=0.3
+ Accept:
+ - "*/*"
+ response:
+ status:
+ code: 200
+ message: OK
+ headers:
+ Server:
+ - nginx
+ Date:
+ - Mon, 05 May 2025 16:09:24 GMT
+ Content-Type:
+ - application/json
+ Content-Length:
+ - '2850'
+ Connection:
+ - keep-alive
+ Access-Control-Allow-Credentials:
+ - 'true'
+ Access-Control-Allow-Methods:
+ - GET, HEAD, PUT, PATCH, POST, DELETE
+ Access-Control-Allow-Origin:
+ - "*"
+ Access-Control-Expose-Headers:
+ - Request-Id, Stripe-Manage-Version, Stripe-Should-Retry, X-Stripe-External-Auth-Required,
+ X-Stripe-Privileged-Session-Required
+ Access-Control-Max-Age:
+ - '300'
+ Cache-Control:
+ - no-cache, no-store
+ Content-Security-Policy:
+ - base-uri 'none'; default-src 'none'; form-action 'none'; frame-ancestors 'none';
+ img-src 'self'; script-src 'self' 'report-sample'; style-src 'self'; worker-src
+ 'none'; upgrade-insecure-requests; report-uri https://q.stripe.com/csp-violation?q=7L6NHIm4wk05H5wi0PfH951BH62utb5j2ZImtzEXvcfJgdc1v5juGoNb0oSAXIHhGQtWiGOiCmz3UG1W
+ Idempotency-Key:
+ - d62353a3-199a-468d-9f78-a4fb10fa28bd
+ Original-Request:
+ - req_8AIKuqTzBWcO76
+ Request-Id:
+ - req_8AIKuqTzBWcO76
+ Stripe-Should-Retry:
+ - 'false'
+ Stripe-Version:
+ - 2025-04-30.basil
+ Vary:
+ - Origin
+ X-Stripe-Priority-Routing-Enabled:
+ - 'true'
+ X-Stripe-Routing-Context-Priority-Tier:
+ - api-testmode
+ X-Wc:
+ - ABGHI
+ Strict-Transport-Security:
+ - max-age=63072000; includeSubDomains; preload
+ body:
+ encoding: UTF-8
+ string: |-
+ {
+ "id": "cs_test_b1lPPmtTEFw5w9GpyzQYlxz2TAN4JeUTosGjyXzfjkx9ocP59UhcF0WgWf",
+ "object": "checkout.session",
+ "adaptive_pricing": null,
+ "after_expiration": null,
+ "allow_promotion_codes": true,
+ "amount_subtotal": 900,
+ "amount_total": 900,
+ "automatic_tax": {
+ "enabled": false,
+ "liability": null,
+ "provider": null,
+ "status": null
+ },
+ "billing_address_collection": null,
+ "cancel_url": "http://localhost:3000/subscription/upgrade",
+ "client_reference_id": null,
+ "client_secret": null,
+ "collected_information": {
+ "shipping_details": null
+ },
+ "consent": null,
+ "consent_collection": null,
+ "created": 1746461364,
+ "currency": "usd",
+ "currency_conversion": null,
+ "custom_fields": [],
+ "custom_text": {
+ "after_submit": null,
+ "shipping_address": null,
+ "submit": null,
+ "terms_of_service_acceptance": null
+ },
+ "customer": "cus_SFxVsSZ9enVBNC",
+ "customer_creation": null,
+ "customer_details": {
+ "address": null,
+ "email": "test@example.com",
+ "name": null,
+ "phone": null,
+ "tax_exempt": "none",
+ "tax_ids": null
+ },
+ "customer_email": null,
+ "discounts": [],
+ "expires_at": 1746547763,
+ "invoice": null,
+ "invoice_creation": null,
+ "livemode": false,
+ "locale": null,
+ "metadata": {},
+ "mode": "subscription",
+ "payment_intent": null,
+ "payment_link": null,
+ "payment_method_collection": "always",
+ "payment_method_configuration_details": {
+ "id": "pmc_1RJyv5QT2jbOS8G0PDwTVBar",
+ "parent": null
+ },
+ "payment_method_options": {
+ "card": {
+ "request_three_d_secure": "automatic"
+ }
+ },
+ "payment_method_types": [
+ "card",
+ "link",
+ "cashapp",
+ "amazon_pay"
+ ],
+ "payment_status": "unpaid",
+ "permissions": null,
+ "phone_number_collection": {
+ "enabled": false
+ },
+ "recovered_from": null,
+ "saved_payment_method_options": {
+ "allow_redisplay_filters": [
+ "always"
+ ],
+ "payment_method_remove": null,
+ "payment_method_save": null
+ },
+ "setup_intent": null,
+ "shipping_address_collection": null,
+ "shipping_cost": null,
+ "shipping_options": [],
+ "status": "open",
+ "submit_type": null,
+ "subscription": null,
+ "success_url": "http://localhost:3000/subscription/success?session_id={CHECKOUT_SESSION_ID}",
+ "total_details": {
+ "amount_discount": 0,
+ "amount_shipping": 0,
+ "amount_tax": 0
+ },
+ "ui_mode": "hosted",
+ "url": "https://checkout.stripe.com/c/pay/cs_test_b1lPPmtTEFw5w9GpyzQYlxz2TAN4JeUTosGjyXzfjkx9ocP59UhcF0WgWf#fid2cGd2ZndsdXFsamtQa2x0cGBrYHZ2QGtkZ2lgYSc%2FY2RpdmApJ2R1bE5gfCc%2FJ3VuWnFgdnFaMDRXT3xwYVRRN29nSlY9QjUzNUttakJibmcxXH11Z2M8R3UzYUh3dGtNNlA8M3ExMTRKd0Z3M2p%2FdXdkcTNHcmg8NWA1YUx%2FbTRdTEM1cXI8TXNIT0FTRkY1NU5RME9EcjBpJyknY3dqaFZgd3Ngdyc%2FcXdwYCknaWR8anBxUXx1YCc%2FJ2hwaXFsWmxxYGgnKSdga2RnaWBVaWRmYG1qaWFgd3YnP3F3cGB4JSUl",
+ "wallet_options": null
+ }
+ recorded_at: Mon, 05 May 2025 16:09:24 GMT
+recorded_with: VCR 6.3.1