summaryrefslogtreecommitdiff
path: root/src/cmd_invoices.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd_invoices.c')
-rw-r--r--src/cmd_invoices.c172
1 files changed, 165 insertions, 7 deletions
diff --git a/src/cmd_invoices.c b/src/cmd_invoices.c
index 73273dc..2a6a9c6 100644
--- a/src/cmd_invoices.c
+++ b/src/cmd_invoices.c
@@ -11,6 +11,7 @@
#include "db.h"
#include "invoice.h"
#include "ledger.h"
+#include "mail.h"
#include "pdf.h"
#include "secret.h"
#include "smtp.h"
@@ -139,6 +140,7 @@ struct draft_line {
const char *note;
const char *vat_code;
char account[16];
+ int is_text;
};
struct invoice_draft {
@@ -211,6 +213,21 @@ static int draft_line_parse(struct req *r, yyjson_val *item, size_t no,
failf(r, "INVALID_ARGS", "row %zu: description is required", no);
return -1;
}
+ int is_text = 0;
+ arg_bool(item, "text", &is_text);
+ if (is_text) {
+ l->article_no = NULL;
+ l->description = description;
+ l->quantity_milli = 1;
+ l->unit = "";
+ l->unit_price_ore = 0;
+ l->amount_ore = 0;
+ l->note = "";
+ l->vat_code = "0";
+ l->is_text = 1;
+ snprintf(l->account, sizeof l->account, "%s", default_account);
+ return 0;
+ }
const char *qty = arg_str(item, "quantity");
int64_t quantity_milli = 0;
if (parse_quantity(qty, &quantity_milli) != 0) {
@@ -460,6 +477,7 @@ static int invoice_view_fill(struct req *r, const struct invoice_draft *d,
v->lines[i].amount_ore = d->lines[i].amount_ore;
v->lines[i].note = d->lines[i].note;
v->lines[i].vat_code = d->lines[i].vat_code;
+ v->lines[i].is_text = d->lines[i].is_text;
}
v->doc.seller.name = v->seller_name;
@@ -670,8 +688,8 @@ static int invoice_store_invoice(struct req *r, const struct invoice_draft *d,
r->db,
"INSERT INTO invoice_rows(org_id,invoice_id,line_no,article_no,"
"description,quantity_milli,unit,unit_price_ore,amount_ore,note,"
- "vat_code,account)"
- " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
+ "vat_code,account,is_text)"
+ " VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)",
-1, &st, NULL) != SQLITE_OK) {
db_error(r);
return -1;
@@ -689,6 +707,7 @@ static int invoice_store_invoice(struct req *r, const struct invoice_draft *d,
sqlite3_bind_text(st, 10, l->note ? l->note : "", -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 11, l->vat_code, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(st, 12, l->account, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int(st, 13, l->is_text);
rc = sqlite3_step(st);
sqlite3_finalize(st);
if (rc != SQLITE_DONE) {
@@ -892,12 +911,12 @@ static yyjson_mut_val *invoice_row_json(struct req *r, sqlite3_stmt *st)
return db_row_json(r->rdoc, st,
"line_no:i,article_no:s,description:s,"
"quantity_milli:i,unit:s,unit_price_ore:i,"
- "amount_ore:i,note:s,vat_code:s,account:s");
+ "amount_ore:i,note:s,vat_code:s,account:s,is_text:b");
}
#define INVOICE_ROW_COLUMNS \
"line_no,article_no,description,quantity_milli,unit,unit_price_ore," \
- "amount_ore,note,vat_code,account"
+ "amount_ore,note,vat_code,account,is_text"
static yyjson_mut_val *h_invoice_get(struct req *r)
{
@@ -910,9 +929,13 @@ static yyjson_mut_val *h_invoice_get(struct req *r)
"SELECT i.id,i.customer_id,c.name,i.number,i.ocr,i.invoice_date,"
"i.due_date,i.delivery_date,i.your_ref,i.our_ref,i.notes,i.net_ore,"
"i.vat_ore,i.total_ore,i.status,i.document_id,i.voucher_id,"
- "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by"
+ "i.last_sent_at,i.last_sent_to,i.created_at,i.created_by,"
+ "i.paid_date,i.payment_voucher_id,COALESCE(pv.series,''),"
+ "COALESCE(pv.number,0)"
" FROM invoices i JOIN customers c"
" ON c.org_id=i.org_id AND c.id=i.customer_id"
+ " LEFT JOIN vouchers pv"
+ " ON pv.org_id=i.org_id AND pv.id=i.payment_voucher_id"
" WHERE i.org_id=?1 AND i.id=?2",
-1, &st, NULL) != SQLITE_OK)
return db_error(r);
@@ -975,6 +998,17 @@ static yyjson_mut_val *h_invoice_get(struct req *r)
sq(sqlite3_column_text(st, 19)));
yyjson_mut_obj_add_int(r->rdoc, o, "created_by",
sqlite3_column_int64(st, 20));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date",
+ sq(sqlite3_column_text(st, 21)));
+ if (sqlite3_column_type(st, 22) == SQLITE_NULL)
+ yyjson_mut_obj_add_null(r->rdoc, o, "payment_voucher_id");
+ else
+ yyjson_mut_obj_add_int(r->rdoc, o, "payment_voucher_id",
+ sqlite3_column_int64(st, 22));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_voucher_series",
+ sq(sqlite3_column_text(st, 23)));
+ yyjson_mut_obj_add_int(r->rdoc, o, "paid_voucher_number",
+ sqlite3_column_int64(st, 24));
sqlite3_finalize(st);
yyjson_mut_val *rows = yyjson_mut_arr(r->rdoc);
@@ -1010,7 +1044,7 @@ static yyjson_mut_val *h_invoice_list(struct req *r)
r->db,
"SELECT i.id,i.number,i.ocr,i.customer_id,c.name,i.invoice_date,"
"i.due_date,i.total_ore,i.status,i.document_id,i.voucher_id,"
- "i.last_sent_at,i.last_sent_to"
+ "i.last_sent_at,i.last_sent_to,i.paid_date"
" FROM invoices i JOIN customers c"
" ON c.org_id=i.org_id AND c.id=i.customer_id"
" WHERE i.org_id=?1"
@@ -1063,6 +1097,8 @@ static yyjson_mut_val *h_invoice_list(struct req *r)
else
yyjson_mut_obj_add_strcpy(r->rdoc, o, "last_sent_to",
sq(sqlite3_column_text(st, 12)));
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date",
+ sq(sqlite3_column_text(st, 13)));
}
sqlite3_finalize(st);
yyjson_mut_val *out = yyjson_mut_obj(r->rdoc);
@@ -1215,6 +1251,10 @@ static yyjson_mut_val *h_invoice_send(struct req *r)
fail(r, "SMTP_NOT_CONFIGURED", "smtp_host and smtp_from must be set");
goto done;
}
+ if (!mail_addr_valid(smtp_from)) {
+ fail(r, "SMTP_NOT_CONFIGURED", "smtp_from must be an email address");
+ goto done;
+ }
const char *user = smtp_user && *smtp_user ? smtp_user : "";
if (*user) {
if (!smtp_password || !*smtp_password) {
@@ -1354,6 +1394,122 @@ static const struct cmd_arg args_invoice_sequence_set[] = {
{ "next_number", ARG_INT, 1, NULL, NULL, "Next invoice number" },
};
+static const struct cmd_arg args_invoice_pay[] = {
+ { "id", ARG_INT, 1, NULL, NULL, "Invoice id" },
+ { "voucher_id", ARG_INT, 1, NULL, NULL, "Payment voucher id" },
+};
+
+/* Marks an invoice paid and links the voucher that settles it. The voucher
+ must credit the invoice receivable account with the invoice total. */
+static yyjson_mut_val *h_invoice_pay(struct req *r)
+{
+ int64_t id = 0, voucher_id = 0;
+ if (!arg_int(r->args, "id", &id) || id <= 0)
+ return fail(r, "INVALID_ARGS", "id is required");
+ if (!arg_int(r->args, "voucher_id", &voucher_id) || voucher_id <= 0)
+ return fail(r, "INVALID_ARGS", "voucher_id is required");
+
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT number,total_ore,status,paid_date FROM invoices"
+ " WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return db_error(r);
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "invoice not found");
+ }
+ int64_t number = sqlite3_column_int64(st, 0);
+ int64_t total = sqlite3_column_int64(st, 1);
+ char status[16], paid[16];
+ snprintf(status, sizeof status, "%s", sq(sqlite3_column_text(st, 2)));
+ snprintf(paid, sizeof paid, "%s", sq(sqlite3_column_text(st, 3)));
+ sqlite3_finalize(st);
+ if (*paid)
+ return fail(r, "CONFLICT", "invoice is already paid");
+ if (strcmp(status, "credited") == 0)
+ return fail(r, "INVALID_ARGS", "a credited invoice cannot be paid");
+
+ char receivable[16];
+ db_setting_copy(r->db, r->org_id, "invoice_receivable_account", "1510",
+ receivable, sizeof receivable);
+
+ char date[16], series[16] = "";
+ int64_t vnumber = 0;
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT date,series,number FROM vouchers WHERE org_id=?1 AND id=?2",
+ -1, &st, NULL) != SQLITE_OK)
+ return db_error(r);
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, voucher_id);
+ if (sqlite3_step(st) != SQLITE_ROW) {
+ sqlite3_finalize(st);
+ return fail(r, "NOT_FOUND", "voucher not found");
+ }
+ snprintf(date, sizeof date, "%s", sq(sqlite3_column_text(st, 0)));
+ snprintf(series, sizeof series, "%s", sq(sqlite3_column_text(st, 1)));
+ vnumber = sqlite3_column_int64(st, 2);
+ sqlite3_finalize(st);
+
+ if (sqlite3_prepare_v2(
+ r->db,
+ "SELECT COALESCE(SUM(r.credit_ore),0) FROM voucher_rows r"
+ " JOIN accounts a ON a.org_id=r.org_id AND a.id=r.account_id"
+ " WHERE r.org_id=?1 AND r.voucher_id=?2 AND a.number=?3",
+ -1, &st, NULL) != SQLITE_OK)
+ return db_error(r);
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, voucher_id);
+ sqlite3_bind_text(st, 3, receivable, -1, SQLITE_TRANSIENT);
+ int64_t credited = 0;
+ if (sqlite3_step(st) == SQLITE_ROW)
+ credited = sqlite3_column_int64(st, 0);
+ sqlite3_finalize(st);
+ if (credited != total)
+ return failf(r, "INVALID_ARGS",
+ "the payment voucher must credit %s with the invoice"
+ " total (%lld), not (%lld)",
+ receivable, (long long)total, (long long)credited);
+
+ if (!r->dry_run) {
+ if (sqlite3_prepare_v2(
+ r->db,
+ "UPDATE invoices SET paid_date=?3,payment_voucher_id=?4"
+ " WHERE org_id=?1 AND id=?2 AND paid_date=''",
+ -1, &st, NULL) != SQLITE_OK)
+ return db_error(r);
+ sqlite3_bind_int64(st, 1, r->org_id);
+ sqlite3_bind_int64(st, 2, id);
+ sqlite3_bind_text(st, 3, date, -1, SQLITE_TRANSIENT);
+ sqlite3_bind_int64(st, 4, voucher_id);
+ int rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (rc != SQLITE_DONE)
+ return db_sqlite_error(r);
+ if (sqlite3_changes(r->db) == 0)
+ return fail(r, "CONFLICT", "invoice is already paid");
+ char *reqjson = audit_args_json(r->args);
+ audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id,
+ "invoice.pay", reqjson, "OK", NULL);
+ free(reqjson);
+ }
+
+ yyjson_mut_val *o = yyjson_mut_obj(r->rdoc);
+ yyjson_mut_obj_add_int(r->rdoc, o, "id", id);
+ yyjson_mut_obj_add_int(r->rdoc, o, "number", number);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "paid_date", date);
+ yyjson_mut_obj_add_int(r->rdoc, o, "payment_voucher_id", voucher_id);
+ yyjson_mut_obj_add_strcpy(r->rdoc, o, "voucher_series", series);
+ yyjson_mut_obj_add_int(r->rdoc, o, "voucher_number", vnumber);
+ if (r->dry_run)
+ yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true);
+ return o;
+}
+
static const struct cmd_arg args_invoice_draft[] = {
{ "customer_id", ARG_INT, 1, NULL, NULL, "Customer id" },
{ "invoice_date", ARG_DATE, 1, NULL, NULL, "Invoice date (YYYY-MM-DD)" },
@@ -1364,7 +1520,7 @@ static const struct cmd_arg args_invoice_draft[] = {
{ "notes", ARG_STR, 0, NULL, NULL, "Free-text notes" },
{ "rows", ARG_JSON, 1, NULL, NULL,
"Array of {article_no,description,quantity,unit,unit_price_ore,note,"
- "vat_code,account}" },
+ "vat_code,account,text}; text rows carry only the description" },
};
static const struct cmd_arg args_invoice_get[] = {
@@ -1401,6 +1557,8 @@ const struct command g_cmd_invoices[] = {
h_invoice_pdf, CMD_ARGS(args_invoice_get) },
{ "invoice.send", "E-mail the stored invoice PDF to the customer",
PERM_WRITE, 1, 1, 1, h_invoice_send, CMD_ARGS(args_invoice_send) },
+ { "invoice.pay", "Mark an invoice paid with a payment voucher",
+ PERM_WRITE, 1, 1, 1, h_invoice_pay, CMD_ARGS(args_invoice_pay) },
};
const struct cmd_table g_cmd_table_invoices = {