This commit is contained in:
Fritz Grimpen 2024-10-03 22:08:59 +00:00
parent d5bd37adfa
commit 99a7997818
6 changed files with 271 additions and 112 deletions

View file

@ -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 }}

View file

@ -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.

View file

@ -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. <vorstand@ccchb.de>"
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()

View file

@ -72,6 +72,18 @@ class InvoicingClient(BaseClient):
self.PaymentTerm = self.get_model('account.invoice.payment_term', cls_name='PaymentTerm') 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') 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): class PartyClient(BaseClient):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View file

@ -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('--claims-account', help='Forderungskonto', default='12001')
argparser.add_argument('--fee-account', help='Beitragskonto', default='40000') argparser.add_argument('--fee-account', help='Beitragskonto', default='40000')
argparser.add_argument('--default-payment-term', default='1m') 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('-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('year', help='Beitragsjahr', type=lambda s: datetime.date(int(s), 1, 1))
argparser.add_argument('csvfile', nargs='?', type=argparse.FileType('r'), default='-') argparser.add_argument('csvfile', nargs='?', type=argparse.FileType('r'), default='-')
@ -40,7 +43,11 @@ def calculate_reference_date(date):
if not args.reference_date: if not args.reference_date:
args.reference_date = calculate_reference_date(args.year) 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 = [ PAYMENT_TERMS_MAPPING = [
({'1m'}, 'Beitrag monatlich/jährlich'), ({'1m'}, 'Beitrag monatlich/jährlich'),
@ -57,7 +64,10 @@ def load_payment_terms(session, invoicing_client):
return ret return ret
def create_invoice(party, payment_term, monthly_fee, yearly_fee, claims_account, fee_account, invoicing_client, reference_date, year): 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 = invoicing_client.Invoice()
invoice.type = 'out' invoice.type = 'out'
invoice.party = party invoice.party = party
@ -65,11 +75,15 @@ def create_invoice(party, payment_term, monthly_fee, yearly_fee, claims_account,
invoice.account = claims_account invoice.account = claims_account
invoice.payment_term = payment_term invoice.payment_term = payment_term
invoice.payment_term_date = reference_date invoice.payment_term_date = reference_date
if update:
for line in invoice.lines:
invoice.lines.remove(line)
if monthly_fee: if monthly_fee:
for month in months:
invoice_line = invoicing_client.InvoiceLine() invoice_line = invoicing_client.InvoiceLine()
invoice_line.description = f"Monatsbeiträge Januar--Dezember {year.year}" invoice_line.description = f"Beitrag {month.year}-{month.month}"
invoice_line.account = fee_account invoice_line.account = fee_account
invoice_line.quantity = decimal.Decimal(12) # Months invoice_line.quantity = decimal.Decimal(1)
invoice_line.unit_price = decimal.Decimal(monthly_fee) invoice_line.unit_price = decimal.Decimal(monthly_fee)
invoice.lines.append(invoice_line) invoice.lines.append(invoice_line)
if yearly_fee: if yearly_fee:
@ -118,11 +132,12 @@ with triad.client.Session.start(args.url, args.username, args.password) as sessi
yearly_fee = None yearly_fee = None
else: else:
print(f'\tYearly fee: {yearly_fee}', file=sys.stderr) 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: if not args.dry_run:
invoice.save() invoice.save()
print(f'\tInvoice: {invoice.id} {invoice.description}', file=sys.stderr) print(f'\tInvoice: {invoice.id} {invoice.description}', file=sys.stderr)
for line in invoice.lines: 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'\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)

179
tryton-scripts/mailings.py Normal file
View file

@ -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()