fix: keep paid limits during Stripe retries (#386)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { Plan } from "@prisma/client";
|
import { Plan } from "@prisma/client";
|
||||||
import { PLAN_PERKS } from "~/lib/constants/payments";
|
import { PLAN_PERKS } from "~/lib/constants/payments";
|
||||||
|
import { isEntitledSubscriptionStatus } from "~/lib/subscription-status";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import Spinner from "@usesend/ui/src/spinner";
|
import Spinner from "@usesend/ui/src/spinner";
|
||||||
@@ -17,13 +18,14 @@ export const PlanDetails = () => {
|
|||||||
|
|
||||||
const planKey = currentTeam.plan as keyof typeof PLAN_PERKS;
|
const planKey = currentTeam.plan as keyof typeof PLAN_PERKS;
|
||||||
const perks = PLAN_PERKS[planKey] || [];
|
const perks = PLAN_PERKS[planKey] || [];
|
||||||
|
const isEntitled = isEntitledSubscriptionStatus(
|
||||||
|
subscriptionQuery.data?.status,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="capitalize text-lg">
|
<div className="capitalize text-lg">
|
||||||
{subscriptionQuery.data?.status === "active"
|
{isEntitled ? planKey.toLowerCase() : "free"}
|
||||||
? planKey.toLowerCase()
|
|
||||||
: "free"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-muted-foreground text-sm">Current plan</div>
|
<div className="text-muted-foreground text-sm">Current plan</div>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const ENTITLED_SUBSCRIPTION_STATUSES = new Set([
|
||||||
|
"active",
|
||||||
|
"trialing",
|
||||||
|
"past_due",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isEntitledSubscriptionStatus(
|
||||||
|
status: string | null | undefined,
|
||||||
|
) {
|
||||||
|
return Boolean(status && ENTITLED_SUBSCRIPTION_STATUSES.has(status));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isEntitledSubscriptionStatus } from "~/lib/subscription-status";
|
||||||
|
|
||||||
|
describe("isEntitledSubscriptionStatus", () => {
|
||||||
|
it("treats retrying subscriptions as entitled", () => {
|
||||||
|
expect(isEntitledSubscriptionStatus("past_due")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats active and trialing subscriptions as entitled", () => {
|
||||||
|
expect(isEntitledSubscriptionStatus("active")).toBe(true);
|
||||||
|
expect(isEntitledSubscriptionStatus("trialing")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats exhausted or incomplete subscriptions as not entitled", () => {
|
||||||
|
expect(isEntitledSubscriptionStatus("unpaid")).toBe(false);
|
||||||
|
expect(isEntitledSubscriptionStatus("canceled")).toBe(false);
|
||||||
|
expect(isEntitledSubscriptionStatus("incomplete")).toBe(false);
|
||||||
|
expect(isEntitledSubscriptionStatus(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
|
import { isEntitledSubscriptionStatus } from "~/lib/subscription-status";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { sendSubscriptionConfirmationEmail } from "../mailer";
|
import { sendSubscriptionConfirmationEmail } from "../mailer";
|
||||||
import { TeamService } from "../service/team-service";
|
import { TeamService } from "../service/team-service";
|
||||||
@@ -149,6 +150,7 @@ export async function syncStripeData(customerId: string) {
|
|||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
const nextPlan = getPlanFromPriceIds(priceIds);
|
const nextPlan = getPlanFromPriceIds(priceIds);
|
||||||
|
const isEntitled = isEntitledSubscriptionStatus(subscription.status);
|
||||||
const isNowPaid = subscription.status === "active" && nextPlan !== "FREE";
|
const isNowPaid = subscription.status === "active" && nextPlan !== "FREE";
|
||||||
const shouldSendSubscriptionConfirmation = !wasPaid && isNowPaid;
|
const shouldSendSubscriptionConfirmation = !wasPaid && isNowPaid;
|
||||||
|
|
||||||
@@ -159,10 +161,10 @@ export async function syncStripeData(customerId: string) {
|
|||||||
priceId: subscription.items.data[0]?.price?.id || "",
|
priceId: subscription.items.data[0]?.price?.id || "",
|
||||||
priceIds: priceIds,
|
priceIds: priceIds,
|
||||||
currentPeriodEnd: new Date(
|
currentPeriodEnd: new Date(
|
||||||
subscription.items.data[0]?.current_period_end * 1000
|
subscription.items.data[0]?.current_period_end * 1000,
|
||||||
),
|
),
|
||||||
currentPeriodStart: new Date(
|
currentPeriodStart: new Date(
|
||||||
subscription.items.data[0]?.current_period_start * 1000
|
subscription.items.data[0]?.current_period_start * 1000,
|
||||||
),
|
),
|
||||||
cancelAtPeriodEnd: subscription.cancel_at
|
cancelAtPeriodEnd: subscription.cancel_at
|
||||||
? new Date(subscription.cancel_at * 1000)
|
? new Date(subscription.cancel_at * 1000)
|
||||||
@@ -176,10 +178,10 @@ export async function syncStripeData(customerId: string) {
|
|||||||
priceId: subscription.items.data[0]?.price?.id || "",
|
priceId: subscription.items.data[0]?.price?.id || "",
|
||||||
priceIds: priceIds,
|
priceIds: priceIds,
|
||||||
currentPeriodEnd: new Date(
|
currentPeriodEnd: new Date(
|
||||||
subscription.items.data[0]?.current_period_end * 1000
|
subscription.items.data[0]?.current_period_end * 1000,
|
||||||
),
|
),
|
||||||
currentPeriodStart: new Date(
|
currentPeriodStart: new Date(
|
||||||
subscription.items.data[0]?.current_period_start * 1000
|
subscription.items.data[0]?.current_period_start * 1000,
|
||||||
),
|
),
|
||||||
cancelAtPeriodEnd: subscription.cancel_at
|
cancelAtPeriodEnd: subscription.cancel_at
|
||||||
? new Date(subscription.cancel_at * 1000)
|
? new Date(subscription.cancel_at * 1000)
|
||||||
@@ -191,7 +193,7 @@ export async function syncStripeData(customerId: string) {
|
|||||||
|
|
||||||
await TeamService.updateTeam(team.id, {
|
await TeamService.updateTeam(team.id, {
|
||||||
plan: subscription.status === "canceled" ? "FREE" : nextPlan,
|
plan: subscription.status === "canceled" ? "FREE" : nextPlan,
|
||||||
isActive: subscription.status === "active",
|
isActive: isEntitled,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldSendSubscriptionConfirmation) {
|
if (shouldSendSubscriptionConfirmation) {
|
||||||
@@ -201,12 +203,12 @@ export async function syncStripeData(customerId: string) {
|
|||||||
teamUsers
|
teamUsers
|
||||||
.map((tu) => tu.user?.email)
|
.map((tu) => tu.user?.email)
|
||||||
.filter((email): email is string => Boolean(email))
|
.filter((email): email is string => Boolean(email))
|
||||||
.map((email) => sendSubscriptionConfirmationEmail(email))
|
.map((email) => sendSubscriptionConfirmationEmail(email)),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err, teamId: team.id },
|
{ err, teamId: team.id },
|
||||||
"[Billing]: Failed sending subscription confirmation email"
|
"[Billing]: Failed sending subscription confirmation email",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user