8x Social · Founding Engineer Task

Sandipan's solution for the 8x Social Founding Engineer problem

I

My read of the problem

The main issue is not simply that payment fields are spread across five tables. The deeper problem is that the system does not clearly separate four concepts:

Job default terms

The default pay terms offered by a job. Today these come partly from brand_campaigns and partly from legacy jobs.cpm_* fields.

Creator agreement

The specific terms agreed with one creator. Overrides live on managed_creators, with different names and sometimes different units.

Post earnings

The frozen calculation inputs and amount earned by one post. Final values are frozen on managed_creator_posts.

Wallet money

The amount credited to and later withdrawn from the creator's wallet, recorded separately in creator_transactions.

Conflating those four produces three classes of failure:

Semantic

The same field means different things to different people or code paths. base_pay is a pre-split deal total but is often interpreted as per-post pay.

Temporal

It is unclear whether changing a term should affect future posts, existing unpaid posts, or already-paid posts.

Execution

Multiple payment paths may apply different checks, or credit the same obligation more than once.

A better system makes ownership and timing explicit:

  • brand_campaigns tracks campaign status and client contract cycles;
  • jobs own versioned default terms;
  • each creator booked onto a job has one complete accepted agreement;
  • each post freezes those terms directly onto its existing post row;
  • all wallet credits go through one transactional, idempotent function.

Non-negotiable invariants

The migration must preserve the following:

  1. Existing post calculations do not change.
  2. managed_creator_posts.total_owed_cents remains the source of truth for what a post earned.
  3. creator_transactions remains the source of truth for wallet balance.
  4. Accepted agreements and frozen post terms are not silently mutated.
  5. Repeating the same payment request cannot create another credit.
  6. A normal payment cannot exceed the post's eligible outstanding amount.
II

Target design

The existing ownership spine keeps its shape. The design adds two explicit layers without replacing the existing post snapshot:

jobs job_pay_term_versions NEW managed_creators creator_pay_agreements NEW managed_creator_posts EXTENDED creator_transactions
The spine keeps its shape: two explicit layers are added, and the post row remains the per-post snapshot.

Job pay-term versions

A job should have immutable, effective-dated versions of its default pay terms. Creating a new version is required when the default changes; existing versions are never edited. This gives the system a clear answer to:

  • what terms were offered at a particular time;
  • which contract cycle produced those terms;
  • whether a change applies only to new bookings;
  • which default a creator agreement came from.

I prefer a versioned table hung off jobs over adding another mutable group of pricing fields directly to the job row. brand_campaigns may remain linked for provenance, but it should not be consulted when calculating creator pay.

Creator pay agreements

Each managed_creators relationship gets an explicit creator pay agreement. The agreement stores a complete set of resolved terms, not just partial overrides. If a creator has only a custom minimum-view threshold, the agreement still contains the final flat pay, CPM rate, bonus milestones, maximum pay and cap terms. Partial overrides may exist in the admin interface, but they are resolved when the agreement is accepted. This removes runtime fallback chains such as:

coalesce(
  creator_override,
  job_default,
  campaign_value,
  legacy_value
)

An agreement has an explicit lifecycle:

draft accepted superseded/ cancelled

Only an accepted agreement may be selected when a post is detected, and accepted agreements must not overlap for the same managed creator. Economic terms become immutable after acceptance; a change creates a new agreement revision.

The existing post remains the snapshot

I would not add a separate post snapshot table. managed_creator_posts is already the per-post snapshot and the source of truth for what the post owes. Splitting frozen inputs into another table would add another join and another place operators must inspect during payout debugging.

Added to the MCP row (new posts)
  • creator_pay_agreement_id
  • calculator version
  • pay model and currency
  • frozen CPM rate, if applicable
  • below-threshold pay
  • frozen bonus milestones
  • per-post maximum
  • snapshot timestamp
Existing fields keep holding
  • frozen per-post flat pay
  • minimum-view threshold
  • calculated pay
  • bonus pay
  • total owed
  • total paid
  • payment status

Legacy posts keep their existing columns and calculator behavior. The new fields remain NULL for them.

There is deliberately no derived terms hash. The post freezes the actual calculation inputs, and the agreement ID plus calculator version provide provenance; mutation is prevented by constraints, permissions and the protection triggers rather than detected after the fact. A hash would add canonicalization, ordering and versioning questions without adding protection.

