Skip to content

Commit ac3c3f1

Browse files
authored
Merge pull request #1188 from jboolean/subs
Sustaining support (subs) and thank-you gifts
2 parents 1114f13 + ef98627 commit ac3c3f1

37 files changed

+1063
-165
lines changed

backend/src/api/AuthenticationController.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import * as UserService from '../business/users/UserService';
1414
import required from '../business/utils/required';
1515
import LoginOutcome from '../enum/LoginOutcome';
16-
import { setAuthCookie } from './auth/authCookieUtils';
16+
import { clearAuthCookie, setAuthCookie } from './auth/authCookieUtils';
1717
import { getUserFromRequestOrCreateAndSetCookie } from './auth/userAuthUtils';
1818
import { Email } from './CommonApiTypes';
1919

@@ -28,6 +28,9 @@ type LoginRequest = {
2828
// Use before sensitive actions
2929
// Remember to check isEmailVerified before performing sensitive actions
3030
requireVerifiedEmail?: boolean;
31+
32+
// If the user is already logged in, and the email is different, should we update the email on a named account?
33+
newEmailBehavior?: 'update' | 'reject';
3134
};
3235

3336
type LoginResponse = {
@@ -58,14 +61,20 @@ export class AuthenticationController extends Controller {
5861
@Request() req: express.Request
5962
): Promise<LoginResponse> {
6063
const userId = await getUserFromRequestOrCreateAndSetCookie(req);
61-
const { requestedEmail, returnToPath, requireVerifiedEmail } = loginRequest;
64+
const {
65+
requestedEmail,
66+
returnToPath,
67+
requireVerifiedEmail,
68+
newEmailBehavior,
69+
} = loginRequest;
6270
const apiBase = getApiBase(req);
6371
const result = await UserService.processLoginRequest(
6472
requestedEmail,
6573
userId,
6674
apiBase,
6775
returnToPath,
68-
requireVerifiedEmail
76+
requireVerifiedEmail,
77+
newEmailBehavior
6978
);
7079

7180
console.log('Login requested', {
@@ -136,4 +145,9 @@ export class AuthenticationController extends Controller {
136145

137146
res.redirect(redirectUrl.toString());
138147
}
148+
149+
@Post('/logout')
150+
public logout(@Request() req: express.Request): void {
151+
clearAuthCookie(required(req.res, 'res'));
152+
}
139153
}

backend/src/api/ColorizationController.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as express from 'express';
2+
import { BadRequest } from 'http-errors';
3+
import Stripe from 'stripe';
24
import {
35
Body,
46
Controller,
@@ -12,11 +14,9 @@ import {
1214
import * as ColorService from '../business/color/ColorService';
1315
import * as UserService from '../business/users/UserService';
1416
import isProduction from '../business/utils/isProduction';
15-
import { getUserFromRequestOrCreateAndSetCookie } from './auth/userAuthUtils';
16-
import stripe from './stripe';
17-
import Stripe from 'stripe';
18-
import { BadRequest } from 'http-errors';
1917
import required from '../business/utils/required';
18+
import stripe from '../third-party/stripe';
19+
import { getUserFromRequestOrCreateAndSetCookie } from './auth/userAuthUtils';
2020

2121
type BuyCreditsSessionRequest = {
2222
quantity: number;

backend/src/api/StripeWebhooksResource.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import express from 'express';
22
import ipfilter from 'express-ipfilter';
3-
import stripe from './stripe';
3+
import stripe from '../third-party/stripe';
44

55
import compact from 'lodash/compact';
66
import groupBy from 'lodash/groupBy';
@@ -14,7 +14,7 @@ import MerchInternalVariant from '../enum/MerchInternalVariant';
1414
const router = express.Router();
1515

1616
type ProductMetadata = {
17-
'product-type'?: 'color-credit' | 'merch';
17+
'product-type'?: 'color-credit' | 'merch' | 'tip';
1818
'merch-internal-variant'?: MerchInternalVariant;
1919
};
2020

@@ -175,6 +175,29 @@ router.post<'/', unknown, unknown, Stripe.Event, unknown>(
175175
}
176176
break;
177177
}
178+
// Used to track active subscriptions
179+
case 'customer.subscription.deleted':
180+
case 'customer.subscription.updated': {
181+
const subscription = event.data.object;
182+
const user = await UserService.getUserByStripeCustomerId(
183+
subscription.customer as string
184+
);
185+
if (!user) {
186+
console.warn('No user found for stripe customer id', subscription);
187+
return;
188+
}
189+
if (event.type === 'customer.subscription.deleted') {
190+
console.log('Subscription deleted', subscription);
191+
await UserService.updateSupportSubscription(user.id, null);
192+
} else if (
193+
subscription.status === 'active' &&
194+
user.stripeSupportSubscriptionId !== subscription.id
195+
) {
196+
console.log('New subscription is active', subscription);
197+
await UserService.updateSupportSubscription(user.id, subscription.id);
198+
}
199+
break;
200+
}
178201
}
179202
res.status(200).send();
180203
}

backend/src/api/TipsController.ts

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
11
/* eslint-disable camelcase */
22
import * as express from 'express';
3-
import { InternalServerError } from 'http-errors';
4-
import { Body, Post, Request, Route, Security } from 'tsoa';
3+
import { HttpError, InternalServerError } from 'http-errors';
4+
import { Body, Get, Post, Request, Route, Security } from 'tsoa';
5+
import * as TipsService from '../business/tips/TipsService';
56
import { getUserFromRequestOrCreateAndSetCookie } from './auth/userAuthUtils';
6-
import stripe from './stripe';
77

8+
import * as GiftRegistry from '../business/tips/GiftRegistry';
9+
import { Gift } from '../business/tips/GiftRegistry';
810
import * as UserService from '../business/users/UserService';
11+
import TipFrequency from '../enum/TipFrequency';
912

1013
type TipSessionRequest = {
1114
amount: number;
1215
successUrl: string;
1316
cancelUrl: string;
17+
frequency?: TipFrequency;
18+
gift?: Gift;
1419
};
1520

21+
type CustomerPortalRequest = {
22+
returnUrl: string;
23+
};
24+
25+
interface GiftApiResponse {
26+
gift: GiftRegistry.Gift;
27+
minimumAmount: number;
28+
frequency: TipFrequency;
29+
}
1630
@Route('tips')
1731
export class TipsController {
1832
@Security('user-token')
@@ -21,48 +35,59 @@ export class TipsController {
2135
@Body() body: TipSessionRequest,
2236
@Request() req: express.Request
2337
): Promise<{ sessionId: string }> {
24-
const { amount, successUrl, cancelUrl } = body;
38+
const {
39+
amount,
40+
successUrl,
41+
cancelUrl,
42+
gift,
43+
frequency = TipFrequency.ONCE,
44+
} = body;
2545
const userId = await getUserFromRequestOrCreateAndSetCookie(req);
2646

2747
const user = await UserService.getUser(userId);
2848

29-
const stripeCustomerId: string | undefined =
30-
user?.stripeCustomerId ?? undefined;
31-
const hasExistingCustomer = !!stripeCustomerId;
32-
3349
try {
34-
const session = await stripe.checkout.sessions.create({
35-
cancel_url: cancelUrl,
36-
mode: 'payment',
37-
success_url: successUrl,
38-
line_items: [
39-
{
40-
price_data: {
41-
currency: 'USD',
42-
product_data: {
43-
name: 'Tip for 1940s.nyc',
44-
},
45-
unit_amount: amount,
46-
},
47-
quantity: 1,
48-
},
49-
],
50-
metadata: {
51-
userId,
52-
},
53-
customer: stripeCustomerId,
54-
// History: Stripe used to always create a customer, then it started creating "guest customers" instead (annoying!). This is to force it to always create a customer which we can attach to the user in the webhook.
55-
customer_creation: hasExistingCustomer ? undefined : 'always',
56-
customer_email: hasExistingCustomer
57-
? undefined
58-
: user?.email ?? undefined,
59-
payment_intent_data: {},
50+
const sessionId = await TipsService.createTipCheckoutSession({
51+
amountMinorUnits: amount,
52+
successUrl,
53+
cancelUrl,
54+
user,
55+
frequency,
56+
gift,
6057
});
6158

62-
return { sessionId: session.id };
59+
return { sessionId: sessionId };
6360
} catch (err) {
61+
if (err instanceof HttpError) {
62+
throw err;
63+
}
6464
console.error('Failed to create session', err);
6565
throw new InternalServerError('Failed to create session');
6666
}
6767
}
68+
69+
@Get('/gifts')
70+
public getGifts(): GiftApiResponse[] {
71+
return GiftRegistry.getAllAvailableGifts().map((gift) => {
72+
return {
73+
gift: gift.gift,
74+
minimumAmount: gift.minimumAmount,
75+
frequency: gift.frequency,
76+
};
77+
});
78+
}
79+
80+
@Security('user-token')
81+
@Post('/customer-portal-session')
82+
public async createCustomerPortalSession(
83+
@Body() { returnUrl }: CustomerPortalRequest,
84+
@Request() req: express.Request
85+
): Promise<{ url: string }> {
86+
const userId = await getUserFromRequestOrCreateAndSetCookie(req);
87+
88+
const user = await UserService.getUser(userId);
89+
const url = await TipsService.createCustomerPortalSession(user, returnUrl);
90+
91+
return { url: url };
92+
}
6893
}

backend/src/api/auth/authCookieUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,7 @@ export function setAuthCookie(token: string, res: Express.Response): void {
1717
export function getAuthCookie(req: Express.Request): string | undefined {
1818
return (req.cookies as Cookies)[USER_TOKEN_COOKIE];
1919
}
20+
21+
export function clearAuthCookie(res: Express.Response): void {
22+
res.clearCookie(USER_TOKEN_COOKIE);
23+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import TipFrequency from '../../enum/TipFrequency';
2+
import isProduction from '../utils/isProduction';
3+
4+
interface GiftRegistryItem {
5+
gift: Gift;
6+
minimumAmount: number;
7+
frequency: TipFrequency;
8+
stripePrice: string;
9+
stripeShippingRate: string;
10+
stripeShippingPrice: string;
11+
shippingCountries: string[];
12+
}
13+
14+
// This id should be unique per frequency
15+
export enum Gift {
16+
TOTE_BAG = 'tote-bag',
17+
}
18+
19+
// This should be the $0 price for the product
20+
const TOTE_BAG_PRICE = isProduction()
21+
? 'price_1QtGCuFCLBtNZLVl8SfHC9c5'
22+
: 'price_1QqH41FCLBtNZLVldOb9KMa3';
23+
24+
const TOTE_BAG_SHIPPING_RATE = isProduction()
25+
? 'shr_1QtGHBFCLBtNZLVl84P2gynP'
26+
: 'shr_1QssVnFCLBtNZLVlK1TZpXet';
27+
28+
const TOTE_BAG_SHIPPING_PRICE = isProduction()
29+
? 'price_1R69yOFCLBtNZLVlD6wzaeug'
30+
: 'price_1QwrXUFCLBtNZLVlsXEdxRYh';
31+
32+
const TOTE_BAG_SHIPPING_COUNTRIES = ['US'];
33+
34+
const TOTE_BAG_DEFAULTS = {
35+
gift: Gift.TOTE_BAG,
36+
stripePrice: TOTE_BAG_PRICE,
37+
stripeShippingRate: TOTE_BAG_SHIPPING_RATE,
38+
stripeShippingPrice: TOTE_BAG_SHIPPING_PRICE,
39+
shippingCountries: TOTE_BAG_SHIPPING_COUNTRIES,
40+
} as const;
41+
42+
const GIFT_REGISTRY: GiftRegistryItem[] = [
43+
{
44+
minimumAmount: 700,
45+
frequency: TipFrequency.MONTHLY,
46+
...TOTE_BAG_DEFAULTS,
47+
},
48+
{
49+
minimumAmount: 700 * 9,
50+
frequency: TipFrequency.ONCE,
51+
...TOTE_BAG_DEFAULTS,
52+
},
53+
];
54+
55+
// Validate registry has has unique gift and frequency
56+
const giftFrequencySet = new Set();
57+
GIFT_REGISTRY.forEach((item) => {
58+
const key = `${item.gift}-${item.frequency}`;
59+
if (giftFrequencySet.has(key)) {
60+
throw new Error(`Duplicate gift and frequency in registry: ${key}`);
61+
}
62+
giftFrequencySet.add(key);
63+
});
64+
65+
export function getGift(
66+
gift: Gift,
67+
frequency: TipFrequency,
68+
amount: number
69+
): GiftRegistryItem | undefined {
70+
return GIFT_REGISTRY.find(
71+
(item) =>
72+
item.gift === gift &&
73+
item.frequency === frequency &&
74+
amount >= item.minimumAmount
75+
);
76+
}
77+
78+
export function getAllAvailableGifts(): GiftRegistryItem[] {
79+
return GIFT_REGISTRY;
80+
}

0 commit comments

Comments
 (0)