AstroBaaS

Tax & compliance

Invoices and credit memos

Free — GPL coresize Mcommitted · 1

Generated from docs/plan/core/invoices-and-credit-memos/PLAN.md in the project's planning notes, which are not part of the public repository. Nothing described below is implemented — it is the written plan for it.

Committed, number 1 in the build order. A frozen invoice and credit-memo record per order, with no fiscal claim. Issuers such as myDATA attach to it. The whole list.

This plan takes over all or part of Credit Memo Documents, Invoice Generation and Order Invoice Documents, entries merged into it.

A core invoice record for each order, and a credit-memo record for each refund. Each record holds the lines, the VAT breakdown per rate and the buyer’s VAT id, frozen at the moment it is created. Issuers attach to these records:

  • a Greek provider through myDATA;
  • a Peppol access point;
  • the shop’s own ΦΗΜ, whose document number is recorded.

The core itself never claims to have issued a legal document.

Rewritten 2026-10-10. The first version had the core issue legal invoices with permanent serial numbers and a signature from the merchant’s private key. That contradicts the owner decision recorded in src/core/models.ts (2026-09-04): AstroBaaS is not a fiscal device and will not become one. In Greece only a ΦΗΜ or a licensed provider issues the legal document (docs/PAID-PLUGINS-EU.md §1). A second, unofficial numbering beside the provider’s would be worse than none.

Built 2026-10-26, not yet marked shipped. The core now delivers what this page describes, including the issuer seam v2: src/lib/commerce/invoice-records.ts, rules in src/lib/commerce/invoice-records-rules.ts, PDF in src/lib/commerce/invoice-pdf.ts, tests in tests/invoice-records.test.mjs, tests/invoice-issuer-seam.test.mjs and tests/e2e/invoice-records.spec.ts. Two names differ: the reference setting is invoice_internal_reference_format, and a credit memo’s “refunded lines” are the refund amount spread over the record’s lines in proportion, because the refund ledger stores amounts, not lines. It stays planned because three older plans are merged into it (core/credit-memo-documents, core/invoice-generation, core/order-invoice-documents). They ask for sequential legal invoice numbers, emailed invoices, logo templates and an order.refund_issued webhook, which this design deliberately leaves out. Marking this page shipped would publish their scope as shipped, and the site’s sync refuses that.

The problem

The receipt at /receipt is an order confirmation, not a tax document. A refund has a ledger entry but no document. Every issuer needs the same thing the core does not yet store:

  • the exact lines and VAT per rate as charged, frozen;
  • the buyer’s VAT id;
  • a stable record id it can attach its own number and identifiers to.

Every issuer plugin would otherwise rebuild that record from the order, each slightly differently.

What it does

  • Invoice record per order, created at the event the shop chooses: paid, shipped, or by hand. It holds:
    • the order lines;
    • the VAT per rate as charged;
    • the seller’s and buyer’s details, including Address.tax_id;
    • the currency;
    • a status (draft, final, cancelled). Once final it is immutable. A correction is a credit memo plus a new invoice record, never an edit.
  • Credit-memo record per refund. It references the invoice record, the refunded lines and the amounts.
  • Issuer hook. Plugins register an issuer, for example the myDATA provider pack or a Peppol pack. When a record becomes final, the issuer is called and stores what it returns on the record. For myDATA that is the ΜΑΡΚ, UID and QR; for a ΦΗΜ, the device’s document number. Records are never double-reported: a record already issued elsewhere is marked issued_externally, and no issuer is called for it.
  • Numbering is the issuer’s. With an issuer, the document number is the one it returns. Without one, the shop may switch on a plain sequential reference for its own bookkeeping. That reference is labelled as an internal reference on the PDF, not as an invoice number.
  • PDF. Renders the record and whatever the issuer returned, using the receipt PDF pipeline already shipped (Greek font included). A document with no issuer says plainly that it is not a tax document.

What it deliberately does NOT do

Each boundary carries its reason. A boundary without a reason gets crossed by the next person who reads this.

  • No legal issuance, signature or fiscal numbering in the core. Reason: the owner decision above, and Greek law: a ΦΗΜ or a ΥΠΑΗΕΣ issues the legal document. Countries where any software may issue an invoice get that through an issuer plugin, not by weakening this rule for everyone.
  • No checkout refusal over a VAT id. Reason: the core documents why tax_id is captured, not validated (models.ts). An issuer that needs a valid id reports the problem on the record. It never blocks the sale.
  • No automatic emailing. Reason: what may be sent, and when, depends on the issuer. A provider may send the document itself.

Data model

  • InvoiceRecord:
    • id, order_id, status, created_at, finalised_at;
    • seller and buyer snapshots, including tax_id;
    • lines[] and vat[] (rate, base, amount), total, currency;
    • issuer ({ id, number?, identifiers?, issued_at?, error? });
    • issued_externally?.
  • CreditMemoRecord: id, invoice_record_id, refund_id, lines[], vat[], total, issuer.
  • Settings: invoice_record_on (paid | shipped | manual) and internal_reference_format (optional).

API

  • GET /api/orders/:id/invoice-records lists the order’s records and their issuer state (staff).
  • POST /api/orders/:id/invoice-records creates or finalises one by hand (admin).
  • GET /api/invoice-records/:id/pdf returns the PDF (staff; the buyer through the order’s receipt token).

Every route added here must also appear in src/pages/openapi.json.ts; a test fails the build if it does not.

Admin

The order detail shows each record with its state: draft, final, or issued (with the issuer’s number), or “issued elsewhere”. A failed issue shows the issuer’s message. Settings → Invoices sets when records are created and the optional internal reference.

The seam: why this is core

Every issuer needs the same frozen record. Keeping it in core means:

  • the myDATA pack, a Peppol pack and a ΦΗΜ bridge all attach to one shape;
  • a shop with no issuer still has a correct VAT breakdown per order.

The issuers themselves own credentials and per-country obligations, so they are paid packs.

Dependencies

  • Orders, refunds and VAT per line (core, shipped).
  • Receipt PDF with Greek font (core, shipped).
  • Address.tax_id (core, shipped).

Acceptance checks

Each of these must be able to fail. Before claiming this is done, break the code deliberately and watch each one go red.

  • A paid order creates one final record whose VAT per rate equals what was charged. Changing a tax rate afterwards does not change the record.
  • A 50% refund creates a credit memo that references the record, with the refunded lines and VAT.
  • Two concurrent “finalise” calls produce one final record.
  • With no issuer, the PDF says it is not a tax document and shows no invoice number.
  • With a test issuer registered, finalising calls it once and stores its number. A record marked issued_externally is never sent to an issuer.
  • An invalid tax_id never blocks checkout; an issuer that rejects it shows the error on the record.

Risks

  • A merchant reads the PDF as a legal invoice when no issuer is set. Mitigation: the “not a tax document” line, tested above.
  • Double reporting when both a ΦΗΜ and a provider are in play. Mitigation: issued_externally and one issuer per record.

Commercial context

Suggested priceCore
Rival anchorMagento Open Source keeps invoice and credit-memo records free; legal issuance is per country everywhere.

The anchor is what the nearest equivalent charges on Shopify or Magento today. It is context for a pricing decision, not the decision.


Rewritten 2026-10-10 after an independent review. See docs/PAID-PLUGINS-EU.md §1 and the tax_id note in src/core/models.ts.