From 2b10e74d358f67a5115b36d32c53933963447cfd Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 11:24:00 -0500 Subject: [PATCH 1/7] 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 From 5d884cf2b666337bb1fe4aab1690bae146bf1e68 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 14:43:01 -0500 Subject: [PATCH 2/7] update and unify modals into generic component --- src/components/MoreInfo.js | 6 +- .../bitcoinConnect/CoursePaymentButton.js | 8 +- .../bitcoinConnect/ResourcePaymentButton.js | 8 +- src/components/forms/course/LessonSelector.js | 10 +- src/components/onboarding/WelcomeModal.js | 9 +- src/components/profile/UserBadges.js | 12 +- src/components/profile/UserProfileCard.js | 10 +- .../profile/subscription/CalendlyEmbed.js | 8 +- .../subscription/CancelSubscription.js | 10 +- .../subscription/LightningAddressForm.js | 8 +- .../profile/subscription/Nip05Form.js | 8 +- .../profile/subscription/RenewSubscription.js | 7 +- .../profile/subscription/SubscribeModal.js | 9 +- src/components/ui/Modal.js | 112 ++++++++++++++++++ 14 files changed, 173 insertions(+), 52 deletions(-) create mode 100644 src/components/ui/Modal.js diff --git a/src/components/MoreInfo.js b/src/components/MoreInfo.js index aed142a..eacd40b 100644 --- a/src/components/MoreInfo.js +++ b/src/components/MoreInfo.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { Tooltip } from 'primereact/tooltip'; import useWindowWidth from '@/hooks/useWindowWidth'; @@ -24,7 +24,7 @@ const MoreInfo = ({ /> {!isMobile && } - setVisible(false)} @@ -32,7 +32,7 @@ const MoreInfo = ({ breakpoints={{ '960px': '75vw', '641px': '90vw' }} > {typeof modalBody === 'string' ?

{modalBody}

: modalBody} -
+ ); }; diff --git a/src/components/bitcoinConnect/CoursePaymentButton.js b/src/components/bitcoinConnect/CoursePaymentButton.js index 49bdb1f..98cb732 100644 --- a/src/components/bitcoinConnect/CoursePaymentButton.js +++ b/src/components/bitcoinConnect/CoursePaymentButton.js @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import dynamic from 'next/dynamic'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { LightningAddress } from '@getalby/lightning-tools'; import { track } from '@vercel/analytics'; import { useToast } from '@/hooks/useToast'; @@ -227,11 +227,11 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId } />
)} - setDialogVisible(false)} header="Make Payment" - style={{ width: isMobile ? '90vw' : '50vw' }} + width={isMobile ? '90vw' : '50vw'} > {invoice ? ( Loading payment details...

)} -
+
); }; diff --git a/src/components/bitcoinConnect/ResourcePaymentButton.js b/src/components/bitcoinConnect/ResourcePaymentButton.js index 75617b3..455319b 100644 --- a/src/components/bitcoinConnect/ResourcePaymentButton.js +++ b/src/components/bitcoinConnect/ResourcePaymentButton.js @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { track } from '@vercel/analytics'; import { LightningAddress } from '@getalby/lightning-tools'; import { useToast } from '@/hooks/useToast'; @@ -122,11 +122,11 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource />
)} - setDialogVisible(false)} header="Make Payment" - style={{ width: isMobile ? '90vw' : '50vw' }} + width={isMobile ? '90vw' : '50vw'} > {invoice ? ( Loading payment details...

)} -
+ ); }; diff --git a/src/components/forms/course/LessonSelector.js b/src/components/forms/course/LessonSelector.js index 52fd7aa..639d6a3 100644 --- a/src/components/forms/course/LessonSelector.js +++ b/src/components/forms/course/LessonSelector.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Dropdown } from 'primereact/dropdown'; import GenericButton from '@/components/buttons/GenericButton'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { Accordion, AccordionTab } from 'primereact/accordion'; import EmbeddedDocumentForm from '@/components/forms/course/embedded/EmbeddedDocumentForm'; import EmbeddedVideoForm from '@/components/forms/course/embedded/EmbeddedVideoForm'; @@ -233,23 +233,23 @@ const LessonSelector = ({ - setShowDocumentForm(false)} header="Create New Document" > - + - setShowVideoForm(false)} header="Create New Video" > - +
); }; diff --git a/src/components/onboarding/WelcomeModal.js b/src/components/onboarding/WelcomeModal.js index 1a49bae..fce0514 100644 --- a/src/components/onboarding/WelcomeModal.js +++ b/src/components/onboarding/WelcomeModal.js @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { useRouter } from 'next/router'; const WelcomeModal = () => { @@ -26,10 +26,11 @@ const WelcomeModal = () => { }; return ( -
@@ -74,7 +75,7 @@ const WelcomeModal = () => {

Let's start your coding journey! 🚀

- + ); }; diff --git a/src/components/profile/UserBadges.js b/src/components/profile/UserBadges.js index b779025..b406138 100644 --- a/src/components/profile/UserBadges.js +++ b/src/components/profile/UserBadges.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import Image from 'next/image'; import { useNDKContext } from '@/context/NDKContext'; import { useSession } from 'next-auth/react'; @@ -99,7 +99,13 @@ const UserBadges = ({ visible, onHide }) => { }; return ( - +
{loading ? (
@@ -147,7 +153,7 @@ const UserBadges = ({ visible, onHide }) => {
)}
-
+ ); }; diff --git a/src/components/profile/UserProfileCard.js b/src/components/profile/UserProfileCard.js index 1414973..e8d2e79 100644 --- a/src/components/profile/UserProfileCard.js +++ b/src/components/profile/UserProfileCard.js @@ -1,7 +1,7 @@ import React, { useRef, useState } from 'react'; import Image from 'next/image'; import { Menu } from 'primereact/menu'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { nip19 } from 'nostr-tools'; import { useImageProxy } from '@/hooks/useImageProxy'; import { useToast } from '@/hooks/useToast'; @@ -284,12 +284,12 @@ const UserProfileCard = ({ user }) => { <> {windowWidth <= 1440 ? : } setShowBadges(false)} /> - setShowRelaysModal(false)} header="Manage Relays" - className="w-[90vw] max-w-[800px]" - modal + width="full" + className="max-w-[800px]" > { setUserRelays={setUserRelays} reInitializeNDK={reInitializeNDK} /> - + ); }; diff --git a/src/components/profile/subscription/CalendlyEmbed.js b/src/components/profile/subscription/CalendlyEmbed.js index fc57588..77fcc71 100644 --- a/src/components/profile/subscription/CalendlyEmbed.js +++ b/src/components/profile/subscription/CalendlyEmbed.js @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { Button } from 'primereact/button'; import useWindowWidth from '@/hooks/useWindowWidth'; @@ -25,10 +25,10 @@ const CalendlyEmbed = ({ visible, onHide, userId, userEmail, userName }) => { ); return ( - @@ -37,7 +37,7 @@ const CalendlyEmbed = ({ visible, onHide, userId, userEmail, userName }) => { data-url={`https://calendly.com/plebdevs/30min?hide_event_type_details=1&hide_gdpr_banner=1&email=${encodeURIComponent(userEmail)}&name=${encodeURIComponent(userName)}&custom_data=${encodeURIComponent(JSON.stringify({ user_id: userId }))}`} style={{ minWidth: '320px', height: '700px' }} /> - + ); }; diff --git a/src/components/profile/subscription/CancelSubscription.js b/src/components/profile/subscription/CancelSubscription.js index 4392e06..1441219 100644 --- a/src/components/profile/subscription/CancelSubscription.js +++ b/src/components/profile/subscription/CancelSubscription.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import axios from 'axios'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; @@ -31,7 +31,11 @@ const CancelSubscription = ({ visible, onHide }) => { }; return ( - +

Are you sure you want to cancel your subscription?

{isProcessing ? ( @@ -46,7 +50,7 @@ const CancelSubscription = ({ visible, onHide }) => { /> )}
-
+ ); }; diff --git a/src/components/profile/subscription/LightningAddressForm.js b/src/components/profile/subscription/LightningAddressForm.js index 7256bb5..7a4823c 100644 --- a/src/components/profile/subscription/LightningAddressForm.js +++ b/src/components/profile/subscription/LightningAddressForm.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import axios from 'axios'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; @@ -111,11 +111,11 @@ const LightningAddressForm = ({ visible, onHide }) => { }; return ( - {existingLightningAddress ? (

Update your Lightning Address details

@@ -216,7 +216,7 @@ const LightningAddressForm = ({ visible, onHide }) => { />
)} - + ); }; diff --git a/src/components/profile/subscription/Nip05Form.js b/src/components/profile/subscription/Nip05Form.js index 9e96be2..586d97d 100644 --- a/src/components/profile/subscription/Nip05Form.js +++ b/src/components/profile/subscription/Nip05Form.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import axios from 'axios'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; @@ -82,11 +82,11 @@ const Nip05Form = ({ visible, onHide }) => { }; return ( - {existingNip05 ?

Update your Pubkey and Name

:

Confirm your Pubkey and Name

}
@@ -126,7 +126,7 @@ const Nip05Form = ({ visible, onHide }) => { />
)} -
+ ); }; diff --git a/src/components/profile/subscription/RenewSubscription.js b/src/components/profile/subscription/RenewSubscription.js index b26d222..56cb3f4 100644 --- a/src/components/profile/subscription/RenewSubscription.js +++ b/src/components/profile/subscription/RenewSubscription.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { Card } from 'primereact/card'; import { ProgressSpinner } from 'primereact/progressspinner'; import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton'; @@ -50,11 +50,10 @@ const RenewSubscription = ({ visible, onHide, subscribedUntil }) => { }; return ( - {isProcessing ? (
@@ -81,7 +80,7 @@ const RenewSubscription = ({ visible, onHide, subscribedUntil }) => { /> )} -
+ ); }; diff --git a/src/components/profile/subscription/SubscribeModal.js b/src/components/profile/subscription/SubscribeModal.js index 131128e..dcc36cb 100644 --- a/src/components/profile/subscription/SubscribeModal.js +++ b/src/components/profile/subscription/SubscribeModal.js @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Dialog } from 'primereact/dialog'; +import Modal from '@/components/ui/Modal'; import { ProgressSpinner } from 'primereact/progressspinner'; import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton'; import axios from 'axios'; @@ -216,11 +216,10 @@ const SubscribeModal = ({ user }) => {
)} - {isProcessing ? (
@@ -230,7 +229,7 @@ const SubscribeModal = ({ user }) => { Processing subscription...
) : ( - +

Unlock Premium Benefits

Subscribe now and elevate your development journey!

@@ -295,7 +294,7 @@ const SubscribeModal = ({ user }) => {
)} -
+ setCalendlyVisible(false)} diff --git a/src/components/ui/Modal.js b/src/components/ui/Modal.js new file mode 100644 index 0000000..5f35479 --- /dev/null +++ b/src/components/ui/Modal.js @@ -0,0 +1,112 @@ +import React from 'react'; +import { Dialog } from 'primereact/dialog'; +import { Button } from 'primereact/button'; + +/** + * A generic modal component based on PrimeReact Dialog with dark styling + * @param {Object} props - Component props + * @param {string} props.header - Modal header text + * @param {boolean} props.visible - Whether the modal is visible + * @param {Function} props.onHide - Function to call when modal is closed + * @param {React.ReactNode} props.children - Modal content + * @param {string} props.className - Additional CSS classes for the modal + * @param {Object} props.style - Additional inline styles for the modal + * @param {string} props.width - Width of the modal (fit, full, or pixel value) + * @param {React.ReactNode} props.footer - Custom footer content + * @param {Object} props.headerStyle - Additional styles for the header + * @param {Object} props.contentStyle - Additional styles for the content + * @param {Object} props.footerStyle - Additional styles for the footer + * @param {Object} props.breakpoints - Responsive breakpoints (e.g. {'960px': '75vw'}) + * @param {boolean} props.modal - Whether the modal requires a click on the mask to hide + * @param {boolean} props.draggable - Whether the modal is draggable + * @param {boolean} props.resizable - Whether the modal is resizable + * @param {boolean} props.maximizable - Whether the modal can be maximized + * @param {boolean} props.dismissableMask - Whether clicking outside closes the modal + * @param {boolean} props.showCloseButton - Whether to show the default close button in the footer + */ +const Modal = ({ + header, + visible, + onHide, + children, + className = '', + style = {}, + width = 'fit', + footer, + headerStyle = {}, + contentStyle = {}, + footerStyle = {}, + breakpoints, + modal, + draggable, + resizable, + maximizable, + dismissableMask, + showCloseButton = false, + ...otherProps +}) => { + // Base dark styling + const baseStyle = { backgroundColor: '#1f2937' }; + const baseHeaderStyle = { backgroundColor: '#1f2937', color: 'white' }; + const baseContentStyle = { backgroundColor: '#1f2937' }; + const baseFooterStyle = { backgroundColor: '#1f2937', borderTop: '1px solid #374151' }; + + // Determine width class + let widthClass = ''; + if (width === 'fit') { + widthClass = 'w-fit'; + } else if (width === 'full') { + widthClass = 'w-full'; + } else { + // Custom width will be handled via style + style.width = width; + } + + // Create footer with close button if requested + const footerContent = showCloseButton ? ( +
+
+ ) : footer; + + // Apply tailwind CSS to modify dialog elements + const dialogClassNames = ` + .p-dialog-footer { + background-color: #1f2937 !important; + border-top: 1px solid #374151 !important; + } + `; + + return ( + <> + + + {children} + + + ); +}; + +export default Modal; \ No newline at end of file From 2c6c230521c67fdb92875577f90cc1a701773644 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 15:48:00 -0500 Subject: [PATCH 3/7] switch back datasource in prisma schema, remove test pubkey in app config --- prisma/schema.prisma | 16 ++++++++-------- src/config/appConfig.js | 3 +-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 20ce2a7..a5bc0e9 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" } diff --git a/src/config/appConfig.js b/src/config/appConfig.js index 092e7da..a8325ba 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,8 +11,7 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', - '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4', + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' ], customLightningAddresses: [ { From dc359dd1d1f1fb238979ddfc604966802907cd2b Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 16:29:54 -0500 Subject: [PATCH 4/7] unify subscription period constants & handle missing payment dates --- .../profile/subscription/SubscribeModal.js | 22 ++++++++---- src/constants/subscriptionPeriods.js | 36 +++++++++++++++++++ src/db/models/userModels.js | 11 ++++-- 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 src/constants/subscriptionPeriods.js diff --git a/src/components/profile/subscription/SubscribeModal.js b/src/components/profile/subscription/SubscribeModal.js index dcc36cb..6c5dd33 100644 --- a/src/components/profile/subscription/SubscribeModal.js +++ b/src/components/profile/subscription/SubscribeModal.js @@ -18,6 +18,7 @@ import NostrIcon from '../../../../public/images/nostr.png'; import Image from 'next/image'; import RenewSubscription from '@/components/profile/subscription/RenewSubscription'; import { SelectButton } from 'primereact/selectbutton'; +import { calculateExpirationDate } from '@/constants/subscriptionPeriods'; const SubscribeModal = ({ user }) => { const { data: session, update } = useSession(); @@ -45,13 +46,20 @@ const SubscribeModal = ({ user }) => { if (user && user.role) { setSubscribed(user.role.subscribed); setSubscriptionType(user.role.subscriptionType || 'monthly'); - const subscribedAt = new Date(user.role.lastPaymentAt); - // Calculate subscription end date based on type - const daysToAdd = subscriptionType === 'yearly' ? 365 : 31; - const subscribedUntil = new Date(subscribedAt.getTime() + daysToAdd * 24 * 60 * 60 * 1000); + // Only calculate dates if lastPaymentAt exists + if (user.role.lastPaymentAt) { + const subscribedAt = new Date(user.role.lastPaymentAt); + + // Use the shared helper to calculate expiration date + const subscribedUntil = calculateExpirationDate(subscribedAt, subscriptionType); + + setSubscribedUntil(subscribedUntil); + } else { + // Reset the subscribedUntil value if no lastPaymentAt + setSubscribedUntil(null); + } - setSubscribedUntil(subscribedUntil); if (user.role.subscriptionExpiredAt) { const expiredAt = new Date(user.role.subscriptionExpiredAt); setSubscriptionExpiredAt(expiredAt); @@ -174,7 +182,7 @@ const SubscribeModal = ({ user }) => {

Thank you for your support 🎉

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

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

Thank you for your support 🎉

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

)} diff --git a/src/constants/subscriptionPeriods.js b/src/constants/subscriptionPeriods.js new file mode 100644 index 0000000..76e5f45 --- /dev/null +++ b/src/constants/subscriptionPeriods.js @@ -0,0 +1,36 @@ +// Constants for subscription periods to maintain consistency across the application +export const SUBSCRIPTION_PERIODS = { + MONTHLY: { + DAYS: 30, + BUFFER_HOURS: 1, // Buffer time for expiration checks + }, + YEARLY: { + DAYS: 365, + BUFFER_HOURS: 1, // Buffer time for expiration checks + } +}; + +// Helper to calculate expiration date (for UI display) +export const calculateExpirationDate = (startDate, subscriptionType) => { + const periodDays = subscriptionType === 'yearly' + ? SUBSCRIPTION_PERIODS.YEARLY.DAYS + : SUBSCRIPTION_PERIODS.MONTHLY.DAYS; + + return new Date(startDate.getTime() + periodDays * 24 * 60 * 60 * 1000); +}; + +// Helper to check if subscription has expired (for backend logic) +export const hasSubscriptionExpired = (lastPaymentDate, subscriptionType) => { + if (!lastPaymentDate) return true; + + const now = new Date(); + const period = subscriptionType === 'yearly' + ? SUBSCRIPTION_PERIODS.YEARLY + : SUBSCRIPTION_PERIODS.MONTHLY; + + const expirationTime = lastPaymentDate.getTime() + + (period.DAYS * 24 * 60 * 60 * 1000) + + (period.BUFFER_HOURS * 60 * 60 * 1000); + + return now.getTime() > expirationTime; +}; \ No newline at end of file diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index dd97c4e..1f97e91 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -1,4 +1,5 @@ import prisma from '../prisma'; +import { SUBSCRIPTION_PERIODS } from '@/constants/subscriptionPeriods'; export const getAllUsers = async () => { return await prisma.user.findMany({ @@ -205,12 +206,16 @@ export const findExpiredSubscriptions = async () => { try { const now = new Date(); - // Define expiration periods + // Use the constants for expiration periods const monthlyExpiration = new Date( - now.getTime() - 30 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000 + now.getTime() - + (SUBSCRIPTION_PERIODS.MONTHLY.DAYS * 24 * 60 * 60 * 1000) - + (SUBSCRIPTION_PERIODS.MONTHLY.BUFFER_HOURS * 60 * 60 * 1000) ); const yearlyExpiration = new Date( - now.getTime() - 365 * 24 * 60 * 60 * 1000 - 1 * 60 * 60 * 1000 + now.getTime() - + (SUBSCRIPTION_PERIODS.YEARLY.DAYS * 24 * 60 * 60 * 1000) - + (SUBSCRIPTION_PERIODS.YEARLY.BUFFER_HOURS * 60 * 60 * 1000) ); // Find expired subscriptions of both types From ab7b5fc273689e3abb33782e17d955c388d52ce7 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 16:55:23 -0500 Subject: [PATCH 5/7] standardize subscription periods and add null date handling --- .../profile/subscription/UserSubscription.js | 18 ++++++++++++------ src/pages/about.js | 3 ++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/profile/subscription/UserSubscription.js b/src/components/profile/subscription/UserSubscription.js index 3b29952..b0d734e 100644 --- a/src/components/profile/subscription/UserSubscription.js +++ b/src/components/profile/subscription/UserSubscription.js @@ -17,6 +17,7 @@ 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'; +import { SUBSCRIPTION_PERIODS, calculateExpirationDate } from '@/constants/subscriptionPeriods'; const UserSubscription = () => { const { data: session, update } = useSession(); @@ -52,13 +53,18 @@ const UserSubscription = () => { useEffect(() => { if (user && user.role) { setSubscribed(user.role.subscribed); - const subscribedAt = new Date(user.role.lastPaymentAt); - // 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); + if (user.role.lastPaymentAt) { + const subscribedAt = new Date(user.role.lastPaymentAt); + + // Use the common helper to calculate expiration date + const subscribedUntil = calculateExpirationDate(subscribedAt, user.role.subscriptionType || 'monthly'); + + setSubscribedUntil(subscribedUntil); + } else { + setSubscribedUntil(null); + } - setSubscribedUntil(subscribedUntil); if (user.role.subscriptionExpiredAt) { const expiredAt = new Date(user.role.subscriptionExpiredAt); setSubscriptionExpiredAt(expiredAt); @@ -243,7 +249,7 @@ const UserSubscription = () => { Current Plan: {user?.role?.subscriptionType || 'monthly'} subscription

