Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/build-and-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ env:

jobs:
build-and-push:
# Only ever publish images built from main (also applies to manual runs).
if: github.ref == 'refs/heads/main' && github.repository == 'ThinkfleetAI/lago-api'
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down
8 changes: 4 additions & 4 deletions app/controllers/api/v1/white_label_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ def not_found_result(resource)
BaseService::Result.new.not_found_failure!(resource:)
end

# Authentication (a valid org API key) is sufficient for this internal
# endpoint; there is no per-resource API-key permission for white_label.
def authorize
true
# `provision` and `reset` are POSTs and require `white_label:write`;
# `index` requires `white_label:read` (see Api::BaseController#mode).
def resource_name
"white_label"
end
end
end
Expand Down
31 changes: 24 additions & 7 deletions app/controllers/white_label_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,20 @@ def show

case agreement.status
when "pending" then render_gate(agreement)
when "accepted" then proceed_to_payment(agreement) # signed but no card yet → go pay
when "active" then render_status(agreement) # all set
# Signed but no card yet → resume the Stripe checkout. Only reachable with
# a post-acceptance link (the signing link is invalidated on acceptance),
# and never falls back to minting a customer portal session.
when "accepted" then resume_payment(agreement)
when "active" then render_status(agreement)
else render_invalid
end
end

def accept
agreement = WhiteLabelAgreement.from_token(params[:token])
return render_invalid if agreement.nil?
# Already signed (e.g. resubmitted) → straight to payment, don't re-sign.
return proceed_to_payment(agreement) if agreement.status == "accepted"
# Already signed (e.g. resubmitted) → resume payment, don't re-sign.
return resume_payment(agreement) if agreement.status == "accepted"
return render_status(agreement) unless agreement.status == "pending"

unless params[:accept] == "1" && params[:signer_name].present? && params[:signer_title].present?
Expand Down Expand Up @@ -96,8 +99,11 @@ def render_gate(agreement, error: nil)
end

def render_status(agreement)
msg = if agreement.status == "active"
msg = case agreement.status
when "active"
"This agreement is active and billing is set up. Nothing further is needed."
when "accepted"
"This agreement has been signed. To finish adding a payment method, please contact your Flobyte representative."
else
"This agreement is no longer available."
end
Expand All @@ -116,6 +122,17 @@ def proceed_to_payment(agreement)
redirect_to_portal(agreement.customer)
end

# Repeat visit after acceptance: only ever send the signer back to Stripe
# checkout. No customer-portal fallback here.
def resume_payment(agreement)
ensure_provider_customer(agreement.customer)

url = white_label_checkout_url(agreement)
return redirect_to(url, allow_other_host: true) if url.present?

render_status(agreement)
end

# Build a Stripe setup-mode Checkout Session with OUR own no-login success page
# (the org's provider-level success_redirect_url points at an in-app, login-
# required page — wrong for a no-login signer). The existing
Expand All @@ -132,7 +149,7 @@ def white_label_checkout_url(agreement)
customer: pc.provider_customer_id,
payment_method_types: ["card"],
success_url: "#{base}/white-label/done",
cancel_url: agreement.signing_url
cancel_url: agreement.payment_resume_url
}
# Setup mode shows no amount; surface the recurring price near the submit
# button so it doesn't look like a blank purchase.
Expand All @@ -158,7 +175,7 @@ def plan_price_text(agreement)
return if plan.nil?

amount = plan.amount_cents.to_i / 100.0
unit = (plan.amount_currency.presence || "USD") == "USD" ? "$" : "#{plan.amount_currency} "
unit = ((plan.amount_currency.presence || "USD") == "USD") ? "$" : "#{plan.amount_currency} "
price = ActiveSupport::NumberHelper.number_to_currency(amount, unit:, precision: 2)
interval = (plan.interval.to_s == "yearly") ? "year" : "month"
"#{price}/#{interval}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ class ChangePlan < BaseMutation

def resolve(plan_code:)
customer = context[:customer_portal_user]
product_key = plan_code.split("-").first
product_key = ::CustomerPortal::AvailablePlansQuery.product_key_for(plan_code)
current_subscription = customer.subscriptions.active.find_by(
"external_id LIKE ?", "%-#{product_key}"
"external_id LIKE ?", ::CustomerPortal::AvailablePlansQuery.external_id_suffix_pattern(product_key)
)
return execution_error(message: "no_active_subscription_for_product") if current_subscription.nil?
if current_subscription.nil?
return execution_error(error: "no_active_subscription_for_product", status: 404, code: "no_active_subscription_for_product")
end

