diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 71f6157fe..813e03f07 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -30,6 +30,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno - Local E2E dashboard smokes that need `/api/test/e2e/*` should start the API/dashboard directly (or through Playwright's webServer command), not via `bun run dev:dashboard`; Turbo runs in strict env mode and drops `DATABUDDY_E2E_MODE`/`DATABUDDY_E2E_TEST_KEY` unless they are added to `turbo.json` `globalEnv`. - Dashboard Playwright public/demo analytics specs call API `/v1/query` anonymously from the browser; keep `DATABUDDY_E2E_MODE` query behavior isolated from production rate limits so CI retries do not exhaust `anon:unknown`. - `apps/api`: Elysia API on port `3001` +- API tests use Vitest through `bun run test` inside `apps/api`; use Vitest test imports rather than `bun:test` in that package. - Public REST docs live in `apps/api/src/rpc/openapi.ts`: `/spec.json` is the generated spec, `/` is the reference UI, and hiding a router there also makes its top-level REST paths return 404 because `/*` uses the same filtered docs router. - `apps/slack`: Slack agent adapter; Slack installs resolve through org-scoped DB integration records, not a single env bot token/default website. Agent calls use the org-scoped internal principal synthesized from the active integration in `slack/installations.ts`, never a global internal secret. - Slack OAuth lives in `apps/api`, but slash commands/events require `apps/slack` to be running too; local `bun run dev:dashboard` runs dashboard + API only, so use `bun run dev:slack` when working on Slack. The Slack package scripts read the root `.env`. @@ -45,7 +46,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno - `SPEC.md` is the intelligence product contract. `insight_observations` is the readable Insights history; `analytics_insights` is the durable investigation projection. The agent outcome owns brief publication and `act`/`ask` promotion; do not replace either with frontend heuristics or collapse the feed into cases. Do not add a parallel agent, evidence API, fixed query choreography, or action-specific lifecycle. - Insights quality reviews must compare fresh baseline/candidate outputs and lead with the product verdict and concrete examples. Score usefulness, noise, reading effort, and retained useful findings separately from code tests and contract passes; preserve interrupted attempts instead of reporting retries as an uninterrupted pass rate. - Insights RPC helpers that take `{ context, ...input }` must strip `context` before parsing a `.strict()` Zod input schema (same pattern as `appendInvestigationReply` / `applyInsightGoalAction`); otherwise CI fails with `Unrecognized key: "context"`. -- `insights.history` / MCP `list_investigations` hide cases while a reply is `queued`/`running` (action-inbox verification); tests must list before reply or expect an empty list while verifying. +- `insights.history` / MCP `list_investigations` hide cases while analysis or verification is queued/running; included clarifications use saved evidence and must not hide or mutate the case. - When reporting what an organization can see in Insights, follow the `insights.brief`/`history` visibility rules instead of counting `analytics_insights`; the projection can contain legacy rows without a readable or published `insight_observations` turn. - Production insight shadows must freeze `--reference-time`, retain a tool-name trace, and pass available GitHub context before supporting quality claims. Postgres and ClickHouse are read-only, but connector token refreshes or cache writes can still occur; never describe the whole run as zero-write. - Automatic investigations have one organization-wide schedule (`off`, `daily`, or `weekly`) and one organization-wide delivery set; website selection is only for manual runs. Do not reintroduce per-website overrides, hourly/custom cadence, or cron input. @@ -167,7 +168,8 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Start in `apps/api/src` - Shared API contracts and procedure logic live in `packages/rpc` - Prefer changing shared router logic in `packages/rpc` rather than duplicating validation in the dashboard -- Investigations run in `apps/insights`; RPC only reads cases and accepts durable replies. Case identity is `websiteId|subjectKey`, where the backend owns the subject key. Persist a new observation for each turn while updating the existing insight row. The stored `changePercent` is already signed. +- Saved investigation tool evidence must use typed, positive field allowlists; do not persist arbitrary tool outputs or rely on generic secret-pattern redaction. Preserve exact measurement scope, and record omissions instead of reconstructing missing raw evidence. +- Investigations run in `apps/insights`; RPC only reads cases and accepts durable replies. Case identity is `websiteId|subjectKey`, where the backend owns the subject key. New analysis appends an observation; a clarification stores its answer on the reply and reads the originating observation's saved evidence without changing case state. The stored `changePercent` is already signed. ### Ingestion and analytics pipeline @@ -179,8 +181,8 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin ## Billing (Autumn) - Retried insight jobs must persist immutable external delivery effects (currently Slack) before calling providers and reuse the effect ID as the provider idempotency key. An insight observation is product memory, not a delivery checkpoint. -- Intelligence pricing should use the existing token-cost-backed `agent_credits` and top-up flow; do not invent per-site or "monitored product" billing without explicit product selection and runtime enforcement. -- Transactional billing email identity has three separate concepts: Autumn customer/billing owner, organization, and actual `to` recipient. Only personalize from the actual recipient record; if it is unavailable, omit the greeting rather than using the owner name. Keep `agent_credits` as an internal feature ID, but describe it to customers as investigation credits and explain that deeper investigations, replies, and rechecks can use more credits. +- Investigations cost $1 per completed result through the separate Autumn `investigation_runs` meter. Clarifications and verification after applying a proposed repair are included. Persist the accepted price with an explicit queued analysis and bind its reservation to that price. Reserve one unit before new analysis and settle only after a readable complete result is persisted; retries reuse durable operation identity. Internal token costs are telemetry. Existing customers without the new entitlement retain legacy `agent_credits` terms; do not convert balances or point legacy credit refills at the new meter. +- Transactional billing email identity has three separate concepts: Autumn customer/billing owner, organization, and actual `to` recipient. Only personalize from the actual recipient record; if it is unavailable, omit the greeting rather than using the owner name. Distinguish fixed-price investigations from legacy credits in billing copy. - `autumn-js` v1.2.2+ — import `autumnHandler` from `autumn-js/fetch` (NOT `autumn-js/elysia`, that export was removed in v1.0) - For Elysia, mount with `.mount(autumnHandler(...))` — NOT `.use()` - `identify` callback receives `(request: Request)` directly, not `({ request })` @@ -202,6 +204,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - ClickHouse helpers and schema: `packages/db/src/clickhouse/*` - `ch:check` is package-scoped; run `cd packages/db && bun run ch:check`, not the root script runner. - After schema changes, use the repo db scripts rather than ad hoc commands +- PostgreSQL deploys use `packages/db db:push` through `init.Dockerfile`; register new schema files in `packages/db/drizzle.config.ts`. `packages/migrate` transforms SDK source and is not a database migration runner. - A shipped ClickHouse table change needs a tracked forward migration alongside its reference DDL: bootstrap `CREATE ... IF NOT EXISTS` does not migrate deployed tables, and Keeper-path or sort-key changes need a shadow-table diff --git a/SPEC.md b/SPEC.md index 1d675b003..d25ca28da 100644 --- a/SPEC.md +++ b/SPEC.md @@ -33,6 +33,37 @@ An append-only explanation of one signal at one point in time. It names the subj The durable work object for one signal. It has an `open` or `resolved` state plus observations, replies, actions, rechecks, and recurrence history. +### Investigation price + +A completed investigation costs **$1**. The billable unit is one explicitly started +analysis of a selected signal or new question, not the durable case that may hold +several analyses over time. A supported measured answer, concrete inspected repair, +or verified no-action conclusion can complete it. Failed, interrupted, inconclusive +work and an unanswered necessary question are not completed investigations. + +Reserve one investigation before starting new analysis. Confirm that reservation +only after its complete result is saved and readable; release it when the work is +incomplete. Persist charge identity and settlement intent with the result so retries +and recovery reuse the same unit. Uncertain payment-provider responses remain +pending for reconciliation rather than starting a second charge. + +Clarifications of the same question use its saved evidence and are included. +Verification after applying that investigation's proposed repair is also included, +as are backend-triggered definition-change checks and deterministic continuations +of saved verification conditions during regular scans. A new question or separate +fresh analysis requires an explicit accepted price persisted with its queued reply; +the reservation must match those immutable terms. The model must never decide +whether a reply incurs a charge. Signal selection, +preparation, model turns, and internal retries do not add customer charges. + +Autumn stores the new unit in a separate `investigation_runs` balance with a $1 +prepaid purchase option. Existing credit balances, credit refills, and attached +legacy plans retain their terms until the customer adopts the new entitlement +through an investigation purchase or a switch to a new plan version. +An exhausted fixed-price balance does not fall back to spending legacy credits. +Chat continues to use credits. Token usage and model costs remain internal +telemetry for fixed-price investigations and included replies. + ### Action An optional proposed change with a target and verification condition. A code action may become a patch and PR. Other actions may target tracking, a goal, a campaign, configuration, or operations. @@ -131,6 +162,14 @@ The Insights brief reads like a short news report: headline, what happened, why ## Continuity - A dashboard, Slack, or MCP reply resumes the same investigation. +- A clarification is anchored to the original observation and typed, allowlisted + goal/funnel measurement fields, with trusted descriptions and exact scope. Raw + profiles, sessions, source files, search queries, arbitrary properties and free-form + context are omitted with explicit limitations. Retained evidence survives history + truncation and later reopening of the same case. + The answer is stored on the reply without new data reads or case-state changes. + Legacy results without saved evidence receive an honest explanation of that + limitation; answering them never silently starts paid analysis. - A GitHub comment or review resumes the agent working on that PR. - A materially worse resolved signal reopens the same investigation with its prior outcomes. - Corrections such as terminology, ownership, or known infrastructure become project memory. @@ -174,6 +213,6 @@ When business meaning is missing, inspect the definition, site, events, and conn ## Implementation constraint -Use `insight_observations` as the append-only Insights source and `analytics_insights` as the current investigation projection. An `act` or `ask` creates or reopens that projection; `resolve` may update an open investigation but never creates or reopens one. Recommendations are a read projection of the latest observation for each signal: standalone setup and measurement recommendations expire at their recheck time unless renewed, while definition recommendations also verify against the current definition. Keep one agent and one evidence/tool stack. Add storage only when this model cannot represent a real use case. +Use `insight_observations` as the append-only Insights source and `analytics_insights` as the current investigation projection. An `act` or `ask` creates or reopens that projection. A complete fixed-price result may create a resolved projection so its paid answer remains readable even when no action is needed; it does not create an interruption or reopen work. Other `resolve` outcomes may update an open investigation but never create or reopen one. Recommendations are a read projection of the latest observation for each signal: standalone setup and measurement recommendations expire at their recheck time unless renewed, while definition recommendations also verify against the current definition. Keep one agent and one evidence/tool stack. Add storage only when this model cannot represent a real use case. Exact error-customer joins run as a private, aggregate-only enrichment after the backend selects a signal. They return counts and coverage, never visitor, profile, session, payment, order, or request identifiers. Identity joins report same-window resolution explicitly; attributed completed-payment matches require the payment to predate the affected profile's first error and remain a lower bound. diff --git a/apps/api/src/billing/autumn-purchase-boundary.test.ts b/apps/api/src/billing/autumn-purchase-boundary.test.ts new file mode 100644 index 000000000..023667bdf --- /dev/null +++ b/apps/api/src/billing/autumn-purchase-boundary.test.ts @@ -0,0 +1,94 @@ +import type { JSONValue } from "ai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { forward } = vi.hoisted(() => ({ + forward: vi.fn(async (request: Request) => + Response.json(await request.json()) + ), +})); +vi.mock("autumn-js/fetch", () => ({ autumnHandler: () => forward })); +vi.mock("@databuddy/auth", () => ({ + auth: { api: { getSession: vi.fn(async () => null) } }, +})); +vi.mock("@databuddy/redis", () => ({ getRedisCache: vi.fn() })); +vi.mock("@databuddy/rpc", () => ({ + getBillingCustomerId: vi.fn(), + getMemberRole: vi.fn(), +})); + +import { handleAutumnRequest } from "./autumn"; + +function request(body: JSONValue, contentType: string | null) { + const value = new Request("https://synthetic.invalid/autumn/attach", { + method: "POST", + body: JSON.stringify(body), + }); + if (contentType) { + value.headers.set("content-type", contentType); + } else { + value.headers.delete("content-type"); + } + return value; +} + +beforeEach(() => { + forward.mockClear(); +}); + +describe.each([ + "application/json", + "text/plain", + null, +])("Autumn investigation boundary with %s content type", (contentType) => { + it("strips new-feature grants nested in another plan before native forwarding", async () => { + const response = await handleAutumnRequest( + request( + { + planId: "pro", + customize: { + addItems: [{ featureId: "investigation_runs", included: 1000 }], + }, + }, + contentType + ) + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ planId: "pro" }); + expect(forward).toHaveBeenCalledTimes(1); + }); + + it("rejects a fixed-unit quantity override on another plan before forwarding", async () => { + const response = await handleAutumnRequest( + request( + { + planId: "pro", + featureQuantities: [ + { featureId: "investigation_runs", quantity: 1000 }, + ], + }, + contentType + ) + ); + expect(response.status).toBe(422); + expect(forward).not.toHaveBeenCalled(); + }); + + it("forwards the exact whole-unit purchase after removing client checkout URLs", async () => { + const purchase = { + planId: "investigations_topup", + featureQuantities: [{ featureId: "investigation_runs", quantity: 10 }], + }; + const response = await handleAutumnRequest( + request( + { + ...purchase, + successUrl: "https://synthetic.invalid/billing", + }, + contentType + ) + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(purchase); + expect(forward).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/billing/autumn.ts b/apps/api/src/billing/autumn.ts index 71030592e..89e7e9a17 100644 --- a/apps/api/src/billing/autumn.ts +++ b/apps/api/src/billing/autumn.ts @@ -1,3 +1,6 @@ +import type { JSONValue } from "ai"; +import { buildHttpErrorResponse } from "@databuddy/shared/http-error-response"; +import { isInvestigationPurchaseValid } from "./investigation-purchase"; import { auth } from "@databuddy/auth"; import { getRedisCache } from "@databuddy/redis"; import { getBillingCustomerId, getMemberRole } from "@databuddy/rpc"; @@ -22,19 +25,19 @@ const FORBIDDEN_BODY_KEYS = new Set([ "prorationBehavior", ]); -function sanitize(value: unknown): unknown { +function sanitize(value: JSONValue): JSONValue { if (Array.isArray(value)) { return value.map(sanitize); } if (!value || typeof value !== "object") { return value; } - const out: Record = {}; + const out: Record = {}; for (const [key, val] of Object.entries(value)) { if (FORBIDDEN_BODY_KEYS.has(key)) { continue; } - out[key] = sanitize(val); + out[key] = val === undefined ? undefined : sanitize(val); } return out; } @@ -43,11 +46,8 @@ async function stripPrivilegedBody(request: Request): Promise { if (request.method === "GET" || request.method === "HEAD") { return request; } - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.includes("application/json")) { - return request; - } - + // The native adapter parses JSON regardless of Content-Type. Apply the same + // restrictions to text/plain and missing-header requests before forwarding. const text = await request.text(); let body: string | null = text || null; if (text) { @@ -133,6 +133,19 @@ async function writeAutumnCache( export async function handleAutumnRequest(request: Request) { const sanitized = await stripPrivilegedBody(request); const segment = autumnPathSegment(sanitized); + if (sanitized.method !== "GET" && sanitized.method !== "HEAD") { + const body: JSONValue = await sanitized + .clone() + .json() + .catch(() => null); + if (!isInvestigationPurchaseValid(body, segment)) { + const response = buildHttpErrorResponse({ + code: "VALIDATION", + error: null, + }); + return Response.json(response.payload, { status: response.status }); + } + } const ttlSec = AUTUMN_CACHE_TTL_SEC[segment]; if (ttlSec === undefined) { diff --git a/apps/api/src/billing/investigation-purchase.test.ts b/apps/api/src/billing/investigation-purchase.test.ts new file mode 100644 index 000000000..3948c66f3 --- /dev/null +++ b/apps/api/src/billing/investigation-purchase.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { isInvestigationPurchaseValid } from "./investigation-purchase"; + +const purchase = (quantity: number | string | null | undefined) => ({ + planId: "investigations_topup", + featureQuantities: [{ featureId: "investigation_runs", quantity }], +}); + +describe("investigation checkout validation", () => { + test.each([1, 37, 1000])("accepts %i whole units only on supported checkout routes", (quantity) => { + expect(isInvestigationPurchaseValid(purchase(quantity), "attach")).toBe(true); + expect(isInvestigationPurchaseValid(purchase(quantity), "previewAttach")).toBe(true); + }); + test.each([0, 0.5, -1, 1001, "10", null, undefined])("rejects invalid quantities", (quantity) => { + expect(isInvestigationPurchaseValid(purchase(quantity), "attach")).toBe(false); + }); + test.each([ + "customerId", "entityId", "freeTrial", "discounts", "version", "customize", + "customPlan", "invoiceMode", "noBillingChanges", "customLineItems", + "carryOverBalances", "carryOverUsages", "subscriptionId", "planSchedule", + "startsAt", "endsAt", "newBillingSubscription", "processorSubscriptionId", + "feature_quantities", "productId", "plan_id", "product_id", + ])("rejects client-controlled override field %s", (key) => { + expect(isInvestigationPurchaseValid({ ...purchase(10), [key]: "override" }, "attach")).toBe(false); + }); + test.each(["plan_id", "productId", "product_id"])("rejects legacy alias %s instead of bypassing fixed-unit validation", (key) => { + expect(isInvestigationPurchaseValid({ [key]: "investigations_topup" }, "attach")).toBe(false); + }); + test.each(["multiAttach", "previewMultiAttach", "updateSubscription", "previewUpdateSubscription", "setupPayment"])("rejects purchases through unsupported route %s", (route) => { + expect(isInvestigationPurchaseValid(purchase(10), route)).toBe(false); + for (const collection of ["plans", "products"]) { + expect(isInvestigationPurchaseValid({ [collection]: [purchase(10)] }, route)).toBe(false); + expect(isInvestigationPurchaseValid({ [collection]: [{ product_id: "investigations_topup" }] }, route)).toBe(false); + } + }); + test("requires one unmodified feature quantity and preserves unrelated SKU validation", () => { + expect(isInvestigationPurchaseValid({ planId: "investigations_topup" }, "attach")).toBe(false); + expect(isInvestigationPurchaseValid({ planId: "investigations_topup", featureQuantities: [{ featureId: "agent_credits", quantity: 10 }] }, "attach")).toBe(false); + expect(isInvestigationPurchaseValid({ planId: "investigations_topup", featureQuantities: [...purchase(5).featureQuantities, ...purchase(5).featureQuantities] }, "attach")).toBe(false); + expect(isInvestigationPurchaseValid({ planId: "investigations_topup", featureQuantities: [{ featureId: "investigation_runs", quantity: 10, price: 0 }] }, "attach")).toBe(false); + expect(isInvestigationPurchaseValid({ planId: "credits_topup", featureQuantities: [{ featureId: "agent_credits", quantity: 2500 }] }, "attach")).toBe(true); + expect(isInvestigationPurchaseValid({ plans: [{ planId: "pro" }, { planId: "credits_topup" }], discounts: [] }, "multiAttach")).toBe(true); + }); + test.each([ + { planId: "pro", customize: { addItems: [{ featureId: "investigation_runs", included: 1000 }] } }, + { planId: "pro", customize: { items: [{ featureId: "investigation_runs", unlimited: true }] } }, + { planId: "pro", featureQuantities: [{ featureId: "investigation_runs", quantity: 1000 }] }, + { plans: [{ planId: "pro", customize: { items: [{ featureId: "investigation_runs", included: 1000 }] } }] }, + { subscriptionId: "subscription", customize: { add_items: [{ feature_id: "investigation_runs", included: 1000 }] } }, + { subscriptionId: "subscription", carryOverBalances: { enabled: true, featureIds: ["investigation_runs"] } }, + ])("rejects investigation grants through another plan or subscription", (body) => { + for (const route of ["attach", "previewAttach", "multiAttach", "updateSubscription", "setupPayment"]) { + expect(isInvestigationPurchaseValid(body, route)).toBe(false); + } + }); +}); diff --git a/apps/api/src/billing/investigation-purchase.ts b/apps/api/src/billing/investigation-purchase.ts new file mode 100644 index 000000000..821d9380b --- /dev/null +++ b/apps/api/src/billing/investigation-purchase.ts @@ -0,0 +1,49 @@ +import type { JSONValue } from "ai"; +import { + INVESTIGATION_USAGE, + investigationQuantitySchema, +} from "@databuddy/shared/billing"; +import { array, literal, strictObject } from "zod"; + +const purchaseSchema = strictObject({ + planId: literal(INVESTIGATION_USAGE.topupPlanId), + featureQuantities: array( + strictObject({ + featureId: literal(INVESTIGATION_USAGE.featureId), + quantity: investigationQuantitySchema, + }) + ).length(1), +}); + +function referencesInvestigationBilling(value: JSONValue): boolean { + if (Array.isArray(value)) { + return value.some(referencesInvestigationBilling); + } + if (!value || typeof value !== "object") { + return false; + } + return Object.entries(value).some(([key, entry]) => { + if (["planId", "plan_id", "productId", "product_id"].includes(key)) { + return entry === INVESTIGATION_USAGE.topupPlanId; + } + if (["featureId", "feature_id"].includes(key)) { + return entry === INVESTIGATION_USAGE.featureId; + } + if (["featureIds", "feature_ids"].includes(key) && Array.isArray(entry)) { + return entry.includes(INVESTIGATION_USAGE.featureId); + } + return typeof entry === "object" && referencesInvestigationBilling(entry); + }); +} + +export function isInvestigationPurchaseValid(body: JSONValue, route: string) { + if (!referencesInvestigationBilling(body)) { + return true; + } + // Only the supported manual checkout can purchase this SKU. Identity comes + // from the authenticated Autumn identify callback, never request fields. + return ( + (route === "attach" || route === "previewAttach") && + purchaseSchema.safeParse(body).success + ); +} diff --git a/apps/api/src/integration/insights-handlers.test.ts b/apps/api/src/integration/insights-handlers.test.ts index e93498b19..b7d8d8c63 100644 --- a/apps/api/src/integration/insights-handlers.test.ts +++ b/apps/api/src/integration/insights-handlers.test.ts @@ -11,9 +11,11 @@ import { } from "@databuddy/db/schema"; import { appRouter, + type Context, createInternalPrincipal, createRPCContext, } from "@databuddy/rpc"; +import { getAutumn } from "@databuddy/rpc/autumn"; import { closeInsightsQueue, getInsightsQueue, @@ -32,12 +34,39 @@ import { signUp, userContext, } from "@databuddy/test"; +import { RPCHandler } from "@orpc/server/fetch"; import { randomUUIDv7 } from "bun"; -import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { call } from "./helpers"; const iit = hasTestDb ? it : it.skip; +async function expectBadReplyRequest( + context: Context, + input: { + body: string; + insightId: string; + intent: string; + acceptedPriceUsd?: number; + replyId?: string; + } +) { + const handler = new RPCHandler({ reply: appRouter.insights.reply }); + const result = await handler.handle( + new Request("https://api.example.invalid/reply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ json: input }), + }), + { context } + ); + expect(result.matched).toBe(true); + expect(result.response?.status).toBe(400); + expect(await result.response?.json()).toMatchObject({ + json: { code: "BAD_REQUEST" }, + }); +} + function investigationOutcome(nextType: "act" | "watch"): InvestigationOutcome { const next: InvestigationOutcome["next"] = nextType === "act" @@ -309,7 +338,7 @@ describe("insight investigation timeline", () => { ]); }); - iit("hides a case from the action inbox while a reply is being verified", async () => { + iit.each(["verification", "clarification"] as const)("keeps clarification independent of case visibility: %s", async (intent) => { const member = await signUp(); const organization = await insertOrganization(); await addToOrganization(member.id, organization.id, "member"); @@ -339,6 +368,7 @@ describe("insight investigation timeline", () => { authorId: member.id, authorName: "Test member", body: "Databuddy applied the suggested action.", + intent, id: randomUUIDv7(), insightId, status: "running", @@ -353,7 +383,7 @@ describe("insight investigation timeline", () => { organizationId: organization.id, }); - expect(result.insights).toEqual([]); + expect(result.insights).toHaveLength(intent === "verification" ? 0 : 1); }); iit("applies an executable goal action and queues verification together", async () => { @@ -1060,6 +1090,100 @@ describe("insight investigation timeline", () => { expect(websiteOnly.insights[0]?.websiteId).toBe(secondWebsite.id); }); + iit( + "persists the accepted $1 analysis quote and rejects idempotent replay with a different durable price", + async () => { + const { member, organization, insightId } = + await seedExecutableGoalAction(); + const context = userContext(member, organization.id); + const originalSecret = process.env.AUTUMN_SECRET_KEY; + process.env.AUTUMN_SECRET_KEY = "synthetic-local-only"; + const getCustomer = vi.spyOn(getAutumn().customers, "get").mockResolvedValue({ + id: member.id, + name: null, + email: null, + createdAt: 0, + fingerprint: null, + stripeId: null, + env: "sandbox", + metadata: {}, + sendEmailReceipts: false, + billingControls: {}, + subscriptions: [], + purchases: [], + flags: {}, + balances: { + investigation_runs: { + featureId: "investigation_runs", + granted: 1, + remaining: 1, + usage: 0, + unlimited: false, + overageAllowed: false, + maxPurchase: null, + nextResetAt: null, + }, + }, + }); + try { + const input = { + body: "Run a fresh signup analysis", + insightId, + intent: "analysis" as const, + acceptedPriceUsd: 1 as const, + replyId: randomUUIDv7(), + }; + for (const acceptedPriceUsd of [undefined, 2]) { + await expectBadReplyRequest(context, { ...input, acceptedPriceUsd }); + } + expect(getCustomer).not.toHaveBeenCalled(); + expect( + await db() + .select() + .from(insightReplies) + .where(eq(insightReplies.id, input.replyId)) + ).toHaveLength(0); + const first = await call(appRouter.insights.reply, context)(input); + const [stored] = await db() + .select() + .from(insightReplies) + .where(eq(insightReplies.id, first.reply.id)); + expect(stored).toMatchObject({ + intent: "analysis", + acceptedPriceCents: 100, + status: "queued", + }); + const retry = await call(appRouter.insights.reply, context)(input); + expect(retry.reply).toEqual(first.reply); + expect( + await db() + .select() + .from(insightReplies) + .where(eq(insightReplies.id, input.replyId)) + ).toHaveLength(1); + for (const acceptedPriceCents of [null, 200]) { + await db() + .update(insightReplies) + .set({ acceptedPriceCents }) + .where(eq(insightReplies.id, first.reply.id)); + await expectCode( + call(appRouter.insights.reply, context)(input), + "CONFLICT" + ); + const [unchanged] = await db() + .select() + .from(insightReplies) + .where(eq(insightReplies.id, first.reply.id)); + expect(unchanged?.acceptedPriceCents).toBe(acceptedPriceCents); + } + } finally { + getCustomer.mockRestore(); + if (originalSecret === undefined) delete process.env.AUTUMN_SECRET_KEY; + else process.env.AUTUMN_SECRET_KEY = originalSecret; + } + } + ); + iit("persists a reply beside every observation for the same signal", async () => { const member = await signUp(); const organization = await insertOrganization(); @@ -1117,6 +1241,15 @@ describe("insight investigation timeline", () => { ]); const context = userContext(member, organization.id); + await expectCode( + call(appRouter.insights.reply, context)({ body: "Fresh analysis", insightId: previousInsightId, intent: "analysis" }), + "BAD_REQUEST" + ); + await expectBadReplyRequest(context, { + body: "Verify", + insightId: previousInsightId, + intent: "verification", + }); const added = await call(appRouter.insights.reply, context)({ body: " The signup form changed in yesterday's deploy. ", insightId: previousInsightId, @@ -1125,6 +1258,11 @@ describe("insight investigation timeline", () => { "The signup form changed in yesterday's deploy." ); expect(added.reply.status).toBe("queued"); + const [includedReply] = await db() + .select() + .from(insightReplies) + .where(eq(insightReplies.id, added.reply.id)); + expect(includedReply?.acceptedPriceCents).toBeNull(); expect( (await getInsightsQueue().getJob(insightsResumeJobId(added.reply.id)))?.data ).toEqual({ replyId: added.reply.id }); @@ -1174,6 +1312,8 @@ describe("insight investigation timeline", () => { authorName: "test", body: "The signup form changed in yesterday's deploy.", insightId, + intent: "clarification", + sourceObservationId: secondObservationId, status: "queued", }), ]); @@ -1278,12 +1418,12 @@ describe("insight investigation timeline", () => { total: 1, websites: [expect.objectContaining({ id: website.id })], }); - const listedWhileVerifying = await mcpTools + const listedWhileClarifying = await mcpTools .find((tool) => tool.name === "list_investigations") ?.handler({ limit: 20, offset: 0, websiteId: website.id }); - expect(listedWhileVerifying?.isError).toBe(false); - expect(listedWhileVerifying?.structuredContent).toMatchObject({ - investigations: [], + expect(listedWhileClarifying?.isError).toBe(false); + expect(listedWhileClarifying?.structuredContent).toMatchObject({ + investigations: [expect.objectContaining({ id: insightId })], }); expect(await db().select().from(insightReplies)).toEqual([ expect.objectContaining({ diff --git a/apps/api/src/routes/webhooks/autumn.test.ts b/apps/api/src/routes/webhooks/autumn.test.ts index e2466ced1..316109695 100644 --- a/apps/api/src/routes/webhooks/autumn.test.ts +++ b/apps/api/src/routes/webhooks/autumn.test.ts @@ -462,12 +462,12 @@ describe("Autumn usage emails", () => { expect(UsageAlertEmail).toHaveBeenCalledWith( expect.objectContaining({ - featureName: "Investigation credits", + featureName: "AI credits", limitAmount: 350, organizationName: "Acme", remainingAmount: 62, usageAmount: 288, - usageUnit: "investigation credits", + usageUnit: "AI credits", }) ); expect(UsageAlertEmail).not.toHaveBeenCalledWith( @@ -475,7 +475,7 @@ describe("Autumn usage emails", () => { ); expect(state.send).toHaveBeenCalledWith( expect.objectContaining({ - subject: "Investigation credits: 82% used", + subject: "AI credits: 82% used", to: "recipient@example.com", }) ); @@ -553,7 +553,7 @@ describe("Autumn usage emails", () => { expect(UsageLimitEmail).toHaveBeenCalledWith( expect.objectContaining({ - featureName: "Investigation credits", + featureName: "AI credits", isAvailable: false, limitAmount: 350, limitType: "spend_limit", @@ -562,11 +562,27 @@ describe("Autumn usage emails", () => { ); expect(state.send).toHaveBeenCalledWith( expect.objectContaining({ - subject: "[Action required] Investigation credits limit reached", + subject: "[Action required] AI credits limit reached", }) ); }); + it("limits new investigations without describing included clarifications as paused", async () => { + state.check.mockResolvedValueOnce({ + allowed: false, + balance: { granted: 10, remaining: 0, usage: 10, overageAllowed: false, nextResetAt: 0 }, + }); + await handleLimitReached({ + customer_id: "user-1", entity_id: "org-1", + feature_id: "investigation_runs", limit_type: "included", + }); + expect(UsageLimitEmail).toHaveBeenCalledWith(expect.objectContaining({ + featureName: "Investigations", usageUnit: "investigations", + pausedActivity: "new investigations (included clarifications remain available)", + featureDescription: expect.stringContaining("$1 per completed investigation"), + })); + }); + it("honors the resolved organization's billing email preference", async () => { state.ownedOrganizations[0]!.organization.emailNotifications = { billing: { usageWarnings: false }, diff --git a/apps/api/src/routes/webhooks/autumn.ts b/apps/api/src/routes/webhooks/autumn.ts index 09847a9d9..ea26ef88a 100644 --- a/apps/api/src/routes/webhooks/autumn.ts +++ b/apps/api/src/routes/webhooks/autumn.ts @@ -21,7 +21,10 @@ import { } from "@databuddy/redis"; import { getAutumn } from "@databuddy/rpc"; import { recordPlanChange } from "@databuddy/services/billing-lifecycle"; -import { DATABUNNY_USAGE } from "@databuddy/shared/billing"; +import { + DATABUNNY_USAGE, + INVESTIGATION_USAGE, +} from "@databuddy/shared/billing"; import { Elysia } from "elysia"; import { log } from "evlog"; import { useLogger } from "evlog/elysia"; @@ -227,6 +230,15 @@ async function resolveBillingOrganization( } function getFeatureCopy(featureId: string): BillingFeatureCopy { + if (featureId === INVESTIGATION_USAGE.featureId) { + return { + description: INVESTIGATION_USAGE.description, + name: INVESTIGATION_USAGE.name, + pausedActivity: + "new investigations (included clarifications remain available)", + unit: INVESTIGATION_USAGE.unit, + }; + } if (featureId === "agent_credits") { return { description: DATABUNNY_USAGE.description, diff --git a/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx b/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx index a82d445e1..20f480bd6 100644 --- a/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx +++ b/apps/dashboard/app/(main)/billing/components/billing-controls-card.tsx @@ -74,7 +74,8 @@ export function BillingControlsCard() { Billing controls - Control investigation credit refills, event alerts, and AI spending. + Control AI credit refills, event alerts, and AI spending. These credit + controls do not purchase $1 investigations. @@ -85,7 +86,7 @@ export function BillingControlsCard() { turnOn: "Turn on", }} defaults={TOPUP_DEFAULTS} - description="Add investigation credits automatically when the organization's balance runs low." + description="Add AI credits automatically when the organization's balance runs low." icon={} initial={topup} limits={TOPUP_LIMITS} @@ -173,7 +174,7 @@ export function BillingControlsCard() { turnOn: "Turn on", }} defaults={SPEND_DEFAULTS} - description="Cap monthly investigation credit spending. Automatic refills stop when the cap is reached." + description="Cap monthly AI credit spending. Automatic refills stop when the cap is reached." icon={} initial={spend} limits={SPEND_LIMITS} @@ -185,7 +186,7 @@ export function BillingControlsCard() { mutationOptions={orpc.billing.setSpendLimit.mutationOptions()} onSaved={refetch} switchLabel="Enable spend limit" - title="Investigation credit spend limit" + title="AI credit spend limit" > {(form, setForm) => ( { + if (window.location.hash === "#topup") { + document.getElementById("topup")?.scrollIntoView({ block: "start" }); + } + }, []); + + async function purchase() { + if (!(quote && canUserUpgrade && hasAccess)) { + return; + } + setIsAttaching(true); + try { + await attach({ + planId: quote.planId, + featureQuantities: quote.featureQuantities, + successUrl: `${window.location.origin}/billing`, + }); + } catch (error) { + toast.error( + getUserFacingErrorMessage( + error, + "We couldn't open checkout. Try again." + ) + ); + } finally { + setIsAttaching(false); + } + } + + return ( + + + Investigations · $1 each + {INVESTIGATION_USAGE.description} + + + {!isLoading && ( +

+ {fixedPrice + ? unlimited + ? "Your plan has unlimited investigations." + : `${balance.toLocaleString()} investigations remaining.` + : "Your investigations currently use legacy AI credit terms. Buying investigations or switching to a new plan version changes future investigations to $1 each; your existing AI credits remain available for chat."} +

+ )} +

