skl-auto-payslip/index.js

286 lines
10 KiB
JavaScript

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')
const path = require('path')
const exec = promisify(require('child_process').exec)
const {spawn} = require('child_process')
// 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']
const HOME = process.env['HOME']
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",
destdir: `${HOME}/serokell/invoices/payslips`
}
async function authAll() {
const {stdout, stderr} = await exec(`gpg --decrypt < ${__dirname}/credentials.json.gpg`)
return authorize(JSON.parse(stdout))
}
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 {
token = await pass_get("skl-auto-payslip")
} 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
}
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
}
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);
await pass_insert('skl-auto-payslip', JSON.stringify(token))
return oAuth2Client
}
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("Opening pdf for review")
await exec(`zathura "${pdf}" || mupdf "${pdf}"`)
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')}`
class App {
constructor(auth) {
this.auth = auth
this.doc = new InvoiceDocument(auth, config.fileId)
}
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)
}
async create(sheetname, template=config.template) { // copy sheet from template
console.log("Creating", sheetname, "from", template)
return this.doc.copyTo(template, sheetname)
}
async update(sheetname, startDate, endDate, hours) { // set values in sheet
console.log("Setting data in", sheetname)
if (hours.indexOf && hours.indexOf(':') != -1) {
const [hour, min] = hours.split(':')
hours = +hour
hours += (+min) / 60
} else {
hours = +hours
}
if (isNaN(hours)) {
throw "hours argument not a float or int"
}
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
})
}
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)
}
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)
}
async authenticate() { // just auth
}
}
module.exports = App
module.exports.authAll = authAll