Ads & Revenue Share — Technical Reference
Where this lives
Backend
apps/backend/graphql/types/ad-revenue.type.js— all ad GraphQL types, inputs, queries and mutationsapps/backend/graphql/resolvers/ad-revenue.resolver.js— resolvers; admin ops gated byrequireSuperAdminapps/backend/managers/ad-managers/ad-revenue.manager.js— business logic: event recording, settlement, real-revenue ingestion, reporting, manual creditapps/backend/validators/ad.validator.js— event, period, settings and report-entry validationapps/backend/services/gam-report.service.js— Google Ad Manager Reporting adapter (credential-guarded integration point)apps/backend/data-access-services/ad/ad-placement.access-service.js— ad slotsapps/backend/data-access-services/ad/ad-event.access-service.js— impression/click events + aggregationapps/backend/data-access-services/ad/ad-revenue-ledger.access-service.js— per-creator/period ledger, period aggregationapps/backend/database/models/AdPlacement.js,AdEvent.js,AdRevenueLedger.js— Sequelize models- Migrations:
20260719000000-create-ad-revenue-system.js(tables),20260719010000-add-ads-enabled-to-user.js(ads_enabled),20260719020000-add-ad-config-to-system-setting.js(ad config columns)
Frontend (web)
apps/frontend-nextjs/src/components/ProfileAdSlot.tsx— the profile ad slot (AdSense / GAM-GPT / house placeholder) + impression/click reportingapps/frontend-nextjs/src/page-components/PublicProfilePage.tsx— renders<ProfileAdSlot profileUserId={user.id} />apps/frontend-nextjs/src/app/layout.tsx— site-wide AdSense loader (id="adsense-loader",strategy="beforeInteractive"), gated byNEXT_PUBLIC_ADSENSE_CLIENT. Required by AdSense's account-verification / Auto ads setup step ("paste this in<head>of every page"), independent of whether any profile actually has aprofileplacement + beta allowlist entry.next/scriptdedupes byid, so this doesn't double-load whenProfileAdSlot.tsxalso renders the same script on a profile page.
Frontend (admin)
apps/frontend-admin/src/app/ads/page.tsx— the/adssuper-admin page (settings, allowlist, placements, revenue, settlement, import, manual transfers)
Technical implementation checklist
-
profileAdsEnabled/activeAdPlacements(client) — drive whether and what the slot renders -
recordAdImpression/recordAdClick— viewer taken from auth context; revenue estimated server-side, never client-declared -
myAdEarnings— a creator's own monthly ledger -
adminAdSettings/adminUpdateAdSettings— read/update split, CPM, thresholds (validated 0–100 / non-negative) -
adminAdPlacements/adminCreateAdPlacement/adminUpdateAdPlacement— placement CRUD -
adminAdEnabledUsers/adminSetUserAdsEnabled— beta allowlist -
adminSettleAdRevenue— settle a period from the server-side estimate -
adminAdRevenueSummary/adminAdRevenueLedger— money generated + per-creator ledger -
adminIngestAdRevenueReport/adminImportAdRevenueFromGam— settle from real revenue (CSV / GAM) -
adminCreditUserCoins— manual coin transfer/adjustment - GAM Reporting API client wired inside
gam-report.service.js#fetchCreatorRevenue— implemented this session: real REST calls against the Ad Manager API (Beta) viagoogleapis'sGoogleAuth, not a stub. Still needs realGAM_SERVICE_ACCOUNT_KEY/GAM_NETWORK_CODEcredentials to go live in production — see the caveat below. - Site-wide AdSense loader in
app/layout.tsx— the<script>Google's AdSense setup flow asks you to paste into<head>of every page (account verification + Auto ads), added as abeforeInteractivenext/scriptgated byNEXT_PUBLIC_ADSENSE_CLIENT
GraphQL surface
Client schema (/web/graphql):
type Query {
activeAdPlacements: [AdPlacement!]!
profileAdsEnabled(userId: ID!): Boolean!
myAdEarnings(limit: Int, offset: Int): [AdRevenueLedgerEntry!]!
}
type Mutation {
recordAdImpression(input: RecordAdEventInput!): AdEventResult!
recordAdClick(input: RecordAdEventInput!): AdEventResult!
}
input RecordAdEventInput { placementId: ID!, profileUserId: ID! }
Admin schema (/admin/graphql, all admin-prefixed, super_admin only):
type Query {
adminAdSettings: AdSettings!
adminAdPlacements: [AdPlacement!]!
adminAdEnabledUsers: [AdBetaUser!]!
adminAdRevenueSummary(period: String!): AdRevenueSummary!
adminAdRevenueLedger(period: String!, limit: Int, offset: Int): [AdRevenueLedgerAdminEntry!]!
}
type Mutation {
adminUpdateAdSettings(input: AdSettingsInput!): AdSettings!
adminCreateAdPlacement(input: AdPlacementInput!): AdPlacement!
adminUpdateAdPlacement(id: ID!, input: AdPlacementUpdateInput!): AdPlacement!
adminSetUserAdsEnabled(userId: ID!, enabled: Boolean!): Boolean!
adminSettleAdRevenue(period: String!): AdSettlementResult!
adminIngestAdRevenueReport(period: String!, entries: [AdRevenueReportEntryInput!]!): AdSettlementResult!
adminImportAdRevenueFromGam(period: String!): AdSettlementResult!
adminCreditUserCoins(userId: ID!, amount: Int!, reason: String): CoinCreditResult!
}
Query/mutation routing between the two schemas is by name convention — fields matching /^admin[A-Z]/ are exposed only on /admin/graphql (see api/server.js#scopeSchemaToAdmin). That is why the admin panel uses adminAdPlacements rather than the client-only activeAdPlacements.
Configuration (system_setting row)
Ad config lives on typed columns of the single system_setting row (read via payout-settings.access-service.js, not the key/value settings manager). Money values are in micros.
| Column | Meaning | Default |
|---|---|---|
ads_enabled | Global on/off | true |
ads_creator_share_percent | Creator's cut of gross (0–100) | 55 |
ads_coins_per_currency_unit | Coins credited per 1 currency unit of creator share | 100 |
ads_min_credit_coins | Minimum coins before a creator is credited | 50 |
ads_impression_cpm_micros | Estimated revenue per 1,000 impressions (micros) | 0 |
ads_click_value_micros | Estimated revenue per click (micros) | 0 |
Edit these from /ads → Ad settings, or directly via adminUpdateAdSettings.
Data model
ad_placement (a slot) → ad_event (impressions/clicks, with server-estimated estimated_revenue_micros) → aggregated at settlement into ad_revenue_ledger (one row per creator per period: impressions, clicks, gross_revenue_micros, creator_share_micros, platform_share_micros, creator_share_coins, status, coin_transaction_id). The allowlist is the ads_enabled boolean on user.
Setup & operations
1. Apply the migrations
npm run migrate
npm run migrate:test # if you run the test database
This creates the ad tables, the ads_enabled user column, and the ad config columns on system_setting.
2. Choose an ad provider / payout model
- Creator fund (coins) — no external ad account required. Configure
ads_impression_cpm_micros/ads_click_value_micros, run monthly settlement, and creators are paid in coins. Simplest; works today. - Google AdSense — set
NEXT_PUBLIC_ADSENSE_CLIENT(e.g.ca-pub-…) infrontend-nextjs, and create a placement with provideradsensewhoseproviderSlotIdis the AdSensedata-ad-slot. Note: sharing AdSense earnings with third parties is against policy — use the coins/creator-fund model for the split. Setting this variable also activates the site-wide loader inlayout.tsx(see below) — AdSense account verification requires that regardless of whether the profile ad slot / revenue-share is used at all. - Google Ad Manager + MCM — the compliant per-creator revenue route. Set
NEXT_PUBLIC_GAM_NETWORK_CODEinfrontend-nextjs, create a placement with provideradmanagerwhoseproviderSlotIdis the GAM ad unit path. The slot tags each impression with acreatorkey-value = the profile owner's id, so GAM reporting attributes revenue per creator.
3. Configure the placement and beta users
In /ads:
- Placements → create an active placement with
surface: profile(ads only render when an activeprofileplacement exists). - Ad settings → set the split and CPM/click values.
- Beta allowlist → search creators by username and add them.
4. Environment variables
| Var | App | Purpose |
|---|---|---|
NEXT_PUBLIC_ADSENSE_CLIENT | frontend-nextjs | AdSense publisher id; enables AdSense rendering on the profile slot and the site-wide verification/Auto-ads loader in app/layout.tsx |
NEXT_PUBLIC_GAM_NETWORK_CODE | frontend-nextjs | GAM network code; enables GPT rendering |
GAM_NETWORK_CODE | backend | GAM network code for the Reporting API |
GAM_SERVICE_ACCOUNT_KEY | backend | Service-account key (path or JSON) for the Reporting API |
GAM_CREATOR_KEY | backend | Custom targeting key name (default creator) |
Without the NEXT_PUBLIC_* vars the slot shows a neutral house placeholder. Without the backend GAM_* vars, adminImportAdRevenueFromGam returns a catchable gam.not_configured and you use CSV import instead.
5. Settle a period
- Estimated:
/ads→ Monthly settlement → enterYYYY-MM→ Run settlement. Aggregates events, splits, credits coins. - Real (CSV): export a GAM report dimensioned by the
creatorkey-value with revenue (micros), impressions and clicks. In/ads→ Import real revenue, paste rowsidentifier, grossMicros[, impressions, clicks](identifier is auto-detected as a user id when it's a UUID, else a username) → Ingest CSV & settle. - Real (API): once
GAM_*is set and the Reporting client is wired ingam-report.service.js#fetchCreatorRevenue, use Import from Google Ad Manager to pull and settle in one click.
Settlement is idempotent per period: creators already credited are skipped. Unresolved report rows are counted as skipped.
6. View money & pay out manually
/ads→ Revenue → pick a period to see gross / platform / creator totals (currency units) and a per-creator ledger. Each row's Transfer coins opens a modal prefilled with the computed creator-share coins — the amount is editable./ads→ Manual coin transfer → search any user and transfer an arbitrary coin amount with a reason. Both go through the normal wallet (transactionType: 'reward',relatedType: 'admin_manual') and feed the cashout flow.
The GAM Reporting API client
gam-report.service.js#fetchCreatorRevenue(period) is the single integration point, and is really implemented as of this session (previously a deliberate stub that threw gam.report_client_unavailable). There's no googleapis (npm) discovery client for Ad Manager — it only covers legacy ad products (adexchangebuyer, dfareporting, adsense, ...) — so this talks directly to the modern REST API (Ad Manager API, Beta, https://admanager.googleapis.com/v1), authenticated with google.auth.GoogleAuth from the already-installed googleapis package (the same service-account pattern used by services/iap/google-iap.service.js). The report itself is an async, job-based flow per the Beta API:
POST networks/{net}/reports— create the report definitionPOST networks/{net}/reports/{id}:run— start a run (long-running operation)GET networks/{net}/operations/reports/runs/{opId}— poll until doneGET networks/{net}/reports/{id}/results/{resultId}:fetchRows— paginated row fetch
dimensioned by the network's custom targeting key matching GAM_CREATOR_KEY (resolved to its numeric id via networks/{net}/customTargetingKeys, then passed as ekvDimensionKeyIds), with metrics AD_SERVER_ALL_REVENUE (gross revenue, already in micros), AD_SERVER_IMPRESSIONS and AD_SERVER_CLICKS. importFromGam then feeds the returned [{ creatorId, grossMicros, impressions, clicks }] rows straight into ingestRevenueReport, which reuses the split/credit logic — GAM money is already in micros, so no conversion is needed.
It still needs real GAM_SERVICE_ACCOUNT_KEY/GAM_NETWORK_CODE credentials to go live in production — scaffolded and unit-tested against mocks, not yet exercised against a live GAM network. The file's own header comment flags one honest caveat worth carrying over here: the Ad Manager API is in Beta and Google's reference docs did not fully enumerate every Dimension/Metric enum value at the time this was written. The EKV_0_VALUE dimension constant (paired with ekvDimensionKeyIds) and the field used to match a custom targeting key's human-readable name (checked as displayName/keyName/name) are the best-documented mapping available, but were not verified end-to-end against a live network. If the live API rejects a request, check the Ad Manager API reference for the current enum names and adjust the constants at the top of the file.
A read-only testConnection() (auth + custom-targeting-key resolution, no report actually run) backs the ads group's connectivity test on the Environment Status page.