diff --git a/src/components/ApiUsage.tsx b/src/components/ApiUsage.tsx index 5f0bccca..3927471f 100644 --- a/src/components/ApiUsage.tsx +++ b/src/components/ApiUsage.tsx @@ -1,6 +1,5 @@ import { Capacity, Loading, Spacer, Text } from "@geist-ui/react"; import { Loader } from "@geist-ui/react-icons"; -import { format, parseISO } from "date-fns"; import React, { useEffect, useState } from "react"; import { sentryException } from "@/util/sentry"; @@ -9,6 +8,7 @@ import { getApiUsageClient } from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; import styles from "./ApiUsage.module.css"; import { Demo } from "./Demo"; +import { formatDate } from "@/util/helpers"; export function ApiUsage(): React.ReactElement { const { subscription, user, userFinishedLoading } = useUser(); @@ -18,7 +18,7 @@ export function ApiUsage(): React.ReactElement { if (!user || !userFinishedLoading) { return; } - getApiUsageClient(user, subscription) + getApiUsageClient(subscription) .then(setApiCalls) .catch(sentryException); }, [user, userFinishedLoading, subscription]); @@ -62,14 +62,10 @@ export function ApiUsage(): React.ReactElement { - getApiUsageClient(user, subscription).then(setApiCalls) + getApiUsageClient(subscription).then(setApiCalls) } /> ); } - -function formatDate(d: string | Date): string { - return format(typeof d === "string" ? parseISO(d) : d, "do MMM yyyy"); -} diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 54431a40..3c9e5438 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -8,13 +8,14 @@ import { productName, SAAS_10K_PRODUCT_ID, } from "@/util/subs"; -import { SupabaseProductWithPrice } from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; import { ApiUsage } from "./ApiUsage"; import styles from "./Dashboard.module.css"; +import { ProductWithPrice } from "@/supabase/domain.types"; +import { formatDate } from "@/util/helpers"; interface DashboardProps { - products: SupabaseProductWithPrice[]; + products: ProductWithPrice[]; } export function Dashboard({ products }: DashboardProps): React.ReactElement { @@ -40,13 +41,27 @@ export function Dashboard({ products }: DashboardProps): React.ReactElement {
- Hello{userDetails?.full_name || ""}, Thanks for using the Reacher{" "} {productName(subscription?.prices?.products)}! -
+ Billing History +
+
+ + Active Subscription + + + {productName(subscription?.prices?.products)} + + {subscription?.cancel_at && ( + + ⚠️ Plan ends on{" "} + {formatDate(new Date(subscription.cancel_at))} + + )} +
{subscription ? ( Manage Subscription @@ -60,20 +75,8 @@ export function Dashboard({ products }: DashboardProps): React.ReactElement { Upgrade Plan )} - - - Billing History -
-
- - Active Subscription - - - {productName(subscription?.prices?.products)} - -
diff --git a/src/components/ProductCard/Sub.tsx b/src/components/ProductCard/Sub.tsx index 622f707f..970738d8 100644 --- a/src/components/ProductCard/Sub.tsx +++ b/src/components/ProductCard/Sub.tsx @@ -7,18 +7,16 @@ import { postData } from "@/util/helpers"; import { sentryException } from "@/util/sentry"; import { getStripe } from "@/util/stripeClient"; import { COMMERCIAL_LICENSE_PRODUCT_ID } from "@/util/subs"; -import type { - SupabasePrice, - SupabaseProductWithPrice, -} from "@/util/supabaseClient"; +import type {} from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; import { Card } from "./Card"; import styles from "./Card.module.css"; import { Tables } from "@/supabase/database.types"; +import { ProductWithPrice } from "@/supabase/domain.types"; export interface ProductCardProps { currency: string; - product: SupabaseProductWithPrice; + product: ProductWithPrice; subscription: Tables<"subscriptions"> | null; } @@ -37,7 +35,7 @@ export function ProductCard({ return

Error: No price found for product {product.id}.

; } - const handleCheckout = async (price: SupabasePrice) => { + const handleCheckout = async (price: Tables<"prices">) => { setPriceIdLoading(price.id); if (!session) { @@ -69,7 +67,7 @@ export function ProductCard({ const priceString = new Intl.NumberFormat("en-US", { style: "currency", - currency: price.currency, + currency: price.currency || undefined, minimumFractionDigits: 0, }).format(price.unit_amount / 100); @@ -155,7 +153,7 @@ export function ProductCard({ ) } - title={product.name} + title={product.name || "No Product Name"} // Latter should never happen /> ); } diff --git a/src/pages/api/stripe/create-checkout-session.ts b/src/pages/api/stripe/create-checkout-session.ts index 9b009f71..9214d2ad 100644 --- a/src/pages/api/stripe/create-checkout-session.ts +++ b/src/pages/api/stripe/create-checkout-session.ts @@ -3,9 +3,9 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getWebappURL } from "@/util/helpers"; import { sentryException } from "@/util/sentry"; import { stripe } from "@/util/stripeServer"; -import { SupabasePrice } from "@/util/supabaseClient"; import { getActiveSubscription, getUser } from "@/util/supabaseServer"; import { createOrRetrieveCustomer } from "@/util/useDatabase"; +import { Tables } from "@/supabase/database.types"; const createCheckoutSession = async ( req: NextApiRequest, @@ -24,7 +24,7 @@ const createCheckoutSession = async ( quantity = 1, metadata = {}, } = req.body as { - price: SupabasePrice; + price: Tables<"prices">; quantity: number; metadata: Record; }; diff --git a/src/pages/api/v0/check_email.ts b/src/pages/api/v0/check_email.ts index 5dcb4e40..176e323c 100644 --- a/src/pages/api/v0/check_email.ts +++ b/src/pages/api/v0/check_email.ts @@ -7,8 +7,8 @@ import dns from "dns/promises"; import { checkUserInDB, cors, removeSensitiveData } from "@/util/api"; import { updateSendinblue } from "@/util/sendinblue"; import { sentryException } from "@/util/sentry"; -import { SupabaseCall } from "@/util/supabaseClient"; import { supabaseAdmin } from "@/util/supabaseServer"; +import { Tables } from "@/supabase/database.types"; const TIMEOUT = 50000; const MAX_PRIORITY = 5; // Higher is faster, 5 is max. @@ -74,7 +74,7 @@ const POST = async ( // Add to supabase const response = await supabaseAdmin - .from("calls") + .from>("calls") .insert({ endpoint: "/v0/check_email", user_id: user.id, diff --git a/src/pages/dashboard.tsx b/src/pages/dashboard.tsx index cdb86837..81192479 100644 --- a/src/pages/dashboard.tsx +++ b/src/pages/dashboard.tsx @@ -5,14 +5,12 @@ import React, { useEffect } from "react"; import { Dashboard, Nav } from "../components"; import { sentryException } from "@/util/sentry"; -import { - getActiveProductsWithPrices, - SupabaseProductWithPrice, -} from "@/util/supabaseClient"; +import { getActiveProductWithPrices } from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; +import { ProductWithPrice } from "@/supabase/domain.types"; export const getStaticProps: GetStaticProps = async () => { - const products = await getActiveProductsWithPrices(); + const products = await getActiveProductWithPrices(); return { props: { @@ -22,7 +20,7 @@ export const getStaticProps: GetStaticProps = async () => { }; interface IndexProps { - products: SupabaseProductWithPrice[]; + products: ProductWithPrice[]; } export default function Index({ products }: IndexProps): React.ReactElement { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4779f075..e72a1727 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -7,11 +7,11 @@ import { useState } from "react"; import { Nav } from "../components"; import { parseHashComponents, postData } from "@/util/helpers"; import { sentryException } from "@/util/sentry"; -import { getActiveProductsWithPrices } from "@/util/supabaseClient"; +import { getActiveProductWithPrices } from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; export const getStaticProps: GetStaticProps = async () => { - const products = await getActiveProductsWithPrices(); + const products = await getActiveProductWithPrices(); return { props: { diff --git a/src/pages/pricing.tsx b/src/pages/pricing.tsx index 0192bcb3..9bda9f69 100644 --- a/src/pages/pricing.tsx +++ b/src/pages/pricing.tsx @@ -7,14 +7,12 @@ import { COMMERCIAL_LICENSE_PRODUCT_ID, SAAS_10K_PRODUCT_ID, } from "@/util/subs"; -import { - getActiveProductsWithPrices, - SupabaseProductWithPrice, -} from "@/util/supabaseClient"; +import { getActiveProductWithPrices } from "@/util/supabaseClient"; import { useUser } from "@/util/useUser"; +import { ProductWithPrice } from "@/supabase/domain.types"; export const getStaticProps: GetStaticProps = async () => { - const products = await getActiveProductsWithPrices(); + const products = await getActiveProductWithPrices(); return { props: { @@ -24,7 +22,7 @@ export const getStaticProps: GetStaticProps = async () => { }; interface PricingProps { - products: SupabaseProductWithPrice[]; + products: ProductWithPrice[]; } export default function Pricing({ diff --git a/src/supabase/domain.types.ts b/src/supabase/domain.types.ts index 5815f895..1e962686 100644 --- a/src/supabase/domain.types.ts +++ b/src/supabase/domain.types.ts @@ -7,3 +7,7 @@ export interface PriceWithProduct extends Tables<"prices"> { export interface SubscriptionWithPrice extends Tables<"subscriptions"> { prices: PriceWithProduct; } + +export interface ProductWithPrice extends Tables<"products"> { + prices: PriceWithProduct[]; +} diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 958a0fef..6976adcc 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,5 +1,6 @@ import axios, { AxiosError, AxiosResponse } from "axios"; import retry from "async-retry"; +import { format, parseISO } from "date-fns"; // Gets the currently depoloyed URL. export const getWebappURL = (): string => { @@ -98,3 +99,7 @@ export function parseHashComponents(hash: string): Record { return acc; }, {} as Record); } + +export function formatDate(d: string | Date): string { + return format(typeof d === "string" ? parseISO(d) : d, "do MMM yyyy"); +} diff --git a/src/util/sendinblue.ts b/src/util/sendinblue.ts index 82641d68..c5d50f73 100644 --- a/src/util/sendinblue.ts +++ b/src/util/sendinblue.ts @@ -22,7 +22,7 @@ function sendinblueDateFormat(d: Date): string { */ export async function updateSendinblue( userId: string, - sendinblueContactId?: string + sendinblueContactId: string | null ): Promise { if (!sendinblueContactId) { throw new Error( diff --git a/src/util/subs.ts b/src/util/subs.ts index e5789c14..629564e6 100644 --- a/src/util/subs.ts +++ b/src/util/subs.ts @@ -1,4 +1,4 @@ -import type { SupabaseProduct } from "./supabaseClient"; +import { Tables } from "@/supabase/database.types"; import { SubscriptionWithPrice } from "@/supabase/domain.types"; // We're hardcoding these as env variables. @@ -13,11 +13,11 @@ if (!SAAS_10K_PRODUCT_ID || !COMMERCIAL_LICENSE_PRODUCT_ID) { } // Get the user-friendly name of a product. -export function productName(product?: SupabaseProduct): string { +export function productName(product?: Tables<"products">): string { return product?.name || "Free Trial"; } // Return the max monthly calls -export function subApiMaxCalls(sub: SubscriptionWithPrice | undefined): number { +export function subApiMaxCalls(sub: SubscriptionWithPrice | null): number { return sub?.prices?.products?.id === SAAS_10K_PRODUCT_ID ? 10000 : 50; } diff --git a/src/util/supabaseClient.ts b/src/util/supabaseClient.ts index c81d7b92..a562fdb6 100644 --- a/src/util/supabaseClient.ts +++ b/src/util/supabaseClient.ts @@ -1,68 +1,19 @@ import { Tables } from "@/supabase/database.types"; -import { CheckEmailOutput } from "@reacherhq/api"; +import { ProductWithPrice } from "@/supabase/domain.types"; import type { PostgrestFilterBuilder } from "@supabase/postgrest-js"; import { createClient, User } from "@supabase/supabase-js"; import { parseISO, subMonths } from "date-fns"; -export interface SupabasePrice { - active: boolean; - currency: string; - description: string | null; - id: string; - interval: string | null; - interval_count: number | null; - metadata: Record; - product_id: string; - products?: SupabaseProduct; // Populated on join. - trial_period_days?: number | null; - type: string; - unit_amount: number | null; -} - -export interface SupabaseProduct { - active: boolean; - description: string | null; - id: string; - image?: string | null; - metadata: Record; - name: string; - prices?: SupabasePrice[]; // Populated on join. -} - -export interface SupabaseProductWithPrice extends SupabaseProduct { - prices: SupabasePrice[]; -} - -export interface SupabaseCustomer { - id: string; - stripe_customer_id: string; -} - -export interface SupabaseCall { - id: number; - user_id: string; - endpoint: string; - created_at: string; - duration?: number; - backend?: string; - backend_ip: string; - domain?: string; - verification_id: string; - is_reachable: "safe" | "invalid" | "risky" | "unknown"; - verif_method?: string; - result?: CheckEmailOutput; // JSON -} - export const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL as string, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string ); -export async function getActiveProductsWithPrices(): Promise< - SupabaseProductWithPrice[] +export async function getActiveProductWithPrices(): Promise< + ProductWithPrice[] > { const { data, error } = await supabase - .from("products") + .from("products") .select("*, prices(*)") .eq("active", true) // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -96,13 +47,11 @@ export function updateUserName( // Get the api calls of a user in the past month. export async function getApiUsageClient( - user: User, - subscription: Tables<"subscriptions"> | undefined + subscription: Tables<"subscriptions"> | null ): Promise { const { error, count } = await supabase - .from("calls") + .from>("calls") .select("*", { count: "exact" }) - .eq("user_id", user.id) .gt("created_at", getUsageStartDate(subscription).toISOString()); if (error) { @@ -117,7 +66,7 @@ export async function getApiUsageClient( // date. // - If not, then it's 1 month rolling. export function getUsageStartDate( - subscription: Tables<"subscriptions"> | undefined + subscription: Tables<"subscriptions"> | null ): Date { if (!subscription) { return subMonths(new Date(), 1); diff --git a/src/util/useDatabase.ts b/src/util/useDatabase.ts index 7ffa960f..e1896036 100644 --- a/src/util/useDatabase.ts +++ b/src/util/useDatabase.ts @@ -3,11 +3,6 @@ import type { Stripe } from "stripe"; import { toDateTime } from "./helpers"; import { stripe } from "./stripeServer"; -import type { - SupabaseCustomer, - SupabasePrice, - SupabaseProduct, -} from "./supabaseClient"; import { supabaseAdmin } from "./supabaseServer"; import { Tables } from "@/supabase/database.types"; @@ -17,7 +12,7 @@ import { Tables } from "@/supabase/database.types"; export const upsertProductRecord = async ( product: Stripe.Product ): Promise => { - const productData: SupabaseProduct = { + const productData: Tables<"products"> = { id: product.id, active: product.active, name: product.name, @@ -33,7 +28,7 @@ export const upsertProductRecord = async ( }; export const upsertPriceRecord = async (price: Stripe.Price): Promise => { - const priceData: SupabasePrice = { + const priceData: Tables<"prices"> = { id: price.id, product_id: typeof price.product === "string" @@ -59,7 +54,7 @@ export const upsertPriceRecord = async (price: Stripe.Price): Promise => { export const createOrRetrieveCustomer = async (user: User): Promise => { const { email, id: uuid } = user; const { data, error } = await supabaseAdmin - .from("customers") + .from>("customers") .select("stripe_customer_id") .eq("id", uuid) .single(); @@ -86,8 +81,9 @@ export const createOrRetrieveCustomer = async (user: User): Promise => { return customer.id; } - if (!data) + if (!data?.stripe_customer_id) { throw new Error("No data retrieved in createOrRetrieveCustomer."); + } return data.stripe_customer_id; }; @@ -129,7 +125,7 @@ export const manageSubscriptionStatusChange = async ( ): Promise => { // Get customer's UUID from mapping table. const { data, error: noCustomerError } = await supabaseAdmin - .from("customers") + .from>("customers") .select("id") .eq("stripe_customer_id", customerId) .single(); diff --git a/src/util/useUser.tsx b/src/util/useUser.tsx index 0a400f40..732e2d64 100644 --- a/src/util/useUser.tsx +++ b/src/util/useUser.tsx @@ -94,8 +94,7 @@ export const UserContextProvider = ( supabase .from("subscriptions") .select("*, prices(*, products(*))") - .in("status", ["trialing", "active", "past_due"]) - .eq("cancel_at_period_end", false); + .in("status", ["trialing", "active", "past_due"]); useEffect(() => { if (user) { Promise.all([getUserDetails(), getSubscription()]) diff --git a/supabase/migrations/20231212153919_timezone.sql b/supabase/migrations/20231212153919_timezone.sql new file mode 100644 index 00000000..b272d41f --- /dev/null +++ b/supabase/migrations/20231212153919_timezone.sql @@ -0,0 +1,41 @@ +DROP VIEW sub_and_calls; + +ALTER TABLE bulk_jobs ALTER COLUMN created_at + TYPE timestamp with time zone; +ALTER TABLE bulk_jobs ALTER COLUMN created_at + SET DEFAULT timezone('utc'::text, now()); +ALTER TABLE bulk_emails ALTER COLUMN created_at + TYPE timestamp with time zone; +ALTER TABLE bulk_jobs ALTER COLUMN created_at + SET DEFAULT timezone('utc'::text, now()); +ALTER TABLE calls ALTER COLUMN created_at + TYPE timestamp with time zone; +ALTER TABLE bulk_jobs ALTER COLUMN created_at + SET DEFAULT timezone('utc'::text, now()); + +CREATE VIEW sub_and_calls +AS + SELECT + u.id as user_id, + s.id AS subscription_id, + s.current_period_start, + s.current_period_end, + COUNT(c.id) AS number_of_calls, + pro.id as product_id + FROM + users u + LEFT JOIN + subscriptions s ON u.id = s.user_id AND s.status = 'active' + LEFT JOIN + prices pri ON pri.id = s.price_id + LEFT JOIN + products pro on pro.id = pri.product_id + LEFT JOIN + calls c ON u.id = c.user_id + AND ( + (s.current_period_start IS NOT NULL AND s.current_period_end IS NOT NULL AND c.created_at BETWEEN s.current_period_start AND s.current_period_end) + OR + (c.created_at >= NOW() - INTERVAL '1 MONTH') + ) + GROUP BY + u.id, s.id, s.current_period_end, pro.id; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 73585c86..72df69c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/types/**/*.ts", "scripts/*.js" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", ".next"] }