Skip to content

Commit 2b83d5e

Browse files
committed
Add billing warning and open invoice filtering
1 parent 52261a5 commit 2b83d5e

File tree

11 files changed

+181
-37
lines changed

11 files changed

+181
-37
lines changed

apps/dashboard/src/@/actions/stripe-actions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ function getStripe() {
2323

2424
export async function getTeamInvoices(
2525
team: Team,
26-
options?: { cursor?: string },
26+
options?: { cursor?: string; status?: "open" },
2727
) {
2828
try {
2929
const customerId = team.stripeCustomerId;
@@ -37,6 +37,8 @@ export async function getTeamInvoices(
3737
customer: customerId,
3838
limit: 10,
3939
starting_after: options?.cursor,
40+
// Only return open invoices if the status is open
41+
status: options?.status,
4042
});
4143

4244
return invoices;

apps/dashboard/src/@/components/billing.tsx

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"use client";
22

33
import { useMutation } from "@tanstack/react-query";
4+
import { AlertTriangleIcon } from "lucide-react";
5+
import Link from "next/link";
46
import { toast } from "sonner";
57
import type {
68
GetBillingCheckoutUrlAction,
79
GetBillingCheckoutUrlOptions,
810
GetBillingPortalUrlAction,
911
GetBillingPortalUrlOptions,
1012
} from "../actions/billing";
13+
import type { Team } from "../api/team";
1114
import { cn } from "../lib/utils";
1215
import { Spinner } from "./ui/Spinner/Spinner";
1316
import { Button, type ButtonProps } from "./ui/button";
@@ -16,6 +19,7 @@ type CheckoutButtonProps = Omit<GetBillingCheckoutUrlOptions, "redirectUrl"> & {
1619
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
1720
buttonProps?: Omit<ButtonProps, "children">;
1821
children: React.ReactNode;
22+
billingStatus: Team["billingStatus"];
1923
};
2024

2125
export function CheckoutButton({
@@ -25,6 +29,7 @@ export function CheckoutButton({
2529
getBillingCheckoutUrl,
2630
children,
2731
buttonProps,
32+
billingStatus,
2833
}: CheckoutButtonProps) {
2934
const getUrlMutation = useMutation({
3035
mutationFn: async () => {
@@ -40,35 +45,65 @@ export function CheckoutButton({
4045
const errorMessage = "Failed to open checkout page";
4146

4247
return (
43-
<Button
44-
{...buttonProps}
45-
className={cn(buttonProps?.className, "gap-2")}
46-
disabled={getUrlMutation.isPending || buttonProps?.disabled}
47-
onClick={async (e) => {
48-
buttonProps?.onClick?.(e);
49-
getUrlMutation.mutate(undefined, {
50-
onSuccess: (res) => {
51-
if (!res.url) {
52-
toast.error(errorMessage);
53-
return;
54-
}
48+
<div className="flex w-full flex-col items-center gap-2">
49+
{/* show warning if the team has an invalid payment method */}
50+
{billingStatus === "invalidPayment" && (
51+
<BillingWarning teamSlug={teamSlug} />
52+
)}
53+
<Button
54+
{...buttonProps}
55+
className={cn(buttonProps?.className, "w-full gap-2")}
56+
disabled={
57+
// disable button if the team has an invalid payment method
58+
// api will return 402 error if the team has an invalid payment method
59+
billingStatus === "invalidPayment" ||
60+
getUrlMutation.isPending ||
61+
buttonProps?.disabled
62+
}
63+
onClick={async (e) => {
64+
buttonProps?.onClick?.(e);
65+
getUrlMutation.mutate(undefined, {
66+
onSuccess: (res) => {
67+
if (!res.url) {
68+
toast.error(errorMessage);
69+
return;
70+
}
5571

56-
const tab = window.open(res.url, "_blank");
72+
const tab = window.open(res.url, "_blank");
5773

58-
if (!tab) {
74+
if (!tab) {
75+
toast.error(errorMessage);
76+
return;
77+
}
78+
},
79+
onError: () => {
5980
toast.error(errorMessage);
60-
return;
61-
}
62-
},
63-
onError: () => {
64-
toast.error(errorMessage);
65-
},
66-
});
67-
}}
68-
>
69-
{getUrlMutation.isPending && <Spinner className="size-4" />}
70-
{children}
71-
</Button>
81+
},
82+
});
83+
}}
84+
>
85+
{getUrlMutation.isPending && <Spinner className="size-4" />}
86+
{children}
87+
</Button>
88+
</div>
89+
);
90+
}
91+
92+
function BillingWarning({ teamSlug }: { teamSlug: string }) {
93+
return (
94+
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-400">
95+
<AlertTriangleIcon className="h-5 w-5 flex-shrink-0" />
96+
<p className="text-sm">
97+
You have outstanding invoices. Please{" "}
98+
<Link
99+
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
100+
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
101+
>
102+
pay them
103+
</Link>{" "}
104+
to continue.
105+
</p>
106+
</div>
72107
);
73108
}
74109

apps/dashboard/src/@/components/blocks/pricing-card.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type PricingCardCta = {
3030

3131
type PricingCardProps = {
3232
teamSlug: string;
33+
billingStatus: Team["billingStatus"];
3334
billingPlan: keyof typeof TEAM_PLANS;
3435
cta?: PricingCardCta;
3536
ctaHint?: string;
@@ -41,6 +42,7 @@ type PricingCardProps = {
4142

4243
export const PricingCard: React.FC<PricingCardProps> = ({
4344
teamSlug,
45+
billingStatus,
4446
billingPlan,
4547
cta,
4648
highlighted = false,
@@ -131,6 +133,7 @@ export const PricingCard: React.FC<PricingCardProps> = ({
131133
<div className="flex flex-col gap-3">
132134
{billingPlanToSkuMap[billingPlan] && cta.type === "checkout" && (
133135
<CheckoutButton
136+
billingStatus={billingStatus}
134137
buttonProps={{
135138
variant: highlighted ? "default" : "outline",
136139
className: highlighted ? undefined : "bg-background",

apps/dashboard/src/app/login/onboarding/team-onboarding/InviteTeamMembers.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function InviteTeamMembersUI(props: {
7777
<Sheet open={showPlanModal} onOpenChange={setShowPlanModal}>
7878
<SheetContent className="!max-w-[1300px] w-full overflow-auto">
7979
<InviteModalContent
80+
billingStatus={props.team.billingStatus}
8081
teamSlug={props.team.slug}
8182
getBillingCheckoutUrl={props.getBillingCheckoutUrl}
8283
trackEvent={props.trackEvent}
@@ -144,6 +145,7 @@ export function InviteTeamMembersUI(props: {
144145

145146
function InviteModalContent(props: {
146147
teamSlug: string;
148+
billingStatus: Team["billingStatus"];
147149
getBillingCheckoutUrl: GetBillingCheckoutUrlAction;
148150
trackEvent: (params: TrackingParams) => void;
149151
}) {
@@ -154,6 +156,7 @@ function InviteModalContent(props: {
154156
const starterPlan = (
155157
<PricingCard
156158
billingPlan="starter"
159+
billingStatus={props.billingStatus}
157160
teamSlug={props.teamSlug}
158161
cta={{
159162
title: "Get Started",
@@ -174,6 +177,7 @@ function InviteModalContent(props: {
174177
const growthPlan = (
175178
<PricingCard
176179
billingPlan="growth"
180+
billingStatus={props.billingStatus}
177181
teamSlug={props.teamSlug}
178182
cta={{
179183
title: "Get Started",
@@ -195,6 +199,7 @@ function InviteModalContent(props: {
195199
const acceleratePlan = (
196200
<PricingCard
197201
billingPlan="accelerate"
202+
billingStatus={props.billingStatus}
198203
teamSlug={props.teamSlug}
199204
cta={{
200205
title: "Get started",
@@ -215,6 +220,7 @@ function InviteModalContent(props: {
215220
const scalePlan = (
216221
<PricingCard
217222
billingPlan="scale"
223+
billingStatus={props.billingStatus}
218224
teamSlug={props.teamSlug}
219225
cta={{
220226
title: "Get started",

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/components/PlanInfoCard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export function PlanInfoCardUI(props: {
119119
</Button>
120120

121121
<CancelPlanButton
122+
teamSlug={props.team.slug}
123+
billingStatus={props.team.billingStatus}
122124
cancelPlan={props.cancelPlan}
123125
currentPlan={props.team.billingPlan}
124126
getTeam={props.getTeam}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"use client";
2+
3+
import {
4+
Select,
5+
SelectContent,
6+
SelectItem,
7+
SelectTrigger,
8+
SelectValue,
9+
} from "@/components/ui/select";
10+
import { useQueryStates } from "nuqs";
11+
import { startTransition } from "react";
12+
import { searchParams } from "../search-params";
13+
14+
export function BillingFilter() {
15+
const [{ status }, setStates] = useQueryStates(
16+
{
17+
cursor: searchParams.cursor,
18+
status: searchParams.status,
19+
},
20+
{
21+
history: "push",
22+
shallow: false,
23+
startTransition,
24+
},
25+
);
26+
return (
27+
<Select
28+
value={status ?? "all"}
29+
onValueChange={(v) => {
30+
setStates({
31+
cursor: null,
32+
// only set the status if it's "open", otherwise clear it
33+
status: v === "open" ? "open" : null,
34+
});
35+
}}
36+
>
37+
<SelectTrigger className="w-48">
38+
<SelectValue placeholder="Filter by status" />
39+
</SelectTrigger>
40+
<SelectContent>
41+
<SelectItem value="all">All Invoices</SelectItem>
42+
<SelectItem value="open">Open Invoices</SelectItem>
43+
</SelectContent>
44+
</Select>
45+
);
46+
}

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/components/billing-history.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { searchParams } from "../search-params";
2525

2626
export function BillingHistory(props: {
2727
invoices: Stripe.Invoice[];
28+
status: "all" | "past_due" | "open";
2829
hasMore: boolean;
2930
}) {
3031
const [isLoading, startTransition] = useTransition();
@@ -74,6 +75,14 @@ export function BillingHistory(props: {
7475
};
7576

7677
if (props.invoices.length === 0) {
78+
if (props.status === "open") {
79+
return (
80+
<div className="py-6 text-center">
81+
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />
82+
<h3 className="mt-2 font-medium text-lg">No open invoices</h3>
83+
</div>
84+
);
85+
}
7786
return (
7887
<div className="py-6 text-center">
7988
<Receipt className="mx-auto h-12 w-12 text-muted-foreground" />

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/invoices/page.tsx

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getTeamBySlug } from "@/api/team";
33
import { redirect } from "next/navigation";
44
import type { SearchParams } from "nuqs/server";
55
import { getValidAccount } from "../../../../../../account/settings/getAccount";
6+
import { BillingFilter } from "./components/billing-filter";
67
import { BillingHistory } from "./components/billing-history";
78
import { searchParamLoader } from "./search-params";
89

@@ -31,19 +32,28 @@ export default async function Page(props: {
3132

3233
const invoices = await getTeamInvoices(team, {
3334
cursor: searchParams.cursor ?? undefined,
35+
status: searchParams.status ?? undefined,
3436
});
3537

3638
return (
3739
<div className="overflow-hidden rounded-lg border bg-card">
38-
<div className="p-6">
39-
<h2 className="font-semibold text-2xl leading-none tracking-tight">
40-
Invoice History
41-
</h2>
42-
<p className="mt-1 text-muted-foreground text-sm">
43-
View your past invoices and payment history
44-
</p>
40+
<div className="flex items-center justify-between p-6">
41+
<div className="flex flex-col gap-1">
42+
<h2 className="font-semibold text-2xl leading-none tracking-tight">
43+
Invoice History
44+
</h2>
45+
<p className="text-muted-foreground text-sm">
46+
View your past invoices and payment history
47+
</p>
48+
</div>
49+
<BillingFilter />
4550
</div>
46-
<BillingHistory invoices={invoices.data} hasMore={invoices.has_more} />
51+
<BillingHistory
52+
invoices={invoices.data}
53+
hasMore={invoices.has_more}
54+
// fall back to "all" if the status is not set
55+
status={searchParams.status ?? "all"}
56+
/>
4757
</div>
4858
);
4959
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { createLoader, parseAsString } from "nuqs/server";
1+
import { createLoader, parseAsString, parseAsStringEnum } from "nuqs/server";
22

33
export const searchParams = {
44
cursor: parseAsString,
5+
status: parseAsStringEnum(["open"]),
56
};
67

78
export const searchParamLoader = createLoader(searchParams);

apps/dashboard/src/components/settings/Account/Billing/CancelPlanModal/CancelPlanModal.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ const cancelReasons: Array<{
8383
];
8484

8585
export function CancelPlanButton(props: {
86+
teamSlug: string;
8687
cancelPlan: CancelPlan;
8788
currentPlan: Team["billingPlan"];
89+
billingStatus: Team["billingStatus"];
8890
getTeam: () => Promise<Team>;
8991
}) {
9092
return (
@@ -102,7 +104,9 @@ export function CancelPlanButton(props: {
102104
</SheetTitle>
103105
</SheetHeader>
104106

105-
{props.currentPlan === "pro" ? (
107+
{props.billingStatus === "invalidPayment" ? (
108+
<UnpaidInvoicesWarning teamSlug={props.teamSlug} />
109+
) : props.currentPlan === "pro" ? (
106110
<ProPlanCancelPlanSheetContent />
107111
) : (
108112
<CancelPlanSheetContent
@@ -115,6 +119,28 @@ export function CancelPlanButton(props: {
115119
);
116120
}
117121

122+
function UnpaidInvoicesWarning({ teamSlug }: { teamSlug: string }) {
123+
return (
124+
<div>
125+
<h2 className="mb-1 font-semibold text-2xl tracking-tight">
126+
Cancel Plan
127+
</h2>
128+
<p className="mb-5 text-muted-foreground text-sm">
129+
You have unpaid invoices. Please pay them before cancelling your plan.
130+
</p>
131+
132+
<Button variant="outline" asChild className="w-full gap-2 bg-card">
133+
<Link
134+
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
135+
target="_blank"
136+
>
137+
See Invoices
138+
</Link>
139+
</Button>
140+
</div>
141+
);
142+
}
143+
118144
function ProPlanCancelPlanSheetContent() {
119145
return (
120146
<div>

0 commit comments

Comments
 (0)