Skip to content

[Stripe] Enable automatic tax on transactions #13002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 23, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
3 changes: 1 addition & 2 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,8 +287,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getStripePublishableKey(): Promise<string>;
getStripeSetupIntentClientSecret(): Promise<string>;
findStripeSubscriptionId(attributionId: string): Promise<string | undefined>;
createOrUpdateStripeCustomerForTeam(teamId: string, currency: string): Promise<void>;
createOrUpdateStripeCustomerForUser(currency: string): Promise<void>;
createStripeCustomerIfNeeded(attributionId: string, currency: string): Promise<void>;
subscribeToStripe(attributionId: string, setupIntentId: string): Promise<void>;
getStripePortalUrl(attributionId: string): Promise<string>;
getUsageLimit(attributionId: string): Promise<number | undefined>;
Expand Down
17 changes: 6 additions & 11 deletions components/server/ee/src/billing/billing-mode.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,20 @@ class StripeServiceMock extends StripeService {
super();
}

async findUncancelledSubscriptionByCustomer(customerId: string): Promise<Stripe.Subscription | undefined> {
if (this.subscription?.customer === customerId) {
return this.subscription as Stripe.Subscription;
async findUncancelledSubscriptionByAttributionId(attributionId: string): Promise<string | undefined> {
const customerId = await this.findCustomerByAttributionId(attributionId);
if (!!customerId && this.subscription?.customer === customerId) {
return this.subscription.id;
}
return undefined;
}

async findCustomerByUserId(userId: string): Promise<Stripe.Customer | undefined> {
async findCustomerByAttributionId(attributionId: string): Promise<string | undefined> {
const customerId = this.subscription?.customer;
if (!customerId) {
return undefined;
}
return {
id: customerId,
} as Stripe.Customer;
}

async findCustomerByTeamId(teamId: string): Promise<Stripe.Customer | undefined> {
return this.findCustomerByUserId(teamId);
return customerId;
}
}

Expand Down
24 changes: 11 additions & 13 deletions components/server/ee/src/billing/billing-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
19 changes: 8 additions & 11 deletions components/server/ee/src/billing/entitlement-service-ubp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,18 @@ export class EntitlementServiceUBP implements EntitlementService {

protected async hasPaidSubscription(user: User, date: Date): Promise<boolean> {
// 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.
Expand Down
121 changes: 54 additions & 67 deletions components/server/ee/src/user/stripe-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,140 +34,128 @@ export class StripeService {
return await this.getStripe().setupIntents.create({ usage: "on_session" });
}

async findCustomerByUserId(userId: string): Promise<Stripe.Customer | undefined> {
return this.findCustomerByQuery(
`metadata['${ATTRIBUTION_ID_METADATA_KEY}']:'${AttributionId.render({ kind: "user", userId })}'`,
);
}

async findCustomerByTeamId(teamId: string): Promise<Stripe.Customer | undefined> {
return this.findCustomerByQuery(
`metadata['${ATTRIBUTION_ID_METADATA_KEY}']:'${AttributionId.render({ kind: "team", teamId })}'`,
);
}

async findCustomerByQuery(query: string): Promise<Stripe.Customer | undefined> {
async findCustomerByAttributionId(attributionId: string): Promise<string | undefined> {
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<Stripe.Customer> {
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<Stripe.Customer> {
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<string> {
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<void> {
await this.getStripe().customers.update(customer.id, { metadata: { preferredCurrency: currency } });
return customer.id;
}

async setDefaultPaymentMethodForCustomer(customer: Stripe.Customer, setupIntentId: string): Promise<void> {
async setDefaultPaymentMethodForCustomer(customerId: string, setupIntentId: string): Promise<void> {
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 } }
Copy link
Member

@svenefftinge svenefftinge Sep 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know why line1 is mandatory? Why don't you copy over the full address here?

Copy link
Contributor Author

@jankeromnes jankeromnes Sep 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

line1 isn't actually mandatory: https://stripe.com/docs/api/customers/create#create_customer-address-line1

However, it seems that Stripe's TypeScript bindings marked it as mandatory, so I provide an empty value (undefined or null were not allowed, but I believe the result is the same).

Also, the country by itself is already the full address we get from the upgrade flow (we only ask for country, not for a full billing address, so new subscribers didn't have a change to fill this in yet):

Screenshot 2022-09-23 at 10 14 47

However, I'm not exactly sure what happens if:

  • You subscribe from a given country
  • Then, you set a complete billing address (e.g. all lines) in your Stripe customer portal
  • Then, you cancel
  • Then, you subscribe from a different country

It's sort of an edge case, but should also be tested. Thanks for pointing it out!

Copy link
Contributor Author

@jankeromnes jankeromnes Sep 23, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I've tested it -- if you cancel and re-subscribe from a different country, your billing address is entirely reset:

Screenshot 2022-09-23 at 15 45 02

I think that's a good thing.

: {}),
});
}

async getPortalUrlForTeam(team: Team): Promise<string> {
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<string> {
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<Stripe.Subscription | undefined> {
async findUncancelledSubscriptionByAttributionId(attributionId: string): Promise<string | undefined> {
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<void> {
await this.getStripe().subscriptions.del(subscriptionId);
}

async createSubscriptionForCustomer(customer: Stripe.Customer): Promise<void> {
async createSubscriptionForCustomer(customerId: string): Promise<void> {
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),
});
}
Expand Down
Loading