-
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"]
}