diff --git a/apps/web/app/subscribe/celebrate/AccessStatus.test.tsx b/apps/web/app/subscribe/celebrate/AccessStatus.test.tsx new file mode 100644 index 00000000..e23f658e --- /dev/null +++ b/apps/web/app/subscribe/celebrate/AccessStatus.test.tsx @@ -0,0 +1,71 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/confetti-burst", () => ({ + ConfettiBurst: () => null, +})); + +vi.mock("@/components/discord-button", () => ({ + DiscordButton: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock("./access-status-helpers", () => ({ + STATUS_POLL_MS: 8_000, + STATUS_MAX_ATTEMPTS: 0, + formatDate: () => null, + derivePhase: () => "loading", + deriveMessage: () => "Checking access...", + shouldContinuePolling: () => true, + PHASE_ICON: { + loading: "o", + syncing: "o", + success: "ok", + warning: "!", + error: "x", + }, + PHASE_BANNER_CLASS: { + loading: "", + syncing: "", + success: " success", + warning: " warning", + error: " error", + }, +})); + +import { AccessStatus } from "./AccessStatus"; + +describe("AccessStatus", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + sessionStorage.clear(); + }); + + it("renders the slow-processing note without a literal HTML entity", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + grant: null, + roleSync: null, + sandbox: { isSandbox: false }, + }), + }), + ); + + render(); + + expect( + await screen.findByText( + /payment is confirmed - you can safely close this page and check back later\./, + ), + ).toBeInTheDocument(); + expect(screen.queryByText(/—/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/subscribe/celebrate/AccessStatus.tsx b/apps/web/app/subscribe/celebrate/AccessStatus.tsx index 4f8b759b..efc7ef74 100644 --- a/apps/web/app/subscribe/celebrate/AccessStatus.tsx +++ b/apps/web/app/subscribe/celebrate/AccessStatus.tsx @@ -15,6 +15,7 @@ import { PHASE_ICON, PHASE_BANNER_CLASS, } from "./access-status-helpers"; +import { getSandboxCelebrateNotice, shouldShowSandboxCelebrateNote } from "./sandboxSummary"; /** Scoped per-tier so separate checkouts each get their own celebration. */ const CELEBRATE_KEY_PREFIX = "celebrate_fresh_consumed:"; @@ -95,6 +96,8 @@ export function AccessStatus({ const phase = derivePhase(data, error); const message = deriveMessage(data, error, phase); const grantDate = formatDate(data?.grant?.validThrough ?? null); + const sandboxNotice = getSandboxCelebrateNotice(data); + const shouldShowSandboxSuccessNote = shouldShowSandboxCelebrateNote(data, phase); /* Don't let transient errors halt polling — loadStatus() clears error on the next successful response, so we keep polling through failures. */ @@ -186,13 +189,33 @@ export function AccessStatus({ {/* Failure guidance */} {phase === "warning" && - roleSyncStatus === "failed" && + (roleSyncStatus === "failed" || + roleSyncStatus === "pending" || + roleSyncStatus === "in_progress") && data?.grant?.status !== "past_due" && ( -

- We'll automatically retry syncing your Discord role. If this persists, contact - the server administrator. -

+
+

+ {roleSyncStatus === "failed" + ? "We'll automatically retry syncing your Discord role. If this persists, contact the server administrator." + : "Your entitlement is active, and Discord role sync is still processing in the background."} +

