Skip to content

Commit

Permalink
Merge pull request #1110 from argos-ci/add-puchase-status-field
Browse files Browse the repository at this point in the history
feat: add purchase status
  • Loading branch information
jsfez authored Nov 23, 2023
2 parents e81863a + 6bfdadf commit ee62bc2
Show file tree
Hide file tree
Showing 13 changed files with 123 additions and 97 deletions.
30 changes: 30 additions & 0 deletions apps/backend/db/migrations/20231122143018_add-purchase-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @param {import('knex').Knex} knex
*/
export const up = async (knex) => {
await knex.schema.alterTable("purchases", (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", (table) => {
table.string("status").notNullable().alter();
});
};

/**
* @param {import('knex').Knex} knex
*/
export const down = async (knex) => {
await knex.schema.alterTable("purchases", (table) => {
table.dropColumn("status");
});
};
6 changes: 4 additions & 2 deletions apps/backend/db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)))
);

Expand Down Expand Up @@ -2758,4 +2759,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());
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());
5 changes: 5 additions & 0 deletions apps/backend/src/database/models/Purchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/database/testing/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const Purchase = defineFactory(models.Purchase, () => ({
endDate: null,
source: "github",
paymentMethodFilled: false,
status: "active",
}));

export const TeamAccount = defineFactory(models.Account, () => ({
Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/graphql/__generated__/resolver-types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 5 additions & 13 deletions apps/backend/src/graphql/definitions/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const typeDefs = gql`
"No paid purchase"
missing
"Payment due"
unpaid
past_due
"Post-cancelation date"
canceled
}
Expand Down Expand Up @@ -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)
Expand All @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/stripe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ const getPurchaseDataFromSubscription = async (
endDate,
trialEndDate,
paymentMethodFilled,
status: subscription.status,
};
};

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/synchronize/github/eventHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
};

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/synchronize/github/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion apps/backend/src/synchronize/github/updatePurchase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const updatePurchase = async (
startDate: effectiveDate,
source: "github",
trialEndDate: payload.marketplace_purchase.free_trial_ends_on,
status: "active",
});
return;
}
Expand All @@ -38,13 +39,17 @@ export const updatePurchase = async (
transaction(async (trx) => {
await Promise.all([
Purchase.query(trx)
.patch({ endDate: effectiveDate })
.patch({
endDate: effectiveDate,
status: new Date(effectiveDate) > new Date() ? "cancelled" : "active",
})
.findById(activePurchase.id),
Purchase.query(trx).insert({
accountId: account.id,
planId: plan.id,
startDate: effectiveDate,
source: "github",
status: "active",
}),
]);
});
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/containers/AccountPlanChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
133 changes: 61 additions & 72 deletions apps/frontend/src/containers/PaymentBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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 = {
Expand All @@ -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,
Expand All @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/src/gql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down

0 comments on commit ee62bc2

Please sign in to comment.