diff --git a/bun.lock b/bun.lock index 1ec3616da9..2bf4d65702 100644 --- a/bun.lock +++ b/bun.lock @@ -211,7 +211,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "8.45.1", + "version": "8.45.3", "bin": { "capgo": "dist/index.js", }, diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 1322338f0c..6894174da5 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -89,7 +89,7 @@ import { app as on_version_update } from '../../supabase/functions/_backend/trig import { app as pluginNotifications } from '../../supabase/functions/_backend/triggers/plugin_notifications.ts' import { app as queue_consumer } from '../../supabase/functions/_backend/triggers/queue_consumer.ts' import { app as send_email } from '../../supabase/functions/_backend/triggers/send_email.ts' -import { app as stripe_event } from '../../supabase/functions/_backend/triggers/stripe_event.ts' +import { app as stripe_event, appUs as stripe_event_us } from '../../supabase/functions/_backend/triggers/stripe_event.ts' import { app as webhook_delivery } from '../../supabase/functions/_backend/triggers/webhook_delivery.ts' import { app as webhook_dispatcher } from '../../supabase/functions/_backend/triggers/webhook_dispatcher.ts' import { BRES, createAllCatch, createHono } from '../../supabase/functions/_backend/utils/hono.ts' @@ -211,6 +211,7 @@ appTriggers.route('/on_version_delete', on_version_delete) appTriggers.route('/on_manifest_create', on_manifest_create) appTriggers.route('/on_deploy_history_create', on_deploy_history_create) appTriggers.route('/stripe_event', stripe_event) +appTriggers.route('/stripe_event_us', stripe_event_us) appTriggers.route('/on_organization_create', on_organization_create) appTriggers.route('/cron_stat_app', cron_stat_app) appTriggers.route('/cron_stat_org', cron_stat_org) diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index c58fb3f0f4..051c2d5574 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -1789,6 +1789,16 @@ "position": 26, "table": "stripe_info", "type": "boolean" + }, + { + "default": "'ee'::text", + "generated": "", + "identity": "", + "name": "billing_account", + "notNull": true, + "position": 27, + "table": "stripe_info", + "type": "text" } ], "constraints": [ @@ -2023,6 +2033,13 @@ "type": "u", "valid": true }, + { + "definition": "CHECK (billing_account = ANY (ARRAY['ee'::text, 'us'::text]))", + "name": "stripe_info_billing_account_check", + "table": "stripe_info", + "type": "c", + "valid": true + }, { "definition": "PRIMARY KEY (customer_id)", "name": "stripe_info_pkey", diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index f62cc02328..5a79589e29 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -442,7 +442,9 @@ CREATE TABLE public.stripe_info ( last_stripe_event_at timestamp with time zone, past_due_at timestamp with time zone, churn_reason text, - is_above_plan boolean + is_above_plan boolean, + billing_account text DEFAULT 'ee'::text NOT NULL, + CONSTRAINT stripe_info_billing_account_check CHECK ((billing_account = ANY (ARRAY['ee'::text, 'us'::text]))) ); ALTER TABLE ONLY public.stripe_info REPLICA IDENTITY FULL; diff --git a/scripts/backfill_retention_metrics.ts b/scripts/backfill_retention_metrics.ts index 2fb56995cf..d55c8790ab 100644 --- a/scripts/backfill_retention_metrics.ts +++ b/scripts/backfill_retention_metrics.ts @@ -1224,19 +1224,20 @@ async function claimProcessedEventsPg(client: PgClient, movements: BackfillReven const values: string[] = [] const placeholders = chunk.map((movement, index) => { - const offset = index * 3 - values.push(movement.event_id, movement.customer_id, movement.date_id) - return `($${offset + 1}, $${offset + 2}, $${offset + 3})` + const offset = index * 4 + values.push(movement.event_id, movement.customer_id, movement.date_id, 'ee') + return `($${offset + 1}, $${offset + 2}, $${offset + 3}, $${offset + 4})` }) const { rows } = await client.query<{ event_id: string }>(` INSERT INTO public.processed_stripe_events ( event_id, customer_id, - date_id + date_id, + billing_account ) VALUES ${placeholders.join(', ')} - ON CONFLICT (event_id) DO NOTHING + ON CONFLICT (billing_account, event_id) DO NOTHING RETURNING event_id `, values) diff --git a/src/pages/settings/organization/Plans.vue b/src/pages/settings/organization/Plans.vue index f5ec3bff87..6e97a7a59d 100644 --- a/src/pages/settings/organization/Plans.vue +++ b/src/pages/settings/organization/Plans.vue @@ -141,7 +141,7 @@ const isTrial = computed(() => currentOrganization?.value ? (!currentOrganizatio const isCreditsOnly = computed(() => isCreditsOnlyOrg(currentOrganization?.value)) async function prefetchStripeCheckoutUrl(plan: Database['public']['Tables']['plans']['Row'], isYear: boolean) { - if (!plan.stripe_id) + if (!plan.name) return const supabase = useSupabase() const session = await supabase.auth.getSession() @@ -155,7 +155,7 @@ async function prefetchStripeCheckoutUrl(plan: Database['public']['Tables']['pla try { const resp = await invokeCapgoApi('private/stripe_checkout', { body: JSON.stringify({ - priceId: plan.stripe_id, + planName: plan.name, successUrl, cancelUrl, recurrence: isYear ? 'year' : 'month', @@ -252,7 +252,7 @@ async function openChangePlan(plan: Database['public']['Tables']['plans']['Row'] } } else { - const didOpenCheckout = await openCheckout(plan.stripe_id, `${globalThis.location.href}?success=1`, `${globalThis.location.href}?cancel=1`, checkoutIsYearly, currentOrganization?.value?.gid ?? '') + const didOpenCheckout = await openCheckout(plan.name, `${globalThis.location.href}?success=1`, `${globalThis.location.href}?cancel=1`, checkoutIsYearly, currentOrganization?.value?.gid ?? '') if (didOpenCheckout) trackPlanCheckoutStarted(plan, checkoutIsYearly, 'direct') } diff --git a/src/services/stripe.ts b/src/services/stripe.ts index 47ab633055..dd38340dfc 100644 --- a/src/services/stripe.ts +++ b/src/services/stripe.ts @@ -149,7 +149,7 @@ export async function getAffonsoReferral() { } } -export async function openCheckout(priceId: string, successUrl: string, cancelUrl: string, isYear: boolean, orgId: string) { +export async function openCheckout(planName: string, successUrl: string, cancelUrl: string, isYear: boolean, orgId: string) { // console.log('openCheckout') const supabase = useSupabase() const session = await supabase.auth.getSession() @@ -160,7 +160,7 @@ export async function openCheckout(priceId: string, successUrl: string, cancelUr try { const resp = await invokeCapgoApi('private/stripe_checkout', { body: JSON.stringify({ - priceId, + planName, successUrl, cancelUrl, recurrence: isYear ? 'year' : 'month', diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index b312e02870..7d1b2f94e3 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -2686,6 +2686,7 @@ export type Database = { build_time_unit: number created_at: string credit_id: string + credit_id_us: string | null description: string id: string market_desc: string | null @@ -2696,8 +2697,11 @@ export type Database = { price_m_id: string price_y: number price_y_id: string + price_m_id_us: string | null + price_y_id_us: string | null storage: number stripe_id: string + stripe_id_us: string | null updated_at: string } Insert: { @@ -2705,6 +2709,7 @@ export type Database = { build_time_unit?: number created_at?: string credit_id: string + credit_id_us?: string | null description?: string id?: string market_desc?: string | null @@ -2713,10 +2718,13 @@ export type Database = { native_build_concurrency?: number price_m?: number price_m_id: string + price_m_id_us?: string | null price_y?: number price_y_id: string + price_y_id_us?: string | null storage: number stripe_id?: string + stripe_id_us?: string | null updated_at?: string } Update: { @@ -2724,6 +2732,7 @@ export type Database = { build_time_unit?: number created_at?: string credit_id?: string + credit_id_us?: string | null description?: string id?: string market_desc?: string | null @@ -2732,10 +2741,13 @@ export type Database = { native_build_concurrency?: number price_m?: number price_m_id?: string + price_m_id_us?: string | null price_y?: number price_y_id?: string + price_y_id_us?: string | null storage?: number stripe_id?: string + stripe_id_us?: string | null updated_at?: string } Relationships: [] @@ -2766,18 +2778,21 @@ export type Database = { } processed_stripe_events: { Row: { + billing_account: string created_at: string customer_id: string date_id: string event_id: string } Insert: { + billing_account?: string created_at?: string customer_id: string date_id: string event_id: string } Update: { + billing_account?: string created_at?: string customer_id?: string date_id?: string @@ -3091,6 +3106,7 @@ export type Database = { created_at: string customer_country: string | null customer_id: string + billing_account: string id: number is_above_plan: boolean | null is_good_plan: boolean | null @@ -3119,6 +3135,7 @@ export type Database = { created_at?: string customer_country?: string | null customer_id: string + billing_account?: string id?: number is_above_plan?: boolean | null is_good_plan?: boolean | null @@ -3147,6 +3164,7 @@ export type Database = { created_at?: string customer_country?: string | null customer_id?: string + billing_account?: string id?: number is_above_plan?: boolean | null is_good_plan?: boolean | null diff --git a/supabase/functions/.env.example b/supabase/functions/.env.example index 179341c88b..687f53a6c7 100644 --- a/supabase/functions/.env.example +++ b/supabase/functions/.env.example @@ -19,9 +19,14 @@ CF_ACCOUNT_ANALYTICS_ID=*** CF_ANALYTICS_TOKEN=*** # To ignore apps that can be used with the API LIMITED_APPS=[{"id": "***.**.**", "ignore": 1}] -# Stripe +# Stripe (EE account — existing production billing) STRIPE_WEBHOOK_SECRET=test STRIPE_SECRET_KEY=test +# Optional US Stripe account scaffolding (leave unset until US billing is enabled) +# STRIPE_SECRET_KEY_US= +# STRIPE_WEBHOOK_SECRET_US= +# New org billing account default: ee (unset = ee). Requires US secrets when set to us. +# STRIPE_NEW_CUSTOMERS_ACCOUNT=ee # Sentry if omitted, errors will not be sent SENTRY_DSN= # Bento to connect email service and send marketing and transactional emails diff --git a/supabase/functions/_backend/private/credits.ts b/supabase/functions/_backend/private/credits.ts index 2c2ea5aae7..af61df4a40 100644 --- a/supabase/functions/_backend/private/credits.ts +++ b/supabase/functions/_backend/private/credits.ts @@ -14,6 +14,7 @@ import { getClaimsFromJWT, middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { checkPermission } from '../utils/rbac.ts' import { createOneTimeCheckout, getCreditCheckoutDetails, getStripe, isStripeEmulatorEnabled } from '../utils/stripe.ts' +import { normalizeBillingAccount, planProductIdOrFilter, resolveBillingAccount, resolvePlanCreditId } from '../utils/stripe_billing_account.ts' import { supabaseAdmin, supabaseClient } from '../utils/supabase.ts' import { getEnv } from '../utils/utils.ts' @@ -234,7 +235,7 @@ async function getCreditTopUpProductId(c: AppContext, customerId: string, token: const supabase = supabaseClient(c, token) const { data: stripeInfo, error: stripeInfoError } = await supabase .from('stripe_info') - .select('product_id') + .select('product_id, billing_account') .eq('customer_id', customerId) .single() @@ -259,13 +260,15 @@ async function getCreditTopUpProductId(c: AppContext, customerId: string, token: return { productId } } + const account = normalizeBillingAccount(stripeInfo.billing_account) const { data: plan, error: planError } = await supabase .from('plans') - .select('credit_id, name') - .eq('stripe_id', stripeInfo.product_id) + .select('credit_id, credit_id_us, name') + .or(planProductIdOrFilter(stripeInfo.product_id)) .single() - if (planError || !plan?.credit_id) { + const creditId = plan ? resolvePlanCreditId(plan, account) : null + if (planError || !creditId) { cloudlogErr({ requestId: c.get('requestId'), message: 'credit_top_up_product_missing', @@ -286,7 +289,7 @@ async function getCreditTopUpProductId(c: AppContext, customerId: string, token: return { productId } } - return { productId: plan.credit_id } + return { productId: creditId } } async function resolveOrgStripeContext(c: AppContext, orgId: string) { @@ -593,7 +596,8 @@ app.post('/complete-top-up', middlewareAuth, async (c) => { const { customerId, token } = await resolveOrgStripeContext(c, body.orgId) const supabase = supabaseClient(c, token) - const stripe = getStripe(c) + const account = await resolveBillingAccount(c, customerId) + const stripe = getStripe(c, account) const session = await resolveCheckoutSession(c, stripe, supabase, body.orgId, customerId, body.sessionId) const resolvedSessionId = session.id @@ -609,7 +613,7 @@ app.post('/complete-top-up', middlewareAuth, async (c) => { const { productId } = await getCreditTopUpProductId(c, customerId, token) const paymentIntentId = getCheckoutSessionPaymentIntentId(session) - const { creditQuantity, itemsSummary } = await getCreditCheckoutDetails(c, session, productId) + const { creditQuantity, itemsSummary } = await getCreditCheckoutDetails(c, session, productId, customerId) if (creditQuantity <= 0) throw simpleError('credit_product_not_found', 'Checkout session does not include the credit product') diff --git a/supabase/functions/_backend/private/stripe_checkout.ts b/supabase/functions/_backend/private/stripe_checkout.ts index 0e5b791737..e0e5ddd163 100644 --- a/supabase/functions/_backend/private/stripe_checkout.ts +++ b/supabase/functions/_backend/private/stripe_checkout.ts @@ -5,11 +5,13 @@ import { middlewareAuth } from '../utils/hono_jwt.ts' import { cloudlog } from '../utils/logging.ts' import { checkPermission } from '../utils/rbac.ts' import { createCheckout } from '../utils/stripe.ts' -import { supabaseClient } from '../utils/supabase.ts' +import { resolveBillingAccount, resolvePlanProductId } from '../utils/stripe_billing_account.ts' +import { supabaseAdmin, supabaseClient } from '../utils/supabase.ts' import { getEnv } from '../utils/utils.ts' interface CheckoutData { - priceId: string + priceId?: string + planName?: string clientReferenceId?: string recurrence: 'month' | 'year' attributionId?: string @@ -25,6 +27,27 @@ export const app = new Hono() app.use('/', useCors) +async function resolveCheckoutPlanProductId(c: Parameters[0], customerId: string, body: CheckoutData) { + if (body.planName) { + const account = await resolveBillingAccount(c, customerId) + const { data: plan, error } = await supabaseAdmin(c) + .from('plans') + .select('stripe_id, stripe_id_us, name') + .eq('name', body.planName) + .single() + + if (error || !plan) + throw simpleError('invalid_plan', 'Invalid plan', { planName: body.planName }) + + return resolvePlanProductId(plan, account) + } + + if (body.priceId) + return body.priceId + + throw simpleError('no_plan_provided', 'No plan provided') +} + app.post('/', middlewareAuth, async (c) => { const body = await parseBody(c) cloudlog({ requestId: c.get('requestId'), message: 'post stripe checkout body', body }) @@ -58,8 +81,10 @@ app.post('/', middlewareAuth, async (c) => { if (!await checkPermission(c, 'org.update_billing', { orgId: body.orgId })) throw simpleError('not_authorize', 'Not authorize') + const planProductId = await resolveCheckoutPlanProductId(c, org.customer_id, body) + cloudlog({ requestId: c.get('requestId'), message: 'user', org }) - const checkout = await createCheckout(c, org.customer_id, body.recurrence ?? 'month', body.priceId ?? 'price_1KkINoGH46eYKnWwwEi97h1B', body.successUrl ?? `${getEnv(c, 'WEBAPP_URL')}/app/usage`, body.cancelUrl ?? `${getEnv(c, 'WEBAPP_URL')}/app/usage`, body.clientReferenceId, body.attributionId, { + const checkout = await createCheckout(c, org.customer_id, body.recurrence ?? 'month', planProductId, body.successUrl ?? `${getEnv(c, 'WEBAPP_URL')}/app/usage`, body.cancelUrl ?? `${getEnv(c, 'WEBAPP_URL')}/app/usage`, body.clientReferenceId, body.attributionId, { visitorId: body.datafastVisitorId, sessionId: body.datafastSessionId, }, body.affonsoReferral) diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index 46614b69b6..d1542ca45b 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -8,6 +8,10 @@ import { closeClient, getPgClient } from '../../utils/pg.ts' import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' import { parseOrgOnboardingIntent } from '../../utils/org_onboarding_intent.ts' +import { + resolveNewOrgBillingAccount, + resolvePlanProductId, +} from '../../utils/stripe_billing_account.ts' import { normalizeWebsiteUrl } from './website.ts' const MAX_ESTIMATED_MAU = 1_000_000 @@ -24,6 +28,7 @@ const bodySchema = z.object({ website: z.string().optional(), intent: z.enum(['ota', 'builder', 'both', 'exploring', 'unknown']).optional(), startingOut: z.boolean().optional(), + country: z.string().max(2).optional(), }) @@ -36,7 +41,7 @@ async function getInitialPlanForMau(c: Context, estimate const adminClient = supabaseAdmin(c) const { data: plan, error } = await adminClient .from('plans') - .select('name, stripe_id, mau') + .select('name, stripe_id, stripe_id_us, mau') .gte('mau', estimatedMau) .order('mau', { ascending: true }) .limit(1) @@ -49,7 +54,13 @@ async function getInitialPlanForMau(c: Context, estimate return plan } -async function createPendingStripeInfo(c: Context, orgId: string, estimatedMau: number) { +async function createPendingStripeInfo( + c: Context, + orgId: string, + estimatedMau: number, + country?: string | null, +) { + const billingAccount = resolveNewOrgBillingAccount(c, country) const plan = await getInitialPlanForMau(c, estimatedMau) const pendingCustomerId = `pending_${orgId}` const trialAt = new Date() @@ -59,7 +70,8 @@ async function createPendingStripeInfo(c: Context, orgId .from('stripe_info') .insert({ customer_id: pendingCustomerId, - product_id: plan.stripe_id, + product_id: resolvePlanProductId(plan, billingAccount), + billing_account: billingAccount, trial_at: trialAt.toISOString(), status: null, is_good_plan: true, @@ -72,11 +84,11 @@ async function createPendingStripeInfo(c: Context, orgId return pendingCustomerId } -async function getOwnerEmail(c: Context, auth: AuthInfo) { +async function getOwnerProfile(c: Context, auth: AuthInfo) { if (auth.authType === 'jwt') { const { data: self, error } = await supabaseWithAuth(c, auth) .from('users') - .select('email') + .select('email, country') .eq('id', auth.userId) .single() @@ -84,22 +96,22 @@ async function getOwnerEmail(c: Context, auth: AuthInfo) throw simpleError('cannot_get_user', 'Cannot get user', { error: error?.message }) } - return self.email + return { email: self.email, country: self.country } } let pgClient try { pgClient = getPgClient(c) - const result = await pgClient.query<{ email: string }>( - 'SELECT email FROM public.users WHERE id = $1::uuid LIMIT 1', + const result = await pgClient.query<{ email: string, country: string | null }>( + 'SELECT email, country FROM public.users WHERE id = $1::uuid LIMIT 1', [auth.userId], ) - const email = result.rows[0]?.email - if (!email) { + const profile = result.rows[0] + if (!profile?.email) { throw simpleError('cannot_get_user', 'Cannot get user') } - return email + return { email: profile.email, country: profile.country } } finally { if (pgClient) { @@ -268,9 +280,9 @@ export async function post( } await ensureApiKeyCanCreateOrganization(c, auth) - const ownerEmail = await getOwnerEmail(c, auth) + const ownerProfile = await getOwnerProfile(c, auth) const orgId = crypto.randomUUID() - const pendingCustomerId = await createPendingStripeInfo(c, orgId, estimatedMau) + const pendingCustomerId = await createPendingStripeInfo(c, orgId, estimatedMau, body.country ?? ownerProfile.country) const onboarding = { intent: parseOrgOnboardingIntent({ intent: body.intent }), starting_out: body.startingOut ?? false, @@ -279,7 +291,7 @@ export async function post( id: orgId, name: body.name, created_by: auth.userId, - management_email: body.email ?? ownerEmail, + management_email: body.email ?? ownerProfile.email, customer_id: pendingCustomerId, website, onboarding, diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts index 8de9aae133..4e9a89e637 100644 --- a/supabase/functions/_backend/triggers/stripe_event.ts +++ b/supabase/functions/_backend/triggers/stripe_event.ts @@ -17,10 +17,11 @@ import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' import * as schema from '../utils/postgres_schema.ts' import { groupIdentifyPosthog } from '../utils/posthog.ts' import { ensureCustomerMetadata, getCreditCheckoutDetails, getStripe, syncStripeCustomerCountry } from '../utils/stripe.ts' +import { DEFAULT_BILLING_ACCOUNT, isStripeAccountConfigured, resolveBillingAccount } from '../utils/stripe_billing_account.ts' import { buildTransferInvoiceFooter, getTransferInvoiceFooterUpdate, isTransferInvoice, normalizeBillingEmail, shouldStampTransferInvoiceFooter, TRANSFER_INVOICE_FOOTER, TRANSFER_INVOICE_FOOTER_MAX_LENGTH } from '../utils/stripe_event.ts' import { customerToSegmentOrg, supabaseAdmin } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' -import { backgroundTask, isStripeConfigured } from '../utils/utils.ts' +import { backgroundTask } from '../utils/utils.ts' export const app = new Hono() @@ -314,10 +315,14 @@ async function lookupOrgCreatorEmail( } async function retrieveStripeCustomerBillingEmail(c: Context, customerId: string): Promise { - if (!customerId || !isStripeConfigured(c)) + if (!customerId) return null - const customer = await getStripe(c).customers.retrieve(customerId) + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) + return null + + const customer = await getStripe(c, account).customers.retrieve(customerId) if ('deleted' in customer && customer.deleted) return null @@ -639,6 +644,8 @@ async function persistStripeInfoAndRevenueMovement( } const shouldRecordMovement = hasRevenueMovement(movement) + const billingAccount = c.get('billingAccount') ?? DEFAULT_BILLING_ACCOUNT + if (Object.keys(transactionUpdateData).length === 0 && !shouldRecordMovement) return 'applied' @@ -681,13 +688,15 @@ async function persistStripeInfoAndRevenueMovement( if (shouldRecordMovement) { const processedEvent = await pgClient.query(` INSERT INTO public.processed_stripe_events ( + billing_account, event_id, customer_id, date_id ) - VALUES ($1, $2, $3) - ON CONFLICT (event_id) DO NOTHING + VALUES ($1, $2, $3, $4) + ON CONFLICT (billing_account, event_id) DO NOTHING `, [ + billingAccount, stripeEventId, customerId, getEventDateId(eventOccurredAtIso), @@ -1451,7 +1460,7 @@ async function cancelingOrFinished( return c.json(BRES) } -app.post('/', middlewareStripeWebhook(), async (c) => { +async function handleStripeEvent(c: Context) { const stripeData = c.get('stripeData')! const stripeEvent = c.get('stripeEvent')! const isCheckoutSession = isCheckoutSessionEvent(stripeEvent) @@ -1604,7 +1613,12 @@ app.post('/', middlewareStripeWebhook(), async (c) => { } } return cancelingOrFinished(c, stripeEvent, stripeData.data, customer) -}) +} + +app.post('/', middlewareStripeWebhook('ee'), handleStripeEvent) + +export const appUs = new Hono() +appUs.post('/', middlewareStripeWebhook('us'), handleStripeEvent) export const stripeEventTestUtils = { BENTO_CHARGE_SUCCEEDED_EVENT, diff --git a/supabase/functions/_backend/utils/credit_auto_top_up.ts b/supabase/functions/_backend/utils/credit_auto_top_up.ts index 3344e187c7..973a3298b3 100644 --- a/supabase/functions/_backend/utils/credit_auto_top_up.ts +++ b/supabase/functions/_backend/utils/credit_auto_top_up.ts @@ -3,8 +3,8 @@ import Stripe from 'stripe' import { getFallbackCreditProductId } from './credits.ts' import { cloudlog, cloudlogErr } from './logging.ts' import { getOneTimePriceId, getStripe, isStripeEmulatorEnabled } from './stripe.ts' +import { isStripeAccountConfigured, normalizeBillingAccount, planProductIdOrFilter, resolveBillingAccount, resolvePlanCreditId } from './stripe_billing_account.ts' import { supabaseAdmin } from './supabase.ts' -import { isStripeConfigured } from './utils.ts' export const MIN_AUTO_TOP_UP_THRESHOLD = 10 export const AUTO_TOP_UP_KIND = 'credit_auto_top_up' @@ -66,7 +66,8 @@ async function getAvailableCredits(c: Context, orgId: string): Promise { } export async function customerHasSavedPaymentMethod(c: Context, customerId: string): Promise { - if (!isStripeConfigured(c)) + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return false try { return Boolean(await getDefaultPaymentMethodId(c, customerId)) @@ -78,7 +79,8 @@ export async function customerHasSavedPaymentMethod(c: Context, customerId: stri } async function getDefaultPaymentMethodId(c: Context, customerId: string): Promise { - const stripe = getStripe(c) + const account = await resolveBillingAccount(c, customerId) + const stripe = getStripe(c, account) const customer = await stripe.customers.retrieve(customerId) if (customer.deleted) return null @@ -118,23 +120,25 @@ async function getCreditProductIdForCustomer(c: Context, customerId: string): Pr const { data: stripeInfo, error: stripeInfoError } = await supabaseAdmin(c) .from('stripe_info') - .select('product_id') + .select('product_id, billing_account') .eq('customer_id', customerId) .maybeSingle() if (stripeInfoError || !stripeInfo?.product_id) return await getFallbackCreditProductId(c, customerId, loadSoloPlan) + const account = normalizeBillingAccount(stripeInfo.billing_account) const { data: plan, error: planError } = await supabaseAdmin(c) .from('plans') - .select('credit_id, name') - .eq('stripe_id', stripeInfo.product_id) + .select('credit_id, credit_id_us, name') + .or(planProductIdOrFilter(stripeInfo.product_id)) .maybeSingle() - if (planError || !plan?.credit_id) + const creditId = plan ? resolvePlanCreditId(plan, account) : null + if (planError || !creditId) return await getFallbackCreditProductId(c, customerId, loadSoloPlan) - return plan.credit_id + return creditId } export async function grantCreditsFromAutoTopUpPayment( @@ -182,14 +186,15 @@ async function chargeOffSessionCredits( return null } + const account = await resolveBillingAccount(c, customerId) const productId = await getCreditProductIdForCustomer(c, customerId) - const priceId = await getOneTimePriceId(c, productId) + const priceId = await getOneTimePriceId(c, productId, account) if (!priceId) { cloudlogErr({ requestId: c.get('requestId'), message: 'credit_auto_top_up_missing_price', orgId, productId }) return null } - const stripe = getStripe(c) + const stripe = getStripe(c, account) const price = await stripe.prices.retrieve(priceId) const unitAmount = price.unit_amount if (!unitAmount || unitAmount <= 0) { @@ -296,7 +301,17 @@ export async function saveAutoTopUpSettings( } export async function maybeAutoTopUpCredits(c: Context, orgId: string): Promise { - if (!isStripeConfigured(c)) + const { data: org, error: orgError } = await supabaseAdmin(c) + .from('orgs') + .select('customer_id') + .eq('id', orgId) + .maybeSingle() + + if (orgError || !org?.customer_id) + return + + const account = await resolveBillingAccount(c, org.customer_id) + if (!isStripeAccountConfigured(c, account)) return const { data: claim, error: claimError } = await supabaseAdmin(c) diff --git a/supabase/functions/_backend/utils/hono_middleware_stripe.ts b/supabase/functions/_backend/utils/hono_middleware_stripe.ts index e8d1bd527a..a4c6185ff8 100644 --- a/supabase/functions/_backend/utils/hono_middleware_stripe.ts +++ b/supabase/functions/_backend/utils/hono_middleware_stripe.ts @@ -5,39 +5,42 @@ import { createFactory } from 'hono/factory' import { simpleError } from './hono.ts' import { cloudlog } from './logging.ts' import { extractDataEvent, parseStripeEvent } from './stripe_event.ts' -import { getEnv } from './utils.ts' +import { type BillingAccount, DEFAULT_BILLING_ACCOUNT, getStripeWebhookSecret } from './stripe_billing_account.ts' export interface MiddlewareKeyVariablesStripe { Bindings: Bindings Variables: { stripeEvent?: Stripe.Event stripeData?: StripeData + billingAccount?: BillingAccount } } export const honoFactory = createFactory() -export function middlewareStripeWebhook() { +export function middlewareStripeWebhook(billingAccount: BillingAccount = DEFAULT_BILLING_ACCOUNT) { return honoFactory.createMiddleware(async (c, next) => { - if (!getEnv(c, 'STRIPE_WEBHOOK_SECRET')) { - cloudlog({ requestId: c.get('requestId'), message: 'Webhook Error: no secret found' }) - throw simpleError('webhook_error_no_secret', 'Webhook Error: no secret found') + const webhookSecret = getStripeWebhookSecret(c, billingAccount) + if (!webhookSecret) { + cloudlog({ requestId: c.get('requestId'), message: 'Webhook Error: no secret found', billingAccount }) + throw simpleError('webhook_error_no_secret', 'Webhook Error: no secret found', { billing_account: billingAccount }) } const signature = c.req.raw.headers.get('stripe-signature') - if (!signature || !getEnv(c, 'STRIPE_WEBHOOK_SECRET')) { - cloudlog({ requestId: c.get('requestId'), message: 'Webhook Error: no signature' }) - throw simpleError('webhook_error_no_signature', 'Webhook Error: no signature') + if (!signature) { + cloudlog({ requestId: c.get('requestId'), message: 'Webhook Error: no signature', billingAccount }) + throw simpleError('webhook_error_no_signature', 'Webhook Error: no signature', { billing_account: billingAccount }) } const body = await c.req.text() - const stripeEvent = await parseStripeEvent(c, body, signature) + const stripeEvent = await parseStripeEvent(c, body, signature, billingAccount) const stripeDataEvent = extractDataEvent(c, stripeEvent) const stripeData = stripeDataEvent.data if (stripeData.customer_id === '') { - cloudlog({ requestId: c.get('requestId'), message: 'Webhook Error: no customer found' }) - throw simpleError('webhook_error_no_customer', 'Webhook Error: no customer found') + cloudlog({ requestId: c.get('requestId'), message: 'Webhook Error: no customer found', billingAccount }) + throw simpleError('webhook_error_no_customer', 'Webhook Error: no customer found', { billing_account: billingAccount }) } c.set('stripeEvent', stripeEvent) c.set('stripeData', stripeDataEvent) + c.set('billingAccount', billingAccount) await next() }) } diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index 7af2f5fa71..fd4027c25d 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -175,6 +175,7 @@ export const users = pgTable('users', { export const stripe_info = pgTable('stripe_info', { id: bigint('id', { mode: 'number' }).primaryKey().notNull(), customer_id: text('customer_id'), + billing_account: text('billing_account').notNull().default('ee'), customer_country: varchar('customer_country', { length: 2 }), product_id: varchar('product_id'), status: text('status'), @@ -191,7 +192,11 @@ export const plans = pgTable('plans', { id: uuid('id').primaryKey().notNull(), name: varchar('name').notNull(), stripe_id: varchar('stripe_id').notNull(), + stripe_id_us: varchar('stripe_id_us'), + price_m_id_us: varchar('price_m_id_us'), + price_y_id_us: varchar('price_y_id_us'), credit_id: text('credit_id').notNull(), + credit_id_us: text('credit_id_us'), native_build_concurrency: integer('native_build_concurrency').notNull().default(2), }) diff --git a/supabase/functions/_backend/utils/stripe.ts b/supabase/functions/_backend/utils/stripe.ts index b2391480a8..efd3068815 100644 --- a/supabase/functions/_backend/utils/stripe.ts +++ b/supabase/functions/_backend/utils/stripe.ts @@ -3,6 +3,18 @@ import type { Database } from './supabase.types.ts' import Stripe from 'stripe' import { simpleError } from './hono.ts' import { cloudlog, cloudlogErr } from './logging.ts' +import type { BillingAccount } from './stripe_billing_account.ts' +import { + DEFAULT_BILLING_ACCOUNT, + assertStripeAccountConfigured, + getStripeSecretKey, + isStripeAccountConfigured, + planProductIdOrFilter, + resolveBillingAccount, + resolvePlanPriceId, +} from './stripe_billing_account.ts' + +export type { BillingAccount } from './stripe_billing_account.ts' import { supabaseAdmin } from './supabase.ts' import { getEnv, isStripeConfigured, trimTrailingSlashes } from './utils.ts' @@ -54,8 +66,8 @@ function buildSupabaseDashboardLink(c: Context, customerId: string): string | nu export type StripeEnvironment = 'live' | 'test' -export function resolveStripeEnvironment(c: Context): StripeEnvironment { - const secretKey = getEnv(c, 'STRIPE_SECRET_KEY') || '' +export function resolveStripeEnvironment(c: Context, account: BillingAccount = DEFAULT_BILLING_ACCOUNT): StripeEnvironment { + const secretKey = getStripeSecretKey(c, account) if (secretKey.startsWith('sk_live') || secretKey.startsWith('rk_live')) return 'live' return 'test' @@ -89,14 +101,15 @@ export function isStripeEmulatorEnabled(c: Context): boolean { return getStripeApiBaseUrl(c) !== null } -export function getStripe(c: Context): Stripe { +export function getStripe(c: Context, account: BillingAccount = DEFAULT_BILLING_ACCOUNT): Stripe { + assertStripeAccountConfigured(c, account) const apiBaseUrl = getStripeApiBaseUrl(c) const apiPort = apiBaseUrl ? Number.parseInt(apiBaseUrl.port || (apiBaseUrl.protocol === 'https:' ? '443' : '80'), 10) : undefined type StripeApiVersion = NonNullable[1]>['apiVersion'] - return new Stripe(getEnv(c, 'STRIPE_SECRET_KEY'), { + return new Stripe(getStripeSecretKey(c, account), { // Keep the pinned runtime API version even when the installed SDK types lag behind it. apiVersion: '2026-03-25.dahlia' as StripeApiVersion, httpClient: Stripe.createFetchHttpClient(), @@ -110,6 +123,11 @@ export function getStripe(c: Context): Stripe { }) } +async function stripeForCustomer(c: Context, customerId: string) { + const account = await resolveBillingAccount(c, customerId) + return getStripe(c, account) +} + function getLicensedSubscriptionItem(items: Stripe.SubscriptionItem[] | undefined) { return items?.find(item => item.plan.usage_type === 'licensed') ?? items?.[0] ?? null } @@ -146,7 +164,8 @@ export async function getSubscriptionData(c: Context, customerId: string, subscr cloudlog({ requestId: c.get('requestId'), message: 'Fetching subscription data', customerId, subscriptionId }) // Retrieve the specific subscription from Stripe - const subscription = await getStripe(c).subscriptions.retrieve(subscriptionId, { + const stripe = await stripeForCustomer(c, customerId) + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['items.data.price'], // Correct expand path for retrieve }) @@ -189,14 +208,22 @@ export async function getSubscriptionData(c: Context, customerId: string, subscr /** * Fetches cancellation details for a Stripe subscription, if available. */ -export async function getCancellationDetails(c: Context, subscriptionId: string | null): Promise { +export async function getCancellationDetails( + c: Context, + subscriptionId: string | null, + customerId?: string | null, +): Promise { if (!subscriptionId) return null - if (!isStripeConfigured(c)) + + const account = customerId + ? await resolveBillingAccount(c, customerId) + : DEFAULT_BILLING_ACCOUNT + if (!isStripeAccountConfigured(c, account)) return null try { - const subscription = await getStripe(c).subscriptions.retrieve(subscriptionId) + const subscription = await getStripe(c, account).subscriptions.retrieve(subscriptionId) return subscription.cancellation_details ?? null } catch (error) { @@ -208,8 +235,9 @@ export async function getCancellationDetails(c: Context, subscriptionId: string async function getActiveSubscription(c: Context, customerId: string, subscriptionId: string | null) { cloudlog({ requestId: c.get('requestId'), message: 'Stored subscription not tracked or not found, checking for others.', customerId, storedSubscriptionId: subscriptionId }) + const stripe = await stripeForCustomer(c, customerId) for (const status of TRACKED_STRIPE_SUBSCRIPTION_STATUSES) { - const subscriptions = await getStripe(c).subscriptions.list({ + const subscriptions = await stripe.subscriptions.list({ customer: customerId, status, limit: 1, @@ -227,7 +255,8 @@ async function getActiveSubscription(c: Context, customerId: string, subscriptio } export async function syncSubscriptionData(c: Context, customerId: string, subscriptionId: string | null): Promise { - if (!isStripeConfigured(c)) + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return try { // Get subscription data from Stripe using the ID stored in our DB @@ -312,35 +341,41 @@ export async function syncSubscriptionData(c: Context, customerId: string, subsc } export async function createPortal(c: Context, customerId: string, callbackUrl: string) { - if (!isStripeConfigured(c)) + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return { url: '' } const allowedReturnUrl = getAllowedRedirectUrl(c, callbackUrl, 'return_url') - const session = await getStripe(c).billingPortal.sessions.create({ + const session = await getStripe(c, account).billingPortal.sessions.create({ customer: customerId, return_url: allowedReturnUrl, }) return { url: session.url } } -export function updateCustomerEmail(c: Context, customerId: string, newEmail: string) { - if (!isStripeConfigured(c)) - return Promise.resolve() - return getStripe(c).customers.update(customerId, { email: newEmail, metadata: { email: newEmail } }, - ) +export async function updateCustomerEmail(c: Context, customerId: string, newEmail: string) { + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) + return + return getStripe(c, account).customers.update(customerId, { email: newEmail, metadata: { email: newEmail } }) } -export function updateCustomerOrganizationName(c: Context, customerId: string, newName: string) { - if (!isStripeConfigured(c)) - return Promise.resolve() - return getStripe(c).customers.update(customerId, { name: newName }) +export async function updateCustomerOrganizationName(c: Context, customerId: string, newName: string) { + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) + return + return getStripe(c, account).customers.update(customerId, { name: newName }) } export async function getStripeCustomerName(c: Context, customerId: string | null | undefined): Promise { - if (!customerId || !isStripeConfigured(c)) + if (!customerId) + return undefined + + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return undefined try { - const customer = await getStripe(c).customers.retrieve(customerId) + const customer = await getStripe(c, account).customers.retrieve(customerId) if (customer.deleted) return null return customer.name ?? null @@ -370,11 +405,15 @@ export function normalizeStripeCountryCode(country: string | null | undefined): } export async function getStripeCustomerCountry(c: Context, customerId: string | null | undefined): Promise { - if (!customerId || !isStripeConfigured(c)) + if (!customerId) + return undefined + + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return undefined try { - const customer = await getStripe(c).customers.retrieve(customerId) + const customer = await getStripe(c, account).customers.retrieve(customerId) if (customer.deleted) return null return normalizeStripeCountryCode(customer.address?.country ?? null) @@ -386,7 +425,11 @@ export async function getStripeCustomerCountry(c: Context, customerId: string | } export async function syncStripeCustomerCountry(c: Context, customerId: string | null | undefined): Promise { - if (!customerId || !isStripeConfigured(c)) + if (!customerId) + return undefined + + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return undefined const customerCountry = await getStripeCustomerCountry(c, customerId) @@ -410,16 +453,18 @@ export async function syncStripeCustomerCountry(c: Context, customerId: string | } export async function cancelSubscription(c: Context, customerId: string) { - if (!isStripeConfigured(c)) + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return + const stripe = getStripe(c, account) let succeeded = true - for await (const subscription of getStripe(c).subscriptions.list({ customer: customerId, status: 'all' })) { + for await (const subscription of stripe.subscriptions.list({ customer: customerId, status: 'all' })) { if (subscription.status === 'canceled' || subscription.status === 'incomplete_expired') continue try { - await getStripe(c).subscriptions.cancel(subscription.id) + await stripe.subscriptions.cancel(subscription.id) } catch (error) { succeeded = false @@ -429,12 +474,12 @@ export async function cancelSubscription(c: Context, customerId: string) { return succeeded } -async function getStoredPlanPriceId(c: Context, planId: string, recurrence: string): Promise { +async function getStoredPlanPriceId(c: Context, planId: string, recurrence: string, account: BillingAccount): Promise { try { const { data, error } = await supabaseAdmin(c) .from('plans') - .select('price_m_id, price_y_id') - .eq('stripe_id', planId) + .select('price_m_id, price_y_id, price_m_id_us, price_y_id_us, stripe_id, stripe_id_us') + .or(planProductIdOrFilter(planId)) .single() if (error) { @@ -442,7 +487,7 @@ async function getStoredPlanPriceId(c: Context, planId: string, recurrence: stri return null } - return recurrence === 'year' ? data.price_y_id : data.price_m_id + return resolvePlanPriceId(data, account, recurrence) } catch (error) { cloudlogErr({ requestId: c.get('requestId'), message: 'getStoredPlanPriceId', planId, recurrence, error }) @@ -450,12 +495,12 @@ async function getStoredPlanPriceId(c: Context, planId: string, recurrence: stri } } -async function getPriceIds(c: Context, planId: string, recurrence: string): Promise<{ priceId: string | null }> { +async function getPriceIds(c: Context, planId: string, recurrence: string, account: BillingAccount): Promise<{ priceId: string | null }> { let priceId = null - if (!isStripeConfigured(c)) + if (!isStripeAccountConfigured(c, account)) return { priceId } try { - const prices = await listPricesByProduct(c, planId) + const prices = await listPricesByProduct(c, planId, account) cloudlog({ requestId: c.get('requestId'), message: 'prices stripe', prices }) prices.data.forEach((price) => { if (price.recurring?.interval === recurrence && price.active && price.recurring?.usage_type === 'licensed') @@ -466,7 +511,7 @@ async function getPriceIds(c: Context, planId: string, recurrence: string): Prom cloudlog({ requestId: c.get('requestId'), message: 'search err', error: err }) } if (!priceId) { - priceId = await getStoredPlanPriceId(c, planId, recurrence) + priceId = await getStoredPlanPriceId(c, planId, recurrence, account) cloudlog({ requestId: c.get('requestId'), message: 'prices fallback', planId, recurrence, priceId }) } return { priceId } @@ -542,9 +587,10 @@ function getAffonsoReferralMetadata(affonsoReferral?: string | null): Record { - if (!isStripeConfigured(c)) +export async function getOneTimePriceId(c: Context, productId: string, account: BillingAccount = DEFAULT_BILLING_ACCOUNT): Promise { + if (!isStripeAccountConfigured(c, account)) return null try { - const prices = await listPricesByProduct(c, productId, true) + const prices = await listPricesByProduct(c, productId, account, true) for (const price of prices.data) { if (price.type === 'one_time' && price.active) @@ -616,10 +662,11 @@ export async function createOneTimeCheckout( datafastAttribution?: DatafastAttribution, affonsoReferral?: string | null, ) { - if (!isStripeConfigured(c)) + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return { url: '' } - const priceId = await getOneTimePriceId(c, productId) + const priceId = await getOneTimePriceId(c, productId, account) if (!priceId) throw new Error(`Cannot find one-time price for product ${productId}`) @@ -627,7 +674,7 @@ export async function createOneTimeCheckout( const allowedCancelUrl = getAllowedRedirectUrl(c, cancelUrl, 'cancel_url') const successUrlWithFlag = allowedSuccessUrl.includes('?') ? `${allowedSuccessUrl}&success=true` : `${allowedSuccessUrl}?success=true` - const session = await getStripe(c).checkout.sessions.create({ + const session = await getStripe(c, account).checkout.sessions.create({ billing_address_collection: 'auto', mode: 'payment', customer: customerId, @@ -675,9 +722,20 @@ export async function createOneTimeCheckout( return { url: session.url } } -export async function getCreditCheckoutDetails(c: Context, session: Stripe.Checkout.Session, expectedProductId: string): Promise { +export async function getCreditCheckoutDetails( + c: Context, + session: Stripe.Checkout.Session, + expectedProductId: string, + customerId?: string | null, +): Promise { + const stripeCustomerId = customerId + ?? (typeof session.customer === 'string' ? session.customer : session.customer?.id ?? '') + const account = stripeCustomerId + ? await resolveBillingAccount(c, stripeCustomerId) + : DEFAULT_BILLING_ACCOUNT + try { - const lineItems = await getStripe(c).checkout.sessions.listLineItems(session.id, { + const lineItems = await getStripe(c, account).checkout.sessions.listLineItems(session.id, { expand: ['data.price.product'], limit: 100, }) @@ -776,8 +834,15 @@ export interface StripeCustomer { } } -export async function createCustomer(c: Context, email: string, userId: string, orgId: string, name: string) { - cloudlog({ requestId: c.get('requestId'), message: 'createCustomer', email, userId, orgId, name }) +export async function createCustomer( + c: Context, + email: string, + userId: string, + orgId: string, + name: string, + billingAccount: BillingAccount = DEFAULT_BILLING_ACCOUNT, +) { + cloudlog({ requestId: c.get('requestId'), message: 'createCustomer', email, userId, orgId, name, billingAccount }) const baseConsoleUrl = trimTrailingSlashes(getEnv(c, 'WEBAPP_URL') || '') const metadata: Record = { user_id: userId, @@ -786,13 +851,14 @@ export async function createCustomer(c: Context, email: string, userId: string, if (baseConsoleUrl) { metadata.log_as = `${baseConsoleUrl}/log-as/${userId}` } - if (!isStripeConfigured(c)) { - cloudlog({ requestId: c.get('requestId'), message: 'createCustomer no stripe key', email, userId, name }) + if (!isStripeAccountConfigured(c, billingAccount)) { + cloudlog({ requestId: c.get('requestId'), message: 'createCustomer no stripe key', email, userId, name, billingAccount }) // create a fake customer id like stripe one and random id const randomId = crypto.randomUUID().replaceAll('-', '').slice(0, 24) return { id: `cus_${randomId}`, email, name, metadata } } - const customer = await getStripe(c).customers.create({ + const stripe = getStripe(c, billingAccount) + const customer = await stripe.customers.create({ email, name, metadata, @@ -801,7 +867,7 @@ export async function createCustomer(c: Context, email: string, userId: string, const supabaseLink = buildSupabaseDashboardLink(c, customer.id) if (supabaseLink) { metadata.supabase = supabaseLink - await getStripe(c).customers.update(customer.id, { metadata }) + await stripe.customers.update(customer.id, { metadata }) } return customer } @@ -809,7 +875,9 @@ export async function createCustomer(c: Context, email: string, userId: string, export async function ensureCustomerMetadata(c: Context, customerId: string, orgId: string, userId?: string | null) { if (!customerId) return - if (!isStripeConfigured(c)) + + const account = await resolveBillingAccount(c, customerId) + if (!isStripeAccountConfigured(c, account)) return const baseConsoleUrl = trimTrailingSlashes(getEnv(c, 'WEBAPP_URL') || '') @@ -828,17 +896,17 @@ export async function ensureCustomerMetadata(c: Context, customerId: string, org metadata.supabase = supabaseLink try { - await getStripe(c).customers.update(customerId, { metadata }) + await getStripe(c, account).customers.update(customerId, { metadata }) } catch (error) { cloudlogErr({ requestId: c.get('requestId'), message: 'ensureCustomerMetadata', error }) } } -export async function removeOldSubscription(c: Context, subscriptionId: string) { - if (!isStripeConfigured(c)) +export async function removeOldSubscription(c: Context, subscriptionId: string, account: BillingAccount = DEFAULT_BILLING_ACCOUNT) { + if (!isStripeAccountConfigured(c, account)) return Promise.resolve() - cloudlog({ requestId: c.get('requestId'), message: 'removeOldSubscription', id: subscriptionId }) - const deletedSubscription = await getStripe(c).subscriptions.cancel(subscriptionId) + cloudlog({ requestId: c.get('requestId'), message: 'removeOldSubscription', id: subscriptionId, billingAccount: account }) + const deletedSubscription = await getStripe(c, account).subscriptions.cancel(subscriptionId) return deletedSubscription } diff --git a/supabase/functions/_backend/utils/stripe_billing_account.ts b/supabase/functions/_backend/utils/stripe_billing_account.ts new file mode 100644 index 0000000000..158c51b563 --- /dev/null +++ b/supabase/functions/_backend/utils/stripe_billing_account.ts @@ -0,0 +1,164 @@ +import type { Context } from 'hono' +import { simpleError } from './hono.ts' +import { supabaseAdmin } from './supabase.ts' +import { getEnv } from './utils.ts' + +// Dual Stripe account scaffolding: EE remains the default production account. +// US secrets are optional; getStripe('us') fails closed when they are unset. + +export type BillingAccount = 'ee' | 'us' + +export const DEFAULT_BILLING_ACCOUNT: BillingAccount = 'ee' +export const BILLING_ACCOUNTS: readonly BillingAccount[] = ['ee', 'us'] + +export function normalizeBillingAccount(value: string | null | undefined): BillingAccount { + const normalized = (value ?? '').trim().toLowerCase() + if (normalized === 'us') + return 'us' + return DEFAULT_BILLING_ACCOUNT +} + +export function getStripeSecretKey(c: Context, account: BillingAccount = DEFAULT_BILLING_ACCOUNT): string { + if (account === 'us') + return getEnv(c, 'STRIPE_SECRET_KEY_US') + return getEnv(c, 'STRIPE_SECRET_KEY') +} + +export function getStripeWebhookSecret(c: Context, account: BillingAccount = DEFAULT_BILLING_ACCOUNT): string { + if (account === 'us') + return getEnv(c, 'STRIPE_WEBHOOK_SECRET_US') + return getEnv(c, 'STRIPE_WEBHOOK_SECRET') +} + +export function isStripeSecretKeyConfigured(secretKey: string): boolean { + const trimmed = secretKey.trim() + if (!trimmed) + return false + if (trimmed.startsWith('sk_')) + return trimmed.length > 3 + if (trimmed.startsWith('rk_')) + return trimmed.length > 3 + return false +} + +export function isStripeWebhookSecretConfigured(webhookSecret: string): boolean { + const trimmed = webhookSecret.trim() + if (!trimmed.startsWith('whsec_')) + return false + return trimmed.length > 6 +} + +export function isStripeAccountConfigured(c: Context, account: BillingAccount = DEFAULT_BILLING_ACCOUNT): boolean { + return isStripeSecretKeyConfigured(getStripeSecretKey(c, account)) +} + +export function isStripeAccountReadyForNewCustomers(c: Context, account: BillingAccount): boolean { + if (!isStripeAccountConfigured(c, account)) + return false + if (account === 'us') + return isStripeWebhookSecretConfigured(getStripeWebhookSecret(c, account)) + return true +} + +export function getNewCustomersBillingAccount(c: Context): BillingAccount { + const requested = normalizeBillingAccount(getEnv(c, 'STRIPE_NEW_CUSTOMERS_ACCOUNT')) + if (requested === 'us' && isStripeAccountReadyForNewCustomers(c, 'us')) + return 'us' + return DEFAULT_BILLING_ACCOUNT +} + +const ISO_COUNTRY_CODE_REGEX = /^[A-Z]{2}$/ + +export function normalizeCountryCode(country: string | null | undefined): string | null { + if (!country) + return null + + const normalized = country.trim().toUpperCase() + if (!ISO_COUNTRY_CODE_REGEX.test(normalized)) + return null + + return normalized +} + +export function getRequestCountryCode(c: Context): string | null { + const headerNames = ['cf-ipcountry', 'CF-IPCountry', 'x-vercel-ip-country'] + for (const headerName of headerNames) { + const normalized = normalizeCountryCode(c.req.header(headerName)) + if (normalized) + return normalized + } + return null +} + +export function resolveNewOrgBillingAccount(c: Context, country?: string | null): BillingAccount { + const normalizedCountry = normalizeCountryCode(country) ?? getRequestCountryCode(c) + if (normalizedCountry === 'US' && isStripeAccountReadyForNewCustomers(c, 'us')) + return 'us' + return getNewCustomersBillingAccount(c) +} + +export async function resolveBillingAccount(c: Context, customerId: string): Promise { + if (!customerId) + return DEFAULT_BILLING_ACCOUNT + + const { data, error } = await supabaseAdmin(c) + .from('stripe_info') + .select('billing_account') + .eq('customer_id', customerId) + .maybeSingle() + + if (error) + return DEFAULT_BILLING_ACCOUNT + + return normalizeBillingAccount(data?.billing_account) +} + +export function assertStripeAccountConfigured(c: Context, account: BillingAccount): void { + if (!isStripeAccountConfigured(c, account)) + throw simpleError( + 'stripe_account_not_configured', + `Stripe billing account "${account}" is not configured`, + { billing_account: account }, + ) +} + +export interface PlanStripeCatalogRow { + stripe_id: string + stripe_id_us?: string | null + price_m_id: string + price_y_id: string + price_m_id_us?: string | null + price_y_id_us?: string | null + credit_id: string + credit_id_us?: string | null +} + +export function resolvePlanProductId(plan: Pick, account: BillingAccount): string { + if (account === 'us' && plan.stripe_id_us) + return plan.stripe_id_us + return plan.stripe_id +} + +export function resolvePlanPriceId( + plan: Pick, + account: BillingAccount, + recurrence: string, +): string | null { + if (account === 'us') { + const usPriceId = recurrence === 'year' ? plan.price_y_id_us : plan.price_m_id_us + return usPriceId ?? null + } + return recurrence === 'year' ? plan.price_y_id : plan.price_m_id +} + +export function resolvePlanCreditId(plan: Pick, account: BillingAccount): string | null { + if (account === 'us') + return plan.credit_id_us ?? null + return plan.credit_id +} + +export function planProductIdOrFilter(productId: string): string { + if (!/^prod_[A-Za-z0-9_]+$/.test(productId)) + throw new Error(`Invalid Stripe product id: ${productId}`) + return `stripe_id.eq.${productId},stripe_id_us.eq.${productId}` +} diff --git a/supabase/functions/_backend/utils/stripe_event.ts b/supabase/functions/_backend/utils/stripe_event.ts index c3d51c7aa8..222a9ed294 100644 --- a/supabase/functions/_backend/utils/stripe_event.ts +++ b/supabase/functions/_backend/utils/stripe_event.ts @@ -3,12 +3,17 @@ import type { StripeData } from './stripe.ts' import Stripe from 'stripe' import { cloudlog, cloudlogErr } from './logging.ts' import { getStripe, parsePriceIds } from './stripe.ts' -import { getEnv } from './utils.ts' +import { type BillingAccount, DEFAULT_BILLING_ACCOUNT, getStripeWebhookSecret } from './stripe_billing_account.ts' -export function parseStripeEvent(c: Context, body: string, signature: string) { - const webhookKey = getEnv(c, 'STRIPE_WEBHOOK_SECRET') +export function parseStripeEvent( + c: Context, + body: string, + signature: string, + billingAccount: BillingAccount = DEFAULT_BILLING_ACCOUNT, +) { + const webhookKey = getStripeWebhookSecret(c, billingAccount) - return getStripe(c).webhooks.constructEventAsync( + return getStripe(c, billingAccount).webhooks.constructEventAsync( body, signature, webhookKey, diff --git a/supabase/functions/_backend/utils/stripe_org.ts b/supabase/functions/_backend/utils/stripe_org.ts index 9898d69a2f..c778c9924a 100644 --- a/supabase/functions/_backend/utils/stripe_org.ts +++ b/supabase/functions/_backend/utils/stripe_org.ts @@ -2,6 +2,12 @@ import type { Context } from 'hono' import type { Database } from './supabase.types.ts' import { cloudlog, cloudlogErr } from './logging.ts' import { createCustomer } from './stripe.ts' +import { + getNewCustomersBillingAccount, + normalizeBillingAccount, + planProductIdOrFilter, + resolvePlanProductId, +} from './stripe_billing_account.ts' import { getDefaultPlan, getStripeCustomer, supabaseAdmin } from './supabase.ts' /** @@ -11,7 +17,13 @@ import { getDefaultPlan, getStripeCustomer, supabaseAdmin } from './supabase.ts' * the plugin isolate graph. */ export async function createStripeCustomer(c: Context, org: Database['public']['Tables']['orgs']['Row']) { - const customer = await createCustomer(c, org.management_email, org.created_by, org.id, org.name) + let billingAccount = getNewCustomersBillingAccount(c) + if (org.customer_id?.startsWith('pending_')) { + const pendingStripeInfo = await getStripeCustomer(c, org.customer_id) + if (pendingStripeInfo?.billing_account) + billingAccount = normalizeBillingAccount(pendingStripeInfo.billing_account) + } + const customer = await createCustomer(c, org.management_email, org.created_by, org.id, org.name, billingAccount) const trial_at = new Date() trial_at.setDate(trial_at.getDate() + 15) const plan = org.customer_id?.startsWith('pending_') @@ -21,7 +33,7 @@ export async function createStripeCustomer(c: Context, org: Database['public'][' const { data } = await supabaseAdmin(c) .from('plans') .select() - .eq('stripe_id', pendingStripeInfo.product_id) + .or(planProductIdOrFilter(pendingStripeInfo.product_id)) .single() return data }) @@ -35,9 +47,10 @@ export async function createStripeCustomer(c: Context, org: Database['public'][' const { error: createInfoError } = await supabaseAdmin(c) .from('stripe_info') .insert({ - product_id: selectedPlan.stripe_id, + product_id: resolvePlanProductId(selectedPlan, billingAccount), customer_id: customer.id, trial_at: trial_at.toISOString(), + billing_account: billingAccount, }) if (createInfoError) { cloudlog({ requestId: c.get('requestId'), message: 'createInfoError', createInfoError }) diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index b312e02870..7d1b2f94e3 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -2686,6 +2686,7 @@ export type Database = { build_time_unit: number created_at: string credit_id: string + credit_id_us: string | null description: string id: string market_desc: string | null @@ -2696,8 +2697,11 @@ export type Database = { price_m_id: string price_y: number price_y_id: string + price_m_id_us: string | null + price_y_id_us: string | null storage: number stripe_id: string + stripe_id_us: string | null updated_at: string } Insert: { @@ -2705,6 +2709,7 @@ export type Database = { build_time_unit?: number created_at?: string credit_id: string + credit_id_us?: string | null description?: string id?: string market_desc?: string | null @@ -2713,10 +2718,13 @@ export type Database = { native_build_concurrency?: number price_m?: number price_m_id: string + price_m_id_us?: string | null price_y?: number price_y_id: string + price_y_id_us?: string | null storage: number stripe_id?: string + stripe_id_us?: string | null updated_at?: string } Update: { @@ -2724,6 +2732,7 @@ export type Database = { build_time_unit?: number created_at?: string credit_id?: string + credit_id_us?: string | null description?: string id?: string market_desc?: string | null @@ -2732,10 +2741,13 @@ export type Database = { native_build_concurrency?: number price_m?: number price_m_id?: string + price_m_id_us?: string | null price_y?: number price_y_id?: string + price_y_id_us?: string | null storage?: number stripe_id?: string + stripe_id_us?: string | null updated_at?: string } Relationships: [] @@ -2766,18 +2778,21 @@ export type Database = { } processed_stripe_events: { Row: { + billing_account: string created_at: string customer_id: string date_id: string event_id: string } Insert: { + billing_account?: string created_at?: string customer_id: string date_id: string event_id: string } Update: { + billing_account?: string created_at?: string customer_id?: string date_id?: string @@ -3091,6 +3106,7 @@ export type Database = { created_at: string customer_country: string | null customer_id: string + billing_account: string id: number is_above_plan: boolean | null is_good_plan: boolean | null @@ -3119,6 +3135,7 @@ export type Database = { created_at?: string customer_country?: string | null customer_id: string + billing_account?: string id?: number is_above_plan?: boolean | null is_good_plan?: boolean | null @@ -3147,6 +3164,7 @@ export type Database = { created_at?: string customer_country?: string | null customer_id?: string + billing_account?: string id?: number is_above_plan?: boolean | null is_good_plan?: boolean | null diff --git a/supabase/migrations/20260827000015_dual_stripe_billing_account.sql b/supabase/migrations/20260827000015_dual_stripe_billing_account.sql new file mode 100644 index 0000000000..b0ff5015d4 --- /dev/null +++ b/supabase/migrations/20260827000015_dual_stripe_billing_account.sql @@ -0,0 +1,161 @@ +-- Dual Stripe account scaffolding: persist billing account per org, nullable US plan catalog columns. +-- EE remains the default; existing rows are backfilled to EE. US product/price IDs stay empty until configured. + +ALTER TABLE public.stripe_info + ADD COLUMN IF NOT EXISTS billing_account text NOT NULL DEFAULT 'ee'; + +ALTER TABLE public.stripe_info + ADD CONSTRAINT stripe_info_billing_account_check + CHECK (billing_account IN ('ee', 'us')) NOT VALID; + +ALTER TABLE public.stripe_info + VALIDATE CONSTRAINT stripe_info_billing_account_check; + +UPDATE public.stripe_info +SET billing_account = 'ee' +WHERE billing_account IS DISTINCT FROM 'ee'; + +ALTER TABLE public.plans + ADD COLUMN IF NOT EXISTS stripe_id_us character varying, + ADD COLUMN IF NOT EXISTS price_m_id_us character varying, + ADD COLUMN IF NOT EXISTS price_y_id_us character varying, + ADD COLUMN IF NOT EXISTS credit_id_us text; + +ALTER TABLE public.processed_stripe_events + ADD COLUMN IF NOT EXISTS billing_account text NOT NULL DEFAULT 'ee'; + +ALTER TABLE public.processed_stripe_events + ADD CONSTRAINT processed_stripe_events_billing_account_check + CHECK (billing_account IN ('ee', 'us')) NOT VALID; + +ALTER TABLE public.processed_stripe_events + VALIDATE CONSTRAINT processed_stripe_events_billing_account_check; + +UPDATE public.processed_stripe_events +SET billing_account = 'ee' +WHERE billing_account IS DISTINCT FROM 'ee'; + +ALTER TABLE public.processed_stripe_events + DROP CONSTRAINT IF EXISTS processed_stripe_events_pkey; + +ALTER TABLE public.processed_stripe_events + ADD PRIMARY KEY (billing_account, event_id); + +CREATE OR REPLACE FUNCTION public.generate_org_user_stripe_info_on_org_create() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + solo_plan_stripe_id varchar; + solo_plan_stripe_id_us varchar; + pending_customer_id varchar; + trial_at_date timestamptz; + org_super_admin_role_id uuid; + owner_country varchar; + selected_billing_account text := 'ee'; + selected_product_id varchar; +BEGIN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', NEW.id::text, true); + + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (NEW.created_by, NEW.id, public.rbac_role_org_super_admin(), false); + + SELECT id INTO org_super_admin_role_id + FROM public.roles + WHERE name = public.rbac_role_org_super_admin() + AND scope_type = public.rbac_scope_org() + LIMIT 1; + + IF org_super_admin_role_id IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.created_by, org_super_admin_role_id, public.rbac_scope_org(), NEW.id, + NEW.created_by, now(), 'Organization creator', true + ) ON CONFLICT DO NOTHING; + END IF; + + IF NEW.customer_id IS NOT NULL THEN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RETURN NEW; + END IF; + + pending_customer_id := 'pending_' || NEW.id::text; + + IF EXISTS ( + SELECT 1 + FROM public.stripe_info + WHERE customer_id = pending_customer_id + ) THEN + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RETURN NEW; + END IF; + + SELECT country INTO owner_country + FROM public.users + WHERE id = NEW.created_by + LIMIT 1; + + IF UPPER(BTRIM(COALESCE(owner_country, ''))) = 'US' THEN + selected_billing_account := 'us'; + END IF; + + SELECT stripe_id, stripe_id_us INTO solo_plan_stripe_id, solo_plan_stripe_id_us + FROM public.plans + WHERE name = 'Solo' + LIMIT 1; + + selected_product_id := solo_plan_stripe_id; + IF selected_billing_account = 'us' + AND solo_plan_stripe_id_us IS NOT NULL + AND BTRIM(solo_plan_stripe_id_us) <> '' THEN + selected_product_id := solo_plan_stripe_id_us; + ELSE + selected_billing_account := 'ee'; + selected_product_id := solo_plan_stripe_id; + END IF; + + IF selected_product_id IS NULL THEN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RAISE WARNING 'Solo plan not found, skipping sync stripe_info creation for org %', NEW.id; + RETURN NEW; + END IF; + + trial_at_date := NOW() + INTERVAL '15 days'; + + INSERT INTO public.stripe_info ( + customer_id, + product_id, + trial_at, + status, + is_good_plan, + billing_account + ) VALUES ( + pending_customer_id, + selected_product_id, + trial_at_date, + NULL, + true, + selected_billing_account + ); + + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.generate_org_user_stripe_info_on_org_create() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() FROM PUBLIC; +GRANT ALL ON FUNCTION public.generate_org_user_stripe_info_on_org_create() TO service_role; diff --git a/tests/stripe-billing-account.unit.test.ts b/tests/stripe-billing-account.unit.test.ts new file mode 100644 index 0000000000..cb09438cc1 --- /dev/null +++ b/tests/stripe-billing-account.unit.test.ts @@ -0,0 +1,197 @@ +import Stripe from 'stripe' +import { HTTPException } from 'hono/http-exception' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockedEnv: Record = { + WEBAPP_URL: 'https://capgo.test', + STRIPE_SECRET_KEY: 'sk_test_ee', +} + +const { mockedSupabaseAdmin } = vi.hoisted(() => ({ + mockedSupabaseAdmin: vi.fn(), +})) + +vi.mock('hono/adapter', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + env: () => mockedEnv, + } +}) + +vi.mock('stripe', () => { + const MockStripe: any = vi.fn() + MockStripe.createFetchHttpClient = vi.fn() + MockStripe.createSubtleCryptoProvider = vi.fn() + MockStripe.errors = { + StripeAuthenticationError: class StripeAuthenticationError extends Error {}, + StripeInvalidRequestError: class StripeInvalidRequestError extends Error {}, + StripePermissionError: class StripePermissionError extends Error {}, + StripeRateLimitError: class StripeRateLimitError extends Error {}, + } + return { default: MockStripe } +}) + +vi.mock('../supabase/functions/_backend/utils/supabase.ts', () => ({ + supabaseAdmin: mockedSupabaseAdmin, +})) + +function createContext() { + return { + get: (key: string) => key === 'requestId' ? 'request-id' : undefined, + } as any +} + +function mockStripeInfoBillingAccount(billingAccount: string) { + mockedSupabaseAdmin.mockReturnValue({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { billing_account: billingAccount }, + error: null, + }), + })), + })), + })), + }) +} + +afterEach(() => { + delete mockedEnv.STRIPE_SECRET_KEY_US + delete mockedEnv.STRIPE_WEBHOOK_SECRET_US + delete mockedEnv.STRIPE_NEW_CUSTOMERS_ACCOUNT + mockedSupabaseAdmin.mockReset() + vi.restoreAllMocks() +}) + +describe('stripe billing account scaffolding', () => { + it('defaults new customers to EE when STRIPE_NEW_CUSTOMERS_ACCOUNT is unset', async () => { + const { getNewCustomersBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(getNewCustomersBillingAccount(createContext())).toBe('ee') + }) + + it('keeps new customers on EE when US secrets are missing even if flag requests US', async () => { + mockedEnv.STRIPE_NEW_CUSTOMERS_ACCOUNT = 'us' + const { getNewCustomersBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(getNewCustomersBillingAccount(createContext())).toBe('ee') + }) + + it('keeps new customers on EE when only US API key is set without webhook secret', async () => { + mockedEnv.STRIPE_NEW_CUSTOMERS_ACCOUNT = 'us' + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_test_us' + const { getNewCustomersBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(getNewCustomersBillingAccount(createContext())).toBe('ee') + }) + + it('keeps new customers on EE when only US webhook secret is set without API key', async () => { + mockedEnv.STRIPE_NEW_CUSTOMERS_ACCOUNT = 'us' + mockedEnv.STRIPE_WEBHOOK_SECRET_US = 'whsec_test_us' + const { getNewCustomersBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(getNewCustomersBillingAccount(createContext())).toBe('ee') + }) + + it('rejects prefix-only US API and webhook secrets for new customer routing', async () => { + mockedEnv.STRIPE_NEW_CUSTOMERS_ACCOUNT = 'us' + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_' + mockedEnv.STRIPE_WEBHOOK_SECRET_US = 'whsec_' + const { getNewCustomersBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(getNewCustomersBillingAccount(createContext())).toBe('ee') + }) + + it('routes new customers to US only when both US API key and webhook secret are configured', async () => { + mockedEnv.STRIPE_NEW_CUSTOMERS_ACCOUNT = 'us' + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_test_us' + mockedEnv.STRIPE_WEBHOOK_SECRET_US = 'whsec_test_us' + const { getNewCustomersBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(getNewCustomersBillingAccount(createContext())).toBe('us') + }) + + it('rejects invalid Stripe product ids in plan filters', async () => { + const { planProductIdOrFilter } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(() => planProductIdOrFilter('plan_test')).toThrow('Invalid Stripe product id') + expect(planProductIdOrFilter('prod_plan_test')).toBe('stripe_id.eq.prod_plan_test,stripe_id_us.eq.prod_plan_test') + }) + + it('uses EE secret key for default getStripe()', async () => { + vi.mocked(Stripe).mockImplementation(function () { + return {} as any + } as any) + + const { getStripe } = await import('../supabase/functions/_backend/utils/stripe.ts') + getStripe(createContext()) + + expect(Stripe).toHaveBeenCalledWith('sk_test_ee', expect.any(Object)) + }) + + it('fails closed for US account when US secret key is missing', async () => { + const { getStripe } = await import('../supabase/functions/_backend/utils/stripe.ts') + expect(() => getStripe(createContext(), 'us')).toThrow(HTTPException) + }) + + it('selects US Stripe client when stripe_info.billing_account is us and US secrets exist', async () => { + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_test_us' + mockStripeInfoBillingAccount('us') + + vi.mocked(Stripe).mockImplementation(function () { + return {} as any + } as any) + + const { resolveBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + const { getStripe } = await import('../supabase/functions/_backend/utils/stripe.ts') + + const account = await resolveBillingAccount(createContext(), 'cus_us_customer') + expect(account).toBe('us') + getStripe(createContext(), account) + + expect(Stripe).toHaveBeenCalledWith('sk_test_us', expect.any(Object)) + }) + + it('resolveBillingAccount defaults to EE for unknown customers', async () => { + mockedSupabaseAdmin.mockReturnValue({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + })), + })), + })), + }) + + const { resolveBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(await resolveBillingAccount(createContext(), 'cus_missing')).toBe('ee') + }) + + it('routes new US orgs to US when US Stripe is ready', async () => { + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_test_us' + mockedEnv.STRIPE_WEBHOOK_SECRET_US = 'whsec_test_us' + const { resolveNewOrgBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(resolveNewOrgBillingAccount(createContext(), 'US')).toBe('us') + expect(resolveNewOrgBillingAccount(createContext(), 'us')).toBe('us') + }) + + it('keeps non-US orgs on EE even when US Stripe is ready', async () => { + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_test_us' + mockedEnv.STRIPE_WEBHOOK_SECRET_US = 'whsec_test_us' + const { resolveNewOrgBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(resolveNewOrgBillingAccount(createContext(), 'FR')).toBe('ee') + }) + + it('falls back to EE for US orgs when US Stripe is not ready', async () => { + const { resolveNewOrgBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(resolveNewOrgBillingAccount(createContext(), 'US')).toBe('ee') + }) + + it('reads request country from Cloudflare headers', async () => { + mockedEnv.STRIPE_SECRET_KEY_US = 'sk_test_us' + mockedEnv.STRIPE_WEBHOOK_SECRET_US = 'whsec_test_us' + const context = { + get: (key: string) => key === 'requestId' ? 'request-id' : undefined, + req: { + header: (name: string) => name.toLowerCase() === 'cf-ipcountry' ? 'US' : undefined, + }, + } as any + const { resolveNewOrgBillingAccount } = await import('../supabase/functions/_backend/utils/stripe_billing_account.ts') + expect(resolveNewOrgBillingAccount(context)).toBe('us') + }) +}) diff --git a/tests/stripe-emulator.test.ts b/tests/stripe-emulator.test.ts index 4fcfee36f7..13011f1ebc 100644 --- a/tests/stripe-emulator.test.ts +++ b/tests/stripe-emulator.test.ts @@ -33,21 +33,48 @@ function expectCheckoutUrlOnEmulator(url: string, baseUrl: string) { expect(checkoutUrl.pathname).toMatch(/^\/checkout\/cs_/) } -function mockStoredPlanPrices(priceMonthId: string, priceYearId: string) { +function mockStripeDbLookups(priceMonthId: string, priceYearId: string) { + const rowResult = { + maybeSingle: vi.fn().mockResolvedValue({ + data: { billing_account: 'ee' }, + error: null, + }), + single: vi.fn().mockResolvedValue({ + data: { + billing_account: 'ee', + price_m_id: priceMonthId, + price_y_id: priceYearId, + price_m_id_us: null, + price_y_id_us: null, + stripe_id: 'plan_test', + stripe_id_us: null, + }, + error: null, + }), + } + + mockedSupabaseAdmin.mockReturnValue({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => rowResult), + or: vi.fn(() => rowResult), + })), + })), + }) +} + +function mockBillingAccountLookup() { mockedSupabaseAdmin.mockReturnValue({ - from: vi.fn().mockReturnValue({ - select: vi.fn().mockReturnValue({ - eq: vi.fn().mockReturnValue({ - single: vi.fn().mockResolvedValue({ - data: { - price_m_id: priceMonthId, - price_y_id: priceYearId, - }, + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { billing_account: 'ee' }, error: null, }), - }), - }), - }), + })), + })), + })), }) } @@ -160,7 +187,7 @@ describe('stripe emulator integration', () => { }, }) - mockStoredPlanPrices(monthlyPrice.id, yearlyPrice.id) + mockStripeDbLookups(monthlyPrice.id, yearlyPrice.id) const checkout = await createCheckout( context, @@ -173,7 +200,7 @@ describe('stripe emulator integration', () => { expect(checkout.url).toBeTruthy() expectCheckoutUrlOnEmulator(checkout.url as string, stripeApiBaseUrl) - expect(mockedSupabaseAdmin).toHaveBeenCalledTimes(1) + expect(mockedSupabaseAdmin).toHaveBeenCalledTimes(2) const sessions = await stripe.checkout.sessions.list({ limit: 10 }) const session = sessions.data.find(candidate => candidate.url === checkout.url) @@ -206,6 +233,8 @@ describe('stripe emulator integration', () => { unit_amount: 100, }) + mockBillingAccountLookup() + const checkout = await createOneTimeCheckout( context, customer.id, diff --git a/tests/stripe-redirects.unit.test.ts b/tests/stripe-redirects.unit.test.ts index fb223b8d4a..3342e5c92d 100644 --- a/tests/stripe-redirects.unit.test.ts +++ b/tests/stripe-redirects.unit.test.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockedEnv: Record = { WEBAPP_URL: 'https://capgo.test', @@ -53,6 +53,39 @@ function createPriceList(recurringInterval = 'month', type = 'recurring') { ] } +function mockBillingAccountLookup() { + const rowResult = { + maybeSingle: vi.fn().mockResolvedValue({ + data: { billing_account: 'ee' }, + error: null, + }), + single: vi.fn().mockResolvedValue({ + data: { + billing_account: 'ee', + price_m_id: 'price_monthly_from_plan', + price_y_id: 'price_yearly_from_plan', + price_m_id_us: null, + price_y_id_us: null, + stripe_id: 'plan_test', + stripe_id_us: null, + }, + error: null, + }), + } + + mockedSupabaseAdmin.mockReturnValue({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => rowResult), + })), + })), + }) +} + +beforeEach(() => { + mockBillingAccountLookup() +}) + afterEach(() => { delete mockedEnv.STRIPE_API_BASE_URL mockedSupabaseAdmin.mockReset() @@ -348,10 +381,33 @@ describe('stripe redirect URL allowlist', () => { from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { billing_account: 'ee' }, + error: null, + }), single: vi.fn().mockResolvedValue({ data: { + billing_account: 'ee', price_m_id: 'price_monthly_from_plan', price_y_id: 'price_yearly_from_plan', + price_m_id_us: null, + price_y_id_us: null, + stripe_id: 'prod_plan_test', + stripe_id_us: null, + }, + error: null, + }), + }), + or: vi.fn().mockReturnValue({ + single: vi.fn().mockResolvedValue({ + data: { + billing_account: 'ee', + price_m_id: 'price_monthly_from_plan', + price_y_id: 'price_yearly_from_plan', + price_m_id_us: null, + price_y_id_us: null, + stripe_id: 'prod_plan_test', + stripe_id_us: null, }, error: null, }), @@ -389,7 +445,7 @@ describe('stripe redirect URL allowlist', () => { createContext(), 'cus_123', 'month', - 'plan_test', + 'prod_plan_test', '/app/success', '/app/cancel', )