Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/api/src/lib/end-user-session-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface AuthenticatedSession {
/** `test` wallets top up against the Stripe sandbox. */
mode: "live" | "test";
markupPercent: number;
/** Top-up bonus multiplier (percent) funded from the developer org's credits. */
bonusPercent: number;
/** Origins allowed to call with this session (CORS), from the project. */
allowedOrigins: string[] | null;
}
Expand Down Expand Up @@ -98,6 +100,12 @@ export async function endUserSessionAuth(c: Context, next: Next) {
"0",
);

const bonusPercent = Number(
session.wallet.bonusPercentOverride ??
session.wallet.project?.endUserTopUpBonusPercent ??
"0",
);

c.set("endUserSession", {
sessionId: session.id,
walletId: session.wallet.id,
Expand All @@ -106,6 +114,7 @@ export async function endUserSessionAuth(c: Context, next: Next) {
organizationId: session.wallet.organizationId,
mode: session.wallet.mode,
markupPercent: Number.isFinite(markupPercent) ? markupPercent : 0,
bonusPercent: Number.isFinite(bonusPercent) ? bonusPercent : 0,
allowedOrigins: session.wallet.project?.allowedOrigins ?? null,
});

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const projectSchema = z.object({
paymentsSdkEnabled: z.boolean(),
endUserEnabled: z.boolean(),
endUserMarkupPercent: z.string(),
endUserTopUpBonusPercent: z.string(),
allowedOrigins: z.array(z.string()).nullable(),
});

Expand Down Expand Up @@ -177,6 +178,7 @@ const transactionSchema = z.object({
"end_user_margin_accrual",
"end_user_refund",
"end_user_margin_payout",
"end_user_bonus",
]),
amount: z.string().nullable(),
creditAmount: z.string().nullable(),
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/routes/platform-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const createTopUp = createRoute({
clientSecret: z.string(),
totalAmount: z.number(),
netCredited: z.number(),
bonusCredited: z.number(),
isInternational: z.boolean(),
}),
},
Expand Down Expand Up @@ -92,6 +93,15 @@ platformWallet.openapi(createTopUp, async (c) => {
const developerMargin = Math.round((amount - netCredited) * 1e6) / 1e6;
const platformFee = feeBreakdown.platformFee + feeBreakdown.internationalFee;

// Developer-funded bonus: the wallet is credited extra spend power on top of
// `netCredited`, paid for out of the developer org's credit balance. Only live
// wallets earn it — test-mode top-ups are Stripe-sandbox and must never spend
// real org credits. This is an uncapped quote; fulfillment re-caps it against
// the org's actual credits at the time the payment succeeds.
const bonusFraction =
session.mode === "test" ? 0 : session.bonusPercent / 100;
const bonusCredited = Math.round(netCredited * bonusFraction * 1e6) / 1e6;
Comment on lines +101 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap the bonus before quoting it

When the developer org already has fewer credits than the configured bonus, this returns and stores the full bonusCredited amount even though the success handler later caps the actual bonus to the org's available balance. In that scenario the payment UI can show (for example) a $15 credit total for a $10 top-up with a 50% bonus, but after the user pays the wallet may receive only $10 because the org had no credits left. Cap the quoted bonus against the current org balance, or make the response explicitly an estimate so the checkout does not promise spend power that fulfillment will not apply.

Useful? React with 👍 / 👎.


const stripeCustomerId = await ensureEndCustomerStripeCustomer(
session.endCustomerId,
session.mode,
Expand All @@ -115,6 +125,8 @@ platformWallet.openapi(createTopUp, async (c) => {
platformFee: platformFee.toString(),
developerMargin: developerMargin.toString(),
netCredited: netCredited.toString(),
bonusPercent: session.bonusPercent.toString(),
bonusCredited: bonusCredited.toString(),
},
});

Expand All @@ -123,12 +135,14 @@ platformWallet.openapi(createTopUp, async (c) => {
amount,
netCredited,
developerMargin,
bonusCredited,
});

return c.json({
clientSecret: paymentIntent.client_secret ?? "",
totalAmount: feeBreakdown.totalAmount,
netCredited,
bonusCredited,
isInternational,
});
});
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const projectSchema = z.object({
paymentsSdkEnabled: z.boolean(),
endUserEnabled: z.boolean(),
endUserMarkupPercent: z.string(),
endUserTopUpBonusPercent: z.string(),
allowedOrigins: z.array(z.string()).nullable(),
});