Post creation and recalculation

When a post is detected:

  1. locate its managed_creators relationship;
  2. select exactly one accepted agreement active at the resolution time;
  3. fail closed if zero or multiple agreements match;
  4. copy all calculation inputs onto the MCP row;
  5. calculate the initial amount from those frozen values;
  6. never re-read the job, campaign, or agreement during later recalculation.

The active agreement uses a half-open interval; a NULL effective_to means the agreement is open-ended:

effective_from <= resolution_time < effective_to
Working assumption: selection time

My working assumption is that the current rule remains: the agreement is selected using first detection time. This must be confirmed, because late-detected posts can cross a term-change boundary.

Per-post pricing

New terms store flat_pay_per_post_cents. Expected platform count is a delivery requirement, not a hidden divisor. The admin interface may show an estimated total commitment, but it should clearly label the primary value as pay per qualifying post.

Legacy compatibility agreements must preserve the current platform division and rounding behavior. Existing posts are not renamed, converted or reinterpreted.

The monthly-cap assumption

The contractual meaning of the monthly cap is not fully defined. For this proposal, I am making one explicit assumption:

Assumption: the monthly cap is a payout-rate limit, not an earnings cap

Under this assumption: the post records the full amount earned; the cap limits how much may be credited to the wallet in a contract month; any remaining amount stays outstanding; excess may be credited in a later month; and enforcement belongs in the wallet-credit function.

III

Illustrative SQL sketch

The following is directional rather than migration-ready.

create type pay_model · agreement_status · agreement_origin
create type pay_model as enum (
  'flat',
  'cpm',
  'flat_plus_cpm'
);

create type agreement_status as enum (
  'draft',
  'accepted',
  'superseded',
  'cancelled'
);

create type agreement_origin as enum (
  'job_default',
  'creator_override',
  'legacy_compatibility'
);

Versioned job defaults

create table job_pay_term_versions
create table job_pay_term_versions (
  id uuid primary key default gen_random_uuid(),
  job_id bigint not null references jobs(id),
  version integer not null,

  pay_model pay_model not null,
  currency_code text not null,

  flat_pay_per_post_cents bigint,
  cpm_cents_per_1k_views numeric(18, 6),

  min_views_threshold bigint,
  below_min_views_pay_cents bigint not null default 0,

  max_pay_per_post_cents bigint,
  bonus_milestones jsonb not null default '[]',

  monthly_payout_cap_cents bigint,
  payout_cap_timezone text,

  effective_from timestamptz not null,
  campaign_cycle_id bigint,

  published_at timestamptz not null,
  created_by uuid not null,
  change_reason text not null,

  unique (job_id, version),

  check (
    (pay_model = 'flat'
      and flat_pay_per_post_cents is not null
      and cpm_cents_per_1k_views is null)
    or
    (pay_model = 'cpm'
      and flat_pay_per_post_cents is null
      and cpm_cents_per_1k_views is not null)
    or
    (pay_model = 'flat_plus_cpm'
      and flat_pay_per_post_cents is not null
      and cpm_cents_per_1k_views is not null)
  )
);

Rows become immutable after publication. Corrections create a new version.

Creator agreements

create table creator_pay_agreements
create table creator_pay_agreements (
  id uuid primary key default gen_random_uuid(),
  managed_creator_id bigint not null
    references managed_creators(id),

  revision integer not null,
  source_job_pay_term_version_id uuid
    references job_pay_term_versions(id),

  origin agreement_origin not null,
  status agreement_status not null default 'draft',

  pay_model pay_model not null,
  currency_code text not null,

  flat_pay_per_post_cents bigint,
  cpm_cents_per_1k_views numeric(18, 6),

  min_views_threshold bigint,
  below_min_views_pay_cents bigint not null default 0,

  max_pay_per_post_cents bigint,
  bonus_milestones jsonb not null default '[]',

  monthly_payout_cap_cents bigint,
  payout_cap_timezone text,

  effective_from timestamptz not null,
  effective_to timestamptz,

  accepted_at timestamptz,
  superseded_at timestamptz,
  cancelled_at timestamptz,

  created_by uuid not null,
  change_reason text not null,

  unique (managed_creator_id, revision),

  check (
    effective_to is null
    or effective_to > effective_from
  ),

  check (
    status <> 'accepted'
    or accepted_at is not null
  ),

  check (
    status <> 'superseded'
    or superseded_at is not null
  ),

  check (
    status <> 'cancelled'
    or cancelled_at is not null
  )
);

