Skip to content

Commit dd334e2

Browse files
authored
Merge pull request wasp-lang#382 from wasp-lang/filip-dry-subscription-status
Dry up subscription status
2 parents b9c1321 + b9d8080 commit dd334e2

File tree

13 files changed

+179
-102
lines changed

13 files changed

+179
-102
lines changed

opensaas-sh/app_diff/src/admin/dashboards/users/UsersTable.tsx.diff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
--- template/app/src/admin/dashboards/users/UsersTable.tsx
22
+++ opensaas-sh/app/src/admin/dashboards/users/UsersTable.tsx
3-
@@ -202,7 +202,7 @@
3+
@@ -207,7 +207,7 @@
44
<p className='text-sm text-black dark:text-white'>{user.subscriptionStatus}</p>
55
</div>
66
<div className='col-span-2 flex items-center'>

opensaas-sh/app_diff/src/analytics/stats.ts.diff

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
--- template/app/src/analytics/stats.ts
22
+++ opensaas-sh/app/src/analytics/stats.ts
3-
@@ -2,10 +2,8 @@
3+
@@ -2,11 +2,9 @@
44
import { type DailyStatsJob } from 'wasp/server/jobs';
55
import Stripe from 'stripe';
6-
import { stripe } from '../payment/stripe/stripeClient'
6+
import { stripe } from '../payment/stripe/stripeClient';
77
-import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
88
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
99
-// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
1010
-import { paymentProcessor } from '../payment/paymentProcessor';
11-
+// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils;
11+
import { SubscriptionStatus } from '../payment/plans';
12+
+// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
1213

1314
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
1415

15-
@@ -41,17 +39,7 @@
16+
@@ -42,17 +40,7 @@
1617
paidUserDelta -= yesterdaysStats.paidUserCount;
1718
}
1819

@@ -27,11 +28,11 @@
2728
- default:
2829
- throw new Error(`Unsupported payment processor: ${paymentProcessor.id}`);
2930
- }
30-
+ let totalRevenue = await fetchTotalStripeRevenue()
31+
+ let totalRevenue = await fetchTotalStripeRevenue();
3132

3233
const { totalViews, prevDayViewsChangePercent } = await getDailyPageViews();
3334

34-
@@ -162,38 +150,3 @@
35+
@@ -163,38 +151,3 @@
3536
// Revenue is in cents so we convert to dollars (or your main currency unit)
3637
return totalRevenue / 100;
3738
}
@@ -70,4 +71,3 @@
7071
- throw error;
7172
- }
7273
-}
73-
\ No newline at end of file

opensaas-sh/app_diff/src/server/scripts/dbSeeds.ts.diff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
--- template/app/src/server/scripts/dbSeeds.ts
22
+++ opensaas-sh/app/src/server/scripts/dbSeeds.ts
3-
@@ -37,9 +37,11 @@
3+
@@ -38,9 +38,11 @@
44
sendNewsletter: false,
55
credits,
66
subscriptionStatus,

