From 385fc25919748340a2a7367473fabd1b624d8af3 Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Sun, 14 Jun 2026 21:47:34 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(seed):=20flobyte.rake=20=E2=80=94=20sc?= =?UTF-8?q?oped=20aistack=20catalog=20seeder=20(Flows/KB/Forms/Functions?= =?UTF-8?q?=20+=20white-label)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idempotent per-org seeder mirroring thinkfleet_memory.rake. Seeds the focused Flobyte catalog: 8 features, active_flow metric, and 8 plans (Starter→Enterprise, yearly variants, Enterprise-Comp, and aistack-whitelabel = $2k base + 200 active flows then $5/flow for white-label/SDK resellers like Whisp). --- lib/tasks/flobyte.rake | 199 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 lib/tasks/flobyte.rake diff --git a/lib/tasks/flobyte.rake b/lib/tasks/flobyte.rake new file mode 100644 index 000000000000..018637fae273 --- /dev/null +++ b/lib/tasks/flobyte.rake @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +# Flobyte (aistack) billing surface seeder. +# +# Idempotently provisions the Flobyte plan catalog — scoped to the focused +# product: Flows, Knowledge Base, Forms, Functions (+ SDK & white-label +# packaging) — plus the metric/feature/entitlement contract, for one +# organization. Customers are scoped to the `flobyte` billing entity; plans +# live at the org level. +# +# All constants + helpers live under the FlobyteCatalog module so they NEVER +# collide with other product seeders (thinkfleet_memory.rake etc.) — rake +# files share top-level scope, so a bare `PLANS =` / `def self.x` would clobber. +# +# Usage: +# bin/rails 'flobyte:seed[]' +# bin/rails 'flobyte:seed[,dry_run]' + +module FlobyteCatalog + BILLING_ENTITY_CODE = "flobyte" + BILLING_ENTITY_NAME = "Flobyte" + + # Only the white-label plan meters usage (active flows). Standard tiers are + # flat price + hard feature caps. + METRICS = [ + {code: "active_flow", name: "Active flows", aggregation_type: :max_agg, field_name: "count"} + ].freeze + + FEATURES = [ + {code: "aistack.flows", name: "Flows", + privileges: [{code: "limit", name: "Active flow limit", value_type: "integer"}]}, + {code: "aistack.knowledge_base", name: "Knowledge Base", + privileges: [{code: "limit", name: "KB document limit", value_type: "integer"}]}, + {code: "aistack.forms", name: "Forms", + privileges: [{code: "limit", name: "Form limit", value_type: "integer"}]}, + {code: "aistack.functions", name: "Functions", + privileges: [{code: "limit", name: "Function limit", value_type: "integer"}]}, + {code: "aistack.user_seats", name: "User seats", + privileges: [{code: "limit", name: "Seat limit", value_type: "integer"}]}, + {code: "aistack.api_keys", name: "API keys (SDK)", privileges: []}, + {code: "aistack.embedding", name: "Embedding / SDK", privileges: []}, + {code: "aistack.white_label", name: "White-label", privileges: []} + ].freeze + + STARTER = {"aistack.flows" => {"limit" => 5}, "aistack.knowledge_base" => {"limit" => 100}, + "aistack.user_seats" => {"limit" => 1}}.freeze + BUSINESS = {"aistack.flows" => {"limit" => 50}, "aistack.knowledge_base" => {"limit" => 1_000}, + "aistack.forms" => {"limit" => 25}, "aistack.user_seats" => {"limit" => 5}}.freeze + BUSINESS_PRO = {"aistack.flows" => {"limit" => 500}, "aistack.knowledge_base" => {"limit" => 10_000}, + "aistack.forms" => {"limit" => 250}, "aistack.functions" => {"limit" => 100}, + "aistack.api_keys" => {}, "aistack.embedding" => {}, "aistack.user_seats" => {"limit" => 20}}.freeze + ENTERPRISE = {"aistack.flows" => {"limit" => 5_000}, "aistack.knowledge_base" => {"limit" => 50_000}, + "aistack.forms" => {}, "aistack.functions" => {}, "aistack.api_keys" => {}, + "aistack.embedding" => {}, "aistack.user_seats" => {"limit" => 1_000}}.freeze + WHITELABEL = {"aistack.flows" => {}, "aistack.knowledge_base" => {}, "aistack.forms" => {}, + "aistack.functions" => {}, "aistack.api_keys" => {}, "aistack.embedding" => {}, + "aistack.white_label" => {}, "aistack.user_seats" => {}}.freeze + + PLANS = [ + {code: "aistack-starter", name: "Starter", description: "Flows + Knowledge Base.", + amount_cents: 0, interval: :monthly, trial_period: 0, entitlements: STARTER}, + {code: "aistack-business", name: "Business", description: "Adds Forms.", + amount_cents: 9_900, interval: :monthly, trial_period: 14, entitlements: BUSINESS}, + {code: "aistack-business-yearly", name: "Business (Yearly)", description: "Adds Forms.", + amount_cents: 99_000, interval: :yearly, trial_period: 14, entitlements: BUSINESS}, + {code: "aistack-business-pro", name: "Business Pro", description: "Adds Functions + SDK.", + amount_cents: 29_900, interval: :monthly, trial_period: 14, entitlements: BUSINESS_PRO}, + {code: "aistack-business-pro-yearly", name: "Business Pro (Yearly)", description: "Adds Functions + SDK.", + amount_cents: 299_000, interval: :yearly, trial_period: 14, entitlements: BUSINESS_PRO}, + {code: "aistack-enterprise", name: "Enterprise", description: "Full scope at scale.", + amount_cents: 300_000, interval: :monthly, trial_period: 0, entitlements: ENTERPRISE}, + {code: "aistack-enterprise-comp", name: "Enterprise (Comp $0)", description: "Internal/comped enterprise.", + amount_cents: 0, interval: :monthly, trial_period: 0, entitlements: ENTERPRISE}, + {code: "aistack-whitelabel", name: "White-Label / SDK", + description: "Reseller white-label license + active-flow usage.", + amount_cents: 200_000, interval: :monthly, trial_period: 0, entitlements: WHITELABEL, + charges: [ + {metric_code: "active_flow", charge_model: "graduated", properties: { + graduated_ranges: [ + {from_value: 0, to_value: 200, flat_amount: "0", per_unit_amount: "0"}, + {from_value: 201, to_value: nil, flat_amount: "0", per_unit_amount: "5.00"} + ] + }} + ]} + ].freeze + + def self.seed!(organization_id, dry_run:) + organization = Organization.find(organization_id) + puts "Seeding Flobyte in organization #{organization.id} (#{organization.name}) dry_run=#{dry_run}\n\n" + ActiveRecord::Base.transaction do + ensure_billing_entity!(organization, dry_run:) + METRICS.each { |m| ensure_metric!(organization, m, dry_run:) } + FEATURES.each { |f| ensure_feature!(organization, f, dry_run:) } + PLANS.each { |p| ensure_plan!(organization, p, dry_run:) } + raise ActiveRecord::Rollback if dry_run + end + puts "\n#{dry_run ? '[dry_run] no changes persisted' : '✓ done'}" + end + + def self.ensure_billing_entity!(organization, dry_run:) + existing = organization.billing_entities.find_by(code: BILLING_ENTITY_CODE) + return existing if existing + puts "+ billing entity '#{BILLING_ENTITY_CODE}'" + return nil if dry_run + organization.billing_entities.create!(code: BILLING_ENTITY_CODE, name: BILLING_ENTITY_NAME, + default_currency: "USD", document_locale: organization.document_locale.presence || "en", + timezone: organization.timezone.presence || "UTC") + end + + def self.ensure_metric!(organization, spec, dry_run:) + return puts("✓ metric '#{spec[:code]}'") if organization.billable_metrics.exists?(code: spec[:code]) + puts "+ metric '#{spec[:code]}'" + return nil if dry_run + organization.billable_metrics.create!(code: spec[:code], name: spec[:name], + aggregation_type: spec[:aggregation_type], field_name: spec[:field_name]) + end + + def self.ensure_feature!(organization, spec, dry_run:) + feature = organization.features.find_by(code: spec[:code]) + if feature + puts "✓ feature '#{spec[:code]}'" + else + puts "+ feature '#{spec[:code]}'" + return nil if dry_run + feature = organization.features.create!(code: spec[:code], name: spec[:name]) + end + spec[:privileges].each do |priv| + next if feature.privileges.exists?(code: priv[:code]) + puts " + privilege '#{spec[:code]}.#{priv[:code]}'" + next if dry_run + feature.privileges.create!(organization:, code: priv[:code], name: priv[:name], value_type: priv[:value_type]) + end + feature + end + + def self.ensure_plan!(organization, spec, dry_run:) + plan = organization.plans.find_by(code: spec[:code]) + attrs = {name: spec[:name], description: spec[:description], amount_cents: spec[:amount_cents], + interval: spec[:interval], trial_period: spec[:trial_period], amount_currency: "USD"} + if plan + puts "~ plan '#{spec[:code]}'" + plan.update!(**attrs) unless dry_run + else + puts "+ plan '#{spec[:code]}'" + return nil if dry_run + plan = organization.plans.create!(code: spec[:code], pay_in_advance: true, **attrs) + end + reset_charges!(organization, plan, spec[:charges] || [], dry_run:) + apply_entitlements!(organization, plan, spec[:entitlements], dry_run:) + plan + end + + # Reset a plan's charges to exactly the spec (so re-scoping pricing is safe). + def self.reset_charges!(organization, plan, charge_specs, dry_run:) + return if dry_run + plan.charges.destroy_all + charge_specs.each do |c| + metric = organization.billable_metrics.find_by!(code: c[:metric_code]) + plan.charges.create!(organization:, billable_metric: metric, + code: c[:metric_code], charge_model: c[:charge_model], + pay_in_advance: false, invoiceable: true, prorated: false, + min_amount_cents: 0, properties: c[:properties]) + puts " + charge on '#{c[:metric_code]}' (#{c[:charge_model]})" + end + end + + def self.apply_entitlements!(organization, plan, spec, dry_run:) + spec.each do |feature_code, privilege_values| + feature = organization.features.find_by(code: feature_code) + next warn(" ! feature '#{feature_code}' missing") unless feature + entitlement = plan.entitlements.find_by(entitlement_feature_id: feature.id) + unless entitlement + puts " + entitle '#{plan.code}' → '#{feature_code}'" + next if dry_run + entitlement = Entitlement::Entitlement.create!(organization:, plan:, feature:) + end + next if dry_run + privilege_values.each do |priv_code, value| + privilege = feature.privileges.find_by(code: priv_code) + next warn(" ! privilege '#{feature_code}.#{priv_code}' missing") unless privilege + coerced = value.to_s + existing = entitlement.values.find_by(entitlement_privilege_id: privilege.id) + if existing + existing.update!(value: coerced) unless existing.value == coerced + else + Entitlement::EntitlementValue.create!(organization:, entitlement:, privilege:, value: coerced) + end + end + end + end +end + +namespace :flobyte do + desc "Seed Flobyte (aistack) plans + entitlements for one organization" + task :seed, %i[organization_id mode] => :environment do |_task, args| + abort "Missing organization_id. Usage: bin/rails 'flobyte:seed[]'" unless args[:organization_id] + FlobyteCatalog.seed!(args[:organization_id], dry_run: args[:mode].to_s == "dry_run") + end +end From b4af336bb3f4a1b3c35782054de24eb9523caf0e Mon Sep 17 00:00:00 2001 From: rrader2890 Date: Tue, 16 Jun 2026 19:19:39 -0400 Subject: [PATCH 2/2] feat(white-label): no-login MSA acceptance gate + provisioning + $1 test plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboards white-label/SDK customers with no login: provision a Stripe-linked customer under the `flobyte` billing entity and email a signed MSA acceptance link. On accept + card save, the Stripe `setup_intent.succeeded` webhook creates the subscription, so the first pay-in-advance invoice bills against a card on file and every renewal auto-charges off-session. - config/white_label/{msa,terms}_v1.md — versioned legal docs (DRAFT, pending counsel) - white_label_agreements table + model (signature audit trail: name/title/ip/ua/version) - WhiteLabelController — token-authenticated hosted accept page (no login) - WhiteLabel::ActivateService — hooked into setup_intent_succeeded_service - flobyte.rake — provision_whitelabel task; aistack-whitelabel + aistack-whitelabel-test ($1) plans NOTE: run `bin/rails db:migrate` in a DB-enabled env to regenerate db/structure.sql before merge (migrations-test CI gate). Does not affect runtime — deploy migrate creates the table regardless. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/white_label_controller.rb | 130 +++++++++++++++++ app/models/white_label_agreement.rb | 56 ++++++++ .../setup_intent_succeeded_service.rb | 4 + app/services/white_label/activate_service.rb | 50 +++++++ config/routes.rb | 4 + config/white_label/msa_v1.md | 131 ++++++++++++++++++ config/white_label/terms_v1.md | 66 +++++++++ ...616000000_create_white_label_agreements.rb | 34 +++++ lib/tasks/flobyte.rake | 77 +++++++++- 9 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 app/controllers/white_label_controller.rb create mode 100644 app/models/white_label_agreement.rb create mode 100644 app/services/white_label/activate_service.rb create mode 100644 config/white_label/msa_v1.md create mode 100644 config/white_label/terms_v1.md create mode 100644 db/migrate/20260616000000_create_white_label_agreements.rb diff --git a/app/controllers/white_label_controller.rb b/app/controllers/white_label_controller.rb new file mode 100644 index 000000000000..e0a7bcb044ce --- /dev/null +++ b/app/controllers/white_label_controller.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +# Public, login-less MSA acceptance gate for white-label / SDK customers. +# +# Inherits ActionController::Base (not the API base) so it can serve a single +# self-contained HTML page. Authentication is the signed token in the URL, so +# CSRF/forgery protection is disabled — the token is the bearer credential. +# +# GET /white-label/:token -> render MSA + Terms + acceptance form +# POST /white-label/:token/accept -> record signature, redirect to Stripe +# +# After the signer saves a card on Stripe, the `setup_intent.succeeded` webhook +# calls WhiteLabel::ActivateService, which creates the subscription. So this +# controller never touches billing directly — it only captures consent + a card. +class WhiteLabelController < ActionController::Base + skip_forgery_protection + + def show + agreement = WhiteLabelAgreement.from_token(params[:token]) + return render_invalid if agreement.nil? + return render_status(agreement) unless agreement.status == "pending" + + render_gate(agreement) + end + + def accept + agreement = WhiteLabelAgreement.from_token(params[:token]) + return render_invalid if agreement.nil? + return render_status(agreement) unless agreement.status == "pending" + + unless params[:accept] == "1" && params[:signer_name].present? && params[:signer_title].present? + return render_gate(agreement, error: "Please enter your name and title and check the acceptance box.") + end + + agreement.accept!( + signer: {name: params[:signer_name], title: params[:signer_title], email: params[:signer_email]}, + request_ip: request.remote_ip, + user_agent: request.user_agent + ) + + checkout = ::Customers::GenerateCheckoutUrlService.call(customer: agreement.customer) + if checkout.success? && checkout.checkout_url.present? + redirect_to checkout.checkout_url, allow_other_host: true + else + # Card setup not available yet (e.g. Stripe customer still provisioning). + # Consent is recorded; payment instructions will follow by email. + render_accepted_without_card + end + end + + private + + def render_gate(agreement, error: nil) + msa = doc_body("msa", agreement.msa_version) + terms = doc_body("terms", agreement.terms_version) + company = agreement.customer.name + err_html = error ? %(