Accepted agreement ranges must not overlap. This could be enforced with an exclusion constraint or through a controlled acceptance function using a creator-scoped lock.

Extend the existing MCP snapshot

alter table managed_creator_posts
alter table managed_creator_posts
  add column creator_pay_agreement_id uuid
    references creator_pay_agreements(id),
  add column calculator_version smallint,
  add column pay_model pay_model,
  add column currency_code text,
  add column cpm_cents_per_1k_views numeric(18, 6),
  add column below_min_views_pay_cents bigint,
  add column max_pay_per_post_cents bigint,
  add column bonus_milestones_snapshot jsonb,
  add column pay_snapshot_created_at timestamptz;

These fields remain nullable for historical posts.

Payment idempotency

There is deliberately no separate payment-events table. The requirements — prevent duplicate wallet credits, link a credit to its post, make both payment paths idempotent — are met directly on creator_transactions:

alter table creator_transactions
alter table creator_transactions
  add column managed_creator_post_id bigint
    references managed_creator_posts(id),
  add column source_system text,
  add column idempotency_key text;

create unique index creator_transactions_idempotency_idx
  on creator_transactions(source_system, idempotency_key)
  where idempotency_key is not null;

A separate creator_payment_events table would be justified only if one payment event can create multiple transactions, events need a lifecycle before ledger insertion, failed payment attempts must be retained, or the transactions table cannot safely carry idempotency metadata. Without evidence of one of those needs, the extra table is unnecessary abstraction. Exceptional corrections still use a separate adjustment flow and compensating ledger entries.

Core functions

The load-bearing behavior is centralized in narrow functions:

publish_job_pay_terms(...)
accept_creator_pay_agreement(...)
snapshot_post_pay_v2(post_id)
calculate_post_pay_v2(post_id, view_count)
recalculate_post_pay(post_id)
credit_post_payment(...)

credit_post_payment should:

  1. lock the post;
  2. recalculate from frozen fields;
  3. check eligibility;
  4. calculate the unpaid amount;
  5. conditionally apply the payout-rate cap;
  6. insert the wallet transaction with its source_system and idempotency_key — the unique index turns a replay into a no-op;
  7. update total_paid_cents;
  8. update payment status;
  9. commit atomically.
One door to the wallet

Both existing payment paths must call this function.

IV

Migration strategy

  • Phase 0: establish current behavior. Before DDL, inventory every reader and writer of the existing fields: application code; SQL functions and triggers; dynamic SQL and literal column names; views and RLS policies; cron jobs and webhooks; edge functions; mobile code; contract generation; support-bot payloads; both payment paths; and direct writes to protected post and wallet fields.

    Create characterization tests covering: NULL versus zero; current source precedence; creator overrides; legacy CPM jobs; platform division; rounding; minimum-view boundaries; bonus milestones; maximum-pay boundaries; and late detections.

    Create a row-level baseline for protected historical fields. Existing reconciliation differences are recorded as known exceptions rather than silently corrected.

  • Phase 1: unify the payment boundary. Introduce the new idempotent payment function while preserving current pricing behavior. Monthly-cap logic initially runs only in dry-run mode. Move payment paths one at a time: compare the function against current behavior; migrate the lower-risk path; observe through a payout cycle; migrate the second path; revoke direct writes after all callers have moved. This phase addresses inconsistent payment execution independently of the pricing migration.

  • Phase 2: additive schema expansion. Create the new tables and nullable MCP columns. The migration contains no large backfill, no rename, no column drop, and no historical post update. Any protection trigger containing literal column names is updated in the same transaction. The migration ends with:

    notify pgrst, 'reload schema';
  • Phase 3: dual-write and compatibility backfill. Enable dual-write before backfilling: new or edited jobs write both legacy fields and job-term versions; new or edited creator relationships write both legacy fields and creator agreements; legacy remains authoritative.

    Backfill the 100,000+ managed creators with an out-of-band restartable worker, not a migration. The worker: processes stable primary-key ranges; commits every 1,000–5,000 rows; preserves exact legacy precedence and rounding; stores a source fingerprint; uses idempotent inserts; supports retries and catch-up passes; and never updates historical posts.

  • Phase 4: production shadow calculation. For new posts, the legacy path stays authoritative while version two calculates a candidate result. Record field-level and cent-level differences without changing owed or paid fields. Require cent-for-cent parity for legacy-compatible agreements across: default and overridden terms; multiple platforms; indivisible totals; thresholds and milestones; maximum-pay rules; legacy CPM jobs; null and zero cases; and late detection.

    Production shadowing is required because staging contains less data and may not contain the real combination of pricing states.

  • Phase 5: canary new posts. Enable version two by job: internal jobs first, then low-volume jobs, then jobs without overrides, then progressively more complex cohorts. Only newly detected posts use version two; existing posts remain permanently on the legacy calculator unless a later migration proves conversion is necessary.

    The rollout has job-level kill switches for:

    v2 snapshotting wallet credit cap enforcement agreement backfill

  • Phase 6: authoritative cutover and later cleanup. After one full payout cycle with clean reconciliation: stop reading creator pay from brand_campaigns; stop reading legacy pricing for version-two jobs; move admin, mobile, support and contract-generation reads; stop dual-writing deprecated pricing fields.

    Removing cpm_submissions and legacy columns is a separate project, after all database and application dependencies have been proven absent.

