Stripe integration + BillingResult
This commit is contained in:
parent
f2264eba16
commit
36da8a5a7f
5
.env
5
.env
@ -1,6 +1,5 @@
|
|||||||
IMG_TAG=20250716
|
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
|
||||||
SERVER1_PORT=5000
|
SERVER1_PORT=5000
|
||||||
SERVER2_PORT=5001
|
SERVER2_PORT=5001
|
||||||
SERVER3_PORT=5002
|
SERVER3_PORT=5002
|
||||||
SALARY_DB=/salary_info.db
|
IMG_TAG=202507281838
|
||||||
NODE_ENV=production
|
|
@ -1,42 +0,0 @@
|
|||||||
# ─── O*NET ───────────────────────────────
|
|
||||||
ONET_USERNAME=aptivaai
|
|
||||||
ONET_PASSWORD=2296ahq
|
|
||||||
|
|
||||||
# ─── Public‐facing React build ───────────
|
|
||||||
NODE_ENV=development
|
|
||||||
REACT_APP_ENV=development
|
|
||||||
APTIVA_API_BASE=https://dev1.aptivaai.com
|
|
||||||
REACT_APP_API_URL=${APTIVA_API_BASE}
|
|
||||||
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
|
|
||||||
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
|
|
||||||
REACT_APP_OPENAI_API_KEY=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
|
|
||||||
|
|
||||||
# ─── Back-end services ───────────────────
|
|
||||||
OPENAI_API_KEY=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
|
|
||||||
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
|
|
||||||
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
|
|
||||||
SALARY_DB=/salary_info.db
|
|
||||||
|
|
||||||
# ─── Database (premium server) ───────────
|
|
||||||
DB_HOST=34.67.180.54
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USER=sqluser
|
|
||||||
DB_PASSWORD=ps<g+2DO-eTb2mb5
|
|
||||||
DB_NAME=user_profile_db
|
|
||||||
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
|
|
||||||
|
|
||||||
# ── Twilio (needed only by server3) ─────────────────────────
|
|
||||||
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
|
|
||||||
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
|
|
||||||
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
|
|
||||||
|
|
||||||
# ─── Anything new goes here ──────────────
|
|
||||||
JWT_SECRET=gW4QsOu4AJA4MooIUC9ld2i71VbBovzV1INsaU6ftxYPrxLIeMq6/OY61j0X2RV7
|
|
||||||
|
|
||||||
# ------------ CORS ------------
|
|
||||||
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
|
|
||||||
SERVER1_PORT=5000
|
|
||||||
SERVER2_PORT=5001
|
|
||||||
SERVER3_PORT=5002
|
|
||||||
|
|
||||||
IMG_TAG=20250716
|
|
42
.env.staging
42
.env.staging
@ -1,42 +0,0 @@
|
|||||||
# ─── O*NET ───────────────────────────────
|
|
||||||
ONET_USERNAME=aptivaai
|
|
||||||
ONET_PASSWORD=2296ahq
|
|
||||||
|
|
||||||
# ─── Public‐facing React build ───────────
|
|
||||||
NODE_ENV=production
|
|
||||||
REACT_APP_ENV=staging
|
|
||||||
APTIVA_API_BASE=https://staging.aptivaai.com
|
|
||||||
REACT_APP_API_URL=${APTIVA_API_BASE}
|
|
||||||
REACT_APP_GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
|
|
||||||
REACT_APP_BLS_API_KEY=80d7a65a809a43f3a306a41ec874d231
|
|
||||||
REACT_APP_OPENAI_API_KEY=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
|
|
||||||
|
|
||||||
# ─── Back-end services ───────────────────
|
|
||||||
OPENAI_API_KEY=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
|
|
||||||
GOOGLE_MAPS_API_KEY=AIzaSyCTMgjiHUF2Vl3QriQu2kDEuZWz39ZAR20
|
|
||||||
COLLEGE_SCORECARD_KEY=BlZ0tIdmXVGI4G8NxJ9e6dXEiGUfAfnQJyw8bumj
|
|
||||||
SALARY_DB=/salary_info.db
|
|
||||||
|
|
||||||
# ─── Database (premium server) ───────────
|
|
||||||
DB_HOST=34.67.180.54
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_USER=sqluser
|
|
||||||
DB_PASSWORD=ps<g+2DO-eTb2mb5
|
|
||||||
DB_NAME=user_profile_db
|
|
||||||
GCP_CLOUD_SQL_PASSWORD=q2O}1PU-R:|l57S0
|
|
||||||
|
|
||||||
# ── Twilio (needed only by server3) ─────────────────────────
|
|
||||||
TWILIO_ACCOUNT_SID=ACd700c6fb9f691ccd9ccab73f2dd4173d
|
|
||||||
TWILIO_AUTH_TOKEN=fb8979ccb172032a249014c9c30eba80
|
|
||||||
TWILIO_MESSAGING_SERVICE_SID=MGMGaa07992a9231c841b1bfb879649026d6
|
|
||||||
|
|
||||||
# ─── Anything new goes here ──────────────
|
|
||||||
JWT_SECRET=a35F0iFAkkdWvSjnaLzepAl/JIxPRUh4NpcGptJgry2Z3KVLX4ZcYY5KaTf7kJY0
|
|
||||||
|
|
||||||
# ------------ env/staging.env ------------
|
|
||||||
CORS_ALLOWED_ORIGINS=https://staging.aptivaai.com,http://34.61.84.49:3000,http://localhost:3000
|
|
||||||
SERVER1_PORT=5000
|
|
||||||
SERVER2_PORT=5001
|
|
||||||
SERVER3_PORT=5002
|
|
||||||
|
|
||||||
IMG_TAG=20250716
|
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -21,3 +21,5 @@ yarn-error.log*
|
|||||||
.bashrc
|
.bashrc
|
||||||
_logout
|
_logout
|
||||||
env/*.env
|
env/*.env
|
||||||
|
*.env
|
||||||
|
uploads/
|
@ -1,12 +1,20 @@
|
|||||||
ARG APPPORT=5002
|
# ---- Dockerfile.server3 (fixed) ------------------------------
|
||||||
FROM node:20-slim
|
FROM node:20-bullseye
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1. native build dependencies + curl
|
||||||
|
RUN apt-get update -y && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
build-essential python3 pkg-config curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 2. node deps
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN apt-get update -y \
|
RUN npm ci --omit=dev --unsafe-perm
|
||||||
&& apt-get install -y build-essential python3 make g++ --no-install-recommends python3 git \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
# 3. static assets & source
|
||||||
RUN npm ci --omit=dev --ignore-scripts
|
COPY public/ /app/public/
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV PORT=5002
|
|
||||||
EXPOSE 5002
|
CMD ["node", "backend/server3.js"]
|
||||||
CMD ["node", "backend/server3.js"]
|
|
||||||
|
@ -18,6 +18,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const rootPath = path.resolve(__dirname, '..'); // Up one level
|
const rootPath = path.resolve(__dirname, '..'); // Up one level
|
||||||
const env = process.env.NODE_ENV?.trim() || 'development';
|
const env = process.env.NODE_ENV?.trim() || 'development';
|
||||||
|
const stage = env === 'staging' ? 'development' : env;
|
||||||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||||||
dotenv.config({ path: envPath }); // Load .env file
|
dotenv.config({ path: envPath }); // Load .env file
|
||||||
|
|
||||||
@ -34,7 +35,6 @@ if (!JWT_SECRET) {
|
|||||||
process.exit(1); // container exits, Docker marks it unhealthy
|
process.exit(1); // container exits, Docker marks it unhealthy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create a MySQL pool for user_profile data
|
// Create a MySQL pool for user_profile data
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: DB_HOST,
|
host: DB_HOST,
|
||||||
@ -62,10 +62,6 @@ if (!process.env.CORS_ALLOWED_ORIGINS) {
|
|||||||
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line
|
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!process.env.APTIVA_API_BASE) {
|
|
||||||
console.error('FATAL APTIVA_API_BASE is not set'); // eslint-disable-line
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
|
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
|
||||||
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||||
|
@ -19,22 +19,53 @@ import db from './config/mysqlPool.js';
|
|||||||
import './jobs/reminderCron.js';
|
import './jobs/reminderCron.js';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
|
import Stripe from 'stripe';
|
||||||
import { createReminder } from './utils/smsService.js';
|
import { createReminder } from './utils/smsService.js';
|
||||||
import { cacheSummary } from "./utils/ctxCache.js";
|
import { cacheSummary } from "./utils/ctxCache.js";
|
||||||
|
|
||||||
const rootPath = path.resolve(__dirname, '..'); // one level up
|
const rootPath = path.resolve(__dirname, '..'); // one level up
|
||||||
const env = (process.env.NODE_ENV || 'production'); // production in prod
|
const env = (process.env.NODE_ENV || 'production'); // production in prod
|
||||||
|
const stage = env === 'staging' ? 'development' : env;
|
||||||
const envPath = path.resolve(rootPath, `.env.${env}`); // => /app/.env.production
|
const envPath = path.resolve(rootPath, `.env.${env}`); // => /app/.env.production
|
||||||
dotenv.config({ path: envPath });
|
if (!process.env.FROM_SECRETS_MANAGER) {
|
||||||
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
dotenv.config({ path: envPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.SERVER3_PORT || 5002;
|
||||||
|
const API_BASE = `http://localhost:${PORT}/api`;
|
||||||
|
|
||||||
|
/* ─── helper: canonical public origin ─────────────────────────── */
|
||||||
|
const PUBLIC_BASE = (
|
||||||
|
process.env.APTIVA_AI_BASE // ← preferred
|
||||||
|
|| process.env.REACT_APP_API_URL // ← old name, tolerated
|
||||||
|
|| ''
|
||||||
|
).replace(/\/+$/, ''); // strip trailing “/”
|
||||||
|
|
||||||
|
/* allow‑list for redirects to block open‑redirect attacks */
|
||||||
|
const ALLOWED_REDIRECT_HOSTS = new Set([
|
||||||
|
new URL(PUBLIC_BASE || 'http://localhost').host
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isSafeRedirect(url) {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return ALLOWED_REDIRECT_HOSTS.has(u.host) && u.protocol === 'https:';
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.SERVER3_PORT || 5002;
|
|
||||||
const { getDocument } = pkg;
|
const { getDocument } = pkg;
|
||||||
const bt = "`".repeat(3);
|
const bt = "`".repeat(3);
|
||||||
|
|
||||||
function internalFetch(req, url, opts = {}) {
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
return fetch(url, {
|
apiVersion: '2024-04-10',
|
||||||
|
});
|
||||||
|
|
||||||
|
// at top of backend/server.js (do once per server codebase)
|
||||||
|
app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
|
||||||
|
|
||||||
|
function internalFetch(req, urlPath, opts = {}) {
|
||||||
|
return fetch(`${API_BASE}${urlPath}`, {
|
||||||
...opts,
|
...opts,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -44,6 +75,58 @@ function internalFetch(req, url, opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.post('/api/premium/stripe/webhook',
|
||||||
|
express.raw({ type: 'application/json' }),
|
||||||
|
async (req, res) => {
|
||||||
|
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
req.body,
|
||||||
|
req.headers['stripe-signature'],
|
||||||
|
process.env.STRIPE_WH_SECRET
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('⚠️ Bad Stripe signature', err.message);
|
||||||
|
return res.status(400).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const upFlags = async (customerId, premium, pro) => {
|
||||||
|
console.log('[Stripe] upFlags ->', { customerId, premium, pro});
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE user_profile
|
||||||
|
SET is_premium = ?, is_pro_premium = ?
|
||||||
|
WHERE stripe_customer_id = ?`,
|
||||||
|
[premium, pro, customerId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'customer.subscription.created':
|
||||||
|
case 'customer.subscription.updated': {
|
||||||
|
const sub = event.data.object;
|
||||||
|
const pid = sub.items.data[0].price.id;
|
||||||
|
const tier = [process.env.STRIPE_PRICE_PRO_MONTH, process.env.STRIPE_PRICE_PRO_YEAR]
|
||||||
|
.includes(pid) ? 'pro' : 'premium';
|
||||||
|
await upFlags(sub.customer, tier === 'premium', tier === 'pro');
|
||||||
|
break;
|
||||||
|
console.log('[Stripe] flags updated', { id: sub.customer, tier });
|
||||||
|
}
|
||||||
|
case 'customer.subscription.deleted': {
|
||||||
|
const sub = event.data.object;
|
||||||
|
await upFlags(sub.customer, 0, 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// ignore everything else
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).end();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
// 2) Basic middlewares
|
// 2) Basic middlewares
|
||||||
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
|
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
|
||||||
app.use(express.json({ limit: '5mb' }));
|
app.use(express.json({ limit: '5mb' }));
|
||||||
@ -53,10 +136,6 @@ if (!process.env.CORS_ALLOWED_ORIGINS) {
|
|||||||
console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
|
console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!process.env.APTIVA_API_BASE) {
|
|
||||||
console.error('FATAL APTIVA_API_BASE is not set');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Allowed origins for CORS (comma-separated in env) ─────── */
|
/* ─── Allowed origins for CORS (comma-separated in env) ─────── */
|
||||||
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||||||
@ -115,8 +194,54 @@ const authenticatePremiumUser = (req, res, next) => {
|
|||||||
|
|
||||||
const pool = db;
|
const pool = db;
|
||||||
|
|
||||||
// at top of backend/server.js (do once per server codebase)
|
|
||||||
app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
|
/** ------------------------------------------------------------------
|
||||||
|
* Returns the user’s stripe_customer_id (or null) given req.id.
|
||||||
|
* Creates a new Stripe Customer & saves it if missing.
|
||||||
|
* ----------------------------------------------------------------- */
|
||||||
|
async function getOrCreateStripeCustomerId(req) {
|
||||||
|
// 1) look up current row
|
||||||
|
const [[row]] = await pool.query(
|
||||||
|
'SELECT stripe_customer_id FROM user_profile WHERE id = ?',
|
||||||
|
[req.id]
|
||||||
|
);
|
||||||
|
if (row?.stripe_customer_id) return row.stripe_customer_id;
|
||||||
|
|
||||||
|
// 2) create → cache → return
|
||||||
|
const customer = await stripe.customers.create({ metadata: { userId: req.id } });
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE user_profile SET stripe_customer_id = ? WHERE id = ?',
|
||||||
|
[customer.id, req.id]
|
||||||
|
);
|
||||||
|
return customer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceMap = {
|
||||||
|
premium: { monthly: process.env.STRIPE_PRICE_PREMIUM_MONTH,
|
||||||
|
annual : process.env.STRIPE_PRICE_PREMIUM_YEAR },
|
||||||
|
pro : { monthly: process.env.STRIPE_PRICE_PRO_MONTH,
|
||||||
|
annual : process.env.STRIPE_PRICE_PRO_YEAR }
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/premium/subscription/status', authenticatePremiumUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [[row]] = await pool.query(
|
||||||
|
'SELECT is_premium, is_pro_premium FROM user_profile WHERE id = ?',
|
||||||
|
[req.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
is_premium : !!row.is_premium,
|
||||||
|
is_pro_premium : !!row.is_pro_premium
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('subscription/status error:', err);
|
||||||
|
return res.status(500).json({ error: 'DB error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
/* ========================================================================
|
/* ========================================================================
|
||||||
* applyOps – executes the “milestones” array inside a fenced ```ops block
|
* applyOps – executes the “milestones” array inside a fenced ```ops block
|
||||||
@ -125,11 +250,10 @@ app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
|
|||||||
async function applyOps(opsObj, req) {
|
async function applyOps(opsObj, req) {
|
||||||
if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return [];
|
if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return [];
|
||||||
|
|
||||||
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
|
|
||||||
const confirmations = [];
|
const confirmations = [];
|
||||||
|
|
||||||
// helper for authenticated fetches that keep headers
|
// helper for authenticated fetches that keep headers
|
||||||
const auth = (path, opts = {}) => internalFetch(req, `${apiBase}${path}`, opts);
|
const auth = (path, opts = {}) => internalFetch(req, path, opts);
|
||||||
|
|
||||||
for (const m of opsObj.milestones) {
|
for (const m of opsObj.milestones) {
|
||||||
const { op } = m || {};
|
const { op } = m || {};
|
||||||
@ -915,9 +1039,9 @@ ${econText}
|
|||||||
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
|
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
|
||||||
let aiRisk = null;
|
let aiRisk = null;
|
||||||
try {
|
try {
|
||||||
const aiRiskRes = await internalFetch(
|
const aiRiskRes = await auth(
|
||||||
req,
|
req,
|
||||||
`${apiBase}/premium/ai-risk-analysis`,
|
'/premium/ai-risk-analysis',
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -1289,7 +1413,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Call your existing milestone endpoint
|
// Call your existing milestone endpoint
|
||||||
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
|
const msRes = await auth(req, '/premium/milestone', {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(milestoneBody)
|
body: JSON.stringify(milestoneBody)
|
||||||
@ -1324,7 +1448,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
|
|||||||
due_date: taskObj.due_date || null
|
due_date: taskObj.due_date || null
|
||||||
};
|
};
|
||||||
|
|
||||||
await internalFetch(req, `${apiBase}/premium/tasks`, {
|
await auth(req, '/premium/tasks', {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(taskBody)
|
body: JSON.stringify(taskBody)
|
||||||
@ -1358,7 +1482,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
|
|||||||
end_date: impObj.end_date || null
|
end_date: impObj.end_date || null
|
||||||
};
|
};
|
||||||
|
|
||||||
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
|
await auth(req, '/premium/milestone-impacts', {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(impactBody)
|
body: JSON.stringify(impactBody)
|
||||||
@ -1531,7 +1655,7 @@ Always end with: “AptivaAI is an educational tool – not advice.”
|
|||||||
|
|
||||||
if (payloadObj?.cloneScenario) {
|
if (payloadObj?.cloneScenario) {
|
||||||
/* ------ CLONE ------ */
|
/* ------ CLONE ------ */
|
||||||
await internalFetch(req, `${apiBase}/premium/career-profile/clone`, {
|
await auth(req, '/premium/career-profile/clone', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body : JSON.stringify(payloadObj.cloneScenario),
|
body : JSON.stringify(payloadObj.cloneScenario),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
@ -3798,6 +3922,57 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/premium/stripe/create-checkout-session',
|
||||||
|
authenticatePremiumUser,
|
||||||
|
async (req, res) => {
|
||||||
|
const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } =
|
||||||
|
req.body || {};
|
||||||
|
|
||||||
|
const priceId = priceMap?.[tier]?.[cycle];
|
||||||
|
if (!priceId) return res.status(400).json({ error: 'Bad tier or cycle' });
|
||||||
|
|
||||||
|
const customerId = await getOrCreateStripeCustomerId(req);
|
||||||
|
|
||||||
|
const base = PUBLIC_BASE || `https://${req.headers.host}`;
|
||||||
|
const defaultSuccess = `${base}/billing?ck=success`;
|
||||||
|
const defaultCancel = `${base}/billing?ck=cancel`;
|
||||||
|
|
||||||
|
const safeSuccess = success_url && isSafeRedirect(success_url)
|
||||||
|
? success_url : defaultSuccess;
|
||||||
|
const safeCancel = cancel_url && isSafeRedirect(cancel_url)
|
||||||
|
? cancel_url : defaultCancel;
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode : 'subscription',
|
||||||
|
customer : customerId,
|
||||||
|
line_items : [{ price: priceId, quantity: 1 }],
|
||||||
|
allow_promotion_codes : true,
|
||||||
|
success_url : safeSuccess,
|
||||||
|
cancel_url : safeCancel
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url: session.url });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/premium/stripe/customer-portal',
|
||||||
|
authenticatePremiumUser,
|
||||||
|
async (req, res) => {
|
||||||
|
const base = PUBLIC_BASE || `https://${req.headers.host}`;
|
||||||
|
const { return_url } = req.query;
|
||||||
|
const safeReturn = return_url && isSafeRedirect(return_url)
|
||||||
|
? return_url
|
||||||
|
: `${base}/billing`;
|
||||||
|
const cid = await getOrCreateStripeCustomerId(req); // never null now
|
||||||
|
|
||||||
|
const portal = await stripe.billingPortal.sessions.create({
|
||||||
|
customer : cid,
|
||||||
|
return_url
|
||||||
|
});
|
||||||
|
res.json({ url: portal.url });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
FALLBACK 404
|
FALLBACK 404
|
||||||
------------------------------------------------------------------ */
|
------------------------------------------------------------------ */
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
const SECRET_KEY = process.env.SECRET_KEY || "supersecurekey";
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds `req.user = { id: <user_profile.id> }`
|
* Adds `req.user = { id: <user_profile.id> }`
|
||||||
@ -10,7 +10,7 @@ export default function authenticateUser(req, res, next) {
|
|||||||
if (!token) return res.status(401).json({ error: "Authorization token required" });
|
if (!token) return res.status(401).json({ error: "Authorization token required" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = jwt.verify(token, SECRET_KEY);
|
const { id } = jwt.verify(token, JWT_SECRET);
|
||||||
req.user = { id }; // attach the id for downstream use
|
req.user = { id }; // attach the id for downstream use
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
72
deploy_all.sh
Executable file
72
deploy_all.sh
Executable file
@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# CONFIG – adjust only the 4 lines below if you change projects
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
ENV=dev # secret suffix, e.g. JWT_SECRET_staging
|
||||||
|
PROJECT=aptivaai-dev
|
||||||
|
ROOT=/home/jcoakley/aptiva-dev1-app
|
||||||
|
REG=us-central1-docker.pkg.dev/${PROJECT}/aptiva-repo
|
||||||
|
|
||||||
|
ENV_FILE="${ROOT}/.env" # ← holds NON‑sensitive values only
|
||||||
|
SECRETS=(
|
||||||
|
JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD
|
||||||
|
STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR
|
||||||
|
DB_HOST DB_PORT DB_USER DB_PASSWORD
|
||||||
|
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID
|
||||||
|
)
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
echo "🛠 Building front‑end bundle"
|
||||||
|
npm ci --silent # installs if node_modules is missing/old
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 1. Build ➔ Push ➔ Bump IMG_TAG in .env
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
TAG=$(date -u +%Y%m%d%H%M)
|
||||||
|
echo "🔨 Building & pushing containers (tag = ${TAG})"
|
||||||
|
|
||||||
|
for svc in server1 server2 server3; do
|
||||||
|
docker build -f Dockerfile."$svc" -t "${REG}/${svc}:${TAG}" .
|
||||||
|
docker push "${REG}/${svc}:${TAG}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# keep .env for static, non‑sensitive keys (ports, API_BASE…)
|
||||||
|
if grep -q '^IMG_TAG=' "$ENV_FILE"; then
|
||||||
|
sed -i "s/^IMG_TAG=.*/IMG_TAG=${TAG}/" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
echo "IMG_TAG=${TAG}" >> "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
echo "✅ .env updated with IMG_TAG=${TAG}"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 2. Export secrets straight from Secret Manager
|
||||||
|
# (they live only in this shell, never on disk)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
echo "🔐 Pulling ${ENV} secrets from Secret Manager"
|
||||||
|
for S in "${SECRETS[@]}"; do
|
||||||
|
export "$S"="$(gcloud secrets versions access latest \
|
||||||
|
--secret="${S}_${ENV}" \
|
||||||
|
--project="$PROJECT")"
|
||||||
|
done
|
||||||
|
|
||||||
|
# A flag so we can see in the container env where they came from
|
||||||
|
export FROM_SECRETS_MANAGER=true
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 3. Re‑create the stack
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Preserve only the variables docker‑compose needs for expansion
|
||||||
|
preserve=IMG_TAG,FROM_SECRETS_MANAGER,REACT_APP_API_URL,$(IFS=,; echo "${SECRETS[*]}")
|
||||||
|
|
||||||
|
|
||||||
|
echo "🚀 docker compose up -d (with preserved env: $preserve)"
|
||||||
|
sudo --preserve-env="$preserve" docker compose up -d --force-recreate 2> >(grep -v 'WARN
|
||||||
|
|
||||||
|
\[0000\]
|
||||||
|
|
||||||
|
')
|
||||||
|
|
||||||
|
echo "✅ Deployment finished"
|
@ -1,15 +1,28 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A single env‑file (.env) contains ONLY non‑secret constants.
|
||||||
|
# Every secret is exported from fetch‑secrets.sh and injected at deploy time.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
x-env: &with-env
|
x-env: &with-env
|
||||||
env_file:
|
env_file:
|
||||||
- ${RUNTIME_ENV_FILE:-.env.production} # default for local runs
|
- .env # committed, non‑secret
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# ───────────────────────────── server1 ─────────────────────────────
|
||||||
server1:
|
server1:
|
||||||
<<: *with-env
|
<<: *with-env
|
||||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
|
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
|
||||||
expose: ["${SERVER1_PORT}"]
|
expose: ["${SERVER1_PORT}"]
|
||||||
environment:
|
environment:
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
DB_HOST: ${DB_HOST}
|
||||||
|
DB_PORT: ${DB_PORT}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
SALARY_DB_PATH: /app/salary_info.db
|
SALARY_DB_PATH: /app/salary_info.db
|
||||||
|
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||||
volumes:
|
volumes:
|
||||||
- ./salary_info.db:/app/salary_info.db:ro
|
- ./salary_info.db:/app/salary_info.db:ro
|
||||||
- ./user_profile.db:/app/user_profile.db
|
- ./user_profile.db:/app/user_profile.db
|
||||||
@ -19,14 +32,26 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# ───────────────────────────── server2 ─────────────────────────────
|
||||||
server2:
|
server2:
|
||||||
|
<<: *with-env
|
||||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG}
|
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG}
|
||||||
expose: ["${SERVER2_PORT}"]
|
expose: ["${SERVER2_PORT}"]
|
||||||
restart: unless-stopped
|
environment:
|
||||||
env_file:
|
ONET_USERNAME: ${ONET_USERNAME}
|
||||||
- ${RUNTIME_ENV_FILE}
|
ONET_PASSWORD: ${ONET_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
DB_HOST: ${DB_HOST}
|
||||||
|
DB_PORT: ${DB_PORT}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
|
SALARY_DB_PATH: /app/salary_info.db
|
||||||
|
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public:/app/public:ro
|
- ./public:/app/public:ro
|
||||||
- ./salary_info.db:/app/salary_info.db:ro
|
- ./salary_info.db:/app/salary_info.db:ro
|
||||||
- ./user_profile.db:/app/user_profile.db
|
- ./user_profile.db:/app/user_profile.db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -35,24 +60,48 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# ───────────────────────────── server3 ─────────────────────────────
|
||||||
server3:
|
server3:
|
||||||
<<: *with-env
|
<<: *with-env
|
||||||
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
|
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
|
||||||
expose: ["${SERVER3_PORT}"]
|
expose: ["${SERVER3_PORT}"]
|
||||||
|
environment:
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
||||||
|
STRIPE_WH_SECRET: ${STRIPE_WH_SECRET}
|
||||||
|
STRIPE_PRICE_PREMIUM_MONTH: ${STRIPE_PRICE_PREMIUM_MONTH}
|
||||||
|
STRIPE_PRICE_PREMIUM_YEAR: ${STRIPE_PRICE_PREMIUM_YEAR}
|
||||||
|
STRIPE_PRICE_PRO_MONTH: ${STRIPE_PRICE_PRO_MONTH}
|
||||||
|
STRIPE_PRICE_PRO_YEAR: ${STRIPE_PRICE_PRO_YEAR}
|
||||||
|
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID}
|
||||||
|
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN}
|
||||||
|
TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID}
|
||||||
|
DB_HOST: ${DB_HOST}
|
||||||
|
DB_PORT: ${DB_PORT}
|
||||||
|
DB_USER: ${DB_USER}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_NAME: ${DB_NAME}
|
||||||
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||||
|
SALARY_DB_PATH: /app/salary_info.db
|
||||||
|
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
|
||||||
|
volumes:
|
||||||
|
- ./salary_info.db:/app/salary_info.db:ro
|
||||||
|
- ./user_profile.db:/app/user_profile.db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# ───────────────────────────── nginx ───────────────────────────────
|
||||||
nginx:
|
nginx:
|
||||||
<<: *with-env
|
<<: *with-env
|
||||||
image: nginx:1.25-alpine
|
image: nginx:1.25-alpine
|
||||||
command: ["nginx", "-g", "daemon off;"]
|
command: ["nginx", "-g", "daemon off;"]
|
||||||
depends_on: [server1, server2, server3]
|
depends_on: [server1, server2, server3]
|
||||||
ports:
|
ports: ["80:80", "443:443"]
|
||||||
- "80:80"
|
|
||||||
- "443:443"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./build:/usr/share/nginx/html:ro
|
- ./build:/usr/share/nginx/html:ro
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
15
package-lock.json
generated
15
package-lock.json
generated
@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "aptiva-dev1-app",
|
"name": "aptiva-dev1-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.0.0",
|
"@radix-ui/react-dialog": "^1.0.0",
|
||||||
@ -53,6 +52,7 @@
|
|||||||
"react-spinners": "^0.15.0",
|
"react-spinners": "^0.15.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
|
"stripe": "^14.0.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"twilio": "^5.7.1",
|
"twilio": "^5.7.1",
|
||||||
@ -18715,6 +18715,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "14.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
|
||||||
|
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=8.1.0",
|
||||||
|
"qs": "^6.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/style-loader": {
|
"node_modules/style-loader": {
|
||||||
"version": "3.3.4",
|
"version": "3.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",
|
||||||
|
@ -47,6 +47,7 @@
|
|||||||
"react-spinners": "^0.15.0",
|
"react-spinners": "^0.15.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
|
"stripe": "^14.0.0",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"twilio": "^5.7.1",
|
"twilio": "^5.7.1",
|
||||||
|
6
playwright.config.js
Normal file
6
playwright.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: 'tests',
|
||||||
|
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
@ -38,6 +38,7 @@ import LoanRepaymentPage from './components/LoanRepaymentPage.js';
|
|||||||
import usePageContext from './utils/usePageContext.js';
|
import usePageContext from './utils/usePageContext.js';
|
||||||
import ChatDrawer from './components/ChatDrawer.js';
|
import ChatDrawer from './components/ChatDrawer.js';
|
||||||
import ChatCtx from './contexts/ChatCtx.js';
|
import ChatCtx from './contexts/ChatCtx.js';
|
||||||
|
import BillingResult from './components/BillingResult.js';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -222,7 +223,7 @@ const uiToolHandlers = useMemo(() => {
|
|||||||
<ProfileCtx.Provider
|
<ProfileCtx.Provider
|
||||||
value={{ financialProfile, setFinancialProfile,
|
value={{ financialProfile, setFinancialProfile,
|
||||||
scenario, setScenario,
|
scenario, setScenario,
|
||||||
user, }}
|
user, setUser}}
|
||||||
>
|
>
|
||||||
<ChatCtx.Provider value={{ setChatSnapshot,
|
<ChatCtx.Provider value={{ setChatSnapshot,
|
||||||
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
|
||||||
@ -523,6 +524,7 @@ const uiToolHandlers = useMemo(() => {
|
|||||||
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
|
||||||
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
|
||||||
<Route path="/preparing" element={<PreparingLanding />} />
|
<Route path="/preparing" element={<PreparingLanding />} />
|
||||||
|
<Route path="/billing" element={<BillingResult />} />
|
||||||
|
|
||||||
{/* Premium-only routes */}
|
{/* Premium-only routes */}
|
||||||
<Route
|
<Route
|
||||||
|
66
src/components/BillingResult.js
Normal file
66
src/components/BillingResult.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState, useContext } from 'react';
|
||||||
|
import { useLocation, Link } from 'react-router-dom';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
import { ProfileCtx } from '../App.js'; // <- exported at very top of App.jsx
|
||||||
|
|
||||||
|
export default function BillingResult() {
|
||||||
|
const { setUser } = useContext(ProfileCtx) || {};
|
||||||
|
const q = new URLSearchParams(useLocation().search);
|
||||||
|
const outcome = q.get('ck'); // 'success' | 'cancel' | null
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
1) Ask the API for the latest user profile (flags, etc.)
|
||||||
|
– will be fast because JWT is already cached
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('token') || '';
|
||||||
|
fetch('/api/user-profile', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(profile => { if (profile && setUser) setUser(profile); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [setUser]);
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
2) UX while waiting for that round‑trip
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
if (loading) {
|
||||||
|
return <p className="p-8 text-center">Checking your subscription…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
3) Success – Stripe completed the checkout flow
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
if (outcome === 'success') {
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto p-8 text-center space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">🎉 Subscription activated!</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Premium features have been unlocked on your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link to="/premium-onboarding">Set up Premium Features</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="secondary" asChild className="w-full">
|
||||||
|
<Link to="/profile">Go to my account</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────
|
||||||
|
4) Cancelled – user backed out of Stripe
|
||||||
|
───────────────────────────────────────────────────────── */
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto p-8 text-center space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Subscription cancelled</h1>
|
||||||
|
<p className="text-gray-600">No changes were made to your account.</p>
|
||||||
|
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<Link to="/paywall">Back to pricing</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -959,7 +959,6 @@ useEffect(() => {
|
|||||||
|
|
||||||
// 8) Build financial projection
|
// 8) Build financial projection
|
||||||
async function buildProjection(milestones) {
|
async function buildProjection(milestones) {
|
||||||
if (!milestones?.length) return;
|
|
||||||
const allMilestones = milestones || [];
|
const allMilestones = milestones || [];
|
||||||
try {
|
try {
|
||||||
setScenarioMilestones(allMilestones);
|
setScenarioMilestones(allMilestones);
|
||||||
|
@ -1,66 +1,121 @@
|
|||||||
import React from 'react';
|
// src/components/Paywall.jsx
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Button } from './ui/button.js';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from './ui/button.js';
|
||||||
|
|
||||||
const Paywall = () => {
|
export default function Paywall() {
|
||||||
const navigate = useNavigate();
|
const nav = useNavigate();
|
||||||
const { state } = useLocation();
|
const [sub, setSub] = useState(null); // null = loading
|
||||||
|
const token = localStorage.getItem('token') || '';
|
||||||
|
|
||||||
const {
|
/* ───────────────── fetch current subscription ─────────────── */
|
||||||
redirectTo = '/premium-onboarding', // wizard by default
|
useEffect(() => {
|
||||||
prevState = {}, // any custom state we passed
|
fetch('/api/premium/subscription/status', {
|
||||||
selectedCareer
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
} = state || {};
|
})
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||||
|
.then(setSub)
|
||||||
|
.catch(() => setSub({ is_premium:0, is_pro_premium:0 }));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
const handleSubscribe = async () => {
|
/* ───────────────── helpers ────────────────────────────────── */
|
||||||
const token = localStorage.getItem('token');
|
const checkout = useCallback(async (tier, cycle) => {
|
||||||
if (!token) return navigate('/signin');
|
const base = window.location.origin; // https://dev1.aptivaai.com
|
||||||
|
const res = await fetch('/api/premium/stripe/create-checkout-session', {
|
||||||
try {
|
method : 'POST',
|
||||||
const res = await fetch('/api/activate-premium', {
|
headers: {
|
||||||
method: 'POST',
|
'Content-Type': 'application/json',
|
||||||
headers: { 'Content-Type': 'application/json',
|
Authorization : `Bearer ${token}`
|
||||||
'Authorization': `Bearer ${token}` }
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tier,
|
||||||
|
cycle,
|
||||||
|
success_url: `${base}/billing?ck=success`,
|
||||||
|
cancel_url : `${base}/billing?ck=cancel`
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
if (!res.ok) return console.error('Checkout failed', await res.text());
|
||||||
|
|
||||||
if (res.status === 401) return navigate('/signin-landing');
|
const { url } = await res.json();
|
||||||
|
window.location.href = url; // redirect to Stripe
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
if (res.ok) {
|
const openPortal = useCallback(async () => {
|
||||||
// 1) grab the fresh token / profile if the API returns it
|
const base = window.location.origin;
|
||||||
const { token: newToken, user } = await res.json().catch(() => ({}));
|
const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, {
|
||||||
if (newToken) localStorage.setItem('token', newToken);
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
|
});
|
||||||
|
if (!res.ok) return console.error('Portal error', await res.text());
|
||||||
|
window.location.href = (await res.json()).url;
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
// 2) give the auth context time to update, then push
|
/* ───────────────── render ─────────────────────────────────── */
|
||||||
navigate(redirectTo, { replace: true, state: prevState });
|
if (!sub) return <p className="p-6 text-center text-sm">Loading …</p>;
|
||||||
} else {
|
|
||||||
console.error('activate-premium failed:', await res.text());
|
if (sub.is_premium || sub.is_pro_premium) {
|
||||||
}
|
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
|
||||||
} catch (err) {
|
|
||||||
console.error('Error activating premium:', err);
|
return (
|
||||||
|
<div className="max-w-lg mx-auto p-6 text-center space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">Your plan: {plan}</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Manage payment method, invoices or cancel anytime.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button onClick={openPortal} className="w-full">
|
||||||
|
Manage subscription
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||||
|
Back to app
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ─── no active sub => show the pricing choices ──────────────── */
|
||||||
return (
|
return (
|
||||||
<div className="paywall">
|
<div className="max-w-lg mx-auto p-6 space-y-8">
|
||||||
<h2>Unlock AptivaAI Premium</h2>
|
<header className="text-center">
|
||||||
<ul>
|
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
|
||||||
<li>✅ Personalized Career Milestone Planning</li>
|
<p className="text-sm text-gray-600">
|
||||||
<li>✅ Comprehensive Financial Projections</li>
|
Choose the plan that fits your needs – cancel anytime.
|
||||||
<li>✅ Resume & Interview Assistance</li>
|
</p>
|
||||||
</ul>
|
</header>
|
||||||
|
|
||||||
<Button
|
{/* Premium tier */}
|
||||||
onClick={handleSubscribe}
|
<section className="border rounded-lg p-4 space-y-4">
|
||||||
className="bg-green-600 hover:bg-green-700"
|
<h3 className="text-lg font-medium">Premium</h3>
|
||||||
>
|
<ul className="text-sm list-disc list-inside space-y-1">
|
||||||
Subscribe Now
|
<li>Career milestone planning</li>
|
||||||
</Button>
|
<li>Financial projections & benchmarks</li>
|
||||||
|
<li>2 × resume optimizations / week</li>
|
||||||
<Button onClick={() => navigate(-1)}>Cancel / Go Back</Button>
|
</ul>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button onClick={() => checkout('premium', 'monthly')}>$4.99 / mo</Button>
|
||||||
|
<Button onClick={() => checkout('premium', 'annual' )}>$49 / yr</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pro tier */}
|
||||||
|
<section className="border rounded-lg p-4 space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">Pro Premium</h3>
|
||||||
|
<ul className="text-sm list-disc list-inside space-y-1">
|
||||||
|
<li>Everything in Premium</li>
|
||||||
|
<li>Priority GPT‑4o usage & higher rate limits</li>
|
||||||
|
<li>5 × resume optimizations / week</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button onClick={() => checkout('pro', 'monthly')}>$7.99 / mo</Button>
|
||||||
|
<Button onClick={() => checkout('pro', 'annual' )}>$79 / yr</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
|
||||||
|
Cancel / Go back
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Paywall;
|
|
||||||
|
Loading…
Reference in New Issue
Block a user