diff --git a/packages/vulcan-payments/lib/components/Checkout.jsx b/packages/vulcan-payments/lib/components/Checkout.jsx index e1af58357..6a5b65c86 100644 --- a/packages/vulcan-payments/lib/components/Checkout.jsx +++ b/packages/vulcan-payments/lib/components/Checkout.jsx @@ -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 ; } diff --git a/packages/vulcan-payments/lib/containers/withCreateCharge.js b/packages/vulcan-payments/lib/containers/withPaymentAction.js similarity index 51% rename from packages/vulcan-payments/lib/containers/withCreateCharge.js rename to packages/vulcan-payments/lib/containers/withPaymentAction.js index 8184844cc..16b770334 100644 --- a/packages/vulcan-payments/lib/containers/withCreateCharge.js +++ b/packages/vulcan-payments/lib/containers/withPaymentAction.js @@ -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, }); diff --git a/packages/vulcan-payments/lib/modules/charges/schema.js b/packages/vulcan-payments/lib/modules/charges/schema.js index 5498f6582..d7a197fe1 100644 --- a/packages/vulcan-payments/lib/modules/charges/schema.js +++ b/packages/vulcan-payments/lib/modules/charges/schema.js @@ -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; diff --git a/packages/vulcan-payments/lib/modules/custom_fields.js b/packages/vulcan-payments/lib/modules/custom_fields.js new file mode 100644 index 000000000..de9dc5d70 --- /dev/null +++ b/packages/vulcan-payments/lib/modules/custom_fields.js @@ -0,0 +1,11 @@ +import Users from 'meteor/vulcan:users'; + +Users.addField([ + { + fieldName: 'stripeCustomerId', + fieldSchema: { + type: String, + optional: true, + } + } +]); diff --git a/packages/vulcan-payments/lib/modules/index.js b/packages/vulcan-payments/lib/modules/index.js index e6c277eaf..f2f945723 100644 --- a/packages/vulcan-payments/lib/modules/index.js +++ b/packages/vulcan-payments/lib/modules/index.js @@ -4,5 +4,6 @@ import '../components/Checkout.jsx'; import './routes.js'; import './i18n.js'; +import './custom_fields.js'; export * from './products.js' \ No newline at end of file diff --git a/packages/vulcan-payments/lib/server/integrations/stripe.js b/packages/vulcan-payments/lib/server/integrations/stripe.js index 3f671ea7e..a3b9b6424 100644 --- a/packages/vulcan-payments/lib/server/integrations/stripe.js +++ b/packages/vulcan-payments/lib/server/integrations/stripe.js @@ -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(); + +}); diff --git a/packages/vulcan-payments/lib/server/main.js b/packages/vulcan-payments/lib/server/main.js index 890d522d3..64ed7b383 100644 --- a/packages/vulcan-payments/lib/server/main.js +++ b/packages/vulcan-payments/lib/server/main.js @@ -1,4 +1,4 @@ export * from '../modules/index.js'; import './mutations.js'; -import './integrations/stripe.js'; +export * from './integrations/stripe.js'; diff --git a/packages/vulcan-payments/lib/server/mutations.js b/packages/vulcan-payments/lib/server/mutations.js index 564c56ee8..d638119f3 100644 --- a/packages/vulcan-payments/lib/server/mutations.js +++ b/packages/vulcan-payments/lib/server/mutations.js @@ -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 = `