Stripe integration + BillingResult

This commit is contained in:
Josh 2025-07-29 12:27:59 +00:00
parent bd8a40419d
commit abfb7d7c54
17 changed files with 545 additions and 186 deletions

5
.env
View File

@ -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
SERVER2_PORT=5001
SERVER3_PORT=5002
SALARY_DB=/salary_info.db
NODE_ENV=production
IMG_TAG=202507281838

View File

@ -1,42 +0,0 @@
# ─── O*NET ───────────────────────────────
ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
# ─── Publicfacing 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

View File

@ -1,42 +0,0 @@
# ─── O*NET ───────────────────────────────
ONET_USERNAME=aptivaai
ONET_PASSWORD=2296ahq
# ─── Publicfacing 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
View File

@ -21,3 +21,5 @@ yarn-error.log*
.bashrc
_logout
env/*.env
*.env
uploads/

View File

@ -1,12 +1,20 @@
ARG APPPORT=5002
FROM node:20-slim
# ---- Dockerfile.server3 (fixed) ------------------------------
FROM node:20-bullseye
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 ./
RUN apt-get update -y \
&& apt-get install -y build-essential python3 make g++ --no-install-recommends python3 git \
&& rm -rf /var/lib/apt/lists/*
RUN npm ci --omit=dev --ignore-scripts
RUN npm ci --omit=dev --unsafe-perm
# 3. static assets & source
COPY public/ /app/public/
COPY . .
ENV PORT=5002
EXPOSE 5002
CMD ["node", "backend/server3.js"]

View File

@ -18,6 +18,7 @@ const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, '..'); // Up one level
const env = process.env.NODE_ENV?.trim() || 'development';
const stage = env === 'staging' ? 'development' : env;
const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath }); // Load .env file
@ -34,7 +35,6 @@ if (!JWT_SECRET) {
process.exit(1); // container exits, Docker marks it unhealthy
}
// Create a MySQL pool for user_profile data
const pool = mysql.createPool({
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
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) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS

View File

@ -19,22 +19,53 @@ import db from './config/mysqlPool.js';
import './jobs/reminderCron.js';
import OpenAI from 'openai';
import Fuse from 'fuse.js';
import Stripe from 'stripe';
import { createReminder } from './utils/smsService.js';
import { cacheSummary } from "./utils/ctxCache.js";
const rootPath = path.resolve(__dirname, '..'); // one level up
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
dotenv.config({ path: envPath });
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
if (!process.env.FROM_SECRETS_MANAGER) {
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 “/”
/* allowlist for redirects to block openredirect 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 PORT = process.env.SERVER3_PORT || 5002;
const { getDocument } = pkg;
const bt = "`".repeat(3);
function internalFetch(req, url, opts = {}) {
return fetch(url, {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
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,
headers: {
"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
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
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');
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) ─────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
@ -115,8 +194,54 @@ const authenticatePremiumUser = (req, res, next) => {
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 users 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
@ -125,11 +250,10 @@ app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
async function applyOps(opsObj, req) {
if (!opsObj?.milestones || !Array.isArray(opsObj.milestones)) return [];
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
const confirmations = [];
// 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) {
const { op } = m || {};
@ -915,9 +1039,9 @@ ${econText}
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
let aiRisk = null;
try {
const aiRiskRes = await internalFetch(
const aiRiskRes = await auth(
req,
`${apiBase}/premium/ai-risk-analysis`,
'/premium/ai-risk-analysis',
{
method: "POST",
body: JSON.stringify({
@ -1289,7 +1413,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
};
// Call your existing milestone endpoint
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
const msRes = await auth(req, '/premium/milestone', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(milestoneBody)
@ -1324,7 +1448,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
due_date: taskObj.due_date || null
};
await internalFetch(req, `${apiBase}/premium/tasks`, {
await auth(req, '/premium/tasks', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskBody)
@ -1358,7 +1482,7 @@ if (embeddedJson) { // <── instead of startsWith("{")…
end_date: impObj.end_date || null
};
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
await auth(req, '/premium/milestone-impacts', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impactBody)
@ -1531,7 +1655,7 @@ Always end with: “AptivaAI is an educational tool not advice.”
if (payloadObj?.cloneScenario) {
/* ------ CLONE ------ */
await internalFetch(req, `${apiBase}/premium/career-profile/clone`, {
await auth(req, '/premium/career-profile/clone', {
method: 'POST',
body : JSON.stringify(payloadObj.cloneScenario),
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
------------------------------------------------------------------ */

View File

@ -1,5 +1,5 @@
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> }`
@ -10,7 +10,7 @@ export default function authenticateUser(req, res, next) {
if (!token) return res.status(401).json({ error: "Authorization token required" });
try {
const { id } = jwt.verify(token, SECRET_KEY);
const { id } = jwt.verify(token, JWT_SECRET);
req.user = { id }; // attach the id for downstream use
next();
} catch (err) {

72
deploy_all.sh Executable file
View 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 NONsensitive 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 frontend 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, nonsensitive 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. Recreate the stack
# ─────────────────────────────────────────────────────────────
# Preserve only the variables dockercompose 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"

View File

@ -1,15 +1,28 @@
# ---------------------------------------------------------------------------
# A single envfile (.env) contains ONLY nonsecret constants.
# Every secret is exported from fetchsecrets.sh and injected at deploy time.
# ---------------------------------------------------------------------------
x-env: &with-env
env_file:
- ${RUNTIME_ENV_FILE:-.env.production} # default for local runs
- .env # committed, nonsecret
restart: unless-stopped
services:
# ───────────────────────────── server1 ─────────────────────────────
server1:
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server1:${IMG_TAG}
expose: ["${SERVER1_PORT}"]
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
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:
- ./salary_info.db:/app/salary_info.db:ro
- ./user_profile.db:/app/user_profile.db
@ -19,12 +32,24 @@ services:
timeout: 5s
retries: 3
# ───────────────────────────── server2 ─────────────────────────────
server2:
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server2:${IMG_TAG}
expose: ["${SERVER2_PORT}"]
restart: unless-stopped
env_file:
- ${RUNTIME_ENV_FILE}
environment:
ONET_USERNAME: ${ONET_USERNAME}
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:
- ./public:/app/public:ro
- ./salary_info.db:/app/salary_info.db:ro
@ -35,24 +60,48 @@ services:
timeout: 5s
retries: 3
# ───────────────────────────── server3 ─────────────────────────────
server3:
<<: *with-env
image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG}
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:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER3_PORT}/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# ───────────────────────────── nginx ───────────────────────────────
nginx:
<<: *with-env
image: nginx:1.25-alpine
command: ["nginx", "-g", "daemon off;"]
depends_on: [server1, server2, server3]
ports:
- "80:80"
- "443:443"
ports: ["80:80", "443:443"]
volumes:
- ./build:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro

15
package-lock.json generated
View File

@ -7,7 +7,6 @@
"": {
"name": "aptiva-dev1-app",
"version": "0.1.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@radix-ui/react-dialog": "^1.0.0",
@ -53,6 +52,7 @@
"react-spinners": "^0.15.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"stripe": "^14.0.0",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"twilio": "^5.7.1",
@ -18715,6 +18715,19 @@
"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": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz",

View File

@ -47,6 +47,7 @@
"react-spinners": "^0.15.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"stripe": "^14.0.0",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"twilio": "^5.7.1",

6
playwright.config.js Normal file
View File

@ -0,0 +1,6 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: 'tests',
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
timeout: 30000,
});

View File

@ -38,6 +38,7 @@ import LoanRepaymentPage from './components/LoanRepaymentPage.js';
import usePageContext from './utils/usePageContext.js';
import ChatDrawer from './components/ChatDrawer.js';
import ChatCtx from './contexts/ChatCtx.js';
import BillingResult from './components/BillingResult.js';
@ -222,7 +223,7 @@ const uiToolHandlers = useMemo(() => {
<ProfileCtx.Provider
value={{ financialProfile, setFinancialProfile,
scenario, setScenario,
user, }}
user, setUser}}
>
<ChatCtx.Provider value={{ setChatSnapshot,
openSupport: () => { setDrawerPane('support'); setDrawerOpen(true); },
@ -523,6 +524,7 @@ const uiToolHandlers = useMemo(() => {
<Route path="/loan-repayment" element={<LoanRepaymentPage />}/>
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
<Route path="/preparing" element={<PreparingLanding />} />
<Route path="/billing" element={<BillingResult />} />
{/* Premium-only routes */}
<Route

View 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 roundtrip
*/
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>
);
}

View File

@ -959,7 +959,6 @@ useEffect(() => {
// 8) Build financial projection
async function buildProjection(milestones) {
if (!milestones?.length) return;
const allMilestones = milestones || [];
try {
setScenarioMilestones(allMilestones);

View File

@ -1,66 +1,121 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
// src/components/Paywall.jsx
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from './ui/button.js';
const Paywall = () => {
const navigate = useNavigate();
const { state } = useLocation();
export default function Paywall() {
const nav = useNavigate();
const [sub, setSub] = useState(null); // null = loading
const token = localStorage.getItem('token') || '';
const {
redirectTo = '/premium-onboarding', // wizard by default
prevState = {}, // any custom state we passed
selectedCareer
} = state || {};
/* ───────────────── fetch current subscription ─────────────── */
useEffect(() => {
fetch('/api/premium/subscription/status', {
headers: { Authorization: `Bearer ${token}` }
})
.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 () => {
const token = localStorage.getItem('token');
if (!token) return navigate('/signin');
try {
const res = await fetch('/api/activate-premium', {
method: 'POST',
headers: { 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` }
/* ───────────────── helpers ────────────────────────────────── */
const checkout = useCallback(async (tier, cycle) => {
const base = window.location.origin; // https://dev1.aptivaai.com
const res = await fetch('/api/premium/stripe/create-checkout-session', {
method : 'POST',
headers: {
'Content-Type': 'application/json',
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) {
// 1) grab the fresh token / profile if the API returns it
const { token: newToken, user } = await res.json().catch(() => ({}));
if (newToken) localStorage.setItem('token', newToken);
if (user) window.dispatchEvent(new Event('user-updated')); // or your context setter
const openPortal = useCallback(async () => {
const base = window.location.origin;
const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, {
headers: { Authorization: `Bearer ${token}` }
});
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
navigate(redirectTo, { replace: true, state: prevState });
} else {
console.error('activate-premium failed:', await res.text());
}
} catch (err) {
console.error('Error activating premium:', err);
}
};
/* ───────────────── render ─────────────────────────────────── */
if (!sub) return <p className="p-6 text-center text-sm">Loading</p>;
if (sub.is_premium || sub.is_pro_premium) {
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
return (
<div className="paywall">
<h2>Unlock AptivaAI Premium</h2>
<ul>
<li> Personalized Career Milestone Planning</li>
<li> Comprehensive Financial Projections</li>
<li> Resume & Interview Assistance</li>
</ul>
<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={handleSubscribe}
className="bg-green-600 hover:bg-green-700"
>
Subscribe Now
<Button onClick={openPortal} className="w-full">
Manage subscription
</Button>
<Button onClick={() => navigate(-1)}>Cancel / Go Back</Button>
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
Back to app
</Button>
</div>
);
};
}
export default Paywall;
/* ─── no active sub => show the pricing choices ──────────────── */
return (
<div className="max-w-lg mx-auto p-6 space-y-8">
<header className="text-center">
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
<p className="text-sm text-gray-600">
Choose the plan that fits your needs cancel anytime.
</p>
</header>
{/* Premium tier */}
<section className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium">Premium</h3>
<ul className="text-sm list-disc list-inside space-y-1">
<li>Career milestone planning</li>
<li>Financial projections &amp; benchmarks</li>
<li>2×resume optimizations / week</li>
</ul>
<div className="grid grid-cols-2 gap-3">
<Button onClick={() => checkout('premium', 'monthly')}>$4.99&nbsp;/mo</Button>
<Button onClick={() => checkout('premium', 'annual' )}>$49&nbsp;/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 GPT4o usage &amp; 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&nbsp;/mo</Button>
<Button onClick={() => checkout('pro', 'annual' )}>$79&nbsp;/yr</Button>
</div>
</section>
<Button variant="secondary" onClick={() => nav(-1)} className="w-full">
Cancel / Go back
</Button>
</div>
);
}