Stripe integration flow

This commit is contained in:
metacryst
2026-03-05 00:29:34 -06:00
parent bdd260c2b5
commit 661bf86a1a
17 changed files with 303 additions and 117 deletions

View File

@@ -28,18 +28,31 @@ export default class AuthHandler {
} }
} }
getProfile(req, res) { getRequestEmail(req, res) {
const token = req.cookies?.auth_token; const token = req.cookies?.auth_token;
if (!token) return res.status(401).send({ error: "No auth token" }); if (!token) return res.status(401).send({ error: "No auth token" });
try { try {
const payload = jwt.verify(token, process.env.JWT_SECRET); const payload = jwt.verify(token, process.env.JWT_SECRET);
const email = payload.email; const email = payload.email;
return email
} catch (e) {
console.error("Error getting profile: ", e)
throw new Error("Error getting email: invalid auth token")
}
}
getProfile(req, res) {
try {
let email = global.auth.getRequestEmail(req, res)
const user = db.members.getByEmail(email); const user = db.members.getByEmail(email);
let connections = db.MEMBER_IN_NETWORK.getByMember(db.members.prefix + "-" + user.id) let connections = db.MEMBER_IN_NETWORK.getByMember(db.members.prefix + "-" + user.id)
let userOrgs = connections.map((c) => { let userOrgs = connections.map((c) => {
return db.networks.get(c.to) let network = db.networks.get(c.to)
delete network.stripeAccountId
delete network.stripeAccessToken
return network
}) })
res.send({ res.send({

View File

@@ -37,6 +37,8 @@ export default class Database {
return model.indices[1] - model.indices[0] + 1 return model.indices[1] - model.indices[0] + 1
} }
// add a get in here that returns a safe copy, not the actual reference
addNode(prefix, node) { addNode(prefix, node) {
try { try {
let model = nodeModels[prefix] let model = nodeModels[prefix]
@@ -76,7 +78,8 @@ export default class Database {
} }
} }
editNode(prefix, id, toEdit) { updateNode(prefix, id, toEdit) {
console.log("update node, ", toEdit)
try { try {
let model = nodeModels[prefix] let model = nodeModels[prefix]
if(model) { if(model) {

View File

@@ -94,7 +94,7 @@ export default class Post {
postToEdit.edited = true; postToEdit.edited = true;
let { authorId, ...rest } = postToEdit let { authorId, ...rest } = postToEdit
global.db.editNode(this.prefix, id, rest) global.db.updateNode(this.prefix, id, rest)
return postToEdit return postToEdit
} catch(e) { } catch(e) {

View File

@@ -43,15 +43,6 @@ export default class Member {
} }
} }
getByNetwork(id) {
let connections = db.MEMBER_IN_NETWORK.getByNetwork(id)
let members = []
connections.forEach((conn) => {
members.push(this.getByID(conn.from))
})
return members
}
async getPersonalData(id) { async getPersonalData(id) {
const filePath = path.join(global.db.PERSONAL_DATA_PATH, id, "db.json"); const filePath = path.join(global.db.PERSONAL_DATA_PATH, id, "db.json");
@@ -63,6 +54,15 @@ export default class Member {
return result return result
} }
getByNetwork(id) {
let connections = db.MEMBER_IN_NETWORK.getByNetwork(id)
let members = []
connections.forEach((conn) => {
members.push(this.getByID(conn.from))
})
return members
}
getByID(id) { getByID(id) {
if(typeof id === 'string') { if(typeof id === 'string') {
id = id.split("-")[1] id = id.split("-")[1]

View File

@@ -14,14 +14,18 @@ export default class Network {
apps: z.array(z.string()), apps: z.array(z.string()),
logo: z.string(), logo: z.string(),
abbreviation: z.string(), abbreviation: z.string(),
created: z.string() created: z.string(),
stripeAccountId: z.string().nullable(),
stripeAccessToken: z.string().nullable()
}) })
.strict() .strict()
get(stringID) { get(id) {
let id = stringID.split("-")[1] if(typeof id === "string") {
id = id.split("-")[1]
}
let index = this.indices[0] + (id - 1) let index = this.indices[0] + (id - 1)
return global.db.nodes[index] return structuredClone(global.db.nodes[index])
} }
getByAbbreviation(abbreviation) { getByAbbreviation(abbreviation) {
@@ -33,6 +37,21 @@ export default class Network {
return null return null
} }
update(id, data) {
if(data.id || data.created) {
throw new Error("Can't update node id or created time!")
}
let currentObject = this.get(id)
let newObject = {...currentObject, ...data}
try {
global.db.updateNode(this.prefix, newObject.id, newObject)
} catch(e) {
console.error(e)
throw new global.ServerError(400, "Failed to add member!");
}
}
save(n) { save(n) {
let id = `${this.prefix}-${n.id}` let id = `${this.prefix}-${n.id}`
let result = this.schema.safeParse(n) let result = this.schema.safeParse(n)

View File

@@ -28,6 +28,7 @@ class Server {
/* Stripe */ /* Stripe */
router.post("/create-checkout-session", PaymentsHandler.newSubscription) router.post("/create-checkout-session", PaymentsHandler.newSubscription)
router.post("/webhook", express.raw({ type: "application/json" }), PaymentsHandler.webhook) router.post("/webhook", express.raw({ type: "application/json" }), PaymentsHandler.webhook)
router.post('/api/stripe/onboarded', PaymentsHandler.finishConnectSetup)
/* Auth */ /* Auth */
router.post('/login', this.auth.login) router.post('/login', this.auth.login)
@@ -39,32 +40,10 @@ class Server {
router.get('/db/images/*', this.getUserImage) router.get('/db/images/*', this.getUserImage)
router.get('/api/orgdata/*', this.getOrgData) router.get('/api/orgdata/*', this.getOrgData)
router.get('/api/mydata/*', this.getPersonalData) router.get('/api/mydata/*', this.getPersonalData)
router.get('/api/stripe/onboarded', this.stripeOnboarded)
router.get('/*', this.get) router.get('/*', this.get)
return router return router
} }
async stripeOnboarded() {
const { code } = req.body;
const response = await stripe.oauth.token({
grant_type: "authorization_code",
code,
});
const { stripe_user_id, access_token } = response;
await db.users.update({
where: { id: req.user.id },
data: {
stripeAccountId: stripe_user_id,
stripeAccessToken: access_token,
}
});
res.json({ success: true });
}
getPersonalData = async (req, res, next) => { getPersonalData = async (req, res, next) => {
try { try {
const memberId = req.params[0] const memberId = req.params[0]
@@ -243,8 +222,10 @@ class Server {
constructor() { constructor() {
this.db = new Database() this.db = new Database()
global.db = this.db
this.auth = new AuthHandler() this.auth = new AuthHandler()
global.db = this.db
global.auth = this.auth
global.payments = PaymentsHandler
const app = express(); const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), PaymentsHandler.webhook) app.post("/webhook", express.raw({ type: "application/json" }), PaymentsHandler.webhook)
const allowedOrigins = new Set([ const allowedOrigins = new Set([

View File

@@ -6,6 +6,56 @@ const stripe = new Stripe(process.env.STRIPE_SECRET);
export default class PaymentsHandler { export default class PaymentsHandler {
static async finishConnectSetup(req, res) {
const { code, networkId } = req.body;
console.log("onboarded", networkId)
const response = await stripe.oauth.token({
grant_type: "authorization_code",
code,
});
const { stripe_user_id, access_token } = response;
await db.networks.update(
networkId,
{
stripeAccountId: stripe_user_id,
stripeAccessToken: access_token, // rarely used, long-term access token for the platform
}
);
res.json({ success: true });
}
static async getProfile(networkId) {
let network = global.db.networks.get(networkId)
if (network) {
if (network.stripeAccountId) {
const account = await stripe.accounts.retrieve(network.stripeAccountId);
return {
chargesEnabled: account.charges_enabled,
payoutsEnabled: account.payouts_enabled,
detailsSubmitted: account.details_submitted,
email: account.email,
country: account.country,
}
} else {
return { connected: false }
}
} else {
throw new Error(`Network ${networkId} not found`)
}
}
static async getCustomers() {
const customers = await stripe.customers.list(
{ limit: 100 },
{ stripeAccount: 'acct_connected_account_id' }
);
console.log(customers)
}
static async newSubscription(req, res) { static async newSubscription(req, res) {
try { try {
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({

View File

@@ -2,16 +2,30 @@ import { WebSocket, WebSocketServer } from 'ws';
import { z } from 'zod'; import { z } from 'zod';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import * as serverFunctions from "../../ui/_/code/bridge/serverFunctions.js" import server from "../../ui/_/code/bridge/serverFunctions.js"
import ForumHandler from "./handlers/ForumHandler.js" import ForumHandler from "./handlers/ForumHandler.js"
import MessagesHandler from "./handlers/MessagesHandler.js" import MessagesHandler from "./handlers/MessagesHandler.js"
export default class Socket { export default class Socket {
wss; wss;
messageSchema = z.object({
functionCallSchema = z.object({
id: z.string(),
name: z.string(),
args: z.array(z.any()),
data: z.union([
z.object({}).passthrough(), // allows any object
z.array(z.any()) // allows any array
]).optional()
})
.strict()
appOperationSchema = z.object({
id: z.string(), id: z.string(),
app: z.string().optional(), app: z.string().optional(),
operation: z.string().optional(), operation: z.string().optional(),
msg: z.union([ msg: z.union([
z.object({}).passthrough(), // allows any object z.object({}).passthrough(), // allows any object
z.array(z.any()) // allows any array z.array(z.any()) // allows any array
@@ -69,11 +83,26 @@ export default class Socket {
// Build a system where the ws obj is updated every time on navigate, so it already has context // Build a system where the ws obj is updated every time on navigate, so it already has context
// this way, we can only send broadcast messages to clients that actually have that app / subapp open // this way, we can only send broadcast messages to clients that actually have that app / subapp open
handleMessage = async (msg, ws) => { handleMessage = async (msg, ws) => {
try { try {
const text = msg.toString(); const text = msg.toString();
const req = JSON.parse(text); const req = JSON.parse(text);
if(!this.messageSchema.safeParse(req).success) throw new Error("Socket.handleMessage: Incoming ws message has incorrect format!") console.log(req)
if(this.appOperationSchema.safeParse(req).success) {
this.handleAppOperation(req, ws)
} else if(this.functionCallSchema.safeParse(req).success) {
this.handleFunction(req, ws)
} else {
throw new Error("Socket.handleMessage: Incoming ws message has incorrect format!")
}
} catch (e) {
console.error("Invalid WS message:", e);
}
}
async handleAppOperation(req, ws) {
let responseData; let responseData;
switch (req.app) { switch (req.app) {
case "FORUM": case "FORUM":
@@ -85,12 +114,7 @@ export default class Socket {
break; break;
default: default:
if(!req.app) { console.log("unknown ws message")
let func = req.msg
responseData = serverFunctions[func.name](...args)
} else {
console.error("unknown ws message")
}
} }
let response = { let response = {
@@ -98,12 +122,23 @@ export default class Socket {
} }
response.msg = responseData response.msg = responseData
if(!this.messageSchema.safeParse(response).success) throw new Error("Socket.handleMessage: Outgoing ws message has incorrect format!") if(!this.appOperationSchema.safeParse(response).success) throw new Error("Socket.handleMessage: Outgoing ws message has incorrect format!")
ws.send(JSON.stringify(response)) ws.send(JSON.stringify(response))
} catch (e) {
console.error("Invalid WS message:", e);
} }
async handleFunction(req, ws) {
console.log("func call: ", req.name, req.args)
let responseData = await server[req.name](...req.args)
let response = {
...req
}
response.data = responseData
console.log(response)
if(!this.functionCallSchema.safeParse(response).success) throw new Error("Socket.handleMessage: Outgoing ws message has incorrect format!")
ws.send(JSON.stringify(response))
} }
broadcast(event) { broadcast(event) {

View File

@@ -1,4 +1,4 @@
const IS_NODE = export const IS_NODE =
typeof process !== "undefined" && typeof process !== "undefined" &&
process.versions?.node != null process.versions?.node != null
@@ -9,9 +9,7 @@ async function bridgeSend(name, args) {
args: args args: args
}) })
const json = await res.json() return res
if (!res.ok) throw new Error(json.error)
return json.result
} }
/** /**
@@ -24,8 +22,6 @@ export function createBridge(funcs) {
get(target, prop) { get(target, prop) {
const orig = target[prop] const orig = target[prop]
if (typeof orig !== "function") return orig
return function (...args) { return function (...args) {
if (IS_NODE) { if (IS_NODE) {
return orig(...args) return orig(...args)

View File

@@ -0,0 +1,14 @@
import fs from "fs"
const handlers = {
getProfile(one, two) {
fs.writeFileSync("output.txt", `${one} ${two}`)
return "written to disk"
},
async getStripeProfile(networkId) {
return global.payments.getProfile(networkId)
}
}
export default handlers

View File

@@ -1,11 +1,10 @@
import fs from "fs" import { createBridge, IS_NODE } from "./bridge.js"
import { createBridge } from "./bridge.js"
const handlers = { let handlers = {}
getProfile(one, two) {
fs.writeFileSync("output.txt", `${one} ${two}`) if (IS_NODE) {
return "written to disk" const mod = await import("./handlers.js")
}, handlers = mod.default
} }
export const { getProfile } = createBridge(handlers) export default createBridge(handlers)

View File

@@ -1,6 +1,7 @@
/* /*
Sam Russell Sam Russell
Captured Sun Captured Sun
3.4.26 - Making horizontalAlign() and verticalAlign() methods of checking for stacks more robust
2.27.26 - Adding parentShadow() function 2.27.26 - Adding parentShadow() function
2.16.26 - Adding event objects to the onTouch callbacks 2.16.26 - Adding event objects to the onTouch callbacks
1.16.26 - Moving nav event dispatch out of pushState, adding null feature to attr() 1.16.26 - Moving nav event dispatch out of pushState, adding null feature to attr()
@@ -673,11 +674,11 @@ HTMLElement.prototype.centerY = function () {
}; };
HTMLElement.prototype.verticalAlign = function (value) { HTMLElement.prototype.verticalAlign = function (value) {
const direction = getComputedStyle(this).flexDirection; // if(!this.classList.contains("HStack") && !this.classList.contains("VStack")) {
if(!direction) { // throw new Error("verticalAlign can be only be used on HStacks or VStacks!")
throw new Error("verticalAlign can be only be used on HStacks or VStacks!") // }
}
const direction = getComputedStyle(this).flexDirection;
if (direction === "column" || direction === "column-reverse") { if (direction === "column" || direction === "column-reverse") {
this.style.justifyContent = value; this.style.justifyContent = value;
} else { } else {
@@ -687,11 +688,11 @@ HTMLElement.prototype.verticalAlign = function (value) {
} }
HTMLElement.prototype.horizontalAlign = function (value) { HTMLElement.prototype.horizontalAlign = function (value) {
const direction = getComputedStyle(this).flexDirection; if(!this.classList.contains("HStack") && !this.classList.contains("VStack")) {
if(!direction) {
throw new Error("horizontalAlign can be only be used on HStacks or VStacks!") throw new Error("horizontalAlign can be only be used on HStacks or VStacks!")
} }
const direction = getComputedStyle(this).flexDirection;
if (direction === "column" || direction === "column-reverse") { if (direction === "column" || direction === "column-reverse") {
this.style.alignItems = value; this.style.alignItems = value;
} else { } else {

View File

@@ -5,6 +5,7 @@ class Connection {
constructor(receiveCB) { constructor(receiveCB) {
this.receiveCB = receiveCB; this.receiveCB = receiveCB;
this.init()
} }
init = async () => { init = async () => {

View File

@@ -1,13 +1,22 @@
import env from "/_/code/env.js" import env from "/_/code/env.js"
import server from "/_/code/bridge/serverFunctions.js"
import "../../components/LoadingCircleSmall.js"
class Settings extends Shadow { class Settings extends Shadow {
stripeDetails = null
handleConnectStripe = () => { handleConnectStripe = () => {
const state = btoa(JSON.stringify({
returnTo: window.location.href,
networkId: global.currentNetwork.id
}));
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: env.client_id, client_id: env.client_id,
scope: 'read_write', scope: 'read_write',
redirect_uri: `${env.baseURL}/stripe/onboardingcomplete`, redirect_uri: `${env.baseURL}/stripe/onboardingcomplete`,
state,
}); });
window.location.href = `https://connect.stripe.com/oauth/authorize?${params}`; window.location.href = `https://connect.stripe.com/oauth/authorize?${params}`;
@@ -21,22 +30,38 @@ class Settings extends Shadow {
.marginLeft(5, pct) .marginLeft(5, pct)
VStack(() => { VStack(() => {
// HStack(() => { HStack(() => {
// p("Stripe Integration") p("Stripe Integration")
// button("Set up Stripe")
// .maxWidth(10, em) if(this.stripeDetails && this.stripeDetails.data.email) {
// .onClick((done) => { p("connected")
// this.handleConnectStripe() } else if(this.stripeDetails && this.stripeDetails.data.connected === false) {
// }) button("Set up Stripe")
// }) .maxWidth(10, em)
// .gap(10, pct) .onClick((done) => {
// .verticalAlign("center") this.handleConnectStripe()
})
} else {
LoadingCircleSmall()
}
})
.gap(10, pct)
.verticalAlign("center")
}) })
.gap(0.5, em) .gap(0.5, em)
.paddingLeft(5, pct) .paddingLeft(5, pct)
.marginTop(4, em) .marginTop(4, em)
}) })
} }
async getStripeProfile() {
this.stripeDetails = await server.getStripeProfile(global.currentNetwork.id)
this.rerender()
}
connectedCallback() {
this.getStripeProfile()
}
} }
register(Settings) register(Settings)

View File

@@ -6,9 +6,29 @@ class AppMenu extends Shadow {
"Settings": {src: "settings-src", size: "1.7em"} "Settings": {src: "settings-src", size: "1.7em"}
} }
unselectedIconStyle(el) {
return el
.paddingBottom(5, px)
.borderBottom("")
}
selectedIconStyle(el) {
return el
.paddingBottom(4, px)
.borderBottom("1px solid var(--accent)")
}
onAppChange() {
let icons = this.$$("img")
icons.forEach((icon) => {
icon.styles(this.unselectedIconStyle)
})
let selected = this.$(`img[app="${global.currentApp}"]`)
selected.styles(this.selectedIconStyle)
}
render() { render() {
VStack(() => { VStack(() => {
function cssVariable(value) { function cssVariable(value) {
return getComputedStyle(document.documentElement) return getComputedStyle(document.documentElement)
.getPropertyValue("--" + value) .getPropertyValue("--" + value)
@@ -25,8 +45,7 @@ class AppMenu extends Shadow {
img(cssVariable(this.images[app].src), this.images[app].size) img(cssVariable(this.images[app].src), this.images[app].size)
.attr({app: app}) .attr({app: app})
.padding(0.3, em) .padding(0.3, em)
.paddingBottom(currentApp === app ? 4 : 5, px) .styles(currentApp === app ? this.selectedIconStyle : this.unselectedIconStyle)
.borderBottom(currentApp === app ? "1px solid var(--accent)" : "")
.onClick(function (done) { .onClick(function (done) {
if(!done) { if(!done) {
this.style.transform = "translateY(10%)" this.style.transform = "translateY(10%)"
@@ -54,7 +73,7 @@ class AppMenu extends Shadow {
}) })
.onEvent("appchange", () => { .onEvent("appchange", () => {
// console.log("app hello?") // BUG: Quill is not acknowledging this event unless there is something else in the function body // console.log("app hello?") // BUG: Quill is not acknowledging this event unless there is something else in the function body
this.rerender() this.onAppChange()
}) })
.onEvent("networkchange", () => { .onEvent("networkchange", () => {
// console.log(global.currentApp) // console.log(global.currentApp)

View File

@@ -0,0 +1,23 @@
class LoadingCircleSmall extends Shadow {
render() {
div()
.borderRadius(100, pct)
.width(1, em).height(1, em)
.backgroundColor("var(--accent")
.transition("transform 1.75s ease-in-out")
.onAppear(function () {
let growing = true;
setInterval(() => {
if (growing) {
this.style.transform = "scale(1.5)";
} else {
this.style.transform = "scale(0.7)";
}
growing = !growing;
}, 750);
});
}
}
register(LoadingCircleSmall)

View File

@@ -27,25 +27,32 @@ let Global = class {
return json return json
} }
onNavigate = async () => { async handleStripeOnboarding() {
console.log("onnavigate", this.getFirstPathSegment())
if(this.getFirstPathSegment() === "stripe") {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const code = params.get("code"); const code = params.get("code");
const { returnTo, networkId } = JSON.parse(atob(params.get("state")));
if (code) { if (code) {
console.log("success!: ", code) console.log("success!: ", code)
await fetch("/api/stripe/connect", { await fetch("/api/stripe/onboarded", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }) body: JSON.stringify({ code, networkId })
}); });
window.location.href = returnTo
} else { } else {
throw new Error("Stripe code is not present!") throw new Error("Stripe code is not present!")
} }
} }
onNavigate = async () => {
console.log("onnavigate", this.getFirstPathSegment())
if(this.getFirstPathSegment() === "stripe") {
this.handleStripeOnboarding()
}
let selectedNetwork = this.networkFromPath() let selectedNetwork = this.networkFromPath()
let selectedApp = this.appFromPath() let selectedApp = this.appFromPath()