Skip to content

feat(careers): charge 1 credit per job posting - #185

Merged
ralyodio merged 1 commit into
masterfrom
feature/careers-credit-billing
Aug 3, 2026
Merged

ralyodio merged 1 commit into
masterfrom
feature/careers-credit-billing

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #183 (merged). Adds billing to the careers widget.

The rule

1 credit per posting, charged once — the first time it goes open.

  • Not per month, not per edit.
  • Closing a role and re-opening it later is free — you buy the posting, not the month it happens to be live, so a seasonal listing isn't billed twice for the same hire.
  • Drafts cost nothing. Switching the widget on costs nothing.

JOB_POSTING_CREDITS lives in lib/credits.ts alongside the other prices. 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.

Two decisions worth reviewing

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. Say the word if you'd rather bill the actor.

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 posting that went live without being billed.

Migration

credit_charged_at ships as a new migration (20260803150000) rather than an edit to the careers migration from #183. That one is already on master and may have been applied; editing an applied migration in place would leave the column silently missing.

UI

The button prices what it's about to charge for — Publish (1 credit) — and becomes Re-open once a posting has already paid, so nobody clicks expecting a second charge.

Checks

  • tsc --noEmit clean
  • vitest run — 1309 passed (14 new), 1 file skipped
  • next build compiles

The 14 new tests pin the money rules: charged once, never for drafts, never twice, owner billed not actor, refunded on write failure. I mutation-checked them — removing the double-charge guard fails 3 of them, so they aren't passing vacuously.

Still not done (from the earlier list)

Unchanged by this PR: no spam protection on the public /api/careers/apply, no email notification on a new application, and /c/ pages still aren't in sitemap.ts.

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.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

52 finding(s)

HIGH/CRITICAL: 10 | MEDIUM: 42

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH js-ssrf-outbound-request lib/sp/platforms/facebook.ts:115
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH js-ssrf-outbound-request lib/sp/platforms/telegram.ts:63
HIGH js-ssrf-outbound-request lib/sp/platforms/threads.ts:138
HIGH manifest-typosquat package.json:59
HIGH secret-generic-credential tests/contract/coinpay.test.ts:4
HIGH secret-generic-credential tests/contract/posthog-integration.test.ts:13
HIGH secret-generic-credential tests/lead-campaign.test.ts:16
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/actions.tsx:96
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM sql-template-interpolation app/(app)/projects/[id]/autoblog/setup/form.tsx:504
MEDIUM sql-template-interpolation app/(app)/projects/[id]/uptime/monitor-actions.tsx:28
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM sql-template-interpolation app/actions/admin.ts:114
MEDIUM sql-template-interpolation app/actions/orgs.ts:328
MEDIUM sql-template-interpolation app/api/lx/keywords/regenerate/route.ts:59
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:196
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:223
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:279
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM sql-template-interpolation lib/audit/checks/security.ts:48
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:367
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:379
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:380
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1340
MEDIUM sql-template-interpolation lib/lx/articleGen.ts:1362
MEDIUM sql-template-interpolation lib/lx/guestPostGen.ts:109
MEDIUM tls-verification-disabled lib/onion.ts:47
MEDIUM sql-template-interpolation lib/sp/platforms/linkedin.ts:177
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:97
MEDIUM sql-template-interpolation scripts/delete-archived-projects.mjs:102

…and 2 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio marked this pull request as ready for review August 3, 2026 16:15
@ralyodio
ralyodio merged commit b369828 into master Aug 3, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant