Skip to content

Commit 220d7a1

Browse files
committed
[monetize] [update] Monetize the SaaS
[entitlement] Add the entitlement back [entitlement] Create entitlement functions [api] Create needed API endpoints [checkout] Create the provider [paywall] [checkout] Create checkout and paywall [portal] Create the customer portal [credits] Finish the credits page [webhook] Add webhook processing [fix] [credit] Properly assign annual credits [webhook] Beautify the webhook listener code
1 parent c37e868 commit 220d7a1

11 files changed

Lines changed: 179 additions & 12 deletions

File tree

src/app/api/ai/route.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { headers } from 'next/headers';
22
import { auth } from '@/lib/auth';
3-
import { deductCredits } from '@/lib/user-entitlement';
3+
import { deductCredits, getUserEntitlement, hasCredits } from '@/lib/user-entitlement';
44
import { getAiResponse } from '@/lib/ai';
55

66
export async function POST(request: Request) {
@@ -18,6 +18,28 @@ export async function POST(request: Request) {
1818
);
1919
}
2020

21+
const entitlement = await getUserEntitlement(session.user.id);
22+
23+
if (!entitlement) {
24+
return Response.json(
25+
{
26+
code: 'no_active_purchase',
27+
message: 'You do not have an active license to use this feature.',
28+
},
29+
{ status: 403 }
30+
);
31+
}
32+
33+
if (!(await hasCredits(session.user.id, 100))) {
34+
return Response.json(
35+
{
36+
code: 'insufficient_credits',
37+
message: 'You do not have enough credits to use this feature.',
38+
},
39+
{ status: 403 }
40+
);
41+
}
42+
2143
/**
2244
* Here you would implement the AI asset generation and credit consumption logic.
2345
* For demonstration, we will just return a dummy response and deduct 100 credits.

src/app/api/checkout/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* This route handles the Purchase actions and sync actions coming from the Freemius React Starter Kit.
3+
*/
4+
import { freemius } from '@/lib/freemius';
5+
import { processPurchaseInfo } from '@/lib/user-entitlement';
6+
7+
const processor = freemius.checkout.request.createProcessor({
8+
onPurchase: processPurchaseInfo,
9+
});
10+
11+
export { processor as GET, processor as POST };

src/app/api/portal/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { freemius } from '@/lib/freemius';
2+
import { getFsUser, processPurchaseInfo } from '@/lib/user-entitlement';
3+
4+
const processor = freemius.customerPortal.request.createProcessor({
5+
getUser: getFsUser,
6+
portalEndpoint: process.env.NEXT_PUBLIC_APP_URL! + '/api/portal',
7+
isSandbox: process.env.NODE_ENV !== 'production',
8+
onRestore: freemius.customerPortal.createRestorer(processPurchaseInfo),
9+
});
10+
11+
export { processor as GET, processor as POST };

src/app/billing/page.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { auth } from '@/lib/auth';
2+
import { freemius } from '@/lib/freemius';
3+
import { headers } from 'next/headers';
4+
import { redirect } from 'next/navigation';
5+
import { CustomerPortal } from '@/react-starter/components/customer-portal';
6+
import AppCheckoutProvider from '@/components/app-checkout-provider';
7+
import AppMain, { AppContent } from '@/components/app-main';
8+
9+
export default async function Billing() {
10+
const session = await auth.api.getSession({
11+
headers: await headers(),
12+
});
13+
14+
if (!session) {
15+
redirect('/login');
16+
}
17+
18+
const checkout = await freemius.checkout.create({
19+
user: session?.user,
20+
isSandbox: process.env.NODE_ENV !== 'production',
21+
});
22+
23+
return (
24+
<AppMain title="Billing" isLoggedIn={true}>
25+
<AppContent>
26+
<AppCheckoutProvider checkout={checkout.serialize()}>
27+
<CustomerPortal endpoint={process.env.NEXT_PUBLIC_APP_URL! + '/api/portal'} />
28+
</AppCheckoutProvider>
29+
</AppContent>
30+
</AppMain>
31+
);
32+
}

src/app/chat/ai-app.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@
33
import { useState } from 'react';
44
import LoginModal from '@/components/login-modal';
55
import { AIChat } from '@/components/ai-chat';
6+
import { Paywall, usePaywall } from '@/react-starter/components/paywall';
67

