119 Commits
Author SHA1 Message Date
Git-CI 56faf41737 ci: bump dev version to 0.2.0-dev0 [skip ci] 2026-09-05 00:35:09 +02:00
Git-CI 26a54647aa ci: release version 0.2.0 [skip ci] 2026-09-05 00:34:52 +02:00
Marcel Peterkau 7aa8a2c979 Merge pull request 'Release 0.2.0' (#27) from dev into main
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/27
2026-09-04 22:27:07 +00:00
Git-CI cd49c04816 ci: bump dev version to 0.1.0-dev20 [skip ci] 2026-09-05 00:25:43 +02:00
Marcel Peterkau 2409b4d123 Merge pull request 'Keep dunning a bounced debit out of the direct-debit track' (#26) from feature/dunning-after-failed-debit into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/26
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-09-04 22:24:05 +00:00
Marcel Peterkau 6a1f305202 Merge branch 'dev' into feature/dunning-after-failed-debit 2026-09-04 22:23:04 +00:00
Git-CI 2137ff7c84 ci: bump dev version to 0.1.0-dev19 [skip ci] 2026-09-05 00:22:07 +02:00
Marcel Peterkau 3e949566ac Merge branch 'dev' into feature/dunning-after-failed-debit 2026-09-04 22:19:51 +00:00
Marcel Peterkau 0abb93c021 Merge pull request 'Feature/data review mailing' (#25) from feature/data-review-mailing into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/25
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-09-04 22:19:04 +00:00
Marcel PeterkauandClaude Opus 5 f80b17c443 Keep dunning a bounced debit out of the direct-debit track
Once the direct debit bounced and the board sent the Rücklastschrift reminder,
the claim is expected as a transfer by the deadline that letter states. Three
places still treated it as a claim the mandate covers, and the housekeeper's was
the one the board kept running into: after the reminder's deadline lapsed, the
finding went back to "Lastschrift überfällig -- Einzug prüfen, eine postalische
Mahnung ist hier nicht vorgesehen", for a claim that had just been dunned.

The rule now asks whether the claim was dunned before treating it as one for the
direct debit. If it was, it continues in the ordinary dunning sequence: the
running deadline shows as the usual "Frist läuft noch" note, and once that has
passed the next dunning level comes due. The SEPA-specific pending-reminder
detour that used to cover the deadline window is gone with it -- the ordinary
path reports the same thing.

The SEPA run now skips a dunned claim as well, instead of quietly collecting the
money the letter asked the member to transfer (which can bounce a second time,
with a second fee). The skip is reported like the incomplete mandates are, so
nothing disappears from the run without saying why; the dialog's wording is no
longer specific to mandates.

And a dunning mail asks for a transfer even from a member with an active
mandate. The shipped template spells the bank details out, but the ready-made
{{payment.instructions}} paragraph, offered by the template editor for exactly
this mail, told them "wir ziehen den Betrag ein, du musst nichts weiter tun" --
in the letter demanding payment.

Reverting the sent reminder is what puts the claim back into the direct-debit
run; the read of "dunned" is a sent reminder, not a draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:16:26 +02:00
Marcel PeterkauandClaude Opus 5 31c1646571 Tell members which reference to put on a transfer
A data-review mail that lists the payment frequency but not how to pay leaves the
board matching anonymous transfers by hand. The mail now closes with the
reference to quote -- member number and full name, joined only where both exist
so a missing half cannot leave a dangling dash. Both are offered because either
identifies the payment on its own: a member who has forgotten their number falls
back on the name, and two members sharing a name are told apart by the number.

The note about the shortened IBAN moved out of the fixed template text into
{{data.iban_hint}}, filled only for members whose bank details are actually
listed -- it used to explain a masked IBAN to members who pay by transfer and see
no IBAN at all. It sits directly under the record as a parenthesised footnote,
which also keeps the dropped line from leaving a blank one behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:08:40 +02:00
Marcel PeterkauandClaude Opus 5 9e9bb7d668 Ask every member to check the data the club stores about them
The club has to keep its member data current, and until now that meant writing
to each member by hand. "Datenüberprüfung anfragen" in the members tab sends one
mail per member, each listing that member's own record: number, name, nickname,
birth date, contact data, address, status, member since, payment frequency, and
the bank details only for members who have any.

Two decisions the record itself forced:

A field with no value is printed as "(nicht hinterlegt)" rather than left out.
The point of the mail is to have gaps filled in, and a missing line is a gap
nobody sees.

The IBAN is masked down to its country code and last four digits. That is enough
to recognise the account, and it keeps a full account number out of a mail the
club sends to dozens of people at once.

Every member is listed as a recipient, with the live memberships that have an
address preselected -- a member who resigned at year's end may still need to
confirm their address, so the board can add them by hand. Members without an
address are skipped and reported instead of failing the run.

Delivery reuses the existing mail machinery: the configured delivery mode, one
SMTP/IMAP connection for the whole run, an archive copy in the member file, a
"data_review_email_sent" event, and the read-only guard before anything is
rendered. A member whose mail fails is reported as a warning and the run
continues -- one bad address must not stop a mailing to the whole club halfway
through. Because the run cannot be taken back, the recipient count is confirmed
once more before it starts.

Subject and text come from a new "Datenüberprüfung" template, editable like the
others, with {{data.sheet}} for the whole record and a {{#data}} block for a
layout of the board's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:05:38 +02:00
Marcel PeterkauandClaude Opus 5 0252a0c0e3 Wait for the options window before grabbing it
OptionsDialog called grab_set() straight after transient(), before the window
was on screen. Tk refuses a grab on a window that is not viewable yet, and this
dialog builds enough tabs that it regularly still is not -- the options UI tests
failed with "grab failed: window not viewable" once the mail-template tab grew
another entry.

The grab now waits for an idle callback, which is what the other dialogs in the
codebase already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:05:26 +02:00
Git-CI e4138e93b6 ci: bump dev version to 0.1.0-dev18 [skip ci] 2026-08-29 01:24:56 +02:00
Marcel Peterkau 071167b626 Merge pull request 'Keep CCMA usable when the member store is mounted read-only' (#24) from feature/read-only-store into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/24
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-28 23:23:14 +00:00
Marcel PeterkauandClaude Opus 5 e87f859101 Refuse the welcome mail on a read-only store before it renders anything
generate_and_send_welcome_mail() arrived with the mail templates, after the
read-only guards were added to the other services, and never got one. On a
read-only store it therefore rendered the mail, could hand it to the mail
server, and only failed when it tried to create the archive directory in the
member file -- surfacing a PermissionError instead of the ReadOnlyStoreError
every other write path reports.

The guard now sits at the top, next to the delivery-mode check, so nothing is
rendered, sent or written. The read-only test covers this path (and the SEPA
batch alongside it) and asserts that nothing at all was left behind: no export
file, no archive directory, no "sent" event.

Its member carries an e-mail address now -- without one the mail services bail
out for that reason, and the write the test exists for is never reached.

Note that the SEPA CSV/XML export keeps writing without a guard on purpose: it
writes to a path the board picks outside the store, which a read-only store has
no say over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:09:05 +02:00
Marcel Peterkau 6a841c9bdd Merge branch 'dev' into feature/read-only-store 2026-08-28 23:05:26 +00:00
Marcel PeterkauandClaude Opus 5 528c24ad42 Clear every read-only marker when the store becomes writable again
"Erneut prüfen" refreshed the banner but left the window title and the status bar
reading "NUR LESEN" -- both were built once and never updated -- while the banner
itself asked for a restart that the recheck exists to avoid.

Title, status bar and banner are refreshed from one place now, so none of them can
be left behind, and the main window owns the title instead of app.py setting it
once at startup. The banner points at "Erneut prüfen" and says outright that no
restart is needed. Since the startup pass was skipped, its task list is empty
rather than current, so a successful recheck offers the housekeeper run that
fills it.

UI tests cover both directions of the recheck; their Tk root moved into a shared
conftest fixture, because a second root in another module invalidates the icon
images bound to the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:01:37 +02:00
Marcel PeterkauandClaude Opus 5 ae53c168fd Merge branch 'dev' into feature/read-only-store
dev gained the editable mail templates, which live in the store like everything
else -- so they follow the same read-only rules: the options dialog skips saving
them, save_mail_template() reports the store instead of a raw PermissionError,
and a store that cannot keep its own copy simply renders from the shipped
default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:26:40 +02:00
Git-CI 3b12de95ed ci: bump dev version to 0.1.0-dev17 [skip ci] 2026-08-28 21:26:00 +02:00
Marcel Peterkau 94aec8fd18 Merge pull request 'Feature/mail templates' (#23) from feature/mail-templates into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/23
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-28 19:24:17 +00:00
Marcel PeterkauandClaude Opus 5 c46662561e Reject unbalanced repeat markers in mail templates
validate_mail_template() only looked at placeholder names, and "{{#claims}}" is
not a placeholder -- so an unclosed block or a stray "{{/claims}}" passed the
check and rendered as itself: the member would read the marker in their mail.

Repeat markers are now checked structurally: every one names a block the template
actually has, openers and closers pair up in order, blocks do not nest (the
renderer does not support it either), and the subject takes no markers at all.
Each case explains what is wrong and what is missing.

The same check runs before sending, not just before saving: the templates are
plain files in the store and can be edited outside CCMA, where refusing to send
beats mailing a marker. Saving several edited templates now validates all of
them before writing the first, so a mistake in one no longer leaves the others
half-saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:35 +02:00
Marcel PeterkauandClaude Opus 5 be042949a2 Keep CCMA usable when the member store is mounted read-only
The encrypted volume holding the member data can be mounted without write
access, but starting against such a store failed: the housekeeper takes a lock
file before doing anything, so its startup pass died with a PermissionError and
took the whole start with it.

The store is now probed with an actual write once at startup -- permissions,
mount options and filesystem state all matter, and only an attempt covers them
together -- and a read-only store opens as a read-only session. The housekeeper
is skipped rather than attempted, every write inside the repository goes through
one guard that reports ReadOnlyStoreError (a RepositoryError, so the dialogs
already handle it) instead of letting an OS error surface, and the services that
archive into the member file check before they start sending or rendering.

The session says so permanently: a warning banner above the tabs, "NUR LESEN" in
the window title and status bar, and refused actions explaining why. Program
settings still save -- they live in the user's config directory -- while the
store-backed ones are skipped with a notice. "Erneut prüfen" picks up a volume
that was remounted writable without restarting.

A store that was never initialized still fails, but says that creating one needs
write access.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:10:31 +02:00
Marcel PeterkauandClaude Opus 5 d3dbb5e96d Insert a working repeat block instead of its description
The placeholder chooser showed repeat blocks as "{{#claims}} … {{/claims}}" and
inserted exactly that on double-click -- the ellipsis is a label, so the saved
template rendered a literal "…" per claim instead of the claim.

Chooser rows are data now: each carries the text it reads as and, separately,
the snippet it inserts. A block contributes a complete, ready-to-edit block with
a sample line built from its own placeholders, and lands on a line of its own
when the cursor sits behind existing text.

Covered from both sides: the snippets are checked against the template validator
and renderer, and a UI test drives the real dialog -- insert, save, send -- and
asserts the mail carries actual claim lines. The Tk tests share one root (the
icon library binds its images to the first one) and skip without a display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:08:23 +02:00
Marcel PeterkauandClaude Opus 5 592c5482d6 Merge branch 'dev' into feature/mail-templates
dev gained the delivery/rollback semantics for the dunning and SEPA mails while
the mail texts were being moved into templates here. Both services keep dev's
flow -- roll back only while nothing has gone out, log the event even when
archiving fails afterwards -- and render their content from the template.

The welcome mail follows the same rule now, including its archiving-failure
path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:50:51 +02:00
Marcel PeterkauandClaude Opus 5 aa0c16347b Send new members a welcome mail that doubles as their first invoice
Accepting a member left the board writing the "welcome, you are a member from
the 1st, and this is what it costs" mail by hand. The member file now offers it
directly: it greets the member, names the membership number and the start of the
membership, and lists every claim that is still open -- admission fee and first
contribution -- with a payment hint that adapts to whether a direct debit
mandate is active.

"Mitglied seit" rejects future dates, so a member accepted this month cannot
carry their actual start date yet; {{membership.start_date}} therefore falls
back to the 1st of the month following the acceptance decision. Individual
claims can be deselected before sending, the finished mail can be previewed,
and the result is archived in the member file like every other generated mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:49:08 +02:00
Marcel PeterkauandClaude Opus 5 d6cd58a788 Render every outgoing e-mail from an editable text template
The wording of the dunning and SEPA pre-notification mails was hard-coded in
Python, so adjusting a single sentence required a new release. Both texts now
live in plain text templates that ship as defaults, are copied into the store's
templates/mail/ directory on first start and can be edited there or under
Optionen -> E-Mail-Vorlagen; an existing file is never overwritten and a deleted
one is restored from the shipped default.

A template carries its subject in the first line and the body after a blank
line. Placeholders use the same {{ ... }} syntax as the document templates and
share their member/organization values, so a name means the same thing in a
letter and in the mail that carries it. Unknown placeholders are rejected while
editing instead of during a send run, a line holding nothing but placeholders
that render empty is dropped, and {{#claims}} ... {{/claims}} repeats per entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:49:08 +02:00
Git-CI 1972dbabb3 ci: bump dev version to 0.1.0-dev16 [skip ci] 2026-08-20 14:58:51 +02:00
Matcha 69e8cd8f78 Merge pull request 'Feature/email delivery settings' (#22) from feature/email-delivery-settings into dev 2026-08-20 10:59:53 +00:00
Marcel Peterkau a4536eedd0 Cover archive-directory setup in the sent/delivered rollback path
archive_dir.mkdir() and the archive path lookup ran outside the
try/except that decides whether to revert mark_reminder_sent (reminder
mail) or keep a SEPA batch running for the other debits. A failure there
(read-only store, full disk) left a reminder booked as "sent" with no
mail ever having gone out, and could still abort an entire SEPA batch
for one member's directory problem.

Moved that setup inside the same try blocks so it's treated exactly like
any other pre-delivery failure: reminder_mail reverts to draft, and
sepa_mail records a warning and continues with the remaining debits.
Also moved debit_mail_bytes() into the per-debit try in sepa_mail for
the same reason. Added a targeted mkdir-failure test for each.
2026-08-20 12:51:17 +02:00
Marcel Peterkau 5fc6d7aec9 Stop rolling back reminders/SEPA mails after a successful send
The previous fix reverted the reminder to "draft" on any failure after
mark_reminder_sent, including failures that happened after the mail had
already been handed to send_via_smtp or appended to an IMAP folder. That
made a successful send followed by a Sent-folder-copy or archive-move
failure look like nothing was sent, inviting a duplicate send/fee booking
on retry -- the same "sent" flag needs to be preserved once delivery is
no longer reversible, per follow-up review.

reminder_mail.generate_and_send_reminder_mail and
sepa_mail.generate_debit_mails now track whether the mail actually left
the building (SMTP accepted / IMAP append succeeded / local file written)
separately from the later archiving step:
- Failure before that point: reminder_mail reverts to draft (unchanged);
  sepa_mail now records a warning and continues with the remaining
  debits instead of aborting the whole batch.
- Failure after that point (Sent-copy append, moving the archive file
  into place): the reminder stays "sent" / the debit stays in the
  batch's results, an event is still logged for traceability (with an
  archive_error note and no document reference), and the caller gets a
  clear error to follow up on manually -- no rollback, no silent loss of
  the fact that the mail already went out.
2026-08-20 12:22:37 +02:00
Marcel Peterkau aab43cc0fe Roll back reminder status when direct send/draft delivery fails
generate_and_send_reminder_mail marked a reminder as "sent" before
attempting SMTP send/IMAP append. If the connection, login, or append
failed, the reminder stayed persisted as sent even though no mail was
ever delivered or filed, so it could not be corrected and resent.

Wrap content generation, archiving, and delivery in a try/except that
calls the existing revert_reminder_sent on any failure and re-raises,
putting the reminder back into "draft" for all three delivery modes.
2026-08-20 12:08:44 +02:00
Marcel Peterkau e3807bf7dc Merge branch 'dev' into feature/email-delivery-settings 2026-08-15 02:37:23 +00:00
Git-CI 47f424252f ci: bump dev version to 0.1.0-dev15 [skip ci] 2026-08-15 04:37:16 +02:00
Marcel Peterkau ac161e8838 Merge pull request 'Feature/sepa export selection' (#21) from feature/sepa-export-selection into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/21
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 02:35:32 +00:00
Marcel PeterkauandClaude Sonnet 5 c68e8b7d6a Document the configurable e-mail delivery and Sent-folder copy features
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:34:34 +02:00
Marcel PeterkauandClaude Sonnet 5 cc4aaef895 Copy directly-sent e-mails to an IMAP Sent folder, with a live folder picker
A raw SMTP send has no server-side "Sent" copy on its own (unlike IMAP
drafts, which are inherently server-side) -- add an opt-in checkbox plus a
configurable target folder so directly sent Mahnungen/SEPA-info-mails still
show up in the account's Gesendet/Sent folder like a normal mail client
would leave them. Applies only to "send" delivery; drafts already live on
the server by definition.

Both the Entwürfe- and Gesendet-folder fields are now editable comboboxes:
a new "Ordnerliste laden" button fetches the real folder list from the IMAP
server (needs working credentials first) via LIST, decoding folder names
from modified UTF-7 (RFC 3501) so names like "Entwürfe" render correctly
instead of as "Entw&APw-rfe". Free text still works -- ensure_imap_folder()
creates the folder on first use if it doesn't exist yet, checked once per
batch rather than before every single message.

mail_delivery.append_to_imap_drafts() became the more general
append_message(client, content, folder=, flags=), reused for both the
\Draft and \Seen cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:34:34 +02:00
Marcel PeterkauandClaude Sonnet 5 6b0da82b45 Add configurable e-mail delivery: direct SMTP send or IMAP drafts
Mahnungs- and SEPA-info-mails could previously only be saved as a local .eml
file that still had to be manually imported into Thunderbird. Add a per-store
"E-Mail-Versand" configuration (Optionen -> E-Mail-Versand, stored in
repository.json alongside the rest of the club's settings, since different
stores may use different mailboxes) with four delivery modes:

- "Lokal speichern": today's behaviour, unchanged default for existing stores.
- "Direkt versenden": sends via SMTP.
- "Als Entwurf ablegen": IMAP APPENDs into a configurable drafts folder, so it
  shows up live in whatever mail client is already watching that account.
- "Jedes Mal fragen": prompts once per generation action (not per e-mail --
  a SEPA batch can cover dozens of members) with Senden/Entwürfe/Abbrechen.

New ccma.services.mail_delivery module (smtplib/imaplib, no new dependency)
opens one authenticated connection per batch and reuses it across all
messages instead of reconnecting per recipient. Both "Verbindung testen"
buttons in Options exercise the same connection path used for real delivery.
The archived per-member copy of every generated e-mail is unaffected and
still always written regardless of delivery mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:34:34 +02:00
Marcel Peterkau b7554478ac Merge remote-tracking branch 'origin/dev' into feature/sepa-export-selection 2026-08-15 04:33:46 +02:00
Git-CI cba463a648 ci: bump dev version to 0.1.0-dev14 [skip ci] 2026-08-15 04:06:10 +02:00
Marcel Peterkau f5c8d81057 Merge pull request 'Feature/housekeeper overdue anniversary fixes' (#20) from feature/housekeeper-overdue-anniversary-fixes into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/20
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 02:04:34 +00:00
Marcel Peterkau 85a865fa0e Merge branch 'dev' into feature/sepa-export-selection 2026-08-15 02:02:14 +00:00
Marcel Peterkau 4951d81ea3 Merge branch 'dev' into feature/housekeeper-overdue-anniversary-fixes 2026-08-15 02:02:02 +00:00
Git-CI e7fdde8691 ci: bump dev version to 0.1.0-dev13 [skip ci] 2026-08-15 04:01:12 +02:00
Marcel Peterkau 4e2595cec4 Merge pull request 'Feature/failed direct debit' (#19) from feature/failed-direct-debit into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/19
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 01:59:06 +00:00
Marcel Peterkau aa5829acb0 Merge branch 'dev' into feature/failed-direct-debit 2026-08-15 01:58:19 +00:00
Git-CI 76e0e8326c ci: bump dev version to 0.1.0-dev12 [skip ci] 2026-08-15 03:55:12 +02:00
Marcel Peterkau f861732131 Merge pull request 'Feature/housekeeper run single member' (#18) from feature/housekeeper-run-single-member into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/18
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-15 01:53:27 +00:00
Marcel PeterkauandClaude Sonnet 5 2ce764b007 Document the SEPA export multi-select feature
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:51:52 +02:00
Marcel PeterkauandClaude Sonnet 5 bfb65b2583 Let individual SEPA debits be excluded from an export, not just all-or-nothing
The SEPA export dialog always exported every pending direct debit in the
filtered date range with no way to hold one back -- e.g. a member whose
Rücklastschrift is still being clarified had to either be collected again
regardless or the whole run skipped. The table now supports multi-select
(everything selected by default) and the CSV/XML/mail exports only include
the currently selected rows. Deselection survives the refresh() that
_prepare() runs right before exporting (tracked by member_id, not row index,
so a debit newly appearing after a requery still defaults to selected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:51:32 +02:00
Marcel PeterkauandClaude Sonnet 5 d87a57775e Document the SEPA overdue-during-active-Mahnung fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:45:49 +02:00
Marcel PeterkauandClaude Sonnet 5 25f5d51d77 Stop flagging SEPA members with a running Rücklastschrift-Mahnung as overdue
sepa_debit_overdue fired unconditionally for every overdue SEPA claim past the
grace period, regardless of whether a reminder had already been sent for it --
so a Rücklastschrift-Mahnung created via the reminder mechanism made no
difference to the housekeeper output. Check for an already-sent reminder whose
own payment deadline hasn't expired yet first; if there is one, show a
low-priority "Rücklastschriftklärung läuft" note instead, mirroring the
non-SEPA "reminder_awaiting_deadline" fix. Once that deadline passes without
resolution, the plain sepa_debit_overdue notice returns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:45:27 +02:00
Marcel PeterkauandClaude Sonnet 5 a1fb2f4e30 Refresh the member tab after a single-member housekeeper run
_execute_housekeeper_run() showed the success dialog but never called
refresh()/on_changed(), so retroactively created claims/payments stayed
invisible in the currently open Mitgliedsakte until it was closed and
reopened, and the overview list wasn't notified either. Match the pattern
used by every other mutating action in this file (save, payment, credit,
reminder, ...): refresh the tab's own contribution lists and notify the
overview callback right after the run succeeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:40:40 +02:00
Marcel PeterkauandClaude Sonnet 5 0641285681 Document the housekeeper overdue-severity and ended-member reminder fixes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:32:07 +02:00
Marcel PeterkauandClaude Sonnet 5 56a3f5f037 Fix housekeeper false-positive overdue errors and ended-member birthday/anniversary noise
An overdue claim whose reminder had already been sent, with that reminder's own
payment deadline still running, fell through to the plain "überfällig" error
finding whenever reminder_due's escalation check returned None to signal "not
yet time for the next Mahnstufe" -- that None was indistinguishable from "never
handled at all" to the caller, so it looked neglected even though a reminder was
already out. Give that case its own low-priority "reminder_awaiting_deadline"
finding instead of silently falling back to the overdue-error path.

Birthday and membership-anniversary findings also kept firing for members whose
status is "ended", which doesn't make sense once the membership itself is over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:20:18 +02:00
Marcel PeterkauandClaude Sonnet 5 4ecdf87d05 Document the multi-item/Rücklastschrift reminder feature and reminder editing
Catches up the changelog for the reminder items table, the configurable
Mahnungen policy in Options, and today's ability to edit reminders (incl.
undoing an already-sent one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:23 +02:00
Marcel PeterkauandClaude Sonnet 5 186af530b0 Allow editing reminders (incl. undoing "sent") and prefill a Rücklastschrift reason
A generated/sent Mahnung email couldn't be corrected before: sent reminders were
locked, so a wording mistake meant living with it. Add edit_reminder_draft() for
draft/generated reminders and revert_reminder_sent() to safely undo a "sent"
reminder (removing the fee items it booked, refusing if a payment already covers
them or a higher Mahnstufe already exists) so it can be edited and resent.

ReminderDialog now supports an edit mode (reminder=... prefills name/detail/
items/deadline/channel, level stays fixed) and claim_tab gained a "Mahnung
bearbeiten" button that reverts-then-edits for sent reminders automatically,
asking for confirmation first since it un-books the fee.

The Rücklastschrift preset now also prefills "Details" with a short explanation
("Leider konnte die Lastschrift ... nicht eingelöst werden.") since that field
goes straight into the reminder email -- the board can append the concrete
reason (mangels Deckung, Konto ungültig, ...) right there instead of the field
starting empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 f3bdffd347 Make ReminderDialog resizable/wider and fix Options dialog bottom clipping
ReminderDialog: override the shared dialog base's resizable(False, False),
widen the initial layout, and pin the items-table edit row's amount field
to the exact same pixel width as the table's fixed "amount" column (via a
pack_propagate(False) frame) while the description field stretches like
the table's own column does -- keeps alignment exact even after resizing.

OptionsDialog: compute the window's initial geometry from its actual
required size after all tabs (incl. "Mahnungen") are built, instead of a
static guess that clipped the Mahnungen tab's tables/buttons at the bottom.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 3940926a22 Line up the reminder items edit row with the table columns
Beschreibung/Betrag now sit on their own row directly under the table,
sized to the same 220:100 ratio as the table's own columns, with the
Hinzufügen/Aktualisieren/Entfernen buttons moved to a row below that
instead of everything being crammed into one row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 d4dfd0066a Fix reminder dialog sizing, item editing, and old-repo fee defaults
The dialog's base class sets resizable(False, False) before this
subclass's content (including the preset-populated items table) exists,
so its initial size stayed locked to a too-small guess and cut off the
bottom. It now explicitly sizes to its actual content after everything,
including the selected preset's items, has been built.

The items table only supported add/remove -- there was no way to change
an already-added row's amount (e.g. after picking the Rücklastschrift
preset, its prefilled fee couldn't be adjusted). Selecting a row now
loads it into the description/amount fields, and a new "Aktualisieren"
button applies edits back to that row.

The Rücklastschrift preset label was missing the "Stufe N:" prefix the
other presets have, inconsistent for no reason.

Also fixed a real gap: repositories created before standard_fee_items
existed had no such key in repository.json at all, so Optionen showed
an empty Standardpositionen table instead of the built-in defaults.
get_reminder_policy() now backfills the defaults when the key is
missing entirely, while still respecting a list the board intentionally
emptied and saved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 a1719cad5e Handle failed direct debits through the same reminder mechanism
A Rücklastschrift is still the member's own failure to ensure cover or
provide valid bank details, so it belongs in the same escalation ladder
as a regular reminder -- it now occupies whatever level the claim is next
due for, alongside a "Rücklastschrift" preset next to the existing
Mahnstufe presets.

Mahnung vorbereiten no longer lets the board pick a level (it's computed
automatically from what's already been sent, since the sequencing was
already server-enforced) and replaces the single fee field with a small
items table (Beschreibung + Betrag), so a reminder can carry several
charges at once -- e.g. Rücklastschriftgebühr plus Bankgebühr plus Porto
-- each landing on the claim as its own line item instead of one lump
sum under a generic label. The description field offers the configured
standard texts but stays free-editable.

Those standard texts and amounts, plus the dunning levels themselves
(name/fee/deadline), are now configurable from Optionen -> Mahnungen
instead of only being editable by hand-editing repository.json, which
was the case for the whole reminder policy until now. Cleaned up
reminder_fee/failed_debit_fee on contribution rules while at it -- both
were unused leftovers superseded by this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:13:04 +02:00
Marcel PeterkauandClaude Sonnet 5 46795fc110 Reword the single-member housekeeper changelog entry so the PR carries a real diff
A prior push mixup (feature/member-contribution-overrides' origin ref had
briefly advanced to include this branch's own "Document the single-member
housekeeper run feature" commit) meant that commit's changelog line already
rode along into dev through PR #17 -- without the actual feature code, which
only exists here. CI's PR gate expects CHANGELOG.json to differ from dev, but
the merged text was already byte-identical, so this branch's PR couldn't pass.
Rewording the entry (adding the actual "Mitgliedsakte" entry point) keeps it
accurate and gives this branch a real changelog diff again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:12:44 +02:00
Marcel Peterkau 227ba8b128 Merge branch 'dev' into feature/housekeeper-run-single-member 2026-08-14 23:35:49 +00:00
Git-CI 7144d961c7 ci: bump dev version to 0.1.0-dev11 [skip ci] 2026-08-15 01:30:42 +02:00
Marcel Peterkau 0a70776154 Merge pull request 'Feature/member contribution overrides' (#17) from feature/member-contribution-overrides into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/17
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-14 23:29:05 +00:00
Marcel Peterkau e0125d008f Merge branch 'dev' into feature/member-contribution-overrides 2026-08-14 23:27:30 +00:00
Git-CI 7596f94444 ci: bump dev version to 0.1.0-dev10 [skip ci] 2026-08-15 01:27:15 +02:00
Marcel Peterkau 9cd611aeba Merge branch 'dev' into feature/member-contribution-overrides 2026-08-14 23:25:16 +00:00
Marcel Peterkau 171ba6bf28 Merge pull request 'Feature/allocation amount prefill' (#16) from feature/allocation-amount-prefill into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/16
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-14 23:24:56 +00:00
Marcel PeterkauandClaude Sonnet 5 ee99577b20 Run the housekeeper for a single member, optionally retroactively
Add a "Hausmeister für dieses Mitglied" button to the member view. It
runs every rule scoped to just that member -- e.g. after editing a
contribution override or payment frequency, to recompute their claims on
demand instead of waiting for or triggering a full run over every
member. A checkbox lets the board force retroactive claim creation for
just that one run, without changing the application-wide default
setting.

Housekeeper.run() gained member_id and retroactive_claims parameters.
Scoping to one member skips the asset checks and leaves every other
member's tasks completely untouched, since their rules never ran this
pass and so fall outside the resolved-scope set that
_resolve_stale_tasks uses to decide what to clear.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:59:01 +02:00
Marcel PeterkauandClaude Sonnet 5 c07bdff04e Document the single-member housekeeper run feature
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:58:10 +02:00
Marcel PeterkauandClaude Sonnet 5 1e29cc1e3d Document allocation prefill and membership fee override features
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:58:10 +02:00
Marcel PeterkauandClaude Sonnet 5 2484a1631d Add individually agreed membership fees (contribution overrides)
Members are sometimes given a fee that deviates from the regular
schedule -- e.g. a reduced rate for students -- for a specific period.
Add a per-member "Beitrag" tab where such deviations can be recorded
with a month-granular date range (Ab/Bis), a mandatory reason, and
either a fixed annual amount or a percentage discount off whichever
base rate is in effect at the time.

Data model: ContributionData gets a contribution_overrides list, each
entry validated (month format, Bis >= Ab, non-overlapping ranges per
member, reason required) and CRUD'd through the repository
(record/update/delete/get_contribution_override), consistent with how
donations already work.

Integration: contribution_claims.py now computes each membership-fee
claim's amount month by month instead of a single rate for the whole
billing period, picking up whichever override (if any) covers each
individual month. That handles an override starting or ending mid
period correctly (e.g. a semiannual payer whose discount begins in
March) without changing behavior for members without overrides.
Already-created claims are never recalculated retroactively, matching
how changes to the global contribution rates already behave.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:57:51 +02:00
Marcel PeterkauandClaude Sonnet 5 44228dd7dd Document the payment allocation amount prefill in the changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:57:32 +02:00
Marcel Peterkau 44af7df8e2 Merge branch 'dev' into feature/allocation-amount-prefill 2026-08-14 22:26:47 +00:00
Git-CI 5cbc5c3aad ci: bump dev version to 0.1.0-dev9 [skip ci] 2026-08-15 00:19:54 +02:00
Marcel Peterkau a91c4ddd24 Merge branch 'dev' into feature/allocation-amount-prefill 2026-08-14 22:18:21 +00:00
Marcel Peterkau 1c26f2ed0a Merge pull request 'Feature/table usability fixes' (#15) from feature/table-usability-fixes into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/15
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-14 22:18:14 +00:00
Marcel Peterkau 222fc1a337 Merge branch 'dev' into feature/allocation-amount-prefill 2026-08-14 22:03:49 +00:00
Marcel Peterkau 9bf62809b7 Merge branch 'dev' into feature/table-usability-fixes 2026-08-14 22:03:08 +00:00
Marcel PeterkauandClaude Sonnet 5 4906db3d55 Prefill the allocation amount when clicking an unallocated claim/donation
Clicking a claim or donation row in the payment allocation table used to
just show "0.00" if nothing was allocated to it yet, requiring the board
to type the amount by hand every time. It now suggests whichever is
smaller: what that claim/donation still has open, or what's left unused
of the payment -- so the common case is click the row, then Zuordnung
setzen, done. Rows that already have an allocation still show their
current value instead, so editing one isn't overwritten by the
suggestion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 00:02:37 +02:00
Marcel PeterkauandClaude Sonnet 5 52904a29b4 Document the table/pane usability tuning and GnuCash import improvements
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:56:51 +02:00
Marcel PeterkauandClaude Sonnet 5 195ae0e228 Replace the GnuCash import checkbox column with native multi-select
Selecting bookings to import now uses the Treeview's own multi-selection
(click, Ctrl+click, Shift+click for ranges) instead of a dedicated
checkbox column that had to be clicked precisely -- more standard and
much faster for marking many rows at once.

Bookings matching an existing payment's date+amount are no longer
blocked from selection; they're still flagged (red row, "Bereits
vorhanden"). If any selected booking is such a duplicate, importing now
asks whether to skip those or instead adopt the booking's description
onto the already-recorded payment. That relabeling is handled by a new
repository.update_payment_reference, which only touches the reference
and gnucash_transaction_id fields, leaving date/amount/allocations
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:43:55 +02:00
Git-CI 3d81a728b1 ci: bump dev version to 0.1.0-dev8 [skip ci] 2026-08-14 23:42:28 +02:00
Marcel Peterkau 7440d18c35 Merge pull request 'Feature/housekeeper dunning rules' (#14) from feature/housekeeper-dunning-rules into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/14
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-14 21:40:48 +00:00
Marcel Peterkau d2f4c9588f Merge branch 'dev' into feature/housekeeper-dunning-rules 2026-08-14 21:40:12 +00:00
Marcel PeterkauandClaude Sonnet 5 e25accd35b Fix column order/widths and sizing in Hausmeister and GnuCash import
Hausmeister: reorder columns to Level, Fällig, Vorgang, Details. Level,
Fällig and Vorgang get a fixed width; only Details stretches to fill the
remaining space.

GnuCash-Import dialog: opens at 80% of the current monitor's size
(monitor-aware via the existing screeninfo-based helpers, not just the
raw/virtual screen size) instead of a fixed 960x640. In its booking
table, Import, Datum, Betrag and Status get a fixed width; Beschreibung
and Memo share the remaining space.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:26:07 +02:00
Git-CI c0486770b8 ci: bump dev version to 0.1.0-dev7 [skip ci] 2026-08-14 23:13:23 +02:00
Marcel PeterkauandClaude Sonnet 5 65bcc9f447 Fix column order/widths and Chronik pane share in the member view
Forderungen: reorder columns to Fällig, Betrag, Status, Forderung.
Zahlungen and Spenden already had the requested order. Across all three
tables, the date/amount-like columns now share one fixed width, the
status column is a third wider (it tends to hold longer label text),
and only the trailing description/reference column stretches to fill
the remaining space.

The Chronik pane now defaults to a third of the member view's width
instead of three fifths, matching the other panes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:11:44 +02:00
Marcel Peterkau 7d3891b39b Merge branch 'dev' into feature/housekeeper-dunning-rules 2026-08-14 21:11:19 +00:00
Marcel Peterkau 241ca69190 Merge pull request 'Feature/claim delete payment create donations' (#13) from feature/claim-delete-payment-create-donations into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/13
Reviewed-by: Matcha <20+matcha@noreply.git.hiabuto.net>
2026-08-14 21:11:07 +00:00
Marcel PeterkauandClaude Sonnet 5 ad5aeb74b6 Document the housekeeper dunning/SEPA rule rework in the changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:03:04 +02:00
Marcel PeterkauandClaude Sonnet 5 4e4aa22589 Stop double-reporting overdue claims and add SEPA-specific followup
The claim-status and reminder-due housekeeper rules used to run
independently, so once a claim was both overdue and past its dunning
grace period, both a generic "überfällig" task and a "Mahnung fällig"
task showed up for the same problem. Rules can't see each other's
output, so the fix is to give reminder-due sole ownership of the whole
overdue lifecycle: a plain overdue notice during the grace period, then
either the dunning escalation or a fallback overdue notice (dunning
hold active, or waiting between reminder levels) -- never both at once.
claim-status now only handles claims that aren't overdue yet ("bald
fällig").

Members with an active SEPA mandate get a new sepa_debit_overdue finding
instead of the postal dunning escalation once a claim is past its grace
period, since mailing a Mahnung makes no sense for a member who pays by
direct debit -- the board needs to check/retrigger the debit instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:02:37 +02:00
Marcel PeterkauandClaude Sonnet 5 42cde2a0c8 Remember the last-used GnuCash account per file
AppConfig now keeps a gnucash_last_accounts map (file path -> account
guid). When the import dialog opens a file it already knows, it
auto-selects whichever account was picked last time for that specific
file instead of always defaulting to the first one alphabetically;
picking a different account updates and persists the mapping right away.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:02:37 +02:00
Marcel PeterkauandClaude Sonnet 5 745e634a8b Import payments per member from a GnuCash file
Adds a "Zahlungen importieren" button to the member Zahlungen tab that opens
a dedicated window: point it at a GnuCash file (plain or gzip-compressed
XML, defaults to the file configured in Optionen), pick one of its accounts,
and narrow the account's bookings down with a description-contains filter
and an optional date range.

Each matching booking gets a checkbox to mark it for import as a payment.
Before anything is ticked, bookings are cross-checked against this member's
existing payments by date + amount; a match is highlighted and its checkbox
is refused, so re-importing the same statement can't create a duplicate
payment. Only bookings with a positive amount on the selected account are
offered, since those are the ones that make sense as an incoming payment.

Parsing lives in ccma.services.gnucash_import, independent of the UI, and
is covered by tests against a synthetic GnuCash XML fixture (plain and
gzip-compressed) -- no gnucash/piecash dependency needed since the native
file format is just XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:02:37 +02:00
Marcel PeterkauandClaude Sonnet 5 e7a18b5cde Document claim deletion, donations, and payment allocation in the changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 23:02:18 +02:00
Marcel PeterkauandClaude Sonnet 5 4dd625c09f Only list open or already-linked claims/donations in the payment dialog
Show a claim or donation in the Zahlung anlegen/bearbeiten allocation
table only if it still has an open balance, or is already linked to this
payment -- fully settled ones no longer clutter the list.

Visibility is decided from a snapshot taken when the dialog opens (or
when a donation is created inline), not recomputed live, so a row never
disappears just because the user temporarily unassigned it with
"Zuordnung lösen" -- it stays there to add back with "Zuordnung setzen"
until the dialog is saved.

Also fixes a capacity display bug this surfaced: "Maximal zuordenbar"
was computed by mixing the persisted claim/donation balance with the
live, currently-edited allocation amount, so it inflated every time the
amount was changed during a session. It's now computed against a fixed
baseline captured at dialog open, so it stays accurate throughout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:28:52 +02:00
Marcel PeterkauandClaude Sonnet 5 a5d9abd59a Allocate open claims and donations directly from the payment dialog
Recording a payment previously meant saving it bare, then separately
opening a claim to allocate money to it. Zahlung anlegen now lists a
member's open claims and donations right in the same dialog with a
select + amount field to assign parts of the payment on the spot, and a
"Neue Spende anlegen" button to create a donation inline and allocate to
it immediately -- covering members who pay more than the membership fee
in one transfer.

Shared the same allocation table in the existing payment-edit dialog so
editing a payment shows and preserves donation allocations too; before
this, saving an edited payment silently dropped any donation allocation
because update_payment only round-tripped claim allocations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:22:08 +02:00
Marcel PeterkauandClaude Sonnet 5 42fb4c4224 Allow deleting claims, creating standalone payments, and add a donations tab
Claims could previously only be cancelled (stornieren), which blocks once a
payment is allocated. Add a hard delete that releases any linked
payments/credits back to being unallocated instead of destroying them.

Payments could only be created from within a claim, forcing immediate
allocation. Add a bare payment creation flow in the Zahlungen tab so
incoming transfers can be logged first and allocated later.

Add a per-member Spenden tab (donations, backed by a new donations list on
ContributionData) so amounts paid beyond the membership fee can be tracked
and existing free payments allocated to them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 21:02:51 +02:00
Git-CI a61ea3cb57 ci: bump dev version to 0.1.0-dev6 [skip ci] 2026-07-30 19:45:19 +02:00
Marcel Peterkau aa94145105 Merge pull request 'feature/payment-frequency-per-member' (#12) from feature/payment-frequency-per-member into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/12
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-30 17:43:09 +00:00
Marcel Peterkau ade92bfe06 Merge branch 'dev' into feature/payment-frequency-per-member 2026-07-30 17:43:00 +00:00
Git-CI 6ac205feb8 ci: bump dev version to 0.1.0-dev5 [skip ci] 2026-07-30 19:42:52 +02:00
Marcel Peterkau 5bacc005f6 Merge branch 'dev' into feature/payment-frequency-per-member 2026-07-30 17:40:45 +00:00
Marcel Peterkau 539859d4c5 Merge pull request 'feat: add application date to members' (#11) from feature/application-date into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/11
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-30 17:40:34 +00:00
Marcel Peterkau 7a0cdbc04e feat: add application date to members 2026-07-30 19:35:48 +02:00
Marcel Peterkau 6b62263706 Merge branch 'dev' into feature/payment-frequency-per-member 2026-07-30 17:31:49 +00:00
Marcel Peterkau ba3238e61a ui: move payment frequency to banking tab 2026-07-30 19:22:12 +02:00
Marcel Peterkau c3cdf71506 feat: add per-member payment frequencies 2026-07-30 19:17:26 +02:00
Git-CI e3f0a1df86 ci: bump dev version to 0.1.0-dev4 [skip ci] 2026-07-30 19:16:01 +02:00
Marcel Peterkau 6d9c50b148 Merge pull request 'feature/member-number-allocation-strategy' (#10) from feature/member-number-allocation-strategy into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/10
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-30 17:13:53 +00:00
Marcel Peterkau 54cb413eff docs: add member numbering strategy to changelog 2026-07-30 19:08:11 +02:00
Marcel Peterkau cfd5de42f2 feat: configure member number allocation strategy 2026-07-30 19:07:18 +02:00
Git-CI 460dcac77c ci: bump dev version to 0.1.0-dev3 [skip ci] 2026-07-30 18:58:41 +02:00
Marcel Peterkau 2280a0c658 Merge pull request 'feature/sepa-direct-debit-export' (#6) from feature/sepa-direct-debit-export into dev
Reviewed-on: https://git.hiabuto.net/C3MA/CCMA/pulls/6
Reviewed-by: Matcha <matcha@noreply.git.hiabuto.net>
2026-07-30 16:57:03 +00:00
Marcel Peterkau f14011efdc docs: describe SEPA and reminder mail features 2026-07-30 18:51:57 +02:00
Marcel Peterkau 060a5aea01 docs: remove temporary legacy claims tool 2026-07-30 18:48:50 +02:00
Marcel Peterkau fc4eacb591 feat: add reminder email drafts 2026-07-30 01:17:33 +02:00
Marcel Peterkau 3d3b845d9f feat: add SEPA direct debit exports and notifications 2026-07-30 01:00:32 +02:00
71 changed files with 12745 additions and 475 deletions
+83
View File
@@ -183,6 +183,80 @@ marker may share the last cell. CCMA removes both markers and clones the whole
row, including its formatting, once per item. With no items, the template row
is removed. A loop that is not closed in the same row is rejected.
## Mail templates
Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails,
SEPA pre-notifications and the data-review request -- is rendered from a plain text
template. The shipped
defaults are copied into the store's `templates/mail/` directory on first start
and can be edited there or under Optionen -> E-Mail-Vorlagen. An existing file is
never overwritten; a deleted one is restored from the shipped default.
A template holds the subject in its first line and the message body after a blank
line:
```
Betreff: {{reminder.name}} {{claim.title}}
Hallo {{member.first_name}},
...
```
Placeholders use the same `{{ ... }}` syntax as the document templates. Which ones
are available depends on the mail; the options dialog lists them per template and
rejects unknown ones when saving. A line that contains nothing but placeholders
which render empty (an optional hint, an empty list) is dropped, and
`{{#claims}} ... {{/claims}}` repeats its content once per claim.
## Data-review mailing
"Datenüberprüfung anfragen" in the members tab asks members to check the data the
club stores about them. Every member appears in the recipient list; preselected are
the live memberships (accepted, active, suspended, resigned at year's end, honorary)
that have an e-mail address. Members without one are skipped and reported.
Each recipient gets one mail listing their own record -- number, name, nickname,
birth date, contact data, address, status, member since, payment frequency, and the
bank details only for members who have any. A field with no value is printed as
`(nicht hinterlegt)` so the gap is visible, and the IBAN is masked down to its
country code and last four digits, which is enough to recognise the account. The
mail closes with the reference to put on a transfer -- member number and full name,
either of which identifies the payment on its own.
The text comes from the `Datenüberprüfung` template. Besides the usual member and
organization placeholders it offers `{{data.sheet}}` (the whole record, one field per
line), `{{data.count}}`, `{{member.iban_masked}}`, `{{payment.reference}}` (the
suggested transfer reference), `{{data.iban_hint}}` (the note about the shortened
IBAN, filled only for members whose bank details are listed) and the repeat block
`{{#data}}{{field.label}}: {{field.value}}{{/data}}` for a custom layout.
Delivery follows the configured e-mail mode (local `.eml` files, direct send, or
IMAP drafts); the run is confirmed once more with the recipient count because it
cannot be taken back. Every mail is archived below the member's
`files/documents/Datenpruefung/` directory and logged as a `data_review_email_sent`
event. A member whose mail fails is reported as a warning and the run continues with
the rest.
## Read-only stores
The member store may be mounted read-only -- the encrypted volume holding the
member data does not have to be writable to look something up. CCMA detects this
at startup by probing the store with an actual write, skips the housekeeper (every
one of its passes writes) and opens in a read-only session: a permanent warning
banner above the tabs, a "NUR LESEN" marker in the window title and status bar,
and every write refused with one clear message instead of an operating system
error.
Program settings still save normally -- they live in the user's config directory,
not in the store. Settings that belong to the store (club data, member numbers,
reminders, e-mail, mail templates) are skipped with a notice. When the volume is
remounted with write access, "Erneut prüfen" in the banner picks that up without a
restart: title, status bar and banner drop their markers, and the housekeeper pass
that was skipped at startup is offered right away.
A store that was never initialized cannot be opened read-only: creating it needs
write access, and CCMA says so instead of failing obscurely.
## Housekeeper rules
The housekeeper runs every rule for every member. Built-in Python rules live in
@@ -217,4 +291,13 @@ only confirming actual dispatch starts the new payment deadline and adds an
optional fee line item. A claim-level dunning hold suppresses automatic and
manual reminder preparation until it is removed or expires.
An overdue claim of a member with an active mandate is not dunned but reported as
a direct debit to look into -- until a reminder for it has actually been sent. A
sent reminder (typically the "Rücklastschrift" preset for a bounced debit) takes
that claim off the direct-debit track for good: it is expected as a transfer by
the stated deadline, so the housekeeper continues it in the ordinary dunning
sequence, the SEPA run leaves it out and says so, and its dunning mail asks for a
transfer even though the mandate is still active. Reverting the sent reminder puts
the claim back into the direct-debit run.
Do not place a real member store inside the source repository.
+1 -1
View File
@@ -1 +1 @@
0.1.0-dev2
0.2.0-dev0
+1
View File
@@ -42,6 +42,7 @@ ccma = [
"assets/CHANGELOG.json",
"assets/splash.png",
"assets/templates/*",
"assets/mail_templates/*",
"assets/themes/forest/**/*",
"assets/themes/forest/*",
]
+33 -1
View File
@@ -3,6 +3,7 @@
"version": "0.2.0",
"date": "upcoming",
"changes": [
"Mitgliedsanträge und Mitgliederakten enthalten jetzt ein separates Feld für das Antragsdatum.",
"Eigentum und aktueller Gewahrsam von Assets werden getrennt dokumentiert; neben Vereinseigentum kann nun auch Eigentum von Mitgliedern oder externen Personen zentral erfasst werden.",
"Mitgliedseigentum wird per UUID mit der Mitgliedsakte verknüpft und erscheint dort gemeinsam mit den vom Verein erhaltenen Assets in getrennt verständlichen Ansichten.",
"Standort, Übergabedatum, Zustand und Wert eines Assets können für Übergabe- und Versicherungsnachweise festgehalten werden; Eigentumsänderungen erscheinen zusätzlich in der Chronik.",
@@ -11,7 +12,38 @@
"Das Austrittsdatum eines Mitglieds wird gespeichert und validiert; automatische Beitragsforderungen werden nicht mehr für Fälligkeiten nach dem Mitgliedschaftsende erzeugt.",
"Anteilig berechnete Mitgliedsbeiträge weisen den abgerechneten Zeitraum verständlicher in ihrer Beschreibung aus.",
"Die Mitgliederliste zeigt standardmäßig aktive Mitglieder, ordnet Nachname vor Vorname an und sortiert aufsteigend nach Nachnamen.",
"Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben."
"Mitglieder mit offenen Hausmeister-Vorgängen werden in der Liste je nach höchster Dringlichkeit blau, gelb oder rot hervorgehoben.",
"Offene Forderungen von Mitgliedern mit aktivem Lastschriftmandat können für einen frei wählbaren Fälligkeitszeitraum gesammelt und als bankfähige SEPA-XML-Datei oder als CSV-Übersicht exportiert werden; unvollständige Mandate werden dabei verständlich ausgewiesen.",
"Für geplante SEPA-Einzüge können personalisierte, mit Thunderbird kompatible E-Mail-Entwürfe erzeugt werden. Die Mitteilungen enthalten Betrag, Einzugsdatum und Mandatsdaten und werden automatisch in der jeweiligen Mitgliederakte archiviert.",
"Mahnungsentwürfe können direkt als personalisierte, mit Thunderbird kompatible E-Mail-Datei ausgegeben und in der Mitgliederakte archiviert werden; dabei werden der Versand verbucht sowie Zahlungsfrist und gegebenenfalls Mahngebühr wirksam.",
"Für automatisch vergebene Mitgliedsnummern kann gewählt werden, ob vorhandene Lücken mit der nächsten freien Nummer gefüllt werden oder stets die höchste bestehende Nummer um eins erhöht wird; die Vergabe ist gegen parallele Doppelbelegungen abgesichert.",
"Die Zahlweise kann pro Mitglied als monatlich, quartalsweise, halbjährlich oder jährlich festgelegt werden; Hausmeister und Lastschriftläufe erzeugen und berücksichtigen die dazu passenden Beitragsforderungen.",
"Forderungen können nun auch vollständig gelöscht werden, nicht nur storniert; zugeordnete Zahlungen werden dabei automatisch wieder gelöst und stehen erneut zur Zuordnung bereit.",
"Zahlungen können direkt im Zahlungen-Tab eines Mitglieds angelegt werden, ohne vorher eine Forderung öffnen zu müssen.",
"Ein neuer Spenden-Tab je Mitglied erfasst Beträge, die über den Mitgliedsbeitrag hinausgehen, und erlaubt die Zuordnung vorhandener oder neuer Zahlungen zu einer Spende.",
"Beim Anlegen oder Bearbeiten einer Zahlung lassen sich offene Forderungen und Spenden direkt im selben Fenster live auswählen und mit Beträgen zuordnen, inklusive der Möglichkeit, dort sofort eine neue Spende anzulegen.",
"Die Zuordnungsübersicht im Zahlungsfenster zeigt nur noch offene oder bereits zugeordnete Forderungen und Spenden; entfernte Zuordnungen lassen sich innerhalb desselben Fensters wieder herstellen.",
"Der Hausmeister meldet eine überfällige Forderung nicht mehr doppelt als eigene Überfällig- und Mahnungsmeldung; bei Mitgliedern mit aktivem Lastschriftmandat erscheint statt einer Mahnung ein Hinweis, dass der Lastschrifteinzug geprüft werden sollte.",
"Die Spaltenbreiten in den Tabellen für Forderungen, Zahlungen, Spenden und Hausmeister-Vorgänge sind jetzt einheitlich: Datum-, Betrag- und Statusspalten haben eine feste Breite, nur die abschließende Beschreibungs- bzw. Referenzspalte passt sich dynamisch an die Fensterbreite an.",
"Die Chronik im Mitgliedsfenster nimmt standardmäßig ein Drittel der Fensterbreite ein.",
"Der GnuCash-Import öffnet sich mit 80 % der Bildschirmgröße und erlaubt die Mehrfachauswahl von Buchungen per Klick, Strg+Klick oder Umschalt+Klick; Buchungen, die einer bereits vorhandenen Zahlung entsprechen, können beim Import übersprungen oder mit der Beschreibung aus GnuCash aktualisiert werden.",
"Beim Anklicken einer noch nicht zugeordneten Forderung oder Spende im Zahlungsfenster wird der Betrag automatisch mit dem sinnvollen Vorschlag vorausgefüllt.",
"Individuell abweichende Mitgliedsbeiträge (z. B. ermäßigter Beitrag für Schüler) können pro Mitglied mit Zeitraum, Begründung und wahlweise als fester Jahresbetrag oder prozentualer Ermäßigung hinterlegt werden; künftig erzeugte Beitragsforderungen berücksichtigen das monatsgenau, auch wenn die Ermäßigung mitten in einer Zahlungsperiode beginnt oder endet.",
"Der Hausmeister kann per Knopfdruck direkt aus der Mitgliederakte gezielt für ein einzelnes Mitglied ausgeführt werden, optional mit rückwirkender Erstellung von Beiträgen/Forderungen nur für diesen einen Lauf; alle anderen Mitglieder bleiben davon unberührt.",
"Eine Mahnung kann statt einer einzelnen Gebühr mehrere frei bearbeitbare Positionen (Beschreibung & Betrag) enthalten; für eine nicht eingelöste Lastschrift steht eine eigene Rücklastschrift-Vorlage bereit, die dieselbe Mahnstufe wie eine reguläre Mahnung belegt und einen vorausgefüllten Hinweistext für die Mahnungs-E-Mail mitbringt.",
"Mahnstufen, Zahlungsfristen und Standardpositionen (Bezeichnung & Betrag) für Mahnungen lassen sich jetzt unter Optionen → Mahnungen zentral konfigurieren.",
"Mahnungsentwürfe können nachträglich bearbeitet werden; auch eine bereits als versandt markierte Mahnung lässt sich dafür sicher zurücksetzen (die gebuchte Mahngebühr wird wieder entfernt), sofern noch keine höhere Mahnstufe existiert oder Zahlungen bereits darauf verbucht wurden.",
"Eine überfällige Forderung, für die bereits eine Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr fälschlich als unbearbeiteter Fehler gemeldet, sondern als niedrig priorisierter Hinweis, dass die laufende Frist noch nicht abgelaufen ist.",
"Geburtstags- und Mitgliedsjubiläums-Hinweise des Hausmeisters werden für Mitglieder mit beendeter Mitgliedschaft nicht mehr angezeigt.",
"Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.",
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt.",
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden.",
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart.",
"Über „Datenüberprüfung anfragen“ in der Mitgliederliste kann eine Bitte um Datenprüfung an beliebig viele Mitglieder auf einmal verschickt werden: Jede Mail listet den eigenen Datensatz des Empfängers auf (fehlende Angaben werden als solche ausgewiesen, die IBAN nur mit den letzten vier Stellen), vorausgewählt sind alle laufenden Mitgliedschaften mit E-Mail-Adresse. Text und Betreff stammen aus der neuen Vorlage „Datenüberprüfung“; jede Mail wird in der Mitgliederakte archiviert und in der Chronik vermerkt. Die Mail nennt außerdem die Zahlweise und den Verwendungszweck, den das Mitglied bei Überweisungen angeben soll (Mitgliedsnummer und voller Name).",
"Eine Forderung, für die bereits eine Mahnung bzw. Rücklastschrift-Zahlungserinnerung versandt wurde, wird nicht mehr als Lastschrift behandelt: Der Hausmeister meldet nach Ablauf der Zahlungsfrist die nächste Mahnstufe statt weiterhin „Einzug prüfen, eine Mahnung ist hier nicht vorgesehen“, der SEPA-Lauf zieht die Forderung nicht erneut ein (und weist die übersprungene Position aus) und die Mahnungsmail bittet um Überweisung, auch wenn das Lastschriftmandat weiterhin aktiv ist. Wird der Versand einer Mahnung zurückgenommen, steht die Forderung wieder für den Einzug bereit."
]
},
{
@@ -0,0 +1,17 @@
Betreff: Bitte prüfen: deine bei {{organization.name}} gespeicherten Daten
Liebes Mitglied,
folgende Daten haben wir über dich gespeichert. Wir bitten dich, diese einmal zu überprüfen und uns Änderungen ggf. mitzuteilen.
{{data.sheet}}
{{data.iban_hint}}
Bitte gib bei Überweisungen an uns immer deine Mitgliedsnummer und/oder deinen vollen Namen (Vorname und Nachname) als Verwendungszweck an dann können wir deine Zahlung sofort zuordnen:
{{payment.reference}}
Stimmt etwas nicht oder fehlt eine Angabe? Dann antworte einfach auf diese Mail oder schreib uns an {{organization.email}}, dann pflegen wir die Änderung ein. Wenn alles passt, musst du nichts weiter tun.
Viele Grüße
{{signature}}
@@ -0,0 +1,21 @@
Betreff: {{reminder.name}} {{claim.title}}
Hallo {{member.first_name}},
zu der Forderung „{{claim.title}}“ ist noch ein Betrag offen.
Offener Betrag: {{claim.balance}} Euro
Ursprünglich fällig: {{claim.due_date}}
Neue Zahlungsfrist: {{reminder.payment_deadline}}
{{reminder.items}}
{{reminder.hint}}
Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:
IBAN: {{organization.iban}}
BIC: {{organization.bic}}
Verwendungszweck: {{payment.reference}}
Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, betrachte sie bitte als gegenstandslos.
Viele Grüße
{{signature}}
@@ -0,0 +1,16 @@
Betreff: SEPA-Lastschrift {{organization.name}} {{debit.collection_date}}
Hallo {{member.first_name}},
hiermit informieren wir dich, dass wir den folgenden Betrag per SEPA-Lastschrift einziehen werden:
Betrag: {{debit.amount}} Euro
Einzugsdatum: {{debit.collection_date}}
Verwendungszweck: {{debit.purpose}}
Mandatsreferenz: {{debit.mandate_reference}}
Gläubiger-Identifikationsnummer: {{organization.creditor_id}}
Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.
Viele Grüße
{{signature}}
@@ -0,0 +1,25 @@
Betreff: Willkommen im Club, {{member.first_name}} deine Mitgliedschaft und der erste Beitrag
Hallo {{member.first_name}},
herzlich willkommen bei {{organization.name}}! Der Vorstand hat deine Aufnahme beschlossen: Ab dem {{membership.start_date}} bist du offiziell Mitglied. Wir freuen uns sehr, dich an Bord zu haben.
Deine Mitgliedsnummer lautet {{member.number}}. Damit ordnen wir Zahlungen und Rückfragen sofort zu am besten schreibst du sie dir kurz auf.
Damit auch die Buchhaltung glücklich ist, kommt hier deine erste Rechnung. Deine Zahlweise ist „{{member.payment_frequency}}“, offen sind aktuell:
{{claims.list}}
Gesamtbetrag: {{claims.total}} Euro
Zahlbar bis: {{claims.first_due_date}}
{{payment.instructions}}
Und jetzt zum wichtigen Teil: Der Club lebt von den Leuten, die mitmachen. Komm zu den Clubabenden vorbei, bring deine Projekte mit, frag Löcher in Bäuche und teile, was du weißt. Es gibt keine dummen Fragen, nur ungelötete Ideen.
Wenn etwas unklar ist, antworte einfach auf diese Mail oder schreib uns an {{organization.email}} wir helfen gerne weiter.
Viel Spaß, viel Neugier und happy hacking!
Viele Grüße
{{signature}}
+10 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import math
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
class AppConfig:
store_path: str = ""
gnucash_path: str = ""
gnucash_last_accounts: dict[str, str] = field(default_factory=dict)
theme_mode: str = "dark"
run_housekeeper_on_startup: bool = True
splash_minimum_seconds: float = 5.0
@@ -43,6 +44,7 @@ class AppConfig:
"schema_version": 1,
"store_path": self.store_path,
"gnucash_path": self.gnucash_path,
"gnucash_last_accounts": self.gnucash_last_accounts,
"theme_mode": self.theme_mode,
"run_housekeeper_on_startup": self.run_housekeeper_on_startup,
"splash_minimum_seconds": _non_negative_float(self.splash_minimum_seconds, 5.0),
@@ -97,9 +99,16 @@ def load_config() -> AppConfig:
monitor_bounds = None
if isinstance(monitor_raw, list) and len(monitor_raw) == 4:
monitor_bounds = tuple(int(value) for value in monitor_raw)
last_accounts_raw = data.get("gnucash_last_accounts")
gnucash_last_accounts = (
{str(key): str(value) for key, value in last_accounts_raw.items()}
if isinstance(last_accounts_raw, dict)
else {}
)
return AppConfig(
store_path=store_override or str(data.get("store_path", "")),
gnucash_path=str(data.get("gnucash_path", "")),
gnucash_last_accounts=gnucash_last_accounts,
theme_mode=str(data.get("theme_mode", "dark")),
run_housekeeper_on_startup=bool(data.get("run_housekeeper_on_startup", True)),
splash_minimum_seconds=_non_negative_float(data.get("splash_minimum_seconds", 5.0), 5.0),
+87
View File
@@ -18,6 +18,18 @@ CLAIM_STATUS_LABELS = {
"cancelled": "STORNIERT",
}
DONATION_STATUS_LABELS = {
"open": "OFFEN",
"partially_allocated": "TEILWEISE ZUGEORDNET",
"allocated": "ZUGEORDNET",
"overallocated": "ÜBERZUGEORDNET",
}
CONTRIBUTION_OVERRIDE_KIND_LABELS = {
"amount": "BETRAG",
"percent": "PROZENT",
}
def decimal_value(value: Any, field_name: str = "Betrag") -> Decimal:
text = str(value).strip().replace(",", ".")
@@ -109,6 +121,81 @@ def claim_balance(data: ContributionData, claim: dict[str, Any]) -> Decimal:
return (claim_total(claim) - allocated_total(data, str(claim.get("claim_id", "")))).quantize(CENT)
def donation_allocated_total(data: ContributionData, donation_id: str) -> Decimal:
return sum(
(
decimal_value(allocation.get("amount", "0"))
for allocation in data.allocations
if str(allocation.get("donation_id", "")) == donation_id
),
Decimal("0"),
)
def donation_amount(donation: dict[str, Any]) -> Decimal:
return decimal_value(donation.get("amount", "0"))
def donation_balance(data: ContributionData, donation: dict[str, Any]) -> Decimal:
donation_id = str(donation.get("donation_id", ""))
return (donation_amount(donation) - donation_allocated_total(data, donation_id)).quantize(CENT)
def donation_status(data: ContributionData, donation: dict[str, Any]) -> str:
balance = donation_balance(data, donation)
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
if balance < 0:
return "overallocated"
if balance == 0:
return "allocated"
if allocated > 0:
return "partially_allocated"
return "open"
def reminder_items_total(reminder: dict[str, Any]) -> Decimal:
return sum(
(decimal_value(item.get("amount", "0")) for item in reminder.get("items") or []),
Decimal("0"),
)
def claim_was_dunned(data: ContributionData, claim: dict[str, Any]) -> bool:
"""True once a reminder for this claim has actually gone out. Such a claim has
left the direct-debit track: the collection failed (or never happened), the member
was asked in writing to pay it, and the money is expected as a transfer by the
stated deadline. Quietly collecting it a second time is exactly what the dunning
letter says will not happen, so both the SEPA run and the housekeeper ask this
before treating a claim as one the mandate still covers."""
claim_id = str(claim.get("claim_id", ""))
if not claim_id:
return False
return any(
str(reminder.get("claim_id", "")) == claim_id and str(reminder.get("status", "")) == "sent"
for reminder in data.reminders
)
def contribution_override_covers_month(override: dict[str, Any], month: str) -> bool:
valid_from = str(override.get("valid_from", ""))
valid_until = str(override.get("valid_until", "") or "")
if valid_from and month < valid_from:
return False
if valid_until and month > valid_until:
return False
return True
def contribution_override_ranges_overlap(
valid_from: str, valid_until: str, other_from: str, other_until: str
) -> bool:
"""Month ranges ('YYYY-MM', empty valid_until/other_until = open-ended) overlap
if each range starts no later than the other one ends."""
starts_before_other_ends = not other_until or valid_from <= other_until
other_starts_before_ends = not valid_until or other_from <= valid_until
return starts_before_other_ends and other_starts_before_ends
def claim_status(data: ContributionData, claim: dict[str, Any], *, today: date | None = None) -> str:
if str(claim.get("status", "")) == "cancelled":
return "cancelled"
+32
View File
@@ -78,6 +78,38 @@ def normalize_date_input(value: str, field_name: str) -> str:
return parsed.isoformat() if parsed else ""
def month_input_hint() -> str:
return "MM.YYYY" if system_date_pattern() == "%d.%m.%Y" else "YYYY-MM"
def parse_month_input(value: str, field_name: str, *, allow_empty: bool = True) -> str:
"""Parses a month-only value (no day) and normalizes it to 'YYYY-MM', which also
sorts and compares correctly as a plain string."""
text = value.strip()
if not text:
if allow_empty:
return ""
raise DateValidationError(f"{field_name} ist erforderlich.")
for pattern, expected in (("%m.%Y", r"\d{2}\.\d{4}"), ("%Y-%m", r"\d{4}-\d{2}")):
if not re.fullmatch(expected, text):
continue
try:
parsed = datetime.strptime(text, pattern)
except ValueError:
continue
return f"{parsed.year:04d}-{parsed.month:02d}"
raise DateValidationError(f"{field_name} muss ein gültiger Monat im Format {month_input_hint()} sein.")
def format_month_for_display(value: str) -> str:
text = value.strip()
match = re.fullmatch(r"(\d{4})-(\d{2})", text)
if not match:
return text
year, month = match.groups()
return f"{month}.{year}" if system_date_pattern() == "%d.%m.%Y" else f"{year}-{month}"
def format_date_for_display(value: str) -> str:
text = value.strip()
if not text:
+437
View File
@@ -0,0 +1,437 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
SUBJECT_HEADER = "Betreff:"
BUILTIN_TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "assets" / "mail_templates"
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
# A repeat block either wraps a single line ("{{#claims}}- {{claim.title}}{{/claims}}")
# or spans several lines with the markers on their own lines; both render one copy of
# the block per entry.
# Matches a single repeat marker, opening or closing. The placeholder pattern
# deliberately does not, so unbalanced markers need a check of their own -- they
# would otherwise survive validation and end up verbatim in a sent mail.
MARKER_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
BLOCK_PATTERN = re.compile(
r"\{\{\s*#\s*([a-z][a-z0-9_.]*)\s*\}\}[ \t]*\n?(.*?)\{\{\s*/\s*\1\s*\}\}[ \t]*(\n?)",
re.IGNORECASE | re.DOTALL,
)
class MailTemplateError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class MailTemplate:
subject: str
body: str
@dataclass(frozen=True, slots=True)
class MailTemplateBlock:
name: str
description: str
# Placeholder name -> short explanation, valid inside this block only.
placeholders: tuple[tuple[str, str], ...]
# The line the inserted block starts out with. It makes the inserted snippet a
# working block that renders real content -- the board edits this line rather
# than having to write the whole construct by hand.
sample: str
@property
def snippet(self) -> str:
return f"{{{{#{self.name}}}}}\n{self.sample}\n{{{{/{self.name}}}}}\n"
@dataclass(frozen=True, slots=True)
class PlaceholderEntry:
"""One row of the options dialog's placeholder chooser: what it reads as, and
what double-clicking it puts into the template."""
label: str
snippet: str
description: str
indented: bool = False
@dataclass(frozen=True, slots=True)
class MailTemplateSpec:
key: str
label: str
filename: str
description: str
# Placeholder name -> short explanation, shown as editing help in the options
# dialog. The mail services must supply exactly these keys, so this doubles as
# the contract a stored template is validated against.
placeholders: tuple[tuple[str, str], ...]
blocks: tuple[MailTemplateBlock, ...] = ()
BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("member.first_name", "Vorname"),
("member.last_name", "Nachname"),
("member.full_name", "Vor- und Nachname"),
("member.nickname", "Nickname"),
("member.number", "Mitgliedsnummer"),
("member.email", "E-Mail-Adresse"),
("member.status", "Mitgliedsstatus"),
("member.accepted_at", "Datum des Aufnahmebeschlusses"),
("member.started_at", "Mitglied seit"),
("member.application_date", "Antragsdatum"),
("member.payment_frequency", "Zahlweise"),
("member.address_line", "Anschrift in einer Zeile"),
("member.iban", "IBAN des Mitglieds"),
("member.mandate_reference", "Mandatsreferenz"),
("member.mandate_active", "Lastschriftmandat aktiv (Ja/Nein)"),
("organization.name", "Vereinsname"),
("organization.address_line", "Anschrift des Vereins"),
("organization.email", "E-Mail-Adresse des Vereins"),
("organization.website", "Website des Vereins"),
("organization.iban", "IBAN des Vereins"),
("organization.bic", "BIC des Vereins"),
("organization.creditor_id", "Gläubiger-Identifikationsnummer"),
("current_date", "Heutiges Datum"),
("signature", "Signatur aus dem Versandfenster"),
)
# The fields a member can check but which no other mail needs, so they stay out of
# BASE_PLACEHOLDERS: the data-review mail is the one that prints the whole record.
MEMBER_RECORD_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("member.birth_date", "Geburtsdatum"),
("member.phone", "Telefonnummer"),
("member.street", "Straße und Hausnummer"),
("member.address_addition", "Adresszusatz"),
("member.postal_code", "Postleitzahl"),
("member.city", "Ort"),
("member.country", "Land"),
("member.account_holder", "Kontoinhaber"),
("member.iban_masked", "IBAN, bis auf die letzten vier Stellen maskiert"),
("member.mandate_signed_at", "Datum der Mandatserteilung"),
)
CLAIM_ITEM_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("claim.title", "Bezeichnung der Forderung"),
("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"),
("claim.due_date", "Fälligkeitsdatum"),
("claim.amount", "Gesamtbetrag der Forderung"),
("claim.balance", "Noch offener Betrag"),
)
MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
MailTemplateSpec(
key="welcome",
label="Willkommen & Erstrechnung",
filename="willkommen.txt",
description=(
"Begrüßung neuer Mitglieder mit Mitgliedsnummer, Beginn der Mitgliedschaft "
"und den ersten offenen Forderungen (Aufnahmegebühr und erster Beitrag)."
),
placeholders=BASE_PLACEHOLDERS
+ (
(
"membership.start_date",
"Beginn der Mitgliedschaft; ist noch keiner erfasst, der 1. des auf den "
"Aufnahmebeschluss folgenden Monats",
),
("claims.list", "Alle ausgewählten Forderungen als Liste, eine je Zeile"),
("claims.total", "Summe der ausgewählten Forderungen"),
("claims.count", "Anzahl der ausgewählten Forderungen"),
("claims.first_due_date", "Früheste Fälligkeit der ausgewählten Forderungen"),
("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
("payment.reference", "Vorgeschlagener Verwendungszweck"),
),
blocks=(
MailTemplateBlock(
name="claims",
description="Wiederholt sich je ausgewählter Forderung",
placeholders=CLAIM_ITEM_PLACEHOLDERS,
sample="{{claim.description}} (fällig {{claim.due_date}}): {{claim.balance}} Euro",
),
),
),
MailTemplateSpec(
key="reminder",
label="Mahnung",
filename="mahnung.txt",
description=(
"Begleitmail zu einer Mahnung bzw. Zahlungserinnerung. Wird beim Versand "
"mit der jeweiligen Mahnstufe, Frist und den Mahnpositionen gefüllt."
),
placeholders=BASE_PLACEHOLDERS
+ CLAIM_ITEM_PLACEHOLDERS
+ (
("reminder.name", "Bezeichnung der Mahnstufe"),
("reminder.level", "Nummer der Mahnstufe"),
("reminder.payment_deadline", "Neue Zahlungsfrist"),
("reminder.detail", "Freitext-Hinweis der Mahnung"),
("reminder.hint", "Hinweiszeile, nur gefüllt wenn ein Freitext hinterlegt ist"),
("reminder.items", "Mahnpositionen als Liste, eine je Zeile"),
("reminder.fee_total", "Summe der Mahnpositionen"),
("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
("payment.reference", "Vorgeschlagener Verwendungszweck"),
),
blocks=(
MailTemplateBlock(
name="reminder.items",
description="Wiederholt sich je Mahnposition",
placeholders=(
("item.description", "Bezeichnung der Position"),
("item.amount", "Betrag der Position"),
),
sample="{{item.description}}: {{item.amount}} Euro",
),
),
),
MailTemplateSpec(
key="sepa",
label="SEPA-Vorabinformation",
filename="sepa-info.txt",
description=(
"Vorabinformation zu einem geplanten Lastschrifteinzug mit Betrag, "
"Einzugsdatum und Mandatsdaten."
),
placeholders=BASE_PLACEHOLDERS
+ (
("debit.amount", "Einzuziehender Betrag"),
("debit.collection_date", "Einzugsdatum"),
("debit.purpose", "Verwendungszweck der Lastschrift"),
("debit.mandate_reference", "Mandatsreferenz"),
),
),
MailTemplateSpec(
key="data_review",
label="Datenüberprüfung",
filename="datenpruefung.txt",
description=(
"Bitte an ein Mitglied, die über es gespeicherten Daten zu prüfen. Wird an "
"beliebig viele Mitglieder auf einmal verschickt und listet je Empfänger "
"dessen eigenen Datensatz auf."
),
placeholders=BASE_PLACEHOLDERS
+ MEMBER_RECORD_PLACEHOLDERS
+ (
("data.sheet", "Alle gespeicherten Daten als Liste, eine Zeile je Feld"),
("data.count", "Anzahl der aufgeführten Felder"),
(
"data.iban_hint",
"Hinweis zur verkürzten IBAN, nur gefüllt wenn Bankdaten aufgeführt werden",
),
(
"payment.reference",
"Vorgeschlagener Verwendungszweck aus Mitgliedsnummer und vollem Namen",
),
),
blocks=(
MailTemplateBlock(
name="data",
description="Wiederholt sich je gespeichertem Datenfeld",
placeholders=(
("field.label", "Bezeichnung des Feldes"),
("field.value", "Gespeicherter Wert"),
),
sample="{{field.label}}: {{field.value}}",
),
),
),
)
def placeholder_entries(key: str) -> list[PlaceholderEntry]:
"""Rows for the placeholder chooser. Repeat blocks contribute a complete,
ready-to-edit block -- inserting one has to leave a working template behind,
not a description of one."""
spec = template_spec(key)
entries = [
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description)
for name, description in spec.placeholders
]
for block in spec.blocks:
entries.append(
PlaceholderEntry(
f"{{{{#{block.name}}}}}{{{{/{block.name}}}}}", block.snippet, block.description
)
)
entries.extend(
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description, indented=True)
for name, description in block.placeholders
)
return entries
def template_spec(key: str) -> MailTemplateSpec:
for spec in MAIL_TEMPLATES:
if spec.key == key:
return spec
raise MailTemplateError(f"Unbekannte E-Mail-Vorlage: {key}")
def default_mail_template(key: str) -> MailTemplate:
spec = template_spec(key)
path = BUILTIN_TEMPLATE_ROOT / spec.filename
try:
return parse_mail_template(path.read_text(encoding="utf-8"))
except OSError as exc:
raise MailTemplateError(
f"Die mitgelieferte Vorlage „{spec.label}“ konnte nicht gelesen werden: {exc}"
) from exc
def parse_mail_template(text: str) -> MailTemplate:
"""Templates are plain text files whose first line carries the subject
("Betreff: ..."), followed by a blank line and the message body -- readable and
editable with any text editor, just like the rest of the store."""
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
subject = ""
if lines and lines[0].strip().casefold().startswith(SUBJECT_HEADER.casefold()):
subject = lines[0].split(":", 1)[1].strip()
lines = lines[1:]
if lines and not lines[0].strip():
lines = lines[1:]
return MailTemplate(subject, "\n".join(lines).strip("\n"))
def serialize_mail_template(subject: str, body: str) -> str:
normalized = body.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
return f"{SUBJECT_HEADER} {subject.strip()}\n\n{normalized}\n"
def render_mail_template(
template: MailTemplate,
values: dict[str, str],
repeats: dict[str, list[dict[str, str]]] | None = None,
) -> MailTemplate:
subject = _render_line(template.subject, values)
body = _render_blocks(template.body, values, repeats or {})
return MailTemplate(subject.strip(), _render_text(body, values))
def validate_block_markers(key: str, subject: str, body: str) -> None:
"""Checks that every {{#block}} is closed by its own {{/block}} and that no
stray marker is left over. An unbalanced marker is not a placeholder, so it
renders as itself -- the recipient would read "{{#claims}}" in their mail."""
spec = template_spec(key)
known = {block.name.casefold(): block.name for block in spec.blocks}
for match in MARKER_PATTERN.finditer(subject):
raise MailTemplateError(
f"Wiederholungsblöcke sind im Betreff nicht möglich: {match.group(0)}"
)
open_marker: tuple[str, str] | None = None
for match in MARKER_PATTERN.finditer(body):
kind, name = match.group(1), match.group(2)
if name.casefold() not in known:
raise MailTemplateError(
f"Unbekannter Wiederholungsblock: {match.group(0)}. Verfügbar: "
+ (", ".join(f"{{{{#{value}}}}}" for value in known.values()) or "keine")
)
if kind == "#":
if open_marker is not None:
raise MailTemplateError(
f"Wiederholungsblöcke können nicht ineinander verschachtelt werden: "
f"{{{{#{open_marker[1]}}}}} ist noch offen, als {match.group(0)} beginnt."
)
open_marker = (kind, name)
continue
if open_marker is None:
raise MailTemplateError(
f"{match.group(0)} steht ohne zugehöriges {{{{#{name}}}}} in der Vorlage."
)
if open_marker[1].casefold() != name.casefold():
raise MailTemplateError(
f"{{{{#{open_marker[1]}}}}} wird durch {match.group(0)} geschlossen. "
f"Erwartet wird {{{{/{open_marker[1]}}}}}."
)
open_marker = None
if open_marker is not None:
raise MailTemplateError(
f"{{{{#{open_marker[1]}}}}} wird nicht geschlossen. Es fehlt "
f"{{{{/{open_marker[1]}}}}}."
)
def validate_mail_template(key: str, subject: str, body: str) -> None:
"""Rejects placeholders the mail service will never supply, so a typo surfaces
while editing the template instead of during a send run."""
spec = template_spec(key)
validate_block_markers(key, subject, body)
known = {name for name, _help in spec.placeholders}
block_names = {block.name for block in spec.blocks}
unknown: set[str] = set()
for block in spec.blocks:
block_known = known | {name for name, _help in block.placeholders}
for match in _blocks_of(body, block.name):
unknown.update(name for name in _placeholder_names(match) if name not in block_known)
remaining = body
for block_name in block_names:
remaining = _strip_blocks(remaining, block_name)
unknown.update(name for name in _placeholder_names(remaining) if name not in known)
unknown.update(name for name in _placeholder_names(subject) if name not in known)
if unknown:
raise MailTemplateError(
"Unbekannte Platzhalter in der Vorlage: " + ", ".join(sorted(unknown))
)
if not subject.strip():
raise MailTemplateError("Die Vorlage benötigt einen Betreff.")
if not body.strip():
raise MailTemplateError("Die Vorlage benötigt einen Text.")
def _placeholder_names(text: str) -> set[str]:
return {match.group(1) for match in PLACEHOLDER_PATTERN.finditer(text)}
def _blocks_of(text: str, name: str) -> list[str]:
return [
match.group(2)
for match in BLOCK_PATTERN.finditer(text)
if match.group(1).casefold() == name.casefold()
]
def _strip_blocks(text: str, name: str) -> str:
return BLOCK_PATTERN.sub(
lambda match: "" if match.group(1).casefold() == name.casefold() else match.group(0), text
)
def _render_blocks(
body: str, values: dict[str, str], repeats: dict[str, list[dict[str, str]]]
) -> str:
def expand(match: re.Match[str]) -> str:
name = match.group(1)
entries = next(
(rows for key, rows in repeats.items() if key.casefold() == name.casefold()), None
)
if entries is None:
return match.group(0)
block = match.group(2)
if block.endswith("\n"):
block = block[:-1]
rendered = [_render_text(block, {**values, **entry}) for entry in entries]
if not rendered:
return ""
return "\n".join(rendered) + match.group(3)
return BLOCK_PATTERN.sub(expand, body)
def _render_text(text: str, values: dict[str, str]) -> str:
# A line that holds nothing but placeholders which all render empty (an optional
# hint, an empty list) is dropped instead of leaving a stray blank line behind.
rendered = []
for line in text.split("\n"):
replaced = _render_line(line, values)
if replaced.strip() or not line.strip() or not _placeholder_names(line):
rendered.append(replaced)
continue
if PLACEHOLDER_PATTERN.sub("", line).strip():
rendered.append(replaced)
return "\n".join(rendered)
def _render_line(line: str, values: dict[str, str]) -> str:
return PLACEHOLDER_PATTERN.sub(
lambda match: values.get(match.group(1), match.group(0)), line
)
+18 -2
View File
@@ -20,6 +20,13 @@ MEMBERSHIP_STATUS_LABELS = {
"ended": "BEENDET",
}
PAYMENT_FREQUENCY_LABELS = {
"monthly": "MONATLICH",
"quarterly": "QUARTALSWEISE",
"semiannual": "HALBJÄHRLICH",
"annual": "JÄHRLICH",
}
ASSET_STATUS_LABELS = {
"available": "VERFUEGBAR",
"issued": "AUSGEGEBEN",
@@ -88,10 +95,11 @@ class Member:
mandate_active: bool = False
mandate_revoked_at: str = ""
status: str = "application"
application_date: str = ""
accepted_at: str = ""
membership_started_at: str = ""
membership_ended_at: str = ""
payment_frequency: str = "annual"
payment_frequency: str = "semiannual"
contribution_rule_id: str = "standard-2022"
honorary: bool = False
notes: str = ""
@@ -134,6 +142,7 @@ class Member:
},
"membership": {
"status": self.status,
"application_date": self.application_date,
"accepted_at": self.accepted_at,
"started_at": self.membership_started_at,
"ended_at": self.membership_ended_at,
@@ -178,12 +187,13 @@ class Member:
mandate_active=bool(banking.get("mandate_active", False)),
mandate_revoked_at=str(banking.get("mandate_revoked_at", "")),
status=str(membership.get("status", "application")),
application_date=str(membership.get("application_date", "")),
accepted_at=str(membership.get("accepted_at", "")),
membership_started_at=str(membership.get("started_at", "")),
membership_ended_at=str(membership.get("ended_at", "")),
honorary=bool(membership.get("honorary", False)),
contribution_rule_id=str(contribution.get("rule_id", "standard-2022")),
payment_frequency=str(contribution.get("payment_frequency", "annual")),
payment_frequency=str(contribution.get("payment_frequency", "semiannual")),
notes=str(data.get("notes", "")),
created_at=str(data.get("created_at", _iso_now())),
updated_at=str(data.get("updated_at", _iso_now())),
@@ -313,6 +323,8 @@ class ContributionData:
credits: list[dict[str, Any]] = field(default_factory=list)
allocations: list[dict[str, Any]] = field(default_factory=list)
reminders: list[dict[str, Any]] = field(default_factory=list)
donations: list[dict[str, Any]] = field(default_factory=list)
contribution_overrides: list[dict[str, Any]] = field(default_factory=list)
schema_version: int = 1
def to_dict(self) -> dict[str, Any]:
@@ -323,6 +335,8 @@ class ContributionData:
"credits": self.credits,
"allocations": self.allocations,
"reminders": self.reminders,
"donations": self.donations,
"contribution_overrides": self.contribution_overrides,
}
@classmethod
@@ -334,6 +348,8 @@ class ContributionData:
credits=list(data.get("credits") or []),
allocations=list(data.get("allocations") or []),
reminders=list(data.get("reminders") or []),
donations=list(data.get("donations") or []),
contribution_overrides=list(data.get("contribution_overrides") or []),
)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import (
MEMBERSHIP_STATUS_LABELS,
PAYMENT_FREQUENCY_LABELS,
Member,
)
def base_placeholder_values(
member: Member,
organization: dict | None = None,
*,
created_at: datetime | None = None,
) -> dict[str, str]:
"""Member and organization placeholders shared by document templates and mail
templates, so the same `{{member.*}}`/`{{organization.*}}` names mean the same
thing in a dunning letter and in the e-mail that carries it."""
organization = organization if isinstance(organization, dict) else {}
timestamp = created_at or datetime.now().astimezone()
organization_address = " ".join(
part
for part in (
str(organization.get("street", "")),
str(organization.get("postal_code", "")),
str(organization.get("city", "")),
)
if part
)
return {
"current_date": format_date_for_display(timestamp.date().isoformat()),
"current_datetime": timestamp.strftime("%d.%m.%Y %H:%M"),
"member.id": member.member_id,
"member.number": member.member_number,
"member.first_name": member.first_name,
"member.last_name": member.last_name,
"member.nickname": member.nickname,
"member.full_name": member.display_name,
"member.email": member.email,
"member.phone": member.phone,
"member.birth_date": format_date_for_display(member.birth_date),
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
"member.accepted_at": format_date_for_display(member.accepted_at),
"member.started_at": format_date_for_display(member.membership_started_at),
"member.ended_at": format_date_for_display(member.membership_ended_at),
"member.application_date": format_date_for_display(member.application_date),
"member.payment_frequency": PAYMENT_FREQUENCY_LABELS.get(
member.payment_frequency, member.payment_frequency
).capitalize(),
"member.street": member.street,
"member.address_addition": member.address_addition,
"member.postal_code": member.postal_code,
"member.city": member.city,
"member.country": member.country,
"member.address_line": " ".join(
part for part in (member.street, member.postal_code, member.city) if part
),
"member.account_holder": member.account_holder,
"member.iban": member.iban,
"member.bic": member.bic,
"member.mandate_reference": member.mandate_reference,
"member.mandate_signed_at": format_date_for_display(member.mandate_signed_at),
"member.mandate_revoked_at": format_date_for_display(member.mandate_revoked_at),
"member.mandate_active": "Ja" if member.mandate_active else "Nein",
"organization.name": str(organization.get("name", "")),
"organization.street": str(organization.get("street", "")),
"organization.postal_code": str(organization.get("postal_code", "")),
"organization.city": str(organization.get("city", "")),
"organization.country": str(organization.get("country", "")),
"organization.address_line": organization_address,
"organization.email": str(organization.get("email", "")),
"organization.phone": str(organization.get("phone", "")),
"organization.website": str(organization.get("website", "")),
"organization.iban": str(organization.get("iban", "")),
"organization.bic": str(organization.get("bic", "")),
"organization.creditor_id": str(organization.get("creditor_id", "")),
}
+2
View File
@@ -8,6 +8,8 @@ ORDER = 20
def evaluate(context: RuleContext):
member = context.member
if member.status == "ended":
return []
try:
birth_date = validate_birth_date(member.birth_date, today=context.today)
except DateValidationError:
+18 -25
View File
@@ -6,6 +6,11 @@ from ccma.rules.api import RuleContext, task
RULE_ID = "claim-status"
ORDER = 50
# Overdue claims (delta < 0) are handled entirely by reminder-due: it owns the whole
# overdue lifecycle (grace period, dunning escalation, SEPA follow-up) so there is
# exactly one finding per overdue claim instead of this rule and that one both firing
# for the same problem.
def evaluate(context: RuleContext):
actions = []
@@ -21,33 +26,21 @@ def evaluate(context: RuleContext):
except ValueError:
continue
delta = (due - context.today).days
if delta < 0 or delta > 14:
continue
claim_key = str(claim.get("claim_key") or claim.get("claim_id") or "unknown")
title = str(claim.get("title") or "Beitragsforderung")
claim_type = str(claim.get("type", ""))
if delta < 0:
actions.append(
task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"overdue:{claim_key}",
severity="error",
title=f"{context.member.display_name}: {title} überfällig",
detail=f"Fälligkeit war vor {-delta} Tagen.",
due_date=due,
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
)
)
elif delta <= 14:
actions.append(
task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"due-soon:{claim_key}",
severity="info",
title=f"{context.member.display_name}: {title} bald fällig",
detail=f"Fälligkeit in {delta} Tagen.",
due_date=due,
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
)
actions.append(
task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"due-soon:{claim_key}",
severity="info",
title=f"{context.member.display_name}: {title} bald fällig",
detail=f"Fälligkeit in {delta} Tagen.",
due_date=due,
code="initial_payment_due_soon" if claim_type == "admission_fee" else "claim_due_soon",
)
)
return actions
+85 -2
View File
@@ -2,6 +2,7 @@ import calendar
from datetime import date, timedelta
from decimal import ROUND_HALF_UP, Decimal
from ccma.domain.contributions import contribution_override_covers_month
from ccma.domain.dates import DateValidationError, parse_iso_date
from ccma.rules.api import RuleContext, create_claim
from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
@@ -9,6 +10,21 @@ from ccma.rules.scripts._shared import CONTRIBUTION_STATUSES
RULE_ID = "contribution-claims"
ORDER = 40
CENT = Decimal("0.01")
MONTH_NAMES = (
"",
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
)
def evaluate(context: RuleContext):
@@ -85,11 +101,31 @@ def _membership_claims(
("first-half", 1, 6, _due_date(year, configured_due_dates[0], "01-31")),
("second-half", 7, 12, _due_date(year, configured_due_dates[1], "07-31")),
]
elif member.payment_frequency == "quarterly":
periods = [
(
f"quarter-{quarter}",
first_month,
first_month + 2,
_recurring_due_date(year, first_month, rule.get("annual_due"), "01-31"),
)
for quarter, first_month in enumerate((1, 4, 7, 10), start=1)
]
elif member.payment_frequency == "monthly":
periods = [
(
f"month-{month:02d}",
month,
month,
_recurring_due_date(year, month, rule.get("annual_due"), "01-31"),
)
for month in range(1, 13)
]
else:
periods = [("annual", 1, 12, _due_date(year, rule.get("annual_due"), "01-31"))]
actions = []
monthly_amount = annual_amount / Decimal(12)
overrides = context.contributions.contribution_overrides
for suffix, first_month, last_month, regular_due in periods:
# The entry year is intentionally billed from the entry month onward,
# even when retroactive claims create old membership-fee claims.
@@ -97,7 +133,17 @@ def _membership_claims(
months = max(0, last_month - charged_from + 1)
if months == 0:
continue
amount = (monthly_amount * months).quantize(CENT, rounding=ROUND_HALF_UP)
# Blended month by month rather than a single rate for the whole period, so a
# contribution override that starts or ends mid-period (e.g. a student discount
# beginning in March for a member who pays semiannually) is billed correctly.
month_amounts = [
_monthly_amount(context.repository_config, overrides, year, month)
for month in range(charged_from, last_month + 1)
]
amount = sum((value for value, _override_id in month_amounts), Decimal("0")).quantize(
CENT, rounding=ROUND_HALF_UP
)
applied_overrides = sorted({override_id for _value, override_id in month_amounts if override_id})
entry_year = started_at.year == year
issue_date = regular_due - timedelta(days=issue_days)
if not entry_year and context.today < issue_date:
@@ -133,6 +179,7 @@ def _membership_claims(
"annual_amount": _money(annual_amount),
"months": months,
"formula": "annual_amount * months / 12",
"contribution_override_ids": applied_overrides,
},
},
)
@@ -140,6 +187,28 @@ def _membership_claims(
return actions
def _monthly_amount(config, overrides, year: int, month: int) -> tuple[Decimal, str]:
"""Returns the effective monthly rate for a single calendar month, and the
override's id if one applied (empty string otherwise)."""
rule = _rule_for(config, date(year, month, 1))
base_annual = Decimal(str(rule.get("annual_amount", "0"))) if rule else Decimal("0")
month_key = f"{year:04d}-{month:02d}"
override = next(
(item for item in overrides if contribution_override_covers_month(item, month_key)), None
)
if override is None:
return base_annual / Decimal(12), ""
kind = str(override.get("kind", ""))
value = Decimal(str(override.get("value", "0")))
if kind == "amount":
annual = value
elif kind == "percent":
annual = base_annual * (Decimal(100) - value) / Decimal(100)
else:
annual = base_annual
return annual / Decimal(12), str(override.get("override_id", ""))
def _rule_for(config, target: date):
selected = None
for rule in config.get("contribution_rules", []):
@@ -168,7 +237,21 @@ def _due_date(year: int, value, fallback: str) -> date:
return date(year, month, day)
def _recurring_due_date(year: int, month: int, value, fallback: str) -> date:
try:
_configured_month, day = (int(part) for part in str(value or fallback).split("-", 1))
except (TypeError, ValueError):
day = int(fallback.split("-", 1)[1])
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _title(year: int, suffix: str) -> str:
if suffix.startswith("month-"):
month = int(suffix.removeprefix("month-"))
return f"Mitgliedsbeitrag {MONTH_NAMES[month]} {year}"
if suffix.startswith("quarter-"):
quarter = int(suffix.removeprefix("quarter-"))
return f"Mitgliedsbeitrag {quarter}. Quartal {year}"
if suffix == "first-half":
return f"Mitgliedsbeitrag 1. Halbjahr {year}"
if suffix == "second-half":
@@ -8,6 +8,8 @@ ORDER = 30
def evaluate(context: RuleContext):
member = context.member
if member.status == "ended":
return []
try:
started_at = parse_iso_date(member.membership_started_at, "Mitglied seit")
except DateValidationError:
+136 -47
View File
@@ -1,7 +1,7 @@
from datetime import date, timedelta
from ccma.domain.contributions import claim_balance, claim_status, money_text
from ccma.rules.api import RuleContext, task
from ccma.domain.contributions import claim_balance, claim_status, claim_was_dunned, money_text
from ccma.rules.api import RuleAction, RuleContext, task
RULE_ID = "reminder-due"
ORDER = 60
@@ -16,10 +16,18 @@ DEFAULT_POLICY = {
}
def evaluate(context: RuleContext):
def evaluate(context: RuleContext) -> list[RuleAction]:
"""Owns the whole lifecycle of an overdue claim: a plain overdue notice during the
grace period, then either the postal dunning escalation or -- for a claim the
member's mandate still covers, where sending a dunning letter makes no sense -- a
distinct "check the direct debit" notice. Falls back to the plain overdue notice
whenever no escalation applies (dunning hold active, or between reminder levels),
so there is always exactly one finding for an overdue claim, never both an overdue
notice and a reminder notice at once."""
policy = context.repository_config.get("reminder_policy") or DEFAULT_POLICY
levels = sorted(policy.get("levels") or [], key=lambda value: int(value.get("level", 0)))
actions = []
grace_days = int(policy.get("grace_days_after_due", 7))
actions: list[RuleAction] = []
for claim in context.contributions.claims:
claim_id = str(claim.get("claim_id", ""))
if not claim_id or claim_status(context.contributions, claim, today=context.today) not in {
@@ -28,54 +36,135 @@ def evaluate(context: RuleContext):
"overdue",
}:
continue
if claim_balance(context.contributions, claim) <= 0 or _hold_is_active(claim, context.today):
if claim_balance(context.contributions, claim) <= 0:
continue
reminders = [
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
]
sent_levels = {
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
}
next_level = next(
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
None,
)
if not next_level:
try:
due = date.fromisoformat(str(claim.get("due_date", "")))
except ValueError:
continue
level = int(next_level.get("level", 0))
trigger_date = _trigger_date(claim, sent_levels, level, policy)
if not trigger_date or context.today < trigger_date:
continue
draft_exists = any(
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
for item in reminders
)
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
balance = money_text(claim_balance(context.contributions, claim))
title = (
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
if draft_exists
else f"{context.member.display_name}: {name} fällig"
)
detail = (
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
)
actions.append(
task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:level-{level}",
severity="warning",
code="reminder_due",
title=title,
detail=detail,
due_date=trigger_date,
)
)
days_overdue = (context.today - due).days
if days_overdue < 0:
continue # not yet due; claim-status handles the "due soon" notice
escalation = None
if days_overdue >= grace_days and not _hold_is_active(claim, context.today):
# A claim that was already dunned is no longer collected by direct debit --
# the member was asked to transfer it, so it continues in the ordinary
# dunning sequence even though the mandate itself is still active.
if context.member.mandate_active and not claim_was_dunned(context.contributions, claim):
escalation = _sepa_action(context, claim, claim_id, days_overdue)
else:
escalation = _reminder_action(context, claim, claim_id, levels, policy)
actions.append(escalation or _overdue_action(context, claim, claim_id, due, days_overdue))
return actions
def _overdue_action(context: RuleContext, claim, claim_id: str, due: date, days_overdue: int) -> RuleAction:
title = str(claim.get("title") or "Beitragsforderung")
claim_type = str(claim.get("type", ""))
claim_key = str(claim.get("claim_key") or claim_id)
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"overdue:{claim_key}",
severity="error",
title=f"{context.member.display_name}: {title} überfällig",
detail=f"Fälligkeit war vor {days_overdue} Tagen.",
due_date=due,
code="initial_payment_overdue" if claim_type == "admission_fee" else "claim_overdue",
)
def _sepa_action(context: RuleContext, claim, claim_id: str, days_overdue: int) -> RuleAction:
balance = money_text(claim_balance(context.contributions, claim))
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:sepa",
severity="warning",
code="sepa_debit_overdue",
title=f"{context.member.display_name}: Lastschrift überfällig Einzug prüfen",
detail=(
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR, seit "
f"{days_overdue} Tagen überfällig. Das Mitglied zahlt per SEPA-Lastschriftmandat, eine "
"postalische Mahnung ist hier nicht vorgesehen -- bitte den Lastschrifteinzug prüfen "
"bzw. erneut anstoßen."
),
)
def _reminder_action(context: RuleContext, claim, claim_id: str, levels, policy) -> RuleAction | None:
reminders = [
item for item in context.contributions.reminders if str(item.get("claim_id", "")) == claim_id
]
sent_levels = {
int(item.get("level", 0)): item for item in reminders if str(item.get("status", "")) == "sent"
}
next_level = next(
(definition for definition in levels if int(definition.get("level", 0)) not in sent_levels),
None,
)
if not next_level:
return None
level = int(next_level.get("level", 0))
trigger_date = _trigger_date(claim, sent_levels, level, policy)
if not trigger_date:
return None
if context.today < trigger_date:
# A reminder for the previous level was already sent and its own payment
# deadline hasn't expired yet -- this is being handled, not neglected, so it
# must not show up as the same "error" severity as a plain unaddressed overdue
# claim (that fallback is what the caller uses when this returns None).
return _awaiting_deadline_action(context, claim, claim_id, level, sent_levels, trigger_date)
draft_exists = any(
int(item.get("level", 0)) == level and str(item.get("status", "draft")) in {"draft", "generated"}
for item in reminders
)
name = str(next_level.get("name") or f"Mahnung Stufe {level}")
balance = money_text(claim_balance(context.contributions, claim))
title = (
f"{context.member.display_name}: Mahnungsentwurf wartet auf Versand"
if draft_exists
else f"{context.member.display_name}: {name} fällig"
)
detail = (
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
f"Mahnstufe {level}, vorgesehen Gebühr: {next_level.get('fee', '0.00')} EUR."
)
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:level-{level}",
severity="warning",
code="reminder_due",
title=title,
detail=detail,
due_date=trigger_date,
)
def _awaiting_deadline_action(
context: RuleContext, claim, claim_id: str, level: int, sent_levels: dict, trigger_date: date
) -> RuleAction:
previous_level = level - 1
previous = sent_levels.get(previous_level) or {}
name = str(previous.get("name") or f"Mahnstufe {previous_level}")
balance = money_text(claim_balance(context.contributions, claim))
return task(
rule_id=RULE_ID,
member=context.member,
key_suffix=f"{claim_id}:level-{previous_level}:awaiting",
severity="info",
code="reminder_awaiting_deadline",
title=f"{context.member.display_name}: {name} versandt, Frist läuft noch",
detail=(
f"Forderung: {claim.get('title', claim_id)}. Offener Betrag: {balance} EUR. "
f"Zahlungsfrist bis {trigger_date.isoformat()} noch nicht abgelaufen."
),
due_date=trigger_date,
)
def _trigger_date(claim, sent_levels, level: int, policy) -> date | None:
if level == 1:
try:
+23 -59
View File
@@ -21,9 +21,11 @@ from ccma.domain.contributions import (
claim_status,
claim_total,
money_text,
reminder_items_total,
)
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
from ccma.domain.models import Member
from ccma.domain.placeholders import base_placeholder_values
from ccma.storage.repository import MemberRepository
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
@@ -97,6 +99,7 @@ class DocumentService:
if not template_path.is_file() or template_path.suffix.casefold() not in SUPPORTED_TEMPLATE_SUFFIXES:
raise DocumentError("Das ausgewählte OpenDocument-Template ist nicht verfügbar.")
self.repository.assert_writable()
member = self.repository.get_member(member_id)
data = None
claim = None
@@ -181,65 +184,14 @@ def _template_values(
reminder: dict | None = None,
organization: dict | None = None,
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
organization = organization or {}
created_at = datetime.now().astimezone()
created_date = format_date_for_display(created_at.date().isoformat())
created_timestamp = created_at.strftime("%d.%m.%Y %H:%M")
organization_address = " ".join(
part
for part in (
str(organization.get("street", "")),
str(organization.get("postal_code", "")),
str(organization.get("city", "")),
)
if part
values = base_placeholder_values(member, organization, created_at=created_at)
values.update(
{
"document.created_date": values["current_date"],
"document.created_at": values["current_datetime"],
}
)
values = {
"document.created_date": created_date,
"document.created_at": created_timestamp,
"current_date": created_date,
"current_datetime": created_timestamp,
"member.id": member.member_id,
"member.number": member.member_number,
"member.first_name": member.first_name,
"member.last_name": member.last_name,
"member.nickname": member.nickname,
"member.full_name": member.display_name,
"member.email": member.email,
"member.phone": member.phone,
"member.birth_date": format_date_for_display(member.birth_date),
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
"member.accepted_at": format_date_for_display(member.accepted_at),
"member.started_at": format_date_for_display(member.membership_started_at),
"member.ended_at": format_date_for_display(member.membership_ended_at),
"member.street": member.street,
"member.address_addition": member.address_addition,
"member.postal_code": member.postal_code,
"member.city": member.city,
"member.country": member.country,
"member.address_line": " ".join(
part for part in (member.street, member.postal_code, member.city) if part
),
"member.account_holder": member.account_holder,
"member.iban": member.iban,
"member.bic": member.bic,
"member.mandate_reference": member.mandate_reference,
"member.mandate_signed_at": format_date_for_display(member.mandate_signed_at),
"member.mandate_revoked_at": format_date_for_display(member.mandate_revoked_at),
"member.mandate_active": "Ja" if member.mandate_active else "Nein",
"organization.name": str(organization.get("name", "")),
"organization.street": str(organization.get("street", "")),
"organization.postal_code": str(organization.get("postal_code", "")),
"organization.city": str(organization.get("city", "")),
"organization.country": str(organization.get("country", "")),
"organization.address_line": organization_address,
"organization.email": str(organization.get("email", "")),
"organization.phone": str(organization.get("phone", "")),
"organization.website": str(organization.get("website", "")),
"organization.iban": str(organization.get("iban", "")),
"organization.bic": str(organization.get("bic", "")),
"organization.creditor_id": str(organization.get("creditor_id", "")),
}
repeats: dict[str, list[dict[str, str]]] = {}
if claim is not None and data is not None:
claim_id = str(claim.get("claim_id", ""))
@@ -293,6 +245,10 @@ def _template_values(
channel_labels = {"email": "E-Mail", "letter": "Brief", "personal": "Persönlich"}
status = str(reminder.get("status", "draft"))
channel = str(reminder.get("channel", ""))
reminder_items = reminder.get("items") or []
item_lines = [
f"{item.get('description', '')}: {item.get('amount', '0.00')} EUR" for item in reminder_items
]
values.update(
{
"reminder.id": str(reminder.get("reminder_id", "")),
@@ -305,11 +261,19 @@ def _template_values(
str(reminder.get("payment_deadline") or "")
),
"reminder.payment_deadline_days": str(reminder.get("payment_deadline_days", "")),
"reminder.fee": f"{reminder.get('fee', '0.00')} EUR",
"reminder.fee": f"{money_text(reminder_items_total(reminder))} EUR",
"reminder.items": "; ".join(item_lines),
"reminder.detail": str(reminder.get("detail", "")),
"reminder.channel": channel_labels.get(channel, channel),
}
)
repeats["reminder.items"] = [
{
"item.description": str(item.get("description", "")),
"item.amount": str(item.get("amount", "")),
}
for item in reminder_items
]
return values, repeats
+169
View File
@@ -0,0 +1,169 @@
from __future__ import annotations
import gzip
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path
from xml.etree import ElementTree as ET
NS = {
"gnc": "http://www.gnucash.org/XML/gnc",
"act": "http://www.gnucash.org/XML/act",
"trn": "http://www.gnucash.org/XML/trn",
"split": "http://www.gnucash.org/XML/split",
"ts": "http://www.gnucash.org/XML/ts",
}
CENT = Decimal("0.01")
class GnuCashImportError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class GnuCashAccount:
guid: str
name: str
full_name: str
account_type: str
@dataclass(frozen=True, slots=True)
class GnuCashTransaction:
guid: str
date: date
description: str
memo: str
amount: Decimal
def _read_root(path: Path) -> ET.Element:
try:
raw = path.read_bytes()
except OSError as exc:
raise GnuCashImportError(f"GnuCash-Datei konnte nicht gelesen werden: {exc}") from exc
if raw[:2] == b"\x1f\x8b":
try:
raw = gzip.decompress(raw)
except OSError as exc:
raise GnuCashImportError(f"GnuCash-Datei konnte nicht entpackt werden: {exc}") from exc
try:
return ET.fromstring(raw)
except ET.ParseError as exc:
raise GnuCashImportError(f"GnuCash-Datei ist kein gültiges XML: {exc}") from exc
def _book(root: ET.Element) -> ET.Element:
book = root.find("gnc:book", NS)
if book is None:
raise GnuCashImportError("Keine GnuCash-Buchführung (gnc:book) in der Datei gefunden.")
return book
def _text(element: ET.Element, path: str) -> str:
return (element.findtext(path, default="", namespaces=NS) or "").strip()
def list_accounts(path: str | Path) -> list[GnuCashAccount]:
root = _read_root(Path(path))
book = _book(root)
by_guid: dict[str, ET.Element] = {}
parent_of: dict[str, str] = {}
for account_el in book.findall("gnc:account", NS):
guid = _text(account_el, "act:id")
if not guid:
continue
by_guid[guid] = account_el
parent_of[guid] = _text(account_el, "act:parent")
if not by_guid:
raise GnuCashImportError("Die Datei enthält keine Konten.")
def full_name(guid: str, seen: frozenset[str]) -> str:
account_el = by_guid.get(guid)
if account_el is None or guid in seen or _text(account_el, "act:type") == "ROOT":
return ""
name = _text(account_el, "act:name")
parent = parent_of.get(guid, "")
if not parent:
return name
parent_name = full_name(parent, seen | {guid})
return f"{parent_name}:{name}" if parent_name else name
accounts = []
for guid, account_el in by_guid.items():
account_type = _text(account_el, "act:type")
if account_type in {"ROOT", ""}:
continue
accounts.append(
GnuCashAccount(
guid=guid,
name=_text(account_el, "act:name"),
full_name=full_name(guid, frozenset()) or _text(account_el, "act:name"),
account_type=account_type,
)
)
accounts.sort(key=lambda item: item.full_name.casefold())
return accounts
def _parse_fraction(text: str) -> Decimal | None:
text = text.strip()
if "/" not in text:
return None
numerator, _, denominator = text.partition("/")
try:
denom = Decimal(denominator)
if denom == 0:
return None
return (Decimal(numerator) / denom).quantize(CENT)
except InvalidOperation:
return None
def _parse_posted_date(text: str) -> date | None:
text = text.strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def list_transactions(path: str | Path, account_guid: str) -> list[GnuCashTransaction]:
"""Return every booking that has a split on the given account, one row per
matching split (a transaction with two splits on the same account, e.g. a
transfer to itself, yields two rows -- rare in practice, harmless here)."""
root = _read_root(Path(path))
book = _book(root)
results: list[GnuCashTransaction] = []
for transaction_el in book.findall("gnc:transaction", NS):
guid = _text(transaction_el, "trn:id")
posted_date = _parse_posted_date(_text(transaction_el, "trn:date-posted/ts:date"))
if not guid or posted_date is None:
continue
description = _text(transaction_el, "trn:description")
splits_el = transaction_el.find("trn:splits", NS)
if splits_el is None:
continue
for split_el in splits_el.findall("trn:split", NS):
if _text(split_el, "split:account") != account_guid:
continue
amount = _parse_fraction(_text(split_el, "split:value"))
if amount is None:
continue
results.append(
GnuCashTransaction(
guid=guid,
date=posted_date,
description=description,
memo=_text(split_el, "split:memo"),
amount=amount,
)
)
results.sort(key=lambda item: (item.date, item.description.casefold()))
return results
+48 -22
View File
@@ -6,7 +6,7 @@ import math
import os
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from datetime import date, datetime
from pathlib import Path
from typing import Any
@@ -67,9 +67,26 @@ class Housekeeper:
today: date | None = None,
*,
member_delay: float = 0.0,
member_id: str | None = None,
retroactive_claims: bool | None = None,
) -> list[HousekeeperFinding]:
"""Run all rules. With `member_id`, only that member is (re-)evaluated --
asset checks are skipped and every other member's tasks are left untouched
(their rules never ran this pass, so they fall outside `successful_scopes`
and `_resolve_stale_tasks` can't touch them). `retroactive_claims`, if given,
overrides the configured setting for just this run without changing it
permanently -- e.g. to recompute one member's full claim history on demand
after a contribution change, without retroactively billing everyone else."""
# The housekeeper exists to write: it refreshes tasks, creates claims and
# updates member records. On a read-only store it must not even take its lock.
self.repository.assert_writable()
current_date = today or date.today()
delay = _non_negative_delay(member_delay)
effective_settings = (
self.settings
if retroactive_claims is None
else replace(self.settings, retroactive_claims=retroactive_claims)
)
with _exclusive_lock(self.lock_path):
original = self._load_state()
working = copy.deepcopy(original)
@@ -78,32 +95,39 @@ class Housekeeper:
now = datetime.now().astimezone().isoformat(timespec="seconds")
items = _items_by_key(working)
successful_scopes: set[tuple[str, str]] = set()
member_ids = set(self.repository.list_member_ids())
all_member_ids = set(self.repository.list_member_ids())
asset_ids = set(self.repository.list_asset_ids())
_remove_orphaned_member_items(items, member_ids)
_remove_orphaned_member_items(items, all_member_ids)
_remove_orphaned_asset_items(items, asset_ids)
if member_id is not None:
if member_id not in all_member_ids:
raise RepositoryError(f"Mitglied nicht gefunden: {member_id}")
selected_member_ids = {member_id}
else:
selected_member_ids = all_member_ids
rules = load_rules(self.repository.root)
repository_config = self.repository.get_configuration()
for index, member_id in enumerate(sorted(member_ids)):
for index, current_member_id in enumerate(sorted(selected_member_ids)):
if index and delay:
time.sleep(delay)
try:
member, contributions = self.repository.preflight_member_record(member_id)
member, contributions = self.repository.preflight_member_record(current_member_id)
except RepositoryError as exc:
self._refresh_record_integrity_task(items, member_id, str(exc), run_id, now)
successful_scopes.add(("member-record-check", member_id))
self._refresh_record_integrity_task(items, current_member_id, str(exc), run_id, now)
successful_scopes.add(("member-record-check", current_member_id))
continue
successful_scopes.add(("member-record-check", member_id))
successful_scopes.add(("member-record-check", current_member_id))
self._refresh_hash_integrity_tasks(
items,
target_type="member",
target_id=member_id,
warnings=self.repository.member_hash_warnings(member_id),
target_id=current_member_id,
warnings=self.repository.member_hash_warnings(current_member_id),
run_id=run_id,
now=now,
)
successful_scopes.add(("member-hash-check", member_id))
successful_scopes.add(("member-hash-check", current_member_id))
for rule in rules:
scope = (rule.rule_id, member.member_id)
try:
@@ -111,7 +135,7 @@ class Housekeeper:
member=member,
contributions=contributions,
today=current_date,
settings=self.settings,
settings=effective_settings,
repository_config=repository_config,
)
actions = rule.evaluate(context)
@@ -141,16 +165,17 @@ class Housekeeper:
else:
successful_scopes.add(scope)
for asset_id in sorted(asset_ids):
self._refresh_hash_integrity_tasks(
items,
target_type="asset",
target_id=asset_id,
warnings=self.repository.asset_hash_warnings(asset_id),
run_id=run_id,
now=now,
)
successful_scopes.add(("asset-hash-check", asset_id))
if member_id is None:
for asset_id in sorted(asset_ids):
self._refresh_hash_integrity_tasks(
items,
target_type="asset",
target_id=asset_id,
warnings=self.repository.asset_hash_warnings(asset_id),
run_id=run_id,
now=now,
)
successful_scopes.add(("asset-hash-check", asset_id))
self._resolve_stale_tasks(items, successful_scopes, run_id, now)
working.update(
@@ -167,6 +192,7 @@ class Housekeeper:
return _open_findings(working["items"])
def delete_task(self, key: str) -> list[HousekeeperFinding]:
self.repository.assert_writable()
selected_key = key.strip()
if not selected_key:
raise RepositoryError("Der Task hat keinen gültigen Key.")
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from ccma.domain.mail_templates import (
MailTemplate,
MailTemplateError,
render_mail_template,
template_spec,
validate_block_markers,
)
from ccma.domain.models import Member
from ccma.domain.placeholders import base_placeholder_values
from ccma.storage.repository import MemberRepository, RepositoryError
def template_values(
member: Member,
organization: dict | None,
*,
signature: str,
created_at: datetime | None = None,
) -> dict[str, str]:
values = base_placeholder_values(member, organization, created_at=created_at)
# A mail always opens with a salutation, so an empty first name would read as
# "Hallo ," -- every other placeholder may legitimately render empty.
values["member.first_name"] = member.first_name.strip() or "Mitglied"
values["signature"] = signature.strip()
return values
def payment_instructions(
member: Member,
organization: dict | None,
*,
due_date: str,
reference: str,
expect_transfer: bool = False,
) -> str:
"""Ready-made payment paragraph: members with an active mandate are told the
money is collected, everyone else gets the club's bank details. `expect_transfer`
overrides that for a claim the mandate no longer covers -- a dunned claim is
expected as a transfer even though the member still has a mandate."""
organization = organization if isinstance(organization, dict) else {}
if member.mandate_active and not expect_transfer:
mandate = member.mandate_reference.strip()
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
return (
f"Du hast uns ein SEPA-Lastschriftmandat erteilt{mandate_hint} wir ziehen den "
"Betrag fristgerecht von deinem Konto ein. Du musst also nichts weiter tun; "
"vor dem Einzug informieren wir dich rechtzeitig per E-Mail."
)
deadline = f" bis zum {due_date}" if due_date else ""
lines = [f"Bitte überweise den Betrag{deadline} auf unser Vereinskonto:", ""]
lines.append(f"IBAN: {str(organization.get('iban', '')).strip()}")
bic = str(organization.get("bic", "")).strip()
if bic:
lines.append(f"BIC: {bic}")
if reference.strip():
lines.append(f"Verwendungszweck: {reference.strip()}")
return "\n".join(lines)
def render_template(
repository: MemberRepository,
key: str,
values: dict[str, str],
repeats: dict[str, list[dict[str, str]]] | None = None,
) -> MailTemplate:
template = repository.get_mail_template(key)
try:
# The templates are plain files in the store and may have been edited outside
# CCMA, so the structural check runs again here: refusing to send beats
# sending a mail with "{{#claims}}" in its text.
validate_block_markers(key, template.subject, template.body)
return render_mail_template(template, values, repeats)
except MailTemplateError as exc:
raise RepositoryError(
f"Die E-Mail-Vorlage „{template_spec(key).label}“ ist fehlerhaft: {exc}"
) from exc
def compose_mail(
*,
recipient: str,
subject: str,
body: str,
sender_name: str,
sender_email: str,
created_at: datetime | None = None,
) -> bytes:
if not recipient.strip():
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
if not sender_email.strip() or "@" not in sender_email:
raise RepositoryError("Für den Versand ist eine gültige Absenderadresse erforderlich.")
timestamp = created_at or datetime.now().astimezone()
message = EmailMessage(policy=SMTP)
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
message["Date"] = format_datetime(timestamp)
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
message["To"] = recipient.strip()
message["Subject"] = subject.strip()
message["X-Mozilla-Draft-Info"] = (
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
)
message.set_content(body.strip() + "\n", charset="utf-8")
return message.as_bytes()
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
import base64
import binascii
import imaplib
import re
import smtplib
import ssl
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from email.parser import BytesParser
from email.policy import default as email_default_policy
from ccma.storage.repository import RepositoryError
def _ssl_context() -> ssl.SSLContext:
return ssl.create_default_context()
def _smtp_client(settings: dict) -> smtplib.SMTP:
host = str(settings.get("smtp_host", "")).strip()
if not host:
raise RepositoryError("Kein SMTP-Server konfiguriert.")
port = int(settings.get("smtp_port", 587))
security = str(settings.get("smtp_security", "starttls"))
client = (
smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30)
if security == "ssl"
else smtplib.SMTP(host, port, timeout=30)
)
if security == "starttls":
client.starttls(context=_ssl_context())
username = str(settings.get("smtp_username", "")).strip()
if username:
client.login(username, str(settings.get("smtp_password", "")))
return client
def _imap_client(settings: dict) -> imaplib.IMAP4:
host = str(settings.get("imap_host", "")).strip()
if not host:
raise RepositoryError("Kein IMAP-Server konfiguriert.")
port = int(settings.get("imap_port", 993))
security = str(settings.get("imap_security", "ssl"))
client = (
imaplib.IMAP4_SSL(host, port, timeout=30)
if security == "ssl"
else imaplib.IMAP4(host, port, timeout=30)
)
if security == "starttls":
client.starttls(_ssl_context())
username = str(settings.get("imap_username", "")).strip()
if username:
client.login(username, str(settings.get("imap_password", "")))
return client
@contextmanager
def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]:
"""Opens one authenticated SMTP connection to reuse across several sends (a SEPA
info-mail batch may cover dozens of members -- reconnecting/re-authenticating per
message would be slow and can trigger provider rate limits)."""
try:
client = _smtp_client(settings)
except (OSError, smtplib.SMTPException) as exc:
raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc
try:
yield client
finally:
with suppress(OSError, smtplib.SMTPException):
client.quit()
@contextmanager
def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]:
try:
client = _imap_client(settings)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc
try:
yield client
finally:
with suppress(OSError, imaplib.IMAP4.error):
client.logout()
def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
message = BytesParser(policy=email_default_policy).parsebytes(message_bytes)
try:
client.send_message(message)
except (OSError, smtplib.SMTPException) as exc:
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None:
try:
status, response = client.append(_encode_imap_utf7(folder), flags, None, message_bytes)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"E-Mail konnte nicht in „{folder}“ abgelegt werden: {exc}") from exc
if status != "OK":
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}")
def ensure_imap_folder(client: imaplib.IMAP4, folder: str) -> None:
"""Creates the folder on the server if it doesn't exist yet -- lets someone type
a not-yet-existing Entwürfe/Gesendet folder name and have it just appear, instead
of every append failing until it's created by hand in a separate mail client."""
encoded = _encode_imap_utf7(folder)
try:
status, _response = client.select(encoded, readonly=True)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht geprüft werden: {exc}") from exc
if status == "OK":
return
try:
status, response = client.create(encoded)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {exc}") from exc
if status != "OK":
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {detail}")
_LIST_RESPONSE_RE = re.compile(
rb'^\((?P<flags>[^)]*)\)\s+(?:"(?P<qdelim>[^"]*)"|(?P<delim>\S+))\s+(?P<name>.+)$'
)
def list_imap_folders(settings: dict) -> list[str]:
"""Human-readable (decoded) folder names for the Options dialog's folder picker
-- fetched live from the server, so it only works once credentials are entered."""
with imap_session(settings) as client:
try:
status, response = client.list()
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"Ordnerliste konnte nicht abgerufen werden: {exc}") from exc
if status != "OK":
raise RepositoryError("Ordnerliste konnte nicht abgerufen werden.")
folders = []
for raw in response or []:
if not raw:
continue
name = _parse_list_response(raw)
if name:
folders.append(name)
return folders
def _parse_list_response(raw: bytes) -> str | None:
match = _LIST_RESPONSE_RE.match(raw)
if not match:
return None
name = match.group("name").decode("utf-8", "replace").strip()
if name.startswith('"') and name.endswith('"') and len(name) >= 2:
name = name[1:-1]
return _decode_imap_utf7(name)
def _decode_imap_utf7(value: str) -> str:
# Modified UTF-7 (RFC 3501 5.1.3): "&" takes the role of "+", and "," takes the
# role of "/" inside the base64 run; "&-" is a literal ampersand.
if "&" not in value:
return value
result: list[str] = []
index = 0
length = len(value)
while index < length:
char = value[index]
if char != "&":
result.append(char)
index += 1
continue
end = value.find("-", index + 1)
if end == -1:
end = length
chunk = value[index + 1 : end]
if chunk == "":
result.append("&")
else:
base64_chunk = chunk.replace(",", "/")
base64_chunk += "=" * (-len(base64_chunk) % 4)
try:
result.append(base64.b64decode(base64_chunk).decode("utf-16-be"))
except (binascii.Error, UnicodeDecodeError):
result.append("&" + chunk + "-")
index = end + 1
return "".join(result)
def _encode_imap_utf7(value: str) -> str:
if "&" not in value and all(32 <= ord(char) <= 126 for char in value):
return value
result: list[str] = []
index = 0
length = len(value)
while index < length:
char = value[index]
if char == "&":
result.append("&-")
index += 1
continue
if 32 <= ord(char) <= 126:
result.append(char)
index += 1
continue
start = index
while index < length and not (32 <= ord(value[index]) <= 126):
index += 1
chunk = value[start:index]
encoded = base64.b64encode(chunk.encode("utf-16-be")).decode("ascii").rstrip("=")
result.append("&" + encoded.replace("/", ",") + "-")
return "".join(result)
def test_smtp_connection(settings: dict) -> None:
with smtp_session(settings):
pass
def test_imap_connection(settings: dict) -> None:
with imap_session(settings):
pass
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
import hashlib
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import (
MEMBERSHIP_STATUS_LABELS,
PAYMENT_FREQUENCY_LABELS,
Member,
)
from ccma.services.mail_composition import compose_mail, render_template, template_values
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.storage.repository import MemberRepository, RepositoryError
# Everyone the club still has a live relationship with. An ended membership is not
# asked to check data the club is about to delete anyway, and an application has not
# been decided on yet -- both can still be picked by hand in the dialog.
DEFAULT_RECIPIENT_STATUSES: tuple[str, ...] = (
"accepted_pending_payment",
"active",
"suspended_contribution",
"resigned_end_of_year",
"honorary",
)
# A field the member is meant to complete reads better as a visible gap than as an
# empty line the eye skips over.
NOT_STORED = "(nicht hinterlegt)"
# Only makes sense next to a masked IBAN, so it is filled per member rather than
# written into the template as a fixed line. It sits directly under the record and
# reads as a footnote to it, which is also why it carries its own parentheses: a
# dropped line must not leave a blank one behind.
IBAN_HINT = (
"(Die IBAN zeigen wir absichtlich nur verkürzt an die letzten vier Stellen "
"genügen zum Abgleich.)"
)
@dataclass(frozen=True, slots=True)
class GeneratedDataReviewMail:
member_id: str
recipient: str
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
export_path: Path | None
archive_path: Path
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mitglied"
def _available_path(directory: Path, filename: str) -> Path:
candidate = directory / filename
if not candidate.exists():
return candidate
stem, suffix = Path(filename).stem, Path(filename).suffix
index = 2
while (directory / f"{stem}-{index}{suffix}").exists():
index += 1
return directory / f"{stem}-{index}{suffix}"
def mask_iban(iban: str) -> str:
"""Shows enough of the IBAN to recognise the account (country code and the last
four digits) without putting the full account number into an e-mail: the member
only has to confirm it is still the right account."""
cleaned = iban.replace(" ", "").strip()
if len(cleaned) < 8:
return cleaned
return f"{cleaned[:2]}{'*' * (len(cleaned) - 6)}{cleaned[-4:]}"
def member_data_fields(member: Member) -> list[tuple[str, str]]:
"""The stored record as the member gets to see it: label and value, in the order
they read naturally. Bank details are only listed for members who have any --
asking someone who pays by transfer to check a mandate they never gave is noise."""
fields: list[tuple[str, str]] = [
("Mitgliedsnummer", member.member_number),
("Name", " ".join(part for part in (member.first_name, member.last_name) if part)),
("Nickname", member.nickname),
("Geburtsdatum", format_date_for_display(member.birth_date)),
("E-Mail", member.email),
("Telefon", member.phone),
("Straße und Hausnummer", member.street),
]
if member.address_addition.strip():
fields.append(("Adresszusatz", member.address_addition))
fields.extend(
[
(
"PLZ und Ort",
" ".join(part for part in (member.postal_code, member.city) if part),
),
("Land", member.country),
("Mitgliedsstatus", MEMBERSHIP_STATUS_LABELS.get(member.status, member.status)),
("Mitglied seit", format_date_for_display(member.membership_started_at)),
(
"Zahlweise",
PAYMENT_FREQUENCY_LABELS.get(
member.payment_frequency, member.payment_frequency
).capitalize(),
),
]
)
if has_bank_details(member):
signed = format_date_for_display(member.mandate_signed_at)
fields.extend(
[
("Kontoinhaber", member.account_holder),
("IBAN", mask_iban(member.iban)),
("Mandatsreferenz", member.mandate_reference),
(
"Lastschriftmandat",
("aktiv" if member.mandate_active else "nicht aktiv")
+ (f", erteilt am {signed}" if signed else ""),
),
]
)
return [(label, value.strip() or NOT_STORED) for label, value in fields]
def has_bank_details(member: Member) -> bool:
"""Whether the club stores anything about this member's account at all. Members
who pay by transfer have nothing here, and asking them to check a mandate they
never gave is noise."""
return any(
value.strip() for value in (member.iban, member.mandate_reference, member.account_holder)
)
def payment_reference(member: Member) -> str:
"""What the member should write on a transfer. Both halves are offered because
either one identifies the payment on its own: a member who has forgotten their
number can fall back on the name, and a name shared by two members is told apart
by the number."""
return " ".join(
part
for part in (
member.member_number.strip(),
" ".join(value for value in (member.first_name, member.last_name) if value).strip(),
)
if part
)
def data_review_recipients(
repository: MemberRepository, *, statuses: tuple[str, ...] | None = None
) -> list[Member]:
"""The members a data-review run is meant for by default -- an e-mail address is
required, so members without one are left out here and reported by the dialog."""
wanted = set(statuses if statuses is not None else DEFAULT_RECIPIENT_STATUSES)
return [
member
for member in repository.list_members()
if member.status in wanted and member.email.strip()
]
def data_review_mail_bytes(
repository: MemberRepository,
*,
member: Member,
organization: dict,
sender_name: str,
sender_email: str,
signature: str,
created_at: datetime | None = None,
) -> bytes:
values = template_values(member, organization, signature=signature, created_at=created_at)
fields = member_data_fields(member)
entries = [{"field.label": label, "field.value": value} for label, value in fields]
values.update(
{
"member.iban_masked": mask_iban(member.iban),
"data.sheet": "\n".join(f"{label}: {value}" for label, value in fields),
"data.count": str(len(fields)),
"data.iban_hint": IBAN_HINT if has_bank_details(member) else "",
"payment.reference": payment_reference(member),
}
)
rendered = render_template(repository, "data_review", values, {"data": entries})
return compose_mail(
recipient=member.email,
subject=rendered.subject,
body=rendered.body,
sender_name=sender_name,
sender_email=sender_email,
created_at=created_at,
)
def generate_data_review_mails(
repository: MemberRepository,
member_ids: list[str],
*,
delivery_mode: str,
output_directory: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
today: date | None = None,
) -> tuple[list[GeneratedDataReviewMail], list[str]]:
"""Sends one data-review mail per member. A member whose mail fails is reported as
a warning and the run continues: a single bad address must not stop a mailing to
the whole club halfway through."""
repository.assert_writable()
output: Path | None = None
email_settings: dict | None = None
smtp_ctx = nullcontext(None)
imap_ctx = nullcontext(None)
if delivery_mode == "local":
if not output_directory:
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
elif delivery_mode == "send":
email_settings = repository.get_email_settings()
smtp_ctx = smtp_session(email_settings)
if email_settings["imap_sent_enabled"]:
imap_ctx = imap_session(email_settings)
elif delivery_mode == "drafts":
email_settings = repository.get_email_settings()
imap_ctx = imap_session(email_settings)
else:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
organization = repository.get_configuration().get("organization") or {}
stamp = (today or date.today()).isoformat()
generated: list[GeneratedDataReviewMail] = []
warnings: list[str] = []
# One connection for the whole run -- this mailing covers every member, so
# reconnecting per recipient would be slow and invites provider rate limits.
with smtp_ctx as smtp_client, imap_ctx as imap_client:
if delivery_mode == "drafts":
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
elif delivery_mode == "send" and imap_client is not None:
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
for member_id in member_ids:
try:
member = repository.get_member(member_id)
except RepositoryError as exc:
warnings.append(f"{member_id}: {exc}")
continue
label = member.member_number or member.display_name
if not member.email.strip():
warnings.append(f"{label}: E-Mail-Adresse fehlt.")
continue
export_path: Path | None = None
archive_path: Path | None = None
# Once this mail has left the building, a later archiving failure must not
# hide that it went out, nor abort the members that come after it.
delivered = False
archive_failure: Exception | None = None
try:
content = data_review_mail_bytes(
repository,
member=member,
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
filename = f"Datenpruefung-{stamp}-{_safe_filename(label)}.eml"
archive_dir = (
repository.members_root
/ member.member_id
/ "files"
/ "documents"
/ "Datenpruefung"
)
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, filename)
if delivery_mode == "local":
export_path = _available_path(output, filename)
export_path.write_bytes(content)
delivered = True
elif delivery_mode == "send":
send_via_smtp(smtp_client, content)
delivered = True
if imap_client is not None:
append_message(
imap_client,
content,
folder=email_settings["imap_sent_folder"],
flags=r"(\Seen)",
)
else:
append_message(
imap_client,
content,
folder=email_settings["imap_drafts_folder"],
flags=r"(\Draft)",
)
delivered = True
archive_path.write_bytes(content)
except (OSError, RepositoryError) as exc:
if not delivered:
if export_path is not None:
export_path.unlink(missing_ok=True)
warnings.append(f"{label}: {exc}")
continue
warnings.append(
f"{label}: E-Mail wurde versandt/abgelegt, konnte aber nicht archiviert "
f"werden ({exc}); bitte manuell prüfen."
)
archive_failure = exc
digest = hashlib.sha256(content).hexdigest()
references: dict[str, str] = {}
if archive_failure is None:
references["document"] = archive_path.relative_to(
repository.members_root / member.member_id / "files"
).as_posix()
data = {
"sha256": digest,
"recipient": member.email,
"delivery_mode": delivery_mode,
"field_count": len(member_data_fields(member)),
}
if archive_failure is not None:
data["archive_error"] = str(archive_failure)
repository.append_event(
member.member_id,
event_type="data_review_email_sent",
summary="Bitte um Datenüberprüfung verschickt",
actor_type="user",
actor_name="Vorstand",
references=references,
data=data,
)
generated.append(
GeneratedDataReviewMail(
member.member_id, member.email, export_path, archive_path
)
)
return generated, warnings
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
import hashlib
import os
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from ccma.domain.contributions import claim_balance, claim_total, money_text, reminder_items_total
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import Member
from ccma.services.mail_composition import (
compose_mail,
payment_instructions,
render_template,
template_values,
)
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedReminderMail:
# None for "send"/"drafts" delivery -- there is no local file, the mail was
# handed straight to the mail server. Always set for "local" delivery.
export_path: Path | None
archive_path: Path
sha256: str
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mahnung"
def _available_path(directory: Path, filename: str) -> Path:
candidate = directory / filename
if not candidate.exists():
return candidate
stem, suffix = Path(filename).stem, Path(filename).suffix
index = 2
while (directory / f"{stem}-{index}{suffix}").exists():
index += 1
return directory / f"{stem}-{index}{suffix}"
def reminder_mail_bytes(
repository: MemberRepository,
*,
member: Member,
reminder: dict,
claim: dict,
balance: str,
organization: dict,
sender_name: str,
sender_email: str,
signature: str,
created_at: datetime | None = None,
) -> bytes:
values = template_values(member, organization, signature=signature, created_at=created_at)
detail = str(reminder.get("detail", "")).strip()
fee_items = reminder.get("items") or []
due_date = format_date_for_display(str(claim.get("due_date", "")))
payment_deadline = format_date_for_display(str(reminder.get("payment_deadline", "")))
reference = " ".join(
part
for part in (
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
str(claim.get("title", "")).strip(),
)
if part
)
values.update(
{
"claim.title": str(claim.get("title", "")),
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
"claim.due_date": due_date,
"claim.amount": money_text(claim_total(claim)),
"claim.balance": balance,
"reminder.name": str(reminder.get("name", "Zahlungserinnerung")),
"reminder.level": str(reminder.get("level", "")),
"reminder.payment_deadline": payment_deadline,
"reminder.detail": detail,
"reminder.hint": f"Hinweis: {detail}" if detail else "",
"reminder.items": "\n".join(
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
for item in fee_items
),
"reminder.fee_total": money_text(reminder_items_total(reminder)),
"payment.reference": reference,
# A dunning mail only goes out because the money did not arrive, so it
# asks for a transfer even from a member with an active mandate: this
# claim has left the direct-debit run and telling them "we collect it,
# you need not do anything" would contradict the letter it carries.
"payment.instructions": payment_instructions(
member,
organization,
due_date=payment_deadline,
reference=reference,
expect_transfer=True,
),
}
)
rendered = render_template(
repository,
"reminder",
values,
{
"reminder.items": [
{
"item.description": str(item.get("description", "")),
"item.amount": str(item.get("amount", "0.00")),
}
for item in fee_items
]
},
)
return compose_mail(
recipient=member.email,
subject=rendered.subject,
body=rendered.body,
sender_name=sender_name,
sender_email=sender_email,
created_at=created_at,
)
def generate_and_send_reminder_mail(
repository: MemberRepository,
member_id: str,
claim_id: str,
reminder_id: str,
*,
delivery_mode: str,
output_path: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> GeneratedReminderMail:
if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
repository.assert_writable()
member = repository.get_member(member_id)
data, claim = repository.get_claim(member_id, claim_id)
reminder = repository._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "")) not in {"draft", "generated"}:
raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.")
if not member.email.strip():
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
if not sender_email.strip() or "@" not in sender_email:
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
target: Path | None = None
if delivery_mode == "local":
if not output_path:
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
export_path: Path | None = None
# Once the mail has actually left the building -- landed on the SMTP server, been
# filed into an IMAP folder, or been written to the local export file -- it must
# not be un-sent again: a later failure (Sent-folder copy, moving the archive file
# into place) can no longer roll the reminder back to "draft", or a retry could
# send/file the same Mahnung a second time and double-book its fee. Everything
# before that point -- including preparing the archive directory/path -- still
# rolls back on failure, since nothing has actually gone out yet.
delivered = False
archive_failure: Exception | None = None
archive_temp: Path | None = None
try:
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
organization = repository.get_configuration().get("organization") or {}
content = reminder_mail_bytes(
repository,
member=member,
reminder=sent_reminder,
claim=updated_claim,
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
archive_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{datetime.now().date().isoformat()}-"
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
)
archive_path = _available_path(archive_dir, filename)
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
archive_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
try:
export_temp.write_bytes(content)
os.replace(export_temp, target)
finally:
export_temp.unlink(missing_ok=True)
export_path = target
delivered = True
elif delivery_mode == "send":
settings = repository.get_email_settings()
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
send_via_smtp(smtp_client, content)
delivered = True
if imap_client is not None:
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
append_message(
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
ensure_imap_folder(client, settings["imap_drafts_folder"])
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
delivered = True
os.replace(archive_temp, archive_path)
except Exception as exc:
if archive_temp is not None:
archive_temp.unlink(missing_ok=True)
if not delivered:
repository.revert_reminder_sent(member_id, claim_id, reminder_id)
raise
archive_failure = exc
digest = hashlib.sha256(content).hexdigest()
references = {"claim_id": claim_id, "reminder_id": reminder_id}
if archive_failure is None:
references["document"] = archive_path.relative_to(
repository.members_root / member_id / "files"
).as_posix()
data = {
"recipient": member.email,
"sha256": digest,
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
"delivery_mode": delivery_mode,
}
if archive_failure is not None:
data["archive_error"] = str(archive_failure)
# Log the event even when archiving failed -- the mail was already delivered/filed,
# so this must stay a traceable "sent" record instead of silently vanishing.
repository.append_event(
member_id,
event_type="reminder_email_sent",
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
actor_type="user",
actor_name="Vorstand",
references=references,
data=data,
)
if archive_failure is not None:
raise RepositoryError(
"Die Mahnung wurde zugestellt bzw. abgelegt, konnte aber nicht vollständig "
f"archiviert werden: {archive_failure}. Die Mahnung bleibt als versandt gebucht "
"-- bitte die Mitgliedsakte manuell prüfen."
) from archive_failure
return GeneratedReminderMail(export_path, archive_path, digest)
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import csv
import io
import re
import unicodedata
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from uuid import uuid4
from ccma.domain.contributions import claim_balance, claim_was_dunned
from ccma.storage.repository import MemberRepository, RepositoryError, validate_iban
PAIN_NAMESPACE = "urn:iso:std:iso:20022:tech:xsd:pain.008.001.08"
SEPA_CHARACTERS = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
"':?,-(+.)/ ÄäÖöÜüß&*$%"
)
ET.register_namespace("", PAIN_NAMESPACE)
@dataclass(frozen=True, slots=True)
class DirectDebit:
member_id: str
member_number: str
account_holder: str
iban: str
bic: str
mandate_reference: str
mandate_signed_at: str
amount: Decimal
purpose: str
claim_ids: tuple[str, ...]
def _safe(value: Any, maximum: int) -> str:
replacements = str.maketrans({"": "'", "": "'", "´": "'", "`": "'", "": "-", "": "-"})
source = str(value).translate(replacements)
normalized: list[str] = []
for character in source:
if character in SEPA_CHARACTERS:
normalized.append(character)
continue
decomposed = unicodedata.normalize("NFKD", character)
replacement = "".join(
part for part in decomposed if not unicodedata.combining(part) and part in SEPA_CHARACTERS
)
normalized.append(replacement or " ")
return re.sub(r"\s+", " ", "".join(normalized)).strip()[:maximum]
def pending_direct_debits(
repository: MemberRepository, *, due_until: date, due_from: date | None = None
) -> tuple[list[DirectDebit], list[str]]:
if due_from and due_from > due_until:
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
debits: list[DirectDebit] = []
warnings: list[str] = []
for member in repository.list_members():
if not member.mandate_active:
continue
member_label = member.member_number or member.display_name
missing = [
label
for value, label in (
(member.iban, "IBAN"),
(member.mandate_reference, "Mandatsreferenz"),
(member.mandate_signed_at, "Mandatsdatum"),
)
if not str(value).strip()
]
if missing:
warnings.append(f"{member_label}: {', '.join(missing)} fehlt.")
continue
try:
validate_iban(member.iban)
date.fromisoformat(member.mandate_signed_at)
except (RepositoryError, ValueError) as exc:
warnings.append(f"{member_label}: {exc}")
continue
data = repository.get_contributions(member.member_id)
selected = []
amount = Decimal("0")
for claim in data.claims:
if str(claim.get("status", "")) == "cancelled":
continue
try:
claim_due = date.fromisoformat(str(claim.get("due_date", "")))
except ValueError:
continue
balance = claim_balance(data, claim)
if not ((due_from is None or claim_due >= due_from) and claim_due <= due_until):
continue
if balance <= 0:
continue
if claim_was_dunned(data, claim):
# The debit for this claim already bounced and the member was dunned:
# the letter told them to transfer the money themselves, so collecting
# it behind their back would contradict it (and can bounce again, with
# another fee). Reverting the sent reminder puts it back in the run.
warnings.append(
f"{member_label}: „{claim.get('title') or 'Forderung'}“ wurde bereits "
"gemahnt Zahlung per Überweisung erwartet, kein erneuter Einzug."
)
continue
selected.append(claim)
amount += balance
if not selected:
continue
titles = [_safe(claim.get("title") or "Forderung", 70) for claim in selected]
purpose = _safe(f"{member.member_number} " + ", ".join(dict.fromkeys(titles)), 140)
debits.append(
DirectDebit(
member_id=member.member_id,
member_number=member.member_number,
account_holder=member.account_holder.strip() or member.display_name,
iban=member.iban.replace(" ", "").upper(),
bic=member.bic.replace(" ", "").upper(),
mandate_reference=_safe(member.mandate_reference, 35),
mandate_signed_at=member.mandate_signed_at,
amount=amount.quantize(Decimal("0.01")),
purpose=purpose,
claim_ids=tuple(str(claim.get("claim_id", "")) for claim in selected),
)
)
return sorted(debits, key=lambda item: (item.member_number, item.account_holder)), warnings
def csv_text(debits: list[DirectDebit], *, collection_date: date) -> str:
target = io.StringIO(newline="")
writer = csv.writer(target, delimiter=";", lineterminator="\r\n")
writer.writerow(
["Einzugsdatum", "Mitgliedsnummer", "Kontoinhaber", "IBAN", "BIC", "Betrag",
"Waehrung", "Mandatsreferenz", "Mandatsdatum", "Verwendungszweck"]
)
for debit in debits:
writer.writerow(
[collection_date.isoformat(), debit.member_number, debit.account_holder, debit.iban,
debit.bic, f"{debit.amount:.2f}".replace(".", ","), "EUR",
debit.mandate_reference, debit.mandate_signed_at, debit.purpose]
)
return target.getvalue()
def write_csv(path: Path | str, debits: list[DirectDebit], *, collection_date: date) -> None:
Path(path).write_text(csv_text(debits, collection_date=collection_date), encoding="utf-8-sig")
def _el(parent: ET.Element, name: str, text: Any | None = None) -> ET.Element:
child = ET.SubElement(parent, f"{{{PAIN_NAMESPACE}}}{name}")
if text is not None:
child.text = str(text)
return child
def pain008_bytes(
debits: list[DirectDebit], *, collection_date: date, organization: dict[str, Any],
message_id: str | None = None, created_at: datetime | None = None,
) -> bytes:
if not debits:
raise RepositoryError("Es gibt keine Lastschriften für den Export.")
creditor_name = _safe(organization.get("name", ""), 70)
creditor_iban = str(organization.get("iban", "")).replace(" ", "").upper()
creditor_bic = str(organization.get("bic", "")).replace(" ", "").upper()
creditor_id = _safe(organization.get("creditor_id", ""), 35)
missing = [label for value, label in (
(creditor_name, "Vereinsname"), (creditor_iban, "Vereins-IBAN"),
(creditor_bic, "Vereins-BIC"), (creditor_id, "Gläubiger-ID"),
) if not value]
if missing:
raise RepositoryError("Für den SEPA-XML-Export fehlen in den Optionen: " + ", ".join(missing) + ".")
validate_iban(creditor_iban)
timestamp = created_at or datetime.now().astimezone()
msg_id = _safe(message_id or f"CCMA-{timestamp:%Y%m%d%H%M%S}-{uuid4().hex[:8]}", 35)
total = sum((item.amount for item in debits), Decimal("0"))
document = ET.Element(f"{{{PAIN_NAMESPACE}}}Document")
initiation = _el(document, "CstmrDrctDbtInitn")
header = _el(initiation, "GrpHdr")
for name, value in (("MsgId", msg_id), ("CreDtTm", timestamp.isoformat(timespec="seconds")),
("NbOfTxs", len(debits)), ("CtrlSum", f"{total:.2f}")):
_el(header, name, value)
_el(_el(header, "InitgPty"), "Nm", creditor_name)
payment = _el(initiation, "PmtInf")
for name, value in (("PmtInfId", _safe(f"{msg_id}-1", 35)), ("PmtMtd", "DD"),
("BtchBookg", "true"), ("NbOfTxs", len(debits)),
("CtrlSum", f"{total:.2f}")):
_el(payment, name, value)
payment_type = _el(payment, "PmtTpInf")
_el(_el(payment_type, "SvcLvl"), "Cd", "SEPA")
_el(_el(payment_type, "LclInstrm"), "Cd", "CORE")
_el(payment_type, "SeqTp", "RCUR")
_el(payment, "ReqdColltnDt", collection_date.isoformat())
_el(_el(payment, "Cdtr"), "Nm", creditor_name)
_el(_el(_el(payment, "CdtrAcct"), "Id"), "IBAN", creditor_iban)
_el(_el(_el(payment, "CdtrAgt"), "FinInstnId"), "BICFI", creditor_bic)
_el(payment, "ChrgBr", "SLEV")
for index, debit in enumerate(debits, start=1):
transaction = _el(payment, "DrctDbtTxInf")
_el(_el(transaction, "PmtId"), "EndToEndId", _safe(f"{debit.member_number}-{index}", 35))
amount = _el(transaction, "InstdAmt", f"{debit.amount:.2f}")
amount.set("Ccy", "EUR")
direct_debit = _el(transaction, "DrctDbtTx")
mandate = _el(direct_debit, "MndtRltdInf")
_el(mandate, "MndtId", debit.mandate_reference)
_el(mandate, "DtOfSgntr", debit.mandate_signed_at)
other = _el(_el(_el(_el(direct_debit, "CdtrSchmeId"), "Id"), "PrvtId"), "Othr")
_el(other, "Id", creditor_id)
_el(_el(other, "SchmeNm"), "Prtry", "SEPA")
financial = _el(_el(transaction, "DbtrAgt"), "FinInstnId")
if debit.bic:
_el(financial, "BICFI", debit.bic)
else:
_el(_el(financial, "Othr"), "Id", "NOTPROVIDED")
_el(_el(transaction, "Dbtr"), "Nm", _safe(debit.account_holder, 70))
_el(_el(_el(transaction, "DbtrAcct"), "Id"), "IBAN", debit.iban)
_el(_el(transaction, "RmtInf"), "Ustrd", debit.purpose)
ET.indent(document, space=" ")
return ET.tostring(document, encoding="utf-8", xml_declaration=True)
def write_pain008(
path: Path | str, debits: list[DirectDebit], *, collection_date: date,
organization: dict[str, Any],
) -> None:
Path(path).write_bytes(
pain008_bytes(debits, collection_date=collection_date, organization=organization)
)
+216
View File
@@ -0,0 +1,216 @@
from __future__ import annotations
import hashlib
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from ccma.domain.models import Member
from ccma.services.mail_composition import compose_mail, render_template, template_values
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.services.sepa import DirectDebit
from ccma.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedDebitMail:
member_id: str
recipient: str
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
export_path: Path | None
archive_path: Path
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mitglied"
def _available_path(directory: Path, filename: str) -> Path:
candidate = directory / filename
if not candidate.exists():
return candidate
stem, suffix = Path(filename).stem, Path(filename).suffix
index = 2
while (directory / f"{stem}-{index}{suffix}").exists():
index += 1
return directory / f"{stem}-{index}{suffix}"
def debit_mail_bytes(
repository: MemberRepository,
*,
member: Member,
debit: DirectDebit,
collection_date: date,
organization: dict,
sender_name: str,
sender_email: str,
signature: str,
created_at: datetime | None = None,
) -> bytes:
values = template_values(member, organization, signature=signature, created_at=created_at)
values.update(
{
"debit.amount": f"{debit.amount:.2f}",
"debit.collection_date": collection_date.strftime("%d.%m.%Y"),
"debit.purpose": debit.purpose,
"debit.mandate_reference": debit.mandate_reference,
}
)
rendered = render_template(repository, "sepa", values)
return compose_mail(
recipient=member.email,
subject=rendered.subject,
body=rendered.body,
sender_name=sender_name,
sender_email=sender_email,
created_at=created_at,
)
def generate_debit_mails(
repository: MemberRepository,
debits: list[DirectDebit],
*,
collection_date: date,
delivery_mode: str,
output_directory: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]:
repository.assert_writable()
output: Path | None = None
email_settings: dict | None = None
smtp_ctx = nullcontext(None)
imap_ctx = nullcontext(None)
if delivery_mode == "local":
if not output_directory:
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
elif delivery_mode == "send":
email_settings = repository.get_email_settings()
smtp_ctx = smtp_session(email_settings)
if email_settings["imap_sent_enabled"]:
imap_ctx = imap_session(email_settings)
elif delivery_mode == "drafts":
email_settings = repository.get_email_settings()
imap_ctx = imap_session(email_settings)
else:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
organization = repository.get_configuration().get("organization") or {}
creditor_id = str(organization.get("creditor_id", "")).strip()
if not creditor_id:
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
generated: list[GeneratedDebitMail] = []
warnings: list[str] = []
# One connection (or two, for "send" with a Sent-folder copy) is opened for the
# whole batch -- a run can cover dozens of members -- instead of reconnecting/
# re-authenticating per recipient.
with smtp_ctx as smtp_client, imap_ctx as imap_client:
if delivery_mode == "drafts":
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
elif delivery_mode == "send" and imap_client is not None:
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
for debit in debits:
member = repository.get_member(debit.member_id)
if not member.email.strip():
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
continue
export_path: Path | None = None
archive_path: Path | None = None
# Once the mail has actually left the building for this debit -- SMTP
# accepted it, it's filed in the IMAP folder, or the local export file was
# written -- a later archiving failure must not abort the whole batch and
# lose track of the fact that this one was already delivered; nor may it
# abort earlier/later debits that have nothing to do with this failure.
delivered = False
archive_failure: Exception | None = None
try:
content = debit_mail_bytes(
repository,
member=member,
debit=debit,
collection_date=collection_date,
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
filename = (
f"SEPA-Info-{collection_date.isoformat()}-"
f"{_safe_filename(member.member_number or member.display_name)}.eml"
)
archive_dir = (
repository.members_root / member.member_id / "files" / "documents" / "SEPA"
)
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, filename)
if delivery_mode == "local":
export_path = _available_path(output, filename)
export_path.write_bytes(content)
delivered = True
elif delivery_mode == "send":
send_via_smtp(smtp_client, content)
delivered = True
if imap_client is not None:
append_message(
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
append_message(
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
)
delivered = True
archive_path.write_bytes(content)
except (OSError, RepositoryError) as exc:
if not delivered:
if export_path is not None:
export_path.unlink(missing_ok=True)
warnings.append(f"{member.member_number or member.display_name}: {exc}")
continue
warnings.append(
f"{member.member_number or member.display_name}: E-Mail wurde versandt/abgelegt, "
f"konnte aber nicht archiviert werden ({exc}); bitte manuell prüfen."
)
archive_failure = exc
digest = hashlib.sha256(content).hexdigest()
references = {}
if archive_failure is None:
references["document"] = archive_path.relative_to(
repository.members_root / member.member_id / "files"
).as_posix()
data = {
"sha256": digest,
"recipient": member.email,
"collection_date": collection_date.isoformat(),
"amount": f"{debit.amount:.2f}",
"delivery_mode": delivery_mode,
}
if archive_failure is not None:
data["archive_error"] = str(archive_failure)
repository.append_event(
member.member_id,
event_type="sepa_notification_generated",
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
actor_type="user",
actor_name="Vorstand",
references=references,
data=data,
)
generated.append(
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
)
return generated, warnings
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import hashlib
import os
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from ccma.domain.contributions import claim_balance, claim_status, claim_total, money_text
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import Member
from ccma.services.mail_composition import (
compose_mail,
payment_instructions,
render_template,
template_values,
)
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
imap_session,
send_via_smtp,
smtp_session,
)
from ccma.storage.repository import MemberRepository, RepositoryError
OPEN_CLAIM_STATUSES = {"open", "partially_paid", "overdue"}
@dataclass(frozen=True, slots=True)
class GeneratedWelcomeMail:
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
export_path: Path | None
archive_path: Path
sha256: str
def open_claims(repository: MemberRepository, member_id: str) -> list[dict]:
"""The claims a welcome mail can bill: everything still owed, which for a fresh
member is the admission fee plus the first contribution claim(s)."""
data = repository.get_contributions(member_id)
selected = [
claim for claim in data.claims if claim_status(data, claim) in OPEN_CLAIM_STATUSES
]
return sorted(selected, key=lambda claim: (str(claim.get("due_date", "")), str(claim.get("title", ""))))
def membership_start(member: Member, *, today: date | None = None) -> date:
"""The date the membership (and with it the first contribution) starts. A member
accepted this month usually starts on the 1st of the next one, and that date
cannot be stored yet -- "Mitglied seit" rejects future dates."""
try:
return date.fromisoformat(str(member.membership_started_at))
except ValueError:
pass
try:
reference = date.fromisoformat(str(member.accepted_at))
except ValueError:
reference = today or date.today()
return date(reference.year + reference.month // 12, reference.month % 12 + 1, 1)
def welcome_mail_bytes(
repository: MemberRepository,
*,
member: Member,
claims: list[dict],
balances: dict[str, Decimal],
organization: dict,
sender_name: str,
sender_email: str,
signature: str,
created_at: datetime | None = None,
) -> bytes:
values = template_values(member, organization, signature=signature, created_at=created_at)
entries = [
{
"claim.title": str(claim.get("title", "")),
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
"claim.due_date": format_date_for_display(str(claim.get("due_date", ""))),
"claim.amount": money_text(claim_total(claim)),
"claim.balance": money_text(balances[str(claim.get("claim_id", ""))]),
}
for claim in claims
]
total = sum((balances[str(claim.get("claim_id", ""))] for claim in claims), Decimal("0"))
due_dates = sorted(
str(claim.get("due_date", "")) for claim in claims if str(claim.get("due_date", ""))
)
first_due_date = format_date_for_display(due_dates[0]) if due_dates else ""
reference = " ".join(
part
for part in (
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
"Mitgliedsbeitrag",
)
if part
)
values.update(
{
"membership.start_date": format_date_for_display(
membership_start(member).isoformat()
),
"claims.list": "\n".join(
f"{entry['claim.description']} (fällig {entry['claim.due_date']}): "
f"{entry['claim.balance']} Euro"
for entry in entries
),
"claims.total": money_text(total),
"claims.count": str(len(entries)),
"claims.first_due_date": first_due_date,
"payment.reference": reference,
"payment.instructions": payment_instructions(
member, organization, due_date=first_due_date, reference=reference
),
}
)
rendered = render_template(repository, "welcome", values, {"claims": entries})
return compose_mail(
recipient=member.email,
subject=rendered.subject,
body=rendered.body,
sender_name=sender_name,
sender_email=sender_email,
created_at=created_at,
)
def generate_and_send_welcome_mail(
repository: MemberRepository,
member_id: str,
*,
claim_ids: list[str] | None = None,
delivery_mode: str,
output_path: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> GeneratedWelcomeMail:
if delivery_mode not in {"local", "send", "drafts"}:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
# The mail is archived in the member file, so a read-only store rules the whole
# path out -- said here, before anything is rendered or handed to a mail server.
repository.assert_writable()
member = repository.get_member(member_id)
if not member.email.strip():
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
data = repository.get_contributions(member_id)
selected_ids = None if claim_ids is None else {str(value) for value in claim_ids}
claims = [
claim
for claim in open_claims(repository, member_id)
if selected_ids is None or str(claim.get("claim_id", "")) in selected_ids
]
if selected_ids is not None and len(claims) != len(selected_ids):
raise RepositoryError("Mindestens eine ausgewählte Forderung ist nicht mehr offen.")
balances = {
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
}
target: Path | None = None
if delivery_mode == "local":
if not output_path:
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
organization = repository.get_configuration().get("organization") or {}
content = welcome_mail_bytes(
repository,
member=member,
claims=claims,
balances=balances,
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
export_path: Path | None = None
archive_path: Path | None = None
archive_temp: Path | None = None
# Same rule as the dunning/SEPA mails: once the mail has reached the SMTP server,
# the IMAP folder or the local export file, a later archiving failure must not
# hide that it went out -- the event is written either way and the archiving
# problem is reported afterwards.
delivered = False
archive_failure: Exception | None = None
try:
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
archive_temp.write_bytes(content)
if delivery_mode == "local":
export_temp = target.with_name(f".{target.name}.tmp")
try:
export_temp.write_bytes(content)
os.replace(export_temp, target)
finally:
export_temp.unlink(missing_ok=True)
export_path = target
delivered = True
elif delivery_mode == "send":
settings = repository.get_email_settings()
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
send_via_smtp(smtp_client, content)
delivered = True
if imap_client is not None:
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
append_message(
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
)
else:
settings = repository.get_email_settings()
with imap_session(settings) as client:
ensure_imap_folder(client, settings["imap_drafts_folder"])
append_message(
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
)
delivered = True
os.replace(archive_temp, archive_path)
except Exception as exc:
if archive_temp is not None:
archive_temp.unlink(missing_ok=True)
if not delivered:
raise
archive_failure = exc
digest = hashlib.sha256(content).hexdigest()
references: dict[str, str] = {}
if archive_failure is None:
references["document"] = archive_path.relative_to(
repository.members_root / member_id / "files"
).as_posix()
event_data = {
"recipient": member.email,
"sha256": digest,
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
"delivery_mode": delivery_mode,
}
if archive_failure is not None:
event_data["archive_error"] = str(archive_failure)
repository.append_event(
member_id,
event_type="welcome_email_sent",
summary="Willkommens- und Erstrechnungsmail erzeugt",
actor_type="user",
actor_name="Vorstand",
references=references,
data=event_data,
)
if archive_failure is not None:
raise RepositoryError(
"Die Willkommensmail wurde zugestellt bzw. abgelegt, konnte aber nicht "
f"vollständig archiviert werden: {archive_failure}. Bitte die Mitgliedsakte "
"manuell prüfen."
) from archive_failure
return GeneratedWelcomeMail(export_path, archive_path, digest)
def _available_path(directory: Path, filename: str) -> Path:
candidate = directory / filename
if not candidate.exists():
return candidate
stem, suffix = Path(filename).stem, Path(filename).suffix
index = 2
while (directory / f"{stem}-{index}{suffix}").exists():
index += 1
return directory / f"{stem}-{index}{suffix}"
+14
View File
@@ -63,6 +63,20 @@ def write_json_atomic(path: Path, data: Any) -> None:
temporary.unlink(missing_ok=True)
def write_text_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
temporary = Path(temporary_name)
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def read_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -404,7 +404,11 @@ class AssetTab(ttk.Frame):
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_asset_record_hashes(self.asset_id)
try:
self.repository.refresh_asset_record_hashes(self.asset_id)
except RepositoryError as exc:
messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
return
self.refresh()
self.on_changed()
+457 -47
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable
from datetime import date
from decimal import Decimal
from pathlib import Path
from tkinter import messagebox, ttk
from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import (
CLAIM_STATUS_LABELS,
@@ -19,10 +19,13 @@ from ccma.domain.contributions import (
decimal_value,
money_text,
payment_allocated_total,
reminder_items_total,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
from ccma.ui.labels import (
CLAIM_ITEM_TYPE_LABELS,
REMINDER_CHANNEL_LABELS,
@@ -91,7 +94,9 @@ class ClaimTab(ttk.Frame):
self.edit_button = ttk.Button(footer, text="Forderung bearbeiten", command=self._edit_claim)
self.edit_button.grid(row=0, column=1, sticky="e", padx=(0, 8))
self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim)
self.cancel_button.grid(row=0, column=2, sticky="e")
self.cancel_button.grid(row=0, column=2, sticky="e", padx=(0, 8))
self.delete_button = ttk.Button(footer, text="Forderung löschen", command=self._delete_claim)
self.delete_button.grid(row=0, column=3, sticky="e")
def _build_ledger(self) -> None:
ledger = ttk.Frame(self, padding=12)
@@ -156,6 +161,17 @@ class ClaimTab(ttk.Frame):
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
)
self.send_reminder_button.pack(side="right", padx=(8, 0))
self.email_reminder_button = ttk.Button(
buttons,
text="E-Mail erzeugen & versandt",
command=self._email_reminder,
state="disabled",
)
self.email_reminder_button.pack(side="right", padx=(8, 0))
self.edit_reminder_button = ttk.Button(
buttons, text="Mahnung bearbeiten", command=self._edit_reminder, state="disabled"
)
self.edit_reminder_button.pack(side="right", padx=(8, 0))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
side="right", padx=(0, 8)
@@ -338,7 +354,7 @@ class ClaimTab(ttk.Frame):
reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"),
"",
"",
f"{reminder.get('fee', '0.00')} EUR",
f"{money_text(reminder_items_total(reminder))} EUR",
display_label(REMINDER_STATUS_LABELS, status),
" · ".join(
part
@@ -425,7 +441,24 @@ class ClaimTab(ttk.Frame):
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
state = "normal" if editable else "disabled"
self.send_reminder_button.configure(state=state)
self.email_reminder_button.configure(state=state)
self.discard_reminder_button.configure(state=state)
# Sent reminders can also be edited (revert-then-edit), just not cancelled ones.
can_edit = bool(reminder and str(reminder.get("status", "draft")) != "cancelled")
self.edit_reminder_button.configure(state="normal" if can_edit else "disabled")
def _email_reminder(self) -> None:
reminder = self._selected_reminder()
if reminder:
ReminderMailDialog(
self,
self.repository,
self.member_id,
self.claim_id,
str(reminder["reminder_id"]),
str(reminder.get("name", "Mahnung")),
self._changed,
)
def _send_reminder(self) -> None:
reminder = self._selected_reminder()
@@ -442,6 +475,32 @@ class ClaimTab(ttk.Frame):
return
self._changed()
def _edit_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder:
return
if str(reminder.get("status", "")) == "sent":
if not messagebox.askyesno(
"Versand zurücksetzen",
"Diese Mahnung wurde bereits versandt. Zum Bearbeiten wird der Versand "
"zurückgesetzt (eine bereits gebuchte Mahngebühr wird von der Forderung "
"entfernt und die Zahlungsfrist gelöscht) -- anschließend muss die Mahnung "
"erneut versandt werden. Fortfahren?",
parent=self,
):
return
try:
reminder = self.repository.revert_reminder_sent(
self.member_id, self.claim_id, str(reminder["reminder_id"])
)
except RepositoryError as exc:
messagebox.showerror("Versand konnte nicht zurückgesetzt werden", str(exc), parent=self)
return
self.refresh()
ReminderDialog(
self, self.repository, self.member_id, self.claim_id, self._changed, reminder=reminder
)
def _discard_reminder(self) -> None:
reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno(
@@ -481,6 +540,24 @@ class ClaimTab(ttk.Frame):
return
self._changed()
def _delete_claim(self) -> None:
allocated = allocated_total(self.data, self.claim_id)
detail = "Diese Forderung wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
if allocated:
detail += (
f"\n\nZugeordnete Zahlungen/Gutschriften in Höhe von {money_text(allocated)} EUR "
"werden dabei gelöst und stehen danach wieder frei zur Verfügung."
)
if not messagebox.askyesno("Forderung löschen", detail, parent=self):
return
try:
self.repository.delete_claim(self.member_id, self.claim_id)
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self.on_changed()
self.on_close()
def _edit_claim(self) -> None:
ClaimEditDialog(
self,
@@ -825,67 +902,297 @@ class AllocateCreditDialog(_Dialog):
class ReminderDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnung vorbereiten", on_saved)
"""Drafts the next reminder in the sequence for a claim. The level is computed
automatically (first not-yet-used level), and a "Vorlage" picks sensible defaults
for name/deadline/fee items -- the matching dunning-policy level if one exists,
a "Rücklastschrift" preset for a bounced direct debit (which occupies the same
level slot as a regular reminder, since it's still the member's own failure to
provide cover/valid bank details), or a blank template for manual entry. The fee
items table itself is always free to edit no matter which preset was picked."""
def __init__(self, master, repository, member_id, claim_id, on_saved, *, reminder: dict | None = None):
super().__init__(master, "Mahnung bearbeiten" if reminder else "Mahnung vorbereiten", on_saved)
# Unlike the small fixed-size dialogs sharing _Dialog, this one holds a table
# of freely-editable fee items whose content varies a lot -- make it resizable
# so a long/multi-item Mahnung doesn't get cramped.
self.resizable(True, True)
self.repository, self.member_id, self.claim_id = repository, member_id, claim_id
policy = repository.get_configuration().get("reminder_policy") or {}
levels = policy.get("levels") or [
{"level": 1, "name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 14}
]
self.definition_by_label = {
f"Stufe {item.get('level', '')}: {item.get('name', '')}": item for item in levels
}
self.level_var = tk.StringVar(value=next(iter(self.definition_by_label)))
self.detail_var = tk.StringVar()
self.fee_var = tk.StringVar()
self.deadline_var = tk.StringVar()
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
self.editing_reminder = reminder
if reminder:
self.level = int(reminder.get("level", 1))
else:
data, _claim = repository.get_claim(member_id, claim_id)
existing_levels = {
int(item.get("level", 0))
for item in data.reminders
if str(item.get("claim_id", "")) == claim_id
and str(item.get("status", "draft")) in {"draft", "generated", "sent"}
}
self.level = max(existing_levels, default=0) + 1
self.policy = repository.get_reminder_policy()
self.items: list[dict[str, str]] = []
self.frame.columnconfigure(1, weight=1)
self.frame.rowconfigure(6, weight=1)
ttk.Label(self.frame, text="Mahnstufe").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
level_combo = ttk.Combobox(
ttk.Label(self.frame, text=str(self.level), style="Mono.TLabel").grid(
row=0, column=1, sticky="w", pady=5
)
self.preset_by_label = self._build_presets()
default_preset = (
next(reversed(self.preset_by_label)) if reminder else next(iter(self.preset_by_label))
)
self.preset_var = tk.StringVar(value=default_preset)
ttk.Label(self.frame, text="Vorlage").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
preset_combo = ttk.Combobox(
self.frame,
textvariable=self.level_var,
values=list(self.definition_by_label),
textvariable=self.preset_var,
values=list(self.preset_by_label),
state="readonly",
width=38,
width=55,
)
level_combo.grid(row=0, column=1, sticky="ew", pady=5)
level_combo.bind("<<ComboboxSelected>>", lambda _event: self._load_definition())
ttk.Label(self.frame, text="Details").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.detail_var, width=45).grid(row=1, column=1, pady=5)
ttk.Label(self.frame, text="Mahngebühr").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.fee_var).grid(row=2, column=1, sticky="ew", pady=5)
preset_combo.grid(row=1, column=1, sticky="ew", pady=5)
preset_combo.bind("<<ComboboxSelected>>", lambda _event: self._apply_preset())
self.name_var = tk.StringVar()
ttk.Label(self.frame, text="Bezeichnung").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.name_var, width=64).grid(
row=2, column=1, sticky="ew", pady=5
)
self.detail_var = tk.StringVar()
ttk.Label(self.frame, text="Details").grid(row=3, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.detail_var, width=64).grid(
row=3, column=1, sticky="ew", pady=5
)
self.deadline_var = tk.StringVar()
ttk.Label(self.frame, text="Zahlungsfrist in Tagen").grid(
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
row=4, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=3, column=1, sticky="ew", pady=5)
ttk.Label(self.frame, text="Versandweg").grid(row=4, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.deadline_var).grid(row=4, column=1, sticky="ew", pady=5)
self.channel_var = tk.StringVar(value=REMINDER_CHANNEL_LABELS["email"])
ttk.Label(self.frame, text="Versandweg").grid(row=5, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
self.frame,
textvariable=self.channel_var,
values=list(REMINDER_CHANNEL_LABELS.values()),
state="readonly",
).grid(row=4, column=1, sticky="ew", pady=5)
self._load_definition()
self._buttons(5, self._save)
).grid(row=5, column=1, sticky="ew", pady=5)
def _load_definition(self) -> None:
definition = self.definition_by_label[self.level_var.get()]
self.fee_var.set(str(definition.get("fee", "0.00")))
self.deadline_var.set(str(definition.get("payment_deadline_days", 14)))
self._build_items_table(row=6)
if reminder:
self.name_var.set(str(reminder.get("name", "")))
self.detail_var.set(str(reminder.get("detail", "")))
self.deadline_var.set(str(reminder.get("payment_deadline_days", 14)))
self.channel_var.set(
display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", "email")))
)
self.items = [dict(item) for item in reminder.get("items") or []]
self._refresh_items()
else:
self._apply_preset()
self._buttons(7, self._save)
# Size to fit the fully built content (incl. the preset-populated items table)
# instead of whatever size Tk guessed before everything was in place -- the
# base dialog sets resizable(False, False) before this subclass's content
# exists, so an explicit geometry() is needed or the window stays clipped.
# The floor of 700 keeps the dialog comfortably wide even for a short name/
# detail; the user can still grow it further since it's resizable now.
self.update_idletasks()
width = max(700, self.winfo_reqwidth())
height = self.winfo_reqheight()
self.geometry(f"{width}x{height}")
self.minsize(width, height)
def _build_presets(self) -> dict[str, dict]:
presets: dict[str, dict] = {}
matching = next((lvl for lvl in self.policy["levels"] if lvl["level"] == self.level), None)
if matching:
fee = decimal_value(matching["fee"])
presets[f"Stufe {self.level}: {matching['name']}"] = {
"name": matching["name"],
"detail": "",
"payment_deadline_days": matching["payment_deadline_days"],
"items": [{"description": "Mahngebühr", "amount": money_text(fee)}] if fee > 0 else [],
}
failed_debit_amount = self._default_amount_for("Rücklastschrift")
presets[f"Stufe {self.level}: Rücklastschrift"] = {
"name": "Rücklastschrift",
# Prefilled so the member sees why they're being charged; the board can
# still append/edit a concrete reason (mangels Deckung, Konto ungültig, ...)
# right here since "Details" goes straight into the reminder email as-is.
"detail": "Leider konnte die Lastschrift von Ihrem Konto nicht eingelöst werden.",
"payment_deadline_days": 14,
"items": [{"description": "Rücklastschriftgebühr", "amount": failed_debit_amount}],
}
presets["Frei (eigene Eingabe)"] = {
"name": "", "detail": "", "payment_deadline_days": 14, "items": []
}
return presets
def _default_amount_for(self, needle: str) -> str:
for entry in self.policy["standard_fee_items"]:
if needle.casefold() in str(entry.get("description", "")).casefold():
return str(entry.get("default_amount", "0.00"))
return "0.00"
def _apply_preset(self) -> None:
preset = self.preset_by_label[self.preset_var.get()]
self.name_var.set(preset["name"])
self.detail_var.set(preset["detail"])
self.deadline_var.set(str(preset["payment_deadline_days"]))
self.items = [dict(item) for item in preset["items"]]
self._refresh_items()
def _build_items_table(self, row: int) -> None:
table_frame = ttk.LabelFrame(self.frame, text="Positionen", padding=8)
table_frame.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=(8, 0))
table_frame.columnconfigure(0, weight=1)
table_frame.rowconfigure(0, weight=1)
amount_column_width = 100
self.items_tree = ttk.Treeview(
table_frame, columns=("description", "amount"), show="headings", height=4, selectmode="browse"
)
self.items_tree.heading("description", text="Beschreibung")
self.items_tree.heading("amount", text="Betrag")
self.items_tree.column("description", width=320, anchor="w")
self.items_tree.column("amount", width=amount_column_width, anchor="w", stretch=False)
self.items_tree.grid(row=0, column=0, sticky="nsew")
self.items_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_item())
# Edit row lines up under the table's own columns: "Beschreibung" stretches to
# fill remaining space just like that Treeview column does, while "Betrag" sits
# in a frame pinned to the exact same pixel width as the fixed "amount" column
# above it (Entry's width= is in characters, not pixels, so a fixed-size frame
# is what gets a genuinely exact match instead of an approximate ratio).
edit_row = ttk.Frame(table_frame)
edit_row.grid(row=1, column=0, sticky="ew", pady=(8, 0))
edit_row.columnconfigure(0, weight=1)
edit_row.columnconfigure(1, weight=0)
self.item_description_var = tk.StringVar()
description_combo = ttk.Combobox(
edit_row,
textvariable=self.item_description_var,
values=[str(entry.get("description", "")) for entry in self.policy["standard_fee_items"]],
)
description_combo.grid(row=0, column=0, sticky="ew", padx=(0, 8))
description_combo.bind("<<ComboboxSelected>>", lambda _event: self._prefill_item_amount())
amount_frame = ttk.Frame(edit_row, width=amount_column_width)
amount_frame.grid(row=0, column=1, sticky="ns")
amount_frame.pack_propagate(False)
self.item_amount_var = tk.StringVar()
ttk.Entry(amount_frame, textvariable=self.item_amount_var).pack(fill="both", expand=True)
buttons_row = ttk.Frame(table_frame)
buttons_row.grid(row=2, column=0, sticky="w", pady=(8, 0))
ttk.Button(buttons_row, text="Position hinzufügen", command=self._add_item).pack(
side="left", padx=(0, 8)
)
ttk.Button(buttons_row, text="Aktualisieren", command=self._update_item).pack(
side="left", padx=(0, 8)
)
ttk.Button(buttons_row, text="Position entfernen", command=self._remove_item).pack(side="left")
def _prefill_item_amount(self) -> None:
description = self.item_description_var.get()
for entry in self.policy["standard_fee_items"]:
if str(entry.get("description", "")) == description:
self.item_amount_var.set(str(entry.get("default_amount", "0.00")))
return
def _refresh_items(self) -> None:
self.items_tree.delete(*self.items_tree.get_children())
for index, item in enumerate(self.items):
self.items_tree.insert(
"", "end", iid=str(index), values=(item["description"], f"{item['amount']} EUR")
)
def _load_selected_item(self) -> None:
selected = self.items_tree.selection()
if not selected:
return
item = self.items[int(selected[0])]
self.item_description_var.set(item["description"])
self.item_amount_var.set(item["amount"])
def _add_item(self) -> None:
description = self.item_description_var.get().strip()
if not description:
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
return
try:
amount = decimal_value(self.item_amount_var.get(), "Betrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.items.append({"description": description, "amount": money_text(amount)})
self.item_description_var.set("")
self.item_amount_var.set("")
self._refresh_items()
def _update_item(self) -> None:
selected = self.items_tree.selection()
if not selected:
messagebox.showerror(
"Auswahl fehlt", "Bitte die zu ändernde Position auswählen.", parent=self
)
return
description = self.item_description_var.get().strip()
if not description:
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
return
try:
amount = decimal_value(self.item_amount_var.get(), "Betrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.items[int(selected[0])] = {"description": description, "amount": money_text(amount)}
self._refresh_items()
def _remove_item(self) -> None:
selected = self.items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
del self.items[int(selected[0])]
self.item_description_var.set("")
self.item_amount_var.set("")
self._refresh_items()
def _save(self):
definition = self.definition_by_label[self.level_var.get()]
try:
self.repository.create_reminder_draft(
self.member_id,
self.claim_id,
level=int(definition.get("level", 1)),
name=str(definition.get("name", "Mahnung")),
payment_deadline_days=int(self.deadline_var.get()),
detail=self.detail_var.get(),
fee=self.fee_var.get(),
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
deadline_days = int(self.deadline_var.get())
except ValueError:
messagebox.showerror(
"Ungültige Frist", "Die Zahlungsfrist muss eine ganze Zahl sein.", parent=self
)
return
try:
if self.editing_reminder:
self.repository.edit_reminder_draft(
self.member_id,
self.claim_id,
str(self.editing_reminder["reminder_id"]),
name=self.name_var.get(),
payment_deadline_days=deadline_days,
detail=self.detail_var.get(),
items=self.items,
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
)
else:
self.repository.create_reminder_draft(
self.member_id,
self.claim_id,
level=self.level,
name=self.name_var.get(),
payment_deadline_days=deadline_days,
detail=self.detail_var.get(),
items=self.items,
channel=storage_key(REMINDER_CHANNEL_LABELS, self.channel_var.get()),
)
except (ValueError, RepositoryError) as exc:
messagebox.showerror("Mahnungsentwurf konnte nicht gespeichert werden", str(exc), parent=self)
return
@@ -893,6 +1200,109 @@ class ReminderDialog(_Dialog):
self.on_saved()
class ReminderMailDialog(tk.Toplevel):
def __init__(
self,
master,
repository,
member_id,
claim_id,
reminder_id,
reminder_name,
on_saved,
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
self.reminder_id = reminder_id
self.reminder_name = reminder_name
self.on_saved = on_saved
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(
value=str(organization.get("name") or "Verwaltung C3MA")
)
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.title("Mahnung als E-Mail erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="EML speichern und als versandt buchen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_path = None
if delivery_mode == "local":
selected = filedialog.asksaveasfilename(
parent=self,
title="Mahnung als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile=f"{self.reminder_name}.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not selected:
return
output_path = selected
try:
result = generate_and_send_reminder_mail(
self.repository,
self.member_id,
self.claim_id,
self.reminder_id,
delivery_mode=delivery_mode,
output_path=output_path,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
return
outcome = {
"local": f"gespeichert:\n{result.export_path}",
"send": "direkt versandt.",
"drafts": "als Entwurf im Postfach abgelegt.",
}[delivery_mode]
messagebox.showinfo(
"Mahnung erzeugt",
f"Die E-Mail wurde {outcome}\n\nDie Mahnung wurde als versandt gebucht.",
parent=self,
)
self.destroy()
self.on_saved()
class DunningHoldDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnsperre setzen", on_saved)
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from tkinter import messagebox, ttk
from ccma.domain.contributions import CONTRIBUTION_OVERRIDE_KIND_LABELS
from ccma.domain.dates import format_month_for_display, month_input_hint
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.labels import storage_key
class _Dialog(tk.Toplevel):
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
super().__init__(master)
self.on_saved = on_saved
self.title(title)
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame)
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
class ContributionOverrideEditDialog(_Dialog):
"""Records an individually agreed membership fee for a member -- e.g. a reduced
student rate -- for a month-granular date range, either as a fixed annual amount
or as a percentage discount off whichever base rate is in effect at the time."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
override: dict | None = None,
):
super().__init__(
master,
"Abweichenden Beitrag bearbeiten" if override else "Abweichenden Beitrag anlegen",
on_saved,
)
self.repository = repository
self.member_id = member_id
self.override_id = str(override.get("override_id")) if override else None
kind_label = CONTRIBUTION_OVERRIDE_KIND_LABELS.get(
str(override.get("kind", "")) if override else "amount",
CONTRIBUTION_OVERRIDE_KIND_LABELS["amount"],
)
self.variables = {
"valid_from": tk.StringVar(
value=format_month_for_display(str(override.get("valid_from", ""))) if override else ""
),
"valid_until": tk.StringVar(
value=format_month_for_display(str(override.get("valid_until", ""))) if override else ""
),
"kind": tk.StringVar(value=kind_label),
"value": tk.StringVar(value=str(override.get("value", "")) if override else ""),
}
self.frame.columnconfigure(1, weight=1)
ttk.Label(self.frame, text=f"Ab ({month_input_hint()})").grid(
row=0, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables["valid_from"], width=42).grid(
row=0, column=1, sticky="ew", pady=5
)
ttk.Label(self.frame, text=f"Bis ({month_input_hint()}, leer = unbefristet)").grid(
row=1, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables["valid_until"], width=42).grid(
row=1, column=1, sticky="ew", pady=5
)
ttk.Label(self.frame, text="Art").grid(row=2, column=0, sticky="w", pady=5, padx=(0, 12))
kind_combo = ttk.Combobox(
self.frame,
textvariable=self.variables["kind"],
values=list(CONTRIBUTION_OVERRIDE_KIND_LABELS.values()),
state="readonly",
width=39,
)
kind_combo.grid(row=2, column=1, sticky="ew", pady=5)
kind_combo.bind("<<ComboboxSelected>>", lambda _event: self._update_value_label())
self.value_label_var = tk.StringVar()
ttk.Label(self.frame, textvariable=self.value_label_var).grid(
row=3, column=0, sticky="w", pady=5, padx=(0, 12)
)
ttk.Entry(self.frame, textvariable=self.variables["value"], width=42).grid(
row=3, column=1, sticky="ew", pady=5
)
ttk.Label(self.frame, text="Begründung").grid(row=4, column=0, sticky="nw", pady=5, padx=(0, 12))
self.reason_text = tk.Text(self.frame, width=42, height=4, wrap="word")
self.reason_text.grid(row=4, column=1, sticky="ew", pady=5)
if override:
self.reason_text.insert("1.0", str(override.get("reason", "")))
self._update_value_label()
self._buttons(5, self._save)
def _update_value_label(self) -> None:
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
self.value_label_var.set("Jahresbetrag (EUR)" if kind == "amount" else "Ermäßigung (%)")
def _save(self) -> None:
kind = storage_key(CONTRIBUTION_OVERRIDE_KIND_LABELS, self.variables["kind"].get())
reason = self.reason_text.get("1.0", "end-1c")
try:
if self.override_id:
self.repository.update_contribution_override(
self.member_id,
self.override_id,
valid_from=self.variables["valid_from"].get(),
valid_until=self.variables["valid_until"].get(),
kind=kind,
value=self.variables["value"].get(),
reason=reason,
)
else:
self.repository.record_contribution_override(
self.member_id,
valid_from=self.variables["valid_from"].get(),
valid_until=self.variables["valid_until"].get(),
kind=kind,
value=self.variables["value"].get(),
reason=reason,
)
except RepositoryError as exc:
messagebox.showerror(
"Abweichender Beitrag konnte nicht gespeichert werden", str(exc), parent=self
)
return
self.destroy()
self.on_saved()
+278
View File
@@ -0,0 +1,278 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from email.parser import BytesParser
from email.policy import default as default_policy
from tkinter import filedialog, messagebox, ttk
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
from ccma.services.member_data_mail import (
data_review_mail_bytes,
data_review_recipients,
generate_data_review_mails,
)
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
class DataReviewMailDialog(tk.Toplevel):
"""Asks the members to check the data the club stores about them. Unlike the
other mails this one goes out to everybody at once, so the recipients are picked
from a list every member appears in -- with the active ones preselected."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
on_sent: Callable[[], None] | None = None,
):
super().__init__(master)
self.repository = repository
self.on_sent = on_sent
self.members: list[Member] = []
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.summary_var = tk.StringVar()
self.warning_var = tk.StringVar()
self.title("Datenüberprüfung anfragen")
self.transient(master.winfo_toplevel())
self.geometry("940x620")
self.minsize(760, 480)
self._build_ui()
self._refresh_members()
self.after_idle(self.grab_set)
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(3, weight=1)
ttk.Label(
self,
text=(
"Bittet die ausgewählten Mitglieder, die über sie gespeicherten Daten zu "
"prüfen. Jede Mail listet den Datensatz genau eines Mitglieds auf; die IBAN "
"erscheint dabei nur mit den letzten vier Stellen. Betreff und Text stammen "
"aus der Vorlage unter Optionen → E-Mail-Vorlagen."
),
wraplength=880,
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
ttk.Label(
self,
text=(
"Vorausgewählt sind alle aktiven Mitgliedschaften mit E-Mail-Adresse "
"einzelne mit Strg+Klick ab- oder zusätzlich auswählen."
),
style="Muted.TLabel",
wraplength=880,
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", padx=14, pady=(0, 6)
)
table = ttk.Frame(self)
table.grid(row=3, column=0, sticky="nsew", padx=14)
table.columnconfigure(0, weight=1)
table.rowconfigure(0, weight=1)
self.table = ttk.Treeview(
table,
columns=("number", "name", "status", "email"),
show="headings",
selectmode="extended",
)
for key, title, width, stretch in (
("number", "Nummer", 90, False),
("name", "Name", 220, True),
("status", "Status", 200, False),
("email", "E-Mail", 260, True),
):
self.table.heading(key, text=title)
self.table.column(key, width=width, anchor="w", stretch=stretch)
self.table.grid(row=0, column=0, sticky="nsew")
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
scroll.grid(row=0, column=1, sticky="ns")
self.table.configure(yscrollcommand=scroll.set)
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
fields = ttk.Frame(self)
fields.grid(row=4, column=0, sticky="ew", padx=14, pady=(10, 0))
fields.columnconfigure(1, weight=1)
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
footer = ttk.Frame(self, padding=14)
footer.grid(row=5, column=0, sticky="ew")
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.warning_var, style="Muted.TLabel", wraplength=520).grid(
row=0, column=0, sticky="w"
)
ttk.Button(footer, text="Abbrechen", command=self.destroy).grid(row=0, column=1, padx=(8, 0))
ttk.Button(footer, text="Vorschau", command=self._preview).grid(row=0, column=2, padx=(8, 0))
ttk.Button(
footer, text="Mails erzeugen", style="Accent.TButton", command=self._generate
).grid(row=0, column=3, padx=(8, 0))
def _refresh_members(self) -> None:
try:
self.members = self.repository.list_members()
preselected = {
member.member_id for member in data_review_recipients(self.repository)
}
except RepositoryError as exc:
messagebox.showerror("Mitglieder konnten nicht gelesen werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for index, member in enumerate(self.members):
self.table.insert(
"",
"end",
iid=str(index),
values=(
member.member_number,
member.display_name,
MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
member.email or "",
),
)
self.table.selection_set(
[
str(index)
for index, member in enumerate(self.members)
if member.member_id in preselected
]
)
without_email = [member for member in self.members if not member.email.strip()]
self.warning_var.set(
f"{len(without_email)} Mitglied(er) ohne E-Mail-Adresse werden übersprungen."
if without_email
else ""
)
self._update_summary()
def _selected_members(self) -> list[Member]:
return [self.members[int(iid)] for iid in sorted(self.table.selection(), key=int)]
def _update_summary(self) -> None:
selected = self._selected_members()
reachable = [member for member in selected if member.email.strip()]
self.summary_var.set(
f"{len(selected)} von {len(self.members)} Mitgliedern ausgewählt "
f"{len(reachable)} Mail(s) werden erzeugt"
)
def _preview(self) -> None:
selected = self._selected_members()
member = next((item for item in selected if item.email.strip()), None)
if member is None:
messagebox.showinfo(
"Keine Vorschau möglich",
"Bitte mindestens ein Mitglied mit E-Mail-Adresse auswählen.",
parent=self,
)
return
try:
content = data_review_mail_bytes(
self.repository,
member=member,
organization=self.repository.get_configuration().get("organization") or {},
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
return
message = BytesParser(policy=default_policy).parsebytes(content)
window = tk.Toplevel(self)
window.title(f"Vorschau für {member.display_name}")
window.transient(self)
window.columnconfigure(0, weight=1)
window.rowconfigure(1, weight=1)
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
row=0, column=0, sticky="w"
)
preview = tk.Text(window, wrap="word", width=88, height=30)
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
preview.insert("1.0", message.get_content())
preview.configure(state="disabled")
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
preview.configure(yscrollcommand=scroll.set)
ttk.Button(window, text="Schließen", command=window.destroy).grid(
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
)
def _generate(self) -> None:
recipients = [member for member in self._selected_members() if member.email.strip()]
if not recipients:
messagebox.showinfo(
"Keine Empfänger",
"Bitte mindestens ein Mitglied mit E-Mail-Adresse auswählen.",
parent=self,
)
return
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_directory = None
if delivery_mode == "local":
selected = filedialog.askdirectory(
parent=self,
title="Ausgabeordner für die Thunderbird-Entwürfe auswählen",
mustexist=True,
)
if not selected:
return
output_directory = selected
# A mailing to the whole club cannot be taken back, so the count is confirmed
# once more -- especially when "Senden" would put it on the wire right away.
action = {
"local": "als Datei abgelegt",
"send": "sofort versandt",
"drafts": "als Entwurf im Postfach abgelegt",
}[delivery_mode]
if not messagebox.askyesno(
"Datenüberprüfung verschicken",
f"An {len(recipients)} Mitglied(er) wird je eine Mail mit deren gespeicherten "
f"Daten {action}.\n\nJetzt erzeugen?",
parent=self,
):
return
self.configure(cursor="watch")
self.update_idletasks()
try:
generated, warnings = generate_data_review_mails(
self.repository,
[member.member_id for member in recipients],
delivery_mode=delivery_mode,
output_directory=output_directory,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Mails konnten nicht erzeugt werden", str(exc), parent=self)
return
finally:
self.configure(cursor="")
location = {
"local": f"in\n{output_directory}\nerzeugt",
"send": "versandt",
"drafts": "als Entwurf im Postfach abgelegt",
}[delivery_mode]
detail = (
f"{len(generated)} Mail(s) wurden {location} und in den Mitgliederakten archiviert."
)
if warnings:
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
if len(warnings) > 12:
detail += f"\n… und {len(warnings) - 12} weitere."
messagebox.showinfo("Datenüberprüfung verschickt", detail, parent=self)
self.destroy()
if self.on_sent is not None:
self.on_sent()
+2
View File
@@ -41,6 +41,7 @@ class NewMemberDialog(tk.Toplevel):
"email",
"phone",
"birth_date",
"application_date",
"member_number",
)
}
@@ -63,6 +64,7 @@ class NewMemberDialog(tk.Toplevel):
("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
(f"Antragsdatum ({date_input_hint()})", "application_date"),
]
if self.number_policy["mode"] == "manual":
fields.append(("Mitgliedsnummer *", "member_number"))
+223
View File
@@ -0,0 +1,223 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import date
from decimal import Decimal
from tkinter import messagebox, ttk
from ccma.domain.contributions import (
decimal_value,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
class _Dialog(tk.Toplevel):
def __init__(self, master: tk.Misc, title: str, on_saved: Callable[[], None]):
super().__init__(master)
self.on_saved = on_saved
self.title(title)
self.transient(master.winfo_toplevel())
self.resizable(False, False)
self.frame = ttk.Frame(self, padding=18)
self.frame.pack(fill="both", expand=True)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _buttons(self, row: int, command: Callable[[], None]) -> None:
buttons = ttk.Frame(self.frame)
buttons.grid(row=row, column=0, columnspan=2, sticky="e", pady=(16, 0))
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Speichern", style="Accent.TButton", command=command).pack(side="left")
class DonationEditDialog(_Dialog):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
donation: dict | None = None,
):
super().__init__(
master, "Spende bearbeiten" if donation else "Spende anlegen", on_saved
)
self.repository = repository
self.member_id = member_id
self.donation_id = str(donation.get("donation_id")) if donation else None
initial_date = str(donation.get("date", "")) if donation else date.today().isoformat()
self.variables = {
"date": tk.StringVar(value=format_date_for_display(initial_date)),
"amount": tk.StringVar(value=str(donation.get("amount", "")) if donation else ""),
"reference": tk.StringVar(value=str(donation.get("reference", "")) if donation else ""),
"purpose": tk.StringVar(value=str(donation.get("purpose", "")) if donation else ""),
}
fields = (
(f"Spendendatum ({date_input_hint()})", "date"),
("Betrag", "amount"),
("Referenz", "reference"),
("Verwendungszweck", "purpose"),
)
self.frame.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
self._buttons(len(fields), self._save)
def _save(self) -> None:
try:
if self.donation_id:
self.repository.update_donation(
self.member_id,
self.donation_id,
donation_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
reference=self.variables["reference"].get(),
purpose=self.variables["purpose"].get(),
)
else:
self.repository.record_donation(
self.member_id,
donation_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
reference=self.variables["reference"].get(),
purpose=self.variables["purpose"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Spende konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class DonationPaymentDialog(_Dialog):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
donation_id: str,
balance: Decimal,
on_saved: Callable[[], None],
):
super().__init__(master, "Zahlung für Spende erfassen", on_saved)
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
initial = money_text(max(balance, Decimal("0")))
self.variables = {
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
"amount": tk.StringVar(value=initial),
"allocation": tk.StringVar(value=initial),
"gnucash": tk.StringVar(),
"reference": tk.StringVar(),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("Dieser Spende zuordnen", "allocation"),
("GnuCash-ID (optional)", "gnucash"),
("Referenz", "reference"),
)
self.frame.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(fields):
ttk.Label(self.frame, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.variables[key], width=38).grid(row=row, column=1, pady=5)
self._buttons(len(fields), self._save)
def _save(self) -> None:
try:
self.repository.record_donation_payment(
self.member_id,
self.donation_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocation_amount=self.variables["allocation"].get(),
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class AllocateDonationPaymentDialog(_Dialog):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
donation_id: str,
balance: Decimal,
on_saved: Callable[[], None],
):
super().__init__(master, "Vorhandene Zahlung zuordnen", on_saved)
self.repository, self.member_id, self.donation_id = repository, member_id, donation_id
data = repository.get_contributions(member_id)
self.payment_by_label = {}
for payment in sorted(
data.payments,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
payment_id = str(payment.get("payment_id", ""))
available = decimal_value(payment.get("amount", "0")) - payment_allocated_total(data, payment_id)
if available <= 0:
continue
label = (
f"{payment.get('date', '')} · {money_text(available)} EUR frei · "
f"{payment.get('reference', '')}"
)
self.payment_by_label[label] = (payment_id, available)
self.payment_var = tk.StringVar()
self.amount_var = tk.StringVar(value=money_text(max(balance, Decimal("0"))))
self.frame.columnconfigure(1, weight=1)
ttk.Label(self.frame, text="Zahlung").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
combo = ttk.Combobox(
self.frame,
textvariable=self.payment_var,
values=list(self.payment_by_label),
state="readonly",
width=60,
)
combo.grid(row=0, column=1, pady=5)
if not self.payment_by_label:
ttk.Label(
self.frame,
text="Für dieses Mitglied gibt es keine Zahlung mit freiem Restbetrag.",
style="Mono.TLabel",
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(3, 5))
amount_row = 2
else:
amount_row = 1
ttk.Label(self.frame, text="Betrag").grid(row=amount_row, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(self.frame, textvariable=self.amount_var).grid(
row=amount_row, column=1, sticky="ew", pady=5
)
combo.bind("<<ComboboxSelected>>", lambda _event: self._select(balance))
self._buttons(amount_row + 1, self._save)
def _select(self, balance: Decimal) -> None:
_payment_id, available = self.payment_by_label[self.payment_var.get()]
self.amount_var.set(money_text(min(available, max(balance, Decimal("0")))))
def _save(self) -> None:
selected = self.payment_by_label.get(self.payment_var.get())
if not selected:
messagebox.showerror("Zahlung auswählen", "Bitte eine Zahlung auswählen.", parent=self)
return
try:
self.repository.allocate_payment_to_donation(
self.member_id, self.donation_id, payment_id=selected[0], amount=self.amount_var.get()
)
except RepositoryError as exc:
messagebox.showerror("Zuordnung fehlgeschlagen", str(exc), parent=self)
return
self.destroy()
self.on_saved()
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
from ccma.storage.repository import MemberRepository
class EmailDeliveryChoiceDialog(tk.Toplevel):
"""Shown once per generation action (not per individual e-mail) when the
repository's delivery mode is "ask" -- lets the board decide per-batch whether
to send immediately or park the e-mail(s) as a draft for later review."""
def __init__(self, master: tk.Misc, *, can_send: bool, can_draft: bool):
super().__init__(master)
self.result: str | None = None
self.title("E-Mail-Versand")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
ttk.Label(
frame,
text="Wie soll mit dieser E-Mail verfahren werden?",
wraplength=360,
justify="left",
).pack(anchor="w", pady=(0, 14))
buttons = ttk.Frame(frame)
buttons.pack(anchor="e")
ttk.Button(buttons, text="Abbrechen", command=self._cancel).pack(side="left", padx=(0, 8))
send_button = ttk.Button(buttons, text="Senden", command=self._choose_send)
send_button.pack(side="left", padx=(0, 8))
send_button.configure(state="normal" if can_send else "disabled")
draft_button = ttk.Button(
buttons, text="Als Entwurf ablegen", style="Accent.TButton", command=self._choose_drafts
)
draft_button.pack(side="left")
draft_button.configure(state="normal" if can_draft else "disabled")
self.bind("<Escape>", lambda _event: self._cancel())
self.protocol("WM_DELETE_WINDOW", self._cancel)
self.after_idle(self.grab_set)
def _choose_send(self) -> None:
self.result = "send"
self.destroy()
def _choose_drafts(self) -> None:
self.result = "drafts"
self.destroy()
def _cancel(self) -> None:
self.result = None
self.destroy()
def resolve_delivery_mode(master: tk.Misc, repository: MemberRepository) -> str | None:
"""Returns the delivery mode to use for the next generated e-mail(s): "local",
"send" or "drafts" -- or None if the board cancelled out of the "ask every time"
prompt, in which case the caller should abort without generating anything."""
settings = repository.get_email_settings()
mode = settings["delivery_mode"]
if mode in {"local", "send", "drafts"}:
return mode
dialog = EmailDeliveryChoiceDialog(
master, can_send=bool(settings["smtp_host"]), can_draft=bool(settings["imap_host"])
)
master.wait_window(dialog)
return dialog.result
+419
View File
@@ -0,0 +1,419 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from decimal import Decimal
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import money_text
from ccma.domain.dates import (
DateValidationError,
date_input_hint,
format_date_for_display,
normalize_date_input,
)
from ccma.services.gnucash_import import (
GnuCashAccount,
GnuCashImportError,
GnuCashTransaction,
list_accounts,
list_transactions,
)
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.monitors import centered_geometry, preferred_monitor
class GnuCashImportDialog(tk.Toplevel):
"""Lets the board point at a GnuCash file, pick one account, narrow its bookings
down with filters, and select (multi-select: click, Ctrl+click, Shift+click for
ranges) which ones to import as payments for this member.
Only bookings with a positive amount on the selected account are offered (money
coming in). A booking whose date + amount already match an existing payment for
this member is flagged, but can still be selected -- at import time, the board is
asked whether to skip those or instead adopt the booking's description onto the
already-recorded payment, rather than silently blocking them."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
config: AppConfig,
on_imported: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.config = config
self.on_imported = on_imported
self.accounts: list[GnuCashAccount] = []
self.account_by_label: dict[str, GnuCashAccount] = {}
self.transactions: list[GnuCashTransaction] = []
self.existing_payment_by_key: dict[tuple[str, str], str] = {}
self.title("Zahlungen aus GnuCash importieren")
self.transient(master.winfo_toplevel())
monitor = preferred_monitor(master.winfo_toplevel())
self.geometry(centered_geometry(round(monitor.width * 0.8), round(monitor.height * 0.8), monitor))
self.minsize(780, 480)
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_ui()
self._load_existing_payment_keys()
self.after_idle(self._activate)
if self.file_var.get().strip():
self._load_file()
def _activate(self) -> None:
try:
self.deiconify()
self.lift()
self.focus_force()
self.grab_set()
except tk.TclError:
return
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
file_frame = ttk.Frame(self, padding=(16, 16, 16, 8))
file_frame.grid(row=0, column=0, sticky="ew")
file_frame.columnconfigure(1, weight=1)
self.file_var = tk.StringVar(value=self.config.gnucash_path)
ttk.Label(file_frame, text="GnuCash-Datei").grid(row=0, column=0, sticky="w", padx=(0, 12))
ttk.Entry(file_frame, textvariable=self.file_var).grid(row=0, column=1, sticky="ew")
ttk.Button(file_frame, text="Durchsuchen…", command=self._browse_file).grid(
row=0, column=2, padx=(8, 0)
)
ttk.Label(file_frame, text="Konto").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=(8, 0))
self.account_var = tk.StringVar()
self.account_combo = ttk.Combobox(
file_frame, textvariable=self.account_var, state="readonly"
)
self.account_combo.grid(row=1, column=1, sticky="ew", pady=(8, 0))
self.account_combo.bind("<<ComboboxSelected>>", lambda _event: self._account_selected())
ttk.Button(file_frame, text="Datei laden", command=self._load_file).grid(
row=1, column=2, padx=(8, 0), pady=(8, 0)
)
filter_frame = ttk.Frame(self, padding=(16, 0, 16, 8))
filter_frame.grid(row=1, column=0, sticky="ew")
ttk.Label(filter_frame, text="Beschreibung enthält").pack(side="left")
self.description_filter_var = tk.StringVar()
ttk.Entry(filter_frame, textvariable=self.description_filter_var, width=26).pack(
side="left", padx=(8, 16)
)
self.description_filter_var.trace_add("write", lambda *_args: self._render_transactions())
ttk.Label(filter_frame, text=f"Von ({date_input_hint()})").pack(side="left")
self.date_from_var = tk.StringVar()
ttk.Entry(filter_frame, textvariable=self.date_from_var, width=12).pack(side="left", padx=(8, 16))
self.date_from_var.trace_add("write", lambda *_args: self._render_transactions())
ttk.Label(filter_frame, text=f"Bis ({date_input_hint()})").pack(side="left")
self.date_to_var = tk.StringVar()
ttk.Entry(filter_frame, textvariable=self.date_to_var, width=12).pack(side="left", padx=(8, 0))
self.date_to_var.trace_add("write", lambda *_args: self._render_transactions())
tree_frame = ttk.Frame(self, padding=(16, 0, 16, 8))
tree_frame.grid(row=2, column=0, sticky="nsew")
tree_frame.columnconfigure(0, weight=1)
tree_frame.rowconfigure(0, weight=1)
self.tree = ttk.Treeview(
tree_frame,
columns=("date", "description", "memo", "amount", "status"),
show="headings",
selectmode="extended",
)
for key, title, width in (
("date", "Datum", 100),
("description", "Beschreibung", 280),
("memo", "Memo", 180),
("amount", "Betrag", 100),
("status", "Status", 220),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w", stretch=key in {"description", "memo"})
self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.tag_configure("duplicate", background="#7a2323", foreground="#ffffff")
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
ttk.Label(
self,
text=(
"Es werden nur Buchungen mit positivem Betrag auf dem gewählten Konto angezeigt "
"(eingehende Zahlungen). Mehrfachauswahl per Klick, Strg+Klick oder Umschalt+Klick. "
"Rot markierte Buchungen haben Datum und Betrag einer bereits vorhandenen Zahlung "
"dieses Mitglieds; beim Import kann gewählt werden, ob diese übersprungen werden "
"oder ob die Beschreibung der vorhandenen Zahlung ersetzt wird."
),
style="Mono.TLabel",
wraplength=900,
).grid(row=3, column=0, sticky="w", padx=16)
summary_frame = ttk.Frame(self, padding=(16, 8, 16, 8))
summary_frame.grid(row=4, column=0, sticky="ew")
self.summary_var = tk.StringVar(value="Keine Datei geladen.")
ttk.Label(summary_frame, textvariable=self.summary_var, style="Mono.TLabel").pack(side="left")
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
buttons.grid(row=5, column=0, sticky="e")
ttk.Button(buttons, text="Schließen", command=self.destroy).pack(side="left", padx=(0, 8))
self.import_button = ttk.Button(
buttons,
text="Ausgewählte importieren",
style="Accent.TButton",
command=self._import_selected,
)
self.import_button.pack(side="left")
def _load_existing_payment_keys(self) -> None:
data = self.repository.get_contributions(self.member_id)
self.existing_payment_by_key = {}
for payment in data.payments:
key = (str(payment.get("date", "")), money_text(payment.get("amount", "0")))
self.existing_payment_by_key.setdefault(key, str(payment.get("payment_id", "")))
def _browse_file(self) -> None:
current_text = self.file_var.get().strip()
current = Path(current_text).expanduser() if current_text else Path.home()
initial_dir = current.parent if current.is_file() else current
selected = filedialog.askopenfilename(
parent=self,
title="GnuCash-Datei auswählen",
initialdir=str(initial_dir) if initial_dir.is_dir() else str(Path.home()),
filetypes=[("GnuCash-Dateien", "*.gnucash *.xac"), ("Alle Dateien", "*.*")],
)
if selected:
self.file_var.set(selected)
self._load_file()
def _load_file(self) -> None:
path_text = self.file_var.get().strip()
if not path_text:
messagebox.showinfo("GnuCash-Datei", "Bitte zuerst eine GnuCash-Datei auswählen.", parent=self)
return
path = Path(path_text).expanduser()
if not path.is_file():
messagebox.showerror("GnuCash-Datei", "Die ausgewählte Datei existiert nicht.", parent=self)
return
try:
self.accounts = list_accounts(path)
except GnuCashImportError as exc:
messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self)
return
self.account_by_label = {
f"{account.full_name} ({account.account_type})": account for account in self.accounts
}
self.account_combo.configure(values=list(self.account_by_label))
if self.account_by_label and self.account_var.get() not in self.account_by_label:
self.account_var.set(self._preferred_account_label() or next(iter(self.account_by_label)))
if self.account_var.get():
self._account_selected()
else:
self.transactions = []
self._render_transactions()
if path_text != self.config.gnucash_path:
self.config.gnucash_path = path_text
self.config.save()
def _current_file_key(self) -> str | None:
path_text = self.file_var.get().strip()
if not path_text:
return None
path = Path(path_text).expanduser()
if not path.is_file():
return None
return str(path.resolve())
def _preferred_account_label(self) -> str | None:
file_key = self._current_file_key()
if file_key is None:
return None
last_guid = self.config.gnucash_last_accounts.get(file_key)
if not last_guid:
return None
return next(
(label for label, account in self.account_by_label.items() if account.guid == last_guid),
None,
)
def _account_selected(self) -> None:
account = self.account_by_label.get(self.account_var.get())
if account is None:
return
path = Path(self.file_var.get().strip()).expanduser()
try:
all_transactions = list_transactions(path, account.guid)
except GnuCashImportError as exc:
messagebox.showerror("GnuCash-Datei konnte nicht gelesen werden", str(exc), parent=self)
self.transactions = []
self._render_transactions()
return
self.transactions = [item for item in all_transactions if item.amount > 0]
self._render_transactions()
file_key = self._current_file_key()
if file_key is not None and self.config.gnucash_last_accounts.get(file_key) != account.guid:
self.config.gnucash_last_accounts[file_key] = account.guid
self.config.save()
def _filtered_transactions(self) -> list[GnuCashTransaction]:
needle = self.description_filter_var.get().strip().casefold()
try:
date_from = normalize_date_input(self.date_from_var.get(), "Von") or None
except DateValidationError:
date_from = None
try:
date_to = normalize_date_input(self.date_to_var.get(), "Bis") or None
except DateValidationError:
date_to = None
result = []
for item in self.transactions:
if needle and needle not in item.description.casefold() and needle not in item.memo.casefold():
continue
iso = item.date.isoformat()
if date_from and iso < date_from:
continue
if date_to and iso > date_to:
continue
result.append(item)
return result
def _duplicate_payment_id(self, item: GnuCashTransaction) -> str | None:
return self.existing_payment_by_key.get((item.date.isoformat(), money_text(item.amount)))
def _is_duplicate(self, item: GnuCashTransaction) -> bool:
return self._duplicate_payment_id(item) is not None
def _render_transactions(self) -> None:
previously_selected = set(self.tree.selection())
self.tree.delete(*self.tree.get_children())
for item in self._filtered_transactions():
duplicate = self._is_duplicate(item)
self.tree.insert(
"",
"end",
iid=item.guid,
values=(
format_date_for_display(item.date.isoformat()),
item.description,
item.memo,
f"{money_text(item.amount)} EUR",
"Bereits vorhanden" if duplicate else "",
),
tags=("duplicate",) if duplicate else (),
)
still_present = [guid for guid in previously_selected if self.tree.exists(guid)]
if still_present:
self.tree.selection_set(still_present)
self._update_summary()
def _update_summary(self) -> None:
filtered = self._filtered_transactions()
selected_guids = set(self.tree.selection())
selected_items = [item for item in filtered if item.guid in selected_guids]
total = sum((item.amount for item in selected_items), Decimal("0"))
self.summary_var.set(
f"{len(filtered)} Buchungen gefunden · {len(selected_items)} ausgewählt · "
f"{money_text(total)} EUR"
)
def _import_selected(self) -> None:
selected_guids = set(self.tree.selection())
selected_items = [item for item in self.transactions if item.guid in selected_guids]
if not selected_items:
messagebox.showinfo("Import", "Bitte mindestens eine Buchung auswählen.", parent=self)
return
new_items = [item for item in selected_items if not self._is_duplicate(item)]
duplicate_items = [item for item in selected_items if self._is_duplicate(item)]
replace_description = False
if duplicate_items:
response = messagebox.askyesnocancel(
"Bereits vorhandene Buchungen ausgewählt",
(
f"{len(duplicate_items)} der ausgewählten Buchungen stimmen in Datum und Betrag "
"mit bereits vorhandenen Zahlungen dieses Mitglieds überein.\n\n"
"Ja: Diese werden beim Import übersprungen.\n"
"Nein: Bei diesen wird stattdessen die Beschreibung der vorhandenen Zahlung "
"durch die Beschreibung aus GnuCash ersetzt.\n"
"Abbrechen: Es wird nichts importiert."
),
parent=self,
)
if response is None:
return
replace_description = not response
imported = 0
updated = 0
processed_guids: set[str] = set()
errors: list[str] = []
for item in new_items:
try:
self.repository.create_payment(
self.member_id,
payment_date=item.date.isoformat(),
amount=money_text(item.amount),
reference=item.description,
gnucash_transaction_id=item.guid,
)
imported += 1
processed_guids.add(item.guid)
except RepositoryError as exc:
errors.append(
f"{format_date_for_display(item.date.isoformat())} · {money_text(item.amount)} EUR: {exc}"
)
if replace_description:
for item in duplicate_items:
payment_id = self._duplicate_payment_id(item)
if not payment_id:
continue
try:
self.repository.update_payment_reference(
self.member_id,
payment_id,
reference=item.description,
gnucash_transaction_id=item.guid,
)
updated += 1
processed_guids.add(item.guid)
except RepositoryError as exc:
errors.append(
f"{format_date_for_display(item.date.isoformat())} · "
f"{money_text(item.amount)} EUR: {exc}"
)
if imported or updated:
self._load_existing_payment_keys()
self._render_transactions()
still_selected = set(self.tree.selection()) - processed_guids
self.tree.selection_set(list(still_selected))
summary_parts = []
if imported:
summary_parts.append(f"{imported} Zahlung(en) importiert")
if updated:
summary_parts.append(f"{updated} Beschreibung(en) aktualisiert")
skipped = len(duplicate_items) if not replace_description else 0
if skipped:
summary_parts.append(f"{skipped} übersprungen (bereits vorhanden)")
summary = ", ".join(summary_parts) or "Keine Buchung verarbeitet."
if errors:
messagebox.showwarning(
"Import teilweise fehlgeschlagen",
f"{summary}.\n\nFehler:\n" + "\n".join(errors),
parent=self,
)
else:
messagebox.showinfo("Import abgeschlossen", f"{summary}.", parent=self)
if imported or updated:
self.on_imported()
+13
View File
@@ -30,6 +30,19 @@ REMINDER_CHANNEL_LABELS = {
"personal": "Persönlich",
}
EMAIL_DELIVERY_MODE_LABELS = {
"local": "Lokal speichern (bisheriges Verhalten)",
"send": "Direkt versenden",
"drafts": "Als Entwurf im Postfach ablegen",
"ask": "Jedes Mal fragen",
}
EMAIL_SECURITY_LABELS = {
"starttls": "STARTTLS",
"ssl": "SSL/TLS",
"none": "Keine",
}
def display_label(labels: Mapping[str, str], key: str) -> str:
return labels.get(key, key)
+113 -9
View File
@@ -10,10 +10,13 @@ from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.asset_tab import AssetTab
from ccma.ui.claim_tab import ClaimTab
from ccma.ui.data_review_mail_dialog import DataReviewMailDialog
from ccma.ui.dialogs import EditAssetDialog, IssueAssetDialog, NewAssetDialog, NewMemberDialog
from ccma.ui.icons import IconStore
from ccma.ui.member_tab import MemberTab
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.options_dialog import OptionsDialog
from ccma.ui.sepa_dialog import SepaExportDialog
from ccma.ui.theme import load_theme
from ccma.ui.work_tabs import AssetsTab, DashboardTab, HousekeeperTab, MembersTab, SearchResultsTab
@@ -97,10 +100,14 @@ class MainWindow(ttk.Frame):
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=1)
self._build_ribbon()
# The read-only notice sits above the tabs rather than inside one, so it stays
# visible no matter which member, asset or housekeeper tab is in front.
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(10, 0))
self.notebook = ttk.Notebook(self)
self.notebook.grid(row=1, column=0, sticky="nsew", pady=(10, 0))
self.notebook.grid(row=2, column=0, sticky="nsew", pady=(10, 0))
self.tabs = TabManager(self.notebook)
self.dashboard = DashboardTab(
self.notebook,
@@ -116,16 +123,75 @@ class MainWindow(ttk.Frame):
icon_name="view-dashboard",
)
status = ttk.Frame(self, padding=(6, 5))
status.grid(row=2, column=0, sticky="ew")
status.grid(row=3, column=0, sticky="ew")
status.columnconfigure(0, weight=1)
ttk.Label(status, textvariable=self.status_var, style="Status.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(
status,
text=f"STORE {self.repository.root} · VERSION {__version__}",
style="Status.TLabel",
).grid(row=0, column=1, sticky="e")
self.store_var = tk.StringVar()
ttk.Label(status, textvariable=self.store_var, style="Status.TLabel").grid(
row=0, column=1, sticky="e"
)
# Everything that reflects write access is refreshed from one place, so a
# store that becomes writable mid-session updates all of it together.
self._refresh_store_state()
def _refresh_store_state(self) -> None:
"""Window title, status bar and banner all say whether the store can be
written to -- they are rebuilt together so none of them can be left behind."""
read_only = self.repository.read_only
title = f"CCMA · v{__version__}"
self.master.title(f"{title} · NUR LESEN (Store schreibgeschützt)" if read_only else title)
store_label = f"STORE {self.repository.root}"
if read_only:
store_label = f"{store_label} · NUR LESEN"
self.store_var.set(f"{store_label} · VERSION {__version__}")
messages = []
if read_only:
messages.append(
TabMessage(
"warning",
"ACHTUNG: Der Mitglieder-Store ist schreibgeschützt eingebunden. "
"Alle Daten sind nur lesbar Änderungen, Dokumente, E-Mails und der "
"Hausmeister sind deaktiviert. Den Store mit Schreibrechten einbinden "
"und hier auf „Erneut prüfen“ klicken ein Neustart ist dafür nicht "
"nötig.",
MessageAction("Erneut prüfen", self._recheck_store_access),
)
)
self.messages.set_messages(messages)
def _recheck_store_access(self) -> None:
"""Lets the board remount the volume without restarting first -- if it came
back writable, every read-only marker goes away and the housekeeper, skipped
at startup, can run right away."""
if self.repository.refresh_read_only():
self._refresh_store_state()
self.status_var.set("Der Store ist weiterhin schreibgeschützt.")
return
self._refresh_store_state()
self.status_var.set("Der Store ist wieder beschreibbar.")
# The startup pass was skipped, so the task list is empty rather than current:
# offer the run that fills it instead of leaving a misleading dashboard.
if messagebox.askyesno(
"Store beschreibbar",
"Der Mitglieder-Store ist jetzt mit Schreibrechten eingebunden. Der "
"Hausmeister wurde beim Start übersprungen, seine Vorgangsliste ist daher "
"noch leer.\n\nJetzt einen Hausmeisterlauf starten?",
parent=self,
):
self.run_housekeeper()
def _refuse_read_only(self, action: str) -> bool:
if not self.repository.read_only:
return False
messagebox.showwarning(
"Store ist schreibgeschützt",
f"{action} ist nicht möglich, solange der Mitglieder-Store nur lesend "
"eingebunden ist.",
parent=self,
)
return True
def _build_ribbon(self) -> None:
ribbon = ttk.Frame(self, style="Ribbon.TFrame")
@@ -187,6 +253,13 @@ class MainWindow(ttk.Frame):
)
assets_button.image = assets_icon
assets_button.pack(side="left", padx=(0, 6))
sepa_icon = self.icons.get("bank-transfer", 24) or self.icons.get("bank", 24)
sepa_button = ttk.Button(
actions, text="SEPA", image=sepa_icon, compound="top", width=14,
command=self.open_sepa_export,
)
sepa_button.image = sepa_icon
sepa_button.pack(side="left", padx=(0, 6))
new_icon = self.icons.get("account-plus", 24)
new_button = ttk.Button(
actions,
@@ -287,12 +360,14 @@ class MainWindow(ttk.Frame):
self.notebook,
self.repository,
member_id,
self.config,
on_close=lambda: self.tabs.close(key),
on_changed=self.refresh_overview,
on_open_claim=self.open_claim,
on_open_assets=self.open_assets,
on_open_asset=self.open_asset,
on_return_asset=self.return_asset,
on_run_housekeeper=self.run_housekeeper_for_member,
)
self.tabs.add(
key,
@@ -371,6 +446,7 @@ class MainWindow(ttk.Frame):
self.findings,
self.open_member,
lambda: self.tabs.close(key),
self.open_data_review_mail,
)
self.tabs.add(
key,
@@ -403,10 +479,22 @@ class MainWindow(ttk.Frame):
icon_name="key-variant",
)
def open_sepa_export(self) -> None:
SepaExportDialog(self, self.repository)
def open_data_review_mail(self) -> None:
if self._refuse_read_only("Der Versand der Datenüberprüfung"):
return
DataReviewMailDialog(self, self.repository)
def new_member(self) -> None:
if self._refuse_read_only("Das Anlegen eines Mitglieds"):
return
NewMemberDialog(self, self.repository, self._member_created)
def new_asset(self) -> None:
if self._refuse_read_only("Das Anlegen eines Assets"):
return
NewAssetDialog(self, self.repository, self._asset_created)
def _member_created(self, member: Member) -> None:
@@ -486,19 +574,35 @@ class MainWindow(ttk.Frame):
)
def run_housekeeper(self) -> list[HousekeeperFinding]:
if self._refuse_read_only("Ein Hausmeisterlauf"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
self.refresh_overview(run_housekeeper=False)
self.status_var.set(f"Hausmeisterlauf beendet: {len(self.findings)} Vorgänge.")
return self.findings
def run_housekeeper_for_member(
self, member_id: str, retroactive_claims: bool = False
) -> list[HousekeeperFinding]:
if self._refuse_read_only("Ein Hausmeisterlauf"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run(
member_id=member_id, retroactive_claims=retroactive_claims or None
)
self.refresh_overview(run_housekeeper=False)
self.status_var.set(f"Hausmeisterlauf für Mitglied beendet: {len(self.findings)} Vorgänge insgesamt.")
return self.findings
def delete_housekeeper_task(self, key: str) -> list[HousekeeperFinding]:
if self._refuse_read_only("Das Löschen eines Hausmeister-Tasks"):
return self.findings
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).delete_task(key)
self.refresh_overview(run_housekeeper=False)
self.status_var.set("Hausmeister-Task gelöscht.")
return self.findings
def refresh_overview(self, *, run_housekeeper: bool = True) -> None:
if run_housekeeper:
if run_housekeeper and not self.repository.read_only:
self.findings = Housekeeper(self.repository, self.config.housekeeper_settings()).run()
self.dashboard.update_data(self.repository.member_count(), self.findings)
members_tab = self.tabs.tabs.get("members")
+471 -29
View File
@@ -7,30 +7,51 @@ from decimal import Decimal
from pathlib import Path
from tkinter import messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import (
CLAIM_STATUS_LABELS,
CONTRIBUTION_OVERRIDE_KIND_LABELS,
DONATION_STATUS_LABELS,
claim_status,
claim_total,
donation_allocated_total,
donation_balance,
donation_status,
money_text,
payment_allocated_total,
)
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, Event
from ccma.domain.dates import age_label, date_input_hint, format_date_for_display, format_month_for_display
from ccma.domain.models import ASSET_STATUS_LABELS, PAYMENT_FREQUENCY_LABELS, Event, HousekeeperFinding
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS as STATUS_LABELS
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.contribution_override_dialog import ContributionOverrideEditDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.donation_dialog import (
AllocateDonationPaymentDialog,
DonationEditDialog,
DonationPaymentDialog,
)
from ccma.ui.file_open import open_path
from ccma.ui.gnucash_import_dialog import GnuCashImportDialog
from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentEditDialog
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
from ccma.ui.scrolling import ScrollableFrame
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
# and amount-like columns get the same fixed width, status columns get a third more
# (they tend to hold longer label text), and only the trailing description/reference
# column stretches to take up whatever space remains.
FIXED_COLUMN_WIDTH = 90
STATUS_COLUMN_WIDTH = round(FIXED_COLUMN_WIDTH * 4 / 3)
CLAIM_TABLE_COLUMNS = (
("due", "Fällig", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("status", "Status", STATUS_COLUMN_WIDTH),
("title", "Forderung", 220),
("due", "Fällig", 100),
("amount", "Betrag", 90),
("status", "Status", 110),
)
@@ -47,28 +68,75 @@ def _claim_sort_value(data, claim: dict, column: str) -> str:
return ""
class _RunHousekeeperDialog(tk.Toplevel):
"""Confirms a housekeeper run scoped to a single member, with the option to
force retroactive claim creation for just this run without touching the
application-wide default setting."""
def __init__(self, master: tk.Misc, member_name: str, on_confirm: Callable[[bool], None]):
super().__init__(master)
self.on_confirm = on_confirm
self.title("Hausmeister für dieses Mitglied ausführen")
self.transient(master.winfo_toplevel())
self.resizable(False, False)
frame = ttk.Frame(self, padding=18)
frame.pack(fill="both", expand=True)
ttk.Label(
frame,
text=(
f"Alle Regeln werden ausschließlich für {member_name} neu ausgeführt. "
"Andere Mitglieder bleiben unberührt."
),
wraplength=380,
justify="left",
).pack(anchor="w", pady=(0, 12))
self.retroactive_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
frame,
text="Rückwirkende Beiträge/Forderungen erstellen",
variable=self.retroactive_var,
).pack(anchor="w", pady=(0, 14))
buttons = ttk.Frame(frame)
buttons.pack(anchor="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Ausführen", style="Accent.TButton", command=self._confirm).pack(
side="left"
)
self.bind("<Escape>", lambda _event: self.destroy())
self.after_idle(self.grab_set)
def _confirm(self) -> None:
retroactive = self.retroactive_var.get()
self.destroy()
self.on_confirm(retroactive)
class MemberTab(ttk.Frame):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
config: AppConfig,
on_close: Callable[[], None],
on_changed: Callable[[], None],
on_open_claim: Callable[[str, str], None],
on_open_assets: Callable[[], None],
on_open_asset: Callable[[str], None],
on_return_asset: Callable[[str], None],
on_run_housekeeper: Callable[[str, bool], list[HousekeeperFinding]],
):
super().__init__(master, padding=12)
self.repository = repository
self.member_id = member_id
self.config = config
self.on_close = on_close
self.on_changed = on_changed
self.on_open_claim = on_open_claim
self.on_open_assets = on_open_assets
self.on_open_asset = on_open_asset
self.on_return_asset = on_return_asset
self.on_run_housekeeper = on_run_housekeeper
self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {}
self._field_sections: dict[str, str] = {}
@@ -102,9 +170,14 @@ class MemberTab(ttk.Frame):
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self._close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
header_actions = ttk.Frame(header)
header_actions.grid(row=0, column=1, sticky="ne", padx=(12, 0))
ttk.Button(
header_actions,
text="Hausmeister für dieses Mitglied",
command=self._run_housekeeper_for_member,
).pack(side="left", padx=(0, 8))
ttk.Button(header_actions, text="Tab schließen", command=self._close).pack(side="left")
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
@@ -114,7 +187,7 @@ class MemberTab(ttk.Frame):
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
self.pane.add(self.timeline_pane, weight=3)
self.pane.add(self.timeline_pane, weight=1)
self._build_details(self.details_pane)
self._build_timeline(self.timeline_pane)
self._pane_position_initialized = False
@@ -126,7 +199,8 @@ class MemberTab(ttk.Frame):
try:
width = int(getattr(event, "width", 0)) or self.pane.winfo_width()
if width > 1:
self.pane.sashpos(0, max(360, int(width * 0.4)))
# Chronik (timeline_pane) defaults to a third of the available width.
self.pane.sashpos(0, max(360, round(width * 2 / 3)))
self._pane_position_initialized = True
except tk.TclError:
return
@@ -153,11 +227,15 @@ class MemberTab(ttk.Frame):
command=self._save,
).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16)
contribution_override_tab = ttk.Frame(notebook, padding=16)
payments_tab = ttk.Frame(notebook, padding=16)
donations_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(contribution_tab, text="Forderungen")
notebook.add(contribution_override_tab, text="Beitrag")
notebook.add(payments_tab, text="Zahlungen")
notebook.add(donations_tab, text="Spenden")
notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente")
@@ -170,6 +248,7 @@ class MemberTab(ttk.Frame):
("E-Mail-Adresse", "email"),
("Telefonnummer", "phone"),
(f"Geburtsdatum ({date_input_hint()})", "birth_date"),
(f"Antragsdatum ({date_input_hint()})", "application_date"),
(f"Aufnahmebeschluss ({date_input_hint()})", "accepted_at"),
(f"Mitglied seit ({date_input_hint()})", "membership_started_at"),
(f"Austrittsdatum ({date_input_hint()})", "membership_ended_at"),
@@ -236,7 +315,18 @@ class MemberTab(ttk.Frame):
(f"Mandat widerrufen am ({date_input_hint()})", "mandate_revoked_at"),
)
banking_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(banking_fields):
self._add_variable("payment_frequency", tk.StringVar(), "banking")
ttk.Label(banking_tab, text="Zahlweise").grid(
row=0, column=0, sticky="w", padx=(0, 12), pady=5
)
ttk.Combobox(
banking_tab,
textvariable=self.variables["payment_frequency"],
values=list(PAYMENT_FREQUENCY_LABELS.values()),
state="readonly",
width=39,
).grid(row=0, column=1, sticky="ew", pady=5)
for row, (label, key) in enumerate(banking_fields, start=1):
self._add_variable(key, tk.StringVar(), "banking")
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
@@ -248,7 +338,7 @@ class MemberTab(ttk.Frame):
text="SEPA-Lastschriftmandat ist aktiv",
variable=self.variables["mandate_active"],
style="Switch",
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5))
).grid(row=len(banking_fields) + 1, column=0, columnspan=2, sticky="w", pady=(12, 5))
contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar()
@@ -256,16 +346,75 @@ class MemberTab(ttk.Frame):
row=0, column=0, sticky="w", pady=(0, 10)
)
self.claims = ttk.Treeview(
contribution_tab, columns=("title", "due", "amount", "status"), show="headings"
contribution_tab, columns=("due", "amount", "status", "title"), show="headings"
)
self.claim_sort_column = "due"
self.claim_sort_descending = False
for key, title, width in CLAIM_TABLE_COLUMNS:
self.claims.heading(key, text=title, command=lambda column=key: self._toggle_claim_sort(column))
self.claims.column(key, width=width, anchor="w")
self.claims.column(key, width=width, anchor="w", stretch=key == "title")
self.claims.grid(row=1, column=0, sticky="nsew")
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
claim_actions = ttk.Frame(contribution_tab)
claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(
claim_actions,
text="Willkommens- und Erstrechnungsmail",
command=self._send_welcome_mail,
).pack(side="left")
contribution_override_tab.columnconfigure(0, weight=1)
contribution_override_tab.rowconfigure(1, weight=1)
ttk.Label(
contribution_override_tab,
text=(
"Individuell vereinbarte Beiträge (z. B. ermäßigter Beitrag für Schüler), die von "
"den regulären Beitragssätzen abweichen. Wirkt sich erst auf künftig erzeugte "
"Forderungen aus, bereits erzeugte Forderungen bleiben unverändert."
),
style="Mono.TLabel",
wraplength=700,
).grid(row=0, column=0, sticky="w", pady=(0, 10))
self.contribution_overrides = ttk.Treeview(
contribution_override_tab,
columns=("valid_from", "valid_until", "kind", "value", "reason"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("valid_from", "Ab", FIXED_COLUMN_WIDTH),
("valid_until", "Bis", FIXED_COLUMN_WIDTH),
("kind", "Art", FIXED_COLUMN_WIDTH),
("value", "Wert", FIXED_COLUMN_WIDTH),
("reason", "Begründung", 320),
):
self.contribution_overrides.heading(key, text=title)
self.contribution_overrides.column(key, width=width, anchor="w", stretch=key == "reason")
self.contribution_overrides.grid(row=1, column=0, sticky="nsew")
self.contribution_overrides.bind(
"<Double-1>", lambda _event: self._edit_selected_contribution_override()
)
self.contribution_overrides.bind(
"<Return>", lambda _event: self._edit_selected_contribution_override()
)
contribution_override_actions = ttk.Frame(contribution_override_tab)
contribution_override_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(
contribution_override_actions,
text="Abweichenden Beitrag anlegen",
command=self._create_contribution_override,
).pack(side="left", padx=(0, 8))
ttk.Button(
contribution_override_actions,
text="Bearbeiten",
command=self._edit_selected_contribution_override,
).pack(side="left", padx=(0, 8))
ttk.Button(
contribution_override_actions,
text="Löschen",
command=self._delete_selected_contribution_override,
).pack(side="left")
payments_tab.columnconfigure(0, weight=1)
payments_tab.rowconfigure(1, weight=1)
@@ -280,25 +429,76 @@ class MemberTab(ttk.Frame):
selectmode="browse",
)
for key, title, width in (
("date", "Datum", 100),
("amount", "Betrag", 90),
("allocated", "Zugeordnet", 90),
("available", "Frei", 90),
("date", "Datum", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
("available", "Frei", FIXED_COLUMN_WIDTH),
("reference", "Referenz", 320),
):
self.payments.heading(key, text=title)
self.payments.column(key, width=width, anchor="w")
self.payments.column(key, width=width, anchor="w", stretch=key == "reference")
self.payments.grid(row=1, column=0, sticky="nsew")
self.payments.bind("<Double-1>", lambda _event: self._edit_selected_payment())
self.payments.bind("<Return>", lambda _event: self._edit_selected_payment())
payment_actions = ttk.Frame(payments_tab)
payment_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(payment_actions, text="Zahlung anlegen", command=self._create_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(payment_actions, text="Zahlung bearbeiten", command=self._edit_selected_payment).pack(
side="left", padx=(0, 8)
)
ttk.Button(payment_actions, text="Zahlung löschen", command=self._delete_selected_payment).pack(
side="left"
)
ttk.Separator(payment_actions, orient="vertical").pack(side="left", fill="y", padx=10)
ttk.Button(
payment_actions, text="Zahlungen importieren", command=self._import_payments
).pack(side="left")
donations_tab.columnconfigure(0, weight=1)
donations_tab.rowconfigure(1, weight=1)
self.donation_summary = tk.StringVar()
ttk.Label(donations_tab, textvariable=self.donation_summary, style="Mono.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 10)
)
self.donations = ttk.Treeview(
donations_tab,
columns=("date", "amount", "allocated", "balance", "status", "reference"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("date", "Datum", FIXED_COLUMN_WIDTH),
("amount", "Betrag", FIXED_COLUMN_WIDTH),
("allocated", "Zugeordnet", FIXED_COLUMN_WIDTH),
("balance", "Offen", FIXED_COLUMN_WIDTH),
("status", "Status", STATUS_COLUMN_WIDTH),
("reference", "Referenz / Zweck", 260),
):
self.donations.heading(key, text=title)
self.donations.column(key, width=width, anchor="w", stretch=key == "reference")
self.donations.grid(row=1, column=0, sticky="nsew")
self.donations.bind("<Double-1>", lambda _event: self._edit_selected_donation())
self.donations.bind("<Return>", lambda _event: self._edit_selected_donation())
donation_actions = ttk.Frame(donations_tab)
donation_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
ttk.Button(donation_actions, text="Spende anlegen", command=self._create_donation).pack(
side="left", padx=(0, 8)
)
ttk.Button(donation_actions, text="Spende bearbeiten", command=self._edit_selected_donation).pack(
side="left", padx=(0, 8)
)
ttk.Button(donation_actions, text="Spende löschen", command=self._delete_selected_donation).pack(
side="left", padx=(0, 8)
)
ttk.Separator(donation_actions, orient="vertical").pack(side="left", fill="y", padx=(0, 8))
ttk.Button(
donation_actions, text="Vorhandene Zahlung zuordnen", command=self._allocate_donation_payment
).pack(side="left", padx=(0, 8))
ttk.Button(donation_actions, text="Zahlung erfassen", command=self._record_donation_payment).pack(
side="left"
)
assets_tab.columnconfigure(0, weight=1)
assets_tab.rowconfigure(1, weight=1)
@@ -421,6 +621,25 @@ class MemberTab(ttk.Frame):
return
self.on_close()
def _run_housekeeper_for_member(self) -> None:
_RunHousekeeperDialog(self, self.member.display_name, self._execute_housekeeper_run)
def _execute_housekeeper_run(self, retroactive: bool) -> None:
try:
findings = self.on_run_housekeeper(self.member_id, retroactive)
except RepositoryError as exc:
messagebox.showerror("Hausmeisterlauf fehlgeschlagen", str(exc), parent=self)
return
member_findings = [item for item in findings if item.member_id == self.member_id]
messagebox.showinfo(
"Hausmeister ausgeführt",
f"Hausmeisterlauf für {self.member.display_name} abgeschlossen. "
f"{len(member_findings)} offene(r) Vorgang/Vorgänge für dieses Mitglied.",
parent=self,
)
self.refresh()
self.on_changed()
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
@@ -464,6 +683,7 @@ class MemberTab(ttk.Frame):
)
date_fields = {
"birth_date",
"application_date",
"accepted_at",
"membership_started_at",
"membership_ended_at",
@@ -474,6 +694,8 @@ class MemberTab(ttk.Frame):
value = getattr(self.member, key)
if key == "status":
variable.set(display_label(STATUS_LABELS, str(value)))
elif key == "payment_frequency":
variable.set(display_label(PAYMENT_FREQUENCY_LABELS, str(value)))
else:
variable.set(format_date_for_display(value) if key in date_fields else value)
if self.notes_text is not None:
@@ -484,6 +706,8 @@ class MemberTab(ttk.Frame):
self._clear_dirty()
self._refresh_events()
self._refresh_contributions()
self._refresh_contribution_overrides()
self._refresh_donations()
self._refresh_assets()
self._refresh_documents()
@@ -520,10 +744,10 @@ class MemberTab(ttk.Frame):
"end",
iid=claim_id,
values=(
claim.get("title", "Beitrag"),
format_date_for_display(str(claim.get("due_date", ""))),
money_text(claim_total(claim)),
CLAIM_STATUS_LABELS.get(status, status.upper()),
claim.get("title", "Beitrag"),
),
)
for payment in sorted(
@@ -558,6 +782,69 @@ class MemberTab(ttk.Frame):
f"Frei {money_text(max(total_amount - total_allocated, Decimal('0')))} EUR"
)
def _refresh_donations(self) -> None:
self.donations.delete(*self.donations.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
self.donation_summary.set(f"FEHLER: {exc}")
return
for donation in sorted(
data.donations,
key=lambda item: (str(item.get("date", "")), str(item.get("created_at", ""))),
reverse=True,
):
donation_id = str(donation.get("donation_id", ""))
amount = donation.get("amount", "0")
allocated = donation_allocated_total(data, donation_id)
balance = donation_balance(data, donation)
status = donation_status(data, donation)
reference = " · ".join(
part for part in (donation.get("reference", ""), donation.get("purpose", "")) if part
)
self.donations.insert(
"",
"end",
iid=donation_id,
values=(
format_date_for_display(str(donation.get("date", ""))),
f"{money_text(amount)} EUR",
f"{money_text(allocated)} EUR",
f"{money_text(balance)} EUR",
DONATION_STATUS_LABELS.get(status, status.upper()),
reference,
),
)
total_amount = sum(
(Decimal(str(item.get("amount", "0"))) for item in data.donations), Decimal("0")
)
self.donation_summary.set(f"{len(data.donations)} Spenden · Gesamt {money_text(total_amount)} EUR")
def _refresh_contribution_overrides(self) -> None:
self.contribution_overrides.delete(*self.contribution_overrides.get_children())
try:
data = self.repository.get_contributions(self.member_id)
except RepositoryError:
return
for override in sorted(
data.contribution_overrides, key=lambda item: str(item.get("valid_from", "")), reverse=True
):
kind = str(override.get("kind", ""))
value = str(override.get("value", ""))
value_text = f"{value} %" if kind == "percent" else f"{value} EUR"
self.contribution_overrides.insert(
"",
"end",
iid=str(override.get("override_id", "")),
values=(
format_month_for_display(str(override.get("valid_from", ""))),
format_month_for_display(str(override.get("valid_until", ""))) or "unbefristet",
CONTRIBUTION_OVERRIDE_KIND_LABELS.get(kind, kind.upper()),
value_text,
override.get("reason", ""),
),
)
def _toggle_claim_sort(self, column: str) -> None:
if self.claim_sort_column == column:
self.claim_sort_descending = not self.claim_sort_descending
@@ -618,10 +905,147 @@ class MemberTab(ttk.Frame):
return
self._payment_changed()
def _create_payment(self) -> None:
PaymentCreateDialog(self, self.repository, self.member_id, self._payment_changed)
def _import_payments(self) -> None:
GnuCashImportDialog(
self, self.repository, self.member_id, self.config, self._payment_changed
)
def _payment_changed(self) -> None:
self.refresh()
self.on_changed()
def _selected_donation(self) -> dict | None:
selected = self.donations.selection()
if not selected:
return None
try:
_data, donation = self.repository.get_donation(self.member_id, selected[0])
except RepositoryError:
return None
return donation
def _create_donation(self) -> None:
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed)
def _edit_selected_donation(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
DonationEditDialog(self, self.repository, self.member_id, self._donation_changed, donation)
def _delete_selected_donation(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
allocated = donation_allocated_total(data, str(donation.get("donation_id", "")))
detail = "Diese Spende wirklich endgültig löschen? Das kann nicht rückgängig gemacht werden."
if allocated:
detail += (
f"\n\nZugeordnete Zahlungen in Höhe von {money_text(allocated)} EUR werden dabei "
"gelöst und stehen danach wieder frei zur Verfügung."
)
if not messagebox.askyesno("Spende löschen", detail, parent=self):
return
try:
self.repository.delete_donation(self.member_id, str(donation.get("donation_id", "")))
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self._donation_changed()
def _record_donation_payment(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
DonationPaymentDialog(
self,
self.repository,
self.member_id,
str(donation.get("donation_id", "")),
donation_balance(data, donation),
self._donation_changed,
)
def _allocate_donation_payment(self) -> None:
donation = self._selected_donation()
if not donation:
messagebox.showinfo("Spende auswählen", "Bitte eine Spende auswählen.", parent=self)
return
data = self.repository.get_contributions(self.member_id)
AllocateDonationPaymentDialog(
self,
self.repository,
self.member_id,
str(donation.get("donation_id", "")),
donation_balance(data, donation),
self._donation_changed,
)
def _donation_changed(self) -> None:
self.refresh()
self.on_changed()
def _selected_contribution_override(self) -> dict | None:
selected = self.contribution_overrides.selection()
if not selected:
return None
try:
_data, override = self.repository.get_contribution_override(self.member_id, selected[0])
except RepositoryError:
return None
return override
def _create_contribution_override(self) -> None:
ContributionOverrideEditDialog(
self, self.repository, self.member_id, self._contribution_override_changed
)
def _edit_selected_contribution_override(self) -> None:
override = self._selected_contribution_override()
if not override:
messagebox.showinfo(
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
)
return
ContributionOverrideEditDialog(
self, self.repository, self.member_id, self._contribution_override_changed, override
)
def _delete_selected_contribution_override(self) -> None:
override = self._selected_contribution_override()
if not override:
messagebox.showinfo(
"Eintrag auswählen", "Bitte einen abweichenden Beitrag auswählen.", parent=self
)
return
if not messagebox.askyesno(
"Abweichenden Beitrag löschen",
"Diesen abweichenden Beitrag wirklich löschen? Bereits erzeugte Forderungen bleiben "
"davon unberührt.",
parent=self,
):
return
try:
self.repository.delete_contribution_override(
self.member_id, str(override.get("override_id", ""))
)
except RepositoryError as exc:
messagebox.showerror("Löschen fehlgeschlagen", str(exc), parent=self)
return
self._contribution_override_changed()
def _contribution_override_changed(self) -> None:
self.refresh()
self.on_changed()
def _refresh_documents(self) -> None:
self.documents.delete(*self.documents.get_children())
self.document_paths.clear()
@@ -688,6 +1112,8 @@ class MemberTab(ttk.Frame):
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
if key == "status":
value = storage_key(STATUS_LABELS, value)
elif key == "payment_frequency":
value = storage_key(PAYMENT_FREQUENCY_LABELS, value)
setattr(self.member, key, value)
if self.notes_text is not None:
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
@@ -704,7 +1130,11 @@ class MemberTab(ttk.Frame):
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_member_record_hashes(self.member_id)
try:
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Prüfsummen konnten nicht erneuert werden", str(exc), parent=self)
return
self.refresh()
self.on_changed()
@@ -724,13 +1154,17 @@ class MemberTab(ttk.Frame):
text = self.comment_var.get().strip()
if not text:
return
self.repository.append_event(
self.member_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
try:
self.repository.append_event(
self.member_id,
event_type="board_comment",
summary=text,
actor_type="user",
actor_name="Vorstand",
)
except RepositoryError as exc:
messagebox.showerror("Kommentar konnte nicht gespeichert werden", str(exc), parent=self)
return
self.comment_var.set("")
self._refresh_events()
@@ -759,6 +1193,14 @@ class MemberTab(ttk.Frame):
except OSError as exc:
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
def _send_welcome_mail(self) -> None:
WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)
def _welcome_mail_sent(self) -> None:
self._refresh_documents()
self._refresh_events()
self.on_changed()
def _create_document(self) -> None:
DocumentTemplateDialog(
self,
+722 -14
View File
@@ -6,15 +6,31 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import decimal_value, money_text
from ccma.domain.mail_templates import (
MAIL_TEMPLATES,
MailTemplateError,
default_mail_template,
placeholder_entries,
template_spec,
validate_mail_template,
)
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
from ccma.services.intervals import (
IntervalValidationError,
normalize_anniversary_intervals,
)
from ccma.services.mail_delivery import list_imap_folders, test_imap_connection, test_smtp_connection
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
from ccma.ui.changelog_view import ChangelogView
from ccma.ui.icons import IconStore
from ccma.ui.labels import THEME_LABELS, display_label, storage_key
from ccma.ui.labels import (
EMAIL_DELIVERY_MODE_LABELS,
EMAIL_SECURITY_LABELS,
THEME_LABELS,
display_label,
storage_key,
)
class OptionsDialog(tk.Toplevel):
@@ -47,6 +63,7 @@ class OptionsDialog(tk.Toplevel):
number_policy = repository.get_member_number_policy()
self.manual_numbers_var = tk.BooleanVar(value=number_policy["mode"] == "manual")
self.number_pattern_var = tk.StringVar(value=number_policy["pattern"])
self.number_strategy_var = tk.StringVar(value=number_policy["allocation_strategy"])
self.number_preview_var = tk.StringVar()
organization = repository.get_configuration().get("organization") or {}
if not isinstance(organization, dict):
@@ -58,14 +75,34 @@ class OptionsDialog(tk.Toplevel):
"website", "iban", "bic", "creditor_id",
)
}
# Mail templates are edited one at a time but saved together with the rest of
# the options, so the edits of every tab are kept in memory until "Speichern".
self.mail_template_key = MAIL_TEMPLATES[0].key
self.mail_template_edits: dict[str, tuple[str, str]] = {}
self.mail_template_original: dict[str, tuple[str, str]] = {}
self.mail_template_var = tk.StringVar(value=MAIL_TEMPLATES[0].label)
self.mail_template_subject_var = tk.StringVar()
self.mail_template_hint_var = tk.StringVar()
self.mail_template_snippets: dict[str, str] = {}
self.title("Optionen")
self.geometry("900x650")
self.minsize(760, 540)
self.transient(master.winfo_toplevel())
self.grab_set()
# Tk refuses a grab on a window that is not on screen yet ("window not
# viewable"), and this dialog builds enough tabs to still be unmapped here --
# so the grab waits for the window like it does in the other dialogs.
self.after_idle(self.grab_set)
self.resizable(True, True)
self._build_ui()
self.bind("<Escape>", lambda _event: self.destroy())
# A ttk.Notebook sizes itself to its tallest/widest tab, so once every tab
# (including "Mahnungen" with its two tables) is built, the natural required
# size reflects whichever one actually needs the most room -- open at that size
# instead of a static guess that clips a table/buttons at the bottom. The floor
# keeps the window from opening awkwardly small on short content.
self.update_idletasks()
width = max(900, self.winfo_reqwidth())
height = max(650, self.winfo_reqheight())
self.geometry(f"{width}x{height}")
self.minsize(760, 540)
self.after_idle(self._center_on_parent)
def _build_ui(self) -> None:
@@ -83,18 +120,27 @@ class OptionsDialog(tk.Toplevel):
member_numbers = ttk.Frame(notebook, padding=16)
organization = ttk.Frame(notebook, padding=16)
automation = ttk.Frame(notebook, padding=16)
reminders = ttk.Frame(notebook, padding=16)
email = ttk.Frame(notebook, padding=16)
mail_templates = ttk.Frame(notebook, padding=16)
changelog = ChangelogView(notebook)
notebook.add(paths, text="Pfade")
notebook.add(appearance, text="Darstellung")
notebook.add(member_numbers, text="Mitgliedsnummern")
notebook.add(organization, text="Verein / Absender")
notebook.add(automation, text="Hausmeister")
notebook.add(reminders, text="Mahnungen")
notebook.add(email, text="E-Mail-Versand")
notebook.add(mail_templates, text="E-Mail-Vorlagen")
notebook.add(changelog, text="Changelog")
self._build_paths(paths)
self._build_appearance(appearance)
self._build_member_numbers(member_numbers)
self._build_organization(organization)
self._build_automation(automation)
self._build_reminders(reminders)
self._build_email(email)
self._build_mail_templates(mail_templates)
buttons = ttk.Frame(root)
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
@@ -280,19 +326,38 @@ class OptionsDialog(tk.Toplevel):
text="Platzhalter: {number}, {number:04d} und optional {year}",
style="Muted.TLabel",
).grid(row=2, column=1, sticky="w", pady=(0, 12))
ttk.Label(parent, text="Nächste Nummer").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Label(parent, text="Vergabestrategie").grid(
row=3, column=0, sticky="nw", padx=(0, 12), pady=6
)
strategy_frame = ttk.Frame(parent)
strategy_frame.grid(row=3, column=1, sticky="w", pady=6)
ttk.Radiobutton(
strategy_frame,
text="Lücken füllen (nächste freie Nummer)",
variable=self.number_strategy_var,
value="next_free",
command=self._update_number_preview,
).pack(anchor="w")
ttk.Radiobutton(
strategy_frame,
text="Immer Max++ (höchste Nummer + 1)",
variable=self.number_strategy_var,
value="max_plus_one",
command=self._update_number_preview,
).pack(anchor="w", pady=(4, 0))
ttk.Label(parent, text="Nächste Nummer").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=6)
ttk.Label(parent, textvariable=self.number_preview_var, style="TimelineHeader.TLabel").grid(
row=3, column=1, sticky="w", pady=6
row=4, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text=(
"Der Zähler wird pro Pattern im Mitglieder-Store geführt. Bereits vergebene Nummern "
"werden niemals doppelt erzeugt."
"Die Strategie wird im Mitglieder-Store gespeichert. Die Vergabe ist gesperrt, "
"solange ein neues Mitglied angelegt wird, damit keine Nummer doppelt entsteht."
),
style="Muted.TLabel",
wraplength=620,
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(14, 0))
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(14, 0))
self.number_pattern_var.trace_add("write", lambda *_args: self._update_number_preview())
self._update_member_number_controls()
@@ -309,11 +374,443 @@ class OptionsDialog(tk.Toplevel):
return
pattern = self.number_pattern_var.get()
try:
preview = self.repository.preview_member_number(pattern)
preview = self.repository.preview_member_number(
pattern, self.number_strategy_var.get()
)
except RepositoryError as exc:
preview = f"UNGÜLTIG: {exc}"
self.number_preview_var.set(preview)
def _build_reminders(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
policy = self.repository.get_reminder_policy()
self.grace_days_var = tk.StringVar(value=str(policy["grace_days_after_due"]))
ttk.Label(parent, text="Kulanzfrist nach Fälligkeit (Tage)").grid(
row=0, column=0, sticky="w", pady=6
)
ttk.Spinbox(parent, from_=0, to=365, textvariable=self.grace_days_var, width=6).grid(
row=0, column=1, sticky="w", pady=6
)
ttk.Label(
parent,
text="Ab wann nach Fälligkeit die erste Mahnstufe fällig wird.",
style="Muted.TLabel",
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
levels_frame = ttk.LabelFrame(parent, text="Mahnstufen", padding=10)
levels_frame.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(4, 0))
levels_frame.columnconfigure(0, weight=1)
self.levels = [dict(level) for level in policy["levels"]]
self.levels_tree = ttk.Treeview(
levels_frame, columns=("level", "name", "fee", "deadline"), show="headings", height=5
)
for key, title, width in (
("level", "Stufe", 55),
("name", "Name", 220),
("fee", "Gebühr", 90),
("deadline", "Frist (Tage)", 100),
):
self.levels_tree.heading(key, text=title)
self.levels_tree.column(key, width=width, anchor="w")
self.levels_tree.grid(row=0, column=0, sticky="ew")
self.levels_tree.bind("<<TreeviewSelect>>", lambda _event: self._load_selected_level())
level_controls = ttk.Frame(levels_frame)
level_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.level_name_var = tk.StringVar()
self.level_fee_var = tk.StringVar()
self.level_deadline_var = tk.StringVar()
ttk.Entry(level_controls, textvariable=self.level_name_var, width=22).pack(side="left", padx=(0, 6))
ttk.Entry(level_controls, textvariable=self.level_fee_var, width=8).pack(side="left", padx=(0, 6))
ttk.Entry(level_controls, textvariable=self.level_deadline_var, width=8).pack(
side="left", padx=(0, 6)
)
ttk.Button(level_controls, text="Übernehmen", command=self._apply_level).pack(
side="left", padx=(0, 6)
)
ttk.Button(level_controls, text="Hinzufügen", command=self._add_level).pack(side="left", padx=(0, 6))
ttk.Button(level_controls, text="Entfernen", command=self._remove_level).pack(side="left")
ttk.Label(
parent,
text="Stufen werden immer nach ihrer Reihenfolge in der Liste 1, 2, 3, … nummeriert.",
style="Muted.TLabel",
).grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
items_frame = ttk.LabelFrame(parent, text="Standardpositionen (Beschreibung & Betrag)", padding=10)
items_frame.grid(row=4, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
items_frame.columnconfigure(0, weight=1)
self.standard_items = [dict(item) for item in policy["standard_fee_items"]]
self.standard_items_tree = ttk.Treeview(
items_frame, columns=("description", "amount"), show="headings", height=5
)
self.standard_items_tree.heading("description", text="Beschreibung")
self.standard_items_tree.heading("amount", text="Standardbetrag")
self.standard_items_tree.column("description", width=260, anchor="w")
self.standard_items_tree.column("amount", width=120, anchor="w")
self.standard_items_tree.grid(row=0, column=0, sticky="ew")
self.standard_items_tree.bind(
"<<TreeviewSelect>>", lambda _event: self._load_selected_standard_item()
)
item_controls = ttk.Frame(items_frame)
item_controls.grid(row=1, column=0, sticky="ew", pady=(8, 0))
self.standard_item_description_var = tk.StringVar()
self.standard_item_amount_var = tk.StringVar()
ttk.Entry(item_controls, textvariable=self.standard_item_description_var, width=26).pack(
side="left", padx=(0, 6)
)
ttk.Entry(item_controls, textvariable=self.standard_item_amount_var, width=10).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Übernehmen", command=self._apply_standard_item).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Hinzufügen", command=self._add_standard_item).pack(
side="left", padx=(0, 6)
)
ttk.Button(item_controls, text="Entfernen", command=self._remove_standard_item).pack(side="left")
ttk.Label(
parent,
text=(
"Diese Vorlagen stehen bei Mahnungen und Rücklastschriften als Positionen zur "
"Auswahl, z. B. Mahngebühr, Rücklastschriftgebühr, Bankgebühr oder Porto."
),
style="Muted.TLabel",
wraplength=560,
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(4, 0))
self._refresh_levels()
self._refresh_standard_items()
def _refresh_levels(self) -> None:
selected = self.levels_tree.selection()
self.levels_tree.delete(*self.levels_tree.get_children())
for index, level in enumerate(self.levels):
self.levels_tree.insert(
"",
"end",
iid=str(index),
values=(
index + 1,
level.get("name", ""),
f"{level.get('fee', '0.00')} EUR",
level.get("payment_deadline_days", 14),
),
)
if selected and self.levels_tree.exists(selected[0]):
self.levels_tree.selection_set(selected[0])
def _load_selected_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
return
level = self.levels[int(selected[0])]
self.level_name_var.set(level.get("name", ""))
self.level_fee_var.set(str(level.get("fee", "0.00")))
self.level_deadline_var.set(str(level.get("payment_deadline_days", 14)))
def _apply_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
return
name = self.level_name_var.get().strip()
if not name:
messagebox.showerror("Name fehlt", "Bitte einen Namen angeben.", parent=self)
return
try:
fee = decimal_value(self.level_fee_var.get(), "Gebühr")
deadline = int(self.level_deadline_var.get())
except (ValueError, TypeError) as exc:
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
return
self.levels[int(selected[0])] = {
"name": name,
"fee": money_text(fee),
"payment_deadline_days": deadline,
}
self._refresh_levels()
def _add_level(self) -> None:
name = self.level_name_var.get().strip() or "Neue Mahnstufe"
try:
fee = decimal_value(self.level_fee_var.get() or "0", "Gebühr")
deadline = int(self.level_deadline_var.get() or "14")
except (ValueError, TypeError) as exc:
messagebox.showerror("Ungültige Eingabe", str(exc), parent=self)
return
self.levels.append({"name": name, "fee": money_text(fee), "payment_deadline_days": deadline})
self._refresh_levels()
def _remove_level(self) -> None:
selected = self.levels_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Mahnstufe auswählen.", parent=self)
return
del self.levels[int(selected[0])]
self._refresh_levels()
def _refresh_standard_items(self) -> None:
selected = self.standard_items_tree.selection()
self.standard_items_tree.delete(*self.standard_items_tree.get_children())
for index, item in enumerate(self.standard_items):
self.standard_items_tree.insert(
"",
"end",
iid=str(index),
values=(item.get("description", ""), f"{item.get('default_amount', '0.00')} EUR"),
)
if selected and self.standard_items_tree.exists(selected[0]):
self.standard_items_tree.selection_set(selected[0])
def _load_selected_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
return
item = self.standard_items[int(selected[0])]
self.standard_item_description_var.set(item.get("description", ""))
self.standard_item_amount_var.set(str(item.get("default_amount", "0.00")))
def _apply_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
description = self.standard_item_description_var.get().strip()
if not description:
messagebox.showerror("Beschreibung fehlt", "Bitte eine Beschreibung angeben.", parent=self)
return
try:
amount = decimal_value(self.standard_item_amount_var.get(), "Standardbetrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.standard_items[int(selected[0])] = {
"description": description,
"default_amount": money_text(amount),
}
self._refresh_standard_items()
def _add_standard_item(self) -> None:
description = self.standard_item_description_var.get().strip() or "Neue Position"
try:
amount = decimal_value(self.standard_item_amount_var.get() or "0", "Standardbetrag")
except ValueError as exc:
messagebox.showerror("Ungültiger Betrag", str(exc), parent=self)
return
self.standard_items.append({"description": description, "default_amount": money_text(amount)})
self._refresh_standard_items()
def _remove_standard_item(self) -> None:
selected = self.standard_items_tree.selection()
if not selected:
messagebox.showerror("Auswahl fehlt", "Bitte eine Position auswählen.", parent=self)
return
del self.standard_items[int(selected[0])]
self._refresh_standard_items()
def _build_email(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
email_settings = self.repository.get_email_settings()
self.email_delivery_mode_var = tk.StringVar(
value=display_label(EMAIL_DELIVERY_MODE_LABELS, email_settings["delivery_mode"])
)
ttk.Label(parent, text="Versandmodus").grid(row=0, column=0, sticky="w", pady=6)
ttk.Combobox(
parent,
textvariable=self.email_delivery_mode_var,
values=list(EMAIL_DELIVERY_MODE_LABELS.values()),
state="readonly",
width=42,
).grid(row=0, column=1, sticky="w", pady=6)
ttk.Label(
parent,
text=(
"Gilt für Mahnungs- und SEPA-Info-Mails. „Jedes Mal fragen“ zeigt vor jeder "
"Erzeugung eine Auswahl zwischen Senden und Entwurf ablegen."
),
style="Muted.TLabel",
wraplength=680,
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
smtp_frame = ttk.LabelFrame(parent, text="SMTP (zum direkten Versenden)", padding=10)
smtp_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(4, 0))
smtp_frame.columnconfigure(1, weight=1)
self.smtp_host_var = tk.StringVar(value=email_settings["smtp_host"])
self.smtp_port_var = tk.StringVar(value=str(email_settings["smtp_port"]))
self.smtp_security_var = tk.StringVar(
value=display_label(EMAIL_SECURITY_LABELS, email_settings["smtp_security"])
)
self.smtp_username_var = tk.StringVar(value=email_settings["smtp_username"])
self.smtp_password_var = tk.StringVar(value=email_settings["smtp_password"])
ttk.Label(smtp_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_host_var).grid(
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
)
ttk.Label(smtp_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_port_var, width=8).grid(row=0, column=3, pady=4)
ttk.Label(smtp_frame, text="Verschlüsselung").grid(
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
)
ttk.Combobox(
smtp_frame,
textvariable=self.smtp_security_var,
values=list(EMAIL_SECURITY_LABELS.values()),
state="readonly",
width=14,
).grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(smtp_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_username_var).grid(
row=2, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(smtp_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(smtp_frame, textvariable=self.smtp_password_var, show="").grid(
row=3, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Button(smtp_frame, text="Verbindung testen", command=self._test_smtp_connection).grid(
row=4, column=0, columnspan=4, sticky="w", pady=(8, 0)
)
imap_frame = ttk.LabelFrame(parent, text="IMAP (für Entwürfe & Gesendet)", padding=10)
imap_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(16, 0))
imap_frame.columnconfigure(1, weight=1)
self.imap_host_var = tk.StringVar(value=email_settings["imap_host"])
self.imap_port_var = tk.StringVar(value=str(email_settings["imap_port"]))
self.imap_security_var = tk.StringVar(
value=display_label(EMAIL_SECURITY_LABELS, email_settings["imap_security"])
)
self.imap_username_var = tk.StringVar(value=email_settings["imap_username"])
self.imap_password_var = tk.StringVar(value=email_settings["imap_password"])
self.imap_drafts_folder_var = tk.StringVar(value=email_settings["imap_drafts_folder"])
self.imap_sent_enabled_var = tk.BooleanVar(value=email_settings["imap_sent_enabled"])
self.imap_sent_folder_var = tk.StringVar(value=email_settings["imap_sent_folder"])
ttk.Label(imap_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_host_var).grid(
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
)
ttk.Label(imap_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_port_var, width=8).grid(row=0, column=3, pady=4)
ttk.Label(imap_frame, text="Verschlüsselung").grid(
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
)
ttk.Combobox(
imap_frame,
textvariable=self.imap_security_var,
values=list(EMAIL_SECURITY_LABELS.values()),
state="readonly",
width=14,
).grid(row=1, column=1, sticky="w", pady=4)
ttk.Label(imap_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_username_var).grid(
row=2, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(imap_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
ttk.Entry(imap_frame, textvariable=self.imap_password_var, show="").grid(
row=3, column=1, columnspan=3, sticky="ew", pady=4
)
ttk.Label(imap_frame, text="Entwürfe-Ordner").grid(row=4, column=0, sticky="w", pady=4, padx=(0, 8))
self.imap_drafts_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_drafts_folder_var)
self.imap_drafts_folder_combo.grid(row=4, column=1, columnspan=3, sticky="ew", pady=4)
ttk.Checkbutton(
imap_frame,
text="Direkt versandte Mahnungen/Info-Mails zusätzlich in einen Gesendet-Ordner ablegen",
variable=self.imap_sent_enabled_var,
).grid(row=5, column=0, columnspan=4, sticky="w", pady=(8, 4))
ttk.Label(imap_frame, text="Gesendet-Ordner").grid(row=6, column=0, sticky="w", pady=4, padx=(0, 8))
self.imap_sent_folder_combo = ttk.Combobox(imap_frame, textvariable=self.imap_sent_folder_var)
self.imap_sent_folder_combo.grid(row=6, column=1, columnspan=3, sticky="ew", pady=4)
buttons_row = ttk.Frame(imap_frame)
buttons_row.grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0))
ttk.Button(buttons_row, text="Verbindung testen", command=self._test_imap_connection).pack(
side="left", padx=(0, 8)
)
ttk.Button(buttons_row, text="Ordnerliste laden", command=self._load_imap_folders).pack(side="left")
ttk.Label(
imap_frame,
text=(
"„Ordnerliste laden“ ruft die Ordner live vom Server ab (Zugangsdaten oben müssen "
"dafür bereits ausgefüllt sein) und füllt beide Dropdowns; freie Eingabe bleibt "
"möglich -- ein dabei angegebener, noch nicht vorhandener Ordner wird bei der "
"nächsten Ablage automatisch angelegt."
),
style="Muted.TLabel",
wraplength=660,
).grid(row=8, column=0, columnspan=4, sticky="w", pady=(6, 0))
ttk.Label(
parent,
text=(
"Server, Zugangsdaten und Passwörter werden mit den übrigen Vereinseinstellungen "
"in repository.json innerhalb dieses Mitglieder-Stores gespeichert -- pro Store "
"lässt sich so ein eigenes Postfach hinterlegen."
),
style="Muted.TLabel",
wraplength=680,
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(12, 0))
def _collect_email_settings(self) -> dict:
return {
"delivery_mode": storage_key(EMAIL_DELIVERY_MODE_LABELS, self.email_delivery_mode_var.get()),
"smtp_host": self.smtp_host_var.get(),
"smtp_port": self.smtp_port_var.get(),
"smtp_security": storage_key(EMAIL_SECURITY_LABELS, self.smtp_security_var.get()),
"smtp_username": self.smtp_username_var.get(),
"smtp_password": self.smtp_password_var.get(),
"imap_host": self.imap_host_var.get(),
"imap_port": self.imap_port_var.get(),
"imap_security": storage_key(EMAIL_SECURITY_LABELS, self.imap_security_var.get()),
"imap_username": self.imap_username_var.get(),
"imap_password": self.imap_password_var.get(),
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
"imap_sent_enabled": self.imap_sent_enabled_var.get(),
"imap_sent_folder": self.imap_sent_folder_var.get(),
}
def _test_smtp_connection(self) -> None:
settings = self._collect_email_settings()
try:
settings["smtp_port"] = int(settings["smtp_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der SMTP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
test_smtp_connection(settings)
except RepositoryError as exc:
messagebox.showerror("SMTP-Verbindung fehlgeschlagen", str(exc), parent=self)
return
messagebox.showinfo("SMTP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
def _test_imap_connection(self) -> None:
settings = self._collect_email_settings()
try:
settings["imap_port"] = int(settings["imap_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
test_imap_connection(settings)
except RepositoryError as exc:
messagebox.showerror("IMAP-Verbindung fehlgeschlagen", str(exc), parent=self)
return
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
def _load_imap_folders(self) -> None:
settings = self._collect_email_settings()
try:
settings["imap_port"] = int(settings["imap_port"])
except ValueError:
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
return
try:
folders = list_imap_folders(settings)
except RepositoryError as exc:
messagebox.showerror("Ordnerliste konnte nicht geladen werden", str(exc), parent=self)
return
self.imap_drafts_folder_combo.configure(values=folders)
self.imap_sent_folder_combo.configure(values=folders)
messagebox.showinfo("Ordnerliste geladen", f"{len(folders)} Ordner gefunden.", parent=self)
def _choose_store(self) -> None:
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
initial = current if current.is_dir() else current.parent
@@ -355,9 +852,19 @@ class OptionsDialog(tk.Toplevel):
anniversary_before = _parse_day_window(self.anniversary_before_var.get(), "Jubiläen vorher")
anniversary_after = _parse_day_window(self.anniversary_after_var.get(), "Jubiläen nachher")
anniversary_intervals = normalize_anniversary_intervals(self.anniversary_intervals_var.get())
grace_days_after_due = _parse_day_window(self.grace_days_var.get(), "Kulanzfrist")
except (ValueError, IntervalValidationError) as exc:
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
return
email_settings = self._collect_email_settings()
try:
email_settings["smtp_port"] = int(email_settings["smtp_port"])
email_settings["imap_port"] = int(email_settings["imap_port"])
except ValueError:
messagebox.showerror(
"Ungültige E-Mail-Einstellung", "SMTP-/IMAP-Port müssen ganze Zahlen sein.", parent=self
)
return
store_text = self.store_var.get().strip()
if not store_text:
messagebox.showerror(
@@ -407,19 +914,220 @@ class OptionsDialog(tk.Toplevel):
self.config_obj.optional_member_fields = tuple(
field for field, variable in self.optional_member_field_vars.items() if variable.get()
)
# Program settings live in the user's config directory and stay writable even
# when the member store does not -- so they are saved either way, and only the
# store-backed settings are skipped (with a notice) on a read-only store.
store_read_only = self.repository.read_only
try:
self.config_obj.save()
self.repository.save_member_number_policy(mode=number_mode, pattern=number_pattern)
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
if not store_read_only:
self.repository.save_member_number_policy(
mode=number_mode,
pattern=number_pattern,
allocation_strategy=self.number_strategy_var.get(),
)
self.repository.save_organization(
{key: variable.get() for key, variable in self.organization_vars.items()}
)
self.repository.save_reminder_policy(
grace_days_after_due=grace_days_after_due,
levels=self.levels,
standard_fee_items=self.standard_items,
)
self.repository.save_email_settings(**email_settings)
self._save_mail_templates()
except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return
if store_read_only:
messagebox.showwarning(
"Store ist schreibgeschützt",
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
"Mitgliedsnummern, Mahnungen, E-Mail-Versand und E-Mail-Vorlagen konnten "
"nicht gespeichert werden, weil der Mitglieder-Store nur lesend "
"eingebunden ist.",
parent=self,
)
if self.on_saved:
self.on_saved(store_changed)
self.destroy()
def _build_mail_templates(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(4, weight=1)
ttk.Label(
parent,
text=(
"Die Texte aller vom Programm erzeugten E-Mails liegen als bearbeitbare "
"Vorlagen im Mitglieder-Store unter templates/mail/. Platzhalter in doppelten "
"geschweiften Klammern werden beim Versand mit den Daten des Mitglieds gefüllt."
),
style="Muted.TLabel",
wraplength=820,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 12))
chooser = ttk.Frame(parent)
chooser.grid(row=1, column=0, columnspan=2, sticky="ew")
chooser.columnconfigure(1, weight=1)
ttk.Label(chooser, text="Vorlage").grid(row=0, column=0, sticky="w", padx=(0, 12))
selector = ttk.Combobox(
chooser,
textvariable=self.mail_template_var,
values=[spec.label for spec in MAIL_TEMPLATES],
state="readonly",
width=32,
)
selector.grid(row=0, column=1, sticky="w")
selector.bind("<<ComboboxSelected>>", lambda _event: self._select_mail_template())
ttk.Button(
chooser, text="Standardtext wiederherstellen", command=self._reset_mail_template
).grid(row=0, column=2, sticky="e", padx=(12, 0))
ttk.Label(
parent, textvariable=self.mail_template_hint_var, style="Muted.TLabel", wraplength=820
).grid(row=2, column=0, columnspan=2, sticky="w", pady=(6, 12))
subject = ttk.Frame(parent)
subject.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 8))
subject.columnconfigure(1, weight=1)
ttk.Label(subject, text="Betreff").grid(row=0, column=0, sticky="w", padx=(0, 12))
ttk.Entry(subject, textvariable=self.mail_template_subject_var).grid(
row=0, column=1, sticky="ew"
)
editor = ttk.Frame(parent)
editor.grid(row=4, column=0, sticky="nsew")
editor.columnconfigure(0, weight=1)
editor.rowconfigure(0, weight=1)
self.mail_template_body = tk.Text(editor, wrap="word", height=16, width=70, undo=True)
self.mail_template_body.grid(row=0, column=0, sticky="nsew")
body_scroll = ttk.Scrollbar(editor, orient="vertical", command=self.mail_template_body.yview)
body_scroll.grid(row=0, column=1, sticky="ns")
self.mail_template_body.configure(yscrollcommand=body_scroll.set)
placeholders = ttk.Frame(parent)
placeholders.grid(row=4, column=1, sticky="nsew", padx=(12, 0))
placeholders.columnconfigure(0, weight=1)
placeholders.rowconfigure(1, weight=1)
ttk.Label(placeholders, text="Platzhalter (Doppelklick fügt ein)").grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 4)
)
self.mail_template_placeholders = ttk.Treeview(
placeholders, columns=("name", "help"), show="headings", selectmode="browse", height=14
)
for key, title, width in (("name", "Platzhalter", 210), ("help", "Bedeutung", 230)):
self.mail_template_placeholders.heading(key, text=title)
self.mail_template_placeholders.column(key, width=width, anchor="w", stretch=key == "help")
self.mail_template_placeholders.grid(row=1, column=0, sticky="nsew")
placeholder_scroll = ttk.Scrollbar(
placeholders, orient="vertical", command=self.mail_template_placeholders.yview
)
placeholder_scroll.grid(row=1, column=1, sticky="ns")
self.mail_template_placeholders.configure(yscrollcommand=placeholder_scroll.set)
self.mail_template_placeholders.bind(
"<Double-1>", lambda _event: self._insert_mail_placeholder()
)
self.mail_template_placeholders.bind(
"<Return>", lambda _event: self._insert_mail_placeholder()
)
self._load_mail_template(self.mail_template_key)
def _load_mail_template(self, key: str) -> None:
if key not in self.mail_template_edits:
try:
template = self.repository.get_mail_template(key)
except (OSError, RepositoryError) as exc:
messagebox.showerror(
"Vorlage konnte nicht geladen werden", str(exc), parent=self
)
return
self.mail_template_edits[key] = (template.subject, template.body)
self.mail_template_original[key] = (template.subject, template.body)
subject, body = self.mail_template_edits[key]
self.mail_template_key = key
spec = template_spec(key)
self.mail_template_var.set(spec.label)
self.mail_template_hint_var.set(f"{spec.description} (Datei: {spec.filename})")
self.mail_template_subject_var.set(subject)
self.mail_template_body.delete("1.0", "end")
self.mail_template_body.insert("1.0", body)
self.mail_template_placeholders.delete(*self.mail_template_placeholders.get_children())
# The row text is a readable label; what actually gets inserted is the entry's
# snippet, which for a repeat block is the whole block and not its description.
self.mail_template_snippets = {}
for entry in placeholder_entries(key):
label = f" {entry.label}" if entry.indented else entry.label
row = self.mail_template_placeholders.insert(
"", "end", values=(label, entry.description)
)
self.mail_template_snippets[row] = entry.snippet
def _capture_mail_template(self) -> None:
if not hasattr(self, "mail_template_body"):
return
self.mail_template_edits[self.mail_template_key] = (
self.mail_template_subject_var.get(),
self.mail_template_body.get("1.0", "end-1c"),
)
def _select_mail_template(self) -> None:
self._capture_mail_template()
label = self.mail_template_var.get()
key = next((spec.key for spec in MAIL_TEMPLATES if spec.label == label), None)
if key:
self._load_mail_template(key)
def _reset_mail_template(self) -> None:
key = self.mail_template_key
spec = template_spec(key)
if not messagebox.askyesno(
"Standardtext wiederherstellen",
f"Soll die Vorlage „{spec.label}“ auf den mitgelieferten Standardtext "
"zurückgesetzt werden? Deine Änderungen an dieser Vorlage gehen dabei verloren.",
parent=self,
):
return
try:
template = default_mail_template(key)
except MailTemplateError as exc:
messagebox.showerror("Standardtext nicht verfügbar", str(exc), parent=self)
return
self.mail_template_edits[key] = (template.subject, template.body)
self._load_mail_template(key)
def _insert_mail_placeholder(self) -> None:
selected = self.mail_template_placeholders.selection()
if not selected:
return
snippet = self.mail_template_snippets.get(selected[0])
if not snippet:
return
# A multi-line block only works on lines of its own, so it starts on a fresh
# one instead of being appended to whatever the cursor sat behind.
if "\n" in snippet.strip() and not self._mail_cursor_at_line_start():
snippet = "\n" + snippet
self.mail_template_body.insert("insert", snippet)
self.mail_template_body.focus_set()
def _mail_cursor_at_line_start(self) -> bool:
return str(self.mail_template_body.index("insert")).split(".")[1] == "0"
def _save_mail_templates(self) -> None:
self._capture_mail_template()
pending = {
key: value
for key, value in self.mail_template_edits.items()
if self.mail_template_original.get(key) != value
}
# Check every edited template before writing the first one: a mistake in one
# must not leave the others half-saved.
for key, (subject, body) in pending.items():
try:
validate_mail_template(key, subject, body)
except MailTemplateError as exc:
raise RepositoryError(f"{template_spec(key).label}: {exc}") from exc
for key, (subject, body) in pending.items():
self.repository.save_mail_template(key, subject=subject, body=body)
def _center_on_parent(self) -> None:
self.update_idletasks()
parent = self.master.winfo_toplevel()
+401 -134
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from datetime import date
from decimal import Decimal, InvalidOperation
from tkinter import messagebox, ttk
@@ -9,10 +10,371 @@ from ccma.domain.contributions import (
claim_balance,
claim_status,
decimal_value,
donation_balance,
money_text,
)
from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.donation_dialog import DonationEditDialog
class _AllocationTable:
"""Embeds a combined claims + donations allocation tree into a payment dialog.
Only claims/donations that are still open (or already linked to this payment) are
listed, so fully-settled ones don't clutter the picture. A row's visibility is
decided once, from a snapshot taken when the dialog opens (or when a new donation
is created inline) -- it never disappears again for the rest of the editing
session just because the user unassigned it. That way "Zuordnung lösen" followed
by "Zuordnung setzen" always works on the same row instead of the target vanishing.
Mutates the ``claim_allocations``/``donation_allocations`` dicts it is given in
place, so the owning dialog can read them back at save time. Nothing is written to
the repository until the dialog's own Save button does so.
"""
def __init__(
self,
parent: tk.Misc,
repository: MemberRepository,
member_id: str,
*,
claim_allocations: dict[str, str],
donation_allocations: dict[str, str],
on_change: Callable[[], None],
get_payment_amount: Callable[[], Decimal],
):
self.repository = repository
self.member_id = member_id
self.claim_allocations = claim_allocations
self.donation_allocations = donation_allocations
self.on_change = on_change
self.get_payment_amount = get_payment_amount
# Fixed snapshot of what this payment already covered when the dialog opened.
# Capacities are computed against this baseline rather than the live, editable
# dicts above -- otherwise "Maximal zuordenbar" would inflate every time the
# user changes an amount, since claim_balance() reflects only persisted state.
self._baseline_claim_allocations = dict(claim_allocations)
self._baseline_donation_allocations = dict(donation_allocations)
self._visible_claim_ids: set[str] = set(claim_allocations)
self._visible_donation_ids: set[str] = set(donation_allocations)
self._load_targets()
self._build(parent)
self._refresh()
def _load_targets(self) -> None:
self.data = self.repository.get_contributions(self.member_id)
self._visible_claim_ids |= {
str(claim.get("claim_id", ""))
for claim in self.data.claims
if str(claim.get("claim_id", ""))
and claim_status(self.data, claim) != "cancelled"
and claim_balance(self.data, claim) > 0
}
self._visible_donation_ids |= {
str(item.get("donation_id", ""))
for item in self.data.donations
if str(item.get("donation_id", "")) and donation_balance(self.data, item) > 0
}
self.claims_by_id = {
str(claim.get("claim_id", "")): claim
for claim in self.data.claims
if str(claim.get("claim_id", "")) in self._visible_claim_ids
}
self.donations_by_id = {
str(item.get("donation_id", "")): item
for item in self.data.donations
if str(item.get("donation_id", "")) in self._visible_donation_ids
}
def _capacities(self) -> None:
self.claim_capacity: dict[str, Decimal] = {}
for claim_id, claim in self.claims_by_id.items():
baseline = decimal_value(self._baseline_claim_allocations.get(claim_id, "0"))
self.claim_capacity[claim_id] = max(claim_balance(self.data, claim) + baseline, Decimal("0"))
self.donation_capacity: dict[str, Decimal] = {}
for donation_id, donation in self.donations_by_id.items():
baseline = decimal_value(self._baseline_donation_allocations.get(donation_id, "0"))
self.donation_capacity[donation_id] = max(
donation_balance(self.data, donation) + baseline, Decimal("0")
)
def _build(self, parent: tk.Misc) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
self.tree = ttk.Treeview(
parent,
columns=("kind", "title", "date", "capacity", "allocated"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("kind", "Typ", 90),
("title", "Forderung / Spende", 260),
("date", "Fällig / Datum", 110),
("capacity", "Maximal zuordenbar", 140),
("allocated", "Aktuell zugeordnet", 140),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w")
self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=self.tree.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.tree.configure(yscrollcommand=scrollbar.set)
self.tree.bind("<<TreeviewSelect>>", self._select)
actions = ttk.Frame(parent)
actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
ttk.Label(actions, text="Betrag für Auswahl").pack(side="left")
self.amount_var = tk.StringVar()
ttk.Entry(actions, textvariable=self.amount_var, width=14).pack(side="left", padx=(8, 8))
ttk.Button(actions, text="Zuordnung setzen", command=self._set).pack(side="left")
ttk.Button(actions, text="Zuordnung lösen", command=self._remove).pack(side="left", padx=(8, 0))
ttk.Separator(actions, orient="vertical").pack(side="left", fill="y", padx=10)
ttk.Button(actions, text="Neue Spende anlegen", command=self._create_donation).pack(side="left")
def _refresh(self) -> None:
selected = self.tree.selection()
self.tree.delete(*self.tree.get_children())
self._capacities()
for claim_id, claim in sorted(
self.claims_by_id.items(),
key=lambda item: (str(item[1].get("due_date", "")), str(item[1].get("title", "")).casefold()),
):
self.tree.insert(
"",
"end",
iid=f"claim:{claim_id}",
values=(
"Forderung",
claim.get("title", "Forderung"),
format_date_for_display(str(claim.get("due_date", ""))),
f"{money_text(self.claim_capacity[claim_id])} EUR",
f"{self.claim_allocations.get(claim_id, '0.00')} EUR",
),
)
for donation_id, donation in sorted(
self.donations_by_id.items(), key=lambda item: str(item[1].get("date", "")), reverse=True
):
label = donation.get("reference") or donation.get("purpose") or "Spende"
self.tree.insert(
"",
"end",
iid=f"donation:{donation_id}",
values=(
"Spende",
label,
format_date_for_display(str(donation.get("date", ""))),
f"{money_text(self.donation_capacity[donation_id])} EUR",
f"{self.donation_allocations.get(donation_id, '0.00')} EUR",
),
)
if selected and self.tree.exists(selected[0]):
self.tree.selection_set(selected[0])
self.on_change()
@staticmethod
def _target(iid: str) -> tuple[str, str]:
kind, target_id = iid.split(":", 1)
return kind, target_id
def _select(self, _event: tk.Event | None = None) -> None:
selected = self.tree.selection()
if not selected:
return
kind, target_id = self._target(selected[0])
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
current = allocations.get(target_id)
if current:
# Already allocated: show the existing amount so it can be reviewed/edited.
self.amount_var.set(current)
return
# Not yet allocated: suggest whichever is smaller -- what this claim/donation
# still needs, or what's left of the payment -- so the common case is just
# "click the row, then Zuordnung setzen".
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
free = max(self._payment_amount() - self.total_allocated(), Decimal("0"))
self.amount_var.set(money_text(min(capacity, free)))
def _payment_amount(self) -> Decimal:
try:
return self.get_payment_amount()
except ValueError:
return Decimal("0")
def _set(self) -> None:
selected = self.tree.selection()
if not selected:
messagebox.showerror(
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
)
return
kind, target_id = self._target(selected[0])
try:
value = decimal_value(self.amount_var.get(), "Zuordnung")
except ValueError as exc:
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self.tree)
return
capacity = (self.claim_capacity if kind == "claim" else self.donation_capacity)[target_id]
if value < 0 or value > capacity:
messagebox.showerror(
"Ungültige Zuordnung",
f"Es können höchstens {money_text(capacity)} EUR zugeordnet werden.",
parent=self.tree,
)
return
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
if value:
allocations[target_id] = money_text(value)
else:
allocations.pop(target_id, None)
self._refresh()
def _remove(self) -> None:
selected = self.tree.selection()
if not selected:
messagebox.showerror(
"Auswahl fehlt", "Bitte eine Forderung oder Spende auswählen.", parent=self.tree
)
return
kind, target_id = self._target(selected[0])
allocations = self.claim_allocations if kind == "claim" else self.donation_allocations
allocations.pop(target_id, None)
self.amount_var.set("0.00")
self._refresh()
def _create_donation(self) -> None:
DonationEditDialog(self.tree, self.repository, self.member_id, self._donation_created)
def _donation_created(self) -> None:
self._load_targets()
self._refresh()
def total_allocated(self) -> Decimal:
return sum(
(
decimal_value(value)
for value in (*self.claim_allocations.values(), *self.donation_allocations.values())
),
Decimal("0"),
)
class PaymentCreateDialog(tk.Toplevel):
"""Records a payment and lets the board allocate parts of it to open claims and/or
donations right away including creating a new donation on the fly instead of
having to save the bare payment first and assign it in a separate step."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_saved: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.on_saved = on_saved
self.claim_allocations: dict[str, str] = {}
self.donation_allocations: dict[str, str] = {}
self.allocation_table: _AllocationTable | None = None
self.title("Zahlung anlegen")
self.transient(master.winfo_toplevel())
self.geometry("880x560")
self.minsize(720, 460)
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_ui()
self.after_idle(self._activate)
def _activate(self) -> None:
try:
self.deiconify()
self.lift()
self.focus_force()
self.grab_set()
except tk.TclError:
return
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
form = ttk.Frame(self, padding=16)
form.grid(row=0, column=0, sticky="ew")
form.columnconfigure(1, weight=1)
self.variables = {
"date": tk.StringVar(value=format_date_for_display(date.today().isoformat())),
"amount": tk.StringVar(),
"gnucash": tk.StringVar(),
"reference": tk.StringVar(),
}
fields = (
(f"Zahlungsdatum ({date_input_hint()})", "date"),
("Zahlungsbetrag", "amount"),
("GnuCash-ID (optional)", "gnucash"),
("Referenz", "reference"),
)
for row, (label, key) in enumerate(fields):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=4)
ttk.Entry(form, textvariable=self.variables[key], width=70).grid(
row=row, column=1, sticky="ew", pady=4
)
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
self.total_var = tk.StringVar()
allocation_frame = ttk.LabelFrame(self, text="Sofort zuordnen (optional)", padding=12)
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
self.allocation_table = _AllocationTable(
allocation_frame,
self.repository,
self.member_id,
claim_allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
on_change=self._refresh_totals,
get_payment_amount=self._current_payment_amount,
)
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
)
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
buttons.grid(row=3, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons, text="Speichern", style="Accent.TButton", command=self._save
).pack(side="left")
self._refresh_totals()
def _current_payment_amount(self) -> Decimal:
return decimal_value(self.variables["amount"].get())
def _refresh_totals(self) -> None:
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
try:
payment_amount = self._current_payment_amount()
free = payment_amount - allocated
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR")
except (ValueError, InvalidOperation):
self.total_var.set(f"Zugeordnet: {money_text(allocated)} EUR · Betrag ungültig")
def _save(self) -> None:
try:
self.repository.create_payment(
self.member_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
claim_allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
except RepositoryError as exc:
messagebox.showerror("Zahlung konnte nicht gespeichert werden", str(exc), parent=self)
return
self.destroy()
self.on_saved()
class PaymentEditDialog(tk.Toplevel):
@@ -40,17 +402,8 @@ class PaymentEditDialog(tk.Toplevel):
)
if self.payment is None:
raise RepositoryError("Zahlung nicht gefunden.")
self.allocations = self._current_allocations()
self.claims_by_id = {
str(claim.get("claim_id", "")): claim
for claim in self.data.claims
if str(claim.get("claim_id", ""))
and (
claim_status(self.data, claim) != "cancelled"
or str(claim.get("claim_id", "")) in self.allocations
)
}
self.capacities = self._claim_capacities()
self.claim_allocations, self.donation_allocations = self._current_allocations()
self.allocation_table: _AllocationTable | None = None
self.title("Zahlung bearbeiten")
self.transient(master.winfo_toplevel())
@@ -59,26 +412,26 @@ class PaymentEditDialog(tk.Toplevel):
self.protocol("WM_DELETE_WINDOW", self.destroy)
self.bind("<Escape>", lambda _event: self.destroy())
self._build_ui()
self._refresh_claims()
self.after_idle(self._activate)
def _current_allocations(self) -> dict[str, str]:
totals: dict[str, Decimal] = {}
def _current_allocations(self) -> tuple[dict[str, str], dict[str, str]]:
claim_totals: dict[str, Decimal] = {}
donation_totals: dict[str, Decimal] = {}
for allocation in self.data.allocations:
if str(allocation.get("payment_id", "")) != self.payment_id:
continue
amount = decimal_value(allocation.get("amount", "0"))
donation_id = str(allocation.get("donation_id", ""))
if donation_id:
donation_totals[donation_id] = donation_totals.get(donation_id, Decimal("0")) + amount
continue
claim_id = str(allocation.get("claim_id", ""))
totals[claim_id] = totals.get(claim_id, Decimal("0")) + decimal_value(
allocation.get("amount", "0")
)
return {claim_id: money_text(amount) for claim_id, amount in totals.items() if claim_id}
def _claim_capacities(self) -> dict[str, Decimal]:
capacities = {}
for claim_id, claim in self.claims_by_id.items():
current = decimal_value(self.allocations.get(claim_id, "0"))
capacities[claim_id] = max(claim_balance(self.data, claim) + current, Decimal("0"))
return capacities
if claim_id:
claim_totals[claim_id] = claim_totals.get(claim_id, Decimal("0")) + amount
return (
{claim_id: money_text(value) for claim_id, value in claim_totals.items()},
{donation_id: money_text(value) for donation_id, value in donation_totals.items()},
)
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
@@ -106,51 +459,26 @@ class PaymentEditDialog(tk.Toplevel):
row=row, column=1, sticky="ew", pady=4
)
self.variables["amount"].trace_add("write", lambda *_args: self._refresh_totals())
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen", padding=12)
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
allocation_frame.columnconfigure(0, weight=1)
allocation_frame.rowconfigure(0, weight=1)
self.claims = ttk.Treeview(
allocation_frame,
columns=("title", "due", "available", "allocated"),
show="headings",
selectmode="browse",
)
for key, title, width in (
("title", "Forderung", 300),
("due", "Fällig", 110),
("available", "Maximal zuordenbar", 150),
("allocated", "Aktuell zugeordnet", 150),
):
self.claims.heading(key, text=title)
self.claims.column(key, width=width, anchor="w")
self.claims.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(allocation_frame, orient="vertical", command=self.claims.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
self.claims.configure(yscrollcommand=scrollbar.set)
self.claims.bind("<<TreeviewSelect>>", self._select_claim)
allocation_actions = ttk.Frame(allocation_frame)
allocation_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
ttk.Label(allocation_actions, text="Betrag für ausgewählte Forderung").pack(side="left")
self.allocation_var = tk.StringVar()
ttk.Entry(allocation_actions, textvariable=self.allocation_var, width=14).pack(
side="left", padx=(8, 8)
)
ttk.Button(allocation_actions, text="Zuordnung setzen", command=self._set_allocation).pack(
side="left"
)
ttk.Button(allocation_actions, text="Zuordnung lösen", command=self._remove_allocation).pack(
side="left", padx=(8, 0)
)
self.total_var = tk.StringVar()
ttk.Label(allocation_actions, textvariable=self.total_var, style="Mono.TLabel").pack(
side="right"
allocation_frame = ttk.LabelFrame(self, text="Aufteilung auf Forderungen und Spenden", padding=12)
allocation_frame.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12))
self.allocation_table = _AllocationTable(
allocation_frame,
self.repository,
self.member_id,
claim_allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
on_change=self._refresh_totals,
get_payment_amount=self._current_payment_amount,
)
ttk.Label(self, textvariable=self.total_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", padx=16, pady=(0, 8)
)
buttons = ttk.Frame(self, padding=(16, 0, 16, 16))
buttons.grid(row=2, column=0, sticky="e")
buttons.grid(row=3, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=(0, 8))
ttk.Button(
buttons,
@@ -158,6 +486,7 @@ class PaymentEditDialog(tk.Toplevel):
style="Accent.TButton",
command=self._save,
).pack(side="left")
self._refresh_totals()
def _activate(self) -> None:
try:
@@ -168,76 +497,13 @@ class PaymentEditDialog(tk.Toplevel):
except tk.TclError:
return
def _refresh_claims(self) -> None:
selected = self.claims.selection()
self.claims.delete(*self.claims.get_children())
ordered = sorted(
self.claims_by_id.items(),
key=lambda item: (
str(item[1].get("due_date", "")),
str(item[1].get("title", "")).casefold(),
),
)
for claim_id, claim in ordered:
self.claims.insert(
"",
"end",
iid=claim_id,
values=(
claim.get("title", "Forderung"),
format_date_for_display(str(claim.get("due_date", ""))),
f"{money_text(self.capacities[claim_id])} EUR",
f"{self.allocations.get(claim_id, '0.00')} EUR",
),
)
if selected and self.claims.exists(selected[0]):
self.claims.selection_set(selected[0])
self._refresh_totals()
def _select_claim(self, _event=None) -> None:
selected = self.claims.selection()
if selected:
self.allocation_var.set(self.allocations.get(selected[0], "0.00"))
def _set_allocation(self) -> None:
selected = self.claims.selection()
if not selected:
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
return
try:
amount = decimal_value(self.allocation_var.get(), "Zuordnung")
except ValueError as exc:
messagebox.showerror("Ungültige Zuordnung", str(exc), parent=self)
return
capacity = self.capacities[selected[0]]
if amount < 0 or amount > capacity:
messagebox.showerror(
"Ungültige Zuordnung",
f"Für diese Forderung können höchstens {money_text(capacity)} EUR zugeordnet werden.",
parent=self,
)
return
if amount:
self.allocations[selected[0]] = money_text(amount)
else:
self.allocations.pop(selected[0], None)
self._refresh_claims()
def _remove_allocation(self) -> None:
selected = self.claims.selection()
if not selected:
messagebox.showerror("Forderung auswählen", "Bitte eine Forderung auswählen.", parent=self)
return
self.allocations.pop(selected[0], None)
self.allocation_var.set("0.00")
self._refresh_claims()
def _current_payment_amount(self) -> Decimal:
return decimal_value(self.variables["amount"].get())
def _refresh_totals(self) -> None:
allocated = sum(
(decimal_value(value) for value in self.allocations.values()), Decimal("0")
)
allocated = self.allocation_table.total_allocated() if self.allocation_table else Decimal("0")
try:
payment_amount = decimal_value(self.variables["amount"].get())
payment_amount = self._current_payment_amount()
free = payment_amount - allocated
self.total_var.set(
f"Zugeordnet: {money_text(allocated)} EUR · Frei: {money_text(free)} EUR"
@@ -252,7 +518,8 @@ class PaymentEditDialog(tk.Toplevel):
self.payment_id,
payment_date=self.variables["date"].get(),
amount=self.variables["amount"].get(),
allocations=self.allocations,
allocations=self.claim_allocations,
donation_allocations=self.donation_allocations,
gnucash_transaction_id=self.variables["gnucash"].get(),
reference=self.variables["reference"].get(),
)
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import tkinter as tk
from datetime import date, timedelta
from decimal import Decimal
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from ccma.domain.dates import DateValidationError, normalize_date_input
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
from ccma.services.sepa_mail import generate_debit_mails
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
def _next_weekday(selected: date) -> date:
while selected.weekday() >= 5:
selected += timedelta(days=1)
return selected
class SepaExportDialog(tk.Toplevel):
def __init__(self, master: tk.Misc, repository: MemberRepository):
super().__init__(master)
self.repository = repository
self.debits = []
self.warnings: list[str] = []
self.title("SEPA-Lastschriften")
self.transient(master.winfo_toplevel())
self.geometry("1000x560")
self.minsize(760, 420)
self.from_var = tk.StringVar(value=date(date.today().year, 1, 1).isoformat())
self.due_var = tk.StringVar(value=date.today().isoformat())
self.collection_var = tk.StringVar(
value=_next_weekday(date.today() + timedelta(days=2)).isoformat()
)
self.summary_var = tk.StringVar()
self.warning_var = tk.StringVar()
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(3, weight=1)
filters = ttk.Frame(self, padding=12)
filters.grid(row=0, column=0, sticky="ew")
ttk.Label(filters, text="Fällig ab").grid(row=0, column=0)
ttk.Entry(filters, textvariable=self.from_var, width=14).grid(
row=0, column=1, padx=(8, 20)
)
ttk.Label(filters, text="Fällig bis").grid(row=0, column=2)
ttk.Entry(filters, textvariable=self.due_var, width=14).grid(
row=0, column=3, padx=(8, 20)
)
ttk.Label(filters, text="Einzugsdatum").grid(row=0, column=4)
ttk.Entry(filters, textvariable=self.collection_var, width=14).grid(
row=0, column=5, padx=(8, 20)
)
ttk.Button(filters, text="Liste aktualisieren", command=self.refresh).grid(row=0, column=6)
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", padx=12, pady=(0, 2)
)
ttk.Label(
self,
text=(
"Standardmäßig sind alle Lastschriften ausgewählt -- einzelne mit Strg+Klick "
"abwählen (z. B. während eine Rücklastschrift geklärt wird), um sie erst bei "
"einem späteren Einzug zu berücksichtigen."
),
style="Muted.TLabel",
wraplength=960,
).grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
self.table = ttk.Treeview(
self,
columns=("number", "name", "iban", "mandate", "purpose", "amount"),
show="headings",
selectmode="extended",
)
for key, label, width, anchor in (
("number", "Mitglied", 100, "w"), ("name", "Kontoinhaber", 170, "w"),
("iban", "IBAN", 190, "w"), ("mandate", "Mandat", 130, "w"),
("purpose", "Verwendungszweck", 280, "w"), ("amount", "Betrag", 90, "e"),
):
self.table.heading(key, text=label)
self.table.column(key, width=width, anchor=anchor)
self.table.grid(row=3, column=0, sticky="nsew", padx=12)
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
footer = ttk.Frame(self, padding=12)
footer.grid(row=4, column=0, sticky="ew")
footer.columnconfigure(0, weight=1)
ttk.Label(footer, textvariable=self.warning_var).grid(row=0, column=0, sticky="w")
ttk.Button(footer, text="CSV-Liste speichern", command=self._save_csv).grid(
row=0, column=1, padx=(8, 0)
)
ttk.Button(footer, text="Info-Mails erzeugen", command=self._open_mail_export).grid(
row=0, column=2, padx=(8, 0)
)
ttk.Button(
footer, text="SEPA-XML speichern", style="Accent.TButton", command=self._save_xml
).grid(row=0, column=3, padx=(8, 0))
ttk.Button(footer, text="Schließen", command=self.destroy).grid(
row=0, column=4, padx=(8, 0)
)
def _dates(self) -> tuple[date, date, date]:
try:
due_from = date.fromisoformat(
normalize_date_input(self.from_var.get(), "Fällig ab")
)
due = date.fromisoformat(normalize_date_input(self.due_var.get(), "Fällig bis"))
collection = date.fromisoformat(
normalize_date_input(self.collection_var.get(), "Einzugsdatum")
)
except (DateValidationError, ValueError) as exc:
raise RepositoryError(str(exc)) from exc
if due_from > due:
raise RepositoryError("„Fällig ab“ darf nicht nach „Fällig bis“ liegen.")
if collection.weekday() >= 5:
raise RepositoryError(
"Das Einzugsdatum muss ein Bankarbeitstag von Montag bis Freitag sein."
)
return due_from, due, collection
def refresh(self) -> None:
# Preserve which members were deliberately deselected across a refresh (e.g.
# _prepare() re-querying right before an export) by identity, not by row index
# -- a debit newly appearing in the requeried list still defaults to selected.
previously_deselected = {
self.debits[int(iid)].member_id
for iid in self.table.get_children()
if iid not in self.table.selection()
}
try:
due_from, due, _collection = self._dates()
self.debits, self.warnings = pending_direct_debits(
self.repository, due_from=due_from, due_until=due
)
except RepositoryError as exc:
messagebox.showerror("SEPA-Liste konnte nicht erstellt werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for index, debit in enumerate(self.debits):
self.table.insert(
"", "end", iid=str(index),
values=(debit.member_number, debit.account_holder, debit.iban,
debit.mandate_reference, debit.purpose, f"{debit.amount:.2f} EUR"),
)
self.table.selection_set(
[
str(index)
for index, debit in enumerate(self.debits)
if debit.member_id not in previously_deselected
]
)
self._update_summary()
self.warning_var.set(
f"{len(self.warnings)} Position(en) übersprungen Details beim Export."
if self.warnings
else ""
)
def _selected_debits(self) -> list:
return [self.debits[int(iid)] for iid in sorted(self.table.selection(), key=int)]
def _update_summary(self) -> None:
selected = self._selected_debits()
total = sum((item.amount for item in self.debits), Decimal("0"))
if len(selected) == len(self.debits):
self.summary_var.set(f"{len(self.debits)} Lastschrift(en) · Summe {total:.2f} EUR")
return
selected_total = sum((item.amount for item in selected), Decimal("0"))
self.summary_var.set(
f"{len(selected)} von {len(self.debits)} Lastschrift(en) ausgewählt · "
f"Summe {selected_total:.2f} EUR (gesamt {total:.2f} EUR)"
)
def _prepare(self) -> tuple[date, date, date] | None:
self.refresh()
if not self.debits:
messagebox.showinfo(
"Keine Lastschriften", "Für den Stichtag ist nichts einzuziehen.", parent=self
)
return None
if not self._selected_debits():
messagebox.showinfo(
"Keine Auswahl", "Bitte mindestens eine Lastschrift auswählen.", parent=self
)
return None
if self.warnings and not messagebox.askyesno(
"Übersprungene Positionen",
"\n".join(self.warnings[:10]) + "\n\nMit den übrigen Lastschriften fortfahren?",
parent=self,
):
return None
return self._dates()
def _target(self, extension: str, title: str, collection: date) -> Path | None:
selected = filedialog.asksaveasfilename(
parent=self, title=title, defaultextension=extension,
initialfile=f"SEPA-Lastschriften-{collection.isoformat()}{extension}",
filetypes=[(title, f"*{extension}"), ("Alle Dateien", "*.*")],
)
return Path(selected) if selected else None
def _save_csv(self) -> None:
dates = self._prepare()
if not dates:
return
target = self._target(".csv", "CSV-Datei", dates[2])
if target:
write_csv(target, self._selected_debits(), collection_date=dates[2])
messagebox.showinfo("Export gespeichert", f"CSV gespeichert:\n{target}", parent=self)
def _save_xml(self) -> None:
dates = self._prepare()
if not dates:
return
target = self._target(".xml", "SEPA-XML", dates[2])
if not target:
return
try:
write_pain008(
target, self._selected_debits(), collection_date=dates[2],
organization=self.repository.get_configuration().get("organization") or {},
)
except RepositoryError as exc:
messagebox.showerror("SEPA-XML konnte nicht erstellt werden", str(exc), parent=self)
return
messagebox.showinfo("Export gespeichert", f"SEPA-XML gespeichert:\n{target}", parent=self)
def _open_mail_export(self) -> None:
dates = self._prepare()
if dates:
SepaMailExportDialog(self, self.repository, self._selected_debits(), dates[2])
class SepaMailExportDialog(tk.Toplevel):
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
debits: list,
collection_date: date,
):
super().__init__(master)
self.repository = repository
self.debits = debits
self.collection_date = collection_date
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(
value=str(organization.get("name") or "Verwaltung C3MA")
)
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.title("SEPA-Info-Mails erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
f"Für {len(debits)} Lastschrift(en) werden Thunderbird-kompatible "
".eml-Entwürfe erzeugt."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="Info-Mails erzeugen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_directory = None
if delivery_mode == "local":
selected = filedialog.askdirectory(
parent=self,
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
mustexist=True,
)
if not selected:
return
output_directory = selected
try:
generated, warnings = generate_debit_mails(
self.repository,
self.debits,
collection_date=self.collection_date,
delivery_mode=delivery_mode,
output_directory=output_directory,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
return
location = {
"local": f"in\n{output_directory}\nerzeugt",
"send": "direkt versandt",
"drafts": "als Entwurf im Postfach abgelegt",
}[delivery_mode]
detail = f"{len(generated)} Info-Mail(s) wurden {location} und in den Mitgliederakten archiviert."
if warnings:
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
self.destroy()
+8 -1
View File
@@ -121,7 +121,14 @@ class SplashScreen(tk.Toplevel):
self.repository.list_members()
member_count = len(self.repository.list_member_ids())
findings = []
if self.run_housekeeper:
# A read-only store makes the housekeeper impossible, not just
# unwanted: every one of its passes writes. Skipping it keeps the
# rest of the startup -- and the whole read-only session -- working.
if self.repository.read_only:
self._messages.put(
("status", "Store ist schreibgeschützt Hausmeister übersprungen …")
)
elif self.run_housekeeper:
self._messages.put(("status", "Starte Hausmeister …"))
findings = Housekeeper(self.repository, self.housekeeper_settings).run(
member_delay=_member_delay_for_splash(
+241
View File
@@ -0,0 +1,241 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable
from decimal import Decimal
from email.parser import BytesParser
from email.policy import default as default_policy
from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import claim_balance, money_text
from ccma.domain.dates import format_date_for_display
from ccma.services.welcome_mail import (
generate_and_send_welcome_mail,
open_claims,
welcome_mail_bytes,
)
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
class WelcomeMailDialog(tk.Toplevel):
"""Sends the welcome mail that doubles as the first invoice for a new member:
membership start, member number and every claim that is still open."""
def __init__(
self,
master: tk.Misc,
repository: MemberRepository,
member_id: str,
on_sent: Callable[[], None],
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.on_sent = on_sent
self.claims: list[dict] = []
organization = repository.get_configuration().get("organization") or {}
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
self.summary_var = tk.StringVar()
self.title("Willkommens- und Erstrechnungsmail")
self.transient(master.winfo_toplevel())
self.resizable(True, True)
self._build_ui()
self._refresh_claims()
self.grab_set()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(2, weight=1)
ttk.Label(
self,
text=(
"Begrüßt das neue Mitglied und stellt die noch offenen Forderungen "
"(Aufnahmegebühr und erster Beitrag) als Erstrechnung zusammen. Text und "
"Betreff stammen aus der Vorlage unter Optionen → E-Mail-Vorlagen."
),
wraplength=640,
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
ttk.Label(
self,
text=(
"Standardmäßig sind alle offenen Forderungen ausgewählt einzelne mit "
"Strg+Klick abwählen."
),
style="Muted.TLabel",
wraplength=640,
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
table = ttk.Frame(self)
table.grid(row=2, column=0, sticky="nsew", padx=14)
table.columnconfigure(0, weight=1)
table.rowconfigure(0, weight=1)
self.table = ttk.Treeview(
table,
columns=("due", "amount", "title"),
show="headings",
selectmode="extended",
height=6,
)
for key, title, width in (
("due", "Fällig", 90),
("amount", "Offen", 90),
("title", "Forderung", 320),
):
self.table.heading(key, text=title)
self.table.column(key, width=width, anchor="w", stretch=key == "title")
self.table.grid(row=0, column=0, sticky="nsew")
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
scroll.grid(row=0, column=1, sticky="ns")
self.table.configure(yscrollcommand=scroll.set)
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
row=3, column=0, sticky="w", padx=14, pady=(6, 10)
)
fields = ttk.Frame(self)
fields.grid(row=4, column=0, sticky="ew", padx=14)
fields.columnconfigure(1, weight=1)
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=5, column=0, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(buttons, text="Vorschau", command=self._preview).pack(side="left", padx=(8, 0))
ttk.Button(
buttons, text="E-Mail erzeugen", style="Accent.TButton", command=self._generate
).pack(side="left", padx=(8, 0))
def _refresh_claims(self) -> None:
try:
self.claims = open_claims(self.repository, self.member_id)
data = self.repository.get_contributions(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Forderungen konnten nicht gelesen werden", str(exc), parent=self)
return
self.table.delete(*self.table.get_children())
for index, claim in enumerate(self.claims):
self.table.insert(
"",
"end",
iid=str(index),
values=(
format_date_for_display(str(claim.get("due_date", ""))),
money_text(claim_balance(data, claim)),
str(claim.get("description", "") or claim.get("title", "")),
),
)
self.table.selection_set(self.table.get_children())
self._update_summary()
def _selected_claims(self) -> list[dict]:
return [self.claims[int(iid)] for iid in self.table.selection()]
def _update_summary(self) -> None:
data = self.repository.get_contributions(self.member_id)
selected = self._selected_claims()
total = sum((claim_balance(data, claim) for claim in selected), Decimal("0"))
self.summary_var.set(
f"{len(selected)} von {len(self.claims)} Forderungen ausgewählt "
f"Gesamtbetrag {money_text(total)} Euro"
)
def _mail_bytes(self) -> bytes:
member = self.repository.get_member(self.member_id)
data = self.repository.get_contributions(self.member_id)
claims = self._selected_claims()
return welcome_mail_bytes(
self.repository,
member=member,
claims=claims,
balances={
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
},
organization=self.repository.get_configuration().get("organization") or {},
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
def _preview(self) -> None:
try:
message = BytesParser(policy=default_policy).parsebytes(self._mail_bytes())
except (OSError, RepositoryError) as exc:
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
return
window = tk.Toplevel(self)
window.title("Vorschau der Willkommensmail")
window.transient(self)
window.columnconfigure(0, weight=1)
window.rowconfigure(1, weight=1)
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
row=0, column=0, sticky="w"
)
preview = tk.Text(window, wrap="word", width=88, height=28)
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
preview.insert("1.0", message.get_content())
preview.configure(state="disabled")
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
preview.configure(yscrollcommand=scroll.set)
ttk.Button(window, text="Schließen", command=window.destroy).grid(
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
)
def _generate(self) -> None:
selected = self._selected_claims()
if not selected and not messagebox.askyesno(
"Keine Forderung ausgewählt",
"Es ist keine Forderung ausgewählt. Soll die Mail ohne Rechnungspositionen "
"erzeugt werden?",
parent=self,
):
return
delivery_mode = resolve_delivery_mode(self, self.repository)
if delivery_mode is None:
return
output_path = None
if delivery_mode == "local":
chosen = filedialog.asksaveasfilename(
parent=self,
title="Willkommensmail als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile="Willkommen.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not chosen:
return
output_path = chosen
try:
result = generate_and_send_welcome_mail(
self.repository,
self.member_id,
claim_ids=[str(claim.get("claim_id", "")) for claim in selected],
delivery_mode=delivery_mode,
output_path=output_path,
sender_name=self.sender_name_var.get(),
sender_email=self.sender_email_var.get(),
signature=self.signature.get("1.0", "end-1c"),
)
except (OSError, RepositoryError) as exc:
messagebox.showerror("E-Mail konnte nicht erzeugt werden", str(exc), parent=self)
return
outcome = {
"local": f"gespeichert:\n{result.export_path}",
"send": "direkt versandt.",
"drafts": "als Entwurf im Postfach abgelegt.",
}[delivery_mode]
messagebox.showinfo(
"Willkommensmail erzeugt",
f"Die E-Mail wurde {outcome}\n\nEine Kopie liegt in der Mitgliederakte.",
parent=self,
)
self.destroy()
self.on_sent()
+15 -5
View File
@@ -245,12 +245,14 @@ class MembersTab(ttk.Frame):
findings: list[HousekeeperFinding],
on_open: Callable[[str], None],
on_close: Callable[[], None],
on_data_review: Callable[[], None] | None = None,
):
super().__init__(master, padding=12)
self.members = members
self.findings = findings
self.on_open = on_open
self.on_close = on_close
self.on_data_review = on_data_review
self._build_ui()
def _build_ui(self) -> None:
@@ -262,7 +264,15 @@ class MembersTab(ttk.Frame):
ttk.Label(header, text="MITGLIEDER", style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
self.count_var = tk.StringVar()
ttk.Label(header, textvariable=self.count_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2)
buttons = ttk.Frame(header)
buttons.grid(row=0, column=1, rowspan=2, sticky="e")
if self.on_data_review is not None:
# The mailing goes to every member, so it belongs where every member is
# listed rather than into the already crowded ribbon.
ttk.Button(
buttons, text="Datenüberprüfung anfragen", command=self.on_data_review
).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="Tab schließen", command=self.on_close).pack(side="left")
filters = _filter_label_frame(self)
filters.grid(row=1, column=0, sticky="ew", pady=(0, 10))
filter_row = ttk.Frame(filters)
@@ -597,15 +607,15 @@ class HousekeeperTab(ttk.Frame):
row=0, column=1, rowspan=2, padx=(0, 8)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=2, rowspan=2)
self.tree = ttk.Treeview(self, columns=("severity", "title", "detail", "due"), show="headings")
self.tree = ttk.Treeview(self, columns=("severity", "due", "title", "detail"), show="headings")
for key, title, width in (
("severity", "Level", 90),
("due", "Fällig", 100),
("title", "Vorgang", 330),
("detail", "Details", 390),
("due", "Fällig", 110),
):
self.tree.heading(key, text=title)
self.tree.column(key, width=width, anchor="w")
self.tree.column(key, width=width, anchor="w", stretch=key == "detail")
self.tree.grid(row=1, column=0, sticky="nsew")
self.tree.bind("<Double-1>", lambda _event: self._open_selected())
self.tree.bind("<<TreeviewSelect>>", lambda _event: self._show_selected_details())
@@ -649,7 +659,7 @@ class HousekeeperTab(ttk.Frame):
"",
"end",
iid=str(index),
values=(finding.severity.upper(), finding.title, finding.detail, finding.due_date or ""),
values=(finding.severity.upper(), finding.due_date or "", finding.title, finding.detail),
)
def _show_selected_details(self) -> None:
+20
View File
@@ -0,0 +1,20 @@
import pytest
@pytest.fixture(scope="session")
def tk_root():
"""One Tk root for the whole test session, shared by every UI test module: the
icon library binds its images to the first root, so a second one (or a root torn
down between tests) invalidates them with 'image "pyimage1" doesn\'t exist'.
Skips where there is no display, which keeps the suite running on headless CI."""
tk = pytest.importorskip("tkinter")
from ccma.ui.theme import load_theme
try:
root = tk.Tk()
except tk.TclError as exc:
pytest.skip(f"kein Display verfügbar: {exc}")
root.withdraw()
load_theme(root, "dark")
yield root
root.destroy()
+24
View File
@@ -48,3 +48,27 @@ def test_store_path_can_be_overridden_from_ccma_environment(tmp_path, monkeypatc
monkeypatch.setenv("CCMA_STORE", str(tmp_path / "store"))
assert load_config().store_path == str(tmp_path / "store")
def test_gnucash_last_account_per_file_round_trips(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config"))
first_file = str(tmp_path / "verein.gnucash")
second_file = str(tmp_path / "spenden.gnucash")
config = AppConfig(
gnucash_last_accounts={first_file: "bank-guid", second_file: "cash-guid"}
)
config.save()
loaded = load_config()
assert loaded.gnucash_last_accounts == {first_file: "bank-guid", second_file: "cash-guid"}
def test_gnucash_last_accounts_defaults_to_empty_dict_for_malformed_value(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("CCMA_CONFIG_DIR", str(tmp_path / "config"))
config_path = tmp_path / "config" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps({"schema_version": 1, "gnucash_last_accounts": "not-a-dict"}), encoding="utf-8"
)
assert load_config().gnucash_last_accounts == {}
+300
View File
@@ -0,0 +1,300 @@
from datetime import date
import pytest
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
def _member(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Test", birth_date="2010-01-01")
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
member.payment_frequency = "semiannual"
repository.save_member(member)
return repository, member
def test_contribution_override_can_be_recorded_with_amount(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="03.2026",
valid_until="",
kind="amount",
value="90.00",
reason="Schüler laut Nachweis",
)
data = repository.get_contributions(member.member_id)
assert data.contribution_overrides == [override]
assert override["valid_from"] == "2026-03"
assert override["valid_until"] == ""
assert override["kind"] == "amount"
assert override["value"] == "90.00"
assert override["reason"] == "Schüler laut Nachweis"
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_recorded"
def test_contribution_override_accepts_percent_kind(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="2026-03",
valid_until="2026-08",
kind="percent",
value="50",
reason="Schüler laut Nachweis",
)
assert override["kind"] == "percent"
assert override["value"] == "50.00"
assert override["valid_until"] == "2026-08"
def test_contribution_override_requires_reason(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="Begründung"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="amount", value="90.00", reason=" "
)
def test_contribution_override_rejects_invalid_percent(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="percent", value="150", reason="Test"
)
with pytest.raises(RepositoryError, match="zwischen 0 und 100"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="percent", value="0", reason="Test"
)
def test_contribution_override_rejects_negative_amount(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="nicht negativ"):
repository.record_contribution_override(
member.member_id, valid_from="03.2026", kind="amount", value="-10", reason="Test"
)
def test_contribution_override_rejects_until_before_from(tmp_path) -> None:
repository, member = _member(tmp_path)
with pytest.raises(RepositoryError, match="darf nicht vor"):
repository.record_contribution_override(
member.member_id,
valid_from="08.2026",
valid_until="03.2026",
kind="amount",
value="90.00",
reason="Test",
)
def test_overlapping_contribution_overrides_are_rejected(tmp_path) -> None:
repository, member = _member(tmp_path)
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="90.00",
reason="Erstes",
)
with pytest.raises(RepositoryError, match="überschneidet sich"):
repository.record_contribution_override(
member.member_id,
valid_from="2026-04",
valid_until="2026-12",
kind="amount",
value="100.00",
reason="Zweites",
)
# Adjacent, non-overlapping range is fine.
second = repository.record_contribution_override(
member.member_id,
valid_from="2026-07",
valid_until="",
kind="amount",
value="100.00",
reason="Zweites, ab Juli",
)
assert second["valid_from"] == "2026-07"
def test_open_ended_override_overlaps_with_any_later_range(tmp_path) -> None:
repository, member = _member(tmp_path)
repository.record_contribution_override(
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Unbefristet"
)
with pytest.raises(RepositoryError, match="überschneidet sich"):
repository.record_contribution_override(
member.member_id,
valid_from="2030-01",
valid_until="2030-06",
kind="amount",
value="100.00",
reason="Später",
)
def test_contribution_override_can_be_updated_and_deleted(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id, valid_from="2026-01", kind="amount", value="90.00", reason="Erstversion"
)
updated = repository.update_contribution_override(
member.member_id,
override["override_id"],
valid_from="2026-02",
valid_until="2026-12",
kind="percent",
value="40",
reason="Korrigiert",
)
assert updated["valid_from"] == "2026-02"
assert updated["kind"] == "percent"
assert updated["reason"] == "Korrigiert"
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_changed"
repository.delete_contribution_override(member.member_id, override["override_id"])
data = repository.get_contributions(member.member_id)
assert data.contribution_overrides == []
assert repository.get_events(member.member_id)[-1].event_type == "contribution_override_deleted"
def test_updating_override_can_keep_its_own_range(tmp_path) -> None:
repository, member = _member(tmp_path)
override = repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="90.00",
reason="Erstversion",
)
# Saving again with the same range must not trip the overlap check against itself.
updated = repository.update_contribution_override(
member.member_id,
override["override_id"],
valid_from="2026-01",
valid_until="2026-06",
kind="amount",
value="95.00",
reason="Betrag korrigiert",
)
assert updated["value"] == "95.00"
def _housekeeper_member(tmp_path, *, payment_frequency="annual", started_at="2025-01-01"):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Override", last_name="Claims", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = started_at
member.membership_started_at = started_at
member.payment_frequency = payment_frequency
repository.save_member(member)
return repository, member
def test_full_year_amount_override_replaces_the_annual_claim_amount(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-12",
kind="amount",
value="60.00",
reason="Schüler laut Nachweis",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claims_by_key = {
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
}
claim = claims_by_key["membership-fee:2026:annual"]
assert claim["amount"] == "60.00"
assert claim["calculation"]["contribution_override_ids"] != []
def test_full_year_percent_override_reduces_the_base_rate(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-01",
valid_until="2026-12",
kind="percent",
value="50",
reason="Schüler laut Nachweis",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim = next(
claim
for claim in repository.get_contributions(member.member_id).claims
if claim["claim_key"] == "membership-fee:2026:annual"
)
assert claim["amount"] == "75.00"
def test_override_starting_mid_period_blends_month_by_month(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="semiannual")
repository.record_contribution_override(
member.member_id,
valid_from="2026-03",
valid_until="2026-08",
kind="amount",
value="60.00",
reason="Schüler ab März",
)
Housekeeper(repository).run(today=date(2026, 12, 31))
claims_by_key = {
claim["claim_key"]: claim for claim in repository.get_contributions(member.member_id).claims
}
# Jan+Feb at 12.50/month (full rate) + Mar-Jun at 5.00/month (override) = 45.00
assert claims_by_key["membership-fee:2026:first-half"]["amount"] == "45.00"
# Jul+Aug at 5.00/month (override) + Sep-Dec at 12.50/month (full rate) = 60.00
assert claims_by_key["membership-fee:2026:second-half"]["amount"] == "60.00"
def test_claim_without_applicable_override_is_unaffected(tmp_path) -> None:
repository, member = _housekeeper_member(tmp_path, payment_frequency="annual")
repository.record_contribution_override(
member.member_id,
valid_from="2020-01",
valid_until="2020-12",
kind="amount",
value="60.00",
reason="Frueherer Zeitraum",
)
Housekeeper(repository).run(today=date(2026, 6, 21))
claim = next(
claim
for claim in repository.get_contributions(member.member_id).claims
if claim["claim_key"] == "membership-fee:2026:annual"
)
assert claim["amount"] == "150.00"
assert claim["calculation"]["contribution_override_ids"] == []
+323 -3
View File
@@ -9,6 +9,9 @@ from ccma.domain.contributions import (
claim_settled_total,
claim_status,
claim_total,
donation_allocated_total,
donation_balance,
donation_status,
payment_allocated_total,
)
from ccma.domain.models import ContributionData
@@ -147,6 +150,36 @@ def test_payment_can_be_split_across_multiple_claims(tmp_path) -> None:
)
def test_combined_dummy_payment_settles_multiple_claims(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "claim-2",
"title": "Zweite Forderung",
"amount": "50.00",
"due_date": "2019-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
payment = repository.record_combined_payment(
member.member_id,
payment_date="2019-12-31",
allocations={"claim-1": "100.00", "claim-2": "50.00"},
method="dummy",
reference="Altbestand",
)
changed = repository.get_contributions(member.member_id)
assert payment["amount"] == "150.00"
assert payment["method"] == "dummy"
assert payment_allocated_total(changed, payment["payment_id"]) == Decimal("150.00")
assert all(claim_balance(changed, claim) == 0 for claim in changed.claims)
assert repository.get_events(member.member_id)[-1].data["method"] == "dummy"
def test_payment_allocation_cannot_overpay_claim(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="12.50")
@@ -218,6 +251,71 @@ def test_payment_can_be_deleted_with_its_allocations(tmp_path) -> None:
assert repository.get_events(member.member_id)[-1].event_type == "payment_deleted"
def test_payment_reference_can_be_replaced_without_touching_amount_or_allocations(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
reference="Alte Referenz",
)
updated = repository.update_payment_reference(
member.member_id,
payment["payment_id"],
reference="Neue Referenz aus GnuCash",
gnucash_transaction_id="TX-99",
)
data = repository.get_contributions(member.member_id)
assert updated["reference"] == "Neue Referenz aus GnuCash"
assert updated["gnucash_transaction_id"] == "TX-99"
assert data.payments[0]["amount"] == "10.00"
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("10.00")
assert repository.get_events(member.member_id)[-1].event_type == "payment_changed"
def test_payment_reference_update_rejects_gnucash_id_already_used_elsewhere(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
gnucash_transaction_id="TX-1",
)
data = repository.get_contributions(member.member_id)
data.claims.append(
{
"claim_id": "claim-2",
"claim_key": "second-claim",
"title": "Zweite Forderung",
"amount": "20.00",
"due_date": "2026-12-31",
"status": "open",
}
)
repository.save_contributions(member.member_id, data)
second_payment = repository.record_payment(
member.member_id,
"claim-2",
payment_date="2026-06-22",
amount="20.00",
allocation_amount="20.00",
)
with pytest.raises(RepositoryError, match="GnuCash-ID bereits verwendet"):
repository.update_payment_reference(
member.member_id,
second_payment["payment_id"],
reference="Duplikatversuch",
gnucash_transaction_id="TX-1",
)
def test_credit_claim_settlement_is_displayed_as_positive_amount() -> None:
claim = {"claim_id": "claim-1", "title": "Kautionsrückzahlung", "amount": "-25.00"}
data = ContributionData(
@@ -247,13 +345,13 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Per E-Mail versandt",
fee="5.00",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
assert reminder["status"] == "draft"
assert reminder["fee_item_id"] is None
assert reminder["fee_item_ids"] == []
repository.mark_reminder_sent(member.member_id, "claim-1", reminder["reminder_id"])
data, claim = repository.get_claim(member.member_id, "claim-1")
@@ -262,7 +360,7 @@ def test_reminder_fee_increases_claim_and_is_audited(tmp_path) -> None:
assert claim_total(claim) == Decimal("105.00")
assert sent["status"] == "sent"
assert sent["payment_deadline"]
assert sent["fee_item_id"]
assert sent["fee_item_ids"]
assert data.reminders[0]["detail"] == "Per E-Mail versandt"
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent"
@@ -281,6 +379,228 @@ def test_claim_with_payment_cannot_be_cancelled(tmp_path) -> None:
repository.cancel_claim(member.member_id, "claim-1")
def test_claim_can_be_deleted_and_releases_allocated_payment(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
payment = repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="60.00",
allocation_amount="60.00",
)
repository.delete_claim(member.member_id, "claim-1")
data = repository.get_contributions(member.member_id)
assert data.claims == []
assert data.allocations == []
assert data.payments[0]["payment_id"] == payment["payment_id"]
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
assert repository.get_events(member.member_id)[-1].event_type == "claim_deleted"
with pytest.raises(RepositoryError, match="nicht gefunden"):
repository.get_claim(member.member_id, "claim-1")
def test_claim_with_payment_can_be_deleted_even_though_it_cannot_be_cancelled(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path)
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-06-21",
amount="10.00",
allocation_amount="10.00",
)
with pytest.raises(RepositoryError, match="Zahlungszuordnungen"):
repository.cancel_claim(member.member_id, "claim-1")
repository.delete_claim(member.member_id, "claim-1")
assert repository.get_contributions(member.member_id).claims == []
def test_bare_payment_can_be_created_without_allocation(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Payment", last_name="Test")
payment = repository.create_payment(
member.member_id,
payment_date="2026-06-21",
amount="42.00",
reference="Überweisung ohne Zuordnung",
)
data = repository.get_contributions(member.member_id)
assert data.payments == [payment]
assert data.allocations == []
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
assert repository.get_events(member.member_id)[-1].event_type == "payment_recorded"
def test_donation_can_be_recorded_paid_and_deleted_releases_payment(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Donation", last_name="Test")
donation = repository.record_donation(
member.member_id,
donation_date="2026-06-21",
amount="30.00",
reference="Sommerfest",
purpose="Freiwillige Zusatzspende",
)
data = repository.get_contributions(member.member_id)
assert donation_status(data, donation) == "open"
assert donation_balance(data, donation) == Decimal("30.00")
payment = repository.record_donation_payment(
member.member_id,
donation["donation_id"],
payment_date="2026-06-22",
amount="30.00",
allocation_amount="30.00",
)
data = repository.get_contributions(member.member_id)
assert donation_allocated_total(data, donation["donation_id"]) == Decimal("30.00")
assert donation_status(data, donation) == "allocated"
repository.delete_donation(member.member_id, donation["donation_id"])
data = repository.get_contributions(member.member_id)
assert data.donations == []
assert data.allocations == []
assert data.payments[0]["payment_id"] == payment["payment_id"]
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("0.00")
assert repository.get_events(member.member_id)[-1].event_type == "donation_deleted"
def test_existing_free_payment_can_be_allocated_to_a_donation(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Donation", last_name="Allocate")
payment = repository.create_payment(
member.member_id,
payment_date="2026-06-21",
amount="100.00",
reference="Mitgliedsbeitrag plus Spende",
)
donation = repository.record_donation(
member.member_id,
donation_date="2026-06-21",
amount="20.00",
reference="Aufrundung",
)
repository.allocate_payment_to_donation(
member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="20.00"
)
data = repository.get_contributions(member.member_id)
assert donation_balance(data, donation) == Decimal("0.00")
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("20.00")
with pytest.raises(RepositoryError, match="nur noch 0.00 EUR"):
repository.allocate_payment_to_donation(
member.member_id, donation["donation_id"], payment_id=payment["payment_id"], amount="1.00"
)
def test_payment_can_be_created_with_immediate_claim_and_donation_allocation(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="30.00")
donation = repository.record_donation(
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Aufrundung"
)
payment = repository.create_payment(
member.member_id,
payment_date="2026-06-21",
amount="50.00",
claim_allocations={"claim-1": "30.00"},
donation_allocations={donation["donation_id"]: "20.00"},
reference="Mitgliedsbeitrag plus Spende",
)
data = repository.get_contributions(member.member_id)
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_balance(data, claim) == Decimal("0.00")
assert donation_balance(data, donation) == Decimal("0.00")
assert payment_allocated_total(data, payment["payment_id"]) == Decimal("50.00")
def test_payment_creation_rejects_allocations_exceeding_amount(tmp_path) -> None:
repository, member = _repository_with_claim(tmp_path, amount="30.00")
with pytest.raises(RepositoryError, match="übersteigen den"):
repository.create_payment(
member.member_id,
payment_date="2026-06-21",
amount="10.00",
claim_allocations={"claim-1": "30.00"},
)
def test_updating_payment_without_donation_allocations_preserves_existing_ones(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Donation", last_name="Preserve")
donation = repository.record_donation(
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Sommerfest"
)
payment = repository.record_donation_payment(
member.member_id,
donation["donation_id"],
payment_date="2026-06-21",
amount="20.00",
allocation_amount="20.00",
)
repository.update_payment(
member.member_id,
payment["payment_id"],
payment_date="22.06.2026",
amount="20.00",
allocations={},
reference="Korrigierte Referenz",
)
data = repository.get_contributions(member.member_id)
assert donation_allocated_total(data, donation["donation_id"]) == Decimal("20.00")
assert data.payments[0]["reference"] == "Korrigierte Referenz"
def test_updating_payment_with_donation_allocations_replaces_them(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Donation", last_name="Replace")
first_donation = repository.record_donation(
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Erste Spende"
)
second_donation = repository.record_donation(
member.member_id, donation_date="2026-06-21", amount="20.00", reference="Zweite Spende"
)
payment = repository.record_donation_payment(
member.member_id,
first_donation["donation_id"],
payment_date="2026-06-21",
amount="20.00",
allocation_amount="20.00",
)
repository.update_payment(
member.member_id,
payment["payment_id"],
payment_date="2026-06-21",
amount="20.00",
allocations={},
donation_allocations={second_donation["donation_id"]: "20.00"},
)
data = repository.get_contributions(member.member_id)
assert donation_allocated_total(data, first_donation["donation_id"]) == Decimal("0.00")
assert donation_allocated_total(data, second_donation["donation_id"]) == Decimal("20.00")
def test_gnucash_id_is_unique_across_member_store(tmp_path) -> None:
repository, first_member = _repository_with_claim(tmp_path / "store")
repository.record_payment(
+72
View File
@@ -0,0 +1,72 @@
import pytest
pytest.importorskip("tkinter")
from ccma.storage.repository import MemberRepository # noqa: E402
@pytest.fixture
def repository(tmp_path):
repository = MemberRepository(tmp_path / "store")
repository.initialize()
organization = repository.get_configuration()["organization"]
organization.update({"name": "CCC Mannheim e.V.", "email": "verwaltung@example.org"})
repository.save_organization(organization)
return repository
def _member(repository, first_name, last_name, *, email="", status="active"):
member = repository.create_member(
first_name=first_name, last_name=last_name, birth_date="1990-01-01"
)
member.email = email
member.status = status
repository.save_member(member)
return member
def test_the_dialog_preselects_reachable_live_memberships(tk_root, repository):
from ccma.ui.data_review_mail_dialog import DataReviewMailDialog
_member(repository, "Ada", "Lovelace", email="ada@example.org")
_member(repository, "Grace", "Hopper", email="grace@example.org", status="ended")
_member(repository, "Anon", "Ymous")
dialog = DataReviewMailDialog(tk_root, repository)
try:
tk_root.update()
selected = [member.display_name for member in dialog._selected_members()]
assert selected == ["Ada Lovelace"]
# Every member stays listed so a resigned one can still be picked by hand.
assert len(dialog.table.get_children()) == 3
assert "1 von 3 Mitgliedern ausgewählt" in dialog.summary_var.get()
assert "1 Mail(s) werden erzeugt" in dialog.summary_var.get()
assert "1 Mitglied(er) ohne E-Mail-Adresse" in dialog.warning_var.get()
finally:
dialog.destroy()
tk_root.update()
def test_a_selection_without_an_address_generates_nothing(tk_root, repository, monkeypatch):
from tkinter import messagebox
import ccma.ui.data_review_mail_dialog as module
_member(repository, "Anon", "Ymous")
dialog = module.DataReviewMailDialog(tk_root, repository)
try:
tk_root.update()
dialog.table.selection_set(dialog.table.get_children())
informed = []
monkeypatch.setattr(messagebox, "showinfo", lambda *args, **kwargs: informed.append(args))
monkeypatch.setattr(
module, "resolve_delivery_mode", lambda *args: pytest.fail("kein Versand erwartet")
)
dialog._generate()
assert informed and "Keine Empfänger" in informed[0][0]
finally:
dialog.destroy()
tk_root.update()
+21
View File
@@ -7,9 +7,11 @@ from ccma.domain.dates import (
age_label,
calculate_age,
format_date_for_display,
format_month_for_display,
normalize_date_input,
parse_date_input,
parse_iso_date,
parse_month_input,
validate_birth_date,
validate_member_dates,
)
@@ -68,6 +70,25 @@ def test_member_dates_must_be_chronological() -> None:
)
def test_month_input_accepts_german_and_iso_formats() -> None:
assert parse_month_input("03.2026", "Ab") == "2026-03"
assert parse_month_input("2026-03", "Ab") == "2026-03"
assert parse_month_input("", "Ab") == ""
with pytest.raises(DateValidationError, match="erforderlich"):
parse_month_input("", "Ab", allow_empty=False)
with pytest.raises(DateValidationError):
parse_month_input("13.2026", "Ab")
with pytest.raises(DateValidationError):
parse_month_input("2026-03-15", "Ab")
def test_month_display_uses_system_pattern(monkeypatch) -> None:
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%d.%m.%Y")
assert format_month_for_display("2026-03") == "03.2026"
monkeypatch.setattr("ccma.domain.dates.system_date_pattern", lambda: "%Y-%m-%d")
assert format_month_for_display("2026-03") == "2026-03"
def test_age_calculation_and_label() -> None:
today = date(2026, 6, 21)
assert calculate_age(date(2000, 6, 21), today) == 26
+1
View File
@@ -129,6 +129,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
"Forderung mit Positionen.fodt",
"Mahnung.fodt",
"Mitglied.fodt",
"mail",
}
+132
View File
@@ -0,0 +1,132 @@
import pytest
from ccma.storage.repository import MemberRepository, RepositoryError
def test_email_settings_default_to_local_delivery_for_new_repositories(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
settings = repository.get_email_settings()
assert settings["delivery_mode"] == "local"
assert settings["smtp_host"] == ""
assert settings["imap_host"] == ""
assert settings["imap_sent_enabled"] is False
assert settings["imap_sent_folder"] == "INBOX.Sent"
def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_email_settings(
delivery_mode="ask",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="starttls",
smtp_username="board@example.org",
smtp_password="secret",
imap_host="mail.example.org",
imap_port=993,
imap_security="ssl",
imap_username="board@example.org",
imap_password="secret",
imap_drafts_folder="INBOX.Entwürfe",
imap_sent_enabled=True,
imap_sent_folder="INBOX.Gesendet",
)
settings = repository.get_email_settings()
assert settings["delivery_mode"] == "ask"
assert settings["smtp_host"] == "mail.example.org"
assert settings["smtp_port"] == 587
assert settings["smtp_password"] == "secret"
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
assert settings["imap_sent_enabled"] is True
assert settings["imap_sent_folder"] == "INBOX.Gesendet"
def test_email_settings_sent_copy_requires_imap_host_even_in_send_only_mode(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Gesendet-Ordner ist ein IMAP-Server erforderlich"):
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="starttls",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
imap_sent_enabled=True,
imap_sent_folder="",
)
@pytest.mark.parametrize(
("overrides", "match"),
[
({"delivery_mode": "carrier-pigeon"}, "Ungültiger Versandmodus"),
({"smtp_security": "rot13"}, "Ungültige SMTP-Verschlüsselung"),
({"imap_security": "rot13"}, "Ungültige IMAP-Verschlüsselung"),
({"delivery_mode": "send", "smtp_host": ""}, "SMTP-Server erforderlich"),
({"delivery_mode": "drafts", "imap_host": ""}, "IMAP-Server erforderlich"),
({"delivery_mode": "ask", "imap_host": ""}, "IMAP-Server erforderlich"),
({"smtp_port": 0}, "SMTP-Port"),
({"smtp_port": 70000}, "SMTP-Port"),
({"imap_port": 0}, "IMAP-Port"),
],
)
def test_email_settings_rejects_invalid_input(tmp_path, overrides, match) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
values = {
"delivery_mode": "send",
"smtp_host": "mail.example.org",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "",
"smtp_password": "",
"imap_host": "mail.example.org",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "",
"imap_password": "",
"imap_drafts_folder": "",
}
values.update(overrides)
with pytest.raises(RepositoryError, match=match):
repository.save_email_settings(**values)
def test_email_settings_local_mode_needs_no_server(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_email_settings(
delivery_mode="local",
smtp_host="",
smtp_port=587,
smtp_security="starttls",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
settings = repository.get_email_settings()
assert settings["delivery_mode"] == "local"
# Blank folder falls back to a sensible default instead of staying empty.
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
+156
View File
@@ -0,0 +1,156 @@
import gzip
from decimal import Decimal
import pytest
from ccma.services.gnucash_import import GnuCashImportError, list_accounts, list_transactions
SAMPLE_GNUCASH_XML = """<?xml version="1.0" encoding="utf-8"?>
<gnc-v2
xmlns:gnc="http://www.gnucash.org/XML/gnc"
xmlns:act="http://www.gnucash.org/XML/act"
xmlns:book="http://www.gnucash.org/XML/book"
xmlns:trn="http://www.gnucash.org/XML/trn"
xmlns:ts="http://www.gnucash.org/XML/ts"
xmlns:split="http://www.gnucash.org/XML/split">
<gnc:book version="2.0.0">
<book:id type="guid">book-1</book:id>
<gnc:account version="2.0.0">
<act:name>Root Account</act:name>
<act:id type="guid">root-guid</act:id>
<act:type>ROOT</act:type>
</gnc:account>
<gnc:account version="2.0.0">
<act:name>Aktiva</act:name>
<act:id type="guid">assets-guid</act:id>
<act:type>ASSET</act:type>
<act:parent type="guid">root-guid</act:parent>
</gnc:account>
<gnc:account version="2.0.0">
<act:name>Girokonto</act:name>
<act:id type="guid">bank-guid</act:id>
<act:type>BANK</act:type>
<act:parent type="guid">assets-guid</act:parent>
</gnc:account>
<gnc:account version="2.0.0">
<act:name>Beitraege</act:name>
<act:id type="guid">income-guid</act:id>
<act:type>INCOME</act:type>
<act:parent type="guid">root-guid</act:parent>
</gnc:account>
<gnc:transaction version="2.0.0">
<trn:id type="guid">tx-1</trn:id>
<trn:date-posted><ts:date>2026-06-21 10:59:00 +0200</ts:date></trn:date-posted>
<trn:description>Mitgliedsbeitrag Max Mustermann</trn:description>
<trn:splits>
<trn:split>
<split:id type="guid">split-1a</split:id>
<split:memo>Beitrag Juni</split:memo>
<split:value>15000/100</split:value>
<split:quantity>15000/100</split:quantity>
<split:account type="guid">bank-guid</split:account>
</trn:split>
<trn:split>
<split:id type="guid">split-1b</split:id>
<split:memo></split:memo>
<split:value>-15000/100</split:value>
<split:quantity>-15000/100</split:quantity>
<split:account type="guid">income-guid</split:account>
</trn:split>
</trn:splits>
</gnc:transaction>
<gnc:transaction version="2.0.0">
<trn:id type="guid">tx-2</trn:id>
<trn:date-posted><ts:date>2026-07-01 08:30:00 +0200</ts:date></trn:date-posted>
<trn:description>Bankgebuehr</trn:description>
<trn:splits>
<trn:split>
<split:id type="guid">split-2a</split:id>
<split:memo></split:memo>
<split:value>-500/100</split:value>
<split:quantity>-500/100</split:quantity>
<split:account type="guid">bank-guid</split:account>
</trn:split>
<trn:split>
<split:id type="guid">split-2b</split:id>
<split:memo></split:memo>
<split:value>500/100</split:value>
<split:quantity>500/100</split:quantity>
<split:account type="guid">income-guid</split:account>
</trn:split>
</trn:splits>
</gnc:transaction>
</gnc:book>
</gnc-v2>
"""
def _write_sample(tmp_path, *, gzipped: bool = False, suffix: str = ".gnucash"):
path = tmp_path / f"test{suffix}"
data = SAMPLE_GNUCASH_XML.encode("utf-8")
if gzipped:
data = gzip.compress(data)
path.write_bytes(data)
return path
def test_list_accounts_builds_hierarchical_full_names(tmp_path) -> None:
path = _write_sample(tmp_path)
accounts = list_accounts(path)
by_guid = {account.guid: account for account in accounts}
assert "root-guid" not in by_guid
assert by_guid["bank-guid"].full_name == "Aktiva:Girokonto"
assert by_guid["bank-guid"].account_type == "BANK"
assert by_guid["assets-guid"].full_name == "Aktiva"
assert by_guid["income-guid"].full_name == "Beitraege"
def test_list_transactions_returns_splits_for_selected_account(tmp_path) -> None:
path = _write_sample(tmp_path)
transactions = list_transactions(path, "bank-guid")
assert [item.guid for item in transactions] == ["tx-1", "tx-2"]
first = transactions[0]
assert first.date.isoformat() == "2026-06-21"
assert first.description == "Mitgliedsbeitrag Max Mustermann"
assert first.memo == "Beitrag Juni"
assert first.amount == Decimal("150.00")
second = transactions[1]
assert second.amount == Decimal("-5.00")
def test_list_transactions_only_includes_splits_on_the_requested_account(tmp_path) -> None:
path = _write_sample(tmp_path)
transactions = list_transactions(path, "income-guid")
assert [item.guid for item in transactions] == ["tx-1", "tx-2"]
assert transactions[0].amount == Decimal("-150.00")
assert transactions[1].amount == Decimal("5.00")
def test_gzip_compressed_gnucash_file_is_read_transparently(tmp_path) -> None:
path = _write_sample(tmp_path, gzipped=True, suffix=".gnucash")
accounts = list_accounts(path)
assert any(account.guid == "bank-guid" for account in accounts)
transactions = list_transactions(path, "bank-guid")
assert len(transactions) == 2
def test_invalid_xml_raises_gnucash_import_error(tmp_path) -> None:
path = tmp_path / "broken.gnucash"
path.write_text("not xml at all <<<", encoding="utf-8")
with pytest.raises(GnuCashImportError):
list_accounts(path)
def test_missing_file_raises_gnucash_import_error(tmp_path) -> None:
with pytest.raises(GnuCashImportError):
list_accounts(tmp_path / "does-not-exist.gnucash")
def test_file_without_book_raises_gnucash_import_error(tmp_path) -> None:
path = tmp_path / "empty.gnucash"
path.write_text('<?xml version="1.0"?><gnc-v2></gnc-v2>', encoding="utf-8")
with pytest.raises(GnuCashImportError, match="gnc:book"):
list_accounts(path)
+4 -5
View File
@@ -29,11 +29,10 @@ def test_housekeeper_reports_initial_payment_and_open_claims(tmp_path) -> None:
)
findings = Housekeeper(repository).run(today=date(2026, 2, 10))
assert {finding.code for finding in findings} == {
"initial_payment_overdue",
"claim_overdue",
"reminder_due",
}
# Both claims are more than the default 7-day grace period overdue, so the dunning
# escalation ("reminder_due") is the single active finding for each -- no separate,
# redundant "overdue" finding alongside it.
assert {finding.code for finding in findings} == {"reminder_due"}
def test_housekeeper_reports_birthdays_before_today_and_after(tmp_path) -> None:
+254
View File
@@ -0,0 +1,254 @@
import pytest
from ccma.services import mail_delivery
from ccma.storage.repository import RepositoryError
class _FakeSmtpClient:
instances: list["_FakeSmtpClient"] = []
def __init__(self, host, port, **kwargs):
self.host = host
self.port = port
self.kwargs = kwargs
self.calls: list[str] = []
self.login_args: tuple | None = None
self.sent_messages: list = []
_FakeSmtpClient.instances.append(self)
def starttls(self, **kwargs):
self.calls.append("starttls")
def login(self, username, password):
self.login_args = (username, password)
self.calls.append("login")
def send_message(self, message):
self.sent_messages.append(message)
self.calls.append("send_message")
def quit(self):
self.calls.append("quit")
class _FakeImapClient:
instances: list["_FakeImapClient"] = []
default_list_result: tuple = ("OK", [])
error = Exception
def __init__(self, host, port, **kwargs):
self.host = host
self.port = port
self.kwargs = kwargs
self.calls: list[str] = []
self.login_args: tuple | None = None
self.appended: list[tuple] = []
self.append_result = ("OK", [b"APPEND completed"])
self.selected: list[str] = []
self.select_result = ("OK", [b"1"])
self.created: list[str] = []
self.create_result = ("OK", [b"CREATE completed"])
self.list_result = _FakeImapClient.default_list_result
_FakeImapClient.instances.append(self)
def starttls(self, *args, **kwargs):
self.calls.append("starttls")
def login(self, username, password):
self.login_args = (username, password)
self.calls.append("login")
def append(self, folder, flags, date_time, message_bytes):
self.appended.append((folder, flags, date_time, message_bytes))
self.calls.append("append")
return self.append_result
def select(self, folder, readonly=False):
self.selected.append(folder)
self.calls.append("select")
return self.select_result
def create(self, folder):
self.created.append(folder)
self.calls.append("create")
return self.create_result
def list(self):
self.calls.append("list")
return self.list_result
def logout(self):
self.calls.append("logout")
@pytest.fixture(autouse=True)
def _reset_fakes():
_FakeSmtpClient.instances.clear()
_FakeImapClient.instances.clear()
_FakeImapClient.default_list_result = ("OK", [])
yield
def _patch_smtp(monkeypatch):
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _FakeSmtpClient)
monkeypatch.setattr(mail_delivery.smtplib, "SMTP_SSL", _FakeSmtpClient)
def _patch_imap(monkeypatch):
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4", _FakeImapClient)
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4_SSL", _FakeImapClient)
SMTP_SETTINGS = {
"smtp_host": "mail.example.org",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "board@example.org",
"smtp_password": "secret",
}
IMAP_SETTINGS = {
"imap_host": "mail.example.org",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "board@example.org",
"imap_password": "secret",
}
MESSAGE = (
b"From: Board <board@example.org>\r\n"
b"To: member@example.org\r\n"
b"Subject: Test\r\n"
b"\r\n"
b"Hello\r\n"
)
def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) -> None:
_patch_smtp(monkeypatch)
with mail_delivery.smtp_session(SMTP_SETTINGS) as client:
mail_delivery.send_via_smtp(client, MESSAGE)
mail_delivery.send_via_smtp(client, MESSAGE)
assert len(_FakeSmtpClient.instances) == 1
fake = _FakeSmtpClient.instances[0]
assert fake.host == "mail.example.org"
assert fake.port == 587
assert fake.login_args == ("board@example.org", "secret")
assert fake.calls == ["starttls", "login", "send_message", "send_message", "quit"]
assert len(fake.sent_messages) == 2
def test_append_message_uses_configured_folder_and_flags(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
mail_delivery.append_message(client, MESSAGE, folder="INBOX.Entwürfe", flags=r"(\Draft)")
fake = _FakeImapClient.instances[0]
assert fake.login_args == ("board@example.org", "secret")
# The folder name travels the wire in modified UTF-7, not raw UTF-8.
assert fake.appended == [("INBOX.Entw&APw-rfe", r"(\Draft)", None, MESSAGE)]
assert fake.calls == ["login", "append", "logout"]
def test_append_message_raises_on_rejected_status(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
client.append_result = ("NO", [b"Mailbox does not exist"])
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
mail_delivery.append_message(client, MESSAGE, folder="Missing", flags=r"(\Seen)")
def test_ensure_imap_folder_skips_creation_when_folder_already_exists(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
mail_delivery.ensure_imap_folder(client, "INBOX.Sent")
fake = _FakeImapClient.instances[0]
assert fake.selected == ["INBOX.Sent"]
assert fake.created == []
def test_ensure_imap_folder_creates_missing_folder(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
client.select_result = ("NO", [b"Mailbox does not exist"])
mail_delivery.ensure_imap_folder(client, "Archiv.Neu")
fake = _FakeImapClient.instances[0]
assert fake.selected == ["Archiv.Neu"]
assert fake.created == ["Archiv.Neu"]
def test_ensure_imap_folder_raises_when_creation_fails(monkeypatch) -> None:
_patch_imap(monkeypatch)
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
client.select_result = ("NO", [b"no such mailbox"])
client.create_result = ("NO", [b"Permission denied"])
with pytest.raises(RepositoryError, match="Permission denied"):
mail_delivery.ensure_imap_folder(client, "Verboten")
@pytest.mark.parametrize(
("plain", "wire"),
[
("INBOX", "INBOX"),
("Entwürfe", "Entw&APw-rfe"),
("INBOX.Entwürfe", "INBOX.Entw&APw-rfe"),
("Test & Foo", "Test &- Foo"),
],
)
def test_imap_utf7_round_trips(plain, wire) -> None:
assert mail_delivery._encode_imap_utf7(plain) == wire
assert mail_delivery._decode_imap_utf7(wire) == plain
def test_list_imap_folders_parses_and_decodes_list_response(monkeypatch) -> None:
_patch_imap(monkeypatch)
_FakeImapClient.default_list_result = (
"OK",
[
rb'(\HasNoChildren) "." INBOX',
rb'(\HasNoChildren) "." INBOX.Entw&APw-rfe',
rb'(\HasNoChildren) "." "INBOX.Gesendete Objekte"',
],
)
folders = mail_delivery.list_imap_folders(IMAP_SETTINGS)
assert folders == ["INBOX", "INBOX.Entwürfe", "INBOX.Gesendete Objekte"]
assert _FakeImapClient.instances[0].calls == ["login", "list", "logout"]
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
def _boom(*args, **kwargs):
raise OSError("connection refused")
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _boom)
with pytest.raises(RepositoryError, match="SMTP-Verbindung fehlgeschlagen"):
with mail_delivery.smtp_session({**SMTP_SETTINGS, "smtp_security": "none"}):
pass
def test_smtp_session_requires_host() -> None:
with pytest.raises(RepositoryError, match="Kein SMTP-Server konfiguriert"):
with mail_delivery.smtp_session({}):
pass
def test_imap_session_requires_host() -> None:
with pytest.raises(RepositoryError, match="Kein IMAP-Server konfiguriert"):
with mail_delivery.imap_session({}):
pass
def test_test_smtp_connection_succeeds_and_closes(monkeypatch) -> None:
_patch_smtp(monkeypatch)
mail_delivery.test_smtp_connection(SMTP_SETTINGS)
assert _FakeSmtpClient.instances[0].calls == ["starttls", "login", "quit"]
def test_test_imap_connection_succeeds_and_closes(monkeypatch) -> None:
_patch_imap(monkeypatch)
mail_delivery.test_imap_connection(IMAP_SETTINGS)
assert _FakeImapClient.instances[0].calls == ["login", "logout"]
+217
View File
@@ -0,0 +1,217 @@
import pytest
from ccma.domain.mail_templates import (
MAIL_TEMPLATES,
MailTemplate,
MailTemplateBlock,
MailTemplateError,
MailTemplateSpec,
default_mail_template,
parse_mail_template,
placeholder_entries,
render_mail_template,
serialize_mail_template,
validate_block_markers,
validate_mail_template,
)
from ccma.storage.repository import MemberRepository, RepositoryError
def test_subject_header_round_trips_through_file_format() -> None:
text = serialize_mail_template("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
template = parse_mail_template(text)
assert text.startswith("Betreff: Hallo {{member.first_name}}\n\n")
assert template == MailTemplate("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
def test_shipped_defaults_only_use_declared_placeholders() -> None:
for spec in MAIL_TEMPLATES:
template = default_mail_template(spec.key)
validate_mail_template(spec.key, template.subject, template.body)
def test_rendering_fills_values_and_expands_repeat_blocks() -> None:
template = MailTemplate(
"Beitrag für {{member.first_name}}",
"Hallo {{member.first_name}},\n"
"{{#claims}}- {{claim.title}}: {{claim.amount}}{{/claims}}\n"
"Summe: {{claims.total}}",
)
rendered = render_mail_template(
template,
{"member.first_name": "Ada", "claims.total": "75.00"},
{
"claims": [
{"claim.title": "Aufnahmegebühr", "claim.amount": "15.00"},
{"claim.title": "Mitgliedsbeitrag", "claim.amount": "60.00"},
]
},
)
assert rendered.subject == "Beitrag für Ada"
assert rendered.body == (
"Hallo Ada,\n- Aufnahmegebühr: 15.00\n- Mitgliedsbeitrag: 60.00\nSumme: 75.00"
)
def test_line_holding_only_an_empty_placeholder_is_dropped() -> None:
template = MailTemplate("Betreff", "Erste Zeile\n{{reminder.hint}}\nLetzte Zeile")
assert render_mail_template(template, {"reminder.hint": ""}).body == "Erste Zeile\nLetzte Zeile"
assert (
render_mail_template(template, {"reminder.hint": "Hinweis: X"}).body
== "Erste Zeile\nHinweis: X\nLetzte Zeile"
)
def test_unknown_placeholder_is_rejected_while_editing() -> None:
with pytest.raises(MailTemplateError, match="member.favourite_colour"):
validate_mail_template("welcome", "Betreff", "Hallo {{member.favourite_colour}}")
def test_store_copies_are_seeded_editable_and_resettable(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
path = repository.mail_templates_root / "willkommen.txt"
assert path.is_file()
repository.save_mail_template("welcome", subject="Moin {{member.first_name}}", body="Kurz und knapp.")
assert repository.get_mail_template("welcome") == MailTemplate(
"Moin {{member.first_name}}", "Kurz und knapp."
)
assert "Moin {{member.first_name}}" in path.read_text(encoding="utf-8")
repository.reset_mail_template("welcome")
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
def test_missing_template_file_falls_back_to_shipped_default(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
(repository.mail_templates_root / "mahnung.txt").unlink()
template = repository.get_mail_template("reminder")
assert template == default_mail_template("reminder")
assert (repository.mail_templates_root / "mahnung.txt").is_file()
def test_saving_a_broken_template_is_refused(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Platzhalter"):
repository.save_mail_template("reminder", subject="Mahnung", body="Hallo {{member.nonsense}}")
with pytest.raises(RepositoryError, match="Betreff"):
repository.save_mail_template("reminder", subject=" ", body="Hallo")
def test_block_rows_insert_a_working_block_not_its_description() -> None:
entries = placeholder_entries("welcome")
block = next(entry for entry in entries if entry.label.startswith("{{#claims}}"))
# The label may abbreviate the block, the inserted snippet may not.
assert block.label == "{{#claims}} … {{/claims}}"
assert block.snippet.startswith("{{#claims}}\n")
assert block.snippet.rstrip().endswith("{{/claims}}")
assert "{{claim.description}}" in block.snippet
assert "" not in block.snippet
assert all("" not in entry.snippet for entry in entries)
def test_inserted_block_validates_and_renders_one_line_per_claim() -> None:
block = next(
entry for entry in placeholder_entries("welcome") if entry.label.startswith("{{#claims}}")
)
body = f"Hallo {{{{member.first_name}}}},\n\n{block.snippet}\nSumme: {{{{claims.total}}}}"
validate_mail_template("welcome", "Betreff", body)
rendered = render_mail_template(
MailTemplate("Betreff", body),
{"member.first_name": "Ada", "claims.total": "75.00"},
{
"claims": [
{
"claim.description": "Aufnahmegebühr",
"claim.due_date": "17.09.2026",
"claim.balance": "15.00",
},
{
"claim.description": "Mitgliedsbeitrag 2. Halbjahr 2026",
"claim.due_date": "17.09.2026",
"claim.balance": "60.00",
},
]
},
)
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in rendered.body
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in rendered.body
assert "" not in rendered.body
def test_every_block_snippet_is_valid_in_its_own_template() -> None:
for spec in MAIL_TEMPLATES:
for block in spec.blocks:
validate_mail_template(spec.key, "Betreff", block.snippet)
@pytest.mark.parametrize(
("body", "expected"),
[
("Hallo\n{{#claims}}\n{{claim.title}}", "wird nicht geschlossen"),
("Hallo\n{{/claims}}\nGruß", "ohne zugehöriges"),
("{{#claims}}\n{{claim.title}}\n{{/claims}}\n{{/claims}}", "ohne zugehöriges"),
("{{#claims}}\n{{#claims}}\nx\n{{/claims}}\n{{/claims}}", "verschachtelt"),
("{{#members}}\nx\n{{/members}}", "Unbekannter Wiederholungsblock"),
("{{ # claims }}\nx", "wird nicht geschlossen"),
],
)
def test_unbalanced_repeat_markers_are_rejected(body, expected) -> None:
with pytest.raises(MailTemplateError, match=expected):
validate_mail_template("welcome", "Betreff", body)
def test_repeat_markers_in_the_subject_are_rejected() -> None:
with pytest.raises(MailTemplateError, match="Betreff"):
validate_mail_template("welcome", "Beitrag {{#claims}}", "Hallo")
def test_a_template_without_blocks_rejects_every_marker() -> None:
with pytest.raises(MailTemplateError, match="Verfügbar: keine"):
validate_mail_template("sepa", "Betreff", "{{#claims}}\nx\n{{/claims}}")
def test_a_closing_marker_must_match_the_open_one(monkeypatch) -> None:
two_blocks = MailTemplateSpec(
key="two-blocks",
label="Test",
filename="test.txt",
description="",
placeholders=(),
blocks=(
MailTemplateBlock("first", "", (("item.a", ""),), "{{item.a}}"),
MailTemplateBlock("second", "", (("item.b", ""),), "{{item.b}}"),
),
)
monkeypatch.setattr("ccma.domain.mail_templates.MAIL_TEMPLATES", (two_blocks,))
with pytest.raises(MailTemplateError, match="Erwartet wird \\{\\{/first\\}\\}"):
validate_block_markers("two-blocks", "Betreff", "{{#first}}\nx\n{{/second}}")
def test_a_broken_template_is_not_stored(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
path = repository.mail_templates_root / "willkommen.txt"
before = path.read_text(encoding="utf-8")
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
repository.save_mail_template(
"welcome", subject="Willkommen", body="Hallo\n{{#claims}}\n{{claim.title}}"
)
assert path.read_text(encoding="utf-8") == before
+122
View File
@@ -0,0 +1,122 @@
import os
import stat
import pytest
pytest.importorskip("tkinter")
from ccma.config import AppConfig # noqa: E402
from ccma.storage.repository import MemberRepository # noqa: E402
# Root ignores the permission bits that stand in for a read-only mount here.
pytestmark = pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
)
def _set_writable(root, writable: bool) -> None:
for path in sorted(root.rglob("*"), reverse=True) + [root]:
mode = stat.S_IMODE(path.stat().st_mode)
path.chmod(mode | stat.S_IWUSR if writable else mode & ~stat.S_IWUSR)
@pytest.fixture
def read_only_window(tk_root, tmp_path):
from ccma.ui.main_window import MainWindow
root_path = tmp_path / "store"
repository = MemberRepository(root_path)
repository.initialize()
repository.create_member(first_name="Ada", last_name="Lovelace", birth_date="1990-01-01")
_set_writable(root_path, False)
window = MainWindow(tk_root, MemberRepository(root_path), AppConfig(store_path=str(root_path)), [], [])
window.pack(fill="both", expand=True)
tk_root.update()
yield window
window.destroy()
tk_root.title("")
tk_root.update()
_set_writable(root_path, True)
def test_a_read_only_store_is_marked_everywhere(read_only_window, tk_root):
assert "NUR LESEN" in tk_root.title()
assert "NUR LESEN" in read_only_window.store_var.get()
assert read_only_window.messages.winfo_manager(), "der Warnbanner fehlt"
def test_the_banner_does_not_ask_for_a_restart(read_only_window):
banner = read_only_window.messages.winfo_children()[0]
text = " ".join(
str(child.cget("text"))
for child in banner.winfo_children()[0].winfo_children()
if "text" in child.keys()
)
# The banner used to end with "und CCMA neu starten", which is wrong -- the
# session recovers in place.
assert "Erneut prüfen" in text
assert "neu starten" not in text.casefold()
assert "Neustart ist dafür nicht nötig" in text
def test_the_data_review_mailing_is_refused_on_a_read_only_store(read_only_window, monkeypatch):
from tkinter import messagebox
warned = []
monkeypatch.setattr(messagebox, "showwarning", lambda *args, **kwargs: warned.append(args))
read_only_window.open_data_review_mail()
assert warned and "schreibgeschützt" in warned[0][0]
assert "Der Versand der Datenüberprüfung ist nicht möglich" in warned[0][1]
assert not [
child
for child in read_only_window.winfo_children()
if child.winfo_class() == "Toplevel"
]
def test_a_remounted_store_clears_every_read_only_marker(read_only_window, tk_root, monkeypatch):
from tkinter import messagebox
_set_writable(read_only_window.repository.root, True)
asked = []
monkeypatch.setattr(
messagebox, "askyesno", lambda *args, **kwargs: asked.append(args) or False
)
read_only_window._recheck_store_access()
tk_root.update()
assert read_only_window.repository.read_only is False
assert "NUR LESEN" not in tk_root.title()
assert "NUR LESEN" not in read_only_window.store_var.get()
assert not read_only_window.messages.winfo_manager(), "der Warnbanner steht noch"
assert read_only_window.status_var.get() == "Der Store ist wieder beschreibbar."
assert asked, "der übersprungene Hausmeisterlauf wird nicht angeboten"
def test_the_skipped_housekeeper_run_can_be_started_right_away(
read_only_window, tk_root, monkeypatch
):
from tkinter import messagebox
_set_writable(read_only_window.repository.root, True)
monkeypatch.setattr(messagebox, "askyesno", lambda *args, **kwargs: True)
read_only_window._recheck_store_access()
tk_root.update()
assert (read_only_window.repository.root / "housekeeper.json").is_file()
assert read_only_window.status_var.get().startswith("Hausmeisterlauf beendet")
def test_a_store_that_is_still_read_only_keeps_its_markers(read_only_window, tk_root):
read_only_window._recheck_store_access()
tk_root.update()
assert "NUR LESEN" in tk_root.title()
assert "NUR LESEN" in read_only_window.store_var.get()
assert read_only_window.messages.winfo_manager()
assert read_only_window.status_var.get() == "Der Store ist weiterhin schreibgeschützt."
+297
View File
@@ -0,0 +1,297 @@
from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
import pytest
from ccma.domain.models import Member
from ccma.services.member_data_mail import (
data_review_recipients,
generate_data_review_mails,
mask_iban,
member_data_fields,
payment_reference,
)
from ccma.storage.repository import MemberRepository, RepositoryError
def _store(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
return repository
def _member(repository, first_name, last_name, **values):
member = repository.create_member(
first_name=first_name, last_name=last_name, birth_date="1990-01-01"
)
for key, value in values.items():
setattr(member, key, value)
repository.save_member(member)
return repository.get_member(member.member_id)
def test_the_mail_lists_the_stored_record_of_that_member(tmp_path):
repository = _store(tmp_path)
member = _member(
repository,
"Ada",
"Lovelace",
email="ada@example.org",
phone="0621 123456",
street="Hauptstraße 1",
postal_code="68159",
city="Mannheim",
status="active",
membership_started_at="2026-01-01",
)
generated, warnings = generate_data_review_mails(
repository,
[member.member_id],
delivery_mode="local",
output_directory=tmp_path / "out",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
message = BytesParser(policy=default).parsebytes(generated[0].export_path.read_bytes())
content = message.get_content()
assert message["To"] == "ada@example.org"
assert "Liebes Mitglied," in content
assert "folgende Daten haben wir über dich gespeichert" in content
assert "Name: Ada Lovelace" in content
assert "E-Mail: ada@example.org" in content
assert "Telefon: 0621 123456" in content
assert "PLZ und Ort: 68159 Mannheim" in content
assert "Mitglied seit: 01.01.2026" in content
assert "Zahlweise: Halbjährlich" in content
# Both halves identify a transfer on their own, so both are offered.
assert "deine Mitgliedsnummer und/oder deinen vollen Namen" in content
assert f"{member.member_number} Ada Lovelace" in content
# Nothing bank-related is stored, so the note about the shortened IBAN is left
# out instead of pointing at a line that is not there.
assert "verkürzt" not in content
assert content.rstrip().endswith("Der Vorstand")
def test_a_field_without_a_value_is_shown_as_missing_not_as_a_gap(tmp_path):
repository = _store(tmp_path)
member = _member(repository, "Ada", "Lovelace", email="ada@example.org")
fields = dict(member_data_fields(member))
assert fields["Telefon"] == "(nicht hinterlegt)"
assert fields["Mitglied seit"] == "(nicht hinterlegt)"
# Nothing bank-related is stored, so the member is not asked to check a mandate
# they never gave.
assert "IBAN" not in fields
assert "Mandatsreferenz" not in fields
def test_bank_details_are_listed_with_a_masked_iban(tmp_path):
repository = _store(tmp_path)
member = _member(
repository,
"Ada",
"Lovelace",
email="ada@example.org",
account_holder="Ada Lovelace",
iban="DE98670505050038907751",
mandate_reference="MANDAT-42",
mandate_signed_at="2026-02-01",
mandate_active=True,
)
generated, _warnings = generate_data_review_mails(
repository,
[member.member_id],
delivery_mode="local",
output_directory=tmp_path / "out",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated[0].export_path.read_bytes()
).get_content()
assert "IBAN: DE****************7751" in content
assert "DE98670505050038907751" not in content
assert "Mandatsreferenz: MANDAT-42" in content
assert "Lastschriftmandat: aktiv, erteilt am 01.02.2026" in content
assert "(Die IBAN zeigen wir absichtlich nur verkürzt an" in content
def test_the_transfer_reference_joins_number_and_name():
member = Member(
member_id="1", member_number="CCMA-0007", first_name="Ada", last_name="Lovelace"
)
assert payment_reference(member) == "CCMA-0007 Ada Lovelace"
# The store insists on a member number, but a record read from elsewhere may not
# have one -- the name then carries the reference without a dangling dash.
assert (
payment_reference(
Member(member_id="1", member_number="", first_name="Ada", last_name="Lovelace")
)
== "Ada Lovelace"
)
def test_mask_iban_keeps_country_and_last_four_digits():
assert mask_iban("DE98 6705 0505 0038 9077 51") == "DE****************7751"
# Too short to mask meaningfully -- a broken value is shown as it is stored, so
# the member can tell the board it is wrong.
assert mask_iban("DE98") == "DE98"
def test_every_selected_member_gets_their_own_mail_and_archive_copy(tmp_path):
repository = _store(tmp_path)
ada = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
grace = _member(repository, "Grace", "Hopper", email="grace@example.org", status="active")
generated, warnings = generate_data_review_mails(
repository,
[ada.member_id, grace.member_id],
delivery_mode="local",
output_directory=tmp_path / "out",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
assert [item.recipient for item in generated] == ["ada@example.org", "grace@example.org"]
for item in generated:
assert item.archive_path.parent.name == "Datenpruefung"
assert item.archive_path.read_bytes() == item.export_path.read_bytes()
ada_content = BytesParser(policy=default).parsebytes(
generated[0].archive_path.read_bytes()
).get_content()
assert "Ada Lovelace" in ada_content
assert "Grace Hopper" not in ada_content
event = repository.get_events(grace.member_id)[-1]
assert event.event_type == "data_review_email_sent"
assert event.data["recipient"] == "grace@example.org"
assert event.references["document"].endswith(".eml")
def test_a_member_without_an_address_is_reported_and_the_run_continues(tmp_path):
repository = _store(tmp_path)
silent = _member(repository, "Anon", "Ymous", status="active")
ada = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
generated, warnings = generate_data_review_mails(
repository,
[silent.member_id, ada.member_id],
delivery_mode="local",
output_directory=tmp_path / "out",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert [item.member_id for item in generated] == [ada.member_id]
assert warnings == [f"{silent.member_number}: E-Mail-Adresse fehlt."]
assert repository.get_events(silent.member_id)[-1].event_type != "data_review_email_sent"
def test_default_recipients_are_the_live_memberships_with_an_address(tmp_path):
repository = _store(tmp_path)
_member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
_member(repository, "Grace", "Hopper", email="grace@example.org", status="honorary")
_member(repository, "Alan", "Turing", email="alan@example.org", status="ended")
_member(repository, "Anon", "Ymous", status="active")
names = [member.display_name for member in data_review_recipients(repository)]
assert names == ["Grace Hopper", "Ada Lovelace"]
def test_send_mode_delivers_via_smtp_without_local_files(tmp_path, monkeypatch):
import ccma.services.member_data_mail as module
repository = _store(tmp_path)
member = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
repository.save_email_settings(
delivery_mode="send",
smtp_host="smtp.example.org",
smtp_port=587,
smtp_security="starttls",
smtp_username="verwaltung",
smtp_password="secret",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="INBOX.Entwürfe",
)
sent = []
monkeypatch.setattr(module, "smtp_session", contextmanager(lambda settings: iter(["client"])))
monkeypatch.setattr(module, "send_via_smtp", lambda client, content: sent.append(content))
generated, warnings = generate_data_review_mails(
repository,
[member.member_id],
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
assert generated[0].export_path is None
assert generated[0].archive_path.read_bytes() == sent[0]
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
def test_a_template_broken_outside_ccma_stops_that_member_without_archiving(tmp_path):
repository = _store(tmp_path)
member = _member(repository, "Ada", "Lovelace", email="ada@example.org", status="active")
(repository.mail_templates_root / "datenpruefung.txt").write_text(
"Betreff: Datenprüfung\n\nHallo,\n{{#data}}\n{{field.label}}\n", encoding="utf-8"
)
generated, warnings = generate_data_review_mails(
repository,
[member.member_id],
delivery_mode="local",
output_directory=tmp_path / "out",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated == []
assert "wird nicht geschlossen" in warnings[0]
archive = repository.members_root / member.member_id / "files" / "documents" / "Datenpruefung"
assert not any(archive.glob("*.eml"))
assert repository.get_events(member.member_id)[-1].event_type != "data_review_email_sent"
def test_an_unknown_delivery_mode_is_refused(tmp_path):
repository = _store(tmp_path)
with pytest.raises(RepositoryError, match="Versandmodus"):
generate_data_review_mails(
repository,
[],
delivery_mode="postal",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
+161
View File
@@ -0,0 +1,161 @@
from email.parser import BytesParser
from email.policy import default
import pytest
pytest.importorskip("tkinter")
from ccma.config import AppConfig # noqa: E402
from ccma.domain.mail_templates import placeholder_entries # noqa: E402
from ccma.services.welcome_mail import generate_and_send_welcome_mail # noqa: E402
from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402
@pytest.fixture
def repository(tmp_path):
repository = MemberRepository(tmp_path / "store")
repository.initialize()
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
return repository
def _open_options(tk_root, repository):
from ccma.ui.options_dialog import OptionsDialog
dialog = OptionsDialog(tk_root, AppConfig(store_path=str(repository.root)), repository)
tk_root.update()
return dialog
def _block_row(dialog):
for row in dialog.mail_template_placeholders.get_children():
label = str(dialog.mail_template_placeholders.item(row, "values")[0])
if label.startswith("{{#claims}}"):
return row
raise AssertionError("Der Wiederholungsblock fehlt in der Platzhalterliste.")
def test_double_click_on_a_block_is_wired_up(tk_root, repository):
dialog = _open_options(tk_root, repository)
try:
assert dialog.mail_template_placeholders.bind("<Double-1>")
assert dialog.mail_template_snippets[_block_row(dialog)] == next(
entry.snippet
for entry in placeholder_entries("welcome")
if entry.label.startswith("{{#claims}}")
)
finally:
dialog.grab_release()
dialog.destroy()
def test_inserting_a_block_puts_editable_template_code_into_the_body(tk_root, repository):
dialog = _open_options(tk_root, repository)
try:
dialog.mail_template_body.delete("1.0", "end")
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:")
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
dialog._insert_mail_placeholder()
tk_root.update()
body = dialog.mail_template_body.get("1.0", "end-1c")
finally:
dialog.grab_release()
dialog.destroy()
assert "" not in body
assert "{{#claims}}" in body and "{{/claims}}" in body
assert "{{claim.description}}" in body
# The block was appended behind existing text and still starts on its own line.
assert body.splitlines()[0] == "Offen sind aktuell:"
assert body.splitlines()[1] == "{{#claims}}"
def test_edited_template_survives_saving_and_produces_real_lines_in_the_mail(
tk_root, repository, tmp_path
):
member = repository.create_member(
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
)
member.email = "ada@example.org"
member.accepted_at = "2026-08-20"
repository.save_member(member)
repository.create_manual_claim(
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
)
dialog = _open_options(tk_root, repository)
try:
dialog.mail_template_subject_var.set("Willkommen, {{member.first_name}}")
dialog.mail_template_body.delete("1.0", "end")
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n")
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
dialog._insert_mail_placeholder()
tk_root.update()
dialog._save_mail_templates()
finally:
dialog.grab_release()
dialog.destroy()
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
assert "" not in content
def test_the_dialog_refuses_to_save_an_unbalanced_block(tk_root, repository):
stored = repository.mail_templates_root / "willkommen.txt"
before = stored.read_text(encoding="utf-8")
dialog = _open_options(tk_root, repository)
try:
dialog.mail_template_body.delete("1.0", "end")
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n{{#claims}}\n{{claim.title}}")
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
dialog._save_mail_templates()
finally:
dialog.grab_release()
dialog.destroy()
assert stored.read_text(encoding="utf-8") == before
def test_a_broken_template_does_not_half_save_the_others(tk_root, repository):
reminder_file = repository.mail_templates_root / "mahnung.txt"
before = reminder_file.read_text(encoding="utf-8")
dialog = _open_options(tk_root, repository)
try:
# Edit a valid template first, then break the second one.
dialog.mail_template_var.set("Mahnung")
dialog._select_mail_template()
dialog.mail_template_body.insert("end", "\nP.S. Bitte Mitgliedsnummer angeben.")
dialog.mail_template_var.set("Willkommen & Erstrechnung")
dialog._select_mail_template()
dialog.mail_template_body.insert("end", "\n{{#claims}}\n{{claim.title}}")
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
dialog._save_mail_templates()
finally:
dialog.grab_release()
dialog.destroy()
assert reminder_file.read_text(encoding="utf-8") == before
+253
View File
@@ -0,0 +1,253 @@
import os
import stat
from datetime import date
import pytest
from ccma.domain.models import ContributionData
from ccma.services.documents import DocumentService
from ccma.services.housekeeper import Housekeeper
from ccma.services.member_data_mail import generate_data_review_mails
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.services.sepa_mail import generate_debit_mails
from ccma.services.welcome_mail import generate_and_send_welcome_mail
from ccma.storage.repository import MemberRepository, ReadOnlyStoreError
# Root ignores the permission bits this test relies on, so the read-only mount it
# stands in for cannot be simulated there.
pytestmark = pytest.mark.skipif(
hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses file permissions"
)
def _set_writable(root, writable: bool) -> None:
mode_add = stat.S_IWUSR
for path in sorted(root.rglob("*"), reverse=True) + [root]:
current = stat.S_IMODE(path.stat().st_mode)
path.chmod(current | mode_add if writable else current & ~mode_add)
@pytest.fixture
def read_only_store(tmp_path):
root = tmp_path / "store"
repository = MemberRepository(root)
repository.initialize()
member = repository.create_member(
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
)
# A complete member: without an address the mail services would bail out early
# for that reason, and the write they are actually being tested for is never
# reached.
member.email = "ada@example.org"
repository.save_member(member)
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "claim-1",
"claim_key": "overdue",
"title": "Offene Forderung",
"amount": "100.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
_set_writable(root, False)
yield MemberRepository(root), member
_set_writable(root, True)
def test_a_writable_store_is_not_reported_as_read_only(tmp_path):
repository = MemberRepository(tmp_path / "store")
repository.initialize()
assert repository.read_only is False
def test_store_directory_that_does_not_exist_yet_counts_as_writable(tmp_path):
assert MemberRepository(tmp_path / "not" / "created" / "yet").read_only is False
def test_read_only_store_opens_and_stays_readable(read_only_store):
repository, member = read_only_store
assert repository.read_only is True
repository.initialize()
assert repository.validate() == []
assert [item.member_id for item in repository.list_members()] == [member.member_id]
assert repository.get_member(member.member_id).first_name == "Ada"
assert repository.get_contributions(member.member_id).claims
assert repository.get_events(member.member_id)
assert repository.get_configuration()["organization"]["name"]
def test_every_write_is_refused_with_one_clear_message(read_only_store):
repository, member = read_only_store
stored = repository.get_member(member.member_id)
for action in (
lambda: repository.save_member(stored),
lambda: repository.create_member(first_name="Grace", last_name="Hopper"),
lambda: repository.create_manual_claim(
member.member_id, title="Beitrag", amount="10.00", due_date="2026-09-30"
),
lambda: repository.save_contributions(member.member_id, ContributionData()),
lambda: repository.append_event(
member.member_id, event_type="comment", summary="Test", actor_type="user"
),
lambda: repository.save_organization({"name": "Test", "iban": "", "bic": ""}),
lambda: repository.create_asset(label="Beamer"),
):
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
action()
def test_housekeeper_refuses_to_run_instead_of_failing_on_its_lock(read_only_store):
repository, _member = read_only_store
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
Housekeeper(repository).run(today=date(2026, 2, 10))
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
Housekeeper(repository).delete_task("some-key")
assert not (repository.root / ".housekeeper.lock").exists()
def test_documents_and_mails_refuse_before_touching_the_member_file(read_only_store, tmp_path):
repository, member = read_only_store
service = DocumentService(repository)
template = next(item for item in service.list_templates() if item.name == "Mitglied")
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
service.generate(template, member.member_id, output_name="Test")
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
"reminder-1",
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_debit_mails(
repository,
[],
collection_date=date(2026, 9, 1),
delivery_mode="local",
output_directory=tmp_path / "sepa",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_data_review_mails(
repository,
[member.member_id],
delivery_mode="local",
output_directory=tmp_path / "datenpruefung",
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
def test_the_welcome_mail_writes_nothing_at_all_when_refused(read_only_store, tmp_path):
"""The refusal has to come before rendering, sending and archiving -- not out of
the failing write at the end, by which point the mail would already be out."""
repository, member = read_only_store
export_path = tmp_path / "Willkommen.eml"
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert not export_path.exists()
files = repository.members_root / member.member_id / "files"
assert not (files / "documents" / "Willkommen").exists()
assert all(
event.event_type != "welcome_email_sent"
for event in repository.get_events(member.member_id)
)
def test_uninitialized_read_only_store_reports_why_it_cannot_be_opened(tmp_path):
root = tmp_path / "empty-store"
root.mkdir()
root.chmod(stat.S_IRUSR | stat.S_IXUSR)
try:
with pytest.raises(ReadOnlyStoreError, match="repository.json"):
MemberRepository(root).initialize()
finally:
root.chmod(stat.S_IRWXU)
def test_remounting_writable_is_picked_up_without_a_restart(read_only_store):
repository, _member = read_only_store
assert repository.read_only is True
_set_writable(repository.root, True)
assert repository.read_only is True, "the cached answer must stay put until refreshed"
assert repository.refresh_read_only() is False
repository.append_event(
_member.member_id,
event_type="comment",
summary="Wieder beschreibbar",
actor_type="user",
)
def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
"""Mirrors what the splash screen's worker thread does -- this is the sequence
that used to abort the whole start with a PermissionError."""
repository, member = read_only_store
repository.initialize()
errors = repository.validate()
members = repository.list_members()
findings = [] if repository.read_only else Housekeeper(repository).run()
assert errors == []
assert [item.member_id for item in members] == [member.member_id]
assert findings == []
assert repository.member_count() == 1
def test_mail_templates_fall_back_to_the_shipped_defaults(read_only_store):
"""A store that predates mail templates has no templates/mail/ directory, and a
read-only one cannot get it -- the shipped default has to carry the session."""
from ccma.domain.mail_templates import default_mail_template
repository, _member = read_only_store
_set_writable(repository.root, True)
for path in repository.mail_templates_root.glob("*.txt"):
path.unlink()
_set_writable(repository.root, False)
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
assert not list(repository.mail_templates_root.glob("*.txt")), "nothing was written"
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
repository.save_mail_template("welcome", subject="Moin", body="Kurz.")
+468
View File
@@ -0,0 +1,468 @@
from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
import pytest
from test_reminders import _overdue_claim_repository
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import RepositoryError
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Bitte Mitgliedsnummer angeben.",
)
export_path = tmp_path / "Zahlungserinnerung.eml"
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert message["To"] == "reminder@example.org"
assert "Zahlungserinnerung" in message["Subject"]
assert "100.00 Euro" in message.get_content()
assert "DE98670505050038907751" in message.get_content()
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "sent"
assert data.reminders[0]["payment_deadline"]
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
assert generated.archive_path.parent.name == "Mahnungen"
def test_a_dunning_mail_asks_for_a_transfer_even_with_an_active_mandate(tmp_path):
"""A member is only dunned once the money did not arrive, so this claim has left
the direct-debit run -- "wir ziehen den Betrag ein, du musst nichts tun" would
contradict the very letter it accompanies."""
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
member.account_holder = "Reminder Test"
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDAT-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
# The shipped template spells the bank details out; this one uses the ready-made
# paragraph, which is what the placeholder chooser offers for a dunning mail.
repository.save_mail_template(
"reminder",
subject="Mahnung: {{claim.title}}",
body="Hallo {{member.first_name}},\n\n{{payment.instructions}}\n\n{{signature}}",
)
reminder = repository.create_reminder_draft(
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "Bitte überweise den Betrag" in content
assert "DE98670505050038907751" in content
assert "ziehen den Betrag" not in content
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Erste Mahnung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert "Offener Betrag: 105.00 Euro" in message.get_content()
assert "Mahngebühr: 5.00 Euro" in message.get_content()
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
import ccma.services.reminder_mail as reminder_mail_module
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
sent = []
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
monkeypatch.setattr(
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path is None
assert generated.archive_path.is_file()
assert len(sent) == 1
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "sent"
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_path, monkeypatch):
import ccma.services.reminder_mail as reminder_mail_module
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="mail.example.org",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
imap_sent_enabled=True,
imap_sent_folder="INBOX.Gesendet",
)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
monkeypatch.setattr(
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
)
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
monkeypatch.setattr(
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
)
ensured = []
appended = []
monkeypatch.setattr(
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
)
monkeypatch.setattr(
reminder_mail_module,
"append_message",
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
)
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert ensured == [("imap-client", "INBOX.Gesendet")]
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
def test_reminder_mail_send_failure_reverts_sent_status(tmp_path, monkeypatch):
import ccma.services.reminder_mail as reminder_mail_module
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
@contextmanager
def _fake_smtp_session(settings):
yield "smtp-client"
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
def _failing_send(client, content):
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", _failing_send)
with pytest.raises(RepositoryError):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
data = repository.get_contributions(member.member_id)
stored_reminder = data.reminders[0]
assert stored_reminder["status"] == "draft"
assert not stored_reminder.get("fee_item_ids")
assert repository.get_events(member.member_id)[-1].event_type == "reminder_sent_reverted"
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert not any(
item.get("description") == "Mahngebühr" for item in claim.get("items", [])
)
def test_reminder_mail_local_write_failure_reverts_sent_status(tmp_path, monkeypatch):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
)
import ccma.services.reminder_mail as reminder_mail_module
def _boom(*args, **kwargs):
raise OSError("disk full")
monkeypatch.setattr(reminder_mail_module.os, "replace", _boom)
with pytest.raises(OSError):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Zahlungserinnerung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "draft"
def test_reminder_mail_archiving_failure_after_smtp_success_does_not_revert(tmp_path, monkeypatch):
import ccma.services.reminder_mail as reminder_mail_module
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
@contextmanager
def _fake_smtp_session(settings):
yield "smtp-client"
monkeypatch.setattr(reminder_mail_module, "smtp_session", _fake_smtp_session)
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
real_replace = reminder_mail_module.os.replace
def _flaky_replace(src, dst):
# Only the final archive move (into the "Mahnungen" folder) should fail --
# the mail has already been handed to send_via_smtp by that point.
if str(dst).endswith(".eml") and "Mahnungen" in str(dst):
raise OSError("disk full")
return real_replace(src, dst)
monkeypatch.setattr(reminder_mail_module.os, "replace", _flaky_replace)
with pytest.raises(RepositoryError, match="zugestellt"):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
data = repository.get_contributions(member.member_id)
stored_reminder = data.reminders[0]
assert stored_reminder["status"] == "sent", "mail was already sent -- must not be un-sent"
assert stored_reminder.get("fee_item_ids")
events = repository.get_events(member.member_id)
assert events[-1].event_type == "reminder_email_sent"
assert events[-1].data["archive_error"]
assert "document" not in events[-1].references
def test_reminder_mail_archive_dir_creation_failure_reverts_sent_status(tmp_path, monkeypatch):
from pathlib import Path
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
original_mkdir = Path.mkdir
def _flaky_mkdir(self, *args, **kwargs):
if self.name == "Mahnungen":
raise OSError("permission denied")
return original_mkdir(self, *args, **kwargs)
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
with pytest.raises(OSError):
generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Zahlungserinnerung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
data = repository.get_contributions(member.member_id)
stored_reminder = data.reminders[0]
assert stored_reminder["status"] == "draft"
assert not stored_reminder.get("fee_item_ids")
+404 -3
View File
@@ -1,7 +1,10 @@
import json
from datetime import date, timedelta
from decimal import Decimal
import pytest
from ccma.domain.contributions import claim_total
from ccma.domain.models import ContributionData
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -29,6 +32,15 @@ def _overdue_claim_repository(tmp_path):
return repository, member
def _sepa_member(repository, member):
member.iban = "DE89370400440532013000"
member.mandate_reference = "MANDATE-1"
member.mandate_signed_at = "2026-01-01"
member.mandate_active = True
repository.save_member(member)
return member
def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
@@ -42,7 +54,7 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
fee="0.00",
items=[],
)
findings = housekeeper.run(today=date(2026, 2, 10))
reminder_task = next(item for item in findings if item.code == "reminder_due")
@@ -58,6 +70,35 @@ def test_reminder_rule_progresses_only_after_sent_deadline(tmp_path) -> None:
assert "Erste Mahnung" in next_task.title
def test_overdue_claim_awaiting_reminder_deadline_is_not_reported_as_error(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
deadline = date.fromisoformat(sent["payment_deadline"])
findings = [
item
for item in housekeeper.run(today=deadline - timedelta(days=1))
if item.member_id == member.member_id
]
# A reminder was already sent and its own deadline hasn't expired yet -- this is
# being handled, so it must not appear as an unaddressed "error"-severity overdue
# claim, only as a low-priority note that the existing deadline is still running.
assert not any(item.code == "claim_overdue" for item in findings)
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
assert awaiting.severity == "info"
assert "Zahlungserinnerung" in awaiting.title
def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
@@ -68,7 +109,11 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
reason="Betrag wird geklärt",
)
assert not any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
findings = housekeeper.run(today=date(2026, 2, 10))
assert not any(item.code == "reminder_due" for item in findings)
# The claim stays visible as a plain overdue notice instead of disappearing entirely
# while the hold suppresses the dunning escalation.
assert any(item.code == "claim_overdue" for item in findings)
with pytest.raises(RepositoryError, match="Mahnsperre aktiv"):
repository.create_reminder_draft(
member.member_id,
@@ -81,6 +126,101 @@ def test_dunning_hold_suppresses_and_then_restores_task(tmp_path) -> None:
assert any(item.code == "reminder_due" for item in housekeeper.run(today=date(2026, 2, 10)))
def test_overdue_claim_and_reminder_finding_are_never_shown_at_once(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
housekeeper = Housekeeper(repository)
# Still within the 7-day default grace period: only the plain overdue notice, no
# dunning escalation yet.
within_grace = housekeeper.run(today=date(2026, 2, 4))
codes = {item.code for item in within_grace if item.member_id == member.member_id}
assert codes == {"claim_overdue"}
# Past the grace period: the dunning escalation takes over as the single finding,
# the redundant plain overdue notice disappears.
past_grace = housekeeper.run(today=date(2026, 2, 10))
codes = {item.code for item in past_grace if item.member_id == member.member_id}
assert codes == {"reminder_due"}
def test_sepa_member_gets_debit_followup_instead_of_reminder(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
findings = housekeeper.run(today=date(2026, 2, 10))
codes = {item.code for item in findings if item.member_id == member.member_id}
assert codes == {"sepa_debit_overdue"}
sepa_finding = next(item for item in findings if item.code == "sepa_debit_overdue")
assert "Lastschrift" in sepa_finding.title
assert "SEPA" in sepa_finding.detail
def test_a_dunned_sepa_claim_continues_in_the_normal_dunning_process(tmp_path) -> None:
"""A bounced direct debit is dunned like any other claim: the money is expected as
a transfer from then on, so the "check the direct debit" notice must not come back
once the Rücklastschrift deadline has passed."""
repository, member = _overdue_claim_repository(tmp_path)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
deadline = date.fromisoformat(sent["payment_deadline"])
findings = [
item
for item in housekeeper.run(today=deadline - timedelta(days=1))
if item.member_id == member.member_id
]
assert not any(item.code == "sepa_debit_overdue" for item in findings)
awaiting = next(item for item in findings if item.code == "reminder_awaiting_deadline")
assert awaiting.severity == "info"
assert "Rücklastschrift" in awaiting.title
# Deadline gone by without payment: the next dunning level is due, not another
# "der Einzug muss geprüft werden".
findings = [
item
for item in housekeeper.run(today=deadline + timedelta(days=1))
if item.member_id == member.member_id
]
assert not any(item.code == "sepa_debit_overdue" for item in findings)
reminder_task = next(item for item in findings if item.code == "reminder_due")
assert "Erste Mahnung" in reminder_task.title
assert "Mahnstufe 2" in reminder_task.detail
def test_an_undunned_sepa_claim_still_asks_to_check_the_debit(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
_sepa_member(repository, member)
housekeeper = Housekeeper(repository)
findings = [
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
]
# A draft alone changes nothing -- only a reminder that actually went out takes the
# claim out of the direct-debit run.
repository.create_reminder_draft(
member.member_id, "claim-1", level=1, name="Rücklastschrift", payment_deadline_days=14
)
still_sepa = [
item for item in housekeeper.run(today=date(2026, 2, 10)) if item.member_id == member.member_id
]
assert {item.code for item in findings} == {"sepa_debit_overdue"}
assert {item.code for item in still_sepa} == {"sepa_debit_overdue"}
def test_draft_can_be_cancelled_but_sent_reminder_cannot(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
@@ -116,7 +256,7 @@ def test_reminder_levels_cannot_be_skipped(tmp_path) -> None:
level=2,
name="Erste Mahnung",
payment_deadline_days=14,
fee="5.00",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
@@ -134,3 +274,264 @@ def test_payment_resolves_open_reminder_task(tmp_path) -> None:
findings = housekeeper.run(today=date(2026, 2, 10))
assert not any(item.code == "reminder_due" for item in findings)
def test_reminder_can_carry_multiple_fee_items(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[
{"description": "Rücklastschriftgebühr", "amount": "5.00"},
{"description": "Bankgebühr", "amount": "3.50"},
],
)
assert draft["fee_item_ids"] == []
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert len(sent["fee_item_ids"]) == 2
data, claim = repository.get_claim(member.member_id, "claim-1")
descriptions = {item["description"] for item in claim["items"] if item["type"] == "fee"}
assert descriptions == {"Rücklastschriftgebühr", "Bankgebühr"}
assert claim_total(claim) == Decimal("108.50")
def test_reminder_items_require_a_description(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
with pytest.raises(RepositoryError, match="Beschreibung"):
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": " ", "amount": "5.00"}],
)
def test_reminder_with_zero_amount_items_creates_no_claim_item(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "0.00"}],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert sent["fee_item_ids"] == []
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
def test_reminder_policy_round_trips_through_save_and_get(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.save_reminder_policy(
grace_days_after_due=10,
levels=[
{"name": "Zahlungserinnerung", "fee": "0.00", "payment_deadline_days": 10},
{"name": "Mahnung", "fee": "7.50", "payment_deadline_days": 14},
],
standard_fee_items=[
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"},
],
)
policy = repository.get_reminder_policy()
assert policy["grace_days_after_due"] == 10
assert [level["level"] for level in policy["levels"]] == [1, 2]
assert policy["levels"][1]["name"] == "Mahnung"
assert policy["levels"][1]["fee"] == "7.50"
assert policy["standard_fee_items"] == [
{"description": "Rücklastschriftgebühr", "default_amount": "6.00"}
]
def test_reminder_policy_rejects_invalid_input(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Mindestens eine Mahnstufe"):
repository.save_reminder_policy(grace_days_after_due=7, levels=[], standard_fee_items=[])
with pytest.raises(RepositoryError, match="benötigt einen Namen"):
repository.save_reminder_policy(
grace_days_after_due=7,
levels=[{"name": " ", "fee": "0.00", "payment_deadline_days": 14}],
standard_fee_items=[],
)
with pytest.raises(RepositoryError, match="nicht negativ"):
repository.save_reminder_policy(
grace_days_after_due=7,
levels=[{"name": "Mahnung", "fee": "-1.00", "payment_deadline_days": 14}],
standard_fee_items=[],
)
def test_edit_reminder_draft_updates_fields_but_not_level(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
edited = repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Erste Mahnung",
payment_deadline_days=10,
detail="Bitte um Ausgleich.",
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
assert edited["level"] == 1
assert edited["name"] == "Erste Mahnung"
assert edited["payment_deadline_days"] == 10
assert edited["detail"] == "Bitte um Ausgleich."
assert edited["items"] == [{"description": "Mahngebühr", "amount": "5.00"}]
def test_edit_reminder_draft_rejects_already_sent_reminder(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
with pytest.raises(RepositoryError, match="Nur ein Entwurf"):
repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Andere Bezeichnung",
payment_deadline_days=14,
)
def test_revert_reminder_sent_removes_booked_fee_and_reopens_draft(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
sent = repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert len(sent["fee_item_ids"]) == 1
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("105.00")
reverted = repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
assert reverted["status"] == "draft"
assert reverted["sent_at"] is None
assert reverted["payment_deadline"] is None
assert reverted["fee_item_ids"] == []
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("100.00")
descriptions = {item["description"] for item in claim["items"]}
assert "Rücklastschriftgebühr" not in descriptions
# Now editable and re-sendable, e.g. after fixing the wording.
repository.edit_reminder_draft(
member.member_id,
"claim-1",
draft["reminder_id"],
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "7.50"}],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
_data, claim = repository.get_claim(member.member_id, "claim-1")
assert claim_total(claim) == Decimal("107.50")
def test_revert_reminder_sent_rejects_when_higher_level_exists(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
first = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
items=[],
)
repository.mark_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
repository.create_reminder_draft(
member.member_id,
"claim-1",
level=2,
name="Erste Mahnung",
payment_deadline_days=14,
items=[{"description": "Mahngebühr", "amount": "5.00"}],
)
with pytest.raises(RepositoryError, match="höhere Mahnstufe"):
repository.revert_reminder_sent(member.member_id, "claim-1", first["reminder_id"])
def test_revert_reminder_sent_rejects_when_payment_already_covers_fee(tmp_path) -> None:
repository, member = _overdue_claim_repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
repository.mark_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
repository.record_payment(
member.member_id,
"claim-1",
payment_date="2026-02-10",
amount="105.00",
allocation_amount="105.00",
)
with pytest.raises(RepositoryError, match="bereits Zahlungen verbucht"):
repository.revert_reminder_sent(member.member_id, "claim-1", draft["reminder_id"])
def test_reminder_policy_backfills_standard_fee_items_for_old_repositories(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
config_path = tmp_path / "repository.json"
raw = json.loads(config_path.read_text(encoding="utf-8"))
# Simulate a repository created before "standard_fee_items" existed at all.
del raw["reminder_policy"]["standard_fee_items"]
config_path.write_text(json.dumps(raw), encoding="utf-8")
policy = repository.get_reminder_policy()
assert policy["standard_fee_items"], "should fall back to built-in defaults"
assert any(item["description"] == "Rücklastschriftgebühr" for item in policy["standard_fee_items"])
# But once the board explicitly saves an empty list, that choice is respected.
repository.save_reminder_policy(
grace_days_after_due=policy["grace_days_after_due"],
levels=policy["levels"],
standard_fee_items=[],
)
assert repository.get_reminder_policy()["standard_fee_items"] == []
+75
View File
@@ -1,4 +1,5 @@
import json
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
import pytest
@@ -24,6 +25,7 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
nickname="Enchantress",
email="ada@example.org",
birth_date="1990-12-10",
application_date="30.07.2026",
member_number="0042",
)
@@ -37,11 +39,35 @@ def test_repository_creates_transparent_member_record(tmp_path) -> None:
raw = json.loads((member_dir / "member.json").read_text(encoding="utf-8"))
assert raw["person"]["first_name"] == "Ada"
assert raw["person"]["nickname"] == "Enchantress"
assert raw["membership"]["application_date"] == "2026-07-30"
assert raw["schema_version"] == 1
assert raw["membership"]["ended_at"] == ""
assert raw["contribution_profile"]["payment_frequency"] == "semiannual"
assert raw["content_hash"]
def test_existing_member_without_payment_frequency_defaults_to_semiannual(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Legacy", last_name="Member")
member_path = repository.members_root / member.member_id / "member.json"
raw = json.loads(member_path.read_text(encoding="utf-8"))
raw["contribution_profile"].pop("payment_frequency")
member_path.write_text(json.dumps(raw), encoding="utf-8")
assert repository.get_member(member.member_id).payment_frequency == "semiannual"
def test_repository_rejects_unknown_payment_frequency(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Invalid", last_name="Frequency")
member.payment_frequency = "weekly"
with pytest.raises(RepositoryError, match="Ungültige Zahlweise"):
repository.save_member(member)
def test_search_matches_name_email_number_and_german_birth_date(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
@@ -120,6 +146,18 @@ def test_repository_accepts_local_date_input_and_rejects_invalid_dates(tmp_path)
repository.create_member(first_name="Invalid", last_name="Date", birth_date="31.02.2000")
def test_existing_member_without_application_date_stays_empty(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Legacy", last_name="Member")
path = repository.members_root / member.member_id / "member.json"
raw = json.loads(path.read_text(encoding="utf-8"))
raw["membership"].pop("application_date")
path.write_text(json.dumps(raw), encoding="utf-8")
assert repository.get_member(member.member_id).application_date == ""
def test_repository_reports_empty_contributions_file(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
@@ -184,6 +222,43 @@ def test_custom_pattern_and_manual_mode(tmp_path) -> None:
repository.create_member(first_name="Duplicate", last_name="Member", member_number="special-7")
def test_member_number_strategy_fills_gaps_or_uses_max_plus_one(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
repository.create_member(first_name="One", last_name="Member", member_number="MA-001")
repository.create_member(first_name="Three", last_name="Member", member_number="MA-003")
repository.save_member_number_policy(
mode="automatic",
pattern="MA-{number:03d}",
allocation_strategy="next_free",
)
assert repository.preview_member_number() == "MA-002"
assert repository.get_member_number_policy()["allocation_strategy"] == "next_free"
repository.save_member_number_policy(
mode="automatic",
pattern="MA-{number:03d}",
allocation_strategy="max_plus_one",
)
assert repository.preview_member_number() == "MA-004"
created = repository.create_member(first_name="Four", last_name="Member")
assert created.member_number == "MA-004"
def test_concurrent_automatic_member_numbers_are_unique(tmp_path) -> None:
MemberRepository(tmp_path).initialize()
def create(index: int) -> str:
repository = MemberRepository(tmp_path)
return repository.create_member(first_name=f"Member {index}", last_name="Concurrent").member_number
with ThreadPoolExecutor(max_workers=8) as executor:
numbers = list(executor.map(create, range(16)))
assert len(numbers) == len(set(numbers)) == 16
@pytest.mark.parametrize("pattern", ["", "CCMA-{year}", "{unknown}-{number}", "{number!r}"])
def test_invalid_member_number_patterns_are_rejected(pattern) -> None:
with pytest.raises(RepositoryError):
+140 -1
View File
@@ -1,13 +1,14 @@
import json
import shutil
from datetime import date
from decimal import Decimal
import pytest
import ccma.services.housekeeper as housekeeper_module
from ccma.rules.loader import RuleLoadError
from ccma.services.housekeeper import Housekeeper
from ccma.storage.repository import MemberRepository
from ccma.storage.repository import MemberRepository, RepositoryError
def test_housekeeper_optionally_waits_between_members(tmp_path, monkeypatch) -> None:
@@ -96,6 +97,43 @@ def test_housekeeper_claim_actions_are_idempotent(tmp_path) -> None:
assert state["last_completed_run"] == "2026-04-15:000002"
@pytest.mark.parametrize(
("frequency", "expected_suffixes", "expected_amount"),
[
("monthly", [f"month-{month:02d}" for month in range(1, 13)], "12.50"),
("quarterly", [f"quarter-{quarter}" for quarter in range(1, 5)], "37.50"),
("semiannual", ["first-half", "second-half"], "75.00"),
("annual", ["annual"], "150.00"),
],
)
def test_housekeeper_creates_claims_for_each_payment_frequency(
tmp_path, frequency, expected_suffixes, expected_amount
) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(
first_name=frequency,
last_name="Frequency",
birth_date="1990-01-01",
)
member.status = "active"
member.accepted_at = "2026-01-01"
member.membership_started_at = "2026-01-01"
member.payment_frequency = frequency
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 12, 31))
membership_claims = [
claim
for claim in repository.get_contributions(member.member_id).claims
if claim.get("type") == "membership_fee"
]
assert [claim["claim_key"].rsplit(":", 1)[-1] for claim in membership_claims] == expected_suffixes
assert {claim["amount"] for claim in membership_claims} == {expected_amount}
assert sum(Decimal(claim["amount"]) for claim in membership_claims) == Decimal("150.00")
def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
@@ -103,6 +141,7 @@ def test_housekeeper_creates_membership_claims_retroactively_since_entry(tmp_pat
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
settings = housekeeper_module.HousekeeperSettings.from_values(
@@ -138,6 +177,7 @@ def test_housekeeper_uses_pre_2022_contribution_amounts_for_legacy_years(tmp_pat
member.status = "active"
member.accepted_at = "2021-04-15"
member.membership_started_at = "2021-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
settings = housekeeper_module.HousekeeperSettings.from_values(
@@ -165,6 +205,7 @@ def test_housekeeper_does_not_create_retroactive_membership_claims_by_default(tm
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
@@ -185,6 +226,7 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -
member.accepted_at = "2024-01-01"
member.membership_started_at = "2024-01-01"
member.membership_ended_at = "2026-12-31"
member.payment_frequency = "annual"
repository.save_member(member)
Housekeeper(repository).run(today=date(2026, 6, 21))
@@ -194,6 +236,39 @@ def test_housekeeper_does_not_create_claims_due_after_membership_end(tmp_path) -
assert "membership-fee:2027:annual" not in claim_keys
def test_housekeeper_skips_birthday_and_anniversary_for_ended_members(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
active = repository.create_member(first_name="Active", last_name="Member", birth_date="1990-06-21")
active.status = "active"
active.accepted_at = "2025-06-21"
active.membership_started_at = "2025-06-21"
repository.save_member(active)
ended = repository.create_member(first_name="Ended", last_name="Member", birth_date="1990-06-21")
ended.status = "ended"
ended.accepted_at = "2025-06-21"
ended.membership_started_at = "2025-06-21"
ended.membership_ended_at = "2025-12-31"
repository.save_member(ended)
settings = housekeeper_module.HousekeeperSettings.from_values(
birthday_days_before=0,
birthday_days_after=0,
anniversary_days_before=0,
anniversary_days_after=0,
anniversary_intervals="1Y",
)
findings = Housekeeper(repository, settings).run(today=date(2026, 6, 21))
codes_by_member = {}
for item in findings:
codes_by_member.setdefault(item.member_id, set()).add(item.code)
assert "birthday" in codes_by_member.get(active.member_id, set())
assert "membership_anniversary" in codes_by_member.get(active.member_id, set())
assert codes_by_member.get(ended.member_id, set()).isdisjoint({"birthday", "membership_anniversary"})
def test_housekeeper_resolves_tasks_not_seen_in_current_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
@@ -339,3 +414,67 @@ def test_housekeeper_removes_items_for_deleted_member_directory(tmp_path) -> Non
assert findings == []
assert not any(item.get("member_id") == member.member_id for item in state["items"])
def test_housekeeper_run_can_target_a_single_member(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member_a = repository.create_member(first_name="Alpha", last_name="Member")
member_b = repository.create_member(first_name="Beta", last_name="Member")
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21))
state_before = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
b_task_before = next(
item for item in state_before["items"] if item.get("member_id") == member_b.member_id
)
member_a.birth_date = "1990-01-01"
repository.save_member(member_a)
findings = housekeeper.run(today=date(2026, 6, 21), member_id=member_a.member_id)
a_findings = [item for item in findings if item.member_id == member_a.member_id]
assert not any(item.code == "missing_birth_date" for item in a_findings)
state_after = json.loads((repository.root / "housekeeper.json").read_text(encoding="utf-8"))
b_task_after = next(
item for item in state_after["items"] if item.get("member_id") == member_b.member_id
)
assert b_task_after == b_task_before
def test_housekeeper_run_rejects_unknown_member_id(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
housekeeper = Housekeeper(repository)
with pytest.raises(RepositoryError, match="nicht gefunden"):
housekeeper.run(today=date(2026, 6, 21), member_id="does-not-exist")
def test_housekeeper_run_retroactive_override_applies_only_to_that_run(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Retro", last_name="Member", birth_date="1990-01-01")
member.status = "active"
member.accepted_at = "2024-04-15"
member.membership_started_at = "2024-04-15"
member.payment_frequency = "annual"
repository.save_member(member)
housekeeper = Housekeeper(repository)
housekeeper.run(today=date(2026, 6, 21), member_id=member.member_id, retroactive_claims=True)
claim_keys = {claim["claim_key"] for claim in repository.get_contributions(member.member_id).claims}
assert "membership-fee:2024:annual" in claim_keys
assert housekeeper.settings.retroactive_claims is False
other = repository.create_member(first_name="NoRetro", last_name="Member", birth_date="1990-01-01")
other.status = "active"
other.accepted_at = "2024-04-15"
other.membership_started_at = "2024-04-15"
other.payment_frequency = "annual"
repository.save_member(other)
housekeeper.run(today=date(2026, 6, 21), member_id=other.member_id)
other_claim_keys = {claim["claim_key"] for claim in repository.get_contributions(other.member_id).claims}
assert "membership-fee:2024:annual" not in other_claim_keys
assert "membership-fee:2026:annual" in other_claim_keys
+717
View File
@@ -0,0 +1,717 @@
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from datetime import UTC, date, datetime
from decimal import Decimal
from email.parser import BytesParser
from email.policy import default
from ccma.domain.models import ContributionData
from ccma.services.housekeeper import Housekeeper
from ccma.services.sepa import PAIN_NAMESPACE, _safe, csv_text, pain008_bytes, pending_direct_debits
from ccma.services.sepa_mail import debit_mail_bytes, generate_debit_mails
from ccma.storage.repository import MemberRepository
from ccma.ui.sepa_dialog import _next_weekday
def _repository(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
member = repository.create_member(first_name="Ada", last_name="Lovelace", member_number="C3-42")
member.status = "active"
member.email = "ada@example.org"
member.account_holder = "Ada Lovelace"
member.iban = "DE89370400440532013000"
member.bic = "COBADEFFXXX"
member.mandate_reference = "MANDAT-42"
member.mandate_signed_at = "2025-01-10"
member.mandate_active = True
repository.save_member(member)
repository.save_contributions(
member.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
},
{
"claim_id": "future",
"title": "Mitgliedsbeitrag 2027",
"amount": "150.00",
"due_date": "2027-01-31",
"status": "open",
},
],
payments=[{"payment_id": "payment", "amount": "25.00"}],
allocations=[{"payment_id": "payment", "claim_id": "due", "amount": "25.00"}],
),
)
return repository, member
def test_pending_debits_select_due_open_balance(tmp_path):
repository, member = _repository(tmp_path)
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert warnings == []
assert len(debits) == 1
assert debits[0].member_id == member.member_id
assert debits[0].amount == Decimal("125.00")
assert debits[0].claim_ids == ("due",)
def test_a_dunned_claim_is_not_collected_again(tmp_path):
"""The dunning letter asked the member to transfer the money -- pulling it by
direct debit anyway would contradict it (and can bounce a second time)."""
repository, member = _repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id,
"due",
level=1,
name="Rücklastschrift",
payment_deadline_days=14,
items=[{"description": "Rücklastschriftgebühr", "amount": "5.00"}],
)
# A draft alone is not a dunning: nothing has gone out yet.
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert [debit.claim_ids for debit in debits] == [("due",)]
assert warnings == []
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
debits, warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
# Nothing else is due in the window, so the member drops out of the run entirely
# -- and says why instead of vanishing silently.
assert debits == []
assert warnings == ["C3-42: „Mitgliedsbeitrag 2026“ wurde bereits gemahnt "
"Zahlung per Überweisung erwartet, kein erneuter Einzug."]
def test_a_dunned_claim_does_not_hold_back_the_members_other_claims(tmp_path):
repository, member = _repository(tmp_path)
draft = repository.create_reminder_draft(
member.member_id, "due", level=1, name="Rücklastschrift", payment_deadline_days=14
)
repository.mark_reminder_sent(member.member_id, "due", draft["reminder_id"])
debits, warnings = pending_direct_debits(repository, due_until=date(2027, 12, 31))
assert [debit.claim_ids for debit in debits] == [("future",)]
assert debits[0].amount == Decimal("150.00")
assert len(warnings) == 1
def test_pending_debits_follow_members_monthly_payment_frequency(tmp_path):
repository, member = _repository(tmp_path)
member.accepted_at = "2025-01-01"
member.membership_started_at = "2025-01-01"
member.payment_frequency = "monthly"
repository.save_member(member)
repository.save_contributions(member.member_id, ContributionData())
Housekeeper(repository).run(today=date(2026, 3, 31))
debits, warnings = pending_direct_debits(
repository,
due_from=date(2026, 1, 1),
due_until=date(2026, 3, 31),
)
assert warnings == []
assert len(debits) == 1
assert debits[0].amount == Decimal("37.50")
assert len(debits[0].claim_ids) == 3
def test_pending_debits_exclude_claims_before_lower_date(tmp_path):
repository, _member = _repository(tmp_path)
debits, warnings = pending_direct_debits(
repository,
due_from=date(2026, 2, 1),
due_until=date(2026, 12, 31),
)
assert warnings == []
assert debits == []
def test_next_weekday_moves_weekend_to_monday():
assert _next_weekday(date(2026, 8, 1)) == date(2026, 8, 3)
assert _next_weekday(date(2026, 8, 3)) == date(2026, 8, 3)
def test_sepa_text_normalizes_unsupported_characters():
assert _safe("Sébastien OConnor Müller", 70) == "Sebastien O'Connor - Müller"
def test_csv_is_semicolon_separated_and_uses_decimal_comma(tmp_path):
repository, _member = _repository(tmp_path)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
text = csv_text(debits, collection_date=date(2026, 8, 3))
assert "Einzugsdatum;Mitgliedsnummer" in text
assert "125,00;EUR;MANDAT-42" in text
def test_pain008_contains_control_sum_mandate_and_creditor(tmp_path):
repository, _member = _repository(tmp_path)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
organization = {
"name": "Chaos Computer Club Mannheim e.V.",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
content = pain008_bytes(
debits,
collection_date=date(2026, 8, 3),
organization=organization,
message_id="CCMA-TEST",
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
)
root = ET.fromstring(content)
ns = {"p": PAIN_NAMESPACE}
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "125.00"
assert root.findtext(".//p:MndtId", namespaces=ns) == "MANDAT-42"
assert (
root.findtext(".//p:DrctDbtTx/p:CdtrSchmeId//p:Othr/p:Id", namespaces=ns)
== "DE98ZZZ09999999999"
)
assert root.find("p:CstmrDrctDbtInitn/p:PmtInf/p:CdtrSchmeId", ns) is None
assert root.findtext(".//p:ReqdColltnDt", namespaces=ns) == "2026-08-03"
def test_pain008_contains_one_logical_batch_with_multiple_transactions(tmp_path):
repository, _member = _repository(tmp_path)
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
organization = {
"name": "Chaos Computer Club Mannheim e.V.",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
root = ET.fromstring(
pain008_bytes(
[debit, debit],
collection_date=date(2026, 8, 3),
organization=organization,
message_id="CCMA-BATCH-TEST",
)
)
ns = {"p": PAIN_NAMESPACE}
assert len(root.findall("p:CstmrDrctDbtInitn", ns)) == 1
assert len(root.findall(".//p:PmtInf", ns)) == 1
assert len(root.findall(".//p:DrctDbtTxInf", ns)) == 2
assert root.findtext(".//p:GrpHdr/p:NbOfTxs", namespaces=ns) == "2"
assert root.findtext(".//p:GrpHdr/p:CtrlSum", namespaces=ns) == "250.00"
def test_debit_mail_is_thunderbird_draft(tmp_path):
repository, member = _repository(tmp_path)
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
content = debit_mail_bytes(
repository,
member=member,
debit=debit,
collection_date=date(2026, 8, 3),
organization={"name": "C3MA", "creditor_id": "DE98ZZZ09999999999"},
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
created_at=datetime(2026, 7, 30, 12, 0, tzinfo=UTC),
)
message = BytesParser(policy=default).parsebytes(content)
assert message["To"] == "ada@example.org"
assert str(message["X-Mozilla-Draft-Info"]).strip().startswith("internal/draft")
assert "125.00 Euro" in message.get_content()
assert "MANDAT-42" in message.get_content()
def test_generated_mail_is_exported_archived_and_logged(tmp_path):
repository, member = _repository(tmp_path)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="local",
output_directory=tmp_path / "mail-export",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
assert generated[0].export_path.is_file()
assert generated[0].archive_path.is_file()
assert generated[0].archive_path.read_bytes() == generated[0].export_path.read_bytes()
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "sepa_notification_generated"
assert event.references["document"].startswith("documents/SEPA/")
def test_generated_mail_send_mode_reuses_one_smtp_connection_for_batch(tmp_path, monkeypatch):
import ccma.services.sepa_mail as sepa_mail_module
repository, member = _repository(tmp_path)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
connections_opened = []
@contextmanager
def fake_smtp_session(settings):
connections_opened.append(settings)
yield object()
sent = []
monkeypatch.setattr(sepa_mail_module, "smtp_session", fake_smtp_session)
monkeypatch.setattr(
sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
)
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert warnings == []
assert generated[0].export_path is None
assert generated[0].archive_path.is_file()
assert len(connections_opened) == 1, "one connection should be reused for the whole batch"
assert len(sent) == len(debits)
def test_generated_mail_send_mode_copies_to_sent_folder_reusing_one_imap_connection(
tmp_path, monkeypatch
):
import ccma.services.sepa_mail as sepa_mail_module
repository, ada = _repository(tmp_path)
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
second.status = "active"
second.email = "grace@example.org"
second.account_holder = "Grace Hopper"
second.iban = "DE89370400440532013000"
second.bic = "COBADEFFXXX"
second.mandate_reference = "MANDAT-43"
second.mandate_signed_at = "2025-01-10"
second.mandate_active = True
repository.save_member(second)
repository.save_contributions(
second.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="mail.example.org",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
imap_sent_enabled=True,
imap_sent_folder="INBOX.Gesendet",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert len(debits) == 2
monkeypatch.setattr(
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
)
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: None)
imap_connections = []
@contextmanager
def fake_imap_session(settings):
imap_connections.append(settings)
yield "imap-client"
monkeypatch.setattr(sepa_mail_module, "imap_session", fake_imap_session)
ensured = []
appended = []
monkeypatch.setattr(
sepa_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append(folder)
)
monkeypatch.setattr(
sepa_mail_module,
"append_message",
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
)
generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert len(imap_connections) == 1, "one IMAP connection should be reused for the whole batch"
assert ensured == ["INBOX.Gesendet"]
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")] * 2
def test_generated_mail_archiving_failure_after_smtp_success_keeps_batch_going(tmp_path, monkeypatch):
import ccma.services.sepa_mail as sepa_mail_module
repository, ada = _repository(tmp_path)
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
second.status = "active"
second.email = "grace@example.org"
second.account_holder = "Grace Hopper"
second.iban = "DE89370400440532013000"
second.bic = "COBADEFFXXX"
second.mandate_reference = "MANDAT-43"
second.mandate_signed_at = "2025-01-10"
second.mandate_active = True
repository.save_member(second)
repository.save_contributions(
second.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert len(debits) == 2
monkeypatch.setattr(
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
)
sent = []
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content))
from pathlib import Path
original_write_bytes = Path.write_bytes
def _flaky_write_bytes(self, data):
if "documents" in str(self) and "C3-43" in self.name:
raise OSError("disk full")
return original_write_bytes(self, data)
monkeypatch.setattr(Path, "write_bytes", _flaky_write_bytes)
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
# Both mails were handed to SMTP -- a broken archive write for one of them must
# not swallow the other, nor pretend the failing one was never sent.
assert len(sent) == 2
assert len(generated) == 2
assert len(warnings) == 1
assert "C3-43" in warnings[0] or "Grace" in warnings[0]
failing = next(g for g in generated if g.member_id == second.member_id)
assert not failing.archive_path.is_file()
succeeding = next(g for g in generated if g.member_id == ada.member_id)
assert succeeding.archive_path.is_file()
grace_event = repository.get_events(second.member_id)[-1]
assert grace_event.event_type == "sepa_notification_generated"
assert grace_event.data["archive_error"]
assert "document" not in grace_event.references
ada_event = repository.get_events(ada.member_id)[-1]
assert "document" in ada_event.references
def test_generated_mail_smtp_failure_for_one_debit_does_not_abort_the_batch(tmp_path, monkeypatch):
import ccma.services.sepa_mail as sepa_mail_module
from ccma.storage.repository import RepositoryError
repository, ada = _repository(tmp_path)
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
second.status = "active"
second.email = "grace@example.org"
second.account_holder = "Grace Hopper"
second.iban = "DE89370400440532013000"
second.bic = "COBADEFFXXX"
second.mandate_reference = "MANDAT-43"
second.mandate_signed_at = "2025-01-10"
second.mandate_active = True
repository.save_member(second)
repository.save_contributions(
second.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert len(debits) == 2
monkeypatch.setattr(
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
)
sent = []
def _flaky_send(client, content):
if b"ada@example.org" in content:
raise RepositoryError("E-Mail konnte nicht versandt werden: connection reset")
sent.append(content)
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", _flaky_send)
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert len(sent) == 1
assert len(generated) == 1
assert generated[0].member_id == second.member_id
assert len(warnings) == 1
assert "connection reset" in warnings[0]
ada_events = repository.get_events(ada.member_id)
assert all(event.event_type != "sepa_notification_generated" for event in ada_events)
def test_generated_mail_archive_dir_creation_failure_for_one_debit_does_not_abort_the_batch(
tmp_path, monkeypatch
):
from pathlib import Path
repository, ada = _repository(tmp_path)
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
second.status = "active"
second.email = "grace@example.org"
second.account_holder = "Grace Hopper"
second.iban = "DE89370400440532013000"
second.bic = "COBADEFFXXX"
second.mandate_reference = "MANDAT-43"
second.mandate_signed_at = "2025-01-10"
second.mandate_active = True
repository.save_member(second)
repository.save_contributions(
second.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert len(debits) == 2
import ccma.services.sepa_mail as sepa_mail_module
monkeypatch.setattr(
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
)
sent = []
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content))
original_mkdir = Path.mkdir
def _flaky_mkdir(self, *args, **kwargs):
if self.name == "SEPA" and second.member_id in str(self):
raise OSError("permission denied")
return original_mkdir(self, *args, **kwargs)
monkeypatch.setattr(Path, "mkdir", _flaky_mkdir)
generated, warnings = generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
# Grace's archive directory could not even be created -- nothing was sent to her,
# so she must NOT show up as delivered, but Ada's mail must still go out.
assert len(sent) == 1
assert len(generated) == 1
assert generated[0].member_id == ada.member_id
assert len(warnings) == 1
assert "Grace" in warnings[0] or "C3-43" in warnings[0]
+1
View File
@@ -2,6 +2,7 @@ def test_ui_modules_import_without_creating_root_window() -> None:
import ccma.app # noqa: F401
import ccma.ui.asset_tab # noqa: F401
import ccma.ui.claim_tab # noqa: F401
import ccma.ui.data_review_mail_dialog # noqa: F401
import ccma.ui.main_window # noqa: F401
import ccma.ui.member_tab # noqa: F401
import ccma.ui.splash # noqa: F401
+295
View File
@@ -0,0 +1,295 @@
from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
import pytest
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
from ccma.storage.repository import MemberRepository, RepositoryError
def _new_member_repository(tmp_path):
repository = MemberRepository(tmp_path)
repository.initialize()
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE98670505050038907751",
"bic": "MANSDE66XXX",
}
)
repository.save_organization(organization)
member = repository.create_member(
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
)
member.email = "ada@example.org"
member.accepted_at = "2026-08-20"
repository.save_member(member)
repository.create_manual_claim(
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
)
repository.create_manual_claim(
member.member_id,
title="Mitgliedsbeitrag 2. Halbjahr 2026",
amount="60.00",
due_date="2026-09-17",
)
return repository, member
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
repository, member = _new_member_repository(tmp_path)
export_path = tmp_path / "Willkommen.eml"
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
content = message.get_content()
assert message["To"] == "ada@example.org"
assert "Willkommen" in message["Subject"]
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
assert "Gesamtbetrag: 75.00 Euro" in content
assert "DE98670505050038907751" in content
assert content.rstrip().endswith("Der Vorstand")
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
def test_welcome_mail_is_archived_and_logged(tmp_path):
repository, member = _new_member_repository(tmp_path)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.archive_path.parent.name == "Willkommen"
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "welcome_email_sent"
assert event.data["recipient"] == "ada@example.org"
assert len(event.data["claim_ids"]) == 2
def test_only_selected_claims_are_billed(tmp_path):
repository, member = _new_member_repository(tmp_path)
admission = next(
claim for claim in open_claims(repository, member.member_id)
if claim["title"] == "Aufnahmegebühr"
)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
claim_ids=[admission["claim_id"]],
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "Aufnahmegebühr" in content
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
assert "Gesamtbetrag: 15.00 Euro" in content
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
repository, member = _new_member_repository(tmp_path)
member.account_holder = "Ada Lovelace"
member.iban = "DE98670505050038907751"
member.mandate_reference = "MANDAT-42"
member.mandate_signed_at = "2026-08-20"
member.mandate_active = True
repository.save_member(member)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
content = BytesParser(policy=default).parsebytes(
generated.export_path.read_bytes()
).get_content()
assert "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
assert "Bitte überweise" not in content
def test_edited_template_is_used_for_the_mail(tmp_path):
repository, member = _new_member_repository(tmp_path)
repository.save_mail_template(
"welcome",
subject="Servus {{member.first_name}}",
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert message["Subject"] == "Servus Ada"
assert message.get_content().replace("\r\n", "\n").strip() == (
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
)
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
import ccma.services.welcome_mail as welcome_mail_module
repository, member = _new_member_repository(tmp_path)
repository.save_email_settings(
delivery_mode="send",
smtp_host="smtp.example.org",
smtp_port=587,
smtp_security="starttls",
smtp_username="verwaltung",
smtp_password="secret",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="INBOX.Entwürfe",
)
sent = []
monkeypatch.setattr(
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
)
monkeypatch.setattr(
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
)
generated = generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path is None
assert generated.archive_path.read_bytes() == sent[0]
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
def test_member_without_email_is_refused(tmp_path):
repository, member = _new_member_repository(tmp_path)
member.email = ""
repository.save_member(member)
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path, monkeypatch):
import ccma.services.welcome_mail as welcome_mail_module
repository, member = _new_member_repository(tmp_path)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
)
monkeypatch.setattr(
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
)
monkeypatch.setattr(welcome_mail_module, "send_via_smtp", lambda client, content: None)
real_replace = welcome_mail_module.os.replace
def _flaky_replace(src, dst):
# Only the final move into the member file fails -- the mail is out by then.
if str(dst).endswith(".eml") and "Willkommen" in str(dst):
raise OSError("disk full")
return real_replace(src, dst)
monkeypatch.setattr(welcome_mail_module.os, "replace", _flaky_replace)
with pytest.raises(RepositoryError, match="zugestellt"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
event = repository.get_events(member.member_id)[-1]
assert event.event_type == "welcome_email_sent"
assert event.data["archive_error"]
assert "document" not in event.references
def test_a_template_broken_outside_ccma_stops_the_send(tmp_path):
repository, member = _new_member_repository(tmp_path)
# The templates are plain files in the store, so they can be edited (and broken)
# with any text editor -- the send has to notice instead of mailing the marker.
(repository.mail_templates_root / "willkommen.txt").write_text(
"Betreff: Willkommen\n\nHallo,\n{{#claims}}\n{{claim.title}}\n",
encoding="utf-8",
)
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
generate_and_send_welcome_mail(
repository,
member.member_id,
delivery_mode="local",
output_path=tmp_path / "Willkommen.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert not (tmp_path / "Willkommen.eml").exists()
archive = repository.members_root / member.member_id / "files" / "documents" / "Willkommen"
assert not archive.exists()
assert repository.get_events(member.member_id)[-1].event_type != "welcome_email_sent"