diff --git a/components/dashboard/src/components/UsageBasedBillingConfig.tsx b/components/dashboard/src/components/UsageBasedBillingConfig.tsx index 5273b4fbf303c1..53980dc1962288 100644 --- a/components/dashboard/src/components/UsageBasedBillingConfig.tsx +++ b/components/dashboard/src/components/UsageBasedBillingConfig.tsx @@ -295,13 +295,8 @@ function CreditCardInputForm(props: { attributionId: string }) { setBillingError(undefined); setIsLoading(true); try { - if (attrId.kind === "team") { - // Create Stripe customer for team & currency (or update currency) - await getGitpodService().server.createOrUpdateStripeCustomerForTeam(attrId.teamId, currency); - } else { - // Create Stripe customer for user & currency (or update currency) - await getGitpodService().server.createOrUpdateStripeCustomerForUser(currency); - } + // Create Stripe customer with currency + await getGitpodService().server.createStripeCustomerIfNeeded(props.attributionId, currency); const result = await stripe.confirmSetup({ elements, confirmParams: { diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 27334ac2fd50c0..b7e78d0e7bc45b 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -287,8 +287,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, getStripePublishableKey(): Promise; getStripeSetupIntentClientSecret(): Promise; findStripeSubscriptionId(attributionId: string): Promise; - createOrUpdateStripeCustomerForTeam(teamId: string, currency: string): Promise; - createOrUpdateStripeCustomerForUser(currency: string): Promise; + createStripeCustomerIfNeeded(attributionId: string, currency: string): Promise; subscribeToStripe(attributionId: string, setupIntentId: string): Promise; getStripePortalUrl(attributionId: string): Promise; getUsageLimit(attributionId: string): Promise; diff --git a/components/server/ee/src/billing/billing-mode.spec.db.ts b/components/server/ee/src/billing/billing-mode.spec.db.ts index e21378c96657cc..50759e3421ff75 100644 --- a/components/server/ee/src/billing/billing-mode.spec.db.ts +++ b/components/server/ee/src/billing/billing-mode.spec.db.ts @@ -50,25 +50,20 @@ class StripeServiceMock extends StripeService { super(); } - async findUncancelledSubscriptionByCustomer(customerId: string): Promise { - if (this.subscription?.customer === customerId) { - return this.subscription as Stripe.Subscription; + async findUncancelledSubscriptionByAttributionId(attributionId: string): Promise { + const customerId = await this.findCustomerByAttributionId(attributionId); + if (!!customerId && this.subscription?.customer === customerId) { + return this.subscription.id; } return undefined; } - async findCustomerByUserId(userId: string): Promise { + async findCustomerByAttributionId(attributionId: string): Promise { const customerId = this.subscription?.customer; if (!customerId) { return undefined; } - return { - id: customerId, - } as Stripe.Customer; - } - - async findCustomerByTeamId(teamId: string): Promise { - return this.findCustomerByUserId(teamId); + return customerId; } } diff --git a/components/server/ee/src/billing/billing-mode.ts b/components/server/ee/src/billing/billing-mode.ts index dbbb5ac0a66385..2b84d105d35054 100644 --- a/components/server/ee/src/billing/billing-mode.ts +++ b/components/server/ee/src/billing/billing-mode.ts @@ -41,7 +41,7 @@ export class BillingModesImpl implements BillingModes { @inject(Config) protected readonly config: Config; @inject(ConfigCatClientFactory) protected readonly configCatClientFactory: ConfigCatClientFactory; @inject(SubscriptionService) protected readonly subscriptionSvc: SubscriptionService; - @inject(StripeService) protected readonly stripeSvc: StripeService; + @inject(StripeService) protected readonly stripeService: StripeService; @inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB; @inject(TeamSubscription2DB) protected readonly teamSubscription2Db: TeamSubscription2DB; @inject(TeamDB) protected readonly teamDB: TeamDB; @@ -116,12 +116,11 @@ export class BillingModesImpl implements BillingModes { // Stripe: Active personal subsciption? let hasUbbPersonal = false; - const customer = await this.stripeSvc.findCustomerByUserId(user.id); - if (customer) { - const subscription = await this.stripeSvc.findUncancelledSubscriptionByCustomer(customer.id); - if (subscription) { - hasUbbPersonal = true; - } + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId( + AttributionId.render({ kind: "user", userId: user.id }), + ); + if (subscriptionId) { + hasUbbPersonal = true; } // 3. Check team memberships/plans @@ -192,12 +191,11 @@ export class BillingModesImpl implements BillingModes { // 3. Now we're usage-based. We only have to figure out whether we have a plan yet or not. const result: BillingMode = { mode: "usage-based" }; - const customer = await this.stripeSvc.findCustomerByTeamId(team.id); - if (customer) { - const subscription = await this.stripeSvc.findUncancelledSubscriptionByCustomer(customer.id); - if (subscription) { - result.paid = true; - } + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId( + AttributionId.render({ kind: "team", teamId: team.id }), + ); + if (subscriptionId) { + result.paid = true; } return result; } diff --git a/components/server/ee/src/billing/entitlement-service-ubp.ts b/components/server/ee/src/billing/entitlement-service-ubp.ts index af2957474a4e43..176fc7c8a60dcf 100644 --- a/components/server/ee/src/billing/entitlement-service-ubp.ts +++ b/components/server/ee/src/billing/entitlement-service-ubp.ts @@ -114,21 +114,18 @@ export class EntitlementServiceUBP implements EntitlementService { protected async hasPaidSubscription(user: User, date: Date): Promise { // Paid user? - const customer = await this.stripeService.findCustomerByUserId(user.id); - if (customer) { - const subscriptionId = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id); - if (subscriptionId) { - return true; - } + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId( + AttributionId.render({ kind: "user", userId: user.id }), + ); + if (subscriptionId) { + return true; } // Member of paid team? const teams = await this.teamDB.findTeamsByUser(user.id); const isTeamSubscribedPromises = teams.map(async (team: Team) => { - const customer = await this.stripeService.findCustomerByTeamId(team.id); - if (!customer) { - return false; - } - const subscriptionId = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id); + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId( + AttributionId.render({ kind: "team", teamId: team.id }), + ); return !!subscriptionId; }); // Return the first truthy promise, or false if all the promises were falsy. diff --git a/components/server/ee/src/user/stripe-service.ts b/components/server/ee/src/user/stripe-service.ts index 550b9a6c7fc2c1..3a5135afea4793 100644 --- a/components/server/ee/src/user/stripe-service.ts +++ b/components/server/ee/src/user/stripe-service.ts @@ -9,12 +9,11 @@ import Stripe from "stripe"; import { Team, User } from "@gitpod/gitpod-protocol"; import { Config } from "../../../src/config"; import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; +import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; const POLL_CREATED_CUSTOMER_INTERVAL_MS = 1000; const POLL_CREATED_CUSTOMER_MAX_ATTEMPTS = 30; -const ATTRIBUTION_ID_METADATA_KEY = "attributionId"; - @injectable() export class StripeService { @inject(Config) protected readonly config: Config; @@ -35,140 +34,128 @@ export class StripeService { return await this.getStripe().setupIntents.create({ usage: "on_session" }); } - async findCustomerByUserId(userId: string): Promise { - return this.findCustomerByQuery( - `metadata['${ATTRIBUTION_ID_METADATA_KEY}']:'${AttributionId.render({ kind: "user", userId })}'`, - ); - } - - async findCustomerByTeamId(teamId: string): Promise { - return this.findCustomerByQuery( - `metadata['${ATTRIBUTION_ID_METADATA_KEY}']:'${AttributionId.render({ kind: "team", teamId })}'`, - ); - } - - async findCustomerByQuery(query: string): Promise { + async findCustomerByAttributionId(attributionId: string): Promise { + const query = `metadata['attributionId']:'${attributionId}'`; const result = await this.getStripe().customers.search({ query }); if (result.data.length > 1) { throw new Error(`Found more than one Stripe customer for query '${query}'`); } - return result.data[0]; - } - - async createCustomerForUser(user: User): Promise { - if (await this.findCustomerByUserId(user.id)) { - throw new Error(`A Stripe customer already exists for user '${user.id}'`); - } - // Create the customer in Stripe - const customer = await this.getStripe().customers.create({ - email: User.getPrimaryEmail(user), - name: User.getName(user), - metadata: { - [ATTRIBUTION_ID_METADATA_KEY]: AttributionId.render({ kind: "user", userId: user.id }), - }, - }); - // Wait for the customer to show up in Stripe search results before proceeding - let attempts = 0; - while (!(await this.findCustomerByUserId(user.id))) { - await new Promise((resolve) => setTimeout(resolve, POLL_CREATED_CUSTOMER_INTERVAL_MS)); - if (++attempts > POLL_CREATED_CUSTOMER_MAX_ATTEMPTS) { - throw new Error(`Could not confirm Stripe customer creation for user '${user.id}'`); - } - } - return customer; + return result.data[0]?.id; } - async createCustomerForTeam(user: User, team: Team): Promise { - if (await this.findCustomerByTeamId(team.id)) { - throw new Error(`A Stripe customer already exists for team '${team.id}'`); + async createCustomerForAttributionId( + attributionId: string, + preferredCurrency: string, + billingEmail?: string, + billingName?: string, + ): Promise { + if (await this.findCustomerByAttributionId(attributionId)) { + throw new Error(`A Stripe customer already exists for '${attributionId}'`); } // Create the customer in Stripe - const userName = User.getName(user); const customer = await this.getStripe().customers.create({ - email: User.getPrimaryEmail(user), - name: userName ? `${userName} (${team.name})` : team.name, - metadata: { - [ATTRIBUTION_ID_METADATA_KEY]: AttributionId.render({ kind: "team", teamId: team.id }), - }, + email: billingEmail, + name: billingName, + metadata: { attributionId, preferredCurrency }, }); // Wait for the customer to show up in Stripe search results before proceeding let attempts = 0; - while (!(await this.findCustomerByTeamId(team.id))) { + while (!(await this.findCustomerByAttributionId(attributionId))) { await new Promise((resolve) => setTimeout(resolve, POLL_CREATED_CUSTOMER_INTERVAL_MS)); if (++attempts > POLL_CREATED_CUSTOMER_MAX_ATTEMPTS) { - throw new Error(`Could not confirm Stripe customer creation for team '${team.id}'`); + throw new Error(`Could not confirm Stripe customer creation for '${attributionId}'`); } } - return customer; - } - - async setPreferredCurrencyForCustomer(customer: Stripe.Customer, currency: string): Promise { - await this.getStripe().customers.update(customer.id, { metadata: { preferredCurrency: currency } }); + return customer.id; } - async setDefaultPaymentMethodForCustomer(customer: Stripe.Customer, setupIntentId: string): Promise { + async setDefaultPaymentMethodForCustomer(customerId: string, setupIntentId: string): Promise { const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId); if (typeof setupIntent.payment_method !== "string") { throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached"); } // Attach the provided payment method to the customer await this.getStripe().paymentMethods.attach(setupIntent.payment_method, { - customer: customer.id, + customer: customerId, }); - await this.getStripe().customers.update(customer.id, { + const paymentMethod = await this.getStripe().paymentMethods.retrieve(setupIntent.payment_method); + await this.getStripe().customers.update(customerId, { invoice_settings: { default_payment_method: setupIntent.payment_method }, + ...(paymentMethod.billing_details.address?.country + ? { address: { line1: "", country: paymentMethod.billing_details.address?.country } } + : {}), }); } async getPortalUrlForTeam(team: Team): Promise { - const customer = await this.findCustomerByTeamId(team.id); - if (!customer) { + const customerId = await this.findCustomerByAttributionId( + AttributionId.render({ kind: "team", teamId: team.id }), + ); + if (!customerId) { throw new Error(`No Stripe Customer ID found for team '${team.id}'`); } const session = await this.getStripe().billingPortal.sessions.create({ - customer: customer.id, + customer: customerId, return_url: this.config.hostUrl.with(() => ({ pathname: `/t/${team.slug}/billing` })).toString(), }); return session.url; } async getPortalUrlForUser(user: User): Promise { - const customer = await this.findCustomerByUserId(user.id); - if (!customer) { + const customerId = await this.findCustomerByAttributionId( + AttributionId.render({ kind: "user", userId: user.id }), + ); + if (!customerId) { throw new Error(`No Stripe Customer ID found for user '${user.id}'`); } const session = await this.getStripe().billingPortal.sessions.create({ - customer: customer.id, + customer: customerId, return_url: this.config.hostUrl.with(() => ({ pathname: `/billing` })).toString(), }); return session.url; } - async findUncancelledSubscriptionByCustomer(customerId: string): Promise { + async findUncancelledSubscriptionByAttributionId(attributionId: string): Promise { + const customerId = await this.findCustomerByAttributionId(attributionId); + if (!customerId) { + return undefined; + } const result = await this.getStripe().subscriptions.list({ customer: customerId, }); if (result.data.length > 1) { throw new Error(`Stripe customer '${customerId}') has more than one subscription!`); } - return result.data[0]; + return result.data[0]?.id; } async cancelSubscription(subscriptionId: string): Promise { await this.getStripe().subscriptions.del(subscriptionId); } - async createSubscriptionForCustomer(customer: Stripe.Customer): Promise { + async createSubscriptionForCustomer(customerId: string): Promise { + const customer = await this.getStripe().customers.retrieve(customerId, { expand: ["tax"] }); + if (!customer || customer.deleted) { + throw new Error(`Stripe customer '${customerId}' could not be found`); + } const currency = customer.metadata.preferredCurrency || "USD"; const priceId = this.config?.stripeConfig?.usageProductPriceIds[currency]; if (!priceId) { throw new Error(`No Stripe Price ID configured for currency '${currency}'`); } + const isAutomaticTaxSupported = customer.tax?.automatic_tax === "supported"; + if (!isAutomaticTaxSupported) { + log.warn("Automatic Stripe tax is not supported for this customer", { + customerId, + taxInformation: customer.tax, + }); + } const startOfNextMonth = new Date(new Date().toISOString().slice(0, 7) + "-01"); // First day of this month (YYYY-MM-01) startOfNextMonth.setMonth(startOfNextMonth.getMonth() + 1); // Add one month await this.getStripe().subscriptions.create({ customer: customer.id, items: [{ price: priceId }], + automatic_tax: { enabled: isAutomaticTaxSupported }, billing_cycle_anchor: Math.round(startOfNextMonth.getTime() / 1000), }); } diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index b3ddc394f609d0..b6bbf50231f124 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -115,7 +115,6 @@ import { EntitlementService, MayStartWorkspaceResult } from "../../../src/billin import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode"; import { BillingModes } from "../billing/billing-mode"; import { BillingService } from "../billing/billing-service"; -import Stripe from "stripe"; import { UsageServiceDefinition } from "@gitpod/usage-api/lib/usage/v1/usage.pb"; import { MessageBusIntegration } from "../../../src/workspace/messagebus-integration"; @@ -1597,12 +1596,11 @@ export class GitpodServerEEImpl extends GitpodServerImpl { { teamId, chargebeeSubscriptionId }, ); } - const teamCustomer = await this.stripeService.findCustomerByTeamId(teamId); - if (teamCustomer) { - const subsciption = await this.stripeService.findUncancelledSubscriptionByCustomer(teamCustomer.id); - if (subsciption) { - await this.stripeService.cancelSubscription(subsciption.id); - } + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId( + AttributionId.render({ kind: "team", teamId: teamId }), + ); + if (subscriptionId) { + await this.stripeService.cancelSubscription(subscriptionId); } } @@ -2063,21 +2061,12 @@ export class GitpodServerEEImpl extends GitpodServerImpl { this.checkAndBlockUser("findStripeSubscriptionId"); - let customer: Stripe.Customer | undefined; try { if (attrId.kind == "team") { await this.guardTeamOperation(attrId.teamId, "get"); - customer = await this.stripeService.findCustomerByTeamId(attrId.teamId); - } else { - customer = await this.stripeService.findCustomerByUserId(attrId.userId); } - - if (!customer?.id) { - return undefined; - } - - const subscription = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id); - return subscription?.id; + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId(attributionId); + return subscriptionId; } catch (error) { log.error(`Failed to get Stripe Subscription ID for '${attributionId}'`, error); throw new ResponseError( @@ -2087,39 +2076,41 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } - async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise { - const user = this.checkAndBlockUser("createOrUpdateStripeCustomerForTeam"); - const team = await this.guardTeamOperation(teamId, "update"); - await this.ensureStripeApiIsAllowed({ team }); - try { - let customer = await this.stripeService.findCustomerByTeamId(team!.id); - if (!customer) { - customer = await this.stripeService.createCustomerForTeam(user, team!); + async createStripeCustomerIfNeeded(ctx: TraceContext, attributionId: string, currency: string): Promise { + const user = this.checkAndBlockUser("createStripeCustomerIfNeeded"); + const attrId = AttributionId.parse(attributionId); + if (!attrId) { + throw new ResponseError(ErrorCodes.BAD_REQUEST, `Invalid attributionId '${attributionId}'`); + } + let team: Team | undefined; + if (attrId.kind === "team") { + team = await this.guardTeamOperation(attrId.teamId, "update"); + await this.ensureStripeApiIsAllowed({ team }); + } else { + if (attrId.userId !== user.id) { + throw new ResponseError( + ErrorCodes.PERMISSION_DENIED, + "Cannot create Stripe customer profile for another user", + ); } - await this.stripeService.setPreferredCurrencyForCustomer(customer, currency); - } catch (error) { - log.error(`Failed to update Stripe customer profile for team '${teamId}'`, error); - throw new ResponseError( - ErrorCodes.INTERNAL_SERVER_ERROR, - `Failed to update Stripe customer profile for team '${teamId}'`, - ); + await this.ensureStripeApiIsAllowed({ user }); } - } - - async createOrUpdateStripeCustomerForUser(ctx: TraceContext, currency: string): Promise { - const user = this.checkAndBlockUser("createOrUpdateStripeCustomerForUser"); - await this.ensureStripeApiIsAllowed({ user }); try { - let customer = await this.stripeService.findCustomerByUserId(user.id); - if (!customer) { - customer = await this.stripeService.createCustomerForUser(user); + if (!(await this.stripeService.findCustomerByAttributionId(attributionId))) { + const billingEmail = User.getPrimaryEmail(user); + const billingName = attrId.kind === "team" ? team!.name : User.getName(user); + await this.stripeService.createCustomerForAttributionId( + attributionId, + currency, + billingEmail, + billingName, + ); } - await this.stripeService.setPreferredCurrencyForCustomer(customer, currency); } catch (error) { - log.error(`Failed to update Stripe customer profile for user '${user.id}'`, error); + log.error(`Failed to create Stripe customer profile for '${attributionId}'`, error); throw new ResponseError( ErrorCodes.INTERNAL_SERVER_ERROR, - `Failed to update Stripe customer profile for user '${user.id}'`, + `Failed to create Stripe customer profile for '${attributionId}'`, ); } } @@ -2134,24 +2125,22 @@ export class GitpodServerEEImpl extends GitpodServerImpl { const user = this.checkAndBlockUser("subscribeToStripe"); - let customer: Stripe.Customer | undefined; try { if (attrId.kind === "team") { const team = await this.guardTeamOperation(attrId.teamId, "update"); await this.ensureStripeApiIsAllowed({ team }); - customer = await this.stripeService.findCustomerByTeamId(team!.id); } else { await this.ensureStripeApiIsAllowed({ user }); - customer = await this.stripeService.findCustomerByUserId(user.id); } - if (!customer) { + const customerId = await this.stripeService.findCustomerByAttributionId(attributionId); + if (!customerId) { throw new Error(`No Stripe customer profile for '${attributionId}'`); } - await this.stripeService.setDefaultPaymentMethodForCustomer(customer, setupIntentId); - await this.stripeService.createSubscriptionForCustomer(customer); + await this.stripeService.setDefaultPaymentMethodForCustomer(customerId, setupIntentId); + await this.stripeService.createSubscriptionForCustomer(customerId); - // Creating a cost center for this team + // Creating a cost center for this customer await this.usageService.setCostCenter({ costCenter: { attributionId: attributionId, diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 245ef48f627755..d739b4035e6df0 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -201,8 +201,7 @@ const defaultFunctions: FunctionsConfig = { getStripePublishableKey: { group: "default", points: 1 }, getStripeSetupIntentClientSecret: { group: "default", points: 1 }, findStripeSubscriptionId: { group: "default", points: 1 }, - createOrUpdateStripeCustomerForTeam: { group: "default", points: 1 }, - createOrUpdateStripeCustomerForUser: { group: "default", points: 1 }, + createStripeCustomerIfNeeded: { group: "default", points: 1 }, subscribeToStripe: { group: "default", points: 1 }, getStripePortalUrl: { group: "default", points: 1 }, listUsage: { group: "default", points: 1 }, diff --git a/components/server/src/user/user-service.ts b/components/server/src/user/user-service.ts index 2028dadd1bc98f..73ea77c7e40a6c 100644 --- a/components/server/src/user/user-service.ts +++ b/components/server/src/user/user-service.ts @@ -196,12 +196,10 @@ export class UserService { } protected async findTeamUsageBasedSubscriptionId(team: Team): Promise { - const customer = await this.stripeService.findCustomerByTeamId(team.id); - if (!customer) { - return; - } - const subscription = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id); - return subscription?.id; + const subscriptionId = await this.stripeService.findUncancelledSubscriptionByAttributionId( + AttributionId.render({ kind: "team", teamId: team.id }), + ); + return subscriptionId; } protected async validateUsageAttributionId(user: User, usageAttributionId: string): Promise { @@ -325,8 +323,8 @@ export class UserService { if (!membership) { throw new Error("Cannot attribute to an unrelated team."); } - const teamCustomer = await this.stripeService.findCustomerByTeamId(attributionId.teamId); - if (!teamCustomer) { + const teamCustomerId = await this.stripeService.findCustomerByAttributionId(usageAttributionId); + if (!teamCustomerId) { throw new Error("Cannot attribute to team without Stripe customer."); } user.usageAttributionId = usageAttributionId; diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index affb78333435a1..87b3b3e75d1c33 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -3130,10 +3130,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { async findStripeSubscriptionId(ctx: TraceContext, attributionId: string): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } - async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise { - throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); - } - async createOrUpdateStripeCustomerForUser(ctx: TraceContext, currency: string): Promise { + async createStripeCustomerIfNeeded(ctx: TraceContext, attributionId: string, currency: string): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } async subscribeToStripe(ctx: TraceContext, attributionId: string, setupIntentId: string): Promise {