diff --git a/app/core/Router.coffee b/app/core/Router.coffee index a2fc26463..86a51e7e3 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -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') diff --git a/app/locale/en.coffee b/app/locale/en.coffee index feef4fc14..31d3d6eea 100644 --- a/app/locale/en.coffee +++ b/app/locale/en.coffee @@ -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." diff --git a/app/schemas/models/payment.schema.coffee b/app/schemas/models/payment.schema.coffee index d4e7d7815..cfb24eae2 100644 --- a/app/schemas/models/payment.schema.coffee +++ b/app/schemas/models/payment.schema.coffee @@ -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' } diff --git a/app/styles/account/invoices-view.sass b/app/styles/account/invoices-view.sass new file mode 100644 index 000000000..392b737f9 --- /dev/null +++ b/app/styles/account/invoices-view.sass @@ -0,0 +1,9 @@ +#invoices-view + .form + #amount + width: 100px + #description + min-width: 400px + width: auto + #pay-button + width: auto diff --git a/app/templates/account/invoices-view.jade b/app/templates/account/invoices-view.jade new file mode 100644 index 000000000..d90965ab5 --- /dev/null +++ b/app/templates/account/invoices-view.jade @@ -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 diff --git a/app/templates/account/main-account-view.jade b/app/templates/account/main-account-view.jade index 9da3337fb..27a5b84d4 100644 --- a/app/templates/account/main-account-view.jade +++ b/app/templates/account/main-account-view.jade @@ -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") diff --git a/app/views/account/InvoicesView.coffee b/app/views/account/InvoicesView.coffee new file mode 100644 index 000000000..a66145184 --- /dev/null +++ b/app/views/account/InvoicesView.coffee @@ -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() diff --git a/scripts/mongodb/addExternalSubs.js b/scripts/mongodb/addExternalSubs.js index 644d41c94..5dd4bcfe7 100644 --- a/scripts/mongodb/addExternalSubs.js +++ b/scripts/mongodb/addExternalSubs.js @@ -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();}); diff --git a/server/payments/payment_handler.coffee b/server/payments/payment_handler.coffee index 85820fc2c..d780fd802 100644 --- a/server/payments/payment_handler.coffee +++ b/server/payments/payment_handler.coffee @@ -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) -> diff --git a/test/server/functional/payment.spec.coffee b/test/server/functional/payment.spec.coffee index 0b6f9bd1d..6dcef8d62 100644 --- a/test/server/functional/payment.spec.coffee +++ b/test/server/functional/payment.spec.coffee @@ -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() + )