Add custom payments

Example:
https://codecombat.com/account/invoices?a=21600&d=9%20monthly%20subscrip
tions
This commit is contained in:
Matt Lott 2015-03-04 15:40:32 -08:00
parent 942bab554a
commit cd59b90e37
10 changed files with 332 additions and 16 deletions

View file

@ -26,6 +26,7 @@ module.exports = class CocoRouter extends Backbone.Router
'account/profile': go('EmployersView') # Show the not-recruiting-now screen 'account/profile': go('EmployersView') # Show the not-recruiting-now screen
'account/payments': go('account/PaymentsView') 'account/payments': go('account/PaymentsView')
'account/subscription': go('account/SubscriptionView') 'account/subscription': go('account/SubscriptionView')
'account/invoices': go('account/InvoicesView')
'admin': go('admin/MainAdminView') 'admin': go('admin/MainAdminView')
'admin/candidates': go('admin/CandidatesView') 'admin/candidates': go('admin/CandidatesView')

View file

@ -965,6 +965,7 @@
payments: "Payments" payments: "Payments"
purchased: "Purchased" purchased: "Purchased"
subscription: "Subscription" subscription: "Subscription"
invoices: "Invoices"
service_apple: "Apple" service_apple: "Apple"
service_web: "Web" service_web: "Web"
paid_on: "Paid On" paid_on: "Paid On"
@ -981,6 +982,16 @@
status_unsubscribed_active: "You're not subscribed and won't be billed, but your account is still active for now." status_unsubscribed_active: "You're not subscribed and won't be billed, but your account is still active for now."
status_unsubscribed: "Get access to new levels, heroes, items, and bonus gems with a CodeCombat subscription!" status_unsubscribed: "Get access to new levels, heroes, items, and bonus gems with a CodeCombat subscription!"
account_invoices:
amount: "Amount in US dollars"
declined: "Your card was declined"
invalid_amount: "Please enter a US dollar amount."
not_logged_in: "Log in or create an account to access invoices."
pay: "Pay Invoice"
purchasing: "Purchasing..."
retrying: "Server error, retrying."
success: "Successfully paid. Thanks!"
loading_error: loading_error:
could_not_load: "Error loading from server" could_not_load: "Error loading from server"
connection_failure: "Connection failed." connection_failure: "Connection failed."

View file

