1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-22 06:39:39 +02:00

Subscription tests and domain (#2209)

* Save work

* Subscriptions and trials domain

* Store family ID on customer

* Remove indirection of stripe calls

* Test simplifications

* Update brakeman

* Fix stripe tests in CI

* Update billing page to show subscription details

* Remove legacy columns

* Complete billing settings page

* Fix hardcoded plan name

* Handle subscriptions for self hosting mode

* Lint fixes
This commit is contained in:
Zach Gollwitzer 2025-05-06 14:05:21 -04:00 committed by GitHub
parent 8c10e87387
commit 5da4bb6dc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1041 additions and 233 deletions

View file

@ -7,7 +7,6 @@ class AccountableSparklinesController < ApplicationController
.where(accountable_type: @accountable.name) .where(accountable_type: @accountable.name)
.balance_series( .balance_series(
currency: family.currency, currency: family.currency,
timezone: family.timezone,
favorable_direction: @accountable.favorable_direction favorable_direction: @accountable.favorable_direction
) )
end end

View file

@ -3,24 +3,19 @@ module Onboardable
included do included do
before_action :require_onboarding_and_upgrade before_action :require_onboarding_and_upgrade
helper_method :subscription_pending?
end end
private 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. # First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
def require_onboarding_and_upgrade def require_onboarding_and_upgrade
return unless Current.user return unless Current.user
return unless redirectable_path?(request.path) return unless redirectable_path?(request.path)
if !Current.user.onboarded? if Current.user.needs_onboarding?
redirect_to onboarding_path 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 redirect_to upgrade_subscription_path
end end
end end

View file

@ -2,6 +2,6 @@ class Settings::BillingsController < ApplicationController
layout "settings" layout "settings"
def show def show
@user = Current.user @family = Current.family
end end
end end

View file

@ -4,49 +4,40 @@ class SubscriptionsController < ApplicationController
# Upgrade page for unsubscribed users # Upgrade page for unsubscribed users
def upgrade def upgrade
if Current.family.subscription&.active?
redirect_to root_path, notice: "You are already subscribed."
else
@plan = params[:plan] || "annual"
render layout: "onboardings" render layout: "onboardings"
end 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
end
redirect_to root_path, notice: "Welcome to Maybe!"
end end
def new def new
price_map = { checkout_session = stripe.create_checkout_session(
monthly: ENV["STRIPE_MONTHLY_PRICE_ID"], plan: params[:plan],
annual: ENV["STRIPE_ANNUAL_PRICE_ID"] family_id: Current.family.id,
} family_email: Current.family.billing_email,
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
price_id = price_map[(params[:plan] || :monthly).to_sym] cancel_url: upgrade_subscription_url
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) Current.family.update!(stripe_customer_id: checkout_session.customer_id)
redirect_to checkout_session.url, allow_other_host: true, status: :see_other
end end
checkout_session_url = stripe.get_checkout_session_url( # Only used for managing our "offline" trials. Paid subscriptions are handled in success callback of checkout session
price_id: price_id, def create
customer_id: Current.family.stripe_customer_id, if Current.family.can_start_trial?
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", Current.family.start_trial_subscription!
cancel_url: upgrade_subscription_url(plan: params[:plan]) 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."
redirect_to checkout_session_url, allow_other_host: true, status: :see_other end
end end
def show 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, customer_id: Current.family.stripe_customer_id,
return_url: settings_billing_url return_url: settings_billing_url
) )
@ -54,12 +45,16 @@ class SubscriptionsController < ApplicationController
redirect_to portal_session_url, allow_other_host: true, status: :see_other redirect_to portal_session_url, allow_other_host: true, status: :see_other
end end
# Stripe redirects here after a successful checkout session and passes the session ID in the URL
def success def success
checkout_session = stripe.retrieve_checkout_session(params[:session_id]) checkout_result = stripe.get_checkout_result(params[:session_id])
Current.session.update(subscribed_at: Time.at(checkout_session.created))
redirect_to root_path, notice: "You have successfully subscribed to Maybe+." if checkout_result.success?
rescue Stripe::InvalidRequestError Current.family.start_subscription!(checkout_result.subscription_id)
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed." 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 end
private private

