Skip to content

Mail every invoice document to the accounting mailbox - #282

Merged
Jan0707 merged 9 commits into
mainfrom
invoice-accounting-mail
Oct 21, 2026
Merged

Jan0707 merged 9 commits into
mainfrom
invoice-accounting-mail

Conversation

@claude

@claude claude Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Richard Hartmann · Slack thread

Before: Invoice documents of the ticket platform only reached the customer. The original invoice went out with the order confirmation; corrections (Rechnungsberichtigung) and cancellations (Stornorechnung) created through the invoice-lifecycle endpoint were not mailed anywhere. Accounting had to collect the PDFs from Directus by hand, so timely recording of each invoice (GoBD) depended on someone remembering to do it.

After: Every generated invoice document is also mailed to the accounting inbox (ACCOUNTING_INVOICE_EMAIL), from where accounting takes it into the bookkeeping system. It is a separate mail, not a BCC on the customer mail, sent through the regular mail setup like every other CMS mail (Directus MailService, sender EMAIL_FROM). Each mail carries exactly one PDF (same bytes and file name as the stored file), and subject and body say what the document is, so it can be classified without opening the PDF:

Ausgangsrechnung programmier.con 2026 Ticket – Rechnung PB-CON26-012
Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung PB-CON26-049 zu PB-CON26-040
Ausgangsrechnung programmier.con 2026 Ticket – Stornorechnung (Gutschrift) PB-CON26-050 zu PB-CON26-040
Dokumentart: Ausgangsrechnung programmier.con 2026 Ticket – Rechnungsberichtigung
Veranstaltung: programmier.con 2026
Rechnungsnummer: PB-CON26-049
Rechnungsdatum: 21.10.2026
Leistungszeitraum: 25.–26.11.2026
Bezug auf Originalrechnung: PB-CON26-040 vom 01.10.2026
Bruttobetrag: 1.189,00 €
Aussteller: Lotum media GmbH

Das Dokument liegt als PDF bei (Rechnungsberichtigung-PB-CON26-049.pdf).

The conference name comes from the order's conference title (just Ticket if it is empty); the invoice number follows the document type as the unique key. The body contains no customer data (no names, e-mail or postal addresses); the PDF carries everything else. Internal (0 €) orders have no invoice, so nothing is sent for them.

How: A shared helper, shared/accounting-mail.ts (sendInvoiceToAccounting), builds subject and plain-text body and sends the mail with the existing sendRawEmail from shared/email-service.ts. sendRawEmail now also accepts attachments and a text-only body; the attachment mapping is shared with sendTemplatedEmail instead of duplicated. The helper:

  • reads the inbox from ACCOUNTING_INVOICE_EMAIL. If it is unset or empty, it skips the mail with an info log, and alerts Slack once per process start with the first affected invoice number (retried with the next document if the post fails; silent when Slack is not configured).
  • retries up to 3 times. If it still fails, it posts a Slack alert with the invoice number and order number so the PDF can be forwarded by hand.
  • never throws, so a failed accounting mail cannot break order processing or the endpoint.
  • never touches sent_at, which keeps recording only the customer dispatch.

Where the mail is sent:

  • ticket-order-processing: right after the original invoice is generated, whether or not the customer mail later succeeds (the document exists either way).
  • invoice-lifecycle: after a correction or cancellation is created. Responses include accounting_mail: 'sent' | 'skipped' | 'failed', and the admin interface shows it in the success message. The confirmation texts now say "no mail to the customer" instead of "no mail at all".

Regenerate: A regenerate (same number, re-rendered) is only allowed while sent_at is null, and that is exactly the state after the paid hook already sent the accounting copy (customer mail failed, or a correction not yet dispatched). Skipping it would leave a stale version in the books while the customer later receives the new one, so the regenerated PDF is sent too, marked (ersetzt vorherige Fassung) in subject and body. A regenerated correction keeps its type and its reference to the corrected invoice.

Tests: Unit tests for the helper (subject/body per document type, missing optional fields, the real sendRawEmail on a fake MailService with exactly one attachment and no custom sender, retry and Slack escalation, unset inbox alert once / retried / silent without Slack), plus hook and endpoint cases: original handed over with the printed invoice date and snapshot, also when the customer mail fails; correction, cancellation and regenerate sent with uploaded bytes = attached bytes; persistent failure leaves the document and sent_at untouched; internal orders and blocked operations send nothing.

prettier:check, lint, test (48 suites, 672 tests) and build pass.

