Refactoring payments package to handle subscriptions

This commit is contained in:
Sacha Greif 2017-10-18 20:05:51 +09:00
parent 296d3c9b82
commit 756029c93d
8 changed files with 258 additions and 52 deletions

View file

@ -4,7 +4,7 @@ import { Components, registerComponent, getSetting, registerSetting, withCurrent
import Users from 'meteor/vulcan:users';
import { intlShape } from 'meteor/vulcan:i18n';
import classNames from 'classnames';
import withCreateCharge from '../containers/withCreateCharge.js';
import withPaymentAction from '../containers/withPaymentAction.js';
import { Products } from '../modules/products.js';
const stripeSettings = getSetting('stripe');
@ -22,7 +22,7 @@ class Checkout extends React.Component {
onToken(token) {
const {createChargeMutation, productKey, associatedCollection, associatedDocument, callback, properties, currentUser, flash, coupon} = this.props;
const {paymentActionMutation, productKey, associatedCollection, associatedDocument, callback, properties, currentUser, flash, coupon} = this.props;
this.setState({ loading: true });
@ -36,7 +36,7 @@ class Checkout extends React.Component {
coupon,
}
createChargeMutation(args).then(response => {
paymentActionMutation(args).then(response => {
// not needed because we just unmount the whole component:
this.setState({ loading: false });
@ -109,7 +109,7 @@ Checkout.contextTypes = {
const WrappedCheckout = (props) => {
const { fragment, fragmentName } = props;
const WrappedCheckout = withCreateCharge({fragment, fragmentName})(Checkout);
const WrappedCheckout = withPaymentAction({fragment, fragmentName})(Checkout);
return <WrappedCheckout {...props}/>;
}

View file

@ -2,14 +2,14 @@ import { graphql } from 'react-apollo';
import gql from 'graphql-tag';
import { getFragment, getFragmentName } from 'meteor/vulcan:core';
export default function withCreateCharge(options) {
export default function withPaymentAction(options) {
const fragment = options.fragment || getFragment(options.fragmentName);
const fragmentName = getFragmentName(fragment) || fragmentName;
const mutation = gql`
mutation createChargeMutation($token: JSON, $userId: String, $productKey: String, $associatedCollection: String, $associatedId: String, $properties: JSON, $coupon: String) {
createChargeMutation(token: $token, userId: $userId, productKey: $productKey, associatedCollection: $associatedCollection, associatedId: $associatedId, properties: $properties, coupon: $coupon) {
mutation paymentActionMutation($token: JSON, $userId: String, $productKey: String, $associatedCollection: String, $associatedId: String, $properties: JSON, $coupon: String) {
paymentActionMutation(token: $token, userId: $userId, productKey: $productKey, associatedCollection: $associatedCollection, associatedId: $associatedId, properties: $properties, coupon: $coupon) {
__typename
...${fragmentName}
}
@ -18,9 +18,9 @@ export default function withCreateCharge(options) {
`;
return graphql(mutation, {
alias: 'withCreateCharge',
alias: 'withPaymentAction',
props: ({ownProps, mutate}) => ({
createChargeMutation: (vars) => {
paymentActionMutation: (vars) => {
return mutate({
variables: vars,
});

View file

@ -1,3 +1,4 @@
import moment from 'moment';
const schema = {
@ -24,6 +25,16 @@ const schema = {
// custom properties
associatedCollection: {
type: String,
optional: true,
},
associatedId: {
type: String,
optional: true,
},
tokenId: {
type: String,
optional: false,
@ -59,6 +70,30 @@ const schema = {
optional: true,
},
// GraphQL only
createdAtFormatted: {
type: String,
optional: true,
resolveAs: {
type: 'String',
resolver: (charge, args, context) => {
return moment(charge.createdAt).format('dddd, MMMM Do YYYY');
}
}
},
stripeChargeUrl: {
type: String,
optional: true,
resolveAs: {
type: 'String',
resolver: (charge, args, context) => {
return `https://dashboard.stripe.com/payments/${charge.data.id}`;
}
}
},
};
export default schema;

View file

@ -0,0 +1,11 @@
import Users from 'meteor/vulcan:users';
Users.addField([
{
fieldName: 'stripeCustomerId',
fieldSchema: {
type: String,
optional: true,
}
}
]);

View file

@ -4,5 +4,6 @@ import '../components/Checkout.jsx';
import './routes.js';
import './i18n.js';
import './custom_fields.js';
export * from './products.js'

View file

@ -1,8 +1,8 @@
import { getSetting, registerSetting, newMutation, editMutation, Collections, runCallbacks, runCallbacksAsync } from 'meteor/vulcan:core';
// import express from 'express';
import express from 'express';
import Stripe from 'stripe';
// import { Picker } from 'meteor/meteorhacks:picker';
// import bodyParser from 'body-parser';
import { Picker } from 'meteor/meteorhacks:picker';
import bodyParser from 'body-parser';
import Charges from '../../modules/charges/collection.js';
import Users from 'meteor/vulcan:users';
import { Products } from '../../modules/products.js';
@ -11,6 +11,10 @@ registerSetting('stripe', null, 'Stripe settings');
const stripeSettings = getSetting('stripe');
// initialize Stripe
const keySecret = Meteor.isDevelopment ? stripeSettings.secretKeyTest : stripeSettings.secretKey;
const stripe = new Stripe(keySecret);
const sampleProduct = {
amount: 10000,
name: 'My Cool Product',
@ -18,20 +22,21 @@ const sampleProduct = {
currency: 'USD',
}
// returns a promise:
export const createCharge = async (args) => {
/*
Create new Stripe charge
(returns a promise)
*/
export const performAction = async (args) => {
let collection, document, returnDocument = {};
const {token, userId, productKey, associatedCollection, associatedId, properties, coupon } = args;
const {token, userId, productKey, associatedCollection, associatedId, properties } = args;
if (!stripeSettings) {
throw new Error('Please fill in your Stripe settings');
}
// initialize Stripe
const keySecret = Meteor.isDevelopment ? stripeSettings.secretKeyTest : stripeSettings.secretKey;
const stripe = new Stripe(keySecret);
// if an associated collection name and document id have been provided,
// get the associated collection and document
@ -45,25 +50,66 @@ export const createCharge = async (args) => {
const definedProduct = Products[productKey];
const product = typeof definedProduct === 'function' ? definedProduct(document) : definedProduct || sampleProduct;
let amount = product.amount;
// get the user performing the transaction
const user = Users.findOne(userId);
// create Stripe customer
const customer = await stripe.customers.create({
email: token.email,
source: token.id
});
let customer;
if (user.stripeCustomerId) {
// if user has a stripe id already, retrieve customer from Stripe
customer = await stripe.customers.retrieve(user.stripeCustomerId);
} else {
// else create new Stripe customer
customer = await stripe.customers.create({
email: token.email,
source: token.id
});
// add stripe customer id to user object
await editMutation({
collection: Users,
documentId: user._id,
set: {stripeCustomerId: customer.id},
validate: false
});
}
// create metadata object
const metadata = {
userId: userId,
userName: Users.getDisplayName(user),
userProfile: Users.getProfileUrl(user, true),
productKey,
...properties
}
if (associatedCollection && associatedId) {
metadata.associatedCollection = associatedCollection;
metadata.associatedId = associatedId;
}
if (product.plan) {
// if product has a plan, subscribe user to it
returnDocument = await subscribeUser({user, customer, product, collection, document, metadata, args});
} else {
// else, perform charge
returnDocument = await createCharge({user, customer, product, collection, document, metadata, args});
}
return returnDocument;
}
/*
Create one-time charge.
*/
export const createCharge = async ({user, customer, product, collection, document, metadata, args}) => {
const {token, userId, productKey, associatedId, properties, coupon } = args;
let amount = product.amount;
// apply discount coupon and add it to metadata, if there is one
if (coupon && product.coupons && product.coupons[coupon]) {
amount -= product.coupons[coupon];
@ -83,19 +129,39 @@ export const createCharge = async (args) => {
// create Stripe charge
const charge = await stripe.charges.create(chargeData);
return processCharge({collection, document, charge, args})
}
/*
Process charge on Vulcan's side
*/
export const processCharge = async ({collection, document, charge, args}) => {
let returnDocument = {};
const {token, userId, productKey, associatedCollection, associatedId, properties, livemode } = args;
// create charge document for storing in our own Charges collection
const chargeDoc = {
createdAt: new Date(),
userId,
tokenId: token.id,
type: 'stripe',
test: !token.livemode,
test: !livemode,
data: charge,
ip: token.client_ip,
associatedCollection,
associatedId,
properties,
productKey,
}
if (token) {
chargeDoc.tokenId = token.id;
chargeDoc.test = !token.livemode; // get livemode from token if provided
chargeDoc.ip = token.client_ip;
}
// insert
const chargeSaved = newMutation({
collection: Charges,
@ -107,12 +173,14 @@ export const createCharge = async (args) => {
// update the associated document
if (collection && document) {
// note: assume a single document can have multiple successive charges associated to it
const chargeIds = document.chargeIds ? [...document.chargeIds, chargeSaved._id] : [chargeSaved._id];
let modifier = {
$set: {chargeIds},
$unset: {}
}
// run collection.charge.sync callbacks
modifier = runCallbacks(`${collection._name}.charge.sync`, modifier, document, chargeDoc);
@ -135,30 +203,121 @@ export const createCharge = async (args) => {
/*
POST route with Picker
Create new subscription plan
*/
export const createPlan = async (options) => {
try {
await stripe.plans.create(options);
} catch (error) {
console.log('// Stripe createPlan error')
console.log(error)
}
}
/*
Subscribe a user to a Stripe plan
*/
export const subscribeUser = async ({user, customer, product, collection, document, metadata, args }) => {
console.log('////////////// subscribeUser')
console.log(product)
try {
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [
{ plan: product.plan },
],
metadata,
});
console.log(subscription)
} catch (error) {
console.log('// Stripe subscribeUser error')
console.log(error)
}
}
/*
Webhooks with Picker
*/
// Picker.middleware(bodyParser.text());
// const app = express()
// Picker.route('/charge', function(params, req, res, next) {
// const body = JSON.parse(req.body);
// app.post('/stripe', function(req, res) {
// // Retrieve the request's body and parse it as JSON
// console.log('////////////// stripe webhook')
// // console.log(body)
// var event_json = JSON.parse(req.body);
// const { token, userId, productKey, associatedCollection, associatedId } = body;
// createCharge({
// token,
// userId,
// productKey,
// associatedCollection,
// associatedId,
// callback: (charge) => {
// // return Stripe charge
// res.end(JSON.stringify(charge));
// }
// });
// console.log(event_json)
// res.send(200);
// });
Picker.middleware(bodyParser.json());
Picker.route('/stripe', async function(params, req, res, next) {
console.log('////////////// stripe webhook')
const body = req.body;
// Retrieve the request's body and parse it as JSON
switch (body.type) {
case 'charge.succeeded':
console.log('////// charge body')
console.log(body)
const charge = body.data.object;
try {
// look up corresponding invoice
const invoice = await stripe.invoices.retrieve(body.data.object.invoice);
console.log('////// invoice')
// look up corresponding subscription
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
console.log('////// subscription')
console.log(subscription)
const { userId, productKey, associatedCollection, associatedId } = subscription.metadata;
if (associatedCollection && associatedId) {
const collection = _.findWhere(Collections, {_name: associatedCollection});
const document = collection.findOne(associatedId);
const args = {
userId,
productKey,
associatedCollection,
associatedId,
livemode: subscription.livemode,
}
processCharge({ collection, document, charge, args});
}
} catch (error) {
console.log('// Stripe webhook error')
console.log(error)
}
break;
}
res.statusCode = 200;
res.end();
});

View file

@ -1,4 +1,4 @@
export * from '../modules/index.js';
import './mutations.js';
import './integrations/stripe.js';
export * from './integrations/stripe.js';

View file

@ -1,16 +1,16 @@
import { addGraphQLSchema, addGraphQLResolvers, addGraphQLMutation, Collections, addCallback } from 'meteor/vulcan:core';
// import Users from 'meteor/vulcan:users';
import { createCharge } from '../server/integrations/stripe.js';
import { performAction } from '../server/integrations/stripe.js';
const resolver = {
Mutation: {
async createChargeMutation(root, args, context) {
return await createCharge(args);
async paymentActionMutation(root, args, context) {
return await performAction(args);
},
},
};
addGraphQLResolvers(resolver);
addGraphQLMutation('createChargeMutation(token: JSON, userId: String, productKey: String, associatedCollection: String, associatedId: String, properties: JSON, coupon: String) : Chargeable');
addGraphQLMutation('paymentActionMutation(token: JSON, userId: String, productKey: String, associatedCollection: String, associatedId: String, properties: JSON, coupon: String) : Chargeable');
function CreateChargeableUnionType() {
const chargeableSchema = `