diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 744e38da3..0fb3db660 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) +STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index d94718f6c..672359a13 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) +STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 4dad7c099..7121f5e3a 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -21,3 +21,6 @@ SENTRY_DSN=$(kamal secrets extract SENTRY_DSN $SECRETS) ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) +STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) +STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css new file mode 100644 index 000000000..318c749e0 --- /dev/null +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -0,0 +1,64 @@ +/* Subscriptions +/* ------------------------------------------------------------------------ */ +:root { + --settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter))); + --settings-subscription-color: oklch(var(--lch-violet-medium)); + --settings-subscription-text-color: oklch(var(--lch-violet-dark)); + + .settings-subscription__button { + --btn-background: var(--settings-subscription-color); + --btn-border-color: var(--color-canvas); + --btn-color: var(--color-canvas); + --focus-ring-color: var(--color-ink); + } + + .settings-subscription__divider { + --divider-color: currentColor; + + color: var(--settings-subscription-color); + margin-block-start: calc(var(--block-space-half) * -1); + } + + .settings-subscription__footer { + color: var(--settings-subscription-text-color); + + &::before { + content: "————"; + display: block; + margin: auto; + text-align: center; + } + } + + .settings-subscription__notch { + animation: wiggle 500ms ease; + background: var(--settings-subscription-background); + box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); + border-radius: 3em; + color: var(--settings-subscription-text-color); + margin-inline: auto; + padding: 0 0.3em 0 1.2em; + transform: rotate(-1deg); + + @media (max-width: 640px) { + padding-block: 0.6em; + padding-inline-start: 0.3em; + } + + .btn { + margin-block: 0.3em; + } + } + + .settings-subscription__panel { + background: var(--settings-subscription-background); + } + + .settings_subscription__warning { + color: var(--settings-subscription-text-color); + + a { + color: var(--settings-subscription-text-color); + } + } +} diff --git a/saas/app/assets/stylesheets/fizzy/saas/application.css b/saas/app/assets/stylesheets/fizzy/saas/application.css deleted file mode 100644 index 0ebd7fe82..000000000 --- a/saas/app/assets/stylesheets/fizzy/saas/application.css +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css, which will include all the files - * listed below. - * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, - * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. - * - * You're free to add application-wide styles to this file and they'll appear at the bottom of the - * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS - * files in this directory. Styles in this file should be added after the last require_* statement. - * It is generally better to create a new file per style scope. - * - *= require_tree . - *= require_self - */ diff --git a/saas/app/controllers/account/billing_portals_controller.rb b/saas/app/controllers/account/billing_portals_controller.rb new file mode 100644 index 000000000..67eaffe6c --- /dev/null +++ b/saas/app/controllers/account/billing_portals_controller.rb @@ -0,0 +1,19 @@ +class Account::BillingPortalsController < ApplicationController + before_action :ensure_admin + before_action :ensure_subscribed_account + + def show + redirect_to create_stripe_billing_portal_session.url, allow_other_host: true + end + + private + def ensure_subscribed_account + unless Current.account.subscribed? + redirect_to account_subscription_path, alert: "No billing information found" + end + end + + def create_stripe_billing_portal_session + Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url) + end +end diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb new file mode 100644 index 000000000..5a7f27fcd --- /dev/null +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -0,0 +1,38 @@ +class Account::SubscriptionsController < ApplicationController + before_action :ensure_admin + before_action :set_stripe_session, only: :show + + def show + end + + def create + session = Stripe::Checkout::Session.create \ + customer: find_or_create_stripe_customer, + mode: "subscription", + line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ], + success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", + cancel_url: account_subscription_url, + metadata: { account_id: Current.account.id, plan_key: Plan.paid.key } + + redirect_to session.url, allow_other_host: true + end + + private + def set_stripe_session + @session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] + end + + def find_or_create_stripe_customer + find_stripe_customer || create_stripe_customer + end + + def find_stripe_customer + Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id + end + + def create_stripe_customer + Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer| + Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete") + end + end +end diff --git a/saas/app/controllers/admin/account_searches_controller.rb b/saas/app/controllers/admin/account_searches_controller.rb new file mode 100644 index 000000000..db655a9ef --- /dev/null +++ b/saas/app/controllers/admin/account_searches_controller.rb @@ -0,0 +1,5 @@ +class Admin::AccountSearchesController < AdminController + def create + redirect_to saas.edit_admin_account_path(params[:q]) + end +end diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb new file mode 100644 index 000000000..dda0794e4 --- /dev/null +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -0,0 +1,25 @@ +class Admin::AccountsController < AdminController + layout "public" + + before_action :set_account, only: %i[ edit update ] + + def index + end + + def edit + end + + def update + @account.update!(account_params) + redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated" + end + + private + def set_account + @account = Account.find_by!(external_account_id: params[:id]) + end + + def account_params + params.expect(account: [ :cards_count ]) + end +end diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb new file mode 100644 index 000000000..6d508fc2c --- /dev/null +++ b/saas/app/controllers/admin/stats_controller.rb @@ -0,0 +1,20 @@ +class Admin::StatsController < AdminController + layout "public" + + def show + @accounts_total = Account.count + @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count + @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count + + @identities_total = Identity.count + @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count + @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count + + @top_accounts = Account + .where("cards_count > 0") + .order(cards_count: :desc) + .limit(20) + + @recent_accounts = Account.order(created_at: :desc).limit(10) + end +end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 000000000..c76c4929b --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,12 @@ +module Card::Limited + extend ActiveSupport::Concern + + included do + before_action :ensure_can_create_cards, only: %i[ create ] + end + + private + def ensure_can_create_cards + head :forbidden if Current.account.exceeding_card_limit? + end +end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb new file mode 100644 index 000000000..31839e089 --- /dev/null +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -0,0 +1,83 @@ +class Stripe::WebhooksController < ApplicationController + allow_unauthenticated_access + skip_before_action :require_account + skip_before_action :verify_authenticity_token + + def create + event = verify_webhook_signature + return head :bad_request unless event + + dispatch_stripe_event(event) + + head :ok + end + + private + def dispatch_stripe_event(event) + case event.type + when "checkout.session.completed" + handle_checkout_completed(event.data.object) + when "customer.subscription.updated" + handle_subscription_updated(event.data.object) + when "customer.subscription.deleted" + handle_subscription_deleted(event.data.object) + when "invoice.payment_failed" + handle_payment_failed(event.data.object) + end + end + + def verify_webhook_signature + payload = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] + + Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]) + rescue Stripe::SignatureVerificationError => e + Rails.logger.error "Stripe webhook signature verification failed: #{e.message}" + nil + end + + def handle_checkout_completed(session) + return unless session.mode == "subscription" + + subscription = find_subscription_by_customer(session.customer) + return unless subscription + + stripe_subscription = Stripe::Subscription.retrieve(session.subscription) + + subscription.update! \ + stripe_subscription_id: stripe_subscription.id, + plan_key: session.metadata["plan_key"], + status: stripe_subscription.status, + current_period_end: extract_current_period_end(stripe_subscription) + end + + def handle_subscription_updated(stripe_subscription) + if subscription = find_subscription_by_customer(stripe_subscription.customer) + subscription.update! \ + status: stripe_subscription.status, + current_period_end: extract_current_period_end(stripe_subscription), + cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil + end + end + + def handle_subscription_deleted(stripe_subscription) + if subscription = find_subscription_by_customer(stripe_subscription.customer) + subscription.update!(status: "canceled", stripe_subscription_id: nil) + end + end + + def handle_payment_failed(invoice) + if subscription = find_subscription_by_customer(invoice.customer) + subscription.update!(status: "past_due") + end + end + + def find_subscription_by_customer(customer_id) + Account::Subscription.find_by(stripe_customer_id: customer_id) + end + + def extract_current_period_end(stripe_subscription) + timestamp = stripe_subscription.items.data.first&.current_period_end + Time.at(timestamp) if timestamp + end +end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb new file mode 100644 index 000000000..32dd45402 --- /dev/null +++ b/saas/app/helpers/subscriptions_helper.rb @@ -0,0 +1,11 @@ +module SubscriptionsHelper + def subscription_period_end_action(subscription) + if subscription.to_be_canceled? + "Your Fizzy subscription ends on" + elsif subscription.canceled? + "Your Fizzy subscription ended on" + else + "Your next payment of $#{ subscription.plan.price } will be billed on".html_safe + end + end +end diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb new file mode 100644 index 000000000..69c033817 --- /dev/null +++ b/saas/app/models/account/billing.rb @@ -0,0 +1,29 @@ +module Account::Billing + extend ActiveSupport::Concern + + included do + has_one :subscription, class_name: "Account::Subscription", dependent: :destroy + end + + NEAR_CARD_LIMIT_THRESHOLD = 100 + + def plan + active_subscription&.plan || Plan.free + end + + def active_subscription + subscription if subscription&.active? + end + + def subscribed? + subscription.present? + end + + def nearing_plan_cards_limit? + plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD + end + + def exceeding_card_limit? + cards_count > plan.card_limit + end +end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb new file mode 100644 index 000000000..cc4989343 --- /dev/null +++ b/saas/app/models/account/subscription.rb @@ -0,0 +1,17 @@ +class Account::Subscription < SaasRecord + belongs_to :account + + enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself) + + validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) } + + delegate :paid?, to: :plan + + def plan + Plan.find(plan_key) + end + + def to_be_canceled? + active? && cancel_at.present? + end +end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb new file mode 100644 index 000000000..e07bf73d8 --- /dev/null +++ b/saas/app/models/plan.rb @@ -0,0 +1,54 @@ +class Plan + PLANS = { + free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, + monthly_v1: { name: "Unlimitted", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV.fetch("STRIPE_MONTHLY_V1_PRICE_ID", "price_1SaHykRwChFE4it8PePOdDpS") } + } + + attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id + + class << self + def all + @all ||= PLANS.map { |key, properties| new(key: key, **properties) } + end + + def free + @free ||= all.find(&:free?) + end + + def paid + @paid ||= all.find(&:paid?) + end + + def find(key) + @all_by_key ||= all.index_by(&:key).with_indifferent_access + @all_by_key[key] + end + + alias [] find + end + + def initialize(key:, name:, price:, card_limit:, storage_limit:, stripe_price_id: nil) + @key = key + @name = name + @price = price + @card_limit = card_limit + @storage_limit = storage_limit + @stripe_price_id = stripe_price_id + end + + def free? + price.zero? + end + + def paid? + !free? + end + + def limit_cards? + card_limit != Float::INFINITY + end + + def formatted_storage_limit + ActionController::Base.helpers.number_to_human_size(storage_limit).delete(" ") + end +end diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb new file mode 100644 index 000000000..3aef9b853 --- /dev/null +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -0,0 +1,7 @@ +
Status: <%= subscription.status.titleize %>
+ +<% if subscription.current_period_end %> +<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>
+<% end %> + +<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb new file mode 100644 index 000000000..d2b865ed8 --- /dev/null +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -0,0 +1,28 @@ +<% if Current.user.admin? %> +If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.
+ + <%= button_to "Upgrade to #{Plan.paid.name} fo $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> + +Cancel any time, no contracts, take your data with you whenever.
+ + <% else %> +Your payment was successful. You're now on the <%= Current.account.plan.name %> plan.
+ <%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %> +Name: <%= @account.name %>
+ +<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %> ++ <%= form.label :cards_count %> + <%= form.number_field :cards_count %> +
+ + <%= form.submit "Update" %> +<% end %> + +<%= link_to "Back", saas.admin_accounts_path %>
diff --git a/saas/app/views/admin/accounts/index.html.erb b/saas/app/views/admin/accounts/index.html.erb new file mode 100644 index 000000000..e3faa98f2 --- /dev/null +++ b/saas/app/views/admin/accounts/index.html.erb @@ -0,0 +1,6 @@ +