V

Risks and rollback

RiskControl
Brief and TG bot disagree on the snapshot modelEmpirical Phase 0 resolution (flagged at the end of this page)
Two payment paths double-creditShared idempotent payment function
Concurrent payments over-creditLock and recheck the post transactionally
Old posts use the new calculatorExplicit calculator-version dispatch
Backfill misses new recordsDual-write first and run catch-up passes

Rollback

Rollback is primarily a feature-flag operation. If version two is suspect: disable it for future posts; pause payment for affected jobs; leave existing version-two snapshots intact; inspect the agreement and frozen values.

Financial records are never destructively rolled back

An incorrect wallet credit is corrected through an approved compensating transaction linked to the original transaction.

VI

Positions and open questions

Where should pay terms live?

Job defaults belong in versioned structures owned by the job. Specific agreed terms belong in complete creator agreements owned by the creator-job relationship. brand_campaigns retains campaign status and client-cycle concerns but is not part of payment resolution.

Per-post or pre-split total?

New terms are per post. The current pre-split total is an implementation detail that creates recurring confusion. Legacy values retain their existing meaning and are converted through the exact current resolver, for future compatibility agreements only.

Enforce or delete the monthly cap?

Do not delete a term that appears in signed contracts. My current assumption is that it is a payout-rate limit, but that must be verified. If it is an earnings cap, the design must change before enforcement.

Remove CPM now?

No. It is still referenced by current readers and remains part of seeding behavior. Stop adding dependencies, migrate consumers, instrument usage, and remove it later.

What proves the migration is safe?

Dependency inventory; characterization tests; historical row-level baseline; restartable production-scale backfill; shadow cent parity; job-level canary; full payout-cycle reconciliation; unauthorized-write and concurrency tests; demonstrated kill switches.

The release condition is not merely that aggregate dollars match: existing protected post and wallet values must remain unchanged.

Eight questions before implementation

  1. Which field is actually used to pay a post: total_owed_cents or settlement_owed_cents?
  2. Does managed_creator_posts.pricing_config_id exist in production, and is it used for every new post?
  3. When creator pricing changes, are old posts always kept on the previous pricing configuration?
  4. Can an existing pricing_configs row be edited, or must every pricing change create a new row?
  5. Which timestamp decides a post's pricing: publication time or detection time?
  6. What is the exact priority between creator override, job default and campaign pricing?
  7. Do both payment paths use the same field and calculation before crediting the wallet?
  8. What does the monthly cap mean today: an earnings cap or only a limit on monthly payout?
VII

Campaign cycles: provenance, not resolution

For the upcoming requirement: a single campaign will run in successive contract periods ("campaign cycles"), and pay terms can differ from one period to the next. Nothing about cycles is built into pay resolution yet. Treat campaign cycles as provenance and scheduling context, not as the place where creator pay is resolved.

brand_campaigns campaign_cycles NEW job_pay_term_versions creator_pay_agreements managed_creator_posts
The cycle sits above the pay-term version as provenance; resolution still flows through one accepted agreement onto the post.

How it would work

Each contract period gets a campaign_cycles row, and a job pay-term version may reference the cycle that produced its terms (the campaign_cycle_id column already reserved in the §III sketch becomes this reference):

create table campaign_cycles · alter job_pay_term_versions
create table campaign_cycles (
  id uuid primary key,
  brand_campaign_id bigint not null references brand_campaigns(id),
  cycle_number integer not null,
  starts_at timestamptz not null,
  ends_at timestamptz,
  status text not null,
  unique (brand_campaign_id, cycle_number)
);

alter table job_pay_term_versions
  add column campaign_cycle_id uuid
    references campaign_cycles(id);

When a new cycle starts and pricing changes:

  1. create a new campaign_cycles row;
  2. create a new job_pay_term_versions row linked to that cycle;
  3. create new creator-agreement revisions for creators whose pricing changes;
  4. give those agreements an effective_from matching the cycle boundary;
  5. existing posts remain frozen to their previous agreement and pricing.
CyclePeriodJob defaultAgreement
1Jan–Mar$10,000 per postV1
2Apr–Jun$12,000 per postV2

A post detected March 28 resolves agreement V1 and freezes $10,000; a post detected April 3 resolves agreement V2 and freezes $12,000. The cycle itself is never queried during post recalculation: it explains why a pricing version exists, but the post still resolves exactly one creator agreement and freezes its terms.

Design decision: cycles must not auto-migrate creators

Do not assume that every creator automatically moves to the new cycle's pricing. When a new cycle starts, the system should support: applying the new default only to newly booked creators; creating new agreements for selected existing creators; keeping some creators on their old agreement; and applying creator-specific overrides within the new cycle. That decision should be explicit during publishing rather than happening automatically.

Unresolved: posts that cross a cycle boundary

A clear rule is needed for posts published in Cycle 1 but detected in Cycle 2, and posts detected in Cycle 1 but approved in Cycle 2. Keep the existing rule and use first detection time, unless contracts say payment is determined by publication time. The cycle must not independently decide pricing; it only helps select or explain the applicable effective-dated agreement.

Position

Model campaign cycles as brand-level contract periods, referenced by job pay-term versions for provenance. A new cycle may create a new job default and new creator-agreement revisions, but payment resolution continues to select one effective creator agreement and freeze it onto the post. Never reprice existing posts merely because the campaign enters a new cycle.


VIII

Recommendation

I would ship this as two independently reversible improvements:

  1. Establish one transactional and idempotent payment-credit boundary while preserving current pricing behavior.
  2. Replace implicit pricing resolution for future posts with versioned job defaults, complete accepted creator agreements, frozen terms on the existing MCP row, and versioned calculation logic.

Historical posts remain untouched.

Sequencing note

Monthly-cap enforcement, deprecated CPM removal and broader schema cleanup proceed only after their semantics and dependencies are independently confirmed.

Response to the payments-schema brief · If any premise above reads wrong, that disagreement is worth having before any DDL ships.

⚑ Material discrepancy: the brief and the TG bot differ

Pointing this out rather than resolving it here: the take-home brief and the TG support bot describe two different snapshot and payment models. This does not necessarily mean the system is broken; it means the bot describes a schema and payment model the brief never mentions. Both cannot be authoritative for payout.

What the brief says
  • resolved numbers are frozen directly onto managed_creator_posts;
  • recalculation reads those frozen MCP fields;
  • total_owed_cents is authoritative for what the post earned.
What the TG bot says
  • MCP pins a pricing_config_id;
  • recalculation follows that pinned configuration;
  • settlement_owed_cents is authoritative for payout;
  • total_owed_cents may be a live-pricing diagnostic.

It touches this proposal in four places: invariant 2 (§I) assumes total_owed_cents is what the post earned; §II rests on the premise that the MCP row is already the per-post snapshot; credit_post_payment (§III) must read whichever column is truly owed and update whichever is truly paid; and the Phase 0 row-level baseline (§IV) must protect the column that is actually settlement-authoritative.

Resolution is empirical and belongs in Phase 0: check whether pricing_config_id and settlement_owed_cents exist in the live schema; read the recalculation path to see whether it reads frozen MCP scalars or follows a pinned configuration; diff total_owed_cents against settlement_owed_cents on recently paid posts; and trace which column each payment path actually pays from. Whichever account survives that check is the one the rest of this proposal should be read against, and the TG bot's payload source joins the Phase 0 dependency inventory either way.