template/app/src/admin/dashboards/users/UsersTable.tsx

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type SubscriptionStatus } from '../../../payment/plans';
1+
import { SubscriptionStatus } from '../../../payment/plans';
22
import { useQuery, getPaginatedUsers } from 'wasp/client/operations';
33
import { useState, useEffect } from 'react';
44
import SwitcherOne from '../../elements/forms/SwitcherOne';
@@ -15,9 +15,11 @@ function AdminSwitch({ id, isAdmin }: Pick<User, 'id' | 'isAdmin'>) {
1515

1616
const UsersTable = () => {
1717
const [currentPage, setCurrentPage] = useState(1);
18-
const [emailFilter, setEmailFilter] = useState<string | undefined>('');
18+
const [emailFilter, setEmailFilter] = useState<string | undefined>(undefined);
1919
const [isAdminFilter, setIsAdminFilter] = useState<boolean | undefined>(undefined);
20-
const [subscriptionStatusFilter, setSubcriptionStatusFilter] = useState<SubscriptionStatus[]>([]);
20+
const [subscriptionStatusFilter, setSubcriptionStatusFilter] = useState<Array<SubscriptionStatus | null>>(
21+
[]
22+
);
2123

2224
const skipPages = currentPage - 1;
2325

@@ -90,31 +92,34 @@ const UsersTable = () => {
9092
</div>
9193
<select
9294
onChange={(e) => {
93-
const targetValue = e.target.value === '' ? null : e.target.value;
94-
setSubcriptionStatusFilter((prevValue) => {
95-
if (prevValue?.includes(targetValue as SubscriptionStatus)) {
96-
return prevValue?.filter((val) => val !== targetValue);
97-
} else if (!!prevValue) {
98-
return [...prevValue, targetValue as SubscriptionStatus];
99-
} else {
100-
return prevValue;
101-
}
102-
});
95+
const selectedValue = e.target.value == 'has_not_subscribed' ? null : e.target.value;
96+
97+
console.log(selectedValue);
98+
if (selectedValue === 'clear-all') {
99+
setSubcriptionStatusFilter([]);
100+
} else {
101+
setSubcriptionStatusFilter((prevValue) => {
102+
if (prevValue.includes(selectedValue as SubscriptionStatus)) {
103+
return prevValue.filter((val) => val !== selectedValue);
104+
} else {
105+
return [...prevValue, selectedValue as SubscriptionStatus];
106+
}
107+
});
108+
}
103109
}}
104110
name='status-filter'
105111
id='status-filter'
106112
className='absolute top-0 left-0 z-20 h-full w-full bg-white opacity-0'
107113
>
108-
<option value=''>Select filters</option>
109-
{['past_due', 'cancel_at_period_end', 'active', 'deleted', null].map((status) => {
110-
if (!subscriptionStatusFilter.includes(status as SubscriptionStatus)) {
111-
return (
112-
<option key={status} value={status || ''}>
113-
{status ? status : 'has not subscribed'}
114-
</option>
115-
);
116-
}
117-
})}
114+
<option value='select-filters'>Select filters</option>
115+
{[...Object.values(SubscriptionStatus), null]
116+
.filter((status) => !subscriptionStatusFilter.includes(status))
117+
.map((status) => {
118+
const extendedStatus = status ?? 'has_not_subscribed'
119+
return <option key={extendedStatus} value={extendedStatus}>
120+
{extendedStatus}
121+
</option>
122+
})}
118123
</select>
119124
<span className='absolute top-1/2 right-4 z-10 -translate-y-1/2'>
120125
<ChevronDownIcon />

template/app/src/analytics/stats.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { type DailyStats } from 'wasp/entities';
22
import { type DailyStatsJob } from 'wasp/server/jobs';
33
import Stripe from 'stripe';
4-
import { stripe } from '../payment/stripe/stripeClient'
4+
import { stripe } from '../payment/stripe/stripeClient';
55
import { listOrders } from '@lemonsqueezy/lemonsqueezy.js';
66
import { getDailyPageViews, getSources } from './providers/plausibleAnalyticsUtils';
77
// import { getDailyPageViews, getSources } from './providers/googleAnalyticsUtils';
88
import { paymentProcessor } from '../payment/paymentProcessor';
9+
import { SubscriptionStatus } from '../payment/plans';
910

1011
export type DailyStatsProps = { dailyStats?: DailyStats; weeklyStats?: DailyStats[]; isLoading?: boolean };
1112

@@ -30,7 +31,7 @@ export const calculateDailyStats: DailyStatsJob<never, void> = async (_args, con
3031
// we don't want to count those users as current paying users
3132
const paidUserCount = await context.entities.User.count({
3233
where: {
33-
subscriptionStatus: 'active',
34+
subscriptionStatus: SubscriptionStatus.Active,
3435
},
3536
});
3637

@@ -196,4 +197,4 @@ async function fetchTotalLemonSqueezyRevenue() {
196197
console.error('Error fetching Lemon Squeezy revenue:', error);
197198
throw error;
198199
}
199-
}
200+
}

template/app/src/demo-ai-app/operations.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
import { HttpError } from 'wasp/server';
1212
import { GeneratedSchedule } from './schedule';
1313
import OpenAI from 'openai';
14+
import { SubscriptionStatus } from '../payment/plans';
1415
import { ensureArgsSchemaOrThrowHttpError } from '../server/validation';
1516

1617
const openai = setupOpenAI();
@@ -61,8 +62,8 @@ export const generateGptResponse: GenerateGptResponse<GenerateGptResponseInput,
6162
const hasCredits = context.user.credits > 0;
6263
const hasValidSubscription =
6364
!!context.user.subscriptionStatus &&
64-
context.user.subscriptionStatus !== 'deleted' &&
65-
context.user.subscriptionStatus !== 'past_due';
65+
context.user.subscriptionStatus !== SubscriptionStatus.Deleted &&
66+
context.user.subscriptionStatus !== SubscriptionStatus.PastDue;
6667
const canUserContinue = hasCredits || hasValidSubscription;
6768

6869
if (!canUserContinue) {

template/app/src/payment/PricingPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useAuth } from 'wasp/client/auth';
22
import { generateCheckoutSession, getCustomerPortalUrl, useQuery } from 'wasp/client/operations';
3-
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName } from './plans';
3+
import { PaymentPlanId, paymentPlans, prettyPaymentPlanName, SubscriptionStatus } from './plans';
44
import { AiFillCheckCircle } from 'react-icons/ai';
55
import { useState } from 'react';
66
import { useNavigate } from 'react-router-dom';
@@ -40,7 +40,7 @@ const PricingPage = () => {
4040
const [isPaymentLoading, setIsPaymentLoading] = useState<boolean>(false);
4141

4242
const { data: user } = useAuth();
43-
const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== 'deleted';
43+
const isUserSubscribed = !!user && !!user.subscriptionStatus && user.subscriptionStatus !== SubscriptionStatus.Deleted;
4444

4545
const {
4646
data: customerPortalUrl,

template/app/src/payment/lemonSqueezy/webhook.ts

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import { type MiddlewareConfigFn, HttpError } from 'wasp/server';
22
import { type PaymentsWebhook } from 'wasp/server/api';
33
import { type PrismaClient } from '@prisma/client';
44
import express from 'express';
5-
import { paymentPlans, PaymentPlanId } from '../plans';
5+
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans';
66
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails';
77
import { type Order, type Subscription, getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
88
import crypto from 'crypto';
99
import { requireNodeEnvVar } from '../../server/utils';
1010

11-
1211
export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => {
1312
try {
1413
const rawBody = request.body.toString('utf8');
@@ -94,7 +93,11 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat
9493
console.log(`Order ${order_number} created for user ${lemonSqueezyId}`);
9594
}
9695

97-
async function handleSubscriptionCreated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
96+
async function handleSubscriptionCreated(
97+
data: Subscription,
98+
userId: string,
99+
prismaUserDelegate: PrismaClient['user']
100+
) {
98101
const { customer_id, status, variant_id } = data.data.attributes;
99102
const lemonSqueezyId = customer_id.toString();
100103

@@ -106,7 +109,7 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri
106109
lemonSqueezyId,
107110
userId,
108111
subscriptionPlan: planId,
109-
subscriptionStatus: status,
112+
subscriptionStatus: status as SubscriptionStatus,
110113
datePaid: new Date(),
111114
},
112115
prismaUserDelegate
@@ -118,26 +121,29 @@ async function handleSubscriptionCreated(data: Subscription, userId: string, pri
118121
console.log(`Subscription created for user ${lemonSqueezyId}`);
119122
}
120123

121-
122124
// NOTE: LemonSqueezy's 'subscription_updated' event is sent as a catch-all and fires even after 'subscription_created' & 'order_created'.
123-
async function handleSubscriptionUpdated(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
125+
async function handleSubscriptionUpdated(
126+
data: Subscription,
127+
userId: string,
128+
prismaUserDelegate: PrismaClient['user']
129+
) {
124130
const { customer_id, status, variant_id } = data.data.attributes;
125131
const lemonSqueezyId = customer_id.toString();
126132

127133
const planId = getPlanIdByVariantId(variant_id.toString());
128134

129135
// We ignore other statuses like 'paused' and 'unpaid' for now, because we block user usage if their status is NOT active.
130136
// Note that a status changes to 'past_due' on a failed payment retry, then after 4 unsuccesful payment retries status
131-
// becomes 'unpaid' and finally 'expired' (i.e. 'deleted').
132-
// NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard.
137+
// becomes 'unpaid' and finally 'expired' (i.e. 'deleted').
138+
// NOTE: ability to pause or trial a subscription is something that has to be additionally configured in the lemon squeezy dashboard.
133139
// If you do enable these features, make sure to handle these statuses here.
134140
if (status === 'past_due' || status === 'active') {
135141
await updateUserLemonSqueezyPaymentDetails(
136142
{
137143
lemonSqueezyId,
138144
userId,
139145
subscriptionPlan: planId,
140-
subscriptionStatus: status,
146+
subscriptionStatus: status as SubscriptionStatus,
141147
...(status === 'active' && { datePaid: new Date() }),
142148
},
143149
prismaUserDelegate
@@ -146,31 +152,41 @@ async function handleSubscriptionUpdated(data: Subscription, userId: string, pri
146152
}
147153
}
148154

149-
async function handleSubscriptionCancelled(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
155+
async function handleSubscriptionCancelled(
156+
data: Subscription,
157+
userId: string,
158+
prismaUserDelegate: PrismaClient['user']
159+
) {
150160
const { customer_id } = data.data.attributes;
151161
const lemonSqueezyId = customer_id.toString();
152162

153163
await updateUserLemonSqueezyPaymentDetails(
154164
{
155165
lemonSqueezyId,
156166
userId,
157-
subscriptionStatus: 'cancel_at_period_end', // cancel_at_period_end is the Stripe equivalent of LemonSqueezy's cancelled
167+
// cancel_at_period_end is the Stripe equivalent of LemonSqueezy's cancelled
168+
subscriptionStatus: 'cancel_at_period_end' as SubscriptionStatus,
158169
},
159170
prismaUserDelegate
160171
);
161172

162173
console.log(`Subscription cancelled for user ${lemonSqueezyId}`);
163174
}
164175

165-
async function handleSubscriptionExpired(data: Subscription, userId: string, prismaUserDelegate: PrismaClient['user']) {
176+
async function handleSubscriptionExpired(
177+
data: Subscription,
178+
userId: string,
179+
prismaUserDelegate: PrismaClient['user']
180+
) {
166181
const { customer_id } = data.data.attributes;
167182
const lemonSqueezyId = customer_id.toString();
168183

169184
await updateUserLemonSqueezyPaymentDetails(
170185
{
171186
lemonSqueezyId,
172187
userId,
173-
subscriptionStatus: 'deleted', // deleted is the Stripe equivalent of LemonSqueezy's expired
188+
// deleted is the Stripe equivalent of LemonSqueezy's expired
189+
subscriptionStatus: SubscriptionStatus.Deleted,
174190
},
175191
prismaUserDelegate
176192
);
@@ -181,7 +197,9 @@ async function handleSubscriptionExpired(data: Subscription, userId: string, pri
181197
async function fetchUserCustomerPortalUrl({ lemonSqueezyId }: { lemonSqueezyId: string }): Promise<string> {
182198
const { data: lemonSqueezyCustomer, error } = await getCustomer(lemonSqueezyId);
183199
if (error) {
184-
throw new Error(`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`);
200+
throw new Error(
201+
`Error fetching customer portal URL for user lemonsqueezy id ${lemonSqueezyId}: ${error}`
202+
);
185203
}
186204
const customerPortalUrl = lemonSqueezyCustomer.data.attributes.urls.customer_portal;
187205
if (!customerPortalUrl) {
@@ -198,4 +216,5 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
198216
throw new Error(`No plan with LemonSqueezy variant id ${variantId}`);
199217
}
200218
return planId;
201-
}
219+
}
220+

template/app/src/payment/plans.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import * as z from 'zod';
22
import { requireNodeEnvVar } from '../server/utils';
33

4-
export const subscriptionStatusSchema = z
5-
.literal('past_due')
6-
.or(z.literal('cancel_at_period_end'))
7-
.or(z.literal('active'))
8-
.or(z.literal('deleted'));
9-
10-
export type SubscriptionStatus = z.infer<typeof subscriptionStatusSchema>;
4+
export enum SubscriptionStatus {
5+
PastDue = 'past_due',
6+
CancelAtPeriodEnd = 'cancel_at_period_end',
7+
Active = 'active',
8+
Deleted = 'deleted',
9+
}
1110

1211
export enum PaymentPlanId {
1312
Hobby = 'hobby',

0 commit comments

Comments
 (0)