plan = customer.organization.plans.find_by!(code: plan_code, parent_id: nil)
plan = ::CustomerPortal::AvailablePlansQuery.find_plan(customer:, code: plan_code)
if plan.nil?
return execution_error(error: "plan_not_found", status: 404, code: "plan_not_found")
end

result = ::Subscriptions::PlanUpgradeService.call(
current_subscription: current_subscription,
Expand All @@ -45,8 +50,6 @@ def resolve(plan_code:)
)

result.success? ? result.subscription : result_error(result)
rescue ActiveRecord::RecordNotFound
execution_error(message: "plan_not_found")
end
end
end
Expand Down
10 changes: 6 additions & 4 deletions app/graphql/mutations/customer_portal/subscriptions/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ class Create < BaseMutation

def resolve(plan_code:)
customer = context[:customer_portal_user]
plan = customer.organization.plans.find_by!(code: plan_code, parent_id: nil)
product_key = plan_code.split("-").first
plan = ::CustomerPortal::AvailablePlansQuery.find_plan(customer:, code: plan_code)
if plan.nil?
return execution_error(error: "plan_not_found", status: 404, code: "plan_not_found")
end

product_key = ::CustomerPortal::AvailablePlansQuery.product_key_for(plan_code)
external_id = "#{customer.external_id}-#{product_key}"

result = ::Subscriptions::CreateService.call(
Expand All @@ -36,8 +40,6 @@ def resolve(plan_code:)
)

result.success? ? result.subscription : result_error(result)
rescue ActiveRecord::RecordNotFound
execution_error(message: "plan_not_found")
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ class Terminate < BaseMutation
def resolve(product_key:)
customer = context[:customer_portal_user]
subscription = customer.subscriptions.active.find_by(
"external_id LIKE ?", "%-#{product_key}"
"external_id LIKE ?", ::CustomerPortal::AvailablePlansQuery.external_id_suffix_pattern(product_key)
)
return execution_error(message: "no_active_subscription_for_product") if subscription.nil?
if subscription.nil?
return execution_error(error: "no_active_subscription_for_product", status: 404, code: "no_active_subscription_for_product")
end

result = ::Subscriptions::TerminateService.call(subscription: subscription)
result.success? ? result.subscription : result_error(result)
Expand Down
55 changes: 48 additions & 7 deletions app/graphql/mutations/organizations/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,66 @@ class Create < BaseMutation

type Types::Organizations::CurrentOrganizationType

DEFAULT_MAX_ORGANIZATIONS_PER_USER = 10

def resolve(name:)
result = ::Organizations::CreateService.call(
name:,
document_numbering: "per_organization"
)
return result_error(result) unless result.success?
user = context[:current_user]

# Same gate as signup: when signup is disabled, only users who already
# administer an organization may create additional ones.
if signup_disabled? && !admin_memberships(user).exists?
return not_allowed_error(code: "signup_disabled")
end

organization = result.organization
organization = nil
limit_reached = false
result = nil

ActiveRecord::Base.transaction do
membership = Membership.create!(user: context[:current_user], organization:)
# Serialize concurrent creations for the same user so the count
# check below cannot be raced.
user.lock!

if admin_memberships(user).count >= max_organizations_per_user
limit_reached = true
raise ActiveRecord::Rollback
end

result = ::Organizations::CreateService.call(
name:,
document_numbering: "per_organization"
)
raise ActiveRecord::Rollback unless result.success?

organization = result.organization
membership = Membership.create!(user:, organization:)
MembershipRole.create!(organization:, membership:, role: Role.admins.first!)
end

return forbidden_error(code: "organization_limit_reached") if limit_reached
return result_error(result) unless result.success?

organization
rescue ActiveRecord::RecordInvalid => e
result ||= BaseService::Result.new
result.record_validation_failure!(record: e.record)
result_error(result)
end

private

def signup_disabled?
ENV.fetch("LAGO_DISABLE_SIGNUP", "false") == "true"
end

def admin_memberships(user)
user.memberships.active.admins
end