View file

@ -44,9 +44,11 @@ class WebhooksController < ApplicationController
head :ok head :ok
rescue JSON::ParserError => error rescue JSON::ParserError => error
Sentry.capture_exception(error) Sentry.capture_exception(error)
Rails.logger.error "JSON parser error: #{error.message}"
head :bad_request head :bad_request
rescue Stripe::SignatureVerificationError => error rescue Stripe::SignatureVerificationError => error
Sentry.capture_exception(error) Sentry.capture_exception(error)
Rails.logger.error "Stripe signature verification error: #{error.message}"
head :bad_request head :bad_request
end end
end end

View file

@ -2,7 +2,7 @@ module Account::Chartable
extend ActiveSupport::Concern extend ActiveSupport::Concern
class_methods do 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) raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
series_interval = interval || period.interval series_interval = interval || period.interval
@ -132,8 +132,7 @@ module Account::Chartable
period: period, period: period,
view: view, view: view,
interval: interval, interval: interval,
favorable_direction: favorable_direction, favorable_direction: favorable_direction
timezone: family.timezone
) )
end end

View file

@ -33,7 +33,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
def historical_balances(account) def historical_balances(account)
start_date = [ account.start_date, 5.years.ago.to_date ].max start_date = [ account.start_date, 5.years.ago.to_date ].max
period = Period.custom(start_date: start_date, end_date: Date.current) 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) to_ai_time_series(balance_series)
end end

View file

@ -54,8 +54,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
currency: family.currency, currency: family.currency,
period: period, period: period,
interval: "1 month", interval: "1 month",
favorable_direction: "up", favorable_direction: "up"
timezone: family.timezone
) )
to_ai_time_series(balance_series) to_ai_time_series(balance_series)

View file

@ -69,7 +69,7 @@ class BalanceSheet
end end
def net_worth_series(period: Period.last_30_days) 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 end
def currency def currency

View file

@ -160,13 +160,14 @@ class Demo::Generator
id: id, id: id,
name: family_name, name: family_name,
currency: currency, currency: currency,
stripe_subscription_status: require_onboarding ? nil : "active",
locale: "en", locale: "en",
country: "US", country: "US",
timezone: "America/New_York", timezone: "America/New_York",
date_format: "%m-%d-%Y" date_format: "%m-%d-%Y"
) )
family.start_subscription!("sub_1234567890")
family.users.create! \ family.users.create! \
email: user_email, email: user_email,
first_name: "Demo", first_name: "Demo",

View file

