diff --git a/mail_templates/beitrag.eml b/mail_templates/beitrag.eml new file mode 100644 index 0000000..f853b8a --- /dev/null +++ b/mail_templates/beitrag.eml @@ -0,0 +1,31 @@ +Hallo {{ invoice.party.name }}, + +für deine ordentliche Mitgliedschaft im Chaos Computer Club Bremen e.V. ist ein Mitgliedsbeitrag fällig. Für das Beitragsjahr {{ year.year }} beträgt der monatliche Beitrag {{ monthly_fee }} €. Dieser Beitrag ist jeweils zu Beginn des Monats fällig. + +Wir berechnen dir daher die folgenden Mitgliedsbeiträge: +{% for line in invoice.lines %} +{{ line.description.ljust(50) }} {{ "{: >.2f}".format(line.unit_price) }} € +{%- endfor %} + +{% if sepa_mandate -%} +Wir werden deine Mitgliedsbeiträge per SEPA-Lastschrift von deinem Konto mit der IBAN {{ sepa_mandate.account_number.number }} zu den folgenden Terminen einziehen: + +{% for payment in invoice.lines_to_pay if not payment.reconiciliation and payment.payable_receivable_balance %} +{{ payment.maturity_date }} {{ "{: >.2f}".format(payment.amount) }} € +{%- endfor %} +{% else -%} +Bitte überweise deine Mitgliedsbeiträge fristgerecht auf unser Konto: + +Kontoinhaber: Chaos Computer Club Bremen e.V. +IBAN: DE28 4306 0967 2027 5271 00 +Bank: GLS Gemeinschaftsbank eG +BIC: GENODEM1GLS +Verwendungszweck: Beitrag {{ invoice.party.name }} + +Du kannst den Beitrag für das gesamte Jahr {{ year.year }} im Voraus überweisen. +{%- endif %} + +Viele Grüße, +der Vorstand + +i.A. {{ sender }} diff --git a/mail_templates/sepa_mandat.eml b/mail_templates/sepa_mandat.eml new file mode 100644 index 0000000..3ed1b80 --- /dev/null +++ b/mail_templates/sepa_mandat.eml @@ -0,0 +1,21 @@ +Hallo {{ party.name }}, + +anbei erhälst du eine Abschrift des SEPA-Lastschriftmandats mit der Mandatsreferenz „{{ sepa_mandate.identification }}“, das zum Einzug deiner Mitgliedsbeiträge bei uns gespeichert ist. + +Name des Zahlungsempfängers: + Chaos Computer Club Bremen e.V. +Anschrift des Zahlungsempfängers: + Konsul-Smidt-Straße 43, 28217 Bremen, Deutschland +Gläubiger-Identifikationsnummer: + DE20 CCC0 0000 9634 98 + +Kontoinhaber*in: {{ sepa_mandate.party.name }} +IBAN der Kontoinhaber*in: {{ sepa_mandate.account_number.number }} +Mandatsreferenz: {{ sepa_mandate.identification }} +Zahlungsart: Wiederkehrende Zahlung +Datum: {{ sepa_mandate.signature_date }} + +Sollten Angaben in diesem Mandat fehlerhaft oder veraltet sein, so teil uns dies bitte per E-Mail an vorstand@ccchb.de mit. + +Viele Grüße, +der Vorstand des Chaos Computer Club Bremen e.V. diff --git a/sepa-mandate/reify.py b/sepa-mandate/reify.py deleted file mode 100644 index 3259a30..0000000 --- a/sepa-mandate/reify.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/env python3 - -import csv -import subprocess -import email.message -import email.utils -import datetime -import argparse -import pathlib -import tempfile -import shutil - -DEFAULT_CSV_FIELDLIST = ("creditor_name", "creditor_iban", "mandate", "recurrence", "date", "email") - -def create_tex(mandate): - return f''' -\csdef{{sepa-m001}}{{{mandate['mandate']}}} -\csdef{{sepa-m006}}{{{mandate['recurrence']}}} -\csdef{{sepa-p001}}{{{mandate['creditor_name']}}} -\csdef{{sepa-d001}}{{{mandate['creditor_iban']}}} -\csdef{{sepa-m008}}{{{mandate['date']}}} -\csdef{{sepa-m009}}{{\emph{{entfällt}}}} -\csdef{{sepa-d002a}}{{}} -\csdef{{sepa-d002b}}{{}} -\csdef{{sepa-p005}}{{}} -\csdef{{sepa-remarks}}{{Dieses Mandat ist eine Abschrift des dem Zahlungsempfänger vorliegenden, originalen SEPA-Lastschriftmandats.\\\\ -Erstellt am {datetime.date.today()}}} -''' - -EMAIL_MESSAGE = """Liebes Mitglied, - -anbei erhälst du eine Abschrift des SEPA-Lastschriftmandats mit der Mandatsreferenz "{mandate[mandate]}", das zum Einzug deiner Mitgliedsbeiträge bei uns gespeichert ist. - -Sollten Angaben in diesem Mandat fehlerhaft oder veraltet sein, so teil uns dies bitte per E-Mail mit an vorstand@ccchb.de. - - -Viele Grüße, -Vorstand des Chaos Computer Club Bremen e.V. -""" - -def create_email(mandate, pdfmandate): - msg = email.message.EmailMessage() - msg["To"] = f"{mandate['creditor_name']} <{mandate['email']}>" - msg["From"] = "Chaos Computer Club Bremen e.V. " - msg["Subject"] = f"Dein SEPA-Lastschriftmandat (MREF: {mandate['mandate']})" - msg["Message-Id"] = email.utils.make_msgid(domain="verein.ccchb.de") - msg["Date"] = datetime.datetime.now() - msg.set_content(EMAIL_MESSAGE.format(mandate=mandate)) - msg.add_attachment(pdfmandate, 'application', 'pdf', filename='Lastschriftmandat.pdf') - return msg - -def main_reify_mandate(mandate, output_dir_path, tex_template_path, tempdir_path, debug): - tex_jobname = str(tempdir_path.joinpath(mandate['mandate'])) - settings_file_path = tempdir_path.joinpath(f'{mandate['mandate']}-settings.tex') - with open(settings_file_path, "w") as texfile: - texfile.write(create_tex(mandate)) - latexmk_args = ["latexmk", "-lualatex", str(tex_template_path), f"-jobname={tex_jobname}"] - if not debug: - latexmk_args.append("-silent") - subprocess.run(latexmk_args) - pdf_file_path = tempdir_path.joinpath(f'{mandate['mandate']}.pdf') - pdf_output_path = output_dir_path.joinpath(f'{mandate['mandate']}.pdf') - shutil.copy(pdf_file_path, pdf_output_path) - if mandate['email']: - mail_output_path = output_dir_path.joinpath(f'{mandate['mandate']}.eml') - with open(pdf_file_path, "rb") as pdffile: - msg = create_email(mandate, pdffile.read()) - with open(mail_output_path, "wb") as mailfile: - mailfile.write(bytes(msg)) - - -def main(): - argparser = argparse.ArgumentParser() - argparser.add_argument("--csv-skip", default=1) - argparser.add_argument("--csv-fields", type=lambda s: s.split(","), default=DEFAULT_CSV_FIELDLIST) - argparser.add_argument("-O", "--output-dir", default="output/") - argparser.add_argument("--build-dir", default="build") - argparser.add_argument("--debug", default=False, action="store_true") - argparser.add_argument("tex_template") - argparser.add_argument("mandate_file") - args = argparser.parse_args() - - output_dir_path = pathlib.Path(args.output_dir) - # Ensure that the output directory exists - output_dir_path.mkdir(parents=True, exist_ok=True) - tex_template_path = pathlib.Path(args.tex_template) - build_dir_path = pathlib.Path(args.build_dir) - # Ensure that the build directory exists - build_dir_path.mkdir(parents=True, exist_ok=True) - - with open(args.mandate_file) as csvfile: - reader = csv.DictReader(csvfile, fieldnames=args.csv_fields) - for mandate in reader: - if not mandate.get('mandate'): - continue - main_reify_mandate(mandate, output_dir_path, tex_template_path, build_dir_path, args.debug) - -if __name__ == '__main__': - main() diff --git a/triad/triad/client.py b/triad/triad/client.py index 026b9ed..264b01e 100644 --- a/triad/triad/client.py +++ b/triad/triad/client.py @@ -71,6 +71,18 @@ class InvoicingClient(BaseClient): self.InvoiceLine = self.get_model('account.invoice.line', cls_name='InvoiceLine') self.PaymentTerm = self.get_model('account.invoice.payment_term', cls_name='PaymentTerm') self.PaymentTermLine = self.get_model('account.invoice.payment_term.line', cls_name='PaymentTermLine') + + def get_invoice_by_number(self, number): + try: + return self.Invoice.find([('number', '=', str(number))])[0] + except IndexError: + raise KeyError(number) + + def get_invoice_by_id(self, _id): + try: + return self.Invoice.find([('id', '=', str(_id))])[0] + except IndexError: + raise KeyError(_id) class PartyClient(BaseClient): def __init__(self, *args, **kwargs): diff --git a/tryton-scripts/beitragsrechnungen.py b/tryton-scripts/beitragsrechnungen.py index 045b464..a506353 100644 --- a/tryton-scripts/beitragsrechnungen.py +++ b/tryton-scripts/beitragsrechnungen.py @@ -17,8 +17,11 @@ argparser.add_argument('--reference-date', help='Referenzdatum für Zahlungsbedi argparser.add_argument('--claims-account', help='Forderungskonto', default='12001') argparser.add_argument('--fee-account', help='Beitragskonto', default='40000') argparser.add_argument('--default-payment-term', default='1m') -argparser.add_argument('--csv-format', type=lambda x: x.split(",") if isinstance(x, str) else list(x), default='party_id,monthly_fee,payment_term,yearly_fee', help='CSV format specification') +argparser.add_argument("-F", "--csv-fields", type=lambda f: f.split(","), help="csv fields") argparser.add_argument('-n', '--dry-run', action='store_true') +argparser.add_argument("-o", "--output", help="output file", type=argparse.FileType("w"), default="-") +argparser.add_argument("--update", help="update invoices", action="store_true") +argparser.add_argument("--months", help="Beitragsmonate", type=lambda m: m.split(","), default="1,2,3,4,5,6,7,8,9,10,11,12") argparser.add_argument('year', help='Beitragsjahr', type=lambda s: datetime.date(int(s), 1, 1)) argparser.add_argument('csvfile', nargs='?', type=argparse.FileType('r'), default='-') @@ -40,7 +43,11 @@ def calculate_reference_date(date): if not args.reference_date: args.reference_date = calculate_reference_date(args.year) -csvreader = csv.DictReader(args.csvfile, fieldnames=args.csv_format) +args.months = [datetime.date(args.year.year, int(m), 1) for m in args.months] + +csvreader = csv.DictReader(args.csvfile, fieldnames=args.csv_fields) +csvwriter = csv.DictWriter(args.output, fieldnames=csvreader.fieldnames + ["invoice_id"]) +csvwriter.writeheader() PAYMENT_TERMS_MAPPING = [ ({'1m'}, 'Beitrag monatlich/jährlich'), @@ -57,21 +64,28 @@ def load_payment_terms(session, invoicing_client): return ret -def create_invoice(party, payment_term, monthly_fee, yearly_fee, claims_account, fee_account, invoicing_client, reference_date, year): - invoice = invoicing_client.Invoice() +def create_invoice(party, payment_term, monthly_fee, months, yearly_fee, claims_account, fee_account, invoicing_client, reference_date, year, invoice_id, update): + if update: + invoice = invoicing_client.Invoice(int(invoice_id)) + else: + invoice = invoicing_client.Invoice() invoice.type = 'out' invoice.party = party invoice.description = f"Beiträge {year.year}" invoice.account = claims_account invoice.payment_term = payment_term invoice.payment_term_date = reference_date + if update: + for line in invoice.lines: + invoice.lines.remove(line) if monthly_fee: - invoice_line = invoicing_client.InvoiceLine() - invoice_line.description = f"Monatsbeiträge Januar--Dezember {year.year}" - invoice_line.account = fee_account - invoice_line.quantity = decimal.Decimal(12) # Months - invoice_line.unit_price = decimal.Decimal(monthly_fee) - invoice.lines.append(invoice_line) + for month in months: + invoice_line = invoicing_client.InvoiceLine() + invoice_line.description = f"Beitrag {month.year}-{month.month}" + invoice_line.account = fee_account + invoice_line.quantity = decimal.Decimal(1) + invoice_line.unit_price = decimal.Decimal(monthly_fee) + invoice.lines.append(invoice_line) if yearly_fee: invoice_line = invoicing_client.InvoiceLine() invoice_line.description = f"Jahresbeitrag {year.year}" @@ -118,11 +132,12 @@ with triad.client.Session.start(args.url, args.username, args.password) as sessi yearly_fee = None else: print(f'\tYearly fee: {yearly_fee}', file=sys.stderr) - invoice = create_invoice(party, payment_term, monthly_fee, yearly_fee, claims_account, fee_account, invoicing_client, args.reference_date, args.year) + invoice = create_invoice(party, payment_term, monthly_fee, args.months, yearly_fee, claims_account, fee_account, invoicing_client, args.reference_date, args.year, member.get('invoice_id'), args.update) if not args.dry_run: invoice.save() print(f'\tInvoice: {invoice.id} {invoice.description}', file=sys.stderr) for line in invoice.lines: - print(f'\tLine: {line.description} {line.quantity} * {line.unit_price}', file=sys.stderr) + print(f'\t\tLine: {line.description} {line.unit_price}', file=sys.stderr) print(f'\tYearly fee: {invoice.total_amount}', file=sys.stderr) - print(f"{member['party_id']},{invoice.id},{invoice.total_amount},{payment_term.name}") + member['invoice_id'] = invoice.id + csvwriter.writerow(member) diff --git a/tryton-scripts/mailings.py b/tryton-scripts/mailings.py new file mode 100644 index 0000000..573cfbb --- /dev/null +++ b/tryton-scripts/mailings.py @@ -0,0 +1,179 @@ +#!/bin/env python3 + +import argparse +import csv +import datetime +import decimal +import email.message +import getpass +import pathlib +import subprocess +import tempfile + +import jinja2 + +import triad.client + +class Env(object): + jinja2_env: jinja2.Environment + args: argparse.Namespace + session: triad.client.Session + +def fee_invoice(env: Env): + csvreader = csv.DictReader(env.args.csvfile, fieldnames=env.args.csv_fields) + csvwriter = csv.DictWriter(env.args.output, fieldnames=csvreader.fieldnames + ["message_id"]) + csvwriter.writeheader() + invoicing_client = triad.client.InvoicingClient.from_session(env.session) + template = env.jinja2_env.get_template("beitrag.eml") + for member in csvreader: + invoice = invoicing_client.get_invoice_by_id(member['invoice_id']) + year = invoice.invoice_date or env.args.year + months = [datetime.date(year.year, month, 1) for month in range(1, 13)] + email_address = member.get('email') or invoice.party.email + try: + sepa_mandate = invoice.party.reception_direct_debits[0] + except IndexError: + sepa_mandate = None + else: + sepa_mandate = sepa_mandate.sepa_mandate + template_args = { + 'monthly_fee': decimal.Decimal(member.get('monthly_fee') or '0.0'), + "yearly_fee": decimal.Decimal(member.get('yearly_fee') or '0.0'), + 'invoice': invoice, + 'sepa_mandate': sepa_mandate, + 'payments': [], + 'sender': env.args.email_sender, + "year": year, + "fee_months": [datetime.date(year.year, month, 1) for month in range(1, 13)] + } + msg = email.message.EmailMessage() + msg["From"] = env.args.email_from + msg["To"] = email_address + msg["Date"] = env.args.email_date or datetime.datetime.now() + msg["Subject"] = f"Beitragsrechnung {year.year} ({invoice.number})" + msg["Message-Id"] = email.utils.make_msgid(domain="verein.ccchb.de") + member['message_id'] = msg["Message-Id"] + msg.set_content(template.render(**template_args)) + with (env.args.output_dir / member['message_id']).open("wb") as mailfile: + mailfile.write(bytes(msg)) + csvwriter.writerow(member) + +def sepa_mandate(env: Env): + csvreader = csv.DictReader(env.args.csvfile, fieldnames=env.args.csv_fields) + csvwriter = csv.DictWriter(env.args.output, fieldnames=csvreader.fieldnames + ["message_id"]) + csvwriter.writeheader() + client = triad.client.BaseClient.from_session(env.session) + template = env.jinja2_env.get_template("sepa_mandat.eml") + for mandate in csvreader: + email_address= mandate.get('email') + if 'sepa_mandate_ref' in mandate: + sepa_mandate = client.get_model('account.payment.sepa.mandate').find([('identification', '=', mandate['sepa_mandate_ref'])]) + party = sepa_mandate.party + elif 'party_id' in mandate: + party = client.get_model('party.party').find([('code', '=', mandate['party_id'])])[0] + email_address = email_address or party.email + sepa_mandate = party.reception_direct_debits[0].sepa_mandate + else: + continue + template_args = { + "party": party, + "sepa_mandate": sepa_mandate + } + msg = email.message.EmailMessage() + msg["From"] = env.args.email_from + msg["To"] = email_address + msg["Date"] = env.args.email_date or datetime.datetime.now() + msg["Subject"] = f"SEPA-Lastschriftmandat MREF: {sepa_mandate.identification}" + msg["Message-Id"] = email.utils.make_msgid(domain="verein.ccchb.de") + mandate['message_id'] = msg["Message-Id"] + msg.set_content(template.render(**template_args)) + if env.args.pdf: + msg.add_attachment(sepa_mandate_build_pdf(env, sepa_mandate), 'application', 'pdf', filename='SEPA-Lastschriftmandat.pdf') + with (env.args.output_dir / mandate['message_id']).open("wb") as mailfile: + mailfile.write(bytes(msg)) + csvwriter.writerow(mandate) + +def sepa_mandate_build_pdf(env, sepa_mandate): + with tempfile.TemporaryDirectory() as tempdir: + tempdir_path = pathlib.Path(tempdir) + jobname = sepa_mandate.identification + with (tempdir_path / (jobname + "-settings.tex")).open("w") as fh: + fh.write(f''' +\\csdef{{sepa-m001}}{{{sepa_mandate.identification}}} +\\csdef{{sepa-m006}}{{Wiederkehrende Zahlung}} +\\csdef{{sepa-p001}}{{{sepa_mandate.party.name}}} +\\csdef{{sepa-d001}}{{{sepa_mandate.account_number.number}}} +\\csdef{{sepa-m008}}{{{sepa_mandate.signature_date}}} +\\csdef{{sepa-m009}}{{\\emph{{entfällt}}}} +\\csdef{{sepa-d002a}}{{}} +\\csdef{{sepa-d002b}}{{}} +\\csdef{{sepa-p005}}{{}} +\\csdef{{sepa-remarks}}{{Dieses Mandat ist eine Abschrift des dem Zahlungsempfänger vorliegenden, originalen SEPA-Lastschriftmandats.\\\\ +Erstellt am {datetime.date.today()}}} +''') + compile_latex(jobname, env.args.latex_template, tempdir) + with (tempdir_path / (jobname + ".pdf")).open("rb") as fh: + return fh.read() + +def compile_latex(jobname, texfile, directory, debug=True): + latexmk_args = ["latexmk", "-lualatex", str(texfile), f"-jobname={jobname}"] + if not debug: + latexmk_args.append("-silent") + subprocess.run(latexmk_args, cwd=directory) + +def main(): + parser = argparse.ArgumentParser() + + parser.add_argument("-U", "--uri", help="Tryton URI") + parser.add_argument("-u", "--username", help="Tryton username") + parser.add_argument("-p", "--password", help="Tryton password") + + parser.add_argument("-O", "--output-dir", help="email output directory", default=".", type=pathlib.Path) + parser.add_argument("-T", "--template-dir", help="email template directory", default="mail_templates") + + # Email arguments + parser.add_argument('--email-sender', help='email signature sender') + parser.add_argument("--email-from", help="email from header") + parser.add_argument("--email-date", help="email date header") + + subparsers = parser.add_subparsers() + + fee_invoice_parser = subparsers.add_parser("fee-invoice") + fee_invoice_parser.add_argument("-o", "--output", type=argparse.FileType("w"), default='-') + fee_invoice_parser.add_argument("-F", "--csv-fields", type=lambda f: f.split(","), help="csv fields") + fee_invoice_parser.add_argument("-y", "--year", type=lambda y: datetime.date(int(y), 1, 1)) + fee_invoice_parser.add_argument("--months", help="Beitragsmonate", type=lambda m: m.split(","), default="1,2,3,4,5,6,7,8,9,10,11,12") + fee_invoice_parser.add_argument("csvfile", type=argparse.FileType("r"), default="-", nargs="?") + fee_invoice_parser.set_defaults(func=fee_invoice) + + sepa_mandate_parser = subparsers.add_parser('sepa-mandate') + sepa_mandate_parser.add_argument('-o', '--output', type=argparse.FileType("w"), default='-') + sepa_mandate_parser.add_argument('-F', '--csv-fields', type=lambda f: f.split(','), help='csv fields') + sepa_mandate_parser.add_argument('--pdf', action='store_true', help='build pdf attachment', default=False) + sepa_mandate_parser.add_argument('--no-pdf', action='store_false', dest='pdf', help='don\'t build pdf attachment') + sepa_mandate_parser.add_argument('--latex-template', help='latex template in $TEXINPUTS') + sepa_mandate_parser.add_argument('csvfile', type=argparse.FileType('r'), default='-', nargs='?') + sepa_mandate_parser.set_defaults(func=sepa_mandate) + + args = parser.parse_args() + + env = Env() + env.jinja2_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(args.template_dir) + ) + env.args = args + + if not args.uri: + args.uri = input("URI for Tryton: ") + + if not args.username: + args.username = input("Username for Tryton: ") + + if not args.password: + args.password = getpass.getpass(f"Password for Tryton user `{args.username}': ") + + with triad.client.Session.start(args.uri, args.username, args.password) as session: + env.session = session + args.func(env) + +main()