def max_organizations_per_user
value = ENV.fetch("LAGO_MAX_ORGANIZATIONS_PER_USER", "").to_i
value.positive? ? value : DEFAULT_MAX_ORGANIZATIONS_PER_USER
end
end
end
end
42 changes: 7 additions & 35 deletions app/graphql/resolvers/customer_portal/available_plans_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,41 +24,13 @@ class AvailablePlansResolver < Resolvers::BaseResolver
type [Types::Plans::Object], null: false

def resolve(product_key: nil, exclude_current: false)
customer = context[:customer_portal_user]
organization = customer.organization

# `self_serve: false` plans (contract-only tiers like enterprise /
# white-label) never appear in the portal, but stay assignable by
# operators (this filter only runs here).
plans = organization.plans.where(parent_id: nil)
.where(self_serve: true)
.includes(:metadata)
.order(:amount_cents)
plans = plans.where("code LIKE ?", "#{product_key}-%") if product_key.present?

if exclude_current
active_plan_codes = customer.subscriptions.active.joins(:plan).pluck("plans.code")
plans = plans.where.not(code: active_plan_codes) if active_plan_codes.any?
end

# Compute which products this customer already has — used for the
# `visible_to_products` filter below.
customer_product_keys = customer.subscriptions.active
.joins(:plan).pluck("plans.code")
.map { |c| c.split("-").first }.uniq

plans.to_a.select do |plan|
meta = plan.metadata&.value || {}
# Hard-hide: any plan tagged hidden_in_portal=true never appears.
next false if meta["hidden_in_portal"].to_s == "true"

# Scoped visibility: if visible_to_products is set, customer must
# already have an active sub in one of those products. If empty/absent,
# plan is visible to all.
allowed = meta["visible_to_products"].to_s.split(",").map(&:strip).reject(&:empty?)
next true if allowed.empty?
(allowed & customer_product_keys).any?
end
# Filtering rules live in CustomerPortal::AvailablePlansQuery so the
# portal subscription mutations enforce exactly the same set.
::CustomerPortal::AvailablePlansQuery.call(
customer: context[:customer_portal_user],
product_key:,
exclude_current:
)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/models/api_key.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class ApiKey < ApplicationRecord
activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage
customer event fee invoice organization payment payment_receipt payment_request payment_method plan subscription lifetime_usage
tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section
billing_entity alert feature security_log quote
billing_entity alert feature security_log quote white_label
].freeze

MODES = %w[read write].freeze
Expand Down
9 changes: 9 additions & 0 deletions app/models/integrations/okta_integration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Integrations
class OktaIntegration < BaseIntegration
validates :client_secret, :client_id, :domain, :organization_name, presence: true
validate :domain_uniqueness
validate :host_destination

settings_accessors :client_id, :domain, :organization_name, :host
secrets_accessors :client_secret
Expand All @@ -14,6 +15,14 @@ def host

private

def host_destination
return if organization_name.blank?

valid = ::LagoHttpClient::OutboundUrlGuard.valid_hostname?(host) &&
::LagoHttpClient::OutboundUrlGuard.allowed?("https://#{host}/")
errors.add(:host, :url_invalid) unless valid
end

def domain_uniqueness
return if domain.blank?

Expand Down
14 changes: 7 additions & 7 deletions app/models/metadata/item_metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,13 @@ def value_correctness
# Table name: item_metadata
# Database name: primary
#
# id :uuid not null, primary key
# owner_type :string not null
# value :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# organization_id :uuid not null
# owner_id :uuid not null
# id :uuid not null, primary key
# owner_type(Polymorphic owner type) :string not null
# value(item_metadata key-value pairs) :jsonb not null
# created_at :datetime not null
# updated_at :datetime not null
# organization_id(Reference to the organization) :uuid not null
# owner_id(Polymorphic owner id) :uuid not null
#
# Indexes
#
Expand Down
22 changes: 12 additions & 10 deletions app/models/payment_providers/adyen_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,21 @@ def payment_type
# Table name: payment_providers
# Database name: primary
#
# id :uuid not null, primary key
# code :string not null
# deleted_at :datetime
# name :string not null
# secrets :string
# settings :jsonb not null
# type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# organization_id :uuid not null
# id :uuid not null, primary key
# code :string not null
# deleted_at :datetime
# name :string not null
# secrets :string
# settings :jsonb not null
# type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# billing_entity_id :uuid
# organization_id :uuid not null
#
# Indexes
#
# index_payment_providers_on_billing_entity_id (billing_entity_id)
# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL)
# index_payment_providers_on_organization_id (organization_id)
#
Expand Down
Loading
Loading