summaryrefslogtreecommitdiff
path: root/src/cmd_payroll.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd_payroll.c')
-rw-r--r--src/cmd_payroll.c445
1 files changed, 445 insertions, 0 deletions
diff --git a/src/cmd_payroll.c b/src/cmd_payroll.c
index e7cafa3..b5db4f1 100644
--- a/src/cmd_payroll.c
+++ b/src/cmd_payroll.c
@@ -9,6 +9,8 @@
#include "audit.h"
#include "db.h"
#include "ledger.h"
+#include "mail.h"
+#include "payslip.h"
#include "secret.h"
#include "tax_table.h"
#include "util.h"
@@ -738,6 +740,431 @@ static yyjson_mut_val *h_payroll_run_get(struct req *r)
}
/* ------------------------------------------------------------------ */
+/* lönebesked */
+/* ------------------------------------------------------------------ */
+
+struct payslip_ctx {
+ struct payslip_data d;
+ char employee_name[200];
+ char personal_no[32];
+ char email[256];
+ char period[16];
+ char pay_date[16];
+ char run_ref[64];
+ char employer_name[256];
+ char employer_address[1024];
+ char employer_postal[64];
+ char employer_city[128];
+ char employer_org_nr[64];
+ char employer_phone[64];
+ char employer_email[256];
+ char filename[320];
+ int64_t voucher_id;
+ int64_t employee_id;
+};
+
+static void payslip_mask(const char *pn, char *out, size_t n)
+{
+ size_t len = pn ? strlen(pn) : 0;
+
+ if (len + 1 > n)
+ len = n - 1;
+ for (size_t i = 0; i < len; i++)
+ out[i] = (i + 4 >= len || pn[i] == '-') ? pn[i] : '*';
+ out[len] = '\0';
+}
+
+static int payslip_prepare(struct req *r, struct payslip_ctx *c)
+{
+ memset(c, 0, sizeof *c);
+ int64_t run_id = 0;
+ if (!arg_int(r->args, "run_id", &run_id) || run_id <= 0) {
+ fail(r, "INVALID_ARGS", "run_id is required");
+ return -1;
+ }
+ int64_t employee_id = 0;
+ int have_employee = arg_int(r->args, "employee_id", &employee_id);
+ if (have_employee && employee_id <= 0) {
+ fail(r, "INVALID_ARGS", "employee_id must be a positive id");
+ return -1;
+ }
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT period,pay_date,COALESCE(voucher_id,0) FROM payroll_runs"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, run_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ fail(r, "NOT_FOUND", "payroll run not found");
+ return -1;
+ }
+ snprintf(c->period, sizeof c->period, "%s",
+ sq(sqlite3_column_text(st, 0)));
+ snprintf(c->pay_date, sizeof c->pay_date, "%s",
+ sq(sqlite3_column_text(st, 1)));
+ c->voucher_id = sqlite3_column_int64(st, 2);
+ sqlite3_finalize(st);
+ st = NULL;
+
+ if (!have_employee) {
+ int64_t lines = 0;
+ if (sqlite3_prepare_v2(r->db,
+ "SELECT count(*) FROM payroll_run_lines"
+ " WHERE org_id=?1 AND run_id=?2",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, run_id);
+ if (sqlite3_step(st) == SQLITE_ROW)
+ lines = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ st = NULL;
+ if (lines == 0) {
+ fail(r, "NOT_FOUND", "payroll run has no lines");
+ return -1;
+ }
+ if (lines > 1) {
+ fail(r, "INVALID_ARGS",
+ "employee_id is required when the run has more than one"
+ " line");
+ return -1;
+ }
+ }
+
+ char sql[512];
+ snprintf(sql, sizeof sql,
+ "SELECT l.employee_id,e.name,e.personal_no_enc,e.email,"
+ "l.gross_ore,l.tax_ore,l.avgifter_ore,l.net_ore,l.tax_table,"
+ "l.tax_column FROM payroll_run_lines l JOIN employees e"
+ " ON e.org_id=l.org_id AND e.id=l.employee_id"
+ " WHERE l.org_id=?1 AND l.run_id=?2%s",
+ have_employee ? " AND l.employee_id=?3" : "");
+ if (sqlite3_prepare_v2(r->db, sql, -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, run_id);
+ if (have_employee)
+ sqlite3_bind_int64(st, 3, employee_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ if (have_employee)
+ fail(r, "NOT_FOUND", "employee has no line in this payroll run");
+ else
+ fail(r, "NOT_FOUND", "payroll run has no lines");
+ return -1;
+ }
+ c->employee_id = sqlite3_column_int64(st, 0);
+ snprintf(c->employee_name, sizeof c->employee_name, "%s",
+ sq(sqlite3_column_text(st, 1)));
+ const char *enc = sq(sqlite3_column_text(st, 2));
+ char *plain = NULL;
+ if (secret_decrypt(enc, &plain) == 0 && plain && *plain) {
+ payslip_mask(plain, c->personal_no, sizeof c->personal_no);
+ free(plain);
+ } else {
+ free(plain);
+ snprintf(c->personal_no, sizeof c->personal_no, "********");
+ }
+ snprintf(c->email, sizeof c->email, "%s", sq(sqlite3_column_text(st, 3)));
+ c->d.gross_ore = sqlite3_column_int64(st, 4);
+ c->d.tax_ore = sqlite3_column_int64(st, 5);
+ c->d.avgifter_ore = sqlite3_column_int64(st, 6);
+ c->d.net_ore = sqlite3_column_int64(st, 7);
+ c->d.tax_table = (int)sqlite3_column_int64(st, 8);
+ c->d.tax_column = (int)sqlite3_column_int64(st, 9);
+ sqlite3_finalize(st);
+ st = NULL;
+
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT COALESCE(name,''),COALESCE(address,''),"
+ "COALESCE(postal_code,''),COALESCE(city,''),COALESCE(org_nr,''),"
+ "COALESCE(phone,''),COALESCE(email,'') FROM orgs WHERE id=?1",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ return -1;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ fail(r, "INTERNAL", "org not found");
+ return -1;
+ }
+ snprintf(c->employer_name, sizeof c->employer_name, "%s",
+ sq(sqlite3_column_text(st, 0)));
+ snprintf(c->employer_address, sizeof c->employer_address, "%s",
+ sq(sqlite3_column_text(st, 1)));
+ snprintf(c->employer_postal, sizeof c->employer_postal, "%s",
+ sq(sqlite3_column_text(st, 2)));
+ snprintf(c->employer_city, sizeof c->employer_city, "%s",
+ sq(sqlite3_column_text(st, 3)));
+ snprintf(c->employer_org_nr, sizeof c->employer_org_nr, "%s",
+ sq(sqlite3_column_text(st, 4)));
+ snprintf(c->employer_phone, sizeof c->employer_phone, "%s",
+ sq(sqlite3_column_text(st, 5)));
+ snprintf(c->employer_email, sizeof c->employer_email, "%s",
+ sq(sqlite3_column_text(st, 6)));
+ sqlite3_finalize(st);
+
+ struct payroll_cfg cfg;
+ payroll_cfg_load(r, &cfg);
+ snprintf(c->run_ref, sizeof c->run_ref, "Lönekörning %s", c->period);
+
+ char safe_name[200];
+ snprintf(safe_name, sizeof safe_name, "%s", c->employee_name);
+ for (char *p = safe_name; *p; p++)
+ if (*p == '/')
+ *p = '-';
+ snprintf(c->filename, sizeof c->filename, "Lönebesked %s %s.pdf",
+ c->period, safe_name);
+
+ c->d.employer.name = c->employer_name;
+ c->d.employer.address = c->employer_address;
+ c->d.employer.postal_code = c->employer_postal;
+ c->d.employer.city = c->employer_city;
+ c->d.employer.org_nr = c->employer_org_nr;
+ c->d.employer.phone = c->employer_phone;
+ c->d.employer.email = c->employer_email;
+ c->d.employee_name = c->employee_name;
+ c->d.personal_no_masked = c->personal_no;
+ c->d.period = c->period;
+ c->d.pay_date = c->pay_date;
+ c->d.run_ref = c->run_ref;
+ c->d.rate_bp = cfg.rate_bp;
+ return 0;
+}
+
+static yyjson_mut_val *h_payroll_payslip(struct req *r)
+{
+ struct payslip_ctx ctx;
+ if (payslip_prepare(r, &ctx) != 0)
+ return NULL;
+ unsigned char *pdf = NULL;
+ size_t len = 0;
+ if (payslip_render(&ctx.d, &pdf, &len) != 0)
+ return fail(r, "INTERNAL", "could not render the payslip");
+ char *b64 = util_b64(pdf, len);
+ free(pdf);
+ if (!b64)
+ return fail(r, "INTERNAL", "could not encode the payslip");
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", ctx.filename);
+ free(b64);
+ return o;
+}
+
+/* Whole kronor with a space separator and two decimals, e.g. "20 792,00". */
+static void payroll_fmt_amount(int64_t ore, char *out, size_t n)
+{
+ uint64_t abs = ore < 0 ? (uint64_t)(-(ore + 1)) + 1 : (uint64_t)ore;
+ char digits[24];
+ snprintf(digits, sizeof digits, "%llu",
+ (unsigned long long)(abs / 100));
+ size_t len = strlen(digits), o = 0;
+ if (ore < 0 && o + 1 < n)
+ out[o++] = '-';
+ for (size_t i = 0; i < len && o + 1 < n; i++) {
+ if (i > 0 && (len - i) % 3 == 0)
+ out[o++] = ' ';
+ out[o++] = digits[i];
+ }
+ snprintf(out + o, n - o, ",%02llu", (unsigned long long)(abs % 100));
+}
+
+static int payroll_store_payslip(struct req *r, const struct payslip_ctx *c,
+ const unsigned char *pdf, size_t len,
+ int64_t *out_id)
+{
+ if (c->voucher_id <= 0) {
+ fail(r, "INTERNAL", "payroll run has no voucher");
+ return -1;
+ }
+ if (db_exec(r->db, "BEGIN IMMEDIATE", NULL) != 0) {
+ fail(r, "DB_BUSY", "could not start transaction");
+ return -1;
+ }
+ unsigned char hash[32];
+ util_sha256(pdf, len, hash);
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes,"
+ "content,created_at,created_by)"
+ " VALUES(?1,?2,?3,'application/pdf',?4,?5,?6,?7)"
+ " ON CONFLICT DO NOTHING",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ goto done;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, c->filename, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, (int64_t)len);
+ sqlite3_bind_blob(st, 5, pdf, (int)len, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 6, ts, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 7, r->sess->user_id);
+ int step = sqlite3_step(st);
+ sqlite3_finalize(st);
+ st = NULL;
+ if (step != SQLITE_DONE) {
+ db_sqlite_error(r);
+ goto done;
+ }
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2"
+ " AND filename=?3",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ goto done;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT);
+ sqlite3_bind_text(st, 3, c->filename, -1, SQLITE_TRANSIENT);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ st = NULL;
+ fail(r, "INTERNAL", "could not store the payslip attachment");
+ goto done;
+ }
+ *out_id = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ st = NULL;
+
+ if (sqlite3_prepare_v2(
+ r->db,
+ "INSERT OR IGNORE INTO voucher_attachments(org_id,voucher_id,"
+ "attachment_id,created_at) VALUES(?1,?2,?3,?4)",
+ -1, &st, NULL) != SQLITE_OK) {
+ db_error(r);
+ goto done;
+ }
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, c->voucher_id);
+ sqlite3_bind_int64(st, 3, *out_id);
+ sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT);
+ step = sqlite3_step(st);
+ sqlite3_finalize(st);
+ st = NULL;
+ if (step != SQLITE_DONE) {
+ db_sqlite_error(r);
+ goto done;
+ }
+ if (db_exec(r->db, "COMMIT", NULL) != 0) {
+ sqlite3_exec(r->db, "ROLLBACK", NULL, NULL, NULL);
+ fail(r, "DB_BUSY", "commit failed");
+ return -1;
+ }
+ return 0;
+
+done:
+ sqlite3_finalize(st);
+ sqlite3_exec(r->db, "ROLLBACK", NULL, NULL, NULL);
+ return -1;
+}
+
+static yyjson_mut_val *h_payroll_payslip_mail(struct req *r)
+{
+ struct payslip_ctx ctx;
+ if (payslip_prepare(r, &ctx) != 0)
+ return NULL;
+ const char *to = arg_str(r->args, "to");
+ if (!to || !*to)
+ to = ctx.email;
+ if (!to || !*to)
+ return fail(r, "INVALID_ARGS", "employee has no e-mail address");
+
+ unsigned char *pdf = NULL;
+ size_t pdf_len = 0;
+ if (payslip_render(&ctx.d, &pdf, &pdf_len) != 0)
+ return fail(r, "INTERNAL", "could not render the payslip");
+
+ char errbuf[512];
+ errbuf[0] = '\0';
+ if (mail_config_check(r->db, r->org_id, errbuf, sizeof errbuf) != 0) {
+ free(pdf);
+ return fail(r, "SMTP_NOT_CONFIGURED",
+ errbuf[0] ? errbuf : "smtp is not configured");
+ }
+ if (r->dry_run) {
+ free(pdf);
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sent_to", to);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", ctx.filename);
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+ }
+
+ int64_t attachment_id = 0;
+ if (payroll_store_payslip(r, &ctx, pdf, pdf_len, &attachment_id) != 0) {
+ free(pdf);
+ return NULL;
+ }
+
+ char subject[64];
+ snprintf(subject, sizeof subject, "Lönebesked %s", ctx.period);
+ char amount[48];
+ payroll_fmt_amount(ctx.d.net_ore, amount, sizeof amount);
+ size_t body_len = strlen(ctx.employee_name) + strlen(ctx.period) +
+ strlen(amount) + strlen(ctx.pay_date) +
+ strlen(ctx.employer_name) + 160;
+ char *body = xmalloc(body_len);
+ snprintf(body, body_len,
+ "Hej %s,\n\nBifogat finner du lönebesked för %s."
+ " Nettolön: %s kr.\nUtbetalningsdatum: %s.\n\n"
+ "Med vänlig hälsning\n%s\n",
+ ctx.employee_name, ctx.period, amount, ctx.pay_date,
+ ctx.employer_name);
+
+ struct mail_message m;
+ memset(&m, 0, sizeof m);
+ m.to = to;
+ m.subject = subject;
+ m.body = body;
+ m.attach_name = ctx.filename;
+ m.attach = pdf;
+ m.attach_len = pdf_len;
+ errbuf[0] = '\0';
+ int mailed = mail_send_pdf(r->db, r->org_id, &m, errbuf, sizeof errbuf);
+ free(body);
+ free(pdf);
+ if (mailed == -1)
+ return fail(r, "SMTP_NOT_CONFIGURED",
+ errbuf[0] ? errbuf : "smtp is not configured");
+ if (mailed != 0)
+ return fail(r, "SMTP_FAILED",
+ errbuf[0] ? errbuf : "smtp send failed");
+
+ char ts[32];
+ util_iso8601(util_now(), ts, sizeof ts);
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "payroll.payslip_mail", reqjson, "OK", NULL);
+ free(reqjson);
+
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "sent_to", to);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "at", ts);
+ yyjson_mut_obj_add_int(r->rdoc, o, "attachment_id", attachment_id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", ctx.filename);
+ return o;
+}
+
+/* ------------------------------------------------------------------ */
/* AGI underlag */
/* ------------------------------------------------------------------ */
@@ -1206,6 +1633,20 @@ static const struct cmd_arg args_run_get[] = {
{ "id", ARG_INT, 1, NULL, NULL, "Payroll run id" },
};
+static const struct cmd_arg args_payslip[] = {
+ { "run_id", ARG_INT, 1, NULL, NULL, "Payroll run id" },
+ { "employee_id", ARG_INT, 0, NULL, NULL,
+ "Employee line; required when the run has more than one" },
+};
+
+static const struct cmd_arg args_payslip_mail[] = {
+ { "run_id", ARG_INT, 1, NULL, NULL, "Payroll run id" },
+ { "employee_id", ARG_INT, 0, NULL, NULL,
+ "Employee line; required when the run has more than one" },
+ { "to", ARG_STR, 0, NULL, NULL,
+ "Recipient; defaults to the employee's e-mail address" },
+};
+
static const struct cmd_arg args_agi[] = {
{ "period", ARG_STR, 1, NULL, NULL, "Salary period YYYY-MM" },
};
@@ -1239,6 +1680,10 @@ const struct command g_cmd_payroll[] = {
0, h_payroll_run_list, CMD_ARGS(args_run_list) },
{ "payroll.run_get", "Get a payroll run with its lines", PERM_READ, 1, 0,
0, h_payroll_run_get, CMD_ARGS(args_run_get) },
+ { "payroll.payslip", "Render one employee's payslip PDF", PERM_READ, 1, 0,
+ 0, h_payroll_payslip, CMD_ARGS(args_payslip) },
+ { "payroll.payslip_mail", "E-mail an employee's payslip PDF", PERM_WRITE,
+ 1, 1, 1, h_payroll_payslip_mail, CMD_ARGS(args_payslip_mail) },
{ "payroll.agi", "AGI underlag per employee (owner; personnummer in"
" clear)", PERM_OWNER, 1, 0, 0, h_payroll_agi, CMD_ARGS(args_agi) },
{ "payroll.pay_tax", "Pay the run's tax and contributions", PERM_WRITE,