2019-05-18 18:52:53 +02:00
|
|
|
const readline = require('./fix-readline.js')
|
|
|
|
const nodemailer = require('nodemailer')
|
|
|
|
const fs = require('fs');
|
|
|
|
const {google} = require('googleapis');
|
|
|
|
const r2 = require('r2')
|
|
|
|
const qs = require('querystring')
|
|
|
|
const {promisify} = require('util')
|
|
|
|
const readFile = promisify(fs.readFile)
|
|
|
|
const base64url = require('base64url')
|
|
|
|
const process = require('process')
|
2019-05-18 19:59:44 +02:00
|
|
|
const path = require('path')
|
|
|
|
const exec = promisify(require('child_process').exec)
|
|
|
|
const {spawn} = require('child_process')
|
2019-05-18 18:52:53 +02:00
|
|
|
|
|
|
|
// If modifying these scopes, delete token.json.
|
|
|
|
const SCOPES = ['https://www.googleapis.com/auth/drive','https://www.googleapis.com/auth/gmail.compose'];
|
|
|
|
const USER = process.env['USER']
|
2019-05-18 19:59:44 +02:00
|
|
|
const HOME = process.env['HOME']
|
2019-05-18 18:52:53 +02:00
|
|
|
|
|
|
|
const UserCapitalized = USER.charAt(0).toUpperCase() + USER.slice(1)
|
|
|
|
|
|
|
|
const config = {
|
|
|
|
fileId: "1CqUglyFNoEWL0lx-e7arqUGOOZEYW__JjcwBmNaXE6E",
|
|
|
|
template: `${UserCapitalized} ${(new Date()).getFullYear()} Template`,
|
|
|
|
to: "billing@serokell.io",
|
2019-05-18 19:59:44 +02:00
|
|
|
destdir: `${HOME}/serokell/invoices/payslips`
|
2019-05-18 18:52:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async function authAll() {
|
2019-05-18 20:05:20 +02:00
|
|
|
const {stdout, stderr} = await exec(`gpg --decrypt < ${__dirname}/credentials.json.gpg`)
|
|
|
|
return authorize(JSON.parse(stdout))
|
2019-05-18 18:52:53 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async function authorize(credentials) {
|
|
|
|
const {client_secret, client_id, redirect_uris} = credentials.installed;
|
|
|
|
const oAuth2Client = new google.auth.OAuth2(
|
|
|
|
client_id, client_secret, redirect_uris[0]);
|
|
|
|
|
|
|
|
// Check if we have previously stored a token.
|
|
|
|
let token
|
|
|
|
try {
|
2019-05-18 19:59:44 +02:00
|
|
|
token = await pass_get("skl-auto-payslip")
|
2019-05-18 18:52:53 +02:00
|
|
|
} catch(e) {
|
|
|
|
return getAccessToken(oAuth2Client)
|
|
|
|
}
|
|
|
|
oAuth2Client.setCredentials(JSON.parse(token));
|
|
|
|
return oAuth2Client
|
|
|
|
}
|
|
|
|
|
|
|
|
async function ask(question) {
|
|
|
|
const rl = readline.createInterface({
|
|
|
|
input: process.stdin,
|
|
|
|
output: process.stdout,
|
|
|
|
});
|
|
|
|
const answer = await rl.questionAsync(question)
|
|
|
|
rl.close()
|
|
|
|
return answer
|
|
|
|
}
|
2019-05-18 19:59:44 +02:00
|
|
|
function pass_insert(name, val) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
const pass = spawn("pass", ['insert', 'skl-auto-payslip', '-m'], {stdio: ['pipe', 'inherit', 'inherit']})
|
|
|
|
pass.stdin.write(val)
|
|
|
|
pass.stdin.end()
|
|
|
|
pass.on('close', (code) => {
|
|
|
|
if (code != 0) reject(`pass exited with code ${code}`)
|
|
|
|
else { resolve() }
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
async function pass_get(name) {
|
|
|
|
const {stdout, stderr} = await exec("pass skl-auto-payslip")
|
|
|
|
return stdout
|
|
|
|
}
|
2019-05-18 18:52:53 +02:00
|
|
|
|
|
|
|
async function getAccessToken(oAuth2Client) {
|
|
|
|
const authUrl = oAuth2Client.generateAuthUrl({
|
|
|
|
access_type: 'offline',
|
|
|
|
scope: SCOPES,
|
|
|
|
});
|
|
|
|
console.log('Authorize this app by visiting this url:', authUrl);
|
|
|
|
const code = await ask('Enter the code from that page here: ')
|
|
|
|
const token = (await oAuth2Client.getToken(code)).tokens
|
|
|
|
oAuth2Client.setCredentials(token);
|
2019-05-18 19:59:44 +02:00
|
|
|
await pass_insert('skl-auto-payslip', JSON.stringify(token))
|
2019-05-18 18:52:53 +02:00
|
|
|
return oAuth2Client
|
|
|
|
}
|
2019-05-18 19:59:44 +02:00
|
|
|
|
2019-05-18 18:52:53 +02:00
|
|
|
class InvoiceDocument {
|
|
|
|
constructor(auth, fileId) {
|
|
|
|
Object.assign(this, {
|
|
|
|
auth, fileId,
|
|
|
|
sheets: google.sheets({version: 'v4', auth}),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
async sheetByName(sheetname) {
|
|
|
|
const {sheets} = (await this.sheets.spreadsheets.get({
|
|
|
|
spreadsheetId: this.fileId,
|
|
|
|
includeGridData: false
|
|
|
|
})).data
|
|
|
|
const r = sheets.find(({properties: {title}}) => title == sheetname)
|
|
|
|
return r ? r.properties.sheetId : r
|
|
|
|
}
|
|
|
|
async sheetAsPdf(destFile, gid) {
|
|
|
|
const params = {
|
|
|
|
format: 'pdf', size: 7, fzr: true,
|
|
|
|
portrait: true, fitw: false, gridlines: false,
|
|
|
|
printtitle: false, sheetnames: false, pagenum: "UNDEFINED",
|
|
|
|
attachment: false, gid, printnotes: false
|
|
|
|
}
|
|
|
|
const token = this.auth.credentials.access_token
|
|
|
|
const dest = fs.createWriteStream(destFile)
|
|
|
|
// thanks, https://gist.github.com/Spencer-Easton/78f9867a691e549c9c70#gistcomment-2622947
|
|
|
|
const res = await r2(`https://docs.google.com/spreadsheets/d/${this.fileId}/export?${qs.stringify(params)}`,
|
|
|
|
{headers: {"Authorization": `Bearer ${token}`}}).response
|
|
|
|
res.body.pipe(dest)
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
res.body.on('end', resolve)
|
|
|
|
.on('error', reject)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
async copyTo(src, dest) {
|
|
|
|
const templateId = await this.sheetByName(src)
|
|
|
|
if (!templateId) throw "template sheet not found"
|
|
|
|
const res = await this.sheets.spreadsheets.sheets.copyTo({
|
|
|
|
spreadsheetId: this.fileId,
|
|
|
|
sheetId: templateId,
|
|
|
|
resource: {
|
|
|
|
destinationSpreadsheetId: this.fileId
|
|
|
|
}
|
|
|
|
})
|
|
|
|
const updres = await this.sheets.spreadsheets.batchUpdate({
|
|
|
|
spreadsheetId: this.fileId,
|
|
|
|
resource: {
|
|
|
|
requests: [{
|
|
|
|
updateSheetProperties: {
|
|
|
|
fields: "title",
|
|
|
|
properties: { sheetId: res.data.sheetId, title: dest }
|
|
|
|
}
|
|
|
|
}]
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
async set(sheetname, vals) {
|
|
|
|
await this.sheets.spreadsheets.values.batchUpdate({
|
|
|
|
spreadsheetId: this.fileId,
|
|
|
|
resource: {
|
|
|
|
valueInputOption: "USER_ENTERED",
|
|
|
|
data: Object.keys(vals).map(range => ({range: `'${sheetname}'!${range}`, values: [[vals[range]]]}))
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
async get(sheetname, cell) {
|
|
|
|
const res = await this.sheets.spreadsheets.values.get({
|
|
|
|
spreadsheetId: this.fileId,
|
|
|
|
range: `'${sheetname}'!${cell}`
|
|
|
|
})
|
|
|
|
return res.data.values
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function composeDraft(title, iban, total, pdf, auth) {
|
|
|
|
const gmail = google.gmail({version: 'v1', auth})
|
|
|
|
const user = await gmail.users.getProfile({userId: 'me'})
|
|
|
|
const email = user.data.emailAddress
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
|
|
streamTransport: true, newline: 'unix'
|
|
|
|
})
|
|
|
|
const body = `Hi!
|
|
|
|
|
|
|
|
The total is ${total} to ${iban} (same as always). Thanks!`
|
|
|
|
// TODO: check if same as always
|
|
|
|
const msg = {
|
|
|
|
from: email,
|
|
|
|
to: config.to,
|
|
|
|
subject: `payslip - ${title}`,
|
|
|
|
text: body,
|
|
|
|
attachments: [{
|
|
|
|
filename: `payslip - ${title}.pdf`,
|
|
|
|
path: pdf
|
|
|
|
}]
|
|
|
|
}
|
|
|
|
transporter.sendMailAsync = promisify(transporter.sendMail)
|
|
|
|
const info = await transporter.sendMailAsync(msg)
|
|
|
|
const pdfStream = fs.createReadStream(pdf)
|
|
|
|
const draft = await gmail.users.drafts.create({
|
|
|
|
userId: 'me',
|
|
|
|
media: {mimeType: 'message/rfc822', body: info.message}
|
|
|
|
})
|
|
|
|
console.log(`Draft created, please review and send: https://mail.google.com/mail/u/${email}/#drafts?compose=${draft.data.message.id}`)
|
|
|
|
}
|
|
|
|
const datefmt = date =>
|
|
|
|
`${date.getFullYear()}-${(""+(date.getMonth()+1)).padStart(2, '0')}-${(""+date.getDate()).padStart(2, '0')}`
|
2019-05-18 19:59:44 +02:00
|
|
|
class App {
|
|
|
|
constructor(auth) {
|
|
|
|
this.auth = auth
|
|
|
|
this.doc = new InvoiceDocument(auth, config.fileId)
|
|
|
|
}
|
2019-05-18 18:52:53 +02:00
|
|
|
async export(sheetname, pdfOut) { // save sheet as pdf
|
|
|
|
console.log("Exporting", sheetname, "to", pdfOut)
|
|
|
|
const sheetId = await this.doc.sheetByName(sheetname)
|
|
|
|
return this.doc.sheetAsPdf(pdfOut, sheetId)
|
2019-05-18 19:59:44 +02:00
|
|
|
}
|
2019-05-18 18:52:53 +02:00
|
|
|
async create(sheetname, template=config.template) { // copy sheet from template
|
|
|
|
console.log("Creating", sheetname, "from", template)
|
|
|
|
return this.doc.copyTo(template, sheetname)
|
2019-05-18 19:59:44 +02:00
|
|
|
}
|
2019-05-18 18:52:53 +02:00
|
|
|
async update(sheetname, startDate, endDate, hours) { // set values in sheet
|
|
|
|
console.log("Setting data in", sheetname)
|
|
|
|
hours = +hours
|
|
|
|
const values = {
|
|
|
|
regular: Math.min(hours, 80),
|
|
|
|
overwork: Math.max(0, hours-80),
|
|
|
|
startDate, endDate
|
|
|
|
}
|
|
|
|
console.log(values)
|
|
|
|
this.doc.set(sheetname, {
|
|
|
|
D16: values.regular, D17: values.overwork,
|
|
|
|
B9: values.startDate, D9: values.endDate
|
|
|
|
})
|
2019-05-18 19:59:44 +02:00
|
|
|
}
|
2019-05-18 18:52:53 +02:00
|
|
|
async draft(sheetname) { // export pdf+create gmail draft
|
|
|
|
const pdfname = `${config.destdir}/${sheetname}.pdf`
|
|
|
|
try {
|
|
|
|
await promisify(fs.mkdir)(config.destdir, {recursive: true})
|
|
|
|
} catch(e) {
|
|
|
|
if (e.code != 'EEXIST') throw e
|
|
|
|
}
|
|
|
|
await this.export(sheetname, pdfname)
|
|
|
|
const [[paid,,,, total]] = await this.doc.get(sheetname, "B33:F33")
|
|
|
|
const iban = paid.replace(/^Paid to /, '')
|
|
|
|
console.log(paid, total)
|
|
|
|
composeDraft(sheetname, iban, total, pdfname, this.auth)
|
2019-05-18 19:59:44 +02:00
|
|
|
}
|
2019-05-18 18:52:53 +02:00
|
|
|
async auto(hours) { // just do everything
|
|
|
|
const date = new Date()
|
|
|
|
const day = date.getDate()
|
|
|
|
let part = 'B'
|
|
|
|
if (day >= 10 && day < 28) {
|
|
|
|
part = 'A'
|
|
|
|
} else if (day < 10) {
|
|
|
|
// talking about last month
|
|
|
|
date.setMonth(date.getMonth()-1)
|
|
|
|
}
|
|
|
|
const month = date.toLocaleString('en-us', {month: 'short'})
|
|
|
|
const sheetname = `${UserCapitalized} ${date.getFullYear()} ${month} ${part}`
|
|
|
|
let startDate, endDate
|
|
|
|
if (part == 'A') {
|
|
|
|
date.setDate(1)
|
|
|
|
startDate = datefmt(date)
|
|
|
|
date.setDate(15)
|
|
|
|
endDate = datefmt(date)
|
|
|
|
} else {
|
|
|
|
date.setDate(16)
|
|
|
|
startDate = datefmt(date)
|
|
|
|
date.setMonth(date.getMonth()+1, 0)
|
|
|
|
endDate = datefmt(date)
|
|
|
|
}
|
|
|
|
const sheetId = await this.doc.sheetByName(sheetname)
|
|
|
|
if (!sheetId) {
|
|
|
|
await this.create(sheetname)
|
|
|
|
} else {
|
|
|
|
const answer = await ask(`Overwrite '${sheetname}'? [yN]: `)
|
|
|
|
if (answer.trim().toLowerCase()[0] != 'y') {
|
|
|
|
console.log("Aborting!")
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
await this.update(sheetname, startDate, endDate, hours)
|
|
|
|
await this.draft(sheetname)
|
|
|
|
}
|
2019-05-18 19:59:44 +02:00
|
|
|
async authenticate() { // just auth
|
2019-05-18 18:52:53 +02:00
|
|
|
}
|
|
|
|
}
|
2019-05-18 19:59:44 +02:00
|
|
|
module.exports = App
|
|
|
|
module.exports.authAll = authAll
|