diff --git a/actions/billingPortal.ts b/actions/billingPortal.ts index 146fd24..52532a3 100644 --- a/actions/billingPortal.ts +++ b/actions/billingPortal.ts @@ -21,15 +21,15 @@ import { logger } from "@/lib/logger" export async function updatePaymentMethodViaBillingPortal() { let url: string try { - const user = await currentUser.customerId() - if (!user?.customerId) { + const customerId = await currentUser.customerId() + if (!customerId) { throw new Error( "An error occurred while creating a billing portal session" ) } url = await billingPortal.sessions.createURL({ - customer: user.customerId, + customer: customerId, return_url: `${baseUrl}/account`, flow_data: { type: "payment_method_update", @@ -52,24 +52,21 @@ export async function updatePaymentMethodViaBillingPortal() { export async function cancelSubscriptionViaBillingPortal() { let url: string try { - const userWithCustomerId = await currentUser.customerId() - const userWithSubscriptionId = await currentUser.subscriptionId() - if ( - !userWithCustomerId?.customerId || - !userWithSubscriptionId?.subscriptionId - ) { + const customerId = await currentUser.customerId() + const subscriptionId = await currentUser.subscriptionId() + if (!customerId || !subscriptionId) { throw new Error( "An error occurred while creating a billing portal session" ) } url = await billingPortal.sessions.createURL({ - customer: userWithCustomerId.customerId, + customer: customerId, return_url: `${baseUrl}/account`, flow_data: { type: "subscription_cancel", subscription_cancel: { - subscription: userWithSubscriptionId.subscriptionId, + subscription: subscriptionId, }, after_completion: { type: "redirect", @@ -90,24 +87,21 @@ export async function cancelSubscriptionViaBillingPortal() { export async function updateSubscriptionViaBillingPortal() { let url: string try { - const userWithCustomerId = await currentUser.customerId() - const userWithSubscriptionId = await currentUser.subscriptionId() - if ( - !userWithCustomerId?.customerId || - !userWithSubscriptionId?.subscriptionId - ) { + const customerId = await currentUser.customerId() + const subscriptionId = await currentUser.subscriptionId() + if (!customerId || !subscriptionId) { throw new Error( "An error occurred while creating a billing portal session" ) } url = await billingPortal.sessions.createURL({ - customer: userWithCustomerId.customerId, + customer: customerId, return_url: `${baseUrl}/account`, flow_data: { type: "subscription_update", subscription_update: { - subscription: userWithSubscriptionId.subscriptionId, + subscription: subscriptionId, }, after_completion: { type: "redirect", diff --git a/app/(app)/account/_components/invoice-card.tsx b/app/(app)/account/_components/invoice-card.tsx index 7182b02..e36bdea 100644 --- a/app/(app)/account/_components/invoice-card.tsx +++ b/app/(app)/account/_components/invoice-card.tsx @@ -1,17 +1,8 @@ import { Suspense } from "react" -import Link from "next/link" -import { - ChevronLeftIcon, - ChevronRightIcon, - ChevronsLeftIcon, - ChevronsRightIcon, - ExternalLink, -} from "lucide-react" +import { ExternalLink } from "lucide-react" import { currentUser } from "@/services/currentUser" -import { invoicesLimit } from "@/lib/constants" -import { centsToCurrency, cn } from "@/lib/utils" +import { centsToCurrency } from "@/lib/utils" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" import { Card, CardContent, @@ -31,6 +22,8 @@ import { } from "@/components/ui/table" import { ErrorBoundary } from "@/components/error-boundary" import { LinkExternal } from "@/components/link-external" +import { Paginator } from "./paginator" +import { PaginatorProvider } from "./paginator-provider" function InvoicesErrorFallback() { return ( @@ -59,10 +52,14 @@ function InvoicesSkeleton() { ) } -async function LoadInvoiceTableRows({ page }: Readonly<{ page: number }>) { - const user = await currentUser.invoices({ page }) +async function LoadInvoiceTableRows({ + dataPromise, +}: Readonly<{ + dataPromise: Promise> | null> +}>) { + const invoices = await dataPromise - if (!user?.invoices || user.invoices.length === 0) { + if (!invoices?.data || invoices.data.length === 0) { return ( @@ -76,21 +73,23 @@ async function LoadInvoiceTableRows({ page }: Readonly<{ page: number }>) { return ( - {user.invoices.map(invoice => ( + {invoices.data.map(invoice => (
- {invoice.invoiceNumber} + {invoice.number}
- {invoice.created.toLocaleDateString()} - {centsToCurrency(invoice.amountPaid)} + + {new Date(invoice.created).toLocaleDateString()} + + {centsToCurrency(invoice.amount_paid)} {invoice.status} @@ -100,104 +99,8 @@ async function LoadInvoiceTableRows({ page }: Readonly<{ page: number }>) { ) } -async function LoadPagination({ page }: Readonly<{ page: number }>) { - const user = await currentUser.invoicesTotal() - - if (!user?.invoicesTotal) { - return null - } - - const totalPages = Math.ceil(user.invoicesTotal / invoicesLimit) - const firstPageDisabled = page === 1 - const lastPageDisabled = totalPages === page - const previousPageDisabled = page === 1 - const nextPageDisabled = totalPages === page - const nextPage = page + 1 - const previousPage = page - 1 - - if (totalPages <= 1) { - return null - } - - return ( -
- - - - -
- ) -} - -export function InvoiceCard({ page }: Readonly<{ page: number }>) { +export function InvoiceCard({ cursor }: Readonly<{ cursor: string }>) { + const invoicesPromise = currentUser.invoices({ cursor }) return ( @@ -216,7 +119,7 @@ export function InvoiceCard({ page }: Readonly<{ page: number }>) { }> }> - + @@ -224,7 +127,9 @@ export function InvoiceCard({ page }: Readonly<{ page: number }>) { - + + + diff --git a/app/(app)/account/_components/paginator-provider.tsx b/app/(app)/account/_components/paginator-provider.tsx new file mode 100644 index 0000000..bd14fef --- /dev/null +++ b/app/(app)/account/_components/paginator-provider.tsx @@ -0,0 +1,113 @@ +"use client" + +import { + createContext, + ReactNode, + use, + useEffect, + useState, + useTransition, +} from "react" +import { useRouter, useSearchParams } from "next/navigation" + +interface GenericData { + id: string +} + +export interface PaginatorContextInterface { + showPaginator: boolean + prevPageDisabled: boolean + nextPageDisabled: boolean + handleNextPage: () => void + handlePrevPage: () => void + isPending: boolean + isPendingNext: boolean + isPendingPrev: boolean +} + +export const PaginatorContext = createContext( + null +) + +export function PaginatorProvider({ + children, + responseToPaginatePromise, +}: Readonly<{ + children: ReactNode + responseToPaginatePromise: Promise<{ data: T[]; hasMore: boolean } | null> +}>) { + const responseToPaginate = use(responseToPaginatePromise) + const router = useRouter() + const searchParams = useSearchParams() + const [hasMore, setHasMore] = useState(true) + const [prevCursors, setPrevCursors] = useState([]) + const [isPending, startTransition] = useTransition() + const [isPendingNext, setIsPendingNext] = useState(false) + const [isPendingPrev, setIsPendingPrev] = useState(false) + const cursor = searchParams.get("cursor") + + useEffect(() => { + if (cursor) { + setPrevCursors(prev => [...prev, cursor]) + } else { + setPrevCursors([]) + } + + setHasMore(responseToPaginate?.hasMore || false) + }, [cursor]) + + const handleNextPage = () => { + if (responseToPaginate?.data.length) { + setIsPendingNext(true) + startTransition(() => { + const lastCustomerId = + responseToPaginate.data[responseToPaginate.data.length - 1].id + const params = new URLSearchParams(searchParams) + params.set("cursor", lastCustomerId) + router.push(`?${params.toString()}`) + setIsPendingNext(false) + }) + } + } + + const handlePrevPage = () => { + if (prevCursors.length > 0) { + setIsPendingPrev(true) + startTransition(() => { + const newPrevCursors = [...prevCursors] + const prevCursor = newPrevCursors.pop() // Remove the current cursor + setPrevCursors(newPrevCursors) + + const params = new URLSearchParams(searchParams) + if (prevCursor && newPrevCursors.length > 0) { + params.set("cursor", newPrevCursors[newPrevCursors.length - 1]) + } else { + params.delete("cursor") + } + router.push(`?${params.toString()}`) + setIsPendingPrev(false) + }) + } + } + + const prevPageDisabled = prevCursors.length === 0 + const nextPageDisabled = !hasMore + const showPaginator = !prevPageDisabled && !nextPageDisabled + + return ( + + {children} + + ) +} diff --git a/app/(app)/account/_components/paginator.tsx b/app/(app)/account/_components/paginator.tsx new file mode 100644 index 0000000..4117d9a --- /dev/null +++ b/app/(app)/account/_components/paginator.tsx @@ -0,0 +1,58 @@ +"use client" + +import { ChevronLeftIcon, ChevronRightIcon, LoaderCircle } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { usePaginator } from "./use-paginator" + +export function Paginator() { + const { + showPaginator, + prevPageDisabled, + nextPageDisabled, + handlePrevPage, + handleNextPage, + isPending, + isPendingPrev, + isPendingNext, + } = usePaginator() + + if (!showPaginator) return null + + return ( +
+ + +
+ ) +} diff --git a/app/(app)/account/_components/payment-method-card.tsx b/app/(app)/account/_components/payment-method-card.tsx index 56a3663..2d0c986 100644 --- a/app/(app)/account/_components/payment-method-card.tsx +++ b/app/(app)/account/_components/payment-method-card.tsx @@ -61,15 +61,14 @@ function NoPaymentMethod() { } async function LoadPaymentMethodCard() { - const user = await currentUser.paymentMethods({ + const paymentMethods = await currentUser.paymentMethods({ limit: 1, }) - - if (!user?.paymentMethods || user.paymentMethods.length === 0) { + if (!paymentMethods || !paymentMethods?.[0]) { return } - const { brand, expMonth, expYear, last4 } = user.paymentMethods[0] + const { brand, expMonth, expYear, last4 } = paymentMethods[0] return ( <> diff --git a/app/(app)/account/_components/subscription-plan-card.tsx b/app/(app)/account/_components/subscription-plan-card.tsx index 11d4777..ef42205 100644 --- a/app/(app)/account/_components/subscription-plan-card.tsx +++ b/app/(app)/account/_components/subscription-plan-card.tsx @@ -75,12 +75,11 @@ function NoSubscriptionPlan() { } async function LoadSubscriptionPlanCard() { - const user = await currentUser.subscriptions({ limit: 1 }) - - if (!user?.subscriptions || user.subscriptions.length === 0) { + const subscriptions = await currentUser.subscriptions({ limit: 1 }) + if (!subscriptions || !subscriptions?.[0]) { return } - const { price, currentPeriodEnd, cancelAtPeriodEnd } = user.subscriptions[0] + const { price, currentPeriodEnd, cancelAtPeriodEnd } = subscriptions[0] return ( <> diff --git a/app/(app)/account/_components/use-paginator.tsx b/app/(app)/account/_components/use-paginator.tsx new file mode 100644 index 0000000..b80c7e8 --- /dev/null +++ b/app/(app)/account/_components/use-paginator.tsx @@ -0,0 +1,15 @@ +import { useContext } from "react" +import { + PaginatorContext, + PaginatorContextInterface, +} from "./paginator-provider" + +export function usePaginator() { + const context = useContext(PaginatorContext) as PaginatorContextInterface + + if (context === undefined) { + throw new Error("usePaginator must be used inside the PaginatorProvider") + } + + return context +} diff --git a/app/(app)/account/page.tsx b/app/(app)/account/page.tsx index ae2d3a0..9f55a4d 100644 --- a/app/(app)/account/page.tsx +++ b/app/(app)/account/page.tsx @@ -15,15 +15,14 @@ export const metadata = { } export default async function AccountPage(props: { - searchParams: Promise<{ page: string }> + searchParams: Promise<{ cursor: string }> }) { const searchParams = await props.searchParams - const { page } = searchParams - const pageNumber: number = page ? parseInt(page) : 1 + const { cursor } = searchParams // Preload data for the account page currentUser.paymentMethods({ limit: 1 }) currentUser.subscriptions({ limit: 1 }) - currentUser.invoices({ page: pageNumber }) + currentUser.invoices({ cursor }) return (
@@ -41,7 +40,7 @@ export default async function AccountPage(props: {
- +
diff --git a/app/(app)/dashboard/_components/recent-transactions-card.tsx b/app/(app)/dashboard/_components/recent-transactions-card.tsx index 0e68657..e71df0e 100644 --- a/app/(app)/dashboard/_components/recent-transactions-card.tsx +++ b/app/(app)/dashboard/_components/recent-transactions-card.tsx @@ -39,9 +39,9 @@ async function LoadRecentTransactions() { {recentTransactions.map(transaction => ( - {transaction.user.email} + {transaction.customer_email} - ${transaction.amountPaid / 100} + ${transaction.amount_paid / 100} {transaction.status} ))} diff --git a/components/boundaries.tsx b/components/boundaries.tsx index 7cd6023..1351727 100644 --- a/components/boundaries.tsx +++ b/components/boundaries.tsx @@ -88,13 +88,13 @@ async function LoadSubscriptionCanceledBoundary({ children: React.ReactNode alternate?: React.ReactNode | string }>) { - const user = await currentUser.subscriptions({ limit: 1 }) + const subscriptions = await currentUser.subscriptions({ limit: 1 }) - if (!user?.subscriptions || user.subscriptions.length === 0) { + if (!subscriptions || subscriptions.length === 0) { return null } - const { cancelAtPeriodEnd } = user.subscriptions[0] + const { cancelAtPeriodEnd } = subscriptions[0] if (cancelAtPeriodEnd) { return alternate ? <>{alternate} : null diff --git a/drizzle/0002_wealthy_talon.sql b/drizzle/0002_wealthy_talon.sql new file mode 100644 index 0000000..a8b88fe --- /dev/null +++ b/drizzle/0002_wealthy_talon.sql @@ -0,0 +1 @@ +DROP TABLE "invoice"; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..9998b73 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1005 @@ +{ + "id": "d3de76f8-00ce-4dd2-89a1-c7d1d195be9f", + "prevId": "ae1ce55a-28d7-42cf-889e-2cd6fb660542", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.authenticator": { + "name": "authenticator", + "schema": "", + "columns": { + "credentialID": { + "name": "credentialID", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentialPublicKey": { + "name": "credentialPublicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credentialDeviceType": { + "name": "credentialDeviceType", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credentialBackedUp": { + "name": "credentialBackedUp", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "authenticator_userId_user_id_fk": { + "name": "authenticator_userId_user_id_fk", + "tableFrom": "authenticator", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authenticator_userId_credentialID_pk": { + "name": "authenticator_userId_credentialID_pk", + "columns": [ + "userId", + "credentialID" + ] + } + }, + "uniqueConstraints": { + "authenticator_credentialID_unique": { + "name": "authenticator_credentialID_unique", + "nullsNotDistinct": false, + "columns": [ + "credentialID" + ] + } + } + }, + "public.checkoutSession": { + "name": "checkoutSession", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "amountTotal": { + "name": "amountTotal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amountSubtotal": { + "name": "amountSubtotal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "mode": { + "name": "mode", + "type": "checkoutSessionMode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "paymentIntentId": { + "name": "paymentIntentId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paymentStatus": { + "name": "paymentStatus", + "type": "checkoutSessionPaymentStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "checkoutSessionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "checkoutSession_paymentIntentId_paymentIntent_id_fk": { + "name": "checkoutSession_paymentIntentId_paymentIntent_id_fk", + "tableFrom": "checkoutSession", + "tableTo": "paymentIntent", + "columnsFrom": [ + "paymentIntentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkoutSession_subscriptionId_subscription_id_fk": { + "name": "checkoutSession_subscriptionId_subscription_id_fk", + "tableFrom": "checkoutSession", + "tableTo": "subscription", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkoutSession_userId_user_id_fk": { + "name": "checkoutSession_userId_user_id_fk", + "tableFrom": "checkoutSession", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkoutSession_priceId_price_id_fk": { + "name": "checkoutSession_priceId_price_id_fk", + "tableFrom": "checkoutSession", + "tableTo": "price", + "columnsFrom": [ + "priceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.customer": { + "name": "customer", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "customer_userId_user_id_fk": { + "name": "customer_userId_user_id_fk", + "tableFrom": "customer", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "customer_userId_stripeCustomerId_pk": { + "name": "customer_userId_stripeCustomerId_pk", + "columns": [ + "userId", + "stripeCustomerId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.paymentIntent": { + "name": "paymentIntent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "paymentIntentStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paymentIntent_userId_user_id_fk": { + "name": "paymentIntent_userId_user_id_fk", + "tableFrom": "paymentIntent", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.paymentMethod": { + "name": "paymentMethod", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expMonth": { + "name": "expMonth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expYear": { + "name": "expYear", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "paymentMethod_userId_user_id_fk": { + "name": "paymentMethod_userId_user_id_fk", + "tableFrom": "paymentMethod", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.price": { + "name": "price", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "pricingPlanInterval", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "intervalCount": { + "name": "intervalCount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "productId": { + "name": "productId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "pricingType", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "unitAmount": { + "name": "unitAmount", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "price_productId_product_id_fk": { + "name": "price_productId_product_id_fk", + "tableFrom": "price", + "tableTo": "product", + "columnsFrom": [ + "productId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "marketingFeatures": { + "name": "marketingFeatures", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cancelAt": { + "name": "cancelAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceledAt": { + "name": "canceledAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelAtPeriodEnd": { + "name": "cancelAtPeriodEnd", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created": { + "name": "created", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "currentPeriodEnd": { + "name": "currentPeriodEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "currentPeriodStart": { + "name": "currentPeriodStart", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endedAt": { + "name": "endedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "priceId": { + "name": "priceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "subscriptionStatus", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trialEnd": { + "name": "trialEnd", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trialStart": { + "name": "trialStart", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "subscription_userId_user_id_fk": { + "name": "subscription_userId_user_id_fk", + "tableFrom": "subscription", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscription_priceId_price_id_fk": { + "name": "subscription_priceId_price_id_fk", + "tableFrom": "subscription", + "tableTo": "price", + "columnsFrom": [ + "priceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": { + "public.checkoutSessionMode": { + "name": "checkoutSessionMode", + "schema": "public", + "values": [ + "payment", + "subscription", + "setup" + ] + }, + "public.checkoutSessionPaymentStatus": { + "name": "checkoutSessionPaymentStatus", + "schema": "public", + "values": [ + "unpaid", + "paid", + "no_payment_required" + ] + }, + "public.checkoutSessionStatus": { + "name": "checkoutSessionStatus", + "schema": "public", + "values": [ + "open", + "complete", + "expired" + ] + }, + "public.paymentIntentStatus": { + "name": "paymentIntentStatus", + "schema": "public", + "values": [ + "requires_payment_method", + "requires_confirmation", + "requires_action", + "processing", + "requires_capture", + "canceled", + "succeeded" + ] + }, + "public.pricingPlanInterval": { + "name": "pricingPlanInterval", + "schema": "public", + "values": [ + "day", + "week", + "month", + "year" + ] + }, + "public.pricingType": { + "name": "pricingType", + "schema": "public", + "values": [ + "recurring", + "one_time" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "user" + ] + }, + "public.subscriptionStatus": { + "name": "subscriptionStatus", + "schema": "public", + "values": [ + "trialing", + "active", + "canceled", + "incomplete", + "incomplete_expired", + "past_due", + "unpaid", + "paused" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e289fe1..c137161 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1728652883077, "tag": "0001_gifted_hex", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1730053200053, + "tag": "0002_wealthy_talon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 6aea2ff..771dcd8 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -116,16 +116,6 @@ export const paymentIntentStatusEnum = pgEnum( paymentIntentStatuses ) -const invoiceStatuses = [ - "draft", - "open", - "paid", - "uncollectible", - "void", -] as const -export type InvoiceStatus = (typeof invoiceStatuses)[number] -export const invoiceStatusEnum = pgEnum("invoiceStatus", invoiceStatuses) - export const users = pgTable("user", { id: text("id") .primaryKey() @@ -361,32 +351,6 @@ export const paymentMethods = pgTable("paymentMethod", { .references(() => users.id), }) -// see: https://docs.stripe.com/api/invoices/object -export const invoices = pgTable("invoice", { - // stripe id (in_123) - id: text("id").primaryKey().notNull(), - amountDue: integer("amountDue").notNull(), - amountPaid: integer("amountPaid").notNull(), - amountRemaining: integer("amountRemaining").notNull(), - created: timestamp("created", { mode: "date" }).notNull().defaultNow(), - hostedInvoiceUrl: text("hostedInvoiceUrl"), - invoiceNumber: text("invoiceNumber"), - invoicePdf: text("invoicePdf"), - paymentIntentId: text("paymentIntentId").references(() => paymentIntents.id), - status: invoiceStatusEnum("status").notNull(), - subscriptionId: text("subscriptionId").references(() => subscriptions.id), - userId: text("userId") - .notNull() - .references(() => users.id), -}) - -export const invoicesRelations = relations(invoices, ({ one }) => ({ - user: one(users, { - fields: [invoices.userId], - references: [users.id], - }), -})) - // Product types export type Product = InferSelectModel export type NewProduct = InferInsertModel @@ -406,9 +370,6 @@ export type SubscriptionPrice = InferSelectModel & { price: Price } -// Invoice types -export type Invoice = InferSelectModel - // Metadata types export type Metadata = { [key: string]: string diff --git a/services/analytic.ts b/services/analytic.ts index 317f1ec..18b1c36 100644 --- a/services/analytic.ts +++ b/services/analytic.ts @@ -203,7 +203,7 @@ export const analytic = { gross: cache(async (range: { from: Date; to: Date }) => { const invoices = await invoice.list({ range }) const gross = invoices.reduce((acc, invoice) => { - return acc + invoice.amountPaid + return acc + invoice.amount_paid }, 0) // convert from cents to dollars with 2 decimal places diff --git a/services/checkout.ts b/services/checkout.ts index 33052d3..9353a96 100644 --- a/services/checkout.ts +++ b/services/checkout.ts @@ -55,7 +55,8 @@ export const checkout = { async create({ priceId }: { priceId: string }) { try { // 0. get the current user with the customer id if it exists - const userWithCustomerId = await currentUser.customerId() + const customerId = await currentUser.customerId() + const email = (await currentUser())?.email // 1. Make sure the price exists, and is active const priceDetails = await price.get({ priceId }) @@ -69,7 +70,7 @@ export const checkout = { // 2.a if the mode is subscription, make sure we have a user, if not redirect to the sign in page // subscriptions require a currentUser - if (mode === "subscription" && !userWithCustomerId?.email) { + if (mode === "subscription" && !email) { return { status: "requiresSession", clientSecret: null, @@ -79,23 +80,23 @@ export const checkout = { // 2.b check to see if the current user has a customerId, if not create a customer in stripe if mode is subscription let stripeCustomerId: string | undefined if (mode === "subscription") { - if (!userWithCustomerId?.customerId) { + if (!customerId) { // we should have a user by now, if not throw an error - if (!userWithCustomerId?.email) { + if (!email) { throw new Error("User with an email is required for subscription") } // create a new customer in stripe const customer = await stripe.customers.create({ - email: userWithCustomerId.email, + email, }) stripeCustomerId = customer.id // new customer id } else { - stripeCustomerId = userWithCustomerId.customerId // existing customer id + stripeCustomerId = customerId // existing customer id } } else { - stripeCustomerId = userWithCustomerId?.customerId ?? undefined // existing customer id if we have one + stripeCustomerId = customerId ?? undefined // existing customer id if we have one } // 3. Create a checkout session with stripe diff --git a/services/currentUser.ts b/services/currentUser.ts index 47e2853..4ea66b1 100644 --- a/services/currentUser.ts +++ b/services/currentUser.ts @@ -1,13 +1,12 @@ import "server-only" import { cache } from "react" +import { unstable_cache as ioCache } from "next/cache" import { and, eq, inArray } from "drizzle-orm" import { Session } from "next-auth" +import Stripe from "stripe" import { db } from "@/drizzle/db" import { checkoutSessions, - countAsNumber, - Invoice, - invoices, PaymentMethod, prices, products, @@ -16,21 +15,26 @@ import { import { auth } from "@/auth" import { invoicesLimit } from "@/lib/constants" import { logger } from "@/lib/logger" +import { stripe } from "@/lib/stripe" type PaginationParams = { page?: number limit?: number } -type InvoiceParams = { page: number; userId: string } +type InvoiceParams = { + cursor: string + userId: string +} type PaymentMethodParams = PaginationParams & { userId: string } type SubscriptionParams = PaginationParams & { userId: string } interface CurrentUserService { customerId: (params: { userId: string }) => Promise subscriptionId: (params: { userId: string }) => Promise - invoices: (params: InvoiceParams) => Promise - invoicesTotal: (params: { userId: string }) => Promise + invoices: ( + params: InvoiceParams + ) => Promise<{ data: Stripe.Invoice[]; hasMore: boolean } | null> paymentMethods: (params: PaymentMethodParams) => Promise subscriptions: (params: SubscriptionParams) => Promise hasPurchasedProduct: (params: { @@ -55,9 +59,7 @@ interface CurrentUser { * * @returns {Promise} - The customer id for the user. */ - customerId: () => Promise< - (Session["user"] & { customerId: string | null }) | null - > + customerId: () => Promise /** * SubscriptionId * @@ -65,36 +67,18 @@ interface CurrentUser { * * @returns {Promise} - The subscription id for the user. */ - subscriptionId: () => Promise< - (Session["user"] & { subscriptionId: string | null }) | null - > + subscriptionId: () => Promise /** * Invoices * * This function is used to get the invoices for a user. * - * @param {number} page - The page number to get. - * @returns {Promise | null>}> - The invoices for the user. - */ - invoices: (params: { page: number }) => Promise< - Session["user"] & { - invoices: Awaited | null> - } - > - /** - * InvoicesTotal - * - * This function is used to get the total number of invoices for a user. - * - * @returns {Promise | null>}> - The total number of invoices for the user. + * @param {string} startingAfter - The starting after object id for pagination. + * @returns {Promise> | null>} - The invoices for the user. */ - invoicesTotal: () => Promise< - Session["user"] & { - invoicesTotal: Awaited | null> - } - > + invoices: ( + params: Omit + ) => Promise> | null> /** * PaymentMethods * @@ -102,15 +86,13 @@ interface CurrentUser { * * @param {number} page - The page number to get. * @param {number} limit - The number of payment methods to get. - * @returns {Promise | null>} - The payment methods for the user. + * @returns {Promise> | null>} - The payment methods for the user. */ - paymentMethods: (params: PaginationParams) => Promise< - Session["user"] & { - paymentMethods: Awaited | null> - } - > + paymentMethods: ( + params: PaginationParams + ) => Promise + > | null> /** * Subscriptions * @@ -118,33 +100,29 @@ interface CurrentUser { * * @param {number} page - The page number to get. * @param {number} limit - The number of subscriptions to get. - * @returns {Promise | null>} - The subscriptions for the user. + * @returns {Promise> | null>} - The subscriptions for the user. */ - subscriptions: (params: PaginationParams) => Promise< - Session["user"] & { - subscriptions: Awaited | null> - } - > + subscriptions: ( + params: PaginationParams + ) => Promise + > | null> /** * HasPurchasedProduct * * This function is used to check if a user has purchased a product. * * @param {string[]} productIds - The ids of the products to check. - * @returns {Promise | null>}> - Whether the user has purchased the product. + * @returns {Promise> | null> - Whether the user has purchased the product. */ - hasPurchasedProduct: (params: { productIds: string[] }) => Promise< - Session["user"] & { - hasPurchasedProduct: Awaited | null> - } - > + hasPurchasedProduct: (params: { + productIds: string[] + }) => Promise + > | null> } -const currentUserService: CurrentUserService = { +export const currentUserService: CurrentUserService = { /** * CustomerId * @@ -181,35 +159,38 @@ const currentUserService: CurrentUserService = { * This function is used to get the invoices for a user. * * @param {string} userId - The id of the user. - * @param {number} page - The page number to get. + * @param {string} startingAfter - The starting after object id for pagination. * @returns {Promise} - The invoices for the user. */ - invoices: cache(async ({ page = 1, userId }: InvoiceParams) => { - return await db.query.invoices.findMany({ - where: (invoices, { eq }) => eq(invoices.userId, userId), - orderBy: (invoices, { desc }) => desc(invoices.created), - limit: invoicesLimit, - offset: (page - 1) * invoicesLimit, - }) - }), - /** - * InvoicesTotal - * - * This function is used to get the total number of invoices for a user. - * - * @param {string} userId - The id of the user. - * @returns {Promise} - The total number of invoices for the user. - */ - invoicesTotal: cache(async ({ userId }: { userId: string }) => { - return ( - ( - await db - .select({ count: countAsNumber() }) - .from(invoices) - .where(eq(invoices.userId, userId)) - )[0].count ?? 0 - ) - }), + invoices: ({ cursor, userId }: InvoiceParams) => + ioCache( + async () => { + const customerId = await currentUserService.customerId({ userId }) + if (!customerId) { + return null + } + + const params: Stripe.InvoiceListParams = { + customer: customerId, + limit: invoicesLimit, + } + + if (cursor) { + params.starting_after = cursor + } + + const invoices = await stripe.invoices.list(params) + return { + data: invoices.data, + hasMore: invoices.has_more, + } + }, + [userId], + { + tags: ["invoices", `invoices:${userId}`], + revalidate: 60 * 60 * 24 * 30, // 30 days + } + )(), /** * PaymentMethods * @@ -324,7 +305,7 @@ const createCurrentUserServiceProxy = ( augmentedArgs ) - return { ...user, [String(prop)]: result } + return result } } diff --git a/services/event.ts b/services/event.ts index fc18551..f3d4f8d 100644 --- a/services/event.ts +++ b/services/event.ts @@ -1,7 +1,10 @@ import "server-only" +import { revalidateTag } from "next/cache" +import { eq } from "drizzle-orm" import Stripe from "stripe" +import { db } from "@/drizzle/db" +import { customers } from "@/drizzle/schema" import { checkout } from "./checkout" -import { invoice } from "./invoice" import { paymentMethod } from "./paymentMethod" import { price } from "./price" import { product } from "./product" @@ -13,8 +16,8 @@ import { subscription } from "./subscription" * Handles Stripe webhook events for various operations: * - Checkout session completion * - Subscription updates and deletions + * - Invoice creation, finalization, and payment * - Payment method attachments - * - Invoice finalization and payments * - Product and price creation, updates, and deletions * @link https://docs.stripe.com/api/events **/ @@ -51,7 +54,14 @@ export const event = { case "invoice.paid": case "invoice.created": const eventInvoice = constructedEvent.data.object as Stripe.Invoice - await invoice.upsert({ invoiceId: eventInvoice.id }) + const customerId = eventInvoice.customer as string + const customer = await db.query.customers.findFirst({ + where: eq(customers.stripeCustomerId, customerId), + }) + + if (customer?.userId) { + revalidateTag(`invoices:${customer.userId}`) + } break case "product.created": case "product.updated": diff --git a/services/invoice.ts b/services/invoice.ts index 6303f2f..0c22937 100644 --- a/services/invoice.ts +++ b/services/invoice.ts @@ -1,8 +1,5 @@ import "server-only" -import { cache } from "react" -import { db } from "@/drizzle/db" -import { Invoice, invoices } from "@/drizzle/schema" -import { logger } from "@/lib/logger" +import { unstable_cache as ioCache } from "next/cache" import { stripe } from "@/lib/stripe" /** @@ -12,125 +9,6 @@ import { stripe } from "@/lib/stripe" * **/ export const invoice = { - /** - * Upsert - * - * This function is used to upsert an invoice. - * - * @param {string} invoiceId - The id of the invoice to upsert. - * @param {number} retryCount - The number of times to retry the upsert (used internally for retries). - * @param {number} maxRetries - The maximum number of retries to attempt (used internally for retries). - * @link https://docs.stripe.com/api/events/types#event_types-invoice.paid - * @link https://docs.stripe.com/api/events/types#event_types-invoice.finalized - **/ - async upsert({ - invoiceId, - retryCount = 0, - maxRetries = 3, - }: { - invoiceId: string - retryCount?: number - maxRetries?: number - }) { - try { - // Stripe events can happen out of order, so we always retrieve the latest data from the API to ensure we have - // the most up-to-date state - const data = await stripe.invoices.retrieve(invoiceId) - - // Check if the customer ID is a string, if not, get the ID from the object - const customerId = - typeof data.customer === "string" ? data.customer : data.customer?.id - - // If the customer ID is not found, log an error and return - if (!customerId) { - logger.error("[invoice][upsert] Customer ID not found") - return - } - - // Check if we have the customer yet - const existingCustomer = await db.query.customers.findFirst({ - where: (customers, { eq }) => - eq(customers.stripeCustomerId, customerId), - }) - - // Check if we have an existing payment intent, if given a payment intent - let existingPaymentIntent = null - if (data.payment_intent) { - const paymentIntentId = - typeof data.payment_intent === "string" - ? data.payment_intent - : data.payment_intent?.id - existingPaymentIntent = await db.query.paymentIntents.findFirst({ - where: (paymentIntents, { eq }) => - eq(paymentIntents.id, paymentIntentId), - }) - } - - // Check if we have an existing subscription, if given a subscription - let existingSubscription = null - if (data.subscription) { - const subscriptionId = - typeof data.subscription === "string" - ? data.subscription - : data.subscription?.id - existingSubscription = await db.query.subscriptions.findFirst({ - where: (subscriptions, { eq }) => - eq(subscriptions.id, subscriptionId), - }) - } - - if (existingCustomer && (existingPaymentIntent || existingSubscription)) { - // Get the userId from the customer - const userId = existingCustomer.userId - - const invoiceData: Invoice = { - id: data.id, - amountDue: data.amount_due, - amountPaid: data.amount_paid, - amountRemaining: data.amount_remaining, - created: new Date(data.created * 1000), - hostedInvoiceUrl: data.hosted_invoice_url ?? null, - invoiceNumber: data.number, - invoicePdf: data.invoice_pdf ?? null, - paymentIntentId: existingPaymentIntent - ? typeof data.payment_intent === "string" - ? data.payment_intent - : (data.payment_intent?.id ?? null) - : null, - status: data.status as Invoice["status"], - subscriptionId: existingSubscription - ? typeof data.subscription === "string" - ? data.subscription - : (data.subscription?.id ?? null) - : null, - userId, - } - - await db - .insert(invoices) - .values(invoiceData) - .onConflictDoUpdate({ target: invoices.id, set: invoiceData }) - - return - } - - if (retryCount < maxRetries) { - logger.info( - "no user / customer found to associate invoice to, retrying in 3 seconds", - { retryCount, maxRetries } - ) - await new Promise(resolve => setTimeout(resolve, 3000)) - await invoice.upsert({ invoiceId, retryCount: retryCount + 1 }) - } else { - logger.info( - "no user / customer found to associate invoice to, max retries reached", - { retryCount, maxRetries } - ) - } - } catch (error) { - logger.error("[invoice][upsert]", { error }) - } - }, /** * List * @@ -140,26 +18,30 @@ export const invoice = { * @param {Date} to - The date to list invoices to. * @returns {Promise} - A promise that resolves to an array of invoices. **/ - list: cache( - async ({ - range, - limit = 10, - }: { - range: { from: Date; to: Date } - limit?: number - }) => { - const invoices = await db.query.invoices.findMany({ - where: (invoice, { eq, and, between }) => - and( - between(invoice.created, range.from, range.to), - eq(invoice.status, "paid") - ), - limit, - with: { - user: true, - }, - }) - return invoices - } - ), + list: ({ + range, + limit = 10, + }: { + range: { from: Date; to: Date } + limit?: number + }) => + ioCache( + async () => { + const invoices = await stripe.invoices.list({ + created: { + gte: Math.floor(range.from.getTime() / 1000), + lte: Math.floor(range.to.getTime() / 1000), + }, + limit, + status: "paid", + expand: ["data.customer"], + }) + return invoices.data + }, + ["invoices"], + { + tags: ["invoices"], + revalidate: 60 * 60 * 3, // 3 hours + } + )(), }