+ 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([]);
+ });
+});