Expand All @@ -54,6 +55,7 @@ const updateProjectSchema = z.object({
.optional(),
endUserEnabled: z.boolean().optional(),
endUserMarkupPercent: z.number().min(0).max(100).optional(),
endUserTopUpBonusPercent: z.number().min(0).max(1000).optional(),
allowedOrigins: z.array(z.string().trim().min(1)).max(20).optional(),
});

Expand Down Expand Up @@ -207,6 +209,7 @@ projects.openapi(updateProject, async (c) => {
defaultRoutingStrategy,
endUserEnabled,
endUserMarkupPercent,
endUserTopUpBonusPercent,
allowedOrigins,
} = c.req.valid("json");

Expand Down Expand Up @@ -251,6 +254,7 @@ projects.openapi(updateProject, async (c) => {
const isUpdatingEndUserSettings =
endUserEnabled !== undefined ||
endUserMarkupPercent !== undefined ||
endUserTopUpBonusPercent !== undefined ||
allowedOrigins !== undefined;
const projectUserOrg = userOrgs.find(
(userOrg) => userOrg.organizationId === project.organizationId,
Expand Down Expand Up @@ -347,6 +351,10 @@ projects.openapi(updateProject, async (c) => {
updateData.endUserMarkupPercent = String(endUserMarkupPercent);
}

if (endUserTopUpBonusPercent !== undefined) {
updateData.endUserTopUpBonusPercent = String(endUserTopUpBonusPercent);
}

if (allowedOrigins !== undefined) {
normalizedAllowedOrigins = normalizeAllowedOrigins(allowedOrigins);
updateData.allowedOrigins = normalizedAllowedOrigins;
Expand Down Expand Up @@ -424,6 +432,15 @@ projects.openapi(updateProject, async (c) => {
new: String(endUserMarkupPercent),
};
}
if (
endUserTopUpBonusPercent !== undefined &&
String(endUserTopUpBonusPercent) !== project.endUserTopUpBonusPercent
) {
changes.endUserTopUpBonusPercent = {
old: project.endUserTopUpBonusPercent,
new: String(endUserTopUpBonusPercent),
};
}
if (normalizedAllowedOrigins !== undefined) {
const previousAllowedOrigins = project.allowedOrigins ?? [];
if (
Expand Down
222 changes: 222 additions & 0 deletions apps/api/src/stripe-end-user-bonus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";

import { db, eq, tables } from "@llmgateway/db";

import {
handleEndUserTopUpRefunded,
handleEndUserTopUpSucceeded,
} from "./stripe.js";
import { deleteAll } from "./testing.js";

import type Stripe from "stripe";

const ORG_ID = "bonus-org-id";
const PROJECT_ID = "bonus-project-id";

interface TopUpMetadata {
walletId: string;
netCredited: string;
developerMargin?: string;
platformFee?: string;
bonusCredited?: string;
}

function makeTopUpIntent(
id: string,
amount: number,
metadata: TopUpMetadata,
): Stripe.PaymentIntent {
return {
id,
amount,
metadata: {
kind: "end_user_topup",
...metadata,
},
} as unknown as Stripe.PaymentIntent;
}

async function seedWallet(opts: {
walletId: string;
customerId: string;
externalId: string;
mode: "live" | "test";
}) {
await db.insert(tables.endCustomer).values({
id: opts.customerId,
organizationId: ORG_ID,
projectId: PROJECT_ID,
externalId: opts.externalId,
mode: opts.mode,
});

await db.insert(tables.wallet).values({
id: opts.walletId,
endCustomerId: opts.customerId,
projectId: PROJECT_ID,
organizationId: ORG_ID,
mode: opts.mode,
balance: "0",
});
}

async function getOrgCredits(): Promise<number> {
const org = await db.query.organization.findFirst({
where: { id: { eq: ORG_ID } },
});
return Number(org?.credits ?? "0");
}

async function getWalletBalance(walletId: string): Promise<number> {
const wallet = await db.query.wallet.findFirst({
where: { id: { eq: walletId } },
});
return Number(wallet?.balance ?? "0");
}

describe("end-user top-up bonus", () => {
beforeEach(async () => {
await db.insert(tables.organization).values({
id: ORG_ID,
name: "Bonus Org",
billingEmail: "bonus@example.com",
credits: "100",
});

await db.insert(tables.project).values({
id: PROJECT_ID,
name: "Bonus Project",
organizationId: ORG_ID,
endUserEnabled: true,
endUserTopUpBonusPercent: "50",
});
});

afterEach(async () => {
await deleteAll();
});

test("credits the bonus and debits the developer org credits (live wallet)", async () => {
await seedWallet({
walletId: "wallet-live",
customerId: "cust-live",
externalId: "ext-live",
mode: "live",
});

await handleEndUserTopUpSucceeded(
makeTopUpIntent("pi_bonus_live", 1000, {
walletId: "wallet-live",
netCredited: "10",
bonusCredited: "5",
}),
);

// $10 paid + $5 bonus of spend power.
expect(await getWalletBalance("wallet-live")).toBe(15);
// The $5 bonus is funded from the org's credit balance.
expect(await getOrgCredits()).toBe(95);

const ledger = await db.query.walletLedger.findMany({
where: { walletId: { eq: "wallet-live" } },
});
const topup = ledger.find((r) => r.type === "topup");
const bonus = ledger.find((r) => r.type === "bonus");
expect(Number(topup?.amount)).toBe(10);
expect(Number(bonus?.amount)).toBe(5);

const bonusTxn = await db.query.transaction.findFirst({
where: {
organizationId: { eq: ORG_ID },
type: { eq: "end_user_bonus" },
},
});
expect(Number(bonusTxn?.creditAmount)).toBe(-5);
});

test("caps the bonus at the org's available credits", async () => {
await db
.update(tables.organization)
.set({ credits: "3" })
.where(eq(tables.organization.id, ORG_ID));

await seedWallet({
walletId: "wallet-cap",
customerId: "cust-cap",
externalId: "ext-cap",
mode: "live",
});

await handleEndUserTopUpSucceeded(
makeTopUpIntent("pi_bonus_cap", 1000, {
walletId: "wallet-cap",
netCredited: "10",
bonusCredited: "5",
}),
);

// Only $3 of bonus could be funded, so the wallet gets $10 + $3.
expect(await getWalletBalance("wallet-cap")).toBe(13);
expect(await getOrgCredits()).toBe(0);
});

test("never applies a bonus to a test-mode wallet", async () => {
await seedWallet({
walletId: "wallet-test",
customerId: "cust-test",
externalId: "ext-test",
mode: "test",
});

await handleEndUserTopUpSucceeded(
makeTopUpIntent("pi_bonus_test", 1000, {
walletId: "wallet-test",
netCredited: "10",
bonusCredited: "5",
}),
);

expect(await getWalletBalance("wallet-test")).toBe(10);
// Org credits are untouched for sandbox top-ups.
expect(await getOrgCredits()).toBe(100);

const bonus = await db.query.walletLedger.findFirst({
where: { walletId: { eq: "wallet-test" }, type: { eq: "bonus" } },
});
expect(bonus).toBeUndefined();
});

test("claws back the bonus and restores org credits on refund", async () => {
await seedWallet({
walletId: "wallet-refund",
customerId: "cust-refund",
externalId: "ext-refund",
mode: "live",
});

await handleEndUserTopUpSucceeded(
makeTopUpIntent("pi_bonus_refund", 1000, {
walletId: "wallet-refund",
netCredited: "10",
bonusCredited: "5",
}),
);
expect(await getWalletBalance("wallet-refund")).toBe(15);
expect(await getOrgCredits()).toBe(95);

const topUpRow = await db.query.walletLedger.findFirst({
where: {
stripePaymentIntentId: { eq: "pi_bonus_refund" },
type: { eq: "topup" },
},
});
expect(topUpRow).toBeDefined();

await handleEndUserTopUpRefunded(topUpRow!);

// Both the paid top-up and the developer-funded bonus are pulled back.
expect(await getWalletBalance("wallet-refund")).toBe(0);
// The $5 bonus is returned to the org's credit balance.
expect(await getOrgCredits()).toBe(100);
});
});
Loading
Loading