mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-27 09:35:39 -05:00
Add custom payments
Example: https://codecombat.com/account/invoices?a=21600&d=9%20monthly%20subscrip tions
This commit is contained in:
parent
942bab554a
commit
cd59b90e37
10 changed files with 332 additions and 16 deletions
|
@ -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')
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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' }
|
||||
|
|
9
app/styles/account/invoices-view.sass
Normal file
9
app/styles/account/invoices-view.sass
Normal file
|
@ -0,0 +1,9 @@
|
|||
#invoices-view
|
||||
.form
|
||||
#amount
|
||||
width: 100px
|
||||
#description
|
||||
min-width: 400px
|
||||
width: auto
|
||||
#pay-button
|
||||
width: auto
|
52
app/templates/account/invoices-view.jade
Normal file
52
app/templates/account/invoices-view.jade
Normal 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
|
|
@ -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")
|
||||
|
|
93
app/views/account/InvoicesView.coffee
Normal file
93
app/views/account/InvoicesView.coffee
Normal 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()
|
|
@ -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();});
|
||||
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue