From ded122806ff6b6510b9aaa6ded268b8f4b42064b Mon Sep 17 00:00:00 2001 From: Leo Lamprecht Date: Fri, 1 Sep 2017 13:35:49 +0200 Subject: [PATCH] Implemented the last sub command --- package-lock.json | 22 ++ package.json | 2 + src/providers/sh/commands/bin/billing.js | 389 +++++++++++++++++++ src/providers/sh/commands/bin/billing/add.js | 255 ++++++++++++ src/providers/sh/commands/bin/domains/buy.js | 3 +- 5 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 src/providers/sh/commands/bin/billing.js create mode 100644 src/providers/sh/commands/bin/billing/add.js diff --git a/package-lock.json b/package-lock.json index 59cdbae..0d469ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,12 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@google/maps": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@google/maps/-/maps-0.4.3.tgz", + "integrity": "sha1-tGiWERTW4HScFYAuDaFJ3Ooig6U=", + "dev": true + }, "acorn": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.1.1.tgz", @@ -1842,6 +1848,16 @@ "sha.js": "2.4.8" } }, + "credit-card": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/credit-card/-/credit-card-3.0.1.tgz", + "integrity": "sha1-uY842UrfMnXxQJHYvSzRQcx4nFY=", + "dev": true, + "requires": { + "lodash.merge": "4.6.0", + "reach": "1.0.0" + } + }, "cross-spawn": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", @@ -6434,6 +6450,12 @@ } } }, + "reach": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/reach/-/reach-1.0.0.tgz", + "integrity": "sha1-TRLRYJYLysZ9xiTw+Ruv6zb3lPY=", + "dev": true + }, "read-pkg": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", diff --git a/package.json b/package.json index 565db6a..aa2a048 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ ] }, "devDependencies": { + "@google/maps": "0.4.3", "ansi-escapes": "2.0.0", "archiver": "2.0.0", "arr-flatten": "1.1.0", @@ -97,6 +98,7 @@ "clipboardy": "1.1.4", "convert-stream": "1.0.2", "copy-webpack-plugin": "4.0.1", + "credit-card": "3.0.1", "dateformat": "2.0.0", "death": "1.1.0", "debug": "3.0.0", diff --git a/src/providers/sh/commands/bin/billing.js b/src/providers/sh/commands/bin/billing.js new file mode 100644 index 0000000..0190d7b --- /dev/null +++ b/src/providers/sh/commands/bin/billing.js @@ -0,0 +1,389 @@ +#!/usr/bin/env node + +// Packages +const chalk = require('chalk') +const minimist = require('minimist') +const ms = require('ms') + +// Utilities +const login = require('../lib/login') +const cfg = require('../lib/cfg') +const { handleError, error } = require('../lib/error') +const NowCreditCards = require('../lib/credit-cards') +const indent = require('../lib/indent') +const listInput = require('../lib/utils/input/list') +const success = require('../lib/utils/output/success') +const promptBool = require('../lib/utils/input/prompt-bool') +const info = require('../lib/utils/output/info') +const logo = require('../lib/utils/output/logo') +const addBilling = require('./billing/add') + +const help = () => { + console.log(` + ${chalk.bold(`${logo} now billing`)} + + ${chalk.dim('Options:')} + + -h, --help Output usage information + -c ${chalk.bold.underline('FILE')}, --config=${chalk.bold.underline( + 'FILE' + )} Config file + -d, --debug Debug mode [off] + -t ${chalk.bold.underline('TOKEN')}, --token=${chalk.bold.underline( + 'TOKEN' + )} Login token + + ${chalk.dim('Examples:')} + + ${chalk.gray('–')} Lists all your credit cards: + + ${chalk.cyan('$ now billing ls')} + + ${chalk.gray('–')} Adds a credit card (interactively): + + ${chalk.cyan(`$ now billing add`)} + + ${chalk.gray('–')} Removes a credit card: + + ${chalk.cyan(`$ now billing rm `)} + + ${chalk.gray('–')} If the id is omitted, you can choose interactively + + ${chalk.gray('–')} Selects your default credit card: + + ${chalk.cyan(`$ now billing set-default `)} + + ${chalk.gray('–')} If the id is omitted, you can choose interactively + `) +} + +const exit = code => { + // We give stdout some time to flush out + // because there's a node bug where + // stdout writes are asynchronous + // https://github.com/nodejs/node/issues/6456 + setTimeout(() => process.exit(code || 0), 100) +} + +let argv +let debug +let apiUrl +let subcommand + +const main = async ctx => { + argv = minimist(ctx.argv.slice(2), { + string: ['config', 'token'], + boolean: ['help', 'debug'], + alias: { + help: 'h', + config: 'c', + debug: 'd', + token: 't' + } + }) + + argv._ = argv._.slice(1) + + debug = argv.debug + apiUrl = argv.url || 'https://api.zeit.co' + subcommand = argv._[0] + + if (argv.config) { + cfg.setConfigFile(argv.config) + } + + if (argv.help || !subcommand) { + help() + exit(0) + } + + const config = await cfg.read({ token: argv.token }) + let token + + try { + token = config.token || (await login(apiUrl)) + } catch (err) { + error(`Authentication error – ${err.message}`) + exit(1) + } + + try { + await run({ token, config }) + } catch (err) { + if (err.userError) { + error(err.message) + } else { + error(`Unknown error: ${err.stack}`) + } + exit(1) + } +} + +module.exports = async ctx => { + try { + await main(ctx) + } catch (err) { + handleError(err) + process.exit(1) + } +} + +// Builds a `choices` object that can be passesd to inquirer.prompt() +function buildInquirerChoices(cards) { + return cards.cards.map(card => { + const _default = + card.id === cards.defaultCardId ? ' ' + chalk.bold('(default)') : '' + const id = `${chalk.cyan(`ID: ${card.id}`)}${_default}` + const number = `${chalk.gray('#### ').repeat(3)}${card.last4}` + const str = [ + id, + indent(card.name, 2), + indent(`${card.brand} ${number}`, 2) + ].join('\n') + + return { + name: str, // Will be displayed by Inquirer + value: card.id, // Will be used to identify the answer + short: card.id // Will be displayed after the users answers + } + }) +} + +async function run({ token, config: { currentTeam, user } }) { + const start = new Date() + const creditCards = new NowCreditCards({ apiUrl, token, debug, currentTeam }) + const args = argv._.slice(1) + + switch (subcommand) { + case 'ls': + case 'list': { + let cards + try { + cards = await creditCards.ls() + } catch (err) { + error(err.message) + return + } + const text = cards.cards + .map(card => { + const _default = + card.id === cards.defaultCardId ? ' ' + chalk.bold('(default)') : '' + const id = `${chalk.gray('-')} ${chalk.cyan( + `ID: ${card.id}` + )}${_default}` + const number = `${chalk.gray('#### ').repeat(3)}${card.last4}` + let address = card.address_line1 + + if (card.address_line2) { + address += `, ${card.address_line2}.` + } else { + address += '.' + } + + address += `\n${card.address_city}, ` + + if (card.address_state) { + address += `${card.address_state}, ` + } + + // Stripe is returning a two digit code for the country, + // but we want the full country name + address += `${card.address_zip}. ${card.address_country}` + + return [ + id, + indent(card.name, 2), + indent(`${card.brand} ${number}`, 2), + indent(address, 2) + ].join('\n') + }) + .join('\n\n') + + const elapsed = ms(new Date() - start) + console.log( + `> ${cards.cards.length} card${cards.cards.length === 1 + ? '' + : 's'} found under ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )} ${chalk.gray(`[${elapsed}]`)}` + ) + if (text) { + console.log(`\n${text}\n`) + } + + break + } + + case 'set-default': { + if (args.length > 1) { + error('Invalid number of arguments') + return exit(1) + } + + const start = new Date() + + let cards + try { + cards = await creditCards.ls() + } catch (err) { + error(err.message) + return + } + + if (cards.cards.length === 0) { + error('You have no credit cards to choose from') + return exit(0) + } + + let cardId = args[0] + + if (cardId === undefined) { + const elapsed = ms(new Date() - start) + const message = `Selecting a new default payment card for ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )} ${chalk.gray(`[${elapsed}]`)}` + const choices = buildInquirerChoices(cards) + + cardId = await listInput({ + message, + choices, + separator: true, + abort: 'end' + }) + } + + // Check if the provided cardId (in case the user + // typed `now billing set-default `) is valid + if (cardId) { + const label = `Are you sure that you to set this card as the default?` + const confirmation = await promptBool(label, { + trailing: '\n' + }) + if (!confirmation) { + info('Aborted') + break + } + const start = new Date() + await creditCards.setDefault(cardId) + + const card = cards.cards.find(card => card.id === cardId) + const elapsed = ms(new Date() - start) + success( + `${card.brand} ending in ${card.last4} is now the default ${chalk.gray( + `[${elapsed}]` + )}` + ) + } else { + console.log('No changes made') + } + + break + } + + case 'rm': + case 'remove': { + if (args.length > 1) { + error('Invalid number of arguments') + return exit(1) + } + + const start = new Date() + let cards + try { + cards = await creditCards.ls() + } catch (err) { + error(err.message) + return + } + + if (cards.cards.length === 0) { + error( + `You have no credit cards to choose from to delete under ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )}` + ) + return exit(0) + } + + let cardId = args[0] + + if (cardId === undefined) { + const elapsed = ms(new Date() - start) + const message = `Selecting a card to ${chalk.underline( + 'remove' + )} under ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )} ${chalk.gray(`[${elapsed}]`)}` + const choices = buildInquirerChoices(cards) + + cardId = await listInput({ + message, + choices, + separator: true, + abort: 'start' + }) + } + + // Shoud check if the provided cardId (in case the user + // typed `now billing rm `) is valid + if (cardId) { + const label = `Are you sure that you want to remove this card?` + const confirmation = await promptBool(label) + if (!confirmation) { + console.log('Aborted') + break + } + const start = new Date() + await creditCards.rm(cardId) + + const deletedCard = cards.cards.find(card => card.id === cardId) + const remainingCards = cards.cards.filter(card => card.id !== cardId) + + let text = `${deletedCard.brand} ending in ${deletedCard.last4} was deleted` + // ${chalk.gray(`[${elapsed}]`)} + + if (cardId === cards.defaultCardId) { + if (remainingCards.length === 0) { + // The user deleted the last card in their account + text += `\n${chalk.yellow('Warning!')} You have no default card` + } else { + // We can't guess the current default card – let's ask the API + const cards = await creditCards.ls() + const newDefaultCard = cards.cards.find( + card => card.id === cards.defaultCardId + ) + + text += `\n${newDefaultCard.brand} ending in ${newDefaultCard.last4} in now default for ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )}` + } + } + + const elapsed = ms(new Date() - start) + text += ` ${chalk.gray(`[${elapsed}]`)}` + success(text) + } else { + console.log('No changes made') + } + + break + } + + case 'add': { + addBilling({ + creditCards, + currentTeam, + user + }) + + break + } + + default: + error('Please specify a valid subcommand: ls | add | rm | set-default') + help() + exit(1) + } + + creditCards.close() +} diff --git a/src/providers/sh/commands/bin/billing/add.js b/src/providers/sh/commands/bin/billing/add.js new file mode 100644 index 0000000..b24fa49 --- /dev/null +++ b/src/providers/sh/commands/bin/billing/add.js @@ -0,0 +1,255 @@ +#!/usr/bin/env node + +// Packages +const ansiEscapes = require('ansi-escapes') +const chalk = require('chalk') +const ccValidator = require('credit-card') + +// Utilities +const textInput = require('../../lib/utils/input/text') +const countries = require('../../lib/utils/billing/country-list') +const cardBrands = require('../../lib/utils/billing/card-brands') +const geocode = require('../../lib/utils/billing/geocode') +const success = require('../../lib/utils/output/success') +const wait = require('../../lib/utils/output/wait') +const { tick } = require('../../lib/utils/output/chars') +const rightPad = require('../../lib/utils/output/right-pad') + +function expDateMiddleware(data) { + return data +} + +module.exports = async function({ + creditCards, + currentTeam, + user, + clear = false +}) { + const state = { + error: undefined, + cardGroupLabel: `> ${chalk.bold( + `Enter your card details for ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )}` + )}`, + + name: { + label: rightPad('Full Name', 12), + placeholder: 'John Appleseed', + validateValue: data => data.trim().length > 0 + }, + + cardNumber: { + label: rightPad('Number', 12), + mask: 'cc', + placeholder: '#### #### #### ####', + validateKeypress: (data, value) => /\d/.test(data) && value.length < 19, + validateValue: data => { + data = data.replace(/ /g, '') + const type = ccValidator.determineCardType(data) + if (!type) { + return false + } + return ccValidator.isValidCardNumber(data, type) + } + }, + + ccv: { + label: rightPad('CCV', 12), + mask: 'ccv', + placeholder: '###', + validateValue: data => { + const brand = state.cardNumber.brand.toLowerCase() + return ccValidator.doesCvvMatchType(data, brand) + } + }, + + expDate: { + label: rightPad('Exp. Date', 12), + mask: 'expDate', + placeholder: 'mm / yyyy', + middleware: expDateMiddleware, + validateValue: data => !ccValidator.isExpired(...data.split(' / ')) + }, + + addressGroupLabel: `\n> ${chalk.bold('Enter your billing address')}`, + + country: { + label: rightPad('Country', 12), + async autoComplete(value) { + for (const country in countries) { + if (!Object.hasOwnProperty.call(countries, country)) { + continue + } + + if (country.startsWith(value)) { + return country.substr(value.length) + } + + const lowercaseCountry = country.toLowerCase() + const lowercaseValue = value.toLowerCase() + + if (lowercaseCountry.startsWith(lowercaseValue)) { + return lowercaseCountry.substr(value.length) + } + } + + return false + }, + validateValue: value => { + for (const country in countries) { + if (!Object.hasOwnProperty.call(countries, country)) { + continue + } + + if (country.toLowerCase() === value.toLowerCase()) { + return true + } + } + + return false + } + }, + + zipCode: { + label: rightPad('ZIP', 12), + validadeKeypress: data => data.trim().length > 0, + validateValue: data => data.trim().length > 0 + }, + + state: { + label: rightPad('State', 12), + validateValue: data => data.trim().length > 0 + }, + + city: { + label: rightPad('City', 12), + validateValue: data => data.trim().length > 0 + }, + + address1: { + label: rightPad('Address', 12), + validateValue: data => data.trim().length > 0 + } + } + + async function render() { + for (const key in state) { + if (!Object.hasOwnProperty.call(state, key)) { + continue + } + const piece = state[key] + if (typeof piece === 'string') { + console.log(piece) + } else if (typeof piece === 'object') { + let result + try { + /* eslint-disable no-await-in-loop */ + result = await textInput({ + label: '- ' + piece.label, + initialValue: piece.initialValue || piece.value, + placeholder: piece.placeholder, + mask: piece.mask, + validateKeypress: piece.validateKeypress, + validateValue: piece.validateValue, + autoComplete: piece.autoComplete + }) + + piece.value = result + + if (key === 'cardNumber') { + let brand = cardBrands[ccValidator.determineCardType(result)] + piece.brand = brand + + if (brand === 'American Express') { + state.ccv.placeholder = '#'.repeat(4) + } else { + state.ccv.placeholder = '#'.repeat(3) + } + + brand = chalk.cyan(`[${brand}]`) + const masked = chalk.gray('#### '.repeat(3)) + result.split(' ')[3] + process.stdout.write( + `${chalk.cyan(tick)} ${piece.label}${masked} ${brand}\n` + ) + } else if (key === 'ccv') { + process.stdout.write( + `${chalk.cyan(tick)} ${piece.label}${'*'.repeat(result.length)}\n` + ) + } else if (key === 'expDate') { + let text = result.split(' / ') + text = text[0] + chalk.gray(' / ') + text[1] + process.stdout.write(`${chalk.cyan(tick)} ${piece.label}${text}\n`) + } else if (key === 'zipCode') { + const stopSpinner = wait(piece.label + result) + const addressInfo = await geocode({ + country: state.country.value, + zipCode: result + }) + if (addressInfo.state) { + state.state.initialValue = addressInfo.state + } + if (addressInfo.city) { + state.city.initialValue = addressInfo.city + } + stopSpinner() + process.stdout.write( + `${chalk.cyan(tick)} ${piece.label}${result}\n` + ) + } else { + process.stdout.write( + `${chalk.cyan(tick)} ${piece.label}${result}\n` + ) + } + } catch (err) { + if (err.message === 'USER_ABORT') { + process.exit(1) + } else { + console.error(err) + } + } + } + } + console.log('') // New line + const stopSpinner = wait('Saving card') + + try { + const res = await creditCards.add({ + name: state.name.value, + cardNumber: state.cardNumber.value, + ccv: state.ccv.value, + expDate: state.expDate.value, + country: state.country.value, + zipCode: state.zipCode.value, + state: state.state.value, + city: state.city.value, + address1: state.address1.value + }) + stopSpinner() + if (clear) { + const linesToClear = state.error ? 15 : 14 + process.stdout.write(ansiEscapes.eraseLines(linesToClear)) + } + success( + `${state.cardNumber + .brand} ending in ${res.last4} was added to ${chalk.bold( + (currentTeam && currentTeam.slug) || user.username || user.email + )}` + ) + } catch (err) { + stopSpinner() + const linesToClear = state.error ? 15 : 14 + process.stdout.write(ansiEscapes.eraseLines(linesToClear)) + state.error = `${chalk.red( + '> Error!' + )} ${err.message} Please make sure the info is correct` + await render() + } + } + + try { + await render() + } catch (err) { + console.erorr(err) + } +} diff --git a/src/providers/sh/commands/bin/domains/buy.js b/src/providers/sh/commands/bin/domains/buy.js index 3582d1e..46ef7d6 100644 --- a/src/providers/sh/commands/bin/domains/buy.js +++ b/src/providers/sh/commands/bin/domains/buy.js @@ -13,6 +13,7 @@ const promptBool = require('../../lib/utils/input/prompt-bool') const eraseLines = require('../../lib/utils/output/erase-lines') const treatBuyError = require('../../lib/utils/domains/treat-buy-error') const NowCreditCards = require('../../lib/credit-cards') +const addBilling = require('../billing/add') module.exports = async function({ domains, args, currentTeam, user, coupon }) { const name = args[0] @@ -59,7 +60,7 @@ module.exports = async function({ domains, args, currentTeam, user, coupon }) { ) info(`Your card will ${bold('not')} be charged`) - await require('../now-billing-add')({ + await addBilling({ creditCards, currentTeam, user,