Deployment:

  • Set ACCOUNTING_INVOICE_EMAIL to the accounting inbox in the Directus env and restart. Nothing else is needed; the mail uses the existing EMAIL_* configuration.
  • Mails go out only for documents created after the deploy. Older documents are not sent retroactively.
  • No Directus schema changes, no new dependencies.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw

Each generated invoice document (original invoice from the paid hook,
Rechnungsberichtigung and Stornorechnung from the invoice-lifecycle
endpoint) is sent as a separate plain-text mail with exactly one PDF
attachment to ACCOUNTING_INVOICE_EMAIL, for timely recording (GoBD).
A pre-issuance regenerate sends the replacement version, marked
"(ersetzt vorherige Fassung)", since accounting already holds the
earlier version under the same number.

Unset env var: skipped with an info log. Failures are retried three
times, then alerted to Slack with the invoice number; they never fail
order processing or the endpoint and never touch sent_at.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
programmierbar-website Ready Ready Preview Oct 21, 2026 2:51pm UTC

Request Review

The accounting upload mailbox rejects mail (550 at RCPT TO) unless the
SMTP envelope sender equals its registered sender address. The Directus
MailService goes through the transactional mail provider, which always
uses its own return path, so it can never pass that check.

Send the accounting copy through its own nodemailer transport configured
by ACCOUNTING_SMTP_HOST/PORT/SECURE/USER/PASSWORD and ACCOUNTING_MAIL_FROM,
created lazily once, with `from` and the envelope sender both set to
ACCOUNTING_MAIL_FROM. No fallback to the MailService. If
ACCOUNTING_INVOICE_EMAIL is set but the SMTP config is incomplete, skip
with a warning per document and a Slack alert once per process.
nodemailer is added as a bundle dependency (same version Directus
ships) and kept external in the Rollup build. The sendRawEmail
attachment extension is reverted, since nothing uses it anymore.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
@Jan0707

Jan0707 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

As discussed, we will not be used an additional mail transporter just for the transfer of invoices to DATEV.

Instead, we will do the following:

Jan: können wir das Mailversand Problem so lösen, dass wir zB eine Mail an accounting@ schicken und von dort aus mittels Zappier oder so eine Weiterleitung zu Datev machen? Dann nutzen wir eine echte Inbox, ohne, dass wir zwei Mailanbieter im CMS haben. Und solange alles weiterhin über Brevo läuft können wir deren Logs etc für alles Nutzen.

Claude: Das geht, aber nur, wenn der Zapier-Schritt die Mail über das Google-Konto von accounting@ verschickt, z. B. mit der Gmail-Aktion „Send Email“. DATEV prüft den technischen Absender. Zapiers eigener Mailversand und eine normale Gmail-Weiterleitung setzen dort eine andere Adresse, und DATEV lehnt dann mit 550 ab. Mit diesem Weg fällt in #282 der eigene SMTP-Zugang weg, und die Buchhaltungskopie geht einfach per Brevo an accounting@.

So we can continue to use the existing SMTP provider (Brevo) and merely send invoices to accounting@lotum.de
From there, they will be forwarded to DATEV.

So, as the next step, we need to remove any hint of the previously created "new shared helper, shared/accounting-mail.ts (sendInvoiceToAccounting), that sends the mail through its own SMTP transport (nodemailer, created lazily once)" and then can re-use the exisitng mail infrastructure after all.

The accounting copy now goes to ACCOUNTING_INVOICE_EMAIL through the shared
email service (Directus MailService, sender EMAIL_FROM) like every other mail;
accounting takes it from that inbox into the bookkeeping system. The dedicated
SMTP transport, its env vars, the nodemailer dependency and the related
misconfiguration alert are removed.

sendRawEmail can now send plain-text-only mails with attachments, reusing the
attachment mapping of sendTemplatedEmail.

Subject and body identify the document for automatic classification:
"Ausgangsrechnung <conference> – Ticketrechnung / Rechnungsberichtigung ... zu ... /
Stornorechnung (Gutschrift) ... zu ...", with Dokumentart, Rechnungsnummer,
Rechnungsdatum, Leistungszeitraum, Bezug auf Originalrechnung, Bruttobetrag
and Aussteller in the body, no customer data.

An unset ACCOUNTING_INVOICE_EMAIL is now reported to Slack once per process
(first affected invoice number; retried after a failed post; silent without
Slack configuration).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
@claude

claude Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

As discussed, we will not be used an additional mail transporter just for the transfer of invoices to DATEV.