+ {roleSyncStatus === "failed" && data?.roleSync?.lastError && ( +

+ Current issue: {data.roleSync.lastError} +

+ )} + {shouldShowSandboxSuccessNote && sandboxNotice && ( +

+ {sandboxNotice} +

+ )} +
)} + {phase === "success" && shouldShowSandboxSuccessNote && sandboxNotice && ( +

+ {sandboxNotice} +

+ )} {phase === "warning" && data?.grant?.status === "past_due" && (

Your payment method may need updating. Please check your billing details. @@ -201,7 +224,7 @@ export function AccessStatus({ {pollExhausted && phase !== "warning" && phase !== "error" && (

This is taking longer than usual. Your access will be activated automatically once - payment is confirmed — you can safely close this page and check back later. + payment is confirmed - you can safely close this page and check back later.

)} diff --git a/apps/web/app/subscribe/celebrate/access-status-helpers.ts b/apps/web/app/subscribe/celebrate/access-status-helpers.ts index 663db461..4f083c17 100644 --- a/apps/web/app/subscribe/celebrate/access-status-helpers.ts +++ b/apps/web/app/subscribe/celebrate/access-status-helpers.ts @@ -12,6 +12,11 @@ export type StatusResponse = { ok: true; discordUserId: string; discordGuildId: string; + sandbox: { + isSandbox: boolean; + provider: "stripe" | "authorize_net" | "nmi" | null; + providerLabel: string | null; + }; tier: { id: string; slug: string; @@ -65,6 +70,12 @@ export function derivePhase(data: StatusResponse | null, error: string | null): const roleStatus = data.roleSync?.status ?? null; if (grantStatus === "active") { + if ( + data.sandbox.isSandbox && + (roleStatus === "pending" || roleStatus === "in_progress" || roleStatus === "failed") + ) { + return "warning"; + } if (roleStatus === "pending" || roleStatus === "in_progress") return "syncing"; if (roleStatus === "failed") return "warning"; return "success"; @@ -94,10 +105,14 @@ export function deriveMessage( ? "Syncing your Discord role..." : "Processing..."; case "success": - return "Access active"; + return data.sandbox.isSandbox ? "Access active in sandbox" : "Access active"; case "warning": if (data.grant.status === "past_due") return "Payment needs attention"; - return "Role sync issue \u2014 we'll retry automatically"; + return data.sandbox.isSandbox + ? roleStatus === "pending" || roleStatus === "in_progress" + ? "Sandbox access active \u2014 role sync is still pending" + : "Sandbox access active \u2014 role sync still needs attention" + : "Role sync issue \u2014 we'll retry automatically"; case "error": if (data.grant.status === "suspended_dispute") return "Access paused due to a dispute"; return "Access inactive"; diff --git a/apps/web/app/subscribe/celebrate/sandboxSummary.test.ts b/apps/web/app/subscribe/celebrate/sandboxSummary.test.ts new file mode 100644 index 00000000..a7ca052c --- /dev/null +++ b/apps/web/app/subscribe/celebrate/sandboxSummary.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { getSandboxCelebrateNotice, shouldShowSandboxCelebrateNote } from "./sandboxSummary"; +import type { StatusResponse } from "./access-status-helpers"; + +const makeStatus = (overrides: Partial = {}): StatusResponse => ({ + ok: true, + discordUserId: "user_1", + discordGuildId: "guild_1", + sandbox: { + isSandbox: true, + provider: "authorize_net", + providerLabel: "Authorize.Net", + }, + tier: { + id: "tier_1", + slug: "starter", + name: "Starter", + displayPrice: "$5 / month", + }, + grant: { + id: "grant_1", + status: "active", + validFrom: 1, + validThrough: null, + source: "authorize_net_subscription", + updatedAt: 2, + }, + roleSync: { + id: "sync_1", + status: "failed", + requestedAt: 1, + updatedAt: 2, + lastError: "Missing role permissions", + }, + ...overrides, +}); + +describe("sandbox celebrate summary", () => { + it("returns additive sandbox copy for sandbox grant results", () => { + expect(getSandboxCelebrateNotice(makeStatus())).toBe( + "Authorize.Net sandbox is active. Your entitlement succeeded for testing, and no real payment occurred.", + ); + }); + + it("shows sandbox note for success-with-warning role sync failures", () => { + expect(shouldShowSandboxCelebrateNote(makeStatus(), "warning")).toBe(true); + }); + + it("shows sandbox note while sandbox role sync is still pending", () => { + expect( + shouldShowSandboxCelebrateNote( + makeStatus({ + roleSync: { + id: "sync_3", + status: "pending", + requestedAt: 1, + updatedAt: 2, + lastError: null, + }, + }), + "warning", + ), + ).toBe(true); + }); + + it("shows sandbox note for full success", () => { + expect( + shouldShowSandboxCelebrateNote( + makeStatus({ + roleSync: { + id: "sync_2", + status: "completed", + requestedAt: 1, + updatedAt: 2, + lastError: null, + }, + }), + "success", + ), + ).toBe(true); + }); + + it("does not show sandbox note outside sandbox or for billing warnings", () => { + expect( + shouldShowSandboxCelebrateNote( + makeStatus({ + sandbox: { + isSandbox: false, + provider: null, + providerLabel: null, + }, + }), + "warning", + ), + ).toBe(false); + + expect( + shouldShowSandboxCelebrateNote( + makeStatus({ + grant: { + id: "grant_2", + status: "past_due", + validFrom: 1, + validThrough: null, + source: "authorize_net_subscription", + updatedAt: 2, + }, + }), + "warning", + ), + ).toBe(false); + }); +}); diff --git a/apps/web/app/subscribe/celebrate/sandboxSummary.ts b/apps/web/app/subscribe/celebrate/sandboxSummary.ts new file mode 100644 index 00000000..173b1195 --- /dev/null +++ b/apps/web/app/subscribe/celebrate/sandboxSummary.ts @@ -0,0 +1,33 @@ +import { formatCelebrateSandboxNotice } from "@/lib/subscribeSandbox"; +import type { StatusResponse } from "./access-status-helpers"; + +export function getSandboxCelebrateNotice(data: StatusResponse | null): string | null { + if (!data?.sandbox.isSandbox || !data.sandbox.provider || !data.sandbox.providerLabel) { + return null; + } + + return formatCelebrateSandboxNotice({ + isSandbox: true, + provider: data.sandbox.provider, + providerLabel: data.sandbox.providerLabel, + }); +} + +export function shouldShowSandboxCelebrateNote( + data: StatusResponse | null, + phase: string, +): boolean { + if (!data?.sandbox.isSandbox || !data.grant) { + return false; + } + + if (data.grant.status === "past_due") { + return false; + } + + if (phase === "success" || phase === "warning") { + return true; + } + + return false; +} diff --git a/apps/web/app/subscribe/pay/CombinedCheckout.test.tsx b/apps/web/app/subscribe/pay/CombinedCheckout.test.tsx index 19fb4e74..57af7b1d 100644 --- a/apps/web/app/subscribe/pay/CombinedCheckout.test.tsx +++ b/apps/web/app/subscribe/pay/CombinedCheckout.test.tsx @@ -9,6 +9,12 @@ const baseMethods = (overrides: Partial = {}): PaymentMe showStripe: false, showAuthorizeNet: false, showNmi: false, + sandboxContext: null, + sandboxContexts: { + authorizeNet: null, + stripe: null, + nmi: null, + }, stripeLabel: "Pay", authorizeNetConfig: { amount: "5.00", @@ -204,4 +210,80 @@ describe("CombinedCheckout age confirmation", () => { expect(requestInit.body).toContain('"fullName":"Ada Lovelace"'); expect(requestInit.body).not.toContain("ageConfirmed"); }); + + it("shows sandbox checkout notice when sandbox context is provided", () => { + renderCheckout({ + methods: baseMethods({ + showAuthorizeNet: true, + authorizeNetApiLoginId: "login", + authorizeNetClientKey: "client", + sandboxContext: { + isSandbox: true, + provider: "authorize_net", + providerLabel: "Authorize.Net", + }, + sandboxContexts: { + authorizeNet: { + isSandbox: true, + provider: "authorize_net", + providerLabel: "Authorize.Net", + }, + stripe: null, + nmi: null, + }, + }), + minimumAgeYears: null, + }); + + expect(screen.getByTestId("sandbox-checkout-notice-inline")).toHaveTextContent( + "Authorize.Net sandbox is active for this checkout. No real payment will occur.", + ); + expect( + screen.getAllByText( + "Authorize.Net sandbox is active for this checkout. No real payment will occur.", + ), + ).toHaveLength(1); + const cardSection = screen + .getByRole("heading", { name: "Credit / Debit Card" }) + .closest(".payment-card") as HTMLElement; + expect( + within(cardSection).queryByText(/Authorize\.Net sandbox is active for this checkout/), + ).not.toBeInTheDocument(); + }); + + it("keeps the sandbox notice scoped to the sandbox method when live Stripe is also visible", () => { + renderCheckout({ + methods: baseMethods({ + showAuthorizeNet: true, + showStripe: true, + authorizeNetApiLoginId: "login", + authorizeNetClientKey: "client", + sandboxContext: null, + sandboxContexts: { + authorizeNet: { + isSandbox: true, + provider: "authorize_net", + providerLabel: "Authorize.Net", + }, + stripe: null, + nmi: null, + }, + }), + minimumAgeYears: null, + }); + + expect(screen.queryByTestId("sandbox-checkout-notice-inline")).not.toBeInTheDocument(); + + const cardSection = screen + .getByRole("heading", { name: "Credit / Debit Card" }) + .closest(".payment-card") as HTMLElement; + const stripeSection = screen + .getByRole("heading", { name: "Stripe" }) + .closest(".payment-card") as HTMLElement; + + expect( + within(cardSection).getByText(/Authorize\.Net sandbox is active for this checkout/), + ).toBeInTheDocument(); + expect(within(stripeSection).queryByText(/No real payment will occur/)).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/app/subscribe/pay/CombinedCheckout.tsx b/apps/web/app/subscribe/pay/CombinedCheckout.tsx index 17f7169a..65da130e 100644 --- a/apps/web/app/subscribe/pay/CombinedCheckout.tsx +++ b/apps/web/app/subscribe/pay/CombinedCheckout.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { formatAgeConfirmationError } from "@/lib/ageRequirement"; +import { formatCheckoutSandboxNotice } from "@/lib/subscribeSandbox"; import { AgeConfirmationField } from "./AgeConfirmationField"; import { AuthorizeNetCard } from "./AuthorizeNetCard"; import type { PaymentMethodsResolved } from "./helpers"; @@ -116,6 +117,16 @@ export function CombinedCheckout({ const canClaimFree = methods.isFree && isActive; // Free-claim takes precedence — never show paid forms for a free tier. const canCheckout = hasPaymentMethods && isActive && !canClaimFree; + const authorizeNetSandboxNotice = methods.sandboxContexts.authorizeNet + ? formatCheckoutSandboxNotice(methods.sandboxContexts.authorizeNet) + : null; + const stripeSandboxNotice = methods.sandboxContexts.stripe + ? formatCheckoutSandboxNotice(methods.sandboxContexts.stripe) + : null; + const nmiSandboxNotice = methods.sandboxContexts.nmi + ? formatCheckoutSandboxNotice(methods.sandboxContexts.nmi) + : null; + const shouldRenderPerMethodSandboxNotices = methods.sandboxContext === null; return ( <> @@ -192,115 +203,131 @@ export function CombinedCheckout({ {/* Payment methods */} {canCheckout && ( -
- {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 && ( -
-

Stripe

-
guardIntake(e, "stripe")} - > - - - - - {ageGateEnabled && ( - + {methods.showStripe && ( +
+

Stripe

+ {shouldRenderPerMethodSandboxNotices && stripeSandboxNotice && ( +
{stripeSandboxNotice}
)} - {renderAgeConfirmationField("stripe")} - - -
- )} +
guardIntake(e, "stripe")} + > + + + + + {ageGateEnabled && ( + + )} + {renderAgeConfirmationField("stripe")} + +
+
+ )} - {methods.showNmi && ( - - )} + {methods.showNmi && ( + + )} +
)} 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 ?? ""); +}