From 6085e4ae006d12582f09acee0a047976e36563bd Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Mon, 3 Aug 2026 15:05:48 +0000
Subject: [PATCH] feat(careers): charge 1 credit per job posting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Charged once, the first time a posting goes open — not per month, not per
edit. Closing a role and re-opening it later is free, so a seasonal
listing isn't billed twice for the same hire. Drafts cost nothing, and
switching the widget on costs nothing.
The project owner pays, not whoever clicked Publish: a teammate
publishing a role shouldn't have it come out of their personal balance,
and the project is already the billing entity for the tracker.
Spend first, write second, refund if the write fails. The reverse order
publishes roles for free whenever the charge fails, which is the
expensive direction to be wrong in. credit_charged_at on the posting is
what makes the charge idempotent — a failed charge leaves an obvious
null rather than a silently-published posting.
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.
The UI prices the button it is about to charge for, and says "Re-open"
instead of "Publish (1 credit)" once a posting has already paid.
---
.../[id]/stats/careers/job-manager.tsx | 20 +-
.../projects/[id]/stats/careers/page.tsx | 4 +-
app/(marketing)/docs/statistics/page.tsx | 5 +
app/actions/careers.ts | 117 +++++++-
lib/credits.ts | 13 +
...20260803150000_careers_posting_credits.sql | 13 +
tests/careers-billing.test.ts | 267 ++++++++++++++++++
7 files changed, 423 insertions(+), 16 deletions(-)
create mode 100644 supabase/migrations/20260803150000_careers_posting_credits.sql
create mode 100644 tests/careers-billing.test.ts
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.
+ 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([]);
+ });
+});