Done in 74ae9ee: the separate transport and its env vars are gone; each invoice document now goes through the existing mail setup (sendRawEmail) to ACCOUNTING_INVOICE_EMAIL, with a subject/body that names it as Ausgangsrechnung with type, number and reference.


Generated by Claude Code

@Jan0707

Jan0707 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Set ACCOUNTING_INVOICE_EMAIL to "accounting@lotum.de"

Subject and Dokumentart now read "Ausgangsrechnung <conference> Ticket – <type>
<number>", e.g. "Ausgangsrechnung programmier.con 2026 Ticket – Rechnung
PB-CON26-012"; just "Ticket" without a conference title. The invoice number
follows the type as the unique key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
@Jan0707
Jan0707 marked this pull request as ready for review October 21, 2026 14:20
Copilot AI balanced review requested due to automatic review settings October 21, 2026 14:20
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-21T14:22:49.136272Z d7ed00a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Accounting delivery and Slack requests can delay paid-order ticket creation without a dedicated time budget.

1 open issue
What changed in this PR

Adds separate accounting emails for invoice documents generated by the Directus ticket platform.

Changes:

  • Sends original, corrected, cancelled, and regenerated PDFs to the configured accounting mailbox.
  • Shares attachment handling and adds retries, Slack alerts, and admin delivery status.
  • Adds tests and deployment documentation.

Paths below are relative to directus-cms/extensions/directus-extension-programmierbar-bundle/ unless otherwise noted.

File Description
docs/​tickets.md (repository root) Documents accounting delivery and configuration.
src/​ticket-order-processing/​index.ts Sends original invoices to accounting.
src/​ticket-order-processing/​__tests__/​index.test.ts Tests accounting handoff and customer-flow isolation.
src/​shared/​postSlackMessage.ts Exposes Slack configuration check.
src/​shared/​invoice-service.ts Exports document labels.
src/​shared/​invoice-generator.ts Exports issuer name.
src/​shared/​email-service.ts Supports raw-mail attachments and text-only bodies.
src/​shared/​accounting-mail.ts Implements accounting messages, retries, and alerts.
src/​shared/​__tests__/​accounting-mail.test.ts Tests message content and failure handling.
src/​invoice-lifecycle/​invoice-actions.vue Displays accounting delivery status.
src/​invoice-lifecycle/​index.ts Sends lifecycle documents to accounting.
src/​invoice-lifecycle/​__tests__/​index.test.ts Tests lifecycle delivery and PDF consistency.
directus-cms/​.env.example (repository root) Adds accounting mailbox setting.

Review effort: Balanced

The paid hook awaited the accounting copy (up to three attempts plus a Slack
alert) before creating tickets and sending the customer mails. It is now
started without awaiting, right after the invoice document exists, so it
still goes out whatever happens later; an unexpected rejection is logged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBHiR6NFRe6QR5SBjSv4nw
Copilot AI balanced review requested due to automatic review settings October 21, 2026 14:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Overlapping replacement deliveries need reliable version identification so accounting can distinguish the current PDF regardless of arrival order.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Add ordered revisions to overlapping replacement emails

directus-cms/​extensions/​directus-extension-programmierbar-bundle/​src/​shared/​accounting-mail.ts:98

Two regenerate requests can overlap while the first waits for accounting mail: the endpoint stores each replacement before sending, and sent_at remains null. If the older send is delayed or retried, its PDF can arrive after the newer one. For a billing-address-only change, both mails have identical subjects, bodies and filenames, including this replacement marker, so accounting cannot tell which version is current from the mail metadata. Include a persisted, ordered revision tied to the stored replacement in each mail, and test reversed delivery order. Keep the paid hook non-blocking.

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 21, 2026 14:41
@Jan0707

Jan0707 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Good catch Copilot, but I think that is to much of an edge case for now.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The supplied code and tests support the delivery flow, with only a non-blocking message wording issue remaining.

1 open issue

Review effort: Balanced

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 21, 2026 14:45
@Jan0707

Jan0707 commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Code Review was stuck. Overwriting and merging now.

@Jan0707
Jan0707 merged commit bb363dd into main Oct 21, 2026
3 checks passed
@Jan0707
Jan0707 deleted the invoice-accounting-mail branch October 21, 2026 14:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No blocking issues remain; the implementation isolates delivery failures and includes focused coverage, though tests were not independently run.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

This branch was successfully deployed

1 active deployment
Preview — 9e4b5f46 Deployed Oct 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants