From 2b10e74d358f67a5115b36d32c53933963447cfd Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 11:24:00 -0500 Subject: [PATCH] update subscriptions frontend and backend to support both monthly and yearly subscriptions, tested minimally --- .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 17 +- .../SubscriptionPaymentButton.js | 37 ++-- .../profile/subscription/SubscribeModal.js | 79 +++++-- .../profile/subscription/UserSubscription.js | 91 ++++++-- src/config/appConfig.js | 3 +- src/db/models/roleModels.js | 1 + src/db/models/userModels.js | 48 ++++- src/pages/api/users/subscription/cron.js | 60 +++++- src/pages/api/users/subscription/index.js | 4 +- yearly_subscriptions.md | 195 ++++++++++++++++++ 12 files changed, 468 insertions(+), 71 deletions(-) create mode 100644 prisma/migrations/20250514143724_add_subscription_type/migration.sql create mode 100644 yearly_subscriptions.md diff --git a/prisma/migrations/20250514143724_add_subscription_type/migration.sql b/prisma/migrations/20250514143724_add_subscription_type/migration.sql new file mode 100644 index 0000000..b36d0fe --- /dev/null +++ b/prisma/migrations/20250514143724_add_subscription_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Role" ADD COLUMN "subscriptionType" TEXT NOT NULL DEFAULT 'monthly'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 648c57f..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23486b9..20ce2a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/components/bitcoinConnect/SubscriptionPaymentButton.js b/src/components/bitcoinConnect/SubscriptionPaymentButton.js index 8ee2819..07ce879 100644 --- a/src/components/bitcoinConnect/SubscriptionPaymentButton.js +++ b/src/components/bitcoinConnect/SubscriptionPaymentButton.js @@ -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 && (
{(oneTime || (!oneTime && !recurring)) && ( { 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)) && ( } 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 = ({ or

Manually enter NWC URL

- *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}
)} diff --git a/src/components/profile/subscription/SubscribeModal.js b/src/components/profile/subscription/SubscribeModal.js index 8a20fdd..131128e 100644 --- a/src/components/profile/subscription/SubscribeModal.js +++ b/src/components/profile/subscription/SubscribeModal.js @@ -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 }) => {

Thank you for your support 🎉

- Pay-as-you-go subscription will renew on {subscribedUntil.toLocaleDateString()} + Pay-as-you-go {user?.role?.subscriptionType || 'monthly'} subscription will renew on {subscribedUntil?.toLocaleDateString()}

)} @@ -170,7 +183,7 @@ const SubscribeModal = ({ user }) => {

Thank you for your support 🎉

- Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()} + Recurring {user?.role?.subscriptionType || 'monthly'} subscription will AUTO renew on {subscribedUntil?.toLocaleDateString()}

)} @@ -218,36 +231,68 @@ const SubscribeModal = ({ user }) => { ) : ( -
-

Unlock Premium Benefits

+
+

Unlock Premium Benefits

Subscribe now and elevate your development journey!

-
+
- + Access ALL current and future PlebDevs content
- + Personal mentorship & guidance and access to exclusive 1:1 booking calendar
- + Claim your own personal plebdevs.com Lightning Address
- Nostr + Nostr Claim your own personal plebdevs.com Nostr NIP-05 identity
- + +
+
+

Select Your Plan

+ 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' && ( +
+ Save ~17% with yearly subscription! +
+ )} +
+ {subscriptionType === 'yearly' ? '500,000' : '50,000'} sats + + ({subscriptionType === 'yearly' ? 'yearly' : 'monthly'}) + +
+
+
+ +
+ +
)} diff --git a/src/components/profile/subscription/UserSubscription.js b/src/components/profile/subscription/UserSubscription.js index d3105fc..3b29952 100644 --- a/src/components/profile/subscription/UserSubscription.js +++ b/src/components/profile/subscription/UserSubscription.js @@ -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 (
{windowWidth < 768 &&

Subscription Management

} @@ -134,37 +154,66 @@ const UserSubscription = () => {
) : (
-
+

Subscribe now and elevate your development journey!

-
+
- + Access ALL current and future PlebDevs content
- + Personal mentorship & guidance and access to exclusive 1:1 booking calendar
- + Claim your own personal plebdevs.com Lightning Address
- Nostr + Nostr Claim your own personal plebdevs.com Nostr NIP-05 identity
+ +
+
+

Select Your Plan

+ 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' && ( +
+ Save ~17% with yearly subscription! +
+ )} +
+ {subscriptionType === 'yearly' ? '500,000' : '50,000'} sats + + ({subscriptionType === 'yearly' ? 'yearly' : 'monthly'}) + +
+
+
+
)} @@ -176,6 +225,9 @@ const UserSubscription = () => { {isProcessing ? (
@@ -186,6 +238,14 @@ const UserSubscription = () => {
) : (
+
+

+ Current Plan: {user?.role?.subscriptionType || 'monthly'} subscription +

+

+ Renews on: {subscribedUntil?.toLocaleDateString()} +

+
{ title="Frequently Asked Questions" className="mt-2 border border-gray-700 rounded-lg" > -
+

How does the subscription work?

- 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.

+
+

What's the difference between monthly and yearly?

+

+ 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. +

+

How do I Subscribe? (Pay as you go)

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.

How do I Subscribe? (Recurring)

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.

diff --git a/src/config/appConfig.js b/src/config/appConfig.js index a8325ba..092e7da 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,7 +11,8 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', + '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4', ], customLightningAddresses: [ { diff --git a/src/db/models/roleModels.js b/src/db/models/roleModels.js index 51ed659..f8e011b 100644 --- a/src/db/models/roleModels.js +++ b/src/db/models/roleModels.js @@ -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, diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 61a0952..dd97c4e 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -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 }, }) ); diff --git a/src/pages/api/users/subscription/cron.js b/src/pages/api/users/subscription/cron.js index 1b7012d..ebf9d9b 100644 --- a/src/pages/api/users/subscription/cron.js +++ b/src/pages/api/users/subscription/cron.js @@ -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); diff --git a/src/pages/api/users/subscription/index.js b/src/pages/api/users/subscription/index.js index 8b828b6..a06e5c5 100644 --- a/src/pages/api/users/subscription/index.js +++ b/src/pages/api/users/subscription/index.js @@ -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) { diff --git a/yearly_subscriptions.md b/yearly_subscriptions.md new file mode 100644 index 0000000..1b073be --- /dev/null +++ b/yearly_subscriptions.md @@ -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