mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19: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)
|
||||
.balance_series(
|
||||
currency: family.currency,
|
||||
timezone: family.timezone,
|
||||
favorable_direction: @accountable.favorable_direction
|
||||
)
|
||||
end
|
||||
|
|
|
@ -3,24 +3,19 @@ module Onboardable
|
|||
|
||||
included do
|
||||
before_action :require_onboarding_and_upgrade
|
||||
helper_method :subscription_pending?
|
||||
end
|
||||
|
||||
private
|
||||
# A subscription goes into "pending" mode immediately after checkout, but before webhooks are processed.
|
||||
def subscription_pending?
|
||||
subscribed_at = Current.session.subscribed_at
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
# First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
|
||||
def require_onboarding_and_upgrade
|
||||
return unless Current.user
|
||||
return unless redirectable_path?(request.path)
|
||||
|
||||
if !Current.user.onboarded?
|
||||
if Current.user.needs_onboarding?
|
||||
redirect_to onboarding_path
|
||||
elsif !Current.family.subscribed? && !Current.family.trialing? && !self_hosted?
|
||||
elsif Current.family.needs_subscription?
|
||||
redirect_to trial_onboarding_path
|
||||
elsif Current.family.upgrade_required?
|
||||
redirect_to upgrade_subscription_path
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,6 +2,6 @@ class Settings::BillingsController < ApplicationController
|
|||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
@family = Current.family
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,49 +4,40 @@ class SubscriptionsController < ApplicationController
|
|||
|
||||
# Upgrade page for unsubscribed users
|
||||
def upgrade
|
||||
render layout: "onboardings"
|
||||
end
|
||||
|
||||
def start_trial
|
||||
unless Current.family.trialing?
|
||||
ActiveRecord::Base.transaction do
|
||||
Current.user.update!(onboarded_at: Time.current)
|
||||
Current.family.update!(trial_started_at: Time.current)
|
||||
end
|
||||
if Current.family.subscription&.active?
|
||||
redirect_to root_path, notice: "You are already subscribed."
|
||||
else
|
||||
@plan = params[:plan] || "annual"
|
||||
render layout: "onboardings"
|
||||
end
|
||||
|
||||
redirect_to root_path, notice: "Welcome to Maybe!"
|
||||
end
|
||||
|
||||
def new
|
||||
price_map = {
|
||||
monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
|
||||
annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
|
||||
}
|
||||
|
||||
price_id = price_map[(params[:plan] || :monthly).to_sym]
|
||||
|
||||
unless Current.family.existing_customer?
|
||||
customer = stripe.create_customer(
|
||||
email: Current.family.primary_user.email,
|
||||
metadata: { family_id: Current.family.id }
|
||||
)
|
||||
|
||||
Current.family.update(stripe_customer_id: customer.id)
|
||||
end
|
||||
|
||||
checkout_session_url = stripe.get_checkout_session_url(
|
||||
price_id: price_id,
|
||||
customer_id: Current.family.stripe_customer_id,
|
||||
checkout_session = stripe.create_checkout_session(
|
||||
plan: params[:plan],
|
||||
family_id: Current.family.id,
|
||||
family_email: Current.family.billing_email,
|
||||
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: upgrade_subscription_url(plan: params[:plan])
|
||||
cancel_url: upgrade_subscription_url
|
||||
)
|
||||
|
||||
redirect_to checkout_session_url, allow_other_host: true, status: :see_other
|
||||
Current.family.update!(stripe_customer_id: checkout_session.customer_id)
|
||||
|
||||
redirect_to checkout_session.url, allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
# Only used for managing our "offline" trials. Paid subscriptions are handled in success callback of checkout session
|
||||
def create
|
||||
if Current.family.can_start_trial?
|
||||
Current.family.start_trial_subscription!
|
||||
redirect_to root_path, notice: "Welcome to Maybe!"
|
||||
else
|
||||
redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue."
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
portal_session_url = stripe.get_billing_portal_session_url(
|
||||
portal_session_url = stripe.create_billing_portal_session_url(
|
||||
customer_id: Current.family.stripe_customer_id,
|
||||
return_url: settings_billing_url
|
||||
)
|
||||
|
@ -54,12 +45,16 @@ class SubscriptionsController < ApplicationController
|
|||
redirect_to portal_session_url, allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
# Stripe redirects here after a successful checkout session and passes the session ID in the URL
|
||||
def success
|
||||
checkout_session = stripe.retrieve_checkout_session(params[:session_id])
|
||||
Current.session.update(subscribed_at: Time.at(checkout_session.created))
|
||||
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
|
||||
rescue Stripe::InvalidRequestError
|
||||
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
|
||||
checkout_result = stripe.get_checkout_result(params[:session_id])
|
||||
|
||||
if checkout_result.success?
|
||||
Current.family.start_subscription!(checkout_result.subscription_id)
|
||||
redirect_to root_path, notice: "Welcome to Maybe! Your subscription has been created."
|
||||
else
|
||||
redirect_to root_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -44,9 +44,11 @@ class WebhooksController < ApplicationController
|
|||
head :ok
|
||||
rescue JSON::ParserError => error
|
||||
Sentry.capture_exception(error)
|
||||
Rails.logger.error "JSON parser error: #{error.message}"
|
||||
head :bad_request
|
||||
rescue Stripe::SignatureVerificationError => error
|
||||
Sentry.capture_exception(error)
|
||||
Rails.logger.error "Stripe signature verification error: #{error.message}"
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,7 +2,7 @@ module Account::Chartable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil, timezone: nil)
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
|
||||
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
|
||||
|
||||
series_interval = interval || period.interval
|
||||
|
@ -132,8 +132,7 @@ module Account::Chartable
|
|||
period: period,
|
||||
view: view,
|
||||
interval: interval,
|
||||
favorable_direction: favorable_direction,
|
||||
timezone: family.timezone
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
|
|||
def historical_balances(account)
|
||||
start_date = [ account.start_date, 5.years.ago.to_date ].max
|
||||
period = Period.custom(start_date: start_date, end_date: Date.current)
|
||||
balance_series = account.balance_series(period: period, interval: "1 month", timezone: family.timezone)
|
||||
balance_series = account.balance_series(period: period, interval: "1 month")
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
|
|
|
@ -54,8 +54,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
|
|||
currency: family.currency,
|
||||
period: period,
|
||||
interval: "1 month",
|
||||
favorable_direction: "up",
|
||||
timezone: family.timezone
|
||||
favorable_direction: "up"
|
||||
)
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
|
|
|
@ -69,7 +69,7 @@ class BalanceSheet
|
|||
end
|
||||
|
||||
def net_worth_series(period: Period.last_30_days)
|
||||
active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up", timezone: family.timezone)
|
||||
active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up")
|
||||
end
|
||||
|
||||
def currency
|
||||
|
|
|
@ -160,13 +160,14 @@ class Demo::Generator
|
|||
id: id,
|
||||
name: family_name,
|
||||
currency: currency,
|
||||
stripe_subscription_status: require_onboarding ? nil : "active",
|
||||
locale: "en",
|
||||
country: "US",
|
||||
timezone: "America/New_York",
|
||||
date_format: "%m-%d-%Y"
|
||||
)
|
||||
|
||||
family.start_subscription!("sub_1234567890")
|
||||
|
||||
family.users.create! \
|
||||
email: user_email,
|
||||
first_name: "Demo",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Family < ApplicationRecord
|
||||
include Syncable, AutoTransferMatchable
|
||||
include Syncable, AutoTransferMatchable, Subscribeable
|
||||
|
||||
DATE_FORMATS = [
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
|
@ -68,6 +68,9 @@ class Family < ApplicationRecord
|
|||
def sync_data(sync, start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
# We don't rely on this value to guard the app, but keep it eventually consistent
|
||||
sync_trial_status!
|
||||
|
||||
Rails.logger.info("Syncing accounts for family #{id}")
|
||||
accounts.manual.each do |account|
|
||||
account.sync_later(start_date: start_date, parent_sync: sync)
|
||||
|
@ -127,22 +130,6 @@ class Family < ApplicationRecord
|
|||
).link_token
|
||||
end
|
||||
|
||||
def subscribed?
|
||||
stripe_subscription_status == "active"
|
||||
end
|
||||
|
||||
def trialing?
|
||||
!subscribed? && trial_started_at.present? && trial_started_at <= 14.days.from_now
|
||||
end
|
||||
|
||||
def trial_remaining_days
|
||||
(14 - (Time.current - trial_started_at).to_i / 86400).to_i
|
||||
end
|
||||
|
||||
def existing_customer?
|
||||
stripe_customer_id.present?
|
||||
end
|
||||
|
||||
def requires_data_provider?
|
||||
# If family has any trades, they need a provider for historical prices
|
||||
return true if trades.any?
|
||||
|
@ -162,18 +149,10 @@ class Family < ApplicationRecord
|
|||
requires_data_provider? && Provider::Registry.get_provider(:synth).nil?
|
||||
end
|
||||
|
||||
def primary_user
|
||||
users.order(:created_at).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
entries.order(:date).first&.date || Date.current
|
||||
end
|
||||
|
||||
def active_accounts_count
|
||||
accounts.active.count
|
||||
end
|
||||
|
||||
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
|
||||
def build_cache_key(key)
|
||||
[
|
||||
|
|
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
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
def initialize(secret_key:, webhook_secret:)
|
||||
@client = Stripe::StripeClient.new(
|
||||
secret_key,
|
||||
stripe_version: "2025-04-30.basil"
|
||||
)
|
||||
@client = Stripe::StripeClient.new(secret_key)
|
||||
@webhook_secret = webhook_secret
|
||||
end
|
||||
|
||||
|
@ -12,56 +11,77 @@ class Provider::Stripe
|
|||
|
||||
case event.type
|
||||
when /^customer\.subscription\./
|
||||
SubscriptionEventProcessor.new(event: event, client: client).process
|
||||
when /^customer\./
|
||||
CustomerEventProcessor.new(event: event, client: client).process
|
||||
SubscriptionEventProcessor.new(event).process
|
||||
else
|
||||
Rails.logger.info "Unhandled event type: #{event.type}"
|
||||
Rails.logger.warn "Unhandled event type: #{event.type}"
|
||||
end
|
||||
end
|
||||
|
||||
def process_webhook_later(webhook_body, sig_header)
|
||||
thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)
|
||||
|
||||
if thin_event.type.start_with?("customer.")
|
||||
StripeEventHandlerJob.perform_later(thin_event.id)
|
||||
else
|
||||
Rails.logger.info "Unhandled event type: #{thin_event.type}"
|
||||
end
|
||||
StripeEventHandlerJob.perform_later(thin_event.id)
|
||||
end
|
||||
|
||||
def create_customer(email:, metadata: {})
|
||||
client.v1.customers.create(
|
||||
email: email,
|
||||
metadata: metadata
|
||||
def create_checkout_session(plan:, family_id:, family_email:, success_url:, cancel_url:)
|
||||
customer = client.v1.customers.create(
|
||||
email: family_email,
|
||||
metadata: {
|
||||
family_id: family_id
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def get_checkout_session_url(price_id:, customer_id: nil, success_url: nil, cancel_url: nil)
|
||||
client.v1.checkout.sessions.create(
|
||||
customer: customer_id,
|
||||
line_items: [ { price: price_id, quantity: 1 } ],
|
||||
session = client.v1.checkout.sessions.create(
|
||||
customer: customer.id,
|
||||
line_items: [ { price: price_id_for(plan), quantity: 1 } ],
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: success_url,
|
||||
cancel_url: cancel_url
|
||||
).url
|
||||
)
|
||||
|
||||
NewCheckoutSession.new(url: session.url, customer_id: customer.id)
|
||||
end
|
||||
|
||||
def get_billing_portal_session_url(customer_id:, return_url: nil)
|
||||
def get_checkout_result(session_id)
|
||||
session = client.v1.checkout.sessions.retrieve(session_id)
|
||||
|
||||
unless session.status == "complete" && session.payment_status == "paid"
|
||||
raise Error, "Checkout session not complete"
|
||||
end
|
||||
|
||||
CheckoutSessionResult.new(success?: true, subscription_id: session.subscription)
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
Rails.logger.error "Error fetching checkout result for session #{session_id}: #{e.message}"
|
||||
CheckoutSessionResult.new(success?: false, subscription_id: nil)
|
||||
end
|
||||
|
||||
def create_billing_portal_session_url(customer_id:, return_url:)
|
||||
client.v1.billing_portal.sessions.create(
|
||||
customer: customer_id,
|
||||
return_url: return_url
|
||||
).url
|
||||
end
|
||||
|
||||
def retrieve_checkout_session(session_id)
|
||||
client.v1.checkout.sessions.retrieve(session_id)
|
||||
def update_customer_metadata(customer_id:, metadata:)
|
||||
client.v1.customers.update(customer_id, metadata: metadata)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :client, :webhook_secret
|
||||
|
||||
NewCheckoutSession = Data.define(:url, :customer_id)
|
||||
CheckoutSessionResult = Data.define(:success?, :subscription_id)
|
||||
|
||||
def price_id_for(plan)
|
||||
prices = {
|
||||
monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
|
||||
annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
|
||||
}
|
||||
|
||||
prices[plan.to_sym || :monthly]
|
||||
end
|
||||
|
||||
def retrieve_event(event_id)
|
||||
client.v1.events.retrieve(event_id)
|
||||
end
|
||||
|
|
|
@ -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
|
||||
def initialize(event:, client:)
|
||||
def initialize(event)
|
||||
@event = event
|
||||
@client = client
|
||||
end
|
||||
|
||||
def process
|
||||
|
@ -9,7 +8,7 @@ class Provider::Stripe::EventProcessor
|
|||
end
|
||||
|
||||
private
|
||||
attr_reader :event, :client
|
||||
attr_reader :event
|
||||
|
||||
def event_data
|
||||
event.data.object
|
||||
|
|
|
@ -2,28 +2,28 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc
|
|||
Error = Class.new(StandardError)
|
||||
|
||||
def process
|
||||
raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
|
||||
raise Error, "Family not found for Stripe customer ID: #{subscription.customer}" unless family
|
||||
|
||||
family.update(
|
||||
stripe_plan_id: plan_id,
|
||||
stripe_subscription_status: subscription_status
|
||||
family.subscription.update(
|
||||
stripe_id: subscription.id,
|
||||
status: subscription.status,
|
||||
interval: subscription_details.plan.interval,
|
||||
amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars
|
||||
currency: subscription_details.plan.currency.upcase,
|
||||
current_period_ends_at: Time.at(subscription_details.current_period_end)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Family.find_by(stripe_customer_id: customer_id)
|
||||
Family.find_by(stripe_customer_id: subscription.customer)
|
||||
end
|
||||
|
||||
def customer_id
|
||||
event_data.customer
|
||||
def subscription_details
|
||||
event_data.items.data.first
|
||||
end
|
||||
|
||||
def plan_id
|
||||
event_data.plan.id
|
||||
end
|
||||
|
||||
def subscription_status
|
||||
event_data.status
|
||||
def subscription
|
||||
event_data
|
||||
end
|
||||
end
|
||||
|
|
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?
|
||||
end
|
||||
|
||||
def needs_onboarding?
|
||||
!onboarded?
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_valid_profile_image
|
||||
return unless profile_image.attached?
|
||||
|
|
|
@ -89,7 +89,7 @@
|
|||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<%= render LinkComponent.new(
|
||||
|
@ -99,8 +99,8 @@
|
|||
</div>
|
||||
|
||||
<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-surface-inset rounded-full" style="width: <%= 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.percentage_of_trial_remaining %>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -22,10 +22,7 @@
|
|||
<%= form_with model: @user do |form| %>
|
||||
<%= form.hidden_field :redirect_to, value: self_hosted? ? "home" : "trial" %>
|
||||
<%= form.hidden_field :set_onboarding_goals_at, value: Time.current %>
|
||||
|
||||
<% if self_hosted? %>
|
||||
<%= form.hidden_field :onboarded_at, value: Time.current %>
|
||||
<% end %>
|
||||
<%= form.hidden_field :onboarded_at, value: Time.current %>
|
||||
|
||||
<div class="space-y-3">
|
||||
<% [
|
||||
|
|
|
@ -29,12 +29,26 @@
|
|||
</p>
|
||||
|
||||
<div class="w-full">
|
||||
<%= render ButtonComponent.new(
|
||||
<% if Current.family.can_start_trial? %>
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Try Maybe for 14 days",
|
||||
href: start_trial_subscription_path,
|
||||
href: subscription_path,
|
||||
full_width: true,
|
||||
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>
|
||||
|
||||
|
|
|
@ -11,19 +11,21 @@
|
|||
) %>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
<% if subscription_pending? %>
|
||||
<% if @family.has_active_subscription? %>
|
||||
<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>
|
||||
<% elsif @user.family.trialing? %>
|
||||
<% elsif @family.trialing? %>
|
||||
<p class="text-primary">
|
||||
You are currently trialing <span class="font-medium">Maybe+</span>
|
||||
You are currently trialing Maybe
|
||||
<span class="text-secondary">
|
||||
(<%= @user.family.trial_remaining_days %> days remaining)
|
||||
(<%= @family.days_left_in_trial %> days remaining)
|
||||
</span>
|
||||
</p>
|
||||
<% elsif @user.family.subscribed? %>
|
||||
<p class="text-primary">You are currently subscribed to <span class="font-medium">Maybe+</span></p>
|
||||
<% else %>
|
||||
<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>
|
||||
|
@ -31,7 +33,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<% if @user.family.subscribed? %>
|
||||
<% if @family.has_active_subscription? %>
|
||||
<%= render LinkComponent.new(
|
||||
text: "Manage",
|
||||
icon: "external-link",
|
||||
|
@ -40,7 +42,7 @@
|
|||
href: subscription_path,
|
||||
rel: "noopener"
|
||||
) %>
|
||||
<% elsif @user.family.trialing? && !subscription_pending? %>
|
||||
<% else %>
|
||||
<%= render LinkComponent.new(
|
||||
text: "Choose plan",
|
||||
variant: "primary",
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
|
||||
|
||||
<% 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 %>
|
||||
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial is over</p>
|
||||
<% end %>
|
||||
|
@ -33,8 +33,8 @@
|
|||
|
||||
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
|
||||
<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: "monthly", checked: params[:plan] == "monthly" %>
|
||||
<%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: @plan == "annual" %>
|
||||
<%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: @plan == "monthly" %>
|
||||
</div>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
|
|
|
@ -54,11 +54,10 @@ Rails.application.routes.draw do
|
|||
resource :security, only: :show
|
||||
end
|
||||
|
||||
resource :subscription, only: %i[new show] do
|
||||
resource :subscription, only: %i[new show create] do
|
||||
collection do
|
||||
get :upgrade
|
||||
get :success
|
||||
post :start_trial
|
||||
end
|
||||
end
|
||||
|
||||
|
|
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.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -222,15 +222,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.string "currency", default: "USD"
|
||||
t.string "locale", default: "en"
|
||||
t.string "stripe_plan_id"
|
||||
t.string "stripe_customer_id"
|
||||
t.string "stripe_subscription_status", default: "incomplete"
|
||||
t.string "date_format", default: "%m-%d-%Y"
|
||||
t.string "country", default: "US"
|
||||
t.datetime "last_synced_at"
|
||||
t.string "timezone"
|
||||
t.boolean "data_enrichment_enabled", default: false
|
||||
t.datetime "trial_started_at"
|
||||
t.boolean "early_access", default: false
|
||||
end
|
||||
|
||||
|
@ -575,6 +572,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
|
|||
t.index ["currency_code"], name: "index_stock_exchanges_on_currency_code"
|
||||
end
|
||||
|
||||
create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "status", null: false
|
||||
t.string "stripe_id"
|
||||
t.decimal "amount", precision: 19, scale: 4
|
||||
t.string "currency"
|
||||
t.string "interval"
|
||||
t.datetime "current_period_ends_at"
|
||||
t.datetime "trial_ends_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_subscriptions_on_family_id"
|
||||
end
|
||||
|
||||
create_table "syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "syncable_type", null: false
|
||||
t.uuid "syncable_id", null: false
|
||||
|
@ -742,6 +753,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_01_172430) do
|
|||
add_foreign_key "security_prices", "securities"
|
||||
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
||||
add_foreign_key "sessions", "users"
|
||||
add_foreign_key "subscriptions", "families"
|
||||
add_foreign_key "syncs", "syncs", column: "parent_id"
|
||||
add_foreign_key "taggings", "tags"
|
||||
add_foreign_key "tags", "families"
|
||||
|
|
|
@ -3,6 +3,7 @@ require "test_helper"
|
|||
class OnboardableTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:empty)
|
||||
@user.family.subscription.destroy
|
||||
end
|
||||
|
||||
test "must complete onboarding before any other action" do
|
||||
|
@ -10,32 +11,18 @@ class OnboardableTest < ActionDispatch::IntegrationTest
|
|||
|
||||
get root_path
|
||||
assert_redirected_to onboarding_path
|
||||
|
||||
@user.family.update!(trial_started_at: 1.day.ago, stripe_subscription_status: "active")
|
||||
|
||||
get root_path
|
||||
assert_redirected_to onboarding_path
|
||||
end
|
||||
|
||||
test "must subscribe if onboarding complete and no trial or subscription is active" do
|
||||
test "must have subscription to visit dashboard" do
|
||||
@user.update!(onboarded_at: 1.day.ago)
|
||||
@user.family.update!(trial_started_at: nil, stripe_subscription_status: "incomplete")
|
||||
|
||||
get root_path
|
||||
assert_redirected_to upgrade_subscription_path
|
||||
end
|
||||
|
||||
test "onboarded trial user can visit dashboard" do
|
||||
@user.update!(onboarded_at: 1.day.ago)
|
||||
@user.family.update!(trial_started_at: 1.day.ago, stripe_subscription_status: "incomplete")
|
||||
|
||||
get root_path
|
||||
assert_response :success
|
||||
assert_redirected_to trial_onboarding_path
|
||||
end
|
||||
|
||||
test "onboarded subscribed user can visit dashboard" do
|
||||
@user.update!(onboarded_at: 1.day.ago)
|
||||
@user.family.update!(stripe_subscription_status: "active")
|
||||
@user.family.start_trial_subscription!
|
||||
|
||||
get root_path
|
||||
assert_response :success
|
||||
|
|
|
@ -1,41 +1,78 @@
|
|||
require "test_helper"
|
||||
require "ostruct"
|
||||
|
||||
class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
sign_in @user = users(:empty)
|
||||
@family = @user.family
|
||||
|
||||
@mock_stripe = mock
|
||||
Provider::Registry.stubs(:get_provider).with(:stripe).returns(@mock_stripe)
|
||||
end
|
||||
|
||||
test "can start trial" do
|
||||
@user.update!(onboarded_at: nil)
|
||||
@user.family.update!(trial_started_at: nil, stripe_subscription_status: "incomplete")
|
||||
|
||||
assert_nil @user.onboarded_at
|
||||
assert_nil @user.family.trial_started_at
|
||||
|
||||
post start_trial_subscription_path
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Welcome to Maybe!", flash[:notice]
|
||||
|
||||
assert @user.reload.onboarded?
|
||||
assert @user.family.reload.trial_started_at.present?
|
||||
end
|
||||
|
||||
test "if user re-enters onboarding, don't restart trial" do
|
||||
onboard_time = 1.day.ago
|
||||
trial_start_time = 1.day.ago
|
||||
|
||||
@user.update!(onboarded_at: onboard_time)
|
||||
@user.family.update!(trial_started_at: trial_start_time, stripe_subscription_status: "incomplete")
|
||||
|
||||
post start_trial_subscription_path
|
||||
assert_redirected_to root_path
|
||||
|
||||
assert @user.reload.family.trial_started_at < Date.current
|
||||
end
|
||||
|
||||
test "redirects to settings if self hosting" do
|
||||
test "disabled for self hosted users" do
|
||||
Rails.application.config.app_mode.stubs(:self_hosted?).returns(true)
|
||||
get subscription_path
|
||||
post subscription_path
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
# Trial subscriptions are managed internally and do NOT go through Stripe
|
||||
test "can create trial subscription" do
|
||||
@family.subscription.destroy
|
||||
@family.reload
|
||||
|
||||
assert_difference "Subscription.count", 1 do
|
||||
post subscription_path
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Welcome to Maybe!", flash[:notice]
|
||||
assert_equal "trialing", @family.subscription.status
|
||||
assert_in_delta Subscription::TRIAL_DAYS.days.from_now, @family.subscription.trial_ends_at, 1.minute
|
||||
end
|
||||
|
||||
test "users who have already trialed cannot create a new subscription" do
|
||||
@family.start_trial_subscription!
|
||||
|
||||
assert_no_difference "Subscription.count" do
|
||||
post subscription_path
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal "You have already started or completed a trial. Please upgrade to continue.", flash[:alert]
|
||||
end
|
||||
|
||||
test "creates new checkout session" do
|
||||
@mock_stripe.expects(:create_checkout_session).with(
|
||||
plan: "monthly",
|
||||
family_id: @family.id,
|
||||
family_email: @family.billing_email,
|
||||
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: upgrade_subscription_url
|
||||
).returns(
|
||||
OpenStruct.new(
|
||||
url: "https://checkout.stripe.com/c/pay/test-session-id",
|
||||
customer_id: "test-customer-id"
|
||||
)
|
||||
)
|
||||
|
||||
get new_subscription_path(plan: "monthly")
|
||||
|
||||
assert_redirected_to "https://checkout.stripe.com/c/pay/test-session-id"
|
||||
assert_equal "test-customer-id", @family.reload.stripe_customer_id
|
||||
end
|
||||
|
||||
test "creates active subscription on checkout success" do
|
||||
@mock_stripe.expects(:get_checkout_result).with("test-session-id").returns(
|
||||
OpenStruct.new(
|
||||
success?: true,
|
||||
subscription_id: "test-subscription-id"
|
||||
)
|
||||
)
|
||||
|
||||
get success_subscription_url(session_id: "test-session-id")
|
||||
|
||||
assert @family.subscription.active?
|
||||
assert_equal "Welcome to Maybe! Your subscription has been created.", flash[:notice]
|
||||
end
|
||||
end
|
||||
|
|
2
test/fixtures/families.yml
vendored
2
test/fixtures/families.yml
vendored
|
@ -1,10 +1,8 @@
|
|||
empty:
|
||||
name: Family
|
||||
stripe_subscription_status: active
|
||||
last_synced_at: <%= Time.now %>
|
||||
|
||||
dylan_family:
|
||||
name: The Dylan Family
|
||||
stripe_subscription_status: active
|
||||
last_synced_at: <%= Time.now %>
|
||||
|
||||
|
|
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
|
||||
password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla
|
||||
onboarded_at: <%= 3.days.ago %>
|
||||
role: admin
|
||||
ai_enabled: true
|
||||
|
||||
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 "ostruct"
|
||||
|
||||
class Provider::Stripe::SubscriptionEventProcessorTest < ActiveSupport::TestCase
|
||||
# test "process" do
|
||||
# end
|
||||
test "handles subscription event" do
|
||||
test_customer_id = "test-customer-id"
|
||||
test_subscription_id = "test-subscription-id"
|
||||
|
||||
mock_event = JSON.parse({
|
||||
type: "customer.subscription.created",
|
||||
data: {
|
||||
object: {
|
||||
id: test_subscription_id,
|
||||
status: "active",
|
||||
customer: test_customer_id,
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
current_period_end: 1.month.from_now.to_i,
|
||||
plan: {
|
||||
interval: "month",
|
||||
amount: 900,
|
||||
currency: "usd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}.to_json, object_class: OpenStruct)
|
||||
|
||||
family = Family.create!(
|
||||
name: "Test Subscribed Family",
|
||||
stripe_customer_id: test_customer_id
|
||||
)
|
||||
|
||||
family.start_subscription!(test_subscription_id)
|
||||
|
||||
processor = Provider::Stripe::SubscriptionEventProcessor.new(mock_event)
|
||||
|
||||
assert_equal "active", family.subscription.status
|
||||
assert_equal test_subscription_id, family.subscription.stripe_id
|
||||
assert_nil family.subscription.amount
|
||||
assert_nil family.subscription.currency
|
||||
assert_nil family.subscription.current_period_ends_at
|
||||
|
||||
processor.process
|
||||
|
||||
family.reload
|
||||
|
||||
assert_equal "active", family.subscription.status
|
||||
assert_equal test_subscription_id, family.subscription.stripe_id
|
||||
assert_equal 9, family.subscription.amount
|
||||
assert_equal "USD", family.subscription.currency
|
||||
assert family.subscription.current_period_ends_at > 20.days.from_now
|
||||
end
|
||||
end
|
||||
|
|
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("<OPENAI_ACCESS_TOKEN>") { ENV["OPENAI_ACCESS_TOKEN"] }
|
||||
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
|
||||
|
||||
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