- Renews on: {subscribedUntil?.toLocaleDateString()} + Renews on: {subscribedUntil ? subscribedUntil.toLocaleDateString() : 'N/A'}

diff --git a/src/pages/about.js b/src/pages/about.js index 2d66796..2bc1dd1 100644 --- a/src/pages/about.js +++ b/src/pages/about.js @@ -18,6 +18,7 @@ import RenewSubscription from '@/components/profile/subscription/RenewSubscripti import Nip05Form from '@/components/profile/subscription/Nip05Form'; import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm'; import MoreInfo from '@/components/MoreInfo'; +import { SUBSCRIPTION_PERIODS } from '@/constants/subscriptionPeriods'; const AboutPage = () => { const { data: session, update } = useSession(); @@ -117,7 +118,7 @@ const AboutPage = () => { 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); + const subscribedUntil = new Date(subscribedAt.getTime() + SUBSCRIPTION_PERIODS.MONTHLY.DAYS * 24 * 60 * 60 * 1000); setSubscribedUntil(subscribedUntil); if (user.role.subscriptionExpiredAt) { const expiredAt = new Date(user.role.subscriptionExpiredAt); From 34d0165c892266e96d9b17e207106b6a9513b56d Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Wed, 14 May 2025 17:23:24 -0500 Subject: [PATCH 6/7] reset prod prices --- src/components/bitcoinConnect/SubscriptionPaymentButton.js | 2 +- src/components/profile/subscription/UserSubscription.js | 5 ----- src/pages/api/users/subscription/cron.js | 2 +- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/components/bitcoinConnect/SubscriptionPaymentButton.js b/src/components/bitcoinConnect/SubscriptionPaymentButton.js index 07ce879..505004c 100644 --- a/src/components/bitcoinConnect/SubscriptionPaymentButton.js +++ b/src/components/bitcoinConnect/SubscriptionPaymentButton.js @@ -38,7 +38,7 @@ const SubscriptionPaymentButtons = ({ // Calculate the amount based on the subscription type const getAmount = () => { - return subscriptionType === 'yearly' ? 500 : 50; + return subscriptionType === 'yearly' ? 500000 : 50000; }; const amount = getAmount(); diff --git a/src/components/profile/subscription/UserSubscription.js b/src/components/profile/subscription/UserSubscription.js index b0d734e..a49975f 100644 --- a/src/components/profile/subscription/UserSubscription.js +++ b/src/components/profile/subscription/UserSubscription.js @@ -117,11 +117,6 @@ const UserSubscription = () => { } }; - // Calculate the subscription amount based on type - const getAmount = () => { - return subscriptionType === 'yearly' ? 500 : 50; - }; - return (
{windowWidth < 768 &&

Subscription Management

} diff --git a/src/pages/api/users/subscription/cron.js b/src/pages/api/users/subscription/cron.js index ebf9d9b..7bb8e87 100644 --- a/src/pages/api/users/subscription/cron.js +++ b/src/pages/api/users/subscription/cron.js @@ -11,7 +11,7 @@ const lnAddress = process.env.LIGHTNING_ADDRESS; // 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; + return subscriptionType === 'yearly' ? 500000 : 50000; }; export default async function handler(req, res) { From 9a21f07577d80278ee03f36ae1d2258740aca632 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Thu, 15 May 2025 09:55:51 -0500 Subject: [PATCH 7/7] remove prd --- yearly_subscriptions.md | 195 ---------------------------------------- 1 file changed, 195 deletions(-) delete mode 100644 yearly_subscriptions.md diff --git a/yearly_subscriptions.md b/yearly_subscriptions.md deleted file mode 100644 index 1b073be..0000000 --- a/yearly_subscriptions.md +++ /dev/null @@ -1,195 +0,0 @@ -# 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