Subscription/checkout flow rewritten end-to-end. The plan-card "Devam Et" button silently wiped brand selection on re-click and the actual proceed button lived offscreen — fixed by collapsing plan/brands/payment/confirm into a single vertical stepper with one sticky CTA. Backend - Stripe Hosted Checkout (`/payments/stripe/checkout`) + webhook (`/payments/stripe/webhook`, raw body) replacing the stubbed Iyzico module. Webhook activates subscription on `checkout.session.completed`, expires the pending subscription on cancel/expire so users can retry. - New `bank_accounts` table — multiple rows, single `is_active` enforced by a partial unique index. Admin CRUD under `/admin/bank-accounts` with multipart QR upload to MinIO; atomic `/activate` swap in a transaction; `GET /payments/bank-info` returns the active row. - `payments` gains `stripe_session_id`, `stripe_payment_intent_id`, `bank_account_id`. EFT flow now reads the active bank account at payment time and stores the FK for reconciliation. - Env: `IYZICO_*` removed, `STRIPE_*` added (validated by zod schema). - `main.ts` `rawBody: true` for Stripe signature verification. - Drizzle 0003 snapshot id collision fixed (VIEW-only migration shared prevId with 0002, blocking new generates). Frontend - `/dashboard/subscription` rewritten as a 4-step vertical stepper with step-aware sticky bottom CTA; plan re-selection is idempotent and preserves brand state. `/dashboard/subscription/pay` deleted; Stripe returns to the same page via `?stripe=success|cancelled` and the UI polls `/subscriptions/me` until the webhook activates the row. - New components: `bank-transfer-card.tsx` (DB-driven IBAN + Kolay Adres + uploaded QR image + receipt upload) and `stripe-checkout-button.tsx`. - Active subscription view, trial onboarding/urgency banner, downgrade and cancel dialogs preserved. - TR/EN i18n: new `subscription.steps.*`, `subscription.stickyCta.*`, `payment.stripe.*`, `payment.bank.*`; provider label updated. PostHog: `method: "iyzico"` → `"stripe"`; new events `iban_copied`, `kolay_adres_copied`, `qr_viewed`, `eft_initiated`, `stripe_redirect_returned`. Deploy runs `db:migrate` which applies 0004_hot_quicksilver (additive: new table + nullable columns; safe to apply on prod). Operator must add `STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET` to env and create the first `bank_accounts` row via the admin endpoint before the bank tab works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
3.5 KiB
TypeScript
110 lines
3.5 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const envSchema = z.object({
|
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
|
PORT: z.coerce.number().default(4000),
|
|
|
|
DATABASE_URL: z.string().url(),
|
|
|
|
REDIS_HOST: z.string().default("127.0.0.1"),
|
|
REDIS_PORT: z.coerce.number().default(6379),
|
|
REDIS_PASSWORD: z.string(),
|
|
|
|
BETTER_AUTH_SECRET: z.string().min(32),
|
|
BETTER_AUTH_URL: z.string().url(),
|
|
|
|
GOOGLE_CLIENT_ID: z.string().optional(),
|
|
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
|
|
|
MINIO_ENDPOINT: z.string(),
|
|
MINIO_ACCESS_KEY: z.string(),
|
|
MINIO_SECRET_KEY: z.string(),
|
|
MINIO_BUCKET_NAME: z.string().default("sase-schemas"),
|
|
MINIO_PUBLIC_URL: z.string(),
|
|
MINIO_USE_SSL: z
|
|
.string()
|
|
.transform((v) => v === "true")
|
|
.default("false"),
|
|
|
|
CORS_ORIGIN: z.string().default("http://localhost:3000"),
|
|
|
|
STRIPE_SECRET_KEY: z.string().optional(),
|
|
STRIPE_PUBLISHABLE_KEY: z.string().optional(),
|
|
STRIPE_WEBHOOK_SECRET: z.string().optional(),
|
|
STRIPE_SUCCESS_URL: z
|
|
.string()
|
|
.url()
|
|
.default("http://localhost:3000/dashboard/subscription?stripe=success"),
|
|
STRIPE_CANCEL_URL: z
|
|
.string()
|
|
.url()
|
|
.default("http://localhost:3000/dashboard/subscription?stripe=cancelled"),
|
|
|
|
PL24_BASE_URL: z.string().optional(),
|
|
PL24_COMPANY_CODE: z.string().optional(),
|
|
PL24_USERNAME: z.string().optional(),
|
|
PL24_PASSWORD: z.string().optional(),
|
|
PL24_COMPANY_CODE_2: z.string().optional(), // de-708171
|
|
PL24_USERNAME_2: z.string().optional(),
|
|
PL24_PASSWORD_2: z.string().optional(),
|
|
PL24_PROXY_DE: z.string().optional(), // http://user:pass@gw.dataimpulse.com:10000
|
|
|
|
EMEX_USERNAME: z.string().optional(),
|
|
EMEX_PASSWORD: z.string().optional(),
|
|
|
|
// Parts-Catalogs (Playwright JWT capture + DataImpulse proxy)
|
|
PCAT_USE_PROXY: z.string().default("true"),
|
|
PCAT_PROXY_HOST: z.string().default("gw.dataimpulse.com"),
|
|
PCAT_PROXY_USER: z.string().optional(),
|
|
PCAT_PROXY_PASS: z.string().optional(),
|
|
|
|
ML_PREDICTION_ENABLED: z
|
|
.string()
|
|
.transform((v) => v === "true")
|
|
.default("false"),
|
|
|
|
// OpenRouter (used by scripts/emex-translate-bootstrap.ts → DeepSeek V3)
|
|
OPENROUTER_API_KEY: z.string().optional(),
|
|
|
|
// Postal Email
|
|
POSTAL_API_URL: z.string().url().optional(),
|
|
POSTAL_API_KEY: z.string().optional(),
|
|
POSTAL_FROM_ADDRESS: z.string().email().default("noreply@sase.tr"),
|
|
POSTAL_FROM_NAME: z.string().default("Sase.tr"),
|
|
|
|
// OpenTelemetry
|
|
OTEL_ENABLED: z
|
|
.string()
|
|
.transform((v) => v === "true")
|
|
.default("false"),
|
|
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
|
OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(),
|
|
OTEL_SERVICE_NAME: z.string().default("sase-api"),
|
|
OTEL_TRACE_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0),
|
|
|
|
// PostHog — product analytics (server-side)
|
|
POSTHOG_API_KEY: z.string().optional(),
|
|
POSTHOG_HOST: z.string().url().default("https://t.sase.tr"),
|
|
|
|
// Sentry — error tracking
|
|
SENTRY_DSN: z.string().url().optional(),
|
|
|
|
// Changelog automation (token-authed internal endpoint for Fusion webhook)
|
|
CHANGELOG_AUTOMATION_TOKEN: z.string().min(32).optional(),
|
|
});
|
|
|
|
export type Env = z.infer<typeof envSchema>;
|
|
|
|
export function validateEnv(
|
|
env: Record<string, unknown> = process.env as Record<string, unknown>,
|
|
): Env {
|
|
const result = envSchema.safeParse(env);
|
|
if (!result.success) {
|
|
const formatted = result.error.format();
|
|
console.error("Environment validation failed:");
|
|
console.error(JSON.stringify(formatted, null, 2));
|
|
throw new Error("Invalid environment variables");
|
|
}
|
|
return result.data;
|
|
}
|