-
Notifications
You must be signed in to change notification settings - Fork 336
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #645 from ParabolInc/add-orgs-stripe
add stripe server integration
- Loading branch information
Showing
32 changed files
with
952 additions
and
401 deletions.
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,33 @@ | ||
// import getRethink from 'server/database/rethinkDriver'; | ||
// import {TRIAL_EXPIRED} from 'universal/utils/constants'; | ||
// import shortid from 'shortid'; | ||
// import ms from 'ms'; | ||
// import markTeamsAsUnpaid from './helpers/markTeamsAsUnpaid'; | ||
// import notifyOrgLeaders from './helpers/notifyOrgLeaders'; | ||
// | ||
// export default async function expireOrgs() { | ||
// const r = getRethink(); | ||
// const now = new Date(); | ||
// const expiredOrgs = await r.table('Organization') | ||
// .between(r.minval, now, {index: 'validUntil'}) | ||
// .filter({isTrial: true}) | ||
// .pluck('id'); | ||
// const createNotification = (orgId, parentId, userId) => ({ | ||
// id: shortid.generate(), | ||
// parentId, | ||
// type: TRIAL_EXPIRED, | ||
// trialExpiresAt: now, | ||
// startAt: now, | ||
// endAt: new Date(now.valueOf() + ms('10y')), | ||
// userId, | ||
// orgId, | ||
// }); | ||
// | ||
// // flag teams as unpaid so subscriptions die. No need to kick them out since mutations won't do anything | ||
// const dbPromises = [ | ||
// markTeamsAsUnpaid(expiredOrgs), | ||
// notifyOrgLeaders(expiredOrgs, createNotification) | ||
// ]; | ||
// await Promise.all(dbPromises); | ||
// | ||
// } |
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,59 @@ | ||
import stripe from 'server/billing/stripe'; | ||
import getRethink from 'server/database/rethinkDriver'; | ||
import {getOldVal} from 'server/graphql/models/utils'; | ||
import shortid from 'shortid'; | ||
import {PAYMENT_REJECTED, TRIAL_EXPIRED} from 'universal/utils/constants'; | ||
|
||
export default async function handleFailedPayment(invoiceId) { | ||
const r = getRethink(); | ||
const invoice = await stripe.invoices.retrieve(invoiceId); | ||
const {metadata: {orgId}} = invoice; | ||
const now = new Date(); | ||
|
||
// flag teams as unpaid | ||
const orgPromise = r.table('Team') | ||
.getAll(orgId, {index: 'orgId'}) | ||
.update({ | ||
isPaid: false | ||
}) | ||
.do(() => { | ||
return r.table('Organization').get(orgId).update({ | ||
isTrial: false | ||
}, {returnChanges: true}) | ||
}); | ||
const userPromise = r.table('User').getAll(orgId, {index: 'billingLeaderOrgs'})('id'); | ||
const promises = [orgPromise, userPromise]; | ||
const [orgRes, userIds] = await Promise.all(promises); | ||
const orgDoc = getOldVal(orgRes); | ||
const parentId = shortid.generate(); | ||
if (orgDoc.isTrial) { | ||
const notifications = userIds.map((userId) => ({ | ||
id: shortid.generate(), | ||
parentId, | ||
type: TRIAL_EXPIRED, | ||
trialExpiresAt: now, | ||
startAt: now, | ||
endAt: new Date(now.getTime() + ms('10y')), | ||
userId, | ||
orgId, | ||
})); | ||
await r.table('Notification').insert(notifications); | ||
} else { | ||
const {last4, brand} = orgDoc.creditCard || {}; | ||
const errorMessage = last4 ? 'Credit card was declined' : 'Payment failed because no credit card is on file'; | ||
const notifications = userIds.map((userId) => ({ | ||
id: shortid.generate(), | ||
parentId, | ||
type: PAYMENT_REJECTED, | ||
errorMessage, | ||
brand, | ||
last4, | ||
startAt: now, | ||
endAt: new Date(now.getTime() + ms('10y')), | ||
userId, | ||
orgId, | ||
})); | ||
await r.table('Notification').insert(notifications); | ||
return true; | ||
} | ||
} |
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,162 @@ | ||
import stripe from 'server/billing/stripe'; | ||
import getRethink from 'server/database/rethinkDriver'; | ||
import shortid from 'shortid'; | ||
import { | ||
ADDED_USERS, | ||
REMOVED_USERS, | ||
INACTIVITY_ADJUSTMENTS, | ||
NEXT_MONTH_CHARGES, | ||
OTHER_ADJUSTMENTS | ||
} from 'server/graphql/models/Invoice/invoiceSchema'; | ||
import { | ||
ADD_USER, | ||
AUTO_PAUSE_USER, | ||
PAUSE_USER, | ||
REMOVE_USER, | ||
UNPAUSE_USER | ||
} from 'server/utils/serverConstants'; | ||
import {fromStripeDate} from 'server/billing/stripeDate'; | ||
|
||
export default async function handleInvoiceCreated(invoiceId) { | ||
const r = getRethink(); | ||
|
||
const stripeLineItems = []; | ||
for (let i = 0; i < 100; i++) { | ||
const options = {limit: 1000}; | ||
if (i > 0) { | ||
options.starting_after = stripeLineItems[stripeLineItems.length - 1].id; | ||
} | ||
const invoiceLines = await stripe.invoices.retrieveLines(invoiceId, options); | ||
stripeLineItems.push(...invoiceLines.data); | ||
if (!invoiceLines.has_more) break; | ||
} | ||
|
||
const invoiceLineItems = []; | ||
const detailedLineItems = { | ||
// [ADDED_USERS]: [], | ||
// [REMOVED_USERS]: [], | ||
// [INACTIVITY_ADJUSTMENTS]: [] | ||
}; | ||
const itemDict = {}; | ||
for (let i = 0; i < stripeLineItems.length; i++) { | ||
const lineItem = stripeLineItems[i]; | ||
const {amount, metadata: {type, userId}, description, period: {start}, proration, quantity} = lineItem; | ||
if (description === null && proration === false) { | ||
// this must be the next month's charge | ||
invoiceLineItems.push({ | ||
id: shortid.generate(), | ||
amount, | ||
type: NEXT_MONTH_CHARGES, | ||
quantity | ||
}); | ||
} else if (!type || !userId) { | ||
invoiceLineItems.push({ | ||
id: shortid.generate(), | ||
amount, | ||
description, | ||
quantity, | ||
type: OTHER_ADJUSTMENTS | ||
}); | ||
} else { | ||
// at this point, we don't care whether it's an auto pause or manual | ||
const safeType = type === AUTO_PAUSE_USER ? PAUSE_USER : type; | ||
itemDict[userId] = itemDict[userId] || {}; | ||
itemDict[userId][safeType] = itemDict[userId][safeType] || {}; | ||
itemDict[userId][safeType][start] = itemDict[userId][safeType][start] || []; | ||
itemDict[userId][safeType][start].push(lineItem); | ||
} | ||
} | ||
const userIds = Object.keys(itemDict); | ||
const usersAndEmails = await r.table('User').getAll(r.args(userIds), {index: 'id'}).pluck('id', 'email'); | ||
const emailLookup = usersAndEmails.reduce((dict, doc) => { | ||
dict[doc.id] = doc.email; | ||
return dict; | ||
}, {}); | ||
for (let i = 0; i < userIds.length; i++) { | ||
const userId = userIds[i]; | ||
const email = emailLookup[userId]; | ||
const typesDict = itemDict[userId]; | ||
const reducedItemsByType = {}; | ||
const userTypes = Object.keys(typesDict); | ||
for (let j = 0; j < userTypes.length; j++) { | ||
const type = userTypes[j]; | ||
const reducedItems = reducedItemsByType[type] = []; | ||
const startTimeDict = typesDict[type]; | ||
const startTimes = Object.keys(startTimeDict); | ||
// unpausing someone ends a period, we'll use this later | ||
const dateField = type === UNPAUSE_USER ? 'endAt' : 'startAt'; | ||
for (let k = 0; k < startTimes.length; k++) { | ||
const startTime = startTimes[k]; | ||
const lineItems = startTimeDict[startTime]; | ||
if (lineItems.length !== 2) { | ||
console.warn(`We did not get 2 line items. What do? Invoice: ${invoiceId}, ${JSON.stringify(lineItems)}`); | ||
return false; | ||
} | ||
reducedItems[k] = { | ||
id: shortid.generate(), | ||
amount: lineItems[0].amount + lineItems[1].amount, | ||
email, | ||
[dateField]: startTime | ||
} | ||
} | ||
} | ||
detailedLineItems[ADDED_USERS] = reducedItemsByType[ADD_USER] || []; | ||
detailedLineItems[REMOVED_USERS] = reducedItemsByType[REMOVE_USER] || []; | ||
const inactivityDetails = detailedLineItems[INACTIVITY_ADJUSTMENTS] = []; | ||
const pausedItems = reducedItemsByType[PAUSE_USER]; | ||
const unpausedItems = reducedItemsByType[UNPAUSE_USER]; | ||
|
||
// if an unpause happened before a pause, we know they came into this period paused, so we don't want a start date | ||
if (unpausedItems && (!pausedItems || unpausedItems[0].period.start < pausedItems[0].period.start)) { | ||
// mutative | ||
const firstUnpausedItem = unpausedItems.shift(); | ||
inactivityDetails.push(firstUnpausedItem); | ||
} | ||
// match up every pause with an unpause so it's clear that Foo was paused for 5 days | ||
for (let j = 0; j < unpausedItems.length; j++) { | ||
const unpausedItem = unpausedItems[j]; | ||
const pausedItem = pausedItems[j]; | ||
inactivityDetails.push({ | ||
...pausedItem, | ||
amount: unpausedItem.amount + pausedItem.amount, | ||
endAt: unpausedItem.endAt | ||
}) | ||
} | ||
|
||
// if there is an extra pause, then it's because they are still on pause while we're invoicing. | ||
if (pausedItems.length > unpausedItems.length) { | ||
const lastPausedItem = pausedItems[pausedItems.length-1]; | ||
inactivityDetails.push(lastPausedItem); | ||
} | ||
} | ||
|
||
// create invoice line items | ||
const lineItemTypes = Object.keys(detailedLineItems); | ||
for (let i = 0; i < lineItemTypes.length; i++) { | ||
const lineItemType = lineItemTypes[i]; | ||
const details = detailedLineItems[lineItemType]; | ||
if (details.length > 0) { | ||
const id = shortid.generate(); | ||
invoiceLineItems.push({ | ||
id, | ||
amount: details.reduce((sum, detail) => sum + detail.amount, 0), | ||
details: details.map((doc) => ({...doc, parentId: id})), | ||
quantity: details.length, | ||
type: lineItemType | ||
}) | ||
} | ||
} | ||
|
||
const invoice = await stripe.invoices.retrieve(invoiceId); | ||
const customer = await stripe.customers.retrieve(invoice.customer); | ||
await r.table('Invoice').insert({ | ||
id: invoiceId, | ||
amount: invoice.total, | ||
invoiceDate: fromStripeDate(invoice.date), | ||
startAt: fromStripeDate(invoice.period_start), | ||
endAt: fromStripeDate(invoice.period_end), | ||
lines: invoiceLineItems, | ||
orgId: customer.metadata.orgId | ||
}); | ||
return true; | ||
} |
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,31 @@ | ||
import stripe from 'server/billing/stripe'; | ||
import getRethink from 'server/database/rethinkDriver'; | ||
|
||
/* | ||
* Triggered when we update the subscription. | ||
* Currently, we only update the subscription when we change quantity which signifies an add/remove/pause | ||
*/ | ||
export default async function handleInvoiceItemCreated(invoiceItemId) { | ||
const r = getRethink(); | ||
const invoiceItem = await stripe.invoiceItems.retrieve(invoiceItemId); | ||
if (!invoiceItem) { | ||
console.warn(`No invoice found for ${invoiceItemId}`) | ||
return false; | ||
} | ||
const {subscription, period: {start}} = invoiceItem; | ||
const hook = await r.table('InvoiceItemHook') | ||
.getAll([start, subscription], {index: 'prorationDateSubId'}) | ||
.nth(0); | ||
if (!hook) { | ||
console.warn(`No hook found in the DB! Need to manually update invoiceItem: ${invoiceItemId}`); | ||
return false; | ||
} | ||
const {type, userId} = hook; | ||
await stripe.invoiceItems.update(invoiceItemId, { | ||
metadata: { | ||
type, | ||
userId | ||
} | ||
}); | ||
return true; | ||
} |
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 stripe from 'server/billing/stripe'; | ||
import getRethink from 'server/database/rethinkDriver'; | ||
|
||
export default async function handleUpdatedSource(cardId, customerId) { | ||
const r = getRethink(); | ||
const customer = await stripe.customers.retrieve(customerId); | ||
const {orgId} = customer.metadata; | ||
const cards = customer.sources.data; | ||
const card = cards.find((card) => card.id === cardId); | ||
if (!card) { | ||
console.warn(`No Credit card found! cardId: ${cardId}, customerId: ${customerId}`) | ||
return false; | ||
} | ||
const {brand, last4, exp_month: expMonth, exp_year: expYear} = card; | ||
const expiry = `${expMonth}/${expYear.substr(2)}`; | ||
await r.table('Organization').get(orgId) | ||
.update({ | ||
creditCard: { | ||
brand, | ||
last4, | ||
expiry | ||
}, | ||
}); | ||
return true; | ||
} |
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,52 @@ | ||
import getRethink from 'server/database/rethinkDriver'; | ||
import { | ||
ADD_USER, | ||
AUTO_PAUSE_USER, | ||
PAUSE_USER, | ||
REMOVE_USER, | ||
UNPAUSE_USER | ||
} from 'server/utils/serverConstants'; | ||
|
||
// returns the delta to activeUserCount and inactiveUserCount | ||
const adjustmentTable = { | ||
[ADD_USER]: [1,0], | ||
[AUTO_PAUSE_USER]: [-1, 1], | ||
[PAUSE_USER]: [-1, 1], | ||
[REMOVE_USER]: [-1, 0], | ||
[UNPAUSE_USER]: [1, -1], | ||
}; | ||
|
||
import stripe from 'server/billing/stripe'; | ||
import shortid from 'shortid'; | ||
import {toStripeDate} from 'server/billing/stripeDate'; | ||
|
||
export default async function adjustUserCount(userId, orgInput, type) { | ||
const r = getRethink(); | ||
const now = new Date(); | ||
const [activeDelta, inactiveDelta] = adjustmentTable[type]; | ||
const orgIds = Array.isArray(orgInput) ? orgInput : [orgInput]; | ||
const {changes: orgChanges} = await r.table('Organization') | ||
.getAll(r.args(orgIds), {index: 'id'}) | ||
.update((row) => ({ | ||
activeUserCount: row('activeUserCount').add(activeDelta), | ||
inactiveUserCount: row('inactiveUserCount').add(inactiveDelta), | ||
updatedAt: now | ||
}), {returnChanges: true}); | ||
|
||
const orgs = orgChanges.map((change) => change.new_val); | ||
const hooks = orgs.map((org) => ({ | ||
id: shortid.generate(), | ||
subId: org.stripeSubscriptionId, | ||
prorationDate: toStripeDate(now), | ||
type, | ||
userId | ||
})); | ||
|
||
// wait here to make sure the webhook finds what it's looking for | ||
await r.table('InvoiceItemHook').insert(hooks); | ||
const stripePromises = orgs.map((org) => stripe.subscriptions.update(org.stripeSubscriptionId, { | ||
quantity: org.activeUserCount, | ||
})); | ||
|
||
await Promise.all(stripePromises); | ||
} |
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,11 @@ | ||
// import getRethink from 'server/database/rethinkDriver'; | ||
// | ||
// export default async function makeTeamsAsUnpaid(orgs) { | ||
// const r = getRethink(); | ||
// const orgIds = orgs.map((org) => org.id); | ||
// return r.table('Team') | ||
// .getAll(r.args(orgIds), {index: 'orgId'}) | ||
// .update({ | ||
// isPaid: false | ||
// }) | ||
// } |
Oops, something went wrong.