From 62d76916caf21cbe652340198214f9d6c874b889 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 11 Nov 2026 12:14:12 +0500 Subject: [PATCH] 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 --- .env.example | 6 +++++ CLAUDE.md | 1 + src/app/dev/pay-stub/pay/methods/route.ts | 15 ++++++++++++ src/app/dev/pay-stub/pay/order/route.ts | 28 +++++++++++++++++++++++ src/lib/__tests__/pay-base.test.ts | 20 ++++++++++++++++ src/lib/pay-base.ts | 16 +++++++++++++ src/lib/store-catalog.ts | 11 +++++++-- 7 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/app/dev/pay-stub/pay/methods/route.ts create mode 100644 src/app/dev/pay-stub/pay/order/route.ts create mode 100644 src/lib/__tests__/pay-base.test.ts create mode 100644 src/lib/pay-base.ts diff --git a/.env.example b/.env.example index b803662..5767c84 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/CLAUDE.md b/CLAUDE.md index 997e804..f687e8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. ### Тариф — это отдельный курс со своей копией уроков diff --git a/src/app/dev/pay-stub/pay/methods/route.ts b/src/app/dev/pay-stub/pay/methods/route.ts new file mode 100644 index 0000000..f7f5b01 --- /dev/null +++ b/src/app/dev/pay-stub/pay/methods/route.ts @@ -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" }, + ], + }); +} diff --git a/src/app/dev/pay-stub/pay/order/route.ts b/src/app/dev/pay-stub/pay/order/route.ts new file mode 100644 index 0000000..713c9fb --- /dev/null +++ b/src/app/dev/pay-stub/pay/order/route.ts @@ -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) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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]) => `${escapeHtml(k)}${escapeHtml(typeof v === "string" ? v : "[файл]")}`) + .join(""); + const html = `Заглушка оплаты + +

Заглушка оплаты (dev)

+

Заказ не создан, данные никуда не отправлены. Так выглядела бы отправка на payment-router:

+${rows}
+

← На дашборд

`; + return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }); +} diff --git a/src/lib/__tests__/pay-base.test.ts b/src/lib/__tests__/pay-base.test.ts new file mode 100644 index 0000000..f8e893f --- /dev/null +++ b/src/lib/__tests__/pay-base.test.ts @@ -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"); + }); +}); diff --git a/src/lib/pay-base.ts b/src/lib/pay-base.ts new file mode 100644 index 0000000..a59bf2f --- /dev/null +++ b/src/lib/pay-base.ts @@ -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; +} diff --git a/src/lib/store-catalog.ts b/src/lib/store-catalog.ts index 56967f9..5ba37ec 100644 --- a/src/lib/store-catalog.ts +++ b/src/lib/store-catalog.ts @@ -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