78
export default function AiApp(props: { examples: string[] }) {
89
const { examples } = props;
910
const [isShowingLogin, setIsShowingLogin] = useState<boolean>(false);
11+
const { hidePaywall, state, showNoActivePurchase, showInsufficientCredits } = usePaywall();
1012

1113
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1214
const handleApiError = (data: any) => {
1315
if (data.code === 'unauthenticated') {
1416
setIsShowingLogin(true);
17+
} else if (data.code === 'no_active_purchase') {
18+
showNoActivePurchase();
19+
} else if (data.code === 'insufficient_credits') {
20+
showInsufficientCredits();
1521
}
1622
};
1723

1824
return (
1925
<>
26+
<Paywall state={state} hidePaywall={hidePaywall} />
27+
2028
<LoginModal isShowing={isShowingLogin} onClose={() => setIsShowingLogin(false)} />
2129

2230
<AIChat examples={examples} onApiError={handleApiError} />

src/app/chat/page.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,24 @@ import { auth } from '@/lib/auth';
33
import { headers } from 'next/headers';
44
import { examples } from '@/lib/ai';
55
import AiApp from './ai-app';
6+
import { freemius } from '@/lib/freemius';
7+
import AppCheckoutProvider from '@/components/app-checkout-provider';
68

79
export default async function Dashboard() {
810
const session = await auth.api.getSession({
911
headers: await headers(),
1012
});
1113

14+
const checkout = await freemius.checkout.create({
15+
user: session?.user,
16+
isSandbox: process.env.NODE_ENV !== 'production',
17+
});
18+
1219
return (
1320
<AppMain title="New Chat" isLoggedIn={!!session}>
14-
<AiApp examples={examples} />
21+
<AppCheckoutProvider checkout={checkout.serialize()}>
22+
<AiApp examples={examples} />
23+
</AppCheckoutProvider>
1524
</AppMain>
1625
);
1726
}

src/app/credits/credits.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
'use client';
22

3+
import { Subscribe } from '@/react-starter/components/subscribe';
34
import { formatNumber } from '@/react-starter/utils/formatter';
45

5-
export default function Credits(props: { credits?: number }) {
6-
const { credits } = props;
6+
export default function Credits(props: { credits?: number; hasSubscription: boolean }) {
7+
const { credits, hasSubscription } = props;
78

8-
return (
9+
const info = (
910
<div className="text-center">
1011
<h2 className="text-lg font-medium">You have {formatNumber(credits ?? 0)} credits</h2>
1112
<p className="mb-10 text-muted-foreground">You can purchase more credits below.</p>
1213
</div>
1314
);
15+
16+
return <Subscribe>{info}</Subscribe>;
1417
}

src/app/credits/page.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import AppMain, { AppContent } from '@/components/app-main';
22
import { auth } from '@/lib/auth';
3-
import { getCredits } from '@/lib/user-entitlement';
3+
import { getCredits, getUserEntitlement } from '@/lib/user-entitlement';
44
import { headers } from 'next/headers';
55
import { redirect } from 'next/navigation';
66
import { ErrorBoundary } from '@/components/error';
77
import Credits from './credits';
8+
import { freemius } from '@/lib/freemius';
9+
import AppCheckoutProvider from '@/components/app-checkout-provider';
810

911
export default async function CreditsPage() {
1012
const session = await auth.api.getSession({
@@ -15,13 +17,21 @@ export default async function CreditsPage() {
1517
redirect('/login');
1618
}
1719

20+
const entitlement = await getUserEntitlement(session.user.id);
1821
const credits = await getCredits(session.user.id);
1922

23+
const checkout = await freemius.checkout.create({
24+
user: session?.user,
25+
isSandbox: process.env.NODE_ENV !== 'production',
26+
});
27+
2028
return (
2129
<AppMain title="Credits & Topups" isLoggedIn={true}>
2230
<AppContent>
2331
<ErrorBoundary>
24-
<Credits credits={credits} />
32+
<AppCheckoutProvider checkout={checkout.serialize()}>
33+
<Credits credits={credits} hasSubscription={!!entitlement} />
34+
</AppCheckoutProvider>
2535
</ErrorBoundary>
2636
</AppContent>
2737
</AppMain>

src/app/webhook/route.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { freemius } from '@/lib/freemius';
2+
import { deleteEntitlement, renewCreditsFromWebhook, syncEntitlementFromWebhook } from '@/lib/user-entitlement';
3+
import { WebhookEventType } from '@freemius/sdk';
4+
5+
const listener = freemius.webhook.createListener();
6+
7+
const licenseEvents: WebhookEventType[] = [
8+
'license.created',
9+
'license.extended',
10+
'license.shortened',
11+
'license.updated',
12+
'license.cancelled',
13+
'license.expired',
14+
'license.plan.changed',
15+
];
16+
17+
listener.on(licenseEvents, async ({ objects: { license } }) => {
18+
if (license && license.id) {
19+
await syncEntitlementFromWebhook(license.id);
20+
}
21+
});
22+
23+
listener.on('license.deleted', async ({ data }) => {
24+
await deleteEntitlement(data.license_id);
25+
});
26+
27+
listener.on('license.extended', async ({ data }) => {
28+
if (data.is_renewal) {
29+
renewCreditsFromWebhook(data.license_id);
30+
}
31+
});
32+
33+
const processor = freemius.webhook.createRequestProcessor(listener);
34+
35+
export { processor as POST };
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
3+
import { CheckoutProvider } from '@/react-starter/components/checkout-provider';
4+
import { CheckoutSerialized } from '@freemius/sdk';
5+
import * as React from 'react';
6+
import { toast } from 'sonner';
7+
import { useRouter } from 'next/navigation';
8+
9+
export default function AppCheckoutProvider(props: { children: React.ReactNode; checkout: CheckoutSerialized }) {
10+
const router = useRouter();
11+
12+
const onAfterSync = React.useCallback(() => {
13+
toast.success(`Successfully updated your subscription! Now you can continue using the app.`);
14+
router.refresh();
15+
}, [router]);
16+
17+
return (
18+
<CheckoutProvider
19+
onAfterSync={onAfterSync}
20+
checkout={props.checkout}
21+
endpoint={process.env.NEXT_PUBLIC_APP_URL! + '/api/checkout'}
22+
>
23+
{props.children}
24+
</CheckoutProvider>
25+
);
26+
}

0 commit comments

Comments
 (0)