Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion api/src/auth0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions api/src/functions/cancel-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
129 changes: 129 additions & 0 deletions api/src/functions/request-student-account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import log from 'loglevel';

import { initSentry, catchErrors, StatusError } from '../errors.ts';
initSentry();

import { getCorsResponseHeaders } from '../cors.ts';
import {
getAuth0UserIdFromToken,
getUserById,
updateUserMetadata,
TrialUserMetadata
} from '../user-data-facade.ts';
import { getPaddleIdForSku } from '../paddle.ts';
import { isAcademic } from 'educhk';


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 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.
*/
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;

const emailDomain = email.split('@')[1]?.toLowerCase();
if (!emailDomain || !isAcademic(emailDomain)) {
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<TrialUserMetadata> & { payment_provider?: string };
const existingExpiry = existingMeta.subscription_expiry;

const hasActiveStudentSub =
existingMeta.subscription_status === 'trialing' &&
existingMeta.payment_provider === 'student-account' &&
existingExpiry &&
existingExpiry > Date.now() + TWO_MONTHS_MS;

if (hasActiveStudentSub) {
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
})
};
} 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;
const expiry = Date.now() + ONE_YEAR_MS;

await updateUserMetadata(userId, {
subscription_status: 'trialing',
payment_provider: 'student-account',
subscription_sku: 'pro-annual',
subscription_plan_id: getPaddleIdForSku('pro-annual'),
subscription_quantity: 1,
subscription_expiry: expiry
});

log.info(`Student account granted for ${email}` +
(school ? ` (${school})` : '') +
`, expires ${new Date(expiry).toISOString()}`
);

return {
statusCode: 200,
headers,
body: JSON.stringify({
success: true,
school,
expiry
})
};
});
2 changes: 1 addition & 1 deletion api/src/functions/update-team-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 4 additions & 1 deletion api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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: {
Expand Down
5 changes: 3 additions & 2 deletions api/src/user-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down
143 changes: 143 additions & 0 deletions api/test/request-student-account.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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({});
});

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);
});
});