@ -4,11 +4,12 @@ PaymentSchema = c.object({title: 'Payment', required: []}, {
purchaser: c.objectId(links: [ {rel: 'extra', href: '/db/user/{($)}'} ]) # in case of gifts purchaser: c.objectId(links: [ {rel: 'extra', href: '/db/user/{($)}'} ]) # in case of gifts
recipient: c.objectId(links: [ {rel: 'extra', href: '/db/user/{($)}'} ]) recipient: c.objectId(links: [ {rel: 'extra', href: '/db/user/{($)}'} ])
service: { enum: ['stripe', 'ios', 'invoice']} service: { enum: ['stripe', 'ios', 'external']}
amount: { type: 'integer', description: 'Payment in cents.' } amount: { type: 'integer', description: 'Payment in cents.' }
created: c.date({title: 'Created', readOnly: true}) created: c.date({title: 'Created', readOnly: true})
gems: { type: 'integer', description: 'The number of gems acquired.' } gems: { type: 'integer', description: 'The number of gems acquired.' }
productID: { enum: ['gems_5', 'gems_10', 'gems_20']} productID: { enum: ['gems_5', 'gems_10', 'gems_20', 'custom']}
description: { type: 'string' }
ios: c.object({title: 'iOS IAP Data'}, { ios: c.object({title: 'iOS IAP Data'}, {
transactionID: { type: 'string' } transactionID: { type: 'string' }

View file

@ -0,0 +1,9 @@
#invoices-view
.form
#amount
width: 100px
#description
min-width: 400px
width: auto
#pay-button
width: auto

View file

@ -0,0 +1,52 @@
extends /templates/base
block content
if me.get('anonymous')
p(data-i18n="account_invoices.not_logged_in")
else
ol.breadcrumb
li
a(href="/")
span.glyphicon.glyphicon-home
li
a(href="/account", data-i18n="nav.account")
li.active(data-i18n="account.invoices")
if state === 'purchasing'
.alert.alert-info(data-i18n="account_invoices.purchasing")
else if state === 'retrying'
#retrying-alert.alert.alert-danger(data-i18n="account_invoices.retrying")
else
.form
.form-group
label.control-label(for="amount", data-i18n="account_invoices.amount")
input#amount.form-control(name="amount", type="text", value="#{amount}")
.form-group
label.control-label(for="description", data-i18n="general.description")
input#description.form-control(name="description", type="text", value="#{description}")
button#pay-button.btn.form-control.btn-primary(data-i18n="account_invoices.pay")
br
if state === 'invoice_paid'
#declined-alert.alert.alert-success.alert-dismissible
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") ×
p= stateMessage
if state === 'validation_error'
#declined-alert.alert.alert-danger.alert-dismissible
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") ×
p= stateMessage
if state === 'declined'
#declined-alert.alert.alert-danger.alert-dismissible
span(data-i18n="account_invoices.declined")
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") ×
if state === 'unknown_error'
#error-alert.alert.alert-danger.alert-dismissible
button.close(type="button" data-dismiss="alert")
span(aria-hidden="true") ×
p(data-i18n="loading_error.unknown")
p= stateMessage

View file

@ -23,3 +23,5 @@ block content
a.btn.btn-lg.btn-primary(href="/account/payments", data-i18n="account.payments") a.btn.btn-lg.btn-primary(href="/account/payments", data-i18n="account.payments")
li.list-group-item li.list-group-item
a.btn.btn-lg.btn-primary(href="/account/subscription", data-i18n="account.subscription") a.btn.btn-lg.btn-primary(href="/account/subscription", data-i18n="account.subscription")
li.list-group-item
a.btn.btn-lg.btn-primary(href="/account/invoices", data-i18n="account.invoices")

View file

@ -0,0 +1,93 @@
RootView = require 'views/core/RootView'
template = require 'templates/account/invoices-view'
stripeHandler = require 'core/services/stripe'
utils = require 'core/utils'
# Internal amount and query params are in cents, display and web form input amount is in USD
# TODO: not supporting bitcoin currently because it pays without a confirmation step
module.exports = class InvoicesView extends RootView
id: "invoices-view"
template: template
events:
'click #pay-button': 'onPayButton'
subscriptions:
'stripe:received-token': 'onStripeReceivedToken'
constructor: (options) ->
super(options)
@amount = utils.getQueryVariable('a', 0)
@description = utils.getQueryVariable('d', '')
getRenderData: ->
c = super()
c.amount = (@amount / 100).toFixed(2)
c.description = @description
c.state = @state
c.stateMessage = @stateMessage
c
onPayButton: ->
@description = $('#description').val()
# Validate input
amount = parseFloat($('#amount').val())
if isNaN(amount) or amount <= 0
@state = 'validation_error'
@stateMessage = $.t('account_invoices.invalid_amount')
@amount = 0
@render()
return
@state = undefined
@stateMessage = undefined
@amount = parseInt(amount * 100)
# Show Stripe handler
application.tracker?.trackEvent 'Started invoice payment'
@timestampForPurchase = new Date().getTime()
stripeHandler.open
amount: @amount
description: @description
onStripeReceivedToken: (e) ->
data = {
amount: @amount
description: @description
stripe: {
token: e.token.id
timestamp: @timestampForPurchase
}
}
@state = 'purchasing'
@render()
jqxhr = $.post('/db/payment/custom', data)
jqxhr.done =>
application.tracker?.trackEvent 'Finished invoice payment',
amount: @amount
description: @description
# Show success UI
@state = 'invoice_paid'
@stateMessage = "$#{(@amount / 100).toFixed(2)} " + $.t('account_invoices.success')
@amount = 0
@description = ''
@render()
jqxhr.fail =>
if jqxhr.status is 402
@state = 'declined'
@stateMessage = arguments[2]
else if jqxhr.status is 500
@state = 'retrying'
f = _.bind @onStripeReceivedToken, @, e
_.delay f, 2000
else
@state = 'unknown_error'
@stateMessage = "#{jqxhr.status}: #{jqxhr.responseText}"
@render()

View file

@ -13,7 +13,7 @@ var purchaserID = '54ed0ac0ca7f1c421c025b3d';
var endDate = '2015-06-01'; var endDate = '2015-06-01';
var gems = 10500; var gems = 10500;
var amount = 1750; var amount = 1750;
var service = 'invoice'; var service = 'external';
emails = emails.map(function(e) { return e.toLowerCase();}); emails = emails.map(function(e) { return e.toLowerCase();});

View file

@ -28,6 +28,11 @@ products = {
gems: 25000 gems: 25000
id: 'gems_20' id: 'gems_20'
} }
'custom': {
# amount expected in request body
id: 'custom'
}
} }
PaymentHandler = class PaymentHandler extends Handler PaymentHandler = class PaymentHandler extends Handler
@ -68,6 +73,9 @@ PaymentHandler = class PaymentHandler extends Handler
stripeTimestamp = parseInt(req.body.stripe?.timestamp) stripeTimestamp = parseInt(req.body.stripe?.timestamp)
productID = req.body.productID productID = req.body.productID
if pathName is 'custom'
return @handleStripePaymentPost(req, res, stripeTimestamp, 'custom', stripeToken)
if not (appleReceipt or (stripeTimestamp and productID)) if not (appleReceipt or (stripeTimestamp and productID))
@logPaymentError(req, "Missing data. Apple? #{!!appleReceipt}. Stripe timestamp? #{!!stripeTimestamp}. Product id? #{!!productID}.") @logPaymentError(req, "Missing data. Apple? #{!!appleReceipt}. Stripe timestamp? #{!!stripeTimestamp}. Product id? #{!!productID}.")
return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp and productID') return @sendBadInputError(res, 'Need either apple.rawReceipt or stripe.timestamp and productID')
@ -85,13 +93,8 @@ PaymentHandler = class PaymentHandler extends Handler
@logPaymentError(req, 'Missing apple transaction id') @logPaymentError(req, 'Missing apple transaction id')
return @sendBadInputError(res, 'Apple purchase? Need to specify which transaction.') return @sendBadInputError(res, 'Apple purchase? Need to specify which transaction.')
@handleApplePaymentPost(req, res, appleReceipt, appleTransactionID, appleLocalPrice) @handleApplePaymentPost(req, res, appleReceipt, appleTransactionID, appleLocalPrice)
@onPostSuccess req
else else
@handleStripePaymentPost(req, res, stripeTimestamp, productID, stripeToken) @handleStripePaymentPost(req, res, stripeTimestamp, productID, stripeToken)
@onPostSuccess req
onPostSuccess: (req) ->
req.user?.saveActiveUser 'payment'
#- Apple payments #- Apple payments
@ -162,7 +165,6 @@ PaymentHandler = class PaymentHandler extends Handler
) )
) )
#- Stripe payments #- Stripe payments
handleStripePaymentPost: (req, res, timestamp, productID, token) -> handleStripePaymentPost: (req, res, timestamp, productID, token) ->
@ -235,6 +237,8 @@ PaymentHandler = class PaymentHandler extends Handler
@recordStripeCharge(req, res, charge) @recordStripeCharge(req, res, charge)
else else
return @sendSuccess(res, @formatEntity(req, payment)) if product.id is 'custom'
# Charged Stripe and recorded it. Recalculate gems to make sure credited the purchase. # Charged Stripe and recorded it. Recalculate gems to make sure credited the purchase.
@recalculateGemsFor(req.user, (err) => @recalculateGemsFor(req.user, (err) =>
if err if err
@ -246,10 +250,12 @@ PaymentHandler = class PaymentHandler extends Handler
) )
) )
chargeStripe: (req, res, product) -> chargeStripe: (req, res, product) ->
amount = parseInt product.amount ? req.body.amount
return @sendError(res, 400, "Invalid amount.") if isNaN(amount)
stripe.charges.create({ stripe.charges.create({
amount: product.amount amount: amount
currency: 'usd' currency: 'usd'
customer: req.user.get('stripe')?.customerID customer: req.user.get('stripe')?.customerID
metadata: { metadata: {
@ -257,6 +263,7 @@ PaymentHandler = class PaymentHandler extends Handler
userID: req.user._id + '' userID: req.user._id + ''
gems: product.gems gems: product.gems
timestamp: parseInt(req.body.stripe?.timestamp) timestamp: parseInt(req.body.stripe?.timestamp)
description: req.body.description
} }
receipt_email: req.user.get('email') receipt_email: req.user.get('email')
}).then( }).then(
@ -272,14 +279,14 @@ PaymentHandler = class PaymentHandler extends Handler
@sendDatabaseError(res, 'Error charging card, please retry.')) @sendDatabaseError(res, 'Error charging card, please retry.'))
) )
recordStripeCharge: (req, res, charge) -> recordStripeCharge: (req, res, charge) ->
return @sendError(res, 500, 'Fake db error for testing.') if req.body.breakAfterCharging return @sendError(res, 500, 'Fake db error for testing.') if req.body.breakAfterCharging
payment = @makeNewInstance(req) payment = @makeNewInstance(req)
payment.set 'service', 'stripe' payment.set 'service', 'stripe'
payment.set 'productID', charge.metadata.productID payment.set 'productID', charge.metadata.productID
payment.set 'amount', parseInt(charge.amount) payment.set 'amount', parseInt(charge.amount)
payment.set 'gems', parseInt(charge.metadata.gems) payment.set 'gems', parseInt(charge.metadata.gems) if charge.metadata.gems
payment.set 'description', charge.metadata.description if charge.metadata.description
payment.set 'stripe', { payment.set 'stripe', {
customerID: charge.customer customerID: charge.customer
timestamp: parseInt(charge.metadata.timestamp) timestamp: parseInt(charge.metadata.timestamp)
@ -291,9 +298,10 @@ PaymentHandler = class PaymentHandler extends Handler
@logPaymentError(req, 'Invalid stripe payment object.') @logPaymentError(req, 'Invalid stripe payment object.')
return @sendBadInputError(res, validation.errors) return @sendBadInputError(res, validation.errors)
payment.save((err) => payment.save((err) =>
return @sendDatabaseError(res, err) if err
return @sendCreated(res, @formatEntity(req, payment)) if payment.productID is 'custom'
# Credit gems # Credit gems
return @sendDatabaseError(res, err) if err
@incrementGemsFor(req.user, parseInt(charge.metadata.gems), (err) => @incrementGemsFor(req.user, parseInt(charge.metadata.gems), (err) =>
if err if err
@logPaymentError(req, 'Stripe incr db error. '+err) @logPaymentError(req, 'Stripe incr db error. '+err)
@ -302,7 +310,6 @@ PaymentHandler = class PaymentHandler extends Handler
) )
) )
#- Confirm all Stripe charges are recorded on our server #- Confirm all Stripe charges are recorded on our server
checkStripeCharges: (req, res) -> checkStripeCharges: (req, res) ->

View file

@ -7,6 +7,7 @@ describe '/db/payment', ->
request = require 'request' request = require 'request'
paymentURL = getURL('/db/payment') paymentURL = getURL('/db/payment')
checkChargesURL = getURL('/db/payment/check-stripe-charges') checkChargesURL = getURL('/db/payment/check-stripe-charges')
customPaymentURL = getURL('/db/payment/custom')
firstApplePayment = { firstApplePayment = {
apple: { apple: {
@ -76,7 +77,6 @@ describe '/db/payment', ->
) )
describe 'posting Stripe purchases', -> describe 'posting Stripe purchases', ->
stripe = require('stripe')(config.stripe.secretKey) stripe = require('stripe')(config.stripe.secretKey)
charge = null charge = null
@ -295,3 +295,143 @@ describe '/db/payment', ->
done() done()
) )
) )
describe '/db/payment/custom', ->
stripe = require('stripe')(config.stripe.secretKey)
it 'clears the db', (done) ->
clearModels [User, Payment], (err) ->
throw err if err
done()
it 'handles a custom purchase with description', (done) ->
timestamp = new Date().getTime()
amount = 5000
description = 'A sweet Coco t-shirt'
stripe.tokens.create({
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeID = joe.get('_id') + ''
data = {
amount: amount
description: description
stripe: {
token: token.id
timestamp: timestamp
}
}
request.post {uri: customPaymentURL, json: data }, (err, res, body) ->
expect(res.statusCode).toBe 201
expect(body.stripe.chargeID).toBeDefined()
expect(body.stripe.timestamp).toBe(timestamp)
expect(body.stripe.customerID).toBeDefined()
expect(body.description).toEqual(description)
expect(body.amount).toEqual(amount)
expect(body.productID).toBe('custom')
expect(body.service).toBe('stripe')
expect(body.recipient).toBe(joeID)
expect(body.purchaser).toBe(joeID)
User.findById(joe.get('_id'), (err, user) ->
expect(user.get('stripe').customerID).toBe(body.stripe.customerID)
criteria =
recipient: user.get('_id')
purchaser: user.get('_id')
amount: amount
description: description
service: 'stripe'
"stripe.customerID": user.get('stripe').customerID
Payment.findOne criteria, (err, payment) ->
expect(err).toBeNull()
expect(payment).not.toBeNull()
done()
)
)
it 'handles a custom purchase without description', (done) ->
timestamp = new Date().getTime()
amount = 73000
stripe.tokens.create({
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeID = joe.get('_id') + ''
data = {
amount: amount
stripe: {
token: token.id
timestamp: timestamp
}
}
request.post {uri: customPaymentURL, json: data }, (err, res, body) ->
expect(res.statusCode).toBe 201
expect(body.stripe.chargeID).toBeDefined()
expect(body.stripe.timestamp).toBe(timestamp)
expect(body.stripe.customerID).toBeDefined()
expect(body.amount).toEqual(amount)
expect(body.productID).toBe('custom')
expect(body.service).toBe('stripe')
expect(body.recipient).toBe(joeID)
expect(body.purchaser).toBe(joeID)
User.findById(joe.get('_id'), (err, user) ->
expect(user.get('stripe').customerID).toBe(body.stripe.customerID)
criteria =
recipient: user.get('_id')
purchaser: user.get('_id')
amount: amount
service: 'stripe'
"stripe.customerID": user.get('stripe').customerID
Payment.findOne criteria, (err, payment) ->
expect(err).toBeNull()
expect(payment).not.toBeNull()
done()
)
)
it 'handles a custom purchase with invalid amount', (done) ->
timestamp = new Date().getTime()
amount = 'free?'
stripe.tokens.create({
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeID = joe.get('_id') + ''
data = {
amount: amount
stripe: {
token: token.id
timestamp: timestamp
}
}
request.post {uri: customPaymentURL, json: data }, (err, res, body) ->
expect(res.statusCode).toBe(400)
done()
)
it 'handles a custom purchase with no amount', (done) ->
timestamp = new Date().getTime()
stripe.tokens.create({
card: { number: '4242424242424242', exp_month: 12, exp_year: 2020, cvc: '123' }
}, (err, token) ->
stripeTokenID = token.id
loginJoe (joe) ->
joeID = joe.get('_id') + ''
data = {
stripe: {
token: token.id
timestamp: timestamp
}
}
request.post {uri: customPaymentURL, json: data }, (err, res, body) ->
expect(res.statusCode).toBe(400)
done()
)