diff options
Diffstat (limited to 'src/cmd_payroll.c')
| -rw-r--r-- | src/cmd_payroll.c | 445 |
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, |