@ -1,5 +1,5 @@
class Family < ApplicationRecord class Family < ApplicationRecord
include Syncable, AutoTransferMatchable include Syncable, AutoTransferMatchable, Subscribeable
DATE_FORMATS = [ DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ], [ "MM-DD-YYYY", "%m-%d-%Y" ],
@ -68,6 +68,9 @@ class Family < ApplicationRecord
def sync_data(sync, start_date: nil) def sync_data(sync, start_date: nil)
update!(last_synced_at: Time.current) 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}") Rails.logger.info("Syncing accounts for family #{id}")
accounts.manual.each do |account| accounts.manual.each do |account|
account.sync_later(start_date: start_date, parent_sync: sync) account.sync_later(start_date: start_date, parent_sync: sync)
@ -127,22 +130,6 @@ class Family < ApplicationRecord
).link_token ).link_token
end 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? def requires_data_provider?
# If family has any trades, they need a provider for historical prices # If family has any trades, they need a provider for historical prices
return true if trades.any? return true if trades.any?
@ -162,18 +149,10 @@ class Family < ApplicationRecord
requires_data_provider? && Provider::Registry.get_provider(:synth).nil? requires_data_provider? && Provider::Registry.get_provider(:synth).nil?
end end
def primary_user
users.order(:created_at).first
end
def oldest_entry_date def oldest_entry_date
entries.order(:date).first&.date || Date.current entries.order(:date).first&.date || Date.current
end 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) # 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) def build_cache_key(key)
[ [

View file

@ -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

View file

@ -1,9 +1,8 @@
class Provider::Stripe class Provider::Stripe
Error = Class.new(StandardError)
def initialize(secret_key:, webhook_secret:) def initialize(secret_key:, webhook_secret:)
@client = Stripe::StripeClient.new( @client = Stripe::StripeClient.new(secret_key)
secret_key,
stripe_version: "2025-04-30.basil"
)
@webhook_secret = webhook_secret @webhook_secret = webhook_secret
end end
@ -12,56 +11,77 @@ class Provider::Stripe
case event.type case event.type
when /^customer\.subscription\./ when /^customer\.subscription\./
SubscriptionEventProcessor.new(event: event, client: client).process SubscriptionEventProcessor.new(event).process
when /^customer\./
CustomerEventProcessor.new(event: event, client: client).process
else else
Rails.logger.info "Unhandled event type: #{event.type}" Rails.logger.warn "Unhandled event type: #{event.type}"
end end
end end
def process_webhook_later(webhook_body, sig_header) def process_webhook_later(webhook_body, sig_header)
thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret) 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) StripeEventHandlerJob.perform_later(thin_event.id)
else
Rails.logger.info "Unhandled event type: #{thin_event.type}"
end
end end
def create_customer(email:, metadata: {}) def create_checkout_session(plan:, family_id:, family_email:, success_url:, cancel_url:)
client.v1.customers.create( customer = client.v1.customers.create(
email: email, email: family_email,
metadata: metadata metadata: {
family_id: family_id
}
) )
end
def get_checkout_session_url(price_id:, customer_id: nil, success_url: nil, cancel_url: nil) session = client.v1.checkout.sessions.create(
client.v1.checkout.sessions.create( customer: customer.id,
customer: customer_id, line_items: [ { price: price_id_for(plan), quantity: 1 } ],
line_items: [ { price: price_id, quantity: 1 } ],
mode: "subscription", mode: "subscription",
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: success_url, success_url: success_url,
cancel_url: cancel_url cancel_url: cancel_url
).url )
NewCheckoutSession.new(url: session.url, customer_id: customer.id)
end 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( client.v1.billing_portal.sessions.create(
customer: customer_id, customer: customer_id,
return_url: return_url return_url: return_url
).url ).url
end end
def retrieve_checkout_session(session_id) def update_customer_metadata(customer_id:, metadata:)
client.v1.checkout.sessions.retrieve(session_id) client.v1.customers.update(customer_id, metadata: metadata)
end end
private private
attr_reader :client, :webhook_secret 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) def retrieve_event(event_id)
client.v1.events.retrieve(event_id) client.v1.events.retrieve(event_id)
end end

View file

@ -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

View file

@ -1,7 +1,6 @@
class Provider::Stripe::EventProcessor class Provider::Stripe::EventProcessor
def initialize(event:, client:) def initialize(event)
@event = event @event = event
@client = client
end end
def process def process
@ -9,7 +8,7 @@ class Provider::Stripe::EventProcessor
end end
private private
attr_reader :event, :client attr_reader :event
def event_data def event_data
event.data.object event.data.object

View file

