diff --git a/app/(app)/projects/[id]/stats/careers/job-manager.tsx b/app/(app)/projects/[id]/stats/careers/job-manager.tsx index de6ef840..fd349a08 100644 --- a/app/(app)/projects/[id]/stats/careers/job-manager.tsx +++ b/app/(app)/projects/[id]/stats/careers/job-manager.tsx @@ -15,6 +15,9 @@ import { type Workplace, workplaceSummary, } from "@/lib/careers/jobs"; +import { JOB_POSTING_CREDITS } from "@/lib/credits"; + +const CREDITS_LABEL = `${JOB_POSTING_CREDITS} credit${JOB_POSTING_CREDITS === 1 ? "" : "s"}`; export interface JobRow { id: string; @@ -31,6 +34,7 @@ export interface JobRow { qualifications: string[]; status: JobStatus; sort_order: number; + credit_charged: boolean; application_count: number; } @@ -66,7 +70,8 @@ export function JobManager({ > hosted board - . Drafts stay private. + . Drafts stay private. Publishing a role costs {CREDITS_LABEL}, once + — closing and re-opening the same posting is free.

{editing === null && ( @@ -188,8 +193,13 @@ function JobCard({ className="btn btn-secondary text-xs" onClick={() => changeStatus("open")} disabled={pending} + title={ + job.credit_charged + ? "Already paid for — re-opening is free" + : `Publishing costs ${CREDITS_LABEL}` + } > - Publish + {job.credit_charged ? "Re-open" : `Publish (${CREDITS_LABEL})`} )} {job.status === "open" && ( @@ -342,7 +352,11 @@ function JobForm({ diff --git a/app/(app)/projects/[id]/stats/careers/page.tsx b/app/(app)/projects/[id]/stats/careers/page.tsx index bde443ae..6fa112c0 100644 --- a/app/(app)/projects/[id]/stats/careers/page.tsx +++ b/app/(app)/projects/[id]/stats/careers/page.tsx @@ -22,6 +22,7 @@ type JobRecord = { qualifications: string[] | null; status: JobStatus; sort_order: number; + credit_charged_at: string | null; }; type ApplicationRecord = { @@ -53,7 +54,7 @@ export default async function CareersPage({ supabase .from("job_postings") .select( - "id, slug, title, department, location, employment_type, workplace, compensation, apply_url, overview, responsibilities, qualifications, status, sort_order", + "id, slug, title, department, location, employment_type, workplace, compensation, apply_url, overview, responsibilities, qualifications, status, sort_order, credit_charged_at", ) .eq("project_id", id) .order("sort_order", { ascending: true }) @@ -77,6 +78,7 @@ export default async function CareersPage({ ...job, responsibilities: job.responsibilities ?? [], qualifications: job.qualifications ?? [], + credit_charged: Boolean(job.credit_charged_at), application_count: countsByJob.get(job.id) ?? 0, })); diff --git a/app/(marketing)/docs/statistics/page.tsx b/app/(marketing)/docs/statistics/page.tsx index eb7312be..839a4fc8 100644 --- a/app/(marketing)/docs/statistics/page.tsx +++ b/app/(marketing)/docs/statistics/page.tsx @@ -149,6 +149,11 @@ window.crawlproof?.track("purchase", "pro_plan");`}

{`
 `}
+

+ Publishing a role costs 1 credit, charged once when it first goes + live. Closing a posting and re-opening it later is free, and the + widget itself costs nothing to switch on. +

A client-rendered job board is invisible to crawlers, so the widget also writes JobPosting schema into diff --git a/app/actions/careers.ts b/app/actions/careers.ts index 4ed4b5d1..9b515a22 100644 --- a/app/actions/careers.ts +++ b/app/actions/careers.ts @@ -2,6 +2,10 @@ import { revalidatePath } from "next/cache"; import { requireProjectAccess } from "@/lib/lx/currentSite"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { consumeCredit, refundCredit } from "@/lib/rateLimit"; +import { JOB_POSTING_CREDITS } from "@/lib/credits"; import { APPLICATION_STATUSES, JOB_STATUSES, @@ -107,18 +111,26 @@ export async function saveJobPosting(input: JobInput): Promise>, + projectId: string, +): Promise { + const { data } = await supabase + .from("projects") + .select("owner_id") + .eq("id", projectId) + .maybeSingle(); + const ownerId = (data as { owner_id?: string } | null)?.owner_id; + if (!ownerId) return { ok: false, error: "Could not find the project owner to bill." }; + + const paid = await consumeCredit(ownerId, JOB_POSTING_CREDITS); + if (!paid.ok) { + return { + ok: false, + error: `Publishing a role costs ${JOB_POSTING_CREDITS} credit${ + JOB_POSTING_CREDITS === 1 ? "" : "s" + }. Not enough balance — top up in Settings → Billing.`, + }; + } + return { ok: true, ownerId, cost: JOB_POSTING_CREDITS }; +} + +async function refundCharge(charge: Charge | null) { + if (!charge) return; + await refundCredit(charge.ownerId, charge.cost); +} + +/** Ledger entry so a spend is explainable later. Best-effort. */ +async function recordCharge(charge: Charge | null, projectId: string, jobId: string) { + if (!charge) return; + try { + await serviceClient() + .from("usage_events") + .insert({ + owner_id: charge.ownerId, + kind: "job_posting_published", + meta: { project_id: projectId, job_id: jobId, credits_spent: charge.cost }, + }); + } catch { + // A missing ledger row must never undo a successful publish. + } +} diff --git a/lib/credits.ts b/lib/credits.ts index e07f1fed..adcd2bf5 100644 --- a/lib/credits.ts +++ b/lib/credits.ts @@ -15,6 +15,19 @@ export const SCAN_CREDITS = 20; // Credits charged for one outreach send (email / SMS recipient / social post). export const OUTREACH_CREDITS = 1; +/** + * Credits charged to publish one job posting on the careers widget. + * + * Charged once per posting, the first time it goes open — not per month and + * not per edit. Closing a role and re-opening it is free, so a seasonal + * listing isn't billed twice for the same hire. + * + * Serving costs us almost nothing (the jobs feed is a cached read), so this is + * priced as a listing fee rather than off cost: 5c a posting at rack, against + * $200-and-up to list the same role on a real job board. + */ +export const JOB_POSTING_CREDITS = 1; + /** * One billable lead-generation tick: discovery, contact lookup and drafting. * diff --git a/supabase/migrations/20260803150000_careers_posting_credits.sql b/supabase/migrations/20260803150000_careers_posting_credits.sql new file mode 100644 index 00000000..fc13a8f0 --- /dev/null +++ b/supabase/migrations/20260803150000_careers_posting_credits.sql @@ -0,0 +1,13 @@ +-- Billing for the careers widget: JOB_POSTING_CREDITS is spent the first time +-- a posting goes open, and never again. +-- +-- A separate migration rather than an edit to 20260803120000_careers_widget.sql +-- because that one is already merged and may have been applied — editing an +-- applied migration in place would leave the column silently missing. +-- +-- Stamped rather than inferred from published_at so that closing and +-- re-opening a role is free (you buy the posting, not the month it is live), +-- and so a failed charge leaves an obvious null instead of a posting that went +-- live without being billed. +alter table public.job_postings + add column if not exists credit_charged_at timestamptz; diff --git a/tests/careers-billing.test.ts b/tests/careers-billing.test.ts new file mode 100644 index 00000000..9b61001b --- /dev/null +++ b/tests/careers-billing.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { JOB_POSTING_CREDITS } from "@/lib/credits"; + +// Publishing a job posting spends a credit. These pin the money rules: +// charged once per posting, never for drafts, never twice for the same role, +// and always refunded if the write that was supposed to publish it fails. + +const state = vi.hoisted(() => ({ + project: { owner_id: "owner-1" } as { owner_id: string } | null, + existingJob: null as { published_at: string | null; credit_charged_at: string | null } | null, + siblings: [] as { slug: string }[], + updateError: null as { message: string } | null, + insertError: null as { message: string } | null, + updates: [] as Record[], + inserts: [] as Record[], + consumeOk: true, + consumed: [] as { owner: string; count: number }[], + refunded: [] as { owner: string; count: number }[], + usageEvents: [] as Record[], +})); + +function resolveOp(op: { kind?: string; payload?: unknown; cols?: string }) { + if (op.kind === "update") { + state.updates.push(op.payload as Record); + return { data: null, error: state.updateError }; + } + if (op.kind === "insert") { + state.inserts.push(op.payload as Record); + if (state.insertError) return { data: null, error: state.insertError }; + return { data: { id: "job-new" }, error: null }; + } + if (op.cols?.includes("credit_charged_at")) return { data: state.existingJob, error: null }; + if (op.cols === "slug") return { data: state.siblings, error: null }; + return { data: null, error: null }; +} + +/** Chainable PostgREST-ish builder; terminal via await, .single(), .maybeSingle(). */ +function builder() { + const op: { kind?: string; payload?: unknown; cols?: string } = {}; + const b: Record = { + select(cols?: string) { + op.cols = cols; + op.kind ??= "select"; + return b; + }, + update(row: unknown) { + op.kind = "update"; + op.payload = row; + return b; + }, + insert(row: unknown) { + op.kind = "insert"; + op.payload = row; + return b; + }, + delete() { + op.kind = "delete"; + return b; + }, + eq: () => b, + order: () => b, + limit: () => b, + maybeSingle: async () => resolveOp(op), + single: async () => resolveOp(op), + then: (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(resolveOp(op)).then(res, rej), + }; + return b; +} + +const fakeSupabase = { + from(table: string) { + if (table === "projects") { + return { + select: () => ({ + eq: () => ({ maybeSingle: async () => ({ data: state.project, error: null }) }), + }), + }; + } + return builder(); + }, +}; + +vi.mock("@/lib/lx/currentSite", () => ({ + requireProjectAccess: async () => ({ + ok: true, + userId: "user-1", + userEmail: "u@example.com", + isOwner: true, + isViewer: false, + supabase: fakeSupabase, + }), +})); + +vi.mock("@/lib/rateLimit", () => ({ + consumeCredit: async (owner: string, count: number) => { + if (!state.consumeOk) return { ok: false }; + state.consumed.push({ owner, count }); + return { ok: true, remaining: 99 }; + }, + refundCredit: async (owner: string, count: number) => { + state.refunded.push({ owner, count }); + }, +})); + +vi.mock("@/lib/supabase/service", () => ({ + serviceClient: () => ({ + from: () => ({ + insert: async (payload: Record) => { + state.usageEvents.push(payload); + return { error: null }; + }, + }), + }), +})); + +vi.mock("@/lib/supabase/server", () => ({ createClient: async () => fakeSupabase })); +vi.mock("next/cache", () => ({ revalidatePath: () => {} })); + +async function actions() { + return await import("@/app/actions/careers"); +} + +const BASE = { projectId: "proj-1", title: "HPC Engineer" }; + +beforeEach(() => { + vi.resetModules(); + state.project = { owner_id: "owner-1" }; + state.existingJob = null; + state.siblings = []; + state.updateError = null; + state.insertError = null; + state.updates = []; + state.inserts = []; + state.consumeOk = true; + state.consumed = []; + state.refunded = []; + state.usageEvents = []; +}); + +describe("publishing a job posting charges a credit", () => { + it("charges the project owner when a new role is created open", async () => { + const { saveJobPosting } = await actions(); + const res = await saveJobPosting({ ...BASE, status: "open" }); + expect(res.ok).toBe(true); + expect(state.consumed).toEqual([{ owner: "owner-1", count: JOB_POSTING_CREDITS }]); + expect(state.inserts[0].credit_charged_at).toBeTruthy(); + }); + + // The person clicking Publish may be a teammate; the project owner is the + // billing entity, so it must not come out of the clicker's balance. + it("bills the owner, not the acting user", async () => { + const { saveJobPosting } = await actions(); + await saveJobPosting({ ...BASE, status: "open" }); + expect(state.consumed[0].owner).toBe("owner-1"); + expect(state.consumed[0].owner).not.toBe("user-1"); + }); + + it("does not charge for a draft", async () => { + const { saveJobPosting } = await actions(); + const res = await saveJobPosting({ ...BASE, status: "draft" }); + expect(res.ok).toBe(true); + expect(state.consumed).toEqual([]); + expect(state.inserts[0].credit_charged_at).toBeUndefined(); + }); + + it("does not charge again when an already-paid role is edited while open", async () => { + state.existingJob = { published_at: "2026-08-01T00:00:00Z", credit_charged_at: "2026-08-01T00:00:00Z" }; + const { saveJobPosting } = await actions(); + const res = await saveJobPosting({ ...BASE, jobId: "job-1", status: "open" }); + expect(res.ok).toBe(true); + expect(state.consumed).toEqual([]); + }); + + it("charges an existing draft the first time it is published", async () => { + state.existingJob = { published_at: null, credit_charged_at: null }; + const { saveJobPosting } = await actions(); + await saveJobPosting({ ...BASE, jobId: "job-1", status: "open" }); + expect(state.consumed).toHaveLength(1); + }); + + it("records a usage event so the spend is explainable", async () => { + const { saveJobPosting } = await actions(); + await saveJobPosting({ ...BASE, status: "open" }); + expect(state.usageEvents).toHaveLength(1); + expect(state.usageEvents[0]).toMatchObject({ + owner_id: "owner-1", + kind: "job_posting_published", + }); + }); +}); + +describe("setJobStatus billing", () => { + it("charges when a draft is published from the list", async () => { + state.existingJob = { published_at: null, credit_charged_at: null }; + const { setJobStatus } = await actions(); + const res = await setJobStatus({ projectId: "proj-1", jobId: "job-1", status: "open" }); + expect(res.ok).toBe(true); + expect(state.consumed).toHaveLength(1); + }); + + // You buy the posting, not the month it happens to be live. + it("re-opening a closed role that already paid is free", async () => { + state.existingJob = { published_at: "2026-07-01T00:00:00Z", credit_charged_at: "2026-07-01T00:00:00Z" }; + const { setJobStatus } = await actions(); + const res = await setJobStatus({ projectId: "proj-1", jobId: "job-1", status: "open" }); + expect(res.ok).toBe(true); + expect(state.consumed).toEqual([]); + }); + + it("closing a role costs nothing", async () => { + const { setJobStatus } = await actions(); + await setJobStatus({ projectId: "proj-1", jobId: "job-1", status: "closed" }); + expect(state.consumed).toEqual([]); + }); +}); + +describe("when the charge cannot be taken", () => { + it("refuses to publish and says why", async () => { + state.consumeOk = false; + const { saveJobPosting } = await actions(); + const res = await saveJobPosting({ ...BASE, status: "open" }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/credit/i); + // Nothing was written, so the role is not silently live. + expect(state.inserts).toEqual([]); + expect(state.updates).toEqual([]); + }); + + it("refuses when the project has no owner to bill", async () => { + state.project = null; + const { saveJobPosting } = await actions(); + const res = await saveJobPosting({ ...BASE, status: "open" }); + expect(res.ok).toBe(false); + expect(state.consumed).toEqual([]); + }); +}); + +describe("refunds", () => { + // Spend-then-write means a failed write would otherwise leave the customer + // charged for a posting that does not exist. + it("refunds when the insert fails after charging", async () => { + state.insertError = { message: "boom" }; + const { saveJobPosting } = await actions(); + const res = await saveJobPosting({ ...BASE, status: "open" }); + expect(res.ok).toBe(false); + expect(state.consumed).toHaveLength(1); + expect(state.refunded).toEqual([{ owner: "owner-1", count: JOB_POSTING_CREDITS }]); + }); + + it("refunds when the publish update fails", async () => { + state.existingJob = { published_at: null, credit_charged_at: null }; + state.updateError = { message: "boom" }; + const { setJobStatus } = await actions(); + const res = await setJobStatus({ projectId: "proj-1", jobId: "job-1", status: "open" }); + expect(res.ok).toBe(false); + expect(state.refunded).toEqual([{ owner: "owner-1", count: JOB_POSTING_CREDITS }]); + }); + + it("does not refund when nothing was charged", async () => { + state.existingJob = { published_at: "2026-07-01T00:00:00Z", credit_charged_at: "2026-07-01T00:00:00Z" }; + state.updateError = { message: "boom" }; + const { setJobStatus } = await actions(); + await setJobStatus({ projectId: "proj-1", jobId: "job-1", status: "open" }); + expect(state.refunded).toEqual([]); + }); +});