#{ERB::Util.html_escape(error)}

) : "" + + body = <<~HTML +

White-Label / SDK Agreement

+

Please review and accept the agreement below on behalf of + #{ERB::Util.html_escape(company)}. After accepting, you'll + be taken to a secure page to add a payment method — no account or login required.

+ #{err_html} +

Master Services & White-Label / SDK License Agreement (#{agreement.msa_version})

+
#{ERB::Util.html_escape(msa)}
+

Acceptable Use & Service Terms (#{agreement.terms_version})

+
#{ERB::Util.html_escape(terms)}
+
+ + + + + +
+ HTML + render html: layout(body).html_safe + end + + def render_status(agreement) + msg = case agreement.status + when "accepted" then "This agreement has already been accepted. If you still need to add a payment method, please use the link in your email or contact us." + when "active" then "This agreement is active and billing is set up. Nothing further is needed." + else "This agreement is no longer available." + end + render html: layout("

White-Label / SDK Agreement

#{msg}

").html_safe + end + + def render_accepted_without_card + body = '

Thank you

Your acceptance has been recorded. ' \ + 'We will email you a secure link to add your payment method shortly.

' + render html: layout(body).html_safe + end + + def render_invalid + render html: layout('

Link expired or invalid

This acceptance ' \ + 'link is no longer valid. Please contact your Flobyte representative for a new link.

').html_safe, + status: :not_found + end + + # Read a versioned legal doc, stripping the leading HTML comment header. + def doc_body(kind, version) + path = Rails.root.join("config/white_label/#{kind}_#{version}.md") + raw = File.exist?(path) ? File.read(path) : "Document unavailable." + raw.sub(/\A\s*/m, "").strip + end + + def layout(inner) + <<~HTML + + + White-Label / SDK Agreement + #{inner} + HTML + end +end diff --git a/app/models/white_label_agreement.rb b/app/models/white_label_agreement.rb new file mode 100644 index 000000000000..be07b0da594d --- /dev/null +++ b/app/models/white_label_agreement.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# A white-label / SDK customer's acceptance of the MSA + Terms. +# +# Lifecycle: +# pending -> created at provisioning; signed gate link emailed to the signer +# accepted -> signer clicked "I Accept"; signature audit trail recorded; +# customer redirected to save a card. WhiteLabel::ActivateService +# then creates the subscription once the card lands. +# active -> subscription created; recurring billing live +# +# The signed token in the gate link is minted with Lago's own MessageVerifier +# (SECRET_KEY_BASE) — same mechanism as the customer portal — so no extra auth +# infrastructure is needed and links self-expire. +class WhiteLabelAgreement < ApplicationRecord + STATUSES = %w[pending accepted active superseded].freeze + TOKEN_PURPOSE = "white_label_agreement" + TOKEN_TTL = 14.days + + belongs_to :organization + belongs_to :customer + + validates :plan_code, :msa_version, :terms_version, presence: true + validates :status, inclusion: {in: STATUSES} + + scope :pending, -> { where(status: "pending") } + scope :awaiting_activation, -> { where(status: "accepted") } + + def self.verifier + ActiveSupport::MessageVerifier.new(ENV.fetch("SECRET_KEY_BASE")) + end + + # Find an agreement from a signed gate token; returns nil if invalid/expired. + def self.from_token(token) + id = verifier.verify(token, purpose: TOKEN_PURPOSE) + find_by(id:) + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def signed_token + self.class.verifier.generate(id, purpose: TOKEN_PURPOSE, expires_in: TOKEN_TTL) + end + + def accept!(signer:, request_ip:, user_agent:) + update!( + status: "accepted", + accepted_at: Time.current, + accepted_by_name: signer[:name], + accepted_by_title: signer[:title], + accepted_by_email: signer[:email], + accepted_ip: request_ip, + accepted_user_agent: user_agent + ) + end +end diff --git a/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb b/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb index b31b9f5ba1c9..8da9a3ffbcf9 100644 --- a/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb +++ b/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb @@ -20,6 +20,10 @@ def call payment_method_details: ).raise_if_error! + # White-label / SDK customers subscribe only once a card is on file, so + # the first pay-in-advance invoice bills cleanly. No-op for everyone else. + ::WhiteLabel::ActivateService.call(customer:) + result.stripe_customer = stripe_customer result rescue ::Stripe::PermissionError => e diff --git a/app/services/white_label/activate_service.rb b/app/services/white_label/activate_service.rb new file mode 100644 index 000000000000..8ae864654d71 --- /dev/null +++ b/app/services/white_label/activate_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module WhiteLabel + # Creates the white-label subscription once the customer has a payment method + # on file. Invoked from the Stripe `setup_intent.succeeded` webhook so the + # pay-in-advance first invoice bills cleanly against a saved card (no race + # between invoice generation and card entry), and every renewal auto-charges + # off-session — "accept once, pay once, never log in." + # + # Idempotent: only acts on an `accepted` agreement, and flips it to `active`. + class ActivateService < BaseService + Result = BaseResult[:subscription, :white_label_agreement] + + def initialize(customer:) + @customer = customer + super + end + + def call + agreement = WhiteLabelAgreement.awaiting_activation.find_by(customer_id: customer.id) + return result if agreement.blank? # nothing to activate — not a WL customer, or already active + + plan = customer.organization.plans.find_by(code: agreement.plan_code) + return result.not_found_failure!(resource: "plan") if plan.blank? + + external_id = "#{customer.external_id}-wl" + + sub_result = ::Subscriptions::CreateService.call( + customer:, + plan:, + params: { + external_id:, + external_customer_id: customer.external_id, + billing_time: "anniversary" + } + ) + return sub_result unless sub_result.success? + + agreement.update!(status: "active", subscription_external_id: external_id) + + result.subscription = sub_result.subscription + result.white_label_agreement = agreement + result + end + + private + + attr_reader :customer + end +end diff --git a/config/routes.rb b/config/routes.rb index 53b361b0f876..41e694094d53 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,6 +16,10 @@ get "/health", to: "application#health" get "/ready", to: "application#ready" + # Public, login-less white-label / SDK MSA acceptance gate (token-authenticated) + get "/white-label/:token", to: "white_label#show", as: :white_label_gate + post "/white-label/:token/accept", to: "white_label#accept" + namespace :data_api do namespace :v1 do resources :charges, only: [] do diff --git a/config/white_label/msa_v1.md b/config/white_label/msa_v1.md new file mode 100644 index 000000000000..272aed586ccc --- /dev/null +++ b/config/white_label/msa_v1.md @@ -0,0 +1,131 @@ + + +# Master Services & White-Label / SDK License Agreement + +**Version v1 — Effective on acceptance** + +This Master Services & White-Label / SDK License Agreement (the **"Agreement"**) is entered into between **[LEGAL ENTITY NAME], a [STATE/COUNTRY] [entity type]** ("**Flobyte**", "**we**", "**us**") and the entity that accepts this Agreement ("**Customer**", "**you**"). By clicking "I Accept," signing an order, or accessing the Service, the individual accepting represents that they are authorized to bind Customer, and Customer agrees to this Agreement as of that date (the "**Effective Date**"). + +## 1. Definitions + +- **"Service"** — the Flobyte hosted platform, APIs, and software development kits ("**SDK**") made available to Customer, together with related documentation. +- **"White-Label Rights"** — the limited rights, if included in Customer's plan, to present the Service under Customer's own brand to End Users. +- **"End User"** — any person or entity that accesses the Service through Customer, whether under Customer's brand or otherwise. +- **"Customer Data"** — data submitted to the Service by or for Customer or its End Users. +- **"Order"** — the plan, fees, and quantities selected by Customer (including via the billing portal), which are incorporated into this Agreement. +- **"Fees"** — the subscription and usage charges for Customer's plan, including the White-Label / SDK license fee and active-flow usage charges. + +## 2. License Grant + +2.1 **Service.** Subject to this Agreement and timely payment, Flobyte grants Customer a non-exclusive, non-transferable, non-sublicensable (except as in Section 3) right to access and use the Service during the Term for Customer's internal business operations and, where licensed, to serve End Users. + +2.2 **SDK.** Flobyte grants Customer a limited, revocable, non-exclusive license to integrate and use the SDK and APIs solely to build and operate applications that interoperate with the Service, subject to the documentation, rate limits, and acceptable-use terms. + +2.3 **White-Label.** If Customer's Order includes White-Label Rights, Customer may present the Service to End Users under Customer's brand, provided Customer (a) does not remove or obscure required attributions where mandated by the documentation, (b) does not misrepresent the Service's capabilities, security, or ownership, and (c) remains solely responsible for its End Users as set out in Section 3. + +## 3. Resale, Sublicensing & End-User Flow-Down + +3.1 Customer may make the Service available to End Users only under a written agreement that is at least as protective of Flobyte as this Agreement, including disclaimers of warranty, limitations of liability, IP protections, and acceptable-use obligations (collectively, "**Flow-Down Terms**"). + +3.2 Customer is fully responsible and liable for its End Users' acts and omissions and for all use of the Service through Customer's account, as if such acts were Customer's own. Customer will be the first line of support to its End Users. + +3.3 Flobyte has no contractual relationship with, and no obligations to, Customer's End Users. + +## 4. Restrictions + +Customer will not, and will not permit any End User to: (a) reverse engineer, decompile, or attempt to derive source code, except to the extent that restriction is prohibited by law; (b) resell, sublicense, or provide the Service except as expressly permitted in Section 3; (c) circumvent usage limits, rate limits, or security controls; (d) use the Service to build a competing product; (e) remove proprietary notices; (f) use the Service in violation of law or the Acceptable Use Policy (the "**Terms**"); or (g) exceed the scope of the licensed plan. + +## 5. Fees, Billing & Taxes + +5.1 **Fees.** Customer will pay the Fees for its plan. The White-Label / SDK plan comprises a recurring license fee plus usage charges for active flows above the included allotment, as stated in the Order. + +5.2 **Billing & auto-charge.** Fees are billed in advance each billing period through Flobyte's billing processor. By providing a payment method, Customer authorizes Flobyte and its processor to automatically charge that method for all Fees, including renewals and usage charges, until the Agreement is terminated. + +5.3 **Auto-renewal.** Subscriptions renew automatically for successive periods equal to the initial term unless either party gives notice of non-renewal at least [30] days before the end of the then-current period. + +5.4 **Late payment.** Overdue amounts accrue interest at the lesser of 1.5% per month or the maximum permitted by law. Flobyte may suspend the Service for non-payment after [10] days' notice. + +5.5 **Taxes.** Fees are exclusive of taxes. Customer is responsible for all taxes other than taxes on Flobyte's net income. + +5.6 **No refunds.** Except as expressly stated, Fees are non-refundable and non-cancelable for the period paid. + +## 6. Term & Termination + +6.1 **Term.** This Agreement begins on the Effective Date and continues for the subscription term in the Order, renewing under Section 5.3. + +6.2 **Termination for cause.** Either party may terminate for the other's material breach not cured within [30] days of notice (or immediately for breach of Sections 3, 4, 7, or 8). + +6.3 **Effect.** On termination, all licenses and White-Label Rights end, Customer ceases use of the Service and SDK, and any accrued Fees become due. Sections that by their nature survive (including 4, 7–12) survive termination. + +6.4 **Suspension.** Flobyte may suspend access to address a security risk, suspected violation of the Terms, or legal requirement, with notice where practicable. + +## 7. Intellectual Property + +7.1 **Flobyte IP.** Flobyte and its licensors retain all right, title, and interest in and to the Service, SDK, software, and all related IP. No rights are granted except as expressly stated. Customer's brand is not affected by White-Label use; Flobyte's underlying IP is not transferred. + +7.2 **Customer IP.** Customer retains all rights in Customer Data and Customer's marks. Customer grants Flobyte a license to use Customer Data solely to provide, secure, and improve the Service and as permitted by the Terms and any data processing addendum. + +7.3 **Feedback.** Customer grants Flobyte a perpetual, irrevocable, royalty-free license to use any feedback or suggestions without restriction. + +## 8. Confidentiality + +Each party will protect the other's Confidential Information with at least reasonable care, use it only to perform under this Agreement, and not disclose it except to representatives bound by confidentiality. This Agreement's terms are Confidential Information. These obligations do not apply to information that is public, independently developed, or rightfully received from a third party. + +## 9. Data Protection & Security + +9.1 Flobyte will maintain commercially reasonable administrative, technical, and organizational safeguards designed to protect Customer Data. + +9.2 To the extent Flobyte processes personal data on Customer's behalf, the parties will comply with the Data Processing Addendum at **[DPA URL]**, which is incorporated by reference. + +9.3 Customer is responsible for the legality of Customer Data and for obtaining all consents required from its End Users. + +## 10. Warranties & Disclaimer + +10.1 Each party warrants it has authority to enter into this Agreement. + +10.2 **EXCEPT AS EXPRESSLY STATED, THE SERVICE AND SDK ARE PROVIDED "AS IS" AND "AS AVAILABLE." FLOBYTE DISCLAIMS ALL IMPLIED WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND ANY WARRANTY THAT THE SERVICE WILL BE UNINTERRUPTED OR ERROR-FREE. FLOBYTE DOES NOT WARRANT ANY RESULTS FROM AI-GENERATED OR AUTOMATED OUTPUTS, WHICH CUSTOMER MUST INDEPENDENTLY VERIFY.** + +## 11. Indemnification + +11.1 **By Flobyte.** Flobyte will defend Customer against third-party claims that the Service, as provided and used in accordance with this Agreement, infringes a third party's intellectual-property rights, and will pay resulting damages finally awarded, subject to the limitations in Section 12. This does not apply to claims arising from White-Label presentation, Customer Data, End-User content, modifications, or combinations not provided by Flobyte. + +11.2 **By Customer.** Customer will defend and indemnify Flobyte against third-party claims arising from (a) Customer Data, (b) Customer's or its End Users' use of the Service, (c) Customer's White-Label presentation or representations to End Users, (d) breach of Section 3 or 4, or (e) violation of law or the Terms. + +## 12. Limitation of Liability + +12.1 **EXCLUSION.** NEITHER PARTY WILL BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, REVENUE, OR DATA, EVEN IF ADVISED OF THE POSSIBILITY. + +12.2 **CAP.** EACH PARTY'S TOTAL AGGREGATE LIABILITY UNDER THIS AGREEMENT WILL NOT EXCEED THE FEES PAID OR PAYABLE BY CUSTOMER TO FLOBYTE IN THE TWELVE (12) MONTHS PRECEDING THE EVENT GIVING RISE TO LIABILITY. + +12.3 **Exceptions.** The cap and exclusion do not apply to Customer's payment obligations, either party's indemnification obligations, or breaches of confidentiality or of Sections 3–4. + +## 13. Compliance + +Customer will comply with all applicable laws, including export controls, sanctions, anti-corruption laws, data-protection laws, and any AI-specific regulations applicable to Customer's use, and will not make the Service available to embargoed jurisdictions or restricted parties. + +## 14. General + +14.1 **Publicity.** Neither party will use the other's marks without consent, except Flobyte may identify Customer as a customer in customer lists unless Customer opts out in writing. + +14.2 **Assignment.** Neither party may assign this Agreement without the other's consent, except to a successor in a merger or sale of substantially all assets, with notice. + +14.3 **Governing law; venue.** This Agreement is governed by the laws of the State of **[HOME_STATE]**, USA, without regard to conflicts rules. The parties submit to the exclusive jurisdiction of the state and federal courts located in **[HOME_STATE_COUNTY/CITY], [HOME_STATE]**. + +14.4 **Force majeure.** Neither party is liable for delays caused by events beyond its reasonable control. + +14.5 **Notices.** Notices must be in writing to the parties' designated contacts (email permitted for routine notices; legal notices to **[NOTICE ADDRESS]**). + +14.6 **Entire agreement.** This Agreement and the Order are the entire agreement and supersede prior understandings. Any conflicting terms in Customer's purchase documents are rejected. Flobyte may update the Terms and the standard form of this Agreement prospectively for renewals. + +14.7 **Severability; waiver.** If any provision is unenforceable, the rest remains in effect. No waiver is effective unless in writing. + +14.8 **Electronic acceptance.** The parties agree that clicking "I Accept," together with the recorded signer name, title, timestamp, IP address, and this version identifier, constitutes a valid and binding electronic signature. + +--- + +*Customer acknowledges it has read, understood, and agrees to be bound by this Agreement and the incorporated Terms.* diff --git a/config/white_label/terms_v1.md b/config/white_label/terms_v1.md new file mode 100644 index 000000000000..7dd3cb0996bd --- /dev/null +++ b/config/white_label/terms_v1.md @@ -0,0 +1,66 @@ + + +# Acceptable Use & Service Terms ("Terms") + +**Version v1** + +These Terms are incorporated into and governed by the Master Services & White-Label / SDK License Agreement (the "Agreement"). Capitalized terms have the meanings in the Agreement. + +## 1. Acceptable Use + +Customer and its End Users will not use the Service to: + +1. violate any law or third-party right, or infringe intellectual-property or privacy rights; +2. transmit malware, or attempt to gain unauthorized access to any system or data; +3. send unsolicited bulk communications (spam) or violate anti-spam or telemarketing laws; +4. process highly sensitive data (e.g., payment card data, government IDs, or special-category personal data) except as expressly agreed in writing; +5. generate, automate, or distribute unlawful, deceptive, harassing, or harmful content; +6. probe, scan, or load-test the Service or its rate limits without prior written consent; +7. use automated outputs as the sole basis for decisions with legal or similarly significant effects on individuals without human review; or +8. resell or expose the SDK or APIs except as permitted under Section 3 of the Agreement. + +## 2. API & SDK Usage + +2.1 Customer will respect published rate limits and usage quotas. Flobyte may throttle, suspend, or charge for usage exceeding the licensed plan. + +2.2 API credentials are confidential. Customer is responsible for all activity under its credentials and will secure and rotate them as needed and on any suspected compromise. + +2.3 Flobyte may modify or deprecate APIs and SDK features with reasonable notice for material breaking changes, except where a change is required for security or legal compliance. + +## 3. White-Label Conduct + +3.1 Customer must not represent that it developed, owns, or solely operates the underlying Service, or make warranties about the Service on Flobyte's behalf beyond those Flobyte provides. + +3.2 Customer's End-User terms must include the Flow-Down Terms required by Section 3 of the Agreement. + +3.3 Customer is responsible for its branding, marketing claims, and regulatory compliance in the markets it serves. + +## 4. Customer Responsibilities + +4.1 Customer is responsible for the accuracy, quality, and legality of Customer Data and for the means by which it acquired it. + +4.2 Customer will maintain appropriate consents and notices for its End Users and comply with applicable data-protection laws. + +4.3 Customer will promptly report any suspected security incident affecting the Service. + +## 5. Service Levels & Support + +5.1 Flobyte will use commercially reasonable efforts to keep the Service available, excluding scheduled maintenance and force-majeure events. Any committed uptime or support response targets, if offered, are stated in the Order or a separate SLA. + +5.2 Customer provides first-line support to its End Users; Flobyte supports Customer per the plan. + +## 6. Suspension + +Flobyte may suspend or limit the Service, in whole or part, to protect the Service or third parties, to comply with law, or for violation of these Terms, with notice where practicable and restoration once the cause is resolved. + +## 7. Changes to These Terms + +Flobyte may update these Terms prospectively. Material changes take effect at the next renewal or upon [30] days' notice. Continued use after the effective date constitutes acceptance. + +--- + +*By accepting the Agreement, Customer accepts these Terms.* diff --git a/db/migrate/20260616000000_create_white_label_agreements.rb b/db/migrate/20260616000000_create_white_label_agreements.rb new file mode 100644 index 000000000000..85b66396edb9 --- /dev/null +++ b/db/migrate/20260616000000_create_white_label_agreements.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CreateWhiteLabelAgreements < ActiveRecord::Migration[8.0] + def change + create_table :white_label_agreements, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :customer, null: false, foreign_key: true, type: :uuid + + t.string :plan_code, null: false + t.string :msa_version, null: false + t.string :terms_version, null: false + t.string :status, null: false, default: "pending" + + # Subscription is created only AFTER the card is on file (see + # WhiteLabel::ActivateService), so this is null until activation. + t.string :subscription_external_id + + # Signature audit trail — what makes the click-accept legally defensible. + t.datetime :accepted_at + t.string :accepted_by_name + t.string :accepted_by_title + t.string :accepted_by_email + t.string :accepted_ip + t.string :accepted_user_agent + + t.timestamps + end + + add_index :white_label_agreements, [:organization_id, :status] + add_index :white_label_agreements, :customer_id, unique: true, + name: "index_white_label_agreements_on_customer_unique", + where: "status <> 'superseded'" + end +end diff --git a/lib/tasks/flobyte.rake b/lib/tasks/flobyte.rake index 018637fae273..536f3b6fa226 100644 --- a/lib/tasks/flobyte.rake +++ b/lib/tasks/flobyte.rake @@ -81,7 +81,10 @@ module FlobyteCatalog {from_value: 201, to_value: nil, flat_amount: "0", per_unit_amount: "5.00"} ] }} - ]} + ]}, + {code: "aistack-whitelabel-test", name: "White-Label / SDK (Test $1)", + description: "Internal end-to-end test of the white-label flow — $1/mo, no usage charges.", + amount_cents: 100, interval: :monthly, trial_period: 0, entitlements: WHITELABEL} ].freeze def self.seed!(organization_id, dry_run:) @@ -164,6 +167,66 @@ module FlobyteCatalog end end + # --------------------------------------------------------------------------- + # White-label / SDK customer provisioning. + # + # Creates a Stripe-linked customer under the `flobyte` billing entity and a + # PENDING agreement, then prints the signed MSA gate link to email the signer. + # The subscription is NOT created here — it is created after the signer accepts + # the MSA and saves a card (WhiteLabel::ActivateService, via Stripe webhook). + # --------------------------------------------------------------------------- + WHITELABEL_PLAN_CODE = "aistack-whitelabel" + MSA_VERSION = "v1" + TERMS_VERSION = "v1" + + def self.provision_whitelabel!(organization_id:, external_id:, name:, email:, plan_code: WHITELABEL_PLAN_CODE) + organization = Organization.find(organization_id) + + plan = organization.plans.find_by(code: plan_code) + abort "Plan '#{plan_code}' not found — run flobyte:seed[#{organization_id}] first" if plan.nil? + + stripe = organization.stripe_payment_providers.first + abort "No Stripe payment provider on organization #{organization_id} — connect Stripe before provisioning" if stripe.nil? + + customer = organization.customers.find_by(external_id:) + if customer + puts "✓ customer '#{external_id}' already exists (#{customer.id})" + else + result = ::Customers::CreateService.call( + organization_id: organization.id, + billing_entity_code: FlobyteCatalog::BILLING_ENTITY_CODE, + external_id:, + name:, + email:, + currency: "USD", + payment_provider: "stripe", + payment_provider_code: stripe.code + ) + result.raise_if_error! + customer = result.customer + puts "+ customer '#{external_id}' (#{customer.id}) under '#{FlobyteCatalog::BILLING_ENTITY_CODE}', Stripe-linked" + end + + agreement = WhiteLabelAgreement.where(customer_id: customer.id) + .where.not(status: "superseded").first + if agreement + puts "✓ agreement already exists (status: #{agreement.status})" + else + agreement = WhiteLabelAgreement.create!( + organization:, customer:, plan_code:, + msa_version: MSA_VERSION, terms_version: TERMS_VERSION, status: "pending" + ) + puts "+ pending agreement #{agreement.id}" + end + + base = ENV["WHITE_LABEL_GATE_URL"].presence || ENV["LAGO_API_URL"].presence || + ENV["LAGO_FRONT_URL"].presence || "http://localhost:3000" + link = "#{base.chomp("/")}/white-label/#{agreement.signed_token}" + + puts "\nEmail this acceptance link to #{name} <#{email}> (valid #{WhiteLabelAgreement::TOKEN_TTL.inspect}):\n\n #{link}\n" + link + end + def self.apply_entitlements!(organization, plan, spec, dry_run:) spec.each do |feature_code, privilege_values| feature = organization.features.find_by(code: feature_code) @@ -196,4 +259,16 @@ namespace :flobyte do abort "Missing organization_id. Usage: bin/rails 'flobyte:seed[]'" unless args[:organization_id] FlobyteCatalog.seed!(args[:organization_id], dry_run: args[:mode].to_s == "dry_run") end + + desc "Provision a white-label/SDK customer (Stripe-linked) + pending MSA gate link. Optional 5th arg = plan_code (default aistack-whitelabel; use aistack-whitelabel-test for a $1 dry run)" + task :provision_whitelabel, %i[organization_id external_id name email plan_code] => :environment do |_task, args| + %i[organization_id external_id name email].each do |k| + abort "Missing #{k}. Usage: bin/rails 'flobyte:provision_whitelabel[,,,,]'" if args[k].blank? + end + FlobyteCatalog.provision_whitelabel!( + organization_id: args[:organization_id], external_id: args[:external_id], + name: args[:name], email: args[:email], + plan_code: args[:plan_code].presence || FlobyteCatalog::WHITELABEL_PLAN_CODE + ) + end end