@ -2,28 +2,28 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc
Error = Class.new(StandardError) Error = Class.new(StandardError)
def process 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( family.subscription.update(
stripe_plan_id: plan_id, stripe_id: subscription.id,
stripe_subscription_status: subscription_status 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 end
private private
def family def family
Family.find_by(stripe_customer_id: customer_id) Family.find_by(stripe_customer_id: subscription.customer)
end end
def customer_id def subscription_details
event_data.customer event_data.items.data.first
end end
def plan_id def subscription
event_data.plan.id event_data
end
def subscription_status
event_data.status
end end
end end

View file

@ -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

View file

@ -156,6 +156,10 @@ class User < ApplicationRecord
onboarded_at.present? onboarded_at.present?
end end
def needs_onboarding?
!onboarded?
end
private private
def ensure_valid_profile_image def ensure_valid_profile_image
return unless profile_image.attached? return unless profile_image.attached?

View file

@ -89,7 +89,7 @@
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<p class="text-sm font-medium text-primary">Free trial</p> <p class="text-sm font-medium text-primary">Free trial</p>
<p class="text-sm text-secondary"><%= Current.family.trial_remaining_days %> days remaining</p> <p class="text-sm text-secondary"><%= Current.family.days_left_in_trial %> days remaining</p>
</div> </div>
<%= render LinkComponent.new( <%= render LinkComponent.new(
@ -99,8 +99,8 @@
</div> </div>
<div class="flex items-center gap-0.5 h-1.5"> <div class="flex items-center gap-0.5 h-1.5">
<div class="h-full bg-warning rounded-full" style="width: <%= 100 - Current.family.trial_remaining_days / 14.0 * 100 %>%"></div> <div class="h-full bg-warning rounded-full" style="width: <%= Current.family.percentage_of_trial_completed %>%"></div>
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.trial_remaining_days / 14.0 * 100 %>%"></div> <div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.percentage_of_trial_remaining %>%"></div>
</div> </div>
</div> </div>
<% end %> <% end %>

View file

@ -22,10 +22,7 @@
<%= form_with model: @user do |form| %> <%= form_with model: @user do |form| %>
<%= form.hidden_field :redirect_to, value: self_hosted? ? "home" : "trial" %> <%= form.hidden_field :redirect_to, value: self_hosted? ? "home" : "trial" %>
<%= form.hidden_field :set_onboarding_goals_at, value: Time.current %> <%= form.hidden_field :set_onboarding_goals_at, value: Time.current %>
<% if self_hosted? %>
<%= form.hidden_field :onboarded_at, value: Time.current %> <%= form.hidden_field :onboarded_at, value: Time.current %>
<% end %>
<div class="space-y-3"> <div class="space-y-3">
<% [ <% [

View file

@ -29,12 +29,26 @@
</p> </p>
<div class="w-full"> <div class="w-full">
<% if Current.family.can_start_trial? %>
<%= render ButtonComponent.new( <%= render ButtonComponent.new(
text: "Try Maybe for 14 days", text: "Try Maybe for 14 days",
href: start_trial_subscription_path, href: subscription_path,
full_width: true, full_width: true,
data: { turbo: false } data: { turbo: false }
) %> ) %>
<% elsif Current.family.trialing? %>
<%= render LinkComponent.new(
text: "Continue trial",
href: root_path,
full_width: true,
) %>
<% else %>
<%= render LinkComponent.new(
text: "Upgrade",
href: upgrade_subscription_path,
full_width: true,
) %>
<% end %>
</div> </div>
</div> </div>

View file

@ -11,19 +11,21 @@
) %> ) %>
<div class="text-sm space-y-1"> <div class="text-sm space-y-1">
<% if subscription_pending? %> <% if @family.has_active_subscription? %>
<p class="text-primary"> <p class="text-primary">
Your subscription is pending. You can still use Maybe+ while we process your subscription. <span>You are currently subscribed to the <span class="font-medium"><%= @family.subscription.name %></span>.</span>
<% if @family.next_billing_date %>
<span>Your plan renews on <span class="font-medium"><%= @family.next_billing_date.strftime("%B %d, %Y") %></span>.</span>
<% end %>
</p> </p>
<% elsif @user.family.trialing? %> <% elsif @family.trialing? %>
<p class="text-primary"> <p class="text-primary">
You are currently trialing <span class="font-medium">Maybe+</span> You are currently trialing Maybe
<span class="text-secondary"> <span class="text-secondary">
(<%= @user.family.trial_remaining_days %> days remaining) (<%= @family.days_left_in_trial %> days remaining)
</span> </span>
</p> </p>
<% elsif @user.family.subscribed? %>
<p class="text-primary">You are currently subscribed to <span class="font-medium">Maybe+</span></p>
<% else %> <% else %>
<p class="text-primary">You are currently <span class="font-medium">not subscribed</span></p> <p class="text-primary">You are currently <span class="font-medium">not subscribed</span></p>
<p class="text-secondary">Once you subscribe to Maybe+, you'll see your billing settings here.</p> <p class="text-secondary">Once you subscribe to Maybe+, you'll see your billing settings here.</p>
@ -31,7 +33,7 @@
</div> </div>
</div> </div>
<% if @user.family.subscribed? %> <% if @family.has_active_subscription? %>
<%= render LinkComponent.new( <%= render LinkComponent.new(
text: "Manage", text: "Manage",
icon: "external-link", icon: "external-link",
@ -40,7 +42,7 @@
href: subscription_path, href: subscription_path,
rel: "noopener" rel: "noopener"
) %> ) %>
<% elsif @user.family.trialing? && !subscription_pending? %> <% else %>
<%= render LinkComponent.new( <%= render LinkComponent.new(
text: "Choose plan", text: "Choose plan",
variant: "primary", variant: "primary",

View file

@ -18,7 +18,7 @@
<%= image_tag "logo-color.png", class: "w-16 mb-6" %> <%= image_tag "logo-color.png", class: "w-16 mb-6" %>
<% if Current.family.trialing? %> <% if Current.family.trialing? %>
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial has <%= Current.family.trial_remaining_days %> days remaining</p> <p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial has <%= Current.family.days_left_in_trial %> days remaining</p>
<% else %> <% else %>
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial is over</p> <p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial is over</p>
<% end %> <% end %>
@ -33,8 +33,8 @@
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %> <%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6">
<%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: params[:plan] == "annual" || params[:plan].blank? %> <%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: @plan == "annual" %>
<%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: params[:plan] == "monthly" %> <%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: @plan == "monthly" %>
</div> </div>
<div class="text-center space-y-2"> <div class="text-center space-y-2">

View file

@ -54,11 +54,10 @@ Rails.application.routes.draw do
resource :security, only: :show resource :security, only: :show
end end
resource :subscription, only: %i[new show] do resource :subscription, only: %i[new show create] do
collection do collection do
get :upgrade get :upgrade
get :success get :success
post :start_trial
end end
end end

View file

@ -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

20
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" 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.datetime "updated_at", null: false
t.string "currency", default: "USD" t.string "currency", default: "USD"
t.string "locale", default: "en" t.string "locale", default: "en"
t.string "stripe_plan_id"
t.string "stripe_customer_id" t.string "stripe_customer_id"
t.string "stripe_subscription_status", default: "incomplete"
t.string "date_format", default: "%m-%d-%Y" t.string "date_format", default: "%m-%d-%Y"
t.string "country", default: "US" t.string "country", default: "US"
t.datetime "last_synced_at" t.datetime "last_synced_at"
t.string "timezone" t.string "timezone"
t.boolean "data_enrichment_enabled", default: false t.boolean "data_enrichment_enabled", default: false
t.datetime "trial_started_at"
t.boolean "early_access", default: false t.boolean "early_access", default: false
end 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" t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code"
end 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| create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "syncable_type", null: false t.string "syncable_type", null: false
t.uuid "syncable_id", 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 "security_prices", "securities"
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id" add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
add_foreign_key "sessions", "users" add_foreign_key "sessions", "users"
add_foreign_key "subscriptions", "families"
add_foreign_key "syncs", "syncs", column: "parent_id" add_foreign_key "syncs", "syncs", column: "parent_id"
add_foreign_key "taggings", "tags" add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families" add_foreign_key "tags", "families"

View file

@ -3,6 +3,7 @@ require "test_helper"
class OnboardableTest < ActionDispatch::IntegrationTest class OnboardableTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in @user = users(:empty) sign_in @user = users(:empty)
@user.family.subscription.destroy
end end
test "must complete onboarding before any other action" do test "must complete onboarding before any other action" do
@ -10,32 +11,18 @@ class OnboardableTest < ActionDispatch::IntegrationTest
get root_path get root_path
assert_redirected_to onboarding_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 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.update!(onboarded_at: 1.day.ago)
@user.family.update!(trial_started_at: nil, stripe_subscription_status: "incomplete")
get root_path get root_path
assert_redirected_to upgrade_subscription_path assert_redirected_to trial_onboarding_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
end end
test "onboarded subscribed user can visit dashboard" do test "onboarded subscribed user can visit dashboard" do
@user.update!(onboarded_at: 1.day.ago) @user.update!(onboarded_at: 1.day.ago)
@user.family.update!(stripe_subscription_status: "active") @user.family.start_trial_subscription!
get root_path get root_path
assert_response :success assert_response :success

View file

@ -1,41 +1,78 @@
require "test_helper" require "test_helper"
require "ostruct"
class SubscriptionsControllerTest < ActionDispatch::IntegrationTest class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
setup do 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 end
test "can start trial" do test "disabled for self hosted users" 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
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
get subscription_path post subscription_path
assert_response :forbidden assert_response :forbidden
end 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 end

View file

@ -1,10 +1,8 @@
empty: empty:
name: Family name: Family
stripe_subscription_status: active
last_synced_at: <%= Time.now %> last_synced_at: <%= Time.now %>
dylan_family: dylan_family:
name: The Dylan Family name: The Dylan Family
stripe_subscription_status: active
last_synced_at: <%= Time.now %> last_synced_at: <%= Time.now %>

9
test/fixtures/subscriptions.yml vendored Normal file
View file

@ -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 %>

View file

@ -5,6 +5,7 @@ empty:
email: user1@email.com email: user1@email.com
password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
onboarded_at: <%= 3.days.ago %> onboarded_at: <%= 3.days.ago %>
role: admin
ai_enabled: true ai_enabled: true
maybe_support_staff: maybe_support_staff:

View file

@ -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

View file

@ -1,7 +0,0 @@
require "test_helper"
class Provider::Stripe::CustomerEventProcessorTest < ActiveSupport::TestCase
# test "process" do
# end
end

View file

@ -1,6 +1,57 @@
require "test_helper" require "test_helper"
require "ostruct"
class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase
# test "process" do test "handles subscription event" do
# end 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 end

View file

@ -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

View file

@ -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

View file

@ -26,6 +26,8 @@ VCR.configure do |config|
config.filter_sensitive_data("<SYNTH_API_KEY>") { ENV["SYNTH_API_KEY"] } config.filter_sensitive_data("<SYNTH_API_KEY>") { ENV["SYNTH_API_KEY"] }
config.filter_sensitive_data("<OPENAI_ACCESS_TOKEN>") { ENV["OPENAI_ACCESS_TOKEN"] } config.filter_sensitive_data("<OPENAI_ACCESS_TOKEN>") { ENV["OPENAI_ACCESS_TOKEN"] }
config.filter_sensitive_data("<OPENAI_ORGANIZATION_ID>") { ENV["OPENAI_ORGANIZATION_ID"] } config.filter_sensitive_data("<OPENAI_ORGANIZATION_ID>") { ENV["OPENAI_ORGANIZATION_ID"] }
config.filter_sensitive_data("<STRIPE_SECRET_KEY>") { ENV["STRIPE_SECRET_KEY"] }
config.filter_sensitive_data("<STRIPE_WEBHOOK_SECRET>") { ENV["STRIPE_WEBHOOK_SECRET"] }
end end
module ActiveSupport module ActiveSupport

View file

@ -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_SECRET_KEY>
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

View file

@ -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 <STRIPE_SECRET_KEY>
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 <STRIPE_SECRET_KEY>
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