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/payments': go('account/PaymentsView')
'account/subscription': go('account/SubscriptionView')
'account/invoices': go('account/InvoicesView')
'admin': go('admin/MainAdminView')
'admin/candidates': go('admin/CandidatesView')

View file

@ -965,6 +965,7 @@
payments: "Payments"
purchased: "Purchased"
subscription: "Subscription"
invoices: "Invoices"
service_apple: "Apple"
service_web: "Web"
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: "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:
could_not_load: "Error loading from server"
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
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.' }
created: c.date({title: 'Created', readOnly: true})
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'}, {
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")
li.list-group-item
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 gems = 10500;
var amount = 1750;
var service = 'invoice';
var service = 'external';
emails = emails.map(function(e) { return e.toLowerCase();});

View file

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

View file

@ -7,6 +7,7 @@ describe '/db/payment', ->
request = require 'request'
paymentURL = getURL('/db/payment')
checkChargesURL = getURL('/db/payment/check-stripe-charges')
customPaymentURL = getURL('/db/payment/custom')
firstApplePayment = {
apple: {
@ -76,7 +77,6 @@ describe '/db/payment', ->
)
describe 'posting Stripe purchases', ->
stripe = require('stripe')(config.stripe.secretKey)
charge = null
@ -295,3 +295,143 @@ describe '/db/payment', ->
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()
)