- {methods.showAuthorizeNet && (
-
-
Credit / Debit Card
-
{
- if (!validateAgeGate("card")) {
- return false;
- }
- if (anyIntake && intake) {
- const err = validateIntake(intake, fullName, email);
- if (err) {
- setIntakeError(err);
+
+ {methods.sandboxContext && (
+
+ {formatCheckoutSandboxNotice(methods.sandboxContext)}
+
+ )}
+
+ {methods.showAuthorizeNet && (
+
+
Credit / Debit Card
+ {shouldRenderPerMethodSandboxNotices && authorizeNetSandboxNotice && (
+
{authorizeNetSandboxNotice}
+ )}
+
{
+ if (!validateAgeGate("card")) {
return false;
}
+ if (anyIntake && intake) {
+ const err = validateIntake(intake, fullName, email);
+ if (err) {
+ setIntakeError(err);
+ return false;
+ }
+ }
+ setIntakeError(null);
+ return true;
}
- setIntakeError(null);
- return true;
- }
- : undefined
- }
- />
-
- )}
+ : undefined
+ }
+ />
+
+ )}
- {methods.showStripe && (
-
+ )}
- {methods.showNmi && (
-
-
NMI
- {renderAgeConfirmationField("nmi")}
-
{
- if (!guardIntake(e, "nmi")) {
- e.preventDefault();
- return;
- }
- // Save intake before navigating to external NMI checkout
- if (anyIntake || ageGateEnabled) {
- e.preventDefault();
- await fetch("/api/subscribe/intake", {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- fullName,
- email,
- ...(ageGateEnabled ? { ageConfirmed } : {}),
- }),
- }).catch(() => {});
- window.open(
- methods.nmiConfig?.hostedUrl ?? "",
- "_blank",
- "noopener,noreferrer",
- );
- }
- }}
- >
- Pay
-
-
- )}
+ {methods.showNmi && (
+
+
NMI
+ {shouldRenderPerMethodSandboxNotices && nmiSandboxNotice && (
+
{nmiSandboxNotice}
+ )}
+ {renderAgeConfirmationField("nmi")}
+
{
+ if (!guardIntake(e, "nmi")) {
+ e.preventDefault();
+ return;
+ }
+ // Save intake before navigating to external NMI checkout
+ if (anyIntake || ageGateEnabled) {
+ e.preventDefault();
+ await fetch("/api/subscribe/intake", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ fullName,
+ email,
+ ...(ageGateEnabled ? { ageConfirmed } : {}),
+ }),
+ }).catch(() => {});
+ window.open(
+ methods.nmiConfig?.hostedUrl ?? "",
+ "_blank",
+ "noopener,noreferrer",
+ );
+ }
+ }}
+ >
+ Pay
+
+
+ )}
+
)}
>
diff --git a/apps/web/app/subscribe/pay/helpers.ts b/apps/web/app/subscribe/pay/helpers.ts
index 3348e83b..589ec4ac 100644
--- a/apps/web/app/subscribe/pay/helpers.ts
+++ b/apps/web/app/subscribe/pay/helpers.ts
@@ -8,6 +8,14 @@ import {
type PaymentProviderFlags,
} from "@/lib/paymentProviders";
import { resolveStripeCheckoutConfig } from "@/lib/stripeCheckout";
+import { optionalEnv } from "@/lib/serverEnv";
+import {
+ resolveCheckoutSandboxContext,
+ resolveCheckoutSandboxContexts,
+ type PaymentMethodSandboxContexts,
+ resolveStripeSandboxEnvironment,
+ type SandboxContext,
+} from "@/lib/subscribeSandbox";
import { fetchTierForCheckout } from "@/lib/tierCatalog";
import { isTierFree } from "@/lib/api/subscribe/freeClaim";
@@ -16,6 +24,8 @@ export type PaymentMethodsResolved = {
showStripe: boolean;
showAuthorizeNet: boolean;
showNmi: boolean;
+ sandboxContext: SandboxContext | null;
+ sandboxContexts: PaymentMethodSandboxContexts;
stripeLabel: string;
authorizeNetConfig: {
amount: string;
@@ -88,9 +98,9 @@ export async function resolvePaymentMethods(
? resolveStripeCheckoutConfig(tier)
: { ok: false as const, error: "Stripe checkout is unavailable." };
const stripeLabel = stripeConfigResult.ok ? "Pay" : "Stripe unavailable";
-
const authorizeNetState = await resolveAuthorizeNetState(tier, guildId, authorizeNetEnabled);
const nmiConfig = resolveNmiState(tier);
+ const stripeEnvironment = resolveStripeSandboxEnvironment(optionalEnv("STRIPE_SECRET_KEY"));
const showStripe = stripeEnabled && Boolean(tier && stripeConfigResult.ok);
const showAuthorizeNet =
@@ -104,12 +114,30 @@ export async function resolvePaymentMethods(
const showNmi = nmiEnabled && Boolean(tier && nmiConfig && nmiConfig.hostedUrl);
const isFree = tier ? isTierFree(tier) : false;
+ const sandboxContexts = resolveCheckoutSandboxContexts({
+ showAuthorizeNet,
+ showStripe,
+ showNmi,
+ authorizeNetEnv: authorizeNetState.env,
+ stripeEnvironment,
+ nmiSandbox: false, // NMI sandbox mode is not yet surfaced from guild config.
+ });
+ const sandboxContext = resolveCheckoutSandboxContext({
+ showAuthorizeNet,
+ showStripe,
+ showNmi,
+ authorizeNetEnv: authorizeNetState.env,
+ stripeEnvironment,
+ nmiSandbox: false, // NMI sandbox mode is not yet surfaced from guild config.
+ });
return {
isFree,
showStripe,
showAuthorizeNet,
showNmi,
+ sandboxContext,
+ sandboxContexts,
stripeLabel,
authorizeNetConfig: authorizeNetState.config,
authorizeNetApiLoginId: authorizeNetState.apiLoginId,
diff --git a/apps/web/e2e/visual.spec.ts b/apps/web/e2e/visual.spec.ts
index 6834f737..9fb6bcc2 100644
--- a/apps/web/e2e/visual.spec.ts
+++ b/apps/web/e2e/visual.spec.ts
@@ -511,8 +511,47 @@ test.describe("visual", () => {
});
}
+ await page.route("**/api/subscribe/status**", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ok: true,
+ discordUserId: visualFlowMemberUserId,
+ discordGuildId: seeded.discordGuildId,
+ sandbox: {
+ isSandbox: true,
+ provider: "authorize_net",
+ providerLabel: "Authorize.Net",
+ },
+ tier: {
+ id: "starter-tier",
+ slug: "starter",
+ name: "Starter",
+ displayPrice: "$5 / month",
+ },
+ grant: {
+ id: "grant_visual_starter",
+ status: "active",
+ validFrom: fixedTimestamp,
+ validThrough: null,
+ source: "authorize_net_subscription",
+ updatedAt: fixedTimestamp,
+ },
+ roleSync: {
+ id: "role_sync_visual_failed",
+ status: "failed",
+ requestedAt: fixedTimestamp,
+ updatedAt: fixedTimestamp,
+ lastError: "Bot role hierarchy is below the managed tier role.",
+ },
+ }),
+ });
+ });
+
await page.goto(`/subscribe/celebrate?tier=starter`);
await expect(page.getByRole("heading", { name: "Your access" })).toBeVisible();
+ await expect(page.getByTestId("sandbox-success-note")).toBeVisible();
await waitForFonts(page);
await expect(page).toHaveScreenshot("subscribe-celebrate-viewport.png");
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-linux.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-linux.png
index b9e6a238..b86b2785 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-linux.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-linux.png differ
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-win32.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-win32.png
index 3d5ce315..0570c08b 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-win32.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-full-chromium-win32.png differ
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-linux.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-linux.png
index 5a34f59e..07233fc9 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-linux.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-linux.png differ
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-win32.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-win32.png
index 3ab3d793..96f4c4ef 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-win32.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-celebrate-viewport-chromium-win32.png differ
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-linux.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-linux.png
index 61e1c8f1..c0fce31b 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-linux.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-linux.png differ
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-win32.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-win32.png
index f68331ae..2b61c529 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-win32.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-age-gate-full-chromium-win32.png differ
diff --git a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-full-chromium-linux.png b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-full-chromium-linux.png
index 51b23478..c0fce31b 100644
Binary files a/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-full-chromium-linux.png and b/apps/web/e2e/visual.spec.ts-snapshots/subscribe-pay-full-chromium-linux.png differ
diff --git a/apps/web/lib/api/subscribe/status.ts b/apps/web/lib/api/subscribe/status.ts
index 3d0e8779..7445c1a1 100644
--- a/apps/web/lib/api/subscribe/status.ts
+++ b/apps/web/lib/api/subscribe/status.ts
@@ -1,4 +1,6 @@
import type { ConvexHttpClient } from "convex/browser";
+import { optionalEnv } from "@/lib/serverEnv";
+import { buildSandboxInfo, resolveStripeSandboxEnvironment } from "@/lib/subscribeSandbox";
type OwnerSession = {
guildId: string;
@@ -174,11 +176,18 @@ export async function resolveSubscribeStatus(args: {
: [];
const roleSync = roleSyncRequests?.[0] ?? null;
+ const sandbox = buildSandboxInfo({
+ source: grant?.source ?? null,
+ authorizeNetEnv: optionalEnv("AUTHORIZE_NET_ENV") ?? null,
+ stripeEnvironment: resolveStripeSandboxEnvironment(optionalEnv("STRIPE_SECRET_KEY")),
+ nmiSandbox: false, // NMI sandbox mode is not yet surfaced from guild config.
+ });
return {
status: 200,
body: {
ok: true,
+ sandbox,
accountId: ownerSession.accountId ?? null,
discordUserId: ownerSession.discordUserId ?? null,
discordGuildId: ownerSession.guildId,
diff --git a/apps/web/lib/subscribeSandbox.test.ts b/apps/web/lib/subscribeSandbox.test.ts
new file mode 100644
index 00000000..c72f86cb
--- /dev/null
+++ b/apps/web/lib/subscribeSandbox.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildSandboxInfo,
+ formatCheckoutSandboxNotice,
+ resolveCheckoutSandboxContexts,
+ resolveCheckoutSandboxContext,
+ resolveGrantSandboxContext,
+} from "./subscribeSandbox";
+
+describe("subscribe sandbox helpers", () => {
+ it("detects authorize.net sandbox checkout context", () => {
+ expect(
+ resolveCheckoutSandboxContext({
+ showAuthorizeNet: true,
+ showStripe: false,
+ showNmi: false,
+ authorizeNetEnv: "sandbox",
+ }),
+ ).toEqual({
+ isSandbox: true,
+ provider: "authorize_net",
+ providerLabel: "Authorize.Net",
+ });
+ });
+
+ it("does not flag production authorize.net checkout as sandbox", () => {
+ expect(
+ resolveCheckoutSandboxContext({
+ showAuthorizeNet: true,
+ showStripe: false,
+ showNmi: false,
+ authorizeNetEnv: "production",
+ }),
+ ).toBeNull();
+ });
+
+ it("treats missing authorize.net env as production-safe", () => {
+ expect(
+ resolveCheckoutSandboxContext({
+ showAuthorizeNet: true,
+ showStripe: false,
+ showNmi: false,
+ }),
+ ).toBeNull();
+ });
+
+ it("keeps mixed checkout methods from showing a global sandbox banner", () => {
+ const contexts = resolveCheckoutSandboxContexts({
+ showAuthorizeNet: true,
+ showStripe: true,
+ showNmi: false,
+ authorizeNetEnv: "sandbox",
+ stripeEnvironment: "production",
+ });
+
+ expect(contexts).toEqual({
+ authorizeNet: {
+ isSandbox: true,
+ provider: "authorize_net",
+ providerLabel: "Authorize.Net",
+ },
+ stripe: null,
+ nmi: null,
+ });
+ expect(
+ resolveCheckoutSandboxContext({
+ showAuthorizeNet: true,
+ showStripe: true,
+ showNmi: false,
+ authorizeNetEnv: "sandbox",
+ stripeEnvironment: "production",
+ }),
+ ).toBeNull();
+ });
+
+ it("does not create a provider-specific global banner when multiple sandbox methods are visible", () => {
+ expect(
+ resolveCheckoutSandboxContext({
+ showAuthorizeNet: true,
+ showStripe: true,
+ showNmi: false,
+ authorizeNetEnv: "sandbox",
+ stripeEnvironment: "sandbox",
+ }),
+ ).toBeNull();
+ });
+
+ it("builds additive checkout copy", () => {
+ expect(
+ formatCheckoutSandboxNotice({
+ isSandbox: true,
+ provider: "authorize_net",
+ providerLabel: "Authorize.Net",
+ }),
+ ).toBe("Authorize.Net sandbox is active for this checkout. No real payment will occur.");
+ });
+
+ it("detects sandbox celebrate state from grant source", () => {
+ expect(
+ resolveGrantSandboxContext({
+ source: "authorize_net_subscription",
+ authorizeNetEnv: "sandbox",
+ }),
+ ).toEqual({
+ isSandbox: true,
+ provider: "authorize_net",
+ providerLabel: "Authorize.Net",
+ });
+ });
+
+ it("clears provider details for production grants", () => {
+ expect(
+ buildSandboxInfo({
+ source: "authorize_net_subscription",
+ authorizeNetEnv: "production",
+ }),
+ ).toEqual({
+ isSandbox: false,
+ provider: null,
+ providerLabel: null,
+ });
+ });
+});
diff --git a/apps/web/lib/subscribeSandbox.ts b/apps/web/lib/subscribeSandbox.ts
new file mode 100644
index 00000000..a0600a98
--- /dev/null
+++ b/apps/web/lib/subscribeSandbox.ts
@@ -0,0 +1,195 @@
+import {
+ detectStripeEnvironment,
+ type ProviderEnvironment,
+ type ProviderKey,
+} from "@/lib/providerEnvironment";
+
+export type SandboxContext = {
+ isSandbox: true;
+ provider: ProviderKey;
+ providerLabel: string;
+};
+
+type CheckoutSandboxInput = {
+ showAuthorizeNet: boolean;
+ showStripe: boolean;
+ showNmi: boolean;
+ authorizeNetEnv?: string | null;
+ stripeEnvironment?: ProviderEnvironment | null;
+ nmiSandbox?: boolean;
+};
+
+type GrantSandboxInput = {
+ source?: string | null;
+ authorizeNetEnv?: string | null;
+ stripeEnvironment?: ProviderEnvironment | null;
+ nmiSandbox?: boolean;
+};
+
+export type SandboxInfo = {
+ isSandbox: boolean;
+ provider: ProviderKey | null;
+ providerLabel: string | null;
+};
+
+export type PaymentMethodSandboxContexts = {
+ authorizeNet: SandboxContext | null;
+ stripe: SandboxContext | null;
+ nmi: SandboxContext | null;
+};
+
+const PROVIDER_LABELS: Record
= {
+ stripe: "Stripe",
+ authorize_net: "Authorize.Net",
+ nmi: "NMI",
+};
+
+function normalizeEnvironment(value: string | null | undefined): "sandbox" | "production" | null {
+ const normalized = value?.trim().toLowerCase();
+ if (normalized === "sandbox" || normalized === "production") {
+ return normalized;
+ }
+ return null;
+}
+
+function resolveProviderSandboxContext(
+ provider: ProviderKey,
+ options?: {
+ authorizeNetEnv?: string | null;
+ stripeEnvironment?: ProviderEnvironment | null;
+ nmiSandbox?: boolean;
+ },
+): SandboxContext | null {
+ switch (provider) {
+ case "authorize_net": {
+ const environment = normalizeEnvironment(options?.authorizeNetEnv) ?? "production";
+ return environment === "sandbox"
+ ? {
+ isSandbox: true,
+ provider,
+ providerLabel: PROVIDER_LABELS[provider],
+ }
+ : null;
+ }
+ case "stripe": {
+ const environment = options?.stripeEnvironment ?? null;
+ return environment === "sandbox"
+ ? {
+ isSandbox: true,
+ provider,
+ providerLabel: PROVIDER_LABELS[provider],
+ }
+ : null;
+ }
+ case "nmi":
+ return options?.nmiSandbox
+ ? {
+ isSandbox: true,
+ provider,
+ providerLabel: PROVIDER_LABELS[provider],
+ }
+ : null;
+ }
+}
+
+function resolveProviderFromGrantSource(source: string | null | undefined): ProviderKey | null {
+ if (!source) {
+ return null;
+ }
+ if (source.startsWith("authorize_net_")) {
+ return "authorize_net";
+ }
+ if (source.startsWith("stripe_")) {
+ return "stripe";
+ }
+ if (source.startsWith("nmi_")) {
+ return "nmi";
+ }
+ return null;
+}
+
+export function resolveCheckoutSandboxContext(input: CheckoutSandboxInput): SandboxContext | null {
+ const contexts = resolveCheckoutSandboxContexts(input);
+ const visibleMethodCount =
+ Number(input.showAuthorizeNet) + Number(input.showStripe) + Number(input.showNmi);
+ const visibleSandboxContexts = Object.values(contexts).filter((context) => context !== null);
+
+ if (
+ visibleMethodCount !== 1 ||
+ visibleSandboxContexts.length === 0 ||
+ visibleSandboxContexts.length !== visibleMethodCount
+ ) {
+ return null;
+ }
+
+ return visibleSandboxContexts[0];
+}
+
+export function resolveCheckoutSandboxContexts(
+ input: CheckoutSandboxInput,
+): PaymentMethodSandboxContexts {
+ return {
+ authorizeNet: input.showAuthorizeNet
+ ? resolveProviderSandboxContext("authorize_net", {
+ authorizeNetEnv: input.authorizeNetEnv,
+ })
+ : null,
+ stripe: input.showStripe
+ ? resolveProviderSandboxContext("stripe", {
+ stripeEnvironment: input.stripeEnvironment,
+ })
+ : null,
+ nmi: input.showNmi
+ ? resolveProviderSandboxContext("nmi", {
+ nmiSandbox: input.nmiSandbox,
+ })
+ : null,
+ };
+}
+
+export function resolveGrantSandboxContext(input: GrantSandboxInput): SandboxContext | null {
+ const provider = resolveProviderFromGrantSource(input.source);
+ if (!provider) {
+ return null;
+ }
+ return resolveProviderSandboxContext(provider, {
+ authorizeNetEnv: input.authorizeNetEnv,
+ stripeEnvironment: input.stripeEnvironment,
+ nmiSandbox: input.nmiSandbox,
+ });
+}
+
+export function buildSandboxInfo(input: GrantSandboxInput): SandboxInfo {
+ const provider = resolveProviderFromGrantSource(input.source);
+ if (!provider) {
+ return { isSandbox: false, provider: null, providerLabel: null };
+ }
+
+ const context = resolveProviderSandboxContext(provider, {
+ authorizeNetEnv: input.authorizeNetEnv,
+ stripeEnvironment: input.stripeEnvironment,
+ nmiSandbox: input.nmiSandbox,
+ });
+
+ if (!context) {
+ return { isSandbox: false, provider: null, providerLabel: null };
+ }
+
+ return {
+ isSandbox: true,
+ provider: context.provider,
+ providerLabel: context.providerLabel,
+ };
+}
+
+export function formatCheckoutSandboxNotice(context: SandboxContext): string {
+ return `${context.providerLabel} sandbox is active for this checkout. No real payment will occur.`;
+}
+
+export function formatCelebrateSandboxNotice(context: SandboxContext): string {
+ return `${context.providerLabel} sandbox is active. Your entitlement succeeded for testing, and no real payment occurred.`;
+}
+
+export function resolveStripeSandboxEnvironment(secretKey: string | null | undefined) {
+ return detectStripeEnvironment(secretKey ?? "");
+}