Skip to content

Commit

Permalink
Merge pull request #645 from ParabolInc/add-orgs-stripe
Browse files Browse the repository at this point in the history
add stripe server integration
  • Loading branch information
mattkrick authored Jan 18, 2017
2 parents 31a31cb + 9c16a69 commit 1e38eba
Show file tree
Hide file tree
Showing 32 changed files with 952 additions and 401 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {PAYMENT_REJECTED} from 'universal/utils/constants';
import shortid from 'shortid';
import ms from 'ms';
import {INACTIVE_DAYS_THRESH, TRIAL_EXTENSION} from 'server/utils/serverConstants';
import stripe from 'server/utils/stripe';
import stripe from './stripe';

// run at 12am everyday
// look for customers that will expire by 12am tomorrow
Expand Down
33 changes: 33 additions & 0 deletions src/server/billing/expireTrials.js
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);
//
// }
59 changes: 59 additions & 0 deletions src/server/billing/handlers/handleFailedPayment.js
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;
}
}
162 changes: 162 additions & 0 deletions src/server/billing/handlers/handleInvoiceCreated.js
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;
}
31 changes: 31 additions & 0 deletions src/server/billing/handlers/handleInvoiceItemCreated.js
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;
}
25 changes: 25 additions & 0 deletions src/server/billing/handlers/handleUpdatedSource.js
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;
}
52 changes: 52 additions & 0 deletions src/server/billing/helpers/adjustUserCount.js
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);
}
11 changes: 11 additions & 0 deletions src/server/billing/helpers/markTeamsAsUnpaid.js
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
// })
// }
Loading

0 comments on commit 1e38eba

Please sign in to comment.