Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions app/(app)/projects/[id]/stats/careers/job-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,6 +34,7 @@ export interface JobRow {
qualifications: string[];
status: JobStatus;
sort_order: number;
credit_charged: boolean;
application_count: number;
}

Expand Down Expand Up @@ -66,7 +70,8 @@ export function JobManager({
>
hosted board
</a>
. Drafts stay private.
. Drafts stay private. Publishing a role costs {CREDITS_LABEL}, once
— closing and re-opening the same posting is free.
</p>
</div>
{editing === null && (
Expand Down Expand Up @@ -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})`}
</button>
)}
{job.status === "open" && (
Expand Down Expand Up @@ -342,7 +352,11 @@ function JobForm({
<Field label="Status">
<select name="status" defaultValue={job?.status ?? "draft"} className="input">
<option value="draft">Draft — not published</option>
<option value="open">Open — live in the widget</option>
<option value="open">
{job?.credit_charged
? "Open — live in the widget"
: `Open — live in the widget (${CREDITS_LABEL})`}
</option>
<option value="closed">Closed — no longer accepting</option>
</select>
</Field>
Expand Down
4 changes: 3 additions & 1 deletion app/(app)/projects/[id]/stats/careers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type JobRecord = {
qualifications: string[] | null;
status: JobStatus;
sort_order: number;
credit_charged_at: string | null;
};

type ApplicationRecord = {
Expand Down Expand Up @@ -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 })
Expand All @@ -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,
}));

Expand Down
5 changes: 5 additions & 0 deletions app/(marketing)/docs/statistics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ window.crawlproof?.track("purchase", "pro_plan");`}</pre>
</p>
<pre className="overflow-x-auto rounded border border-[var(--color-border)] bg-[#0b0d10] p-3 font-mono text-xs leading-relaxed">{`<script data-site="<project_id>" data-careers-path="/jobs" src="https://crawlproof.com/stats.js" async></script>
<script data-site="<project_id>" data-careers="off" src="https://crawlproof.com/stats.js" async></script>`}</pre>
<p className="text-sm leading-relaxed">
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.
</p>
<p className="text-sm leading-relaxed">
A client-rendered job board is invisible to crawlers, so the widget
also writes <span className="font-mono">JobPosting</span> schema into
Expand Down
117 changes: 105 additions & 12 deletions app/actions/careers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -107,18 +111,26 @@ export async function saveJobPosting(input: JobInput): Promise<Ok<{ id: string }
updated_at: new Date().toISOString(),
};

const { data: existing } = input.jobId
? await access.supabase
.from("job_postings")
.select("published_at, credit_charged_at")
.eq("id", input.jobId)
.eq("project_id", input.projectId)
.maybeSingle()
: { data: null };

// published_at is the datePosted we hand to schema.org, so it must be set
// the first time a role goes open and then left alone.
let charge: Charge | null = null;
if (status === "open") {
const { data: existing } = input.jobId
? await access.supabase
.from("job_postings")
.select("published_at")
.eq("id", input.jobId)
.eq("project_id", input.projectId)
.maybeSingle()
: { data: null };
if (!existing?.published_at) row.published_at = new Date().toISOString();
if (!existing?.credit_charged_at) {
const attempt = await chargeForPublish(access.supabase, input.projectId);
if (!attempt.ok) return attempt;
charge = attempt;
row.credit_charged_at = new Date().toISOString();
}
}

if (input.jobId) {
Expand All @@ -127,7 +139,11 @@ export async function saveJobPosting(input: JobInput): Promise<Ok<{ id: string }
.update(row)
.eq("id", input.jobId)
.eq("project_id", input.projectId);
if (error) return { ok: false, error: error.message };
if (error) {
await refundCharge(charge);
return { ok: false, error: error.message };
}
await recordCharge(charge, input.projectId, input.jobId);
revalidateCareers(input.projectId);
return { ok: true, id: input.jobId };
}
Expand All @@ -143,8 +159,12 @@ export async function saveJobPosting(input: JobInput): Promise<Ok<{ id: string }
.insert({ ...row, slug: uniqueSlug(title, taken), created_by: access.userId })
.select("id")
.single();
if (error) return { ok: false, error: error.message };
if (error) {
await refundCharge(charge);
return { ok: false, error: error.message };
}

await recordCharge(charge, input.projectId, data.id as string);
revalidateCareers(input.projectId);
return { ok: true, id: data.id as string };
}
Expand All @@ -162,23 +182,37 @@ export async function setJobStatus(input: {
status: input.status,
updated_at: new Date().toISOString(),
};

let charge: Charge | null = null;
if (input.status === "open") {
const { data: existing } = await access.supabase
.from("job_postings")
.select("published_at")
.select("published_at, credit_charged_at")
.eq("id", input.jobId)
.eq("project_id", input.projectId)
.maybeSingle();
if (!existing?.published_at) patch.published_at = new Date().toISOString();
// Re-opening a role that already paid is free — you buy the posting, not
// the month it happens to be live.
if (!existing?.credit_charged_at) {
const attempt = await chargeForPublish(access.supabase, input.projectId);
if (!attempt.ok) return attempt;
charge = attempt;
patch.credit_charged_at = new Date().toISOString();
}
}

const { error } = await access.supabase
.from("job_postings")
.update(patch)
.eq("id", input.jobId)
.eq("project_id", input.projectId);
if (error) return { ok: false, error: error.message };
if (error) {
await refundCharge(charge);
return { ok: false, error: error.message };
}

await recordCharge(charge, input.projectId, input.jobId);
revalidateCareers(input.projectId);
return { ok: true };
}
Expand Down Expand Up @@ -233,3 +267,62 @@ export async function setApplicationStatus(input: {
function revalidateCareers(projectId: string) {
revalidatePath(`/projects/${projectId}/stats/careers`);
}

/** A credit spend that has happened but whose posting write hasn't landed yet. */
type Charge = { ok: true; ownerId: string; cost: number };

/**
* Spend JOB_POSTING_CREDITS for a posting about to go open.
*
* 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 on write failure. The reverse order would
* publish roles for free whenever the charge failed, which is the expensive
* direction to be wrong in.
*/
async function chargeForPublish(
supabase: Awaited<ReturnType<typeof createClient>>,
projectId: string,
): Promise<Charge | Err> {
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.
}
}
13 changes: 13 additions & 0 deletions lib/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
13 changes: 13 additions & 0 deletions supabase/migrations/20260803150000_careers_posting_credits.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading