diff --git a/packages/cloud/shared/src/lib/services/__tests__/ai-billing-anon-affiliate.test.ts b/packages/cloud/shared/src/lib/services/__tests__/ai-billing-anon-affiliate.test.ts index a2a47bcacdd94..ab311cf106892 100644 --- a/packages/cloud/shared/src/lib/services/__tests__/ai-billing-anon-affiliate.test.ts +++ b/packages/cloud/shared/src/lib/services/__tests__/ai-billing-anon-affiliate.test.ts @@ -27,13 +27,14 @@ mock.module("../../pricing", () => ({ calculateCost: mock(async () => ({ inputCost: 0.1, outputCost: 0.2, totalCost: 0.3 })), })); -// Active affiliate code (10% markup) owned by AFFILIATE_USER. +// Active affiliate code (10% markup) owned by AFFILIATE_USER by default. const AFFILIATE_USER = "00000000-0000-4000-8000-00000000aff1"; +let affiliateUserId = AFFILIATE_USER; mock.module("../../../db/repositories/affiliates", () => ({ affiliatesRepository: { getAffiliateCodeByCode: mock(async () => ({ id: "aff-code-1", - user_id: AFFILIATE_USER, + user_id: affiliateUserId, markup_percent: "10", is_active: true, })), @@ -46,6 +47,17 @@ mock.module("../redeemable-earnings", () => ({ redeemableEarningsService: { addEarnings }, })); +const reserve = mock(async (params: unknown) => ({ + reservedAmount: 0, + reservationTransactionId: "reservation-1", + reconcile: mock(async () => undefined), + params, +})); +mock.module("../credits", () => ({ + creditsService: { reserve }, + InsufficientCreditsError: class InsufficientCreditsError extends Error {}, +})); + // Side-effect writers billUsage calls — stub so the test needs no DB rows. mock.module("../usage", () => ({ usageService: { recordUsage: mock(async () => undefined), record: mock(async () => undefined) }, @@ -54,7 +66,7 @@ mock.module("../generations", () => ({ generationsService: { record: mock(async () => undefined), create: mock(async () => undefined) }, })); -const { billUsage } = await import("../ai-billing"); +const { billFlatUsage, billUsage, reserveCredits } = await import("../ai-billing"); const USAGE = { promptTokens: 1000, completionTokens: 500, totalTokens: 1500 }; const BASE = { @@ -65,7 +77,9 @@ const BASE = { }; beforeEach(() => { + affiliateUserId = AFFILIATE_USER; addEarnings.mockClear(); + reserve.mockClear(); }); describe("billUsage affiliate earnings guard (#10853)", () => { @@ -78,7 +92,7 @@ describe("billUsage affiliate earnings guard (#10853)", () => { expect(result.totalCost).toBeCloseTo(0.3, 6); }); - test("a real paying org with the same affiliate code STILL credits the affiliate (regression)", async () => { + test("a real paying org with collected settlement STILL credits the affiliate (regression)", async () => { const result = await billUsage( { ...BASE, organizationId: "00000000-0000-4000-8000-0000000000org" }, USAGE, @@ -97,6 +111,91 @@ describe("billUsage affiliate earnings guard (#10853)", () => { expect(result.totalCost).toBeCloseTo(0.3 + 0.03, 6); }); + test("paying org self-referral via request affiliate code is ignored", async () => { + affiliateUserId = BASE.userId; + + const result = await billUsage( + { ...BASE, organizationId: "00000000-0000-4000-8000-0000000000org" }, + USAGE, + ); + + expect(addEarnings).not.toHaveBeenCalled(); + expect(result.totalCost).toBeCloseTo(0.3, 6); + }); + + test("uncollectable overage does not mint affiliate earnings", async () => { + const reconcile = mock(async (actualCost: number) => ({ + reservedAmount: 0.3, + actualCost, + reservationTransactionId: "reservation-1", + settlementTransactionIds: [], + adjustmentType: "uncollected_overage" as const, + })); + + const result = await billUsage( + { ...BASE, organizationId: "00000000-0000-4000-8000-0000000000org" }, + USAGE, + { + reservedAmount: 0.3, + reservationTransactionId: "reservation-1", + reconcile, + }, + ); + + expect(result.totalCost).toBeCloseTo(0.33, 6); + expect(reconcile).toHaveBeenCalledWith(result.totalCost); + expect(addEarnings).not.toHaveBeenCalled(); + }); + + test("flat billing uncollectable overage does not mint affiliate earnings", async () => { + const reconcile = mock(async (actualCost: number) => ({ + reservedAmount: 1, + actualCost, + reservationTransactionId: "reservation-flat-1", + settlementTransactionIds: [], + adjustmentType: "uncollected_overage" as const, + })); + + const result = await billFlatUsage( + { ...BASE, organizationId: "00000000-0000-4000-8000-0000000000org" }, + { totalCost: 1, baseTotalCost: 1 / 1.2, platformMarkup: 1 - 1 / 1.2 }, + { + reservedAmount: 1, + reservationTransactionId: "reservation-flat-1", + reconcile, + }, + ); + + expect(result.totalCost).toBeCloseTo(1.1, 6); + expect(reconcile).toHaveBeenCalledWith(result.totalCost); + expect(addEarnings).not.toHaveBeenCalled(); + }); + + test("pre-request reservation includes affiliate markup so payout is backed upfront", async () => { + await reserveCredits( + { ...BASE, organizationId: "00000000-0000-4000-8000-0000000000org" }, + 1000, + 500, + ); + + expect(reserve).toHaveBeenCalledTimes(1); + const arg = reserve.mock.calls[0][0] as { estimatedCostMultiplier?: number }; + expect(arg.estimatedCostMultiplier).toBeCloseTo(1.1, 6); + }); + + test("self-referral does not inflate the pre-request reservation", async () => { + affiliateUserId = BASE.userId; + + await reserveCredits( + { ...BASE, organizationId: "00000000-0000-4000-8000-0000000000org" }, + 1000, + 500, + ); + + const arg = reserve.mock.calls[0][0] as { estimatedCostMultiplier?: number }; + expect(arg.estimatedCostMultiplier).toBeUndefined(); + }); + test("paying org affiliate earnings use deterministic request sourceId for dedupe", async () => { await billUsage( { diff --git a/packages/cloud/shared/src/lib/services/ai-billing.ts b/packages/cloud/shared/src/lib/services/ai-billing.ts index 7e321b1f20828..34888827956b3 100644 --- a/packages/cloud/shared/src/lib/services/ai-billing.ts +++ b/packages/cloud/shared/src/lib/services/ai-billing.ts @@ -22,7 +22,12 @@ import { } from "../pricing"; import { logger } from "../utils/logger"; import type { PricingBillingSource } from "./ai-pricing-definitions"; -import { type CreditReservation, creditsService, InsufficientCreditsError } from "./credits"; +import { + type CreditReconciliationResult, + type CreditReservation, + creditsService, + InsufficientCreditsError, +} from "./credits"; import { generationsService } from "./generations"; import { redeemableEarningsService } from "./redeemable-earnings"; import { usageService } from "./usage"; @@ -93,6 +98,49 @@ function getAffiliateEarningsSourceId( return `legacy_${crypto.randomUUID()}`; } +type AffiliateCodeRecord = NonNullable< + Awaited> +>; + +interface BillableAffiliate { + affiliate: AffiliateCodeRecord; + markupPercent: number; +} + +async function resolveBillableAffiliate(context: BillingContext): Promise { + if (!context.affiliateCode || context.organizationId === "anonymous") return null; + const affiliate = await affiliatesRepository.getAffiliateCodeByCode(context.affiliateCode); + if (!affiliate?.is_active) return null; + if (affiliate.user_id === context.userId) return null; + const markupPercent = Number(affiliate.markup_percent) / 100; + if (!Number.isFinite(markupPercent) || markupPercent <= 0) return null; + return { affiliate, markupPercent }; +} + +function collectedTotalCost( + totalCost: number, + reservation: CreditReservation | undefined, + reconciliation: CreditReconciliationResult | void | undefined, +): number { + if (!reservation || !reconciliation) return totalCost; + if (reconciliation.adjustmentType === "uncollected_overage") { + return Math.min(totalCost, reconciliation.reservedAmount); + } + return totalCost; +} + +function collectedAffiliateEarnings(params: { + nominalEarnings: number; + preAffiliateTotalCost: number; + totalCost: number; + reservation?: CreditReservation; + reconciliation?: CreditReconciliationResult | void; +}): number { + const collected = collectedTotalCost(params.totalCost, params.reservation, params.reconciliation); + const collectedMarkup = Math.max(0, collected - params.preAffiliateTotalCost); + return Math.min(params.nominalEarnings, collectedMarkup); +} + // ============================================================================ // Usage Normalization // ============================================================================ @@ -167,6 +215,7 @@ export async function reserveCredits( ): Promise { const provider = context.provider ?? getProviderFromModel(context.model); const normalizedModel = normalizeModelName(context.model); + const affiliate = await resolveBillableAffiliate(context); return await creditsService.reserve({ organizationId: context.organizationId, @@ -175,6 +224,7 @@ export async function reserveCredits( billingSource: context.billingSource, estimatedInputTokens, estimatedOutputTokens, + ...(affiliate && { estimatedCostMultiplier: 1 + affiliate.markupPercent }), userId: context.userId, description: context.description ?? `AI request: ${context.model}`, }); @@ -241,60 +291,21 @@ export async function billUsage( let baseTotalCost = totalCost / PLATFORM_MARKUP_MULTIPLIER; let platformMarkup = totalCost - baseTotalCost; - // Apply affiliate markup if present — but NOT for anonymous (free-tier) - // requests. An "anonymous" org pays $0 (its reservation is a no-op), so there - // is no collected affiliate revenue to share; minting affiliate earnings here - // would create cashable redeemable_earnings out of nothing — an org owner - // could farm their own affiliate code via free anon requests (#10853). Only - // credit the affiliate when the request is billed to a real paying org. - let _appliedAffiliateMarkup = false; - if (context.affiliateCode && context.organizationId !== "anonymous") { - const affiliate = await affiliatesRepository.getAffiliateCodeByCode(context.affiliateCode); - if (affiliate && affiliate.is_active) { - const markupPercent = Number(affiliate.markup_percent) / 100; - - // Calculate affiliate markup based on total cost (after platform markup) - const affiliateMarkupBaseCost = totalCost; - const affiliateEarnings = affiliateMarkupBaseCost * markupPercent; - - // Update total costs charged to the user - inputCost += inputCost * markupPercent; - outputCost += outputCost * markupPercent; - totalCost += affiliateEarnings; - _appliedAffiliateMarkup = true; - - // Credit the affiliate owner - if (affiliateEarnings > 0) { - const sourceId = getAffiliateEarningsSourceId(context, "usage"); - - await redeemableEarningsService - .addEarnings({ - userId: affiliate.user_id, - amount: affiliateEarnings, - source: "affiliate", - sourceId, - description: `Affiliate markup earnings from model: ${context.model}`, - metadata: { - appId: null, // this isn't from a specific miniapp, but from an affiliate SKU - model: context.model, - tokens: totalTokens, - }, - dedupeBySourceId: true, - }) - .catch((err) => { - logger.error("[AI Billing] Failed to add affiliate earnings", { - error: err instanceof Error ? err.message : String(err), - affiliateId: affiliate.id, - amount: affiliateEarnings, - }); - }); - } - } + const preAffiliateTotalCost = totalCost; + const affiliate = await resolveBillableAffiliate(context); + const affiliateEarnings = affiliate ? preAffiliateTotalCost * affiliate.markupPercent : 0; + + if (affiliateEarnings > 0) { + inputCost += inputCost * affiliate.markupPercent; + outputCost += outputCost * affiliate.markupPercent; + totalCost += affiliateEarnings; } - // Reconcile reservation (refund excess or charge overage) + // Reconcile reservation (refund excess or charge overage) before crediting any + // cashable affiliate earnings, so uncollectable overage cannot mint payouts. + let reconciliation: CreditReconciliationResult | void | undefined; if (reservation) { - await reservation.reconcile(totalCost); + reconciliation = await reservation.reconcile(totalCost); logger.info("[AI Billing] Credits reconciled", { model: context.model, reserved: reservation.reservedAmount, @@ -307,6 +318,42 @@ export async function billUsage( }); } + if (affiliate && affiliateEarnings > 0) { + const payableEarnings = collectedAffiliateEarnings({ + nominalEarnings: affiliateEarnings, + preAffiliateTotalCost, + totalCost, + reservation, + reconciliation, + }); + + if (payableEarnings > 0) { + const sourceId = getAffiliateEarningsSourceId(context, "usage"); + + await redeemableEarningsService + .addEarnings({ + userId: affiliate.affiliate.user_id, + amount: payableEarnings, + source: "affiliate", + sourceId, + description: `Affiliate markup earnings from model: ${context.model}`, + metadata: { + appId: null, + model: context.model, + tokens: totalTokens, + }, + dedupeBySourceId: true, + }) + .catch((err) => { + logger.error("[AI Billing] Failed to add affiliate earnings", { + error: err instanceof Error ? err.message : String(err), + affiliateId: affiliate.affiliate.id, + amount: payableEarnings, + }); + }); + } + } + return { inputCost, outputCost, @@ -334,45 +381,18 @@ export async function billFlatUsage( const outputCost = 0; const provider = context.provider ?? getProviderFromModel(context.model); - if (context.affiliateCode) { - const affiliate = await affiliatesRepository.getAffiliateCodeByCode(context.affiliateCode); - if (affiliate && affiliate.is_active) { - const markupPercent = Number(affiliate.markup_percent) / 100; - const affiliateEarnings = totalCost * markupPercent; - totalCost += affiliateEarnings; - inputCost = totalCost; - - if (affiliateEarnings > 0) { - const sourceId = getAffiliateEarningsSourceId(context, "flat"); - - await redeemableEarningsService - .addEarnings({ - userId: affiliate.user_id, - amount: affiliateEarnings, - source: "affiliate", - sourceId, - description: `Affiliate markup earnings from model: ${context.model}`, - metadata: { - appId: null, - model: context.model, - provider, - flatOperation: true, - }, - dedupeBySourceId: true, - }) - .catch((err) => { - logger.error("[AI Billing] Failed to add flat-operation affiliate earnings", { - error: err instanceof Error ? err.message : String(err), - affiliateId: affiliate.id, - amount: affiliateEarnings, - }); - }); - } - } + const preAffiliateTotalCost = totalCost; + const affiliate = await resolveBillableAffiliate(context); + const affiliateEarnings = affiliate ? preAffiliateTotalCost * affiliate.markupPercent : 0; + + if (affiliateEarnings > 0) { + totalCost += affiliateEarnings; + inputCost = totalCost; } + let reconciliation: CreditReconciliationResult | void | undefined; if (reservation) { - await reservation.reconcile(totalCost); + reconciliation = await reservation.reconcile(totalCost); logger.info("[AI Billing] Flat credits reconciled", { model: context.model, reserved: reservation.reservedAmount, @@ -380,6 +400,43 @@ export async function billFlatUsage( }); } + if (affiliate && affiliateEarnings > 0) { + const payableEarnings = collectedAffiliateEarnings({ + nominalEarnings: affiliateEarnings, + preAffiliateTotalCost, + totalCost, + reservation, + reconciliation, + }); + + if (payableEarnings > 0) { + const sourceId = getAffiliateEarningsSourceId(context, "flat"); + + await redeemableEarningsService + .addEarnings({ + userId: affiliate.affiliate.user_id, + amount: payableEarnings, + source: "affiliate", + sourceId, + description: `Affiliate markup earnings from model: ${context.model}`, + metadata: { + appId: null, + model: context.model, + provider, + flatOperation: true, + }, + dedupeBySourceId: true, + }) + .catch((err) => { + logger.error("[AI Billing] Failed to add flat-operation affiliate earnings", { + error: err instanceof Error ? err.message : String(err), + affiliateId: affiliate.affiliate.id, + amount: payableEarnings, + }); + }); + } + } + return { inputCost, outputCost, diff --git a/packages/cloud/shared/src/lib/services/credits.ts b/packages/cloud/shared/src/lib/services/credits.ts index 3c81c4a07029d..7962ae6717ad2 100644 --- a/packages/cloud/shared/src/lib/services/credits.ts +++ b/packages/cloud/shared/src/lib/services/credits.ts @@ -110,6 +110,8 @@ export interface ReserveCreditsParams { billingSource?: PricingBillingSource; estimatedInputTokens?: number; estimatedOutputTokens?: number; + /** Multiplies model-estimated reservations for caller-known markups. */ + estimatedCostMultiplier?: number; } export interface ReservationSweepStats { @@ -2009,7 +2011,14 @@ export class CreditsService { if (params.amount !== undefined && params.amount < 0) { throw new Error("reserve() amount must be non-negative"); } + if ( + params.estimatedCostMultiplier !== undefined && + (!Number.isFinite(params.estimatedCostMultiplier) || params.estimatedCostMultiplier < 0) + ) { + throw new Error("reserve() estimatedCostMultiplier must be non-negative"); + } + const estimatedCostMultiplier = params.estimatedCostMultiplier ?? 1; let reservedAmount: number; let estimatedCost: number; let model: string | undefined; @@ -2031,7 +2040,7 @@ export class CreditsService { params.billingSource, ); - estimatedCost = totalCost; + estimatedCost = totalCost * estimatedCostMultiplier; reservedAmount = Math.max(estimatedCost * COST_BUFFER, MIN_RESERVATION); } else { throw new Error("reserve() requires either `amount` or `model`"); @@ -2047,6 +2056,7 @@ export class CreditsService { settlement_marker: RESERVATION_SETTLEMENT_MARKER, estimated_cost: estimatedCost, reserved_amount: reservedAmount, + ...(estimatedCostMultiplier !== 1 && { estimated_cost_multiplier: estimatedCostMultiplier }), ...(model && { model }), }, });