-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
10 changed files
with
433 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import Joi from "joi" | ||
|
||
const errorHandler = (req, res, error) => { | ||
// Error response present if it's an axios error | ||
// statusCode present if it's a custom http-error | ||
let status = error.response?.status || error.statusCode || 500 | ||
const message = error.response?.data?.message || error.message | ||
|
||
// Check to see if it's a Joi error, | ||
// and make sure to expose it | ||
if (Joi.isError(error)) { | ||
status = 422 | ||
error.expose = true | ||
} | ||
|
||
// Something went wrong, log it | ||
console.error(`${status} -`, message) | ||
|
||
// Respond with error code and message | ||
res.status(status).json({ | ||
message: error.expose ? message : `Faulty ${req.baseUrl}`, | ||
}) | ||
} | ||
|
||
export default errorHandler |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import StripeAPI from "stripe" | ||
|
||
export const Stripe = (secretKey = process.env.STRIPE_SECRET_KEY) => { | ||
const stripeApi = StripeAPI(secretKey) | ||
|
||
const log = (...args) => { | ||
console.log("Stripe:", ...args) | ||
} | ||
|
||
const getStripeSubscription = async ({ id }) => { | ||
const subscription = await stripeApi.subscriptions.retrieve(id) | ||
|
||
log("Fetched subscription", { stripeSubscriptionId: subscription.id }) | ||
|
||
return subscription | ||
} | ||
|
||
return { | ||
getStripeSubscription, | ||
} | ||
} | ||
|
||
export default Stripe() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import axios from "axios" | ||
|
||
export const Userbase = ( | ||
accessToken = process.env.USERBASE_ADMIN_API_ACCESS_TOKEN | ||
) => { | ||
const userbaseApi = axios.create({ | ||
baseURL: "https://v1.userbase.com/v1/admin", | ||
headers: { | ||
Authorization: `Bearer ${accessToken}`, | ||
}, | ||
}) | ||
|
||
const log = (...args) => { | ||
console.log("Userbase:", ...args) | ||
} | ||
|
||
const verifyUserbaseAuthToken = async ({ | ||
userbaseAuthToken, | ||
userbaseUserId, | ||
}) => { | ||
const { data: user } = await userbaseApi.get( | ||
"auth-tokens/" + userbaseAuthToken | ||
) | ||
|
||
log("Auth token verified", { | ||
userbaseUserId: data.userId, | ||
}) | ||
|
||
if (user.userId !== userbaseUserId) { | ||
throw Error("userbaseAuthToken / userbaseUserId mismatch") | ||
} | ||
|
||
return user | ||
} | ||
|
||
const getUserbaseUser = async ({ userbaseUserId }) => { | ||
let { data: user } = await userbaseApi.get("users/" + userbaseUserId) | ||
|
||
user.stripeData = user[process.env.USERBASE_STRIPE_ENV] || {} | ||
user.protectedProfile = user.protectedProfile || {} | ||
user.profile = user.profile || {} | ||
|
||
log("Fetched user", { userbaseUserId: user.userId, user }) | ||
|
||
return user | ||
} | ||
|
||
return { | ||
verifyUserbaseAuthToken, | ||
getUserbaseUser, | ||
} | ||
} | ||
|
||
export default Userbase() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import axios from "axios" | ||
|
||
export const Userlist = (pushKey = process.env.USERLIST_PUSH_KEY) => { | ||
const userlistApi = axios.create({ | ||
baseURL: "https://push.userlist.com", | ||
headers: { | ||
Authorization: `Push ${pushKey}`, | ||
}, | ||
}) | ||
|
||
const log = (...args) => { | ||
console.log("Userlist:", ...args) | ||
} | ||
|
||
const upsertUserlistSubscriber = async ({ | ||
identifier, | ||
email, | ||
signed_up_at, | ||
properties, | ||
}) => { | ||
const { data, status } = await userlistApi.post(`users`, { | ||
identifier, | ||
email, | ||
signed_up_at, | ||
properties, | ||
}) | ||
|
||
log("Subscriber updated", identifier, data) | ||
log("Subscriber updated - status", status) | ||
} | ||
|
||
return { | ||
upsertUserlistSubscriber, | ||
} | ||
} | ||
|
||
export default Userlist() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import createError from "http-errors" | ||
import userbase from "./services/userbase" | ||
import userlist from "./services/userlist" | ||
|
||
const syncSettings = async (userbaseUserId) => { | ||
try { | ||
// Get the Userbase User and pluck data wanted | ||
const userbaseUser = await userbase.getUserbaseUser({ userbaseUserId }) | ||
|
||
const { | ||
userId, | ||
email, | ||
creationDate, | ||
profile: { newsletter }, | ||
} = userbaseUser | ||
|
||
const props = { | ||
newsletter_at: null, | ||
test: null, | ||
} | ||
|
||
if (newsletter == "1") { | ||
// == Matches both 1 and "1" | ||
// Signed up before using date | ||
props.newsletter_at = new Date(0) | ||
} else if (newsletter == "0") { | ||
// == Matches both 0 and "0" | ||
props.newsletter_at = null | ||
} else if (newsletter) { | ||
// Hopefully a date | ||
props.newsletter_at = new Date(newsletter) | ||
} | ||
|
||
// Push data on user into Userlist | ||
await userlist.upsertUserlistSubscriber({ | ||
identifier: userId, | ||
email: email, | ||
signed_up_at: creationDate, | ||
properties: props, | ||
}) | ||
console.log("syncSettings - success", userId) | ||
} catch (error) { | ||
const { message } = error.response?.data || error.request?.data || error | ||
throw new createError.InternalServerError( | ||
"syncSettings - error: " + message | ||
) | ||
} | ||
} | ||
|
||
export default syncSettings |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
import createError from "http-errors" | ||
import userbase from "./services/userbase" | ||
import userlist from "./services/userlist" | ||
import stripe from "./services/stripe" | ||
|
||
const timestampDate = (timestamp) => { | ||
return timestamp ? new Date(timestamp * 1000) : null | ||
} | ||
|
||
const syncSubscription = async (userbaseUserId) => { | ||
try { | ||
// Get the Userbase User and pluck data wanted | ||
const userbaseUser = await userbase.getUserbaseUser({ userbaseUserId }) | ||
|
||
const { | ||
userId, | ||
email, | ||
username, | ||
creationDate, | ||
size, | ||
protectedProfile: { | ||
stripeCustomerId: oldStripeCustomerId, | ||
stripePlanId: oldStripePlanId, | ||
}, | ||
stripeData: { subscriptionPlanId, subscriptionId }, | ||
} = userbaseUser | ||
|
||
const props = { | ||
username: username, | ||
size: size, | ||
// Must use null to null stuff out in userlist, | ||
// undefined does not work | ||
subscription_id: subscriptionId || null, | ||
subscription_plan_id: subscriptionPlanId || null, | ||
subscription_status: null, | ||
subscription_created_at: null, | ||
subscription_start_date: null, | ||
subscription_cancel_at: null, | ||
subscription_canceled_at: null, | ||
subscription_ended_at: null, | ||
} | ||
|
||
if (subscriptionId) { | ||
const { | ||
status, | ||
created, | ||
start_date, | ||
cancel_at, | ||
canceled_at, | ||
ended_at, | ||
} = await stripe.getStripeSubscription({ | ||
id: subscriptionId, | ||
}) | ||
|
||
props.subscription_status = status | ||
props.subscription_created_at = timestampDate(created) | ||
props.subscription_start_date = timestampDate(start_date) | ||
props.subscription_cancel_at = timestampDate(cancel_at) | ||
props.subscription_canceled_at = timestampDate(canceled_at) | ||
props.subscription_ended_at = timestampDate(ended_at) | ||
} else if (oldStripeCustomerId) { | ||
// Older users that cancelled before migrating to Userbase's integrated payment | ||
props.subscription_id = null // Did not save subscription id | ||
props.subscription_plan_id = oldStripePlanId | ||
props.subscription_status = "canceled" // "canceled" is same as used by Stripe, no double "ll" | ||
} | ||
|
||
// Push data on user into Userlist | ||
await userlist.upsertUserlistSubscriber({ | ||
identifier: userId, | ||
email: email, | ||
signed_up_at: creationDate, | ||
properties: props, | ||
}) | ||
|
||
console.log("syncSubscription - success", userId) | ||
} catch (error) { | ||
const { message } = error.response?.data || error.request?.data || error | ||
throw new createError.InternalServerError( | ||
"syncSubscription - error: " + message | ||
) | ||
} | ||
} | ||
|
||
export default syncSubscription |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import createError from "http-errors" | ||
import Joi from "joi" | ||
import errorHandler from "../api-utils/error-handler" | ||
import syncSubscription from "../api-utils/sync-subscription" | ||
|
||
/* | ||
* Webhook used to keep the Stripe Subscription information | ||
* in sync with Userlist on all subscription events. | ||
* | ||
* @todo add a secret as query param, to mimimize abuse? | ||
* | ||
*/ | ||
|
||
export default async function handler(req, res) { | ||
console.log(`${req.baseUrl} - ${req.method}`) | ||
|
||
try { | ||
// Only handle POST requests for webhooks | ||
if (req.method === "POST") { | ||
await webhookHandler(req, res) | ||
} else { | ||
throw createError(405, `${req.method} not allowed`) | ||
} | ||
} catch (error) { | ||
errorHandler(req, res, error) | ||
} | ||
} | ||
|
||
const webhookHandler = async (req, res) => { | ||
// 1. Validate | ||
|
||
const bodySchema = Joi.object({ | ||
type: Joi.string() | ||
.valid( | ||
"customer.subscription.created", | ||
"customer.subscription.updated", | ||
"customer.subscription.deleted" | ||
) | ||
.required(), | ||
data: Joi.object({ | ||
object: Joi.object({ | ||
metadata: { | ||
__userbase_user_id: Joi.string().required(), | ||
}, | ||
}).required(), | ||
}).required(), | ||
}).options({ allowUnknown: true }) | ||
|
||
// Deconstruct and rename user id | ||
const { | ||
data: { | ||
object: { | ||
metadata: { __userbase_user_id: userbaseUserId }, | ||
}, | ||
}, | ||
} = await bodySchema.validateAsync(req.body) | ||
|
||
// 2. Do the thing | ||
|
||
await syncSubscription(userbaseUserId) | ||
|
||
// 3. Respond | ||
|
||
res.send("OK") | ||
} |
Oops, something went wrong.