From 833cdc1bf7654eb7edec0af50a23d90c1a3409ef Mon Sep 17 00:00:00 2001 From: Jeremy Sfez Date: Thu, 23 Nov 2023 12:27:12 +0100 Subject: [PATCH 1/2] feat: add purchase status --- .../20231122143018_add-purchase-status.js | 30 +++++++++++++++++++ apps/backend/db/structure.sql | 18 ++++++++--- apps/backend/src/database/models/Purchase.ts | 5 ++++ apps/backend/src/database/testing/factory.ts | 1 + .../graphql/__generated__/resolver-types.ts | 6 ++-- .../src/graphql/definitions/Account.ts | 18 ++++------- apps/backend/src/stripe/index.ts | 1 + .../src/synchronize/github/eventHelpers.ts | 2 +- apps/backend/src/synchronize/github/events.ts | 1 + .../src/synchronize/github/updatePurchase.ts | 8 ++++- .../src/containers/AccountPlanChip.tsx | 4 +-- apps/frontend/src/gql/graphql.ts | 6 ++-- 12 files changed, 73 insertions(+), 27 deletions(-) create mode 100644 apps/backend/db/migrations/20231122143018_add-purchase-status.js diff --git a/apps/backend/db/migrations/20231122143018_add-purchase-status.js b/apps/backend/db/migrations/20231122143018_add-purchase-status.js new file mode 100644 index 000000000..9528eb34d --- /dev/null +++ b/apps/backend/db/migrations/20231122143018_add-purchase-status.js @@ -0,0 +1,30 @@ +/** + * @param {import('knex').Knex} knex + */ +export const up = async (knex) => { + await knex.schema.alterTable("purchases", async (table) => { + table.string("status"); + }); + + await knex("purchases").update({ + status: knex.raw(`CASE + WHEN status IS NULL AND "endDate" IS NOT NULL AND "endDate" < NOW() THEN 'canceled' + WHEN status IS NULL AND "endDate" IS NULL AND "trialEndDate" IS NOT NULL AND "trialEndDate" > NOW() THEN 'trialing' + WHEN status IS NULL THEN 'active' + ELSE status + END`), + }); + + await knex.schema.alterTable("purchases", async (table) => { + table.string("status").notNullable().alter(); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +export const down = async (knex) => { + await knex.schema.alterTable("purchases", async (table) => { + table.dropColumn("status"); + }); +}; diff --git a/apps/backend/db/structure.sql b/apps/backend/db/structure.sql index 7aaacfb3e..c8e6d0ba5 100644 --- a/apps/backend/db/structure.sql +++ b/apps/backend/db/structure.sql @@ -3,7 +3,7 @@ -- -- Dumped from database version 13.10 --- Dumped by pg_dump version 14.9 (Homebrew) +-- Dumped by pg_dump version 14.7 (Homebrew) SET statement_timeout = 0; SET lock_timeout = 0; @@ -292,7 +292,7 @@ CREATE TABLE public.files ( key character varying(255) NOT NULL, width integer, height integer, - type text NOT NULL, + type text, CONSTRAINT files_type_check CHECK ((type = ANY (ARRAY['screenshot'::text, 'screenshotDiff'::text, 'playwrightTrace'::text]))) ); @@ -403,7 +403,7 @@ ALTER SEQUENCE public.github_installations_id_seq OWNED BY public.github_install -- CREATE TABLE public.github_pull_requests ( - id bigint NOT NULL, + id integer NOT NULL, "createdAt" timestamp with time zone NOT NULL, "updatedAt" timestamp with time zone NOT NULL, "commentDeleted" boolean DEFAULT false NOT NULL, @@ -811,6 +811,7 @@ CREATE TABLE public.purchases ( "trialEndDate" timestamp with time zone, "paymentMethodFilled" boolean, "stripeSubscriptionId" character varying(255), + status character varying(255) NOT NULL, CONSTRAINT check_stripe_subscription CHECK ((((source)::text <> 'stripe'::text) OR ("stripeSubscriptionId" IS NOT NULL))) ); @@ -1608,6 +1609,14 @@ ALTER TABLE ONLY public.files ADD CONSTRAINT files_pkey PRIMARY KEY (id); +-- +-- Name: files files_type_not_null_constraint; Type: CHECK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE public.files + ADD CONSTRAINT files_type_not_null_constraint CHECK ((type IS NOT NULL)) NOT VALID; + + -- -- Name: github_accounts github_accounts_githubid_unique; Type: CONSTRAINT; Schema: public; Owner: postgres -- @@ -2758,4 +2767,5 @@ INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('2023102 INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('20231106114751_pw-traces.js', 1, NOW()); INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('20231115151718_file-type.js', 1, NOW()); INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('20231115160027_playwrightTraceIndex.js', 1, NOW()); -INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('20231115210334_file-type-not-null.js', 1, NOW()); \ No newline at end of file +INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('20231115210334_file-type-not-null.js', 1, NOW()); +INSERT INTO public.knex_migrations(name, batch, migration_time) VALUES ('20231122143018_add-purchase-status.js', 1, NOW()); \ No newline at end of file diff --git a/apps/backend/src/database/models/Purchase.ts b/apps/backend/src/database/models/Purchase.ts index f3216abe9..5a05a06b7 100644 --- a/apps/backend/src/database/models/Purchase.ts +++ b/apps/backend/src/database/models/Purchase.ts @@ -25,6 +25,10 @@ export class Purchase extends Model { type: ["string"], enum: ["github", "stripe"], }, + status: { + type: ["string"], + enum: ["active", "canceled", "trialing", "past_due"], + }, endDate: { type: ["string", "null"] }, startDate: { type: ["string"] }, trialEndDate: { type: ["string", "null"] }, @@ -41,6 +45,7 @@ export class Purchase extends Model { startDate!: string; trialEndDate!: string | null; paymentMethodFilled!: boolean | null; + status!: string; static override get relationMappings(): RelationMappings { return { diff --git a/apps/backend/src/database/testing/factory.ts b/apps/backend/src/database/testing/factory.ts index 4d19ad88a..19b7ffda8 100644 --- a/apps/backend/src/database/testing/factory.ts +++ b/apps/backend/src/database/testing/factory.ts @@ -84,6 +84,7 @@ export const Purchase = defineFactory(models.Purchase, () => ({ endDate: null, source: "github", paymentMethodFilled: false, + status: "active", })); export const TeamAccount = defineFactory(models.Account, () => ({ diff --git a/apps/backend/src/graphql/__generated__/resolver-types.ts b/apps/backend/src/graphql/__generated__/resolver-types.ts index aa7fd79fc..ddb82336e 100644 --- a/apps/backend/src/graphql/__generated__/resolver-types.ts +++ b/apps/backend/src/graphql/__generated__/resolver-types.ts @@ -633,10 +633,10 @@ export enum IPurchaseStatus { Canceled = 'canceled', /** No paid purchase */ Missing = 'missing', - /** Ongoing trial */ - Trialing = 'trialing', /** Payment due */ - Unpaid = 'unpaid' + PastDue = 'past_due', + /** Ongoing trial */ + Trialing = 'trialing' } export type IQuery = { diff --git a/apps/backend/src/graphql/definitions/Account.ts b/apps/backend/src/graphql/definitions/Account.ts index ffad2c311..e69c174d1 100644 --- a/apps/backend/src/graphql/definitions/Account.ts +++ b/apps/backend/src/graphql/definitions/Account.ts @@ -47,7 +47,7 @@ export const typeDefs = gql` "No paid purchase" missing "Payment due" - unpaid + past_due "Post-cancelation date" canceled } @@ -198,21 +198,11 @@ export const resolvers: IResolvers = { return account.$getActivePurchase(); }, purchaseStatus: async (account) => { - if (account.forcedPlanId !== null) { - return IPurchaseStatus.Active; - } - + if (account.forcedPlanId !== null) return IPurchaseStatus.Active; if (account.type === "user") return null; const purchase = await account.$getActivePurchase(); - const hasPaidPlan = - purchase && purchase.plan && purchase.plan.name !== "free"; - - if (hasPaidPlan) { - if (purchase.$isTrialActive()) return IPurchaseStatus.Trialing; - if (!purchase.paymentMethodFilled) return IPurchaseStatus.Unpaid; - return IPurchaseStatus.Active; - } + if (purchase) return purchase.status as IPurchaseStatus; const hasOldPaidPurchase = await Purchase.query() .where("accountId", account.id) @@ -224,6 +214,8 @@ export const resolvers: IResolvers = { .limit(1) .resultSize(); if (hasOldPaidPurchase) return IPurchaseStatus.Canceled; + + // No paid purchase return IPurchaseStatus.Missing; }, trialStatus: async (account) => { diff --git a/apps/backend/src/stripe/index.ts b/apps/backend/src/stripe/index.ts index 7a3bde293..0a28fc6e0 100644 --- a/apps/backend/src/stripe/index.ts +++ b/apps/backend/src/stripe/index.ts @@ -166,6 +166,7 @@ const getPurchaseDataFromSubscription = async ( endDate, trialEndDate, paymentMethodFilled, + status: subscription.status, }; }; diff --git a/apps/backend/src/synchronize/github/eventHelpers.ts b/apps/backend/src/synchronize/github/eventHelpers.ts index fc7e0cd12..99fd9c76e 100644 --- a/apps/backend/src/synchronize/github/eventHelpers.ts +++ b/apps/backend/src/synchronize/github/eventHelpers.ts @@ -159,7 +159,7 @@ export const cancelPurchase = async ( if (activePurchase && activePurchase.source === "github") { await Purchase.query() .findById(activePurchase.id) - .patch({ endDate: payload.effective_date }); + .patch({ endDate: payload.effective_date, status: "canceled" }); } }; diff --git a/apps/backend/src/synchronize/github/events.ts b/apps/backend/src/synchronize/github/events.ts index c5b25ab88..b42de653c 100644 --- a/apps/backend/src/synchronize/github/events.ts +++ b/apps/backend/src/synchronize/github/events.ts @@ -44,6 +44,7 @@ export const handleGitHubEvents = async ({ startDate: payload.effective_date, source: "github", trialEndDate: payload.marketplace_purchase.free_trial_ends_on, + status: "active", }); return; } diff --git a/apps/backend/src/synchronize/github/updatePurchase.ts b/apps/backend/src/synchronize/github/updatePurchase.ts index 9a24c6163..c79a1fce1 100644 --- a/apps/backend/src/synchronize/github/updatePurchase.ts +++ b/apps/backend/src/synchronize/github/updatePurchase.ts @@ -25,6 +25,7 @@ export const updatePurchase = async ( startDate: effectiveDate, source: "github", trialEndDate: payload.marketplace_purchase.free_trial_ends_on, + status: "active", }); return; } @@ -38,13 +39,18 @@ export const updatePurchase = async ( transaction(async (trx) => { await Promise.all([ Purchase.query(trx) - .patch({ endDate: effectiveDate }) + .patch({ + endDate: effectiveDate, + status: + effectiveDate > new Date().toISOString() ? "cancelled" : "active", + }) .findById(activePurchase.id), Purchase.query(trx).insert({ accountId: account.id, planId: plan.id, startDate: effectiveDate, source: "github", + status: "active", }), ]); }); diff --git a/apps/frontend/src/containers/AccountPlanChip.tsx b/apps/frontend/src/containers/AccountPlanChip.tsx index ccd7a20d5..25f010b63 100644 --- a/apps/frontend/src/containers/AccountPlanChip.tsx +++ b/apps/frontend/src/containers/AccountPlanChip.tsx @@ -27,8 +27,8 @@ export const AccountPlanChip = (props: PlanChipProps) => { : null; case PurchaseStatus.Trialing: return { color: "info", children: "Trial" }; - case PurchaseStatus.Unpaid: - return { color: "danger", children: "Unpaid" }; + case PurchaseStatus.PastDue: + return { color: "danger", children: "Past Due" }; case null: return { color: "neutral", children: "Hobby" }; default: diff --git a/apps/frontend/src/gql/graphql.ts b/apps/frontend/src/gql/graphql.ts index 885f5d9ca..14add303e 100644 --- a/apps/frontend/src/gql/graphql.ts +++ b/apps/frontend/src/gql/graphql.ts @@ -627,10 +627,10 @@ export enum PurchaseStatus { Canceled = 'canceled', /** No paid purchase */ Missing = 'missing', - /** Ongoing trial */ - Trialing = 'trialing', /** Payment due */ - Unpaid = 'unpaid' + PastDue = 'past_due', + /** Ongoing trial */ + Trialing = 'trialing' } export type Query = { From 6bfdadf1f6b7db1d750c8673d657536a087dece9 Mon Sep 17 00:00:00 2001 From: Jeremy Sfez Date: Thu, 23 Nov 2023 12:27:34 +0100 Subject: [PATCH 2/2] feat: add past due to payment banner --- .../20231122143018_add-purchase-status.js | 6 +- apps/backend/db/structure.sql | 12 +- .../src/synchronize/github/updatePurchase.ts | 3 +- .../frontend/src/containers/PaymentBanner.tsx | 133 ++++++++---------- 4 files changed, 67 insertions(+), 87 deletions(-) diff --git a/apps/backend/db/migrations/20231122143018_add-purchase-status.js b/apps/backend/db/migrations/20231122143018_add-purchase-status.js index 9528eb34d..b486fdaf1 100644 --- a/apps/backend/db/migrations/20231122143018_add-purchase-status.js +++ b/apps/backend/db/migrations/20231122143018_add-purchase-status.js @@ -2,7 +2,7 @@ * @param {import('knex').Knex} knex */ export const up = async (knex) => { - await knex.schema.alterTable("purchases", async (table) => { + await knex.schema.alterTable("purchases", (table) => { table.string("status"); }); @@ -15,7 +15,7 @@ export const up = async (knex) => { END`), }); - await knex.schema.alterTable("purchases", async (table) => { + await knex.schema.alterTable("purchases", (table) => { table.string("status").notNullable().alter(); }); }; @@ -24,7 +24,7 @@ export const up = async (knex) => { * @param {import('knex').Knex} knex */ export const down = async (knex) => { - await knex.schema.alterTable("purchases", async (table) => { + await knex.schema.alterTable("purchases", (table) => { table.dropColumn("status"); }); }; diff --git a/apps/backend/db/structure.sql b/apps/backend/db/structure.sql index c8e6d0ba5..8b858b9ba 100644 --- a/apps/backend/db/structure.sql +++ b/apps/backend/db/structure.sql @@ -292,7 +292,7 @@ CREATE TABLE public.files ( key character varying(255) NOT NULL, width integer, height integer, - type text, + type text NOT NULL, CONSTRAINT files_type_check CHECK ((type = ANY (ARRAY['screenshot'::text, 'screenshotDiff'::text, 'playwrightTrace'::text]))) ); @@ -403,7 +403,7 @@ ALTER SEQUENCE public.github_installations_id_seq OWNED BY public.github_install -- CREATE TABLE public.github_pull_requests ( - id integer NOT NULL, + id bigint NOT NULL, "createdAt" timestamp with time zone NOT NULL, "updatedAt" timestamp with time zone NOT NULL, "commentDeleted" boolean DEFAULT false NOT NULL, @@ -1609,14 +1609,6 @@ ALTER TABLE ONLY public.files ADD CONSTRAINT files_pkey PRIMARY KEY (id); --- --- Name: files files_type_not_null_constraint; Type: CHECK CONSTRAINT; Schema: public; Owner: postgres --- - -ALTER TABLE public.files - ADD CONSTRAINT files_type_not_null_constraint CHECK ((type IS NOT NULL)) NOT VALID; - - -- -- Name: github_accounts github_accounts_githubid_unique; Type: CONSTRAINT; Schema: public; Owner: postgres -- diff --git a/apps/backend/src/synchronize/github/updatePurchase.ts b/apps/backend/src/synchronize/github/updatePurchase.ts index c79a1fce1..ea686a5b2 100644 --- a/apps/backend/src/synchronize/github/updatePurchase.ts +++ b/apps/backend/src/synchronize/github/updatePurchase.ts @@ -41,8 +41,7 @@ export const updatePurchase = async ( Purchase.query(trx) .patch({ endDate: effectiveDate, - status: - effectiveDate > new Date().toISOString() ? "cancelled" : "active", + status: new Date(effectiveDate) > new Date() ? "cancelled" : "active", }) .findById(activePurchase.id), Purchase.query(trx).insert({ diff --git a/apps/frontend/src/containers/PaymentBanner.tsx b/apps/frontend/src/containers/PaymentBanner.tsx index d73a418e9..5b2966778 100644 --- a/apps/frontend/src/containers/PaymentBanner.tsx +++ b/apps/frontend/src/containers/PaymentBanner.tsx @@ -11,9 +11,6 @@ import { Button } from "@/ui/Button"; import { Container } from "@/ui/Container"; import { StripePortalLink } from "@/ui/StripeLink"; -const now = new Date(); -const FREE_PLAN_EXPIRATION_DATE = new Date("2023-06-01"); - export const PaymentBannerFragment = graphql(` fragment PaymentBanner_Account on Account { id @@ -93,14 +90,14 @@ const BannerCta = ({ } }; -const getBannerProps = ({ +const getTeamBannerProps = ({ purchaseStatus, trialDaysRemaining, hasGithubPurchase, missingPaymentMethod, pendingCancelAt, }: { - purchaseStatus: PurchaseStatus | null; + purchaseStatus: PurchaseStatus; trialDaysRemaining: number | null; hasGithubPurchase: boolean; missingPaymentMethod: boolean; @@ -111,66 +108,63 @@ const getBannerProps = ({ bannerColor?: BannerProps["color"]; action: SubmitAction; } => { - if ( - (purchaseStatus === PurchaseStatus.Active || - purchaseStatus === PurchaseStatus.Trialing || - purchaseStatus === PurchaseStatus.Unpaid) && - missingPaymentMethod - ) { - const trialMessage = - trialDaysRemaining !== null - ? `Your trial ends in ${trialDaysRemaining} days. ` - : ""; - - return { - message: `${trialMessage}Add a payment method to retain access to team features.`, - buttonLabel: "Add payment method", - bannerColor: - !trialDaysRemaining || trialDaysRemaining < 5 ? "warning" : "neutral", - action: "stripePortalSession", - }; - } - - if ( - purchaseStatus === PurchaseStatus.Trialing || - purchaseStatus === PurchaseStatus.Active - ) { - const subscriptionType = - purchaseStatus === PurchaseStatus.Trialing ? "trial" : "subscription"; - const action = "stripePortalSession"; - - if (!pendingCancelAt) { - return { action, message: "" }; + switch (purchaseStatus) { + case PurchaseStatus.PastDue: + return { + bannerColor: "warning", + message: + "Your subscription is past due. Please update your payment info.", + buttonLabel: "Manage subscription", + action: hasGithubPurchase ? "settings" : "stripeCheckoutSession", + }; + + case PurchaseStatus.Canceled: + case PurchaseStatus.Missing: + return { + bannerColor: "danger", + message: "Upgrade to Pro plan to use team features.", + ...(hasGithubPurchase + ? { action: "settings", buttonLabel: "Manage subscription" } + : { action: "stripeCheckoutSession", buttonLabel: "Upgrade" }), + }; + + case PurchaseStatus.Active: + case PurchaseStatus.Trialing: { + if (missingPaymentMethod) { + const remainingDayMessage = `Your trial ends in ${trialDaysRemaining} days. `; + return { + message: `${ + trialDaysRemaining ? remainingDayMessage : "" + }Add a payment method to retain access to team features.`, + buttonLabel: "Add payment method", + bannerColor: + !trialDaysRemaining || trialDaysRemaining < 5 + ? "warning" + : "neutral", + action: "stripePortalSession", + }; + } + + if (pendingCancelAt) { + const formatDate = (date: string) => moment(date).format("LL"); + const subscriptionTypeLabel = + purchaseStatus === "trialing" ? "trial" : "subscription"; + return { + action: "stripePortalSession", + buttonLabel: `Reactivate ${subscriptionTypeLabel}`, + message: `Your ${subscriptionTypeLabel} has been canceled. You can still use team features until the trial ends on ${formatDate( + pendingCancelAt, + )}.`, + }; + } + + // Trial is active + return { action: "stripePortalSession", message: "" }; } - return { - action, - buttonLabel: `Reactivate ${subscriptionType}`, - message: `Your ${subscriptionType} has been canceled. You can still use team features until the trial ends on ${moment( - pendingCancelAt, - ).format("LL")}.`, - }; - } - - const action = hasGithubPurchase ? "settings" : "stripeCheckoutSession"; - const buttonLabel = hasGithubPurchase ? "Manage subscription" : "Upgrade"; - - if (now < FREE_PLAN_EXPIRATION_DATE) { - return { - action, - buttonLabel, - bannerColor: "neutral", - message: - "Starting June 1st, 2023, a Pro plan will be required to use team features.", - }; + default: + return { action: "stripePortalSession", message: "" }; } - - return { - action, - buttonLabel, - bannerColor: "danger", - message: "Upgrade to Pro plan to use team features.", - }; }; export type PaymentBannerProps = { @@ -181,10 +175,6 @@ export const PaymentBanner = memo((props: PaymentBannerProps) => { const account = useFragment(PaymentBannerFragment, props.account); const { data: { me } = {} } = useQuery(PaymentBannerQuery); - if (!me || account.__typename === "User") { - return null; - } - const { purchase, permissions, @@ -193,15 +183,14 @@ export const PaymentBanner = memo((props: PaymentBannerProps) => { pendingCancelAt, } = account; - const missingPaymentMethod = Boolean( - purchase && !purchase.paymentMethodFilled, - ); + // no banner for user account + if (!me || !purchaseStatus) return null; - const { message, buttonLabel, bannerColor, action } = getBannerProps({ - purchaseStatus: purchaseStatus ?? null, + const { message, buttonLabel, bannerColor, action } = getTeamBannerProps({ + purchaseStatus, trialDaysRemaining: purchase?.trialDaysRemaining ?? null, hasGithubPurchase: Boolean(purchase && purchase.source === "github"), - missingPaymentMethod, + missingPaymentMethod: Boolean(purchase && !purchase.paymentMethodFilled), pendingCancelAt: pendingCancelAt, }); const userIsOwner = permissions.includes(Permission.Write);