Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -109,6 +109,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 @@ -171,6 +172,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
65 changes: 63 additions & 2 deletions apps/api/src/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1934,6 +1934,7 @@ async function handleEndUserTopUpSucceeded(
const netCredited = Number(md.netCredited);
const developerMargin = Number(md.developerMargin ?? "0");
const platformFee = Number(md.platformFee ?? "0");
const bonusCredited = Number(md.bonusCredited ?? "0");
const grossPaid = paymentIntent.amount / 100;

if (!walletId || !Number.isFinite(netCredited) || netCredited <= 0) {
Expand Down Expand Up @@ -2026,7 +2027,67 @@ async function handleEndUserTopUpSucceeded(
});
}

return updated.balance;
// Developer-funded bonus: credit the wallet extra spend power on top of
// the paid amount, debited from the developer org's credit balance. Only
// for live wallets, and capped at the org's available credits so the
// balance can never go negative. The org row is locked (SELECT … FOR
// UPDATE) so a concurrent debit can't oversell the balance.
let finalBalance = updated.balance;
if (bonusCredited > 0 && wallet.mode !== "test") {
const [org] = await tx
.select({ credits: tables.organization.credits })
.from(tables.organization)
.where(eq(tables.organization.id, wallet.organizationId))
.for("update")

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 Lock the organization before the wallet for bonus top-ups

When bonusCredited > 0, this takes the organization FOR UPDATE lock after the transaction has already updated the wallet row above. The worker's credit-deduction transaction can do the inverse for batches that include both this org's regular credit usage and this end-user wallet (apps/worker/src/worker.ts:1229 updates the organization, then apps/worker/src/worker.ts:1276 updates the wallet), so a concurrent bonus top-up can deadlock and abort one side. Keep the lock order consistent, e.g. lock/debit the org before touching the wallet.

Useful? React with 👍 / 👎.

.limit(1);

const availableCredits = Math.max(0, Number(org?.credits ?? "0"));
const bonusToApply =
Math.round(Math.min(bonusCredited, availableCredits) * 1e6) / 1e6;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (bonusToApply > 0) {
const [bonusUpdated] = await tx
.update(tables.wallet)
.set({ balance: sql`${tables.wallet.balance} + ${bonusToApply}` })
.where(eq(tables.wallet.id, walletId))
.returning();
finalBalance = bonusUpdated.balance;

await tx.insert(tables.walletLedger).values({
walletId,
endCustomerId: wallet.endCustomerId,
organizationId: wallet.organizationId,
type: "bonus",
amount: String(bonusToApply),
balanceAfter: bonusUpdated.balance,
stripePaymentIntentId: paymentIntent.id,
description: "End-user top-up bonus",
});

await tx
.update(tables.organization)
.set({
credits: sql`${tables.organization.credits} - ${bonusToApply}`,
})
.where(eq(tables.organization.id, wallet.organizationId));

await tx.insert(tables.transaction).values({
organizationId: wallet.organizationId,
type: "end_user_bonus",
amount: String(bonusToApply),
creditAmount: String(-bonusToApply),
status: "completed",
stripePaymentIntentId: paymentIntent.id,
description: `End-user top-up bonus (wallet ${walletId})`,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} else {
logger.warn(
`Skipping end-user top-up bonus for wallet ${walletId}: developer org ${wallet.organizationId} has insufficient credits (${availableCredits} available, ${bonusCredited} needed)`,
);
}
}

return finalBalance;
});
} catch (err) {
const code =
Expand All @@ -2044,7 +2105,7 @@ async function handleEndUserTopUpSucceeded(
const updated = { balance: newBalance };

logger.info(
`Credited ${netCredited} to end-user wallet ${walletId} (margin ${developerMargin}, platform fee ${platformFee})`,
`Credited ${netCredited} to end-user wallet ${walletId} (margin ${developerMargin}, platform fee ${platformFee}, bonus quote ${bonusCredited}, balance now ${newBalance})`,
);
Comment on lines 2135 to 2137

// Notify the developer's webhook endpoints (best-effort). Skip for test-mode
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/features/embeddable-payments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,15 @@ Your backend ──(secret key sk_)──▶ POST /v1/sessions ──▶ ephemer
- Your **secret key** (`sk_…`) never leaves your backend. It mints short-lived **ephemeral session tokens** (`es_…`) scoped to one end-user wallet.
- The **browser** only ever holds the `es_…` token (and a publishable Stripe key). It calls the gateway directly; usage is billed to that user's wallet.
- **Markup is applied at top-up time**: if you set a 20% markup and a user buys $10, their wallet is credited the net spend power and your **margin accrues to your organization** for later payout.
- **Top-up bonus (optional)**: set a bonus percent to credit end-users _more_ than they pay — e.g. a 50% bonus turns a $10 top-up into $15 of spend power. The extra credits are funded from **your organization's credit balance** at top-up time (capped at your available credits), so it's a promotional lever you can switch on or off anytime. Markup and bonus are independent: the bonus is applied on top of the net credited amount.

## Set up in the dashboard

Before you write any code, configure the project you want to embed:

1. Open the LLM Gateway dashboard and select your project.
2. Go to **Settings → Payments SDK** and turn on **End-user sessions** (this requires the preview to be enabled for your project).
3. _(Optional)_ Set a **markup percent** — the margin you earn on every top-up.
3. _(Optional)_ Set a **markup percent** — the margin you earn on every top-up — and/or a **top-up bonus percent** to gift end-users extra credit (funded from your organization's credit balance).
4. Add the browser origins allowed to call the gateway, one per line (e.g. `https://app.example.com`), then click **Save Settings**.
5. Under **Platform Secret Keys**, click **Create Live Key** (or **Create Test Key**) and copy the `sk_…` value immediately.
6. Store it as a server-side environment variable, for example `LLMGATEWAY_SECRET_KEY`.
Expand Down
1 change: 1 addition & 0 deletions apps/gateway/src/lib/end-user-session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function makeWallet(mode: "live" | "test"): Wallet {
balance: "10",
currency: "USD",
markupPercentOverride: null,
bonusPercentOverride: null,
spendCapPerSession: null,
status: "active",
};
Expand Down
22 changes: 22 additions & 0 deletions apps/ui/src/components/settings/sdk-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export function SdkSettings({
const [markupPercent, setMarkupPercent] = useState(
Number(initialProject.endUserMarkupPercent ?? "0"),
);
const [bonusPercent, setBonusPercent] = useState(
Number(initialProject.endUserTopUpBonusPercent ?? "0"),
);
const [allowedOriginsText, setAllowedOriginsText] = useState(
(initialProject.allowedOrigins ?? []).join("\n"),
);
Expand Down Expand Up @@ -165,6 +168,7 @@ export function SdkSettings({
body: {
endUserEnabled,
endUserMarkupPercent: markupPercent,
endUserTopUpBonusPercent: bonusPercent,
allowedOrigins,
},
});
Expand Down Expand Up @@ -310,6 +314,24 @@ export function SdkSettings({
onChange={(event) => setMarkupPercent(Number(event.target.value))}
/>
</div>
<div className="grid gap-2 sm:max-w-56">
<Label htmlFor="bonusPercent">Top-up bonus percent</Label>
<Input
id="bonusPercent"
type="number"
min={0}
max={1000}
step={0.01}
value={bonusPercent}
disabled={isPreview}
onChange={(event) => setBonusPercent(Number(event.target.value))}
/>
<p className="text-muted-foreground text-sm">
End-users get this much extra credit on top-ups (e.g. 50% turns a
$10 top-up into $15). The bonus is funded from your organization's
credit balance; set to 0 to disable.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="allowedOrigins">Allowed origins</Label>
<Textarea
Expand Down
2 changes: 2 additions & 0 deletions packages/db/migrations/1783033216_gray_komodo.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "project" ADD COLUMN "end_user_top_up_bonus_percent" numeric DEFAULT '0' NOT NULL;--> statement-breakpoint
ALTER TABLE "wallet" ADD COLUMN "bonus_percent_override" numeric;
Loading
Loading