diff --git a/packages/app/control/docs/advanced/api-reference.mdx b/packages/app/control/docs/advanced/api-reference.mdx index 665ee50d2..e6b80c14b 100644 --- a/packages/app/control/docs/advanced/api-reference.mdx +++ b/packages/app/control/docs/advanced/api-reference.mdx @@ -65,7 +65,7 @@ List all Echo apps owned by the authenticated user. ``` >["items"][number]`} /> @@ -83,7 +83,7 @@ Get detailed app information for programmatic access. **Response Type:** >`} /> @@ -105,7 +105,7 @@ Get authenticated user's credit balance. **Response Type:** >`} /> @@ -129,8 +129,8 @@ Get user's free tier spending information for a specific app. ``` >["userSpendInfo"]`} + path="src/services/db/user/app-spend-pool.ts" + type={`Awaited>["userSpendInfo"]`} /> ## Payments diff --git a/packages/app/control/eslint.config.mjs b/packages/app/control/eslint.config.mjs index 86de1e3c2..767f3a2d7 100644 --- a/packages/app/control/eslint.config.mjs +++ b/packages/app/control/eslint.config.mjs @@ -112,9 +112,69 @@ export default tseslint.config( }, }, }, + 'no-db-client-outside-db': { + rules: { + 'no-db-client-outside-db': { + meta: { + type: 'problem', + docs: { + description: + 'Disallow @/services/db/client imports outside of @db/ folder', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + noDbClientOutsideDb: + 'Direct imports from @/services/db/client are not allowed outside of the @/services/db/ folder. Create a new service in @/services/db instead.', + }, + }, + create(context) { + const filename = context.getFilename(); + + // Allow imports within the @db/ folder structure + const isDbFolderFile = filename.includes('/services/db/'); + + // Allow imports in the client.ts file itself + const isClientFile = filename.includes('/services/db/client.ts'); + + if (isDbFolderFile || isClientFile) { + return {}; + } + + return { + ImportDeclaration(node) { + // Check if the import source is @/services/db/client or relative imports to services/db/client + if ( + node.source && + node.source.type === 'Literal' && + typeof node.source.value === 'string' + ) { + const importPath = node.source.value; + const isDbClientImport = + importPath === '@/services/db/client' || + importPath.endsWith('/db/client') || + importPath.endsWith('./services/db/client') || + importPath.endsWith('../services/db/client'); + + if (isDbClientImport) { + context.report({ + node, + messageId: 'noDbClientOutsideDb', + }); + } + } + }, + }; + }, + }, + }, + }, }, rules: { 'no-process-env-outside-env/no-process-env-outside-env': 'error', + 'no-db-client-outside-db/no-db-client-outside-db': 'error', }, } ); diff --git a/packages/app/control/scripts/generate-api-types.ts b/packages/app/control/scripts/generate-api-types.ts index 5cac4cdfd..4553acbec 100644 --- a/packages/app/control/scripts/generate-api-types.ts +++ b/packages/app/control/scripts/generate-api-types.ts @@ -201,9 +201,9 @@ import { z } from 'zod'; // Utility type to extract types from route handlers type ExtractRouteHandlerTypes = T extends OriginalRouteHandler ? { - params: TParams extends z.Schema ? z.infer : TParams; - query: TQuery extends z.Schema ? z.infer : TQuery; - body: TBody extends z.Schema ? z.infer : TBody; + params: TParams extends z.Schema ? z.input : TParams; + query: TQuery extends z.Schema ? z.input : TQuery; + body: TBody extends z.Schema ? z.input : TBody; response: TResponse; serverErrorBody: TServerErrorBody; internalErrorBody: TInternalErrorBody; @@ -385,9 +385,9 @@ import { z } from 'zod'; // Utility type to extract types from route handlers type ExtractRouteHandlerTypes = T extends OriginalRouteHandler ? { - params: TParams extends z.Schema ? z.infer : TParams; - query: TQuery extends z.Schema ? z.infer : TQuery; - body: TBody extends z.Schema ? z.infer : TBody; + params: TParams extends z.Schema ? z.input : TParams; + query: TQuery extends z.Schema ? z.input : TQuery; + body: TBody extends z.Schema ? z.input : TBody; response: TResponse; serverErrorBody: TServerErrorBody; internalErrorBody: TInternalErrorBody; diff --git a/packages/app/control/scripts/seed-app-usage.ts b/packages/app/control/scripts/seed-app-usage.ts index 4073c486d..100ead3b3 100644 --- a/packages/app/control/scripts/seed-app-usage.ts +++ b/packages/app/control/scripts/seed-app-usage.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx -import { db } from '../src/lib/db'; +// eslint-disable-next-line no-db-client-outside-db/no-db-client-outside-db +import { db } from '../src/services/db/client'; import { faker } from '@faker-js/faker'; import { subDays, addDays, format } from 'date-fns'; diff --git a/packages/app/control/scripts/seed-markup-rewards.ts b/packages/app/control/scripts/seed-markup-rewards.ts index 4483bb5c8..03316a29b 100644 --- a/packages/app/control/scripts/seed-markup-rewards.ts +++ b/packages/app/control/scripts/seed-markup-rewards.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx -import { db } from '../src/lib/db'; +// eslint-disable-next-line no-db-client-outside-db/no-db-client-outside-db +import { db } from '../src/services/db/client'; import { faker } from '@faker-js/faker'; import { addDays, subDays, format } from 'date-fns'; diff --git a/packages/app/control/scripts/seed-referral-rewards.ts b/packages/app/control/scripts/seed-referral-rewards.ts index bf243396a..fac6509f3 100644 --- a/packages/app/control/scripts/seed-referral-rewards.ts +++ b/packages/app/control/scripts/seed-referral-rewards.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx -import { db } from '../src/lib/db'; +// eslint-disable-next-line no-db-client-outside-db/no-db-client-outside-db +import { db } from '../src/services/db/client'; import { faker } from '@faker-js/faker'; import { addDays, subDays, format } from 'date-fns'; diff --git a/packages/app/control/scripts/seed-users.ts b/packages/app/control/scripts/seed-users.ts index 198823215..a9eb50bd7 100644 --- a/packages/app/control/scripts/seed-users.ts +++ b/packages/app/control/scripts/seed-users.ts @@ -1,6 +1,7 @@ #!/usr/bin/env tsx -import { db } from '../src/lib/db'; +// eslint-disable-next-line no-db-client-outside-db/no-db-client-outside-db +import { db } from '../src/services/db/client'; import { faker } from '@faker-js/faker'; interface SeedUsersOptions { diff --git a/packages/app/control/src/app/(app)/(home)/activity/_components/filters/event-types-filter.tsx b/packages/app/control/src/app/(app)/(home)/activity/_components/filters/event-types-filter.tsx index 6d147460c..d3d13085f 100644 --- a/packages/app/control/src/app/(app)/(home)/activity/_components/filters/event-types-filter.tsx +++ b/packages/app/control/src/app/(app)/(home)/activity/_components/filters/event-types-filter.tsx @@ -15,7 +15,7 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { useFiltersContext } from '../../contexts/filters-context'; -import { FeedActivityType } from '@/services/feed/types'; +import { FeedActivityType } from '@/services/db/feed/types'; export const EventTypesFilter = () => { const { eventType, setEventType } = useFiltersContext(); diff --git a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/index.tsx b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/index.tsx index 4f431c168..3ddae9eb2 100644 --- a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/index.tsx +++ b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/index.tsx @@ -1,8 +1,8 @@ import { TransactionContent } from './transactions'; import { SignInContent } from './sign-in'; -import type { FeedActivity } from '@/services/feed/types'; -import { FeedActivityType } from '@/services/feed/types'; +import type { FeedActivity } from '@/services/db/feed/types'; +import { FeedActivityType } from '@/services/db/feed/types'; interface Props { activity: FeedActivity; diff --git a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/sign-in.tsx b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/sign-in.tsx index 308ddd0c0..e14c9dd7b 100644 --- a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/sign-in.tsx +++ b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/sign-in.tsx @@ -1,4 +1,4 @@ -import type { SignInEventData } from '@/services/feed/types'; +import type { SignInEventData } from '@/services/db/feed/types'; import { User, Users } from 'lucide-react'; interface Props { diff --git a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/transactions.tsx b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/transactions.tsx index 651f215c4..7b3adb16e 100644 --- a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/transactions.tsx +++ b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/content/transactions.tsx @@ -1,5 +1,5 @@ import { formatCurrency } from '@/lib/utils'; -import type { TransactionEventData } from '@/services/feed/types'; +import type { TransactionEventData } from '@/services/db/feed/types'; import { DollarSign } from 'lucide-react'; interface Props { diff --git a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/index.tsx b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/index.tsx index b96102e1d..b6f2e845b 100644 --- a/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/index.tsx +++ b/packages/app/control/src/app/(app)/(home)/activity/_components/list/item/index.tsx @@ -14,7 +14,7 @@ import { UserAvatar } from '@/components/utils/user-avatar'; import { ItemContent } from './content'; -import type { FeedActivity } from '@/services/feed/types'; +import type { FeedActivity } from '@/services/db/feed/types'; interface Props { activity: FeedActivity; diff --git a/packages/app/control/src/app/(app)/(home)/activity/contexts/filters-context.tsx b/packages/app/control/src/app/(app)/(home)/activity/contexts/filters-context.tsx index a3698eadd..704d9e6ed 100644 --- a/packages/app/control/src/app/(app)/(home)/activity/contexts/filters-context.tsx +++ b/packages/app/control/src/app/(app)/(home)/activity/contexts/filters-context.tsx @@ -5,7 +5,7 @@ import { createContext, useContext, useEffect, useState } from 'react'; import { subDays } from 'date-fns'; import { ActivityTimeframe } from '@/types/timeframes'; -import type { FeedActivityType } from '@/services/feed/types'; +import type { FeedActivityType } from '@/services/db/feed/types'; interface FiltersContextType { appId: string | undefined; diff --git a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/index.tsx b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/index.tsx index 4f431c168..3ddae9eb2 100644 --- a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/index.tsx +++ b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/index.tsx @@ -1,8 +1,8 @@ import { TransactionContent } from './transactions'; import { SignInContent } from './sign-in'; -import type { FeedActivity } from '@/services/feed/types'; -import { FeedActivityType } from '@/services/feed/types'; +import type { FeedActivity } from '@/services/db/feed/types'; +import { FeedActivityType } from '@/services/db/feed/types'; interface Props { activity: FeedActivity; diff --git a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/sign-in.tsx b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/sign-in.tsx index f2099d2d4..5fe130b06 100644 --- a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/sign-in.tsx +++ b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/sign-in.tsx @@ -1,4 +1,4 @@ -import type { SignInEventData } from '@/services/feed/types'; +import type { SignInEventData } from '@/services/db/feed/types'; interface Props { activity: SignInEventData; diff --git a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/transactions.tsx b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/transactions.tsx index 380b494a7..feb7f93ce 100644 --- a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/transactions.tsx +++ b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/content/transactions.tsx @@ -1,5 +1,5 @@ import { formatCurrency } from '@/lib/utils'; -import type { TransactionEventData } from '@/services/feed/types'; +import type { TransactionEventData } from '@/services/db/feed/types'; interface Props { numUsers: number; diff --git a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/item.tsx b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/item.tsx index 217639d40..c37cbfc40 100644 --- a/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/item.tsx +++ b/packages/app/control/src/app/(app)/(home)/dashboard/_components/personal/feed/items/item.tsx @@ -3,7 +3,7 @@ import { AvatarCirclesSkeleton, } from '@/components/ui/avatar-circles'; import { UserAvatar } from '@/components/utils/user-avatar'; -import type { FeedActivity } from '@/services/feed/types'; +import type { FeedActivity } from '@/services/db/feed/types'; import { formatDistanceToNow } from 'date-fns'; import { Code } from 'lucide-react'; import { ItemContent } from './content'; diff --git a/packages/app/control/src/app/(app)/(home)/new/_components/form.tsx b/packages/app/control/src/app/(app)/(home)/new/_components/form.tsx index 0e31f7134..9b08f34c3 100644 --- a/packages/app/control/src/app/(app)/(home)/new/_components/form.tsx +++ b/packages/app/control/src/app/(app)/(home)/new/_components/form.tsx @@ -18,7 +18,7 @@ import { Separator } from '@/components/ui/separator'; import { api } from '@/trpc/client'; import { MarkupInput } from '../../../_components/markup/input'; -import { createAppSchema } from '@/services/apps/lib/schemas'; +import { createAppSchema } from '@/services/db/apps/lib/schemas'; export const CreateAppForm = () => { const form = useForm>({ diff --git a/packages/app/control/src/app/(app)/admin/_components/completedPayouts.tsx b/packages/app/control/src/app/(app)/admin/_components/completedPayouts.tsx index fb968b6ae..a48781a69 100644 --- a/packages/app/control/src/app/(app)/admin/_components/completedPayouts.tsx +++ b/packages/app/control/src/app/(app)/admin/_components/completedPayouts.tsx @@ -11,27 +11,13 @@ export function CompletedPayoutsTable({ pageSize = 10 }: Props) { const [page, setPage] = useState(0); const { data, isLoading, isFetching } = - api.admin.payouts.listCompleted.useQuery({ + api.admin.payouts.list.completed.useQuery({ cursor: page, page_size: pageSize, }); if (isLoading) return
Loading completed payouts...
; - const payouts = (data?.items ?? []) as Array<{ - id: string; - amount: number; - createdAt: string; - type: string; - userEmail: string | null; - userName: string | null; - echoAppName: string | null; - recipientGithubUrl: string | null; - recipientAddress: string | null; - transactionId: string | null; - senderAddress: string | null; - }>; - return (
@@ -55,7 +41,7 @@ export function CompletedPayoutsTable({ pageSize = 10 }: Props) { - {payouts.map(p => ( + {data?.items.map(p => ( {new Date(p.createdAt).toLocaleString()} @@ -63,22 +49,22 @@ export function CompletedPayoutsTable({ pageSize = 10 }: Props) { {p.type} ${p.amount.toFixed(2)} - {p.userEmail ? ( + {p.user?.email ? (
- {p.userName ?? '—'} + {p.user.name ?? '—'} - {p.userEmail} + {p.user.email}
) : ( '—' )} - {p.echoAppName ?? '—'} + {p.echoApp?.name ?? '—'} - {p.recipientGithubUrl ? ( + {p.recipientGithubLink?.githubUrl ? ( ) : ( - (p.recipientAddress ?? '—') + (p.recipientGithubLink?.githubUrl ?? '—') )} diff --git a/packages/app/control/src/app/(app)/admin/_components/pendingPayouts.tsx b/packages/app/control/src/app/(app)/admin/_components/pendingPayouts.tsx index d2469ac12..13843e599 100644 --- a/packages/app/control/src/app/(app)/admin/_components/pendingPayouts.tsx +++ b/packages/app/control/src/app/(app)/admin/_components/pendingPayouts.tsx @@ -11,7 +11,7 @@ export function PendingPayoutsTable({ pageSize = 10 }: Props) { const [page, setPage] = useState(0); const { data, isLoading, refetch, isFetching } = - api.admin.payouts.listPending.useQuery({ + api.admin.payouts.list.pending.useQuery({ cursor: page, page_size: pageSize, }); @@ -38,18 +38,6 @@ export function PendingPayoutsTable({ pageSize = 10 }: Props) { if (isLoading) return
Loading pending payouts...
; - const payouts = (data?.items ?? []) as Array<{ - id: string; - amount: number; - createdAt: string; - type: string; - userEmail: string | null; - userName: string | null; - echoAppName: string | null; - recipientGithubUrl: string | null; - recipientAddress: string | null; - }>; - return (
@@ -84,7 +72,7 @@ export function PendingPayoutsTable({ pageSize = 10 }: Props) { - {payouts.map(p => ( + {data?.items.map(p => ( {new Date(p.createdAt).toLocaleString()} @@ -92,22 +80,22 @@ export function PendingPayoutsTable({ pageSize = 10 }: Props) { {p.type} ${p.amount.toFixed(2)} - {p.userEmail ? ( + {p.user?.email ? (
- {p.userName ?? '—'} + {p.user.name ?? '—'} - {p.userEmail} + {p.user.email}
) : ( '—' )} - {p.echoAppName ?? '—'} + {p.echoApp?.name ?? '—'} - {p.recipientGithubUrl ? ( + {p.recipientGithubLink?.githubUrl ? (
) : ( - (p.recipientAddress ?? '—') + (p.recipientGithubLink?.githubUrl ?? '—') )} diff --git a/packages/app/control/src/app/(app)/admin/credit-grants/[code]/(code)/_components/edit-form.tsx b/packages/app/control/src/app/(app)/admin/credit-grants/[code]/(code)/_components/edit-form.tsx index bb56f16f9..6b75a947b 100644 --- a/packages/app/control/src/app/(app)/admin/credit-grants/[code]/(code)/_components/edit-form.tsx +++ b/packages/app/control/src/app/(app)/admin/credit-grants/[code]/(code)/_components/edit-form.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner'; import type { RouterOutputs } from '@/trpc/client'; import { api } from '@/trpc/client'; -import type { adminCreateCreditGrantSchema } from '@/services/admin/schemas'; +import type { adminCreateCreditGrantSchema } from '@/services/db/admin/schemas'; import { CreditGrantForm } from '../../../_components/form'; import { revalidateCodePage } from '../_actions/revalidate'; diff --git a/packages/app/control/src/app/(app)/admin/credit-grants/_components/form.tsx b/packages/app/control/src/app/(app)/admin/credit-grants/_components/form.tsx index 5f0e75c5a..dbe56149e 100644 --- a/packages/app/control/src/app/(app)/admin/credit-grants/_components/form.tsx +++ b/packages/app/control/src/app/(app)/admin/credit-grants/_components/form.tsx @@ -36,7 +36,7 @@ import { } from '@/components/ui/popover'; import { Calendar } from '@/components/ui/calendar'; -import { adminCreateCreditGrantSchema } from '@/services/admin/schemas'; +import { adminCreateCreditGrantSchema } from '@/services/db/admin/schemas'; import { useEffect } from 'react'; import { Textarea } from '@/components/ui/textarea'; diff --git a/packages/app/control/src/app/(app)/admin/credit-grants/new/_components/create-form.tsx b/packages/app/control/src/app/(app)/admin/credit-grants/new/_components/create-form.tsx index b0780c04e..73ee6baa1 100644 --- a/packages/app/control/src/app/(app)/admin/credit-grants/new/_components/create-form.tsx +++ b/packages/app/control/src/app/(app)/admin/credit-grants/new/_components/create-form.tsx @@ -12,7 +12,7 @@ import { CreditGrantForm } from '../../_components/form'; import { api } from '@/trpc/client'; -import type { adminCreateCreditGrantSchema } from '@/services/admin/schemas'; +import type { adminCreateCreditGrantSchema } from '@/services/db/admin/schemas'; export const CreateCreditGrantForm = () => { const utils = api.useUtils(); diff --git a/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/header/buttons/owner/setup-app/app-icon.tsx b/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/header/buttons/owner/setup-app/app-icon.tsx index 4ebc300dc..10c354a45 100644 --- a/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/header/buttons/owner/setup-app/app-icon.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/(overview)/_components/header/buttons/owner/setup-app/app-icon.tsx @@ -22,7 +22,7 @@ import { Button } from '@/components/ui/button'; import { api } from '@/trpc/client'; -import type { updateAppSchema } from '@/services/apps/lib/schemas'; +import type { updateAppSchema } from '@/services/db/apps/lib/schemas'; const profilePictureSchema = z.object({ profilePictureUrl: z.url(), diff --git a/packages/app/control/src/app/(app)/app/[id]/referrals/_components/owner/update-dialog.tsx b/packages/app/control/src/app/(app)/app/[id]/referrals/_components/owner/update-dialog.tsx index fceb6fbfb..10afe46bc 100644 --- a/packages/app/control/src/app/(app)/app/[id]/referrals/_components/owner/update-dialog.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/referrals/_components/owner/update-dialog.tsx @@ -27,7 +27,7 @@ import { } from '@/components/ui/form'; import { PercentInput } from '@/components/ui/percent-input'; -import { setAppReferralRewardSchema } from '@/services/apps/referral-reward'; +import { setAppReferralRewardSchema } from '@/services/db/apps/referral-reward'; import { api } from '@/trpc/client'; diff --git a/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/field.tsx b/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/field.tsx index f693e6af9..d16442efd 100644 --- a/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/field.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/field.tsx @@ -9,7 +9,7 @@ import { FormItem, FormMessage, } from '@/components/ui/form'; -import type { updateAppSchema } from '@/services/apps/lib/schemas'; +import type { updateAppSchema } from '@/services/db/apps/lib/schemas'; interface Props { name: keyof typeof updateAppSchema.shape; diff --git a/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/index.tsx b/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/index.tsx index 750208fc0..782521d3d 100644 --- a/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/index.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/settings/(general)/_components/index.tsx @@ -13,7 +13,7 @@ import { AppDescription } from './description'; import { AppProfilePicture } from './profile-picture'; import { AppHomepage } from './homepage'; -import type { updateAppSchema } from '@/services/apps/lib/schemas'; +import type { updateAppSchema } from '@/services/db/apps/lib/schemas'; import { api } from '@/trpc/server'; import { CopyButton } from '@/components/ui/copy-button'; diff --git a/packages/app/control/src/app/(app)/app/[id]/settings/_components/app-details-form-provider.tsx b/packages/app/control/src/app/(app)/app/[id]/settings/_components/app-details-form-provider.tsx index cbe4a3118..a228e04c0 100644 --- a/packages/app/control/src/app/(app)/app/[id]/settings/_components/app-details-form-provider.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/settings/_components/app-details-form-provider.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner'; import { FormProvider, type FormProviderProps } from './form/context'; -import { updateAppSchema } from '@/services/apps/lib/schemas'; +import { updateAppSchema } from '@/services/db/apps/lib/schemas'; interface Props extends Omit>, 'schema'> { diff --git a/packages/app/control/src/app/(app)/app/[id]/settings/monetization/_components/markup/provider.tsx b/packages/app/control/src/app/(app)/app/[id]/settings/monetization/_components/markup/provider.tsx index 229789890..7e99ef863 100644 --- a/packages/app/control/src/app/(app)/app/[id]/settings/monetization/_components/markup/provider.tsx +++ b/packages/app/control/src/app/(app)/app/[id]/settings/monetization/_components/markup/provider.tsx @@ -1,6 +1,6 @@ 'use client'; -import { updateMarkupSchema } from '@/services/apps/lib/schemas'; +import { updateMarkupSchema } from '@/services/db/apps/lib/schemas'; import { FormProvider, type FormProviderProps, diff --git a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/api/oauth/authorize/route.ts b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/api/oauth/authorize/route.ts index cb7671e3e..00453435b 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/api/oauth/authorize/route.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/api/oauth/authorize/route.ts @@ -7,7 +7,7 @@ import { import { isValidRedirectUri } from '../../../../_lib/redirect-uri'; import { auth } from '@/auth'; import { env } from '@/env'; -import { getApp } from '@/services/apps/get'; +import { getApp } from '@/services/db/apps/get'; import { NextResponse } from 'next/server'; import { oauthRoute, diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts index 89fd43ef0..4e83d9e94 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts @@ -6,23 +6,27 @@ import { jwtVerify } from 'jose'; import { authCodeJwtPayloadSchema } from '@/app/(auth)/(oauth)/(authorize)/_lib/code'; import { isValidRedirectUri } from '@/app/(auth)/(oauth)/_lib/redirect-uri'; -import { db } from '@/lib/db'; -import { PermissionService } from '@/lib/permissions/service'; -import { AppRole, MembershipStatus } from '@/lib/permissions/types'; +import { PermissionService } from '@/services/db/apps/permissions/service'; +import { AppRole } from '@/services/db/apps/permissions/types'; import { createEchoAccessJwt } from '@/lib/access-token'; -import { createRefreshToken } from './refresh'; import { tokenResponse } from './response'; import { logger } from '@/logger'; import { env } from '@/env'; -import type { TokenMetadata } from './types'; import { OAuthError, OAuthErrorType, } from '@/app/(auth)/(oauth)/_lib/oauth-error'; import { oauthValidationError } from '@/app/(auth)/(oauth)/_lib/oauth-route'; +import { getUser } from '@/services/db/admin/user/user'; +import { getApp } from '@/services/db/apps/get'; +import { createAppMembership } from '@/services/db/apps/membership'; + +import { issueOAuthToken } from '@/services/db/auth/oauth-token'; + +import type { TokenMetadata } from '@/types/token-metadata'; export const handleIssueTokenSchema = z.object({ redirect_uri: z.url({ @@ -122,9 +126,7 @@ export async function handleIssueToken( }); } - const user = await db.user.findUnique({ - where: { id: user_id, isArchived: false }, - }); + const user = await getUser(user_id); if (!user) { throw new OAuthError({ @@ -134,12 +136,7 @@ export async function handleIssueToken( } /* 7️⃣ Find and validate the Echo app (client) */ - const app = await db.echoApp.findUnique({ - where: { - id: client_id, - isArchived: false, - }, - }); + const app = await getApp(client_id); if (!app) { throw new OAuthError({ @@ -171,15 +168,7 @@ export async function handleIssueToken( }, }); // User needs to join the app - await db.appMembership.create({ - data: { - userId: user.id, - echoAppId: app.id, - status: MembershipStatus.ACTIVE, - role: AppRole.CUSTOMER, - totalSpent: 0, - }, - }); + await createAppMembership(user.id, app.id, {}); userRole = AppRole.CUSTOMER; } catch { throw new OAuthError({ @@ -189,28 +178,11 @@ export async function handleIssueToken( } } - const [session, refreshToken] = await db.$transaction(async tx => { - const appSession = await tx.appSession.create({ - data: { - userId: user.id, - echoAppId: app.id, - deviceName: metadata?.deviceName, - userAgent: metadata?.userAgent, - ipAddress: metadata?.ipAddress, - }, - }); - - const newEchoRefreshToken = await createRefreshToken( - { - userId: user.id, - echoAppId: app.id, - scope, - sessionId: appSession.id, - }, - tx - ); - - return [appSession, newEchoRefreshToken]; + const { session, refreshToken } = await issueOAuthToken({ + userId: user.id, + appId: app.id, + scope, + metadata, }); const accessToken = await createEchoAccessJwt({ @@ -231,8 +203,6 @@ export async function handleIssueToken( }); return tokenResponse({ - user, - app, accessToken, refreshToken, }); diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/refresh.ts b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/refresh.ts index 26176b96d..a363186b7 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/refresh.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/refresh.ts @@ -1,25 +1,23 @@ import z from 'zod'; -import { nanoid } from 'nanoid'; - -import { addSeconds, subMilliseconds } from 'date-fns'; - import { tokenResponse } from './response'; -import { db } from '@/lib/db'; - import { logger } from '@/logger'; -import { env } from '@/env'; import { createEchoAccessJwt } from '@/lib/access-token'; -import type { Prisma } from '@/generated/prisma'; -import type { TokenMetadata } from './types'; import { oauthValidationError } from '@/app/(auth)/(oauth)/_lib/oauth-route'; import { OAuthError, OAuthErrorType, } from '@/app/(auth)/(oauth)/_lib/oauth-error'; +import { + archiveRefreshToken, + findRefreshToken, +} from '@/services/db/auth/refresh'; +import { refreshOAuthToken } from '@/services/db/auth/oauth-token'; + +import type { TokenMetadata } from '@/types/token-metadata'; export const handleRefreshTokenSchema = z.object({ refresh_token: z.string({ @@ -42,32 +40,7 @@ export async function handleRefreshToken( }, }); - const refreshToken = await db.refreshToken.findUnique({ - where: { - token, - OR: [ - { isArchived: false, archivedAt: null }, - { - isArchived: true, - archivedAt: { - lt: subMilliseconds( - new Date(), - env.OAUTH_REFRESH_TOKEN_ARCHIVE_GRACE_MS - ), - }, - expiresAt: { lt: new Date() }, - }, - ], - session: { isArchived: false, revokedAt: null }, - echoApp: { isArchived: false }, - user: { isArchived: false }, - }, - include: { - user: true, - echoApp: true, - session: true, - }, - }); + const refreshToken = await findRefreshToken(token); if (!refreshToken) { throw new OAuthError({ @@ -101,86 +74,25 @@ export async function handleRefreshToken( const sessionId = session.id; // Deactivate old refresh token - await archiveRefreshToken(token); - - const newRefreshToken = await db.$transaction(async tx => { - const [newRefreshToken] = await Promise.all([ - await createRefreshToken( - { - userId: user.id, - echoAppId: app.id, - scope, - sessionId, - }, - tx - ), - await db.appSession.update({ - where: { id: sessionId }, - data: { - lastSeenAt: new Date(), - ...(metadata?.userAgent ? { userAgent: metadata.userAgent } : {}), - ...(metadata?.ipAddress ? { ipAddress: metadata.ipAddress } : {}), - ...(metadata?.deviceName ? { deviceName: metadata.deviceName } : {}), - }, - }), - ]); - - return newRefreshToken; + + const { newRefreshToken, updatedSession } = await refreshOAuthToken({ + userId: user.id, + appId: app.id, + scope, + metadata, + sessionId, + token, }); const accessToken = await createEchoAccessJwt({ user_id: user.id, app_id: app.id, - session_id: sessionId, + session_id: updatedSession.id, scope, }); return tokenResponse({ - user, - app, accessToken, refreshToken: newRefreshToken, }); } - -const archiveRefreshToken = async (refreshToken: string) => { - await db.refreshToken.update({ - where: { token: refreshToken }, - data: { isArchived: true, archivedAt: new Date() }, - }); -}; - -const createRefreshTokenParamsSchema = z.object({ - userId: z.uuid(), - echoAppId: z.uuid(), - scope: z.string().default(''), - sessionId: z.uuid(), -}); - -export const createRefreshToken = async ( - params: z.input, - tx?: Prisma.TransactionClient -) => { - const client = tx ?? db; - const tokenData = createRefreshTokenParamsSchema.parse(params); - return client.refreshToken.create({ - data: { - token: `refresh_${nanoid(48)}`, - expiresAt: createEchoRefreshTokenExpiry(), - ...tokenData, - }, - }); -}; - -const createEchoRefreshTokenExpiry = () => { - const expiry = addSeconds(new Date(), env.OAUTH_REFRESH_TOKEN_EXPIRY_SECONDS); - logger.emit({ - severityText: 'DEBUG', - body: 'Echo refresh token expiry calculated', - attributes: { - expiryTime: expiry.toISOString(), - function: 'createEchoRefreshTokenExpiry', - }, - }); - return expiry; -}; diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/response.ts b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/response.ts index 6c7a1a928..552a5e0cc 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/response.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/response.ts @@ -2,20 +2,14 @@ import { differenceInSeconds } from 'date-fns'; import type { createEchoAccessJwt } from '@/lib/access-token'; -import type { createRefreshToken } from './refresh'; - -import type { EchoApp, User } from '@/generated/prisma'; +import type { createRefreshToken } from '@/services/db/auth/refresh'; interface TokenResponseParams { - user: User; - app: EchoApp; accessToken: Awaited>; refreshToken: Awaited>; } export const tokenResponse = ({ - user, - app, accessToken, refreshToken, }: TokenResponseParams) => ({ @@ -29,13 +23,13 @@ export const tokenResponse = ({ ), scope: accessToken.scope, user: { - id: user.id, - email: user.email, - name: user.name ?? '', + id: refreshToken.user.id, + email: refreshToken.user.email, + name: refreshToken.user.name ?? '', }, echo_app: { - id: app.id, - name: app.name, - description: app.description ?? '', + id: refreshToken.echoApp.id, + name: refreshToken.echoApp.name, + description: refreshToken.echoApp.description ?? '', }, }); diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/route.ts b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/route.ts index bac0cb0b3..706143df8 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/route.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/route.ts @@ -11,9 +11,10 @@ import { oauthValidationError, } from '../../../_lib/oauth-route'; -import type { TokenMetadata } from './_lib/types'; import { OAuthError, OAuthErrorType } from '../../../_lib/oauth-error'; +import type { TokenMetadata } from '@/types/token-metadata'; + const invalidGrantError = oauthValidationError({ error: OAuthErrorType.UNSUPPORTED_GRANT_TYPE, error_description: diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/userinfo/route.ts b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/userinfo/route.ts index 4432bf0db..282e87ce4 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/userinfo/route.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/userinfo/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { logger } from '@/logger'; import { authMiddleware } from '@/lib/api/auth-route'; -import { getFullUser } from '@/services/user/get'; +import { getFullUser } from '@/services/db/user/get'; import { getUnixTime } from 'date-fns'; import { oauthRoute, OAuthRouteError } from '../../../_lib/oauth-route'; import { OAuthErrorType } from '../../../_lib/oauth-error'; diff --git a/packages/app/control/src/app/api/stripe/webhook/route.ts b/packages/app/control/src/app/api/stripe/webhook/route.ts index 48b33f3fc..ba3e67775 100644 --- a/packages/app/control/src/app/api/stripe/webhook/route.ts +++ b/packages/app/control/src/app/api/stripe/webhook/route.ts @@ -1,47 +1,17 @@ -import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; -import { db } from '@/lib/db'; -import { - handlePaymentSuccess, - processPaymentUpdate, - PaymentStatus, -} from '@/lib/payment-processing'; -import Stripe from 'stripe'; -import { logger } from '@/logger'; -import { env } from '@/env'; -const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: '2025-05-28.basil', -}); +import { handleStripeEvent } from '@/services/stripe/webhook/handle-event'; -const webhookSecret = env.STRIPE_WEBHOOK_SECRET; +import { logger } from '@/logger'; + +import type { NextRequest } from 'next/server'; +import { constructStripeEvent } from '@/services/stripe/webhook/construct-event'; // POST /api/stripe/webhook - Handle Stripe webhooks export async function POST(request: NextRequest) { try { const body = await request.text(); - const signature = request.headers.get('stripe-signature'); - - if (!signature) { - return NextResponse.json({ error: 'Missing signature' }, { status: 400 }); - } - - let event: Stripe.Event; - - try { - // Verify webhook signature - event = stripe.webhooks.constructEvent(body, signature, webhookSecret); - } catch (err) { - logger.emit({ - severityText: 'ERROR', - body: 'Webhook signature verification failed', - attributes: { - error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined, - }, - }); - return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); - } + const event = constructStripeEvent(request, body); logger.emit({ severityText: 'INFO', @@ -52,30 +22,7 @@ export async function POST(request: NextRequest) { }, }); - // Handle the event - switch (event.type) { - case 'checkout.session.completed': - await handleCheckoutSessionCompleted(event.data.object); - break; - case 'payment_intent.succeeded': - await handlePaymentSuccess(event.data.object); - break; - case 'payment_intent.payment_failed': - await handlePaymentFailure(event.data.object); - break; - case 'invoice.payment_succeeded': - await handleInvoicePayment(event.data.object); - break; - default: - logger.emit({ - severityText: 'WARN', - body: 'Unhandled Stripe webhook event type', - attributes: { - eventType: event.type, - eventId: event.id, - }, - }); - } + await handleStripeEvent(event); return NextResponse.json({ received: true }); } catch (error) { @@ -93,213 +40,3 @@ export async function POST(request: NextRequest) { ); } } - -async function handleCheckoutSessionCompleted( - session: Stripe.Checkout.Session -) { - try { - const { metadata, amount_total, currency, payment_link, payment_intent } = - session; - const userId = metadata?.userId; - const echoAppId = metadata?.echoAppId; - const description = metadata?.description; - - if (!userId || !amount_total) { - logger.emit({ - severityText: 'ERROR', - body: 'Missing userId or amount in checkout session metadata', - attributes: { - sessionId: session.id, - userId, - amount_total, - handler: 'handleCheckoutSessionCompleted', - }, - }); - return; - } - - // Determine the payment ID to update based on whether this is from a payment link - let paymentId: string; - if (payment_link) { - // This checkout session was created from a payment link - paymentId = payment_link as string; - logger.emit({ - severityText: 'INFO', - body: 'Checkout session completed from payment link', - attributes: { - paymentId, - sessionId: session.id, - handler: 'handleCheckoutSessionCompleted', - }, - }); - } else if (payment_intent) { - // This is a direct checkout session - paymentId = session.id; - logger.emit({ - severityText: 'INFO', - body: 'Direct checkout session completed', - attributes: { - paymentId, - sessionId: session.id, - handler: 'handleCheckoutSessionCompleted', - }, - }); - } else { - logger.emit({ - severityText: 'ERROR', - body: 'No payment_link or payment_intent found in checkout session', - attributes: { - sessionId: session.id, - handler: 'handleCheckoutSessionCompleted', - }, - }); - return; - } - - // Use a database transaction to atomically update payment status and user balance - await db.$transaction(async tx => { - // Update payment in database - const updatedPayment = await tx.payment.findMany({ - where: { - paymentId: paymentId, - status: PaymentStatus.PENDING, - }, - }); - - let paymentRecord; - if (updatedPayment.length === 0) { - logger.emit({ - severityText: 'WARN', - body: 'No pending payment found for payment ID, creating new record', - attributes: { - paymentId, - handler: 'handleCheckoutSessionCompleted', - }, - }); - // Create a new payment record if one doesn't exist - paymentRecord = await tx.payment.create({ - data: { - paymentId: paymentId, - amount: amount_total, - currency: currency ?? 'usd', - status: PaymentStatus.COMPLETED, - description: description ?? 'Echo credits purchase', - userId, - }, - }); - logger.emit({ - severityText: 'INFO', - body: 'Created new payment record', - attributes: { - paymentId, - userId, - handler: 'handleCheckoutSessionCompleted', - }, - }); - } else { - // Get the existing payment record - paymentRecord = await tx.payment.update({ - where: { - paymentId: paymentId, - }, - data: { - status: PaymentStatus.COMPLETED, - }, - }); - } - - // Process the payment update using the cleaned up payment processing logic - if (paymentRecord) { - await processPaymentUpdate(tx, { - userId, - amountInCents: amount_total, - paymentRecord, - metadata: metadata ?? {}, - echoAppId, - }); - } - }); - - const creditsAdded = Math.floor(amount_total / 100); - const isFreeTier = metadata?.type === 'free-tier-credits'; - logger.emit({ - severityText: 'INFO', - body: 'Checkout completed successfully', - attributes: { - userId, - echoAppId, - creditsAdded, - isFreeTier, - handler: 'handleCheckoutSessionCompleted', - }, - }); - } catch (error) { - logger.emit({ - severityText: 'ERROR', - body: 'Error handling checkout completion', - attributes: { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - handler: 'handleCheckoutSessionCompleted', - }, - }); - } -} - -async function handlePaymentFailure(paymentIntent: Stripe.PaymentIntent) { - try { - const { id } = paymentIntent; - - await db.payment.updateMany({ - where: { paymentId: id }, - data: { status: PaymentStatus.FAILED }, - }); - - logger.emit({ - severityText: 'WARN', - body: 'Payment failed', - attributes: { - paymentIntentId: id, - handler: 'handlePaymentFailure', - }, - }); - } catch (error) { - logger.emit({ - severityText: 'ERROR', - body: 'Error handling payment failure', - attributes: { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - handler: 'handlePaymentFailure', - }, - }); - } -} - -async function handleInvoicePayment(invoice: Stripe.Invoice) { - try { - const { id, amount_paid, currency } = invoice; - - // Handle recurring payments if needed - logger.emit({ - severityText: 'INFO', - body: 'Invoice payment received', - attributes: { - invoiceId: id, - amount_paid, - currency, - handler: 'handleInvoicePayment', - }, - }); - } catch (error) { - logger.emit({ - severityText: 'ERROR', - body: 'Error handling invoice payment', - attributes: { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - handler: 'handleInvoicePayment', - }, - }); - } -} diff --git a/packages/app/control/src/app/api/v1/apps/[id]/route.ts b/packages/app/control/src/app/api/v1/apps/[id]/route.ts index ab8250328..051450f1f 100644 --- a/packages/app/control/src/app/api/v1/apps/[id]/route.ts +++ b/packages/app/control/src/app/api/v1/apps/[id]/route.ts @@ -2,8 +2,8 @@ import { NextResponse } from 'next/server'; import { logger } from '@/logger'; import { authRoute } from '../../../../../lib/api/auth-route'; import { z } from 'zod'; -import { appIdSchema } from '@/services/apps/lib/schemas'; -import { getApp } from '@/services/apps/get'; +import { appIdSchema } from '@/services/db/apps/lib/schemas'; +import { getApp } from '@/services/db/apps/get'; const paramsSchema = z.object({ id: appIdSchema, diff --git a/packages/app/control/src/app/api/v1/apps/route.ts b/packages/app/control/src/app/api/v1/apps/route.ts index b81ee8dfc..defd78929 100644 --- a/packages/app/control/src/app/api/v1/apps/route.ts +++ b/packages/app/control/src/app/api/v1/apps/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; import { logger } from '@/logger'; import { authRoute } from '../../../../lib/api/auth-route'; -import { listAppsSchema, listOwnerApps } from '@/services/apps/list'; -import { paginationSchema } from '@/services/lib/pagination'; +import { listAppsSchema, listOwnerApps } from '@/services/db/apps/list'; +import { paginationSchema } from '@/services/db/_lib/pagination'; const querySchema = paginationSchema.extend(listAppsSchema.shape); diff --git a/packages/app/control/src/app/api/v1/balance/[id]/free/route.ts b/packages/app/control/src/app/api/v1/balance/[id]/free/route.ts index 8ed1f7b61..ecb8857ad 100644 --- a/packages/app/control/src/app/api/v1/balance/[id]/free/route.ts +++ b/packages/app/control/src/app/api/v1/balance/[id]/free/route.ts @@ -1,11 +1,13 @@ -import { getCustomerSpendInfoForApp } from '@/lib/spend-pools/fetch-user-spend'; -import { NextResponse } from 'next/server'; -import { authRoute } from '../../../../../../lib/api/auth-route'; -import { appIdSchema } from '@/services/apps/lib/schemas'; import { z } from 'zod'; +import { NextResponse } from 'next/server'; + +import { getUserSpendInfoForApp } from '@/services/db/user/app-spend-pool'; + +import { authRoute } from '@/lib/api/auth-route'; + const getFreeBalanceParamsSchema = z.object({ - id: appIdSchema, + id: z.uuid(), }); export const GET = authRoute @@ -13,10 +15,7 @@ export const GET = authRoute .handler(async (_, context) => { const { id } = context.params; - const spendPoolInfo = await getCustomerSpendInfoForApp( - context.ctx.userId, - id - ); + const spendPoolInfo = await getUserSpendInfoForApp(context.ctx.userId, id); return NextResponse.json(spendPoolInfo); }); diff --git a/packages/app/control/src/app/api/v1/balance/free/route.ts b/packages/app/control/src/app/api/v1/balance/free/route.ts index c8b06a267..0db1662a8 100644 --- a/packages/app/control/src/app/api/v1/balance/free/route.ts +++ b/packages/app/control/src/app/api/v1/balance/free/route.ts @@ -5,13 +5,12 @@ import { z } from 'zod'; import { NextResponse } from 'next/server'; -import { appIdSchema } from '@/services/apps/lib/schemas'; +import { getUserSpendInfoForApp } from '@/services/db/user/app-spend-pool'; -import { getCustomerSpendInfoForApp } from '@/lib/spend-pools/fetch-user-spend'; import { authRoute } from '@/lib/api/auth-route'; const getFreeBalanceBodySchema = z.object({ - echoAppId: appIdSchema, + echoAppId: z.uuid(), }); export const POST = authRoute @@ -19,7 +18,7 @@ export const POST = authRoute .handler(async (_, context) => { const { echoAppId } = context.body; - const spendPoolInfo = await getCustomerSpendInfoForApp( + const spendPoolInfo = await getUserSpendInfoForApp( context.ctx.userId, echoAppId ); diff --git a/packages/app/control/src/app/api/v1/balance/route.ts b/packages/app/control/src/app/api/v1/balance/route.ts index a2dc8544f..1f783ef6c 100644 --- a/packages/app/control/src/app/api/v1/balance/route.ts +++ b/packages/app/control/src/app/api/v1/balance/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getUserGlobalBalance } from '@/services/user/balance'; +import { getUserGlobalBalance } from '@/services/db/user/balance'; import { logger } from '@/logger'; import { authRoute } from '../../../../lib/api/auth-route'; diff --git a/packages/app/control/src/app/api/v1/base/payment-link/route.ts b/packages/app/control/src/app/api/v1/base/payment-link/route.ts index ef8a94387..fbdc2c61d 100644 --- a/packages/app/control/src/app/api/v1/base/payment-link/route.ts +++ b/packages/app/control/src/app/api/v1/base/payment-link/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; -import { handlePaymentSuccessFromx402 } from '@/lib/base'; import z from 'zod'; import { authRoute } from '../../../../../lib/api/auth-route'; +import { handlePaymentSuccess } from '@/services/db/payments/success'; +import { logger } from '@/logger'; const querySchema = z.object({ amount: z.number().positive(), @@ -10,17 +11,55 @@ const querySchema = z.object({ export const GET = authRoute.query(querySchema).handler(async (_, context) => { const { amount } = context.query; - const normalizedCentsAmount = amount * 100; + const userId = context.ctx.userId; + const amountInCents = amount * 100; - await handlePaymentSuccessFromx402({ - userId: context.ctx.userId, - amount: normalizedCentsAmount, + const metadata = { + 'payment-type': 'x402', + 'transaction-id': 'x402_' + crypto.randomUUID(), + amount: amount.toString(), currency: 'usd', - metadata: {}, - }); + }; - return NextResponse.json( - { message: 'Payment created successfully' }, - { status: 201 } - ); + try { + await handlePaymentSuccess({ + userId, + amountInCents, + currency: 'usd', + paymentId: metadata['transaction-id'], + metadata, + description: 'Echo credits purchase with x402', + }); + + logger.emit({ + severityText: 'INFO', + body: 'Payment succeeded', + attributes: { + transactionId: metadata['transaction-id'], + isFreeTier: false, + userId, + function: 'handlePaymentSuccessFromx402', + }, + }); + + return NextResponse.json( + { message: 'Payment created successfully' }, + { status: 201 } + ); + } catch (error) { + logger.emit({ + severityText: 'ERROR', + body: 'Error handling payment success', + attributes: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + userId, + function: 'handlePaymentSuccessFromx402', + }, + }); + return NextResponse.json( + { message: 'Payment creation failed' }, + { status: 500 } + ); + } }); diff --git a/packages/app/control/src/app/api/v1/stripe/payment-link/route.ts b/packages/app/control/src/app/api/v1/stripe/payment-link/route.ts index 26d65acdb..df89709bb 100644 --- a/packages/app/control/src/app/api/v1/stripe/payment-link/route.ts +++ b/packages/app/control/src/app/api/v1/stripe/payment-link/route.ts @@ -2,15 +2,21 @@ import Stripe from 'stripe'; import { NextResponse } from 'next/server'; -import { createPaymentLink, createPaymentLinkSchema } from '@/services/stripe'; +import { + createCreditsPaymentLink, + createCreditsPaymentLinkSchema, +} from '@/services/stripe/create-link/credits'; import { authRoute } from '../../../../../lib/api/auth-route'; export const POST = authRoute - .body(createPaymentLinkSchema) + .body(createCreditsPaymentLinkSchema) .handler(async (_, context) => { try { - const result = await createPaymentLink(context.ctx.userId, context.body); + const result = await createCreditsPaymentLink( + context.ctx.userId, + context.body + ); return NextResponse.json(result); } catch (error) { if (error instanceof Stripe.errors.StripeError) { diff --git a/packages/app/control/src/app/api/v1/user/referral/route.ts b/packages/app/control/src/app/api/v1/user/referral/route.ts index 4c3a8ed18..e11cd6898 100644 --- a/packages/app/control/src/app/api/v1/user/referral/route.ts +++ b/packages/app/control/src/app/api/v1/user/referral/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import { setUserReferrerForAppIfExists } from '@/lib/referral-codes'; import { z } from 'zod'; -import { appIdSchema } from '@/services/apps/lib/schemas'; +import { appIdSchema } from '@/services/db/apps/lib/schemas'; import { authRoute } from '../../../../../lib/api/auth-route'; +import { setAppMembershipReferrer } from '@/services/db/apps/membership'; const setUserReferrerForAppSchema = z.object({ echoAppId: appIdSchema, @@ -14,7 +14,7 @@ export const POST = authRoute .handler(async (_, context) => { const { echoAppId, code } = context.body; - const success = await setUserReferrerForAppIfExists( + const success = await setAppMembershipReferrer( context.ctx.userId, echoAppId, code diff --git a/packages/app/control/src/app/api/v1/user/route.ts b/packages/app/control/src/app/api/v1/user/route.ts index a21914c8f..86e770b14 100644 --- a/packages/app/control/src/app/api/v1/user/route.ts +++ b/packages/app/control/src/app/api/v1/user/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { getFullUser } from '@/services/user/get'; +import { getFullUser } from '@/services/db/user/get'; import { authRoute } from '@/lib/api/auth-route'; diff --git a/packages/app/control/src/auth/index.ts b/packages/app/control/src/auth/index.ts index 86f31e093..04ab44e30 100644 --- a/packages/app/control/src/auth/index.ts +++ b/packages/app/control/src/auth/index.ts @@ -4,7 +4,8 @@ import NextAuth from 'next-auth'; import { PrismaAdapter } from '@auth/prisma-adapter'; -import { db } from '@/lib/db'; +// eslint-disable-next-line no-db-client-outside-db/no-db-client-outside-db +import { db } from '@/services/db/client'; import { authConfig } from './config'; import { emailProviders } from './providers'; diff --git a/packages/app/control/src/auth/providers.ts b/packages/app/control/src/auth/providers.ts index 1146ba40a..cbbd3dafb 100644 --- a/packages/app/control/src/auth/providers.ts +++ b/packages/app/control/src/auth/providers.ts @@ -3,7 +3,9 @@ import GithubProvider from 'next-auth/providers/github'; import Credentials from 'next-auth/providers/credentials'; import Resend from 'next-auth/providers/resend'; -import { db } from '@/lib/db'; +import { getUserByEmail } from '@/services/db/user/get'; +import { createUser } from '@/services/db/user/create'; + import { env } from '@/env'; import type { OAuthProvider } from './types'; @@ -42,11 +44,7 @@ export const testProviders: Provider[] = [ name: 'Test1', credentials: {}, authorize: async () => { - const existingUser = await db.user.findUnique({ - where: { - email: 'test@example.com', - }, - }); + const existingUser = await getUserByEmail('test@example.com'); if (existingUser) { return { @@ -57,13 +55,11 @@ export const testProviders: Provider[] = [ }; } - const user = await db.user.create({ - data: { - id: '11111111-1111-1111-1111-111111111111', - name: 'Integration Test User', - email: 'test@example.com', - image: 'http://echo.merit.systems/logo/light.svg', - }, + const user = await createUser({ + id: '11111111-1111-1111-1111-111111111111', + name: 'Integration Test User', + email: 'test@example.com', + image: 'http://echo.merit.systems/logo/light.svg', }); return { @@ -79,11 +75,7 @@ export const testProviders: Provider[] = [ name: 'Second Test User', credentials: {}, authorize: async () => { - const existingUser = await db.user.findUnique({ - where: { - email: 'test2@example.com', - }, - }); + const existingUser = await getUserByEmail('test2@example.com'); if (existingUser) { return { @@ -94,13 +86,11 @@ export const testProviders: Provider[] = [ }; } - const user = await db.user.create({ - data: { - id: '33333333-3333-3333-3333-333333333333', - name: 'Integration Test User', - email: 'test2@example.com', - image: 'http://echo.merit.systems/logo/light.svg', - }, + const user = await createUser({ + id: '33333333-3333-3333-3333-333333333333', + name: 'Integration Test User', + email: 'test2@example.com', + image: 'http://echo.merit.systems/logo/light.svg', }); return { @@ -116,11 +106,7 @@ export const testProviders: Provider[] = [ name: 'Third Test User', credentials: {}, authorize: async () => { - const existingUser = await db.user.findUnique({ - where: { - email: 'test3@example.com', - }, - }); + const existingUser = await getUserByEmail('test3@example.com'); if (existingUser) { return { @@ -131,13 +117,11 @@ export const testProviders: Provider[] = [ }; } - const user = await db.user.create({ - data: { - id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', - name: 'Third Test User', - email: 'test3@example.com', - image: 'http://echo.merit.systems/logo/light.svg', - }, + const user = await createUser({ + id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + name: 'Third Test User', + email: 'test3@example.com', + image: 'http://echo.merit.systems/logo/light.svg', }); return { diff --git a/packages/app/control/src/components/overview-chart/ChartPanel.tsx b/packages/app/control/src/components/overview-chart/ChartPanel.tsx index 6415f59ed..aff2f00bb 100644 --- a/packages/app/control/src/components/overview-chart/ChartPanel.tsx +++ b/packages/app/control/src/components/overview-chart/ChartPanel.tsx @@ -13,7 +13,7 @@ import { import { Skeleton } from '@/components/ui/skeleton'; import { ChartAreaLinear } from './LinearChart'; -import type { ChartItem } from '@/services/admin/type/chart'; +import type { ChartItem } from '@/services/db/admin/type/chart'; // Grid layout configuration (mirrors OverviewPanel) interface GridConfig { diff --git a/packages/app/control/src/components/overview-panel/OverviewPanel.tsx b/packages/app/control/src/components/overview-panel/OverviewPanel.tsx index 86fc3050f..44f5945c9 100644 --- a/packages/app/control/src/components/overview-panel/OverviewPanel.tsx +++ b/packages/app/control/src/components/overview-panel/OverviewPanel.tsx @@ -10,7 +10,7 @@ import { } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { cn, formatCurrency } from '@/lib/utils'; -import type { OverviewMetricConfig } from '@/services/admin/type/overview-metric'; +import type { OverviewMetricConfig } from '@/services/db/admin/type/overview-metric'; // Metric display types type MetricDisplayType = diff --git a/packages/app/control/src/components/server-side-data-table/ActionControls.tsx b/packages/app/control/src/components/server-side-data-table/ActionControls.tsx index 84acb91f3..51468db70 100644 --- a/packages/app/control/src/components/server-side-data-table/ActionControls.tsx +++ b/packages/app/control/src/components/server-side-data-table/ActionControls.tsx @@ -22,11 +22,11 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import type { FilterParams } from '@/services/lib/filtering'; -import { toFilterParams } from '@/services/lib/filtering'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { toMultiSortParams } from '@/services/lib/sorting'; -import type { PaginationParams } from '@/services/lib/pagination'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { toFilterParams } from '@/services/db/_lib/filtering'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { toMultiSortParams } from '@/services/db/_lib/sorting'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; // Table state interface for passing to actions export interface TableState { diff --git a/packages/app/control/src/components/server-side-data-table/FilterControls.tsx b/packages/app/control/src/components/server-side-data-table/FilterControls.tsx index 872771154..7bd0472b8 100644 --- a/packages/app/control/src/components/server-side-data-table/FilterControls.tsx +++ b/packages/app/control/src/components/server-side-data-table/FilterControls.tsx @@ -22,7 +22,7 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; -import type { FilterOperator } from '@/services/lib/filtering'; +import type { FilterOperator } from '@/services/db/_lib/filtering'; // Enhanced filter structure to support operators interface FilterConfig { diff --git a/packages/app/control/src/components/server-side-data-table/StatefulDataTable.tsx b/packages/app/control/src/components/server-side-data-table/StatefulDataTable.tsx index fb1ce8283..4db121dae 100644 --- a/packages/app/control/src/components/server-side-data-table/StatefulDataTable.tsx +++ b/packages/app/control/src/components/server-side-data-table/StatefulDataTable.tsx @@ -12,11 +12,11 @@ import { BaseTable } from './BaseTable'; import type { PaginationParams, PaginatedResponse, -} from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { toMultiSortParams } from '@/services/lib/sorting'; -import type { FilterParams } from '@/services/lib/filtering'; -import { toFilterParams } from '@/services/lib/filtering'; +} from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { toMultiSortParams } from '@/services/db/_lib/sorting'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { toFilterParams } from '@/services/db/_lib/filtering'; import type { TypedColumnDef } from './BaseTable'; import type { ActionConfig, ActionGroup } from './ActionControls'; import { createCheckboxColumn } from './CheckBoxColumn'; diff --git a/packages/app/control/src/lib/api/auth-route.ts b/packages/app/control/src/lib/api/auth-route.ts index 957107f77..cb05147b2 100644 --- a/packages/app/control/src/lib/api/auth-route.ts +++ b/packages/app/control/src/lib/api/auth-route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { createZodRoute } from './create-route'; -import { findApiKeyByHash } from '@/services/api-keys'; +import { findApiKeyByHash } from '@/services/db/api-keys'; import { authenticateEchoAccessJwt } from '@/lib/access-token'; diff --git a/packages/app/control/src/lib/base/index.ts b/packages/app/control/src/lib/base/index.ts deleted file mode 100644 index 36083f159..000000000 --- a/packages/app/control/src/lib/base/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { NextRequest } from 'next/server'; -import { db } from '../db'; -import { processPaymentUpdate, PaymentStatus } from '../payment-processing'; -import { logger } from '@/logger'; - -export const formatAmountFromQueryParams = ( - req: NextRequest -): number | null => { - const formattedAmount = req.nextUrl.searchParams.get('amount') ?? '1'; - - const amount = parseFloat(formattedAmount); - - if (isNaN(amount)) { - return null; - } - - return amount; -}; - -export const formatPriceForMiddleware = (amount: number): string => { - return '$' + amount; -}; - -export async function handlePaymentSuccessFromx402({ - userId, - amount, - currency, - metadata, -}: { - userId: string; - amount: number; - currency: string; - metadata: Record; -}) { - try { - if (!userId) { - logger.emit({ - severityText: 'ERROR', - body: 'No userId in payment metadata', - attributes: { - function: 'handlePaymentSuccessFromx402', - }, - }); - return; - } - - const cryptoMetadata = { - 'payment-type': 'x402', - 'transaction-id': 'x402_' + crypto.randomUUID(), - amount: amount.toString(), - currency: currency, - ...metadata, - }; - - // Use a database transaction to atomically update payment status and user balance - await db.$transaction(async tx => { - // Update payment in database - const paymentRecord = await tx.payment.upsert({ - where: { paymentId: cryptoMetadata['transaction-id'] }, - update: { status: PaymentStatus.COMPLETED }, - create: { - paymentId: cryptoMetadata['transaction-id'], - amount, - currency, - status: PaymentStatus.COMPLETED, - description: 'Echo credits purchase with x402', - userId, - }, - }); - - // Process the payment update using the cleaned up payment processing logic - await processPaymentUpdate(tx, { - userId, - amountInCents: amount, - paymentRecord, - metadata: cryptoMetadata || {}, - echoAppId: metadata?.echoAppId, - }); - }); - - const isFreeTier = metadata?.type === 'free-tier-credits'; - logger.emit({ - severityText: 'INFO', - body: 'Payment succeeded', - attributes: { - transactionId: cryptoMetadata['transaction-id'], - isFreeTier, - userId, - function: 'handlePaymentSuccessFromx402', - }, - }); - } catch (error) { - logger.emit({ - severityText: 'ERROR', - body: 'Error handling payment success', - attributes: { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - userId, - function: 'handlePaymentSuccessFromx402', - }, - }); - } -} diff --git a/packages/app/control/src/lib/payment-processing.ts b/packages/app/control/src/lib/payment-processing.ts deleted file mode 100644 index adce06a15..000000000 --- a/packages/app/control/src/lib/payment-processing.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { updateSpendPoolFromPayment } from './spend-pools'; -import { updateUserBalanceFromPayment } from '../services/user/balance'; -import type { Payment } from '@/generated/prisma'; -import type { PrismaClient } from '@/generated/prisma'; -import { db } from './db'; -import type Stripe from 'stripe'; -import { logger } from '@/logger'; - -interface PaymentProcessingData { - userId: string; - amountInCents: number; - paymentRecord: Payment; - metadata?: Record; - echoAppId?: string; -} - -export enum PaymentStatus { - PENDING = 'pending', - COMPLETED = 'completed', - FAILED = 'failed', -} -/** - * Process a payment by updating either free tier spend pools or user personal balance - * @param tx - The database transaction - * @param data - Payment processing data - */ -export async function processPaymentUpdate( - tx: Parameters[0]>[0], - data: PaymentProcessingData -): Promise { - const { userId, amountInCents, paymentRecord, metadata, echoAppId } = data; - - // Handle free tier pool creation and funding if this is a free tier payment - if (metadata?.type === 'free-tier-credits' && echoAppId && paymentRecord) { - await updateSpendPoolFromPayment( - tx, - echoAppId, - paymentRecord, - amountInCents, - metadata - ); - - logger.emit({ - severityText: 'INFO', - body: 'Free tier payment processed', - attributes: { - echoAppId, - amountInCents, - amountInDollars: amountInCents / 100, - function: 'processPaymentUpdate', - }, - }); - } else { - // For non-free-tier payments, update user's totalPaid balance - await updateUserBalanceFromPayment(tx, userId, amountInCents); - - logger.emit({ - severityText: 'INFO', - body: 'Personal balance updated for user', - attributes: { - userId, - amountInCents, - amountInDollars: amountInCents / 100, - function: 'processPaymentUpdate', - }, - }); - } -} - -export async function handlePaymentSuccess( - paymentIntent: Stripe.PaymentIntent -) { - try { - const { id, amount, currency, metadata } = paymentIntent; - const userId = metadata?.userId; - - if (!userId) { - console.error('No userId in payment metadata'); - return; - } - - // Use a database transaction to atomically update payment status and user balance - await db.$transaction(async tx => { - // Update payment in database - const paymentRecord = await tx.payment.upsert({ - where: { paymentId: id }, - update: { status: PaymentStatus.COMPLETED }, - create: { - paymentId: id, - amount, - currency, - status: PaymentStatus.COMPLETED, - description: 'Echo credits purchase', - userId, - }, - }); - - // Process the payment update using the cleaned up payment processing logic - await processPaymentUpdate(tx, { - userId, - amountInCents: amount, - paymentRecord, - metadata: metadata || {}, - echoAppId: metadata?.echoAppId, - }); - }); - - const isFreeTier = metadata?.type === 'free-tier-credits'; - logger.emit({ - severityText: 'INFO', - body: 'Payment succeeded', - attributes: { - paymentIntentId: id, - isFreeTier, - function: 'handlePaymentSuccess', - }, - }); - } catch (error) { - logger.emit({ - severityText: 'ERROR', - body: 'Error handling payment success', - attributes: { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - paymentIntentId: paymentIntent.id, - function: 'handlePaymentSuccess', - }, - }); - } -} diff --git a/packages/app/control/src/lib/referral-codes/index.ts b/packages/app/control/src/lib/referral-codes/index.ts deleted file mode 100644 index 0a162f6c8..000000000 --- a/packages/app/control/src/lib/referral-codes/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { db } from '@/lib/db'; - -export async function setUserReferrerForAppIfExists( - userId: string, - echoAppId: string, - code: string -): Promise { - const appMembership = await db.appMembership.findUnique({ - where: { - userId_echoAppId: { - userId, - echoAppId, - }, - }, - select: { - referrerId: true, - }, - }); - - if (appMembership?.referrerId) { - // If the user already has a referrer, return false - return false; - } - - const referralCode = await db.referralCode.findUnique({ - where: { - code, - }, - }); - - if (!referralCode) { - return false; - } - - await db.appMembership.update({ - where: { - userId_echoAppId: { - userId, - echoAppId, - }, - }, - data: { - referrerId: referralCode.id, - }, - }); - - await db.referralCode.update({ - where: { id: referralCode.id }, - data: { - usedAt: new Date(), - }, - }); - - return true; -} diff --git a/packages/app/control/src/lib/spend-pools/index.ts b/packages/app/control/src/lib/spend-pools/index.ts deleted file mode 100644 index c712e0fb9..000000000 --- a/packages/app/control/src/lib/spend-pools/index.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { SpendPool, Payment } from '@/generated/prisma'; -import type { PrismaClient } from '@/generated/prisma'; - -// Export functions from fetch-user-spend.ts -export { getCustomerSpendInfoForApp } from './fetch-user-spend'; - -/** - * Internal function to fund a spend pool within an existing transaction - */ -async function fundSpendPoolInternal( - tx: Parameters[0]>[0], - spendPoolId: string, - paymentId: string, - amount: number -): Promise { - // Update the payment to link it to the spend pool - await tx.payment.update({ - where: { id: paymentId }, - data: { - spendPoolId, - }, - }); - - // Update the total amount in the spend pool - await tx.spendPool.update({ - where: { id: spendPoolId }, - data: { - totalPaid: { - increment: amount, - }, - }, - }); -} - -/** - * Update spend pool balance from a payment within an existing transaction - * @param tx - The database transaction - * @param echoAppId - The app ID to create/fund the spend pool for - * @param paymentRecord - The payment record - * @param amountInCents - The payment amount in cents - * @param metadata - Payment metadata containing pool configuration - */ -export async function updateSpendPoolFromPayment( - tx: Parameters[0]>[0], - echoAppId: string, - paymentRecord: Payment, - amountInCents: number, - metadata: Record -): Promise { - // Get or create the free tier spend pool for this app - const spendPool = await getOrCreateFreeTierSpendPoolInternal( - tx, - echoAppId, - metadata?.poolName - ); - - // Fund the spend pool with this payment - await fundSpendPoolInternal( - tx, - spendPool.id, - paymentRecord.id, - amountInCents / 100 // Convert from cents to dollars - ); - - console.log( - `Free tier pool ${spendPool.id} funded with $${amountInCents / 100} from payment ${paymentRecord.id}` - ); - - // Set default spend limit if provided - if (metadata?.defaultSpendLimit) { - const defaultLimit = parseFloat(metadata.defaultSpendLimit); - if (!isNaN(defaultLimit) && defaultLimit > 0) { - // This will be applied to users when they access the app - console.log( - `Default spend limit of $${defaultLimit} will be applied to new users for pool ${spendPool.id}` - ); - } - } -} - -/** - * Internal function to get or create a free tier spend pool within an existing transaction - */ -async function getOrCreateFreeTierSpendPoolInternal( - tx: Parameters[0]>[0], - appId: string, - poolName?: string -): Promise { - // First try to find an existing free tier pool - let spendPool = await tx.spendPool.findFirst({ - where: { - echoAppId: appId, - isArchived: false, - }, - }); - - spendPool ??= await tx.spendPool.create({ - data: { - name: - poolName ?? - `Free Tier Credits - ${new Date().toISOString().split('T')[0]}`, - description: 'Free tier credits pool for app users', - totalPaid: 0, // Will be funded by payments - echoAppId: appId, - }, - }); - - return spendPool; -} diff --git a/packages/app/control/src/middleware.ts b/packages/app/control/src/middleware.ts index a050e4754..c4458e9b4 100644 --- a/packages/app/control/src/middleware.ts +++ b/packages/app/control/src/middleware.ts @@ -9,10 +9,6 @@ import { paymentMiddleware } from 'x402-next'; import { facilitator } from '@coinbase/x402'; import { middleware } from '@/auth/middleware'; -import { - formatAmountFromQueryParams, - formatPriceForMiddleware, -} from '@/lib/base'; import { env } from './env'; export const config = { @@ -23,15 +19,15 @@ export const config = { }; export const x402MiddlewareGenerator = (req: NextRequest) => { - const amount = formatAmountFromQueryParams(req); + const amount = parseFloat(req.nextUrl.searchParams.get('amount') ?? '1'); - if (!amount) { + if (!amount || isNaN(amount)) { return async () => { return NextResponse.json({ error: `Invalid amount` }, { status: 400 }); }; } - const paymentAmount = formatPriceForMiddleware(amount); + const paymentAmount = `$${amount}`; return paymentMiddleware( env.RESOURCE_WALLET_ADDRESS as Address, diff --git a/packages/app/control/src/services/admin/completed-payouts.ts b/packages/app/control/src/services/admin/completed-payouts.ts deleted file mode 100644 index ccc1b0e9c..000000000 --- a/packages/app/control/src/services/admin/completed-payouts.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { db } from '@/lib/db'; -import type { PayoutType } from '@/services/payouts/referrals'; -import { PayoutStatus } from '@/services/payouts/referrals'; -import { - type PaginationParams, - toPaginatedReponse, -} from '@/services/lib/pagination'; - -type CompletedPayoutListItem = { - id: string; - amount: number; - status: string; - type: PayoutType; - createdAt: string; - updatedAt: string; - description: string | null; - // recipient - recipientAddress: string | null; - recipientGithubLinkId: string | null; - recipientGithubUrl: string | null; - // context - userId: string | null; - userEmail: string | null; - userName: string | null; - echoAppId: string | null; - echoAppName: string | null; - // transaction details - transactionId: string | null; // tx hash from Merit - senderAddress: string | null; -}; - -export async function adminListCompletedPayouts(pagination: PaginationParams) { - const { page, page_size } = pagination; - const skip = page * page_size; - - const [items, totalCount] = await Promise.all([ - db.payout.findMany({ - where: { status: PayoutStatus.COMPLETED }, - orderBy: { createdAt: 'desc' }, - skip, - take: page_size, - include: { - user: { select: { id: true, email: true, name: true } }, - echoApp: { select: { id: true, name: true } }, - recipientGithubLink: { select: { id: true, githubUrl: true } }, - }, - }), - db.payout.count({ where: { status: PayoutStatus.COMPLETED } }), - ]); - - const mapped: CompletedPayoutListItem[] = items.map(p => ({ - id: p.id, - amount: Number(p.amount), - status: p.status, - type: p.type as PayoutType, - createdAt: p.createdAt.toISOString(), - updatedAt: p.updatedAt.toISOString(), - description: p.description ?? null, - recipientAddress: p.recipientAddress ?? null, - recipientGithubLinkId: p.recipientGithubLinkId ?? null, - recipientGithubUrl: p.recipientGithubLink?.githubUrl ?? null, - userId: p.user?.id ?? null, - userEmail: p.user?.email ?? null, - userName: p.user?.name ?? null, - echoAppId: p.echoApp?.id ?? null, - echoAppName: p.echoApp?.name ?? null, - transactionId: p.transactionId ?? null, - senderAddress: p.senderAddress ?? null, - })); - - return toPaginatedReponse({ - items: mapped, - page, - page_size, - total_count: totalCount, - }); -} diff --git a/packages/app/control/src/services/admin/pending-payouts.ts b/packages/app/control/src/services/admin/pending-payouts.ts deleted file mode 100644 index 4079eb23c..000000000 --- a/packages/app/control/src/services/admin/pending-payouts.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { db } from '@/lib/db'; -import type { PayoutType } from '@/services/payouts/referrals'; -import { PayoutStatus } from '@/services/payouts/referrals'; -import { - type PaginationParams, - toPaginatedReponse, -} from '@/services/lib/pagination'; - -type PendingPayoutListItem = { - id: string; - amount: number; - status: string; - type: PayoutType; - createdAt: string; - updatedAt: string; - description: string | null; - // recipient - recipientAddress: string | null; - recipientGithubLinkId: string | null; - recipientGithubUrl: string | null; - // context - userId: string | null; - userEmail: string | null; - userName: string | null; - echoAppId: string | null; - echoAppName: string | null; -}; - -export async function adminListPendingPayouts(pagination: PaginationParams) { - const { page, page_size } = pagination; - const skip = page * page_size; - - const [items, totalCount] = await Promise.all([ - db.payout.findMany({ - where: { status: PayoutStatus.PENDING }, - orderBy: { createdAt: 'desc' }, - skip, - take: page_size, - include: { - user: { select: { id: true, email: true, name: true } }, - echoApp: { select: { id: true, name: true } }, - recipientGithubLink: { select: { id: true, githubUrl: true } }, - }, - }), - db.payout.count({ where: { status: PayoutStatus.PENDING } }), - ]); - - const mapped: PendingPayoutListItem[] = items.map(p => ({ - id: p.id, - amount: Number(p.amount), - status: p.status, - type: p.type as PayoutType, - createdAt: p.createdAt.toISOString(), - updatedAt: p.updatedAt.toISOString(), - description: p.description ?? null, - recipientAddress: p.recipientAddress ?? null, - recipientGithubLinkId: p.recipientGithubLinkId ?? null, - recipientGithubUrl: p.recipientGithubLink?.githubUrl ?? null, - userId: p.user?.id ?? null, - userEmail: p.user?.email ?? null, - userName: p.user?.name ?? null, - echoAppId: p.echoApp?.id ?? null, - echoAppName: p.echoApp?.name ?? null, - })); - - return toPaginatedReponse({ - items: mapped, - page, - page_size, - total_count: totalCount, - }); -} diff --git a/packages/app/control/src/services/apps/free-tier.ts b/packages/app/control/src/services/apps/free-tier.ts deleted file mode 100644 index 6767f5ae9..000000000 --- a/packages/app/control/src/services/apps/free-tier.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { db } from '@/lib/db'; -import type { AppId } from './lib/schemas'; -import { AppRole } from '@/lib/permissions'; -import z from 'zod'; - -export const getFreeTierSpendPool = async (appId: AppId, userId: string) => { - const spendPool = await db.spendPool.findFirst({ - where: { - echoAppId: appId, - isArchived: false, - echoApp: { - appMemberships: { - some: { - userId, - role: AppRole.OWNER, - }, - }, - }, - }, - }); - - if (!spendPool) { - return null; - } - - return { - ...spendPool, - archivedAt: spendPool.archivedAt?.toISOString(), - createdAt: spendPool.createdAt.toISOString(), - updatedAt: spendPool.updatedAt.toISOString(), - totalPaid: Number(spendPool.totalPaid), - totalSpent: Number(spendPool.totalSpent), - balance: Number(spendPool.totalPaid.minus(spendPool.totalSpent)), - perUserSpendLimit: spendPool.perUserSpendLimit?.toNumber(), - }; -}; - -export const updateFreeTierSpendPoolSchema = z.object({ - perUserSpendLimit: z.number().positive(), -}); - -export const updateFreeTierSpendPool = async ( - appId: string, - userId: string, - input: z.infer -) => { - const parsedInput = updateFreeTierSpendPoolSchema.safeParse(input); - - if (!parsedInput.success) { - throw new Error('Invalid input'); - } - - // FROM JSON: - // this is a bit of a hack because we are currently only allowing one spend pool per app - // IMO the abstraction of updating by app ID is better than specifying the spend poolID in the input - await db.spendPool.updateMany({ - where: { - echoAppId: appId, - echoApp: { - appMemberships: { - some: { - userId, - role: AppRole.OWNER, - }, - }, - }, - }, - data: parsedInput.data, - }); -}; diff --git a/packages/app/control/src/services/lib/filtering.ts b/packages/app/control/src/services/db/_lib/filtering.ts similarity index 100% rename from packages/app/control/src/services/lib/filtering.ts rename to packages/app/control/src/services/db/_lib/filtering.ts diff --git a/packages/app/control/src/services/lib/pagination.ts b/packages/app/control/src/services/db/_lib/pagination.ts similarity index 100% rename from packages/app/control/src/services/lib/pagination.ts rename to packages/app/control/src/services/db/_lib/pagination.ts diff --git a/packages/app/control/src/services/lib/schemas.ts b/packages/app/control/src/services/db/_lib/schemas.ts similarity index 100% rename from packages/app/control/src/services/lib/schemas.ts rename to packages/app/control/src/services/db/_lib/schemas.ts diff --git a/packages/app/control/src/services/lib/sorting.ts b/packages/app/control/src/services/db/_lib/sorting.ts similarity index 100% rename from packages/app/control/src/services/lib/sorting.ts rename to packages/app/control/src/services/db/_lib/sorting.ts diff --git a/packages/app/control/src/services/admin/admin.ts b/packages/app/control/src/services/db/admin/admin.ts similarity index 91% rename from packages/app/control/src/services/admin/admin.ts rename to packages/app/control/src/services/db/admin/admin.ts index a762216d6..04582bebf 100644 --- a/packages/app/control/src/services/admin/admin.ts +++ b/packages/app/control/src/services/db/admin/admin.ts @@ -1,14 +1,11 @@ import z from 'zod'; -import type { mintCreditsToUserSchema } from '../credits/mint'; -import { mintCreditsToUser } from '../credits/mint'; - -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { EchoApp, Prisma, User } from '@/generated/prisma'; -import type { PaginationParams } from '../lib/pagination'; -import { toPaginatedReponse } from '../lib/pagination'; +import type { PaginationParams } from '../_lib/pagination'; +import { toPaginatedReponse } from '../_lib/pagination'; import type { adminCreateCreditGrantSchema, adminUpdateCreditGrantSchema, @@ -39,12 +36,6 @@ export async function adminGetAppsForUser(userId: string): Promise { }); } -export async function adminMintCreditsToUser( - input: z.infer -) { - return await mintCreditsToUser(input); -} - export const adminGetCreditGrantSchema = z.object({ code: z.string(), }); diff --git a/packages/app/control/src/services/admin/app-earnings-summary.ts b/packages/app/control/src/services/db/admin/app-earnings-summary.ts similarity index 99% rename from packages/app/control/src/services/admin/app-earnings-summary.ts rename to packages/app/control/src/services/db/admin/app-earnings-summary.ts index 19cef39dc..b411b4f4b 100644 --- a/packages/app/control/src/services/admin/app-earnings-summary.ts +++ b/packages/app/control/src/services/db/admin/app-earnings-summary.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { OverviewMetricConfig } from './type/overview-metric'; import { percentChange } from './util/percent-change'; diff --git a/packages/app/control/src/services/admin/app-earnings.ts b/packages/app/control/src/services/db/admin/app-earnings.ts similarity index 94% rename from packages/app/control/src/services/admin/app-earnings.ts rename to packages/app/control/src/services/db/admin/app-earnings.ts index 9ce926a44..bf816c5fb 100644 --- a/packages/app/control/src/services/admin/app-earnings.ts +++ b/packages/app/control/src/services/db/admin/app-earnings.ts @@ -4,13 +4,13 @@ * sorting, and filtering parameters and returns app earnings data in the expected format. */ -import type { PaginationParams } from '@/services/lib/pagination'; -import { toPaginatedReponse } from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { buildOrderByClause } from '@/services/admin/util/build-order-by-clause'; -import type { FilterParams } from '@/services/lib/filtering'; -import { db } from '@/lib/db'; -import { buildFilterClauses } from '@/services/admin/util/build-filter-clause'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; +import { toPaginatedReponse } from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { buildOrderByClause } from '@/services/db/admin/util/build-order-by-clause'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { db } from '@/services/db/client'; +import { buildFilterClauses } from '@/services/db/admin/util/build-filter-clause'; interface AppEarningsRow { id: string; diff --git a/packages/app/control/src/services/admin/app/app.ts b/packages/app/control/src/services/db/admin/app/app.ts similarity index 91% rename from packages/app/control/src/services/admin/app/app.ts rename to packages/app/control/src/services/db/admin/app/app.ts index 6b9d4efc2..af7c82163 100644 --- a/packages/app/control/src/services/admin/app/app.ts +++ b/packages/app/control/src/services/db/admin/app/app.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; export const getApp = async (appId: string) => { const app = await db.echoApp.findUnique({ diff --git a/packages/app/control/src/services/admin/app/users.ts b/packages/app/control/src/services/db/admin/app/users.ts similarity index 95% rename from packages/app/control/src/services/admin/app/users.ts rename to packages/app/control/src/services/db/admin/app/users.ts index 85805ae11..9d7826045 100644 --- a/packages/app/control/src/services/admin/app/users.ts +++ b/packages/app/control/src/services/db/admin/app/users.ts @@ -4,13 +4,13 @@ * sorting, and filtering parameters and returns app user data in the expected format. */ -import type { PaginationParams } from '@/services/lib/pagination'; -import { toPaginatedReponse } from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { buildOrderByClause } from '@/services/admin/util/build-order-by-clause'; -import type { FilterParams } from '@/services/lib/filtering'; -import { db } from '@/lib/db'; -import { buildFilterClauses } from '@/services/admin/util/build-filter-clause'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; +import { toPaginatedReponse } from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { buildOrderByClause } from '@/services/db/admin/util/build-order-by-clause'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { db } from '@/services/db/client'; +import { buildFilterClauses } from '@/services/db/admin/util/build-filter-clause'; interface AppUsersRow { id: string; diff --git a/packages/app/control/src/services/admin/email-campaigns.ts b/packages/app/control/src/services/db/admin/email-campaigns.ts similarity index 96% rename from packages/app/control/src/services/admin/email-campaigns.ts rename to packages/app/control/src/services/db/admin/email-campaigns.ts index 9b2eabcbf..23c565c89 100644 --- a/packages/app/control/src/services/admin/email-campaigns.ts +++ b/packages/app/control/src/services/db/admin/email-campaigns.ts @@ -1,6 +1,6 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { queueJob } from '@/services/email/queue'; -import { EmailType } from '../email/emails/types'; +import { EmailType } from '../../email/emails/types'; type CampaignLabel = { key: string; diff --git a/packages/app/control/src/services/admin/home-page.ts b/packages/app/control/src/services/db/admin/home-page.ts similarity index 99% rename from packages/app/control/src/services/admin/home-page.ts rename to packages/app/control/src/services/db/admin/home-page.ts index 1361ba999..958246817 100644 --- a/packages/app/control/src/services/admin/home-page.ts +++ b/packages/app/control/src/services/db/admin/home-page.ts @@ -1,6 +1,6 @@ -import type { ChartItem } from '@/services/admin/type/chart'; +import type { ChartItem } from '@/services/db/admin/type/chart'; import type { ChartConfig } from '@/components/ui/chart'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; interface GetHomePageChartInput { startDate?: Date; diff --git a/packages/app/control/src/services/admin/payments-summary.ts b/packages/app/control/src/services/db/admin/payments-summary.ts similarity index 99% rename from packages/app/control/src/services/admin/payments-summary.ts rename to packages/app/control/src/services/db/admin/payments-summary.ts index f65fcd09b..811ad3f87 100644 --- a/packages/app/control/src/services/admin/payments-summary.ts +++ b/packages/app/control/src/services/db/admin/payments-summary.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { OverviewMetricConfig } from './type/overview-metric'; import { percentChange } from './util/percent-change'; diff --git a/packages/app/control/src/services/admin/payments.ts b/packages/app/control/src/services/db/admin/payments.ts similarity index 90% rename from packages/app/control/src/services/admin/payments.ts rename to packages/app/control/src/services/db/admin/payments.ts index fc23d331c..33ab411ee 100644 --- a/packages/app/control/src/services/admin/payments.ts +++ b/packages/app/control/src/services/db/admin/payments.ts @@ -4,13 +4,13 @@ * sorting, and filtering parameters and returns payment data in the expected format. */ -import type { PaginationParams } from '@/services/lib/pagination'; -import { toPaginatedReponse } from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { buildOrderByClause } from '@/services/admin/util/build-order-by-clause'; -import type { FilterParams } from '@/services/lib/filtering'; -import { db } from '@/lib/db'; -import { buildFilterClauses } from '@/services/admin/util/build-filter-clause'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; +import { toPaginatedReponse } from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { buildOrderByClause } from '@/services/db/admin/util/build-order-by-clause'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { db } from '@/services/db/client'; +import { buildFilterClauses } from '@/services/db/admin/util/build-filter-clause'; interface PaymentRow { id: string; diff --git a/packages/app/control/src/services/db/admin/payouts.ts b/packages/app/control/src/services/db/admin/payouts.ts new file mode 100644 index 000000000..77f8a7613 --- /dev/null +++ b/packages/app/control/src/services/db/admin/payouts.ts @@ -0,0 +1,110 @@ +import { db } from '@/services/db/client'; +import { PayoutStatus } from '@/types/payouts'; +import { + type PaginationParams, + toPaginatedReponse, +} from '@/services/db/_lib/pagination'; + +import type { PayoutType } from '@/types/payouts'; +import { z } from 'zod'; + +export const adminGetPayoutSchema = z.object({ + payoutId: z.uuid(), +}); + +export const adminGetPayout = async ({ + payoutId, +}: z.infer) => { + return await db.payout.findUnique({ + where: { id: payoutId }, + include: { + recipientGithubLink: { select: { githubId: true } }, + echoApp: { include: { githubLink: { select: { githubId: true } } } }, + }, + }); +}; + +export const adminUpdatePayoutSchema = z.object({ + payoutId: z.uuid(), + status: z.enum(PayoutStatus), + transactionId: z.string(), + senderAddress: z.string(), +}); + +export const adminUpdatePayout = async ( + input: z.infer +) => { + const { payoutId, ...data } = adminUpdatePayoutSchema.parse(input); + return await db.payout.update({ + where: { id: payoutId }, + data, + }); +}; + +export async function adminListPendingPayouts(pagination: PaginationParams) { + const { page, page_size } = pagination; + const skip = page * page_size; + + const [items, totalCount] = await Promise.all([ + db.payout.findMany({ + where: { status: PayoutStatus.PENDING }, + orderBy: { createdAt: 'desc' }, + skip, + take: page_size, + include: { + user: { select: { id: true, email: true, name: true } }, + echoApp: { + select: { + id: true, + name: true, + githubLink: { select: { githubId: true } }, + }, + }, + recipientGithubLink: { select: { id: true, githubUrl: true } }, + }, + }), + db.payout.count({ where: { status: PayoutStatus.PENDING } }), + ]); + + return toPaginatedReponse({ + items: items.map(item => ({ + ...item, + type: item.type as PayoutType, + amount: Number(item.amount), + })), + page, + page_size, + total_count: totalCount, + }); +} + +export async function adminListCompletedPayouts(pagination: PaginationParams) { + const { page, page_size } = pagination; + const skip = page * page_size; + + const [items, totalCount] = await Promise.all([ + db.payout.findMany({ + where: { status: PayoutStatus.COMPLETED }, + orderBy: { createdAt: 'desc' }, + skip, + take: page_size, + include: { + user: { select: { id: true, email: true, name: true } }, + echoApp: { select: { id: true, name: true } }, + recipientGithubLink: { select: { id: true, githubUrl: true } }, + }, + }), + db.payout.count({ where: { status: PayoutStatus.COMPLETED } }), + ]); + + return toPaginatedReponse({ + items: items.map(item => ({ + ...item, + type: item.type as PayoutType, + amount: Number(item.amount), + })), + page, + page_size, + total_count: totalCount, + }); +} diff --git a/packages/app/control/src/services/admin/schemas.ts b/packages/app/control/src/services/db/admin/schemas.ts similarity index 100% rename from packages/app/control/src/services/admin/schemas.ts rename to packages/app/control/src/services/db/admin/schemas.ts diff --git a/packages/app/control/src/services/admin/type/chart.ts b/packages/app/control/src/services/db/admin/type/chart.ts similarity index 100% rename from packages/app/control/src/services/admin/type/chart.ts rename to packages/app/control/src/services/db/admin/type/chart.ts diff --git a/packages/app/control/src/services/admin/type/overview-metric.ts b/packages/app/control/src/services/db/admin/type/overview-metric.ts similarity index 100% rename from packages/app/control/src/services/admin/type/overview-metric.ts rename to packages/app/control/src/services/db/admin/type/overview-metric.ts diff --git a/packages/app/control/src/services/admin/type/parameter.ts b/packages/app/control/src/services/db/admin/type/parameter.ts similarity index 80% rename from packages/app/control/src/services/admin/type/parameter.ts rename to packages/app/control/src/services/db/admin/type/parameter.ts index ca25710d1..3d30d253c 100644 --- a/packages/app/control/src/services/admin/type/parameter.ts +++ b/packages/app/control/src/services/db/admin/type/parameter.ts @@ -1,4 +1,4 @@ -import type { FilterValue } from '@/services/lib/filtering'; +import type { FilterValue } from '@/services/db/_lib/filtering'; // Values allowed as SQL query parameters export type SQLParameter = string | number | boolean | Date | null; diff --git a/packages/app/control/src/services/admin/user-earning-summary.ts b/packages/app/control/src/services/db/admin/user-earning-summary.ts similarity index 99% rename from packages/app/control/src/services/admin/user-earning-summary.ts rename to packages/app/control/src/services/db/admin/user-earning-summary.ts index 5edd8e24b..abcf95b50 100644 --- a/packages/app/control/src/services/admin/user-earning-summary.ts +++ b/packages/app/control/src/services/db/admin/user-earning-summary.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { OverviewMetricConfig } from './type/overview-metric'; import { percentChange } from './util/percent-change'; diff --git a/packages/app/control/src/services/admin/user-earnings.ts b/packages/app/control/src/services/db/admin/user-earnings.ts similarity index 92% rename from packages/app/control/src/services/admin/user-earnings.ts rename to packages/app/control/src/services/db/admin/user-earnings.ts index 5630dd897..e4e3abae6 100644 --- a/packages/app/control/src/services/admin/user-earnings.ts +++ b/packages/app/control/src/services/db/admin/user-earnings.ts @@ -4,13 +4,13 @@ * sorting, and filtering parameters and returns data in the expected format. */ -import type { PaginationParams } from '@/services/lib/pagination'; -import { toPaginatedReponse } from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { buildOrderByClause } from '@/services/admin/util/build-order-by-clause'; -import type { FilterParams } from '@/services/lib/filtering'; -import { db } from '@/lib/db'; -import { buildFilterClauses } from '@/services/admin/util/build-filter-clause'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; +import { toPaginatedReponse } from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { buildOrderByClause } from '@/services/db/admin/util/build-order-by-clause'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { db } from '@/services/db/client'; +import { buildFilterClauses } from '@/services/db/admin/util/build-filter-clause'; interface UserEarningsRow { id: string; diff --git a/packages/app/control/src/services/admin/user-spending-summary.ts b/packages/app/control/src/services/db/admin/user-spending-summary.ts similarity index 99% rename from packages/app/control/src/services/admin/user-spending-summary.ts rename to packages/app/control/src/services/db/admin/user-spending-summary.ts index 0a7f65e9a..58190a9a4 100644 --- a/packages/app/control/src/services/admin/user-spending-summary.ts +++ b/packages/app/control/src/services/db/admin/user-spending-summary.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { OverviewMetricConfig } from './type/overview-metric'; import { percentChange } from './util/percent-change'; diff --git a/packages/app/control/src/services/admin/user-spending.ts b/packages/app/control/src/services/db/admin/user-spending.ts similarity index 87% rename from packages/app/control/src/services/admin/user-spending.ts rename to packages/app/control/src/services/db/admin/user-spending.ts index 519be7e0b..6b4ee2ee4 100644 --- a/packages/app/control/src/services/admin/user-spending.ts +++ b/packages/app/control/src/services/db/admin/user-spending.ts @@ -4,13 +4,13 @@ * sorting, and filtering parameters and returns spending data in the expected format. */ -import type { PaginationParams } from '@/services/lib/pagination'; -import { toPaginatedReponse } from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { buildOrderByClause } from '@/services/admin/util/build-order-by-clause'; -import type { FilterParams } from '@/services/lib/filtering'; -import { db } from '@/lib/db'; -import { buildFilterClauses } from '@/services/admin/util/build-filter-clause'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; +import { toPaginatedReponse } from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { buildOrderByClause } from '@/services/db/admin/util/build-order-by-clause'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { db } from '@/services/db/client'; +import { buildFilterClauses } from '@/services/db/admin/util/build-filter-clause'; interface UserSpendingRow { id: string; diff --git a/packages/app/control/src/services/admin/user/apps.ts b/packages/app/control/src/services/db/admin/user/apps.ts similarity index 95% rename from packages/app/control/src/services/admin/user/apps.ts rename to packages/app/control/src/services/db/admin/user/apps.ts index e005444d8..bf9b3298a 100644 --- a/packages/app/control/src/services/admin/user/apps.ts +++ b/packages/app/control/src/services/db/admin/user/apps.ts @@ -6,13 +6,13 @@ * sorting, and filtering parameters and returns user app data in the expected format. */ -import type { PaginationParams } from '@/services/lib/pagination'; -import { toPaginatedReponse } from '@/services/lib/pagination'; -import type { MultiSortParams } from '@/services/lib/sorting'; -import { buildOrderByClause } from '@/services/admin/util/build-order-by-clause'; -import type { FilterParams } from '@/services/lib/filtering'; -import { db } from '@/lib/db'; -import { buildFilterClauses } from '@/services/admin/util/build-filter-clause'; +import type { PaginationParams } from '@/services/db/_lib/pagination'; +import { toPaginatedReponse } from '@/services/db/_lib/pagination'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; +import { buildOrderByClause } from '@/services/db/admin/util/build-order-by-clause'; +import type { FilterParams } from '@/services/db/_lib/filtering'; +import { db } from '@/services/db/client'; +import { buildFilterClauses } from '@/services/db/admin/util/build-filter-clause'; interface UserAppRow { id: string; diff --git a/packages/app/control/src/services/admin/user/user-charts.ts b/packages/app/control/src/services/db/admin/user/user-charts.ts similarity index 97% rename from packages/app/control/src/services/admin/user/user-charts.ts rename to packages/app/control/src/services/db/admin/user/user-charts.ts index edc165b80..48211bdfe 100644 --- a/packages/app/control/src/services/admin/user/user-charts.ts +++ b/packages/app/control/src/services/db/admin/user/user-charts.ts @@ -1,5 +1,5 @@ import type { ChartItem } from '../type/chart'; -import { getUserCreatorActivity } from '@/services/user/activity'; +import { getUserCreatorActivity } from '@/services/db/user/activity'; import type { ChartConfig } from '@/components/ui/chart'; interface GetUserAppsChartsInput { diff --git a/packages/app/control/src/services/admin/user/user-summary.ts b/packages/app/control/src/services/db/admin/user/user-summary.ts similarity index 99% rename from packages/app/control/src/services/admin/user/user-summary.ts rename to packages/app/control/src/services/db/admin/user/user-summary.ts index 2530d6c09..cebb3af2d 100644 --- a/packages/app/control/src/services/admin/user/user-summary.ts +++ b/packages/app/control/src/services/db/admin/user/user-summary.ts @@ -3,7 +3,7 @@ * Combines earnings, spending, and app data into summary metrics for OverviewPanel */ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { OverviewMetricConfig } from '../type/overview-metric'; import { percentChange } from '../util/percent-change'; diff --git a/packages/app/control/src/services/admin/user/user.ts b/packages/app/control/src/services/db/admin/user/user.ts similarity index 87% rename from packages/app/control/src/services/admin/user/user.ts rename to packages/app/control/src/services/db/admin/user/user.ts index f29a967b0..7207737f9 100644 --- a/packages/app/control/src/services/admin/user/user.ts +++ b/packages/app/control/src/services/db/admin/user/user.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; export const getUser = async (userId: string) => { const user = await db.user.findUnique({ diff --git a/packages/app/control/src/services/admin/util/build-filter-clause.ts b/packages/app/control/src/services/db/admin/util/build-filter-clause.ts similarity index 98% rename from packages/app/control/src/services/admin/util/build-filter-clause.ts rename to packages/app/control/src/services/db/admin/util/build-filter-clause.ts index 14c7e3ae2..ae1dc2c48 100644 --- a/packages/app/control/src/services/admin/util/build-filter-clause.ts +++ b/packages/app/control/src/services/db/admin/util/build-filter-clause.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ -import type { FilterParams } from '@/services/lib/filtering'; +import type { FilterParams } from '@/services/db/_lib/filtering'; import type { SQLParameter, NonArrayFilterValue, -} from '@/services/admin/type/parameter'; +} from '@/services/db/admin/type/parameter'; import { convertValue } from './convert-value'; type BuildFilterConfig = { diff --git a/packages/app/control/src/services/admin/util/build-order-by-clause.ts b/packages/app/control/src/services/db/admin/util/build-order-by-clause.ts similarity index 95% rename from packages/app/control/src/services/admin/util/build-order-by-clause.ts rename to packages/app/control/src/services/db/admin/util/build-order-by-clause.ts index f0dd2c3db..114bfdf1d 100644 --- a/packages/app/control/src/services/admin/util/build-order-by-clause.ts +++ b/packages/app/control/src/services/db/admin/util/build-order-by-clause.ts @@ -1,4 +1,4 @@ -import type { MultiSortParams } from '@/services/lib/sorting'; +import type { MultiSortParams } from '@/services/db/_lib/sorting'; type OrderByColumnMappings = Record; diff --git a/packages/app/control/src/services/admin/util/convert-value.ts b/packages/app/control/src/services/db/admin/util/convert-value.ts similarity index 100% rename from packages/app/control/src/services/admin/util/convert-value.ts rename to packages/app/control/src/services/db/admin/util/convert-value.ts diff --git a/packages/app/control/src/services/admin/util/percent-change.ts b/packages/app/control/src/services/db/admin/util/percent-change.ts similarity index 100% rename from packages/app/control/src/services/admin/util/percent-change.ts rename to packages/app/control/src/services/db/admin/util/percent-change.ts diff --git a/packages/app/control/src/services/api-keys.ts b/packages/app/control/src/services/db/api-keys.ts similarity index 94% rename from packages/app/control/src/services/api-keys.ts rename to packages/app/control/src/services/db/api-keys.ts index 84949bfb9..6286c72c6 100644 --- a/packages/app/control/src/services/api-keys.ts +++ b/packages/app/control/src/services/db/api-keys.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import type { ApiKey, Prisma } from '@/generated/prisma'; -import { db } from '../lib/db'; -import { AppRole, MembershipStatus } from '../lib/permissions/types'; -import { PermissionService } from '../lib/permissions'; -import type { PaginationParams } from './lib/pagination'; -import { toPaginatedReponse } from './lib/pagination'; +import { db } from './client'; +import { AppRole, MembershipStatus } from './apps/permissions/types'; +import { PermissionService } from './apps/permissions'; +import type { PaginationParams } from './_lib/pagination'; +import { toPaginatedReponse } from './_lib/pagination'; import { createHmac, randomBytes, randomUUID } from 'node:crypto'; import { env } from '@/env'; diff --git a/packages/app/control/src/services/apps/count.ts b/packages/app/control/src/services/db/apps/count.ts similarity index 87% rename from packages/app/control/src/services/apps/count.ts rename to packages/app/control/src/services/db/apps/count.ts index c7a8acda6..d37b7a5ae 100644 --- a/packages/app/control/src/services/apps/count.ts +++ b/packages/app/control/src/services/db/apps/count.ts @@ -1,7 +1,7 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { Prisma } from '@/generated/prisma'; -import { AppRole } from '@/lib/permissions'; +import { AppRole } from '@/services/db/apps/permissions'; export const countApps = async (where: Prisma.EchoAppWhereInput) => { return await db.echoApp.count({ diff --git a/packages/app/control/src/services/apps/create.ts b/packages/app/control/src/services/db/apps/create.ts similarity index 89% rename from packages/app/control/src/services/apps/create.ts rename to packages/app/control/src/services/db/apps/create.ts index cbd1b140d..0ceb18c97 100644 --- a/packages/app/control/src/services/apps/create.ts +++ b/packages/app/control/src/services/db/apps/create.ts @@ -2,12 +2,12 @@ import type { z } from 'zod'; import { createAppSchema } from './lib/schemas'; -import { queueJob } from '../email/queue'; -import { EmailType } from '../email/emails/types'; +import { queueJob } from '../../email/queue'; +import { EmailType } from '../../email/emails/types'; import { logger } from '@/logger'; -import { db } from '@/lib/db'; -import { AppRole, MembershipStatus } from '@/lib/permissions'; +import { db } from '@/services/db/client'; +import { AppRole, MembershipStatus } from '@/services/db/apps/permissions'; export const createApp = async ( userId: string, diff --git a/packages/app/control/src/services/apps/delete.ts b/packages/app/control/src/services/db/apps/delete.ts similarity index 74% rename from packages/app/control/src/services/apps/delete.ts rename to packages/app/control/src/services/db/apps/delete.ts index 7e79a64da..9fb70ca53 100644 --- a/packages/app/control/src/services/apps/delete.ts +++ b/packages/app/control/src/services/db/apps/delete.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; import { appIdSchema } from './lib/schemas'; -import { db } from '@/lib/db'; -import type { UserId } from '../lib/schemas'; -import { AppRole } from '@/lib/permissions'; +import { db } from '@/services/db/client'; +import type { UserId } from '../_lib/schemas'; +import { AppRole } from '@/services/db/apps/permissions'; export const deleteAppSchema = z.object({ appId: appIdSchema, diff --git a/packages/app/control/src/services/apps/earnings.ts b/packages/app/control/src/services/db/apps/earnings.ts similarity index 93% rename from packages/app/control/src/services/apps/earnings.ts rename to packages/app/control/src/services/db/apps/earnings.ts index 5fab06b26..c06bdccb3 100644 --- a/packages/app/control/src/services/apps/earnings.ts +++ b/packages/app/control/src/services/db/apps/earnings.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import z from 'zod'; export const appEarningsSchema = z.object({ diff --git a/packages/app/control/src/services/db/apps/free-tier.ts b/packages/app/control/src/services/db/apps/free-tier.ts new file mode 100644 index 000000000..0dfd5fbe4 --- /dev/null +++ b/packages/app/control/src/services/db/apps/free-tier.ts @@ -0,0 +1,133 @@ +import z from 'zod'; + +import { db } from '@/services/db/client'; +import { AppRole } from '@/services/db/apps/permissions'; + +import type { Prisma } from '@/generated/prisma'; + +export const getFreeTierSpendPool = async (appId: string, userId: string) => { + const spendPool = await db.spendPool.findFirst({ + where: { + echoAppId: appId, + isArchived: false, + echoApp: { + appMemberships: { + some: { + userId, + role: AppRole.OWNER, + }, + }, + }, + }, + }); + + if (!spendPool) { + return null; + } + + return { + ...spendPool, + archivedAt: spendPool.archivedAt?.toISOString(), + createdAt: spendPool.createdAt.toISOString(), + updatedAt: spendPool.updatedAt.toISOString(), + totalPaid: Number(spendPool.totalPaid), + totalSpent: Number(spendPool.totalSpent), + balance: Number(spendPool.totalPaid.minus(spendPool.totalSpent)), + perUserSpendLimit: spendPool.perUserSpendLimit?.toNumber(), + }; +}; + +export const updateFreeTierSpendPoolSchema = z.object({ + perUserSpendLimit: z.number().positive(), +}); + +export const updateFreeTierSpendPool = async ( + appId: string, + userId: string, + input: z.infer +) => { + const parsedInput = updateFreeTierSpendPoolSchema.safeParse(input); + + if (!parsedInput.success) { + throw new Error('Invalid input'); + } + + // FROM JSON: + // this is a bit of a hack because we are currently only allowing one spend pool per app + // IMO the abstraction of updating by app ID is better than specifying the spend poolID in the input + await db.spendPool.updateMany({ + where: { + echoAppId: appId, + echoApp: { + appMemberships: { + some: { + userId, + role: AppRole.OWNER, + }, + }, + }, + }, + data: parsedInput.data, + }); +}; + +interface UpdateSpendPoolFromPaymentData { + echoAppId: string; + paymentId: string; + amountInCents: number; + metadata: Record; +} + +export async function updateSpendPoolFromPayment( + tx: Prisma.TransactionClient, + data: UpdateSpendPoolFromPaymentData +): Promise { + const { echoAppId, paymentId, amountInCents, metadata } = data; + + // Get or create the free tier spend pool for this app + let spendPool = await tx.spendPool.findFirst({ + where: { + echoAppId, + isArchived: false, + }, + }); + + spendPool ??= await tx.spendPool.create({ + data: { + name: + metadata.poolName ?? + `Free Tier Credits - ${new Date().toISOString().split('T')[0]}`, + description: 'Free tier credits pool for app users', + totalPaid: 0, // Will be funded by payments + echoAppId, + }, + }); + + // Fund the spend pool with this payment + await tx.payment.update({ + where: { paymentId }, + data: { + spendPoolId: spendPool.id, + }, + }); + + // Update the total amount in the spend pool + await tx.spendPool.update({ + where: { id: spendPool.id }, + data: { + totalPaid: { + increment: amountInCents / 100, + }, + }, + }); + + // Set default spend limit if provided + if (metadata?.defaultSpendLimit) { + const defaultLimit = parseFloat(metadata.defaultSpendLimit); + if (!isNaN(defaultLimit) && defaultLimit > 0) { + console.log( + `Default spend limit of $${defaultLimit} will be applied to new users for pool ${spendPool.id}` + ); + } + } +} diff --git a/packages/app/control/src/services/apps/get.ts b/packages/app/control/src/services/db/apps/get.ts similarity index 59% rename from packages/app/control/src/services/apps/get.ts rename to packages/app/control/src/services/db/apps/get.ts index 7f5c4fc2a..60cb144d1 100644 --- a/packages/app/control/src/services/apps/get.ts +++ b/packages/app/control/src/services/db/apps/get.ts @@ -1,9 +1,9 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { appSelect } from './lib/select'; import type { AppId } from './lib/schemas'; -import { AppRole } from '@/lib/permissions'; +import { AppRole } from '@/services/db/apps/permissions'; export const getApp = async (appId: AppId) => { return await db.echoApp.findUnique({ @@ -31,3 +31,16 @@ export const getAppOwner = async (appId: AppId) => { return owner?.user; }; + +export const getAppWithOwnerCheck = async (appId: AppId, userId: string) => { + return await db.echoApp.findUnique({ + where: { + id: appId, + isArchived: false, + appMemberships: { + some: { userId, role: AppRole.OWNER, isArchived: false }, + }, + }, + select: appSelect, + }); +}; diff --git a/packages/app/control/src/services/apps/github-link.ts b/packages/app/control/src/services/db/apps/github-link.ts similarity index 84% rename from packages/app/control/src/services/apps/github-link.ts rename to packages/app/control/src/services/db/apps/github-link.ts index dcc6faf80..d62d10286 100644 --- a/packages/app/control/src/services/apps/github-link.ts +++ b/packages/app/control/src/services/db/apps/github-link.ts @@ -1,9 +1,9 @@ import type { z } from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; -import { resolveGithubId } from '../github/link'; -import { githubLinkSchema } from '../github/schema'; +import { resolveGithubId } from '../../github/link'; +import { githubLinkSchema } from '../../github/schema'; export const getGithubLink = async (appId: string) => { return await db.githubLink.findUnique({ diff --git a/packages/app/control/src/services/apps/lib/schemas.ts b/packages/app/control/src/services/db/apps/lib/schemas.ts similarity index 100% rename from packages/app/control/src/services/apps/lib/schemas.ts rename to packages/app/control/src/services/db/apps/lib/schemas.ts diff --git a/packages/app/control/src/services/apps/lib/select.ts b/packages/app/control/src/services/db/apps/lib/select.ts similarity index 100% rename from packages/app/control/src/services/apps/lib/select.ts rename to packages/app/control/src/services/db/apps/lib/select.ts diff --git a/packages/app/control/src/services/apps/list.ts b/packages/app/control/src/services/db/apps/list.ts similarity index 92% rename from packages/app/control/src/services/apps/list.ts rename to packages/app/control/src/services/db/apps/list.ts index d1ca035a2..4aa62c14b 100644 --- a/packages/app/control/src/services/apps/list.ts +++ b/packages/app/control/src/services/db/apps/list.ts @@ -1,15 +1,15 @@ import z from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { countApps } from './count'; -import { type PaginationParams, toPaginatedReponse } from '../lib/pagination'; +import { type PaginationParams, toPaginatedReponse } from '../_lib/pagination'; import { appSelect } from './lib/select'; import type { Prisma } from '@/generated/prisma'; -import { AppRole, MembershipStatus } from '@/lib/permissions'; +import { AppRole, MembershipStatus } from '@/services/db/apps/permissions'; export const listAppsSchema = z.object({ search: z.string().optional(), diff --git a/packages/app/control/src/services/apps/markup.ts b/packages/app/control/src/services/db/apps/markup.ts similarity index 89% rename from packages/app/control/src/services/apps/markup.ts rename to packages/app/control/src/services/db/apps/markup.ts index 289f76a97..89f86a912 100644 --- a/packages/app/control/src/services/apps/markup.ts +++ b/packages/app/control/src/services/db/apps/markup.ts @@ -1,9 +1,9 @@ import type z from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { updateMarkupSchema } from './lib/schemas'; -import { AppRole } from '@/lib/permissions'; +import { AppRole } from '@/services/db/apps/permissions'; import type { AppId } from './lib/schemas'; export const getAppMarkup = async (appId: AppId) => { diff --git a/packages/app/control/src/services/apps/membership.ts b/packages/app/control/src/services/db/apps/membership.ts similarity index 75% rename from packages/app/control/src/services/apps/membership.ts rename to packages/app/control/src/services/db/apps/membership.ts index c853bd180..bd88d7985 100644 --- a/packages/app/control/src/services/apps/membership.ts +++ b/packages/app/control/src/services/db/apps/membership.ts @@ -1,11 +1,11 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { appSelect } from './lib/select'; -import { AppRole, MembershipStatus } from '@/lib/permissions'; +import { AppRole, MembershipStatus } from '@/services/db/apps/permissions'; import type { AppId } from './lib/schemas'; import { z } from 'zod'; -import type { PaginationParams } from '../lib/pagination'; -import { toPaginatedReponse } from '../lib/pagination'; +import type { PaginationParams } from '../_lib/pagination'; +import { toPaginatedReponse } from '../_lib/pagination'; import type { Prisma } from '@/generated/prisma'; export const getAppMembership = async (userId: string, appId: AppId) => { @@ -157,3 +157,57 @@ const countAppMembershipsInternal = async ( ) => { return await db.appMembership.count({ where }); }; + +export async function setAppMembershipReferrer( + userId: string, + echoAppId: string, + code: string +): Promise { + const appMembership = await db.appMembership.findUnique({ + where: { + userId_echoAppId: { + userId, + echoAppId, + }, + referrerId: null, + }, + }); + + if (appMembership) { + // If the user already has a referrer, return false + return false; + } + + const referralCode = await db.referralCode.findUnique({ + where: { + code, + }, + }); + + if (!referralCode) { + return false; + } + + await db.$transaction(async tx => { + await tx.appMembership.update({ + where: { + userId_echoAppId: { + userId, + echoAppId, + }, + }, + data: { + referrerId: referralCode.id, + }, + }); + + await tx.referralCode.update({ + where: { id: referralCode.id }, + data: { + usedAt: new Date(), + }, + }); + }); + + return true; +} diff --git a/packages/app/control/src/lib/permissions/index.ts b/packages/app/control/src/services/db/apps/permissions/index.ts similarity index 100% rename from packages/app/control/src/lib/permissions/index.ts rename to packages/app/control/src/services/db/apps/permissions/index.ts diff --git a/packages/app/control/src/lib/permissions/service.ts b/packages/app/control/src/services/db/apps/permissions/service.ts similarity index 99% rename from packages/app/control/src/lib/permissions/service.ts rename to packages/app/control/src/services/db/apps/permissions/service.ts index 83bd343b1..93fac4e08 100644 --- a/packages/app/control/src/lib/permissions/service.ts +++ b/packages/app/control/src/services/db/apps/permissions/service.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { UserAppAccess } from './types'; import { AppRole, MembershipStatus, Permission } from './types'; import type { EchoApp, AppMembership } from '@/generated/prisma'; diff --git a/packages/app/control/src/lib/permissions/types.ts b/packages/app/control/src/services/db/apps/permissions/types.ts similarity index 100% rename from packages/app/control/src/lib/permissions/types.ts rename to packages/app/control/src/services/db/apps/permissions/types.ts diff --git a/packages/app/control/src/services/apps/referral-code.ts b/packages/app/control/src/services/db/apps/referral-code.ts similarity index 95% rename from packages/app/control/src/services/apps/referral-code.ts rename to packages/app/control/src/services/db/apps/referral-code.ts index 4dfaf07e0..5685680c3 100644 --- a/packages/app/control/src/services/apps/referral-code.ts +++ b/packages/app/control/src/services/db/apps/referral-code.ts @@ -1,9 +1,9 @@ import z from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { appIdSchema } from './lib/schemas'; -import type { UserId } from '@/services/lib/schemas'; +import type { UserId } from '@/services/db/_lib/schemas'; import { env } from '@/env'; diff --git a/packages/app/control/src/services/apps/referral-reward.ts b/packages/app/control/src/services/db/apps/referral-reward.ts similarity index 97% rename from packages/app/control/src/services/apps/referral-reward.ts rename to packages/app/control/src/services/db/apps/referral-reward.ts index 60fe1d4ce..5a547d6a7 100644 --- a/packages/app/control/src/services/apps/referral-reward.ts +++ b/packages/app/control/src/services/db/apps/referral-reward.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { z } from 'zod'; import type { AppId } from './lib/schemas'; diff --git a/packages/app/control/src/services/apps/stats.ts b/packages/app/control/src/services/db/apps/stats.ts similarity index 97% rename from packages/app/control/src/services/apps/stats.ts rename to packages/app/control/src/services/db/apps/stats.ts index 5a42741de..274fd8ecc 100644 --- a/packages/app/control/src/services/apps/stats.ts +++ b/packages/app/control/src/services/db/apps/stats.ts @@ -1,8 +1,8 @@ import z from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; -import { userIdSchema } from '../lib/schemas'; +import { userIdSchema } from '../_lib/schemas'; import { appIdSchema } from './lib/schemas'; export const getBucketedAppStatsSchema = z.object({ diff --git a/packages/app/control/src/services/apps/tokens.ts b/packages/app/control/src/services/db/apps/tokens.ts similarity index 81% rename from packages/app/control/src/services/apps/tokens.ts rename to packages/app/control/src/services/db/apps/tokens.ts index 936b9902f..1b4275e60 100644 --- a/packages/app/control/src/services/apps/tokens.ts +++ b/packages/app/control/src/services/db/apps/tokens.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { AppId } from './lib/schemas'; diff --git a/packages/app/control/src/services/apps/transactions.ts b/packages/app/control/src/services/db/apps/transactions.ts similarity index 97% rename from packages/app/control/src/services/apps/transactions.ts rename to packages/app/control/src/services/db/apps/transactions.ts index 8b0f1b3b2..4b1c17ca6 100644 --- a/packages/app/control/src/services/apps/transactions.ts +++ b/packages/app/control/src/services/db/apps/transactions.ts @@ -2,12 +2,12 @@ import z from 'zod'; import { format } from 'date-fns'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { toPaginatedReponse, type PaginationParams, -} from '@/services/lib/pagination'; +} from '@/services/db/_lib/pagination'; export const listAppTransactionsSchema = z.object({ appId: z.uuid(), diff --git a/packages/app/control/src/services/apps/update.ts b/packages/app/control/src/services/db/apps/update.ts similarity index 86% rename from packages/app/control/src/services/apps/update.ts rename to packages/app/control/src/services/db/apps/update.ts index 979fdc3d3..e16d34028 100644 --- a/packages/app/control/src/services/apps/update.ts +++ b/packages/app/control/src/services/db/apps/update.ts @@ -3,9 +3,9 @@ import type z from 'zod'; import type { AppId } from './lib/schemas'; import { updateAppSchema } from './lib/schemas'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; -import { AppRole } from '@/lib/permissions'; +import { AppRole } from '@/services/db/apps/permissions'; export const updateApp = async ( appId: AppId, diff --git a/packages/app/control/src/services/apps/users.ts b/packages/app/control/src/services/db/apps/users.ts similarity index 97% rename from packages/app/control/src/services/apps/users.ts rename to packages/app/control/src/services/db/apps/users.ts index 6e315acd5..2024e30e7 100644 --- a/packages/app/control/src/services/apps/users.ts +++ b/packages/app/control/src/services/db/apps/users.ts @@ -1,10 +1,10 @@ import z from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import { type PaginationParams, toPaginatedReponse, -} from '@/services/lib/pagination'; +} from '@/services/db/_lib/pagination'; import type { Prisma } from '@/generated/prisma'; export const appUsersSchema = z.object({ diff --git a/packages/app/control/src/services/db/auth/oauth-token.ts b/packages/app/control/src/services/db/auth/oauth-token.ts new file mode 100644 index 000000000..cbc7804f4 --- /dev/null +++ b/packages/app/control/src/services/db/auth/oauth-token.ts @@ -0,0 +1,91 @@ +import { db } from '../client'; + +import { createAppSession, updateAppSession } from './session'; +import { createRefreshToken, archiveRefreshToken } from './refresh'; + +import type { TokenMetadata } from '@/types/token-metadata'; + +interface IssueOAuthTokenParams { + userId: string; + appId: string; + scope: string; + metadata?: TokenMetadata; +} + +export const issueOAuthToken = async ({ + userId, + appId, + scope, + metadata, +}: IssueOAuthTokenParams) => { + return await db.$transaction(async tx => { + const session = await createAppSession( + { + userId: userId, + appId: appId, + metadata, + }, + tx + ); + + const refreshToken = await createRefreshToken( + { + userId: userId, + echoAppId: appId, + scope, + sessionId: session.id, + }, + tx + ); + + return { + session, + refreshToken, + }; + }); +}; + +interface RefreshOAuthTokenParams { + userId: string; + appId: string; + scope: string; + metadata?: TokenMetadata; + sessionId: string; + token: string; +} + +export const refreshOAuthToken = async ({ + userId, + appId, + scope, + metadata, + sessionId, + token, +}: RefreshOAuthTokenParams) => { + return await db.$transaction(async tx => { + const [newRefreshToken, updatedSession] = await Promise.all([ + createRefreshToken( + { + userId, + echoAppId: appId, + scope, + sessionId, + }, + tx + ), + updateAppSession( + { + sessionId, + metadata, + }, + tx + ), + archiveRefreshToken(token, tx), + ]); + + return { + newRefreshToken, + updatedSession, + }; + }); +}; diff --git a/packages/app/control/src/services/db/auth/refresh.ts b/packages/app/control/src/services/db/auth/refresh.ts new file mode 100644 index 000000000..a47ac2817 --- /dev/null +++ b/packages/app/control/src/services/db/auth/refresh.ts @@ -0,0 +1,77 @@ +import z from 'zod'; + +import { nanoid } from 'nanoid'; + +import { addSeconds, subMilliseconds } from 'date-fns'; + +import { db } from '../client'; + +import { env } from '@/env'; + +import type { Prisma } from '@/generated/prisma'; + +export const findRefreshToken = async (token: string) => { + return await db.refreshToken.findUnique({ + where: { + token, + OR: [ + { isArchived: false, archivedAt: null }, + { + isArchived: true, + archivedAt: { + lt: subMilliseconds( + new Date(), + env.OAUTH_REFRESH_TOKEN_ARCHIVE_GRACE_MS + ), + }, + expiresAt: { lt: new Date() }, + }, + ], + session: { isArchived: false, revokedAt: null }, + echoApp: { isArchived: false }, + user: { isArchived: false }, + }, + include: { + user: true, + echoApp: true, + session: true, + }, + }); +}; + +const createRefreshTokenParamsSchema = z.object({ + userId: z.uuid(), + echoAppId: z.uuid(), + scope: z.string().default(''), + sessionId: z.uuid(), +}); + +export const createRefreshToken = async ( + params: z.input, + tx?: Prisma.TransactionClient +) => { + const client = tx ?? db; + const tokenData = createRefreshTokenParamsSchema.parse(params); + return client.refreshToken.create({ + data: { + token: `refresh_${nanoid(48)}`, + expiresAt: addSeconds(new Date(), env.OAUTH_REFRESH_TOKEN_EXPIRY_SECONDS), + ...tokenData, + }, + include: { + user: true, + echoApp: true, + }, + }); +}; + +export const archiveRefreshToken = async ( + refreshToken: string, + tx?: Prisma.TransactionClient +) => { + const client = tx ?? db; + await client.refreshToken.update({ + where: { token: refreshToken }, + data: { isArchived: true, archivedAt: new Date() }, + }); +}; diff --git a/packages/app/control/src/services/db/auth/session.ts b/packages/app/control/src/services/db/auth/session.ts new file mode 100644 index 000000000..2ada416e7 --- /dev/null +++ b/packages/app/control/src/services/db/auth/session.ts @@ -0,0 +1,57 @@ +import z from 'zod'; + +import { db } from '../client'; + +import type { Prisma } from '@/generated/prisma'; + +const metdataSchema = z.object({ + deviceName: z.string().optional(), + userAgent: z.string().optional(), + ipAddress: z.string().optional(), +}); + +const createAppSessionParamsSchema = z.object({ + userId: z.uuid(), + appId: z.uuid(), + metadata: metdataSchema.optional(), +}); + +export const createAppSession = async ( + params: z.input, + tx?: Prisma.TransactionClient +) => { + const client = tx ?? db; + const { userId, appId, metadata } = + createAppSessionParamsSchema.parse(params); + return client.appSession.create({ + data: { + userId: userId, + echoAppId: appId, + deviceName: metadata?.deviceName, + userAgent: metadata?.userAgent, + ipAddress: metadata?.ipAddress, + }, + }); +}; + +const updateAppSessionParamsSchema = z.object({ + sessionId: z.uuid(), + metadata: metdataSchema.optional(), +}); + +export const updateAppSession = async ( + params: z.input, + tx?: Prisma.TransactionClient +) => { + const client = tx ?? db; + const { sessionId, metadata } = updateAppSessionParamsSchema.parse(params); + return await client.appSession.update({ + where: { id: sessionId }, + data: { + lastSeenAt: new Date(), + ...(metadata?.userAgent ? { userAgent: metadata.userAgent } : {}), + ...(metadata?.ipAddress ? { ipAddress: metadata.ipAddress } : {}), + ...(metadata?.deviceName ? { deviceName: metadata.deviceName } : {}), + }, + }); +}; diff --git a/packages/app/control/src/lib/db.ts b/packages/app/control/src/services/db/client.ts similarity index 100% rename from packages/app/control/src/lib/db.ts rename to packages/app/control/src/services/db/client.ts diff --git a/packages/app/control/src/services/credits/grant.ts b/packages/app/control/src/services/db/credits/grant.ts similarity index 98% rename from packages/app/control/src/services/credits/grant.ts rename to packages/app/control/src/services/db/credits/grant.ts index a4905ef4f..24c8c5003 100644 --- a/packages/app/control/src/services/credits/grant.ts +++ b/packages/app/control/src/services/db/credits/grant.ts @@ -2,7 +2,7 @@ import z from 'zod'; import { mintCreditsToUser } from './mint'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { Prisma } from '@/generated/prisma'; import { EnumPaymentSource } from '@/generated/prisma'; diff --git a/packages/app/control/src/services/credits/mint.ts b/packages/app/control/src/services/db/credits/mint.ts similarity index 84% rename from packages/app/control/src/services/credits/mint.ts rename to packages/app/control/src/services/db/credits/mint.ts index 94f01e4f9..2ff46dd8c 100644 --- a/packages/app/control/src/services/credits/mint.ts +++ b/packages/app/control/src/services/db/credits/mint.ts @@ -1,14 +1,21 @@ import z from 'zod'; -import { db } from '@/lib/db'; -import { processPaymentUpdate, PaymentStatus } from '@/lib/payment-processing'; +import { Decimal } from '@/generated/prisma/runtime/library'; + +import { db } from '@/services/db/client'; + +import { updateSpendPoolFromPayment } from '@/services/db/apps/free-tier'; + +import { handlePaymentSuccess } from '../payments/success'; -import type { Payment } from '@/generated/prisma'; -import { EnumPaymentSource, type Prisma } from '@/generated/prisma'; import { logger } from '@/logger'; -import { updateSpendPoolFromPayment } from '@/lib/spend-pools'; -import { Decimal } from '@/generated/prisma/runtime/library'; + +import { PaymentStatus } from '@/types/payments'; +import { EnumPaymentSource } from '@/generated/prisma'; + import type { User } from '@auth/core/types'; +import type { Payment } from '@/generated/prisma'; +import type { Prisma } from '@/generated/prisma'; export const mintCreditsToUserSchema = z.object({ userId: z.uuid(), @@ -37,8 +44,6 @@ export const mintCreditsToUser = async ( input: z.infer, tx?: Prisma.TransactionClient ) => { - const client = tx ?? db; - const result = mintCreditsToUserSchema.safeParse(input); if (!result.success) { @@ -74,34 +79,27 @@ export const mintCreditsToUser = async ( // Generate a unique payment ID for tracking const paymentId = `mint_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - // Create payment record - const paymentRecord = await client.payment.create({ - data: { - paymentId: paymentId, - amount: amountInDollars, + await handlePaymentSuccess( + { + userId, + amountInCents, currency: 'usd', - status: PaymentStatus.COMPLETED, - source: source, + paymentId, + metadata: { + ...metadata, + type: isFreeTier ? 'free-tier-credits' : 'admin-issued', + mintedAt: new Date().toISOString(), + ...(poolName && { poolName }), + ...(defaultSpendLimit && { + defaultSpendLimit: defaultSpendLimit.toString(), + }), + }, + echoAppId, description, - userId, + source, }, - }); - - await processPaymentUpdate(client, { - userId, - amountInCents, - paymentRecord, - metadata: { - ...metadata, - type: isFreeTier ? 'free-tier-credits' : 'admin-issued', - mintedAt: new Date().toISOString(), - ...(poolName && { poolName }), - ...(defaultSpendLimit && { - defaultSpendLimit: defaultSpendLimit.toString(), - }), - }, - echoAppId: isFreeTier ? echoAppId : undefined, - }); + tx + ); logger.emit({ severityText: 'INFO', @@ -213,13 +211,12 @@ export async function createFreeTierPaymentFromBalance( }, }); - await updateSpendPoolFromPayment( - client, - echoAppId.id, - freeTierPayment, - Number(amountInCentsDecimal), - {} - ); + await updateSpendPoolFromPayment(client, { + echoAppId: echoAppId.id, + paymentId: freeTierPayment.paymentId, + amountInCents: Number(amountInCentsDecimal), + metadata: {}, + }); // decrement the user's balance await client.user.update({ diff --git a/packages/app/control/src/services/db/emails.ts b/packages/app/control/src/services/db/emails.ts new file mode 100644 index 000000000..8648edda7 --- /dev/null +++ b/packages/app/control/src/services/db/emails.ts @@ -0,0 +1,11 @@ +import { db } from './client'; + +import type { Prisma } from '@/generated/prisma'; + +export const createEmail = async ( + data: Prisma.OutboundEmailSentCreateArgs['data'] +) => { + return await db.outboundEmailSent.create({ + data, + }); +}; diff --git a/packages/app/control/src/services/feed/index.ts b/packages/app/control/src/services/db/feed/index.ts similarity index 95% rename from packages/app/control/src/services/feed/index.ts rename to packages/app/control/src/services/db/feed/index.ts index 3ccb6a9ff..60c7ddcf5 100644 --- a/packages/app/control/src/services/feed/index.ts +++ b/packages/app/control/src/services/db/feed/index.ts @@ -1,10 +1,10 @@ -import { db } from '@/lib/db'; -import type { UserId } from '../lib/schemas'; +import { db } from '@/services/db/client'; +import type { UserId } from '../_lib/schemas'; import type { FeedActivity } from './types'; import { FeedActivityType } from './types'; import z from 'zod'; -import type { TimeBasedPaginationParams } from '../lib/pagination'; -import { toTimeBasedPaginatedReponse } from '../lib/pagination'; +import type { TimeBasedPaginationParams } from '../_lib/pagination'; +import { toTimeBasedPaginatedReponse } from '../_lib/pagination'; import { appIdSchema } from '../apps/lib/schemas'; export const userFeedSchema = z.object({ diff --git a/packages/app/control/src/services/feed/types.ts b/packages/app/control/src/services/db/feed/types.ts similarity index 100% rename from packages/app/control/src/services/feed/types.ts rename to packages/app/control/src/services/db/feed/types.ts diff --git a/packages/app/control/src/services/db/payments/create.ts b/packages/app/control/src/services/db/payments/create.ts new file mode 100644 index 000000000..fbb4aeb8d --- /dev/null +++ b/packages/app/control/src/services/db/payments/create.ts @@ -0,0 +1,13 @@ +import { db } from '../client'; + +import type { Prisma } from '@/generated/prisma'; + +export const createPayment = async ( + data: Prisma.PaymentCreateArgs['data'], + tx?: Prisma.TransactionClient +) => { + const client = tx ?? db; + return await client.payment.create({ + data, + }); +}; diff --git a/packages/app/control/src/services/db/payments/get.ts b/packages/app/control/src/services/db/payments/get.ts new file mode 100644 index 000000000..fb9575bdc --- /dev/null +++ b/packages/app/control/src/services/db/payments/get.ts @@ -0,0 +1,7 @@ +import { db } from '@/services/db/client'; + +export const getPaymentById = async (id: string) => { + return await db.payment.findUnique({ + where: { paymentId: id }, + }); +}; diff --git a/packages/app/control/src/services/payments.ts b/packages/app/control/src/services/db/payments/list.ts similarity index 90% rename from packages/app/control/src/services/payments.ts rename to packages/app/control/src/services/db/payments/list.ts index d22420924..5966b8ea3 100644 --- a/packages/app/control/src/services/payments.ts +++ b/packages/app/control/src/services/db/payments/list.ts @@ -1,6 +1,10 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; + +import { + type PaginationParams, + toPaginatedReponse, +} from '@/services/db/_lib/pagination'; -import { type PaginationParams, toPaginatedReponse } from './lib/pagination'; import type { Prisma } from '@/generated/prisma'; export async function listCreditPayments( diff --git a/packages/app/control/src/services/db/payments/success.ts b/packages/app/control/src/services/db/payments/success.ts new file mode 100644 index 000000000..603912919 --- /dev/null +++ b/packages/app/control/src/services/db/payments/success.ts @@ -0,0 +1,102 @@ +import { db } from '../client'; + +import { updateSpendPoolFromPayment } from '../apps/free-tier'; +import { updateUserBalanceFromPayment } from '../user/balance'; + +import { logger } from '@/logger'; + +import { PaymentStatus } from '@/types/payments'; + +import type { EnumPaymentSource, Prisma } from '@/generated/prisma'; + +interface PaymentSuccessData { + userId: string; + amountInCents: number; + currency: string; + paymentId: string; + metadata: Record; + source?: EnumPaymentSource; + echoAppId?: string; + description?: string; +} + +export const handlePaymentSuccess = async ( + data: PaymentSuccessData, + tx?: Prisma.TransactionClient +) => { + if (tx) { + await handlePaymentSuccessInternal(data, tx); + } else { + await db.$transaction(async tx => { + await handlePaymentSuccessInternal(data, tx); + }); + } +}; + +const handlePaymentSuccessInternal = async ( + data: PaymentSuccessData, + tx: Prisma.TransactionClient +) => { + const { + userId, + amountInCents, + currency, + paymentId, + metadata, + echoAppId, + description, + source, + } = data; + + const paymentRecord = await tx.payment.upsert({ + where: { paymentId }, + update: { status: PaymentStatus.COMPLETED }, + create: { + paymentId, + amount: amountInCents / 100, + currency, + status: PaymentStatus.COMPLETED, + description: description ?? 'Echo credits purchase', + userId, + source, + }, + }); + + // Handle free tier pool creation and funding if this is a free tier payment + if (metadata.type === 'free-tier-credits' && echoAppId) { + await updateSpendPoolFromPayment(tx, { + echoAppId, + paymentId: paymentRecord.paymentId, + amountInCents, + metadata, + }); + + logger.emit({ + severityText: 'INFO', + body: 'Free tier payment processed', + attributes: { + echoAppId, + amount: amountInCents, + amountInDollars: amountInCents / 100, + function: 'processPaymentUpdate', + }, + }); + } else { + // For non-free-tier payments, update user's totalPaid balance + await updateUserBalanceFromPayment(tx, { + userId, + amountInCents, + }); + + logger.emit({ + severityText: 'INFO', + body: 'Personal balance updated for user', + attributes: { + userId, + amount: amountInCents, + amountInDollars: amountInCents / 100, + function: 'processPaymentUpdate', + }, + }); + } +}; diff --git a/packages/app/control/src/services/db/payments/update.ts b/packages/app/control/src/services/db/payments/update.ts new file mode 100644 index 000000000..96e2d2de1 --- /dev/null +++ b/packages/app/control/src/services/db/payments/update.ts @@ -0,0 +1,13 @@ +import { db } from '@/services/db/client'; + +import type { PaymentStatus } from '@/types/payments'; + +export const updatePaymentStatus = async ( + id: string, + status: PaymentStatus +) => { + return await db.payment.update({ + where: { paymentId: id }, + data: { status: status }, + }); +}; diff --git a/packages/app/control/src/services/user/activity.ts b/packages/app/control/src/services/db/user/activity.ts similarity index 99% rename from packages/app/control/src/services/user/activity.ts rename to packages/app/control/src/services/db/user/activity.ts index b175774f1..935d1490d 100644 --- a/packages/app/control/src/services/user/activity.ts +++ b/packages/app/control/src/services/db/user/activity.ts @@ -1,6 +1,6 @@ import z from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { TransactionMetadata } from '@/generated/prisma'; export const getUserCreatorActivitySchema = z.object({ diff --git a/packages/app/control/src/lib/spend-pools/fetch-user-spend.ts b/packages/app/control/src/services/db/user/app-spend-pool.ts similarity index 85% rename from packages/app/control/src/lib/spend-pools/fetch-user-spend.ts rename to packages/app/control/src/services/db/user/app-spend-pool.ts index 147b3555b..426117d2a 100644 --- a/packages/app/control/src/lib/spend-pools/fetch-user-spend.ts +++ b/packages/app/control/src/services/db/user/app-spend-pool.ts @@ -1,14 +1,7 @@ -import type { Prisma } from '@/generated/prisma'; -import { db } from '../db'; +import { db } from '../client'; -export const getCustomerSpendInfoForApp = async ( - userId: string, - appId: string, - tx?: Prisma.TransactionClient -) => { - const client = tx ?? db; - - const spendPool = await client.spendPool.findFirst({ +export const getUserSpendInfoForApp = async (userId: string, appId: string) => { + const spendPool = await db.spendPool.findFirst({ where: { echoAppId: appId, isArchived: false, diff --git a/packages/app/control/src/services/user/balance.ts b/packages/app/control/src/services/db/user/balance.ts similarity index 84% rename from packages/app/control/src/services/user/balance.ts rename to packages/app/control/src/services/db/user/balance.ts index 5f851a2dc..57cb5482d 100644 --- a/packages/app/control/src/services/user/balance.ts +++ b/packages/app/control/src/services/db/user/balance.ts @@ -1,4 +1,4 @@ -import { db } from '../../lib/db'; +import { db } from '../client'; import type { PrismaClient } from '@/generated/prisma'; export const getUserGlobalBalance = async (userId: string) => { @@ -55,17 +55,16 @@ export const getUserAppBalance = async (userId: string, echoAppId: string) => { }; }; -/** - * Update user balance from a payment within an existing transaction - * @param tx - The database transaction - * @param userId - The user ID to update - * @param amountInCents - The payment amount in cents - */ +interface UpdateUserBalanceFromPaymentData { + userId: string; + amountInCents: number; +} + export async function updateUserBalanceFromPayment( tx: Parameters[0]>[0], - userId: string, - amountInCents: number + data: UpdateUserBalanceFromPaymentData ): Promise { + const { userId, amountInCents } = data; // Convert from cents to dollars and update user's totalPaid balance await tx.user.update({ where: { id: userId }, diff --git a/packages/app/control/src/services/db/user/create.ts b/packages/app/control/src/services/db/user/create.ts new file mode 100644 index 000000000..f2977db09 --- /dev/null +++ b/packages/app/control/src/services/db/user/create.ts @@ -0,0 +1,9 @@ +import { db } from '../client'; + +import type { Prisma } from '@/generated/prisma'; + +export const createUser = async (data: Prisma.UserCreateArgs['data']) => { + return await db.user.create({ + data, + }); +}; diff --git a/packages/app/control/src/services/db/user/get.ts b/packages/app/control/src/services/db/user/get.ts new file mode 100644 index 000000000..509acfb01 --- /dev/null +++ b/packages/app/control/src/services/db/user/get.ts @@ -0,0 +1,35 @@ +import { db } from '@/services/db/client'; + +const publicUserSelect = { + id: true, + name: true, + image: true, + createdAt: true, +}; + +export const getPublicUser = async (userId: string) => { + return await db.user.findUnique({ + where: { id: userId }, + select: publicUserSelect, + }); +}; + +const fullUserSelect = { + ...publicUserSelect, + email: true, + updatedAt: true, +}; + +export const getFullUser = async (userId: string) => { + return await db.user.findUnique({ + where: { id: userId }, + select: fullUserSelect, + }); +}; + +export const getUserByEmail = async (email: string) => { + return await db.user.findUnique({ + where: { email }, + select: fullUserSelect, + }); +}; diff --git a/packages/app/control/src/services/user/github-link.ts b/packages/app/control/src/services/db/user/github-link.ts similarity index 87% rename from packages/app/control/src/services/user/github-link.ts rename to packages/app/control/src/services/db/user/github-link.ts index 0561232f5..f43c10273 100644 --- a/packages/app/control/src/services/user/github-link.ts +++ b/packages/app/control/src/services/db/user/github-link.ts @@ -1,9 +1,9 @@ import type { z } from 'zod'; -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; -import { resolveGithubId } from '../github/link'; -import { githubLinkSchema } from '../github/schema'; +import { resolveGithubId } from '../../github/link'; +import { githubLinkSchema } from '../../github/schema'; export const getGithubLinkForUser = async (userId: string) => { return await db.githubLink.findUnique({ diff --git a/packages/app/control/src/services/user/initial-free-tier.ts b/packages/app/control/src/services/db/user/initial-free-tier.ts similarity index 94% rename from packages/app/control/src/services/user/initial-free-tier.ts rename to packages/app/control/src/services/db/user/initial-free-tier.ts index acf035c67..b9f21197e 100644 --- a/packages/app/control/src/services/user/initial-free-tier.ts +++ b/packages/app/control/src/services/db/user/initial-free-tier.ts @@ -1,5 +1,5 @@ -import { db } from '@/lib/db'; -import { mintCreditsToUser } from '@/services/credits/mint'; +import { db } from '@/services/db/client'; +import { mintCreditsToUser } from '@/services/db/credits/mint'; import type { User } from '@/generated/prisma'; import { EnumPaymentSource } from '@/generated/prisma'; import { env } from '@/env'; diff --git a/packages/app/control/src/services/payouts/markup/index.ts b/packages/app/control/src/services/db/user/payouts/markup.ts similarity index 90% rename from packages/app/control/src/services/payouts/markup/index.ts rename to packages/app/control/src/services/db/user/payouts/markup.ts index 07dae93fd..e3aa87a91 100644 --- a/packages/app/control/src/services/payouts/markup/index.ts +++ b/packages/app/control/src/services/db/user/payouts/markup.ts @@ -1,5 +1,6 @@ -import { db } from '@/lib/db'; -import { PayoutStatus, PayoutType } from '@/services/payouts/referrals'; +import { db } from '@/services/db/client'; + +import { PayoutStatus, PayoutType } from '@/types/payouts'; interface AppMarkupEarnings { byApp: Record; @@ -20,6 +21,32 @@ interface AppMarkupEarnings { >; } +export const listPendingMarkupPayouts = async (userId: string) => { + return await db.payout.findMany({ + where: { + type: PayoutType.MARKUP, + status: PayoutStatus.PENDING, + echoApp: { + appMemberships: { + some: { + userId, + role: 'owner', + isArchived: false, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + echoAppId: true, + amount: true, + status: true, + createdAt: true, + }, + }); +}; + export async function calculateAppMarkupEarnings( echoAppId: string ): Promise { diff --git a/packages/app/control/src/services/payouts/referrals/index.ts b/packages/app/control/src/services/db/user/payouts/referrals.ts similarity index 92% rename from packages/app/control/src/services/payouts/referrals/index.ts rename to packages/app/control/src/services/db/user/payouts/referrals.ts index bba8b3be4..0cb5d936b 100644 --- a/packages/app/control/src/services/payouts/referrals/index.ts +++ b/packages/app/control/src/services/db/user/payouts/referrals.ts @@ -1,14 +1,6 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; -export enum PayoutType { - REFERRAL = 'referral', - MARKUP = 'markup', -} - -export enum PayoutStatus { - PENDING = 'pending', - COMPLETED = 'completed', -} +import { PayoutStatus, PayoutType } from '@/types/payouts'; interface UserReferralEarnings { byApp: Record; @@ -23,6 +15,24 @@ interface UserReferralEarnings { >; } +export const listPendingReferralPayouts = async (userId: string) => { + return await db.payout.findMany({ + where: { + userId, + type: PayoutType.REFERRAL, + status: PayoutStatus.PENDING, + }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + echoAppId: true, + amount: true, + status: true, + createdAt: true, + }, + }); +}; + export async function calculateUserReferralEarnings( userId: string ): Promise { diff --git a/packages/app/control/src/services/user/terms-agreement.ts b/packages/app/control/src/services/db/user/terms-agreement.ts similarity index 98% rename from packages/app/control/src/services/user/terms-agreement.ts rename to packages/app/control/src/services/db/user/terms-agreement.ts index 96017b949..8808e8e36 100644 --- a/packages/app/control/src/services/user/terms-agreement.ts +++ b/packages/app/control/src/services/db/user/terms-agreement.ts @@ -1,4 +1,4 @@ -import { db } from '@/lib/db'; +import { db } from '@/services/db/client'; import type { User } from '@/generated/prisma'; import { env } from '@/env'; diff --git a/packages/app/control/src/services/email/emails/create-app.ts b/packages/app/control/src/services/email/emails/create-app.ts index 8ff203a75..214a29adb 100644 --- a/packages/app/control/src/services/email/emails/create-app.ts +++ b/packages/app/control/src/services/email/emails/create-app.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { sendEmailWithRetry } from '../lib/send'; -import { getFullUser } from '@/services/user/get'; -import { countOwnerApps } from '@/services/apps/count'; +import { getFullUser } from '@/services/db/user/get'; +import { countOwnerApps } from '@/services/db/apps/count'; import { logger } from '@/logger'; -import { db } from '@/lib/db'; +import { createEmail } from '@/services/db/emails'; export const createAppFollowUpEmailSchema = z.object({ userId: z.uuid(), @@ -63,13 +63,11 @@ export async function scheduleCreateAppFollowUpEmail( }); } - await db.outboundEmailSent.create({ - data: { - emailCampaignId: 'create-app-follow-up', - userId, - echoAppId: appId, - createdAt: new Date(), - }, + await createEmail({ + emailCampaignId: 'create-app-follow-up', + userId, + echoAppId: appId, + createdAt: new Date(), }); return data; diff --git a/packages/app/control/src/services/email/emails/limbo-app-reminder.ts b/packages/app/control/src/services/email/emails/limbo-app-reminder.ts index b69fe2f6d..fb95814df 100644 --- a/packages/app/control/src/services/email/emails/limbo-app-reminder.ts +++ b/packages/app/control/src/services/email/emails/limbo-app-reminder.ts @@ -2,10 +2,10 @@ import { z } from 'zod'; import { sendEmailWithRetry } from '../lib/send'; -import { getFullUser } from '@/services/user/get'; +import { getFullUser } from '@/services/db/user/get'; import { logger } from '@/logger'; -import { db } from '@/lib/db'; +import { createEmail } from '@/services/db/emails'; export const limboAppReminderEmailSchema = z.object({ userId: z.uuid(), @@ -54,13 +54,11 @@ export async function scheduleLimboAppReminderEmail( }); } - await db.outboundEmailSent.create({ - data: { - emailCampaignId: 'limbo-app-reminder', - userId, - echoAppId: appId, - createdAt: new Date(), - }, + await createEmail({ + emailCampaignId: 'limbo-app-reminder', + userId, + echoAppId: appId, + createdAt: new Date(), }); return data; } diff --git a/packages/app/control/src/services/merit/client.ts b/packages/app/control/src/services/merit/client.ts new file mode 100644 index 000000000..c3507c9aa --- /dev/null +++ b/packages/app/control/src/services/merit/client.ts @@ -0,0 +1,8 @@ +import { env } from '@/env'; +import { MeritSDK } from '@merit-systems/sdk'; + +export const meritClient = new MeritSDK({ + apiKey: env.MERIT_API_KEY, + baseURL: env.MERIT_BASE_URL, + checkoutURL: env.MERIT_CHECKOUT_URL, +}); diff --git a/packages/app/control/src/services/payouts/merit/index.ts b/packages/app/control/src/services/merit/index.ts similarity index 56% rename from packages/app/control/src/services/payouts/merit/index.ts rename to packages/app/control/src/services/merit/index.ts index eae252a8f..229e05868 100644 --- a/packages/app/control/src/services/payouts/merit/index.ts +++ b/packages/app/control/src/services/merit/index.ts @@ -1,14 +1,14 @@ -import { db } from '@/lib/db'; import type { OutgoingPayment } from '@merit-systems/sdk'; -import { MeritSDK } from '@merit-systems/sdk'; -import { PayoutStatus } from '../referrals'; +import { PayoutStatus } from '@/types/payouts'; import { env } from '@/env'; - -const sdk = new MeritSDK({ - apiKey: env.MERIT_API_KEY, - baseURL: env.MERIT_BASE_URL, - checkoutURL: env.MERIT_CHECKOUT_URL, -}); +import { meritClient } from './client'; +import type { adminGetPayoutSchema } from '../db/admin/payouts'; +import { + adminGetPayout, + adminListPendingPayouts, + adminUpdatePayout, +} from '../db/admin/payouts'; +import type z from 'zod'; const SENDER_GITHUB_ID = Number(env.MERIT_SENDER_GITHUB_ID); @@ -18,22 +18,17 @@ function generateCheckoutUrl( senderGithubId: number, payOutEchoId: string ) { - const checkoutUrl = sdk.checkout.generateCheckoutUrl({ + return meritClient.checkout.generateCheckoutUrl({ items: [{ type: 'user', id: payeeGithubId, amount: amount }], senderGithubId, groupId: payOutEchoId, }); - return checkoutUrl; } -export async function generateCheckoutUrlForPayout(payoutId: string) { - const payout = await db.payout.findUnique({ - where: { id: payoutId }, - include: { - recipientGithubLink: { select: { githubId: true } }, - echoApp: { include: { githubLink: { select: { githubId: true } } } }, - }, - }); +export async function generateCheckoutUrlForPayout({ + payoutId, +}: z.infer) { + const payout = await adminGetPayout({ payoutId }); if (!payout) { throw new Error('Payout not found'); @@ -44,10 +39,6 @@ export async function generateCheckoutUrlForPayout(payoutId: string) { const payeeGithubId = payout.recipientGithubLink.githubId; const amount = Number(payout.amount); - console.log('payeeGithubId', payeeGithubId); - console.log('amount', amount); - console.log('SENDER_GITHUB_ID', SENDER_GITHUB_ID); - console.log('payout.id', payout.id); const checkoutUrl = generateCheckoutUrl( payeeGithubId, amount, @@ -61,11 +52,12 @@ async function pollForCompletedPayoutTransaction( senderGithubId: number, payOutEchoId: string ): Promise { - console.log('senderGithubId', senderGithubId); - console.log('payOutEchoId', payOutEchoId); - const payout = await sdk.payments.getPaymentsBySender(senderGithubId, { - group_id: payOutEchoId, - }); + const payout = await meritClient.payments.getPaymentsBySender( + senderGithubId, + { + group_id: payOutEchoId, + } + ); console.log('payout', payout); if (payout.items.length > 0) { return payout.items; @@ -74,10 +66,7 @@ async function pollForCompletedPayoutTransaction( } async function logCompletedPayoutTransaction(payout: OutgoingPayment) { - const existing = await db.payout.findUnique({ - where: { id: payout.group_id }, - select: { status: true }, - }); + const existing = await adminGetPayout({ payoutId: payout.group_id! }); if (!existing) { return; @@ -87,23 +76,16 @@ async function logCompletedPayoutTransaction(payout: OutgoingPayment) { return; } - await db.payout.update({ - where: { id: payout.group_id }, - data: { - status: PayoutStatus.COMPLETED, - transactionId: payout.tx_hash, - senderAddress: payout.sender_id.toString(), - }, + await adminUpdatePayout({ + payoutId: payout.group_id!, + status: PayoutStatus.COMPLETED, + transactionId: payout.tx_hash, + senderAddress: payout.sender_id.toString(), }); } export async function pollMeritCheckout(payoutId: string) { - const payout = await db.payout.findUnique({ - where: { id: payoutId }, - include: { - echoApp: { include: { githubLink: { select: { githubId: true } } } }, - }, - }); + const payout = await adminGetPayout({ payoutId }); if (!payout) { throw new Error('Payout not found'); @@ -132,14 +114,12 @@ export async function pollMeritCheckout(payoutId: string) { } export async function syncPendingPayoutsOnce() { - const pending = await db.payout.findMany({ - where: { status: PayoutStatus.PENDING }, - include: { - echoApp: { include: { githubLink: { select: { githubId: true } } } }, - }, + const pending = await adminListPendingPayouts({ + page: 0, + page_size: 10000, }); - for (const p of pending) { + for (const p of pending.items) { const senderGithubId = p.echoApp?.githubLink?.githubId ?? SENDER_GITHUB_ID; const results = await pollForCompletedPayoutTransaction( senderGithubId, @@ -150,5 +130,5 @@ export async function syncPendingPayoutsOnce() { } } - return { scanned: pending.length }; + return { scanned: pending.items.length }; } diff --git a/packages/app/control/src/services/stripe.ts b/packages/app/control/src/services/stripe.ts deleted file mode 100644 index f47898305..000000000 --- a/packages/app/control/src/services/stripe.ts +++ /dev/null @@ -1,257 +0,0 @@ -import Stripe from 'stripe'; - -import { z } from 'zod'; - -import { db } from '@/lib/db'; -import { PaymentStatus } from '@/lib/payment-processing'; -import { logger } from '@/logger'; -import { env } from '@/env'; - -const stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: '2025-05-28.basil', -}); - -export const createPaymentLinkSchema = z.object({ - amount: z.number(), - description: z.string().optional(), - successUrl: z.url().optional(), -}); - -export async function createPaymentLink( - userId: string, - { - amount, - description = 'Echo Credits', - successUrl, - }: z.infer -) { - if (amount < 1) { - logger.emit({ - severityText: 'WARN', - body: 'Invalid amount for payment link creation', - attributes: { - userId, - amount, - function: 'createPaymentLink', - }, - }); - throw new Error('A minimum of 1 dollar is required to purchase credits'); - } - - logger.emit({ - severityText: 'INFO', - body: 'Creating Stripe payment link', - attributes: { - userId, - amount, - description, - function: 'createPaymentLink', - }, - }); - - // Create Stripe product - const product = await stripe.products.create({ - name: description, - description: `${description} - ${amount} USD`, - }); - - logger.emit({ - severityText: 'DEBUG', - body: 'Created Stripe product', - attributes: { - productId: product.id, - productName: product.name, - userId, - function: 'createPaymentLink', - }, - }); - - // Create Stripe price - const amountInCents = Math.round(amount * 100); - const price = await stripe.prices.create({ - unit_amount: amountInCents, - currency: 'usd', - product: product.id, - }); - - // Prepare payment link configuration - const paymentLinkConfig: Stripe.PaymentLinkCreateParams = { - line_items: [ - { - price: price.id, - quantity: 1, - }, - ], - metadata: { - userId, - description, - }, - after_completion: { - type: 'redirect', - redirect: { - url: successUrl ?? `${env.NEXT_PUBLIC_APP_URL}/credits?payment=success`, - }, - }, - }; - - // Create Stripe payment link - const paymentLink = await stripe.paymentLinks.create(paymentLinkConfig); - - // Create pending payment record - const payment = await db.payment.create({ - data: { - paymentId: paymentLink.id, - amount: amount, - currency: 'usd', - status: PaymentStatus.PENDING, - description: description, - userId, - }, - }); - - return { - paymentLink: { - id: paymentLink.id, - url: paymentLink.url, - amount: amount, - currency: 'usd', - status: PaymentStatus.PENDING, - created: Math.floor(Date.now() / 1000), - metadata: { - userId, - description, - }, - }, - payment, - }; -} - -export const createFreeTierPaymentLinkSchema = createPaymentLinkSchema.extend({ - appId: z.string(), - poolName: z.string().optional(), - defaultSpendLimit: z.number().optional(), -}); - -/** - * Create a Stripe payment link for free tier credits pool - * @param user - The authenticated user (app owner) - * @param appId - The app ID to create the free tier pool for - * @param request - Payment link request details - * @returns Payment link and database record - */ -export async function createFreeTierPaymentLink( - userId: string, - { - amount, - description = 'Free Tier Credits Pool', - successUrl, - poolName, - defaultSpendLimit, - appId, - }: z.infer -) { - if (amount < 1) { - throw new Error( - 'A minimum of 1 dollar is required to purchase free tier credits' - ); - } - - // Verify the app exists and user has access - const app = await db.echoApp.findFirst({ - where: { - id: appId, - isArchived: false, - }, - include: { - appMemberships: { - where: { - userId, - role: 'owner', - status: 'active', - isArchived: false, - }, - }, - }, - }); - - if (!app || app.appMemberships.length === 0) { - throw new Error('App not found'); - } - - // Convert amount to cents for Stripe - const amountInCents = Math.round(amount * 100); - - // Create Stripe product - const product = await stripe.products.create({ - name: `${description} - ${app.name}`, - description: `${description} for ${app.name} - ${amount} USD`, - }); - - // Create Stripe price - const price = await stripe.prices.create({ - unit_amount: amountInCents, - currency: 'usd', - product: product.id, - }); - - // Prepare after_completion configuration - const defaultSuccessUrl = `${env.NEXT_PUBLIC_APP_URL}/app/${appId}/free-tier?payment=success&type=free-tier`; - const afterCompletion: Stripe.PaymentLinkCreateParams.AfterCompletion = { - type: 'redirect', - redirect: { - url: successUrl ?? defaultSuccessUrl, - }, - }; - - // Prepare payment link configuration with app metadata - const paymentLinkConfig: Stripe.PaymentLinkCreateParams = { - line_items: [ - { - price: price.id, - quantity: 1, - }, - ], - metadata: { - userId, - echoAppId: appId, - description, - type: 'free-tier-credits', - poolName: - poolName ?? - `Free Tier Credits - ${new Date().toISOString().split('T')[0]}`, - defaultSpendLimit: defaultSpendLimit?.toString() ?? '100', - }, - after_completion: afterCompletion, - }; - - // Create Stripe payment link - const paymentLink = await stripe.paymentLinks.create(paymentLinkConfig); - - // Create pending payment record - const payment = await db.payment.create({ - data: { - paymentId: paymentLink.id, - amount: amount, - currency: 'usd', - status: PaymentStatus.PENDING, - description: description, - userId, - }, - }); - - return { - paymentLink: { - id: paymentLink.id, - url: paymentLink.url, - amount: amount, - currency: 'usd', - status: PaymentStatus.PENDING, - created: Math.floor(Date.now() / 1000), - metadata: { - userId, - description, - }, - }, - payment, - }; -} diff --git a/packages/app/control/src/services/stripe/client.ts b/packages/app/control/src/services/stripe/client.ts new file mode 100644 index 000000000..542b5cba6 --- /dev/null +++ b/packages/app/control/src/services/stripe/client.ts @@ -0,0 +1,7 @@ +import Stripe from 'stripe'; + +import { env } from '@/env'; + +export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: '2025-05-28.basil', +}); diff --git a/packages/app/control/src/services/stripe/create-link/credits.ts b/packages/app/control/src/services/stripe/create-link/credits.ts new file mode 100644 index 000000000..52dfcb998 --- /dev/null +++ b/packages/app/control/src/services/stripe/create-link/credits.ts @@ -0,0 +1,36 @@ +import z from 'zod'; + +import { env } from '@/env'; + +import { createPaymentLink } from './lib'; + +export const createCreditsPaymentLinkSchema = z.object({ + amount: z.number().min(1), + description: z.string().default('Echo Credits'), + successUrl: z + .url() + .optional() + .default(`${env.NEXT_PUBLIC_APP_URL}/credits?payment=success`), +}); + +export const createCreditsPaymentLink = async ( + userId: string, + { + amount, + // wonky cuz sdk expects description + description: name, + successUrl, + }: z.infer +) => { + const description = `${name} - ${amount} USD`; + return createPaymentLink(userId, { + amount, + name, + description, + successUrl, + metadata: { + userId, + description, + }, + }); +}; diff --git a/packages/app/control/src/services/stripe/create-link/free-tier.ts b/packages/app/control/src/services/stripe/create-link/free-tier.ts new file mode 100644 index 000000000..947f7d6ab --- /dev/null +++ b/packages/app/control/src/services/stripe/create-link/free-tier.ts @@ -0,0 +1,46 @@ +import z from 'zod'; + +import { getAppWithOwnerCheck } from '@/services/db/apps/get'; +import { env } from '@/env'; +import { createPaymentLink } from './lib'; + +export const createFreeTierPaymentLinkSchema = z.object({ + amount: z.number().min(1), + appId: z.uuid(), + poolName: z.string().optional().default('Free Tier Credits'), + defaultSpendLimit: z.number().optional().default(100), +}); + +export const createFreeTierPaymentLink = async ( + userId: string, + { + amount, + poolName, + defaultSpendLimit, + appId, + }: z.infer +) => { + // Verify the app exists and user has access + const app = await getAppWithOwnerCheck(appId, userId); + + if (!app) { + throw new Error('App not found'); + } + + const description = `${poolName} for ${app.name} - ${amount} USD`; + + return await createPaymentLink(userId, { + amount, + name: `${poolName} - ${app.name}`, + description, + successUrl: `${env.NEXT_PUBLIC_APP_URL}/app/${appId}/free-tier?payment=success&type=free-tier`, + metadata: { + userId, + echoAppId: appId, + description, + poolName, + type: 'free-tier-credits', + defaultSpendLimit: defaultSpendLimit.toString(), + }, + }); +}; diff --git a/packages/app/control/src/services/stripe/create-link/lib.ts b/packages/app/control/src/services/stripe/create-link/lib.ts new file mode 100644 index 000000000..7a06d0f13 --- /dev/null +++ b/packages/app/control/src/services/stripe/create-link/lib.ts @@ -0,0 +1,102 @@ +import z from 'zod'; + +import { stripe } from '../client'; + +import { createPayment } from '@/services/db/payments/create'; + +import { logger } from '@/logger'; + +import { PaymentStatus } from '@/types/payments'; + +import type Stripe from 'stripe'; + +const createPaymentLinkSchema = z.object({ + amount: z.number().min(1), + name: z.string(), + description: z.string(), + successUrl: z.url(), + metadata: z.custom().optional(), +}); + +export const createPaymentLink = async ( + userId: string, + parameters: z.infer +) => { + const { amount, name, description, successUrl, metadata } = + createPaymentLinkSchema.parse(parameters); + + logger.emit({ + severityText: 'INFO', + body: 'Creating Stripe payment link', + attributes: { + userId, + amount, + description, + function: 'createPaymentLink', + }, + }); + + const product = await stripe.products.create({ + name, + description, + }); + + logger.emit({ + severityText: 'DEBUG', + body: 'Created Stripe product', + attributes: { + productId: product.id, + productName: product.name, + userId, + function: 'createPaymentLink', + }, + }); + + const price = await stripe.prices.create({ + unit_amount: Math.round(amount * 100), + currency: 'usd', + product: product.id, + }); + + const paymentLink = await stripe.paymentLinks.create({ + line_items: [ + { + price: price.id, + quantity: 1, + }, + ], + metadata, + after_completion: { + type: 'redirect', + redirect: { + url: successUrl, + }, + }, + }); + + // Create pending payment record + const payment = await createPayment({ + paymentId: paymentLink.id, + amount: amount, + currency: 'usd', + status: PaymentStatus.PENDING, + description: description, + userId, + }); + + return { + paymentLink: { + id: paymentLink.id, + url: paymentLink.url, + amount: amount, + currency: 'usd', + status: PaymentStatus.PENDING, + created: Math.floor(Date.now() / 1000), + metadata: { + userId, + description, + }, + }, + payment, + }; +}; diff --git a/packages/app/control/src/services/stripe/webhook/construct-event.ts b/packages/app/control/src/services/stripe/webhook/construct-event.ts new file mode 100644 index 000000000..949d4e373 --- /dev/null +++ b/packages/app/control/src/services/stripe/webhook/construct-event.ts @@ -0,0 +1,32 @@ +import { stripe } from '../client'; + +import { env } from '@/env'; +import { logger } from '@/logger'; + +import type { NextRequest } from 'next/server'; + +export const constructStripeEvent = (request: NextRequest, body: string) => { + const signature = request.headers.get('stripe-signature'); + + if (!signature) { + throw new Error('Missing signature'); + } + + try { + return stripe.webhooks.constructEvent( + body, + signature, + env.STRIPE_WEBHOOK_SECRET + ); + } catch (err) { + logger.emit({ + severityText: 'ERROR', + body: 'Webhook signature verification failed', + attributes: { + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }, + }); + throw err; + } +}; diff --git a/packages/app/control/src/services/stripe/webhook/handle-event.ts b/packages/app/control/src/services/stripe/webhook/handle-event.ts new file mode 100644 index 000000000..2538cac6c --- /dev/null +++ b/packages/app/control/src/services/stripe/webhook/handle-event.ts @@ -0,0 +1,30 @@ +import type Stripe from 'stripe'; + +import { handleCheckoutSessionCompleted } from './handlers/checkout-completed'; +import { handlePaymentFailure } from './handlers/payment-failure'; +import { handleInvoicePayment } from './handlers/invoice-payment'; + +import { logger } from '@/logger'; + +export const handleStripeEvent = async (event: Stripe.Event) => { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutSessionCompleted(event.data.object); + break; + case 'payment_intent.payment_failed': + await handlePaymentFailure(event.data.object); + break; + case 'invoice.payment_succeeded': + await handleInvoicePayment(event.data.object); + break; + default: + logger.emit({ + severityText: 'WARN', + body: 'Unhandled Stripe webhook event type', + attributes: { + eventType: event.type, + eventId: event.id, + }, + }); + } +}; diff --git a/packages/app/control/src/services/stripe/webhook/handlers/checkout-completed.ts b/packages/app/control/src/services/stripe/webhook/handlers/checkout-completed.ts new file mode 100644 index 000000000..bd29b755c --- /dev/null +++ b/packages/app/control/src/services/stripe/webhook/handlers/checkout-completed.ts @@ -0,0 +1,131 @@ +import type Stripe from 'stripe'; + +import { getPaymentById } from '@/services/db/payments/get'; +import { handlePaymentSuccess } from '@/services/db/payments/success'; + +import { logger } from '@/logger'; + +import { PaymentStatus } from '@/types/payments'; + +export const handleCheckoutSessionCompleted = async ( + session: Stripe.Checkout.Session +) => { + console.log('handleCheckoutSessionCompleted'); + try { + const { metadata, amount_total, currency, payment_link, payment_intent } = + session; + const userId = metadata?.userId; + const echoAppId = metadata?.echoAppId; + const description = metadata?.description; + + if (!userId || !amount_total) { + logger.emit({ + severityText: 'ERROR', + body: 'Missing userId or amount in checkout session metadata', + attributes: { + sessionId: session.id, + userId, + amount_total, + handler: 'handleCheckoutSessionCompleted', + }, + }); + return; + } + + // Determine the payment ID to update based on whether this is from a payment link + let paymentId: string; + if (payment_link) { + // This checkout session was created from a payment link + paymentId = payment_link as string; + logger.emit({ + severityText: 'INFO', + body: 'Checkout session completed from payment link', + attributes: { + paymentId, + sessionId: session.id, + handler: 'handleCheckoutSessionCompleted', + }, + }); + } else if (payment_intent) { + // This is a direct checkout session + paymentId = session.id; + logger.emit({ + severityText: 'INFO', + body: 'Direct checkout session completed', + attributes: { + paymentId, + sessionId: session.id, + handler: 'handleCheckoutSessionCompleted', + }, + }); + } else { + logger.emit({ + severityText: 'ERROR', + body: 'No payment_link or payment_intent found in checkout session', + attributes: { + sessionId: session.id, + handler: 'handleCheckoutSessionCompleted', + }, + }); + return; + } + + const payment = await getPaymentById(paymentId); + + if (!payment) { + logger.emit({ + severityText: 'ERROR', + body: 'Payment not found', + attributes: { + paymentId, + handler: 'handleCheckoutSessionCompleted', + }, + }); + return; + } + + if ((payment.status as PaymentStatus) === PaymentStatus.COMPLETED) { + logger.emit({ + severityText: 'ERROR', + body: 'Payment already completed', + attributes: { + paymentId, + handler: 'handleCheckoutSessionCompleted', + }, + }); + return; + } + + await handlePaymentSuccess({ + userId, + amountInCents: amount_total, + currency: currency ?? 'usd', + paymentId, + metadata, + echoAppId, + description, + }); + + logger.emit({ + severityText: 'INFO', + body: 'Checkout completed successfully', + attributes: { + userId, + echoAppId, + creditsAdded: Math.floor(amount_total / 100), + isFreeTier: metadata?.type === 'free-tier-credits', + handler: 'handleCheckoutSessionCompleted', + }, + }); + } catch (error) { + logger.emit({ + severityText: 'ERROR', + body: 'Error handling checkout completion', + attributes: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + handler: 'handleCheckoutSessionCompleted', + }, + }); + } +}; diff --git a/packages/app/control/src/services/stripe/webhook/handlers/invoice-payment.ts b/packages/app/control/src/services/stripe/webhook/handlers/invoice-payment.ts new file mode 100644 index 000000000..e25e751c5 --- /dev/null +++ b/packages/app/control/src/services/stripe/webhook/handlers/invoice-payment.ts @@ -0,0 +1,31 @@ +import { logger } from '@/logger'; + +import type Stripe from 'stripe'; + +export const handleInvoicePayment = async (invoice: Stripe.Invoice) => { + try { + const { id, amount_paid, currency } = invoice; + + // Handle recurring payments if needed + logger.emit({ + severityText: 'INFO', + body: 'Invoice payment received', + attributes: { + invoiceId: id, + amount_paid, + currency, + handler: 'handleInvoicePayment', + }, + }); + } catch (error) { + logger.emit({ + severityText: 'ERROR', + body: 'Error handling invoice payment', + attributes: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + handler: 'handleInvoicePayment', + }, + }); + } +}; diff --git a/packages/app/control/src/services/stripe/webhook/handlers/payment-failure.ts b/packages/app/control/src/services/stripe/webhook/handlers/payment-failure.ts new file mode 100644 index 000000000..d493f8b7a --- /dev/null +++ b/packages/app/control/src/services/stripe/webhook/handlers/payment-failure.ts @@ -0,0 +1,34 @@ +import type Stripe from 'stripe'; + +import { updatePaymentStatus } from '@/services/db/payments/update'; +import { PaymentStatus } from '@/types/payments'; +import { logger } from '@/logger'; + +export const handlePaymentFailure = async ( + paymentIntent: Stripe.PaymentIntent +) => { + try { + const { id } = paymentIntent; + + await updatePaymentStatus(id, PaymentStatus.FAILED); + + logger.emit({ + severityText: 'WARN', + body: 'Payment failed', + attributes: { + paymentIntentId: id, + handler: 'handlePaymentFailure', + }, + }); + } catch (error) { + logger.emit({ + severityText: 'ERROR', + body: 'Error handling payment failure', + attributes: { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + handler: 'handlePaymentFailure', + }, + }); + } +}; diff --git a/packages/app/control/src/services/user/get.ts b/packages/app/control/src/services/user/get.ts deleted file mode 100644 index 1e8045a55..000000000 --- a/packages/app/control/src/services/user/get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { db } from '@/lib/db'; - -export const getPublicUser = async (userId: string) => { - return await db.user.findUnique({ - where: { id: userId }, - select: { - id: true, - name: true, - image: true, - createdAt: true, - }, - }); -}; - -export const getFullUser = async (userId: string) => { - return await db.user.findUnique({ - where: { id: userId }, - select: { - id: true, - name: true, - image: true, - email: true, - createdAt: true, - updatedAt: true, - }, - }); -}; diff --git a/packages/app/control/src/trpc/routers/admin/app.ts b/packages/app/control/src/trpc/routers/admin/app.ts index 41dc71453..af1f648de 100644 --- a/packages/app/control/src/trpc/routers/admin/app.ts +++ b/packages/app/control/src/trpc/routers/admin/app.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { adminProcedure, createTRPCRouter } from '../../trpc'; -import { getAppUsersWithPagination } from '@/services/admin/app/users'; -import { paginationParamsSchema } from '@/services/lib/pagination'; -import { multiSortParamsSchema } from '@/services/lib/sorting'; -import { filterParamsSchema } from '@/services/lib/filtering'; -import { getApp } from '@/services/admin/app/app'; +import { getAppUsersWithPagination } from '@/services/db/admin/app/users'; +import { paginationParamsSchema } from '@/services/db/_lib/pagination'; +import { multiSortParamsSchema } from '@/services/db/_lib/sorting'; +import { filterParamsSchema } from '@/services/db/_lib/filtering'; +import { getApp } from '@/services/db/admin/app/app'; export const adminAppRouter = createTRPCRouter({ getAppUsersWithPagination: adminProcedure diff --git a/packages/app/control/src/trpc/routers/admin/base.ts b/packages/app/control/src/trpc/routers/admin/base.ts index d720233db..8e3bb9b7c 100644 --- a/packages/app/control/src/trpc/routers/admin/base.ts +++ b/packages/app/control/src/trpc/routers/admin/base.ts @@ -5,29 +5,16 @@ import { adminProcedure, protectedProcedure } from '../../trpc'; import { adminGetUsers, adminGetAppsForUser, - adminMintCreditsToUser, downloadUsersCsv, downloadUsersCsvSchema, isAdmin, -} from '@/services/admin/admin'; -import { mintCreditsToUserSchema } from '@/services/credits/mint'; +} from '@/services/db/admin/admin'; export const adminBaseProcedures = { isAdmin: protectedProcedure.query(async ({ ctx }) => { return await isAdmin(ctx.session.user.id); }), - mintCredits: adminProcedure - .input(mintCreditsToUserSchema) - .mutation(async ({ input }) => { - await adminMintCreditsToUser(input); - - return { - success: true, - message: `Successfully minted $${input.amountInDollars} to user ${input.userId}`, - }; - }), - getUsers: adminProcedure.query(async () => { return await adminGetUsers(); }), diff --git a/packages/app/control/src/trpc/routers/admin/credit-grants.ts b/packages/app/control/src/trpc/routers/admin/credit-grants.ts index c0d00329f..47c54e035 100644 --- a/packages/app/control/src/trpc/routers/admin/credit-grants.ts +++ b/packages/app/control/src/trpc/routers/admin/credit-grants.ts @@ -12,11 +12,11 @@ import { adminListCreditGrantUsages, adminListCreditGrantUsagesSchema, adminUpdateCreditGrant, -} from '@/services/admin/admin'; +} from '@/services/db/admin/admin'; import { adminCreateCreditGrantSchema, adminUpdateCreditGrantSchema, -} from '@/services/admin/schemas'; +} from '@/services/db/admin/schemas'; import { TRPCError } from '@trpc/server'; diff --git a/packages/app/control/src/trpc/routers/admin/earnings.ts b/packages/app/control/src/trpc/routers/admin/earnings.ts index b63d25d64..7c355a479 100644 --- a/packages/app/control/src/trpc/routers/admin/earnings.ts +++ b/packages/app/control/src/trpc/routers/admin/earnings.ts @@ -1,12 +1,12 @@ import { adminProcedure, createTRPCRouter } from '../../trpc'; -import { getUserEarningsWithPagination } from '@/services/admin/user-earnings'; -import { getAppEarningsWithPagination } from '@/services/admin/app-earnings'; -import { getUserEarningsOverviewMetrics } from '@/services/admin/user-earning-summary'; -import { getAppEarningsOverviewMetrics } from '@/services/admin/app-earnings-summary'; -import { paginationParamsSchema } from '@/services/lib/pagination'; -import { multiSortParamsSchema } from '@/services/lib/sorting'; -import { filterParamsSchema } from '@/services/lib/filtering'; +import { getUserEarningsWithPagination } from '@/services/db/admin/user-earnings'; +import { getAppEarningsWithPagination } from '@/services/db/admin/app-earnings'; +import { getUserEarningsOverviewMetrics } from '@/services/db/admin/user-earning-summary'; +import { getAppEarningsOverviewMetrics } from '@/services/db/admin/app-earnings-summary'; +import { paginationParamsSchema } from '@/services/db/_lib/pagination'; +import { multiSortParamsSchema } from '@/services/db/_lib/sorting'; +import { filterParamsSchema } from '@/services/db/_lib/filtering'; export const adminEarningsRouter = createTRPCRouter({ getUserEarningsWithPagination: adminProcedure diff --git a/packages/app/control/src/trpc/routers/admin/email-campaigns.ts b/packages/app/control/src/trpc/routers/admin/email-campaigns.ts index 995ca0d80..7e2146843 100644 --- a/packages/app/control/src/trpc/routers/admin/email-campaigns.ts +++ b/packages/app/control/src/trpc/routers/admin/email-campaigns.ts @@ -6,7 +6,7 @@ import { listAvailableEmailCampaigns, getSentCampaignsForApps, scheduleCampaignForApps, -} from '@/services/admin/email-campaigns'; +} from '@/services/db/admin/email-campaigns'; export const adminEmailCampaignsRouter = createTRPCRouter({ list: adminProcedure.query(async () => { diff --git a/packages/app/control/src/trpc/routers/admin/payments.ts b/packages/app/control/src/trpc/routers/admin/payments.ts index d682a5625..7b002515a 100644 --- a/packages/app/control/src/trpc/routers/admin/payments.ts +++ b/packages/app/control/src/trpc/routers/admin/payments.ts @@ -1,10 +1,10 @@ import { adminProcedure, createTRPCRouter } from '../../trpc'; -import { getPaymentsWithPagination } from '@/services/admin/payments'; -import { getPaymentsOverviewMetrics } from '@/services/admin/payments-summary'; -import { paginationParamsSchema } from '@/services/lib/pagination'; -import { multiSortParamsSchema } from '@/services/lib/sorting'; -import { filterParamsSchema } from '@/services/lib/filtering'; +import { getPaymentsWithPagination } from '@/services/db/admin/payments'; +import { getPaymentsOverviewMetrics } from '@/services/db/admin/payments-summary'; +import { paginationParamsSchema } from '@/services/db/_lib/pagination'; +import { multiSortParamsSchema } from '@/services/db/_lib/sorting'; +import { filterParamsSchema } from '@/services/db/_lib/filtering'; export const adminPaymentsRouter = createTRPCRouter({ getPaymentsWithPagination: adminProcedure diff --git a/packages/app/control/src/trpc/routers/admin/payouts.ts b/packages/app/control/src/trpc/routers/admin/payouts.ts index d205242b1..576e3ca8b 100644 --- a/packages/app/control/src/trpc/routers/admin/payouts.ts +++ b/packages/app/control/src/trpc/routers/admin/payouts.ts @@ -1,41 +1,44 @@ -import { z } from 'zod'; - import { adminProcedure, createTRPCRouter, paginatedProcedure, } from '../../trpc'; -import { adminListPendingPayouts } from '@/services/admin/pending-payouts'; -import { adminListCompletedPayouts } from '@/services/admin/completed-payouts'; +import { + adminListPendingPayouts, + adminListCompletedPayouts, + adminGetPayoutSchema, +} from '@/services/db/admin/payouts'; import { generateCheckoutUrlForPayout, pollMeritCheckout, syncPendingPayoutsOnce, -} from '@/services/payouts/merit'; +} from '@/services/merit'; export const adminPayoutsRouter = createTRPCRouter({ - listPending: paginatedProcedure - .concat(adminProcedure) - .query(async ({ ctx }) => { - return await adminListPendingPayouts(ctx.pagination); - }), - listCompleted: paginatedProcedure - .concat(adminProcedure) - .query(async ({ ctx }) => { - return await adminListCompletedPayouts(ctx.pagination); - }), + list: { + pending: paginatedProcedure + .concat(adminProcedure) + .query(async ({ ctx }) => { + return await adminListPendingPayouts(ctx.pagination); + }), + completed: paginatedProcedure + .concat(adminProcedure) + .query(async ({ ctx }) => { + return await adminListCompletedPayouts(ctx.pagination); + }), + }, startMeritCheckout: adminProcedure - .input(z.object({ payoutId: z.string() })) + .input(adminGetPayoutSchema) .mutation(async ({ input }) => { - const checkoutUrl = await generateCheckoutUrlForPayout(input.payoutId); + const checkoutUrl = await generateCheckoutUrlForPayout(input); if (!checkoutUrl) { throw new Error('Checkout URL not found'); } return { url: checkoutUrl.url }; }), pollMeritCheckout: adminProcedure - .input(z.object({ payoutId: z.string() })) + .input(adminGetPayoutSchema) .mutation(async ({ input }) => { return await pollMeritCheckout(input.payoutId); }), diff --git a/packages/app/control/src/trpc/routers/admin/spending.ts b/packages/app/control/src/trpc/routers/admin/spending.ts index e959299b8..30d9fe0a3 100644 --- a/packages/app/control/src/trpc/routers/admin/spending.ts +++ b/packages/app/control/src/trpc/routers/admin/spending.ts @@ -1,10 +1,10 @@ import { adminProcedure, createTRPCRouter } from '../../trpc'; -import { getUserSpendingWithPagination } from '@/services/admin/user-spending'; -import { getUserSpendingOverviewMetrics } from '@/services/admin/user-spending-summary'; -import { paginationParamsSchema } from '@/services/lib/pagination'; -import { multiSortParamsSchema } from '@/services/lib/sorting'; -import { filterParamsSchema } from '@/services/lib/filtering'; +import { getUserSpendingWithPagination } from '@/services/db/admin/user-spending'; +import { getUserSpendingOverviewMetrics } from '@/services/db/admin/user-spending-summary'; +import { paginationParamsSchema } from '@/services/db/_lib/pagination'; +import { multiSortParamsSchema } from '@/services/db/_lib/sorting'; +import { filterParamsSchema } from '@/services/db/_lib/filtering'; export const adminSpendingRouter = createTRPCRouter({ getUserSpendingWithPagination: adminProcedure diff --git a/packages/app/control/src/trpc/routers/admin/tokens.ts b/packages/app/control/src/trpc/routers/admin/tokens.ts index f066442f7..6faedb1da 100644 --- a/packages/app/control/src/trpc/routers/admin/tokens.ts +++ b/packages/app/control/src/trpc/routers/admin/tokens.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { adminProcedure, createTRPCRouter } from '../../trpc'; -import { getHomePageChart } from '@/services/admin/home-page'; +import { getHomePageChart } from '@/services/db/admin/home-page'; export const adminTokensRouter = createTRPCRouter({ getHomePageChart: adminProcedure diff --git a/packages/app/control/src/trpc/routers/admin/user.ts b/packages/app/control/src/trpc/routers/admin/user.ts index f8ee25258..28b580801 100644 --- a/packages/app/control/src/trpc/routers/admin/user.ts +++ b/packages/app/control/src/trpc/routers/admin/user.ts @@ -2,13 +2,13 @@ import { z } from 'zod'; import { adminProcedure, createTRPCRouter } from '../../trpc'; -import { getUserAppsWithPagination } from '@/services/admin/user/apps'; -import { getUserOverviewMetrics } from '@/services/admin/user/user-summary'; -import { paginationParamsSchema } from '@/services/lib/pagination'; -import { multiSortParamsSchema } from '@/services/lib/sorting'; -import { filterParamsSchema } from '@/services/lib/filtering'; -import { getUser } from '@/services/admin/user/user'; -import { getUserAppsCharts } from '@/services/admin/user/user-charts'; +import { getUserAppsWithPagination } from '@/services/db/admin/user/apps'; +import { getUserOverviewMetrics } from '@/services/db/admin/user/user-summary'; +import { paginationParamsSchema } from '@/services/db/_lib/pagination'; +import { multiSortParamsSchema } from '@/services/db/_lib/sorting'; +import { filterParamsSchema } from '@/services/db/_lib/filtering'; +import { getUser } from '@/services/db/admin/user/user'; +import { getUserAppsCharts } from '@/services/db/admin/user/user-charts'; export const adminUserRouter = createTRPCRouter({ getUserAppsWithPagination: adminProcedure diff --git a/packages/app/control/src/trpc/routers/apps/index.ts b/packages/app/control/src/trpc/routers/apps/index.ts index 8730f86ca..a4d58ff6d 100644 --- a/packages/app/control/src/trpc/routers/apps/index.ts +++ b/packages/app/control/src/trpc/routers/apps/index.ts @@ -8,7 +8,7 @@ import { } from '@/trpc/trpc'; import { TRPCError } from '@trpc/server'; -import { createApp } from '@/services/apps/create'; +import { createApp } from '@/services/db/apps/create'; import { appOwnerProcedure, protectedAppProcedure, @@ -23,61 +23,61 @@ import { createAppMembershipSchema, updateAppMembershipReferrer, updateAppMembershipReferrerSchema, -} from '@/services/apps/membership'; +} from '@/services/db/apps/membership'; import { listAppsSchema, listPublicApps, listMemberApps, listOwnerApps, -} from '@/services/apps/list'; -import { updateApp } from '@/services/apps/update'; -import { getAppOwner } from '@/services/apps/get'; -import { appIdSchema } from '@/services/apps/lib/schemas'; -import { getAppMarkup, updateMarkup } from '@/services/apps/markup'; +} from '@/services/db/apps/list'; +import { updateApp } from '@/services/db/apps/update'; +import { getAppOwner } from '@/services/db/apps/get'; +import { appIdSchema } from '@/services/db/apps/lib/schemas'; +import { getAppMarkup, updateMarkup } from '@/services/db/apps/markup'; import { getGithubLink, updateGithubLinkSchema, updateGithubLink, -} from '@/services/apps/github-link'; +} from '@/services/db/apps/github-link'; import { createFreeTierPaymentLink, createFreeTierPaymentLinkSchema, -} from '@/services/stripe'; +} from '@/services/stripe/create-link/free-tier'; import { getFreeTierSpendPool, updateFreeTierSpendPool, updateFreeTierSpendPoolSchema, -} from '@/services/apps/free-tier'; -import { listFreeTierPayments } from '@/services/payments'; -import { countAppTokens } from '@/services/apps/tokens'; +} from '@/services/db/apps/free-tier'; +import { listFreeTierPayments } from '@/services/db/payments/list'; +import { countAppTokens } from '@/services/db/apps/tokens'; import { countAppTransactions, countAppTransactionsSchema, listAppTransactions, listAppTransactionsSchema, -} from '@/services/apps/transactions'; +} from '@/services/db/apps/transactions'; import { getBucketedAppStats, getBucketedAppStatsSchema, getOverallAppStats, getOverallAppStatsSchema, -} from '@/services/apps/stats'; +} from '@/services/db/apps/stats'; import { listAppUsers, countAppUsers, appUsersSchema, -} from '@/services/apps/users'; +} from '@/services/db/apps/users'; import { countMemberApps, countOwnerApps, countPublicApps, -} from '@/services/apps/count'; -import { appEarningsSchema, getAppEarnings } from '@/services/apps/earnings'; +} from '@/services/db/apps/count'; +import { appEarningsSchema, getAppEarnings } from '@/services/db/apps/earnings'; import { getAppReferralReward, setAppReferralReward, setAppReferralRewardSchema, -} from '@/services/apps/referral-reward'; +} from '@/services/db/apps/referral-reward'; import { createAppReferralCode, createAppReferralCodeSchema, @@ -85,17 +85,17 @@ import { getReferralCodeByCode, getReferralCodeByCodeSchema, getUserAppReferralCode, -} from '@/services/apps/referral-code'; -import { deleteApp, deleteAppSchema } from '@/services/apps/delete'; +} from '@/services/db/apps/referral-code'; +import { deleteApp, deleteAppSchema } from '@/services/db/apps/delete'; import { createAppSchema, updateAppSchema, updateMarkupSchema, -} from '@/services/apps/lib/schemas'; +} from '@/services/db/apps/lib/schemas'; import { createFreeTierPaymentFromBalance, createFreeTierPaymentFromBalanceSchema, -} from '@/services/credits/mint'; +} from '@/services/db/credits/mint'; export const appsRouter = createTRPCRouter({ create: protectedProcedure diff --git a/packages/app/control/src/trpc/routers/apps/procedures.ts b/packages/app/control/src/trpc/routers/apps/procedures.ts index 2aa80d485..f7450ce03 100644 --- a/packages/app/control/src/trpc/routers/apps/procedures.ts +++ b/packages/app/control/src/trpc/routers/apps/procedures.ts @@ -1,9 +1,9 @@ -import { getAppOwner } from '@/services/apps/get'; +import { getAppOwner } from '@/services/db/apps/get'; import { protectedProcedure, publicProcedure } from '../../trpc'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { appIdSchema } from '@/services/apps/lib/schemas'; -import { getApp } from '@/services/apps/get'; +import { appIdSchema } from '@/services/db/apps/lib/schemas'; +import { getApp } from '@/services/db/apps/get'; const appIdInput = z.object({ appId: appIdSchema }); diff --git a/packages/app/control/src/trpc/routers/credits.ts b/packages/app/control/src/trpc/routers/credits.ts index e88bb0c79..d95e5ef5b 100644 --- a/packages/app/control/src/trpc/routers/credits.ts +++ b/packages/app/control/src/trpc/routers/credits.ts @@ -7,7 +7,7 @@ import { getCreditGrantCodeWithUsages, redeemCreditGrantCode, redeemCreditGrantCodeSchema, -} from '@/services/credits/grant'; +} from '@/services/db/credits/grant'; export const creditsRouter = createTRPCRouter({ grant: { diff --git a/packages/app/control/src/trpc/routers/user/index.ts b/packages/app/control/src/trpc/routers/user/index.ts index f0d0b7fd3..f25f7f12a 100644 --- a/packages/app/control/src/trpc/routers/user/index.ts +++ b/packages/app/control/src/trpc/routers/user/index.ts @@ -10,10 +10,13 @@ import { import { userPayoutRouter } from './payout'; -import { getPublicUser, getFullUser } from '@/services/user/get'; -import { getUserFeed, userFeedSchema } from '@/services/feed'; -import { listCreditPayments } from '@/services/payments'; -import { createPaymentLink, createPaymentLinkSchema } from '@/services/stripe'; +import { getPublicUser, getFullUser } from '@/services/db/user/get'; +import { getUserFeed, userFeedSchema } from '@/services/db/feed'; +import { listCreditPayments } from '@/services/db/payments/list'; +import { + createCreditsPaymentLink, + createCreditsPaymentLinkSchema, +} from '@/services/stripe/create-link/credits'; import { countApiKeys, createApiKey, @@ -26,34 +29,34 @@ import { listApiKeysSchema, updateApiKey, updateApiKeySchema, -} from '@/services/api-keys'; +} from '@/services/db/api-keys'; import { getGithubLinkForUser, updateGithubLinkForUser, updateUserGithubLinkSchema, -} from '@/services/user/github-link'; +} from '@/services/db/user/github-link'; import { getUserAppBalance, getUserGlobalBalance, -} from '@/services/user/balance'; -import { appIdSchema } from '@/services/apps/lib/schemas'; -import { getCustomerSpendInfoForApp } from '@/lib/spend-pools'; +} from '@/services/db/user/balance'; +import { appIdSchema } from '@/services/db/apps/lib/schemas'; +import { getUserSpendInfoForApp } from '@/services/db/user/app-spend-pool'; -import { userIdSchema } from '@/services/lib/schemas'; +import { userIdSchema } from '@/services/db/_lib/schemas'; import { hasClaimedInitialFreeTierCredits, issueInitialFreeTierCredits, -} from '@/services/user/initial-free-tier'; +} from '@/services/db/user/initial-free-tier'; import { acceptLatestPrivacyPolicy, acceptLatestTermsAndServices, needsLatestPrivacyPolicy, needsLatestTermsAndServices, -} from '@/services/user/terms-agreement'; +} from '@/services/db/user/terms-agreement'; import { getUserCreatorActivity, getUserCreatorActivitySchema, -} from '@/services/user/activity'; +} from '@/services/db/user/activity'; export const userRouter = createTRPCRouter({ payout: userPayoutRouter, @@ -88,7 +91,7 @@ export const userRouter = createTRPCRouter({ free: protectedProcedure .input(appIdSchema) .query(async ({ ctx, input }) => { - return getCustomerSpendInfoForApp(ctx.session.user.id, input); + return getUserSpendInfoForApp(ctx.session.user.id, input); }), }), }, @@ -101,9 +104,9 @@ export const userRouter = createTRPCRouter({ }), create: protectedProcedure - .input(createPaymentLinkSchema) + .input(createCreditsPaymentLinkSchema) .mutation(async ({ ctx, input }) => { - return await createPaymentLink(ctx.session.user.id, input); + return await createCreditsPaymentLink(ctx.session.user.id, input); }), }, diff --git a/packages/app/control/src/trpc/routers/user/payout.ts b/packages/app/control/src/trpc/routers/user/payout.ts index cf5d1773b..84bda016c 100644 --- a/packages/app/control/src/trpc/routers/user/payout.ts +++ b/packages/app/control/src/trpc/routers/user/payout.ts @@ -6,16 +6,15 @@ import { calculateUserReferralEarningsForApp, claimAllReferralRewards, claimReferralRewardForApp, - PayoutStatus, - PayoutType, -} from '@/services/payouts/referrals'; -import { db } from '@/lib/db'; + listPendingReferralPayouts, +} from '@/services/db/user/payouts/referrals'; import { calculateUserMarkupEarnings, calculateAppMarkupEarnings, claimAllMarkupRewards, claimMarkupRewardForApp, -} from '@/services/payouts/markup'; + listPendingMarkupPayouts, +} from '@/services/db/user/payouts/markup'; export const userPayoutRouter = createTRPCRouter({ referral: { @@ -24,21 +23,7 @@ export const userPayoutRouter = createTRPCRouter({ }), pending: protectedProcedure.query(async ({ ctx }) => { - return await db.payout.findMany({ - where: { - userId: ctx.session.user.id, - type: PayoutType.REFERRAL, - status: PayoutStatus.PENDING, - }, - orderBy: { createdAt: 'desc' }, - select: { - id: true, - echoAppId: true, - amount: true, - status: true, - createdAt: true, - }, - }); + return await listPendingReferralPayouts(ctx.session.user.id); }), claimForApp: protectedProcedure @@ -69,35 +54,7 @@ export const userPayoutRouter = createTRPCRouter({ }), pending: protectedProcedure.query(async ({ ctx }) => { - // Only show pending markup payouts for apps owned by the user - const ownedApps = await db.appMembership.findMany({ - where: { - userId: ctx.session.user.id, - role: 'owner', - isArchived: false, - }, - select: { echoAppId: true }, - }); - - if (ownedApps.length === 0) return []; - - const appIds = ownedApps.map(a => a.echoAppId); - - return await db.payout.findMany({ - where: { - type: PayoutType.MARKUP, - status: PayoutStatus.PENDING, - echoAppId: { in: appIds }, - }, - orderBy: { createdAt: 'desc' }, - select: { - id: true, - echoAppId: true, - amount: true, - status: true, - createdAt: true, - }, - }); + return await listPendingMarkupPayouts(ctx.session.user.id); }), claimForApp: protectedProcedure diff --git a/packages/app/control/src/trpc/trpc.ts b/packages/app/control/src/trpc/trpc.ts index 93ea21611..e47dcba8f 100644 --- a/packages/app/control/src/trpc/trpc.ts +++ b/packages/app/control/src/trpc/trpc.ts @@ -3,8 +3,8 @@ import superjson from 'superjson'; import z, { ZodError } from 'zod'; import { auth } from '@/auth'; import type { Session } from 'next-auth'; -import { timeBasedPaginationSchema } from '@/services/lib/pagination'; -import { isAdmin as getIsAdmin } from '@/services/admin/admin'; +import { timeBasedPaginationSchema } from '@/services/db/_lib/pagination'; +import { isAdmin as getIsAdmin } from '@/services/db/admin/admin'; /** * Context that is passed to all TRPC procedures diff --git a/packages/app/control/src/types/payments.ts b/packages/app/control/src/types/payments.ts new file mode 100644 index 000000000..c57447719 --- /dev/null +++ b/packages/app/control/src/types/payments.ts @@ -0,0 +1,5 @@ +export enum PaymentStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', +} diff --git a/packages/app/control/src/types/payouts.ts b/packages/app/control/src/types/payouts.ts new file mode 100644 index 000000000..7314a1107 --- /dev/null +++ b/packages/app/control/src/types/payouts.ts @@ -0,0 +1,9 @@ +export enum PayoutType { + REFERRAL = 'referral', + MARKUP = 'markup', +} + +export enum PayoutStatus { + PENDING = 'pending', + COMPLETED = 'completed', +} diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/types.ts b/packages/app/control/src/types/token-metadata.ts similarity index 100% rename from packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/types.ts rename to packages/app/control/src/types/token-metadata.ts diff --git a/packages/sdk/ts/src/api-types.ts b/packages/sdk/ts/src/api-types.ts index 8e67cf85d..39eb9e3c1 100644 --- a/packages/sdk/ts/src/api-types.ts +++ b/packages/sdk/ts/src/api-types.ts @@ -38,8 +38,8 @@ export type GetAppsResponse = { }; export type GetAppsQuery = { - page: number; - page_size: number; + page?: number | undefined; + page_size?: number | undefined; search?: string | undefined; }; @@ -134,9 +134,9 @@ export type GetOauthAuthorizeQuery = { redirect_uri: string; code_challenge: string; code_challenge_method: 'S256'; - scope: string; - state: string; - response_type: 'code'; + scope?: string | undefined; + state?: string | undefined; + response_type?: 'code' | undefined; prompt?: 'none' | undefined; new_user?: 'true' | undefined; referral_code?: string | undefined;