+ Prepaid investigations do not expire. Plan AI credits are separate; no + investigations are bundled with the new plan versions. +

+ {hasAccess ? ( + <> + + Investigations to buy + setQuantity(event.target.value)} + step={1} + type="number" + value={quantity} + /> + + 1–1,000 investigations, $1 each. + + {!parsedQuantity.success && ( + + Enter a whole number between 1 and 1,000. + + )} + + + {!canUserUpgrade && ( +

+ Ask an organization owner or admin to add balance. +

+ )} + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/app/(main)/billing/components/topup-card.tsx b/apps/dashboard/app/(main)/billing/components/topup-card.tsx index 204a38080..4878876d3 100644 --- a/apps/dashboard/app/(main)/billing/components/topup-card.tsx +++ b/apps/dashboard/app/(main)/billing/components/topup-card.tsx @@ -37,10 +37,10 @@ export function TopupCard() { if (typeof window === "undefined") { return; } - if (window.location.hash !== "#topup") { + if (window.location.hash !== "#chat-topup") { return; } - const el = document.getElementById("topup"); + const el = document.getElementById("chat-topup"); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } @@ -76,11 +76,11 @@ export function TopupCard() { }; return ( - + - Add investigation credits + Add AI credits {DATABUNNY_USAGE.description} Purchased credits stack with your plan @@ -91,7 +91,7 @@ export function TopupCard() {
- {quantity.toLocaleString()} investigation credits + {quantity.toLocaleString()} AI credits diff --git a/apps/dashboard/app/(main)/billing/page.tsx b/apps/dashboard/app/(main)/billing/page.tsx index 8d6ef2b46..aa00d65fa 100644 --- a/apps/dashboard/app/(main)/billing/page.tsx +++ b/apps/dashboard/app/(main)/billing/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { INVESTIGATION_USAGE } from "@databuddy/shared/billing"; + import AttachDialog from "@/components/autumn/attach-dialog"; import { useBillingContext } from "@/components/providers/billing-provider"; import { getCustomerPlanName } from "@/lib/autumn/customer-plan-name"; @@ -19,6 +21,7 @@ import { CancelSubscriptionDialog } from "./components/cancel-subscription-dialo import { ConsumptionChart } from "./components/consumption-chart"; import { ErrorState } from "./components/empty-states"; import { PlanStatusBadge } from "./components/plan-status-badge"; +import { InvestigationTopupCard } from "./components/investigation-topup-card"; import { TopupCard } from "./components/topup-card"; import { UsageBreakdownTable } from "./components/usage-breakdown-table"; import { UsageRow } from "./components/usage-row"; @@ -324,7 +327,11 @@ export default function BillingPage() { basePlanId != null && INTELLIGENCE_PLAN_ID_SET.has(basePlanId); return allAddOns.filter((plan) => { - if (isSSOPlan(plan) || plan.id === TOPUP_PRODUCT_ID) { + if ( + isSSOPlan(plan) || + plan.id === TOPUP_PRODUCT_ID || + plan.id === INVESTIGATION_USAGE.topupPlanId + ) { return false; } if (onIntelligencePlan && plan.id === CREDITS_BOOSTER_PLAN_ID) { @@ -528,6 +535,7 @@ export default function BillingPage() { + {!isFree && } {!isFree && } diff --git a/apps/dashboard/app/(main)/insights/[id]/page.tsx b/apps/dashboard/app/(main)/insights/[id]/page.tsx index 5c008dfd1..576626788 100644 --- a/apps/dashboard/app/(main)/insights/[id]/page.tsx +++ b/apps/dashboard/app/(main)/insights/[id]/page.tsx @@ -1,11 +1,14 @@ "use client"; +import { INVESTIGATION_USAGE } from "@databuddy/shared/billing"; + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { type FormEvent, useId, useState } from "react"; import { toast } from "sonner"; import { TopBar } from "@/components/layout/top-bar"; +import { MessageResponse } from "@/components/ai-elements/message"; import { insightQueries, type InsightByIdResponse } from "@/lib/insight-api"; import { orpc } from "@/lib/orpc"; import { @@ -156,7 +159,8 @@ function CaseState({ ); const verifying = reported && - reported.status !== "failed" && + reported.intent !== "clarification" && + (reported.status === "queued" || reported.status === "running") && reported.createdAt > latest.createdAt; const label = verifying ? "Measuring" @@ -287,7 +291,7 @@ function CaseActivity({
) : null} - {canReply && !isResolved && ( + {canReply && ( {item.body}

+ {item.assistantText && ( +
+

Databuddy

+ + {item.assistantText} + +
+ )} {(item.status === "queued" || item.status === "running") && (

{item.status === "queued" - ? "Queued for investigation…" - : "Databuddy is investigating…"} + ? "Reply queued…" + : item.intent === "clarification" + ? "Databuddy is answering…" + : "Databuddy is investigating…"}

)} {item.status === "failed" && (
- Investigation failed. + Reply failed. {onRetry && (
); @@ -684,14 +701,25 @@ function ReplyComposer({ if (!trimmed) { return; } - sendReply(trimmed, "Databuddy is checking the latest context"); + sendReply(trimmed, "Databuddy is answering your clarification"); }; - const sendReply = (message: string, successMessage: string) => { + const sendReply = ( + message: string, + successMessage: string, + intent: "clarification" | "analysis" = "clarification" + ) => { if (disabled || replyMutation.isPending) { return; } replyMutation.mutate( - { body: message, insightId }, + { + body: message, + insightId, + intent, + ...(intent === "analysis" + ? { acceptedPriceUsd: INVESTIGATION_USAGE.priceUsd } + : {}), + }, { onSuccess: (data) => { if (data.reply.status !== "failed") { @@ -704,17 +732,32 @@ function ReplyComposer({ return (
- Add context + Question