Route storefront checkout to a local stub outside production

The checkout form and payment-method lookup were hardcoded to the live
payment router, so any local LMS (next dev included) created real
orders: a stray test click on 20261111 produced order 1794380127.

- resolvePayBase(): NEXT_PUBLIC_PAY_BASE_URL if set, otherwise the live
  router for production builds and /dev/pay-stub for everything else.
- /dev/pay-stub/pay/{methods,order}: dev-only stand-ins (404 in
  production) that make no outbound calls and store nothing.
- Document in CLAUDE.md that a local `next build && next start` still
  hits the live router unless NEXT_PUBLIC_PAY_BASE_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 12:14:12 +05:00
co-authored by Claude Opus 5.5
parent ba6da38b9c
commit 62d76916ca
7 changed files with 95 additions and 2 deletions
+6
View File
@@ -21,6 +21,12 @@ TURNSTILE_SECRET_KEY=""
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
NEXT_PUBLIC_METRIKA_COUNTER_ID=""
# Адрес payment-router для чекаута витрины (/pay/order, /pay/methods). Build-time.
# Пусто: dev → локальная заглушка /dev/pay-stub, production-сборка → боевой
# https://obsidian.second-brain.ru. Для локальной `next build && next start`
# задать явно (например http://localhost:9), иначе форма уйдёт на прод.
NEXT_PUBLIC_PAY_BASE_URL=""
# Kinescope API
KINESCOPE_API_KEY=""
+1
View File
@@ -220,6 +220,7 @@ S3_REGION="eu-central"
### Env
- **Чекаут витрины ходит в боевой payment-router из любой production-сборки.** Адрес `/pay/order` и `/pay/methods` выбирает `resolvePayBase` (`src/lib/pay-base.ts`): `NEXT_PUBLIC_PAY_BASE_URL`, иначе при `NODE_ENV=production` — `https://obsidian.second-brain.ru`, иначе — локальная заглушка `/dev/pay-stub` (в проде 404, ничего не сохраняет и не шлёт). `next dev` безопасен, а **локальная `next build && next start` без `NEXT_PUBLIC_PAY_BASE_URL` отправит форму на прод**: реальный заказ, pending в леджере, Telegram владельцу (случай 20261111, заказ 1794380127). Для такой сборки задавать переменную явно, например `http://localhost:9`. Браузерные проверки — только с сетевой блокировкой внешних доменов и без отправки форм.
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
### Тариф — это отдельный курс со своей копией уроков
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
// Local stand-in for payment-router GET /pay/methods (see src/lib/pay-base.ts).
// Production: 404. No outbound calls.
export function GET() {
if (process.env.NODE_ENV === "production") {
return new NextResponse(null, { status: 404 });
}
return NextResponse.json({
methods: [
{ id: "stub-card", label: "Заглушка: карта", provider: "dev-stub", kind: "online" },
{ id: "stub-invoice", label: "Заглушка: счёт", provider: "dev-stub", kind: "manual" },
],
});
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
// Local stand-in for payment-router POST /pay/order (see src/lib/pay-base.ts).
// Echoes the submitted fields back as a page. Stores nothing, sends nothing.
// Production: 404.
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
}
export async function POST(request: NextRequest) {
if (process.env.NODE_ENV === "production") {
return new NextResponse(null, { status: 404 });
}
const form = await request.formData();
const rows = [...form.entries()]
.map(([k, v]) => `<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(typeof v === "string" ? v : "[файл]")}</td></tr>`)
.join("");
const html = `<!doctype html><meta charset="utf-8"><title>Заглушка оплаты</title>
<body style="font-family:'Fira Mono',ui-monospace,monospace;background:#F5F5F0;color:#323232;padding:24px">
<h1 style="font-size:20px">Заглушка оплаты (dev)</h1>
<p>Заказ не создан, данные никуда не отправлены. Так выглядела бы отправка на payment-router:</p>
<table style="border:2px solid #323232;border-collapse:collapse" cellpadding="6">${rows}</table>
<p><a href="/dashboard">← На дашборд</a></p></body>`;
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { DEV_PAY_STUB_BASE, LIVE_PAY_BASE, resolvePayBase } from "../pay-base";
describe("resolvePayBase", () => {
it("dev без переменной — локальная заглушка", () => {
expect(resolvePayBase({ nodeEnv: "development" })).toBe(DEV_PAY_STUB_BASE);
expect(resolvePayBase({ nodeEnv: "test" })).toBe(DEV_PAY_STUB_BASE);
expect(resolvePayBase({})).toBe(DEV_PAY_STUB_BASE);
});
it("production без переменной — боевой роутер", () => {
expect(resolvePayBase({ nodeEnv: "production" })).toBe(LIVE_PAY_BASE);
expect(resolvePayBase({ nodeEnv: "production", payBaseUrl: " " })).toBe(LIVE_PAY_BASE);
});
it("явная переменная побеждает в любом режиме", () => {
expect(resolvePayBase({ nodeEnv: "production", payBaseUrl: "http://localhost:9" })).toBe("http://localhost:9");
expect(resolvePayBase({ nodeEnv: "development", payBaseUrl: "https://pay.example/" })).toBe("https://pay.example");
});
});
+16
View File
@@ -0,0 +1,16 @@
// Where the storefront checkout talks to: the payment router's /pay/order and
// /pay/methods. Production builds default to the live router; everything else
// (next dev, tests) defaults to the local stub so a stray click can't create a
// real order. An explicit NEXT_PUBLIC_PAY_BASE_URL always wins.
//
// ⚠️ NODE_ENV is "production" for a local `next build && next start` too —
// such a build hits the live router unless NEXT_PUBLIC_PAY_BASE_URL is set.
export const LIVE_PAY_BASE = "https://obsidian.second-brain.ru";
export const DEV_PAY_STUB_BASE = "/dev/pay-stub";
export function resolvePayBase(env: { payBaseUrl?: string; nodeEnv?: string }): string {
const explicit = env.payBaseUrl?.trim();
if (explicit) return explicit.replace(/\/+$/, "");
return env.nodeEnv === "production" ? LIVE_PAY_BASE : DEV_PAY_STUB_BASE;
}
+9 -2
View File
@@ -5,6 +5,8 @@
// price here is a display bug, not a money bug. When a price changes:
// products.yaml → landing → this file.
import { resolvePayBase } from "@/lib/pay-base";
export interface StoreTariff {
sku: string; // must match payment-router/products.yaml
label: string;
@@ -17,8 +19,13 @@ export interface StoreCatalogEntry {
tariffs: StoreTariff[];
}
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order";
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
// Literal process.env.* reads so Next inlines them into the client bundle.
const PAY_BASE = resolvePayBase({
payBaseUrl: process.env.NEXT_PUBLIC_PAY_BASE_URL,
nodeEnv: process.env.NODE_ENV,
});
export const PAY_ORDER_URL = `${PAY_BASE}/pay/order`;
export const PAY_METHODS_URL = `${PAY_BASE}/pay/methods`;
// Keys = course slugs shown as locked cards on the student dashboard.
// Only tariffs of the course's "native" landing are listed — cross-bundles