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:
parent
8c10e87387
commit
5da4bb6dc3
40 changed files with 1041 additions and 233 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
[
|
[
|
||||||
|
|
81
app/models/family/subscribeable.rb
Normal file
81
app/models/family/subscribeable.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
37
app/models/subscription.rb
Normal file
37
app/models/subscription.rb
Normal 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
|
|
@ -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?
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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">
|
||||||
<% [
|
<% [
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
46
db/migrate/20250502164951_create_subscriptions.rb
Normal file
46
db/migrate/20250502164951_create_subscriptions.rb
Normal 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
20
db/schema.rb
generated
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
2
test/fixtures/families.yml
vendored
2
test/fixtures/families.yml
vendored
|
@ -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
9
test/fixtures/subscriptions.yml
vendored
Normal 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 %>
|
1
test/fixtures/users.yml
vendored
1
test/fixtures/users.yml
vendored
|
@ -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:
|
||||||
|
|
13
test/models/family/subscribeable_test.rb
Normal file
13
test/models/family/subscribeable_test.rb
Normal 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
|
|
@ -1,7 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class Provider::Stripe::CustomerEventProcessorTest < ActiveSupport::TestCase
|
|
||||||
# test "process" do
|
|
||||||
|
|
||||||
# end
|
|
||||||
end
|
|
|
@ -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
|
||||||
|
|
45
test/models/provider/stripe_test.rb
Normal file
45
test/models/provider/stripe_test.rb
Normal 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
|
33
test/models/subscription_test.rb
Normal file
33
test/models/subscription_test.rb
Normal 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
|
|
@ -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
|
||||||
|
|
181
test/vcr_cassettes/stripe/checkout_session.yml
Normal file
181
test/vcr_cassettes/stripe/checkout_session.yml
Normal 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
|
298
test/vcr_cassettes/stripe/create_checkout_session.yml
Normal file
298
test/vcr_cassettes/stripe/create_checkout_session.yml
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue