Market Development Funds (MDF) are budget allocations a partner can request from Matters.AI to co-fund a go-to-market campaign — an event, a digital push, content production, or a joint co-marketing motion. The Partners Portal turns that into a structured workflow: a partner org admin submits a request, an internal reviewer approves (optionally at a reduced amount) or rejects it, and once approved the reviewer marks it claimed when the funds are drawn down.
Everything below is backed by real code:
apps/partners/lib/actions/mdf.tsapps/partners/lib/actions/admin.tsapps/partners/app/(portal)/mdf/apps/partners/app/(admin)/admin/mdf/mdf_requests table — apps/partners/lib/db/schema.tspartner_tiersapps/partners/app/api/upload/mdf-attachment/route.tsmdf_requests table#MDF state lives in one Neon Postgres table, mdf_requests. It is the only table in the schema that uses a classic serial integer primary key (everything else is uuid), which is why request IDs surface to users as small integers like #42.
export const mdfRequests = pgTable("mdf_requests", {
id: serial("id").primaryKey(),
clerkOrgId: text("clerk_org_id").notNull(),
submittedByClerkUserId: text("submitted_by_clerk_user_id").notNull(),
sfAccountId: text("sf_account_id").notNull().default(""),
// Campaign details
campaignName: text("campaign_name").notNull(),
campaignType: text("campaign_type").notNull(), // event | digital | content | co-marketing | other
campaignStartDate: timestamp("campaign_start_date").notNull(),
campaignEndDate: timestamp("campaign_end_date").notNull(),
description: text("description").notNull(),
expectedLeads: integer("expected_leads"),
expectedPipeline: integer("expected_pipeline_usd"),
// Budget
requestedAmountUsd: integer("requested_amount_usd").notNull(),
// Supporting docs
attachmentR2Keys: text("attachment_r2_keys").array().notNull().default(sql`'{}'::text[]`),
// Status
status: text("status").notNull().default("submitted"),
reviewNotes: text("review_notes"),
approvedAmountUsd: integer("approved_amount_usd"),
approvedAt: timestamp("approved_at"),
claimedAt: timestamp("claimed_at"),
// Timestamps
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
| Column | Type | Set when | Notes |
|---|---|---|---|
id | serial PK | insert | Small integer; shown to users as #<id>. |
clerk_org_id | text notNull | submit | The partner org the request belongs to; every read is scoped to it. |
submitted_by_clerk_user_id | text notNull | submit | The org admin who filed the request. |
sf_account_id | text notNull default "" | submit | Copied from the session's Salesforce account; "" if none is linked. |
campaign_name | text notNull | submit | Free text, min 3 chars. |
campaign_type | text notNull | submit | One of event, digital, content, co-marketing, other. |
campaign_start_date | timestamp notNull | submit | Parsed from the form date input. |
campaign_end_date | timestamp notNull | submit | Must be strictly after the start date. |
description | text notNull | submit | Min 20 chars. |
expected_leads | integer nullable | submit | Optional forecast. |
expected_pipeline_usd | integer nullable | submit | Optional forecast (note the column name is expected_pipeline_usd, field key expectedPipeline). |
requested_amount_usd | integer notNull | submit | Positive integer USD. |
attachment_r2_keys | text[] notNull default {} | submit | R2 object keys, each under the mdf/ prefix. |
status | text notNull default submitted | lifecycle | See the lifecycle below. |
review_notes | text nullable | approve / reject | Reviewer's approval note or rejection reason. |
approved_amount_usd | integer nullable | approve | The funded amount; ≤ requested_amount_usd. |
approved_at | timestamp nullable | approve | Stamped only on approval. |
claimed_at | timestamp nullable | claim | Stamped only on "Mark Claimed". |
created_at / updated_at | timestamp notNull | insert / every write | updated_at is bumped on every status transition. |
The table's full place in the relational model — and how clerk_org_id, sf_account_id, and the partner_tiers.slug link up — is documented in Data Model.
There are exactly five statuses. The column defaults to submitted on insert; every later value is written by an admin action.
submitted ──▶ approved ──▶ claimed
│
└───────▶ rejected
| Status | Meaning | Set by |
|---|---|---|
submitted | Filed by the partner, awaiting triage. This is the insert default. | submitMdfRequest |
under_review | A reviewer has picked it up but not yet decided. | (reserved transitional state — see note) |
approved | Funded; approved_amount_usd + approved_at are set. | approveMdfRequest |
rejected | Declined; review_notes carries the reason. Terminal. | rejectMdfRequest |
claimed | Funds drawn down; claimed_at is set. Terminal. | claimMdfRequest |
Two important truths from the code:
submitted is the only value the insert ever writes. under_review exists in the schema vocabulary and in both the partner and admin label/badge maps, and the admin board groups it alongside submitted under "Needs Review" — but no action in mdf.ts or admin.ts writes under_review today. It is a reserved status the review UI is already wired to handle.approved, rejected, and claimed are terminal. The reviewer can only act on a request while it is in an actionable state.The set of statuses an admin may still decide on is a single constant in admin.ts:
const MDF_ACTIONABLE_STATUSES = ["submitted", "under_review"] as const;
Both approveMdfRequest and rejectMdfRequest enforce this in two places — defence in depth against a double-click or a concurrent reviewer:
select the row first and reject early if its status is not in MDF_ACTIONABLE_STATUSES ("This request has already been actioned.").UPDATE itself re-asserts the precondition in its WHERE clause with inArray(mdfRequests.status, [...MDF_ACTIONABLE_STATUSES]), then checks .returning(). If a concurrent transition already moved the row, zero rows come back and the action returns the same "already been actioned" error — so a partner notification fires at most once and an approve-after-reject (or vice-versa) can never both land.claimMdfRequest uses the same pattern but pinned to a single status: it only transitions a row where status = "approved", and re-asserts eq(mdfRequests.status, "approved") in the WHERE. A request can therefore be claimed at most once.
The MDF page lives at /mdf in the portal. It reads the org's 20 most recent requests, renders a summary stat band and a status table, and — only for org admins — shows the new-request form.
Submission is gated twice:
<MdfRequestForm> when session.orgRole === "org:admin"; non-admins instead see a notice: "Only organisation admins can submit MDF requests. Contact your admin to proceed."submitMdfRequest independently re-checks session.orgRole !== "org:admin" and returns a _form error, so a non-admin cannot submit even by replaying the request.Every request begins with requirePartnerSession(), so an unauthenticated or non-partner caller never reaches the action body.
The form posts a FormData payload that is validated by MdfSchema in mdf.ts:
const MdfSchema = z
.object({
campaignName: z.string().min(3, "Campaign name required"),
campaignType: z.enum(["event", "digital", "content", "co-marketing", "other"]),
campaignStartDate: z.string().min(1, "Start date required"),
campaignEndDate: z.string().min(1, "End date required"),
description: z.string().min(20, "Please describe the campaign (min 20 chars)"),
expectedLeads: z.coerce.number().int().nonnegative().optional(),
expectedPipeline: z.coerce.number().int().nonnegative().optional(),
requestedAmountUsd: z.coerce.number().int().positive("Amount must be positive"),
attachmentR2Keys: z.array(z.string()).default([]),
})
.refine((d) => new Date(d.campaignEndDate) > new Date(d.campaignStartDate), {
message: "End date must be after start date",
path: ["campaignEndDate"],
});
Field (name) | UI control | Rule | Required |
|---|---|---|---|
campaignName | text | min 3 chars | Yes |
campaignType | select | one of event / digital / content / co-marketing / other | Yes |
requestedAmountUsd | number (min=1) | coerced int, must be positive | Yes |
campaignStartDate | date | non-empty; must be before end date | Yes |
campaignEndDate | date | non-empty; strictly after start date | Yes |
description | textarea (minLength=20) | min 20 chars | Yes |
expectedLeads | number (min=0) | coerced non-negative int | No |
expectedPipeline | number (min=0) | coerced non-negative int | No |
attachmentR2Keys | hidden inputs | array of R2 keys, each under mdf/ | No |
The dropdown options come from the exported CAMPAIGN_TYPES constant, so the select and the Zod enum can never drift:
export const CAMPAIGN_TYPES = [
{ value: "event", label: "Event / Conference" },
{ value: "digital", label: "Digital Marketing" },
{ value: "content", label: "Content Creation" },
{ value: "co-marketing", label: "Co-Marketing Campaign" },
{ value: "other", label: "Other" },
] as const;
On safeParse failure the action returns parsed.error.flatten().fieldErrors, which the form renders inline per field (<FieldError>). The HTML max and minLength attributes are convenience-only — the server-side Zod schema is the authority.
On success the action inserts a row with status: "submitted", copies clerkOrgId, submittedByClerkUserId, and sfAccountId from the session, stamps createdAt/updatedAt, records the audit event, calls revalidatePath("/mdf"), and returns { success: true, requestId }. The form then shows a confirmation card: "Your MDF request #<id> has been submitted. Your partner manager will review it within 3–5 business days."
Supporting documents (a budget sheet, an event prospectus) are uploaded directly from the browser to Cloudflare R2 via a presigned PUT. The server action never touches the file bytes — it only stores the resulting object keys.
The flow, driven by handleFileAdd in MdfRequestForm.tsx:
The browser POSTs { contentType, size, filename } (JSON) to POST /api/upload/mdf-attachment.
The route (route.ts) requires a partner session, rate-limits per org via uploadLimiter().limit(orgId) (returns 429 when exceeded), and validates the file:
application/pdf, image/jpeg, image/png, DOCX (…wordprocessingml.document), XLSX (…spreadsheetml.sheet).10 * 1024 * 1024 bytes (10 MB).application/json (415 otherwise).The route derives the extension server-side from the MIME type (never the client filename) and builds the key:
const key = `mdf/${orgId}/${randomUUID()}.${ext}`;
It returns a presigned PutObjectCommand URL (expiresIn: 300 — 5 minutes). The browser PUTs the raw file to that URL, then appends the returned key as a hidden attachmentR2Keys input.
When the form is submitted, the action applies a prefix guard before Zod — every key must start with mdf/, otherwise it rejects with "Invalid attachment keys." This stops a tampered payload from pointing at objects outside the MDF namespace:
const attachmentKeys = formData.getAll("attachmentR2Keys").filter(Boolean) as string[];
if (attachmentKeys.some((k) => !k.startsWith("mdf/"))) {
return { errors: { _form: ["Invalid attachment keys."] } };
}
In the admin review row the stored keys render as download links against NEXT_PUBLIC_R2_PUBLIC_URL (default https://partners-assets.matters.ai), so a reviewer opens each attachment directly: ${r2Url}/${key}.
The internal review surface lives at /admin/mdf. It loads the 100 most recent requests across all orgs (joined to partner_profiles for the company name) and splits them into three buckets:
submitted or under_review, rendered as full MdfAdminRow cards.approved, also rendered as MdfAdminRow cards so the "Mark Claimed" action is reachable.rejected, claimed), shown as a compact read-only table.The header summarises <n> pending review · $<k>k requested · $<k>k approved.
The approve form (on each MdfAdminRow) posts requestId, an approvedAmount, and optional notes to approveMdfRequest. The amount input defaults to the full requestedAmountUsd and carries a client-side max={request.requestedAmountUsd} — but that ceiling is re-enforced on the server:
if (parsed.data.approvedAmount != null && parsed.data.approvedAmount > existing.requestedAmountUsd)
return { error: "Approved amount cannot exceed the requested amount." };
So a reviewer can fund less than requested, but never more. On success the action sets status: "approved", approvedAmountUsd, approvedAt, reviewNotes, fires an mdf_status notification to the org ("Your MDF request "…" has been approved…"), records the audit event, and revalidates /admin/mdf.
The reject form requires a rejection reason (notes). rejectMdfRequest sets status: "rejected" and reviewNotes, sends an mdf_status rejection notification, and audits — all behind the same actionable-status guard so it only ever fires once.
Once a request is approved, the same card shows a Mark Claimed button (a StatefulButton). claimMdfRequest transitions approved → claimed, stamps claimedAt, and records mdf.claimed. It refuses any non-approved row with "Only approved requests can be claimed." Claiming is the operational signal that the funds were actually drawn down against the approved amount.
The review board is reachable by internal staff, but acting on a request is write-gated. There are two distinct gates at play:
/admin/mdf calls requireInternalReadSession(). Read-tier internal accounts can open the board and see every request's full detail (campaign, amounts, description, attachments).canWrite = can(accountType, "admin.write") and passes it down as the canWrite prop to every MdfAdminRow. When canWrite is false the row renders all the data but hides the approve / reject / claim controls entirely (canReview and canClaim both require canWrite).On the server side this is belt-and-suspenders: all three mutating actions begin with requireAdminWriteSession(), so even a forged form post from a read-tier account is rejected before any DB write. A read-only internal reviewer therefore has full visibility and zero mutation power.
Partner-side, submission is restricted to org:admin (see above). The full account-type matrix and the requireInternalReadSession / requireAdminWriteSession gate tiers are documented in RBAC.
partner_tiers#Each partner tier carries an MDF budget ceiling, stored as mdf_quarterly_limit_usd on the partner_tiers catalog table:
export const partnerTiers = pgTable("partner_tiers", {
// …
slug: text("slug").notNull().unique(), // registered | silver | gold
displayName: text("display_name").notNull(),
arrRange: text("arr_range"), // admin-entered, not computed
mdfQuarterlyLimitUsd: integer("mdf_quarterly_limit_usd"),
dealProtection: text("deal_protection"),
// …
});
The canonical tier vocabulary is registered < silver < gold (see apps/partners/lib/tiers.ts — TIER_RANK / TIER_SLUGS), synced read-only from Salesforce's Partner_Tier__c. A partner's tier governs the quarterly MDF allowance their org is expected to stay within across approved requests; the figure is admin-entered per tier, not derived. Tiers, their benefits, and the enablement content gated by them are covered in Enablement & Tiers.
Note:
mdf_quarterly_limit_usdis a catalog/reference value describing each tier's allowance. The approve action enforces the per-request "≤ requested amount" bound in code; the quarterly ceiling is a tier-level budgeting figure rather than a hard per-request runtime gate.
Every MDF state change writes an immutable entry via recordActivity, viewable in the Audit Log. Three actions are emitted:
action | Emitted by | target_type / target_id | Summary |
|---|---|---|---|
mdf.submitted | submitMdfRequest | mdf / request id | MDF request: <campaign> ($<amount>) |
mdf.status_changed | approveMdfRequest, rejectMdfRequest | mdf / request id | MDF request <id> (<campaign>) approved / … rejected |
mdf.claimed | claimMdfRequest | mdf / request id | MDF request <id> claimed |
Note the actor attribution: mdf.submitted records the partner (actorClerkUserId + actorEmail from the partner session), while the admin actions record actorClerkUserId: null with the internal reviewer's actorEmail — because internal staff act outside the partner org's user namespace. Both mdf.status_changed events carry structured metadata ({ status, approvedAmountUsd } on approve, { status } on reject). Crucially, each audit write happens only after .returning() confirms the row actually transitioned, so the log never records a state change that the transition guard blocked.
mdf_requests and partner_tiers tables in full relational context.org:admin submission gate, requireInternalReadSession vs requireAdminWriteSession, and the canWrite prop.mdf.submitted, mdf.status_changed, and mdf.claimed show up.