From f706c152b25af73781754d4371c1c843b61d968c Mon Sep 17 00:00:00 2001 From: Tiebe Groosman Date: Sat, 11 Apr 2026 16:46:47 +0200 Subject: [PATCH 1/3] feat: add student account verification endpoint Add academic email verification using JetBrains SWOT data (git submodule). Students with .edu/.ac.uk/etc emails get HTTP Toolkit Pro free for 1 year, renewable when less than 2 months remain. - Add swot.ts module that reads SWOT repo data for domain verification - Add POST /request-student-account authenticated endpoint - Register route and CORS in server.ts --- .gitmodules | 3 + api/src/functions/request-student-account.ts | 118 +++++++++++++++++++ api/src/server.ts | 5 +- api/src/swot.ts | 81 +++++++++++++ swot | 1 + 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .gitmodules create mode 100644 api/src/functions/request-student-account.ts create mode 100644 api/src/swot.ts create mode 160000 swot diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1938002 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "swot"] + path = swot + url = https://github.com/JetBrains/swot.git diff --git a/api/src/functions/request-student-account.ts b/api/src/functions/request-student-account.ts new file mode 100644 index 0000000..0a4ccfc --- /dev/null +++ b/api/src/functions/request-student-account.ts @@ -0,0 +1,118 @@ +import log from 'loglevel'; + +import { initSentry, catchErrors, StatusError } from '../errors.ts'; +initSentry(); + +import { getCorsResponseHeaders } from '../cors.ts'; +import { + getAuth0UserIdFromToken, + getUserById, + updateUserMetadata, + PayingUserMetadata +} from '../user-data-facade.ts'; +import { getPaddleIdForSku } from '../paddle.ts'; +import { isAcademic, findSchoolNames } from '../swot.ts'; + +const BearerRegex = /^Bearer (\S+)$/; + +const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; +const TWO_MONTHS_MS = 60 * 24 * 60 * 60 * 1000; + +/* +This endpoint expects requests to be sent with a Bearer authorization, +containing a valid access token for the Auth0 app. + +If the token is valid, the user's email is checked against the SWOT academic +email database. If it's a recognized academic email, the user is granted a +free Pro subscription for one year (renewable within 2 months of expiry). +If the email is not academic, a 403 is returned with a structured error body +so the frontend can show a fallback contact form. +*/ +export const handler = catchErrors(async (event) => { + let headers = getCorsResponseHeaders(event); + + if (event.httpMethod === 'OPTIONS') { + return { statusCode: 200, headers, body: '' }; + } else if (event.httpMethod !== 'POST') { + return { statusCode: 405, headers, body: '' }; + } + + const { authorization } = event.headers; + + const tokenMatch = BearerRegex.exec(authorization); + if (!tokenMatch) return { statusCode: 401, headers, body: '' }; + const accessToken = tokenMatch[1]; + + const userId = await getAuth0UserIdFromToken(accessToken); + const user = await getUserById(userId); + + if (!user.email) { + throw new StatusError(400, 'No email address associated with this account'); + } + + const email = user.email; + + if (!isAcademic(email)) { + log.info(`Student account rejected for non-academic email: ${email}`); + return { + statusCode: 403, + headers, + body: JSON.stringify({ + error: 'not_academic', + message: `The email address ${email} is not recognized as an academic email address. ` + + 'If you believe this is incorrect, please contact support.' + }) + }; + } + + const existingMeta = user.app_metadata as Partial; + const existingExpiry = existingMeta.subscription_expiry; + const hasActiveStudentSub = + existingMeta.subscription_status === 'active' && + existingMeta.payment_provider === 'manual' && + existingMeta.subscription_sku === 'pro-annual' && + existingExpiry && + existingExpiry > Date.now(); + + if (hasActiveStudentSub && existingExpiry > Date.now() + TWO_MONTHS_MS) { + return { + statusCode: 409, + headers, + body: JSON.stringify({ + error: 'already_active', + message: 'You already have an active student subscription. ' + + 'You can renew when less than 2 months remain.', + expiry: existingMeta.subscription_expiry + }) + }; + } + + const schoolNames = findSchoolNames(email); + const expiry = Date.now() + ONE_YEAR_MS; + + await updateUserMetadata(userId, { + subscription_status: 'active', + payment_provider: 'manual', + subscription_sku: 'pro-annual', + subscription_plan_id: getPaddleIdForSku('pro-annual'), + subscription_quantity: 1, + subscription_expiry: expiry, + update_url: '', + cancel_url: '' + }); + + log.info(`Student account granted for ${email}` + + (schoolNames.length > 0 ? ` (${schoolNames[0]})` : '') + + `, expires ${new Date(expiry).toISOString()}` + ); + + return { + statusCode: 200, + headers, + body: JSON.stringify({ + success: true, + school: schoolNames.length > 0 ? schoolNames[0] : undefined, + expiry + }) + }; +}); diff --git a/api/src/server.ts b/api/src/server.ts index 89a8ac9..210eaad 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -73,7 +73,8 @@ apiRouter.options('*', (req, res) => { '/update-team', '/update-team-size', '/cancel-subscription', - '/log-abuse-report' + '/log-abuse-report', + '/request-student-account' ].includes(req.path)) { // Account APIs are limited to our own hosts: const headers = getCorsResponseHeaders(event); @@ -131,6 +132,8 @@ apiRouter.post('/contact-form', lambdaWrapper('contact-form')); apiRouter.post('/update-team', lambdaWrapper('update-team')); apiRouter.post('/update-team-size', lambdaWrapper('update-team-size')); apiRouter.post('/cancel-subscription', lambdaWrapper('cancel-subscription')); + +apiRouter.post('/request-student-account', lambdaWrapper('request-student-account')); apiRouter.post('/log-abuse-report', (req, res) => { reportError(`Abuse report`, { extraMetadata: { diff --git a/api/src/swot.ts b/api/src/swot.ts new file mode 100644 index 0000000..0ed92e8 --- /dev/null +++ b/api/src/swot.ts @@ -0,0 +1,81 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +const SWOT_DOMAINS_DIR = path.resolve( + import.meta.dirname, '../../swot/lib/domains' +); + +function loadLineSet(filename: string): Set { + const filePath = path.join(SWOT_DOMAINS_DIR, filename); + const content = fs.readFileSync(filePath, 'utf-8'); + return new Set( + content.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + ); +} + +const tlds = loadLineSet('tlds.txt'); +const stoplist = loadLineSet('stoplist.txt'); + +// "user@cs.stanford.edu" -> ["edu", "stanford", "cs"] +function domainParts(emailOrDomain: string): string[] { + return emailOrDomain + .trim() + .toLowerCase() + .replace(/^.*@/, '') + .replace(/^.*:\/\//, '') + .replace(/:.*$/, '') + .split('.') + .reverse(); +} + +// Progressively reconstruct domain from TLD outward and check against set. +// For parts ["edu", "stanford", "cs"]: checks "edu", "stanford.edu", "cs.stanford.edu" +function checkSet(set: Set, parts: string[]): boolean { + let subj = ''; + for (const part of parts) { + subj = subj ? `${part}.${subj}` : part; + if (set.has(subj)) return true; + } + return false; +} + +function isUnderTLD(parts: string[]): boolean { + return checkSet(tlds, parts); +} + +function isStoplisted(parts: string[]): boolean { + return checkSet(stoplist, parts); +} + +// Walk domain hierarchy looking for institution files. +// For parts ["edu", "stanford"]: tries edu.txt, edu/stanford.txt +function findSchoolNamesFromParts(parts: string[]): string[] { + let resourcePath = ''; + for (const part of parts) { + resourcePath = resourcePath ? `${resourcePath}/${part}` : part; + const filePath = path.join(SWOT_DOMAINS_DIR, `${resourcePath}.txt`); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const names = content.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + if (names.length > 0) return names; + } catch { + continue; + } + } + return []; +} + +// Mirrors JetBrains SWOT Kotlin logic: !stoplisted && (underTLD || knownInstitution) +export function isAcademic(emailOrDomain: string): boolean { + const parts = domainParts(emailOrDomain); + if (parts.length === 0 || parts[0] === '') return false; + return !isStoplisted(parts) && (isUnderTLD(parts) || findSchoolNamesFromParts(parts).length > 0); +} + +export function findSchoolNames(emailOrDomain: string): string[] { + return findSchoolNamesFromParts(domainParts(emailOrDomain)); +} diff --git a/swot b/swot new file mode 160000 index 0000000..4b2093a --- /dev/null +++ b/swot @@ -0,0 +1 @@ +Subproject commit 4b2093a57a9de57d3b04fba2b415ed23dc8cb364 From 9c953e76a3e3d1b9ff5c5a92adb99b448daa423e Mon Sep 17 00:00:00 2001 From: Tiebe Groosman Date: Wed, 22 Apr 2026 13:17:14 +0200 Subject: [PATCH 2/3] feat: switch student verification to educhk and add coverage --- .gitmodules | 3 - api/package-lock.json | 7 ++ api/package.json | 1 + api/src/auth0.ts | 2 +- api/src/functions/cancel-subscription.ts | 2 + api/src/functions/request-student-account.ts | 34 ++++---- api/src/functions/update-team-size.ts | 2 +- api/src/swot.ts | 81 ------------------ api/src/user-data.ts | 5 +- api/test/request-student-account.spec.ts | 86 ++++++++++++++++++++ swot | 1 - 11 files changed, 118 insertions(+), 106 deletions(-) delete mode 100644 .gitmodules delete mode 100644 api/src/swot.ts create mode 100644 api/test/request-student-account.spec.ts delete mode 160000 swot diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1938002..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "swot"] - path = swot - url = https://github.com/JetBrains/swot.git diff --git a/api/package-lock.json b/api/package-lock.json index c4788bf..b95c446 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -12,6 +12,7 @@ "@httptoolkit/util": "^0.1.5", "@sentry/node": "^8.55.0", "auth0": "^4.3.1", + "educhk": "2026.4.22", "express": "^4.22.1", "express-rate-limit": "^7.4.1", "handlebars": "^4.7.9", @@ -3802,6 +3803,12 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/educhk": { + "version": "2026.4.22", + "resolved": "https://registry.npmjs.org/educhk/-/educhk-2026.4.22.tgz", + "integrity": "sha512-fRYEcK3qtVsh+UKZqqJmVS/FGaSSSH/6vQ/7RX+5osvO9wMYotjEBtqtzsskOtxGr/P6jnkA23pIi41VKBk6BA==", + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", diff --git a/api/package.json b/api/package.json index 3eed748..28fe91a 100644 --- a/api/package.json +++ b/api/package.json @@ -46,6 +46,7 @@ "@httptoolkit/util": "^0.1.5", "@sentry/node": "^8.55.0", "auth0": "^4.3.1", + "educhk": "2026.4.22", "express": "^4.22.1", "express-rate-limit": "^7.4.1", "handlebars": "^4.7.9", diff --git a/api/src/auth0.ts b/api/src/auth0.ts index c0ca1a9..f03cf40 100644 --- a/api/src/auth0.ts +++ b/api/src/auth0.ts @@ -131,7 +131,7 @@ export interface TrialUserMetadata extends BaseMetadata { } export interface PayingUserMetadata extends TrialUserMetadata { - payment_provider: 'paddle' | 'paypro' | 'manual'; + payment_provider: 'paddle' | 'paypro' | 'manual' | 'student-account'; // Only set for Paddle customers. Used for transaction lookup API requests. paddle_user_id?: number | string; // New ids should all be strings. diff --git a/api/src/functions/cancel-subscription.ts b/api/src/functions/cancel-subscription.ts index 120c268..cd4b385 100644 --- a/api/src/functions/cancel-subscription.ts +++ b/api/src/functions/cancel-subscription.ts @@ -54,6 +54,8 @@ export const handler = catchErrors(async (event) => { await PayPro.cancelSubscription(userData.subscription_id); } else if (userData.payment_provider === 'manual') { throw new StatusError(400, "To cancel this manually managed subscription please contact billing@httptoolkit.com"); + } else if (userData.payment_provider === 'student-account') { + throw new StatusError(400, "Student subscriptions cannot be cancelled manually, and will expire automatically after 1 year."); } else { throw new UnreachableCheck(userData.payment_provider); } diff --git a/api/src/functions/request-student-account.ts b/api/src/functions/request-student-account.ts index 0a4ccfc..3369fc5 100644 --- a/api/src/functions/request-student-account.ts +++ b/api/src/functions/request-student-account.ts @@ -8,10 +8,11 @@ import { getAuth0UserIdFromToken, getUserById, updateUserMetadata, - PayingUserMetadata + TrialUserMetadata } from '../user-data-facade.ts'; import { getPaddleIdForSku } from '../paddle.ts'; -import { isAcademic, findSchoolNames } from '../swot.ts'; +import { isAcademic } from 'educhk'; + const BearerRegex = /^Bearer (\S+)$/; @@ -22,9 +23,9 @@ const TWO_MONTHS_MS = 60 * 24 * 60 * 60 * 1000; This endpoint expects requests to be sent with a Bearer authorization, containing a valid access token for the Auth0 app. -If the token is valid, the user's email is checked against the SWOT academic -email database. If it's a recognized academic email, the user is granted a -free Pro subscription for one year (renewable within 2 months of expiry). +If the token is valid, the user's email domain is checked with educhk. +If it's a recognized academic email, the user is granted a free Pro trial +for one year (renewable within 2 months of expiry). If the email is not academic, a 403 is returned with a structured error body so the frontend can show a fallback contact form. */ @@ -52,7 +53,8 @@ export const handler = catchErrors(async (event) => { const email = user.email; - if (!isAcademic(email)) { + const emailDomain = email.split('@')[1]?.toLowerCase(); + if (!emailDomain || !isAcademic(emailDomain)) { log.info(`Student account rejected for non-academic email: ${email}`); return { statusCode: 403, @@ -65,11 +67,11 @@ export const handler = catchErrors(async (event) => { }; } - const existingMeta = user.app_metadata as Partial; + const existingMeta = user.app_metadata as Partial & { payment_provider?: string }; const existingExpiry = existingMeta.subscription_expiry; const hasActiveStudentSub = - existingMeta.subscription_status === 'active' && - existingMeta.payment_provider === 'manual' && + existingMeta.subscription_status === 'trialing' && + existingMeta.payment_provider === 'student-account' && existingMeta.subscription_sku === 'pro-annual' && existingExpiry && existingExpiry > Date.now(); @@ -87,22 +89,20 @@ export const handler = catchErrors(async (event) => { }; } - const schoolNames = findSchoolNames(email); + const school = emailDomain; const expiry = Date.now() + ONE_YEAR_MS; await updateUserMetadata(userId, { - subscription_status: 'active', - payment_provider: 'manual', + subscription_status: 'trialing', + payment_provider: 'student-account', subscription_sku: 'pro-annual', subscription_plan_id: getPaddleIdForSku('pro-annual'), subscription_quantity: 1, - subscription_expiry: expiry, - update_url: '', - cancel_url: '' + subscription_expiry: expiry }); log.info(`Student account granted for ${email}` + - (schoolNames.length > 0 ? ` (${schoolNames[0]})` : '') + + (school ? ` (${school})` : '') + `, expires ${new Date(expiry).toISOString()}` ); @@ -111,7 +111,7 @@ export const handler = catchErrors(async (event) => { headers, body: JSON.stringify({ success: true, - school: schoolNames.length > 0 ? schoolNames[0] : undefined, + school, expiry }) }; diff --git a/api/src/functions/update-team-size.ts b/api/src/functions/update-team-size.ts index 5ee5b27..9a7b18f 100644 --- a/api/src/functions/update-team-size.ts +++ b/api/src/functions/update-team-size.ts @@ -57,7 +57,7 @@ export const handler = catchErrors(async (event) => { throw new StatusError(403, "Your account does not have a Team subscription"); } else if (ownerData.subscription_status !== 'active' || ownerData.subscription_expiry < Date.now()) { throw new StatusError(403, "Your account does not have an active subscription"); - } else if (ownerData.payment_provider === 'manual') { + } else if (ownerData.payment_provider === 'manual' || ownerData.payment_provider === 'student-account') { throw new StatusError(400, "Cannot update manually managed subscription. Please contact billing@httptoolkit.tech"); } else if (ownerData.payment_provider !== 'paddle') { throw new StatusError(500, "Cannot update non-Paddle team subscription"); diff --git a/api/src/swot.ts b/api/src/swot.ts deleted file mode 100644 index 0ed92e8..0000000 --- a/api/src/swot.ts +++ /dev/null @@ -1,81 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const SWOT_DOMAINS_DIR = path.resolve( - import.meta.dirname, '../../swot/lib/domains' -); - -function loadLineSet(filename: string): Set { - const filePath = path.join(SWOT_DOMAINS_DIR, filename); - const content = fs.readFileSync(filePath, 'utf-8'); - return new Set( - content.split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0) - ); -} - -const tlds = loadLineSet('tlds.txt'); -const stoplist = loadLineSet('stoplist.txt'); - -// "user@cs.stanford.edu" -> ["edu", "stanford", "cs"] -function domainParts(emailOrDomain: string): string[] { - return emailOrDomain - .trim() - .toLowerCase() - .replace(/^.*@/, '') - .replace(/^.*:\/\//, '') - .replace(/:.*$/, '') - .split('.') - .reverse(); -} - -// Progressively reconstruct domain from TLD outward and check against set. -// For parts ["edu", "stanford", "cs"]: checks "edu", "stanford.edu", "cs.stanford.edu" -function checkSet(set: Set, parts: string[]): boolean { - let subj = ''; - for (const part of parts) { - subj = subj ? `${part}.${subj}` : part; - if (set.has(subj)) return true; - } - return false; -} - -function isUnderTLD(parts: string[]): boolean { - return checkSet(tlds, parts); -} - -function isStoplisted(parts: string[]): boolean { - return checkSet(stoplist, parts); -} - -// Walk domain hierarchy looking for institution files. -// For parts ["edu", "stanford"]: tries edu.txt, edu/stanford.txt -function findSchoolNamesFromParts(parts: string[]): string[] { - let resourcePath = ''; - for (const part of parts) { - resourcePath = resourcePath ? `${resourcePath}/${part}` : part; - const filePath = path.join(SWOT_DOMAINS_DIR, `${resourcePath}.txt`); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const names = content.split('\n') - .map(line => line.trim()) - .filter(line => line.length > 0); - if (names.length > 0) return names; - } catch { - continue; - } - } - return []; -} - -// Mirrors JetBrains SWOT Kotlin logic: !stoplisted && (underTLD || knownInstitution) -export function isAcademic(emailOrDomain: string): boolean { - const parts = domainParts(emailOrDomain); - if (parts.length === 0 || parts[0] === '') return false; - return !isStoplisted(parts) && (isUnderTLD(parts) || findSchoolNamesFromParts(parts).length > 0); -} - -export function findSchoolNames(emailOrDomain: string): string[] { - return findSchoolNamesFromParts(domainParts(emailOrDomain)); -} diff --git a/api/src/user-data.ts b/api/src/user-data.ts index 633154f..119eba5 100644 --- a/api/src/user-data.ts +++ b/api/src/user-data.ts @@ -268,7 +268,8 @@ async function buildUserBillingData( let can_manage_subscription = false; if ( (rawMetadata.subscription_status === 'active' || rawMetadata.subscription_status === 'past_due') && - rawMetadata.payment_provider !== 'manual' + rawMetadata.payment_provider !== 'manual' && + rawMetadata.payment_provider !== 'student-account' ) { can_manage_subscription = owner // If your sub has an owner, you can only manage the subscription if that's you: @@ -334,7 +335,7 @@ async function getTransactions(rawMetadata: RawMetadata) { } else if (billingMetadata.payment_provider === 'paypro') { transactionsCacheKey = `paypro-${rawMetadata.email}`; transactionsRequest = lookupPayProOrders(rawMetadata.email); - } else if (billingMetadata.payment_provider === 'manual') { + } else if (billingMetadata.payment_provider === 'manual' || billingMetadata.payment_provider === 'student-account') { transactionsRequest = Promise.resolve([]); // No transactions for manual subscriptions transactionsCacheKey = `manual-${rawMetadata.email}`; } else { diff --git a/api/test/request-student-account.spec.ts b/api/test/request-student-account.spec.ts new file mode 100644 index 0000000..b6ecb74 --- /dev/null +++ b/api/test/request-student-account.spec.ts @@ -0,0 +1,86 @@ +import * as net from 'net'; +import { DestroyableServer } from 'destroyable-server'; + +import { expect } from 'chai'; + +import { + startAPI, + givenUser, + freshAuthToken, + givenAuthToken +} from './test-setup/setup.ts'; +import { testDB } from './test-setup/database.ts'; + +const requestStudentAccount = (server: net.Server, authToken?: string) => fetch( + `http://localhost:${(server.address() as net.AddressInfo).port}/api/request-student-account`, + { + method: 'POST', + headers: authToken + ? { Authorization: `Bearer ${authToken}` } + : undefined + } +); + +describe('Request student account API', () => { + + let apiServer: DestroyableServer; + + beforeEach(async () => { + apiServer = await startAPI(); + }); + + afterEach(async () => { + await apiServer.destroy(); + }); + + it('grants a student account for an academic email', async () => { + const authToken = freshAuthToken(); + const userId = 'student-user'; + const userEmail = 'student@stanford.edu'; + + await givenUser(userId, userEmail, {}); + await givenAuthToken(authToken, userId); + + const response = await requestStudentAccount(apiServer, authToken); + expect(response.status).to.equal(200); + + const body = await response.json(); + expect(body.success).to.equal(true); + expect(body.school).to.equal('stanford.edu'); + expect(body.expiry).to.be.greaterThan(Date.now()); + + const dbUser = await testDB.query( + 'SELECT app_metadata FROM users WHERE auth0_user_id = $1', + [userId] + ); + + expect(dbUser.rows[0].app_metadata).to.include({ + subscription_status: 'trialing', + payment_provider: 'student-account', + subscription_sku: 'pro-annual', + subscription_quantity: 1 + }); + }); + + it('rejects non-academic emails', async () => { + const authToken = freshAuthToken(); + const userId = 'non-student-user'; + const userEmail = 'user@gmail.com'; + + await givenUser(userId, userEmail, {}); + await givenAuthToken(authToken, userId); + + const response = await requestStudentAccount(apiServer, authToken); + expect(response.status).to.equal(403); + + const body = await response.json(); + expect(body.error).to.equal('not_academic'); + + const dbUser = await testDB.query( + 'SELECT app_metadata FROM users WHERE auth0_user_id = $1', + [userId] + ); + + expect(dbUser.rows[0].app_metadata).to.deep.equal({}); + }); +}); diff --git a/swot b/swot deleted file mode 160000 index 4b2093a..0000000 --- a/swot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4b2093a57a9de57d3b04fba2b415ed23dc8cb364 From 7df4a73c5cea8b7b863d6188e2b2d9cdbf3616c7 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Fri, 24 Apr 2026 19:09:11 +0200 Subject: [PATCH 3/3] Show a warning for student requests over existing paid accounts --- api/src/functions/request-student-account.ts | 17 ++++-- api/test/request-student-account.spec.ts | 57 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/api/src/functions/request-student-account.ts b/api/src/functions/request-student-account.ts index 3369fc5..6cdaa3a 100644 --- a/api/src/functions/request-student-account.ts +++ b/api/src/functions/request-student-account.ts @@ -69,14 +69,14 @@ export const handler = catchErrors(async (event) => { const existingMeta = user.app_metadata as Partial & { payment_provider?: string }; const existingExpiry = existingMeta.subscription_expiry; + const hasActiveStudentSub = existingMeta.subscription_status === 'trialing' && existingMeta.payment_provider === 'student-account' && - existingMeta.subscription_sku === 'pro-annual' && existingExpiry && - existingExpiry > Date.now(); + existingExpiry > Date.now() + TWO_MONTHS_MS; - if (hasActiveStudentSub && existingExpiry > Date.now() + TWO_MONTHS_MS) { + if (hasActiveStudentSub) { return { statusCode: 409, headers, @@ -87,6 +87,17 @@ export const handler = catchErrors(async (event) => { expiry: existingMeta.subscription_expiry }) }; + } else if (existingMeta.subscription_status === 'active') { + return { + statusCode: 409, + headers, + body: JSON.stringify({ + error: 'paid_account', + message: 'You have an active paid subscription. ' + + 'Please cancel your existing subscription first, and try again.', + expiry: existingMeta.subscription_expiry + }) + }; } const school = emailDomain; diff --git a/api/test/request-student-account.spec.ts b/api/test/request-student-account.spec.ts index b6ecb74..5559528 100644 --- a/api/test/request-student-account.spec.ts +++ b/api/test/request-student-account.spec.ts @@ -83,4 +83,61 @@ describe('Request student account API', () => { expect(dbUser.rows[0].app_metadata).to.deep.equal({}); }); + + it('rejects existing student accounts', async () => { + const authToken = freshAuthToken(); + const userId = 'paying-student-user'; + const userEmail = 'paying-student@stanford.edu'; + + const initialData = { + payment_provider: 'student-account', + subscription_status: 'trialing', + subscription_expiry: Date.now() + 1000 * 60 * 60 * 24 * 365, // 1 year in future + subscription_sku: 'pro-annual' + } + + await givenUser(userId, userEmail, initialData); + await givenAuthToken(authToken, userId); + + const response = await requestStudentAccount(apiServer, authToken); + expect(response.status).to.equal(409); + + const body = await response.json(); + expect(body.error).to.equal('already_active'); + expect(body.message).to.equal('You already have an active student subscription. You can renew when less than 2 months remain.'); + + const dbUser = await testDB.query( + 'SELECT app_metadata FROM users WHERE auth0_user_id = $1', + [userId] + ); + + expect(dbUser.rows[0].app_metadata).to.deep.equal(initialData); + }); + + it('rejects existing paying users', async () => { + const authToken = freshAuthToken(); + const userId = 'paying-student-user'; + const userEmail = 'paying-student@stanford.edu'; + + const initialData = { + payment_provider: 'paddle', + subscription_status: 'active' + } + await givenUser(userId, userEmail, initialData); + await givenAuthToken(authToken, userId); + + const response = await requestStudentAccount(apiServer, authToken); + expect(response.status).to.equal(409); + + const body = await response.json(); + expect(body.error).to.equal('paid_account'); + expect(body.message).to.equal('You have an active paid subscription. Please cancel your existing subscription first, and try again.'); + + const dbUser = await testDB.query( + 'SELECT app_metadata FROM users WHERE auth0_user_id = $1', + [userId] + ); + + expect(dbUser.rows[0].app_metadata).to.deep.equal(initialData); + }); });