UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

521 lines (466 loc) • 20.1 kB
/** * Routes related to the EE forge billing api * * @namespace api * @memberof forge.ee.billing */ const { Readable } = require('stream') /** * @typedef {import('stripe').Stripe.Event} StripeEvent */ module.exports = async function (app) { /** @type {import('stripe').Stripe} */ const stripe = require('stripe')(app.config.billing.stripe.key) function logStripeEvent (/** @type {StripeEvent} */ event, team, subscription, teamId = null, stripeCustomerId = null, subscriptionUnknown = false) { const intro = `Stripe ${event.type} event ${event.data.object.id} from ${stripeCustomerId} received for` if (team) { if (subscriptionUnknown) { app.log.warn(`${intro} team '${team.hashid}' for unknown subscription`) } else { app.log.info(`${intro} team '${team.hashid}'`) } } else if (teamId) { app.log.error(`${intro} unknown team by team ID '${teamId}'`) } else if (subscription) { app.log.warn(`${intro} deleted team with orphaned subscription`) } else { app.log.error(`${intro} unknown team by Stripe Customer ID`) } } async function updateSubscriptionStatus (subscription, newStatus, team, user) { if (subscription.status === newStatus) { return } const oldStatus = subscription.status subscription.status = newStatus await subscription.save() if (team) { const changes = new app.auditLog.formatters.UpdatesCollection() changes.push('status', oldStatus, newStatus) await app.auditLog.Team.billing.subscription.updated(user, null, team, subscription, changes) } } async function parseChargeEvent (/** @type {StripeEvent} */ event) { const stripeCustomerId = event.data.object.customer const subscription = await app.db.models.Subscription.byCustomerId(stripeCustomerId) const team = subscription?.Team logStripeEvent(event, team, subscription, null, stripeCustomerId) return { stripeCustomerId, subscription, team } } async function parseCheckoutEvent (/** @type {StripeEvent} */ event) { const stripeCustomerId = event.data.object.customer const stripeSubscriptionId = event.data.object.subscription const metadata = event.data.object.metadata || {} const teamId = event.data.object?.client_reference_id let team, subscription if (teamId) { team = await app.db.models.Team.byId(teamId) } else { const subscription = await app.db.models.Subscription.byCustomerId(stripeCustomerId) team = subscription?.Team } logStripeEvent(event, team, subscription, teamId, stripeCustomerId) return { stripeSubscriptionId, stripeCustomerId, team, metadata } } async function parseSubscriptionEvent (/** @type {StripeEvent} */ event) { const stripeSubscriptionId = event.data.object.id const stripeCustomerId = event.data.object.customer let subscription = await app.db.models.Subscription.byCustomerId(stripeCustomerId) const team = subscription?.Team // Check this event is for the known subscription for this customer. // A customer could have additional subscriptions created manually within // stripe - we must make sure we don't respond to events on those ones. let subscriptionUnknown = false if (subscription && subscription.subscription !== stripeSubscriptionId) { subscription = null subscriptionUnknown = true } logStripeEvent(event, team, subscription, null, stripeCustomerId, subscriptionUnknown) return { stripeSubscriptionId, stripeCustomerId, subscription, team } } /** * Need to work out what auth needs to have happend */ app.addHook('preHandler', async (request, response) => { if (request.params.teamId) { request.teamMembership = await request.session.User.getTeamMembership(request.params.teamId) if (!request.teamMembership && !request.session.User.admin) { response.code(404).type('text/html').send('Not Found') } request.team = await app.db.models.Team.byId(request.params.teamId) if (!request.team) { response.code(404).type('text/html').send('Not Found') } } }) /** * Callback for Stripe to report events * @name /ee/billing/callback * @static * @memberof forge.ee.billing */ app.post('/callback', { config: { allowAnonymous: true }, preParsing: function (request, reply, payload, done) { const chunks = [] payload.on('data', chunk => { chunks.push(chunk) }) payload.on('end', () => { const raw = Buffer.concat(chunks) request.rawBody = raw done(null, Readable.from(raw)) }) }, schema: { body: { type: 'object', required: ['type', 'data'], properties: { type: { type: 'string' }, data: { type: 'object' } } } } }, async (request, response) => { const sig = request.headers['stripe-signature'] /** @type {StripeEvent} */ let event = request.body if (app.config.billing?.stripe?.wh_secret) { try { event = stripe.webhooks.constructEvent(request.rawBody, sig, app.config.billing.stripe.wh_secret) } catch (err) { app.log.error(`Stripe event failed signature: ${err.toString()}`) response.code(400).type('text/hml').send('Failed Signature') return } } switch (event.type) { case 'charge.failed': { await parseChargeEvent(event) // Do nothing - just log event (handled above) break } case 'checkout.session.completed': { const { team, stripeSubscriptionId, stripeCustomerId, metadata } = await parseCheckoutEvent(event) if (!team) { response.status(200).send() return } let teamModified = false let currentTeamType = team.TeamType if (metadata.teamTypeId) { const [teamTypeId] = app.db.models.TeamType.decodeHashid(metadata.teamTypeId) if (teamTypeId !== team.TeamTypeId) { const newTeamType = await app.db.models.TeamType.byId(teamTypeId) const auditUpdates = { old: { id: team.TeamType.hashid, name: team.TeamType.name }, new: { id: newTeamType.hashid, name: newTeamType.name } } team.TeamTypeId = teamTypeId teamModified = true currentTeamType = newTeamType await app.auditLog.Team.team.type.changed(request.session?.User || 'system', null, team, auditUpdates) } } if (team.suspended) { app.auditLog.Team.team.unsuspended(request.session?.User || 'system', null, team) app.auditLog.Platform.platform.team.unsuspended(request.session?.User || 'system', null, team) team.suspended = false teamModified = true } if (teamModified) { await team.save() } await app.db.controllers.Subscription.createSubscription(team, stripeSubscriptionId, stripeCustomerId) await app.auditLog.Team.billing.session.completed(request.session?.User || 'system', null, team, event.data.object) app.log.info(`Created Subscription for team '${team.hashid}' (${currentTeamType.name}) with Stripe Customer ID '${stripeCustomerId}'`) break } case 'checkout.session.expired': { await parseCheckoutEvent(event) // Do nothing - just log (handled above) break } case 'customer.subscription.created': { const { team, stripeCustomerId, subscription } = await parseSubscriptionEvent(event) if (!team) { response.status(200).send() return } if (!subscription) { response.status(200).send() return } if (!event.data.object.metadata?.free_trial) { return } if (!app.db.controllers.Subscription.freeTrialCreditEnabled()) { app.log.error(`Received a new subscription with the trial flag set for ${team.hashid}, but trials are not configured.`) return } // Apply free trial in the form of credit to the Stripe customer that owns this team const creditAmount = app.config.billing.stripe.new_customer_free_credit await stripe.customers.createBalanceTransaction( stripeCustomerId, { amount: -creditAmount, currency: 'usd' } ) app.log.info(`Applied a credit of ${creditAmount} to ${stripeCustomerId} from team ${team.hashid}`) await app.auditLog.Team.billing.subscription.creditApplied(request.session.User, null, team, subscription, creditAmount) break } case 'customer.subscription.updated': { const { stripeSubscriptionId, team, subscription } = await parseSubscriptionEvent(event) if (!subscription) { response.status(200).send() return } const stripeSubscriptionStatus = event.data.object.status if (Object.values(app.db.models.Subscription.STATUS).includes(stripeSubscriptionStatus)) { await updateSubscriptionStatus(subscription, stripeSubscriptionStatus, team, request.session?.User || 'system') } else { app.log.warn(`Stripe subscription ${stripeSubscriptionId} has transitioned in Stripe to a state not currently handled: '${stripeSubscriptionStatus}'`) } break } case 'customer.subscription.deleted': { const { team, subscription } = await parseSubscriptionEvent(event) if (!subscription) { response.status(200).send() return } // Cancel our copy of the subscription await updateSubscriptionStatus(subscription, app.db.models.Subscription.STATUS.CANCELED, team, request.session?.User || 'system') if (!team) { response.status(200).send() return } // Suspend all projects of that team const projects = await app.db.models.Project.byTeam(team.hashid) await Promise.all(projects.map(async (project) => { app.log.info(`Stopping project ${project.id} from team ${team.hashid}`) if (await app.containers.stop(project)) { await app.auditLog.Project.project.suspended(request.session?.User || 'system', null, project) } })) app.log.info(`Suspended all projects for team ${team.hashid}`) // to-do: Suspend all devices and their updates - we think this happens automatically // to-do: Downgrade the team type (if possible) break } } response.code(200).send() } ) /** * Get Billing details for a team * @name /ee/billing/teams/:team * @static * @memberof forge.ee.billing */ app.get('/teams/:teamId', { preHandler: app.needsPermission('team:edit') }, async (request, response) => { const team = request.team const sub = await team.getSubscription() if (!sub || (!sub.isActive() && !sub.isUnmanaged())) { return response.code(404).send({ code: 'not_found', error: 'Team does not have a subscription' }) } if (sub.isUnmanaged()) { return response.code(403).send({ code: 'billing_unmanaged', error: 'Team does not have a managed subscription' }) } try { const stripeSubscriptionPromise = stripe.subscriptions.retrieve( sub.subscription, { expand: ['items.data.price.product'] } ) const stripeCustomerPromise = stripe.customers.retrieve(sub.customer) const stripeSubscription = await stripeSubscriptionPromise const stripeCustomer = await stripeCustomerPromise const information = { next_billing_date: stripeSubscription.current_period_end, items: [], customer: { name: stripeCustomer.name, balance: stripeCustomer.balance } } stripeSubscription.items.data.forEach(item => { information.items.push({ name: item.price.product.name, price: item.price.unit_amount, quantity: item.quantity }) }) response.status(200).send(information) } catch (err) { if (err.code === 'resource_missing') { return response.code(404).send({ code: 'not_found', error: 'Team does not have a subscription' }) } throw err } }) /** * Set up new billing subscription for a team * @name /ee/billing/teams/:team * @static * @memberof forge.ee.billing */ app.post('/teams/:teamId', { preHandler: app.needsPermission('team:edit') }, async (request, response) => { const team = request.team if (request.body?.teamTypeId) { const targetTeamType = await app.db.models.TeamType.byId(request.body.teamTypeId) if (!targetTeamType) { response.code(400).send({ code: 'invalid_team_type', error: 'Invalid team type' }) return } try { await request.team.checkTeamTypeUpdateAllowed(targetTeamType) } catch (err) { const result = { code: err.code || 'unexpected_error', error: err.toString() } if (err.errors) { result.errors = err.errors } response.code(400).send(result) return } } try { const session = await app.billing.createSubscriptionSession(team, request.session.User, request.body?.teamTypeId) await app.auditLog.Team.billing.session.created(request.session.User, null, team, session) response.code(200).type('application/json').send({ billingURL: session.url }) } catch (err) { // Standard errors let responseMessage if (err.errors) { responseMessage = err.errors.map(err => err.message).join(',') } else { responseMessage = err.toString() } // Catch all response.code(500).type('application/json').send({ code: 'unexpected_error', error: responseMessage }) } }) /** * Set up manual billing for a team * Admin only * @name /ee/billing/teams/:team/manual * @static * @memberof forge.ee.billing */ app.post('/teams/:teamId/manual', { preHandler: app.needsPermission('team:billing:manual') }, async (request, response) => { const team = request.team try { await app.billing.enableManualBilling(team) if (request.body?.teamTypeId) { const teamType = await app.db.models.TeamType.byId(request.body.teamTypeId) if (teamType) { team.setTeamType(teamType) await team.save() } } response.code(200).send({}) } catch (err) { // Standard errors let responseMessage if (err.errors) { responseMessage = err.errors.map(err => err.message).join(',') } else { responseMessage = err.toString() } // Catch all response.code(500).type('application/json').send({ code: err.code || 'unexpected_error', error: responseMessage }) } }) /** * Disables manual billing for a team * Admin only * @name /ee/billing/teams/:team/manual * @static * @memberof forge.ee.billing */ app.delete('/teams/:teamId/manual', { preHandler: app.needsPermission('team:billing:manual') }, async (request, response) => { const team = request.team try { await app.billing.disableManualBilling(team) response.code(200).send({}) } catch (err) { // Standard errors let responseMessage if (err.errors) { responseMessage = err.errors.map(err => err.message).join(',') } else { responseMessage = err.toString() } // Catch all response.code(500).type('application/json').send({ code: err.code || 'unexpected_error', error: responseMessage }) } }) /** * Update team trial settings * Admin only * @name /ee/billing/teams/:team/trial * @static * @memberof forge.ee.billing */ app.post('/teams/:teamId/trial', { preHandler: app.needsPermission('team:billing:trial') }, async (request, response) => { const team = request.team try { await app.billing.updateTrialSettings(team, request.body) response.code(200).send({}) } catch (err) { // Standard errors let responseMessage if (err.errors) { responseMessage = err.errors.map(err => err.message).join(',') } else { responseMessage = err.toString() } // Catch all response.code(400).type('application/json').send({ code: err.code || 'unexpected_error', error: responseMessage }) } }) /** * Redirect to the Stripe Customer portal * @name /ee/billing/teams/:team/customer-portal * @static * @memberof forge.ee.billing */ app.get('/teams/:teamId/customer-portal', { preHandler: app.needsPermission('team:edit') }, async (request, response) => { const team = request.team const sub = await team.getSubscription() const portal = await stripe.billingPortal.sessions.create({ customer: sub.customer, return_url: `${app.config.base_url}/team/${team.slug}/overview` }) response.redirect(303, portal.url) }) }