+
-
+
-
- 2.450
-
- Total Product
+ {dailyStats?.paidUserCount}
+ Total Paying Users
-
- 2.59%
-
+
+ {isLoading ? '...' : dailyStats?.paidUserDelta !== 0 ? dailyStats?.paidUserDelta : '-'}
+ {dailyStats?.paidUserDelta !== 0 ? isDeltaPositive ? : : null}
diff --git a/src/client/admin/components/TotalProfitCard.tsx b/src/client/admin/components/TotalProfitCard.tsx
deleted file mode 100644
index 337dd62..0000000
--- a/src/client/admin/components/TotalProfitCard.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-const TotalProfitCard = () => {
- return (
-
-
-
-
-
-
- $45,2K
-
- Total Profit
-
-
-
- 4.35%
-
-
-
-
- );
-};
-
-export default TotalProfitCard;
diff --git a/src/client/admin/components/TotalRevenueCard.tsx b/src/client/admin/components/TotalRevenueCard.tsx
new file mode 100644
index 0000000..4a2b057
--- /dev/null
+++ b/src/client/admin/components/TotalRevenueCard.tsx
@@ -0,0 +1,62 @@
+import { useMemo, useEffect } from 'react';
+import { UpArrow, DownArrow } from '../common/icons';
+import type { DailyStatsProps } from '../common/types';
+
+const TotalRevenueCard = ({dailyStats, weeklyStats, isLoading}: DailyStatsProps) => {
+ const isDeltaPositive = useMemo(() => {
+ if (!weeklyStats) return false;
+ return (weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) > 0;
+ }, [weeklyStats]);
+
+ const delta = useMemo(() => {
+ if (!weeklyStats) return;
+ return weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue;
+ }, [weeklyStats]);
+
+ const deltaPercentage = useMemo(() => {
+ if (!weeklyStats || !weeklyStats[1]?.totalRevenue) return;
+ return ((weeklyStats[0].totalRevenue - weeklyStats[1]?.totalRevenue) / weeklyStats[1]?.totalRevenue) * 100;
+ }, [weeklyStats]);
+
+ return (
+
+
+
+
+
+
${dailyStats?.totalRevenue}
+ Total Revenue
+
+
+
+ {isLoading ? '...' : !!deltaPercentage ? deltaPercentage + '%' : '-'}
+ {!!deltaPercentage ? isDeltaPositive ? : : null}
+
+
+
+ );
+};
+
+export default TotalRevenueCard;
diff --git a/src/client/admin/components/TotalSignupsCard.tsx b/src/client/admin/components/TotalSignupsCard.tsx
index d60191b..5235472 100644
--- a/src/client/admin/components/TotalSignupsCard.tsx
+++ b/src/client/admin/components/TotalSignupsCard.tsx
@@ -1,53 +1,49 @@
-const TotalSignupsCard = () => {
+import { useMemo } from 'react';
+import { UpArrow } from '../common/icons';
+import type { DailyStatsProps } from '../common/types';
+
+const TotalSignupsCard = ({ dailyStats, isLoading }: DailyStatsProps) => {
+ const isDeltaPositive = useMemo(() => {
+ return !!dailyStats?.userDelta && dailyStats.userDelta > 0;
+ }, [dailyStats]);
+
return (
-
-
+
+
-
+
-
- 3.456
-
- Total Users
+ {dailyStats?.userCount}
+ Total Signups
-
- 0.95%
-
+
+ {isLoading ? '...' : isDeltaPositive ? dailyStats?.userDelta : '-'}
+ {!!dailyStats && isDeltaPositive && }
diff --git a/src/client/admin/pages/Dashboard/ECommerce.tsx b/src/client/admin/pages/Dashboard/ECommerce.tsx
index 9bc2d6a..1fccfd0 100644
--- a/src/client/admin/pages/Dashboard/ECommerce.tsx
+++ b/src/client/admin/pages/Dashboard/ECommerce.tsx
@@ -1,26 +1,29 @@
import TotalSignupsCard from '../../components/TotalSignupsCard';
import TotalPageViewsCard from '../../components/TotalPaidViewsCard';
import TotalPayingUsersCard from '../../components/TotalPayingUsersCard';
-import TotalProfitCard from '../../components/TotalProfitCard';
+import TotalRevenueCard from '../../components/TotalRevenueCard';
import DailyActiveUsersChart from '../../components/DailyActiveUsersChart';
import ReferrerTable from '../../components/ReferrerTable';
import DefaultLayout from '../../layout/DefaultLayout';
+import { useQuery } from '@wasp/queries';
+import getDailyStats from '@wasp/queries/getDailyStats';
const ECommerce = () => {
+ const { data: stats, isLoading, error } = useQuery(getDailyStats);
+
return (
-
+
-
-
-
+
+
+
-
-
- {/*
*/}
+
+
-
diff --git a/src/server/actions.ts b/src/server/actions.ts
index 66a8aad..321ada4 100644
--- a/src/server/actions.ts
+++ b/src/server/actions.ts
@@ -161,3 +161,4 @@ export const updateUser: UpdateUser
, User> = async (user, context)
data: user
});
}
+
diff --git a/src/server/queries.ts b/src/server/queries.ts
index cd88827..06203c6 100644
--- a/src/server/queries.ts
+++ b/src/server/queries.ts
@@ -1,6 +1,11 @@
import HttpError from '@wasp/core/HttpError.js';
-import type { RelatedObject } from '@wasp/entities';
-import type { GetRelatedObjects } from '@wasp/queries/types';
+import type { DailyStats, RelatedObject } from '@wasp/entities';
+import type { GetRelatedObjects, GetDailyStats } from '@wasp/queries/types';
+
+type DailyStatsValues = {
+ dailyStats: DailyStats;
+ weeklyStats: DailyStats[];
+};
export const getRelatedObjects: GetRelatedObjects = async (args, context) => {
if (!context.user) {
@@ -9,8 +14,28 @@ export const getRelatedObjects: GetRelatedObjects = async
return context.entities.RelatedObject.findMany({
where: {
user: {
- id: context.user.id
- }
+ id: context.user.id,
+ },
},
- })
+ });
+};
+
+export const getDailyStats: GetDailyStats = async (_args, context) => {
+ if (!context.user?.isAdmin) {
+ throw new HttpError(401);
+ }
+ const dailyStats = await context.entities.DailyStats.findFirstOrThrow({
+ orderBy: {
+ date: 'desc',
+ },
+ });
+
+ const weeklyStats = await context.entities.DailyStats.findMany({
+ orderBy: {
+ date: 'desc',
+ },
+ take: 7,
+ });
+
+ return {dailyStats, weeklyStats};
}
\ No newline at end of file
diff --git a/src/server/webhooks.ts b/src/server/webhooks.ts
index 4d49656..54bfc6b 100644
--- a/src/server/webhooks.ts
+++ b/src/server/webhooks.ts
@@ -19,8 +19,9 @@ export const STRIPE_WEBHOOK_IPS = [
'54.187.216.72',
];
+// make sure the api version matches the version in the Stripe dashboard
const stripe = new Stripe(process.env.STRIPE_KEY!, {
- apiVersion: '2022-11-15',
+ apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
});
export const stripeWebhook: StripeWebhook = async (request, response, context) => {
@@ -132,25 +133,37 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
if (subscription.cancel_at_period_end) {
console.log('Subscription canceled at period end');
- const customer = await context.entities.User.findFirst({
+ let customer = await context.entities.User.findFirst({
where: {
stripeId: userStripeId,
},
select: {
+ id: true,
email: true,
},
});
- if (customer?.email) {
- await emailSender.send({
- to: customer.email,
- subject: 'We hate to see you go :(',
- text: 'We hate to see you go. Here is a sweet offer...',
- html: 'We hate to see you go. Here is a sweet offer...',
+ if (customer) {
+ await context.entities.User.update({
+ where: {
+ id: customer.id,
+ },
+ data: {
+ subscriptionStatus: 'canceled',
+ },
});
+
+ if (customer.email) {
+ await emailSender.send({
+ to: customer.email,
+ subject: 'We hate to see you go :(',
+ text: 'We hate to see you go. Here is a sweet offer...',
+ html: 'We hate to see you go. Here is a sweet offer...',
+ });
+ }
}
}
- } else if (event.type === 'customer.subscription.deleted' || event.type === 'customer.subscription.canceled') {
+ } else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
userStripeId = subscription.customer as string;
@@ -165,6 +178,7 @@ export const stripeWebhook: StripeWebhook = async (request, response, context) =
},
data: {
hasPaid: false,
+ subscriptionStatus: 'deleted',
},
});
} else {
diff --git a/src/server/workers/calculateDailyStats.ts b/src/server/workers/calculateDailyStats.ts
new file mode 100644
index 0000000..0e63788
--- /dev/null
+++ b/src/server/workers/calculateDailyStats.ts
@@ -0,0 +1,125 @@
+import type { DailyStats } from '@wasp/jobs/dailyStats';
+import Stripe from 'stripe';
+
+const stripe = new Stripe(process.env.STRIPE_KEY!, {
+ apiVersion: '2022-11-15', // TODO find out where this is in the Stripe dashboard and document
+});
+
+export const calculateDailyStats: DailyStats = async (_args, context) => {
+ const currentDate = new Date();
+ const yesterdaysDate = new Date(new Date().setDate(currentDate.getDate() - 1));
+
+ try {
+ const yesterdaysStats = await context.entities.DailyStats.findFirst({
+ where: {
+ date: {
+ equals: yesterdaysDate,
+ },
+ },
+ });
+
+ const userCount = await context.entities.User.count({});
+ // users can have paid but canceled subscriptions which terminate at the end of the period
+ // we don't want to count those users as current paying users
+ const paidUserCount = await context.entities.User.count({
+ where: {
+ hasPaid: true,
+ subscriptionStatus: 'active',
+ },
+ });
+
+ let userDelta = userCount;
+ let paidUserDelta = paidUserCount;
+ if (yesterdaysStats) {
+ userDelta -= yesterdaysStats.userCount;
+ paidUserDelta -= yesterdaysStats.paidUserCount;
+ }
+
+ const newRunningTotal = await calculateTotalRevenue(context);
+
+ await context.entities.DailyStats.upsert({
+ where: {
+ date: currentDate,
+ },
+ create: {
+ date: currentDate,
+ userCount,
+ paidUserCount,
+ userDelta,
+ paidUserDelta,
+ totalRevenue: newRunningTotal,
+ },
+ update: {
+ userCount,
+ paidUserCount,
+ userDelta,
+ paidUserDelta,
+ totalRevenue: newRunningTotal,
+ },
+ });
+ } catch (error) {
+ console.error('Error calculating daily stats: ', error);
+ }
+};
+
+async function fetchDailyStripeRevenue() {
+ const startOfDay = new Date();
+ startOfDay.setHours(0, 0, 0, 0); // Sets to beginning of day
+ const startOfDayTimestamp = Math.floor(startOfDay.getTime() / 1000); // Convert to Unix timestamp in seconds
+
+ const endOfDay = new Date();
+ endOfDay.setHours(23, 59, 59, 999); // Sets to end of day
+ const endOfDayTimestamp = Math.floor(endOfDay.getTime() / 1000); // Convert to Unix timestamp in seconds
+
+ let nextPageCursor = undefined;
+ const allPayments = [] as Stripe.Invoice[];
+
+ while (true) {
+ const params = {
+ query: `created>=${startOfDayTimestamp} AND created<=${endOfDayTimestamp} AND status:"paid"`,
+ limit: 100,
+ page: nextPageCursor,
+ };
+ const payments = await stripe.invoices.search(params);
+
+ if (payments.next_page) {
+ nextPageCursor = payments.next_page;
+ }
+
+ console.log('\n\nstripe invoice payments: ', payments, '\n\n');
+
+ payments.data.forEach((invoice) => allPayments.push(invoice));
+
+ if (!payments.has_more) {
+ break;
+ }
+ }
+
+ const dailyTotalInCents = allPayments.reduce((total, invoice) => {
+ return total + invoice.amount_paid;
+ }, 0);
+
+ return dailyTotalInCents;
+}
+
+async function calculateTotalRevenue(context: any) {
+ const revenueInCents = await fetchDailyStripeRevenue();
+
+ const revenueInDollars = revenueInCents / 100;
+
+ const lastTotalEntry = await context.entities.DailyStats.find({
+ where: {
+ // date is yesterday
+ date: {
+ equals: new Date(new Date().setDate(new Date().getDate() - 1)),
+ },
+ },
+ });
+
+ let newRunningTotal = revenueInDollars;
+ if (lastTotalEntry) {
+ newRunningTotal += lastTotalEntry.totalRevenue;
+ }
+
+ return newRunningTotal;
+}