From acb469fa1e3a483807eb726a3c0e6b66835757fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=ADt?= Date: Tue, 28 Jul 2026 11:34:14 +0200 Subject: [PATCH] refactor: extract cloud billing behind host extension points Cloud billing no longer lives in this repository. The hosting deployment owns it end-to-end and plugs in through generic extension points: - OrganizationBillingPort is replaced by the billing-agnostic OrganizationLifecyclePort (organizationCreated hook, noop community layer); generate-id gains createIdGenerator so hosts mint their own prefixed ids without registering prefixes here. - The five billing tables (organization_billing, usage_record, usage_aggregate, billing_webhook_event, billing_provider_meter) leave the schema. The new migration is deliberately a no-op: the tables are owned by the hosting deployment and keep their data; drizzle tablesFilter entries guard against future generated drops. - BackendRuntimeCapabilities becomes a generic Record; the community backend advertises {} and the selfhost smoke asserts that. - The studio sidebar takes extension nav items from a slot module, the marketing nav from a config module (both empty here, aliased by the hosted app), and the cloud /pricing page plus the pricing-preview harness and unused billing API error contracts are removed. Product payment semantics (App Store / Google Play / Stripe billing retry, grace periods, Play Billing SDK) are untouched. Co-Authored-By: Claude Fable 5 --- apps/backend/src/BackendApp.ts | 22 +- .../backend/src/rpc-smoke.integration.test.ts | 2 +- apps/backend/src/testing/smoke-ids.ts | 1 - apps/backend/src/testing/smoke-seed.ts | 25 +- apps/www/scripts/pricing-preview/index.html | 12 - apps/www/scripts/pricing-preview/main.tsx | 6 - .../scripts/pricing-preview/vite.config.ts | 16 -- .../docs/component-overview/cards/faq.tsx | 22 +- .../component-overview/cards/sidebar-nav.tsx | 12 +- .../docs/api/organizations-and-projects.mdx | 2 +- .../enterprise/organization-nav-slot.ts | 27 ++ .../studio/enterprise/runtime-capabilities.ts | 33 ++- .../sidebar/organization-settings-sidebar.tsx | 106 ------- .../sidebar/organization-sidebar.tsx | 10 +- .../www/landing/marketing-nav-config.ts | 14 + .../features/www/landing/sections/footer.tsx | 4 +- .../features/www/landing/sections/navbar.tsx | 65 ++--- apps/www/src/features/www/pricing/plans.ts | 237 ---------------- .../src/features/www/pricing/pricing-model.ts | 138 ---------- .../src/features/www/pricing/pricing-page.tsx | 37 --- .../www/pricing/sections/calculator.tsx | 259 ------------------ .../src/features/www/pricing/sections/faq.tsx | 34 --- .../features/www/pricing/sections/hero.tsx | 24 -- .../features/www/pricing/sections/matrix.tsx | 111 -------- .../features/www/pricing/sections/plans.tsx | 83 ------ .../www/pricing/sections/self-host.tsx | 26 -- apps/www/src/routeTree.gen.ts | 21 -- apps/www/src/routes/_marketing/pricing.tsx | 11 - drizzle.config.ts | 17 +- packages/api-contracts/src/errors/Billing.ts | 28 -- packages/api-contracts/src/errors/index.ts | 1 - packages/core/package.json | 2 +- packages/core/src/services/index.ts | 2 +- .../organizations/OrganizationBillingPort.ts | 31 --- .../OrganizationLifecyclePort.ts | 31 +++ .../organizations/OrganizationService.ts | 20 +- .../core/src/services/slack/slack-client.ts | 8 +- packages/core/src/utils/generate-id.ts | 19 +- .../OrganizationBillingPort.test.ts | 13 - .../OrganizationLifecyclePort.test.ts | 13 + .../OrganizationService.integration.test.ts | 135 ++++----- packages/core/test/utils/generate-id.test.ts | 2 +- .../migration.sql | 10 + packages/db/src/relations.ts | 6 - packages/db/src/schema.ts | 211 +------------- packages/db/src/types.ts | 19 +- packages/shared/src/billing.ts | 3 - packages/shared/src/index.ts | 1 - selfhost/smoke.mts | 5 +- turbo.json | 1 - 50 files changed, 301 insertions(+), 1637 deletions(-) delete mode 100644 apps/www/scripts/pricing-preview/index.html delete mode 100644 apps/www/scripts/pricing-preview/main.tsx delete mode 100644 apps/www/scripts/pricing-preview/vite.config.ts create mode 100644 apps/www/src/features/studio/enterprise/organization-nav-slot.ts delete mode 100644 apps/www/src/features/studio/shell/components/sidebar/organization-settings-sidebar.tsx create mode 100644 apps/www/src/features/www/landing/marketing-nav-config.ts delete mode 100644 apps/www/src/features/www/pricing/plans.ts delete mode 100644 apps/www/src/features/www/pricing/pricing-model.ts delete mode 100644 apps/www/src/features/www/pricing/pricing-page.tsx delete mode 100644 apps/www/src/features/www/pricing/sections/calculator.tsx delete mode 100644 apps/www/src/features/www/pricing/sections/faq.tsx delete mode 100644 apps/www/src/features/www/pricing/sections/hero.tsx delete mode 100644 apps/www/src/features/www/pricing/sections/matrix.tsx delete mode 100644 apps/www/src/features/www/pricing/sections/plans.tsx delete mode 100644 apps/www/src/features/www/pricing/sections/self-host.tsx delete mode 100644 apps/www/src/routes/_marketing/pricing.tsx delete mode 100644 packages/api-contracts/src/errors/Billing.ts delete mode 100644 packages/core/src/services/organizations/OrganizationBillingPort.ts create mode 100644 packages/core/src/services/organizations/OrganizationLifecyclePort.ts delete mode 100644 packages/core/test/services/organizations/OrganizationBillingPort.test.ts create mode 100644 packages/core/test/services/organizations/OrganizationLifecyclePort.test.ts create mode 100644 packages/db/src/alchemy-migrations/20260728120000_remove_billing_tables/migration.sql delete mode 100644 packages/shared/src/billing.ts diff --git a/apps/backend/src/BackendApp.ts b/apps/backend/src/BackendApp.ts index a3583e2e2..04fbc2701 100644 --- a/apps/backend/src/BackendApp.ts +++ b/apps/backend/src/BackendApp.ts @@ -36,7 +36,7 @@ import { NotificationSendingService, NotificationsConfigurationService, NotificationTokenService, - OrganizationBillingPort, + OrganizationLifecyclePort, OrganizationMembershipSyncPort, OrganizationMembershipWebhookPort, OrganizationService, @@ -637,10 +637,8 @@ const HealthCheckRoute = Layer.effectDiscard( }), ); -export interface BackendRuntimeCapabilities { - readonly auditLogs: boolean; - readonly billing: boolean; -} +/** Feature-supplied capability flags advertised to clients, keyed by feature name. */ +export type BackendRuntimeCapabilities = Readonly>; const RuntimeCapabilitiesRoute = (capabilities: BackendRuntimeCapabilities) => Layer.effectDiscard( @@ -658,7 +656,7 @@ const RuntimeCapabilitiesRoute = (capabilities: BackendRuntimeCapabilities) => export type BackendCoreFeatureServices = | AuditLogPort - | OrganizationBillingPort + | OrganizationLifecyclePort | OrganizationMembershipSyncPort | OrganizationMembershipWebhookPort; @@ -924,12 +922,12 @@ export interface BackendFeatureComposition = { group: RpcGroup.make(), routes: () => Layer.empty, - runtimeCapabilities: { auditLogs: false, billing: false }, + runtimeCapabilities: {}, services: () => Layer.empty, supportServices: () => Layer.mergeAll( AuditLogPort.noop, - OrganizationBillingPort.noop, + OrganizationLifecyclePort.noop, OrganizationMembershipSyncPort.noop, OrganizationMembershipWebhookPort.noop, ), @@ -1067,10 +1065,10 @@ export const buildBackendFetch = < // layer dependency — so `Layer.provide` does not discharge it (it would // silently leak to the request handler and die with "Service not found"). // `HttpRouter.provideRequest` is the combinator that satisfies these - // request-scoped requirements. The Autumn webhook handler resolves `Db` + - // `BillingService` at request time (both part of the merged graph below); - // the Apple handler resolves the live public App Store service the same way; - // the Google handler additionally resolves its Pub/Sub OIDC verifier. + // request-scoped requirements. Feature-supplied webhook routes resolve their + // own services at request time the same way (all part of the merged graph + // below); the Apple handler resolves the live public App Store service; the + // Google handler additionally resolves its Pub/Sub OIDC verifier. const WebhookRoutesLayer = Layer.mergeAll( AppleServerToServerNotificationRouteLayer, GooglePlayRtdnNotificationRouteLayer, diff --git a/apps/backend/src/rpc-smoke.integration.test.ts b/apps/backend/src/rpc-smoke.integration.test.ts index 8eda92f78..335a493a7 100644 --- a/apps/backend/src/rpc-smoke.integration.test.ts +++ b/apps/backend/src/rpc-smoke.integration.test.ts @@ -407,7 +407,7 @@ describe("Backend runtime capabilities", () => { ); expect(status).toBe(200); - expect(JSON.parse(text)).toEqual({ enterprise: { auditLogs: false, billing: false } }); + expect(JSON.parse(text)).toEqual({ enterprise: {} }); }); }); diff --git a/apps/backend/src/testing/smoke-ids.ts b/apps/backend/src/testing/smoke-ids.ts index 9e210ced4..6a9fe7c36 100644 --- a/apps/backend/src/testing/smoke-ids.ts +++ b/apps/backend/src/testing/smoke-ids.ts @@ -18,7 +18,6 @@ export const makeSmokeIds = (runId: string) => { adminMemberId: `smk_mem_admin_${suffix}`, adminUserId: `smk_admin_${suffix}`, apiKeyId: `smk_api_key_${suffix}`, - billingId: `smk_billing_${suffix}`, invitedEmail: `smoke-invite-${suffix}@example.test`, invitedUserId: `smk_invite_${suffix}`, normalEmail: `smoke-user-${suffix}@example.test`, diff --git a/apps/backend/src/testing/smoke-seed.ts b/apps/backend/src/testing/smoke-seed.ts index 7ab768d49..8962b294f 100644 --- a/apps/backend/src/testing/smoke-seed.ts +++ b/apps/backend/src/testing/smoke-seed.ts @@ -2,8 +2,6 @@ import { apiKeys, apikey, auditLogs, - BillingSubscriptionStatus, - BillingTier, captureProjectPolicies, Db, eq, @@ -15,7 +13,6 @@ import { invitation, member, organization, - organizationBilling, paymentProviderConfigurationProducts, paymentProviderConfigurations, paywallLocationShowings, @@ -33,8 +30,6 @@ import { productPerks, products, projects, - usageAggregates, - usageRecords, user, webhookDeliveries, webhookDeliveryAttempts, @@ -46,8 +41,8 @@ import { makeSmokeIds } from "./smoke-ids.ts"; /** * Deterministic fixture for the backend RPC smoke. Seeds an `admin`-role user, a - * normal user, their organization/memberships, a project, a seeded API key, and a - * billing row — everything the {@link rpcSmokeCases} manifest reads or scopes + * normal user, their organization/memberships, a project, and a seeded API key + * — everything the {@link rpcSmokeCases} manifest reads or scopes * against. Kept separate from the lean shared `CoreTestFixture` because the smoke * needs a richer, smoke-specific tenant; it still rides the same once-deployed * stack + `testConnections` as the service-level integration tests. @@ -186,11 +181,6 @@ export const resetSmokeData = (runId: string) => .where(eq(captureProjectPolicies.projectId, ids.projectId)); yield* db.delete(projects).where(eq(projects.id, ids.projectId)); - yield* db.delete(usageRecords).where(eq(usageRecords.organizationId, ids.organizationId)); - yield* db.delete(usageAggregates).where(eq(usageAggregates.organizationId, ids.organizationId)); - yield* db - .delete(organizationBilling) - .where(eq(organizationBilling.organizationId, ids.organizationId)); yield* db.delete(invitation).where(eq(invitation.organizationId, ids.organizationId)); yield* db.delete(member).where(eq(member.organizationId, ids.organizationId)); yield* db.delete(organization).where(eq(organization.id, ids.organizationId)); @@ -296,15 +286,4 @@ export const seedSmokeData = (runId: string) => projectId: ids.projectId, updatedAt: now, }); - yield* db.insert(organizationBilling).values({ - billingProviderId: "smoke", - currentPeriodEnd: null, - currentPeriodStart: null, - externalCustomerId: null, - externalSubscriptionId: null, - id: ids.billingId, - organizationId: ids.organizationId, - subscriptionStatus: BillingSubscriptionStatus.None, - tier: BillingTier.Free, - }); }); diff --git a/apps/www/scripts/pricing-preview/index.html b/apps/www/scripts/pricing-preview/index.html deleted file mode 100644 index 612641f4f..000000000 --- a/apps/www/scripts/pricing-preview/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Pricing preview - - -
- - - diff --git a/apps/www/scripts/pricing-preview/main.tsx b/apps/www/scripts/pricing-preview/main.tsx deleted file mode 100644 index 2bdb30894..000000000 --- a/apps/www/scripts/pricing-preview/main.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { createRoot } from "react-dom/client"; - -import "../../src/styles/globals.css"; -import { PricingPage } from "../../src/features/www/pricing/pricing-page"; - -createRoot(document.getElementById("root")!).render(); diff --git a/apps/www/scripts/pricing-preview/vite.config.ts b/apps/www/scripts/pricing-preview/vite.config.ts deleted file mode 100644 index 3ff532e40..000000000 --- a/apps/www/scripts/pricing-preview/vite.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import tailwindcss from "@tailwindcss/vite"; -import viteReact from "@vitejs/plugin-react"; -import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite"; - -export default defineConfig({ - root: fileURLToPath(new URL(".", import.meta.url)), - plugins: [tailwindcss(), viteReact()], - resolve: { - alias: { - "@": fileURLToPath(new URL("../../src", import.meta.url)), - }, - dedupe: ["react", "react-dom"], - }, - server: { port: 5200 }, -}); diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx index 6819b81a6..b895b6033 100644 --- a/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx +++ b/apps/www/src/features/design/components/docs/component-overview/cards/faq.tsx @@ -20,18 +20,18 @@ const GENERAL_QUESTIONS = [ }, ]; -const BILLING_QUESTIONS = [ +const SYNC_QUESTIONS = [ { - q: "What is the difference between Basic and Pro pricing tiers?", - a: "Basic includes budgeting, goal tracking, and up to 3 linked accounts. Pro adds unlimited accounts, dividend tracking, portfolio analysis, and priority support.", + q: "How often does data refresh?", + a: "Connected institutions sync every six hours, and you can pull a manual refresh from the account detail view at any time.", }, { - q: "How do I cancel my subscription?", - a: "Go to Settings > Billing > Manage Plan and click Cancel. Your access continues until the end of your current billing period.", + q: "Why is a transaction missing?", + a: "Pending transactions appear once the institution posts them. If a posted transaction is still missing after 48 hours, reconnect the account from Settings.", }, { - q: "Do you offer a free trial?", - a: "Yes. All new accounts start with a 14-day Pro trial. No credit card required.", + q: "Can I import a statement manually?", + a: "Yes. Upload a CSV or OFX file from the account detail view and map the columns once — the mapping is remembered for later imports.", }, ]; @@ -72,8 +72,8 @@ export function Faq() { General - - Billing + + Sync Goals @@ -82,8 +82,8 @@ export function Faq() { - - + + diff --git a/apps/www/src/features/design/components/docs/component-overview/cards/sidebar-nav.tsx b/apps/www/src/features/design/components/docs/component-overview/cards/sidebar-nav.tsx index c391f4e38..ab3e6b1b9 100644 --- a/apps/www/src/features/design/components/docs/component-overview/cards/sidebar-nav.tsx +++ b/apps/www/src/features/design/components/docs/component-overview/cards/sidebar-nav.tsx @@ -172,13 +172,13 @@ export function SidebarNav() { - Billing + Members diff --git a/apps/www/src/features/docs/content/docs/api/organizations-and-projects.mdx b/apps/www/src/features/docs/content/docs/api/organizations-and-projects.mdx index bc53978bb..332d410d2 100644 --- a/apps/www/src/features/docs/content/docs/api/organizations-and-projects.mdx +++ b/apps/www/src/features/docs/content/docs/api/organizations-and-projects.mdx @@ -4,7 +4,7 @@ description: Create organizations and projects, list the projects in an organiza --- Organizations and projects are the account-level containers in Voidhash. An **organization** groups -your team and billing; a **project** lives inside an organization and owns everything else — +your team; a **project** lives inside an organization and owns everything else — products, perks, paywalls, API keys, and persons. The endpoints on this page manage those containers and the user account itself. diff --git a/apps/www/src/features/studio/enterprise/organization-nav-slot.ts b/apps/www/src/features/studio/enterprise/organization-nav-slot.ts new file mode 100644 index 000000000..e219d6309 --- /dev/null +++ b/apps/www/src/features/studio/enterprise/organization-nav-slot.ts @@ -0,0 +1,27 @@ +import type { LucideIcon } from "lucide-react"; + +import type { RuntimeCapabilities } from "./runtime-capabilities"; + +/** A single sidebar entry, matching the item shape `SidebarShell` renders. */ +export interface OrganizationNavItem { + title: string; + url: string; + icon?: LucideIcon; + isActive?: () => boolean; +} + +export interface OrganizationNavSlotContext { + readonly capabilities: RuntimeCapabilities | undefined; + readonly organizationSlug: string; + readonly pathname: string; +} + +/** + * Community extension slot for host-provided organization settings entries. + * Hosts replace this module to append their own nav items. + */ +export function organizationSettingsNavItems( + _context: OrganizationNavSlotContext, +): OrganizationNavItem[] { + return []; +} diff --git a/apps/www/src/features/studio/enterprise/runtime-capabilities.ts b/apps/www/src/features/studio/enterprise/runtime-capabilities.ts index c4afc2d8e..0f152e930 100644 --- a/apps/www/src/features/studio/enterprise/runtime-capabilities.ts +++ b/apps/www/src/features/studio/enterprise/runtime-capabilities.ts @@ -2,11 +2,18 @@ import { useQuery } from "@tanstack/react-query"; import { env } from "@/lib/env"; -const disabledCapabilities = { - enterprise: { auditLogs: false, billing: false }, -} as const; +/** Enabled enterprise capability ids advertised by the host composition. */ +export type EnterpriseCapabilities = Readonly>; -const loadRuntimeCapabilities = async () => { +export interface RuntimeCapabilities { + readonly enterprise: EnterpriseCapabilities; +} + +const disabledCapabilities: RuntimeCapabilities = { + enterprise: {}, +}; + +const loadRuntimeCapabilities = async (): Promise => { try { const apiBaseUrl = env.VITE_APP_API_URL.replace(/\/+$/, ""); const response = await fetch(`${apiBaseUrl}/api/runtime-capabilities`, { @@ -15,17 +22,15 @@ const loadRuntimeCapabilities = async () => { if (!response.ok) return disabledCapabilities; const body = (await response.json()) as { - readonly enterprise?: { - readonly auditLogs?: unknown; - readonly billing?: unknown; - }; - }; - return { - enterprise: { - auditLogs: body.enterprise?.auditLogs === true, - billing: body.enterprise?.billing === true, - }, + readonly enterprise?: Readonly>; }; + const enterprise: Record = {}; + for (const [capability, enabled] of Object.entries(body.enterprise ?? {})) { + if (enabled === true) { + enterprise[capability] = true; + } + } + return { enterprise }; } catch { return disabledCapabilities; } diff --git a/apps/www/src/features/studio/shell/components/sidebar/organization-settings-sidebar.tsx b/apps/www/src/features/studio/shell/components/sidebar/organization-settings-sidebar.tsx deleted file mode 100644 index 77f443d9d..000000000 --- a/apps/www/src/features/studio/shell/components/sidebar/organization-settings-sidebar.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// "use client"; - -// import type * as React from "react"; - -// import { Link, useLocation, useParams } from "@tanstack/react-router"; -// import { -// GradientAvatar, -// Sidebar, -// SidebarContent, -// SidebarGroup, -// SidebarGroupLabel, -// SidebarHeader, -// SidebarMenu, -// SidebarMenuButton, -// SidebarMenuItem, -// } from "@voidhash/ui"; -// import { useAuth } from "@/features/studio/components/auth-context"; - -// import { NavMain } from "./nav-main"; - -// const SidebarProjects = ({ organizationSlug }: { organizationSlug: string }) => { -// const { user } = useAuth(); - -// const organization = user.organizations.find((o) => o.slug === organizationSlug); -// const projects = organization -// ? user.projects.filter((p) => p.organizationId === organization.id) -// : []; - -// return ( -// -// {projects.map((project) => ( -// -// -// -//
-// -// {project.name} -//
-// -//
-//
-// ))} -//
-// ); -// }; - -// export function OrganizationSettingsSidebar({ ...props }: React.ComponentProps) { -// const pathname = useLocation({ -// select: (location) => location.pathname, -// }); -// const { organizationSlug } = useParams({ -// strict: false, -// }); - -// const data = { -// navMain: [ -// { -// items: [ -// { -// isActive: () => pathname.startsWith(`/studio/${organizationSlug}/~/settings/general`), -// title: "General", -// url: `/studio/${organizationSlug}/~/settings/general`, -// }, -// { -// isActive: () => pathname.startsWith(`/studio/${organizationSlug}/~/settings/billing`), -// title: "Billing", -// url: `/studio/${organizationSlug}/~/settings/billing`, -// }, -// ], -// title: "Team", -// }, -// ], -// }; - -// return ( -// -// -//
-//
Team Settings
-//
-//
-// -// -// -// Projects -// -// -// -//
-// ); -// } diff --git a/apps/www/src/features/studio/shell/components/sidebar/organization-sidebar.tsx b/apps/www/src/features/studio/shell/components/sidebar/organization-sidebar.tsx index ddb947a93..30046b9a2 100644 --- a/apps/www/src/features/studio/shell/components/sidebar/organization-sidebar.tsx +++ b/apps/www/src/features/studio/shell/components/sidebar/organization-sidebar.tsx @@ -14,6 +14,7 @@ import { import { Grid2X2, Plus, Settings } from "lucide-react"; import { useState } from "react"; import { useAuth } from "@/features/studio/components/auth-context"; +import { organizationSettingsNavItems } from "@/features/studio/enterprise/organization-nav-slot"; import { useRuntimeCapabilities } from "@/features/studio/enterprise/runtime-capabilities"; import { CreateProjectModal } from "@/features/studio/projects/create-project-modal"; @@ -61,14 +62,7 @@ export function OrganizationSidebar({ title: "General", url: `/studio/${organizationSlug}/~/settings`, }, - ...(capabilities?.enterprise.billing - ? [{ - isActive: () => - pathname.startsWith(`/studio/${organizationSlug}/~/settings/billing`), - title: "Billing", - url: `/studio/${organizationSlug}/~/settings/billing`, - }] - : []), + ...organizationSettingsNavItems({ capabilities, organizationSlug, pathname }), ], }, ], diff --git a/apps/www/src/features/www/landing/marketing-nav-config.ts b/apps/www/src/features/www/landing/marketing-nav-config.ts new file mode 100644 index 000000000..3b31c5c67 --- /dev/null +++ b/apps/www/src/features/www/landing/marketing-nav-config.ts @@ -0,0 +1,14 @@ +import type { LucideIcon } from "lucide-react"; + +/** A marketing navigation link. Hrefs are plain strings so hosts can add pages the OSS build has no route for. */ +export interface MarketingNavLink { + label: string; + href: string; + icon: LucideIcon; +} + +/** + * Company links rendered in the marketing navbar and footer. The community build + * ships none; hosts replace this module to add the pages their deployment has. + */ +export const MARKETING_COMPANY_LINKS: MarketingNavLink[] = []; diff --git a/apps/www/src/features/www/landing/sections/footer.tsx b/apps/www/src/features/www/landing/sections/footer.tsx index c926bd510..9842d065f 100644 --- a/apps/www/src/features/www/landing/sections/footer.tsx +++ b/apps/www/src/features/www/landing/sections/footer.tsx @@ -1,5 +1,7 @@ import { Logo } from "@voidhash/ui"; +import { MARKETING_COMPANY_LINKS } from "@/features/www/landing/marketing-nav-config"; + const GITHUB_REPO = "https://github.com/voidhashcom/voidhash"; const LINK_COLUMNS: { @@ -39,7 +41,7 @@ const LINK_COLUMNS: { { title: "COMPANY", links: [ - { label: "Pricing", href: "/pricing" }, + ...MARKETING_COMPANY_LINKS.map((link) => ({ label: link.label, href: link.href })), { label: "Blog" }, { label: "Security" }, { label: "Privacy" }, diff --git a/apps/www/src/features/www/landing/sections/navbar.tsx b/apps/www/src/features/www/landing/sections/navbar.tsx index 5c4f4e485..cdf1a92cf 100644 --- a/apps/www/src/features/www/landing/sections/navbar.tsx +++ b/apps/www/src/features/www/landing/sections/navbar.tsx @@ -24,10 +24,10 @@ import { LayoutTemplateIcon, type LucideIcon, MenuIcon, - TagIcon, UsersIcon, } from "lucide-react"; +import { MARKETING_COMPANY_LINKS } from "@/features/www/landing/marketing-nav-config"; import { signUpCtaLabel } from "@/lib/waitlist"; type NavLink = { @@ -38,8 +38,9 @@ type NavLink = { }; /** - * Product links point at landing page sections, so they stay root-relative: from `/pricing` a - * bare `#paywalls` fragment resolves against a page that has no such section and goes nowhere. + * Product links point at landing page sections, so they stay root-relative: from a standalone + * marketing page a bare `#paywalls` fragment resolves against a page that has no such section + * and goes nowhere. */ const PRODUCT_LINKS: NavLink[] = [ { @@ -73,8 +74,6 @@ const DEVELOPER_LINKS: NavLink[] = [ { label: "API reference", href: "/docs/api", icon: BracesIcon }, ]; -const PRICING_LINK: NavLink = { label: "Pricing", href: "/pricing", icon: TagIcon }; - /** Shared look for the top-level triggers and links. */ const NAV_ITEM_CLASS = "h-auto rounded-lg px-3 py-2 font-normal font-sans text-zinc-400 text-sm/4.5 tracking-[-0.03em] transition-colors hover:bg-white/5 hover:text-white focus:bg-white/5 focus:text-white data-pressed:bg-white/10 data-popup-open:bg-white/5 data-popup-open:text-white data-popup-open:hover:bg-white/5"; @@ -147,14 +146,16 @@ export function LandingNavbar() { - - } - > - {PRICING_LINK.label} - - + {MARKETING_COMPANY_LINKS.map((link) => ( + + } + > + {link.label} + + + ))}
@@ -177,25 +178,27 @@ export function LandingNavbar() { {[ { title: "Product", links: PRODUCT_LINKS }, { title: "Developers", links: DEVELOPER_LINKS }, - { title: "Company", links: [PRICING_LINK] }, - ].map((group) => ( -
-
- {group.title} + { title: "Company", links: MARKETING_COMPANY_LINKS }, + ] + .filter((group) => group.links.length > 0) + .map((group) => ( +
+
+ {group.title} +
+ {group.links.map((link) => ( + + + + {link.label} + + + ))}
- {group.links.map((link) => ( - - - - {link.label} - - - ))} -
- ))} + ))}
-
-
-
- - - ); -} diff --git a/apps/www/src/features/www/pricing/sections/faq.tsx b/apps/www/src/features/www/pricing/sections/faq.tsx deleted file mode 100644 index 2068f2543..000000000 --- a/apps/www/src/features/www/pricing/sections/faq.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@voidhash/ui"; - -import { LandingSection, SectionHeader } from "@/features/www/landing/shared"; - -import { FAQ } from "../plans"; - -/** Renders the pricing FAQ. */ -export function PricingFaq() { - return ( - -
-
- -
- - {FAQ.map((entry) => ( - - - {entry.question} - - - {entry.answer} - - - ))} - -
-
- ); -} diff --git a/apps/www/src/features/www/pricing/sections/hero.tsx b/apps/www/src/features/www/pricing/sections/hero.tsx deleted file mode 100644 index d9b995423..000000000 --- a/apps/www/src/features/www/pricing/sections/hero.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { HeroShader } from "@/features/www/hero/hero-shader"; -import { LandingSection } from "@/features/www/landing/shared"; - -/** Renders the pricing page hero, backed by the same lenticular shader as the landing hero. */ -export function PricingHero() { - return ( - -
-
- -
-
-

- Pricing that starts at zero and scales with you -

-

- Every plan includes the whole platform. You only ever pay for the revenue we track and - the events we ingest for you. -

-
-
-
- ); -} diff --git a/apps/www/src/features/www/pricing/sections/matrix.tsx b/apps/www/src/features/www/pricing/sections/matrix.tsx deleted file mode 100644 index bc5938621..000000000 --- a/apps/www/src/features/www/pricing/sections/matrix.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { cn } from "@voidhash/ui"; -import { CheckIcon, MinusIcon } from "lucide-react"; - -import { LandingSection, SectionHeader } from "@/features/www/landing/shared"; - -import { MATRIX, type MatrixValue, PLANS } from "../plans"; - -/** - * Height of the sticky navbar the header row has to clear: 32px of controls plus its padding - * (`p-4` on mobile, `p-6` from `md`) and the hairline underneath. - */ -const HEADER_OFFSET = "top-[65px] md:top-[81px]"; - -/** Shared column geometry, so the header row and every body row land in the same lanes. */ -const ROW = "flex items-stretch gap-4 px-6 md:px-12 xl:px-32"; - -/** The matrix minus rows for capabilities we have not shipped, and any group left empty by that. */ -const VISIBLE_MATRIX = MATRIX.map((group) => ({ - ...group, - rows: group.rows.filter((row) => !row.hidden), -})).filter((group) => group.rows.length > 0); -const LABEL_CELL = "flex-1 min-w-0 py-4"; -const VALUE_CELL = "w-32 shrink-0 py-4 md:w-40"; - -function Cell({ value }: { value: MatrixValue }) { - if (value === true) { - return ; - } - if (value === false) { - return ; - } - return ( -
{value}
- ); -} - -/** Renders the plan comparison matrix. */ -export function PricingMatrix() { - return ( - -
- -
-
-
-
-
Feature
- {PLANS.map((plan) => ( -
- {plan.name} -
- ))} -
- {VISIBLE_MATRIX.map((group) => ( -
-
- {group.title} -
- {group.rows.map((row) => ( -
-
-
- {row.label} -
- {row.detail ? ( -
- {row.detail} -
- ) : null} -
-
- -
-
- -
-
- -
-
- ))} -
- ))} -
-
-
- ); -} diff --git a/apps/www/src/features/www/pricing/sections/plans.tsx b/apps/www/src/features/www/pricing/sections/plans.tsx deleted file mode 100644 index 6ce139396..000000000 --- a/apps/www/src/features/www/pricing/sections/plans.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { Button, cn } from "@voidhash/ui"; -import { CheckIcon } from "lucide-react"; - -import { LandingSection } from "@/features/www/landing/shared"; - -import { type Plan, PLANS } from "../plans"; -import { PricingSelfHost } from "./self-host"; - -/** Lifts the recommended column off the flat surface without boxing it in a card. */ -const HIGHLIGHT_BACKGROUND = - "radial-gradient(ellipse 100% 100% at 50% 0% in oklab, var(--color-zinc-900) 0%, var(--color-zinc-950) 100%)"; - -function PlanColumn({ plan }: { plan: Plan }) { - const recommended = plan.id === "grow"; - - return ( -
-
-
- {plan.name} -
-
-
- {plan.priceNote} -
-
-
- {plan.price} -
- {plan.priceSuffix ? ( -
- {plan.priceSuffix} -
- ) : null} -
-
-

- {plan.description} -

- -
-
- {plan.highlights.map((highlight) => ( -
- -
- {highlight} -
-
- ))} -
-
- ); -} - -/** Renders the three plan columns. */ -export function PricingPlans() { - return ( - -
- {PLANS.map((plan) => ( - - ))} -
- -
- ); -} diff --git a/apps/www/src/features/www/pricing/sections/self-host.tsx b/apps/www/src/features/www/pricing/sections/self-host.tsx deleted file mode 100644 index 28e338b66..000000000 --- a/apps/www/src/features/www/pricing/sections/self-host.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ArrowUpRightIcon } from "lucide-react"; - -/** - * Renders the one-line self-hosting note under the plan columns. - * - * Sits inside the plans section rather than its own one, so the free-forever alternative reads - * as a footnote to the tiers rather than an unrelated pitch further down the page. - */ -export function PricingSelfHost() { - return ( -
-
- Voidhash is open source — you can also run the whole platform yourself, for free. -
- - Self-hosting guide - - -
- ); -} diff --git a/apps/www/src/routeTree.gen.ts b/apps/www/src/routeTree.gen.ts index 377daac8c..c8c6f0dc8 100644 --- a/apps/www/src/routeTree.gen.ts +++ b/apps/www/src/routeTree.gen.ts @@ -26,7 +26,6 @@ import { Route as AuthResetPasswordRouteImport } from './routes/auth/reset-passw import { Route as AuthLogoutRouteImport } from './routes/auth/logout' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthForgotPasswordRouteImport } from './routes/auth/forgot-password' -import { Route as MarketingPricingRouteImport } from './routes/_marketing/pricing' import { Route as StudioAuthenticatedRouteRouteImport } from './routes/studio/_authenticated/route' import { Route as StudioAuthenticatedIndexRouteImport } from './routes/studio/_authenticated/index' import { Route as AuthDevicesIndexRouteImport } from './routes/auth/devices/index' @@ -168,11 +167,6 @@ const AuthForgotPasswordRoute = AuthForgotPasswordRouteImport.update({ path: '/forgot-password', getParentRoute: () => AuthRouteRoute, } as any) -const MarketingPricingRoute = MarketingPricingRouteImport.update({ - id: '/pricing', - path: '/pricing', - getParentRoute: () => MarketingRouteRoute, -} as any) const StudioAuthenticatedRouteRoute = StudioAuthenticatedRouteRouteImport.update({ id: '/_authenticated', @@ -614,7 +608,6 @@ export interface FileRoutesByFullPath { '/design': typeof DesignRouteRouteWithChildren '/docs': typeof DocsRouteRouteWithChildren '/studio': typeof StudioRouteRouteWithChildren - '/pricing': typeof MarketingPricingRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute '/auth/login': typeof AuthLoginRoute '/auth/logout': typeof AuthLogoutRoute @@ -683,7 +676,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/studio': typeof StudioAuthenticatedIndexRoute - '/pricing': typeof MarketingPricingRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute '/auth/login': typeof AuthLoginRoute '/auth/logout': typeof AuthLogoutRoute @@ -756,7 +748,6 @@ export interface FileRoutesById { '/docs': typeof DocsRouteRouteWithChildren '/studio': typeof StudioRouteRouteWithChildren '/studio/_authenticated': typeof StudioAuthenticatedRouteRouteWithChildren - '/_marketing/pricing': typeof MarketingPricingRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute '/auth/login': typeof AuthLoginRoute '/auth/logout': typeof AuthLogoutRoute @@ -833,7 +824,6 @@ export interface FileRouteTypes { | '/design' | '/docs' | '/studio' - | '/pricing' | '/auth/forgot-password' | '/auth/login' | '/auth/logout' @@ -902,7 +892,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/studio' - | '/pricing' | '/auth/forgot-password' | '/auth/login' | '/auth/logout' @@ -974,7 +963,6 @@ export interface FileRouteTypes { | '/docs' | '/studio' | '/studio/_authenticated' - | '/_marketing/pricing' | '/auth/forgot-password' | '/auth/login' | '/auth/logout' @@ -1182,13 +1170,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthForgotPasswordRouteImport parentRoute: typeof AuthRouteRoute } - '/_marketing/pricing': { - id: '/_marketing/pricing' - path: '/pricing' - fullPath: '/pricing' - preLoaderRoute: typeof MarketingPricingRouteImport - parentRoute: typeof MarketingRouteRoute - } '/studio/_authenticated': { id: '/studio/_authenticated' path: '' @@ -1585,12 +1566,10 @@ declare module '@tanstack/react-router' { } interface MarketingRouteRouteChildren { - MarketingPricingRoute: typeof MarketingPricingRoute MarketingIndexRoute: typeof MarketingIndexRoute } const MarketingRouteRouteChildren: MarketingRouteRouteChildren = { - MarketingPricingRoute: MarketingPricingRoute, MarketingIndexRoute: MarketingIndexRoute, } diff --git a/apps/www/src/routes/_marketing/pricing.tsx b/apps/www/src/routes/_marketing/pricing.tsx deleted file mode 100644 index 86acb098b..000000000 --- a/apps/www/src/routes/_marketing/pricing.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { PricingPage } from "@/features/www/pricing/pricing-page"; - -export const Route = createFileRoute("/_marketing/pricing")({ - component: MarketingPricing, -}); - -function MarketingPricing() { - return ; -} diff --git a/drizzle.config.ts b/drizzle.config.ts index ed0036ccd..f194c0299 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -4,5 +4,20 @@ export default defineConfig({ dialect: "postgresql", out: "./packages/db/src/alchemy-migrations", schema: "./packages/db/src/schema.ts", - tablesFilter: ["!cluster_*", "!mimic_*", "!effect_sql_migrations"], + tablesFilter: [ + "!cluster_*", + "!mimic_*", + "!effect_sql_migrations", + // Cloud-billing tables are owned by the hosting deployment and may still + // exist (with live data) in databases this schema is applied to. They are + // absent from `schema.ts` but present in the newest tracked snapshot, so + // without these filters `db:generate` would propose DROP TABLE for them. + // See `packages/db/src/alchemy-migrations/20260728120000_remove_billing_tables/`. + // Do not remove. + "!organization_billing", + "!usage_record", + "!usage_aggregate", + "!billing_webhook_event", + "!billing_provider_meter", + ], }); diff --git a/packages/api-contracts/src/errors/Billing.ts b/packages/api-contracts/src/errors/Billing.ts deleted file mode 100644 index dbf947e27..000000000 --- a/packages/api-contracts/src/errors/Billing.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Schema } from "effect"; - -/** Generic billing service error */ -export class ApiBillingServiceError extends Schema.TaggedErrorClass()( - "Api/BillingServiceError", - { - cause: Schema.String, - }, - { httpApiStatus: 500 }, -) {} - -/** Organization billing not found */ -export class ApiOrganizationBillingNotFoundError extends Schema.TaggedErrorClass()( - "Api/OrganizationBillingNotFoundError", - { - message: Schema.String, - }, - { httpApiStatus: 404 }, -) {} - -/** Invalid billing tier error */ -export class ApiInvalidBillingTierError extends Schema.TaggedErrorClass()( - "Api/InvalidBillingTierError", - { - message: Schema.String, - }, - { httpApiStatus: 400 }, -) {} diff --git a/packages/api-contracts/src/errors/index.ts b/packages/api-contracts/src/errors/index.ts index 8bc023782..6e0373ba9 100644 --- a/packages/api-contracts/src/errors/index.ts +++ b/packages/api-contracts/src/errors/index.ts @@ -1,6 +1,5 @@ export * from "./Analytics.ts"; export * from "./ApiKey.ts"; -export * from "./Billing.ts"; export * from "./Common.ts"; export * from "./Person.ts"; export * from "./Organization.ts"; diff --git a/packages/core/package.json b/packages/core/package.json index 6aa1ceed5..e4e6ff07a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -113,7 +113,7 @@ "./services/schema/SchemaService": "./src/services/schema/SchemaService.ts", "./services/schema/SchemaInvalidator": "./src/services/schema/SchemaInvalidator.ts", "./services/organizations/OrganizationService": "./src/services/organizations/OrganizationService.ts", - "./services/organizations/OrganizationBillingPort": "./src/services/organizations/OrganizationBillingPort.ts", + "./services/organizations/OrganizationLifecyclePort": "./src/services/organizations/OrganizationLifecyclePort.ts", "./services/organizations/OrganizationMembershipSyncPort": "./src/services/organizations/OrganizationMembershipSyncPort.ts", "./services/organizations/OrganizationMembershipWebhookPort": "./src/services/organizations/OrganizationMembershipWebhookPort.ts", "./services/organizations/WorkosOrgPort": "./src/services/organizations/WorkosOrgPort.ts", diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index a05063d3b..8f6b21ab2 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -24,7 +24,7 @@ export * from "./fxRates/FxRateService.ts"; export * from "./slack/slack-client.ts"; export * from "./internalFeatureFlags/InternalFeatureFlagService.ts"; export * from "./organizations/OrganizationService.ts"; -export * from "./organizations/OrganizationBillingPort.ts"; +export * from "./organizations/OrganizationLifecyclePort.ts"; export * from "./organizations/OrganizationMembershipSyncPort.ts"; export * from "./organizations/OrganizationMembershipWebhookPort.ts"; export * from "./organizations/WorkosOrgPort.ts"; diff --git a/packages/core/src/services/organizations/OrganizationBillingPort.ts b/packages/core/src/services/organizations/OrganizationBillingPort.ts deleted file mode 100644 index 1c05d5d44..000000000 --- a/packages/core/src/services/organizations/OrganizationBillingPort.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Context, Effect, Layer, Schema } from "effect"; - -/** Stable error exposed by organization billing extensions to the core organization service. */ -export class OrganizationBillingPortError extends Schema.TaggedErrorClass( - "OrganizationBillingPortError", -)("OrganizationBillingPortError", { - cause: Schema.String, - message: Schema.String, -}) {} - -export interface OrganizationBillingPortShape { - /** Provisions the optional billing extension for a newly created organization. */ - readonly initializeOrganizationBilling: (input: { - readonly organizationId: string; - readonly email?: string; - }) => Effect.Effect; -} - -/** Optional extension point invoked after core creates an organization. */ -export class OrganizationBillingPort extends Context.Service< - OrganizationBillingPort, - OrganizationBillingPortShape ->()("@voidhash/core/OrganizationBillingPort") { - /** Community layer for deployments without platform billing. */ - static readonly noop: Layer.Layer = Layer.succeed( - OrganizationBillingPort, - { - initializeOrganizationBilling: () => Effect.void, - }, - ); -} diff --git a/packages/core/src/services/organizations/OrganizationLifecyclePort.ts b/packages/core/src/services/organizations/OrganizationLifecyclePort.ts new file mode 100644 index 000000000..106854da1 --- /dev/null +++ b/packages/core/src/services/organizations/OrganizationLifecyclePort.ts @@ -0,0 +1,31 @@ +import { Context, Effect, Layer, Schema } from "effect"; + +/** Stable error exposed by organization lifecycle extensions to the core organization service. */ +export class OrganizationLifecyclePortError extends Schema.TaggedErrorClass( + "OrganizationLifecyclePortError", +)("OrganizationLifecyclePortError", { + cause: Schema.String, + message: Schema.String, +}) {} + +export interface OrganizationLifecyclePortShape { + /** Invoked after core creates an organization so host deployments can provision extensions. */ + readonly organizationCreated: (input: { + readonly organizationId: string; + readonly email?: string; + }) => Effect.Effect; +} + +/** Optional extension point invoked around organization lifecycle transitions. */ +export class OrganizationLifecyclePort extends Context.Service< + OrganizationLifecyclePort, + OrganizationLifecyclePortShape +>()("@voidhash/core/OrganizationLifecyclePort") { + /** Community layer for deployments without lifecycle extensions. */ + static readonly noop: Layer.Layer = Layer.succeed( + OrganizationLifecyclePort, + { + organizationCreated: () => Effect.void, + }, + ); +} diff --git a/packages/core/src/services/organizations/OrganizationService.ts b/packages/core/src/services/organizations/OrganizationService.ts index d5a31c4de..46d88eb7f 100644 --- a/packages/core/src/services/organizations/OrganizationService.ts +++ b/packages/core/src/services/organizations/OrganizationService.ts @@ -16,7 +16,7 @@ import { createSlug } from "../../utils/create-slug.ts"; import { generateId } from "../../utils/generate-id.ts"; import { checkOrganizationPermission } from "../../utils/permissions.ts"; import { PublicFileStore } from "../storage/PublicFileStore.ts"; -import { OrganizationBillingPort } from "./OrganizationBillingPort.ts"; +import { OrganizationLifecyclePort } from "./OrganizationLifecyclePort.ts"; import { WorkosOrgPort } from "./WorkosOrgPort.ts"; /** @@ -39,17 +39,17 @@ export class OrganizationServiceError extends Schema.TaggedErrorClass()( "OrganizationService", { make: Effect.gen(function* () { const workosOrgPort = yield* WorkosOrgPort; - const organizationBilling = yield* OrganizationBillingPort; + const organizationLifecycle = yield* OrganizationLifecyclePort; const publicFileStore = yield* PublicFileStore; const db = yield* Db; @@ -278,15 +278,17 @@ export class OrganizationService extends Context.Service()( ), ); - // Provision billing — non-fatal: log and continue if it fails. - yield* organizationBilling - .initializeOrganizationBilling({ + // Run the organization-created hook — non-fatal: log and continue if it fails. + yield* organizationLifecycle + .organizationCreated({ email: sessionUser.email, organizationId: orgId, }) .pipe( Effect.catch((error) => - Effect.logWarning(`Failed to initialize billing for org ${orgId}: ${error}`), + Effect.logWarning( + `Failed to run the organization-created hook for org ${orgId}: ${error}`, + ), ), ); diff --git a/packages/core/src/services/slack/slack-client.ts b/packages/core/src/services/slack/slack-client.ts index 0b3007a14..ca7685e05 100644 --- a/packages/core/src/services/slack/slack-client.ts +++ b/packages/core/src/services/slack/slack-client.ts @@ -4,9 +4,9 @@ * but generic enough for any internal notification. * * Uses the runtime's built-in `fetch` so it runs natively on Cloudflare Workers - * without an Effect `HttpClient` dependency — same approach as - * {@link createAutumnClient}. The bot token (`xoxb-…`) and default channel are - * read from {@link SlackConfig} at layer build (worker boot), keeping the + * without an Effect `HttpClient` dependency. The bot token (`xoxb-…`) and + * default channel are read from {@link SlackConfig} at layer build (worker + * boot), keeping the * resolver (Alchemy secrets / `Config`) decoupled from the constructor. * * When the bot token or the target channel is missing the client **fails @@ -53,7 +53,7 @@ export class SlackClientTag extends Context.Service /** * Runtime configuration for the live Slack client. Fields are Effect-of-string * so the resolver (Alchemy secrets / `Config`) is decoupled from the - * constructor — matches the {@link AutumnConfig} pattern. + * constructor. */ export interface SlackConfig { readonly botToken: Effect.Effect; diff --git a/packages/core/src/utils/generate-id.ts b/packages/core/src/utils/generate-id.ts index a2854a29c..cad25517d 100644 --- a/packages/core/src/utils/generate-id.ts +++ b/packages/core/src/utils/generate-id.ts @@ -46,12 +46,6 @@ const prefixes = { appStoreTransaction: "app_store_tx", analyticsIngestDlq: "an_ing_dlq", analyticsEvent: "an_evt", - // Billing - organizationBilling: "org_bill", - usageRecord: "usage", - usageAggregate: "usage_agg", - billingWebhookEvent: "bill_wh", - billingProviderMeter: "bill_meter", // Webhooks webhookEndpoint: "wh_ep", webhookDelivery: "wh_del", @@ -93,5 +87,14 @@ const prefixes = { pushNotificationDeliveryAttempt: "push_att", } as const; -export const generateId = (prefix: TPrefix) => - `${prefixes[prefix]}_${createId()}`; +/** + * Builds a prefixed cuid2 id generator over a caller-owned prefix table, so + * host packages can mint ids in the same `_` shape without + * registering their prefixes in core. + */ +export const createIdGenerator = + >(table: TPrefixes) => + (prefix: TPrefix) => + `${table[prefix]}_${createId()}`; + +export const generateId = createIdGenerator(prefixes); diff --git a/packages/core/test/services/organizations/OrganizationBillingPort.test.ts b/packages/core/test/services/organizations/OrganizationBillingPort.test.ts deleted file mode 100644 index 8c3c2d030..000000000 --- a/packages/core/test/services/organizations/OrganizationBillingPort.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Effect } from "effect"; -import { describe, it } from "vitest"; - -import { OrganizationBillingPort } from "@voidhash/core/services/organizations/OrganizationBillingPort"; - -describe("OrganizationBillingPort", () => { - it("does nothing when the Community layer is installed", async () => { - await Effect.gen(function* () { - const port = yield* OrganizationBillingPort; - yield* port.initializeOrganizationBilling({ organizationId: "org_community" }); - }).pipe(Effect.provide(OrganizationBillingPort.noop), Effect.runPromise); - }); -}); diff --git a/packages/core/test/services/organizations/OrganizationLifecyclePort.test.ts b/packages/core/test/services/organizations/OrganizationLifecyclePort.test.ts new file mode 100644 index 000000000..ec304098a --- /dev/null +++ b/packages/core/test/services/organizations/OrganizationLifecyclePort.test.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect"; +import { describe, it } from "vitest"; + +import { OrganizationLifecyclePort } from "@voidhash/core/services/organizations/OrganizationLifecyclePort"; + +describe("OrganizationLifecyclePort", () => { + it("does nothing when the Community layer is installed", async () => { + await Effect.gen(function* () { + const port = yield* OrganizationLifecyclePort; + yield* port.organizationCreated({ organizationId: "org_community" }); + }).pipe(Effect.provide(OrganizationLifecyclePort.noop), Effect.runPromise); + }); +}); diff --git a/packages/core/test/services/organizations/OrganizationService.integration.test.ts b/packages/core/test/services/organizations/OrganizationService.integration.test.ts index 29af8e738..1cb3f9f8e 100644 --- a/packages/core/test/services/organizations/OrganizationService.integration.test.ts +++ b/packages/core/test/services/organizations/OrganizationService.integration.test.ts @@ -14,9 +14,9 @@ * *real* database, and each test asserts the persisted side effect rather than * just the return value. * - * `OrganizationBillingPort` is the optional extension seam; a recording stub - * stands in so the non-fatal provisioning step can be driven to succeed or - * fail. + * `OrganizationLifecyclePort` is the optional extension seam; a recording stub + * stands in so the non-fatal organization-created hook can be driven to succeed + * or fail. * * Conventions used throughout: * - Names/slugs/ids are unique per call so a leftover row from a crashed run @@ -40,8 +40,8 @@ import { Effect, Layer } from "effect"; import { describe, expect } from "vitest"; import { - OrganizationBillingPort, - OrganizationBillingPortError, + OrganizationLifecyclePort, + OrganizationLifecyclePortError, OrganizationService, OrganizationServiceError, WorkosOrgPort, @@ -308,25 +308,28 @@ const makeFakePort = (config: FakePortConfig = {}): FakePort => { return { calls, layer }; }; -// --- fake OrganizationBillingPort ------------------------------------------ +// --- fake OrganizationLifecyclePort ---------------------------------------- -interface BillingCalls { - initializeOrganizationBilling: Array<{ organizationId: string; email?: string }>; +interface LifecycleCalls { + organizationCreated: Array<{ organizationId: string; email?: string }>; } -interface FakeBilling { - readonly calls: BillingCalls; - readonly layer: Layer.Layer; +interface FakeLifecycle { + readonly calls: LifecycleCalls; + readonly layer: Layer.Layer; } -const makeFakeBilling = (options: { fail?: boolean } = {}): FakeBilling => { - const calls: BillingCalls = { initializeOrganizationBilling: [] }; - const layer = Layer.succeed(OrganizationBillingPort, { - initializeOrganizationBilling: (input) => { - calls.initializeOrganizationBilling.push(input); +const makeFakeLifecycle = (options: { fail?: boolean } = {}): FakeLifecycle => { + const calls: LifecycleCalls = { organizationCreated: [] }; + const layer = Layer.succeed(OrganizationLifecyclePort, { + organizationCreated: (input) => { + calls.organizationCreated.push(input); if (options.fail) { return Effect.fail( - new OrganizationBillingPortError({ cause: "fake", message: "billing init failed" }), + new OrganizationLifecyclePortError({ + cause: "fake", + message: "organization-created hook failed", + }), ); } return Effect.void; @@ -339,17 +342,17 @@ const makeFakeBilling = (options: { fail?: boolean } = {}): FakeBilling => { /** * Provide `OrganizationService` plus its non-harness collaborators: the fake - * WorkOS port and a recording billing stub. Provided at the top-level test pipe so the body's + * WorkOS port and a recording lifecycle stub. Provided at the top-level test pipe so the body's * `OrganizationService` requirement is fully discharged before the harness sees * it (leaving only harness services in `R`). */ -const orgServiceLayer = (port: FakePort, billing: FakeBilling) => +const orgServiceLayer = (port: FakePort, lifecycle: FakeLifecycle) => Layer.mergeAll( OrganizationService.layer.pipe( - Layer.provide(Layer.mergeAll(port.layer, billing.layer)), + Layer.provide(Layer.mergeAll(port.layer, lifecycle.layer)), ), port.layer, - billing.layer, + lifecycle.layer, ); // --- session builders ------------------------------------------------------- @@ -419,7 +422,7 @@ describe("OrganizationService.getOrganizationById", () => { expect(org.id).toBe(CoreTestFixture.organizationId); expect(org.slug).toBe(CoreTestFixture.organizationSlug); }).pipe( - Effect.provide(orgServiceLayer(makeFakePort(), makeFakeBilling())), + Effect.provide(orgServiceLayer(makeFakePort(), makeFakeLifecycle())), CoreAuthSession.authenticate(), ), ); @@ -431,7 +434,7 @@ describe("OrganizationService.getOrganizationById", () => { const error = yield* Effect.flip(svc.getOrganizationById(`org_missing_${Date.now()}`)); expect(error).toBeInstanceOf(OrganizationNotFoundError); }).pipe( - Effect.provide(orgServiceLayer(makeFakePort(), makeFakeBilling())), + Effect.provide(orgServiceLayer(makeFakePort(), makeFakeLifecycle())), CoreAuthSession.authenticate(), ), ); @@ -445,7 +448,7 @@ describe("OrganizationService.getOrganizationById", () => { ); expect(error).toBeInstanceOf(ActionForbiddenError); }).pipe( - Effect.provide(orgServiceLayer(makeFakePort(), makeFakeBilling())), + Effect.provide(orgServiceLayer(makeFakePort(), makeFakeLifecycle())), CoreAuthSession.authenticate(), ), ); @@ -461,7 +464,7 @@ describe("OrganizationService.getOrganizationBySlug", () => { .pipe(as(userSessionWithOrgs([CoreTestFixture.organizationId]))); expect(org.id).toBe(CoreTestFixture.organizationId); }).pipe( - Effect.provide(orgServiceLayer(makeFakePort(), makeFakeBilling())), + Effect.provide(orgServiceLayer(makeFakePort(), makeFakeLifecycle())), CoreAuthSession.authenticate(), ), ); @@ -473,7 +476,7 @@ describe("OrganizationService.getOrganizationBySlug", () => { const error = yield* Effect.flip(svc.getOrganizationBySlug(`it-missing-${Date.now()}`)); expect(error).toBeInstanceOf(OrganizationNotFoundError); }).pipe( - Effect.provide(orgServiceLayer(makeFakePort(), makeFakeBilling())), + Effect.provide(orgServiceLayer(makeFakePort(), makeFakeLifecycle())), CoreAuthSession.authenticate(), ), ); @@ -489,7 +492,7 @@ describe("OrganizationService.getOrganizationBySlug", () => { ); expect(error).toBeInstanceOf(ActionForbiddenError); }).pipe( - Effect.provide(orgServiceLayer(makeFakePort(), makeFakeBilling())), + Effect.provide(orgServiceLayer(makeFakePort(), makeFakeLifecycle())), CoreAuthSession.authenticate(), ), ); @@ -497,10 +500,10 @@ describe("OrganizationService.getOrganizationBySlug", () => { describe("OrganizationService.createOrganization", () => { test( - "creates the WorkOS org, local org + owner member, provisions billing, returns {id,name,slug}", + "creates the WorkOS org, local org + owner member, runs the created hook, returns {id,name,slug}", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); return withCleanup((track) => Effect.gen(function* () { const svc = yield* OrganizationService; @@ -527,14 +530,14 @@ describe("OrganizationService.createOrganization", () => { expect(owner).toBeDefined(); expect(owner?.role).toBe("owner"); - // Billing was invoked for the new org (non-fatal step, but it ran). + // The lifecycle hook was invoked for the new org (non-fatal step, but it ran). expect( - billing.calls.initializeOrganizationBilling.some( + lifecycle.calls.organizationCreated.some( (c) => c.organizationId === created.id, ), ).toBe(true); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -542,7 +545,7 @@ describe("OrganizationService.createOrganization", () => { "appends a short id when the base slug is in SLUG_BLACKLIST", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); return withCleanup((track) => Effect.gen(function* () { const svc = yield* OrganizationService; @@ -556,7 +559,7 @@ describe("OrganizationService.createOrganization", () => { const orgRow = yield* findOrgRow(created.id); expect(orgRow?.slug).toBe(created.slug); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -564,7 +567,7 @@ describe("OrganizationService.createOrganization", () => { "appends a short id when the base slug already exists in the DB", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); return withCleanup((track) => Effect.gen(function* () { const svc = yield* OrganizationService; @@ -584,7 +587,7 @@ describe("OrganizationService.createOrganization", () => { expect(firstRow?.slug).toBe(first.slug); expect(secondRow?.slug).toBe(second.slug); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -594,7 +597,7 @@ describe("OrganizationService.createOrganization", () => { const port = makeFakePort({ onCreateOrganization: () => Effect.fail(portError("workos org create boom")), }); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const name = uniqueName("wos-fail"); return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -605,7 +608,7 @@ describe("OrganizationService.createOrganization", () => { expect(port.calls.createMembership.length).toBe(0); const orgRow = yield* findOrgRowBySlug(slugFor(name)); expect(orgRow).toBeUndefined(); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -615,7 +618,7 @@ describe("OrganizationService.createOrganization", () => { const port = makeFakePort({ onCreateMembership: () => Effect.fail(portError("workos membership boom")), }); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const name = uniqueName("wm-fail"); return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -626,7 +629,7 @@ describe("OrganizationService.createOrganization", () => { expect(port.calls.deleteOrganization.length).toBe(1); const orgRow = yield* findOrgRowBySlug(slugFor(name)); expect(orgRow).toBeUndefined(); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -644,7 +647,7 @@ describe("OrganizationService.createOrganization", () => { name: input.name, }), }); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const name = uniqueName("db-fail"); return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -655,33 +658,33 @@ describe("OrganizationService.createOrganization", () => { expect(port.calls.deleteOrganization.length).toBe(1); const orgRow = yield* findOrgRowBySlug(slugFor(name)); expect(orgRow).toBeUndefined(); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); test( - "still creates the org when billing init fails (non-fatal)", + "still creates the org when the organization-created hook fails (non-fatal)", (() => { const port = makeFakePort(); - const billing = makeFakeBilling({ fail: true }); + const lifecycle = makeFakeLifecycle({ fail: true }); return withCleanup((track) => Effect.gen(function* () { const svc = yield* OrganizationService; - const name = uniqueName("billing-fail"); + const name = uniqueName("lifecycle-fail"); const created = yield* svc.createOrganization({ name }); track.orgs.push(created.id); expect(created.name).toBe(name); - expect(billing.calls.initializeOrganizationBilling.length).toBe(1); + expect(lifecycle.calls.organizationCreated.length).toBe(1); - // The org + owner member persisted despite the billing failure. + // The org + owner member persisted despite the hook failure. const orgRow = yield* findOrgRow(created.id); expect(orgRow).toBeDefined(); const members = yield* findMembersInOrg(created.id); expect(members.length).toBe(1); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -689,7 +692,7 @@ describe("OrganizationService.createOrganization", () => { "fails with OrganizationServiceError for an api-key (no user) session, writing nothing", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const name = uniqueName("no-user"); return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -702,7 +705,7 @@ describe("OrganizationService.createOrganization", () => { expect(port.calls.createOrganization.length).toBe(0); const orgRow = yield* findOrgRowBySlug(slugFor(name)); expect(orgRow).toBeUndefined(); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -710,7 +713,7 @@ describe("OrganizationService.createOrganization", () => { "fails with OrganizationServiceError when the session user has no WorkOS id and none is found by email", (() => { const port = makeFakePort({ findUserByEmail: null }); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const name = uniqueName("no-workos-id"); return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -726,7 +729,7 @@ describe("OrganizationService.createOrganization", () => { expect(port.calls.createOrganization.length).toBe(0); const orgRow = yield* findOrgRowBySlug(slugFor(name)); expect(orgRow).toBeUndefined(); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); }); @@ -736,7 +739,7 @@ describe("OrganizationService.updateOrganization", () => { "renames the org in WorkOS and the local DB", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const orgId = uniqueId("upd-org"); const wo = uniqueId("upd-wo"); return withCleanup((track) => @@ -761,7 +764,7 @@ describe("OrganizationService.updateOrganization", () => { const orgRow = yield* findOrgRow(orgId); expect(orgRow?.name).toBe(newName); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -769,7 +772,7 @@ describe("OrganizationService.updateOrganization", () => { "forbids a caller without organization:all and writes nothing", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); return Effect.gen(function* () { const svc = yield* OrganizationService; const before = yield* findOrgRow(CoreTestFixture.organizationId); @@ -786,7 +789,7 @@ describe("OrganizationService.updateOrganization", () => { expect(port.calls.updateOrganization.length).toBe(0); const after = yield* findOrgRow(CoreTestFixture.organizationId); expect(after?.name).toBe(before?.name); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -794,7 +797,7 @@ describe("OrganizationService.updateOrganization", () => { "fails with OrganizationNotFoundError for an unknown org", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const orgId = `org_missing_${Date.now()}`; return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -805,7 +808,7 @@ describe("OrganizationService.updateOrganization", () => { ); expect(error).toBeInstanceOf(OrganizationNotFoundError); expect(port.calls.updateOrganization.length).toBe(0); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -815,7 +818,7 @@ describe("OrganizationService.updateOrganization", () => { const port = makeFakePort({ onUpdateOrganization: () => Effect.fail(portError("workos update boom")), }); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const orgId = uniqueId("upd-fail-org"); return withCleanup((track) => Effect.gen(function* () { @@ -838,7 +841,7 @@ describe("OrganizationService.updateOrganization", () => { const orgRow = yield* findOrgRow(orgId); expect(orgRow?.name).toBe("Keep"); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); }); @@ -848,7 +851,7 @@ describe("OrganizationService.deleteOrganization", () => { "deletes the org in WorkOS and locally, cascading members", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const orgId = uniqueId("del-org"); const wo = uniqueId("del-wo"); return withCleanup((track) => @@ -878,7 +881,7 @@ describe("OrganizationService.deleteOrganization", () => { const members = yield* findMembersInOrg(orgId); expect(members.length).toBe(0); }), - ).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + ).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -886,7 +889,7 @@ describe("OrganizationService.deleteOrganization", () => { "forbids a caller without organization:all and retains the org", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); return Effect.gen(function* () { const svc = yield* OrganizationService; const error = yield* Effect.flip( @@ -899,7 +902,7 @@ describe("OrganizationService.deleteOrganization", () => { expect(port.calls.deleteOrganization.length).toBe(0); const orgRow = yield* findOrgRow(CoreTestFixture.organizationId); expect(orgRow).toBeDefined(); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); @@ -907,7 +910,7 @@ describe("OrganizationService.deleteOrganization", () => { "succeeds silently and skips WorkOS when the org row is absent (idempotent)", (() => { const port = makeFakePort(); - const billing = makeFakeBilling(); + const lifecycle = makeFakeLifecycle(); const orgId = `org_missing_${Date.now()}`; return Effect.gen(function* () { const svc = yield* OrganizationService; @@ -917,7 +920,7 @@ describe("OrganizationService.deleteOrganization", () => { // No WorkOS delete fired because there was no local org to mirror. expect(port.calls.deleteOrganization.length).toBe(0); - }).pipe(Effect.provide(orgServiceLayer(port, billing)), CoreAuthSession.authenticate()); + }).pipe(Effect.provide(orgServiceLayer(port, lifecycle)), CoreAuthSession.authenticate()); })(), ); }); diff --git a/packages/core/test/utils/generate-id.test.ts b/packages/core/test/utils/generate-id.test.ts index 55b148178..1dd98d014 100644 --- a/packages/core/test/utils/generate-id.test.ts +++ b/packages/core/test/utils/generate-id.test.ts @@ -12,7 +12,7 @@ import { generateId } from "../../src/utils/generate-id.ts"; // (prefix-key, expected string prefix) pairs lifted from the `prefixes` const in // the source. We deliberately spot-check a representative spread of entity types -// (auth, billing, payment providers, webhooks, feature flags, …) including a few +// (auth, payment providers, webhooks, feature flags, …) including a few // where the literal prefix is a non-obvious abbreviation (org, pw_loc_show, …). const PREFIX_CASES = [ ["user", "user"], diff --git a/packages/db/src/alchemy-migrations/20260728120000_remove_billing_tables/migration.sql b/packages/db/src/alchemy-migrations/20260728120000_remove_billing_tables/migration.sql new file mode 100644 index 000000000..9f0413e31 --- /dev/null +++ b/packages/db/src/alchemy-migrations/20260728120000_remove_billing_tables/migration.sql @@ -0,0 +1,10 @@ +-- Cloud billing left this repository: `organization_billing`, `usage_record`, +-- `usage_aggregate`, `billing_webhook_event` and `billing_provider_meter` are +-- no longer declared in `packages/db/src/schema.ts`. +-- +-- Deliberately NOT a set of DROP TABLE statements. Those tables are owned by +-- the hosting deployment now — it keeps its own schema for them and their data +-- must survive this release. Self-hosted deployments simply never create them. +-- `drizzle.config.ts` excludes them via `tablesFilter` so future generates do +-- not re-propose the drops. +SELECT 1; diff --git a/packages/db/src/relations.ts b/packages/db/src/relations.ts index 63adf805b..0620774e5 100644 --- a/packages/db/src/relations.ts +++ b/packages/db/src/relations.ts @@ -149,12 +149,6 @@ export const relations = defineRelations(schema, (r) => ({ to: r.paywallDeploys.id, }), }, - organizationBilling: { - organization: r.one.organization({ - from: r.organizationBilling.organizationId, - to: r.organization.id, - }), - }, webhookEndpoints: { project: r.one.projects({ from: r.webhookEndpoints.projectId, to: r.projects.id }), deliveries: r.many.webhookDeliveries(), diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 002c94df7..8c59a5106 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1700,189 +1700,9 @@ export const paywallComponentVersions = pgTable( ); // ============================================ -// BILLING TABLES +// WORKOS WEBHOOK TABLES // ============================================ -export const BillingTier = { - Enterprise: 3, - Free: 1, - Pro: 2, -} as const; - -export type BillingTierValue = (typeof BillingTier)[keyof typeof BillingTier]; - -export const BillingSubscriptionStatus = { - Active: 1, - Canceled: 2, - None: 0, - PastDue: 3, - Trialing: 4, -} as const; - -export type BillingSubscriptionStatusValue = - (typeof BillingSubscriptionStatus)[keyof typeof BillingSubscriptionStatus]; - -/** - * Links organizations to their billing configuration and provider customer - */ -export const organizationBilling = pgTable( - "organization_billing", - { - id: varchar("id", { length: 255 }).primaryKey(), - organizationId: varchar("organization_id", { length: 255 }).notNull(), - - /** Current billing tier */ - tier: smallint("tier").notNull().default(BillingTier.Free), - - /** Billing provider (e.g., 'polar', 'stripe') */ - billingProviderId: varchar("billing_provider_id", { length: 50 }).notNull().default("polar"), - - /** External customer ID in the billing provider (e.g., Polar customer ID) */ - externalCustomerId: varchar("external_customer_id", { length: 255 }), - - /** Subscription status synced from provider */ - subscriptionStatus: smallint("subscription_status") - .notNull() - .default(BillingSubscriptionStatus.None), - - /** External subscription ID in the billing provider */ - externalSubscriptionId: varchar("external_subscription_id", { - length: 255, - }), - - /** Current billing period start */ - currentPeriodStart: timestamp("current_period_start", { withTimezone: true, precision: 3 }), - - /** Current billing period end */ - currentPeriodEnd: timestamp("current_period_end", { withTimezone: true, precision: 3 }), - - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true, precision: 3 }).$onUpdate( - () => new Date(), - ), - }, - (table) => [ - uniqueIndex("organization_id_unique_idx").on(table.organizationId), - index("external_customer_id_idx").on(table.externalCustomerId), - index("billing_provider_id_idx").on(table.billingProviderId), - ], -); - -/** - * Local usage records - stored locally first, then synced to provider asynchronously - */ -export const usageRecords = pgTable( - "usage_record", - { - id: varchar("id", { length: 255 }).primaryKey(), - organizationId: varchar("organization_id", { length: 255 }).notNull(), - - /** Metric identifier (e.g., 'paywall_conversions', 'monthly_tracked_revenue') */ - metricId: varchar("metric_id", { length: 100 }).notNull(), - - /** Usage value */ - value: bigint("value", { mode: "number" }).notNull(), - - /** Billing period this usage belongs to */ - periodStart: timestamp("period_start", { withTimezone: true, precision: 3 }).notNull(), - periodEnd: timestamp("period_end", { withTimezone: true, precision: 3 }).notNull(), - - /** Whether this record has been synced to the billing provider */ - syncedToProvider: boolean("synced_to_provider").notNull().default(false), - syncedAt: timestamp("synced_at", { withTimezone: true, precision: 3 }), - syncError: varchar("sync_error", { length: 500 }), - - /** Additional context for the usage event */ - metadata: jsonb("metadata").$type>(), - - /** When the usage event occurred */ - occurredAt: timestamp("occurred_at", { withTimezone: true, precision: 3 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).defaultNow(), - }, - (table) => [ - index("org_metric_period_idx").on( - table.organizationId, - table.metricId, - table.periodStart, - table.periodEnd, - ), - index("synced_to_provider_idx").on(table.syncedToProvider), - ], -); - -/** - * Pre-computed usage aggregates for performance - */ -export const usageAggregates = pgTable( - "usage_aggregate", - { - id: varchar("id", { length: 255 }).primaryKey(), - organizationId: varchar("organization_id", { length: 255 }).notNull(), - - /** Metric identifier */ - metricId: varchar("metric_id", { length: 100 }).notNull(), - - /** Billing period */ - periodStart: timestamp("period_start", { withTimezone: true, precision: 3 }).notNull(), - periodEnd: timestamp("period_end", { withTimezone: true, precision: 3 }).notNull(), - - /** Aggregated total value for the period */ - totalValue: bigint("total_value", { mode: "number" }).notNull().default(0), - - /** Limit for this metric (null = unlimited) */ - limitValue: bigint("limit_value", { mode: "number" }), - - /** Threshold at which to show warnings */ - warnThreshold: bigint("warn_threshold", { mode: "number" }), - - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true, precision: 3 }).$onUpdate( - () => new Date(), - ), - }, - (table) => [ - uniqueIndex("org_metric_period_unique_idx").on( - table.organizationId, - table.metricId, - table.periodStart, - ), - ], -); - -/** - * Billing webhook events for idempotency tracking - */ -export const billingWebhookEvents = pgTable( - "billing_webhook_event", - { - id: varchar("id", { length: 255 }).primaryKey(), - - /** Billing provider ID (e.g., 'polar', 'stripe') */ - providerId: varchar("provider_id", { length: 50 }).notNull(), - - /** External event ID from the provider */ - externalEventId: varchar("external_event_id", { length: 255 }).notNull(), - - /** Event type (e.g., 'subscription.created') */ - eventType: varchar("event_type", { length: 100 }).notNull(), - - /** Full event payload */ - payload: jsonb("payload").$type(), - - /** When the event was processed (null = not yet processed) */ - processedAt: timestamp("processed_at", { withTimezone: true, precision: 3 }), - - /** Error message if processing failed */ - error: varchar("error", { length: 500 }), - - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).defaultNow(), - }, - (table) => [ - uniqueIndex("provider_event_unique_idx").on(table.providerId, table.externalEventId), - index("processed_at_idx").on(table.processedAt), - ], -); - /** * WorkOS webhook events — idempotency tracking for the `/api/webhooks/workos` * endpoint. Insert with `processedAt = null`, mutate on success, leave the @@ -1917,35 +1737,6 @@ export const workosWebhookEvents = pgTable( ], ); -/** - * Billing provider meters - tracks meter sync status with provider - */ -export const billingProviderMeters = pgTable( - "billing_provider_meter", - { - id: varchar("id", { length: 255 }).primaryKey(), - - /** Billing provider ID (e.g., 'polar', 'stripe') */ - providerId: varchar("provider_id", { length: 50 }).notNull(), - - /** Internal metric ID */ - metricId: varchar("metric_id", { length: 100 }).notNull(), - - /** External meter ID in the provider */ - externalMeterId: varchar("external_meter_id", { length: 255 }).notNull(), - - /** External meter slug (Polar-specific) */ - externalMeterSlug: varchar("external_meter_slug", { length: 255 }), - - lastSyncedAt: timestamp("last_synced_at", { withTimezone: true, precision: 3 }), - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true, precision: 3 }).$onUpdate( - () => new Date(), - ), - }, - (table) => [uniqueIndex("provider_metric_unique_idx").on(table.providerId, table.metricId)], -); - // ============================================ // WEBHOOK TABLES // ============================================ diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 720cee400..59bf2bca3 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -127,27 +127,10 @@ export type PaywallLocationShowing = InferSelectModel; export type UpdatePaywallLocationShowing = InferUpdateModel; -// Billing Types -export type OrganizationBilling = InferSelectModel; -export type InsertOrganizationBilling = InferInsertModel; -export type UpdateOrganizationBilling = InferUpdateModel; - -export type UsageRecord = InferSelectModel; -export type InsertUsageRecord = InferInsertModel; - -export type UsageAggregate = InferSelectModel; -export type InsertUsageAggregate = InferInsertModel; -export type UpdateUsageAggregate = InferUpdateModel; - -export type BillingWebhookEvent = InferSelectModel; -export type InsertBillingWebhookEvent = InferInsertModel; - +// WorkOS Webhook Types export type WorkosWebhookEvent = InferSelectModel; export type InsertWorkosWebhookEvent = InferInsertModel; -export type BillingProviderMeter = InferSelectModel; -export type InsertBillingProviderMeter = InferInsertModel; - // Feature Flag Types export type FeatureFlag = InferSelectModel; export type InsertFeatureFlag = InferInsertModel; diff --git a/packages/shared/src/billing.ts b/packages/shared/src/billing.ts deleted file mode 100644 index c900b4ba7..000000000 --- a/packages/shared/src/billing.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Errors have been moved to: -// - @voidhash/generated-clients (API layer) -// - @voidhash/core/domain/errors (domain layer) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dfc4f62fd..9c5968fe1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,7 +3,6 @@ export * from "./analytics"; export * from "./api-key"; export * from "./app-store"; export * from "./auth"; -export * from "./billing"; export * from "./customer"; export * from "./deploy-changeset"; export * from "./errors"; diff --git a/selfhost/smoke.mts b/selfhost/smoke.mts index 80ffb8a52..e5a4c12e5 100644 --- a/selfhost/smoke.mts +++ b/selfhost/smoke.mts @@ -26,10 +26,7 @@ const assertApplicationSurface = async (): Promise => { throw new Error(`Runtime capabilities returned ${capabilitiesResponse.status}`); } const capabilities = await capabilitiesResponse.json(); - if ( - JSON.stringify(capabilities) !== - JSON.stringify({ enterprise: { auditLogs: false, billing: false } }) - ) { + if (JSON.stringify(capabilities) !== JSON.stringify({ enterprise: {} })) { throw new Error(`Unexpected Community capabilities: ${JSON.stringify(capabilities)}`); } diff --git a/turbo.json b/turbo.json index 226ab3161..9f0a60ec7 100644 --- a/turbo.json +++ b/turbo.json @@ -18,7 +18,6 @@ "DATABASE_PASSWORD", "DATABASE_NAME", "VOIDHASH_SECRET_KEY", - "POLAR_ACCESS_TOKEN", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "WORKOS_AUTHKIT_DOMAIN",