Skip to content

Commit b20f3b5

Browse files
authored
fix: keep paid limits during Stripe retries (#386)
1 parent bd78ed9 commit b20f3b5

4 files changed

Lines changed: 45 additions & 10 deletions

File tree

apps/web/src/components/payments/PlanDetails.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Plan } from "@prisma/client";
22
import { PLAN_PERKS } from "~/lib/constants/payments";
3+
import { isEntitledSubscriptionStatus } from "~/lib/subscription-status";
34
import { CheckCircle2 } from "lucide-react";
45
import { api } from "~/trpc/react";
56
import Spinner from "@usesend/ui/src/spinner";
@@ -17,13 +18,14 @@ export const PlanDetails = () => {
1718

1819
const planKey = currentTeam.plan as keyof typeof PLAN_PERKS;
1920
const perks = PLAN_PERKS[planKey] || [];
21+
const isEntitled = isEntitledSubscriptionStatus(
22+
subscriptionQuery.data?.status,
23+
);
2024

2125
return (
2226
<div>
2327
<div className="capitalize text-lg">
24-
{subscriptionQuery.data?.status === "active"
25-
? planKey.toLowerCase()
26-
: "free"}
28+
{isEntitled ? planKey.toLowerCase() : "free"}
2729
</div>
2830
<div className="flex items-center gap-2">
2931
<div className="text-muted-foreground text-sm">Current plan</div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
const ENTITLED_SUBSCRIPTION_STATUSES = new Set([
2+
"active",
3+
"trialing",
4+
"past_due",
5+
]);
6+
7+
export function isEntitledSubscriptionStatus(
8+
status: string | null | undefined,
9+
) {
10+
return Boolean(status && ENTITLED_SUBSCRIPTION_STATUSES.has(status));
11+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isEntitledSubscriptionStatus } from "~/lib/subscription-status";
3+
4+
describe("isEntitledSubscriptionStatus", () => {
5+
it("treats retrying subscriptions as entitled", () => {
6+
expect(isEntitledSubscriptionStatus("past_due")).toBe(true);
7+
});
8+
9+
it("treats active and trialing subscriptions as entitled", () => {
10+
expect(isEntitledSubscriptionStatus("active")).toBe(true);
11+
expect(isEntitledSubscriptionStatus("trialing")).toBe(true);
12+
});
13+
14+
it("treats exhausted or incomplete subscriptions as not entitled", () => {
15+
expect(isEntitledSubscriptionStatus("unpaid")).toBe(false);
16+
expect(isEntitledSubscriptionStatus("canceled")).toBe(false);
17+
expect(isEntitledSubscriptionStatus("incomplete")).toBe(false);
18+
expect(isEntitledSubscriptionStatus(null)).toBe(false);
19+
});
20+
});

apps/web/src/server/billing/payments.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Stripe from "stripe";
22
import { env } from "~/env";
3+
import { isEntitledSubscriptionStatus } from "~/lib/subscription-status";
34
import { db } from "../db";
45
import { sendSubscriptionConfirmationEmail } from "../mailer";
56
import { TeamService } from "../service/team-service";
@@ -149,6 +150,7 @@ export async function syncStripeData(customerId: string) {
149150
.filter((id): id is string => Boolean(id));
150151

151152
const nextPlan = getPlanFromPriceIds(priceIds);
153+
const isEntitled = isEntitledSubscriptionStatus(subscription.status);
152154
const isNowPaid = subscription.status === "active" && nextPlan !== "FREE";
153155
const shouldSendSubscriptionConfirmation = !wasPaid && isNowPaid;
154156

@@ -159,10 +161,10 @@ export async function syncStripeData(customerId: string) {
159161
priceId: subscription.items.data[0]?.price?.id || "",
160162
priceIds: priceIds,
161163
currentPeriodEnd: new Date(
162-
subscription.items.data[0]?.current_period_end * 1000
164+
subscription.items.data[0]?.current_period_end * 1000,
163165
),
164166
currentPeriodStart: new Date(
165-
subscription.items.data[0]?.current_period_start * 1000
167+
subscription.items.data[0]?.current_period_start * 1000,
166168
),
167169
cancelAtPeriodEnd: subscription.cancel_at
168170
? new Date(subscription.cancel_at * 1000)
@@ -176,10 +178,10 @@ export async function syncStripeData(customerId: string) {
176178
priceId: subscription.items.data[0]?.price?.id || "",
177179
priceIds: priceIds,
178180
currentPeriodEnd: new Date(
179-
subscription.items.data[0]?.current_period_end * 1000
181+
subscription.items.data[0]?.current_period_end * 1000,
180182
),
181183
currentPeriodStart: new Date(
182-
subscription.items.data[0]?.current_period_start * 1000
184+
subscription.items.data[0]?.current_period_start * 1000,
183185
),
184186
cancelAtPeriodEnd: subscription.cancel_at
185187
? new Date(subscription.cancel_at * 1000)
@@ -191,7 +193,7 @@ export async function syncStripeData(customerId: string) {
191193

192194
await TeamService.updateTeam(team.id, {
193195
plan: subscription.status === "canceled" ? "FREE" : nextPlan,
194-
isActive: subscription.status === "active",
196+
isActive: isEntitled,
195197
});
196198

197199
if (shouldSendSubscriptionConfirmation) {
@@ -201,12 +203,12 @@ export async function syncStripeData(customerId: string) {
201203
teamUsers
202204
.map((tu) => tu.user?.email)
203205
.filter((email): email is string => Boolean(email))
204-
.map((email) => sendSubscriptionConfirmationEmail(email))
206+
.map((email) => sendSubscriptionConfirmationEmail(email)),
205207
);
206208
} catch (err) {
207209
logger.error(
208210
{ err, teamId: team.id },
209-
"[Billing]: Failed sending subscription confirmation email"
211+
"[Billing]: Failed sending subscription confirmation email",
210212
);
211213
}
212214
}

0 commit comments

Comments
 (0)