fix: keep paid limits during Stripe retries (#386)

This commit is contained in:
KM Koushik
2026-04-01 13:37:09 +11:00
committed by GitHub
parent bd78ed9ad9
commit b20f3b5d74
4 changed files with 45 additions and 10 deletions
@@ -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>
+11
View File
@@ -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);
});
});
+9 -7
View File
@@ -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",
); );
} }
} }