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:
@@ -21,6 +21,12 @@ TURNSTILE_SECRET_KEY=""
|
|||||||
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
|
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
|
||||||
NEXT_PUBLIC_METRIKA_COUNTER_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
|
||||||
KINESCOPE_API_KEY=""
|
KINESCOPE_API_KEY=""
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ S3_REGION="eu-central"
|
|||||||
|
|
||||||
### Env
|
### 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`.
|
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
|
||||||
|
|
||||||
### Тариф — это отдельный курс со своей копией уроков
|
### Тариф — это отдельный курс со своей копией уроков
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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]) => `<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" } });
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
// price here is a display bug, not a money bug. When a price changes:
|
// price here is a display bug, not a money bug. When a price changes:
|
||||||
// products.yaml → landing → this file.
|
// products.yaml → landing → this file.
|
||||||
|
|
||||||
|
import { resolvePayBase } from "@/lib/pay-base";
|
||||||
|
|
||||||
export interface StoreTariff {
|
export interface StoreTariff {
|
||||||
sku: string; // must match payment-router/products.yaml
|
sku: string; // must match payment-router/products.yaml
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,8 +19,13 @@ export interface StoreCatalogEntry {
|
|||||||
tariffs: StoreTariff[];
|
tariffs: StoreTariff[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PAY_ORDER_URL = "https://obsidian.second-brain.ru/pay/order";
|
// Literal process.env.* reads so Next inlines them into the client bundle.
|
||||||
export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
|
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.
|
// Keys = course slugs shown as locked cards on the student dashboard.
|
||||||
// Only tariffs of the course's "native" landing are listed — cross-bundles
|
// Only tariffs of the course's "native" landing are listed — cross-bundles
|
||||||
|
|||||||
Reference in New Issue
Block a user