mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 05:25:24 +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>
|
||||
|
||||
|
|
|
@ -26,13 +26,13 @@
|
|||
size: "sm",
|
||||
as_button: true,
|
||||
data: { action: "rule--actions#remove", rule__actions_destroy_param: action.persisted? }) %>
|
||||
|
||||
|
||||
<%# Templates for different input types - these will be cloned and used by the Stimulus controller %>
|
||||
<template data-rule--actions-target="selectTemplate">
|
||||
<span class="font-medium uppercase text-xs">to</span>
|
||||
<%= form.select :value, [], {} %>
|
||||
</template>
|
||||
|
||||
|
||||
<template data-rule--actions-target="textTemplate">
|
||||
<span class="font-medium uppercase text-xs">to</span>
|
||||
<%= form.text_field :value, placeholder: "Enter a value" %>
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue