update subscriptions frontend and backend to support both monthly and yearly subscriptions, tested minimally

This commit is contained in:
austinkelsay 2025-05-14 11:24:00 -05:00
parent 64235797fe
commit 2b10e74d35
No known key found for this signature in database
GPG Key ID: 5A763922E5BA08EE
12 changed files with 468 additions and 71 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Role" ADD COLUMN "subscriptionType" TEXT NOT NULL DEFAULT 'monthly';

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
provider = "postgresql"

View File

@ -1,14 +1,14 @@
// datasource db {
// provider = "postgresql"
// url = env("DATABASE_URL")
// }
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
provider = "postgresql"
url = env("DATABASE_URL")
}
// datasource db {
// provider = "postgresql"
// url = env("POSTGRES_PRISMA_URL")
// directUrl = env("POSTGRES_URL_NON_POOLING")
// }
generator client {
provider = "prisma-client-js"
}
@ -83,6 +83,7 @@ model Role {
userId String @unique
subscribed Boolean @default(false)
admin Boolean @default(false)
subscriptionType String @default("monthly")
subscriptionStartDate DateTime?
lastPaymentAt DateTime?
subscriptionExpiredAt DateTime?

View File

@ -25,6 +25,7 @@ const SubscriptionPaymentButtons = ({
oneTime = false,
recurring = false,
layout = 'row',
subscriptionType = 'monthly',
}) => {
const [invoice, setInvoice] = useState(null);
const [showRecurringOptions, setShowRecurringOptions] = useState(false);
@ -34,7 +35,13 @@ const SubscriptionPaymentButtons = ({
const router = useRouter();
const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS;
const amount = 50000;
// Calculate the amount based on the subscription type
const getAmount = () => {
return subscriptionType === 'yearly' ? 500 : 50;
};
const amount = getAmount();
useEffect(() => {
// Initialize Bitcoin Connect as early as possible
@ -74,7 +81,7 @@ const SubscriptionPaymentButtons = ({
await ln.fetch();
const newInvoice = await ln.requestInvoice({
satoshi: amount,
comment: `Subscription Purchase. User: ${session?.user?.id}`,
comment: `${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription Purchase. User: ${session?.user?.id}`,
});
return newInvoice;
} catch (error) {
@ -86,7 +93,7 @@ const SubscriptionPaymentButtons = ({
};
const handlePaymentSuccess = async response => {
track('Subscription Payment', { method: 'pay_as_you_go', userId: session?.user?.id });
track('Subscription Payment', { method: 'pay_as_you_go', type: subscriptionType, userId: session?.user?.id });
showToast('success', 'Payment Successful', 'Your payment has been processed successfully.');
if (onSuccess) onSuccess(response);
};
@ -117,9 +124,9 @@ const SubscriptionPaymentButtons = ({
const initNwcOptions = {
name: 'plebdevs.com',
requestMethods: ['pay_invoice'],
maxAmount: 50000,
maxAmount: amount,
editable: false,
budgetRenewal: 'monthly',
budgetRenewal: subscriptionType === 'yearly' ? 'yearly' : 'monthly',
expiresAt: yearFromNow,
};
@ -164,10 +171,11 @@ const SubscriptionPaymentButtons = ({
userId: session.user.id,
isSubscribed: true,
nwc: newNWCUrl,
subscriptionType: subscriptionType,
});
if (subscriptionResponse.status === 200) {
track('Subscription Payment', { method: 'recurring', userId: session?.user?.id });
track('Subscription Payment', { method: 'recurring', type: subscriptionType, userId: session?.user?.id });
showToast('success', 'Subscription Setup', 'Recurring subscription setup successful!');
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
} else {
@ -229,10 +237,11 @@ const SubscriptionPaymentButtons = ({
userId: session.user.id,
isSubscribed: true,
nwc: nwcInput,
subscriptionType: subscriptionType,
});
if (subscriptionResponse.status === 200) {
track('Subscription Payment', { method: 'recurring-manual', userId: session?.user?.id });
track('Subscription Payment', { method: 'recurring-manual', type: subscriptionType, userId: session?.user?.id });
showToast('success', 'NWC', 'Subscription setup successful!');
if (onRecurringSubscriptionSuccess) onRecurringSubscriptionSuccess();
} else {
@ -256,11 +265,11 @@ const SubscriptionPaymentButtons = ({
<>
{!invoice && (
<div
className={`w-full flex ${layout === 'row' ? 'flex-row justify-between' : 'flex-col items-center'}`}
className={`w-full flex ${layout === 'row' ? 'flex-row justify-between' : 'flex-col items-center gap-4'}`}
>
{(oneTime || (!oneTime && !recurring)) && (
<GenericButton
label="Pay as you go"
label={`Pay as you go (${(amount).toLocaleString()} sats)`}
icon="pi pi-bolt"
onClick={async () => {
if (status === 'unauthenticated') {
@ -272,12 +281,12 @@ const SubscriptionPaymentButtons = ({
}
}}
severity="primary"
className="w-fit mt-4 text-[#f8f8ff]"
className={`mt-4 text-[#f8f8ff] ${layout === 'col' ? 'w-full max-w-md' : 'w-fit'}`}
/>
)}
{(recurring || (!oneTime && !recurring)) && (
<GenericButton
label="Setup Recurring Subscription"
label={`Setup Recurring ${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription`}
icon={
<Image
src="/images/nwc-logo.svg"
@ -288,7 +297,7 @@ const SubscriptionPaymentButtons = ({
/>
}
severity="help"
className="w-fit mt-4 text-[#f8f8ff] bg-purple-600"
className={`mt-4 text-[#f8f8ff] bg-purple-600 ${layout === 'col' ? 'w-full max-w-md' : 'w-fit'}`}
onClick={() => {
if (status === 'unauthenticated') {
console.log('unauthenticated');
@ -309,7 +318,7 @@ const SubscriptionPaymentButtons = ({
<span className="my-4 text-lg font-bold">or</span>
<p className="text-lg font-bold">Manually enter NWC URL</p>
<span className="text-sm text-gray-500">
*make sure you set a budget of at least 50000 sats and set budget renewal to monthly
*make sure you set a budget of at least {(amount).toLocaleString()} sats and set budget renewal to {subscriptionType}
</span>
<input
type="text"
@ -333,7 +342,7 @@ const SubscriptionPaymentButtons = ({
onPaid={handlePaymentSuccess}
onError={handlePaymentError}
paymentMethods="external"
title={`Pay ${amount} sats`}
title={`Pay ${(amount).toLocaleString()} sats`}
/>
</div>
)}

View File

@ -17,6 +17,7 @@ import LightningAddressForm from '@/components/profile/subscription/LightningAdd
import NostrIcon from '../../../../public/images/nostr.png';
import Image from 'next/image';
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
import { SelectButton } from 'primereact/selectbutton';
const SubscribeModal = ({ user }) => {
const { data: session, update } = useSession();
@ -33,19 +34,30 @@ const SubscribeModal = ({ user }) => {
const [nip05Visible, setNip05Visible] = useState(false);
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
const [subscriptionType, setSubscriptionType] = useState('monthly');
const subscriptionOptions = [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
];
useEffect(() => {
if (user && user.role) {
setSubscribed(user.role.subscribed);
setSubscriptionType(user.role.subscriptionType || 'monthly');
const subscribedAt = new Date(user.role.lastPaymentAt);
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
// Calculate subscription end date based on type
const daysToAdd = subscriptionType === 'yearly' ? 365 : 31;
const subscribedUntil = new Date(subscribedAt.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);
if (user.role.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt);
setSubscriptionExpiredAt(expiredAt);
}
}
}, [user]);
}, [user, subscriptionType]);
const handleSubscriptionSuccess = async response => {
setIsProcessing(true);
@ -53,6 +65,7 @@ const SubscribeModal = ({ user }) => {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
subscriptionType: subscriptionType,
});
if (apiResponse.data) {
await update();
@ -161,7 +174,7 @@ const SubscribeModal = ({ user }) => {
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-3">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">
Pay-as-you-go subscription will renew on {subscribedUntil.toLocaleDateString()}
Pay-as-you-go {user?.role?.subscriptionType || 'monthly'} subscription will renew on {subscribedUntil?.toLocaleDateString()}
</p>
</div>
)}
@ -170,7 +183,7 @@ const SubscribeModal = ({ user }) => {
<Message className="w-fit" severity="success" text="Subscribed!" />
<p className="mt-3">Thank you for your support 🎉</p>
<p className="text-sm text-gray-400">
Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}
Recurring {user?.role?.subscriptionType || 'monthly'} subscription will AUTO renew on {subscribedUntil?.toLocaleDateString()}
</p>
</div>
)}
@ -218,36 +231,68 @@ const SubscribeModal = ({ user }) => {
</div>
) : (
<Card className="shadow-lg">
<div className="text-center mb-4">
<h2 className="text-2xl font-bold text-primary">Unlock Premium Benefits</h2>
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-primary mb-2">Unlock Premium Benefits</h2>
<p className="text-gray-400">Subscribe now and elevate your development journey!</p>
</div>
<div className="flex flex-col gap-4 mb-4 w-[60%] mx-auto">
<div className="flex flex-col gap-6 mb-6 w-[75%] mx-auto">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<i className="pi pi-book text-2xl text-primary mr-3 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<i className="pi pi-calendar text-2xl text-primary mr-3 text-red-400"></i>
<span>
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<i className="pi pi-bolt text-2xl text-primary mr-3 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={26} height={26} className="mr-2" />
<Image src={NostrIcon} alt="Nostr" width={26} height={26} className="mr-3" />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
/>
<div className="subscription-plan-selector my-8">
<div className="flex flex-col items-center mb-4">
<h3 className="text-xl font-bold mb-4">Select Your Plan</h3>
<SelectButton
value={subscriptionType}
options={subscriptionOptions}
onChange={(e) => setSubscriptionType(e.value)}
className="mb-3 w-full max-w-[300px] mx-auto"
pt={{
button: { className: 'text-base px-8 py-2 text-white' },
root: { className: 'flex justify-center' }
}}
/>
{subscriptionType === 'yearly' && (
<div className="savings-message text-sm text-green-500 font-semibold mt-2">
Save ~17% with yearly subscription!
</div>
)}
<div className="price-display text-2xl font-bold mt-3">
{subscriptionType === 'yearly' ? '500,000' : '50,000'} sats
<span className="text-sm text-gray-400 ml-2">
({subscriptionType === 'yearly' ? 'yearly' : 'monthly'})
</span>
</div>
</div>
</div>
<div className="mt-6">
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
subscriptionType={subscriptionType}
layout="col"
/>
</div>
</Card>
)}
</Dialog>

View File

@ -16,6 +16,7 @@ import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
import Nip05Form from '@/components/profile/subscription/Nip05Form';
import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
import { SelectButton } from 'primereact/selectbutton';
const UserSubscription = () => {
const { data: session, update } = useSession();
@ -32,10 +33,19 @@ const UserSubscription = () => {
const [nip05Visible, setNip05Visible] = useState(false);
const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
const [subscriptionType, setSubscriptionType] = useState('monthly');
const subscriptionOptions = [
{ label: 'Monthly', value: 'monthly' },
{ label: 'Yearly', value: 'yearly' },
];
useEffect(() => {
if (session && session?.user) {
setUser(session.user);
if (session.user.role?.subscriptionType) {
setSubscriptionType(session.user.role.subscriptionType);
}
}
}, [session]);
@ -43,7 +53,11 @@ const UserSubscription = () => {
if (user && user.role) {
setSubscribed(user.role.subscribed);
const subscribedAt = new Date(user.role.lastPaymentAt);
const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
// Calculate subscription end date based on type
const daysToAdd = user.role.subscriptionType === 'yearly' ? 365 : 31;
const subscribedUntil = new Date(subscribedAt.getTime() + daysToAdd * 24 * 60 * 60 * 1000);
setSubscribedUntil(subscribedUntil);
if (user.role.subscriptionExpiredAt) {
const expiredAt = new Date(user.role.subscriptionExpiredAt);
@ -58,6 +72,7 @@ const UserSubscription = () => {
const apiResponse = await axios.put('/api/users/subscription', {
userId: session.user.id,
isSubscribed: true,
subscriptionType: subscriptionType,
});
if (apiResponse.data) {
await update();
@ -96,6 +111,11 @@ const UserSubscription = () => {
}
};
// Calculate the subscription amount based on type
const getAmount = () => {
return subscriptionType === 'yearly' ? 500 : 50;
};
return (
<div className="py-4 px-1">
{windowWidth < 768 && <h1 className="text-3xl font-bold mb-6">Subscription Management</h1>}
@ -134,37 +154,66 @@ const UserSubscription = () => {
</div>
) : (
<div className="flex flex-col">
<div className="mb-2">
<div className="mb-4">
<p className="text-gray-400">
Subscribe now and elevate your development journey!
</p>
</div>
<div className="flex flex-col gap-4 mb-1">
<div className="flex flex-col gap-5 mb-5">
<div className="flex items-center">
<i className="pi pi-book text-2xl text-primary mr-2 text-blue-400"></i>
<i className="pi pi-book text-2xl text-primary mr-3 text-blue-400"></i>
<span>Access ALL current and future PlebDevs content</span>
</div>
<div className="flex items-center">
<i className="pi pi-calendar text-2xl text-primary mr-2 text-red-400"></i>
<i className="pi pi-calendar text-2xl text-primary mr-3 text-red-400"></i>
<span>
Personal mentorship & guidance and access to exclusive 1:1 booking calendar
</span>
</div>
<div className="flex items-center">
<i className="pi pi-bolt text-2xl text-primary mr-2 text-yellow-500"></i>
<i className="pi pi-bolt text-2xl text-primary mr-3 text-yellow-500"></i>
<span>Claim your own personal plebdevs.com Lightning Address</span>
</div>
<div className="flex items-center">
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className="mr-2" />
<Image src={NostrIcon} alt="Nostr" width={25} height={25} className="mr-3" />
<span>Claim your own personal plebdevs.com Nostr NIP-05 identity</span>
</div>
</div>
<div className="subscription-plan-selector my-6">
<div className="flex flex-col items-center mb-4">
<h3 className="text-xl font-bold mb-3">Select Your Plan</h3>
<SelectButton
value={subscriptionType}
options={subscriptionOptions}
onChange={(e) => setSubscriptionType(e.value)}
className="mb-3 w-full max-w-[300px] mx-auto"
pt={{
button: { className: 'text-base px-8 py-2 text-white' },
root: { className: 'flex justify-center' }
}}
/>
{subscriptionType === 'yearly' && (
<div className="savings-message text-sm text-green-500 font-semibold mt-2">
Save ~17% with yearly subscription!
</div>
)}
<div className="price-display text-2xl font-bold mt-3">
{subscriptionType === 'yearly' ? '500,000' : '50,000'} sats
<span className="text-sm text-gray-400 ml-2">
({subscriptionType === 'yearly' ? 'yearly' : 'monthly'})
</span>
</div>
</div>
</div>
<SubscriptionPaymentButtons
onSuccess={handleSubscriptionSuccess}
onRecurringSubscriptionSuccess={handleRecurringSubscriptionSuccess}
onError={handleSubscriptionError}
setIsProcessing={setIsProcessing}
layout={windowWidth < 768 ? 'col' : 'row'}
subscriptionType={subscriptionType}
/>
</div>
)}
@ -176,6 +225,9 @@ const UserSubscription = () => {
<Card
title="Subscription Benefits"
className="h-[330px] border border-gray-700 rounded-lg"
pt={{
content: { className: 'py-0' },
}}
>
{isProcessing ? (
<div className="w-full flex flex-col mx-auto justify-center items-center mt-4">
@ -186,6 +238,14 @@ const UserSubscription = () => {
</div>
) : (
<div className="flex flex-col">
<div className="mb-1">
<p className="text-gray-300 mb-1">
<span className="font-semibold">Current Plan:</span> {user?.role?.subscriptionType || 'monthly'} subscription
</p>
<p className="text-gray-300">
<span className="font-semibold">Renews on:</span> {subscribedUntil?.toLocaleDateString()}
</p>
</div>
<div className="flex flex-col gap-4">
<GenericButton
severity="info"
@ -249,28 +309,35 @@ const UserSubscription = () => {
title="Frequently Asked Questions"
className="mt-2 border border-gray-700 rounded-lg"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-5">
<div>
<h3 className="text-lg font-semibold">How does the subscription work?</h3>
<p>
Think of the subscriptions as a paetreon type model. You pay a monthly fee and in
Think of the subscriptions as a paetreon type model. You pay a monthly or yearly fee and in
return you get access to premium features and all of the paid content. You can
cancel at any time.
</p>
</div>
<div>
<h3 className="text-lg font-semibold">What&apos;s the difference between monthly and yearly?</h3>
<p>
The yearly subscription offers a ~17% discount compared to paying monthly for a year.
Both plans give you the same access to all features and content.
</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Pay as you go)</h3>
<p>
The pay as you go subscription is a one-time payment that gives you access to all
of the premium features for one month. You will need to manually renew your
subscription every month.
of the premium features for one month or year, depending on your selected plan. You will need to manually renew your
subscription when it expires.
</p>
</div>
<div>
<h3 className="text-lg font-semibold">How do I Subscribe? (Recurring)</h3>
<p>
The recurring subscription option allows you to submit a Nostr Wallet Connect URI
that will be used to automatically send the subscription fee every month. You can
that will be used to automatically send the subscription fee on your chosen schedule. You can
cancel at any time.
</p>
</div>

View File

@ -11,7 +11,8 @@ const appConfig = {
],
authorPubkeys: [
'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345'
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345',
'6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4',
],
customLightningAddresses: [
{

View File

@ -6,6 +6,7 @@ export const createRole = async data => {
user: { connect: { id: data.userId } },
admin: data.admin,
subscribed: data.subscribed,
subscriptionType: data.subscriptionType || 'monthly',
// Add other fields as needed, with default values or null if not provided
subscriptionStartDate: null,
lastPaymentAt: null,

View File

@ -165,7 +165,7 @@ export const deleteUser = async id => {
});
};
export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
export const updateUserSubscription = async (userId, isSubscribed, nwc, subscriptionType = 'monthly') => {
try {
const now = new Date();
return await prisma.user.update({
@ -175,6 +175,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
upsert: {
create: {
subscribed: isSubscribed,
subscriptionType: subscriptionType,
subscriptionStartDate: isSubscribed ? now : null,
lastPaymentAt: isSubscribed ? now : null,
nwc: nwc ? nwc : null,
@ -182,6 +183,7 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
},
update: {
subscribed: isSubscribed,
subscriptionType: subscriptionType,
subscriptionStartDate: isSubscribed ? { set: now } : { set: null },
lastPaymentAt: isSubscribed ? now : { set: null },
nwc: nwc ? nwc : null,
@ -202,20 +204,34 @@ export const updateUserSubscription = async (userId, isSubscribed, nwc) => {
export const findExpiredSubscriptions = async () => {
try {
const now = new Date();
const oneMonthAndOneHourAgo = new Date(
now.getTime() - 1 * 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
// Define expiration periods
const monthlyExpiration = new Date(
now.getTime() - 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
);
const yearlyExpiration = new Date(
now.getTime() - 365 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000
);
// Find expired subscriptions of both types
const result = await prisma.role.findMany({
where: {
subscribed: true,
lastPaymentAt: {
lt: oneMonthAndOneHourAgo,
},
OR: [
{
subscriptionType: 'monthly',
lastPaymentAt: { lt: monthlyExpiration }
},
{
subscriptionType: 'yearly',
lastPaymentAt: { lt: yearlyExpiration }
}
]
},
select: {
userId: true,
nwc: true,
subscriptionType: true,
subscriptionExpiredAt: true,
subscriptionStartDate: true,
admin: true,
@ -231,6 +247,24 @@ export const findExpiredSubscriptions = async () => {
export const expireUserSubscriptions = async userIds => {
try {
const now = new Date();
// First, get the subscription types for each userId
const subscriptions = await prisma.role.findMany({
where: {
userId: { in: userIds },
},
select: {
userId: true,
subscriptionType: true,
},
});
// Create a map of userId to subscription type
const subscriptionTypes = {};
subscriptions.forEach(sub => {
subscriptionTypes[sub.userId] = sub.subscriptionType || 'monthly';
});
const updatePromises = userIds.map(userId =>
prisma.role.update({
where: { userId },
@ -240,6 +274,8 @@ export const expireUserSubscriptions = async userIds => {
lastPaymentAt: null,
nwc: null,
subscriptionExpiredAt: now,
// Keep the subscription type for historical data and easy renewal
// subscriptionType: Don't change the existing value
},
})
);

View File

@ -7,18 +7,39 @@ import { webln } from '@getalby/sdk';
import { LightningAddress } from '@getalby/lightning-tools';
const lnAddress = process.env.LIGHTNING_ADDRESS;
const amount = 50000; // Set the subscription amount in satoshis
// Calculate subscription amount based on type
const getAmount = (subscriptionType) => {
// 500K for yearly (saves ~17% compared to monthly), 50K for monthly
return subscriptionType === 'yearly' ? 500 : 50;
};
export default async function handler(req, res) {
if (req.method === 'GET') {
try {
// Get all expired subscriptions (both monthly and yearly)
// The findExpiredSubscriptions function handles different expiration periods:
// - Monthly: 30 days + 1 hour
// - Yearly: 365 days + 1 hour
const expiredSubscriptions = await findExpiredSubscriptions();
console.log('expiredSubscriptions', expiredSubscriptions);
console.log(`Found ${expiredSubscriptions.length} expired subscriptions to process`);
// Track stats for reporting
const stats = {
monthly: { processed: 0, renewed: 0, expired: 0 },
yearly: { processed: 0, renewed: 0, expired: 0 },
};
const stillExpired = [];
for (const { userId, nwc } of expiredSubscriptions) {
for (const { userId, nwc, subscriptionType = 'monthly' } of expiredSubscriptions) {
// Track processed subscriptions by type
stats[subscriptionType].processed++;
if (nwc) {
try {
console.log(`Processing ${subscriptionType} subscription renewal for user ${userId}`);
const amount = getAmount(subscriptionType);
const nwcProvider = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: nwc,
});
@ -26,30 +47,49 @@ export default async function handler(req, res) {
const ln = new LightningAddress(lnAddress);
await ln.fetch();
const newInvoice = await ln.requestInvoice({ satoshi: amount });
const newInvoice = await ln.requestInvoice({
satoshi: amount,
comment: `${subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1)} Subscription Renewal for User: ${userId}`,
});
console.log(`Generated invoice for ${amount} sats for ${subscriptionType} subscription`);
const response = await nwcProvider.sendPayment(newInvoice?.paymentRequest);
if (response && response?.preimage) {
console.log(`SUBSCRIPTION AUTO-RENEWED`, response);
await updateUserSubscription(userId, true, nwc);
console.log(`SUBSCRIPTION AUTO-RENEWED (${subscriptionType}) for User: ${userId}`);
// Re-subscribe the user with the same subscription type
await updateUserSubscription(userId, true, nwc, subscriptionType);
// Track successful renewals
stats[subscriptionType].renewed++;
continue; // Skip adding to stillExpired list
} else {
console.log(`Payment failed for user ${userId}: (stillExpired)`, response);
console.log(`Payment failed for ${subscriptionType} subscription for user ${userId}: (stillExpired)`, response);
}
} catch (error) {
console.error(`Payment failed for user ${userId}:`, error);
console.error(`Payment failed for ${subscriptionType} subscription for user ${userId}:`, error);
}
} else {
console.log(`No NWC found for user ${userId}, marking as expired`);
}
// Track failed renewals that will be expired
stats[subscriptionType].expired++;
stillExpired.push(userId);
}
// Expire all subscriptions that couldn't be renewed
const expiredCount = await expireUserSubscriptions(stillExpired);
console.log(`Processed ${expiredSubscriptions.length} total subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly)`);
console.log(`Renewed ${stats.monthly.renewed + stats.yearly.renewed} total subscriptions (${stats.monthly.renewed} monthly, ${stats.yearly.renewed} yearly)`);
console.log(`Expired ${expiredCount} total subscriptions (${stats.monthly.expired} monthly, ${stats.yearly.expired} yearly)`);
res.status(200).json({
message: `Cron job completed successfully.
Processed ${expiredSubscriptions.length} subscriptions.
Expired ${expiredCount} subscriptions.`,
Processed ${expiredSubscriptions.length} subscriptions (${stats.monthly.processed} monthly, ${stats.yearly.processed} yearly).
Renewed ${stats.monthly.renewed + stats.yearly.renewed} subscriptions (${stats.monthly.renewed} monthly, ${stats.yearly.renewed} yearly).
Expired ${expiredCount} subscriptions (${stats.monthly.expired} monthly, ${stats.yearly.expired} yearly).`,
stats
});
} catch (error) {
console.error('Cron job error:', error);

View File

@ -12,8 +12,8 @@ export default async function handler(req, res) {
if (req.method === 'PUT') {
try {
const { userId, isSubscribed, nwc } = req.body;
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc);
const { userId, isSubscribed, nwc, subscriptionType = 'monthly' } = req.body;
const updatedUser = await updateUserSubscription(userId, isSubscribed, nwc, subscriptionType);
res.status(200).json(updatedUser);
} catch (error) {

195
yearly_subscriptions.md Normal file
View File

@ -0,0 +1,195 @@
# Yearly Subscription Implementation Plan
## 1. Database Schema Updates
```prisma
model Role {
// Existing fields...
subscriptionType String @default("monthly") // Options: "monthly", "yearly"
// Other fields remain the same
}
```
## 2. UI Component Updates
### SubscribeModal.js
- Add toggle between monthly/yearly subscription options
- Update pricing display (50,000 sats monthly / 500,000 sats yearly)
- Show savings message for yearly option (~17% discount)
### SubscriptionPaymentButton.js
- Add subscription type parameter
- Modify amount calculation based on subscription type
- Update NWC configuration for yearly budgets
```javascript
// Example modification
const getAmount = (subscriptionType) => {
return subscriptionType === 'yearly' ? 500000 : 50000;
};
// For NWC setup
const budgetRenewal = subscriptionType === 'yearly' ? 'yearly' : 'monthly';
```
## 3. API Endpoints Updates
### /api/users/subscription
- Update to accept subscriptionType parameter
- Modify database update to store subscription type
```javascript
// Example modification
export const updateUserSubscription = async (userId, isSubscribed, nwc, subscriptionType = 'monthly') => {
try {
const now = new Date();
return await prisma.user.update({
where: { id: userId },
data: {
role: {
upsert: {
create: {
subscribed: isSubscribed,
subscriptionType: subscriptionType,
// Other fields remain the same
},
update: {
subscribed: isSubscribed,
subscriptionType: subscriptionType,
// Other fields remain the same
},
},
},
},
include: {
role: true,
},
});
} finally {
await prisma.$disconnect();
}
};
```
## 4. Cron Job Modifications
### cron.js
- Update expiration calculation to check subscription type
- For monthly: expire after 30 days + 1 hour
- For yearly: expire after 365 days + 1 hour
```javascript
export const findExpiredSubscriptions = async () => {
const now = new Date();
// Define expiration periods
const monthlyExpiration = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000);
const yearlyExpiration = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000);
// Find expired subscriptions of both types
const expiredSubscriptions = await prisma.role.findMany({
where: {
subscribed: true,
OR: [
{
subscriptionType: 'monthly',
lastPaymentAt: { lt: monthlyExpiration }
},
{
subscriptionType: 'yearly',
lastPaymentAt: { lt: yearlyExpiration }
}
]
},
select: {
userId: true,
nwc: true,
subscriptionType: true,
subscriptionExpiredAt: true,
subscriptionStartDate: true,
admin: true,
}
});
return expiredSubscriptions;
};
```
## 5. Testing Plan
### Database Testing
1. Verify Prisma schema correctly includes the subscriptionType field with default value "monthly"
2. Confirm migrations apply correctly to existing database
### UI Testing
1. **Monthly Subscription UI**
- Verify the subscription selector defaults to monthly
- Check pricing shows 50,000 sats for monthly
- Ensure subscription buttons show "Monthly" where appropriate
2. **Yearly Subscription UI**
- Verify selecting yearly plan updates all UI elements
- Check pricing shows 500,000 sats for yearly
- Confirm ~17% savings message appears
- Ensure subscription buttons show "Yearly" where appropriate
### Payment Flow Testing
1. **Monthly One-time Payment**
- Test subscription purchase with "Pay as you go" for monthly plan
- Verify subscription is created with type "monthly"
- Confirm user profile shows correct subscription expiration date (30 days)
2. **Monthly Recurring Payment**
- Test subscription setup with "Setup Recurring Monthly Subscription"
- Verify NWC configuration with monthly budget renewal
- Confirm subscription is created with type "monthly"
3. **Yearly One-time Payment**
- Test subscription purchase with "Pay as you go" for yearly plan
- Verify subscription is created with type "yearly"
- Confirm user profile shows correct subscription expiration date (365 days)
4. **Yearly Recurring Payment**
- Test subscription setup with "Setup Recurring Yearly Subscription"
- Verify NWC configuration with yearly budget renewal
- Confirm subscription is created with type "yearly"
### Cron Job Testing
1. **Recently Active Monthly Subscription**
- Set up test account with monthly subscription
- Verify subscription not marked as expired by cron job
2. **Recently Active Yearly Subscription**
- Set up test account with yearly subscription
- Verify subscription not marked as expired by cron job
3. **Expired Monthly Subscription**
- Create test account with monthly subscription
- Manually adjust lastPaymentAt date to be >30 days ago
- Run cron job and verify subscription is expired
4. **Expired Yearly Subscription**
- Create test account with yearly subscription
- Manually adjust lastPaymentAt date to be >365 days ago
- Run cron job and verify subscription is expired
5. **Auto-renewal Testing**
- Set up NWC for test accounts (both monthly and yearly)
- Manually adjust lastPaymentAt date to trigger expiration
- Run cron job and verify proper renewal amount is charged
- Confirm subscription type is maintained after renewal
## 6. Implementation Steps
1. ✅ Create database migration for schema changes
2. ✅ Modify frontend subscription components
3. ✅ Update backend models and API endpoints
4. ✅ Update cron job logic
5. Test all flows thoroughly
6. Deploy changes
## 7. Marketing Considerations
- Highlight savings with yearly subscription (~17% discount)
- Update documentation and marketing materials
- Consider grandfathering existing subscribers or offering upgrade path