feat(webapp): promo credits — /promo signup landing, redeem at plan selection, usage display (#4138)
## What & why Signup promo credits. A new logged-out `/promo?code=<code>` landing page validates the code and carries it through signup via a cookie. When the new organization is activated by selecting a plan, the code is redeemed and its credits are applied; the usage page then shows the remaining promo credits and their expiry. ## Notes - The code is redeemed at **plan selection**, not org creation: the credit grant targets the org's usage allowance, which only exists once a plan is selected — applying at creation would have nothing to grant onto. Redemption is best-effort and never blocks plan selection. - Pairs with the corresponding billing-service change (promo code validate/apply/credits + grant issuance); the two are released together. ## Testing Verified locally end to end: `/promo` shows the offer, a new account carries the code through signup, selecting the Free plan redeems it, and the usage page shows the remaining credits. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Promo credits: a /promo signup landing page, redeeming a promo code when a new org selects a plan, and showing remaining credits on the usage page.
|
||||
@@ -36,7 +36,14 @@ const quotes: QuoteType[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
export function LoginPageLayout({
|
||||
children,
|
||||
rightContent,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Replaces the default testimonials panel on the right (e.g. a promo highlight). */
|
||||
rightContent?: React.ReactNode;
|
||||
}) {
|
||||
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * quotes.length);
|
||||
@@ -62,23 +69,27 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden grid-rows-[1fr_auto] pb-6 lg:grid">
|
||||
<div className="flex h-full flex-col items-center justify-center px-16">
|
||||
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
|
||||
{randomQuote?.quote}
|
||||
</Header3>
|
||||
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4 px-8">
|
||||
<Paragraph>Trusted by developers at</Paragraph>
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
|
||||
<LyftLogo className="w-11" />
|
||||
<UnkeyLogo />
|
||||
<MiddayLogo />
|
||||
<AppsmithLogo />
|
||||
<CalComLogo />
|
||||
<TldrawLogo />
|
||||
</div>
|
||||
</div>
|
||||
{rightContent ?? (
|
||||
<>
|
||||
<div className="flex h-full flex-col items-center justify-center px-16">
|
||||
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
|
||||
{randomQuote?.quote}
|
||||
</Header3>
|
||||
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4 px-8">
|
||||
<Paragraph>Trusted by developers at</Paragraph>
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
|
||||
<LyftLogo className="w-11" />
|
||||
<UnkeyLogo />
|
||||
<MiddayLogo />
|
||||
<AppsmithLogo />
|
||||
<CalComLogo />
|
||||
<TldrawLogo />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { UsagePresenter, type UsageSeriesData } from "~/presenters/v3/UsagePresenter.server";
|
||||
import { getPromoCredits } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { formatCurrency, formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { useBillingLimit } from "~/hooks/useOrganizations";
|
||||
@@ -81,14 +82,26 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
startDate,
|
||||
});
|
||||
|
||||
// Credit-grant balance (promo now, other grant types later). Cheap + cached +
|
||||
// fails to null, and applies to any org with grants — not gated on plan tier.
|
||||
const promoCredits = await getPromoCredits(organization.id);
|
||||
|
||||
return typeddefer({
|
||||
usage,
|
||||
tasks,
|
||||
months,
|
||||
isCurrentMonth: startDate.toISOString() === months[0].toISOString(),
|
||||
promoCredits,
|
||||
});
|
||||
}
|
||||
|
||||
const creditExpiryFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "utc",
|
||||
});
|
||||
|
||||
const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
@@ -96,7 +109,8 @@ const monthDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const { usage, tasks, months, isCurrentMonth } = useTypedLoaderData<typeof loader>();
|
||||
const { usage, tasks, months, isCurrentMonth, promoCredits } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const currentPlan = useCurrentPlan();
|
||||
const billingLimit = useBillingLimit();
|
||||
const planLimitCents = currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0;
|
||||
@@ -139,6 +153,45 @@ export default function Page() {
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
{promoCredits && (
|
||||
<div className="flex flex-col gap-1 border-t border-grid-dimmed p-3">
|
||||
<div className="flex items-end gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header2 className="whitespace-nowrap">Promo credits</Header2>
|
||||
<p className="whitespace-nowrap text-3xl font-medium text-text-bright">
|
||||
{formatCurrency(promoCredits.remainingCents / 100, false)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-1 flex-col gap-1 pb-1">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-charcoal-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500"
|
||||
style={{
|
||||
width: `${
|
||||
promoCredits.grantedCents > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
0,
|
||||
(promoCredits.remainingCents / promoCredits.grantedCents) * 100
|
||||
)
|
||||
)
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{formatCurrency(promoCredits.remainingCents / 100, false)} of{" "}
|
||||
{formatCurrency(promoCredits.grantedCents / 100, false)} remaining
|
||||
{promoCredits.expiresAt
|
||||
? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}`
|
||||
: ""}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 border-t border-grid-dimmed p-3">
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Await
|
||||
@@ -171,6 +224,7 @@ export default function Page() {
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3">
|
||||
<Card>
|
||||
<Card.Header>Usage by day</Card.Header>
|
||||
|
||||
@@ -79,6 +79,10 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
avatar,
|
||||
});
|
||||
|
||||
// A promo code carried over from the /promo landing page (via cookie) is
|
||||
// redeemed later, once the org is activated through plan selection and its
|
||||
// usage entitlement exists — not here, where there's nothing to grant onto.
|
||||
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const configurationId = url.searchParams.get("configurationId");
|
||||
@@ -94,8 +98,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
if (next) {
|
||||
params.set("next", next);
|
||||
}
|
||||
const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`;
|
||||
return redirect(redirectUrl);
|
||||
return redirect(`${organizationPath(organization)}/projects/new?${params.toString()}`);
|
||||
}
|
||||
|
||||
return redirect(organizationPath(organization));
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
|
||||
import { LoginPageLayout } from "~/components/LoginPageLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server";
|
||||
import { validatePromoCode } from "~/services/platform.v3.server";
|
||||
import { setPromoCodeCookie } from "~/services/promoCode.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
|
||||
export const meta: MetaFunction = () => [{ title: "Claim your Trigger.dev credits" }];
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const userId = await getUserId(request);
|
||||
const url = requestUrl(request);
|
||||
const code = url.searchParams.get("code")?.trim() || null;
|
||||
|
||||
const authMethods = {
|
||||
showGithubAuth: isGithubAuthSupported,
|
||||
showGoogleAuth: isGoogleAuthSupported,
|
||||
};
|
||||
|
||||
// Credits are only granted to brand-new accounts, so an already-signed-in
|
||||
// user can't redeem a code.
|
||||
if (userId) {
|
||||
return typedjson({ view: "signed_in" as const, ...authMethods });
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return typedjson({ view: "invalid" as const, ...authMethods });
|
||||
}
|
||||
|
||||
const validated = await validatePromoCode(code);
|
||||
if (!validated || !validated.valid) {
|
||||
return typedjson({ view: "invalid" as const, ...authMethods });
|
||||
}
|
||||
|
||||
// Stash the code so it survives the OAuth round-trip and can be applied once
|
||||
// the new org selects a plan.
|
||||
return typedjson(
|
||||
{
|
||||
view: "valid" as const,
|
||||
amountInCents: validated.amountInCents ?? 0,
|
||||
expiresAt: validated.expiresAt ?? null,
|
||||
...authMethods,
|
||||
},
|
||||
{ headers: { "Set-Cookie": await setPromoCodeCookie(code) } }
|
||||
);
|
||||
}
|
||||
|
||||
function formatDollars(cents: number) {
|
||||
const dollars = cents / 100;
|
||||
return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatExpiry(iso: string | null) {
|
||||
if (!iso) return null;
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function SignInForm({
|
||||
showGithubAuth,
|
||||
showGoogleAuth,
|
||||
}: {
|
||||
showGithubAuth: boolean;
|
||||
showGoogleAuth: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Fieldset className="w-full">
|
||||
<div className="flex flex-col items-center gap-y-3">
|
||||
{showGithubAuth && (
|
||||
<Form action="/auth/github" method="post" className="w-full">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with github"
|
||||
>
|
||||
<GitHubLightIcon className="mr-2 size-5" />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
{showGoogleAuth && (
|
||||
<Form action="/auth/google" method="post" className="w-full">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with google"
|
||||
>
|
||||
<GoogleLogo className="mr-2 size-5" />
|
||||
<span className="text-text-bright">Continue with Google</span>
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
<LinkButton
|
||||
to="/login/magic"
|
||||
variant="secondary/extra-large"
|
||||
fullWidth
|
||||
data-action="continue with email"
|
||||
className="text-text-bright"
|
||||
>
|
||||
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
|
||||
Continue with Email
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Paragraph variant="extra-small" className="mt-2 text-center">
|
||||
By signing up you agree to our{" "}
|
||||
<TextLink href="https://trigger.dev/legal" target="_blank">
|
||||
terms
|
||||
</TextLink>{" "}
|
||||
and{" "}
|
||||
<TextLink href="https://trigger.dev/legal/privacy" target="_blank">
|
||||
privacy
|
||||
</TextLink>{" "}
|
||||
policy.
|
||||
</Paragraph>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PromoPage() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<div className="flex w-full flex-col">
|
||||
{data.view === "signed_in" ? (
|
||||
<>
|
||||
<Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing>
|
||||
Promo codes are for new accounts
|
||||
</Header2>
|
||||
<Paragraph variant="base" spacing>
|
||||
You're already signed in. Promo credits can only be added to a brand-new account.
|
||||
</Paragraph>
|
||||
<LinkButton to="/" variant="secondary/medium">
|
||||
Go to dashboard
|
||||
</LinkButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Header2 className="sm:text-2xl md:text-3xl lg:text-4xl" spacing>
|
||||
{data.view === "valid"
|
||||
? `Claim ${formatDollars(data.amountInCents)} credits`
|
||||
: "Create your account"}
|
||||
</Header2>
|
||||
{data.view === "valid" ? (
|
||||
<Paragraph variant="base" spacing>
|
||||
These are only available for new accounts on the Free plan.
|
||||
{formatExpiry(data.expiresAt)
|
||||
? ` The credits expire on ${formatExpiry(data.expiresAt)}.`
|
||||
: ""}
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Callout variant="warning" className="mb-6 w-full">
|
||||
That promo code isn't valid. You can still sign up below but credits won't be added.
|
||||
</Callout>
|
||||
)}
|
||||
<SignInForm showGithubAuth={data.showGithubAuth} showGoogleAuth={data.showGoogleAuth} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,8 @@ import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { setPlan } from "~/services/platform.v3.server";
|
||||
import { applyPromoCode, bustPromoCreditsCache, setPlan } from "~/services/platform.v3.server";
|
||||
import { clearPromoCodeCookie, getPromoCodeFromCookie } from "~/services/promoCode.server";
|
||||
import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -153,9 +154,26 @@ export const action = dashboardAction(
|
||||
}
|
||||
}
|
||||
|
||||
return await setPlan(organization, request, form.callerPath, payload, {
|
||||
const result = await setPlan(organization, request, form.callerPath, payload, {
|
||||
invalidateBillingCache: engine.invalidateBillingCache.bind(engine),
|
||||
// Redeem a promo code carried from the /promo landing page. This runs only
|
||||
// once the Free plan has actually been provisioned (the grant target), so a
|
||||
// failed plan change never burns the one-time code. Best-effort: it must
|
||||
// never change the plan-selection outcome.
|
||||
onFreePlanProvisioned: async (response) => {
|
||||
const promoCode = await getPromoCodeFromCookie(request);
|
||||
if (!promoCode) {
|
||||
return;
|
||||
}
|
||||
const applied = await applyPromoCode(organization.id, user.id, promoCode);
|
||||
if (applied?.applied) {
|
||||
bustPromoCreditsCache(organization.id);
|
||||
response.headers.append("Set-Cookie", await clearPromoCodeCookie());
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
@@ -8,7 +6,6 @@ import {
|
||||
machines as machinesFromPlatform,
|
||||
type BillingAlertsResult,
|
||||
type CreatePrivateLinkConnectionBody,
|
||||
type CurrentPlan,
|
||||
type Limits,
|
||||
type MachineCode,
|
||||
type PrivateLinkConnection,
|
||||
@@ -18,20 +15,14 @@ import {
|
||||
type UpdateBillingAlertsRequest,
|
||||
type UsageResult,
|
||||
type UsageSeriesParams,
|
||||
type CurrentPlan,
|
||||
} from "@trigger.dev/platform";
|
||||
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
asPlatformSchema,
|
||||
BillingLimitResultSchema,
|
||||
BillingLimitsActiveResultSchema,
|
||||
BillingLimitsPendingResolvesResultSchema,
|
||||
EntitlementResultSchema,
|
||||
asPlatformSchema,
|
||||
type BillingLimitResult,
|
||||
type BillingLimitsActiveResult,
|
||||
type BillingLimitsPendingResolvesResult,
|
||||
@@ -39,10 +30,19 @@ import {
|
||||
type ResolveBillingLimitRequest,
|
||||
type UpdateBillingLimitRequest,
|
||||
} from "~/services/billingLimit.schemas";
|
||||
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
function initializeClient() {
|
||||
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
|
||||
@@ -82,6 +82,78 @@ function recordPlatformFailure(fn: string, kind: "caught" | "no_success") {
|
||||
platformClientFailuresCounter.add(1, { function: fn, kind });
|
||||
}
|
||||
|
||||
export type ValidatedPromoCode = {
|
||||
valid: boolean;
|
||||
amountInCents?: number;
|
||||
expiresAt?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a promo code (no org context). Returns `undefined` when billing
|
||||
* isn't configured or the call fails, so callers fall back to treating the
|
||||
* code as not-yet-validated rather than crashing the page.
|
||||
*/
|
||||
export async function validatePromoCode(code: string): Promise<ValidatedPromoCode | undefined> {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [error, result] = await tryCatch(client.validatePromoCode(code));
|
||||
if (error) {
|
||||
recordPlatformFailure("validatePromoCode", "caught");
|
||||
logger.error("validatePromoCode threw", { error });
|
||||
return undefined;
|
||||
}
|
||||
if (!result.success) {
|
||||
recordPlatformFailure("validatePromoCode", "no_success");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: result.valid,
|
||||
amountInCents: result.amountInCents,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export type AppliedPromoCode = {
|
||||
applied: boolean;
|
||||
amountInCents?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a promo code to a newly created org. Returns `undefined` when billing
|
||||
* isn't configured or the call fails — callers treat that as "not applied" and
|
||||
* must never block org creation on it.
|
||||
*/
|
||||
export async function applyPromoCode(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
code: string
|
||||
): Promise<AppliedPromoCode | undefined> {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [error, result] = await tryCatch(client.applyPromoCode(orgId, { code, userId }));
|
||||
if (error) {
|
||||
recordPlatformFailure("applyPromoCode", "caught");
|
||||
logger.error("applyPromoCode threw", { error });
|
||||
return undefined;
|
||||
}
|
||||
if (!result.success) {
|
||||
recordPlatformFailure("applyPromoCode", "no_success");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
applied: result.applied,
|
||||
amountInCents: result.amountInCents,
|
||||
reason: result.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function initializePlatformCache() {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = createLRUMemoryStore(1000);
|
||||
@@ -119,6 +191,11 @@ function initializePlatformCache() {
|
||||
fresh: 60_000,
|
||||
stale: 120_000,
|
||||
}),
|
||||
promoCredits: new Namespace<PromoCreditsData | null>(ctx, {
|
||||
stores: [memory, redisCacheStore],
|
||||
fresh: 60_000,
|
||||
stale: 120_000,
|
||||
}),
|
||||
});
|
||||
|
||||
return cache;
|
||||
@@ -135,6 +212,12 @@ export function bustBillingLimitCaches(organizationId: string) {
|
||||
invalidateBillingLimitCaches(organizationId);
|
||||
}
|
||||
|
||||
// Clear the cached promo-credits read so a just-granted code shows on the usage
|
||||
// page immediately rather than after the stale TTL.
|
||||
export function bustPromoCreditsCache(organizationId: string) {
|
||||
platformCache.promoCredits.remove(organizationId).catch(() => {});
|
||||
}
|
||||
|
||||
type Machines = typeof machinesFromPlatform;
|
||||
|
||||
const MachineOverrideValues = z.object({
|
||||
@@ -417,7 +500,15 @@ export async function setPlan(
|
||||
request: Request,
|
||||
callerPath: string,
|
||||
plan: SetPlanBody,
|
||||
opts?: { invalidateBillingCache?: (orgId: string) => void }
|
||||
opts?: {
|
||||
invalidateBillingCache?: (orgId: string) => void;
|
||||
// Runs only after the Free plan has actually been provisioned, with the
|
||||
// redirect it will return — the single success path where side effects that
|
||||
// depend on a working free-plan entitlement (e.g. redeeming a promo code)
|
||||
// are safe. It never fires on an error path, so callers can't act on a
|
||||
// plan change that didn't happen.
|
||||
onFreePlanProvisioned?: (response: Response) => void | Promise<void>;
|
||||
}
|
||||
) {
|
||||
if (!client) {
|
||||
return redirectWithErrorMessage(callerPath, request, "Error setting plan", {
|
||||
@@ -447,7 +538,9 @@ export async function setPlan(
|
||||
// Selecting Free provisions the plan directly, so any free result is a success.
|
||||
opts?.invalidateBillingCache?.(organization.id);
|
||||
platformCache.entitlement.remove(organization.id).catch(() => {});
|
||||
return redirect(newProjectPath(organization, "You're on the Free plan."));
|
||||
const response = redirect(newProjectPath(organization, "You're on the Free plan."));
|
||||
await opts?.onFreePlanProvisioned?.(response);
|
||||
return response;
|
||||
}
|
||||
case "create_subscription_flow_start": {
|
||||
return redirect(result.checkoutUrl);
|
||||
@@ -465,6 +558,12 @@ export async function setPlan(
|
||||
return redirectWithSuccessMessage(callerPath, request, "Subscription canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
// Unrecognised action shape — surface an error rather than falling through to
|
||||
// an implicit undefined return, so callers always get a Response back.
|
||||
return redirectWithErrorMessage(callerPath, request, "Error setting plan", {
|
||||
ephemeral: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function setConcurrencyAddOn(organizationId: string, amount: number) {
|
||||
@@ -658,6 +757,45 @@ export async function getEntitlement(
|
||||
return result.val;
|
||||
}
|
||||
|
||||
export type PromoCreditsData = {
|
||||
grantedCents: number;
|
||||
remainingCents: number;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remaining promo/credit-grant balance for an org, or null when it has none.
|
||||
* Billing-side gating keeps this cheap for orgs without credits; the SWR cache
|
||||
* keeps repeated dashboard loads off the network. Fails closed to null so the
|
||||
* display is simply hidden on any error — never blocks the page.
|
||||
*/
|
||||
export async function getPromoCredits(organizationId: string): Promise<PromoCreditsData | null> {
|
||||
if (!client) return null;
|
||||
|
||||
const result = await platformCache.promoCredits.swr(organizationId, async () => {
|
||||
try {
|
||||
const response = await client.promoCredits(organizationId);
|
||||
if (!response.success) {
|
||||
recordPlatformFailure("promoCredits", "no_success");
|
||||
// Return undefined (not null) so SWR doesn't cache a transient failure
|
||||
// as "no credits" and hide the display for the stale TTL. null is
|
||||
// reserved for a successful "org has no promo credits" response.
|
||||
return undefined;
|
||||
}
|
||||
return response.promoCredits;
|
||||
} catch (_e) {
|
||||
recordPlatformFailure("promoCredits", "caught");
|
||||
logger.error("promoCredits threw", { error: _e });
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
if (result.err || result.val === undefined) {
|
||||
return null;
|
||||
}
|
||||
return result.val;
|
||||
}
|
||||
|
||||
export async function getBillingLimit(
|
||||
organizationId: string
|
||||
): Promise<BillingLimitResult | undefined> {
|
||||
@@ -980,8 +1118,8 @@ export type {
|
||||
BillingLimitConfig,
|
||||
BillingLimitPageData,
|
||||
BillingLimitResult,
|
||||
BillingLimitsActiveResult,
|
||||
BillingLimitState,
|
||||
BillingLimitsActiveResult,
|
||||
EntitlementResult,
|
||||
ResolveBillingLimitRequest,
|
||||
UpdateBillingLimitRequest,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createCookie } from "@remix-run/node";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
// Carries a promo code from the landing page through signup to first-org
|
||||
// creation. httpOnly + sameSite=lax so it survives the OAuth round-trip,
|
||||
// matching the existing redirect-to cookie.
|
||||
export const promoCodeCookie = createCookie("promo-code", {
|
||||
maxAge: 60 * 60, // 1 hour — enough to complete signup
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
});
|
||||
|
||||
export async function setPromoCodeCookie(code: string): Promise<string> {
|
||||
return await promoCodeCookie.serialize(code);
|
||||
}
|
||||
|
||||
export async function getPromoCodeFromCookie(request: Request): Promise<string | null> {
|
||||
const value = await promoCodeCookie.parse(request.headers.get("Cookie"));
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export async function clearPromoCodeCookie(): Promise<string> {
|
||||
return await promoCodeCookie.serialize("", { maxAge: 0 });
|
||||
}
|
||||
@@ -126,7 +126,7 @@
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"@trigger.dev/otlp-importer": "workspace:*",
|
||||
"@trigger.dev/platform": "1.0.29",
|
||||
"@trigger.dev/platform": "1.2.0",
|
||||
"@trigger.dev/plugins": "workspace:*",
|
||||
"@trigger.dev/rbac": "workspace:*",
|
||||
"@trigger.dev/redis-worker": "workspace:*",
|
||||
|
||||
Generated
+35
-14
@@ -504,8 +504,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../internal-packages/otlp-importer
|
||||
'@trigger.dev/platform':
|
||||
specifier: 1.0.29
|
||||
version: 1.0.29
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0(zod@3.25.76)
|
||||
'@trigger.dev/plugins':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugins
|
||||
@@ -3074,10 +3074,18 @@ packages:
|
||||
resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.29.7':
|
||||
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7':
|
||||
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3177,6 +3185,10 @@ packages:
|
||||
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -8320,8 +8332,10 @@ packages:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1
|
||||
|
||||
'@trigger.dev/platform@1.0.29':
|
||||
resolution: {integrity: sha512-75lsz0igwY9tqWfT6U7Huj+94VWic3//B4Cux4muCzH/ZC8Hz22O9fsMe+R7JtQy7HsemG42R+Zwy5ITnSgFYg==}
|
||||
'@trigger.dev/platform@1.2.0':
|
||||
resolution: {integrity: sha512-Wa/XlMlmo1vhol5DEBYW1gMW4wgUUqr/ClDQb4IgwrRdPeLLmTpIVsgMHmYJXkERqR04xVaX7wBibWl4sW+1Hg==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@types/acorn@4.0.6':
|
||||
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
|
||||
@@ -16411,9 +16425,6 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.18.0
|
||||
|
||||
zod@3.23.8:
|
||||
resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
@@ -18580,8 +18591,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
optional: true
|
||||
|
||||
'@babel/helper-string-parser@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-identifier@7.28.5':
|
||||
optional: true
|
||||
|
||||
'@babel/helper-validator-identifier@7.29.7': {}
|
||||
|
||||
'@babel/helper-validator-option@7.22.15': {}
|
||||
@@ -18689,6 +18706,12 @@ snapshots:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
to-fast-properties: 2.0.0
|
||||
|
||||
'@babel/types@7.29.0':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
optional: true
|
||||
|
||||
'@babel/types@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
@@ -24566,9 +24589,9 @@ snapshots:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@trigger.dev/platform@1.0.29':
|
||||
'@trigger.dev/platform@1.2.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
zod: 3.23.8
|
||||
zod: 3.25.76
|
||||
|
||||
'@types/acorn@4.0.6':
|
||||
dependencies:
|
||||
@@ -29249,8 +29272,8 @@ snapshots:
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
'@babel/parser': 7.27.0
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
optional: true
|
||||
|
||||
@@ -30288,7 +30311,7 @@ snapshots:
|
||||
|
||||
node-abi@3.89.0:
|
||||
dependencies:
|
||||
semver: 7.8.1
|
||||
semver: 7.7.3
|
||||
optional: true
|
||||
|
||||
node-abort-controller@3.1.1: {}
|
||||
@@ -34109,8 +34132,6 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod@3.23.8: {}
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
||||
Reference in New Issue
Block a user